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 ( if (
/^@budibase\/[^/]+\/.*$/.test(importPath) && /^@budibase\/[^/]+\/.*$/.test(importPath) &&
importPath !== "@budibase/backend-core/tests" && importPath !== "@budibase/backend-core/tests" &&
importPath !== "@budibase/string-templates/test/utils" importPath !== "@budibase/string-templates/test/utils" &&
importPath !== "@budibase/client/manifest.json"
) { ) {
context.report({ context.report({
node, 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", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.2.46", "version": "3.3.1",
"npmClient": "yarn", "npmClient": "yarn",
"concurrency": 20, "concurrency": 20,
"command": { "command": {

View File

@ -45,6 +45,11 @@
--purple: #806fde; --purple: #806fde;
--purple-dark: #130080; --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-small: 4px;
--rounded-medium: 8px; --rounded-medium: 8px;
--rounded-large: 16px; --rounded-large: 16px;

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
<script> <script>
import { datasources } from "@/stores/builder"
import { Divider, Heading } from "@budibase/bbui" import { Divider, Heading } from "@budibase/bbui"
export let dividerState export let dividerState
@ -6,6 +7,21 @@
export let dataSet export let dataSet
export let value export let value
export let onSelect 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> </script>
{#if dividerState} {#if dividerState}
@ -21,15 +37,16 @@
{#each dataSet as data} {#each dataSet as data}
<li <li
class="spectrum-Menu-item" class="spectrum-Menu-item"
class:is-selected={value?.label === data.label && class:is-selected={isSelected(data) && value?.type === data.type}
value?.type === data.type}
role="option" role="option"
aria-selected="true" aria-selected="true"
tabindex="0" tabindex="0"
on:click={() => onSelect(data)} on:click={() => onSelect(data)}
> >
<span class="spectrum-Menu-itemLabel"> <span class="spectrum-Menu-itemLabel">
{data.datasourceName ? `${data.datasourceName} - ` : ""}{data.label} {data.datasourceName && displayDatasourceName
? `${data.datasourceName} - `
: ""}{data.label}
</span> </span>
<svg <svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" 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 IntegrationQueryEditor from "@/components/integration/index.svelte"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { findAllComponents } from "@/helpers/components" import { findAllComponents } from "@/helpers/components"
import {
extractFields,
extractJSONArrayFields,
extractRelationships,
} from "@/helpers/bindings"
import ClientBindingPanel from "@/components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "@/components/common/bindings/ClientBindingPanel.svelte"
import DataSourceCategory from "@/components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte" import DataSourceCategory from "@/components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte"
import { API } from "@/api" import { API } from "@/api"
import { datasourceSelect as format } from "@/helpers/data/format" import { sortAndFormat } from "@/helpers/data/format"
export let value = {} export let value = {}
export let otherSources export let otherSources
@ -51,25 +56,13 @@
let modal let modal
$: text = value?.label ?? "Choose an option" $: text = value?.label ?? "Choose an option"
$: tables = $tablesStore.list $: tables = sortAndFormat.tables($tablesStore.list, $datasources.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)
})
$: viewsV1 = $viewsStore.list.map(view => ({ $: viewsV1 = $viewsStore.list.map(view => ({
...view, ...view,
label: view.name, label: view.name,
type: "view", type: "view",
})) }))
$: viewsV2 = $viewsV2Store.list.map(format.viewV2) $: viewsV2 = sortAndFormat.viewsV2($viewsV2Store.list, $datasources.list)
$: views = [...(viewsV1 || []), ...(viewsV2 || [])] $: views = [...(viewsV1 || []), ...(viewsV2 || [])]
$: queries = $queriesStore.list $: queries = $queriesStore.list
.filter(q => showAllQueries || q.queryVerb === "read" || q.readable) .filter(q => showAllQueries || q.queryVerb === "read" || q.readable)
@ -93,67 +86,9 @@
value: `{{ literal ${safe(provider._id)} }}`, value: `{{ literal ${safe(provider._id)} }}`,
type: "provider", type: "provider",
})) }))
$: links = bindings $: links = extractRelationships(bindings)
// Get only link bindings $: fields = extractFields(bindings)
.filter(x => x.fieldSchema?.type === "link") $: jsonArrays = extractJSONArrayFields(bindings)
// 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} }}`,
}
})
$: custom = { $: custom = {
type: "custom", type: "custom",
label: "JSON / CSV", label: "JSON / CSV",
@ -303,6 +238,7 @@
dataSet={views} dataSet={views}
{value} {value}
onSelect={handleSelected} onSelect={handleSelected}
identifiers={["tableId", "name"]}
/> />
{/if} {/if}
{#if queries?.length} {#if queries?.length}
@ -312,6 +248,7 @@
dataSet={queries} dataSet={queries}
{value} {value}
onSelect={handleSelected} onSelect={handleSelected}
identifiers={["_id"]}
/> />
{/if} {/if}
{#if links?.length} {#if links?.length}
@ -321,6 +258,7 @@
dataSet={links} dataSet={links}
{value} {value}
onSelect={handleSelected} onSelect={handleSelected}
identifiers={["tableId", "fieldName"]}
/> />
{/if} {/if}
{#if fields?.length} {#if fields?.length}
@ -330,6 +268,7 @@
dataSet={fields} dataSet={fields}
{value} {value}
onSelect={handleSelected} onSelect={handleSelected}
identifiers={["providerId", "tableId", "fieldName"]}
/> />
{/if} {/if}
{#if jsonArrays?.length} {#if jsonArrays?.length}
@ -339,6 +278,7 @@
dataSet={jsonArrays} dataSet={jsonArrays}
{value} {value}
onSelect={handleSelected} onSelect={handleSelected}
identifiers={["providerId", "tableId", "fieldName"]}
/> />
{/if} {/if}
{#if showDataProviders && dataProviders?.length} {#if showDataProviders && dataProviders?.length}
@ -348,6 +288,7 @@
dataSet={dataProviders} dataSet={dataProviders}
{value} {value}
onSelect={handleSelected} onSelect={handleSelected}
identifiers={["providerId"]}
/> />
{/if} {/if}
<DataSourceCategory <DataSourceCategory

View File

@ -1,22 +1,32 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Popover, Select } from "@budibase/bbui"
import { createEventDispatcher, onMount } from "svelte" import { createEventDispatcher, onMount } from "svelte"
import { tables as tablesStore, viewsV2 } from "@/stores/builder" import {
import { tableSelect as format } from "@/helpers/data/format" 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 export let value
let anchorRight, dropdownRight
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: tables = $tablesStore.list.map(format.table) $: tables = sortAndFormat.tables($tableStore.list, $datasourceStore.list)
$: views = $viewsV2.list.map(format.viewV2) $: views = sortAndFormat.viewsV2($viewsV2Store.list, $datasourceStore.list)
$: options = [...(tables || []), ...(views || [])] $: options = [...(tables || []), ...(views || [])]
$: text = value?.label ?? "Choose an option"
const onChange = e => { const onChange = e => {
dispatch( dispatch(
"change", "change",
options.find(x => x.resourceId === e.detail) options.find(x => x.resourceId === e.resourceId)
) )
dropdownRight.hide()
} }
onMount(() => { onMount(() => {
@ -29,10 +39,47 @@
}) })
</script> </script>
<Select <div class="container" bind:this={anchorRight}>
on:change={onChange} <Select
value={value?.resourceId} readonly
{options} value={text}
getOptionValue={x => x.resourceId} options={[text]}
getOptionLabel={x => x.label} 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 processModals = () => {
const defaultCacheFn = key => { const defaultCacheFn = key => {
temporalStore.actions.setExpiring(key, {}, oneDayInSeconds) temporalStore.setExpiring(key, {}, oneDayInSeconds)
} }
const dismissableModals = [ const dismissableModals = [
@ -50,7 +50,7 @@
}, },
] ]
return dismissableModals.filter(modal => { 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 oneDayInSeconds = 86400
const defaultCacheFn = key => { const defaultCacheFn = key => {
temporalStore.actions.setExpiring(key, {}, oneDayInSeconds) temporalStore.setExpiring(key, {}, oneDayInSeconds)
} }
const upgradeAction = key => { const upgradeAction = key => {
@ -148,7 +148,7 @@ export const getBanners = () => {
buildUsersAboveLimitBanner(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER), buildUsersAboveLimitBanner(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER),
].filter(licensingBanner => { ].filter(licensingBanner => {
return ( return (
!temporalStore.actions.getExpiring(licensingBanner.key) && !temporalStore.getExpiring(licensingBanner.key) &&
licensingBanner.criteria() 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, datasourceName: datasource?.name,
} }
}, },
viewV2: view => ({ viewV2: (view, datasources) => {
const datasource = datasources
?.filter(f => f.entities)
.flatMap(d => d.entities)
.find(ds => ds._id === view.tableId)
return {
...view, ...view,
label: view.name, label: view.name,
type: "viewV2", type: "viewV2",
}), datasourceName: datasource?.name,
}
},
} }
export const tableSelect = { export const tableSelect = {
@ -31,3 +38,36 @@ export const tableSelect = {
resourceId: view.id, 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, lowercase,
isBuilderInputFocused, isBuilderInputFocused,
} from "./helpers" } 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 $: useAccountPortal = cloud && !$admin.disableAccountPortal
navigation.actions.init($redirect) navigation.init($redirect)
const validateTenantId = async () => { const validateTenantId = async () => {
const host = window.location.host const host = window.location.host

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<script> <script>
import { auth } from "@/stores/portal" import { admin, auth } from "@/stores/portal"
import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui" import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ProfileModal from "@/components/settings/ProfileModal.svelte" import ProfileModal from "@/components/settings/ProfileModal.svelte"
@ -13,6 +13,8 @@
let updatePasswordModal let updatePasswordModal
let apiKeyModal let apiKeyModal
$: isOwner = $auth.accountPortalAccess && $admin.cloud
const logout = async () => { const logout = async () => {
try { try {
await auth.logout() await auth.logout()
@ -32,7 +34,16 @@
</MenuItem> </MenuItem>
<MenuItem icon="Moon" on:click={() => themeModal.show()}>Theme</MenuItem> <MenuItem icon="Moon" on:click={() => themeModal.show()}>Theme</MenuItem>
{#if !$auth.isSSO} {#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 Update password
</MenuItem> </MenuItem>
{/if} {/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 // Will ensure all parents of a node are expanded so that it is visible in the tree
makeNodeVisible(componentId: string) { 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) 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 { Component, FieldType, Screen, Table } from "@budibase/types"
import { utils } from "@budibase/shared-core" 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 component: string
name: string name: string
friendlyName?: string friendlyName?: string
@ -41,10 +50,11 @@ interface ComponentDefinition {
settings?: ComponentSetting[] settings?: ComponentSetting[]
features?: Record<string, boolean> features?: Record<string, boolean>
typeSupportPresets?: Record<string, any> typeSupportPresets?: Record<string, any>
illegalChildren?: string[] legalDirectChildren: string[]
illegalChildren: string[]
} }
interface ComponentSetting { export interface ComponentSetting {
key: string key: string
type: string type: string
section?: string section?: string
@ -55,20 +65,9 @@ interface ComponentSetting {
settings?: 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 = { export const INITIAL_COMPONENTS_STATE: ComponentState = {
components: {}, components: {},
customComponents: [], customComponents: [],
selectedComponentId: null,
componentToPaste: null,
settingsCache: {}, settingsCache: {},
} }
@ -441,6 +440,11 @@ export class ComponentStore extends BudiStore<ComponentState> {
* @returns * @returns
*/ */
createInstance(componentName: string, presetProps: any, parent: any) { 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) const definition = this.getDefinition(componentName)
if (!definition) { if (!definition) {
return null return null
@ -462,7 +466,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Standard post processing // Standard post processing
this.enrichEmptySettings(instance, { this.enrichEmptySettings(instance, {
parent, parent,
screen: get(selectedScreen), screen,
useDefaultValues: true, useDefaultValues: true,
}) })
@ -483,7 +487,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Add step name to form steps // Add step name to form steps
if (componentName.endsWith("/formstep") && $selectedScreen) { if (componentName.endsWith("/formstep") && $selectedScreen) {
const parentForm = findClosestMatchingComponent( const parentForm = findClosestMatchingComponent(
$selectedScreen.props, screen.props,
get(selectedComponent)._id, get(selectedComponent)._id,
(component: Component) => component._component.endsWith("/form") (component: Component) => component._component.endsWith("/form")
) )
@ -543,7 +547,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Find the selected component // Find the selected component
let selectedComponentId = state.selectedComponentId let selectedComponentId = state.selectedComponentId
if (selectedComponentId?.startsWith(`${screen._id}-`)) { if (selectedComponentId?.startsWith(`${screen._id}-`)) {
selectedComponentId = screen.props._id || null selectedComponentId = screen.props._id
} }
const currentComponent = findComponent( const currentComponent = findComponent(
screen.props, screen.props,
@ -654,7 +658,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Determine the next component to select, and select it before deletion // Determine the next component to select, and select it before deletion
// to avoid an intermediate state of no component selection // to avoid an intermediate state of no component selection
const state = get(this.store) const state = get(this.store)
let nextId: string | null = "" let nextId = ""
if (state.selectedComponentId === component._id) { if (state.selectedComponentId === component._id) {
nextId = this.getNext() nextId = this.getNext()
if (!nextId) { if (!nextId) {
@ -741,7 +745,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
if (!state.componentToPaste) { if (!state.componentToPaste) {
return return
} }
let newComponentId: string | null = "" let newComponentId = ""
// Remove copied component if cutting, regardless if pasting works // Remove copied component if cutting, regardless if pasting works
let componentToPaste = cloneDeep(state.componentToPaste) let componentToPaste = cloneDeep(state.componentToPaste)
@ -842,7 +846,10 @@ export class ComponentStore extends BudiStore<ComponentState> {
getPrevious() { getPrevious() {
const state = get(this.store) const state = get(this.store)
const componentId = state.selectedComponentId 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 parent = findComponentParent(screen.props, componentId)
const index = parent?._children.findIndex( const index = parent?._children.findIndex(
(x: Component) => x._id === componentId (x: Component) => x._id === componentId
@ -891,7 +898,10 @@ export class ComponentStore extends BudiStore<ComponentState> {
const state = get(this.store) const state = get(this.store)
const component = get(selectedComponent) const component = get(selectedComponent)
const componentId = component?._id 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 parent = findComponentParent(screen.props, componentId)
const index = parent?._children.findIndex( const index = parent?._children.findIndex(
(x: Component) => x._id === componentId (x: Component) => x._id === componentId
@ -1158,7 +1168,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
} }
async handleEjectBlock(componentId: string, ejectedDefinition: Component) { async handleEjectBlock(componentId: string, ejectedDefinition: Component) {
let nextSelectedComponentId: string | null = null let nextSelectedComponentId: string | undefined
await screenStore.patch((screen: Screen) => { await screenStore.patch((screen: Screen) => {
const block = findComponent(screen.props, componentId) const block = findComponent(screen.props, componentId)
@ -1194,7 +1204,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
(x: Component) => x._id === componentId (x: Component) => x._id === componentId
) )
parent._children[index] = ejectedDefinition parent._children[index] = ejectedDefinition
nextSelectedComponentId = ejectedDefinition._id ?? null nextSelectedComponentId = ejectedDefinition._id
}, null) }, null)
// Select new root component // Select new root component

View File

@ -3,7 +3,7 @@ import { appStore } from "./app.js"
import { componentStore, selectedComponent } from "./components" import { componentStore, selectedComponent } from "./components"
import { navigationStore } from "./navigation.js" import { navigationStore } from "./navigation.js"
import { themeStore } from "./theme.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 { builderStore } from "./builder.js"
import { hoverStore } from "./hover.js" import { hoverStore } from "./hover.js"
import { previewStore } from "./preview.js" import { previewStore } from "./preview.js"
@ -16,6 +16,7 @@ import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js"
import { deploymentStore } from "./deployments.js" import { deploymentStore } from "./deployments.js"
import { contextMenuStore } from "./contextMenu.js" import { contextMenuStore } from "./contextMenu.js"
import { snippets } from "./snippets" import { snippets } from "./snippets"
import { screenComponentErrors } from "./screenComponent"
// Backend // Backend
import { tables } from "./tables" import { tables } from "./tables"
@ -67,6 +68,7 @@ export {
snippets, snippets,
rowActions, rowActions,
appPublished, appPublished,
screenComponentErrors,
} }
export const reset = () => { 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, navigationStore,
selectedComponent, selectedComponent,
} from "@/stores/builder" } from "@/stores/builder"
import { createHistoryStore, HistoryStore } from "@/stores/builder/history" import { createHistoryStore } from "@/stores/builder/history"
import { API } from "@/api" import { API } from "@/api"
import { BudiStore } from "../BudiStore" 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 { interface ScreenState {
screens: Screen[] screens: Screen[]
selectedScreenId: string | null | undefined selectedScreenId?: string
selected?: Screen
} }
export const INITIAL_SCREENS_STATE: ScreenState = { export const initialScreenState: ScreenState = {
screens: [], screens: [],
selectedScreenId: null,
} }
// Review the nulls
export class ScreenStore extends BudiStore<ScreenState> { export class ScreenStore extends BudiStore<ScreenState> {
history: HistoryStore<Screen> history: any
save: (doc: Screen) => Promise<Screen> delete: any
delete: (doc: Screen) => Promise<void> save: any
constructor() { constructor() {
super(INITIAL_SCREENS_STATE) super(initialScreenState)
// Bind scope // Bind scope
this.select = this.select.bind(this) this.select = this.select.bind(this)
@ -49,14 +55,16 @@ export class ScreenStore extends BudiStore<ScreenState> {
this.removeCustomLayout = this.removeCustomLayout.bind(this) this.removeCustomLayout = this.removeCustomLayout.bind(this)
this.history = createHistoryStore({ 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, selectDoc: this.select,
beforeAction: () => {},
afterAction: () => { afterAction: () => {
// Ensure a valid component is selected // Ensure a valid component is selected
if (!get(selectedComponent)) { if (!get(selectedComponent)) {
this.update(state => ({ this.update(state => ({
...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 entire store back to base config
*/ */
reset() { reset() {
this.store.set({ ...INITIAL_SCREENS_STATE }) this.store.set({ ...initialScreenState })
} }
/** /**
* Replace ALL store screens with application package screens * Replace ALL store screens with application package screens
* @param {object} pkg * @param {FetchAppPackageResponse} pkg
*/ */
syncAppScreens(pkg: { screens: Screen[] }) { syncAppScreens(pkg: FetchAppPackageResponse) {
this.update(state => ({ this.update(state => ({
...state, ...state,
screens: [...pkg.screens], screens: [...pkg.screens],
@ -114,7 +122,7 @@ export class ScreenStore extends BudiStore<ScreenState> {
* Recursively parses the entire screen doc and checks for components * Recursively parses the entire screen doc and checks for components
* violating illegal child configurations. * violating illegal child configurations.
* *
* @param {object} screen * @param {Screen} screen
* @throws Will throw an error containing the name of the component causing * @throws Will throw an error containing the name of the component causing
* the invalid screen state * the invalid screen state
*/ */
@ -125,7 +133,7 @@ export class ScreenStore extends BudiStore<ScreenState> {
illegalChildren: string[] = [], illegalChildren: string[] = [],
legalDirectChildren: string[] = [] legalDirectChildren: string[] = []
): string | undefined => { ): string | undefined => {
const type = component._component const type: string = component._component
if (illegalChildren.includes(type)) { if (illegalChildren.includes(type)) {
return type return type
@ -148,7 +156,20 @@ export class ScreenStore extends BudiStore<ScreenState> {
illegalChildren = [] 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 // Append blacklisted components and remove duplicates
if (definition?.illegalChildren?.length) { 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 * 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 * screen id to ensure that it is selected in the builder
* *
* @param {object} screen * @param {Screen} screen The screen being modified/created
* @returns {object}
*/ */
async saveScreen(screen: Screen): Promise<Screen> { async saveScreen(screen: Screen) {
const appState = get(appStore) const appState = get(appStore)
// Validate screen structure if the app supports it // 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 * After saving a screen, sync plugins and routes to the appStore
* @param {object} savedScreen * @param {Screen} savedScreen
*/ */
async syncScreenData(savedScreen: Screen) { async syncScreenData(savedScreen: Screen) {
const appState = get(appStore) 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. * supports deeply mutating the current doc rather than just appending data.
*/ */
sequentialScreenPatch = Utils.sequential( sequentialScreenPatch = Utils.sequential(
async ( async (patchFn: (screen: Screen) => any, screenId: string) => {
patchFn: (screen: Screen) => any,
screenId: string
): Promise<Screen | undefined> => {
const state = get(this.store) const state = get(this.store)
const screen = state.screens.find(screen => screen._id === screenId) const screen = state.screens.find(screen => screen._id === screenId)
if (!screen) { 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 * @param {string | null} screenId
* @returns
*/ */
async patch( async patch(
patchFn: (screen: Screen) => void, patchFn: (screen: Screen) => any,
screenId: string | undefined | null screenId?: string | null
) { ): Promise<SaveScreenResponse | void> {
// Default to the currently selected screen // Default to the currently selected screen
if (!screenId) { if (!screenId) {
const state = get(this.store) 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 * the screen supplied. If no screen is provided, the target has
* been removed by another user and will be filtered from the store. * been removed by another user and will be filtered from the store.
* Used to marshal updates for the websocket * Used to marshal updates for the websocket
* @param {string} screenId *
* @param {object} screen * @param {string} screenId the target screen id
* @returns * @param {Screen} screen the replacement screen
*/ */
async replace(screenId: string, screen: Screen) { async replace(screenId: string, screen: Screen) {
if (!screenId) { if (!screenId) {
@ -346,17 +362,24 @@ export class ScreenStore extends BudiStore<ScreenState> {
* Any deleted screens will then have their routes/links purged * Any deleted screens will then have their routes/links purged
* *
* Wrapped by {@link delete} * Wrapped by {@link delete}
* @param {object | array} screens * @param {Screen | Screen[]} screens
* @returns
*/ */
async deleteScreen(screen: Screen) { async deleteScreen(screens: Screen | Screen[]) {
const screensToDelete = [screen] const screensToDelete = Array.isArray(screens) ? screens : [screens]
// Build array of promises to speed up bulk deletions // Build array of promises to speed up bulk deletions
let promises: Promise<any>[] = [] let promises: Promise<DeleteScreenResponse>[] = []
let deleteUrls: string[] = [] let deleteUrls: string[] = []
screensToDelete.forEach(screen => {
// 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 // Delete the screen
promises.push(API.deleteScreen(screen._id!, screen._rev!)) promises.push(API.deleteScreen(screen._id, screen._rev))
// Remove links to this screen // Remove links to this screen
deleteUrls.push(screen.routing.route) deleteUrls.push(screen.routing.route)
}) })
@ -375,11 +398,11 @@ export class ScreenStore extends BudiStore<ScreenState> {
state.selectedScreenId && state.selectedScreenId &&
deletedIds.includes(state.selectedScreenId) deletedIds.includes(state.selectedScreenId)
) { ) {
state.selectedScreenId = null delete state.selectedScreenId
componentStore.update(state => ({ componentStore.update(state => {
...state, delete state.selectedComponentId
selectedComponentId: null, return state
})) })
} }
// Update routing // Update routing
@ -390,7 +413,6 @@ export class ScreenStore extends BudiStore<ScreenState> {
return state return state
}) })
return
} }
/** /**
@ -399,12 +421,11 @@ export class ScreenStore extends BudiStore<ScreenState> {
* After a successful update, this method ensures that there is only * After a successful update, this method ensures that there is only
* ONE home screen per user Role. * ONE home screen per user Role.
* *
* @param {object} screen * @param {Screen} screen
* @param {string} name e.g "routing.homeScreen" or "showNavigation" * @param {string} name e.g "routing.homeScreen" or "showNavigation"
* @param {any} value * @param {any} value
* @returns
*/ */
async updateSetting(screen: Screen, name: string, value: string) { async updateSetting(screen: Screen, name: string, value: any) {
if (!screen || !name) { if (!screen || !name) {
return return
} }
@ -461,11 +482,14 @@ export class ScreenStore extends BudiStore<ScreenState> {
/** /**
* Parse the entire screen component tree and ensure settings are valid * Parse the entire screen component tree and ensure settings are valid
* and up-to-date. Ensures stability after a product update. * and up-to-date. Ensures stability after a product update.
* @param {object} screen * @param {Screen} screen
*/ */
async enrichEmptySettings(screen: Screen) { async enrichEmptySettings(screen: Screen) {
// Flatten the recursive component tree // 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 // Iterate over all components and run checks
components.forEach(component => { components.forEach(component => {

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { it, expect, describe, beforeEach, vi } from "vitest" import { it, expect, describe, beforeEach, vi } from "vitest"
import { createAdminStore } from "./admin" import { AdminStore } from "./admin"
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { API } from "@/api" import { API } from "@/api"
import { auth } from "@/stores/portal" import { auth } from "@/stores/portal"
@ -46,16 +46,7 @@ describe("admin store", () => {
ctx.writableReturn = { update: vi.fn(), subscribe: vi.fn() } ctx.writableReturn = { update: vi.fn(), subscribe: vi.fn() }
writable.mockReturnValue(ctx.writableReturn) writable.mockReturnValue(ctx.writableReturn)
ctx.returnedStore = createAdminStore() ctx.returnedStore = new AdminStore()
})
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(),
})
}) })
describe("init method", () => { 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 { API } from "@/api"
import { auth } from "@/stores/portal" import { auth } from "@/stores/portal"
import { banner } from "@budibase/bbui" import { banner } from "@budibase/bbui"
@ -7,15 +7,17 @@ import {
GetEnvironmentResponse, GetEnvironmentResponse,
SystemStatusResponse, SystemStatusResponse,
} from "@budibase/types" } from "@budibase/types"
import { BudiStore } from "../BudiStore"
interface PortalAdminStore extends GetEnvironmentResponse { interface AdminState extends GetEnvironmentResponse {
loaded: boolean loaded: boolean
checklist?: ConfigChecklistResponse checklist?: ConfigChecklistResponse
status?: SystemStatusResponse status?: SystemStatusResponse
} }
export function createAdminStore() { export class AdminStore extends BudiStore<AdminState> {
const admin = writable<PortalAdminStore>({ constructor() {
super({
loaded: false, loaded: false,
multiTenancy: false, multiTenancy: false,
cloud: false, cloud: false,
@ -24,25 +26,25 @@ export function createAdminStore() {
offlineMode: false, offlineMode: false,
maintenance: [], maintenance: [],
}) })
async function init() {
await getChecklist()
await getEnvironment()
// enable system status checks in the cloud
if (get(admin).cloud) {
await getSystemStatus()
checkStatus()
} }
admin.update(store => { async init() {
await this.getChecklist()
await this.getEnvironment()
// enable system status checks in the cloud
if (get(this.store).cloud) {
await this.getSystemStatus()
this.checkStatus()
}
this.update(store => {
store.loaded = true store.loaded = true
return store return store
}) })
} }
async function getEnvironment() { async getEnvironment() {
const environment = await API.getEnvironment() const environment = await API.getEnvironment()
admin.update(store => { this.update(store => {
store.multiTenancy = environment.multiTenancy store.multiTenancy = environment.multiTenancy
store.cloud = environment.cloud store.cloud = environment.cloud
store.disableAccountPortal = environment.disableAccountPortal store.disableAccountPortal = environment.disableAccountPortal
@ -56,43 +58,36 @@ export function createAdminStore() {
}) })
} }
const checkStatus = async () => { async checkStatus() {
const health = get(admin)?.status?.health const health = get(this.store).status?.health
if (!health?.passing) { if (!health?.passing) {
await banner.showStatus() await banner.showStatus()
} }
} }
async function getSystemStatus() { async getSystemStatus() {
const status = await API.getSystemStatus() const status = await API.getSystemStatus()
admin.update(store => { this.update(store => {
store.status = status store.status = status
return store return store
}) })
} }
async function getChecklist() { async getChecklist() {
const tenantId = get(auth).tenantId const tenantId = get(auth).tenantId
const checklist = await API.getChecklist(tenantId) const checklist = await API.getChecklist(tenantId)
admin.update(store => { this.update(store => {
store.checklist = checklist store.checklist = checklist
return store return store
}) })
} }
function unload() { unload() {
admin.update(store => { this.update(store => {
store.loaded = false store.loaded = false
return store 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 logs?: SearchAuditLogsResponse
} }
export class AuditLogsStore extends BudiStore<PortalAuditLogsStore> { class AuditLogsStore extends BudiStore<PortalAuditLogsStore> {
constructor() { constructor() {
super({}) super({})
} }

View File

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

View File

@ -1,38 +1,31 @@
import { writable } from "svelte/store" import { BudiStore } from "../BudiStore"
type GotoFuncType = (path: string) => void type GotoFuncType = (path: string) => void
interface PortalNavigationStore { interface NavigationState {
initialisated: boolean initialisated: boolean
goto: GotoFuncType goto: GotoFuncType
} }
export function createNavigationStore() { class NavigationStore extends BudiStore<NavigationState> {
const store = writable<PortalNavigationStore>({ constructor() {
super({
initialisated: false, initialisated: false,
goto: undefined as any, goto: undefined as any,
}) })
const { set, subscribe } = store }
const init = (gotoFunc: GotoFuncType) => { init(gotoFunc: GotoFuncType) {
if (typeof gotoFunc !== "function") { if (typeof gotoFunc !== "function") {
throw new Error( throw new Error(
`gotoFunc must be a function, found a "${typeof gotoFunc}" instead` `gotoFunc must be a function, found a "${typeof gotoFunc}" instead`
) )
} }
this.set({
set({
initialisated: true, initialisated: true,
goto: gotoFunc, 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 settingsDefinition
let settingsDefinitionMap let settingsDefinitionMap
let missingRequiredSettings = false let missingRequiredSettings = false
let componentErrors = false
// Temporary styles which can be added in the app preview for things like // Temporary styles which can be added in the app preview for things like
// DND. We clear these whenever a new instance is received. // 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 // Derive definition properties which can all be optional, so need to be
// coerced to booleans // coerced to booleans
$: componentErrors = instance?._meta?.errors
$: hasChildren = !!definition?.hasChildren $: hasChildren = !!definition?.hasChildren
$: showEmptyState = definition?.showEmptyState !== false $: showEmptyState = definition?.showEmptyState !== false
$: hasMissingRequiredSettings = missingRequiredSettings?.length > 0 $: hasMissingRequiredSettings = missingRequiredSettings?.length > 0
$: editable = !!definition?.editable && !hasMissingRequiredSettings $: editable = !!definition?.editable && !hasMissingRequiredSettings
$: hasComponentErrors = componentErrors?.length > 0
$: requiredAncestors = definition?.requiredAncestors || [] $: requiredAncestors = definition?.requiredAncestors || []
$: missingRequiredAncestors = requiredAncestors.filter( $: missingRequiredAncestors = requiredAncestors.filter(
ancestor => !$component.ancestors.includes(`${BudibasePrefix}${ancestor}`) ancestor => !$component.ancestors.includes(`${BudibasePrefix}${ancestor}`)
) )
$: hasMissingRequiredAncestors = missingRequiredAncestors?.length > 0 $: hasMissingRequiredAncestors = missingRequiredAncestors?.length > 0
$: errorState = hasMissingRequiredSettings || hasMissingRequiredAncestors $: errorState =
hasMissingRequiredSettings ||
hasMissingRequiredAncestors ||
hasComponentErrors
// Interactive components can be selected, dragged and highlighted inside // Interactive components can be selected, dragged and highlighted inside
// the builder preview // the builder preview
@ -692,6 +698,7 @@
<ComponentErrorState <ComponentErrorState
{missingRequiredSettings} {missingRequiredSettings}
{missingRequiredAncestors} {missingRequiredAncestors}
{componentErrors}
/> />
{:else} {:else}
<svelte:component this={constructor} bind:this={ref} {...initialSettings}> <svelte:component this={constructor} bind:this={ref} {...initialSettings}>

View File

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

View File

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

View File

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

View File

@ -42,6 +42,14 @@ const createScreenStore = () => {
if ($builderStore.layout) { if ($builderStore.layout) {
activeLayout = $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 { } else {
// Find the correct screen by matching the current route // Find the correct screen by matching the current route
screens = $appStore.screens || [] 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 { derived, get, Readable, Writable } from "svelte/store"
import { import {
DataFetchDefinition, DataFetchDefinition,
@ -10,12 +8,10 @@ import { enrichSchemaWithRelColumns, memo } from "../../../utils"
import { cloneDeep } from "lodash" import { cloneDeep } from "lodash"
import { import {
SaveRowRequest, SaveRowRequest,
SaveTableRequest,
UIDatasource, UIDatasource,
UIFieldMutation, UIFieldMutation,
UIFieldSchema, UIFieldSchema,
UIRow, UIRow,
UpdateViewRequest,
ViewV2Type, ViewV2Type,
} from "@budibase/types" } from "@budibase/types"
import { Store as StoreContext, BaseStoreProps } from "." import { Store as StoreContext, BaseStoreProps } from "."
@ -79,7 +75,7 @@ export const deriveStores = (context: StoreContext): DerivedDatasourceStore => {
const schema = derived(definition, $definition => { const schema = derived(definition, $definition => {
const schema: Record<string, any> | undefined = getDatasourceSchema({ const schema: Record<string, any> | undefined = getDatasourceSchema({
API, API,
datasource: get(datasource) as any, // TODO: see line 1 datasource: get(datasource),
definition: $definition ?? undefined, definition: $definition ?? undefined,
}) })
if (!schema) { if (!schema) {
@ -137,7 +133,7 @@ export const deriveStores = (context: StoreContext): DerivedDatasourceStore => {
let type = $datasource?.type let type = $datasource?.type
// @ts-expect-error // @ts-expect-error
if (type === "provider") { if (type === "provider") {
type = ($datasource as any).value?.datasource?.type // TODO: see line 1 type = ($datasource as any).value?.datasource?.type
} }
// Handle calculation views // Handle calculation views
if ( if (
@ -196,15 +192,13 @@ export const createActions = (context: StoreContext): ActionDatasourceStore => {
const refreshDefinition = async () => { const refreshDefinition = async () => {
const def = await getDatasourceDefinition({ const def = await getDatasourceDefinition({
API, 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 // Saves the datasource definition
const saveDefinition = async ( const saveDefinition = async (newDefinition: DataFetchDefinition) => {
newDefinition: SaveTableRequest | UpdateViewRequest
) => {
// Update local state // Update local state
const originalDefinition = get(definition) const originalDefinition = get(definition)
definition.set(newDefinition) definition.set(newDefinition)
@ -245,7 +239,7 @@ export const createActions = (context: StoreContext): ActionDatasourceStore => {
delete newDefinition.schema[column].default 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 // Adds a schema mutation for a single field
@ -321,7 +315,7 @@ export const createActions = (context: StoreContext): ActionDatasourceStore => {
await saveDefinition({ await saveDefinition({
...$definition, ...$definition,
schema: newSchema, schema: newSchema,
} as any) // TODO: see line 1 })
resetSchemaMutations() resetSchemaMutations()
} }

View File

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

View File

@ -101,12 +101,12 @@ export const fetchData = <
// Creates an empty fetch instance with no datasource configured, so no data // Creates an empty fetch instance with no datasource configured, so no data
// will initially be loaded // will initially be loaded
const createEmptyFetchInstance = ({ const createEmptyFetchInstance = <T extends DataFetchDatasource>({
API, API,
datasource, datasource,
}: { }: {
API: APIClient API: APIClient
datasource: DataFetchDatasource datasource: T
}) => { }) => {
const handler = DataFetchMap[datasource?.type] const handler = DataFetchMap[datasource?.type]
if (!handler) { if (!handler) {
@ -114,7 +114,7 @@ const createEmptyFetchInstance = ({
} }
return new handler({ return new handler({
API, API,
datasource: null as never, datasource: datasource as any,
query: null 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 * Utility to wrap an async function and ensure all invocations happen
* sequentially. * sequentially.
* @param fn the async function to run * @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 = < export const sequential = <
TReturn, TReturn,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,12 +4,17 @@ import {
JsTimeoutError, JsTimeoutError,
setJSRunner, setJSRunner,
setOnErrorLog, setOnErrorLog,
setTestingBackendJS,
} from "@budibase/string-templates" } from "@budibase/string-templates"
import { context, logging } from "@budibase/backend-core" import { context, logging } from "@budibase/backend-core"
import tracer from "dd-trace" import tracer from "dd-trace"
import { IsolatedVM } from "./vm" import { IsolatedVM } from "./vm"
export function init() { 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>) => { setJSRunner((js: string, ctx: Record<string, any>) => {
return tracer.trace("runJS", {}, () => { return tracer.trace("runJS", {}, () => {
try { 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" import { Expectations, TestAPI } from "./base"
export class AutomationAPI extends TestAPI { export class AutomationAPI extends TestAPI {
@ -33,4 +38,18 @@ export class AutomationAPI extends TestAPI {
}) })
return result 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, LoopStep,
UserBindings, UserBindings,
isBasicSearchOperator, isBasicSearchOperator,
ContextEmitter,
} from "@budibase/types" } from "@budibase/types"
import { import {
AutomationContext, AutomationContext,
@ -71,6 +72,24 @@ function getLoopIterations(loopStep: LoopStep) {
return 0 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. * 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 * 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 chainCount: number
private appId: string private appId: string
private automation: Automation private automation: Automation
private emitter: any private emitter: ContextEmitter
private context: AutomationContext private context: AutomationContext
private job: Job private job: Job
private loopStepOutputs: LoopStep[] private loopStepOutputs: LoopStep[]
@ -270,20 +289,9 @@ class Orchestrator {
appId: this.appId, appId: this.appId,
automationId: this.automation._id, automationId: this.automation._id,
}) })
this.context.env = await sdkUtils.getEnvironmentVariables()
this.context.user = this.currentUser
try { await enrichBaseContext(this.context)
const { config } = await configs.getSettingsConfigDoc() this.context.user = this.currentUser
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 = {}
}
let metadata let metadata

View File

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

View File

@ -23,6 +23,7 @@
}, },
"dependencies": { "dependencies": {
"@budibase/handlebars-helpers": "^0.13.2", "@budibase/handlebars-helpers": "^0.13.2",
"@budibase/vm-browserify": "^1.1.4",
"dayjs": "^1.10.8", "dayjs": "^1.10.8",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"lodash.clonedeep": "^4.5.0" "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 { LITERAL_MARKER } from "../helpers/constants"
import { getJsHelperList } from "./list" import { getJsHelperList } from "./list"
import { iifeWrapper } from "../iife" import { iifeWrapper } from "../iife"
import { JsTimeoutError, UserScriptError } from "../errors" import { JsTimeoutError, UserScriptError } from "../errors"
import { cloneDeep } from "lodash/fp" 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. // 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). // 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> let clonedContext: Record<string, any>
if (isBackendService()) { 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 // 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. // difference in how JS executes on the frontend vs the backend, e.g.
// consider this snippet: // consider this snippet:
@ -96,10 +103,9 @@ export function processJS(handlebars: string, context: any) {
clonedContext = cloneDeep(context) clonedContext = cloneDeep(context)
} }
const sandboxContext = { const sandboxContext: Record<string, any> = {
$: (path: string) => getContextValue(path, clonedContext), $: (path: string) => getContextValue(path, clonedContext),
helpers: getJsHelperList(), helpers: getJsHelperList(),
// Proxy to evaluate snippets when running in the browser // Proxy to evaluate snippets when running in the browser
snippets: new Proxy( 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 // 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)}}}` return `{{${LITERAL_MARKER} js_result-${JSON.stringify(res)}}}`
} catch (error: any) { } catch (error: any) {
onErrorLog && onErrorLog(error) 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 { create, TemplateDelegate } from "handlebars"
import { registerAll, registerMinimum } from "./helpers/index" import { registerAll, registerMinimum } from "./helpers/index"
import { postprocess, preprocess } from "./processors" import { postprocess, postprocessWithLogs, preprocess } from "./processors"
import { import {
atob, atob,
btoa, btoa,
FIND_ANY_HBS_REGEX, FIND_ANY_HBS_REGEX,
FIND_HBS_REGEX, FIND_HBS_REGEX,
findDoubleHbsInstances, findDoubleHbsInstances,
frontendWrapJS,
isBackendService, isBackendService,
prefixStrings, prefixStrings,
} from "./utilities" } from "./utilities"
import { convertHBSBlock } from "./conversion" import { convertHBSBlock } from "./conversion"
import { removeJSRunner, setJSRunner } from "./helpers/javascript" import { removeJSRunner, setJSRunner } from "./helpers/javascript"
import manifest from "./manifest.json" import manifest from "./manifest.json"
import { ProcessOptions } from "./types" import { Log, ProcessOptions } from "./types"
import { UserScriptError } from "./errors" 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 { helpersToRemoveForJs, getJsHelperList } from "./helpers/list"
export { FIND_ANY_HBS_REGEX } from "./utilities" export { FIND_ANY_HBS_REGEX } from "./utilities"
export { setJSRunner, setOnErrorLog } from "./helpers/javascript" export { setJSRunner, setOnErrorLog } from "./helpers/javascript"
@ -187,23 +191,27 @@ export function processObjectSync(
return object return object
} }
/** // keep the logging function internal, don't want to add this to the process options directly
* This will process a single handlebars containing string. If the string passed in has no valid handlebars statements // as it can't be used for object processing etc.
* then nothing will occur. This is a pure sync call and therefore does not have the full functionality of the async call. function processStringSyncInternal(
* @param {string} string The template string which is the filled from the context object. str: string,
* @param {object} context An object of information which will be used to enrich the string. context?: object,
* @param {object|undefined} [opts] optional - specify some options for processing. opts?: ProcessOptions & { logging: false }
* @returns {string} The enriched string, all templates should have been replaced if they can be. ): string
*/ function processStringSyncInternal(
export function processStringSync( str: string,
context?: object,
opts?: ProcessOptions & { logging: true }
): { result: string; logs: Log[] }
function processStringSyncInternal(
string: string, string: string,
context?: object, context?: object,
opts?: ProcessOptions opts?: ProcessOptions & { logging: boolean }
): string { ): string | { result: string; logs: Log[] } {
// Take a copy of input in case of error // Take a copy of input in case of error
const input = string const input = string
if (typeof string !== "string") { if (typeof string !== "string") {
throw "Cannot process non-string types." throw new Error("Cannot process non-string types.")
} }
function process(stringPart: string) { function process(stringPart: string) {
// context is needed to check for overlap between helpers and context // context is needed to check for overlap between helpers and context
@ -217,16 +225,24 @@ export function processStringSync(
}, },
...context, ...context,
}) })
return postprocess(processedString) return opts?.logging
? postprocessWithLogs(processedString)
: postprocess(processedString)
} }
try { try {
if (opts && opts.onlyFound) { if (opts && opts.onlyFound) {
let logs: Log[] = []
const blocks = findHBSBlocks(string) const blocks = findHBSBlocks(string)
for (let block of blocks) { for (let block of blocks) {
const outcome = process(block) const outcome = process(block)
if (typeof outcome === "object" && "result" in outcome) {
logs = logs.concat(outcome.logs || [])
string = string.replace(block, outcome.result)
} else {
string = string.replace(block, outcome) string = string.replace(block, outcome)
} }
return string }
return !opts?.logging ? string : { result: string, logs }
} else { } else {
return process(string) 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 * By default with expressions like {{ name }} handlebars will escape various
* characters, which can be problematic. To fix this we use the syntax {{{ name }}}, * 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 { JsTimeoutError, UserScriptError } from "./errors"
export function browserJSSetup() { export function browserJSSetup() {
/** // tests are in jest - we need to use node VM for these
* Use polyfilled vm to run JS scripts in a browser Env const jsSandbox = isTest() ? vm : browserVM
*/ // Use polyfilled vm to run JS scripts in a browser Env
setJSRunner((js: string, context: Record<string, any>) => { setJSRunner((js: string, context: Record<string, any>) => {
createContext(context) jsSandbox.createContext(context)
const wrappedJs = ` const wrappedJs = frontendWrapJS(js)
result = {
result: null,
error: null,
};
try { const result = jsSandbox.runInNewContext(wrappedJs, context)
result.result = ${js};
} catch (e) {
result.error = e;
}
result;
`
const result = runInNewContext(wrappedJs, context, { timeout: 1000 })
if (result.error) { if (result.error) {
throw new UserScriptError(result.error) throw new UserScriptError(result.error)
} }

View File

@ -1,9 +1,16 @@
import { FIND_HBS_REGEX } from "../utilities" import { FIND_HBS_REGEX } from "../utilities"
import * as preprocessor from "./preprocessor" import * as preprocessor from "./preprocessor"
import type { Preprocessor } from "./preprocessor"
import * as postprocessor from "./postprocessor" 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) { for (let processor of processors) {
// if a literal statement has occurred stop // if a literal statement has occurred stop
if (typeof output !== "string") { if (typeof output !== "string") {
@ -16,10 +23,18 @@ function process(output: string, processors: any[], opts?: ProcessOptions) {
continue continue
} }
for (let match of matches) { 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) { 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) { export function postprocess(string: string) {
return process(string, postprocessor.processors).result
}
export function postprocessWithLogs(string: string) {
return process(string, postprocessor.processors) return process(string, postprocessor.processors)
} }

View File

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

View File

@ -8,3 +8,11 @@ export interface ProcessOptions {
onlyFound?: boolean onlyFound?: boolean
disabledHelpers?: string[] 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 const ALPHA_NUMERIC_REGEX = /^[A-Za-z0-9]+$/g
export const FIND_HBS_REGEX = /{{([^{].*?)}}/g export const FIND_HBS_REGEX = /{{([^{].*?)}}/g
export const FIND_ANY_HBS_REGEX = /{?{{([^{].*?)}}}?/g export const FIND_ANY_HBS_REGEX = /{?{{([^{].*?)}}}?/g
export const FIND_TRIPLE_HBS_REGEX = /{{{([^{].*?)}}}/g export const FIND_TRIPLE_HBS_REGEX = /{{{([^{].*?)}}}/g
const isJest = () => typeof jest !== "undefined"
export const isBackendService = () => { 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 // We consider the tests for string-templates to be frontend, so that they
// test the frontend JS functionality. // test the frontend JS functionality.
if (isJest()) { if (isTest()) {
return false return false
} }
return typeof window === "undefined" return typeof window === "undefined"
@ -86,3 +91,20 @@ export const prefixStrings = (
const regexPattern = new RegExp(`\\b(${escapedStrings.join("|")})\\b`, "g") const regexPattern = new RegExp(`\\b(${escapedStrings.join("|")})\\b`, "g")
return baseString.replace(regexPattern, `${prefix}$1`) 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") 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 () => { it("should prevent access to the process global", async () => {
expect(processJS(`return process`)).toEqual( expect(processJS(`return process`)).toEqual(
"ReferenceError: process is not defined" "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, Automation,
AutomationActionStepId, AutomationActionStepId,
AutomationLogPage, AutomationLogPage,
AutomationResults,
AutomationStatus, AutomationStatus,
AutomationStepDefinition, AutomationStepDefinition,
AutomationTriggerDefinition, AutomationTriggerDefinition,
AutomationTriggerStepId, AutomationTriggerStepId,
DidNotTriggerResponse,
Row, Row,
} from "../../../documents" } from "../../../documents"
import { DocumentDestroyResponse } from "@budibase/nano" import { DocumentDestroyResponse } from "@budibase/nano"
@ -74,4 +76,10 @@ export interface TestAutomationRequest {
fields: Record<string, any> fields: Record<string, any>
row?: Row 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 { Document } from "../../document"
import { EventEmitter } from "events"
import { User } from "../../global" import { User } from "../../global"
import { ReadStream } from "fs" import { ReadStream } from "fs"
import { Row } from "../row" import { Row } from "../row"
import { Table } from "../table" import { Table } from "../table"
import { AutomationStep, AutomationTrigger } from "./schema" import { AutomationStep, AutomationTrigger } from "./schema"
import { ContextEmitter } from "../../../sdk"
export enum AutomationIOType { export enum AutomationIOType {
OBJECT = "object", 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 { export interface AutomationLog extends AutomationResults, Document {
automationName: string automationName: string
_rev?: string _rev?: string
@ -218,7 +226,7 @@ export interface AutomationLogPage {
export interface AutomationStepInputBase { export interface AutomationStepInputBase {
context: Record<string, any> context: Record<string, any>
emitter: EventEmitter emitter: ContextEmitter
appId: string appId: string
apiKey?: string apiKey?: string
} }

View File

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

View File

@ -24,3 +24,18 @@ export type InsertAtPositionFn = (_: {
value: string value: string
cursor?: { anchor: number } cursor?: { anchor: number }
}) => void }) => 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 "./bindings"
export * from "./components" export * from "./components"
export * from "./dataFetch" export * from "./dataFetch"
export * from "./datasource"

View File

@ -2131,9 +2131,9 @@
through2 "^2.0.0" through2 "^2.0.0"
"@budibase/pro@npm:@budibase/pro@latest": "@budibase/pro@npm:@budibase/pro@latest":
version "3.2.44" version "3.2.47"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.2.44.tgz#90367bb2167aafd8c809e000a57d349e5dc4bb78" resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.2.47.tgz#150d7b16b14932d03c84bdb0e6d570d490c28a5c"
integrity sha512-Zv2PBVUZUS6/psOpIRIDlW3jrOHWWPhpQXzCk00kIQJaqjkdcvuTXSedQ70u537sQmLu8JsSWbui9MdfF8ksVw== integrity sha512-UeTIq7yzMUK6w/akUsRafoD/Kif6PXv4d7K1arn8GTMjwFm9QYu2hg1YkQ+duNdwyZ/GEPlEAV5SYK+NDgtpdA==
dependencies: dependencies:
"@anthropic-ai/sdk" "^0.27.3" "@anthropic-ai/sdk" "^0.27.3"
"@budibase/backend-core" "*" "@budibase/backend-core" "*"
@ -2152,6 +2152,13 @@
scim-patch "^0.8.1" scim-patch "^0.8.1"
scim2-parse-filter "^0.2.8" 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": "@bull-board/api@5.10.2":
version "5.10.2" version "5.10.2"
resolved "https://registry.yarnpkg.com/@bull-board/api/-/api-5.10.2.tgz#ae8ff6918b23897bf879a6ead3683f964374c4b3" 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" resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607"
integrity sha512-bup+4tap3Hympa+JBJUG7XuOsdNQ6fxt0MHyXMKuLBKn0OqsTfvUxkUrroEX1+B2VsSHvCjiIcZVxRtYa4nllA== 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: infer-owner@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467" 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" char-regex "^1.0.2"
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
"string-width-cjs@npm:string-width@^4.2.0": "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==
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:
version "4.2.3" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -18747,7 +18750,7 @@ stringify-object@^3.2.1:
is-obj "^1.0.1" is-obj "^1.0.1"
is-regexp "^1.0.0" 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" version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -18761,13 +18764,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
dependencies: dependencies:
ansi-regex "^4.1.0" 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: strip-ansi@^7.0.1:
version "7.0.1" version "7.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2"
@ -20515,7 +20511,7 @@ worker-farm@1.7.0:
dependencies: dependencies:
errno "~0.1.7" 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" version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -20533,15 +20529,6 @@ wrap-ansi@^5.1.0:
string-width "^3.0.0" string-width "^3.0.0"
strip-ansi "^5.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: wrap-ansi@^8.1.0:
version "8.1.0" version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"