From e88ffea1a4262579bfdb7e1fcf6f0e179542bcd4 Mon Sep 17 00:00:00 2001 From: Gerard Burns <Ghrehh@users.noreply.github.com> Date: Tue, 18 Jun 2024 11:18:05 +0100 Subject: [PATCH 1/2] Modal component (#13848) * wip * wip * wip * wip * wip * add note for illegalChildren reset behavior * on close working * wip * lint * wip * Fix potential remounting loop caused by spreading props and unnecessary component keying * theme * user prompt * dotted border for empty * PR Feedback * lint * fix modal background color * use bbui modal * lint * fix indicator and prevent closing modal in builder * pr feedback * pr feedback * fix fullscreen --------- Co-authored-by: deanhannigan <deanhannigan@gmail.com> Co-authored-by: Andrew Kingston <andrew@kingston.dev> --- packages/bbui/src/Modal/Modal.svelte | 2 +- .../actions/CloseModal.svelte | 8 + .../actions/OpenModal.svelte | 36 +++++ .../ButtonActionEditor/actions/index.js | 2 + .../controls/ButtonActionEditor/manifest.json | 12 ++ .../new/_components/NewComponentPanel.svelte | 9 +- .../new/_components/componentStructure.json | 2 +- .../builder/src/stores/builder/screens.js | 9 +- packages/client/manifest.json | 49 +++++- .../client/src/components/ClientApp.svelte | 10 +- .../client/src/components/app/Layout.svelte | 3 + .../client/src/components/app/Link.svelte | 9 +- .../client/src/components/app/Modal.svelte | 141 ++++++++++++++++++ .../app/blocks/form/FormBlock.svelte | 63 ++++---- .../app/blocks/form/InnerFormBlock.svelte | 16 +- packages/client/src/components/app/index.js | 1 + .../components/preview/IndicatorSet.svelte | 5 +- packages/client/src/sdk.js | 2 + packages/client/src/stores/index.js | 1 + packages/client/src/stores/modal.js | 32 ++++ packages/client/src/utils/buttonActions.js | 14 ++ packages/frontend-core/src/utils/utils.js | 3 + 22 files changed, 376 insertions(+), 53 deletions(-) create mode 100644 packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/CloseModal.svelte create mode 100644 packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/OpenModal.svelte create mode 100644 packages/client/src/components/app/Modal.svelte create mode 100644 packages/client/src/stores/modal.js diff --git a/packages/bbui/src/Modal/Modal.svelte b/packages/bbui/src/Modal/Modal.svelte index 4656be69d1..dec1455d0c 100644 --- a/packages/bbui/src/Modal/Modal.svelte +++ b/packages/bbui/src/Modal/Modal.svelte @@ -162,6 +162,7 @@ max-height: 100%; } .modal-inner-wrapper { + padding: 40px; flex: 1 1 auto; display: flex; flex-direction: row; @@ -176,7 +177,6 @@ border: 2px solid var(--spectrum-global-color-gray-200); overflow: visible; max-height: none; - margin: 40px 0; transform: none; --spectrum-dialog-confirm-border-radius: var( --spectrum-global-dimension-size-100 diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/CloseModal.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/CloseModal.svelte new file mode 100644 index 0000000000..ed0ca2c72b --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/CloseModal.svelte @@ -0,0 +1,8 @@ +<div class="root">This action doesn't require any settings.</div> + +<style> + .root { + max-width: 400px; + margin: 0 auto; + } +</style> diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/OpenModal.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/OpenModal.svelte new file mode 100644 index 0000000000..8e61b8763f --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/OpenModal.svelte @@ -0,0 +1,36 @@ +<script> + import { Select, Label } from "@budibase/bbui" + import { selectedScreen } from "stores/builder" + import { findAllMatchingComponents } from "helpers/components" + + export let parameters + + $: modalOptions = getModalOptions($selectedScreen) + + const getModalOptions = screen => { + const modalComponents = findAllMatchingComponents(screen.props, component => + component._component.endsWith("/modal") + ) + return modalComponents.map(modal => ({ + label: modal._instanceName, + value: modal._id, + })) + } +</script> + +<div class="root"> + <Label small>Modal</Label> + <Select bind:value={parameters.id} options={modalOptions} /> +</div> + +<style> + .root { + display: grid; + column-gap: var(--spacing-l); + row-gap: var(--spacing-s); + grid-template-columns: 60px 1fr; + align-items: center; + max-width: 400px; + margin: 0 auto; + } +</style> diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/index.js b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/index.js index 587993377d..606ee41d02 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/index.js +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/index.js @@ -21,5 +21,7 @@ export { default as ShowNotification } from "./ShowNotification.svelte" export { default as PromptUser } from "./PromptUser.svelte" export { default as OpenSidePanel } from "./OpenSidePanel.svelte" export { default as CloseSidePanel } from "./CloseSidePanel.svelte" +export { default as OpenModal } from "./OpenModal.svelte" +export { default as CloseModal } from "./CloseModal.svelte" export { default as ClearRowSelection } from "./ClearRowSelection.svelte" export { default as DownloadFile } from "./DownloadFile.svelte" diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/manifest.json b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/manifest.json index 2840a0d662..4022926e7f 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/manifest.json +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/manifest.json @@ -157,6 +157,18 @@ "component": "CloseSidePanel", "dependsOnFeature": "sidePanel" }, + { + "name": "Open Modal", + "type": "application", + "component": "OpenModal", + "dependsOnFeature": "modal" + }, + { + "name": "Close Modal", + "type": "application", + "component": "CloseModal", + "dependsOnFeature": "modal" + }, { "name": "Clear Row Selection", "type": "data", diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/NewComponentPanel.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/NewComponentPanel.svelte index c7c58a6e16..361e07a026 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/NewComponentPanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/NewComponentPanel.svelte @@ -59,7 +59,14 @@ // Build up list of illegal children from ancestors let illegalChildren = definition.illegalChildren || [] path.forEach(ancestor => { - if (ancestor._component === `@budibase/standard-components/sidepanel`) { + // Sidepanels and modals can be nested anywhere in the component tree, but really they are always rendered at the top level. + // Because of this, it doesn't make sense to carry over any parent illegal children to them, so the array is reset here. + if ( + [ + "@budibase/standard-components/sidepanel", + "@budibase/standard-components/modal", + ].includes(ancestor._component) + ) { illegalChildren = [] } const def = componentStore.getDefinition(ancestor._component) diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json index ba6f403d81..ff58a66221 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json @@ -14,7 +14,7 @@ { "name": "Layout", "icon": "ClassicGridView", - "children": ["container", "section", "sidepanel"] + "children": ["container", "section", "sidepanel", "modal"] }, { "name": "Data", diff --git a/packages/builder/src/stores/builder/screens.js b/packages/builder/src/stores/builder/screens.js index 7339593960..b1bef10c36 100644 --- a/packages/builder/src/stores/builder/screens.js +++ b/packages/builder/src/stores/builder/screens.js @@ -125,7 +125,14 @@ export class ScreenStore extends BudiStore { return } - if (type === "@budibase/standard-components/sidepanel") { + // Sidepanels and modals can be nested anywhere in the component tree, but really they are always rendered at the top level. + // Because of this, it doesn't make sense to carry over any parent illegal children to them, so the array is reset here. + if ( + [ + "@budibase/standard-components/sidepanel", + "@budibase/standard-components/modal", + ].includes(type) + ) { illegalChildren = [] } diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 38e9bd8a87..00b503626f 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -11,6 +11,7 @@ "continueIfAction": true, "showNotificationAction": true, "sidePanel": true, + "modal": true, "skeletonLoader": true }, "typeSupportPresets": { @@ -6975,7 +6976,7 @@ "name": "Side Panel", "icon": "RailRight", "hasChildren": true, - "illegalChildren": ["section", "sidepanel"], + "illegalChildren": ["section", "sidepanel", "modal"], "showEmptyState": false, "draggable": false, "info": "Side panels are hidden by default. They will only be revealed when triggered by the 'Open Side Panel' action.", @@ -6993,6 +6994,52 @@ } ] }, + "modal": { + "name": "Modal", + "icon": "MBox", + "hasChildren": true, + "illegalChildren": ["section", "modal", "sidepanel"], + "showEmptyState": false, + "draggable": false, + "info": "Modals are hidden by default. They will only be revealed when triggered by the 'Open Modal' action.", + "settings": [ + { + "type": "boolean", + "key": "ignoreClicksOutside", + "label": "Ignore clicks outside", + "defaultValue": false + }, + { + "type": "event", + "key": "onClose", + "label": "On close" + }, + { + "type": "select", + "label": "Size", + "key": "size", + "defaultValue": "small", + "options": [ + { + "label": "Small", + "value": "small" + }, + { + "label": "Medium", + "value": "medium" + }, + { + "label": "Large", + "value": "large" + }, + { + "label": "Fullscreen", + "value": "fullscreen" + } + ] + } + ] + }, "rowexplorer": { "block": true, "name": "Row Explorer Block", diff --git a/packages/client/src/components/ClientApp.svelte b/packages/client/src/components/ClientApp.svelte index c1bdc92ac4..9bfb1192ea 100644 --- a/packages/client/src/components/ClientApp.svelte +++ b/packages/client/src/components/ClientApp.svelte @@ -20,6 +20,7 @@ devToolsEnabled, environmentStore, sidePanelStore, + modalStore, } from "stores" import NotificationDisplay from "components/overlay/NotificationDisplay.svelte" import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte" @@ -104,10 +105,15 @@ }) } const handleHashChange = () => { - const { open } = $sidePanelStore - if (open) { + const { open: sidePanelOpen } = $sidePanelStore + if (sidePanelOpen) { sidePanelStore.actions.close() } + + const { open: modalOpen } = $modalStore + if (modalOpen) { + modalStore.actions.close() + } } window.addEventListener("hashchange", handleHashChange) return () => { diff --git a/packages/client/src/components/app/Layout.svelte b/packages/client/src/components/app/Layout.svelte index 72da3e9012..af74e14aa0 100644 --- a/packages/client/src/components/app/Layout.svelte +++ b/packages/client/src/components/app/Layout.svelte @@ -12,6 +12,7 @@ linkable, builderStore, sidePanelStore, + modalStore, appStore, } = sdk const context = getContext("context") @@ -77,6 +78,7 @@ !$builderStore.inBuilder && $sidePanelStore.open && !$sidePanelStore.ignoreClicksOutside + $: screenId = $builderStore.inBuilder ? `${$builderStore.screen?._id}-screen` : "screen" @@ -198,6 +200,7 @@ const handleClickLink = () => { mobileOpen = false sidePanelStore.actions.close() + modalStore.actions.close() } </script> diff --git a/packages/client/src/components/app/Link.svelte b/packages/client/src/components/app/Link.svelte index 7eddcc6fe5..1ddc63066d 100644 --- a/packages/client/src/components/app/Link.svelte +++ b/packages/client/src/components/app/Link.svelte @@ -1,7 +1,7 @@ <script> import { getContext } from "svelte" - const { linkable, styleable, builderStore, sidePanelStore } = + const { linkable, styleable, builderStore, sidePanelStore, modalStore } = getContext("sdk") const component = getContext("component") @@ -29,6 +29,11 @@ // overrides the color when it's passed as inline style. $: styles = enrichStyles($component.styles, color) + const handleUrlChange = () => { + sidePanelStore.actions.close() + modalStore.actions.close() + } + const getSanitizedUrl = (url, externalLink, newTab) => { if (!url) { return externalLink || newTab ? "#/" : "/" @@ -109,7 +114,7 @@ class:italic class:underline class="align--{align || 'left'} size--{size || 'M'}" - on:click={sidePanelStore.actions.close} + on:click={handleUrlChange} > {componentText} </a> diff --git a/packages/client/src/components/app/Modal.svelte b/packages/client/src/components/app/Modal.svelte new file mode 100644 index 0000000000..5c60735959 --- /dev/null +++ b/packages/client/src/components/app/Modal.svelte @@ -0,0 +1,141 @@ +<script> + import { getContext } from "svelte" + import { Modal, Icon } from "@budibase/bbui" + + const component = getContext("component") + const { styleable, modalStore, builderStore, dndIsDragging } = + getContext("sdk") + + export let onClose + export let ignoreClicksOutside + export let size + let modal + + // Open modal automatically in builder + $: { + if ($builderStore.inBuilder) { + if ( + $component.inSelectedPath && + $modalStore.contentId !== $component.id + ) { + modalStore.actions.open($component.id) + } else if ( + !$component.inSelectedPath && + $modalStore.contentId === $component.id && + !$dndIsDragging + ) { + modalStore.actions.close() + } + } + } + + $: open = $modalStore.contentId === $component.id + + const handleModalClose = async () => { + if (onClose) { + await onClose() + } + modalStore.actions.close() + } + + const handleOpen = (open, modal) => { + if (!modal) return + + if (open) { + modal.show() + } else { + modal.hide() + } + } + + $: handleOpen(open, modal) +</script> + +<!-- Conditional displaying in the builder is necessary otherwise previews don't update properly upon component deletion --> +{#if !$builderStore.inBuilder || open} + <Modal + on:cancel={handleModalClose} + bind:this={modal} + disableCancel={$builderStore.inBuilder} + zIndex={2} + > + <div use:styleable={$component.styles} class={`modal-content ${size}`}> + <div class="modal-header"> + <Icon + color="var(--spectrum-global-color-gray-800)" + name="Close" + hoverable + on:click={handleModalClose} + /> + </div> + <div class="modal-main"> + <div class="modal-main-inner"> + <slot /> + </div> + </div> + </div> + </Modal> +{/if} + +<style> + .modal-content { + display: flex; + flex-direction: column; + max-width: 100%; + box-sizing: border-box; + padding: 12px 0px 40px; + } + + .small { + width: 400px; + min-height: 200px; + } + + .medium { + width: 600px; + min-height: 400px; + } + + .large { + width: 800px; + min-height: 600px; + } + + .fullscreen { + width: calc(100vw - 80px); + min-height: calc(100vh - 80px); + } + + .modal-header { + display: flex; + flex-direction: row; + justify-content: flex-end; + flex-shrink: 0; + flex-grow: 0; + padding: 0 12px 12px; + box-sizing: border-box; + } + + .modal-main { + padding: 0 40px; + flex-grow: 1; + display: flex; + flex-direction: column; + } + + .modal-main :global(.component > *) { + max-width: 100%; + } + + .modal-main-inner { + flex-grow: 1; + display: flex; + flex-direction: column; + word-break: break-word; + } + + .modal-main-inner:empty { + border-radius: 3px; + border: 2px dashed var(--spectrum-global-color-gray-400); + } +</style> diff --git a/packages/client/src/components/app/blocks/form/FormBlock.svelte b/packages/client/src/components/app/blocks/form/FormBlock.svelte index d249569731..e3aa20ffa6 100644 --- a/packages/client/src/components/app/blocks/form/FormBlock.svelte +++ b/packages/client/src/components/app/blocks/form/FormBlock.svelte @@ -31,41 +31,23 @@ let schema - $: formattedFields = convertOldFieldFormat(fields) - $: fieldsOrDefault = getDefaultFields(formattedFields, schema) $: fetchSchema(dataSource) $: id = $component.id - // We could simply spread $$props into the inner form and append our - // additions, but that would create svelte warnings about unused props and - // make maintenance in future more confusing as we typically always have a - // proper mapping of schema settings to component exports, without having to - // search multiple files - $: innerProps = { - dataSource, - actionUrl, - actionType, - size, - disabled, - fields: fieldsOrDefault, - title, - description, - schema, - notificationOverride, - buttons: - buttons || - Utils.buildFormBlockButtonConfig({ - _id: id, - showDeleteButton, - showSaveButton, - saveButtonLabel, - deleteButtonLabel, - notificationOverride, - actionType, - actionUrl, - dataSource, - }), - buttonPosition: buttons ? buttonPosition : "top", - } + $: formattedFields = convertOldFieldFormat(fields) + $: fieldsOrDefault = getDefaultFields(formattedFields, schema) + $: buttonsOrDefault = + buttons || + Utils.buildFormBlockButtonConfig({ + _id: id, + showDeleteButton, + showSaveButton, + saveButtonLabel, + deleteButtonLabel, + notificationOverride, + actionType, + actionUrl, + dataSource, + }) // Provide additional data context for live binding eval export const getAdditionalDataContext = () => { @@ -123,5 +105,18 @@ </script> <FormBlockWrapper {actionType} {dataSource} {rowId} {noRowsMessage}> - <InnerFormBlock {...innerProps} /> + <InnerFormBlock + {dataSource} + {actionUrl} + {actionType} + {size} + {disabled} + fields={fieldsOrDefault} + {title} + {description} + {schema} + {notificationOverride} + buttons={buttonsOrDefault} + buttonPosition={buttons ? buttonPosition : "top"} + /> </FormBlockWrapper> diff --git a/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte b/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte index b0733f3f4b..0227107dd2 100644 --- a/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte +++ b/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte @@ -91,15 +91,13 @@ {#if description} <BlockComponent type="text" props={{ text: description }} order={1} /> {/if} - {#key fields} - <BlockComponent type="container"> - <div class="form-block fields" class:mobile={$context.device.mobile}> - {#each fields as field, idx} - <FormBlockComponent {field} {schema} order={idx} /> - {/each} - </div> - </BlockComponent> - {/key} + <BlockComponent type="container"> + <div class="form-block fields" class:mobile={$context.device.mobile}> + {#each fields as field, idx} + <FormBlockComponent {field} {schema} order={idx} /> + {/each} + </div> + </BlockComponent> </BlockComponent> {#if buttonPosition === "bottom"} <BlockComponent diff --git a/packages/client/src/components/app/index.js b/packages/client/src/components/app/index.js index e23e19704c..6d9df6e588 100644 --- a/packages/client/src/components/app/index.js +++ b/packages/client/src/components/app/index.js @@ -37,6 +37,7 @@ export { default as markdownviewer } from "./MarkdownViewer.svelte" export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte" export { default as grid } from "./Grid.svelte" export { default as sidepanel } from "./SidePanel.svelte" +export { default as modal } from "./Modal.svelte" export { default as gridblock } from "./GridBlock.svelte" export * from "./charts" export * from "./forms" diff --git a/packages/client/src/components/preview/IndicatorSet.svelte b/packages/client/src/components/preview/IndicatorSet.svelte index 3cbd7e2464..2b941b2662 100644 --- a/packages/client/src/components/preview/IndicatorSet.svelte +++ b/packages/client/src/components/preview/IndicatorSet.svelte @@ -57,7 +57,9 @@ return } nextState.indicators[idx].visible = - nextState.indicators[idx].insideSidePanel || entries[0].isIntersecting + nextState.indicators[idx].insideModal || + nextState.indicators[idx].insideSidePanel || + entries[0].isIntersecting if (++callbackCount === observers.length) { state = nextState updating = false @@ -139,6 +141,7 @@ height: elBounds.height + 4, visible: false, insideSidePanel: !!child.closest(".side-panel"), + insideModal: !!child.closest(".modal-content"), }) }) } diff --git a/packages/client/src/sdk.js b/packages/client/src/sdk.js index 90e0f9c7dc..50d3f857d5 100644 --- a/packages/client/src/sdk.js +++ b/packages/client/src/sdk.js @@ -11,6 +11,7 @@ import { currentRole, environmentStore, sidePanelStore, + modalStore, dndIsDragging, confirmationStore, roleStore, @@ -53,6 +54,7 @@ export default { componentStore, environmentStore, sidePanelStore, + modalStore, dndIsDragging, currentRole, confirmationStore, diff --git a/packages/client/src/stores/index.js b/packages/client/src/stores/index.js index e9b1ce4434..f2b80ed732 100644 --- a/packages/client/src/stores/index.js +++ b/packages/client/src/stores/index.js @@ -27,6 +27,7 @@ export { dndIsDragging, } from "./dnd" export { sidePanelStore } from "./sidePanel" +export { modalStore } from "./modal" export { hoverStore } from "./hover" // Context stores are layered and duplicated, so it is not a singleton diff --git a/packages/client/src/stores/modal.js b/packages/client/src/stores/modal.js new file mode 100644 index 0000000000..4d1331283d --- /dev/null +++ b/packages/client/src/stores/modal.js @@ -0,0 +1,32 @@ +import { writable } from "svelte/store" + +export const createModalStore = () => { + const initialState = { + contentId: null, + } + const store = writable(initialState) + + const open = id => { + store.update(state => { + state.contentId = id + return state + }) + } + + const close = () => { + store.update(state => { + state.contentId = null + return state + }) + } + + return { + subscribe: store.subscribe, + actions: { + open, + close, + }, + } +} + +export const modalStore = createModalStore() diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js index 482b36cdb8..bd220b8e85 100644 --- a/packages/client/src/utils/buttonActions.js +++ b/packages/client/src/utils/buttonActions.js @@ -12,6 +12,7 @@ import { uploadStore, rowSelectionStore, sidePanelStore, + modalStore, } from "stores" import { API } from "api" import { ActionTypes } from "constants" @@ -436,6 +437,17 @@ const closeSidePanelHandler = () => { sidePanelStore.actions.close() } +const openModalHandler = action => { + const { id } = action.parameters + if (id) { + modalStore.actions.open(id) + } +} + +const closeModalHandler = () => { + modalStore.actions.close() +} + const downloadFileHandler = async action => { const { url, fileName } = action.parameters try { @@ -499,6 +511,8 @@ const handlerMap = { ["Prompt User"]: promptUserHandler, ["Open Side Panel"]: openSidePanelHandler, ["Close Side Panel"]: closeSidePanelHandler, + ["Open Modal"]: openModalHandler, + ["Close Modal"]: closeModalHandler, ["Download File"]: downloadFileHandler, } diff --git a/packages/frontend-core/src/utils/utils.js b/packages/frontend-core/src/utils/utils.js index 65690cd535..1bee3d6c04 100644 --- a/packages/frontend-core/src/utils/utils.js +++ b/packages/frontend-core/src/utils/utils.js @@ -161,6 +161,9 @@ export const buildFormBlockButtonConfig = props => { { "##eventHandlerType": "Close Side Panel", }, + { + "##eventHandlerType": "Close Modal", + }, // Clear a create form once submitted ...(actionType !== "Create" ? [] From 2b96cbcad79dab09de3cbc1626bd3d52094cf09b Mon Sep 17 00:00:00 2001 From: Peter Clement <PClmnt@users.noreply.github.com> Date: Tue, 18 Jun 2024 13:45:58 +0100 Subject: [PATCH 2/2] Expose old row binding in automations (#13931) * expose old row through the emitter * accidentally added oldRow to step * fix row fetch in external datasources * add test for new / old row comparison * add testing for old row update event * allow function overloading in test files * update tests per comments * handle event race condition * update test data modal to account for old row output * switch icon positioning --- .eslintrc.json | 3 +- .../SetupPanel/AutomationBlockSetup.svelte | 75 +++++++++++++++---- .../src/api/controllers/row/external.ts | 4 +- .../server/src/api/controllers/row/index.ts | 4 +- .../src/api/controllers/row/internal.ts | 6 +- .../src/api/routes/tests/automation.spec.ts | 43 ++++++++++- .../server/src/api/routes/tests/row.spec.ts | 49 ++++++++++++ .../routes/tests/utilities/TestFunctions.ts | 11 +-- .../src/automations/triggerInfo/rowUpdated.ts | 9 ++- packages/server/src/automations/triggers.ts | 10 ++- packages/server/src/events/BudibaseEmitter.ts | 10 ++- packages/server/src/events/utils.ts | 4 + .../server/src/tests/utilities/structures.ts | 30 ++++++++ .../types/src/documents/app/automation.ts | 9 +++ 14 files changed, 234 insertions(+), 33 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 9dab2f1a88..f614f1ad91 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -92,7 +92,8 @@ // differs to external, but the API is broadly the same "jest/no-conditional-expect": "off", // have to turn this off to allow function overloading in typescript - "no-dupe-class-members": "off" + "no-dupe-class-members": "off", + "no-redeclare": "off" } }, { diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index b8b7c5ae54..57ca19ddb2 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -16,6 +16,8 @@ DatePicker, DrawerContent, Toggle, + Icon, + Divider, } from "@budibase/bbui" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import { automationStore, selectedAutomation, tables } from "stores/builder" @@ -89,6 +91,8 @@ ? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])] : [] + let testDataRowVisibility = {} + const getInputData = (testData, blockInputs) => { // Test data is not cloned for reactivity let newInputData = testData || cloneDeep(blockInputs) @@ -196,7 +200,8 @@ (automation.trigger?.event === "row:update" || automation.trigger?.event === "row:save") ) { - if (name !== "id" && name !== "revision") return `trigger.row.${name}` + let noRowKeywordBindings = ["id", "revision", "oldRow"] + if (!noRowKeywordBindings.includes(name)) return `trigger.row.${name}` } /* End special cases for generating custom schemas based on triggers */ @@ -372,7 +377,11 @@ function getFieldLabel(key, value) { const requiredSuffix = requiredProperties.includes(key) ? "*" : "" - return `${value.title || (key === "row" ? "Table" : key)} ${requiredSuffix}` + return `${value.title || (key === "row" ? "Row" : key)} ${requiredSuffix}` + } + + function toggleTestDataRowVisibility(key) { + testDataRowVisibility[key] = !testDataRowVisibility[key] } function handleAttachmentParams(keyValueObj) { @@ -607,20 +616,48 @@ on:change={e => onChange(e, key)} /> {:else if value.customType === "row"} - <RowSelector - value={inputData[key]} - meta={inputData["meta"] || {}} - on:change={e => { - if (e.detail?.key) { - onChange(e, e.detail.key) - } else { - onChange(e, key) - } - }} - {bindings} - {isTestModal} - {isUpdateRow} - /> + {#if isTestModal} + <div class="align-horizontally"> + <Icon + name={testDataRowVisibility[key] ? "Remove" : "Add"} + hoverable + on:click={() => toggleTestDataRowVisibility(key)} + /> + <Label size="XL">{label}</Label> + </div> + {#if testDataRowVisibility[key]} + <RowSelector + value={inputData[key]} + meta={inputData["meta"] || {}} + on:change={e => { + if (e.detail?.key) { + onChange(e, e.detail.key) + } else { + onChange(e, key) + } + }} + {bindings} + {isTestModal} + {isUpdateRow} + /> + {/if} + <Divider /> + {:else} + <RowSelector + value={inputData[key]} + meta={inputData["meta"] || {}} + on:change={e => { + if (e.detail?.key) { + onChange(e, e.detail.key) + } else { + onChange(e, key) + } + }} + {bindings} + {isTestModal} + {isUpdateRow} + /> + {/if} {:else if value.customType === "webhookUrl"} <WebhookDisplay on:change={e => onChange(e, key)} @@ -736,6 +773,12 @@ width: 320px; } + .align-horizontally { + display: flex; + gap: var(--spacing-s); + align-items: center; + } + .fields { display: flex; flex-direction: column; diff --git a/packages/server/src/api/controllers/row/external.ts b/packages/server/src/api/controllers/row/external.ts index d301155231..5b12b5c207 100644 --- a/packages/server/src/api/controllers/row/external.ts +++ b/packages/server/src/api/controllers/row/external.ts @@ -39,9 +39,10 @@ export async function handleRequest<T extends Operation>( export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) { const tableId = utils.getTableId(ctx) - const { _id, ...rowData } = ctx.request.body + const { _id, ...rowData } = ctx.request.body const table = await sdk.tables.getTable(tableId) + const { row: dataToUpdate } = await inputProcessing( ctx.user?._id, cloneDeep(table), @@ -79,6 +80,7 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) { ...response, row: enrichedRow, table, + oldRow: beforeRow, } } diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index 945b7ca847..760b73f404 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -55,13 +55,13 @@ export async function patch( return save(ctx) } try { - const { row, table } = await pickApi(tableId).patch(ctx) + const { row, table, oldRow } = await pickApi(tableId).patch(ctx) if (!row) { ctx.throw(404, "Row not found") } ctx.status = 200 ctx.eventEmitter && - ctx.eventEmitter.emitRow(`row:update`, appId, row, table) + ctx.eventEmitter.emitRow(`row:update`, appId, row, table, oldRow) ctx.message = `${table.name} updated successfully.` ctx.body = row gridSocket?.emitRowUpdate(ctx, row) diff --git a/packages/server/src/api/controllers/row/internal.ts b/packages/server/src/api/controllers/row/internal.ts index cc903bd74a..54d9b6a536 100644 --- a/packages/server/src/api/controllers/row/internal.ts +++ b/packages/server/src/api/controllers/row/internal.ts @@ -85,13 +85,15 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) { // the row has been updated, need to put it into the ctx ctx.request.body = row as any await userController.updateMetadata(ctx as any) - return { row: ctx.body as Row, table } + return { row: ctx.body as Row, table, oldRow } } - return finaliseRow(table, row, { + const result = await finaliseRow(table, row, { oldTable: dbTable, updateFormula: true, }) + + return { ...result, oldRow } } export async function find(ctx: UserCtx): Promise<Row> { diff --git a/packages/server/src/api/routes/tests/automation.spec.ts b/packages/server/src/api/routes/tests/automation.spec.ts index 711cfb8d4f..8cbd14d8b3 100644 --- a/packages/server/src/api/routes/tests/automation.spec.ts +++ b/packages/server/src/api/routes/tests/automation.spec.ts @@ -13,6 +13,7 @@ import { events } from "@budibase/backend-core" import sdk from "../../../sdk" import { Automation } from "@budibase/types" import { mocks } from "@budibase/backend-core/tests" +import { FilterConditions } from "../../../automations/steps/filter" const MAX_RETRIES = 4 let { @@ -21,6 +22,7 @@ let { automationTrigger, automationStep, collectAutomation, + filterAutomation, } = setup.structures describe("/automations", () => { @@ -155,7 +157,12 @@ describe("/automations", () => { automation.appId = config.appId automation = await config.createAutomation(automation) await setup.delay(500) - const res = await testAutomation(config, automation) + const res = await testAutomation(config, automation, { + row: { + name: "Test", + description: "TEST", + }, + }) expect(events.automation.tested).toHaveBeenCalledTimes(1) // this looks a bit mad but we don't actually have a way to wait for a response from the automation to // know that it has finished all of its actions - this is currently the best way @@ -436,4 +443,38 @@ describe("/automations", () => { expect(res).toEqual(true) }) }) + + describe("Update Row Old / New Row comparison", () => { + it.each([ + { oldCity: "asdsadsadsad", newCity: "new" }, + { oldCity: "Belfast", newCity: "Belfast" }, + ])( + "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.createTable() + + let automation = await filterAutomation() + automation.definition.trigger.inputs.tableId = table._id + automation.definition.steps[0].inputs = { + condition: FilterConditions.EQUAL, + field: "{{ trigger.row.City }}", + value: "{{ trigger.oldRow.City }}", + } + automation.appId = config.appId! + automation = await config.createAutomation(automation) + let triggerInputs = { + oldRow: { + City: oldCity, + }, + row: { + City: newCity, + }, + } + const res = await testAutomation(config, automation, triggerInputs) + expect(res.body.steps[1].outputs.result).toEqual(expectedResult) + } + ) + }) }) diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index f822615a87..e5599c6d81 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -1,6 +1,7 @@ import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import tk from "timekeeper" +import emitter from "../../../../src/events" import { outputProcessing } from "../../../utilities/rowProcessor" import * as setup from "./utilities" import { context, InternalTable, tenancy } from "@budibase/backend-core" @@ -24,6 +25,7 @@ import { StaticQuotaName, Table, TableSourceType, + UpdatedRowEventEmitter, } from "@budibase/types" import { generator, mocks } from "@budibase/backend-core/tests" import _, { merge } from "lodash" @@ -31,6 +33,28 @@ import * as uuid from "uuid" const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString() tk.freeze(timestamp) +interface WaitOptions { + name: string + matchFn?: (event: any) => boolean +} +async function waitForEvent( + opts: WaitOptions, + callback: () => Promise<void> +): Promise<any> { + const p = new Promise((resolve: any) => { + const listener = (event: any) => { + if (opts.matchFn && !opts.matchFn(event)) { + return + } + resolve(event) + emitter.off(opts.name, listener) + } + emitter.on(opts.name, listener) + }) + + await callback() + return await p +} describe.each([ ["internal", undefined], @@ -608,6 +632,31 @@ describe.each([ await assertRowUsage(rowUsage) }) + it("should update only the fields that are supplied and emit the correct oldRow", async () => { + let beforeRow = await config.api.row.save(table._id!, { + name: "test", + description: "test", + }) + const opts = { + name: "row:update", + matchFn: (event: UpdatedRowEventEmitter) => + event.row._id === beforeRow._id, + } + const event = await waitForEvent(opts, async () => { + await config.api.row.patch(table._id!, { + _id: beforeRow._id!, + _rev: beforeRow._rev!, + tableId: table._id!, + name: "Updated Name", + }) + }) + + expect(event.oldRow).toBeDefined() + expect(event.oldRow.name).toEqual("test") + expect(event.row.name).toEqual("Updated Name") + expect(event.oldRow.description).toEqual(beforeRow.description) + expect(event.row.description).toEqual(beforeRow.description) + }) it("should throw an error when given improper types", async () => { const existing = await config.api.row.save(table._id!, {}) const rowUsage = await getRowUsage() diff --git a/packages/server/src/api/routes/tests/utilities/TestFunctions.ts b/packages/server/src/api/routes/tests/utilities/TestFunctions.ts index 8a843551ac..27d8592849 100644 --- a/packages/server/src/api/routes/tests/utilities/TestFunctions.ts +++ b/packages/server/src/api/routes/tests/utilities/TestFunctions.ts @@ -158,15 +158,16 @@ export const getDB = () => { return context.getAppDB() } -export const testAutomation = async (config: any, automation: any) => { +export const testAutomation = async ( + config: any, + automation: any, + triggerInputs: any +) => { return runRequest(automation.appId, async () => { return await config.request .post(`/api/automations/${automation._id}/test`) .send({ - row: { - name: "Test", - description: "TEST", - }, + ...triggerInputs, }) .set(config.defaultHeaders()) .expect("Content-Type", /json/) diff --git a/packages/server/src/automations/triggerInfo/rowUpdated.ts b/packages/server/src/automations/triggerInfo/rowUpdated.ts index 5e60015808..eab7c40a09 100644 --- a/packages/server/src/automations/triggerInfo/rowUpdated.ts +++ b/packages/server/src/automations/triggerInfo/rowUpdated.ts @@ -27,10 +27,17 @@ export const definition: AutomationTriggerSchema = { }, outputs: { properties: { - row: { + oldRow: { type: AutomationIOType.OBJECT, customType: AutomationCustomIOType.ROW, description: "The row that was updated", + title: "Old Row", + }, + row: { + type: AutomationIOType.OBJECT, + customType: AutomationCustomIOType.ROW, + description: "The row before it was updated", + title: "Row", }, id: { type: AutomationIOType.STRING, diff --git a/packages/server/src/automations/triggers.ts b/packages/server/src/automations/triggers.ts index 223b8d2eb6..9aa80035bd 100644 --- a/packages/server/src/automations/triggers.ts +++ b/packages/server/src/automations/triggers.ts @@ -8,7 +8,13 @@ import { checkTestFlag } from "../utilities/redis" import * as utils from "./utils" import env from "../environment" import { context, db as dbCore } from "@budibase/backend-core" -import { Automation, Row, AutomationData, AutomationJob } from "@budibase/types" +import { + Automation, + Row, + AutomationData, + AutomationJob, + UpdatedRowEventEmitter, +} from "@budibase/types" import { executeInThread } from "../threads/automation" export const TRIGGER_DEFINITIONS = definitions @@ -65,7 +71,7 @@ async function queueRelevantRowAutomations( }) } -emitter.on("row:save", async function (event) { +emitter.on("row:save", async function (event: UpdatedRowEventEmitter) { /* istanbul ignore next */ if (!event || !event.row || !event.row.tableId) { return diff --git a/packages/server/src/events/BudibaseEmitter.ts b/packages/server/src/events/BudibaseEmitter.ts index 43871d8754..8feb36bbf5 100644 --- a/packages/server/src/events/BudibaseEmitter.ts +++ b/packages/server/src/events/BudibaseEmitter.ts @@ -13,8 +13,14 @@ import { Table, Row } from "@budibase/types" * This is specifically quite important for template strings used in automations. */ class BudibaseEmitter extends EventEmitter { - emitRow(eventName: string, appId: string, row: Row, table?: Table) { - rowEmission({ emitter: this, eventName, appId, row, table }) + emitRow( + eventName: string, + appId: string, + row: Row, + table?: Table, + oldRow?: Row + ) { + rowEmission({ emitter: this, eventName, appId, row, table, oldRow }) } emitTable(eventName: string, appId: string, table?: Table) { diff --git a/packages/server/src/events/utils.ts b/packages/server/src/events/utils.ts index 20efb453f2..b972c8e473 100644 --- a/packages/server/src/events/utils.ts +++ b/packages/server/src/events/utils.ts @@ -7,6 +7,7 @@ type BBEventOpts = { appId: string table?: Table row?: Row + oldRow?: Row metadata?: any } @@ -18,6 +19,7 @@ type BBEvent = { appId: string tableId?: string row?: Row + oldRow?: Row table?: BBEventTable id?: string revision?: string @@ -31,9 +33,11 @@ export function rowEmission({ row, table, metadata, + oldRow, }: BBEventOpts) { let event: BBEvent = { row, + oldRow, appId, tableId: row?.tableId, } diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts index 7213cc66f1..a59719ab2c 100644 --- a/packages/server/src/tests/utilities/structures.ts +++ b/packages/server/src/tests/utilities/structures.ts @@ -359,6 +359,36 @@ export function collectAutomation(tableId?: string): Automation { return automation as Automation } +export function filterAutomation(tableId?: string): Automation { + const automation: any = { + name: "looping", + type: "automation", + definition: { + steps: [ + { + id: "b", + type: "ACTION", + internal: true, + stepId: AutomationActionStepId.FILTER, + inputs: {}, + schema: BUILTIN_ACTION_DEFINITIONS.EXECUTE_SCRIPT.schema, + }, + ], + trigger: { + id: "a", + type: "TRIGGER", + event: "row:save", + stepId: AutomationTriggerStepId.ROW_SAVED, + inputs: { + tableId, + }, + schema: TRIGGER_DEFINITIONS.ROW_SAVED.schema, + }, + }, + } + return automation as Automation +} + export function basicAutomationResults( automationId: string ): AutomationResults { diff --git a/packages/types/src/documents/app/automation.ts b/packages/types/src/documents/app/automation.ts index 63291fa3bb..5954a47151 100644 --- a/packages/types/src/documents/app/automation.ts +++ b/packages/types/src/documents/app/automation.ts @@ -2,6 +2,8 @@ import { Document } from "../document" import { EventEmitter } from "events" import { User } from "../global" import { ReadStream } from "fs" +import { Row } from "./row" +import { Table } from "./table" export enum AutomationIOType { OBJECT = "object", @@ -252,3 +254,10 @@ export type BucketedContent = AutomationAttachmentContent & { bucket: string path: string } + +export type UpdatedRowEventEmitter = { + row: Row + oldRow: Row + table: Table + appId: string +}