Merge branch 'master' into fix/cheeks-ux-review-console-log

This commit is contained in:
Michael Drury 2025-01-29 13:26:32 +00:00 committed by GitHub
commit d7ce6bead3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1026 additions and 760 deletions

View File

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

View File

@ -8,27 +8,52 @@
// Strategies are defined as [Popover]To[Anchor]. // Strategies are defined as [Popover]To[Anchor].
// They can apply for both horizontal and vertical alignment. // They can apply for both horizontal and vertical alignment.
const Strategies = { type Strategy =
StartToStart: "StartToStart", // e.g. left alignment | "StartToStart"
EndToEnd: "EndToEnd", // e.g. right alignment | "EndToEnd"
StartToEnd: "StartToEnd", // e.g. right-outside alignment | "StartToEnd"
EndToStart: "EndToStart", // e.g. left-outside alignment | "EndToStart"
MidPoint: "MidPoint", // centers relative to midpoints | "MidPoint"
ScreenEdge: "ScreenEdge", // locks to screen edge | "ScreenEdge"
export interface Styles {
maxHeight?: number
minWidth?: number
maxWidth?: number
offset?: number
left: number
top: number
} }
export default function positionDropdown(element, opts) { export type UpdateHandler = (
let resizeObserver anchorBounds: DOMRect,
elementBounds: DOMRect,
styles: Styles
) => Styles
interface Opts {
anchor?: HTMLElement
align: string
maxHeight?: number
maxWidth?: number
minWidth?: number
useAnchorWidth: boolean
offset: number
customUpdate?: UpdateHandler
resizable: boolean
wrap: boolean
}
export default function positionDropdown(element: HTMLElement, opts: Opts) {
let resizeObserver: ResizeObserver
let latestOpts = opts let latestOpts = opts
// We need a static reference to this function so that we can properly // We need a static reference to this function so that we can properly
// clean up the scroll listener. // clean up the scroll listener.
const scrollUpdate = () => { const scrollUpdate = () => updatePosition(latestOpts)
updatePosition(latestOpts)
}
// Updates the position of the dropdown // Updates the position of the dropdown
const updatePosition = opts => { const updatePosition = (opts: Opts) => {
const { const {
anchor, anchor,
align, align,
@ -51,12 +76,12 @@ export default function positionDropdown(element, opts) {
const winWidth = window.innerWidth const winWidth = window.innerWidth
const winHeight = window.innerHeight const winHeight = window.innerHeight
const screenOffset = 8 const screenOffset = 8
let styles = { let styles: Styles = {
maxHeight, maxHeight,
minWidth: useAnchorWidth ? anchorBounds.width : minWidth, minWidth: useAnchorWidth ? anchorBounds.width : minWidth,
maxWidth: useAnchorWidth ? anchorBounds.width : maxWidth, maxWidth: useAnchorWidth ? anchorBounds.width : maxWidth,
left: null, left: 0,
top: null, top: 0,
} }
// Ignore all our logic for custom logic // Ignore all our logic for custom logic
@ -81,67 +106,67 @@ export default function positionDropdown(element, opts) {
} }
// Applies a dynamic max height constraint if appropriate // Applies a dynamic max height constraint if appropriate
const applyMaxHeight = height => { const applyMaxHeight = (height: number) => {
if (!styles.maxHeight && resizable) { if (!styles.maxHeight && resizable) {
styles.maxHeight = height styles.maxHeight = height
} }
} }
// Applies the X strategy to our styles // Applies the X strategy to our styles
const applyXStrategy = strategy => { const applyXStrategy = (strategy: Strategy) => {
switch (strategy) { switch (strategy) {
case Strategies.StartToStart: case "StartToStart":
default: default:
styles.left = anchorBounds.left styles.left = anchorBounds.left
break break
case Strategies.EndToEnd: case "EndToEnd":
styles.left = anchorBounds.right - elementBounds.width styles.left = anchorBounds.right - elementBounds.width
break break
case Strategies.StartToEnd: case "StartToEnd":
styles.left = anchorBounds.right + offset styles.left = anchorBounds.right + offset
break break
case Strategies.EndToStart: case "EndToStart":
styles.left = anchorBounds.left - elementBounds.width - offset styles.left = anchorBounds.left - elementBounds.width - offset
break break
case Strategies.MidPoint: case "MidPoint":
styles.left = styles.left =
anchorBounds.left + anchorBounds.left +
anchorBounds.width / 2 - anchorBounds.width / 2 -
elementBounds.width / 2 elementBounds.width / 2
break break
case Strategies.ScreenEdge: case "ScreenEdge":
styles.left = winWidth - elementBounds.width - screenOffset styles.left = winWidth - elementBounds.width - screenOffset
break break
} }
} }
// Applies the Y strategy to our styles // Applies the Y strategy to our styles
const applyYStrategy = strategy => { const applyYStrategy = (strategy: Strategy) => {
switch (strategy) { switch (strategy) {
case Strategies.StartToStart: case "StartToStart":
styles.top = anchorBounds.top styles.top = anchorBounds.top
applyMaxHeight(winHeight - anchorBounds.top - screenOffset) applyMaxHeight(winHeight - anchorBounds.top - screenOffset)
break break
case Strategies.EndToEnd: case "EndToEnd":
styles.top = anchorBounds.bottom - elementBounds.height styles.top = anchorBounds.bottom - elementBounds.height
applyMaxHeight(anchorBounds.bottom - screenOffset) applyMaxHeight(anchorBounds.bottom - screenOffset)
break break
case Strategies.StartToEnd: case "StartToEnd":
default: default:
styles.top = anchorBounds.bottom + offset styles.top = anchorBounds.bottom + offset
applyMaxHeight(winHeight - anchorBounds.bottom - screenOffset) applyMaxHeight(winHeight - anchorBounds.bottom - screenOffset)
break break
case Strategies.EndToStart: case "EndToStart":
styles.top = anchorBounds.top - elementBounds.height - offset styles.top = anchorBounds.top - elementBounds.height - offset
applyMaxHeight(anchorBounds.top - screenOffset) applyMaxHeight(anchorBounds.top - screenOffset)
break break
case Strategies.MidPoint: case "MidPoint":
styles.top = styles.top =
anchorBounds.top + anchorBounds.top +
anchorBounds.height / 2 - anchorBounds.height / 2 -
elementBounds.height / 2 elementBounds.height / 2
break break
case Strategies.ScreenEdge: case "ScreenEdge":
styles.top = winHeight - elementBounds.height - screenOffset styles.top = winHeight - elementBounds.height - screenOffset
applyMaxHeight(winHeight - 2 * screenOffset) applyMaxHeight(winHeight - 2 * screenOffset)
break break
@ -150,81 +175,83 @@ export default function positionDropdown(element, opts) {
// Determine X strategy // Determine X strategy
if (align === "right") { if (align === "right") {
applyXStrategy(Strategies.EndToEnd) applyXStrategy("EndToEnd")
} else if (align === "right-outside" || align === "right-context-menu") { } else if (align === "right-outside" || align === "right-context-menu") {
applyXStrategy(Strategies.StartToEnd) applyXStrategy("StartToEnd")
} else if (align === "left-outside" || align === "left-context-menu") { } else if (align === "left-outside" || align === "left-context-menu") {
applyXStrategy(Strategies.EndToStart) applyXStrategy("EndToStart")
} else if (align === "center") { } else if (align === "center") {
applyXStrategy(Strategies.MidPoint) applyXStrategy("MidPoint")
} else { } else {
applyXStrategy(Strategies.StartToStart) applyXStrategy("StartToStart")
} }
// Determine Y strategy // Determine Y strategy
if (align === "right-outside" || align === "left-outside") { if (align === "right-outside" || align === "left-outside") {
applyYStrategy(Strategies.MidPoint) applyYStrategy("MidPoint")
} else if ( } else if (
align === "right-context-menu" || align === "right-context-menu" ||
align === "left-context-menu" align === "left-context-menu"
) { ) {
applyYStrategy(Strategies.StartToStart) applyYStrategy("StartToStart")
styles.top -= 5 // Manual adjustment for action menu padding if (styles.top) {
styles.top -= 5 // Manual adjustment for action menu padding
}
} else { } else {
applyYStrategy(Strategies.StartToEnd) applyYStrategy("StartToEnd")
} }
// Handle screen overflow // Handle screen overflow
if (doesXOverflow()) { if (doesXOverflow()) {
// Swap left to right // Swap left to right
if (align === "left") { if (align === "left") {
applyXStrategy(Strategies.EndToEnd) applyXStrategy("EndToEnd")
} }
// Swap right-outside to left-outside // Swap right-outside to left-outside
else if (align === "right-outside") { else if (align === "right-outside") {
applyXStrategy(Strategies.EndToStart) applyXStrategy("EndToStart")
} }
} }
if (doesYOverflow()) { if (doesYOverflow()) {
// If wrapping, lock to the bottom of the screen and also reposition to // If wrapping, lock to the bottom of the screen and also reposition to
// the side to not block the anchor // the side to not block the anchor
if (wrap) { if (wrap) {
applyYStrategy(Strategies.MidPoint) applyYStrategy("MidPoint")
if (doesYOverflow()) { if (doesYOverflow()) {
applyYStrategy(Strategies.ScreenEdge) applyYStrategy("ScreenEdge")
} }
applyXStrategy(Strategies.StartToEnd) applyXStrategy("StartToEnd")
if (doesXOverflow()) { if (doesXOverflow()) {
applyXStrategy(Strategies.EndToStart) applyXStrategy("EndToStart")
} }
} }
// Othewise invert as normal // Othewise invert as normal
else { else {
// If using an outside strategy then lock to the bottom of the screen // If using an outside strategy then lock to the bottom of the screen
if (align === "left-outside" || align === "right-outside") { if (align === "left-outside" || align === "right-outside") {
applyYStrategy(Strategies.ScreenEdge) applyYStrategy("ScreenEdge")
} }
// Otherwise flip above // Otherwise flip above
else { else {
applyYStrategy(Strategies.EndToStart) applyYStrategy("EndToStart")
} }
} }
} }
} }
// Apply styles for (const [key, value] of Object.entries(styles)) {
Object.entries(styles).forEach(([style, value]) => { const name = key as keyof Styles
if (value != null) { if (value != null) {
element.style[style] = `${value.toFixed(0)}px` element.style[name] = `${value}px`
} else { } else {
element.style[style] = null element.style[name] = ""
} }
}) }
} }
// The actual svelte action callback which creates observers on the relevant // The actual svelte action callback which creates observers on the relevant
// DOM elements // DOM elements
const update = newOpts => { const update = (newOpts: Opts) => {
latestOpts = newOpts latestOpts = newOpts
// Cleanup old state // Cleanup old state

View File

@ -1,22 +1,23 @@
<script> <script lang="ts">
import "@spectrum-css/checkbox/dist/index-vars.css" import "@spectrum-css/checkbox/dist/index-vars.css"
import "@spectrum-css/fieldgroup/dist/index-vars.css" import "@spectrum-css/fieldgroup/dist/index-vars.css"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import type { ChangeEventHandler } from "svelte/elements"
export let value = false export let value = false
export let id = null export let id: string | undefined = undefined
export let text = null export let text: string | undefined = undefined
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let size export let size: "S" | "M" | "L" | "XL" = "M"
export let indeterminate = false export let indeterminate = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = event => { const onChange: ChangeEventHandler<HTMLInputElement> = event => {
dispatch("change", event.target.checked) dispatch("change", event.currentTarget.checked)
} }
$: sizeClass = `spectrum-Checkbox--size${size || "M"}` $: sizeClass = `spectrum-Checkbox--size${size}`
</script> </script>
<label <label

View File

@ -1,19 +1,24 @@
<script> <script lang="ts" context="module">
type O = any
type V = any
</script>
<script lang="ts" generics="O, V">
import "@spectrum-css/fieldgroup/dist/index-vars.css" import "@spectrum-css/fieldgroup/dist/index-vars.css"
import "@spectrum-css/radio/dist/index-vars.css" import "@spectrum-css/radio/dist/index-vars.css"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let direction = "vertical" export let direction: "horizontal" | "vertical" = "vertical"
export let value = [] export let value: V[] = []
export let options = [] export let options: O[] = []
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let getOptionLabel = option => option export let getOptionLabel = (option: O) => `${option}`
export let getOptionValue = option => option export let getOptionValue = (option: O) => option as unknown as V
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher<{ change: V[] }>()
const onChange = optionValue => { const onChange = (optionValue: V) => {
if (!value.includes(optionValue)) { if (!value.includes(optionValue)) {
dispatch("change", [...value, optionValue]) dispatch("change", [...value, optionValue])
} else { } else {

View File

@ -1,4 +1,10 @@
<script> <script lang="ts" context="module">
type O = any
</script>
<script lang="ts" generics="O">
import type { ChangeEventHandler } from "svelte/elements"
import "@spectrum-css/inputgroup/dist/index-vars.css" import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css"
import "@spectrum-css/menu/dist/index-vars.css" import "@spectrum-css/menu/dist/index-vars.css"
@ -6,33 +12,38 @@
import clickOutside from "../../Actions/click_outside" import clickOutside from "../../Actions/click_outside"
import Popover from "../../Popover/Popover.svelte" import Popover from "../../Popover/Popover.svelte"
export let value = null export let value: string | undefined = undefined
export let id = null export let id: string | undefined = undefined
export let placeholder = "Choose an option or type" export let placeholder = "Choose an option or type"
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let options = [] export let options: O[] = []
export let getOptionLabel = option => option export let getOptionLabel = (option: O) => `${option}`
export let getOptionValue = option => option export let getOptionValue = (option: O) => `${option}`
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher<{
change: string
blur: void
type: string
pick: string
}>()
let open = false let open = false
let focus = false let focus = false
let anchor let anchor
const selectOption = value => { const selectOption = (value: string) => {
dispatch("change", value) dispatch("change", value)
open = false open = false
} }
const onType = e => { const onType: ChangeEventHandler<HTMLInputElement> = e => {
const value = e.target.value const value = e.currentTarget.value
dispatch("type", value) dispatch("type", value)
selectOption(value) selectOption(value)
} }
const onPick = value => { const onPick = (value: string) => {
dispatch("pick", value) dispatch("pick", value)
selectOption(value) selectOption(value)
} }

View File

@ -1,28 +1,33 @@
<script> <script lang="ts">
import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css"
// @ts-expect-error no types for the version of svelte-portal we're on.
import Portal from "svelte-portal" import Portal from "svelte-portal"
import { createEventDispatcher, getContext, onDestroy } from "svelte" import { createEventDispatcher, getContext, onDestroy } from "svelte"
import positionDropdown from "../Actions/position_dropdown" import positionDropdown, {
type UpdateHandler,
} from "../Actions/position_dropdown"
import clickOutside from "../Actions/click_outside" import clickOutside from "../Actions/click_outside"
import { fly } from "svelte/transition" import { fly } from "svelte/transition"
import Context from "../context" import Context from "../context"
import type { KeyboardEventHandler } from "svelte/elements"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher<{ open: void; close: void }>()
export let anchor export let anchor: HTMLElement
export let align = "right" export let align: "left" | "right" | "left-outside" | "right-outside" =
export let portalTarget "right"
export let minWidth export let portalTarget: string | undefined = undefined
export let maxWidth export let minWidth: number | undefined = undefined
export let maxHeight export let maxWidth: number | undefined = undefined
export let maxHeight: number | undefined = undefined
export let open = false export let open = false
export let useAnchorWidth = false export let useAnchorWidth = false
export let dismissible = true export let dismissible = true
export let offset = 4 export let offset = 4
export let customHeight export let customHeight: string | undefined = undefined
export let animate = true export let animate = true
export let customZindex export let customZindex: string | undefined = undefined
export let handlePostionUpdate export let handlePostionUpdate: UpdateHandler | undefined = undefined
export let showPopover = true export let showPopover = true
export let clickOutsideOverride = false export let clickOutsideOverride = false
export let resizable = true export let resizable = true
@ -30,7 +35,7 @@
const animationDuration = 260 const animationDuration = 260
let timeout let timeout: ReturnType<typeof setTimeout>
let blockPointerEvents = false let blockPointerEvents = false
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum" $: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
@ -65,13 +70,13 @@
} }
} }
const handleOutsideClick = e => { const handleOutsideClick = (e: MouseEvent) => {
if (clickOutsideOverride) { if (clickOutsideOverride) {
return return
} }
if (open) { if (open) {
// Stop propagation if the source is the anchor // Stop propagation if the source is the anchor
let node = e.target let node = e.target as Node | null
let fromAnchor = false let fromAnchor = false
while (!fromAnchor && node && node.parentNode) { while (!fromAnchor && node && node.parentNode) {
fromAnchor = node === anchor fromAnchor = node === anchor
@ -86,7 +91,7 @@
} }
} }
function handleEscape(e) { const handleEscape: KeyboardEventHandler<HTMLDivElement> = e => {
if (!clickOutsideOverride) { if (!clickOutsideOverride) {
return return
} }

View File

@ -31,6 +31,11 @@
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"
@ -81,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",

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

@ -10,3 +10,4 @@ export {
isBuilderInputFocused, isBuilderInputFocused,
} from "./helpers" } from "./helpers"
export * as featureFlag from "./featureFlags" export * as featureFlag from "./featureFlags"
export * as bindings from "./bindings"

View File

@ -1437,8 +1437,6 @@ class AutomationStore extends BudiStore<AutomationState> {
this.history = createHistoryStore({ this.history = createHistoryStore({
getDoc: this.actions.getDefinition.bind(this), getDoc: this.actions.getDefinition.bind(this),
selectDoc: this.actions.select.bind(this), selectDoc: this.actions.select.bind(this),
beforeAction: () => {},
afterAction: () => {},
}) })
// Then wrap save and delete with history // Then wrap save and delete with history

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: Screen = 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

@ -30,10 +30,28 @@ import {
} from "@/constants/backend" } from "@/constants/backend"
import { BudiStore } from "../BudiStore" import { BudiStore } from "../BudiStore"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import { Component, FieldType, Screen, Table } from "@budibase/types" import {
Component as ComponentType,
FieldType,
Screen,
Table,
} from "@budibase/types"
import { utils } from "@budibase/shared-core" import { utils } from "@budibase/shared-core"
interface ComponentDefinition { interface Component extends ComponentType {
_id: string
}
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,9 +59,11 @@ interface ComponentDefinition {
settings?: ComponentSetting[] settings?: ComponentSetting[]
features?: Record<string, boolean> features?: Record<string, boolean>
typeSupportPresets?: Record<string, any> typeSupportPresets?: Record<string, any>
legalDirectChildren: string[]
illegalChildren: string[]
} }
interface ComponentSetting { export interface ComponentSetting {
key: string key: string
type: string type: string
section?: string section?: string
@ -54,20 +74,9 @@ interface ComponentSetting {
settings?: ComponentSetting[] settings?: ComponentSetting[]
} }
interface ComponentState {
components: Record<string, ComponentDefinition>
customComponents: string[]
selectedComponentId: string | null
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: {},
} }
@ -254,7 +263,10 @@ export class ComponentStore extends BudiStore<ComponentState> {
* @param {object} opts * @param {object} opts
* @returns * @returns
*/ */
enrichEmptySettings(component: Component, opts: any) { enrichEmptySettings(
component: Component,
opts: { screen?: Screen; parent?: Component; useDefaultValues?: boolean }
) {
if (!component?._component) { if (!component?._component) {
return return
} }
@ -364,7 +376,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
getSchemaForDatasource(screen, dataSource, {}) getSchemaForDatasource(screen, dataSource, {})
// Finds fields by types from the schema of the configured datasource // Finds fields by types from the schema of the configured datasource
const findFieldTypes = (fieldTypes: any) => { const findFieldTypes = (fieldTypes: FieldType | FieldType[]) => {
if (!Array.isArray(fieldTypes)) { if (!Array.isArray(fieldTypes)) {
fieldTypes = [fieldTypes] fieldTypes = [fieldTypes]
} }
@ -439,14 +451,23 @@ export class ComponentStore extends BudiStore<ComponentState> {
* @param {object} parent * @param {object} parent
* @returns * @returns
*/ */
createInstance(componentName: string, presetProps: any, parent: any) { createInstance(
componentName: string,
presetProps: any,
parent: any
): Component | null {
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
} }
// Generate basic component structure // Generate basic component structure
let instance = { let instance: Component = {
_id: Helpers.uuid(), _id: Helpers.uuid(),
_component: definition.component, _component: definition.component,
_styles: { _styles: {
@ -461,7 +482,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,
}) })
@ -473,7 +494,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
} }
// Custom post processing for creation only // Custom post processing for creation only
let extras: any = {} let extras: Partial<Component> = {}
if (definition.hasChildren) { if (definition.hasChildren) {
extras._children = [] extras._children = []
} }
@ -481,8 +502,8 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Add step name to form steps // Add step name to form steps
if (componentName.endsWith("/formstep")) { if (componentName.endsWith("/formstep")) {
const parentForm = findClosestMatchingComponent( const parentForm = findClosestMatchingComponent(
get(selectedScreen).props, screen.props,
get(selectedComponent)._id, get(selectedComponent)?._id,
(component: Component) => component._component.endsWith("/form") (component: Component) => component._component.endsWith("/form")
) )
const formSteps = findAllMatchingComponents( const formSteps = findAllMatchingComponents(
@ -510,7 +531,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
async create( async create(
componentName: string, componentName: string,
presetProps: any, presetProps: any,
parent: any, parent: Component,
index: number index: number
) { ) {
const state = get(this.store) const state = get(this.store)
@ -541,7 +562,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,
@ -652,7 +673,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) {
@ -739,7 +760,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)
@ -767,7 +788,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
if (!cut) { if (!cut) {
componentToPaste = makeComponentUnique(componentToPaste) componentToPaste = makeComponentUnique(componentToPaste)
} }
newComponentId = componentToPaste._id! newComponentId = componentToPaste._id
// Strip grid position metadata if pasting into a new screen, but keep // Strip grid position metadata if pasting into a new screen, but keep
// alignment metadata // alignment metadata
@ -841,6 +862,9 @@ export class ComponentStore extends BudiStore<ComponentState> {
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
@ -890,6 +914,9 @@ export class ComponentStore extends BudiStore<ComponentState> {
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
@ -904,7 +931,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// If we have children, select first child, and the node is not collapsed // If we have children, select first child, and the node is not collapsed
if ( if (
component._children?.length && component?._children?.length &&
(state.selectedComponentId === navComponentId || (state.selectedComponentId === navComponentId ||
componentTreeNodesStore.isNodeExpanded(component._id)) componentTreeNodesStore.isNodeExpanded(component._id))
) { ) {
@ -1156,7 +1183,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)
@ -1192,7 +1219,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
@ -1328,12 +1355,15 @@ export const componentStore = new ComponentStore()
export const selectedComponent = derived( export const selectedComponent = derived(
[componentStore, selectedScreen], [componentStore, selectedScreen],
([$store, $selectedScreen]) => { ([$store, $selectedScreen]): Component | null => {
if ( if (
$selectedScreen && $selectedScreen &&
$store.selectedComponentId?.startsWith(`${$selectedScreen._id}-`) $store.selectedComponentId?.startsWith(`${$selectedScreen._id}-`)
) { ) {
return $selectedScreen?.props return {
...$selectedScreen.props,
_id: $selectedScreen.props._id!,
}
} }
if (!$selectedScreen || !$store.selectedComponentId) { if (!$selectedScreen || !$store.selectedComponentId) {
return null return null

View File

@ -16,8 +16,8 @@ export const initialState = {
export const createHistoryStore = ({ export const createHistoryStore = ({
getDoc, getDoc,
selectDoc, selectDoc,
beforeAction, beforeAction = () => {},
afterAction, afterAction = () => {},
}) => { }) => {
// Use a derived store to check if we are able to undo or redo any operations // Use a derived store to check if we are able to undo or redo any operations
const store = writable(initialState) const store = writable(initialState)

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"

View File

@ -6,12 +6,13 @@ import { findComponentsBySettingsType } from "@/helpers/screen"
import { UIDatasourceType, Screen } from "@budibase/types" import { UIDatasourceType, Screen } from "@budibase/types"
import { queries } from "./queries" import { queries } from "./queries"
import { views } from "./views" import { views } from "./views"
import { featureFlag } from "@/helpers" import { bindings, featureFlag } from "@/helpers"
import { getBindableProperties } from "@/dataBinding"
function reduceBy<TItem extends {}, TKey extends keyof TItem>( function reduceBy<TItem extends {}, TKey extends keyof TItem>(
key: TKey, key: TKey,
list: TItem[] list: TItem[]
) { ): Record<string, any> {
return list.reduce( return list.reduce(
(result, item) => ({ (result, item) => ({
...result, ...result,
@ -31,6 +32,9 @@ const validationKeyByType: Record<UIDatasourceType, string | null> = {
viewV2: "id", viewV2: "id",
query: "_id", query: "_id",
custom: null, custom: null,
link: "rowId",
field: "value",
jsonarray: "value",
} }
export const screenComponentErrors = derived( export const screenComponentErrors = derived(
@ -52,6 +56,9 @@ export const screenComponentErrors = derived(
["table", "dataSource"] ["table", "dataSource"]
)) { )) {
const componentSettings = component[setting.key] const componentSettings = component[setting.key]
if (!componentSettings) {
continue
}
const { label } = componentSettings const { label } = componentSettings
const type = componentSettings.type as UIDatasourceType const type = componentSettings.type as UIDatasourceType
@ -59,8 +66,26 @@ export const screenComponentErrors = derived(
if (!validationKey) { if (!validationKey) {
continue 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] const resourceId = componentSettings[validationKey]
if (!datasources[resourceId]) { if (!{ ...datasources, ...componentDatasources }[resourceId]) {
const friendlyTypeName = friendlyNameByType[type] ?? type const friendlyTypeName = friendlyNameByType[type] ?? type
result[component._id!] = [ result[component._id!] = [
`The ${friendlyTypeName} named "${label}" could not be found`, `The ${friendlyTypeName} named "${label}" could not be found`,
@ -78,6 +103,11 @@ export const screenComponentErrors = derived(
...reduceBy("_id", $queries.list), ...reduceBy("_id", $queries.list),
} }
if (!$selectedScreen) {
// Skip validation if a screen is not selected.
return {}
}
return getInvalidDatasources($selectedScreen, datasources) return getInvalidDatasources($selectedScreen, datasources)
} }
) )

View File

@ -13,15 +13,32 @@ import {
import { createHistoryStore } 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 {
FetchAppPackageResponse,
DeleteScreenResponse,
Screen,
Component,
SaveScreenResponse,
} from "@budibase/types"
import { ComponentDefinition } from "./components"
export const INITIAL_SCREENS_STATE = { interface ScreenState {
screens: [], screens: Screen[]
selectedScreenId: null, selectedScreenId?: string
} }
export class ScreenStore extends BudiStore { export const initialScreenState: ScreenState = {
screens: [],
}
// Review the nulls
export class ScreenStore extends BudiStore<ScreenState> {
history: any
delete: any
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)
@ -38,14 +55,15 @@ export class ScreenStore extends BudiStore {
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,
afterAction: () => { afterAction: () => {
// Ensure a valid component is selected // Ensure a valid component is selected
if (!get(selectedComponent)) { if (!get(selectedComponent)) {
this.update(state => ({ componentStore.update(state => ({
...state, ...state,
selectedComponentId: get(this.store).selected?.props._id, selectedComponentId: get(selectedScreen)?._id,
})) }))
} }
}, },
@ -59,14 +77,14 @@ export class ScreenStore extends BudiStore {
* 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) { syncAppScreens(pkg: FetchAppPackageResponse) {
this.update(state => ({ this.update(state => ({
...state, ...state,
screens: [...pkg.screens], screens: [...pkg.screens],
@ -79,7 +97,7 @@ export class ScreenStore extends BudiStore {
* @param {string} screenId * @param {string} screenId
* @returns * @returns
*/ */
select(screenId) { select(screenId: string) {
// Check this screen exists // Check this screen exists
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)
@ -103,18 +121,18 @@ export class ScreenStore extends BudiStore {
* 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
*/ */
validate(screen) { validate(screen: Screen) {
// Recursive function to find any illegal children in component trees // Recursive function to find any illegal children in component trees
const findIllegalChild = ( const findIllegalChild = (
component, component: Component,
illegalChildren = [], illegalChildren: string[] = [],
legalDirectChildren = [] legalDirectChildren: string[] = []
) => { ): string | undefined => {
const type = component._component const type: string = component._component
if (illegalChildren.includes(type)) { if (illegalChildren.includes(type)) {
return type return type
@ -137,7 +155,13 @@ export class ScreenStore extends BudiStore {
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 // Reset whitelist for direct children
legalDirectChildren = [] legalDirectChildren = []
if (definition?.legalDirectChildren?.length) { if (definition?.legalDirectChildren?.length) {
@ -172,7 +196,7 @@ export class ScreenStore extends BudiStore {
const illegalChild = findIllegalChild(screen.props) const illegalChild = findIllegalChild(screen.props)
if (illegalChild) { if (illegalChild) {
const def = componentStore.getDefinition(illegalChild) const def = componentStore.getDefinition(illegalChild)
throw `You can't place a ${def.name} here` throw `You can't place a ${def?.name} here`
} }
} }
@ -180,10 +204,9 @@ export class ScreenStore extends BudiStore {
* 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) { 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
@ -228,9 +251,9 @@ export class ScreenStore extends BudiStore {
/** /**
* 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) { async syncScreenData(savedScreen: Screen) {
const appState = get(appStore) const appState = get(appStore)
// If plugins changed we need to fetch the latest app metadata // If plugins changed we need to fetch the latest app metadata
let usedPlugins = appState.usedPlugins let usedPlugins = appState.usedPlugins
@ -256,28 +279,32 @@ export class ScreenStore extends BudiStore {
* This is slightly better than just a traditional "patch" endpoint and this * This is slightly better than just a traditional "patch" endpoint and this
* 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(async (patchFn, screenId) => { sequentialScreenPatch = Utils.sequential(
const state = get(this.store) async (patchFn: (screen: Screen) => any, screenId: string) => {
const screen = state.screens.find(screen => screen._id === screenId) const state = get(this.store)
if (!screen) { const screen = state.screens.find(screen => screen._id === screenId)
return if (!screen) {
} return
let clone = cloneDeep(screen) }
const result = patchFn(clone) let clone = cloneDeep(screen)
const result = patchFn(clone)
// An explicit false result means skip this change // An explicit false result means skip this change
if (result === false) { if (result === false) {
return return
}
return this.save(clone)
} }
return this.save(clone) )
})
/** /**
* @param {function} patchFn * @param {Function} patchFn the patch action to be applied
* @param {string | null} screenId * @param {string | null} screenId
* @returns
*/ */
async patch(patchFn, screenId) { async patch(
patchFn: (screen: Screen) => any,
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)
@ -294,11 +321,11 @@ export class ScreenStore extends BudiStore {
* 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, screen) { async replace(screenId: string, screen: Screen) {
if (!screenId) { if (!screenId) {
return return
} }
@ -334,20 +361,27 @@ export class ScreenStore extends BudiStore {
* 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(screens) { async deleteScreen(screens: Screen | Screen[]) {
const screensToDelete = Array.isArray(screens) ? screens : [screens] 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 = [] let promises: Promise<DeleteScreenResponse>[] = []
let deleteUrls = [] let deleteUrls: string[] = []
screensToDelete.forEach(screen => {
// Delete the screen // In this instance _id will have been set
promises.push(API.deleteScreen(screen._id, screen._rev)) // Underline the expectation that _id and _rev will be set after filtering
// Remove links to this screen screensToDelete
deleteUrls.push(screen.routing.route) .filter(
}) (screen): screen is Screen & { _id: string; _rev: string } =>
!!screen._id || !!screen._rev
)
.forEach(screen => {
// Delete the screen
promises.push(API.deleteScreen(screen._id, screen._rev))
// Remove links to this screen
deleteUrls.push(screen.routing.route)
})
await Promise.all(promises) await Promise.all(promises)
await navigationStore.deleteLink(deleteUrls) await navigationStore.deleteLink(deleteUrls)
const deletedIds = screensToDelete.map(screen => screen._id) const deletedIds = screensToDelete.map(screen => screen._id)
@ -359,12 +393,15 @@ export class ScreenStore extends BudiStore {
}) })
// Deselect the current screen if it was deleted // Deselect the current screen if it was deleted
if (deletedIds.includes(state.selectedScreenId)) { if (
state.selectedScreenId = null state.selectedScreenId &&
componentStore.update(state => ({ deletedIds.includes(state.selectedScreenId)
...state, ) {
selectedComponentId: null, delete state.selectedScreenId
})) componentStore.update(state => {
delete state.selectedComponentId
return state
})
} }
// Update routing // Update routing
@ -375,7 +412,6 @@ export class ScreenStore extends BudiStore {
return state return state
}) })
return null
} }
/** /**
@ -384,18 +420,17 @@ export class ScreenStore extends BudiStore {
* 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, name, value) { async updateSetting(screen: Screen, name: string, value: any) {
if (!screen || !name) { if (!screen || !name) {
return return
} }
// Apply setting update // Apply setting update
const patchFn = screen => { const patchFn = (screen: Screen) => {
if (!screen) { if (!screen) {
return false return false
} }
@ -422,7 +457,7 @@ export class ScreenStore extends BudiStore {
) )
}) })
if (otherHomeScreens.length && updatedScreen.routing.homeScreen) { if (otherHomeScreens.length && updatedScreen.routing.homeScreen) {
const patchFn = screen => { const patchFn = (screen: Screen) => {
screen.routing.homeScreen = false screen.routing.homeScreen = false
} }
for (let otherHomeScreen of otherHomeScreens) { for (let otherHomeScreen of otherHomeScreens) {
@ -432,11 +467,11 @@ export class ScreenStore extends BudiStore {
} }
// Move to layouts store // Move to layouts store
async removeCustomLayout(screen) { async removeCustomLayout(screen: Screen) {
// Pull relevant settings from old layout, if required // Pull relevant settings from old layout, if required
const layout = get(layoutStore).layouts.find(x => x._id === screen.layoutId) const layout = get(layoutStore).layouts.find(x => x._id === screen.layoutId)
const patchFn = screen => { const patchFn = (screen: Screen) => {
screen.layoutId = null delete screen.layoutId
screen.showNavigation = layout?.props.navigation !== "None" screen.showNavigation = layout?.props.navigation !== "None"
screen.width = layout?.props.width || "Large" screen.width = layout?.props.width || "Large"
} }
@ -446,11 +481,14 @@ export class ScreenStore extends BudiStore {
/** /**
* 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) { async enrichEmptySettings(screen: Screen) {
// Flatten the recursive component tree // Flatten the recursive component tree
const components = findAllMatchingComponents(screen.props, x => 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

@ -16,7 +16,14 @@ import { auth, appsStore } from "@/stores/portal"
import { screenStore } from "./screens" import { screenStore } from "./screens"
import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core" import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { Automation, Datasource, Role, Table, UIUser } from "@budibase/types" import {
Automation,
Datasource,
Role,
Table,
UIUser,
Screen,
} from "@budibase/types"
export const createBuilderWebsocket = (appId: string) => { export const createBuilderWebsocket = (appId: string) => {
const socket = createWebsocket("/socket/builder") const socket = createWebsocket("/socket/builder")

View File

@ -8,7 +8,7 @@ export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
* 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 = fn => { export const sequential = fn => {
let queue = [] let queue = []

View File

@ -1,7 +1,6 @@
import { import {
checkBuilderEndpoint, checkBuilderEndpoint,
getAllTableRows, getAllTableRows,
clearAllAutomations,
testAutomation, testAutomation,
} from "./utilities/TestFunctions" } from "./utilities/TestFunctions"
import * as setup from "./utilities" import * as setup from "./utilities"
@ -12,9 +11,9 @@ import {
import { configs, context, events } from "@budibase/backend-core" import { configs, context, events } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { import {
Automation,
ConfigType, ConfigType,
FieldType, FieldType,
isDidNotTriggerResponse,
SettingsConfig, SettingsConfig,
Table, Table,
} from "@budibase/types" } from "@budibase/types"
@ -22,11 +21,13 @@ import { mocks } from "@budibase/backend-core/tests"
import { removeDeprecated } from "../../../automations/utils" import { removeDeprecated } from "../../../automations/utils"
import { createAutomationBuilder } from "../../../automations/tests/utilities/AutomationTestBuilder" import { createAutomationBuilder } from "../../../automations/tests/utilities/AutomationTestBuilder"
import { automations } from "@budibase/shared-core" import { automations } from "@budibase/shared-core"
import { basicTable } from "../../../tests/utilities/structures"
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
const FilterConditions = automations.steps.filter.FilterConditions const FilterConditions = automations.steps.filter.FilterConditions
const MAX_RETRIES = 4 const MAX_RETRIES = 4
let { const {
basicAutomation, basicAutomation,
newAutomation, newAutomation,
automationTrigger, automationTrigger,
@ -37,10 +38,11 @@ let {
} = setup.structures } = setup.structures
describe("/automations", () => { describe("/automations", () => {
let request = setup.getRequest() const config = new TestConfiguration()
let config = setup.getConfig()
afterAll(setup.afterAll) afterAll(() => {
config.end()
})
beforeAll(async () => { beforeAll(async () => {
await config.init() await config.init()
@ -52,40 +54,26 @@ describe("/automations", () => {
describe("get definitions", () => { describe("get definitions", () => {
it("returns a list of definitions for actions", async () => { it("returns a list of definitions for actions", async () => {
const res = await request const res = await config.api.automation.getActions()
.get(`/api/automations/action/list`) expect(Object.keys(res).length).not.toEqual(0)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(Object.keys(res.body).length).not.toEqual(0)
}) })
it("returns a list of definitions for triggerInfo", async () => { it("returns a list of definitions for triggerInfo", async () => {
const res = await request const res = await config.api.automation.getTriggers()
.get(`/api/automations/trigger/list`) expect(Object.keys(res).length).not.toEqual(0)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(Object.keys(res.body).length).not.toEqual(0)
}) })
it("returns all of the definitions in one", async () => { it("returns all of the definitions in one", async () => {
const res = await request const { action, trigger } = await config.api.automation.getDefinitions()
.get(`/api/automations/definitions/list`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
let definitionsLength = Object.keys( let definitionsLength = Object.keys(
removeDeprecated(BUILTIN_ACTION_DEFINITIONS) removeDeprecated(BUILTIN_ACTION_DEFINITIONS)
).length ).length
expect(Object.keys(res.body.action).length).toBeGreaterThanOrEqual( expect(Object.keys(action).length).toBeGreaterThanOrEqual(
definitionsLength definitionsLength
) )
expect(Object.keys(res.body.trigger).length).toEqual( expect(Object.keys(trigger).length).toEqual(
Object.keys(removeDeprecated(TRIGGER_DEFINITIONS)).length Object.keys(removeDeprecated(TRIGGER_DEFINITIONS)).length
) )
}) })
@ -93,38 +81,27 @@ describe("/automations", () => {
describe("create", () => { describe("create", () => {
it("creates an automation with no steps", async () => { it("creates an automation with no steps", async () => {
const automation = newAutomation() const { message, automation } = await config.api.automation.post(
automation.definition.steps = [] newAutomation({ steps: [] })
)
const res = await request expect(message).toEqual("Automation created successfully")
.post(`/api/automations`) expect(automation.name).toEqual("My Automation")
.set(config.defaultHeaders()) expect(automation._id).not.toEqual(null)
.send(automation)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toEqual("Automation created successfully")
expect(res.body.automation.name).toEqual("My Automation")
expect(res.body.automation._id).not.toEqual(null)
expect(events.automation.created).toHaveBeenCalledTimes(1) expect(events.automation.created).toHaveBeenCalledTimes(1)
expect(events.automation.stepCreated).not.toHaveBeenCalled() expect(events.automation.stepCreated).not.toHaveBeenCalled()
}) })
it("creates an automation with steps", async () => { it("creates an automation with steps", async () => {
const automation = newAutomation()
automation.definition.steps.push(automationStep())
jest.clearAllMocks() jest.clearAllMocks()
const res = await request const { message, automation } = await config.api.automation.post(
.post(`/api/automations`) newAutomation({ steps: [automationStep(), automationStep()] })
.set(config.defaultHeaders()) )
.send(automation)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toEqual("Automation created successfully") expect(message).toEqual("Automation created successfully")
expect(res.body.automation.name).toEqual("My Automation") expect(automation.name).toEqual("My Automation")
expect(res.body.automation._id).not.toEqual(null) expect(automation._id).not.toEqual(null)
expect(events.automation.created).toHaveBeenCalledTimes(1) expect(events.automation.created).toHaveBeenCalledTimes(1)
expect(events.automation.stepCreated).toHaveBeenCalledTimes(2) expect(events.automation.stepCreated).toHaveBeenCalledTimes(2)
}) })
@ -241,13 +218,9 @@ describe("/automations", () => {
describe("find", () => { describe("find", () => {
it("should be able to find the automation", async () => { it("should be able to find the automation", async () => {
const automation = await config.createAutomation() const automation = await config.createAutomation()
const res = await request const { _id, _rev } = await config.api.automation.get(automation._id!)
.get(`/api/automations/${automation._id}`) expect(_id).toEqual(automation._id)
.set(config.defaultHeaders()) expect(_rev).toEqual(automation._rev)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body._id).toEqual(automation._id)
expect(res.body._rev).toEqual(automation._rev)
}) })
}) })
@ -348,106 +321,104 @@ describe("/automations", () => {
describe("trigger", () => { describe("trigger", () => {
it("does not trigger an automation when not synchronous and in dev", async () => { it("does not trigger an automation when not synchronous and in dev", async () => {
let automation = newAutomation() const { automation } = await config.api.automation.post(newAutomation())
automation = await config.createAutomation(automation) await config.api.automation.trigger(
const res = await request automation._id!,
.post(`/api/automations/${automation._id}/trigger`) {
.set(config.defaultHeaders()) fields: {},
.expect("Content-Type", /json/) timeout: 1000,
.expect(400) },
{
expect(res.body.message).toEqual( status: 400,
"Only apps in production support this endpoint" body: {
message: "Only apps in production support this endpoint",
},
}
) )
}) })
it("triggers a synchronous automation", async () => { it("triggers a synchronous automation", async () => {
mocks.licenses.useSyncAutomations() mocks.licenses.useSyncAutomations()
let automation = collectAutomation() const { automation } = await config.api.automation.post(
automation = await config.createAutomation(automation) collectAutomation()
const res = await request )
.post(`/api/automations/${automation._id}/trigger`) await config.api.automation.trigger(
.set(config.defaultHeaders()) automation._id!,
.expect("Content-Type", /json/) {
.expect(200) fields: {},
timeout: 1000,
expect(res.body.success).toEqual(true) },
expect(res.body.value).toEqual([1, 2, 3]) {
status: 200,
body: {
success: true,
value: [1, 2, 3],
},
}
)
}) })
it("should throw an error when attempting to trigger a disabled automation", async () => { it("should throw an error when attempting to trigger a disabled automation", async () => {
mocks.licenses.useSyncAutomations() mocks.licenses.useSyncAutomations()
let automation = collectAutomation() const { automation } = await config.api.automation.post(
automation = await config.createAutomation({ collectAutomation({ disabled: true })
...automation, )
disabled: true,
})
const res = await request await config.api.automation.trigger(
.post(`/api/automations/${automation._id}/trigger`) automation._id!,
.set(config.defaultHeaders()) {
.expect("Content-Type", /json/) fields: {},
.expect(400) timeout: 1000,
},
expect(res.body.message).toEqual("Automation is disabled") {
status: 400,
body: {
message: "Automation is disabled",
},
}
)
}) })
it("triggers an asynchronous automation", async () => { it("triggers an asynchronous automation", async () => {
let automation = newAutomation() const { automation } = await config.api.automation.post(newAutomation())
automation = await config.createAutomation(automation)
await config.publish() await config.publish()
const res = await request await config.withProdApp(() =>
.post(`/api/automations/${automation._id}/trigger`) config.api.automation.trigger(
.set(config.defaultHeaders({}, true)) automation._id!,
.expect("Content-Type", /json/) {
.expect(200) fields: {},
timeout: 1000,
expect(res.body.message).toEqual( },
`Automation ${automation._id} has been triggered.` {
status: 200,
body: {
message: `Automation ${automation._id} has been triggered.`,
},
}
)
) )
}) })
}) })
describe("update", () => { describe("update", () => {
const update = async (automation: Automation) => {
return request
.put(`/api/automations`)
.set(config.defaultHeaders())
.send(automation)
.expect("Content-Type", /json/)
.expect(200)
}
const updateWithPost = async (automation: Automation) => {
return request
.post(`/api/automations`)
.set(config.defaultHeaders())
.send(automation)
.expect("Content-Type", /json/)
.expect(200)
}
it("updates a automations name", async () => { it("updates a automations name", async () => {
const automation = await config.createAutomation(newAutomation()) const { automation } = await config.api.automation.post(basicAutomation())
automation.name = "Updated Name" automation.name = "Updated Name"
jest.clearAllMocks() jest.clearAllMocks()
const res = await update(automation) const { automation: updatedAutomation, message } =
await config.api.automation.update(automation)
const automationRes = res.body.automation expect(updatedAutomation._id).toEqual(automation._id)
const message = res.body.message expect(updatedAutomation._rev).toBeDefined()
expect(updatedAutomation._rev).not.toEqual(automation._rev)
// doc attributes expect(updatedAutomation.name).toEqual("Updated Name")
expect(automationRes._id).toEqual(automation._id)
expect(automationRes._rev).toBeDefined()
expect(automationRes._rev).not.toEqual(automation._rev)
// content updates
expect(automationRes.name).toEqual("Updated Name")
expect(message).toEqual( expect(message).toEqual(
`Automation ${automation._id} updated successfully.` `Automation ${automation._id} updated successfully.`
) )
// events
expect(events.automation.created).not.toHaveBeenCalled() expect(events.automation.created).not.toHaveBeenCalled()
expect(events.automation.stepCreated).not.toHaveBeenCalled() expect(events.automation.stepCreated).not.toHaveBeenCalled()
expect(events.automation.stepDeleted).not.toHaveBeenCalled() expect(events.automation.stepDeleted).not.toHaveBeenCalled()
@ -455,26 +426,23 @@ describe("/automations", () => {
}) })
it("updates a automations name using POST request", async () => { it("updates a automations name using POST request", async () => {
const automation = await config.createAutomation(newAutomation()) const { automation } = await config.api.automation.post(basicAutomation())
automation.name = "Updated Name" automation.name = "Updated Name"
jest.clearAllMocks() jest.clearAllMocks()
// the POST request will defer to the update // the POST request will defer to the update when an id has been supplied.
// when an id has been supplied. const { automation: updatedAutomation, message } =
const res = await updateWithPost(automation) await config.api.automation.post(automation)
const automationRes = res.body.automation expect(updatedAutomation._id).toEqual(automation._id)
const message = res.body.message expect(updatedAutomation._rev).toBeDefined()
// doc attributes expect(updatedAutomation._rev).not.toEqual(automation._rev)
expect(automationRes._id).toEqual(automation._id)
expect(automationRes._rev).toBeDefined() expect(updatedAutomation.name).toEqual("Updated Name")
expect(automationRes._rev).not.toEqual(automation._rev)
// content updates
expect(automationRes.name).toEqual("Updated Name")
expect(message).toEqual( expect(message).toEqual(
`Automation ${automation._id} updated successfully.` `Automation ${automation._id} updated successfully.`
) )
// events
expect(events.automation.created).not.toHaveBeenCalled() expect(events.automation.created).not.toHaveBeenCalled()
expect(events.automation.stepCreated).not.toHaveBeenCalled() expect(events.automation.stepCreated).not.toHaveBeenCalled()
expect(events.automation.stepDeleted).not.toHaveBeenCalled() expect(events.automation.stepDeleted).not.toHaveBeenCalled()
@ -482,16 +450,14 @@ describe("/automations", () => {
}) })
it("updates an automation trigger", async () => { it("updates an automation trigger", async () => {
let automation = newAutomation() const { automation } = await config.api.automation.post(newAutomation())
automation = await config.createAutomation(automation)
automation.definition.trigger = automationTrigger( automation.definition.trigger = automationTrigger(
TRIGGER_DEFINITIONS.WEBHOOK TRIGGER_DEFINITIONS.WEBHOOK
) )
jest.clearAllMocks() jest.clearAllMocks()
await update(automation) await config.api.automation.update(automation)
// events
expect(events.automation.created).not.toHaveBeenCalled() expect(events.automation.created).not.toHaveBeenCalled()
expect(events.automation.stepCreated).not.toHaveBeenCalled() expect(events.automation.stepCreated).not.toHaveBeenCalled()
expect(events.automation.stepDeleted).not.toHaveBeenCalled() expect(events.automation.stepDeleted).not.toHaveBeenCalled()
@ -499,16 +465,13 @@ describe("/automations", () => {
}) })
it("adds automation steps", async () => { it("adds automation steps", async () => {
let automation = newAutomation() const { automation } = await config.api.automation.post(newAutomation())
automation = await config.createAutomation(automation)
automation.definition.steps.push(automationStep()) automation.definition.steps.push(automationStep())
automation.definition.steps.push(automationStep()) automation.definition.steps.push(automationStep())
jest.clearAllMocks() jest.clearAllMocks()
// check the post request honours updates with same id await config.api.automation.update(automation)
await update(automation)
// events
expect(events.automation.stepCreated).toHaveBeenCalledTimes(2) expect(events.automation.stepCreated).toHaveBeenCalledTimes(2)
expect(events.automation.created).not.toHaveBeenCalled() expect(events.automation.created).not.toHaveBeenCalled()
expect(events.automation.stepDeleted).not.toHaveBeenCalled() expect(events.automation.stepDeleted).not.toHaveBeenCalled()
@ -516,32 +479,25 @@ describe("/automations", () => {
}) })
it("removes automation steps", async () => { it("removes automation steps", async () => {
let automation = newAutomation() const { automation } = await config.api.automation.post(newAutomation())
automation.definition.steps.push(automationStep())
automation = await config.createAutomation(automation)
automation.definition.steps = [] automation.definition.steps = []
jest.clearAllMocks() jest.clearAllMocks()
// check the post request honours updates with same id await config.api.automation.update(automation)
await update(automation)
// events expect(events.automation.stepDeleted).toHaveBeenCalledTimes(1)
expect(events.automation.stepDeleted).toHaveBeenCalledTimes(2)
expect(events.automation.stepCreated).not.toHaveBeenCalled() expect(events.automation.stepCreated).not.toHaveBeenCalled()
expect(events.automation.created).not.toHaveBeenCalled() expect(events.automation.created).not.toHaveBeenCalled()
expect(events.automation.triggerUpdated).not.toHaveBeenCalled() expect(events.automation.triggerUpdated).not.toHaveBeenCalled()
}) })
it("adds and removes automation steps", async () => { it("adds and removes automation steps", async () => {
let automation = newAutomation() const { automation } = await config.api.automation.post(newAutomation())
automation = await config.createAutomation(automation)
automation.definition.steps = [automationStep(), automationStep()] automation.definition.steps = [automationStep(), automationStep()]
jest.clearAllMocks() jest.clearAllMocks()
// check the post request honours updates with same id await config.api.automation.update(automation)
await update(automation)
// events
expect(events.automation.stepCreated).toHaveBeenCalledTimes(2) expect(events.automation.stepCreated).toHaveBeenCalledTimes(2)
expect(events.automation.stepDeleted).toHaveBeenCalledTimes(1) expect(events.automation.stepDeleted).toHaveBeenCalledTimes(1)
expect(events.automation.created).not.toHaveBeenCalled() expect(events.automation.created).not.toHaveBeenCalled()
@ -551,16 +507,24 @@ describe("/automations", () => {
describe("fetch", () => { describe("fetch", () => {
it("return all the automations for an instance", async () => { it("return all the automations for an instance", async () => {
await clearAllAutomations(config) const fetchResponse = await config.api.automation.fetch()
const autoConfig = await config.createAutomation(basicAutomation()) for (const auto of fetchResponse.automations) {
const res = await request await config.api.automation.delete(auto)
.get(`/api/automations`) }
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.automations[0]).toEqual( const { automation: automation1 } = await config.api.automation.post(
expect.objectContaining(autoConfig) newAutomation()
)
const { automation: automation2 } = await config.api.automation.post(
newAutomation()
)
const { automation: automation3 } = await config.api.automation.post(
newAutomation()
)
const { automations } = await config.api.automation.fetch()
expect(automations).toEqual(
expect.arrayContaining([automation1, automation2, automation3])
) )
}) })
@ -575,29 +539,25 @@ describe("/automations", () => {
describe("destroy", () => { describe("destroy", () => {
it("deletes a automation by its ID", async () => { it("deletes a automation by its ID", async () => {
const automation = await config.createAutomation() const { automation } = await config.api.automation.post(newAutomation())
const res = await request const { id } = await config.api.automation.delete(automation)
.delete(`/api/automations/${automation._id}/${automation._rev}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.id).toEqual(automation._id) expect(id).toEqual(automation._id)
expect(events.automation.deleted).toHaveBeenCalledTimes(1) expect(events.automation.deleted).toHaveBeenCalledTimes(1)
}) })
it("cannot delete a row action automation", async () => { it("cannot delete a row action automation", async () => {
const automation = await config.createAutomation( const { automation } = await config.api.automation.post(
setup.structures.rowActionAutomation() setup.structures.rowActionAutomation()
) )
await request
.delete(`/api/automations/${automation._id}/${automation._rev}`) await config.api.automation.delete(automation, {
.set(config.defaultHeaders()) status: 422,
.expect("Content-Type", /json/) body: {
.expect(422, {
message: "Row actions automations cannot be deleted", message: "Row actions automations cannot be deleted",
status: 422, status: 422,
}) },
})
expect(events.automation.deleted).not.toHaveBeenCalled() expect(events.automation.deleted).not.toHaveBeenCalled()
}) })
@ -614,10 +574,19 @@ describe("/automations", () => {
describe("checkForCollectStep", () => { describe("checkForCollectStep", () => {
it("should return true if a collect step exists in an automation", async () => { it("should return true if a collect step exists in an automation", async () => {
let automation = collectAutomation() const { automation } = await config.api.automation.post(
await config.createAutomation(automation) collectAutomation()
let res = await sdk.automations.utils.checkForCollectStep(automation) )
expect(res).toEqual(true) expect(sdk.automations.utils.checkForCollectStep(automation)).toEqual(
true
)
})
it("should return false if a collect step does not exist in an automation", async () => {
const { automation } = await config.api.automation.post(newAutomation())
expect(sdk.automations.utils.checkForCollectStep(automation)).toEqual(
false
)
}) })
}) })
@ -628,28 +597,45 @@ describe("/automations", () => {
])( ])(
"triggers an update row automation and compares new to old rows with old city '%s' and new city '%s'", "triggers an update row automation and compares new to old rows with old city '%s' and new city '%s'",
async ({ oldCity, newCity }) => { async ({ oldCity, newCity }) => {
const expectedResult = oldCity === newCity let table = await config.api.table.save(basicTable())
let table = await config.createTable() const { automation } = await config.api.automation.post(
filterAutomation({
definition: {
trigger: {
inputs: {
tableId: table._id,
},
},
steps: [
{
inputs: {
condition: FilterConditions.EQUAL,
field: "{{ trigger.row.City }}",
value: "{{ trigger.oldRow.City }}",
},
},
],
},
})
)
let automation = await filterAutomation(config.getAppId()) const res = await config.api.automation.test(automation._id!, {
automation.definition.trigger.inputs.tableId = table._id fields: {},
automation.definition.steps[0].inputs = {
condition: FilterConditions.EQUAL,
field: "{{ trigger.row.City }}",
value: "{{ trigger.oldRow.City }}",
}
automation = await config.createAutomation(automation)
let triggerInputs = {
oldRow: { oldRow: {
City: oldCity, City: oldCity,
}, },
row: { row: {
City: newCity, City: newCity,
}, },
})
if (isDidNotTriggerResponse(res)) {
throw new Error("Automation did not trigger")
} }
const res = await testAutomation(config, automation, triggerInputs)
expect(res.body.steps[1].outputs.result).toEqual(expectedResult) const expectedResult = oldCity === newCity
expect(res.steps[1].outputs.result).toEqual(expectedResult)
} }
) )
}) })
@ -657,16 +643,18 @@ describe("/automations", () => {
let table: Table let table: Table
beforeAll(async () => { beforeAll(async () => {
table = await config.createTable({ table = await config.api.table.save(
name: "table", basicTable(undefined, {
type: "table", name: "table",
schema: { type: "table",
Approved: { schema: {
name: "Approved", Approved: {
type: FieldType.BOOLEAN, name: "Approved",
type: FieldType.BOOLEAN,
},
}, },
}, })
}) )
}) })
const testCases = [ const testCases = [
@ -712,33 +700,29 @@ describe("/automations", () => {
it.each(testCases)( it.each(testCases)(
"$description", "$description",
async ({ filters, row, oldRow, expectToRun }) => { async ({ filters, row, oldRow, expectToRun }) => {
let automation = await updateRowAutomationWithFilters( let req = updateRowAutomationWithFilters(config.getAppId(), table._id!)
config.getAppId(), req.definition.trigger.inputs = {
table._id!
)
automation.definition.trigger.inputs = {
tableId: table._id, tableId: table._id,
filters, filters,
} }
automation = await config.createAutomation(automation)
const inputs = { const { automation } = await config.api.automation.post(req)
row: { const res = await config.api.automation.test(automation._id!, {
tableId: table._id, fields: {},
...row,
},
oldRow: { oldRow: {
tableId: table._id, tableId: table._id,
...oldRow, ...oldRow,
}, },
} row: {
tableId: table._id,
...row,
},
})
const res = await testAutomation(config, automation, inputs) if (isDidNotTriggerResponse(res)) {
expect(expectToRun).toEqual(false)
if (expectToRun) {
expect(res.body.steps[1].outputs.success).toEqual(true)
} else { } else {
expect(res.body.outputs.success).toEqual(false) expect(res.steps[1].outputs.success).toEqual(expectToRun)
} }
} }
) )

View File

@ -53,15 +53,6 @@ export const clearAllApps = async (
}) })
} }
export const clearAllAutomations = async (config: TestConfiguration) => {
const { automations } = await config.getAllAutomations()
for (let auto of automations) {
await context.doInAppContext(config.getAppId(), async () => {
await config.deleteAutomation(auto)
})
}
}
export const wipeDb = async () => { export const wipeDb = async () => {
const couchInfo = db.getCouchInfo() const couchInfo = db.getCouchInfo()
const nano = Nano({ const nano = Nano({

View File

@ -258,7 +258,7 @@ export default class TestConfiguration {
} }
} }
async withApp(app: App | string, f: () => Promise<void>) { async withApp<R>(app: App | string, f: () => Promise<R>) {
const oldAppId = this.appId const oldAppId = this.appId
this.appId = typeof app === "string" ? app : app.appId this.appId = typeof app === "string" ? app : app.appId
try { try {
@ -268,6 +268,10 @@ export default class TestConfiguration {
} }
} }
async withProdApp<R>(f: () => Promise<R>) {
return await this.withApp(this.getProdAppId(), f)
}
// UTILS // UTILS
_req<Req extends Record<string, any> | void, Res>( _req<Req extends Record<string, any> | void, Res>(

View File

@ -1,8 +1,17 @@
import { import {
Automation, Automation,
CreateAutomationResponse,
DeleteAutomationResponse,
FetchAutomationResponse, FetchAutomationResponse,
GetAutomationActionDefinitionsResponse,
GetAutomationStepDefinitionsResponse,
GetAutomationTriggerDefinitionsResponse,
TestAutomationRequest, TestAutomationRequest,
TestAutomationResponse, TestAutomationResponse,
TriggerAutomationRequest,
TriggerAutomationResponse,
UpdateAutomationRequest,
UpdateAutomationResponse,
} from "@budibase/types" } from "@budibase/types"
import { Expectations, TestAPI } from "./base" import { Expectations, TestAPI } from "./base"
@ -20,6 +29,39 @@ export class AutomationAPI extends TestAPI {
return result return result
} }
getActions = async (
expectations?: Expectations
): Promise<GetAutomationActionDefinitionsResponse> => {
return await this._get<GetAutomationActionDefinitionsResponse>(
`/api/automations/actions/list`,
{
expectations,
}
)
}
getTriggers = async (
expectations?: Expectations
): Promise<GetAutomationTriggerDefinitionsResponse> => {
return await this._get<GetAutomationTriggerDefinitionsResponse>(
`/api/automations/triggers/list`,
{
expectations,
}
)
}
getDefinitions = async (
expectations?: Expectations
): Promise<GetAutomationStepDefinitionsResponse> => {
return await this._get<GetAutomationStepDefinitionsResponse>(
`/api/automations/definitions/list`,
{
expectations,
}
)
}
fetch = async ( fetch = async (
expectations?: Expectations expectations?: Expectations
): Promise<FetchAutomationResponse> => { ): Promise<FetchAutomationResponse> => {
@ -31,11 +73,14 @@ export class AutomationAPI extends TestAPI {
post = async ( post = async (
body: Automation, body: Automation,
expectations?: Expectations expectations?: Expectations
): Promise<Automation> => { ): Promise<CreateAutomationResponse> => {
const result = await this._post<Automation>(`/api/automations`, { const result = await this._post<CreateAutomationResponse>(
body, `/api/automations`,
expectations, {
}) body,
expectations,
}
)
return result return result
} }
@ -52,4 +97,40 @@ export class AutomationAPI extends TestAPI {
} }
) )
} }
trigger = async (
id: string,
body: TriggerAutomationRequest,
expectations?: Expectations
): Promise<TriggerAutomationResponse> => {
return await this._post<TriggerAutomationResponse>(
`/api/automations/${id}/trigger`,
{
expectations,
body,
}
)
}
update = async (
body: UpdateAutomationRequest,
expectations?: Expectations
): Promise<UpdateAutomationResponse> => {
return await this._put<UpdateAutomationResponse>(`/api/automations`, {
body,
expectations,
})
}
delete = async (
automation: Automation,
expectations?: Expectations
): Promise<DeleteAutomationResponse> => {
return await this._delete<DeleteAutomationResponse>(
`/api/automations/${automation._id!}/${automation._rev!}`,
{
expectations,
}
)
}
} }

View File

@ -19,43 +19,43 @@ import { PluginAPI } from "./plugin"
import { WebhookAPI } from "./webhook" import { WebhookAPI } from "./webhook"
export default class API { export default class API {
table: TableAPI
legacyView: LegacyViewAPI
viewV2: ViewV2API
row: RowAPI
permission: PermissionAPI
datasource: DatasourceAPI
screen: ScreenAPI
application: ApplicationAPI application: ApplicationAPI
backup: BackupAPI
attachment: AttachmentAPI attachment: AttachmentAPI
user: UserAPI automation: AutomationAPI
backup: BackupAPI
datasource: DatasourceAPI
legacyView: LegacyViewAPI
permission: PermissionAPI
plugin: PluginAPI
query: QueryAPI query: QueryAPI
roles: RoleAPI roles: RoleAPI
templates: TemplateAPI row: RowAPI
rowAction: RowActionAPI rowAction: RowActionAPI
automation: AutomationAPI screen: ScreenAPI
plugin: PluginAPI table: TableAPI
templates: TemplateAPI
user: UserAPI
viewV2: ViewV2API
webhook: WebhookAPI webhook: WebhookAPI
constructor(config: TestConfiguration) { constructor(config: TestConfiguration) {
this.table = new TableAPI(config)
this.legacyView = new LegacyViewAPI(config)
this.viewV2 = new ViewV2API(config)
this.row = new RowAPI(config)
this.permission = new PermissionAPI(config)
this.datasource = new DatasourceAPI(config)
this.screen = new ScreenAPI(config)
this.application = new ApplicationAPI(config) this.application = new ApplicationAPI(config)
this.backup = new BackupAPI(config)
this.attachment = new AttachmentAPI(config) this.attachment = new AttachmentAPI(config)
this.user = new UserAPI(config) this.automation = new AutomationAPI(config)
this.backup = new BackupAPI(config)
this.datasource = new DatasourceAPI(config)
this.legacyView = new LegacyViewAPI(config)
this.permission = new PermissionAPI(config)
this.plugin = new PluginAPI(config)
this.query = new QueryAPI(config) this.query = new QueryAPI(config)
this.roles = new RoleAPI(config) this.roles = new RoleAPI(config)
this.templates = new TemplateAPI(config) this.row = new RowAPI(config)
this.rowAction = new RowActionAPI(config) this.rowAction = new RowActionAPI(config)
this.automation = new AutomationAPI(config) this.screen = new ScreenAPI(config)
this.plugin = new PluginAPI(config) this.table = new TableAPI(config)
this.templates = new TemplateAPI(config)
this.user = new UserAPI(config)
this.viewV2 = new ViewV2API(config)
this.webhook = new WebhookAPI(config) this.webhook = new WebhookAPI(config)
} }
} }

View File

@ -34,6 +34,7 @@ import {
Webhook, Webhook,
WebhookActionType, WebhookActionType,
BuiltinPermissionID, BuiltinPermissionID,
DeepPartial,
} from "@budibase/types" } from "@budibase/types"
import { LoopInput } from "../../definitions/automations" import { LoopInput } from "../../definitions/automations"
import { merge } from "lodash" import { merge } from "lodash"
@ -184,21 +185,12 @@ export function newAutomation({
steps, steps,
trigger, trigger,
}: { steps?: AutomationStep[]; trigger?: AutomationTrigger } = {}) { }: { steps?: AutomationStep[]; trigger?: AutomationTrigger } = {}) {
const automation = basicAutomation() return basicAutomation({
definition: {
if (trigger) { steps: steps || [automationStep()],
automation.definition.trigger = trigger trigger: trigger || automationTrigger(),
} else { },
automation.definition.trigger = automationTrigger() })
}
if (steps) {
automation.definition.steps = steps
} else {
automation.definition.steps = [automationStep()]
}
return automation
} }
export function rowActionAutomation() { export function rowActionAutomation() {
@ -211,8 +203,8 @@ export function rowActionAutomation() {
return automation return automation
} }
export function basicAutomation(appId?: string): Automation { export function basicAutomation(opts?: DeepPartial<Automation>): Automation {
return { const baseAutomation: Automation = {
name: "My Automation", name: "My Automation",
screenId: "kasdkfldsafkl", screenId: "kasdkfldsafkl",
live: true, live: true,
@ -241,8 +233,9 @@ export function basicAutomation(appId?: string): Automation {
steps: [], steps: [],
}, },
type: "automation", type: "automation",
appId: appId!, appId: "appId",
} }
return merge(baseAutomation, opts)
} }
export function basicCronAutomation(appId: string, cron: string): Automation { export function basicCronAutomation(appId: string, cron: string): Automation {
@ -387,16 +380,21 @@ export function loopAutomation(
return automation as Automation return automation as Automation
} }
export function collectAutomation(tableId?: string): Automation { export function collectAutomation(opts?: DeepPartial<Automation>): Automation {
const automation: any = { const baseAutomation: Automation = {
appId: "appId",
name: "looping", name: "looping",
type: "automation", type: "automation",
definition: { definition: {
steps: [ steps: [
{ {
id: "b", id: "b",
type: "ACTION", name: "b",
tagline: "An automation action step",
icon: "Icon",
type: AutomationStepType.ACTION,
internal: true, internal: true,
description: "Execute script",
stepId: AutomationActionStepId.EXECUTE_SCRIPT, stepId: AutomationActionStepId.EXECUTE_SCRIPT,
inputs: { inputs: {
code: "return [1,2,3]", code: "return [1,2,3]",
@ -405,8 +403,12 @@ export function collectAutomation(tableId?: string): Automation {
}, },
{ {
id: "c", id: "c",
type: "ACTION", name: "c",
type: AutomationStepType.ACTION,
tagline: "An automation action step",
icon: "Icon",
internal: true, internal: true,
description: "Collect",
stepId: AutomationActionStepId.COLLECT, stepId: AutomationActionStepId.COLLECT,
inputs: { inputs: {
collection: "{{ literal steps.1.value }}", collection: "{{ literal steps.1.value }}",
@ -416,24 +418,28 @@ export function collectAutomation(tableId?: string): Automation {
], ],
trigger: { trigger: {
id: "a", id: "a",
type: "TRIGGER", type: AutomationStepType.TRIGGER,
event: AutomationEventType.ROW_SAVE, event: AutomationEventType.ROW_SAVE,
stepId: AutomationTriggerStepId.ROW_SAVED, stepId: AutomationTriggerStepId.ROW_SAVED,
name: "trigger Step",
tagline: "An automation trigger",
description: "A trigger",
icon: "Icon",
inputs: { inputs: {
tableId, tableId: "tableId",
}, },
schema: TRIGGER_DEFINITIONS.ROW_SAVED.schema, schema: TRIGGER_DEFINITIONS.ROW_SAVED.schema,
}, },
}, },
} }
return automation return merge(baseAutomation, opts)
} }
export function filterAutomation(appId: string, tableId?: string): Automation { export function filterAutomation(opts?: DeepPartial<Automation>): Automation {
const automation: Automation = { const automation: Automation = {
name: "looping", name: "looping",
type: "automation", type: "automation",
appId, appId: "appId",
definition: { definition: {
steps: [ steps: [
{ {
@ -459,13 +465,13 @@ export function filterAutomation(appId: string, tableId?: string): Automation {
event: AutomationEventType.ROW_SAVE, event: AutomationEventType.ROW_SAVE,
stepId: AutomationTriggerStepId.ROW_SAVED, stepId: AutomationTriggerStepId.ROW_SAVED,
inputs: { inputs: {
tableId: tableId!, tableId: "tableId",
}, },
schema: TRIGGER_DEFINITIONS.ROW_SAVED.schema, schema: TRIGGER_DEFINITIONS.ROW_SAVED.schema,
}, },
}, },
} }
return automation return merge(automation, opts)
} }
export function updateRowAutomationWithFilters( export function updateRowAutomationWithFilters(

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

@ -1,4 +1,5 @@
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, postprocessWithLogs, preprocess } from "./processors" import { postprocess, postprocessWithLogs, preprocess } from "./processors"
@ -14,10 +15,10 @@ import {
} 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 { Log, 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 type { Log, LogType } from "./types"
export { setTestingBackendJS } from "./environment" export { setTestingBackendJS } from "./environment"
@ -507,15 +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 = frontendWrapJS(js) const wrappedJs = frontendWrapJS(js)
const result = runInNewContext(wrappedJs, context, { timeout: 1000 }) const result = jsSandbox.runInNewContext(wrappedJs, context)
if (result.error) { if (result.error) {
throw new UserScriptError(result.error) throw new UserScriptError(result.error)
} }

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

@ -75,6 +75,7 @@ export interface TestAutomationRequest {
revision?: string revision?: string
fields: Record<string, any> fields: Record<string, any>
row?: Row row?: Row
oldRow?: Row
} }
export type TestAutomationResponse = AutomationResults | DidNotTriggerResponse export type TestAutomationResponse = AutomationResults | DidNotTriggerResponse

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

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

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"