Merge branch 'master' into BUDI-8986/convert-screen-store

This commit is contained in:
Adria Navarro 2025-01-27 15:29:31 +01:00
commit 588d4b7485
82 changed files with 1465 additions and 757 deletions

View File

@ -41,11 +41,12 @@ module.exports = {
if (
/^@budibase\/[^/]+\/.*$/.test(importPath) &&
importPath !== "@budibase/backend-core/tests" &&
importPath !== "@budibase/string-templates/test/utils"
importPath !== "@budibase/string-templates/test/utils" &&
importPath !== "@budibase/client/manifest.json"
) {
context.report({
node,
message: `Importing from @budibase is not allowed, except for @budibase/backend-core/tests and @budibase/string-templates/test/utils.`,
message: `Importing from @budibase is not allowed, except for @budibase/backend-core/tests, @budibase/string-templates/test/utils and @budibase/client/manifest.json.`,
})
}
},

View File

@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.2.46",
"version": "3.3.1",
"npmClient": "yarn",
"concurrency": 20,
"command": {

View File

@ -45,6 +45,11 @@
--purple: #806fde;
--purple-dark: #130080;
--error-bg: rgba(226, 109, 105, 0.3);
--warning-bg: rgba(255, 210, 106, 0.3);
--error-content: rgba(226, 109, 105, 0.6);
--warning-content: rgba(255, 210, 106, 0.6);
--rounded-small: 4px;
--rounded-medium: 8px;
--rounded-large: 16px;

View File

@ -293,7 +293,7 @@
type: RowSelector,
props: {
row: inputData["oldRow"] || {
tableId: inputData["row"].tableId,
tableId: inputData["row"]?.tableId,
},
meta: {
fields: inputData["meta"]?.oldFields || {},

View File

@ -12,7 +12,7 @@
decodeJSBinding,
encodeJSBinding,
processObjectSync,
processStringSync,
processStringWithLogsSync,
} from "@budibase/string-templates"
import { readableToRuntimeBinding } from "@/dataBinding"
import CodeEditor from "../CodeEditor/CodeEditor.svelte"
@ -41,6 +41,7 @@
InsertAtPositionFn,
JSONValue,
} from "@budibase/types"
import type { Log } from "@budibase/string-templates"
import type { CompletionContext } from "@codemirror/autocomplete"
const dispatch = createEventDispatcher()
@ -66,6 +67,7 @@
let insertAtPos: InsertAtPositionFn | undefined
let targetMode: BindingMode | null = null
let expressionResult: string | undefined
let expressionLogs: Log[] | undefined
let expressionError: string | undefined
let evaluating = false
@ -157,7 +159,7 @@
(expression: string | null, context: any, snippets: Snippet[]) => {
try {
expressionError = undefined
expressionResult = processStringSync(
const output = processStringWithLogsSync(
expression || "",
{
...context,
@ -167,6 +169,8 @@
noThrow: false,
}
)
expressionResult = output.result
expressionLogs = output.logs
} catch (err: any) {
expressionResult = undefined
expressionError = err
@ -421,6 +425,7 @@
<EvaluationSidePanel
{expressionResult}
{expressionError}
{expressionLogs}
{evaluating}
expression={editorValue ? editorValue : ""}
/>

View File

@ -4,11 +4,13 @@
import { Helpers } from "@budibase/bbui"
import { fade } from "svelte/transition"
import { UserScriptError } from "@budibase/string-templates"
import type { Log } from "@budibase/string-templates"
import type { JSONValue } from "@budibase/types"
// this can be essentially any primitive response from the JS function
export let expressionResult: JSONValue | undefined = undefined
export let expressionError: string | undefined = undefined
export let expressionLogs: Log[] = []
export let evaluating = false
export let expression: string | null = null
@ -16,6 +18,11 @@
$: empty = expression == null || expression?.trim() === ""
$: success = !error && !empty
$: highlightedResult = highlight(expressionResult)
$: highlightedLogs = expressionLogs.map(l => ({
log: highlight(l.log.join(", ")),
line: l.line,
type: l.type,
}))
const formatError = (err: any) => {
if (err.code === UserScriptError.code) {
@ -25,14 +32,14 @@
}
// json can be any primitive type
const highlight = (json?: any | null) => {
const highlight = (json?: JSONValue | null) => {
if (json == null) {
return ""
}
// Attempt to parse and then stringify, in case this is valid result
try {
json = JSON.stringify(JSON.parse(json), null, 2)
json = JSON.stringify(JSON.parse(json as any), null, 2)
} catch (err) {
// couldn't parse/stringify, just treat it as the raw input
}
@ -61,7 +68,7 @@
<div class="header" class:success class:error>
<div class="header-content">
{#if error}
<Icon name="Alert" color="var(--spectrum-global-color-red-600)" />
<Icon name="Alert" color="var(--error-content)" />
<div>Error</div>
{#if evaluating}
<div transition:fade|local={{ duration: 130 }}>
@ -90,8 +97,36 @@
{:else if error}
{formatError(expressionError)}
{:else}
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html highlightedResult}
<div class="output-lines">
{#each highlightedLogs as logLine}
<div
class="line"
class:error-log={logLine.type === "error"}
class:warn-log={logLine.type === "warn"}
>
<div class="icon-log">
{#if logLine.type === "error"}
<Icon
size="XS"
name="CloseCircle"
color="var(--error-content)"
/>
{:else if logLine.type === "warn"}
<Icon size="XS" name="Alert" color="var(--warning-content)" />
{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
<span>{@html logLine.log}</span>
</div>
{#if logLine.line}
<span style="color: var(--blue)">:{logLine.line}</span>
{/if}
</div>
{/each}
<div class="line">
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html highlightedResult}
</div>
</div>
{/if}
</div>
</div>
@ -130,20 +165,37 @@
height: 100%;
z-index: 1;
position: absolute;
opacity: 10%;
}
.header.error::before {
background: var(--spectrum-global-color-red-400);
background: var(--error-bg);
}
.body {
flex: 1 1 auto;
padding: var(--spacing-m) var(--spacing-l);
font-family: var(--font-mono);
font-size: 12px;
overflow-y: scroll;
overflow-y: auto;
overflow-x: hidden;
white-space: pre-wrap;
white-space: pre-line;
word-wrap: break-word;
height: 0;
}
.output-lines {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.line {
border-bottom: var(--border-light);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: end;
padding: var(--spacing-s);
}
.icon-log {
display: flex;
gap: var(--spacing-s);
align-items: start;
}
</style>

View File

@ -1,4 +1,5 @@
<script>
import { datasources } from "@/stores/builder"
import { Divider, Heading } from "@budibase/bbui"
export let dividerState
@ -6,6 +7,21 @@
export let dataSet
export let value
export let onSelect
export let identifiers = ["resourceId"]
$: displayDatasourceName = $datasources.list.length > 1
function isSelected(entry) {
if (!identifiers.length) {
return false
}
for (const identifier of identifiers) {
if (entry[identifier] !== value?.[identifier]) {
return false
}
}
return true
}
</script>
{#if dividerState}
@ -21,15 +37,16 @@
{#each dataSet as data}
<li
class="spectrum-Menu-item"
class:is-selected={value?.label === data.label &&
value?.type === data.type}
class:is-selected={isSelected(data) && value?.type === data.type}
role="option"
aria-selected="true"
tabindex="0"
on:click={() => onSelect(data)}
>
<span class="spectrum-Menu-itemLabel">
{data.datasourceName ? `${data.datasourceName} - ` : ""}{data.label}
{data.datasourceName && displayDatasourceName
? `${data.datasourceName} - `
: ""}{data.label}
</span>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"

View File

@ -31,10 +31,15 @@
import IntegrationQueryEditor from "@/components/integration/index.svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
import { findAllComponents } from "@/helpers/components"
import {
extractFields,
extractJSONArrayFields,
extractRelationships,
} from "@/helpers/bindings"
import ClientBindingPanel from "@/components/common/bindings/ClientBindingPanel.svelte"
import DataSourceCategory from "@/components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte"
import { API } from "@/api"
import { datasourceSelect as format } from "@/helpers/data/format"
import { sortAndFormat } from "@/helpers/data/format"
export let value = {}
export let otherSources
@ -51,25 +56,13 @@
let modal
$: text = value?.label ?? "Choose an option"
$: tables = $tablesStore.list
.map(table => format.table(table, $datasources.list))
.sort((a, b) => {
// sort tables alphabetically, grouped by datasource
const dsA = a.datasourceName ?? ""
const dsB = b.datasourceName ?? ""
const dsComparison = dsA.localeCompare(dsB)
if (dsComparison !== 0) {
return dsComparison
}
return a.label.localeCompare(b.label)
})
$: tables = sortAndFormat.tables($tablesStore.list, $datasources.list)
$: viewsV1 = $viewsStore.list.map(view => ({
...view,
label: view.name,
type: "view",
}))
$: viewsV2 = $viewsV2Store.list.map(format.viewV2)
$: viewsV2 = sortAndFormat.viewsV2($viewsV2Store.list, $datasources.list)
$: views = [...(viewsV1 || []), ...(viewsV2 || [])]
$: queries = $queriesStore.list
.filter(q => showAllQueries || q.queryVerb === "read" || q.readable)
@ -93,67 +86,9 @@
value: `{{ literal ${safe(provider._id)} }}`,
type: "provider",
}))
$: links = bindings
// Get only link bindings
.filter(x => x.fieldSchema?.type === "link")
// Filter out bindings provided by forms
.filter(x => !x.component?.endsWith("/form"))
.map(binding => {
const { providerId, readableBinding, fieldSchema } = binding || {}
const { name, tableId } = fieldSchema || {}
const safeProviderId = safe(providerId)
return {
providerId,
label: readableBinding,
fieldName: name,
tableId,
type: "link",
// These properties will be enriched by the client library and provide
// details of the parent row of the relationship field, from context
rowId: `{{ ${safeProviderId}.${safe("_id")} }}`,
rowTableId: `{{ ${safeProviderId}.${safe("tableId")} }}`,
}
})
$: fields = bindings
.filter(
x =>
x.fieldSchema?.type === "attachment" ||
(x.fieldSchema?.type === "array" && x.tableId)
)
.map(binding => {
const { providerId, readableBinding, runtimeBinding } = binding
const { name, type, tableId } = binding.fieldSchema
return {
providerId,
label: readableBinding,
fieldName: name,
fieldType: type,
tableId,
type: "field",
value: `{{ literal ${runtimeBinding} }}`,
}
})
$: jsonArrays = bindings
.filter(
x =>
x.fieldSchema?.type === "jsonarray" ||
(x.fieldSchema?.type === "json" && x.fieldSchema?.subtype === "array")
)
.map(binding => {
const { providerId, readableBinding, runtimeBinding, tableId } = binding
const { name, type, prefixKeys, subtype } = binding.fieldSchema
return {
providerId,
label: readableBinding,
fieldName: name,
fieldType: type,
tableId,
prefixKeys,
type: type === "jsonarray" ? "jsonarray" : "queryarray",
subtype,
value: `{{ literal ${runtimeBinding} }}`,
}
})
$: links = extractRelationships(bindings)
$: fields = extractFields(bindings)
$: jsonArrays = extractJSONArrayFields(bindings)
$: custom = {
type: "custom",
label: "JSON / CSV",
@ -303,6 +238,7 @@
dataSet={views}
{value}
onSelect={handleSelected}
identifiers={["tableId", "name"]}
/>
{/if}
{#if queries?.length}
@ -312,6 +248,7 @@
dataSet={queries}
{value}
onSelect={handleSelected}
identifiers={["_id"]}
/>
{/if}
{#if links?.length}
@ -321,6 +258,7 @@
dataSet={links}
{value}
onSelect={handleSelected}
identifiers={["tableId", "fieldName"]}
/>
{/if}
{#if fields?.length}
@ -330,6 +268,7 @@
dataSet={fields}
{value}
onSelect={handleSelected}
identifiers={["providerId", "tableId", "fieldName"]}
/>
{/if}
{#if jsonArrays?.length}
@ -339,6 +278,7 @@
dataSet={jsonArrays}
{value}
onSelect={handleSelected}
identifiers={["providerId", "tableId", "fieldName"]}
/>
{/if}
{#if showDataProviders && dataProviders?.length}
@ -348,6 +288,7 @@
dataSet={dataProviders}
{value}
onSelect={handleSelected}
identifiers={["providerId"]}
/>
{/if}
<DataSourceCategory

View File

@ -1,22 +1,32 @@
<script>
import { Select } from "@budibase/bbui"
import { Popover, Select } from "@budibase/bbui"
import { createEventDispatcher, onMount } from "svelte"
import { tables as tablesStore, viewsV2 } from "@/stores/builder"
import { tableSelect as format } from "@/helpers/data/format"
import {
tables as tableStore,
datasources as datasourceStore,
viewsV2 as viewsV2Store,
} from "@/stores/builder"
import DataSourceCategory from "./DataSourceSelect/DataSourceCategory.svelte"
import { sortAndFormat } from "@/helpers/data/format"
export let value
let anchorRight, dropdownRight
const dispatch = createEventDispatcher()
$: tables = $tablesStore.list.map(format.table)
$: views = $viewsV2.list.map(format.viewV2)
$: tables = sortAndFormat.tables($tableStore.list, $datasourceStore.list)
$: views = sortAndFormat.viewsV2($viewsV2Store.list, $datasourceStore.list)
$: options = [...(tables || []), ...(views || [])]
$: text = value?.label ?? "Choose an option"
const onChange = e => {
dispatch(
"change",
options.find(x => x.resourceId === e.detail)
options.find(x => x.resourceId === e.resourceId)
)
dropdownRight.hide()
}
onMount(() => {
@ -29,10 +39,47 @@
})
</script>
<Select
on:change={onChange}
value={value?.resourceId}
{options}
getOptionValue={x => x.resourceId}
getOptionLabel={x => x.label}
/>
<div class="container" bind:this={anchorRight}>
<Select
readonly
value={text}
options={[text]}
on:click={dropdownRight.show}
/>
</div>
<Popover bind:this={dropdownRight} anchor={anchorRight}>
<div class="dropdown">
<DataSourceCategory
heading="Tables"
dataSet={tables}
{value}
onSelect={onChange}
/>
{#if views?.length}
<DataSourceCategory
dividerState={true}
heading="Views"
dataSet={views}
{value}
onSelect={onChange}
/>
{/if}
</div>
</Popover>
<style>
.container {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
.container :global(:first-child) {
flex: 1 1 auto;
}
.dropdown {
padding: var(--spacing-m) 0;
z-index: 99999999;
}
</style>

View File

@ -20,7 +20,7 @@
const processModals = () => {
const defaultCacheFn = key => {
temporalStore.actions.setExpiring(key, {}, oneDayInSeconds)
temporalStore.setExpiring(key, {}, oneDayInSeconds)
}
const dismissableModals = [
@ -50,7 +50,7 @@
},
]
return dismissableModals.filter(modal => {
return !temporalStore.actions.getExpiring(modal.key) && modal.criteria()
return !temporalStore.getExpiring(modal.key) && modal.criteria()
})
}

View File

@ -6,7 +6,7 @@ import { BANNER_TYPES } from "@budibase/bbui"
const oneDayInSeconds = 86400
const defaultCacheFn = key => {
temporalStore.actions.setExpiring(key, {}, oneDayInSeconds)
temporalStore.setExpiring(key, {}, oneDayInSeconds)
}
const upgradeAction = key => {
@ -148,7 +148,7 @@ export const getBanners = () => {
buildUsersAboveLimitBanner(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER),
].filter(licensingBanner => {
return (
!temporalStore.actions.getExpiring(licensingBanner.key) &&
!temporalStore.getExpiring(licensingBanner.key) &&
licensingBanner.criteria()
)
})

View File

@ -0,0 +1,74 @@
import { makePropSafe } from "@budibase/string-templates"
import { UIBinding } from "@budibase/types"
export function extractRelationships(bindings: UIBinding[]) {
return (
bindings
// Get only link bindings
.filter(x => x.fieldSchema?.type === "link")
// Filter out bindings provided by forms
.filter(x => !x.component?.endsWith("/form"))
.map(binding => {
const { providerId, readableBinding, fieldSchema } = binding || {}
const { name, tableId } = fieldSchema || {}
const safeProviderId = makePropSafe(providerId)
return {
providerId,
label: readableBinding,
fieldName: name,
tableId,
type: "link",
// These properties will be enriched by the client library and provide
// details of the parent row of the relationship field, from context
rowId: `{{ ${safeProviderId}.${makePropSafe("_id")} }}`,
rowTableId: `{{ ${safeProviderId}.${makePropSafe("tableId")} }}`,
}
})
)
}
export function extractFields(bindings: UIBinding[]) {
return bindings
.filter(
x =>
x.fieldSchema?.type === "attachment" ||
(x.fieldSchema?.type === "array" && x.tableId)
)
.map(binding => {
const { providerId, readableBinding, runtimeBinding } = binding
const { name, type, tableId } = binding.fieldSchema!
return {
providerId,
label: readableBinding,
fieldName: name,
fieldType: type,
tableId,
type: "field",
value: `{{ literal ${runtimeBinding} }}`,
}
})
}
export function extractJSONArrayFields(bindings: UIBinding[]) {
return bindings
.filter(
x =>
x.fieldSchema?.type === "jsonarray" ||
(x.fieldSchema?.type === "json" && x.fieldSchema?.subtype === "array")
)
.map(binding => {
const { providerId, readableBinding, runtimeBinding, tableId } = binding
const { name, type, prefixKeys, subtype } = binding.fieldSchema!
return {
providerId,
label: readableBinding,
fieldName: name,
fieldType: type,
tableId,
prefixKeys,
type: type === "jsonarray" ? "jsonarray" : "queryarray",
subtype,
value: `{{ literal ${runtimeBinding} }}`,
}
})
}

View File

@ -9,11 +9,18 @@ export const datasourceSelect = {
datasourceName: datasource?.name,
}
},
viewV2: view => ({
...view,
label: view.name,
type: "viewV2",
}),
viewV2: (view, datasources) => {
const datasource = datasources
?.filter(f => f.entities)
.flatMap(d => d.entities)
.find(ds => ds._id === view.tableId)
return {
...view,
label: view.name,
type: "viewV2",
datasourceName: datasource?.name,
}
},
}
export const tableSelect = {
@ -31,3 +38,36 @@ export const tableSelect = {
resourceId: view.id,
}),
}
export const sortAndFormat = {
tables: (tables, datasources) => {
return tables
.map(table => {
const formatted = datasourceSelect.table(table, datasources)
return {
...formatted,
resourceId: table._id,
}
})
.sort((a, b) => {
// sort tables alphabetically, grouped by datasource
const dsA = a.datasourceName ?? ""
const dsB = b.datasourceName ?? ""
const dsComparison = dsA.localeCompare(dsB)
if (dsComparison !== 0) {
return dsComparison
}
return a.label.localeCompare(b.label)
})
},
viewsV2: (views, datasources) => {
return views.map(view => {
const formatted = datasourceSelect.viewV2(view, datasources)
return {
...formatted,
resourceId: view.id,
}
})
},
}

View File

@ -9,3 +9,5 @@ export {
lowercase,
isBuilderInputFocused,
} from "./helpers"
export * as featureFlag from "./featureFlags"
export * as bindings from "./bindings"

View File

@ -0,0 +1,46 @@
import { Component, Screen, ScreenProps } from "@budibase/types"
import clientManifest from "@budibase/client/manifest.json"
export function findComponentsBySettingsType(
screen: Screen,
type: string | string[]
) {
const typesArray = Array.isArray(type) ? type : [type]
const result: {
component: Component
setting: {
type: string
key: string
}
}[] = []
function recurseFieldComponentsInChildren(component: ScreenProps) {
if (!component) {
return
}
const definition = getManifestDefinition(component)
const setting =
"settings" in definition &&
definition.settings.find((s: any) => typesArray.includes(s.type))
if (setting && "type" in setting) {
result.push({
component,
setting: { type: setting.type!, key: setting.key! },
})
}
component._children?.forEach(child => {
recurseFieldComponentsInChildren(child)
})
}
recurseFieldComponentsInChildren(screen?.props)
return result
}
function getManifestDefinition(component: Component) {
const componentType = component._component.split("/").slice(-1)[0]
const definition =
clientManifest[componentType as keyof typeof clientManifest]
return definition
}

View File

@ -18,7 +18,7 @@
$: useAccountPortal = cloud && !$admin.disableAccountPortal
navigation.actions.init($redirect)
navigation.init($redirect)
const validateTenantId = async () => {
const host = window.location.host

View File

@ -11,6 +11,7 @@
selectedScreen,
hoverStore,
componentTreeNodesStore,
screenComponentErrors,
snippets,
} from "@/stores/builder"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
@ -68,6 +69,7 @@
port: window.location.port,
},
snippets: $snippets,
componentErrors: $screenComponentErrors,
}
// Refresh the preview when required

View File

@ -15,6 +15,7 @@
import {
appsStore,
organisation,
admin,
auth,
groups,
licensing,
@ -42,6 +43,7 @@
app => app.status === AppStatus.DEPLOYED
)
$: userApps = getUserApps(publishedApps, userGroups, $auth.user)
$: isOwner = $auth.accountPortalAccess && $admin.cloud
function getUserApps(publishedApps, userGroups, user) {
if (sdk.users.isAdmin(user)) {
@ -111,7 +113,13 @@
</MenuItem>
<MenuItem
icon="LockClosed"
on:click={() => changePasswordModal.show()}
on:click={() => {
if (isOwner) {
window.location.href = `${$admin.accountPortalUrl}/portal/account`
} else {
changePasswordModal.show()
}
}}
>
Update password
</MenuItem>

View File

@ -30,10 +30,16 @@
try {
loading = true
if (forceResetPassword) {
const email = $auth.user.email
const tenantId = $auth.user.tenantId
await auth.updateSelf({
password,
forceResetPassword: false,
})
if (!$auth.user) {
// Update self will clear the platform user, so need to login
await auth.login(email, password, tenantId)
}
$goto("../portal/")
} else {
await auth.resetPassword(password, resetCode)

View File

@ -1,5 +1,5 @@
<script>
import { auth } from "@/stores/portal"
import { admin, auth } from "@/stores/portal"
import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import ProfileModal from "@/components/settings/ProfileModal.svelte"
@ -13,6 +13,8 @@
let updatePasswordModal
let apiKeyModal
$: isOwner = $auth.accountPortalAccess && $admin.cloud
const logout = async () => {
try {
await auth.logout()
@ -32,7 +34,16 @@
</MenuItem>
<MenuItem icon="Moon" on:click={() => themeModal.show()}>Theme</MenuItem>
{#if !$auth.isSSO}
<MenuItem icon="LockClosed" on:click={() => updatePasswordModal.show()}>
<MenuItem
icon="LockClosed"
on:click={() => {
if (isOwner) {
window.location.href = `${$admin.accountPortalUrl}/portal/account`
} else {
updatePasswordModal.show()
}
}}
>
Update password
</MenuItem>
{/if}

View File

@ -49,7 +49,12 @@ export class ComponentTreeNodesStore extends BudiStore<OpenNodesState> {
// Will ensure all parents of a node are expanded so that it is visible in the tree
makeNodeVisible(componentId: string) {
const selectedScreen = get(selectedScreenStore)
const selectedScreen: Screen | undefined = get(selectedScreenStore)
if (!selectedScreen) {
console.error("Invalid node " + componentId)
return {}
}
const path = findComponentPath(selectedScreen?.props, componentId)

View File

@ -33,7 +33,16 @@ import { Utils } from "@budibase/frontend-core"
import { Component, FieldType, Screen, Table } from "@budibase/types"
import { utils } from "@budibase/shared-core"
interface ComponentDefinition {
export interface ComponentState {
components: Record<string, ComponentDefinition>
customComponents: string[]
selectedComponentId?: string
componentToPaste?: Component
settingsCache: Record<string, ComponentSetting[]>
selectedScreenId?: string | null
}
export interface ComponentDefinition {
component: string
name: string
friendlyName?: string
@ -41,10 +50,11 @@ interface ComponentDefinition {
settings?: ComponentSetting[]
features?: Record<string, boolean>
typeSupportPresets?: Record<string, any>
illegalChildren?: string[]
legalDirectChildren: string[]
illegalChildren: string[]
}
interface ComponentSetting {
export interface ComponentSetting {
key: string
type: string
section?: string
@ -55,20 +65,9 @@ interface ComponentSetting {
settings?: ComponentSetting[]
}
interface ComponentState {
components: Record<string, ComponentDefinition>
customComponents: string[]
selectedComponentId: string | null | undefined
componentToPaste?: Component | null
settingsCache: Record<string, ComponentSetting[]>
selectedScreenId?: string | null
}
export const INITIAL_COMPONENTS_STATE: ComponentState = {
components: {},
customComponents: [],
selectedComponentId: null,
componentToPaste: null,
settingsCache: {},
}
@ -441,6 +440,11 @@ export class ComponentStore extends BudiStore<ComponentState> {
* @returns
*/
createInstance(componentName: string, presetProps: any, parent: any) {
const screen = get(selectedScreen)
if (!screen) {
throw "A valid screen must be selected"
}
const definition = this.getDefinition(componentName)
if (!definition) {
return null
@ -462,7 +466,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Standard post processing
this.enrichEmptySettings(instance, {
parent,
screen: get(selectedScreen),
screen,
useDefaultValues: true,
})
@ -483,7 +487,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Add step name to form steps
if (componentName.endsWith("/formstep") && $selectedScreen) {
const parentForm = findClosestMatchingComponent(
$selectedScreen.props,
screen.props,
get(selectedComponent)._id,
(component: Component) => component._component.endsWith("/form")
)
@ -543,7 +547,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Find the selected component
let selectedComponentId = state.selectedComponentId
if (selectedComponentId?.startsWith(`${screen._id}-`)) {
selectedComponentId = screen.props._id || null
selectedComponentId = screen.props._id
}
const currentComponent = findComponent(
screen.props,
@ -654,7 +658,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Determine the next component to select, and select it before deletion
// to avoid an intermediate state of no component selection
const state = get(this.store)
let nextId: string | null = ""
let nextId = ""
if (state.selectedComponentId === component._id) {
nextId = this.getNext()
if (!nextId) {
@ -741,7 +745,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
if (!state.componentToPaste) {
return
}
let newComponentId: string | null = ""
let newComponentId = ""
// Remove copied component if cutting, regardless if pasting works
let componentToPaste = cloneDeep(state.componentToPaste)
@ -842,7 +846,10 @@ export class ComponentStore extends BudiStore<ComponentState> {
getPrevious() {
const state = get(this.store)
const componentId = state.selectedComponentId
const screen = get(selectedScreen)!
const screen = get(selectedScreen)
if (!screen) {
throw "A valid screen must be selected"
}
const parent = findComponentParent(screen.props, componentId)
const index = parent?._children.findIndex(
(x: Component) => x._id === componentId
@ -891,7 +898,10 @@ export class ComponentStore extends BudiStore<ComponentState> {
const state = get(this.store)
const component = get(selectedComponent)
const componentId = component?._id
const screen = get(selectedScreen)!
const screen = get(selectedScreen)
if (!screen) {
throw "A valid screen must be selected"
}
const parent = findComponentParent(screen.props, componentId)
const index = parent?._children.findIndex(
(x: Component) => x._id === componentId
@ -1158,7 +1168,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
}
async handleEjectBlock(componentId: string, ejectedDefinition: Component) {
let nextSelectedComponentId: string | null = null
let nextSelectedComponentId: string | undefined
await screenStore.patch((screen: Screen) => {
const block = findComponent(screen.props, componentId)
@ -1194,7 +1204,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
(x: Component) => x._id === componentId
)
parent._children[index] = ejectedDefinition
nextSelectedComponentId = ejectedDefinition._id ?? null
nextSelectedComponentId = ejectedDefinition._id
}, null)
// Select new root component

View File

@ -3,7 +3,7 @@ import { appStore } from "./app.js"
import { componentStore, selectedComponent } from "./components"
import { navigationStore } from "./navigation.js"
import { themeStore } from "./theme.js"
import { screenStore, selectedScreen, sortedScreens } from "./screens.js"
import { screenStore, selectedScreen, sortedScreens } from "./screens"
import { builderStore } from "./builder.js"
import { hoverStore } from "./hover.js"
import { previewStore } from "./preview.js"
@ -16,6 +16,7 @@ import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js"
import { deploymentStore } from "./deployments.js"
import { contextMenuStore } from "./contextMenu.js"
import { snippets } from "./snippets"
import { screenComponentErrors } from "./screenComponent"
// Backend
import { tables } from "./tables"
@ -67,6 +68,7 @@ export {
snippets,
rowActions,
appPublished,
screenComponentErrors,
}
export const reset = () => {

View File

@ -0,0 +1,113 @@
import { derived } from "svelte/store"
import { tables } from "./tables"
import { selectedScreen } from "./screens"
import { viewsV2 } from "./viewsV2"
import { findComponentsBySettingsType } from "@/helpers/screen"
import { UIDatasourceType, Screen } from "@budibase/types"
import { queries } from "./queries"
import { views } from "./views"
import { bindings, featureFlag } from "@/helpers"
import { getBindableProperties } from "@/dataBinding"
function reduceBy<TItem extends {}, TKey extends keyof TItem>(
key: TKey,
list: TItem[]
): Record<string, any> {
return list.reduce(
(result, item) => ({
...result,
[item[key] as string]: item,
}),
{}
)
}
const friendlyNameByType: Partial<Record<UIDatasourceType, string>> = {
viewV2: "view",
}
const validationKeyByType: Record<UIDatasourceType, string | null> = {
table: "tableId",
view: "name",
viewV2: "id",
query: "_id",
custom: null,
link: "rowId",
field: "value",
jsonarray: "value",
}
export const screenComponentErrors = derived(
[selectedScreen, tables, views, viewsV2, queries],
([$selectedScreen, $tables, $views, $viewsV2, $queries]): Record<
string,
string[]
> => {
if (!featureFlag.isEnabled("CHECK_SCREEN_COMPONENT_SETTINGS_ERRORS")) {
return {}
}
function getInvalidDatasources(
screen: Screen,
datasources: Record<string, any>
) {
const result: Record<string, string[]> = {}
for (const { component, setting } of findComponentsBySettingsType(
screen,
["table", "dataSource"]
)) {
const componentSettings = component[setting.key]
if (!componentSettings) {
continue
}
const { label } = componentSettings
const type = componentSettings.type as UIDatasourceType
const validationKey = validationKeyByType[type]
if (!validationKey) {
continue
}
const componentBindings = getBindableProperties(
$selectedScreen,
component._id
)
const componentDatasources = {
...reduceBy(
"rowId",
bindings.extractRelationships(componentBindings)
),
...reduceBy("value", bindings.extractFields(componentBindings)),
...reduceBy(
"value",
bindings.extractJSONArrayFields(componentBindings)
),
}
const resourceId = componentSettings[validationKey]
if (!{ ...datasources, ...componentDatasources }[resourceId]) {
const friendlyTypeName = friendlyNameByType[type] ?? type
result[component._id!] = [
`The ${friendlyTypeName} named "${label}" could not be found`,
]
}
}
return result
}
const datasources = {
...reduceBy("_id", $tables.list),
...reduceBy("name", $views.list),
...reduceBy("id", $viewsV2.list),
...reduceBy("_id", $queries.list),
}
if (!$selectedScreen) {
// Skip validation if a screen is not selected.
return {}
}
return getInvalidDatasources($selectedScreen, datasources)
}
)

View File

@ -10,29 +10,35 @@ import {
navigationStore,
selectedComponent,
} from "@/stores/builder"
import { createHistoryStore, HistoryStore } from "@/stores/builder/history"
import { createHistoryStore } from "@/stores/builder/history"
import { API } from "@/api"
import { BudiStore } from "../BudiStore"
import { Component, Screen } from "@budibase/types"
import {
FetchAppPackageResponse,
DeleteScreenResponse,
Screen,
Component,
SaveScreenResponse,
} from "@budibase/types"
import { ComponentDefinition } from "./components"
interface ScreenState {
screens: Screen[]
selectedScreenId: string | null | undefined
selected?: Screen
selectedScreenId?: string
}
export const INITIAL_SCREENS_STATE: ScreenState = {
export const initialScreenState: ScreenState = {
screens: [],
selectedScreenId: null,
}
// Review the nulls
export class ScreenStore extends BudiStore<ScreenState> {
history: HistoryStore<Screen>
save: (doc: Screen) => Promise<Screen>
delete: (doc: Screen) => Promise<void>
history: any
delete: any
save: any
constructor() {
super(INITIAL_SCREENS_STATE)
super(initialScreenState)
// Bind scope
this.select = this.select.bind(this)
@ -49,14 +55,16 @@ export class ScreenStore extends BudiStore<ScreenState> {
this.removeCustomLayout = this.removeCustomLayout.bind(this)
this.history = createHistoryStore({
getDoc: id => get(this.store).screens?.find(screen => screen._id === id),
getDoc: (id: string) =>
get(this.store).screens?.find(screen => screen._id === id),
selectDoc: this.select,
beforeAction: () => {},
afterAction: () => {
// Ensure a valid component is selected
if (!get(selectedComponent)) {
this.update(state => ({
...state,
selectedComponentId: get(this.store).selected?.props._id,
selectedComponentId: get(selectedScreen)?.props._id,
}))
}
},
@ -70,14 +78,14 @@ export class ScreenStore extends BudiStore<ScreenState> {
* Reset entire store back to base config
*/
reset() {
this.store.set({ ...INITIAL_SCREENS_STATE })
this.store.set({ ...initialScreenState })
}
/**
* Replace ALL store screens with application package screens
* @param {object} pkg
* @param {FetchAppPackageResponse} pkg
*/
syncAppScreens(pkg: { screens: Screen[] }) {
syncAppScreens(pkg: FetchAppPackageResponse) {
this.update(state => ({
...state,
screens: [...pkg.screens],
@ -114,7 +122,7 @@ export class ScreenStore extends BudiStore<ScreenState> {
* Recursively parses the entire screen doc and checks for components
* violating illegal child configurations.
*
* @param {object} screen
* @param {Screen} screen
* @throws Will throw an error containing the name of the component causing
* the invalid screen state
*/
@ -125,7 +133,7 @@ export class ScreenStore extends BudiStore<ScreenState> {
illegalChildren: string[] = [],
legalDirectChildren: string[] = []
): string | undefined => {
const type = component._component
const type: string = component._component
if (illegalChildren.includes(type)) {
return type
@ -148,7 +156,20 @@ export class ScreenStore extends BudiStore<ScreenState> {
illegalChildren = []
}
const definition = componentStore.getDefinition(component._component)
const definition: ComponentDefinition | null =
componentStore.getDefinition(component._component)
if (definition == null) {
throw `Invalid defintion ${component._component}`
}
// Reset whitelist for direct children
legalDirectChildren = []
if (definition?.legalDirectChildren?.length) {
legalDirectChildren = definition.legalDirectChildren.map(x => {
return `@budibase/standard-components/${x}`
})
}
// Append blacklisted components and remove duplicates
if (definition?.illegalChildren?.length) {
@ -184,10 +205,9 @@ export class ScreenStore extends BudiStore<ScreenState> {
* Core save method. If creating a new screen, the store will sync the target
* screen id to ensure that it is selected in the builder
*
* @param {object} screen
* @returns {object}
* @param {Screen} screen The screen being modified/created
*/
async saveScreen(screen: Screen): Promise<Screen> {
async saveScreen(screen: Screen) {
const appState = get(appStore)
// Validate screen structure if the app supports it
@ -232,7 +252,7 @@ export class ScreenStore extends BudiStore<ScreenState> {
/**
* After saving a screen, sync plugins and routes to the appStore
* @param {object} savedScreen
* @param {Screen} savedScreen
*/
async syncScreenData(savedScreen: Screen) {
const appState = get(appStore)
@ -261,10 +281,7 @@ export class ScreenStore extends BudiStore<ScreenState> {
* supports deeply mutating the current doc rather than just appending data.
*/
sequentialScreenPatch = Utils.sequential(
async (
patchFn: (screen: Screen) => any,
screenId: string
): Promise<Screen | undefined> => {
async (patchFn: (screen: Screen) => any, screenId: string) => {
const state = get(this.store)
const screen = state.screens.find(screen => screen._id === screenId)
if (!screen) {
@ -282,14 +299,13 @@ export class ScreenStore extends BudiStore<ScreenState> {
)
/**
* @param {function} patchFn
* @param {Function} patchFn the patch action to be applied
* @param {string | null} screenId
* @returns
*/
async patch(
patchFn: (screen: Screen) => void,
screenId: string | undefined | null
) {
patchFn: (screen: Screen) => any,
screenId?: string | null
): Promise<SaveScreenResponse | void> {
// Default to the currently selected screen
if (!screenId) {
const state = get(this.store)
@ -306,9 +322,9 @@ export class ScreenStore extends BudiStore<ScreenState> {
* the screen supplied. If no screen is provided, the target has
* been removed by another user and will be filtered from the store.
* Used to marshal updates for the websocket
* @param {string} screenId
* @param {object} screen
* @returns
*
* @param {string} screenId the target screen id
* @param {Screen} screen the replacement screen
*/
async replace(screenId: string, screen: Screen) {
if (!screenId) {
@ -346,20 +362,27 @@ export class ScreenStore extends BudiStore<ScreenState> {
* Any deleted screens will then have their routes/links purged
*
* Wrapped by {@link delete}
* @param {object | array} screens
* @returns
* @param {Screen | Screen[]} screens
*/
async deleteScreen(screen: Screen) {
const screensToDelete = [screen]
async deleteScreen(screens: Screen | Screen[]) {
const screensToDelete = Array.isArray(screens) ? screens : [screens]
// Build array of promises to speed up bulk deletions
let promises: Promise<any>[] = []
let promises: Promise<DeleteScreenResponse>[] = []
let deleteUrls: string[] = []
screensToDelete.forEach(screen => {
// Delete the screen
promises.push(API.deleteScreen(screen._id!, screen._rev!))
// Remove links to this screen
deleteUrls.push(screen.routing.route)
})
// In this instance _id will have been set
// Underline the expectation that _id and _rev will be set after filtering
screensToDelete
.filter(
(screen): screen is Screen & { _id: string; _rev: string } =>
!!screen._id || !!screen._rev
)
.forEach(screen => {
// Delete the screen
promises.push(API.deleteScreen(screen._id, screen._rev))
// Remove links to this screen
deleteUrls.push(screen.routing.route)
})
await Promise.all(promises)
await navigationStore.deleteLink(deleteUrls)
const deletedIds = screensToDelete.map(screen => screen._id)
@ -375,11 +398,11 @@ export class ScreenStore extends BudiStore<ScreenState> {
state.selectedScreenId &&
deletedIds.includes(state.selectedScreenId)
) {
state.selectedScreenId = null
componentStore.update(state => ({
...state,
selectedComponentId: null,
}))
delete state.selectedScreenId
componentStore.update(state => {
delete state.selectedComponentId
return state
})
}
// Update routing
@ -390,7 +413,6 @@ export class ScreenStore extends BudiStore<ScreenState> {
return state
})
return
}
/**
@ -399,12 +421,11 @@ export class ScreenStore extends BudiStore<ScreenState> {
* After a successful update, this method ensures that there is only
* ONE home screen per user Role.
*
* @param {object} screen
* @param {Screen} screen
* @param {string} name e.g "routing.homeScreen" or "showNavigation"
* @param {any} value
* @returns
*/
async updateSetting(screen: Screen, name: string, value: string) {
async updateSetting(screen: Screen, name: string, value: any) {
if (!screen || !name) {
return
}
@ -461,11 +482,14 @@ export class ScreenStore extends BudiStore<ScreenState> {
/**
* Parse the entire screen component tree and ensure settings are valid
* and up-to-date. Ensures stability after a product update.
* @param {object} screen
* @param {Screen} screen
*/
async enrichEmptySettings(screen: Screen) {
// Flatten the recursive component tree
const components = findAllMatchingComponents(screen.props, (x: string) => x)
const components = findAllMatchingComponents(
screen.props,
(x: Component) => x
)
// Iterate over all components and run checks
components.forEach(component => {

View File

@ -3,7 +3,7 @@ import { get, writable } from "svelte/store"
import { API } from "@/api"
import { Constants } from "@budibase/frontend-core"
import { componentStore, appStore } from "@/stores/builder"
import { INITIAL_SCREENS_STATE, ScreenStore } from "@/stores/builder/screens"
import { initialScreenState, ScreenStore } from "@/stores/builder/screens"
import {
getScreenFixture,
getComponentFixture,
@ -73,7 +73,7 @@ describe("Screens store", () => {
vi.clearAllMocks()
const screenStore = new ScreenStore()
ctx.test = {
ctx.bb = {
get store() {
return get(screenStore)
},
@ -81,74 +81,76 @@ describe("Screens store", () => {
}
})
it("Create base screen store with defaults", ctx => {
expect(ctx.test.store).toStrictEqual(INITIAL_SCREENS_STATE)
it("Create base screen store with defaults", ({ bb }) => {
expect(bb.store).toStrictEqual(initialScreenState)
})
it("Syncs all screens from the app package", ctx => {
expect(ctx.test.store.screens.length).toBe(0)
it("Syncs all screens from the app package", ({ bb }) => {
expect(bb.store.screens.length).toBe(0)
const screens = Array(2)
.fill()
.map(() => getScreenFixture().json())
ctx.test.screenStore.syncAppScreens({ screens })
bb.screenStore.syncAppScreens({ screens })
expect(ctx.test.store.screens).toStrictEqual(screens)
expect(bb.store.screens).toStrictEqual(screens)
})
it("Reset the screen store back to the default state", ctx => {
expect(ctx.test.store.screens.length).toBe(0)
it("Reset the screen store back to the default state", ({ bb }) => {
expect(bb.store.screens.length).toBe(0)
const screens = Array(2)
.fill()
.map(() => getScreenFixture().json())
ctx.test.screenStore.syncAppScreens({ screens })
expect(ctx.test.store.screens).toStrictEqual(screens)
bb.screenStore.syncAppScreens({ screens })
expect(bb.store.screens).toStrictEqual(screens)
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
selectedScreenId: screens[0]._id,
}))
ctx.test.screenStore.reset()
bb.screenStore.reset()
expect(ctx.test.store).toStrictEqual(INITIAL_SCREENS_STATE)
expect(bb.store).toStrictEqual(initialScreenState)
})
it("Marks a valid screen as selected", ctx => {
it("Marks a valid screen as selected", ({ bb }) => {
const screens = Array(2)
.fill()
.map(() => getScreenFixture().json())
ctx.test.screenStore.syncAppScreens({ screens })
expect(ctx.test.store.screens.length).toBe(2)
bb.screenStore.syncAppScreens({ screens })
expect(bb.store.screens.length).toBe(2)
ctx.test.screenStore.select(screens[0]._id)
bb.screenStore.select(screens[0]._id)
expect(ctx.test.store.selectedScreenId).toEqual(screens[0]._id)
expect(bb.store.selectedScreenId).toEqual(screens[0]._id)
})
it("Skip selecting a screen if it is not present", ctx => {
it("Skip selecting a screen if it is not present", ({ bb }) => {
const screens = Array(2)
.fill()
.map(() => getScreenFixture().json())
ctx.test.screenStore.syncAppScreens({ screens })
expect(ctx.test.store.screens.length).toBe(2)
bb.screenStore.syncAppScreens({ screens })
expect(bb.store.screens.length).toBe(2)
ctx.test.screenStore.select("screen_abc")
bb.screenStore.select("screen_abc")
expect(ctx.test.store.selectedScreenId).toBeNull()
expect(bb.store.selectedScreenId).toBeUndefined()
})
it("Approve a valid empty screen config", ctx => {
it("Approve a valid empty screen config", ({ bb }) => {
const coreScreen = getScreenFixture()
ctx.test.screenStore.validate(coreScreen.json())
bb.screenStore.validate(coreScreen.json())
})
it("Approve a valid screen config with one component and no illegal children", ctx => {
it("Approve a valid screen config with one component and no illegal children", ({
bb,
}) => {
const coreScreen = getScreenFixture()
const formBlock = getComponentFixture(`${COMP_PREFIX}/formblock`)
@ -157,12 +159,12 @@ describe("Screens store", () => {
const defSpy = vi.spyOn(componentStore, "getDefinition")
defSpy.mockReturnValueOnce(COMPONENT_DEFINITIONS.formblock)
ctx.test.screenStore.validate(coreScreen.json())
bb.screenStore.validate(coreScreen.json())
expect(defSpy).toHaveBeenCalled()
})
it("Reject an attempt to nest invalid components", ctx => {
it("Reject an attempt to nest invalid components", ({ bb }) => {
const coreScreen = getScreenFixture()
const formOne = getComponentFixture(`${COMP_PREFIX}/form`)
@ -178,14 +180,14 @@ describe("Screens store", () => {
return defMap[comp]
})
expect(() => ctx.test.screenStore.validate(coreScreen.json())).toThrowError(
expect(() => bb.screenStore.validate(coreScreen.json())).toThrowError(
`You can't place a ${COMPONENT_DEFINITIONS.form.name} here`
)
expect(defSpy).toHaveBeenCalled()
})
it("Reject an attempt to deeply nest invalid components", ctx => {
it("Reject an attempt to deeply nest invalid components", ({ bb }) => {
const coreScreen = getScreenFixture()
const formOne = getComponentFixture(`${COMP_PREFIX}/form`)
@ -210,14 +212,16 @@ describe("Screens store", () => {
return defMap[comp]
})
expect(() => ctx.test.screenStore.validate(coreScreen.json())).toThrowError(
expect(() => bb.screenStore.validate(coreScreen.json())).toThrowError(
`You can't place a ${COMPONENT_DEFINITIONS.form.name} here`
)
expect(defSpy).toHaveBeenCalled()
})
it("Save a brand new screen and add it to the store. No validation", async ctx => {
it("Save a brand new screen and add it to the store. No validation", async ({
bb,
}) => {
const coreScreen = getScreenFixture()
const formOne = getComponentFixture(`${COMP_PREFIX}/form`)
@ -225,7 +229,7 @@ describe("Screens store", () => {
appStore.set({ features: { componentValidation: false } })
expect(ctx.test.store.screens.length).toBe(0)
expect(bb.store.screens.length).toBe(0)
const newDocId = getScreenDocId()
const newDoc = { ...coreScreen.json(), _id: newDocId }
@ -235,15 +239,15 @@ describe("Screens store", () => {
vi.spyOn(API, "fetchAppRoutes").mockResolvedValue({
routes: [],
})
await ctx.test.screenStore.save(coreScreen.json())
await bb.screenStore.save(coreScreen.json())
expect(saveSpy).toHaveBeenCalled()
expect(ctx.test.store.screens.length).toBe(1)
expect(bb.store.screens.length).toBe(1)
expect(ctx.test.store.screens[0]).toStrictEqual(newDoc)
expect(bb.store.screens[0]).toStrictEqual(newDoc)
expect(ctx.test.store.selectedScreenId).toBe(newDocId)
expect(bb.store.selectedScreenId).toBe(newDocId)
// The new screen should be selected
expect(get(componentStore).selectedComponentId).toBe(
@ -251,7 +255,7 @@ describe("Screens store", () => {
)
})
it("Sync an updated screen to the screen store on save", async ctx => {
it("Sync an updated screen to the screen store on save", async ({ bb }) => {
const existingScreens = Array(4)
.fill()
.map(() => {
@ -261,7 +265,7 @@ describe("Screens store", () => {
return screenDoc
})
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
screens: existingScreens.map(screen => screen.json()),
}))
@ -279,16 +283,18 @@ describe("Screens store", () => {
})
// Saved the existing screen having modified it.
await ctx.test.screenStore.save(existingScreens[2].json())
await bb.screenStore.save(existingScreens[2].json())
expect(routeSpy).toHaveBeenCalled()
expect(saveSpy).toHaveBeenCalled()
// On save, the screen is spliced back into the store with the saved content
expect(ctx.test.store.screens[2]).toStrictEqual(existingScreens[2].json())
expect(bb.store.screens[2]).toStrictEqual(existingScreens[2].json())
})
it("Sync API data to relevant stores on save. Updated plugins", async ctx => {
it("Sync API data to relevant stores on save. Updated plugins", async ({
bb,
}) => {
const coreScreen = getScreenFixture()
const newDocId = getScreenDocId()
@ -318,7 +324,7 @@ describe("Screens store", () => {
routes: [],
})
await ctx.test.screenStore.syncScreenData(newDoc)
await bb.screenStore.syncScreenData(newDoc)
expect(routeSpy).toHaveBeenCalled()
expect(appPackageSpy).toHaveBeenCalled()
@ -326,7 +332,9 @@ describe("Screens store", () => {
expect(get(appStore).usedPlugins).toStrictEqual(plugins)
})
it("Sync API updates to relevant stores on save. Plugins unchanged", async ctx => {
it("Sync API updates to relevant stores on save. Plugins unchanged", async ({
bb,
}) => {
const coreScreen = getScreenFixture()
const newDocId = getScreenDocId()
@ -343,7 +351,7 @@ describe("Screens store", () => {
routes: [],
})
await ctx.test.screenStore.syncScreenData(newDoc)
await bb.screenStore.syncScreenData(newDoc)
expect(routeSpy).toHaveBeenCalled()
expect(appPackageSpy).not.toHaveBeenCalled()
@ -352,46 +360,48 @@ describe("Screens store", () => {
expect(get(appStore).usedPlugins).toStrictEqual([plugin])
})
it("Proceed to patch if appropriate config are supplied", async ctx => {
vi.spyOn(ctx.test.screenStore, "sequentialScreenPatch").mockImplementation(
() => {
return false
}
)
it("Proceed to patch if appropriate config are supplied", async ({ bb }) => {
vi.spyOn(bb.screenStore, "sequentialScreenPatch").mockImplementation(() => {
return false
})
const noop = () => {}
await ctx.test.screenStore.patch(noop, "test")
expect(ctx.test.screenStore.sequentialScreenPatch).toHaveBeenCalledWith(
await bb.screenStore.patch(noop, "test")
expect(bb.screenStore.sequentialScreenPatch).toHaveBeenCalledWith(
noop,
"test"
)
})
it("Return from the patch if all valid config are not present", async ctx => {
vi.spyOn(ctx.test.screenStore, "sequentialScreenPatch")
await ctx.test.screenStore.patch()
expect(ctx.test.screenStore.sequentialScreenPatch).not.toBeCalled()
it("Return from the patch if all valid config are not present", async ({
bb,
}) => {
vi.spyOn(bb.screenStore, "sequentialScreenPatch")
await bb.screenStore.patch()
expect(bb.screenStore.sequentialScreenPatch).not.toBeCalled()
})
it("Acquire the currently selected screen on patch, if not specified", async ctx => {
vi.spyOn(ctx.test.screenStore, "sequentialScreenPatch")
await ctx.test.screenStore.patch()
it("Acquire the currently selected screen on patch, if not specified", async ({
bb,
}) => {
vi.spyOn(bb.screenStore, "sequentialScreenPatch")
await bb.screenStore.patch()
const noop = () => {}
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
selectedScreenId: "screen_123",
}))
await ctx.test.screenStore.patch(noop)
expect(ctx.test.screenStore.sequentialScreenPatch).toHaveBeenCalledWith(
await bb.screenStore.patch(noop)
expect(bb.screenStore.sequentialScreenPatch).toHaveBeenCalledWith(
noop,
"screen_123"
)
})
// Used by the websocket
it("Ignore a call to replace if no screenId is provided", ctx => {
it("Ignore a call to replace if no screenId is provided", ({ bb }) => {
const existingScreens = Array(4)
.fill()
.map(() => {
@ -400,14 +410,16 @@ describe("Screens store", () => {
screenDoc._json._id = existingDocId
return screenDoc.json()
})
ctx.test.screenStore.syncAppScreens({ screens: existingScreens })
bb.screenStore.syncAppScreens({ screens: existingScreens })
ctx.test.screenStore.replace()
bb.screenStore.replace()
expect(ctx.test.store.screens).toStrictEqual(existingScreens)
expect(bb.store.screens).toStrictEqual(existingScreens)
})
it("Remove a screen from the store if a single screenId is supplied", ctx => {
it("Remove a screen from the store if a single screenId is supplied", ({
bb,
}) => {
const existingScreens = Array(4)
.fill()
.map(() => {
@ -416,17 +428,17 @@ describe("Screens store", () => {
screenDoc._json._id = existingDocId
return screenDoc.json()
})
ctx.test.screenStore.syncAppScreens({ screens: existingScreens })
bb.screenStore.syncAppScreens({ screens: existingScreens })
ctx.test.screenStore.replace(existingScreens[1]._id)
bb.screenStore.replace(existingScreens[1]._id)
const filtered = existingScreens.filter(
screen => screen._id != existingScreens[1]._id
)
expect(ctx.test.store.screens).toStrictEqual(filtered)
expect(bb.store.screens).toStrictEqual(filtered)
})
it("Replace an existing screen with a new version of itself", ctx => {
it("Replace an existing screen with a new version of itself", ({ bb }) => {
const existingScreens = Array(4)
.fill()
.map(() => {
@ -436,7 +448,7 @@ describe("Screens store", () => {
return screenDoc
})
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
screens: existingScreens.map(screen => screen.json()),
}))
@ -444,15 +456,14 @@ describe("Screens store", () => {
const formBlock = getComponentFixture(`${COMP_PREFIX}/formblock`)
existingScreens[2].addChild(formBlock)
ctx.test.screenStore.replace(
existingScreens[2]._id,
existingScreens[2].json()
)
bb.screenStore.replace(existingScreens[2]._id, existingScreens[2].json())
expect(ctx.test.store.screens.length).toBe(4)
expect(bb.store.screens.length).toBe(4)
})
it("Add a screen when attempting to replace one not present in the store", ctx => {
it("Add a screen when attempting to replace one not present in the store", ({
bb,
}) => {
const existingScreens = Array(4)
.fill()
.map(() => {
@ -462,7 +473,7 @@ describe("Screens store", () => {
return screenDoc
})
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
screens: existingScreens.map(screen => screen.json()),
}))
@ -470,13 +481,13 @@ describe("Screens store", () => {
const newScreenDoc = getScreenFixture()
newScreenDoc._json._id = getScreenDocId()
ctx.test.screenStore.replace(newScreenDoc._json._id, newScreenDoc.json())
bb.screenStore.replace(newScreenDoc._json._id, newScreenDoc.json())
expect(ctx.test.store.screens.length).toBe(5)
expect(ctx.test.store.screens[4]).toStrictEqual(newScreenDoc.json())
expect(bb.store.screens.length).toBe(5)
expect(bb.store.screens[4]).toStrictEqual(newScreenDoc.json())
})
it("Delete a single screen and remove it from the store", async ctx => {
it("Delete a single screen and remove it from the store", async ({ bb }) => {
const existingScreens = Array(3)
.fill()
.map(() => {
@ -486,14 +497,14 @@ describe("Screens store", () => {
return screenDoc
})
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
screens: existingScreens.map(screen => screen.json()),
}))
const deleteSpy = vi.spyOn(API, "deleteScreen")
await ctx.test.screenStore.delete(existingScreens[2].json())
await bb.screenStore.delete(existingScreens[2].json())
vi.spyOn(API, "fetchAppRoutes").mockResolvedValue({
routes: [],
@ -501,13 +512,15 @@ describe("Screens store", () => {
expect(deleteSpy).toBeCalled()
expect(ctx.test.store.screens.length).toBe(2)
expect(bb.store.screens.length).toBe(2)
// Just confirm that the routes at are being initialised
expect(get(appStore).routes).toEqual([])
})
it("Upon delete, reset selected screen and component ids if the screen was selected", async ctx => {
it("Upon delete, reset selected screen and component ids if the screen was selected", async ({
bb,
}) => {
const existingScreens = Array(3)
.fill()
.map(() => {
@ -517,7 +530,7 @@ describe("Screens store", () => {
return screenDoc
})
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
screens: existingScreens.map(screen => screen.json()),
selectedScreenId: existingScreens[2]._json._id,
@ -528,14 +541,16 @@ describe("Screens store", () => {
selectedComponentId: existingScreens[2]._json._id,
}))
await ctx.test.screenStore.delete(existingScreens[2].json())
await bb.screenStore.delete(existingScreens[2].json())
expect(ctx.test.store.screens.length).toBe(2)
expect(get(componentStore).selectedComponentId).toBeNull()
expect(ctx.test.store.selectedScreenId).toBeNull()
expect(bb.store.screens.length).toBe(2)
expect(get(componentStore).selectedComponentId).toBeUndefined()
expect(bb.store.selectedScreenId).toBeUndefined()
})
it("Delete multiple is not supported and should leave the store unchanged", async ctx => {
it("Delete multiple is not supported and should leave the store unchanged", async ({
bb,
}) => {
const existingScreens = Array(3)
.fill()
.map(() => {
@ -547,7 +562,7 @@ describe("Screens store", () => {
const storeScreens = existingScreens.map(screen => screen.json())
ctx.test.screenStore.update(state => ({
bb.screenStore.update(state => ({
...state,
screens: existingScreens.map(screen => screen.json()),
}))
@ -556,42 +571,40 @@ describe("Screens store", () => {
const deleteSpy = vi.spyOn(API, "deleteScreen")
await ctx.test.screenStore.delete(targets)
await bb.screenStore.delete(targets)
expect(deleteSpy).not.toHaveBeenCalled()
expect(ctx.test.store.screens.length).toBe(3)
expect(ctx.test.store.screens).toStrictEqual(storeScreens)
expect(bb.store.screens.length).toBe(3)
expect(bb.store.screens).toStrictEqual(storeScreens)
})
it("Update a screen setting", async ctx => {
it("Update a screen setting", async ({ bb }) => {
const screenDoc = getScreenFixture()
const existingDocId = getScreenDocId()
screenDoc._json._id = existingDocId
await ctx.test.screenStore.update(state => ({
await bb.screenStore.update(state => ({
...state,
screens: [screenDoc.json()],
}))
const patchedDoc = screenDoc.json()
const patchSpy = vi
.spyOn(ctx.test.screenStore, "patch")
.spyOn(bb.screenStore, "patch")
.mockImplementation(async patchFn => {
patchFn(patchedDoc)
return
})
await ctx.test.screenStore.updateSetting(
patchedDoc,
"showNavigation",
false
)
await bb.screenStore.updateSetting(patchedDoc, "showNavigation", false)
expect(patchSpy).toBeCalled()
expect(patchedDoc.showNavigation).toBe(false)
})
it("Ensure only one homescreen per role after updating setting. All screens same role", async ctx => {
it("Ensure only one homescreen per role after updating setting. All screens same role", async ({
bb,
}) => {
const existingScreens = Array(3)
.fill()
.map(() => {
@ -611,23 +624,21 @@ describe("Screens store", () => {
// Set the 2nd screen as the home screen
storeScreens[1].routing.homeScreen = true
await ctx.test.screenStore.update(state => ({
await bb.screenStore.update(state => ({
...state,
screens: storeScreens,
}))
const patchSpy = vi
.spyOn(ctx.test.screenStore, "patch")
.spyOn(bb.screenStore, "patch")
.mockImplementation(async (patchFn, screenId) => {
const target = ctx.test.store.screens.find(
screen => screen._id === screenId
)
const target = bb.store.screens.find(screen => screen._id === screenId)
patchFn(target)
await ctx.test.screenStore.replace(screenId, target)
await bb.screenStore.replace(screenId, target)
})
await ctx.test.screenStore.updateSetting(
await bb.screenStore.updateSetting(
storeScreens[0],
"routing.homeScreen",
true
@ -637,13 +648,15 @@ describe("Screens store", () => {
expect(patchSpy).toBeCalledTimes(2)
// The new homescreen for BASIC
expect(ctx.test.store.screens[0].routing.homeScreen).toBe(true)
expect(bb.store.screens[0].routing.homeScreen).toBe(true)
// The previous home screen for the BASIC role is now unset
expect(ctx.test.store.screens[1].routing.homeScreen).toBe(false)
expect(bb.store.screens[1].routing.homeScreen).toBe(false)
})
it("Ensure only one homescreen per role when updating screen setting. Multiple screen roles", async ctx => {
it("Ensure only one homescreen per role when updating screen setting. Multiple screen roles", async ({
bb,
}) => {
const expectedRoles = [
Constants.Roles.BASIC,
Constants.Roles.POWER,
@ -675,30 +688,24 @@ describe("Screens store", () => {
sorted[9].routing.homeScreen = true
// Set screens state
await ctx.test.screenStore.update(state => ({
await bb.screenStore.update(state => ({
...state,
screens: sorted,
}))
const patchSpy = vi
.spyOn(ctx.test.screenStore, "patch")
.spyOn(bb.screenStore, "patch")
.mockImplementation(async (patchFn, screenId) => {
const target = ctx.test.store.screens.find(
screen => screen._id === screenId
)
const target = bb.store.screens.find(screen => screen._id === screenId)
patchFn(target)
await ctx.test.screenStore.replace(screenId, target)
await bb.screenStore.replace(screenId, target)
})
// ADMIN homeScreen updated from 0 to 2
await ctx.test.screenStore.updateSetting(
sorted[2],
"routing.homeScreen",
true
)
await bb.screenStore.updateSetting(sorted[2], "routing.homeScreen", true)
const results = ctx.test.store.screens.reduce((acc, screen) => {
const results = bb.store.screens.reduce((acc, screen) => {
if (screen.routing.homeScreen) {
acc[screen.routing.roleId] = acc[screen.routing.roleId] || []
acc[screen.routing.roleId].push(screen)
@ -706,7 +713,7 @@ describe("Screens store", () => {
return acc
}, {})
const screens = ctx.test.store.screens
const screens = bb.store.screens
// Should still only be one of each homescreen
expect(results[Constants.Roles.ADMIN].length).toBe(1)
expect(screens[2].routing.homeScreen).toBe(true)
@ -724,74 +731,80 @@ describe("Screens store", () => {
expect(patchSpy).toBeCalledTimes(2)
})
it("Sequential patch check. Exit if the screenId is not valid.", async ctx => {
it("Sequential patch check. Exit if the screenId is not valid.", async ({
bb,
}) => {
const screenDoc = getScreenFixture()
const existingDocId = getScreenDocId()
screenDoc._json._id = existingDocId
const original = screenDoc.json()
await ctx.test.screenStore.update(state => ({
await bb.screenStore.update(state => ({
...state,
screens: [original],
}))
const saveSpy = vi
.spyOn(ctx.test.screenStore, "save")
.spyOn(bb.screenStore, "save")
.mockImplementation(async () => {
return
})
// A screen with this Id does not exist
await ctx.test.screenStore.sequentialScreenPatch(() => {}, "123")
await bb.screenStore.sequentialScreenPatch(() => {}, "123")
expect(saveSpy).not.toBeCalled()
})
it("Sequential patch check. Exit if the patchFn result is false", async ctx => {
it("Sequential patch check. Exit if the patchFn result is false", async ({
bb,
}) => {
const screenDoc = getScreenFixture()
const existingDocId = getScreenDocId()
screenDoc._json._id = existingDocId
const original = screenDoc.json()
// Set screens state
await ctx.test.screenStore.update(state => ({
await bb.screenStore.update(state => ({
...state,
screens: [original],
}))
const saveSpy = vi
.spyOn(ctx.test.screenStore, "save")
.spyOn(bb.screenStore, "save")
.mockImplementation(async () => {
return
})
// Returning false from the patch will abort the save
await ctx.test.screenStore.sequentialScreenPatch(() => {
await bb.screenStore.sequentialScreenPatch(() => {
return false
}, "123")
expect(saveSpy).not.toBeCalled()
})
it("Sequential patch check. Patch applied and save requested", async ctx => {
it("Sequential patch check. Patch applied and save requested", async ({
bb,
}) => {
const screenDoc = getScreenFixture()
const existingDocId = getScreenDocId()
screenDoc._json._id = existingDocId
const original = screenDoc.json()
await ctx.test.screenStore.update(state => ({
await bb.screenStore.update(state => ({
...state,
screens: [original],
}))
const saveSpy = vi
.spyOn(ctx.test.screenStore, "save")
.spyOn(bb.screenStore, "save")
.mockImplementation(async () => {
return
})
await ctx.test.screenStore.sequentialScreenPatch(screen => {
await bb.screenStore.sequentialScreenPatch(screen => {
screen.name = "updated"
}, existingDocId)

View File

@ -20,9 +20,9 @@ import {
Automation,
Datasource,
Role,
Screen,
Table,
UIUser,
Screen,
} from "@budibase/types"
export const createBuilderWebsocket = (appId: string) => {

View File

@ -1,5 +1,5 @@
import { it, expect, describe, beforeEach, vi } from "vitest"
import { createAdminStore } from "./admin"
import { AdminStore } from "./admin"
import { writable, get } from "svelte/store"
import { API } from "@/api"
import { auth } from "@/stores/portal"
@ -46,16 +46,7 @@ describe("admin store", () => {
ctx.writableReturn = { update: vi.fn(), subscribe: vi.fn() }
writable.mockReturnValue(ctx.writableReturn)
ctx.returnedStore = createAdminStore()
})
it("returns the created store", ctx => {
expect(ctx.returnedStore).toEqual({
subscribe: expect.toBe(ctx.writableReturn.subscribe),
init: expect.toBeFunc(),
unload: expect.toBeFunc(),
getChecklist: expect.toBeFunc(),
})
ctx.returnedStore = new AdminStore()
})
describe("init method", () => {

View File

@ -1,4 +1,4 @@
import { writable, get } from "svelte/store"
import { get } from "svelte/store"
import { API } from "@/api"
import { auth } from "@/stores/portal"
import { banner } from "@budibase/bbui"
@ -7,42 +7,44 @@ import {
GetEnvironmentResponse,
SystemStatusResponse,
} from "@budibase/types"
import { BudiStore } from "../BudiStore"
interface PortalAdminStore extends GetEnvironmentResponse {
interface AdminState extends GetEnvironmentResponse {
loaded: boolean
checklist?: ConfigChecklistResponse
status?: SystemStatusResponse
}
export function createAdminStore() {
const admin = writable<PortalAdminStore>({
loaded: false,
multiTenancy: false,
cloud: false,
isDev: false,
disableAccountPortal: false,
offlineMode: false,
maintenance: [],
})
export class AdminStore extends BudiStore<AdminState> {
constructor() {
super({
loaded: false,
multiTenancy: false,
cloud: false,
isDev: false,
disableAccountPortal: false,
offlineMode: false,
maintenance: [],
})
}
async function init() {
await getChecklist()
await getEnvironment()
async init() {
await this.getChecklist()
await this.getEnvironment()
// enable system status checks in the cloud
if (get(admin).cloud) {
await getSystemStatus()
checkStatus()
if (get(this.store).cloud) {
await this.getSystemStatus()
this.checkStatus()
}
admin.update(store => {
this.update(store => {
store.loaded = true
return store
})
}
async function getEnvironment() {
async getEnvironment() {
const environment = await API.getEnvironment()
admin.update(store => {
this.update(store => {
store.multiTenancy = environment.multiTenancy
store.cloud = environment.cloud
store.disableAccountPortal = environment.disableAccountPortal
@ -56,43 +58,36 @@ export function createAdminStore() {
})
}
const checkStatus = async () => {
const health = get(admin)?.status?.health
async checkStatus() {
const health = get(this.store).status?.health
if (!health?.passing) {
await banner.showStatus()
}
}
async function getSystemStatus() {
async getSystemStatus() {
const status = await API.getSystemStatus()
admin.update(store => {
this.update(store => {
store.status = status
return store
})
}
async function getChecklist() {
async getChecklist() {
const tenantId = get(auth).tenantId
const checklist = await API.getChecklist(tenantId)
admin.update(store => {
this.update(store => {
store.checklist = checklist
return store
})
}
function unload() {
admin.update(store => {
unload() {
this.update(store => {
store.loaded = false
return store
})
}
return {
subscribe: admin.subscribe,
init,
unload,
getChecklist,
}
}
export const admin = createAdminStore()
export const admin = new AdminStore()

View File

@ -13,7 +13,7 @@ interface PortalAuditLogsStore {
logs?: SearchAuditLogsResponse
}
export class AuditLogsStore extends BudiStore<PortalAuditLogsStore> {
class AuditLogsStore extends BudiStore<PortalAuditLogsStore> {
constructor() {
super({})
}

View File

@ -121,8 +121,8 @@ class AuthStore extends BudiStore<PortalAuthStore> {
}
}
async login(username: string, password: string) {
const tenantId = get(this.store).tenantId
async login(username: string, password: string, targetTenantId?: string) {
const tenantId = targetTenantId || get(this.store).tenantId
await API.logIn(tenantId, username, password)
await this.getSelf()
}

View File

@ -1,38 +1,31 @@
import { writable } from "svelte/store"
import { BudiStore } from "../BudiStore"
type GotoFuncType = (path: string) => void
interface PortalNavigationStore {
interface NavigationState {
initialisated: boolean
goto: GotoFuncType
}
export function createNavigationStore() {
const store = writable<PortalNavigationStore>({
initialisated: false,
goto: undefined as any,
})
const { set, subscribe } = store
class NavigationStore extends BudiStore<NavigationState> {
constructor() {
super({
initialisated: false,
goto: undefined as any,
})
}
const init = (gotoFunc: GotoFuncType) => {
init(gotoFunc: GotoFuncType) {
if (typeof gotoFunc !== "function") {
throw new Error(
`gotoFunc must be a function, found a "${typeof gotoFunc}" instead`
)
}
set({
this.set({
initialisated: true,
goto: gotoFunc,
})
}
return {
subscribe,
actions: {
init,
},
}
}
export const navigation = createNavigationStore()
export const navigation = new NavigationStore()

View File

@ -1,16 +0,0 @@
import { writable } from "svelte/store"
import { API } from "@/api"
export function templatesStore() {
const { subscribe, set } = writable([])
return {
subscribe,
load: async () => {
const templates = await API.getAppTemplates()
set(templates)
},
}
}
export const templates = templatesStore()

View File

@ -0,0 +1,16 @@
import { API } from "@/api"
import { BudiStore } from "../BudiStore"
import { TemplateMetadata } from "@budibase/types"
class TemplateStore extends BudiStore<TemplateMetadata[]> {
constructor() {
super([])
}
async load() {
const templates = await API.getAppTemplates()
this.set(templates)
}
}
export const templates = new TemplateStore()

View File

@ -1,45 +0,0 @@
import { createLocalStorageStore } from "@budibase/frontend-core"
import { get } from "svelte/store"
export const createTemporalStore = () => {
const initialValue = {}
const localStorageKey = `bb-temporal`
const store = createLocalStorageStore(localStorageKey, initialValue)
const setExpiring = (key, data, duration) => {
const updated = {
...data,
expiry: Date.now() + duration * 1000,
}
store.update(state => ({
...state,
[key]: updated,
}))
}
const getExpiring = key => {
const entry = get(store)[key]
if (!entry) {
return
}
const currentExpiry = entry.expiry
if (currentExpiry < Date.now()) {
store.update(state => {
delete state[key]
return state
})
return null
} else {
return entry
}
}
return {
subscribe: store.subscribe,
actions: { setExpiring, getExpiring },
}
}
export const temporalStore = createTemporalStore()

View File

@ -0,0 +1,53 @@
import { get } from "svelte/store"
import { BudiStore, PersistenceType } from "../BudiStore"
type TemporalItem = Record<string, any> & { expiry: number }
type TemporalState = Record<string, TemporalItem>
class TemporalStore extends BudiStore<TemporalState> {
constructor() {
super(
{},
{
persistence: {
key: "bb-temporal",
type: PersistenceType.LOCAL,
},
}
)
}
setExpiring = (
key: string,
data: Record<string, any>,
durationSeconds: number
) => {
const updated: TemporalItem = {
...data,
expiry: Date.now() + durationSeconds * 1000,
}
this.update(state => ({
...state,
[key]: updated,
}))
}
getExpiring(key: string) {
const entry = get(this.store)[key]
if (!entry) {
return null
}
const currentExpiry = entry.expiry
if (currentExpiry < Date.now()) {
this.update(state => {
delete state[key]
return state
})
return null
} else {
return entry
}
}
}
export const temporalStore = new TemporalStore()

View File

@ -1,37 +0,0 @@
import { createLocalStorageStore } from "@budibase/frontend-core"
import { derived } from "svelte/store"
import {
DefaultBuilderTheme,
ensureValidTheme,
getThemeClassNames,
ThemeOptions,
ThemeClassPrefix,
} from "@budibase/shared-core"
export const getThemeStore = () => {
const themeElement = document.documentElement
const initialValue = {
theme: DefaultBuilderTheme,
}
const store = createLocalStorageStore("bb-theme", initialValue)
const derivedStore = derived(store, $store => ({
...$store,
theme: ensureValidTheme($store.theme, DefaultBuilderTheme),
}))
// Update theme class when store changes
derivedStore.subscribe(({ theme }) => {
const classNames = getThemeClassNames(theme).split(" ")
ThemeOptions.forEach(option => {
const className = `${ThemeClassPrefix}${option.id}`
themeElement.classList.toggle(className, classNames.includes(className))
})
})
return {
...store,
subscribe: derivedStore.subscribe,
}
}
export const themeStore = getThemeStore()

View File

@ -0,0 +1,45 @@
import { derived, Writable } from "svelte/store"
import {
DefaultBuilderTheme,
ensureValidTheme,
getThemeClassNames,
ThemeOptions,
ThemeClassPrefix,
} from "@budibase/shared-core"
import { Theme } from "@budibase/types"
import { DerivedBudiStore, PersistenceType } from "../BudiStore"
interface ThemeState {
theme: Theme
}
class ThemeStore extends DerivedBudiStore<ThemeState, ThemeState> {
constructor() {
const makeDerivedStore = (store: Writable<ThemeState>) => {
return derived(store, $store => ({
...$store,
theme: ensureValidTheme($store.theme, DefaultBuilderTheme),
}))
}
super({ theme: DefaultBuilderTheme }, makeDerivedStore, {
persistence: {
key: "bb-theme",
type: PersistenceType.LOCAL,
},
})
// Update theme class when store changes
this.subscribe(({ theme }) => {
const classNames = getThemeClassNames(theme).split(" ")
ThemeOptions.forEach(option => {
const className = `${ThemeClassPrefix}${option.id}`
document.documentElement.classList.toggle(
className,
classNames.includes(className)
)
})
})
}
}
export const themeStore = new ThemeStore()

View File

@ -103,6 +103,7 @@
let settingsDefinition
let settingsDefinitionMap
let missingRequiredSettings = false
let componentErrors = false
// Temporary styles which can be added in the app preview for things like
// DND. We clear these whenever a new instance is received.
@ -137,16 +138,21 @@
// Derive definition properties which can all be optional, so need to be
// coerced to booleans
$: componentErrors = instance?._meta?.errors
$: hasChildren = !!definition?.hasChildren
$: showEmptyState = definition?.showEmptyState !== false
$: hasMissingRequiredSettings = missingRequiredSettings?.length > 0
$: editable = !!definition?.editable && !hasMissingRequiredSettings
$: hasComponentErrors = componentErrors?.length > 0
$: requiredAncestors = definition?.requiredAncestors || []
$: missingRequiredAncestors = requiredAncestors.filter(
ancestor => !$component.ancestors.includes(`${BudibasePrefix}${ancestor}`)
)
$: hasMissingRequiredAncestors = missingRequiredAncestors?.length > 0
$: errorState = hasMissingRequiredSettings || hasMissingRequiredAncestors
$: errorState =
hasMissingRequiredSettings ||
hasMissingRequiredAncestors ||
hasComponentErrors
// Interactive components can be selected, dragged and highlighted inside
// the builder preview
@ -692,6 +698,7 @@
<ComponentErrorState
{missingRequiredSettings}
{missingRequiredAncestors}
{componentErrors}
/>
{:else}
<svelte:component this={constructor} bind:this={ref} {...initialSettings}>

View File

@ -8,6 +8,7 @@
| { key: string; label: string }[]
| undefined
export let missingRequiredAncestors: string[] | undefined
export let componentErrors: string[] | undefined
const component = getContext("component")
const { styleable, builderStore } = getContext("sdk")
@ -15,6 +16,7 @@
$: styles = { ...$component.styles, normal: {}, custom: null, empty: true }
$: requiredSetting = missingRequiredSettings?.[0]
$: requiredAncestor = missingRequiredAncestors?.[0]
$: errorMessage = componentErrors?.[0]
</script>
{#if $builderStore.inBuilder}
@ -23,6 +25,8 @@
<Icon name="Alert" color="var(--spectrum-global-color-static-red-600)" />
{#if requiredAncestor}
<MissingRequiredAncestor {requiredAncestor} />
{:else if errorMessage}
{errorMessage}
{:else if requiredSetting}
<MissingRequiredSetting {requiredSetting} />
{/if}
@ -34,7 +38,7 @@
.component-placeholder {
display: flex;
flex-direction: row;
justify-content: flex-start;
justify-content: center;
align-items: center;
color: var(--spectrum-global-color-gray-600);
font-size: var(--font-size-s);

View File

@ -43,6 +43,7 @@ const loadBudibase = async () => {
usedPlugins: window["##BUDIBASE_USED_PLUGINS##"],
location: window["##BUDIBASE_LOCATION##"],
snippets: window["##BUDIBASE_SNIPPETS##"],
componentErrors: window["##BUDIBASE_COMPONENT_ERRORS##"],
})
// Set app ID - this window flag is set by both the preview and the real

View File

@ -19,6 +19,7 @@ const createBuilderStore = () => {
eventResolvers: {},
metadata: null,
snippets: null,
componentErrors: {},
// Legacy - allow the builder to specify a layout
layout: null,

View File

@ -42,6 +42,14 @@ const createScreenStore = () => {
if ($builderStore.layout) {
activeLayout = $builderStore.layout
}
// Attach meta
const errors = $builderStore.componentErrors || {}
const attachComponentMeta = component => {
component._meta = { errors: errors[component._id] || [] }
component._children?.forEach(attachComponentMeta)
}
attachComponentMeta(activeScreen.props)
} else {
// Find the correct screen by matching the current route
screens = $appStore.screens || []

View File

@ -1,5 +1,3 @@
// TODO: datasource and defitions are unions of the different implementations. At this point, the datasource does not know what type is being used, and the assignations will cause TS exceptions. Casting it "as any" for now. This should be fixed improving the type usages.
import { derived, get, Readable, Writable } from "svelte/store"
import {
DataFetchDefinition,
@ -10,12 +8,10 @@ import { enrichSchemaWithRelColumns, memo } from "../../../utils"
import { cloneDeep } from "lodash"
import {
SaveRowRequest,
SaveTableRequest,
UIDatasource,
UIFieldMutation,
UIFieldSchema,
UIRow,
UpdateViewRequest,
ViewV2Type,
} from "@budibase/types"
import { Store as StoreContext, BaseStoreProps } from "."
@ -79,7 +75,7 @@ export const deriveStores = (context: StoreContext): DerivedDatasourceStore => {
const schema = derived(definition, $definition => {
const schema: Record<string, any> | undefined = getDatasourceSchema({
API,
datasource: get(datasource) as any, // TODO: see line 1
datasource: get(datasource),
definition: $definition ?? undefined,
})
if (!schema) {
@ -137,7 +133,7 @@ export const deriveStores = (context: StoreContext): DerivedDatasourceStore => {
let type = $datasource?.type
// @ts-expect-error
if (type === "provider") {
type = ($datasource as any).value?.datasource?.type // TODO: see line 1
type = ($datasource as any).value?.datasource?.type
}
// Handle calculation views
if (
@ -196,15 +192,13 @@ export const createActions = (context: StoreContext): ActionDatasourceStore => {
const refreshDefinition = async () => {
const def = await getDatasourceDefinition({
API,
datasource: get(datasource) as any, // TODO: see line 1
datasource: get(datasource),
})
definition.set(def as any) // TODO: see line 1
definition.set(def ?? null)
}
// Saves the datasource definition
const saveDefinition = async (
newDefinition: SaveTableRequest | UpdateViewRequest
) => {
const saveDefinition = async (newDefinition: DataFetchDefinition) => {
// Update local state
const originalDefinition = get(definition)
definition.set(newDefinition)
@ -245,7 +239,7 @@ export const createActions = (context: StoreContext): ActionDatasourceStore => {
delete newDefinition.schema[column].default
}
}
return await saveDefinition(newDefinition as any) // TODO: see line 1
return await saveDefinition(newDefinition)
}
// Adds a schema mutation for a single field
@ -321,7 +315,7 @@ export const createActions = (context: StoreContext): ActionDatasourceStore => {
await saveDefinition({
...$definition,
schema: newSchema,
} as any) // TODO: see line 1
})
resetSchemaMutations()
}

View File

@ -21,7 +21,7 @@ export default class ViewFetch extends BaseDataFetch<ViewV1Datasource, Table> {
getSchema(definition: Table) {
const { datasource } = this.options
return definition?.views?.[datasource.name]?.schema
return definition?.views?.[datasource?.name]?.schema
}
async getData() {

View File

@ -101,12 +101,12 @@ export const fetchData = <
// Creates an empty fetch instance with no datasource configured, so no data
// will initially be loaded
const createEmptyFetchInstance = ({
const createEmptyFetchInstance = <T extends DataFetchDatasource>({
API,
datasource,
}: {
API: APIClient
datasource: DataFetchDatasource
datasource: T
}) => {
const handler = DataFetchMap[datasource?.type]
if (!handler) {
@ -114,7 +114,7 @@ const createEmptyFetchInstance = ({
}
return new handler({
API,
datasource: null as never,
datasource: datasource as any,
query: null as any,
})
}

View File

@ -10,7 +10,7 @@ export const sleep = (ms: number) =>
* Utility to wrap an async function and ensure all invocations happen
* sequentially.
* @param fn the async function to run
* @return {Promise} a sequential version of the function
* @return {Function} a sequential version of the function
*/
export const sequential = <
TReturn,

View File

@ -7,7 +7,7 @@
"../shared-core/src",
"../string-templates/src"
],
"ext": "js,ts,json,svelte",
"ext": "js,ts,json,svelte,hbs",
"ignore": [
"**/*.spec.ts",
"**/*.spec.js",

View File

@ -2,7 +2,7 @@ import * as triggers from "../../automations/triggers"
import { sdk as coreSdk } from "@budibase/shared-core"
import { DocumentType } from "../../db/utils"
import { updateTestHistory, removeDeprecated } from "../../automations/utils"
import { setTestFlag, clearTestFlag } from "../../utilities/redis"
import { withTestFlag } from "../../utilities/redis"
import { context, cache, events, db as dbCore } from "@budibase/backend-core"
import { automations, features } from "@budibase/pro"
import {
@ -231,24 +231,25 @@ export async function test(
ctx: UserCtx<TestAutomationRequest, TestAutomationResponse>
) {
const db = context.getAppDB()
let automation = await db.get<Automation>(ctx.params.id)
await setTestFlag(automation._id!)
const testInput = prepareTestInput(ctx.request.body)
const response = await triggers.externalTrigger(
automation,
{
...testInput,
appId: ctx.appId,
user: sdk.users.getUserContextBindings(ctx.user),
},
{ getResponses: true }
)
// save a test history run
await updateTestHistory(ctx.appId, automation, {
...ctx.request.body,
occurredAt: new Date().getTime(),
const automation = await db.tryGet<Automation>(ctx.params.id)
if (!automation) {
ctx.throw(404, `Automation ${ctx.params.id} not found`)
}
const { request, appId } = ctx
const { body } = request
ctx.body = await withTestFlag(automation._id!, async () => {
const occurredAt = new Date().getTime()
await updateTestHistory(appId, automation, { ...body, occurredAt })
const user = sdk.users.getUserContextBindings(ctx.user)
return await triggers.externalTrigger(
automation,
{ ...prepareTestInput(body), appId, user },
{ getResponses: true }
)
})
await clearTestFlag(automation._id!)
ctx.body = response
await events.automation.tested(automation)
}

View File

@ -73,7 +73,8 @@
hiddenComponentIds,
usedPlugins,
location,
snippets
snippets,
componentErrors
} = parsed
// Set some flags so the app knows we're in the builder
@ -91,6 +92,7 @@
window["##BUDIBASE_USED_PLUGINS##"] = usedPlugins
window["##BUDIBASE_LOCATION##"] = location
window["##BUDIBASE_SNIPPETS##"] = snippets
window['##BUDIBASE_COMPONENT_ERRORS##'] = componentErrors
// Initialise app
try {

View File

@ -5,8 +5,11 @@ import {
sendAutomationAttachmentsToStorage,
} from "../automationUtils"
import { buildCtx } from "./utils"
import { CreateRowStepInputs, CreateRowStepOutputs } from "@budibase/types"
import { EventEmitter } from "events"
import {
ContextEmitter,
CreateRowStepInputs,
CreateRowStepOutputs,
} from "@budibase/types"
export async function run({
inputs,
@ -15,7 +18,7 @@ export async function run({
}: {
inputs: CreateRowStepInputs
appId: string
emitter: EventEmitter
emitter: ContextEmitter
}): Promise<CreateRowStepOutputs> {
if (inputs.row == null || inputs.row.tableId == null) {
return {

View File

@ -1,8 +1,11 @@
import { EventEmitter } from "events"
import { destroy } from "../../api/controllers/row"
import { buildCtx } from "./utils"
import { getError } from "../automationUtils"
import { DeleteRowStepInputs, DeleteRowStepOutputs } from "@budibase/types"
import {
ContextEmitter,
DeleteRowStepInputs,
DeleteRowStepOutputs,
} from "@budibase/types"
export async function run({
inputs,
@ -11,7 +14,7 @@ export async function run({
}: {
inputs: DeleteRowStepInputs
appId: string
emitter: EventEmitter
emitter: ContextEmitter
}): Promise<DeleteRowStepOutputs> {
if (inputs.id == null) {
return {

View File

@ -1,8 +1,8 @@
import { EventEmitter } from "events"
import * as queryController from "../../api/controllers/query"
import { buildCtx } from "./utils"
import * as automationUtils from "../automationUtils"
import {
ContextEmitter,
ExecuteQueryStepInputs,
ExecuteQueryStepOutputs,
} from "@budibase/types"
@ -14,7 +14,7 @@ export async function run({
}: {
inputs: ExecuteQueryStepInputs
appId: string
emitter: EventEmitter
emitter: ContextEmitter
}): Promise<ExecuteQueryStepOutputs> {
if (inputs.query == null) {
return {

View File

@ -2,10 +2,10 @@ import * as scriptController from "../../api/controllers/script"
import { buildCtx } from "./utils"
import * as automationUtils from "../automationUtils"
import {
ContextEmitter,
ExecuteScriptStepInputs,
ExecuteScriptStepOutputs,
} from "@budibase/types"
import { EventEmitter } from "events"
export async function run({
inputs,
@ -16,7 +16,7 @@ export async function run({
inputs: ExecuteScriptStepInputs
appId: string
context: object
emitter: EventEmitter
emitter: ContextEmitter
}): Promise<ExecuteScriptStepOutputs> {
if (inputs.code == null) {
return {

View File

@ -1,8 +1,11 @@
import { EventEmitter } from "events"
import * as rowController from "../../api/controllers/row"
import * as automationUtils from "../automationUtils"
import { buildCtx } from "./utils"
import { UpdateRowStepInputs, UpdateRowStepOutputs } from "@budibase/types"
import {
ContextEmitter,
UpdateRowStepInputs,
UpdateRowStepOutputs,
} from "@budibase/types"
export async function run({
inputs,
@ -11,7 +14,7 @@ export async function run({
}: {
inputs: UpdateRowStepInputs
appId: string
emitter: EventEmitter
emitter: ContextEmitter
}): Promise<UpdateRowStepOutputs> {
if (inputs.rowId == null || inputs.row == null) {
return {

View File

@ -1,4 +1,4 @@
import { EventEmitter } from "events"
import { ContextEmitter } from "@budibase/types"
export async function getFetchResponse(fetched: any) {
let status = fetched.status,
@ -22,7 +22,7 @@ export async function getFetchResponse(fetched: any) {
// opts can contain, body, params and version
export function buildCtx(
appId: string,
emitter?: EventEmitter | null,
emitter?: ContextEmitter | null,
opts: any = {}
) {
const ctx: any = {

View File

@ -1,5 +1,4 @@
import { v4 as uuidv4 } from "uuid"
import { testAutomation } from "../../../api/routes/tests/utilities/TestFunctions"
import { BUILTIN_ACTION_DEFINITIONS } from "../../actions"
import { TRIGGER_DEFINITIONS } from "../../triggers"
import {
@ -7,7 +6,6 @@ import {
AppActionTriggerOutputs,
Automation,
AutomationActionStepId,
AutomationResults,
AutomationStep,
AutomationStepInputs,
AutomationTrigger,
@ -24,6 +22,7 @@ import {
ExecuteQueryStepInputs,
ExecuteScriptStepInputs,
FilterStepInputs,
isDidNotTriggerResponse,
LoopStepInputs,
OpenAIStepInputs,
QueryRowsStepInputs,
@ -36,6 +35,7 @@ import {
SearchFilters,
ServerLogStepInputs,
SmtpEmailStepInputs,
TestAutomationRequest,
UpdateRowStepInputs,
WebhookTriggerInputs,
WebhookTriggerOutputs,
@ -279,7 +279,7 @@ class StepBuilder extends BaseStepBuilder {
class AutomationBuilder extends BaseStepBuilder {
private automationConfig: Automation
private config: TestConfiguration
private triggerOutputs: any
private triggerOutputs: TriggerOutputs
private triggerSet = false
constructor(
@ -398,21 +398,19 @@ class AutomationBuilder extends BaseStepBuilder {
async run() {
const automation = await this.save()
const results = await testAutomation(
this.config,
automation,
this.triggerOutputs
const response = await this.config.api.automation.test(
automation._id!,
this.triggerOutputs as TestAutomationRequest
)
return this.processResults(results)
}
private processResults(results: {
body: AutomationResults
}): AutomationResults {
results.body.steps.shift()
if (isDidNotTriggerResponse(response)) {
throw new Error(response.message)
}
response.steps.shift()
return {
trigger: results.body.trigger,
steps: results.body.steps,
trigger: response.trigger,
steps: response.steps,
}
}
}

View File

@ -21,6 +21,7 @@ import {
AutomationRowEvent,
UserBindings,
AutomationResults,
DidNotTriggerResponse,
} from "@budibase/types"
import { executeInThread } from "../threads/automation"
import { dataFilters, sdk } from "@budibase/shared-core"
@ -33,14 +34,6 @@ const JOB_OPTS = {
import * as automationUtils from "../automations/automationUtils"
import { doesTableExist } from "../sdk/app/tables/getters"
type DidNotTriggerResponse = {
outputs: {
success: false
status: AutomationStatus.STOPPED
}
message: AutomationStoppedReason.TRIGGER_FILTER_NOT_MET
}
async function getAllAutomations() {
const db = context.getAppDB()
let automations = await db.allDocs<Automation>(
@ -156,14 +149,26 @@ export function isAutomationResults(
)
}
interface AutomationTriggerParams {
fields: Record<string, any>
timeout?: number
appId?: string
user?: UserBindings
}
export async function externalTrigger(
automation: Automation,
params: {
fields: Record<string, any>
timeout?: number
appId?: string
user?: UserBindings
},
params: AutomationTriggerParams,
options: { getResponses: true }
): Promise<AutomationResults | DidNotTriggerResponse>
export async function externalTrigger(
automation: Automation,
params: AutomationTriggerParams,
options?: { getResponses: false }
): Promise<AutomationJob | DidNotTriggerResponse>
export async function externalTrigger(
automation: Automation,
params: AutomationTriggerParams,
{ getResponses }: { getResponses?: boolean } = {}
): Promise<AutomationResults | DidNotTriggerResponse | AutomationJob> {
if (automation.disabled) {

View File

@ -4,12 +4,17 @@ import {
JsTimeoutError,
setJSRunner,
setOnErrorLog,
setTestingBackendJS,
} from "@budibase/string-templates"
import { context, logging } from "@budibase/backend-core"
import tracer from "dd-trace"
import { IsolatedVM } from "./vm"
export function init() {
// enforce that if we're using isolated-VM runner then we are running backend JS
if (env.isTest()) {
setTestingBackendJS()
}
setJSRunner((js: string, ctx: Record<string, any>) => {
return tracer.trace("runJS", {}, () => {
try {

View File

@ -1,4 +1,9 @@
import { Automation, FetchAutomationResponse } from "@budibase/types"
import {
Automation,
FetchAutomationResponse,
TestAutomationRequest,
TestAutomationResponse,
} from "@budibase/types"
import { Expectations, TestAPI } from "./base"
export class AutomationAPI extends TestAPI {
@ -33,4 +38,18 @@ export class AutomationAPI extends TestAPI {
})
return result
}
test = async (
id: string,
body: TestAutomationRequest,
expectations?: Expectations
): Promise<TestAutomationResponse> => {
return await this._post<TestAutomationResponse>(
`/api/automations/${id}/test`,
{
body,
expectations,
}
)
}
}

View File

@ -29,6 +29,7 @@ import {
LoopStep,
UserBindings,
isBasicSearchOperator,
ContextEmitter,
} from "@budibase/types"
import {
AutomationContext,
@ -71,6 +72,24 @@ function getLoopIterations(loopStep: LoopStep) {
return 0
}
export async function enrichBaseContext(context: Record<string, any>) {
context.env = await sdkUtils.getEnvironmentVariables()
try {
const { config } = await configs.getSettingsConfigDoc()
context.settings = {
url: config.platformUrl,
logo: config.logoUrl,
company: config.company,
}
} catch (e) {
// if settings doc doesn't exist, make the settings blank
context.settings = {}
}
return context
}
/**
* The automation orchestrator is a class responsible for executing automations.
* It handles the context of the automation and makes sure each step gets the correct
@ -80,7 +99,7 @@ class Orchestrator {
private chainCount: number
private appId: string
private automation: Automation
private emitter: any
private emitter: ContextEmitter
private context: AutomationContext
private job: Job
private loopStepOutputs: LoopStep[]
@ -270,20 +289,9 @@ class Orchestrator {
appId: this.appId,
automationId: this.automation._id,
})
this.context.env = await sdkUtils.getEnvironmentVariables()
this.context.user = this.currentUser
try {
const { config } = await configs.getSettingsConfigDoc()
this.context.settings = {
url: config.platformUrl,
logo: config.logoUrl,
company: config.company,
}
} catch (e) {
// if settings doc doesn't exist, make the settings blank
this.context.settings = {}
}
await enrichBaseContext(this.context)
this.context.user = this.currentUser
let metadata

View File

@ -58,30 +58,14 @@ export function checkSlashesInUrl(url: string) {
export async function updateEntityMetadata(
type: string,
entityId: string,
updateFn: any
updateFn: (metadata: Document) => Document
) {
const db = context.getAppDB()
const id = generateMetadataID(type, entityId)
// read it to see if it exists, we'll overwrite it no matter what
let rev, metadata: Document
try {
const oldMetadata = await db.get<any>(id)
rev = oldMetadata._rev
metadata = updateFn(oldMetadata)
} catch (err) {
rev = null
metadata = updateFn({})
}
const metadata = updateFn((await db.tryGet(id)) || {})
metadata._id = id
if (rev) {
metadata._rev = rev
}
const response = await db.put(metadata)
return {
...metadata,
_id: id,
_rev: response.rev,
}
return { ...metadata, _id: id, _rev: response.rev }
}
export async function saveEntityMetadata(
@ -89,26 +73,17 @@ export async function saveEntityMetadata(
entityId: string,
metadata: Document
): Promise<Document> {
return updateEntityMetadata(type, entityId, () => {
return metadata
})
return updateEntityMetadata(type, entityId, () => metadata)
}
export async function deleteEntityMetadata(type: string, entityId: string) {
const db = context.getAppDB()
const id = generateMetadataID(type, entityId)
let rev
try {
const metadata = await db.get<any>(id)
if (metadata) {
rev = metadata._rev
}
} catch (err) {
// don't need to error if it doesn't exist
}
if (id && rev) {
await db.remove(id, rev)
const metadata = await db.tryGet(id)
if (!metadata) {
return
}
await db.remove(metadata)
}
export function escapeDangerousCharacters(string: string) {

View File

@ -89,17 +89,22 @@ export async function setDebounce(id: string, seconds: number) {
await debounceClient.store(id, "debouncing", seconds)
}
export async function setTestFlag(id: string) {
await flagClient.store(id, { testing: true }, AUTOMATION_TEST_FLAG_SECONDS)
}
export async function checkTestFlag(id: string) {
const flag = await flagClient?.get(id)
return !!(flag && flag.testing)
}
export async function clearTestFlag(id: string) {
await devAppClient.delete(id)
export async function withTestFlag<R>(id: string, fn: () => Promise<R>) {
// TODO(samwho): this has a bit of a problem where if 2 automations are tested
// at the same time, the second one will overwrite the first one's flag. We
// should instead use an atomic counter and only clear the flag when the
// counter reaches 0.
await flagClient.store(id, { testing: true }, AUTOMATION_TEST_FLAG_SECONDS)
try {
return await fn()
} finally {
await devAppClient.delete(id)
}
}
export function getSocketPubSubClients() {

View File

@ -23,6 +23,7 @@
},
"dependencies": {
"@budibase/handlebars-helpers": "^0.13.2",
"@budibase/vm-browserify": "^1.1.4",
"dayjs": "^1.10.8",
"handlebars": "^4.7.8",
"lodash.clonedeep": "^4.5.0"

View File

@ -0,0 +1,23 @@
function isJest() {
return (
process.env.NODE_ENV === "jest" ||
(process.env.JEST_WORKER_ID != null &&
process.env.JEST_WORKER_ID !== "null")
)
}
export function isTest() {
return isJest()
}
export const isJSAllowed = () => {
return process && !process.env.NO_JS
}
export const isTestingBackendJS = () => {
return process && process.env.BACKEND_JS
}
export const setTestingBackendJS = () => {
process.env.BACKEND_JS = "1"
}

View File

@ -1,9 +1,16 @@
import { atob, isBackendService, isJSAllowed } from "../utilities"
import {
atob,
frontendWrapJS,
isBackendService,
isJSAllowed,
} from "../utilities"
import { LITERAL_MARKER } from "../helpers/constants"
import { getJsHelperList } from "./list"
import { iifeWrapper } from "../iife"
import { JsTimeoutError, UserScriptError } from "../errors"
import { cloneDeep } from "lodash/fp"
import { Log, LogType } from "../types"
import { isTest } from "../environment"
// The method of executing JS scripts depends on the bundle being built.
// This setter is used in the entrypoint (either index.js or index.mjs).
@ -81,7 +88,7 @@ export function processJS(handlebars: string, context: any) {
let clonedContext: Record<string, any>
if (isBackendService()) {
// On the backned, values are copied across the isolated-vm boundary and
// On the backend, values are copied across the isolated-vm boundary and
// so we don't need to do any cloning here. This does create a fundamental
// difference in how JS executes on the frontend vs the backend, e.g.
// consider this snippet:
@ -96,10 +103,9 @@ export function processJS(handlebars: string, context: any) {
clonedContext = cloneDeep(context)
}
const sandboxContext = {
const sandboxContext: Record<string, any> = {
$: (path: string) => getContextValue(path, clonedContext),
helpers: getJsHelperList(),
// Proxy to evaluate snippets when running in the browser
snippets: new Proxy(
{},
@ -114,8 +120,49 @@ export function processJS(handlebars: string, context: any) {
),
}
const logs: Log[] = []
// logging only supported on frontend
if (!isBackendService()) {
// this counts the lines in the wrapped JS *before* the user's code, so that we can minus it
const jsLineCount = frontendWrapJS(js).split(js)[0].split("\n").length
const buildLogResponse = (type: LogType) => {
return (...props: any[]) => {
if (!isTest()) {
console[type](...props)
}
props.forEach((prop, index) => {
if (typeof prop === "object") {
props[index] = JSON.stringify(prop)
}
})
// quick way to find out what line this is being called from
// its an anonymous function and we look for the overall length to find the
// line number we care about (from the users function)
// JS stack traces are in the format function:line:column
const lineNumber = new Error().stack?.match(
/<anonymous>:(\d+):\d+/
)?.[1]
logs.push({
log: props,
line: lineNumber ? parseInt(lineNumber) - jsLineCount : undefined,
type,
})
}
}
sandboxContext.console = {
log: buildLogResponse("log"),
info: buildLogResponse("info"),
debug: buildLogResponse("debug"),
warn: buildLogResponse("warn"),
error: buildLogResponse("error"),
// table should be treated differently, but works the same
// as the rest of the logs for now
table: buildLogResponse("table"),
}
}
// Create a sandbox with our context and run the JS
const res = { data: runJS(js, sandboxContext) }
const res = { data: runJS(js, sandboxContext), logs }
return `{{${LITERAL_MARKER} js_result-${JSON.stringify(res)}}}`
} catch (error: any) {
onErrorLog && onErrorLog(error)

View File

@ -1,23 +1,27 @@
import { createContext, runInNewContext } from "vm"
import browserVM from "@budibase/vm-browserify"
import vm from "vm"
import { create, TemplateDelegate } from "handlebars"
import { registerAll, registerMinimum } from "./helpers/index"
import { postprocess, preprocess } from "./processors"
import { postprocess, postprocessWithLogs, preprocess } from "./processors"
import {
atob,
btoa,
FIND_ANY_HBS_REGEX,
FIND_HBS_REGEX,
findDoubleHbsInstances,
frontendWrapJS,
isBackendService,
prefixStrings,
} from "./utilities"
import { convertHBSBlock } from "./conversion"
import { removeJSRunner, setJSRunner } from "./helpers/javascript"
import manifest from "./manifest.json"
import { ProcessOptions } from "./types"
import { Log, ProcessOptions } from "./types"
import { UserScriptError } from "./errors"
import { isTest } from "./environment"
export type { Log, LogType } from "./types"
export { setTestingBackendJS } from "./environment"
export { helpersToRemoveForJs, getJsHelperList } from "./helpers/list"
export { FIND_ANY_HBS_REGEX } from "./utilities"
export { setJSRunner, setOnErrorLog } from "./helpers/javascript"
@ -187,23 +191,27 @@ export function processObjectSync(
return object
}
/**
* This will process a single handlebars containing string. If the string passed in has no valid handlebars statements
* then nothing will occur. This is a pure sync call and therefore does not have the full functionality of the async call.
* @param {string} string The template string which is the filled from the context object.
* @param {object} context An object of information which will be used to enrich the string.
* @param {object|undefined} [opts] optional - specify some options for processing.
* @returns {string} The enriched string, all templates should have been replaced if they can be.
*/
export function processStringSync(
// keep the logging function internal, don't want to add this to the process options directly
// as it can't be used for object processing etc.
function processStringSyncInternal(
str: string,
context?: object,
opts?: ProcessOptions & { logging: false }
): string
function processStringSyncInternal(
str: string,
context?: object,
opts?: ProcessOptions & { logging: true }
): { result: string; logs: Log[] }
function processStringSyncInternal(
string: string,
context?: object,
opts?: ProcessOptions
): string {
opts?: ProcessOptions & { logging: boolean }
): string | { result: string; logs: Log[] } {
// Take a copy of input in case of error
const input = string
if (typeof string !== "string") {
throw "Cannot process non-string types."
throw new Error("Cannot process non-string types.")
}
function process(stringPart: string) {
// context is needed to check for overlap between helpers and context
@ -217,16 +225,24 @@ export function processStringSync(
},
...context,
})
return postprocess(processedString)
return opts?.logging
? postprocessWithLogs(processedString)
: postprocess(processedString)
}
try {
if (opts && opts.onlyFound) {
let logs: Log[] = []
const blocks = findHBSBlocks(string)
for (let block of blocks) {
const outcome = process(block)
string = string.replace(block, outcome)
if (typeof outcome === "object" && "result" in outcome) {
logs = logs.concat(outcome.logs || [])
string = string.replace(block, outcome.result)
} else {
string = string.replace(block, outcome)
}
}
return string
return !opts?.logging ? string : { result: string, logs }
} else {
return process(string)
}
@ -239,6 +255,42 @@ export function processStringSync(
}
}
/**
* This will process a single handlebars containing string. If the string passed in has no valid handlebars statements
* then nothing will occur. This is a pure sync call and therefore does not have the full functionality of the async call.
* @param {string} string The template string which is the filled from the context object.
* @param {object} context An object of information which will be used to enrich the string.
* @param {object|undefined} [opts] optional - specify some options for processing.
* @returns {string} The enriched string, all templates should have been replaced if they can be.
*/
export function processStringSync(
string: string,
context?: object,
opts?: ProcessOptions
): string {
return processStringSyncInternal(string, context, {
...opts,
logging: false,
})
}
/**
* Same as function above, but allows logging to be returned - this is only for JS bindings.
*/
export function processStringWithLogsSync(
string: string,
context?: object,
opts?: ProcessOptions
): { result: string; logs: Log[] } {
if (isBackendService()) {
throw new Error("Logging disabled for backend bindings")
}
return processStringSyncInternal(string, context, {
...opts,
logging: true,
})
}
/**
* By default with expressions like {{ name }} handlebars will escape various
* characters, which can be problematic. To fix this we use the syntax {{{ name }}},
@ -456,28 +508,15 @@ export function convertToJS(hbs: string) {
export { JsTimeoutError, UserScriptError } from "./errors"
export function browserJSSetup() {
/**
* Use polyfilled vm to run JS scripts in a browser Env
*/
// tests are in jest - we need to use node VM for these
const jsSandbox = isTest() ? vm : browserVM
// Use polyfilled vm to run JS scripts in a browser Env
setJSRunner((js: string, context: Record<string, any>) => {
createContext(context)
jsSandbox.createContext(context)
const wrappedJs = `
result = {
result: null,
error: null,
};
const wrappedJs = frontendWrapJS(js)
try {
result.result = ${js};
} catch (e) {
result.error = e;
}
result;
`
const result = runInNewContext(wrappedJs, context, { timeout: 1000 })
const result = jsSandbox.runInNewContext(wrappedJs, context)
if (result.error) {
throw new UserScriptError(result.error)
}

View File

@ -1,9 +1,16 @@
import { FIND_HBS_REGEX } from "../utilities"
import * as preprocessor from "./preprocessor"
import type { Preprocessor } from "./preprocessor"
import * as postprocessor from "./postprocessor"
import { ProcessOptions } from "../types"
import type { Postprocessor } from "./postprocessor"
import { Log, ProcessOptions } from "../types"
function process(output: string, processors: any[], opts?: ProcessOptions) {
function process(
output: string,
processors: (Preprocessor | Postprocessor)[],
opts?: ProcessOptions
) {
let logs: Log[] = []
for (let processor of processors) {
// if a literal statement has occurred stop
if (typeof output !== "string") {
@ -16,10 +23,18 @@ function process(output: string, processors: any[], opts?: ProcessOptions) {
continue
}
for (let match of matches) {
output = processor.process(output, match, opts)
const res = processor.process(output, match, opts || {})
if (typeof res === "object") {
if ("logs" in res && res.logs) {
logs = logs.concat(res.logs)
}
output = res.result
} else {
output = res as string
}
}
}
return output
return { result: output, logs }
}
export function preprocess(string: string, opts: ProcessOptions) {
@ -30,8 +45,13 @@ export function preprocess(string: string, opts: ProcessOptions) {
)
}
return process(string, processors, opts)
return process(string, processors, opts).result
}
export function postprocess(string: string) {
return process(string, postprocessor.processors).result
}
export function postprocessWithLogs(string: string) {
return process(string, postprocessor.processors)
}

View File

@ -1,12 +1,16 @@
import { LITERAL_MARKER } from "../helpers/constants"
import { Log } from "../types"
export enum PostProcessorNames {
CONVERT_LITERALS = "convert-literals",
}
type PostprocessorFn = (statement: string) => string
export type PostprocessorFn = (statement: string) => {
result: any
logs?: Log[]
}
class Postprocessor {
export class Postprocessor {
name: PostProcessorNames
private readonly fn: PostprocessorFn
@ -23,12 +27,12 @@ class Postprocessor {
export const processors = [
new Postprocessor(
PostProcessorNames.CONVERT_LITERALS,
(statement: string) => {
(statement: string): { result: any; logs?: Log[] } => {
if (
typeof statement !== "string" ||
!statement.includes(LITERAL_MARKER)
) {
return statement
return { result: statement }
}
const splitMarkerIndex = statement.indexOf("-")
const type = statement.substring(12, splitMarkerIndex)
@ -38,20 +42,22 @@ export const processors = [
)
switch (type) {
case "string":
return value
return { result: value }
case "number":
return parseFloat(value)
return { result: parseFloat(value) }
case "boolean":
return value === "true"
return { result: value === "true" }
case "object":
return JSON.parse(value)
case "js_result":
return { result: JSON.parse(value) }
case "js_result": {
// We use the literal helper to process the result of JS expressions
// as we want to be able to return any types.
// We wrap the value in an abject to be able to use undefined properly.
return JSON.parse(value).data
const parsed = JSON.parse(value)
return { result: parsed.data, logs: parsed.logs }
}
}
return value
return { result: value }
}
),
]

View File

@ -11,9 +11,12 @@ export enum PreprocessorNames {
NORMALIZE_SPACES = "normalize-spaces",
}
type PreprocessorFn = (statement: string, opts?: ProcessOptions) => string
export type PreprocessorFn = (
statement: string,
opts?: ProcessOptions
) => string
class Preprocessor {
export class Preprocessor {
name: string
private readonly fn: PreprocessorFn

View File

@ -8,3 +8,11 @@ export interface ProcessOptions {
onlyFound?: boolean
disabledHelpers?: string[]
}
export type LogType = "log" | "info" | "debug" | "warn" | "error" | "table"
export interface Log {
log: any[]
line?: number
type?: LogType
}

View File

@ -1,15 +1,20 @@
import { isTest, isTestingBackendJS } from "./environment"
const ALPHA_NUMERIC_REGEX = /^[A-Za-z0-9]+$/g
export const FIND_HBS_REGEX = /{{([^{].*?)}}/g
export const FIND_ANY_HBS_REGEX = /{?{{([^{].*?)}}}?/g
export const FIND_TRIPLE_HBS_REGEX = /{{{([^{].*?)}}}/g
const isJest = () => typeof jest !== "undefined"
export const isBackendService = () => {
// allow configuring backend JS mode when testing - we default to assuming
// frontend, but need a method to control this
if (isTest() && isTestingBackendJS()) {
return true
}
// We consider the tests for string-templates to be frontend, so that they
// test the frontend JS functionality.
if (isJest()) {
if (isTest()) {
return false
}
return typeof window === "undefined"
@ -86,3 +91,20 @@ export const prefixStrings = (
const regexPattern = new RegExp(`\\b(${escapedStrings.join("|")})\\b`, "g")
return baseString.replace(regexPattern, `${prefix}$1`)
}
export function frontendWrapJS(js: string) {
return `
result = {
result: null,
error: null,
};
try {
result.result = ${js};
} catch (e) {
result.error = e;
}
result;
`
}

View File

@ -125,11 +125,6 @@ describe("Javascript", () => {
expect(processJS(`throw "Error"`)).toEqual("Error")
})
it("should timeout after one second", () => {
const output = processJS(`while (true) {}`)
expect(output).toBe("Timed out while executing JS")
})
it("should prevent access to the process global", async () => {
expect(processJS(`return process`)).toEqual(
"ReferenceError: process is not defined"

View File

@ -0,0 +1,53 @@
import {
processStringWithLogsSync,
encodeJSBinding,
defaultJSSetup,
} from "../src/index"
const processJS = (js: string, context?: object) => {
return processStringWithLogsSync(encodeJSBinding(js), context)
}
describe("Javascript", () => {
beforeAll(() => {
defaultJSSetup()
})
describe("Test logging in JS bindings", () => {
it("should execute a simple expression", () => {
const output = processJS(
`console.log("hello");
console.log("world");
console.log("foo");
return "hello"`
)
expect(output.result).toEqual("hello")
expect(output.logs[0].log).toEqual(["hello"])
expect(output.logs[0].line).toEqual(1)
expect(output.logs[1].log).toEqual(["world"])
expect(output.logs[1].line).toEqual(2)
expect(output.logs[2].log).toEqual(["foo"])
expect(output.logs[2].line).toEqual(3)
})
})
it("should log comma separated values", () => {
const output = processJS(`console.log(1, { a: 1 }); return 1`)
expect(output.logs[0].log).toEqual([1, JSON.stringify({ a: 1 })])
expect(output.logs[0].line).toEqual(1)
})
it("should return the type working with warn", () => {
const output = processJS(`console.warn("warning"); return 1`)
expect(output.logs[0].log).toEqual(["warning"])
expect(output.logs[0].line).toEqual(1)
expect(output.logs[0].type).toEqual("warn")
})
it("should return the type working with error", () => {
const output = processJS(`console.error("error"); return 1`)
expect(output.logs[0].log).toEqual(["error"])
expect(output.logs[0].line).toEqual(1)
expect(output.logs[0].type).toEqual("error")
})
})

View File

@ -2,10 +2,12 @@ import {
Automation,
AutomationActionStepId,
AutomationLogPage,
AutomationResults,
AutomationStatus,
AutomationStepDefinition,
AutomationTriggerDefinition,
AutomationTriggerStepId,
DidNotTriggerResponse,
Row,
} from "../../../documents"
import { DocumentDestroyResponse } from "@budibase/nano"
@ -74,4 +76,10 @@ export interface TestAutomationRequest {
fields: Record<string, any>
row?: Row
}
export interface TestAutomationResponse {}
export type TestAutomationResponse = AutomationResults | DidNotTriggerResponse
export function isDidNotTriggerResponse(
response: TestAutomationResponse
): response is DidNotTriggerResponse {
return !!("message" in response && response.message)
}

View File

@ -1,10 +1,10 @@
import { Document } from "../../document"
import { EventEmitter } from "events"
import { User } from "../../global"
import { ReadStream } from "fs"
import { Row } from "../row"
import { Table } from "../table"
import { AutomationStep, AutomationTrigger } from "./schema"
import { ContextEmitter } from "../../../sdk"
export enum AutomationIOType {
OBJECT = "object",
@ -205,6 +205,14 @@ export interface AutomationResults {
}[]
}
export interface DidNotTriggerResponse {
outputs: {
success: false
status: AutomationStatus.STOPPED
}
message: AutomationStoppedReason.TRIGGER_FILTER_NOT_MET
}
export interface AutomationLog extends AutomationResults, Document {
automationName: string
_rev?: string
@ -218,7 +226,7 @@ export interface AutomationLogPage {
export interface AutomationStepInputBase {
context: Record<string, any>
emitter: EventEmitter
emitter: ContextEmitter
appId: string
apiKey?: string
}

View File

@ -1,5 +1,6 @@
export enum FeatureFlag {
USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR",
CHECK_SCREEN_COMPONENT_SETTINGS_ERRORS = "CHECK_SCREEN_COMPONENT_SETTINGS_ERRORS",
// Account-portal
DIRECT_LOGIN_TO_ACCOUNT_PORTAL = "DIRECT_LOGIN_TO_ACCOUNT_PORTAL",
@ -7,6 +8,7 @@ export enum FeatureFlag {
export const FeatureFlagDefaults = {
[FeatureFlag.USE_ZOD_VALIDATOR]: false,
[FeatureFlag.CHECK_SCREEN_COMPONENT_SETTINGS_ERRORS]: false,
// Account-portal
[FeatureFlag.DIRECT_LOGIN_TO_ACCOUNT_PORTAL]: false,

View File

@ -24,3 +24,18 @@ export type InsertAtPositionFn = (_: {
value: string
cursor?: { anchor: number }
}) => void
export interface UIBinding {
tableId?: string
fieldSchema?: {
name: string
tableId: string
type: string
subtype?: string
prefixKeys?: string
}
component?: string
providerId: string
readableBinding?: string
runtimeBinding?: string
}

View File

@ -0,0 +1,9 @@
export type UIDatasourceType =
| "table"
| "view"
| "viewV2"
| "query"
| "custom"
| "link"
| "field"
| "jsonarray"

View File

@ -2,3 +2,4 @@ export * from "./stores"
export * from "./bindings"
export * from "./components"
export * from "./dataFetch"
export * from "./datasource"

View File

@ -2131,9 +2131,9 @@
through2 "^2.0.0"
"@budibase/pro@npm:@budibase/pro@latest":
version "3.2.44"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.2.44.tgz#90367bb2167aafd8c809e000a57d349e5dc4bb78"
integrity sha512-Zv2PBVUZUS6/psOpIRIDlW3jrOHWWPhpQXzCk00kIQJaqjkdcvuTXSedQ70u537sQmLu8JsSWbui9MdfF8ksVw==
version "3.2.47"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.2.47.tgz#150d7b16b14932d03c84bdb0e6d570d490c28a5c"
integrity sha512-UeTIq7yzMUK6w/akUsRafoD/Kif6PXv4d7K1arn8GTMjwFm9QYu2hg1YkQ+duNdwyZ/GEPlEAV5SYK+NDgtpdA==
dependencies:
"@anthropic-ai/sdk" "^0.27.3"
"@budibase/backend-core" "*"
@ -2152,6 +2152,13 @@
scim-patch "^0.8.1"
scim2-parse-filter "^0.2.8"
"@budibase/vm-browserify@^1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@budibase/vm-browserify/-/vm-browserify-1.1.4.tgz#eecb001bd9521cb7647e26fb4d2d29d0a4dce262"
integrity sha512-/dyOLj+jQNKe6sVfLP6NdwA79OZxEWHCa41VGsjKJC9DYo6l2fEcL5BNXq2pATqrbgWmOlEbcRulfZ+7W0QRUg==
dependencies:
indexof "^0.0.1"
"@bull-board/api@5.10.2":
version "5.10.2"
resolved "https://registry.yarnpkg.com/@bull-board/api/-/api-5.10.2.tgz#ae8ff6918b23897bf879a6ead3683f964374c4b3"
@ -11925,6 +11932,11 @@ indexes-of@^1.0.1:
resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607"
integrity sha512-bup+4tap3Hympa+JBJUG7XuOsdNQ6fxt0MHyXMKuLBKn0OqsTfvUxkUrroEX1+B2VsSHvCjiIcZVxRtYa4nllA==
indexof@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
integrity sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==
infer-owner@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467"
@ -18646,16 +18658,7 @@ string-length@^4.0.1:
char-regex "^1.0.2"
strip-ansi "^6.0.0"
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -18747,7 +18750,7 @@ stringify-object@^3.2.1:
is-obj "^1.0.1"
is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -18761,13 +18764,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2"
@ -20515,7 +20511,7 @@ worker-farm@1.7.0:
dependencies:
errno "~0.1.7"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -20533,15 +20529,6 @@ wrap-ansi@^5.1.0:
string-width "^3.0.0"
strip-ansi "^5.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"