Merge branch 'master' into fix/cheeks-ux-review-console-log
This commit is contained in:
commit
d7ce6bead3
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.3",
|
||||
"npmClient": "yarn",
|
||||
"concurrency": 20,
|
||||
"command": {
|
||||
|
|
|
@ -8,27 +8,52 @@
|
|||
|
||||
// Strategies are defined as [Popover]To[Anchor].
|
||||
// They can apply for both horizontal and vertical alignment.
|
||||
const Strategies = {
|
||||
StartToStart: "StartToStart", // e.g. left alignment
|
||||
EndToEnd: "EndToEnd", // e.g. right alignment
|
||||
StartToEnd: "StartToEnd", // e.g. right-outside alignment
|
||||
EndToStart: "EndToStart", // e.g. left-outside alignment
|
||||
MidPoint: "MidPoint", // centers relative to midpoints
|
||||
ScreenEdge: "ScreenEdge", // locks to screen edge
|
||||
type Strategy =
|
||||
| "StartToStart"
|
||||
| "EndToEnd"
|
||||
| "StartToEnd"
|
||||
| "EndToStart"
|
||||
| "MidPoint"
|
||||
| "ScreenEdge"
|
||||
|
||||
export interface Styles {
|
||||
maxHeight?: number
|
||||
minWidth?: number
|
||||
maxWidth?: number
|
||||
offset?: number
|
||||
left: number
|
||||
top: number
|
||||
}
|
||||
|
||||
export default function positionDropdown(element, opts) {
|
||||
let resizeObserver
|
||||
export type UpdateHandler = (
|
||||
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
|
||||
|
||||
// We need a static reference to this function so that we can properly
|
||||
// clean up the scroll listener.
|
||||
const scrollUpdate = () => {
|
||||
updatePosition(latestOpts)
|
||||
}
|
||||
const scrollUpdate = () => updatePosition(latestOpts)
|
||||
|
||||
// Updates the position of the dropdown
|
||||
const updatePosition = opts => {
|
||||
const updatePosition = (opts: Opts) => {
|
||||
const {
|
||||
anchor,
|
||||
align,
|
||||
|
@ -51,12 +76,12 @@ export default function positionDropdown(element, opts) {
|
|||
const winWidth = window.innerWidth
|
||||
const winHeight = window.innerHeight
|
||||
const screenOffset = 8
|
||||
let styles = {
|
||||
let styles: Styles = {
|
||||
maxHeight,
|
||||
minWidth: useAnchorWidth ? anchorBounds.width : minWidth,
|
||||
maxWidth: useAnchorWidth ? anchorBounds.width : maxWidth,
|
||||
left: null,
|
||||
top: null,
|
||||
left: 0,
|
||||
top: 0,
|
||||
}
|
||||
|
||||
// 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
|
||||
const applyMaxHeight = height => {
|
||||
const applyMaxHeight = (height: number) => {
|
||||
if (!styles.maxHeight && resizable) {
|
||||
styles.maxHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
// Applies the X strategy to our styles
|
||||
const applyXStrategy = strategy => {
|
||||
const applyXStrategy = (strategy: Strategy) => {
|
||||
switch (strategy) {
|
||||
case Strategies.StartToStart:
|
||||
case "StartToStart":
|
||||
default:
|
||||
styles.left = anchorBounds.left
|
||||
break
|
||||
case Strategies.EndToEnd:
|
||||
case "EndToEnd":
|
||||
styles.left = anchorBounds.right - elementBounds.width
|
||||
break
|
||||
case Strategies.StartToEnd:
|
||||
case "StartToEnd":
|
||||
styles.left = anchorBounds.right + offset
|
||||
break
|
||||
case Strategies.EndToStart:
|
||||
case "EndToStart":
|
||||
styles.left = anchorBounds.left - elementBounds.width - offset
|
||||
break
|
||||
case Strategies.MidPoint:
|
||||
case "MidPoint":
|
||||
styles.left =
|
||||
anchorBounds.left +
|
||||
anchorBounds.width / 2 -
|
||||
elementBounds.width / 2
|
||||
break
|
||||
case Strategies.ScreenEdge:
|
||||
case "ScreenEdge":
|
||||
styles.left = winWidth - elementBounds.width - screenOffset
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Applies the Y strategy to our styles
|
||||
const applyYStrategy = strategy => {
|
||||
const applyYStrategy = (strategy: Strategy) => {
|
||||
switch (strategy) {
|
||||
case Strategies.StartToStart:
|
||||
case "StartToStart":
|
||||
styles.top = anchorBounds.top
|
||||
applyMaxHeight(winHeight - anchorBounds.top - screenOffset)
|
||||
break
|
||||
case Strategies.EndToEnd:
|
||||
case "EndToEnd":
|
||||
styles.top = anchorBounds.bottom - elementBounds.height
|
||||
applyMaxHeight(anchorBounds.bottom - screenOffset)
|
||||
break
|
||||
case Strategies.StartToEnd:
|
||||
case "StartToEnd":
|
||||
default:
|
||||
styles.top = anchorBounds.bottom + offset
|
||||
applyMaxHeight(winHeight - anchorBounds.bottom - screenOffset)
|
||||
break
|
||||
case Strategies.EndToStart:
|
||||
case "EndToStart":
|
||||
styles.top = anchorBounds.top - elementBounds.height - offset
|
||||
applyMaxHeight(anchorBounds.top - screenOffset)
|
||||
break
|
||||
case Strategies.MidPoint:
|
||||
case "MidPoint":
|
||||
styles.top =
|
||||
anchorBounds.top +
|
||||
anchorBounds.height / 2 -
|
||||
elementBounds.height / 2
|
||||
break
|
||||
case Strategies.ScreenEdge:
|
||||
case "ScreenEdge":
|
||||
styles.top = winHeight - elementBounds.height - screenOffset
|
||||
applyMaxHeight(winHeight - 2 * screenOffset)
|
||||
break
|
||||
|
@ -150,81 +175,83 @@ export default function positionDropdown(element, opts) {
|
|||
|
||||
// Determine X strategy
|
||||
if (align === "right") {
|
||||
applyXStrategy(Strategies.EndToEnd)
|
||||
applyXStrategy("EndToEnd")
|
||||
} else if (align === "right-outside" || align === "right-context-menu") {
|
||||
applyXStrategy(Strategies.StartToEnd)
|
||||
applyXStrategy("StartToEnd")
|
||||
} else if (align === "left-outside" || align === "left-context-menu") {
|
||||
applyXStrategy(Strategies.EndToStart)
|
||||
applyXStrategy("EndToStart")
|
||||
} else if (align === "center") {
|
||||
applyXStrategy(Strategies.MidPoint)
|
||||
applyXStrategy("MidPoint")
|
||||
} else {
|
||||
applyXStrategy(Strategies.StartToStart)
|
||||
applyXStrategy("StartToStart")
|
||||
}
|
||||
|
||||
// Determine Y strategy
|
||||
if (align === "right-outside" || align === "left-outside") {
|
||||
applyYStrategy(Strategies.MidPoint)
|
||||
applyYStrategy("MidPoint")
|
||||
} else if (
|
||||
align === "right-context-menu" ||
|
||||
align === "left-context-menu"
|
||||
) {
|
||||
applyYStrategy(Strategies.StartToStart)
|
||||
applyYStrategy("StartToStart")
|
||||
if (styles.top) {
|
||||
styles.top -= 5 // Manual adjustment for action menu padding
|
||||
}
|
||||
} else {
|
||||
applyYStrategy(Strategies.StartToEnd)
|
||||
applyYStrategy("StartToEnd")
|
||||
}
|
||||
|
||||
// Handle screen overflow
|
||||
if (doesXOverflow()) {
|
||||
// Swap left to right
|
||||
if (align === "left") {
|
||||
applyXStrategy(Strategies.EndToEnd)
|
||||
applyXStrategy("EndToEnd")
|
||||
}
|
||||
// Swap right-outside to left-outside
|
||||
else if (align === "right-outside") {
|
||||
applyXStrategy(Strategies.EndToStart)
|
||||
applyXStrategy("EndToStart")
|
||||
}
|
||||
}
|
||||
if (doesYOverflow()) {
|
||||
// If wrapping, lock to the bottom of the screen and also reposition to
|
||||
// the side to not block the anchor
|
||||
if (wrap) {
|
||||
applyYStrategy(Strategies.MidPoint)
|
||||
applyYStrategy("MidPoint")
|
||||
if (doesYOverflow()) {
|
||||
applyYStrategy(Strategies.ScreenEdge)
|
||||
applyYStrategy("ScreenEdge")
|
||||
}
|
||||
applyXStrategy(Strategies.StartToEnd)
|
||||
applyXStrategy("StartToEnd")
|
||||
if (doesXOverflow()) {
|
||||
applyXStrategy(Strategies.EndToStart)
|
||||
applyXStrategy("EndToStart")
|
||||
}
|
||||
}
|
||||
// Othewise invert as normal
|
||||
else {
|
||||
// If using an outside strategy then lock to the bottom of the screen
|
||||
if (align === "left-outside" || align === "right-outside") {
|
||||
applyYStrategy(Strategies.ScreenEdge)
|
||||
applyYStrategy("ScreenEdge")
|
||||
}
|
||||
// Otherwise flip above
|
||||
else {
|
||||
applyYStrategy(Strategies.EndToStart)
|
||||
applyYStrategy("EndToStart")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply styles
|
||||
Object.entries(styles).forEach(([style, value]) => {
|
||||
for (const [key, value] of Object.entries(styles)) {
|
||||
const name = key as keyof Styles
|
||||
if (value != null) {
|
||||
element.style[style] = `${value.toFixed(0)}px`
|
||||
element.style[name] = `${value}px`
|
||||
} else {
|
||||
element.style[style] = null
|
||||
element.style[name] = ""
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The actual svelte action callback which creates observers on the relevant
|
||||
// DOM elements
|
||||
const update = newOpts => {
|
||||
const update = (newOpts: Opts) => {
|
||||
latestOpts = newOpts
|
||||
|
||||
// Cleanup old state
|
|
@ -1,22 +1,23 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import "@spectrum-css/checkbox/dist/index-vars.css"
|
||||
import "@spectrum-css/fieldgroup/dist/index-vars.css"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import type { ChangeEventHandler } from "svelte/elements"
|
||||
|
||||
export let value = false
|
||||
export let id = null
|
||||
export let text = null
|
||||
export let id: string | undefined = undefined
|
||||
export let text: string | undefined = undefined
|
||||
export let disabled = false
|
||||
export let readonly = false
|
||||
export let size
|
||||
export let size: "S" | "M" | "L" | "XL" = "M"
|
||||
export let indeterminate = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const onChange = event => {
|
||||
dispatch("change", event.target.checked)
|
||||
const onChange: ChangeEventHandler<HTMLInputElement> = event => {
|
||||
dispatch("change", event.currentTarget.checked)
|
||||
}
|
||||
|
||||
$: sizeClass = `spectrum-Checkbox--size${size || "M"}`
|
||||
$: sizeClass = `spectrum-Checkbox--size${size}`
|
||||
</script>
|
||||
|
||||
<label
|
||||
|
|
|
@ -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/radio/dist/index-vars.css"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let direction = "vertical"
|
||||
export let value = []
|
||||
export let options = []
|
||||
export let direction: "horizontal" | "vertical" = "vertical"
|
||||
export let value: V[] = []
|
||||
export let options: O[] = []
|
||||
export let disabled = false
|
||||
export let readonly = false
|
||||
export let getOptionLabel = option => option
|
||||
export let getOptionValue = option => option
|
||||
export let getOptionLabel = (option: O) => `${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)) {
|
||||
dispatch("change", [...value, optionValue])
|
||||
} else {
|
||||
|
|
|
@ -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/popover/dist/index-vars.css"
|
||||
import "@spectrum-css/menu/dist/index-vars.css"
|
||||
|
@ -6,33 +12,38 @@
|
|||
import clickOutside from "../../Actions/click_outside"
|
||||
import Popover from "../../Popover/Popover.svelte"
|
||||
|
||||
export let value = null
|
||||
export let id = null
|
||||
export let value: string | undefined = undefined
|
||||
export let id: string | undefined = undefined
|
||||
export let placeholder = "Choose an option or type"
|
||||
export let disabled = false
|
||||
export let readonly = false
|
||||
export let options = []
|
||||
export let getOptionLabel = option => option
|
||||
export let getOptionValue = option => option
|
||||
export let options: O[] = []
|
||||
export let getOptionLabel = (option: O) => `${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 focus = false
|
||||
let anchor
|
||||
|
||||
const selectOption = value => {
|
||||
const selectOption = (value: string) => {
|
||||
dispatch("change", value)
|
||||
open = false
|
||||
}
|
||||
|
||||
const onType = e => {
|
||||
const value = e.target.value
|
||||
const onType: ChangeEventHandler<HTMLInputElement> = e => {
|
||||
const value = e.currentTarget.value
|
||||
dispatch("type", value)
|
||||
selectOption(value)
|
||||
}
|
||||
|
||||
const onPick = value => {
|
||||
const onPick = (value: string) => {
|
||||
dispatch("pick", value)
|
||||
selectOption(value)
|
||||
}
|
||||
|
|
|
@ -1,28 +1,33 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
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 { 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 { fly } from "svelte/transition"
|
||||
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 align = "right"
|
||||
export let portalTarget
|
||||
export let minWidth
|
||||
export let maxWidth
|
||||
export let maxHeight
|
||||
export let anchor: HTMLElement
|
||||
export let align: "left" | "right" | "left-outside" | "right-outside" =
|
||||
"right"
|
||||
export let portalTarget: string | undefined = undefined
|
||||
export let minWidth: number | undefined = undefined
|
||||
export let maxWidth: number | undefined = undefined
|
||||
export let maxHeight: number | undefined = undefined
|
||||
export let open = false
|
||||
export let useAnchorWidth = false
|
||||
export let dismissible = true
|
||||
export let offset = 4
|
||||
export let customHeight
|
||||
export let customHeight: string | undefined = undefined
|
||||
export let animate = true
|
||||
export let customZindex
|
||||
export let handlePostionUpdate
|
||||
export let customZindex: string | undefined = undefined
|
||||
export let handlePostionUpdate: UpdateHandler | undefined = undefined
|
||||
export let showPopover = true
|
||||
export let clickOutsideOverride = false
|
||||
export let resizable = true
|
||||
|
@ -30,7 +35,7 @@
|
|||
|
||||
const animationDuration = 260
|
||||
|
||||
let timeout
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
let blockPointerEvents = false
|
||||
|
||||
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
|
||||
|
@ -65,13 +70,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
const handleOutsideClick = e => {
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (clickOutsideOverride) {
|
||||
return
|
||||
}
|
||||
if (open) {
|
||||
// Stop propagation if the source is the anchor
|
||||
let node = e.target
|
||||
let node = e.target as Node | null
|
||||
let fromAnchor = false
|
||||
while (!fromAnchor && node && node.parentNode) {
|
||||
fromAnchor = node === anchor
|
||||
|
@ -86,7 +91,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleEscape(e) {
|
||||
const handleEscape: KeyboardEventHandler<HTMLDivElement> = e => {
|
||||
if (!clickOutsideOverride) {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -31,6 +31,11 @@
|
|||
import IntegrationQueryEditor from "@/components/integration/index.svelte"
|
||||
import { makePropSafe as safe } from "@budibase/string-templates"
|
||||
import { findAllComponents } from "@/helpers/components"
|
||||
import {
|
||||
extractFields,
|
||||
extractJSONArrayFields,
|
||||
extractRelationships,
|
||||
} from "@/helpers/bindings"
|
||||
import ClientBindingPanel from "@/components/common/bindings/ClientBindingPanel.svelte"
|
||||
import DataSourceCategory from "@/components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte"
|
||||
import { API } from "@/api"
|
||||
|
@ -81,67 +86,9 @@
|
|||
value: `{{ literal ${safe(provider._id)} }}`,
|
||||
type: "provider",
|
||||
}))
|
||||
$: links = bindings
|
||||
// Get only link bindings
|
||||
.filter(x => x.fieldSchema?.type === "link")
|
||||
// Filter out bindings provided by forms
|
||||
.filter(x => !x.component?.endsWith("/form"))
|
||||
.map(binding => {
|
||||
const { providerId, readableBinding, fieldSchema } = binding || {}
|
||||
const { name, tableId } = fieldSchema || {}
|
||||
const safeProviderId = safe(providerId)
|
||||
return {
|
||||
providerId,
|
||||
label: readableBinding,
|
||||
fieldName: name,
|
||||
tableId,
|
||||
type: "link",
|
||||
// These properties will be enriched by the client library and provide
|
||||
// details of the parent row of the relationship field, from context
|
||||
rowId: `{{ ${safeProviderId}.${safe("_id")} }}`,
|
||||
rowTableId: `{{ ${safeProviderId}.${safe("tableId")} }}`,
|
||||
}
|
||||
})
|
||||
$: fields = bindings
|
||||
.filter(
|
||||
x =>
|
||||
x.fieldSchema?.type === "attachment" ||
|
||||
(x.fieldSchema?.type === "array" && x.tableId)
|
||||
)
|
||||
.map(binding => {
|
||||
const { providerId, readableBinding, runtimeBinding } = binding
|
||||
const { name, type, tableId } = binding.fieldSchema
|
||||
return {
|
||||
providerId,
|
||||
label: readableBinding,
|
||||
fieldName: name,
|
||||
fieldType: type,
|
||||
tableId,
|
||||
type: "field",
|
||||
value: `{{ literal ${runtimeBinding} }}`,
|
||||
}
|
||||
})
|
||||
$: jsonArrays = bindings
|
||||
.filter(
|
||||
x =>
|
||||
x.fieldSchema?.type === "jsonarray" ||
|
||||
(x.fieldSchema?.type === "json" && x.fieldSchema?.subtype === "array")
|
||||
)
|
||||
.map(binding => {
|
||||
const { providerId, readableBinding, runtimeBinding, tableId } = binding
|
||||
const { name, type, prefixKeys, subtype } = binding.fieldSchema
|
||||
return {
|
||||
providerId,
|
||||
label: readableBinding,
|
||||
fieldName: name,
|
||||
fieldType: type,
|
||||
tableId,
|
||||
prefixKeys,
|
||||
type: type === "jsonarray" ? "jsonarray" : "queryarray",
|
||||
subtype,
|
||||
value: `{{ literal ${runtimeBinding} }}`,
|
||||
}
|
||||
})
|
||||
$: links = extractRelationships(bindings)
|
||||
$: fields = extractFields(bindings)
|
||||
$: jsonArrays = extractJSONArrayFields(bindings)
|
||||
$: custom = {
|
||||
type: "custom",
|
||||
label: "JSON / CSV",
|
||||
|
|
|
@ -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} }}`,
|
||||
}
|
||||
})
|
||||
}
|
|
@ -10,3 +10,4 @@ export {
|
|||
isBuilderInputFocused,
|
||||
} from "./helpers"
|
||||
export * as featureFlag from "./featureFlags"
|
||||
export * as bindings from "./bindings"
|
||||
|
|
|
@ -1437,8 +1437,6 @@ class AutomationStore extends BudiStore<AutomationState> {
|
|||
this.history = createHistoryStore({
|
||||
getDoc: this.actions.getDefinition.bind(this),
|
||||
selectDoc: this.actions.select.bind(this),
|
||||
beforeAction: () => {},
|
||||
afterAction: () => {},
|
||||
})
|
||||
|
||||
// Then wrap save and delete with history
|
||||
|
|
|
@ -49,7 +49,12 @@ export class ComponentTreeNodesStore extends BudiStore<OpenNodesState> {
|
|||
|
||||
// Will ensure all parents of a node are expanded so that it is visible in the tree
|
||||
makeNodeVisible(componentId: string) {
|
||||
const selectedScreen: Screen = get(selectedScreenStore)
|
||||
const selectedScreen: Screen | undefined = get(selectedScreenStore)
|
||||
|
||||
if (!selectedScreen) {
|
||||
console.error("Invalid node " + componentId)
|
||||
return {}
|
||||
}
|
||||
|
||||
const path = findComponentPath(selectedScreen.props, componentId)
|
||||
|
||||
|
|
|
@ -30,10 +30,28 @@ import {
|
|||
} from "@/constants/backend"
|
||||
import { BudiStore } from "../BudiStore"
|
||||
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"
|
||||
|
||||
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
|
||||
name: string
|
||||
friendlyName?: string
|
||||
|
@ -41,9 +59,11 @@ interface ComponentDefinition {
|
|||
settings?: ComponentSetting[]
|
||||
features?: Record<string, boolean>
|
||||
typeSupportPresets?: Record<string, any>
|
||||
legalDirectChildren: string[]
|
||||
illegalChildren: string[]
|
||||
}
|
||||
|
||||
interface ComponentSetting {
|
||||
export interface ComponentSetting {
|
||||
key: string
|
||||
type: string
|
||||
section?: string
|
||||
|
@ -54,20 +74,9 @@ interface 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 = {
|
||||
components: {},
|
||||
customComponents: [],
|
||||
selectedComponentId: null,
|
||||
componentToPaste: null,
|
||||
settingsCache: {},
|
||||
}
|
||||
|
||||
|
@ -254,7 +263,10 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
* @param {object} opts
|
||||
* @returns
|
||||
*/
|
||||
enrichEmptySettings(component: Component, opts: any) {
|
||||
enrichEmptySettings(
|
||||
component: Component,
|
||||
opts: { screen?: Screen; parent?: Component; useDefaultValues?: boolean }
|
||||
) {
|
||||
if (!component?._component) {
|
||||
return
|
||||
}
|
||||
|
@ -364,7 +376,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
getSchemaForDatasource(screen, 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)) {
|
||||
fieldTypes = [fieldTypes]
|
||||
}
|
||||
|
@ -439,14 +451,23 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
* @param {object} parent
|
||||
* @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)
|
||||
if (!definition) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Generate basic component structure
|
||||
let instance = {
|
||||
let instance: Component = {
|
||||
_id: Helpers.uuid(),
|
||||
_component: definition.component,
|
||||
_styles: {
|
||||
|
@ -461,7 +482,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
// Standard post processing
|
||||
this.enrichEmptySettings(instance, {
|
||||
parent,
|
||||
screen: get(selectedScreen),
|
||||
screen,
|
||||
useDefaultValues: true,
|
||||
})
|
||||
|
||||
|
@ -473,7 +494,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
}
|
||||
|
||||
// Custom post processing for creation only
|
||||
let extras: any = {}
|
||||
let extras: Partial<Component> = {}
|
||||
if (definition.hasChildren) {
|
||||
extras._children = []
|
||||
}
|
||||
|
@ -481,8 +502,8 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
// Add step name to form steps
|
||||
if (componentName.endsWith("/formstep")) {
|
||||
const parentForm = findClosestMatchingComponent(
|
||||
get(selectedScreen).props,
|
||||
get(selectedComponent)._id,
|
||||
screen.props,
|
||||
get(selectedComponent)?._id,
|
||||
(component: Component) => component._component.endsWith("/form")
|
||||
)
|
||||
const formSteps = findAllMatchingComponents(
|
||||
|
@ -510,7 +531,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
async create(
|
||||
componentName: string,
|
||||
presetProps: any,
|
||||
parent: any,
|
||||
parent: Component,
|
||||
index: number
|
||||
) {
|
||||
const state = get(this.store)
|
||||
|
@ -541,7 +562,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
// Find the selected component
|
||||
let selectedComponentId = state.selectedComponentId
|
||||
if (selectedComponentId?.startsWith(`${screen._id}-`)) {
|
||||
selectedComponentId = screen.props._id || null
|
||||
selectedComponentId = screen.props._id
|
||||
}
|
||||
const currentComponent = findComponent(
|
||||
screen.props,
|
||||
|
@ -652,7 +673,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
// Determine the next component to select, and select it before deletion
|
||||
// to avoid an intermediate state of no component selection
|
||||
const state = get(this.store)
|
||||
let nextId: string | null = ""
|
||||
let nextId = ""
|
||||
if (state.selectedComponentId === component._id) {
|
||||
nextId = this.getNext()
|
||||
if (!nextId) {
|
||||
|
@ -739,7 +760,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
if (!state.componentToPaste) {
|
||||
return
|
||||
}
|
||||
let newComponentId: string | null = ""
|
||||
let newComponentId = ""
|
||||
|
||||
// Remove copied component if cutting, regardless if pasting works
|
||||
let componentToPaste = cloneDeep(state.componentToPaste)
|
||||
|
@ -767,7 +788,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
if (!cut) {
|
||||
componentToPaste = makeComponentUnique(componentToPaste)
|
||||
}
|
||||
newComponentId = componentToPaste._id!
|
||||
newComponentId = componentToPaste._id
|
||||
|
||||
// Strip grid position metadata if pasting into a new screen, but keep
|
||||
// alignment metadata
|
||||
|
@ -841,6 +862,9 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
const state = get(this.store)
|
||||
const componentId = state.selectedComponentId
|
||||
const screen = get(selectedScreen)
|
||||
if (!screen) {
|
||||
throw "A valid screen must be selected"
|
||||
}
|
||||
const parent = findComponentParent(screen.props, componentId)
|
||||
const index = parent?._children.findIndex(
|
||||
(x: Component) => x._id === componentId
|
||||
|
@ -890,6 +914,9 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
const component = get(selectedComponent)
|
||||
const componentId = component?._id
|
||||
const screen = get(selectedScreen)
|
||||
if (!screen) {
|
||||
throw "A valid screen must be selected"
|
||||
}
|
||||
const parent = findComponentParent(screen.props, componentId)
|
||||
const index = parent?._children.findIndex(
|
||||
(x: Component) => x._id === componentId
|
||||
|
@ -904,7 +931,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
|
||||
// If we have children, select first child, and the node is not collapsed
|
||||
if (
|
||||
component._children?.length &&
|
||||
component?._children?.length &&
|
||||
(state.selectedComponentId === navComponentId ||
|
||||
componentTreeNodesStore.isNodeExpanded(component._id))
|
||||
) {
|
||||
|
@ -1156,7 +1183,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
}
|
||||
|
||||
async handleEjectBlock(componentId: string, ejectedDefinition: Component) {
|
||||
let nextSelectedComponentId: string | null = null
|
||||
let nextSelectedComponentId: string | undefined
|
||||
|
||||
await screenStore.patch((screen: Screen) => {
|
||||
const block = findComponent(screen.props, componentId)
|
||||
|
@ -1192,7 +1219,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
(x: Component) => x._id === componentId
|
||||
)
|
||||
parent._children[index] = ejectedDefinition
|
||||
nextSelectedComponentId = ejectedDefinition._id ?? null
|
||||
nextSelectedComponentId = ejectedDefinition._id
|
||||
}, null)
|
||||
|
||||
// Select new root component
|
||||
|
@ -1328,12 +1355,15 @@ export const componentStore = new ComponentStore()
|
|||
|
||||
export const selectedComponent = derived(
|
||||
[componentStore, selectedScreen],
|
||||
([$store, $selectedScreen]) => {
|
||||
([$store, $selectedScreen]): Component | null => {
|
||||
if (
|
||||
$selectedScreen &&
|
||||
$store.selectedComponentId?.startsWith(`${$selectedScreen._id}-`)
|
||||
) {
|
||||
return $selectedScreen?.props
|
||||
return {
|
||||
...$selectedScreen.props,
|
||||
_id: $selectedScreen.props._id!,
|
||||
}
|
||||
}
|
||||
if (!$selectedScreen || !$store.selectedComponentId) {
|
||||
return null
|
||||
|
|
|
@ -16,8 +16,8 @@ export const initialState = {
|
|||
export const createHistoryStore = ({
|
||||
getDoc,
|
||||
selectDoc,
|
||||
beforeAction,
|
||||
afterAction,
|
||||
beforeAction = () => {},
|
||||
afterAction = () => {},
|
||||
}) => {
|
||||
// Use a derived store to check if we are able to undo or redo any operations
|
||||
const store = writable(initialState)
|
||||
|
|
|
@ -3,7 +3,7 @@ import { appStore } from "./app.js"
|
|||
import { componentStore, selectedComponent } from "./components"
|
||||
import { navigationStore } from "./navigation.js"
|
||||
import { themeStore } from "./theme.js"
|
||||
import { screenStore, selectedScreen, sortedScreens } from "./screens.js"
|
||||
import { screenStore, selectedScreen, sortedScreens } from "./screens"
|
||||
import { builderStore } from "./builder.js"
|
||||
import { hoverStore } from "./hover.js"
|
||||
import { previewStore } from "./preview.js"
|
||||
|
|
|
@ -6,12 +6,13 @@ import { findComponentsBySettingsType } from "@/helpers/screen"
|
|||
import { UIDatasourceType, Screen } from "@budibase/types"
|
||||
import { queries } from "./queries"
|
||||
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>(
|
||||
key: TKey,
|
||||
list: TItem[]
|
||||
) {
|
||||
): Record<string, any> {
|
||||
return list.reduce(
|
||||
(result, item) => ({
|
||||
...result,
|
||||
|
@ -31,6 +32,9 @@ const validationKeyByType: Record<UIDatasourceType, string | null> = {
|
|||
viewV2: "id",
|
||||
query: "_id",
|
||||
custom: null,
|
||||
link: "rowId",
|
||||
field: "value",
|
||||
jsonarray: "value",
|
||||
}
|
||||
|
||||
export const screenComponentErrors = derived(
|
||||
|
@ -52,6 +56,9 @@ export const screenComponentErrors = derived(
|
|||
["table", "dataSource"]
|
||||
)) {
|
||||
const componentSettings = component[setting.key]
|
||||
if (!componentSettings) {
|
||||
continue
|
||||
}
|
||||
const { label } = componentSettings
|
||||
const type = componentSettings.type as UIDatasourceType
|
||||
|
||||
|
@ -59,8 +66,26 @@ export const screenComponentErrors = derived(
|
|||
if (!validationKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
const componentBindings = getBindableProperties(
|
||||
$selectedScreen,
|
||||
component._id
|
||||
)
|
||||
|
||||
const componentDatasources = {
|
||||
...reduceBy(
|
||||
"rowId",
|
||||
bindings.extractRelationships(componentBindings)
|
||||
),
|
||||
...reduceBy("value", bindings.extractFields(componentBindings)),
|
||||
...reduceBy(
|
||||
"value",
|
||||
bindings.extractJSONArrayFields(componentBindings)
|
||||
),
|
||||
}
|
||||
|
||||
const resourceId = componentSettings[validationKey]
|
||||
if (!datasources[resourceId]) {
|
||||
if (!{ ...datasources, ...componentDatasources }[resourceId]) {
|
||||
const friendlyTypeName = friendlyNameByType[type] ?? type
|
||||
result[component._id!] = [
|
||||
`The ${friendlyTypeName} named "${label}" could not be found`,
|
||||
|
@ -78,6 +103,11 @@ export const screenComponentErrors = derived(
|
|||
...reduceBy("_id", $queries.list),
|
||||
}
|
||||
|
||||
if (!$selectedScreen) {
|
||||
// Skip validation if a screen is not selected.
|
||||
return {}
|
||||
}
|
||||
|
||||
return getInvalidDatasources($selectedScreen, datasources)
|
||||
}
|
||||
)
|
||||
|
|
|
@ -13,15 +13,32 @@ import {
|
|||
import { createHistoryStore } from "@/stores/builder/history"
|
||||
import { API } from "@/api"
|
||||
import { BudiStore } from "../BudiStore"
|
||||
import {
|
||||
FetchAppPackageResponse,
|
||||
DeleteScreenResponse,
|
||||
Screen,
|
||||
Component,
|
||||
SaveScreenResponse,
|
||||
} from "@budibase/types"
|
||||
import { ComponentDefinition } from "./components"
|
||||
|
||||
export const INITIAL_SCREENS_STATE = {
|
||||
screens: [],
|
||||
selectedScreenId: null,
|
||||
interface ScreenState {
|
||||
screens: Screen[]
|
||||
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() {
|
||||
super(INITIAL_SCREENS_STATE)
|
||||
super(initialScreenState)
|
||||
|
||||
// Bind scope
|
||||
this.select = this.select.bind(this)
|
||||
|
@ -38,14 +55,15 @@ export class ScreenStore extends BudiStore {
|
|||
this.removeCustomLayout = this.removeCustomLayout.bind(this)
|
||||
|
||||
this.history = createHistoryStore({
|
||||
getDoc: id => get(this.store).screens?.find(screen => screen._id === id),
|
||||
getDoc: (id: string) =>
|
||||
get(this.store).screens?.find(screen => screen._id === id),
|
||||
selectDoc: this.select,
|
||||
afterAction: () => {
|
||||
// Ensure a valid component is selected
|
||||
if (!get(selectedComponent)) {
|
||||
this.update(state => ({
|
||||
componentStore.update(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() {
|
||||
this.store.set({ ...INITIAL_SCREENS_STATE })
|
||||
this.store.set({ ...initialScreenState })
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace ALL store screens with application package screens
|
||||
* @param {object} pkg
|
||||
* @param {FetchAppPackageResponse} pkg
|
||||
*/
|
||||
syncAppScreens(pkg) {
|
||||
syncAppScreens(pkg: FetchAppPackageResponse) {
|
||||
this.update(state => ({
|
||||
...state,
|
||||
screens: [...pkg.screens],
|
||||
|
@ -79,7 +97,7 @@ export class ScreenStore extends BudiStore {
|
|||
* @param {string} screenId
|
||||
* @returns
|
||||
*/
|
||||
select(screenId) {
|
||||
select(screenId: string) {
|
||||
// Check this screen exists
|
||||
const state = get(this.store)
|
||||
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
|
||||
* violating illegal child configurations.
|
||||
*
|
||||
* @param {object} screen
|
||||
* @param {Screen} screen
|
||||
* @throws Will throw an error containing the name of the component causing
|
||||
* the invalid screen state
|
||||
*/
|
||||
validate(screen) {
|
||||
validate(screen: Screen) {
|
||||
// Recursive function to find any illegal children in component trees
|
||||
const findIllegalChild = (
|
||||
component,
|
||||
illegalChildren = [],
|
||||
legalDirectChildren = []
|
||||
) => {
|
||||
const type = component._component
|
||||
component: Component,
|
||||
illegalChildren: string[] = [],
|
||||
legalDirectChildren: string[] = []
|
||||
): string | undefined => {
|
||||
const type: string = component._component
|
||||
|
||||
if (illegalChildren.includes(type)) {
|
||||
return type
|
||||
|
@ -137,7 +155,13 @@ export class ScreenStore extends BudiStore {
|
|||
illegalChildren = []
|
||||
}
|
||||
|
||||
const definition = componentStore.getDefinition(component._component)
|
||||
const definition: ComponentDefinition | null =
|
||||
componentStore.getDefinition(component._component)
|
||||
|
||||
if (definition == null) {
|
||||
throw `Invalid defintion ${component._component}`
|
||||
}
|
||||
|
||||
// Reset whitelist for direct children
|
||||
legalDirectChildren = []
|
||||
if (definition?.legalDirectChildren?.length) {
|
||||
|
@ -172,7 +196,7 @@ export class ScreenStore extends BudiStore {
|
|||
const illegalChild = findIllegalChild(screen.props)
|
||||
if (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
|
||||
* screen id to ensure that it is selected in the builder
|
||||
*
|
||||
* @param {object} screen
|
||||
* @returns {object}
|
||||
* @param {Screen} screen The screen being modified/created
|
||||
*/
|
||||
async saveScreen(screen) {
|
||||
async saveScreen(screen: Screen) {
|
||||
const appState = get(appStore)
|
||||
|
||||
// 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
|
||||
* @param {object} savedScreen
|
||||
* @param {Screen} savedScreen
|
||||
*/
|
||||
async syncScreenData(savedScreen) {
|
||||
async syncScreenData(savedScreen: Screen) {
|
||||
const appState = get(appStore)
|
||||
// If plugins changed we need to fetch the latest app metadata
|
||||
let usedPlugins = appState.usedPlugins
|
||||
|
@ -256,7 +279,8 @@ export class ScreenStore extends BudiStore {
|
|||
* This is slightly better than just a traditional "patch" endpoint and this
|
||||
* supports deeply mutating the current doc rather than just appending data.
|
||||
*/
|
||||
sequentialScreenPatch = Utils.sequential(async (patchFn, screenId) => {
|
||||
sequentialScreenPatch = Utils.sequential(
|
||||
async (patchFn: (screen: Screen) => any, screenId: string) => {
|
||||
const state = get(this.store)
|
||||
const screen = state.screens.find(screen => screen._id === screenId)
|
||||
if (!screen) {
|
||||
|
@ -270,14 +294,17 @@ export class ScreenStore extends BudiStore {
|
|||
return
|
||||
}
|
||||
return this.save(clone)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {function} patchFn
|
||||
* @param {Function} patchFn the patch action to be applied
|
||||
* @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
|
||||
if (!screenId) {
|
||||
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
|
||||
* been removed by another user and will be filtered from the store.
|
||||
* Used to marshal updates for the websocket
|
||||
* @param {string} screenId
|
||||
* @param {object} screen
|
||||
* @returns
|
||||
*
|
||||
* @param {string} screenId the target screen id
|
||||
* @param {Screen} screen the replacement screen
|
||||
*/
|
||||
async replace(screenId, screen) {
|
||||
async replace(screenId: string, screen: Screen) {
|
||||
if (!screenId) {
|
||||
return
|
||||
}
|
||||
|
@ -334,15 +361,22 @@ export class ScreenStore extends BudiStore {
|
|||
* Any deleted screens will then have their routes/links purged
|
||||
*
|
||||
* Wrapped by {@link delete}
|
||||
* @param {object | array} screens
|
||||
* @returns
|
||||
* @param {Screen | Screen[]} screens
|
||||
*/
|
||||
async deleteScreen(screens) {
|
||||
async deleteScreen(screens: Screen | Screen[]) {
|
||||
const screensToDelete = Array.isArray(screens) ? screens : [screens]
|
||||
// Build array of promises to speed up bulk deletions
|
||||
let promises = []
|
||||
let deleteUrls = []
|
||||
screensToDelete.forEach(screen => {
|
||||
let promises: Promise<DeleteScreenResponse>[] = []
|
||||
let deleteUrls: string[] = []
|
||||
|
||||
// In this instance _id will have been set
|
||||
// Underline the expectation that _id and _rev will be set after filtering
|
||||
screensToDelete
|
||||
.filter(
|
||||
(screen): screen is Screen & { _id: string; _rev: string } =>
|
||||
!!screen._id || !!screen._rev
|
||||
)
|
||||
.forEach(screen => {
|
||||
// Delete the screen
|
||||
promises.push(API.deleteScreen(screen._id, screen._rev))
|
||||
// Remove links to this screen
|
||||
|
@ -359,12 +393,15 @@ export class ScreenStore extends BudiStore {
|
|||
})
|
||||
|
||||
// Deselect the current screen if it was deleted
|
||||
if (deletedIds.includes(state.selectedScreenId)) {
|
||||
state.selectedScreenId = null
|
||||
componentStore.update(state => ({
|
||||
...state,
|
||||
selectedComponentId: null,
|
||||
}))
|
||||
if (
|
||||
state.selectedScreenId &&
|
||||
deletedIds.includes(state.selectedScreenId)
|
||||
) {
|
||||
delete state.selectedScreenId
|
||||
componentStore.update(state => {
|
||||
delete state.selectedComponentId
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
// Update routing
|
||||
|
@ -375,7 +412,6 @@ export class ScreenStore extends BudiStore {
|
|||
|
||||
return state
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -384,18 +420,17 @@ export class ScreenStore extends BudiStore {
|
|||
* After a successful update, this method ensures that there is only
|
||||
* ONE home screen per user Role.
|
||||
*
|
||||
* @param {object} screen
|
||||
* @param {Screen} screen
|
||||
* @param {string} name e.g "routing.homeScreen" or "showNavigation"
|
||||
* @param {any} value
|
||||
* @returns
|
||||
*/
|
||||
async updateSetting(screen, name, value) {
|
||||
async updateSetting(screen: Screen, name: string, value: any) {
|
||||
if (!screen || !name) {
|
||||
return
|
||||
}
|
||||
|
||||
// Apply setting update
|
||||
const patchFn = screen => {
|
||||
const patchFn = (screen: Screen) => {
|
||||
if (!screen) {
|
||||
return false
|
||||
}
|
||||
|
@ -422,7 +457,7 @@ export class ScreenStore extends BudiStore {
|
|||
)
|
||||
})
|
||||
if (otherHomeScreens.length && updatedScreen.routing.homeScreen) {
|
||||
const patchFn = screen => {
|
||||
const patchFn = (screen: Screen) => {
|
||||
screen.routing.homeScreen = false
|
||||
}
|
||||
for (let otherHomeScreen of otherHomeScreens) {
|
||||
|
@ -432,11 +467,11 @@ export class ScreenStore extends BudiStore {
|
|||
}
|
||||
|
||||
// Move to layouts store
|
||||
async removeCustomLayout(screen) {
|
||||
async removeCustomLayout(screen: Screen) {
|
||||
// Pull relevant settings from old layout, if required
|
||||
const layout = get(layoutStore).layouts.find(x => x._id === screen.layoutId)
|
||||
const patchFn = screen => {
|
||||
screen.layoutId = null
|
||||
const patchFn = (screen: Screen) => {
|
||||
delete screen.layoutId
|
||||
screen.showNavigation = layout?.props.navigation !== "None"
|
||||
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
|
||||
* 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
|
||||
const components = findAllMatchingComponents(screen.props, x => x)
|
||||
const components = findAllMatchingComponents(
|
||||
screen.props,
|
||||
(x: Component) => x
|
||||
)
|
||||
|
||||
// Iterate over all components and run checks
|
||||
components.forEach(component => {
|
|
@ -3,7 +3,7 @@ import { get, writable } from "svelte/store"
|
|||
import { API } from "@/api"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
import { componentStore, appStore } from "@/stores/builder"
|
||||
import { INITIAL_SCREENS_STATE, ScreenStore } from "@/stores/builder/screens"
|
||||
import { initialScreenState, ScreenStore } from "@/stores/builder/screens"
|
||||
import {
|
||||
getScreenFixture,
|
||||
getComponentFixture,
|
||||
|
@ -73,7 +73,7 @@ describe("Screens store", () => {
|
|||
vi.clearAllMocks()
|
||||
|
||||
const screenStore = new ScreenStore()
|
||||
ctx.test = {
|
||||
ctx.bb = {
|
||||
get store() {
|
||||
return get(screenStore)
|
||||
},
|
||||
|
@ -81,74 +81,76 @@ describe("Screens store", () => {
|
|||
}
|
||||
})
|
||||
|
||||
it("Create base screen store with defaults", ctx => {
|
||||
expect(ctx.test.store).toStrictEqual(INITIAL_SCREENS_STATE)
|
||||
it("Create base screen store with defaults", ({ bb }) => {
|
||||
expect(bb.store).toStrictEqual(initialScreenState)
|
||||
})
|
||||
|
||||
it("Syncs all screens from the app package", ctx => {
|
||||
expect(ctx.test.store.screens.length).toBe(0)
|
||||
it("Syncs all screens from the app package", ({ bb }) => {
|
||||
expect(bb.store.screens.length).toBe(0)
|
||||
|
||||
const screens = Array(2)
|
||||
.fill()
|
||||
.map(() => getScreenFixture().json())
|
||||
|
||||
ctx.test.screenStore.syncAppScreens({ screens })
|
||||
bb.screenStore.syncAppScreens({ screens })
|
||||
|
||||
expect(ctx.test.store.screens).toStrictEqual(screens)
|
||||
expect(bb.store.screens).toStrictEqual(screens)
|
||||
})
|
||||
|
||||
it("Reset the screen store back to the default state", ctx => {
|
||||
expect(ctx.test.store.screens.length).toBe(0)
|
||||
it("Reset the screen store back to the default state", ({ bb }) => {
|
||||
expect(bb.store.screens.length).toBe(0)
|
||||
|
||||
const screens = Array(2)
|
||||
.fill()
|
||||
.map(() => getScreenFixture().json())
|
||||
|
||||
ctx.test.screenStore.syncAppScreens({ screens })
|
||||
expect(ctx.test.store.screens).toStrictEqual(screens)
|
||||
bb.screenStore.syncAppScreens({ screens })
|
||||
expect(bb.store.screens).toStrictEqual(screens)
|
||||
|
||||
ctx.test.screenStore.update(state => ({
|
||||
bb.screenStore.update(state => ({
|
||||
...state,
|
||||
selectedScreenId: screens[0]._id,
|
||||
}))
|
||||
|
||||
ctx.test.screenStore.reset()
|
||||
bb.screenStore.reset()
|
||||
|
||||
expect(ctx.test.store).toStrictEqual(INITIAL_SCREENS_STATE)
|
||||
expect(bb.store).toStrictEqual(initialScreenState)
|
||||
})
|
||||
|
||||
it("Marks a valid screen as selected", ctx => {
|
||||
it("Marks a valid screen as selected", ({ bb }) => {
|
||||
const screens = Array(2)
|
||||
.fill()
|
||||
.map(() => getScreenFixture().json())
|
||||
|
||||
ctx.test.screenStore.syncAppScreens({ screens })
|
||||
expect(ctx.test.store.screens.length).toBe(2)
|
||||
bb.screenStore.syncAppScreens({ screens })
|
||||
expect(bb.store.screens.length).toBe(2)
|
||||
|
||||
ctx.test.screenStore.select(screens[0]._id)
|
||||
bb.screenStore.select(screens[0]._id)
|
||||
|
||||
expect(ctx.test.store.selectedScreenId).toEqual(screens[0]._id)
|
||||
expect(bb.store.selectedScreenId).toEqual(screens[0]._id)
|
||||
})
|
||||
|
||||
it("Skip selecting a screen if it is not present", ctx => {
|
||||
it("Skip selecting a screen if it is not present", ({ bb }) => {
|
||||
const screens = Array(2)
|
||||
.fill()
|
||||
.map(() => getScreenFixture().json())
|
||||
|
||||
ctx.test.screenStore.syncAppScreens({ screens })
|
||||
expect(ctx.test.store.screens.length).toBe(2)
|
||||
bb.screenStore.syncAppScreens({ screens })
|
||||
expect(bb.store.screens.length).toBe(2)
|
||||
|
||||
ctx.test.screenStore.select("screen_abc")
|
||||
bb.screenStore.select("screen_abc")
|
||||
|
||||
expect(ctx.test.store.selectedScreenId).toBeNull()
|
||||
expect(bb.store.selectedScreenId).toBeUndefined()
|
||||
})
|
||||
|
||||
it("Approve a valid empty screen config", ctx => {
|
||||
it("Approve a valid empty screen config", ({ bb }) => {
|
||||
const coreScreen = getScreenFixture()
|
||||
ctx.test.screenStore.validate(coreScreen.json())
|
||||
bb.screenStore.validate(coreScreen.json())
|
||||
})
|
||||
|
||||
it("Approve a valid screen config with one component and no illegal children", ctx => {
|
||||
it("Approve a valid screen config with one component and no illegal children", ({
|
||||
bb,
|
||||
}) => {
|
||||
const coreScreen = getScreenFixture()
|
||||
const formBlock = getComponentFixture(`${COMP_PREFIX}/formblock`)
|
||||
|
||||
|
@ -157,12 +159,12 @@ describe("Screens store", () => {
|
|||
const defSpy = vi.spyOn(componentStore, "getDefinition")
|
||||
defSpy.mockReturnValueOnce(COMPONENT_DEFINITIONS.formblock)
|
||||
|
||||
ctx.test.screenStore.validate(coreScreen.json())
|
||||
bb.screenStore.validate(coreScreen.json())
|
||||
|
||||
expect(defSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("Reject an attempt to nest invalid components", ctx => {
|
||||
it("Reject an attempt to nest invalid components", ({ bb }) => {
|
||||
const coreScreen = getScreenFixture()
|
||||
|
||||
const formOne = getComponentFixture(`${COMP_PREFIX}/form`)
|
||||
|
@ -178,14 +180,14 @@ describe("Screens store", () => {
|
|||
return defMap[comp]
|
||||
})
|
||||
|
||||
expect(() => ctx.test.screenStore.validate(coreScreen.json())).toThrowError(
|
||||
expect(() => bb.screenStore.validate(coreScreen.json())).toThrowError(
|
||||
`You can't place a ${COMPONENT_DEFINITIONS.form.name} here`
|
||||
)
|
||||
|
||||
expect(defSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("Reject an attempt to deeply nest invalid components", ctx => {
|
||||
it("Reject an attempt to deeply nest invalid components", ({ bb }) => {
|
||||
const coreScreen = getScreenFixture()
|
||||
|
||||
const formOne = getComponentFixture(`${COMP_PREFIX}/form`)
|
||||
|
@ -210,14 +212,16 @@ describe("Screens store", () => {
|
|||
return defMap[comp]
|
||||
})
|
||||
|
||||
expect(() => ctx.test.screenStore.validate(coreScreen.json())).toThrowError(
|
||||
expect(() => bb.screenStore.validate(coreScreen.json())).toThrowError(
|
||||
`You can't place a ${COMPONENT_DEFINITIONS.form.name} here`
|
||||
)
|
||||
|
||||
expect(defSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("Save a brand new screen and add it to the store. No validation", async ctx => {
|
||||
it("Save a brand new screen and add it to the store. No validation", async ({
|
||||
bb,
|
||||
}) => {
|
||||
const coreScreen = getScreenFixture()
|
||||
const formOne = getComponentFixture(`${COMP_PREFIX}/form`)
|
||||
|
||||
|
@ -225,7 +229,7 @@ describe("Screens store", () => {
|
|||
|
||||
appStore.set({ features: { componentValidation: false } })
|
||||
|
||||
expect(ctx.test.store.screens.length).toBe(0)
|
||||
expect(bb.store.screens.length).toBe(0)
|
||||
|
||||
const newDocId = getScreenDocId()
|
||||
const newDoc = { ...coreScreen.json(), _id: newDocId }
|
||||
|
@ -235,15 +239,15 @@ describe("Screens store", () => {
|
|||
vi.spyOn(API, "fetchAppRoutes").mockResolvedValue({
|
||||
routes: [],
|
||||
})
|
||||
await ctx.test.screenStore.save(coreScreen.json())
|
||||
await bb.screenStore.save(coreScreen.json())
|
||||
|
||||
expect(saveSpy).toHaveBeenCalled()
|
||||
|
||||
expect(ctx.test.store.screens.length).toBe(1)
|
||||
expect(bb.store.screens.length).toBe(1)
|
||||
|
||||
expect(ctx.test.store.screens[0]).toStrictEqual(newDoc)
|
||||
expect(bb.store.screens[0]).toStrictEqual(newDoc)
|
||||
|
||||
expect(ctx.test.store.selectedScreenId).toBe(newDocId)
|
||||
expect(bb.store.selectedScreenId).toBe(newDocId)
|
||||
|
||||
// The new screen should be selected
|
||||
expect(get(componentStore).selectedComponentId).toBe(
|
||||
|
@ -251,7 +255,7 @@ describe("Screens store", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("Sync an updated screen to the screen store on save", async ctx => {
|
||||
it("Sync an updated screen to the screen store on save", async ({ bb }) => {
|
||||
const existingScreens = Array(4)
|
||||
.fill()
|
||||
.map(() => {
|
||||
|
@ -261,7 +265,7 @@ describe("Screens store", () => {
|
|||
return screenDoc
|
||||
})
|
||||
|
||||
ctx.test.screenStore.update(state => ({
|
||||
bb.screenStore.update(state => ({
|
||||
...state,
|
||||
screens: existingScreens.map(screen => screen.json()),
|
||||
}))
|
||||
|
@ -279,16 +283,18 @@ describe("Screens store", () => {
|
|||
})
|
||||
|
||||
// Saved the existing screen having modified it.
|
||||
await ctx.test.screenStore.save(existingScreens[2].json())
|
||||
await bb.screenStore.save(existingScreens[2].json())
|
||||
|
||||
expect(routeSpy).toHaveBeenCalled()
|
||||
expect(saveSpy).toHaveBeenCalled()
|
||||
|
||||
// On save, the screen is spliced back into the store with the saved content
|
||||
expect(ctx.test.store.screens[2]).toStrictEqual(existingScreens[2].json())
|
||||
expect(bb.store.screens[2]).toStrictEqual(existingScreens[2].json())
|
||||
})
|
||||
|
||||
it("Sync API data to relevant stores on save. Updated plugins", async ctx => {
|
||||
it("Sync API data to relevant stores on save. Updated plugins", async ({
|
||||
bb,
|
||||
}) => {
|
||||
const coreScreen = getScreenFixture()
|
||||
|
||||
const newDocId = getScreenDocId()
|
||||
|
@ -318,7 +324,7 @@ describe("Screens store", () => {
|
|||
routes: [],
|
||||
})
|
||||
|
||||
await ctx.test.screenStore.syncScreenData(newDoc)
|
||||
await bb.screenStore.syncScreenData(newDoc)
|
||||
|
||||
expect(routeSpy).toHaveBeenCalled()
|
||||
expect(appPackageSpy).toHaveBeenCalled()
|
||||
|
@ -326,7 +332,9 @@ describe("Screens store", () => {
|
|||
expect(get(appStore).usedPlugins).toStrictEqual(plugins)
|
||||
})
|
||||
|
||||
it("Sync API updates to relevant stores on save. Plugins unchanged", async ctx => {
|
||||
it("Sync API updates to relevant stores on save. Plugins unchanged", async ({
|
||||
bb,
|
||||
}) => {
|
||||
const coreScreen = getScreenFixture()
|
||||
|
||||
const newDocId = getScreenDocId()
|
||||
|
@ -343,7 +351,7 @@ describe("Screens store", () => {
|
|||
routes: [],
|
||||
})
|
||||
|
||||
await ctx.test.screenStore.syncScreenData(newDoc)
|
||||
await bb.screenStore.syncScreenData(newDoc)
|
||||
|
||||
expect(routeSpy).toHaveBeenCalled()
|
||||
expect(appPackageSpy).not.toHaveBeenCalled()
|
||||
|
@ -352,46 +360,48 @@ describe("Screens store", () => {
|
|||
expect(get(appStore).usedPlugins).toStrictEqual([plugin])
|
||||
})
|
||||
|
||||
it("Proceed to patch if appropriate config are supplied", async ctx => {
|
||||
vi.spyOn(ctx.test.screenStore, "sequentialScreenPatch").mockImplementation(
|
||||
() => {
|
||||
it("Proceed to patch if appropriate config are supplied", async ({ bb }) => {
|
||||
vi.spyOn(bb.screenStore, "sequentialScreenPatch").mockImplementation(() => {
|
||||
return false
|
||||
}
|
||||
)
|
||||
})
|
||||
const noop = () => {}
|
||||
|
||||
await ctx.test.screenStore.patch(noop, "test")
|
||||
expect(ctx.test.screenStore.sequentialScreenPatch).toHaveBeenCalledWith(
|
||||
await bb.screenStore.patch(noop, "test")
|
||||
expect(bb.screenStore.sequentialScreenPatch).toHaveBeenCalledWith(
|
||||
noop,
|
||||
"test"
|
||||
)
|
||||
})
|
||||
|
||||
it("Return from the patch if all valid config are not present", async ctx => {
|
||||
vi.spyOn(ctx.test.screenStore, "sequentialScreenPatch")
|
||||
await ctx.test.screenStore.patch()
|
||||
expect(ctx.test.screenStore.sequentialScreenPatch).not.toBeCalled()
|
||||
it("Return from the patch if all valid config are not present", async ({
|
||||
bb,
|
||||
}) => {
|
||||
vi.spyOn(bb.screenStore, "sequentialScreenPatch")
|
||||
await bb.screenStore.patch()
|
||||
expect(bb.screenStore.sequentialScreenPatch).not.toBeCalled()
|
||||
})
|
||||
|
||||
it("Acquire the currently selected screen on patch, if not specified", async ctx => {
|
||||
vi.spyOn(ctx.test.screenStore, "sequentialScreenPatch")
|
||||
await ctx.test.screenStore.patch()
|
||||
it("Acquire the currently selected screen on patch, if not specified", async ({
|
||||
bb,
|
||||
}) => {
|
||||
vi.spyOn(bb.screenStore, "sequentialScreenPatch")
|
||||
await bb.screenStore.patch()
|
||||
|
||||
const noop = () => {}
|
||||
ctx.test.screenStore.update(state => ({
|
||||
bb.screenStore.update(state => ({
|
||||
...state,
|
||||
selectedScreenId: "screen_123",
|
||||
}))
|
||||
|
||||
await ctx.test.screenStore.patch(noop)
|
||||
expect(ctx.test.screenStore.sequentialScreenPatch).toHaveBeenCalledWith(
|
||||
await bb.screenStore.patch(noop)
|
||||
expect(bb.screenStore.sequentialScreenPatch).toHaveBeenCalledWith(
|
||||
noop,
|
||||
"screen_123"
|
||||
)
|
||||
})
|
||||
|
||||
// Used by the websocket
|
||||
it("Ignore a call to replace if no screenId is provided", ctx => {
|
||||
it("Ignore a call to replace if no screenId is provided", ({ bb }) => {
|
||||
const existingScreens = Array(4)
|
||||
.fill()
|
||||
.map(() => {
|
||||
|
@ -400,14 +410,16 @@ describe("Screens store", () => {
|
|||
screenDoc._json._id = existingDocId
|
||||
return screenDoc.json()
|
||||
})
|
||||
ctx.test.screenStore.syncAppScreens({ screens: existingScreens })
|
||||
bb.screenStore.syncAppScreens({ screens: existingScreens })
|
||||
|
||||
ctx.test.screenStore.replace()
|
||||
bb.screenStore.replace()
|
||||
|
||||
expect(ctx.test.store.screens).toStrictEqual(existingScreens)
|
||||
expect(bb.store.screens).toStrictEqual(existingScreens)
|
||||
})
|
||||
|
||||
it("Remove a screen from the store if a single screenId is supplied", ctx => {
|
||||
it("Remove a screen from the store if a single screenId is supplied", ({
|
||||
bb,
|
||||
}) => {
|
||||
const existingScreens = Array(4)
|
||||
.fill()
|
||||
.map(() => {
|
||||
|
@ -416,17 +428,17 @@ describe("Screens store", () => {
|
|||
screenDoc._json._id = existingDocId
|
||||
return screenDoc.json()
|
||||
})
|
||||
ctx.test.screenStore.syncAppScreens({ screens: existingScreens })
|
||||
bb.screenStore.syncAppScreens({ screens: existingScreens })
|
||||
|
||||
ctx.test.screenStore.replace(existingScreens[1]._id)
|
||||
bb.screenStore.replace(existingScreens[1]._id)
|
||||
|
||||
const filtered = existingScreens.filter(
|
||||
screen => screen._id != existingScreens[1]._id
|
||||
)
|
||||
expect(ctx.test.store.screens).toStrictEqual(filtered)
|
||||
expect(bb.store.screens).toStrictEqual(filtered)
|
||||
})
|
||||
|
||||
it("Replace an existing screen with a new version of itself", ctx => {
|
||||
it("Replace an existing screen with a new version of itself", ({ bb }) => {
|
||||
const existingScreens = Array(4)
|
||||
.fill()
|
||||
.map(() => {
|
||||
|
@ -436,7 +448,7 @@ describe("Screens store", () => {
|
|||
return screenDoc
|
||||
})
|
||||
|
||||
ctx.test.screenStore.update(state => ({
|
||||
bb.screenStore.update(state => ({
|
||||
...state,
|
||||
screens: existingScreens.map(screen => screen.json()),
|
||||
}))
|
||||
|
@ -444,15 +456,14 @@ describe("Screens store", () => {
|
|||
const formBlock = getComponentFixture(`${COMP_PREFIX}/formblock`)
|
||||
existingScreens[2].addChild(formBlock)
|
||||
|
||||
ctx.test.screenStore.replace(
|
||||
existingScreens[2]._id,
|
||||
existingScreens[2].json()
|
||||
)
|
||||
bb.screenStore.replace(existingScreens[2]._id, existingScreens[2].json())
|
||||
|
||||
expect(ctx.test.store.screens.length).toBe(4)
|
||||
expect(bb.store.screens.length).toBe(4)
|
||||
})
|
||||
|
||||
it("Add a screen when attempting to replace one not present in the store", ctx => {
|
||||
it("Add a screen when attempting to replace one not present in the store", ({
|
||||
bb,
|
||||
}) => {
|
||||
const existingScreens = Array(4)
|
||||
.fill()
|
||||
.map(() => {
|
||||
|
@ -462,7 +473,7 @@ describe("Screens store", () => {
|
|||
return screenDoc
|
||||
})
|
||||
|
||||
ctx.test.screenStore.update(state => ({
|
||||
bb.screenStore.update(state => ({
|
||||
...state,
|
||||
screens: existingScreens.map(screen => screen.json()),
|
||||
}))
|
||||
|
@ -470,13 +481,13 @@ describe("Screens store", () => {
|
|||
const newScreenDoc = getScreenFixture()
|
||||
newScreenDoc._json._id = getScreenDocId()
|
||||
|
||||
ctx.test.screenStore.replace(newScreenDoc._json._id, newScreenDoc.json())
|
||||
bb.screenStore.replace(newScreenDoc._json._id, newScreenDoc.json())
|
||||
|
||||
expect(ctx.test.store.screens.length).toBe(5)
|
||||
expect(ctx.test.store.screens[4]).toStrictEqual(newScreenDoc.json())
|
||||
expect(bb.store.screens.length).toBe(5)
|
||||
expect(bb.store.screens[4]).toStrictEqual(newScreenDoc.json())
|
||||
})
|
||||
|
||||
it("Delete a single screen and remove it from the store", async ctx => {
|
||||
it("Delete a single screen and remove it from the store", async ({ bb }) => {
|
||||
const existingScreens = Array(3)
|
||||
.fill()
|
||||
.map(() => {
|
||||
|
@ -486,14 +497,14 @@ describe("Screens store", () => {
|
|||
return screenDoc
|
||||
})
|
||||
|
||||
ctx.test.screenStore.update(state => ({
|
||||
bb.screenStore.update(state => ({
|
||||
...state,
|
||||
screens: existingScreens.map(screen => screen.json()),
|
||||
}))
|
||||
|
||||
const deleteSpy = vi.spyOn(API, "deleteScreen")
|
||||
|
||||
await ctx.test.screenStore.delete(existingScreens[2].json())
|
||||
await bb.screenStore.delete(existingScreens[2].json())
|
||||
|
||||
vi.spyOn(API, "fetchAppRoutes").mockResolvedValue({
|
||||
routes: [],
|
||||
|
@ -501,13 +512,15 @@ describe("Screens store", () => {
|
|||
|
||||
expect(deleteSpy).toBeCalled()
|
||||
|
||||
expect(ctx.test.store.screens.length).toBe(2)
|
||||
expect(bb.store.screens.length).toBe(2)
|
||||
|
||||
// Just confirm that the routes at are being initialised
|
||||
expect(get(appStore).routes).toEqual([])
|
||||
})
|
||||
|
||||
it("Upon delete, reset selected screen and component ids if the screen was selected", async ctx => {
|
||||
it("Upon delete, reset selected screen and component ids if the screen was selected", async ({
|
||||
bb,
|
||||
}) => {
|
||||
const existingScreens = Array(3)
|
||||
.fill()
|
||||
.map(() => {
|
||||
|
@ -517,7 +530,7 @@ describe("Screens store", () => {
|
|||
return screenDoc
|
||||
})
|
||||
|
||||
ctx.test.screenStore.update(state => ({
|
||||
bb.screenStore.update(state => ({
|
||||
...state,
|
||||
screens: existingScreens.map(screen => screen.json()),
|
||||
selectedScreenId: existingScreens[2]._json._id,
|
||||
|
@ -528,14 +541,16 @@ describe("Screens store", () => {
|
|||
selectedComponentId: existingScreens[2]._json._id,
|
||||
}))
|
||||
|
||||
await ctx.test.screenStore.delete(existingScreens[2].json())
|
||||
await bb.screenStore.delete(existingScreens[2].json())
|
||||
|
||||
expect(ctx.test.store.screens.length).toBe(2)
|
||||
expect(get(componentStore).selectedComponentId).toBeNull()
|
||||
expect(ctx.test.store.selectedScreenId).toBeNull()
|
||||
expect(bb.store.screens.length).toBe(2)
|
||||
expect(get(componentStore).selectedComponentId).toBeUndefined()
|
||||
expect(bb.store.selectedScreenId).toBeUndefined()
|
||||
})
|
||||
|
||||
it("Delete multiple is not supported and should leave the store unchanged", async ctx => {
|
||||
it("Delete multiple is not supported and should leave the store unchanged", async ({
|
||||
bb,
|
||||
}) => {
|
||||
const existingScreens = Array(3)
|
||||
.fill()
|
||||
.map(() => {
|
||||
|
@ -547,7 +562,7 @@ describe("Screens store", () => {
|
|||
|
||||
const storeScreens = existingScreens.map(screen => screen.json())
|
||||
|
||||
ctx.test.screenStore.update(state => ({
|
||||
bb.screenStore.update(state => ({
|
||||
...state,
|
||||
screens: existingScreens.map(screen => screen.json()),
|
||||
}))
|
||||
|
@ -556,42 +571,40 @@ describe("Screens store", () => {
|
|||
|
||||
const deleteSpy = vi.spyOn(API, "deleteScreen")
|
||||
|
||||
await ctx.test.screenStore.delete(targets)
|
||||
await bb.screenStore.delete(targets)
|
||||
|
||||
expect(deleteSpy).not.toHaveBeenCalled()
|
||||
expect(ctx.test.store.screens.length).toBe(3)
|
||||
expect(ctx.test.store.screens).toStrictEqual(storeScreens)
|
||||
expect(bb.store.screens.length).toBe(3)
|
||||
expect(bb.store.screens).toStrictEqual(storeScreens)
|
||||
})
|
||||
|
||||
it("Update a screen setting", async ctx => {
|
||||
it("Update a screen setting", async ({ bb }) => {
|
||||
const screenDoc = getScreenFixture()
|
||||
const existingDocId = getScreenDocId()
|
||||
screenDoc._json._id = existingDocId
|
||||
|
||||
await ctx.test.screenStore.update(state => ({
|
||||
await bb.screenStore.update(state => ({
|
||||
...state,
|
||||
screens: [screenDoc.json()],
|
||||
}))
|
||||
|
||||
const patchedDoc = screenDoc.json()
|
||||
const patchSpy = vi
|
||||
.spyOn(ctx.test.screenStore, "patch")
|
||||
.spyOn(bb.screenStore, "patch")
|
||||
.mockImplementation(async patchFn => {
|
||||
patchFn(patchedDoc)
|
||||
return
|
||||
})
|
||||
|
||||
await ctx.test.screenStore.updateSetting(
|
||||
patchedDoc,
|
||||
"showNavigation",
|
||||
false
|
||||
)
|
||||
await bb.screenStore.updateSetting(patchedDoc, "showNavigation", false)
|
||||
|
||||
expect(patchSpy).toBeCalled()
|
||||
expect(patchedDoc.showNavigation).toBe(false)
|
||||
})
|
||||
|
||||
it("Ensure only one homescreen per role after updating setting. All screens same role", async ctx => {
|
||||
it("Ensure only one homescreen per role after updating setting. All screens same role", async ({
|
||||
bb,
|
||||
}) => {
|
||||
const existingScreens = Array(3)
|
||||
.fill()
|
||||
.map(() => {
|
||||
|
@ -611,23 +624,21 @@ describe("Screens store", () => {
|
|||
// Set the 2nd screen as the home screen
|
||||
storeScreens[1].routing.homeScreen = true
|
||||
|
||||
await ctx.test.screenStore.update(state => ({
|
||||
await bb.screenStore.update(state => ({
|
||||
...state,
|
||||
screens: storeScreens,
|
||||
}))
|
||||
|
||||
const patchSpy = vi
|
||||
.spyOn(ctx.test.screenStore, "patch")
|
||||
.spyOn(bb.screenStore, "patch")
|
||||
.mockImplementation(async (patchFn, screenId) => {
|
||||
const target = ctx.test.store.screens.find(
|
||||
screen => screen._id === screenId
|
||||
)
|
||||
const target = bb.store.screens.find(screen => screen._id === screenId)
|
||||
patchFn(target)
|
||||
|
||||
await ctx.test.screenStore.replace(screenId, target)
|
||||
await bb.screenStore.replace(screenId, target)
|
||||
})
|
||||
|
||||
await ctx.test.screenStore.updateSetting(
|
||||
await bb.screenStore.updateSetting(
|
||||
storeScreens[0],
|
||||
"routing.homeScreen",
|
||||
true
|
||||
|
@ -637,13 +648,15 @@ describe("Screens store", () => {
|
|||
expect(patchSpy).toBeCalledTimes(2)
|
||||
|
||||
// The new homescreen for BASIC
|
||||
expect(ctx.test.store.screens[0].routing.homeScreen).toBe(true)
|
||||
expect(bb.store.screens[0].routing.homeScreen).toBe(true)
|
||||
|
||||
// The previous home screen for the BASIC role is now unset
|
||||
expect(ctx.test.store.screens[1].routing.homeScreen).toBe(false)
|
||||
expect(bb.store.screens[1].routing.homeScreen).toBe(false)
|
||||
})
|
||||
|
||||
it("Ensure only one homescreen per role when updating screen setting. Multiple screen roles", async ctx => {
|
||||
it("Ensure only one homescreen per role when updating screen setting. Multiple screen roles", async ({
|
||||
bb,
|
||||
}) => {
|
||||
const expectedRoles = [
|
||||
Constants.Roles.BASIC,
|
||||
Constants.Roles.POWER,
|
||||
|
@ -675,30 +688,24 @@ describe("Screens store", () => {
|
|||
sorted[9].routing.homeScreen = true
|
||||
|
||||
// Set screens state
|
||||
await ctx.test.screenStore.update(state => ({
|
||||
await bb.screenStore.update(state => ({
|
||||
...state,
|
||||
screens: sorted,
|
||||
}))
|
||||
|
||||
const patchSpy = vi
|
||||
.spyOn(ctx.test.screenStore, "patch")
|
||||
.spyOn(bb.screenStore, "patch")
|
||||
.mockImplementation(async (patchFn, screenId) => {
|
||||
const target = ctx.test.store.screens.find(
|
||||
screen => screen._id === screenId
|
||||
)
|
||||
const target = bb.store.screens.find(screen => screen._id === screenId)
|
||||
patchFn(target)
|
||||
|
||||
await ctx.test.screenStore.replace(screenId, target)
|
||||
await bb.screenStore.replace(screenId, target)
|
||||
})
|
||||
|
||||
// ADMIN homeScreen updated from 0 to 2
|
||||
await ctx.test.screenStore.updateSetting(
|
||||
sorted[2],
|
||||
"routing.homeScreen",
|
||||
true
|
||||
)
|
||||
await bb.screenStore.updateSetting(sorted[2], "routing.homeScreen", true)
|
||||
|
||||
const results = ctx.test.store.screens.reduce((acc, screen) => {
|
||||
const results = bb.store.screens.reduce((acc, screen) => {
|
||||
if (screen.routing.homeScreen) {
|
||||
acc[screen.routing.roleId] = acc[screen.routing.roleId] || []
|
||||
acc[screen.routing.roleId].push(screen)
|
||||
|
@ -706,7 +713,7 @@ describe("Screens store", () => {
|
|||
return acc
|
||||
}, {})
|
||||
|
||||
const screens = ctx.test.store.screens
|
||||
const screens = bb.store.screens
|
||||
// Should still only be one of each homescreen
|
||||
expect(results[Constants.Roles.ADMIN].length).toBe(1)
|
||||
expect(screens[2].routing.homeScreen).toBe(true)
|
||||
|
@ -724,74 +731,80 @@ describe("Screens store", () => {
|
|||
expect(patchSpy).toBeCalledTimes(2)
|
||||
})
|
||||
|
||||
it("Sequential patch check. Exit if the screenId is not valid.", async ctx => {
|
||||
it("Sequential patch check. Exit if the screenId is not valid.", async ({
|
||||
bb,
|
||||
}) => {
|
||||
const screenDoc = getScreenFixture()
|
||||
const existingDocId = getScreenDocId()
|
||||
screenDoc._json._id = existingDocId
|
||||
|
||||
const original = screenDoc.json()
|
||||
|
||||
await ctx.test.screenStore.update(state => ({
|
||||
await bb.screenStore.update(state => ({
|
||||
...state,
|
||||
screens: [original],
|
||||
}))
|
||||
|
||||
const saveSpy = vi
|
||||
.spyOn(ctx.test.screenStore, "save")
|
||||
.spyOn(bb.screenStore, "save")
|
||||
.mockImplementation(async () => {
|
||||
return
|
||||
})
|
||||
|
||||
// A screen with this Id does not exist
|
||||
await ctx.test.screenStore.sequentialScreenPatch(() => {}, "123")
|
||||
await bb.screenStore.sequentialScreenPatch(() => {}, "123")
|
||||
expect(saveSpy).not.toBeCalled()
|
||||
})
|
||||
|
||||
it("Sequential patch check. Exit if the patchFn result is false", async ctx => {
|
||||
it("Sequential patch check. Exit if the patchFn result is false", async ({
|
||||
bb,
|
||||
}) => {
|
||||
const screenDoc = getScreenFixture()
|
||||
const existingDocId = getScreenDocId()
|
||||
screenDoc._json._id = existingDocId
|
||||
|
||||
const original = screenDoc.json()
|
||||
// Set screens state
|
||||
await ctx.test.screenStore.update(state => ({
|
||||
await bb.screenStore.update(state => ({
|
||||
...state,
|
||||
screens: [original],
|
||||
}))
|
||||
|
||||
const saveSpy = vi
|
||||
.spyOn(ctx.test.screenStore, "save")
|
||||
.spyOn(bb.screenStore, "save")
|
||||
.mockImplementation(async () => {
|
||||
return
|
||||
})
|
||||
|
||||
// Returning false from the patch will abort the save
|
||||
await ctx.test.screenStore.sequentialScreenPatch(() => {
|
||||
await bb.screenStore.sequentialScreenPatch(() => {
|
||||
return false
|
||||
}, "123")
|
||||
|
||||
expect(saveSpy).not.toBeCalled()
|
||||
})
|
||||
|
||||
it("Sequential patch check. Patch applied and save requested", async ctx => {
|
||||
it("Sequential patch check. Patch applied and save requested", async ({
|
||||
bb,
|
||||
}) => {
|
||||
const screenDoc = getScreenFixture()
|
||||
const existingDocId = getScreenDocId()
|
||||
screenDoc._json._id = existingDocId
|
||||
|
||||
const original = screenDoc.json()
|
||||
|
||||
await ctx.test.screenStore.update(state => ({
|
||||
await bb.screenStore.update(state => ({
|
||||
...state,
|
||||
screens: [original],
|
||||
}))
|
||||
|
||||
const saveSpy = vi
|
||||
.spyOn(ctx.test.screenStore, "save")
|
||||
.spyOn(bb.screenStore, "save")
|
||||
.mockImplementation(async () => {
|
||||
return
|
||||
})
|
||||
|
||||
await ctx.test.screenStore.sequentialScreenPatch(screen => {
|
||||
await bb.screenStore.sequentialScreenPatch(screen => {
|
||||
screen.name = "updated"
|
||||
}, existingDocId)
|
||||
|
||||
|
|
|
@ -16,7 +16,14 @@ import { auth, appsStore } from "@/stores/portal"
|
|||
import { screenStore } from "./screens"
|
||||
import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core"
|
||||
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) => {
|
||||
const socket = createWebsocket("/socket/builder")
|
||||
|
|
|
@ -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
|
||||
* sequentially.
|
||||
* @param fn the async function to run
|
||||
* @return {Promise} a sequential version of the function
|
||||
* @return {Function} a sequential version of the function
|
||||
*/
|
||||
export const sequential = fn => {
|
||||
let queue = []
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import {
|
||||
checkBuilderEndpoint,
|
||||
getAllTableRows,
|
||||
clearAllAutomations,
|
||||
testAutomation,
|
||||
} from "./utilities/TestFunctions"
|
||||
import * as setup from "./utilities"
|
||||
|
@ -12,9 +11,9 @@ import {
|
|||
import { configs, context, events } from "@budibase/backend-core"
|
||||
import sdk from "../../../sdk"
|
||||
import {
|
||||
Automation,
|
||||
ConfigType,
|
||||
FieldType,
|
||||
isDidNotTriggerResponse,
|
||||
SettingsConfig,
|
||||
Table,
|
||||
} from "@budibase/types"
|
||||
|
@ -22,11 +21,13 @@ import { mocks } from "@budibase/backend-core/tests"
|
|||
import { removeDeprecated } from "../../../automations/utils"
|
||||
import { createAutomationBuilder } from "../../../automations/tests/utilities/AutomationTestBuilder"
|
||||
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 MAX_RETRIES = 4
|
||||
let {
|
||||
const {
|
||||
basicAutomation,
|
||||
newAutomation,
|
||||
automationTrigger,
|
||||
|
@ -37,10 +38,11 @@ let {
|
|||
} = setup.structures
|
||||
|
||||
describe("/automations", () => {
|
||||
let request = setup.getRequest()
|
||||
let config = setup.getConfig()
|
||||
const config = new TestConfiguration()
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
afterAll(() => {
|
||||
config.end()
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
|
@ -52,40 +54,26 @@ describe("/automations", () => {
|
|||
|
||||
describe("get definitions", () => {
|
||||
it("returns a list of definitions for actions", async () => {
|
||||
const res = await request
|
||||
.get(`/api/automations/action/list`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(Object.keys(res.body).length).not.toEqual(0)
|
||||
const res = await config.api.automation.getActions()
|
||||
expect(Object.keys(res).length).not.toEqual(0)
|
||||
})
|
||||
|
||||
it("returns a list of definitions for triggerInfo", async () => {
|
||||
const res = await request
|
||||
.get(`/api/automations/trigger/list`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(Object.keys(res.body).length).not.toEqual(0)
|
||||
const res = await config.api.automation.getTriggers()
|
||||
expect(Object.keys(res).length).not.toEqual(0)
|
||||
})
|
||||
|
||||
it("returns all of the definitions in one", async () => {
|
||||
const res = await request
|
||||
.get(`/api/automations/definitions/list`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
const { action, trigger } = await config.api.automation.getDefinitions()
|
||||
|
||||
let definitionsLength = Object.keys(
|
||||
removeDeprecated(BUILTIN_ACTION_DEFINITIONS)
|
||||
).length
|
||||
|
||||
expect(Object.keys(res.body.action).length).toBeGreaterThanOrEqual(
|
||||
expect(Object.keys(action).length).toBeGreaterThanOrEqual(
|
||||
definitionsLength
|
||||
)
|
||||
expect(Object.keys(res.body.trigger).length).toEqual(
|
||||
expect(Object.keys(trigger).length).toEqual(
|
||||
Object.keys(removeDeprecated(TRIGGER_DEFINITIONS)).length
|
||||
)
|
||||
})
|
||||
|
@ -93,38 +81,27 @@ describe("/automations", () => {
|
|||
|
||||
describe("create", () => {
|
||||
it("creates an automation with no steps", async () => {
|
||||
const automation = newAutomation()
|
||||
automation.definition.steps = []
|
||||
const { message, automation } = await config.api.automation.post(
|
||||
newAutomation({ steps: [] })
|
||||
)
|
||||
|
||||
const res = await request
|
||||
.post(`/api/automations`)
|
||||
.set(config.defaultHeaders())
|
||||
.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(message).toEqual("Automation created successfully")
|
||||
expect(automation.name).toEqual("My Automation")
|
||||
expect(automation._id).not.toEqual(null)
|
||||
expect(events.automation.created).toHaveBeenCalledTimes(1)
|
||||
expect(events.automation.stepCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("creates an automation with steps", async () => {
|
||||
const automation = newAutomation()
|
||||
automation.definition.steps.push(automationStep())
|
||||
jest.clearAllMocks()
|
||||
|
||||
const res = await request
|
||||
.post(`/api/automations`)
|
||||
.set(config.defaultHeaders())
|
||||
.send(automation)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
const { message, automation } = await config.api.automation.post(
|
||||
newAutomation({ steps: [automationStep(), automationStep()] })
|
||||
)
|
||||
|
||||
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(message).toEqual("Automation created successfully")
|
||||
expect(automation.name).toEqual("My Automation")
|
||||
expect(automation._id).not.toEqual(null)
|
||||
expect(events.automation.created).toHaveBeenCalledTimes(1)
|
||||
expect(events.automation.stepCreated).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
@ -241,13 +218,9 @@ describe("/automations", () => {
|
|||
describe("find", () => {
|
||||
it("should be able to find the automation", async () => {
|
||||
const automation = await config.createAutomation()
|
||||
const res = await request
|
||||
.get(`/api/automations/${automation._id}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(res.body._id).toEqual(automation._id)
|
||||
expect(res.body._rev).toEqual(automation._rev)
|
||||
const { _id, _rev } = await config.api.automation.get(automation._id!)
|
||||
expect(_id).toEqual(automation._id)
|
||||
expect(_rev).toEqual(automation._rev)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -348,106 +321,104 @@ describe("/automations", () => {
|
|||
|
||||
describe("trigger", () => {
|
||||
it("does not trigger an automation when not synchronous and in dev", async () => {
|
||||
let automation = newAutomation()
|
||||
automation = await config.createAutomation(automation)
|
||||
const res = await request
|
||||
.post(`/api/automations/${automation._id}/trigger`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
|
||||
expect(res.body.message).toEqual(
|
||||
"Only apps in production support this endpoint"
|
||||
const { automation } = await config.api.automation.post(newAutomation())
|
||||
await config.api.automation.trigger(
|
||||
automation._id!,
|
||||
{
|
||||
fields: {},
|
||||
timeout: 1000,
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
body: {
|
||||
message: "Only apps in production support this endpoint",
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("triggers a synchronous automation", async () => {
|
||||
mocks.licenses.useSyncAutomations()
|
||||
let automation = collectAutomation()
|
||||
automation = await config.createAutomation(automation)
|
||||
const res = await request
|
||||
.post(`/api/automations/${automation._id}/trigger`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.success).toEqual(true)
|
||||
expect(res.body.value).toEqual([1, 2, 3])
|
||||
const { automation } = await config.api.automation.post(
|
||||
collectAutomation()
|
||||
)
|
||||
await config.api.automation.trigger(
|
||||
automation._id!,
|
||||
{
|
||||
fields: {},
|
||||
timeout: 1000,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
body: {
|
||||
success: true,
|
||||
value: [1, 2, 3],
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw an error when attempting to trigger a disabled automation", async () => {
|
||||
mocks.licenses.useSyncAutomations()
|
||||
let automation = collectAutomation()
|
||||
automation = await config.createAutomation({
|
||||
...automation,
|
||||
disabled: true,
|
||||
})
|
||||
const { automation } = await config.api.automation.post(
|
||||
collectAutomation({ disabled: true })
|
||||
)
|
||||
|
||||
const res = await request
|
||||
.post(`/api/automations/${automation._id}/trigger`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(400)
|
||||
|
||||
expect(res.body.message).toEqual("Automation is disabled")
|
||||
await config.api.automation.trigger(
|
||||
automation._id!,
|
||||
{
|
||||
fields: {},
|
||||
timeout: 1000,
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
body: {
|
||||
message: "Automation is disabled",
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("triggers an asynchronous automation", async () => {
|
||||
let automation = newAutomation()
|
||||
automation = await config.createAutomation(automation)
|
||||
const { automation } = await config.api.automation.post(newAutomation())
|
||||
await config.publish()
|
||||
|
||||
const res = await request
|
||||
.post(`/api/automations/${automation._id}/trigger`)
|
||||
.set(config.defaultHeaders({}, true))
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.message).toEqual(
|
||||
`Automation ${automation._id} has been triggered.`
|
||||
await config.withProdApp(() =>
|
||||
config.api.automation.trigger(
|
||||
automation._id!,
|
||||
{
|
||||
fields: {},
|
||||
timeout: 1000,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
body: {
|
||||
message: `Automation ${automation._id} has been triggered.`,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const automation = await config.createAutomation(newAutomation())
|
||||
const { automation } = await config.api.automation.post(basicAutomation())
|
||||
automation.name = "Updated Name"
|
||||
jest.clearAllMocks()
|
||||
|
||||
const res = await update(automation)
|
||||
const { automation: updatedAutomation, message } =
|
||||
await config.api.automation.update(automation)
|
||||
|
||||
const automationRes = res.body.automation
|
||||
const message = res.body.message
|
||||
expect(updatedAutomation._id).toEqual(automation._id)
|
||||
expect(updatedAutomation._rev).toBeDefined()
|
||||
expect(updatedAutomation._rev).not.toEqual(automation._rev)
|
||||
|
||||
// doc attributes
|
||||
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(updatedAutomation.name).toEqual("Updated Name")
|
||||
expect(message).toEqual(
|
||||
`Automation ${automation._id} updated successfully.`
|
||||
)
|
||||
// events
|
||||
|
||||
expect(events.automation.created).not.toHaveBeenCalled()
|
||||
expect(events.automation.stepCreated).not.toHaveBeenCalled()
|
||||
expect(events.automation.stepDeleted).not.toHaveBeenCalled()
|
||||
|
@ -455,26 +426,23 @@ describe("/automations", () => {
|
|||
})
|
||||
|
||||
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"
|
||||
jest.clearAllMocks()
|
||||
|
||||
// the POST request will defer to the update
|
||||
// when an id has been supplied.
|
||||
const res = await updateWithPost(automation)
|
||||
// the POST request will defer to the update when an id has been supplied.
|
||||
const { automation: updatedAutomation, message } =
|
||||
await config.api.automation.post(automation)
|
||||
|
||||
const automationRes = res.body.automation
|
||||
const message = res.body.message
|
||||
// doc attributes
|
||||
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(updatedAutomation._id).toEqual(automation._id)
|
||||
expect(updatedAutomation._rev).toBeDefined()
|
||||
expect(updatedAutomation._rev).not.toEqual(automation._rev)
|
||||
|
||||
expect(updatedAutomation.name).toEqual("Updated Name")
|
||||
expect(message).toEqual(
|
||||
`Automation ${automation._id} updated successfully.`
|
||||
)
|
||||
// events
|
||||
|
||||
expect(events.automation.created).not.toHaveBeenCalled()
|
||||
expect(events.automation.stepCreated).not.toHaveBeenCalled()
|
||||
expect(events.automation.stepDeleted).not.toHaveBeenCalled()
|
||||
|
@ -482,16 +450,14 @@ describe("/automations", () => {
|
|||
})
|
||||
|
||||
it("updates an automation trigger", async () => {
|
||||
let automation = newAutomation()
|
||||
automation = await config.createAutomation(automation)
|
||||
const { automation } = await config.api.automation.post(newAutomation())
|
||||
automation.definition.trigger = automationTrigger(
|
||||
TRIGGER_DEFINITIONS.WEBHOOK
|
||||
)
|
||||
jest.clearAllMocks()
|
||||
|
||||
await update(automation)
|
||||
await config.api.automation.update(automation)
|
||||
|
||||
// events
|
||||
expect(events.automation.created).not.toHaveBeenCalled()
|
||||
expect(events.automation.stepCreated).not.toHaveBeenCalled()
|
||||
expect(events.automation.stepDeleted).not.toHaveBeenCalled()
|
||||
|
@ -499,16 +465,13 @@ describe("/automations", () => {
|
|||
})
|
||||
|
||||
it("adds automation steps", async () => {
|
||||
let automation = newAutomation()
|
||||
automation = await config.createAutomation(automation)
|
||||
const { automation } = await config.api.automation.post(newAutomation())
|
||||
automation.definition.steps.push(automationStep())
|
||||
automation.definition.steps.push(automationStep())
|
||||
jest.clearAllMocks()
|
||||
|
||||
// check the post request honours updates with same id
|
||||
await update(automation)
|
||||
await config.api.automation.update(automation)
|
||||
|
||||
// events
|
||||
expect(events.automation.stepCreated).toHaveBeenCalledTimes(2)
|
||||
expect(events.automation.created).not.toHaveBeenCalled()
|
||||
expect(events.automation.stepDeleted).not.toHaveBeenCalled()
|
||||
|
@ -516,32 +479,25 @@ describe("/automations", () => {
|
|||
})
|
||||
|
||||
it("removes automation steps", async () => {
|
||||
let automation = newAutomation()
|
||||
automation.definition.steps.push(automationStep())
|
||||
automation = await config.createAutomation(automation)
|
||||
const { automation } = await config.api.automation.post(newAutomation())
|
||||
automation.definition.steps = []
|
||||
jest.clearAllMocks()
|
||||
|
||||
// check the post request honours updates with same id
|
||||
await update(automation)
|
||||
await config.api.automation.update(automation)
|
||||
|
||||
// events
|
||||
expect(events.automation.stepDeleted).toHaveBeenCalledTimes(2)
|
||||
expect(events.automation.stepDeleted).toHaveBeenCalledTimes(1)
|
||||
expect(events.automation.stepCreated).not.toHaveBeenCalled()
|
||||
expect(events.automation.created).not.toHaveBeenCalled()
|
||||
expect(events.automation.triggerUpdated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("adds and removes automation steps", async () => {
|
||||
let automation = newAutomation()
|
||||
automation = await config.createAutomation(automation)
|
||||
const { automation } = await config.api.automation.post(newAutomation())
|
||||
automation.definition.steps = [automationStep(), automationStep()]
|
||||
jest.clearAllMocks()
|
||||
|
||||
// check the post request honours updates with same id
|
||||
await update(automation)
|
||||
await config.api.automation.update(automation)
|
||||
|
||||
// events
|
||||
expect(events.automation.stepCreated).toHaveBeenCalledTimes(2)
|
||||
expect(events.automation.stepDeleted).toHaveBeenCalledTimes(1)
|
||||
expect(events.automation.created).not.toHaveBeenCalled()
|
||||
|
@ -551,16 +507,24 @@ describe("/automations", () => {
|
|||
|
||||
describe("fetch", () => {
|
||||
it("return all the automations for an instance", async () => {
|
||||
await clearAllAutomations(config)
|
||||
const autoConfig = await config.createAutomation(basicAutomation())
|
||||
const res = await request
|
||||
.get(`/api/automations`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
const fetchResponse = await config.api.automation.fetch()
|
||||
for (const auto of fetchResponse.automations) {
|
||||
await config.api.automation.delete(auto)
|
||||
}
|
||||
|
||||
expect(res.body.automations[0]).toEqual(
|
||||
expect.objectContaining(autoConfig)
|
||||
const { automation: automation1 } = await config.api.automation.post(
|
||||
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,28 +539,24 @@ describe("/automations", () => {
|
|||
|
||||
describe("destroy", () => {
|
||||
it("deletes a automation by its ID", async () => {
|
||||
const automation = await config.createAutomation()
|
||||
const res = await request
|
||||
.delete(`/api/automations/${automation._id}/${automation._rev}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
const { automation } = await config.api.automation.post(newAutomation())
|
||||
const { id } = await config.api.automation.delete(automation)
|
||||
|
||||
expect(res.body.id).toEqual(automation._id)
|
||||
expect(id).toEqual(automation._id)
|
||||
expect(events.automation.deleted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("cannot delete a row action automation", async () => {
|
||||
const automation = await config.createAutomation(
|
||||
const { automation } = await config.api.automation.post(
|
||||
setup.structures.rowActionAutomation()
|
||||
)
|
||||
await request
|
||||
.delete(`/api/automations/${automation._id}/${automation._rev}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(422, {
|
||||
|
||||
await config.api.automation.delete(automation, {
|
||||
status: 422,
|
||||
body: {
|
||||
message: "Row actions automations cannot be deleted",
|
||||
status: 422,
|
||||
},
|
||||
})
|
||||
|
||||
expect(events.automation.deleted).not.toHaveBeenCalled()
|
||||
|
@ -614,10 +574,19 @@ describe("/automations", () => {
|
|||
|
||||
describe("checkForCollectStep", () => {
|
||||
it("should return true if a collect step exists in an automation", async () => {
|
||||
let automation = collectAutomation()
|
||||
await config.createAutomation(automation)
|
||||
let res = await sdk.automations.utils.checkForCollectStep(automation)
|
||||
expect(res).toEqual(true)
|
||||
const { automation } = await config.api.automation.post(
|
||||
collectAutomation()
|
||||
)
|
||||
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'",
|
||||
async ({ oldCity, newCity }) => {
|
||||
const expectedResult = oldCity === newCity
|
||||
let table = await config.api.table.save(basicTable())
|
||||
|
||||
let table = await config.createTable()
|
||||
|
||||
let automation = await filterAutomation(config.getAppId())
|
||||
automation.definition.trigger.inputs.tableId = table._id
|
||||
automation.definition.steps[0].inputs = {
|
||||
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 }}",
|
||||
}
|
||||
automation = await config.createAutomation(automation)
|
||||
let triggerInputs = {
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const res = await config.api.automation.test(automation._id!, {
|
||||
fields: {},
|
||||
oldRow: {
|
||||
City: oldCity,
|
||||
},
|
||||
row: {
|
||||
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,7 +643,8 @@ describe("/automations", () => {
|
|||
let table: Table
|
||||
|
||||
beforeAll(async () => {
|
||||
table = await config.createTable({
|
||||
table = await config.api.table.save(
|
||||
basicTable(undefined, {
|
||||
name: "table",
|
||||
type: "table",
|
||||
schema: {
|
||||
|
@ -667,6 +654,7 @@ describe("/automations", () => {
|
|||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const testCases = [
|
||||
|
@ -712,33 +700,29 @@ describe("/automations", () => {
|
|||
it.each(testCases)(
|
||||
"$description",
|
||||
async ({ filters, row, oldRow, expectToRun }) => {
|
||||
let automation = await updateRowAutomationWithFilters(
|
||||
config.getAppId(),
|
||||
table._id!
|
||||
)
|
||||
automation.definition.trigger.inputs = {
|
||||
let req = updateRowAutomationWithFilters(config.getAppId(), table._id!)
|
||||
req.definition.trigger.inputs = {
|
||||
tableId: table._id,
|
||||
filters,
|
||||
}
|
||||
automation = await config.createAutomation(automation)
|
||||
|
||||
const inputs = {
|
||||
row: {
|
||||
tableId: table._id,
|
||||
...row,
|
||||
},
|
||||
const { automation } = await config.api.automation.post(req)
|
||||
const res = await config.api.automation.test(automation._id!, {
|
||||
fields: {},
|
||||
oldRow: {
|
||||
tableId: table._id,
|
||||
...oldRow,
|
||||
},
|
||||
}
|
||||
row: {
|
||||
tableId: table._id,
|
||||
...row,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await testAutomation(config, automation, inputs)
|
||||
|
||||
if (expectToRun) {
|
||||
expect(res.body.steps[1].outputs.success).toEqual(true)
|
||||
if (isDidNotTriggerResponse(res)) {
|
||||
expect(expectToRun).toEqual(false)
|
||||
} else {
|
||||
expect(res.body.outputs.success).toEqual(false)
|
||||
expect(res.steps[1].outputs.success).toEqual(expectToRun)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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 () => {
|
||||
const couchInfo = db.getCouchInfo()
|
||||
const nano = Nano({
|
||||
|
|
|
@ -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
|
||||
this.appId = typeof app === "string" ? app : app.appId
|
||||
try {
|
||||
|
@ -268,6 +268,10 @@ export default class TestConfiguration {
|
|||
}
|
||||
}
|
||||
|
||||
async withProdApp<R>(f: () => Promise<R>) {
|
||||
return await this.withApp(this.getProdAppId(), f)
|
||||
}
|
||||
|
||||
// UTILS
|
||||
|
||||
_req<Req extends Record<string, any> | void, Res>(
|
||||
|
|
|
@ -1,8 +1,17 @@
|
|||
import {
|
||||
Automation,
|
||||
CreateAutomationResponse,
|
||||
DeleteAutomationResponse,
|
||||
FetchAutomationResponse,
|
||||
GetAutomationActionDefinitionsResponse,
|
||||
GetAutomationStepDefinitionsResponse,
|
||||
GetAutomationTriggerDefinitionsResponse,
|
||||
TestAutomationRequest,
|
||||
TestAutomationResponse,
|
||||
TriggerAutomationRequest,
|
||||
TriggerAutomationResponse,
|
||||
UpdateAutomationRequest,
|
||||
UpdateAutomationResponse,
|
||||
} from "@budibase/types"
|
||||
import { Expectations, TestAPI } from "./base"
|
||||
|
||||
|
@ -20,6 +29,39 @@ export class AutomationAPI extends TestAPI {
|
|||
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 (
|
||||
expectations?: Expectations
|
||||
): Promise<FetchAutomationResponse> => {
|
||||
|
@ -31,11 +73,14 @@ export class AutomationAPI extends TestAPI {
|
|||
post = async (
|
||||
body: Automation,
|
||||
expectations?: Expectations
|
||||
): Promise<Automation> => {
|
||||
const result = await this._post<Automation>(`/api/automations`, {
|
||||
): Promise<CreateAutomationResponse> => {
|
||||
const result = await this._post<CreateAutomationResponse>(
|
||||
`/api/automations`,
|
||||
{
|
||||
body,
|
||||
expectations,
|
||||
})
|
||||
}
|
||||
)
|
||||
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,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,43 +19,43 @@ import { PluginAPI } from "./plugin"
|
|||
import { WebhookAPI } from "./webhook"
|
||||
|
||||
export default class API {
|
||||
table: TableAPI
|
||||
legacyView: LegacyViewAPI
|
||||
viewV2: ViewV2API
|
||||
row: RowAPI
|
||||
permission: PermissionAPI
|
||||
datasource: DatasourceAPI
|
||||
screen: ScreenAPI
|
||||
application: ApplicationAPI
|
||||
backup: BackupAPI
|
||||
attachment: AttachmentAPI
|
||||
user: UserAPI
|
||||
automation: AutomationAPI
|
||||
backup: BackupAPI
|
||||
datasource: DatasourceAPI
|
||||
legacyView: LegacyViewAPI
|
||||
permission: PermissionAPI
|
||||
plugin: PluginAPI
|
||||
query: QueryAPI
|
||||
roles: RoleAPI
|
||||
templates: TemplateAPI
|
||||
row: RowAPI
|
||||
rowAction: RowActionAPI
|
||||
automation: AutomationAPI
|
||||
plugin: PluginAPI
|
||||
screen: ScreenAPI
|
||||
table: TableAPI
|
||||
templates: TemplateAPI
|
||||
user: UserAPI
|
||||
viewV2: ViewV2API
|
||||
webhook: WebhookAPI
|
||||
|
||||
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.backup = new BackupAPI(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.roles = new RoleAPI(config)
|
||||
this.templates = new TemplateAPI(config)
|
||||
this.row = new RowAPI(config)
|
||||
this.rowAction = new RowActionAPI(config)
|
||||
this.automation = new AutomationAPI(config)
|
||||
this.plugin = new PluginAPI(config)
|
||||
this.screen = new ScreenAPI(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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,6 +34,7 @@ import {
|
|||
Webhook,
|
||||
WebhookActionType,
|
||||
BuiltinPermissionID,
|
||||
DeepPartial,
|
||||
} from "@budibase/types"
|
||||
import { LoopInput } from "../../definitions/automations"
|
||||
import { merge } from "lodash"
|
||||
|
@ -184,21 +185,12 @@ export function newAutomation({
|
|||
steps,
|
||||
trigger,
|
||||
}: { steps?: AutomationStep[]; trigger?: AutomationTrigger } = {}) {
|
||||
const automation = basicAutomation()
|
||||
|
||||
if (trigger) {
|
||||
automation.definition.trigger = trigger
|
||||
} else {
|
||||
automation.definition.trigger = automationTrigger()
|
||||
}
|
||||
|
||||
if (steps) {
|
||||
automation.definition.steps = steps
|
||||
} else {
|
||||
automation.definition.steps = [automationStep()]
|
||||
}
|
||||
|
||||
return automation
|
||||
return basicAutomation({
|
||||
definition: {
|
||||
steps: steps || [automationStep()],
|
||||
trigger: trigger || automationTrigger(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function rowActionAutomation() {
|
||||
|
@ -211,8 +203,8 @@ export function rowActionAutomation() {
|
|||
return automation
|
||||
}
|
||||
|
||||
export function basicAutomation(appId?: string): Automation {
|
||||
return {
|
||||
export function basicAutomation(opts?: DeepPartial<Automation>): Automation {
|
||||
const baseAutomation: Automation = {
|
||||
name: "My Automation",
|
||||
screenId: "kasdkfldsafkl",
|
||||
live: true,
|
||||
|
@ -241,8 +233,9 @@ export function basicAutomation(appId?: string): Automation {
|
|||
steps: [],
|
||||
},
|
||||
type: "automation",
|
||||
appId: appId!,
|
||||
appId: "appId",
|
||||
}
|
||||
return merge(baseAutomation, opts)
|
||||
}
|
||||
|
||||
export function basicCronAutomation(appId: string, cron: string): Automation {
|
||||
|
@ -387,16 +380,21 @@ export function loopAutomation(
|
|||
return automation as Automation
|
||||
}
|
||||
|
||||
export function collectAutomation(tableId?: string): Automation {
|
||||
const automation: any = {
|
||||
export function collectAutomation(opts?: DeepPartial<Automation>): Automation {
|
||||
const baseAutomation: Automation = {
|
||||
appId: "appId",
|
||||
name: "looping",
|
||||
type: "automation",
|
||||
definition: {
|
||||
steps: [
|
||||
{
|
||||
id: "b",
|
||||
type: "ACTION",
|
||||
name: "b",
|
||||
tagline: "An automation action step",
|
||||
icon: "Icon",
|
||||
type: AutomationStepType.ACTION,
|
||||
internal: true,
|
||||
description: "Execute script",
|
||||
stepId: AutomationActionStepId.EXECUTE_SCRIPT,
|
||||
inputs: {
|
||||
code: "return [1,2,3]",
|
||||
|
@ -405,8 +403,12 @@ export function collectAutomation(tableId?: string): Automation {
|
|||
},
|
||||
{
|
||||
id: "c",
|
||||
type: "ACTION",
|
||||
name: "c",
|
||||
type: AutomationStepType.ACTION,
|
||||
tagline: "An automation action step",
|
||||
icon: "Icon",
|
||||
internal: true,
|
||||
description: "Collect",
|
||||
stepId: AutomationActionStepId.COLLECT,
|
||||
inputs: {
|
||||
collection: "{{ literal steps.1.value }}",
|
||||
|
@ -416,24 +418,28 @@ export function collectAutomation(tableId?: string): Automation {
|
|||
],
|
||||
trigger: {
|
||||
id: "a",
|
||||
type: "TRIGGER",
|
||||
type: AutomationStepType.TRIGGER,
|
||||
event: AutomationEventType.ROW_SAVE,
|
||||
stepId: AutomationTriggerStepId.ROW_SAVED,
|
||||
name: "trigger Step",
|
||||
tagline: "An automation trigger",
|
||||
description: "A trigger",
|
||||
icon: "Icon",
|
||||
inputs: {
|
||||
tableId,
|
||||
tableId: "tableId",
|
||||
},
|
||||
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 = {
|
||||
name: "looping",
|
||||
type: "automation",
|
||||
appId,
|
||||
appId: "appId",
|
||||
definition: {
|
||||
steps: [
|
||||
{
|
||||
|
@ -459,13 +465,13 @@ export function filterAutomation(appId: string, tableId?: string): Automation {
|
|||
event: AutomationEventType.ROW_SAVE,
|
||||
stepId: AutomationTriggerStepId.ROW_SAVED,
|
||||
inputs: {
|
||||
tableId: tableId!,
|
||||
tableId: "tableId",
|
||||
},
|
||||
schema: TRIGGER_DEFINITIONS.ROW_SAVED.schema,
|
||||
},
|
||||
},
|
||||
}
|
||||
return automation
|
||||
return merge(automation, opts)
|
||||
}
|
||||
|
||||
export function updateRowAutomationWithFilters(
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@budibase/handlebars-helpers": "^0.13.2",
|
||||
"@budibase/vm-browserify": "^1.1.4",
|
||||
"dayjs": "^1.10.8",
|
||||
"handlebars": "^4.7.8",
|
||||
"lodash.clonedeep": "^4.5.0"
|
||||
|
|
|
@ -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 { registerAll, registerMinimum } from "./helpers/index"
|
||||
import { postprocess, postprocessWithLogs, preprocess } from "./processors"
|
||||
|
@ -14,10 +15,10 @@ import {
|
|||
} from "./utilities"
|
||||
import { convertHBSBlock } from "./conversion"
|
||||
import { removeJSRunner, setJSRunner } from "./helpers/javascript"
|
||||
|
||||
import manifest from "./manifest.json"
|
||||
import { Log, ProcessOptions } from "./types"
|
||||
import { UserScriptError } from "./errors"
|
||||
import { isTest } from "./environment"
|
||||
|
||||
export type { Log, LogType } from "./types"
|
||||
export { setTestingBackendJS } from "./environment"
|
||||
|
@ -507,15 +508,15 @@ export function convertToJS(hbs: string) {
|
|||
export { JsTimeoutError, UserScriptError } from "./errors"
|
||||
|
||||
export function browserJSSetup() {
|
||||
/**
|
||||
* Use polyfilled vm to run JS scripts in a browser Env
|
||||
*/
|
||||
// tests are in jest - we need to use node VM for these
|
||||
const jsSandbox = isTest() ? vm : browserVM
|
||||
// Use polyfilled vm to run JS scripts in a browser Env
|
||||
setJSRunner((js: string, context: Record<string, any>) => {
|
||||
createContext(context)
|
||||
jsSandbox.createContext(context)
|
||||
|
||||
const wrappedJs = frontendWrapJS(js)
|
||||
|
||||
const result = runInNewContext(wrappedJs, context, { timeout: 1000 })
|
||||
const result = jsSandbox.runInNewContext(wrappedJs, context)
|
||||
if (result.error) {
|
||||
throw new UserScriptError(result.error)
|
||||
}
|
||||
|
|
|
@ -125,11 +125,6 @@ describe("Javascript", () => {
|
|||
expect(processJS(`throw "Error"`)).toEqual("Error")
|
||||
})
|
||||
|
||||
it("should timeout after one second", () => {
|
||||
const output = processJS(`while (true) {}`)
|
||||
expect(output).toBe("Timed out while executing JS")
|
||||
})
|
||||
|
||||
it("should prevent access to the process global", async () => {
|
||||
expect(processJS(`return process`)).toEqual(
|
||||
"ReferenceError: process is not defined"
|
||||
|
|
|
@ -75,6 +75,7 @@ export interface TestAutomationRequest {
|
|||
revision?: string
|
||||
fields: Record<string, any>
|
||||
row?: Row
|
||||
oldRow?: Row
|
||||
}
|
||||
export type TestAutomationResponse = AutomationResults | DidNotTriggerResponse
|
||||
|
||||
|
|
|
@ -24,3 +24,18 @@ export type InsertAtPositionFn = (_: {
|
|||
value: string
|
||||
cursor?: { anchor: number }
|
||||
}) => void
|
||||
|
||||
export interface UIBinding {
|
||||
tableId?: string
|
||||
fieldSchema?: {
|
||||
name: string
|
||||
tableId: string
|
||||
type: string
|
||||
subtype?: string
|
||||
prefixKeys?: string
|
||||
}
|
||||
component?: string
|
||||
providerId: string
|
||||
readableBinding?: string
|
||||
runtimeBinding?: string
|
||||
}
|
||||
|
|
|
@ -1 +1,9 @@
|
|||
export type UIDatasourceType = "table" | "view" | "viewV2" | "query" | "custom"
|
||||
export type UIDatasourceType =
|
||||
| "table"
|
||||
| "view"
|
||||
| "viewV2"
|
||||
| "query"
|
||||
| "custom"
|
||||
| "link"
|
||||
| "field"
|
||||
| "jsonarray"
|
||||
|
|
49
yarn.lock
49
yarn.lock
|
@ -2131,9 +2131,9 @@
|
|||
through2 "^2.0.0"
|
||||
|
||||
"@budibase/pro@npm:@budibase/pro@latest":
|
||||
version "3.2.44"
|
||||
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.2.44.tgz#90367bb2167aafd8c809e000a57d349e5dc4bb78"
|
||||
integrity sha512-Zv2PBVUZUS6/psOpIRIDlW3jrOHWWPhpQXzCk00kIQJaqjkdcvuTXSedQ70u537sQmLu8JsSWbui9MdfF8ksVw==
|
||||
version "3.2.47"
|
||||
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.2.47.tgz#150d7b16b14932d03c84bdb0e6d570d490c28a5c"
|
||||
integrity sha512-UeTIq7yzMUK6w/akUsRafoD/Kif6PXv4d7K1arn8GTMjwFm9QYu2hg1YkQ+duNdwyZ/GEPlEAV5SYK+NDgtpdA==
|
||||
dependencies:
|
||||
"@anthropic-ai/sdk" "^0.27.3"
|
||||
"@budibase/backend-core" "*"
|
||||
|
@ -2152,6 +2152,13 @@
|
|||
scim-patch "^0.8.1"
|
||||
scim2-parse-filter "^0.2.8"
|
||||
|
||||
"@budibase/vm-browserify@^1.1.4":
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@budibase/vm-browserify/-/vm-browserify-1.1.4.tgz#eecb001bd9521cb7647e26fb4d2d29d0a4dce262"
|
||||
integrity sha512-/dyOLj+jQNKe6sVfLP6NdwA79OZxEWHCa41VGsjKJC9DYo6l2fEcL5BNXq2pATqrbgWmOlEbcRulfZ+7W0QRUg==
|
||||
dependencies:
|
||||
indexof "^0.0.1"
|
||||
|
||||
"@bull-board/api@5.10.2":
|
||||
version "5.10.2"
|
||||
resolved "https://registry.yarnpkg.com/@bull-board/api/-/api-5.10.2.tgz#ae8ff6918b23897bf879a6ead3683f964374c4b3"
|
||||
|
@ -11925,6 +11932,11 @@ indexes-of@^1.0.1:
|
|||
resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607"
|
||||
integrity sha512-bup+4tap3Hympa+JBJUG7XuOsdNQ6fxt0MHyXMKuLBKn0OqsTfvUxkUrroEX1+B2VsSHvCjiIcZVxRtYa4nllA==
|
||||
|
||||
indexof@^0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
|
||||
integrity sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==
|
||||
|
||||
infer-owner@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467"
|
||||
|
@ -18646,16 +18658,7 @@ string-length@^4.0.1:
|
|||
char-regex "^1.0.2"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
|
@ -18747,7 +18750,7 @@ stringify-object@^3.2.1:
|
|||
is-obj "^1.0.1"
|
||||
is-regexp "^1.0.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
|
@ -18761,13 +18764,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
|
|||
dependencies:
|
||||
ansi-regex "^4.1.0"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2"
|
||||
|
@ -20515,7 +20511,7 @@ worker-farm@1.7.0:
|
|||
dependencies:
|
||||
errno "~0.1.7"
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
|
@ -20533,15 +20529,6 @@ wrap-ansi@^5.1.0:
|
|||
string-width "^3.0.0"
|
||||
strip-ansi "^5.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
|
|
Loading…
Reference in New Issue