Merge branch 'master' of github.com:Budibase/budibase into dependabot/npm_and_yarn/ws-7.5.10

This commit is contained in:
mike12345567 2024-06-18 18:45:53 +01:00
commit 8172b6391e
36 changed files with 610 additions and 86 deletions

View File

@ -92,7 +92,8 @@
// differs to external, but the API is broadly the same // differs to external, but the API is broadly the same
"jest/no-conditional-expect": "off", "jest/no-conditional-expect": "off",
// have to turn this off to allow function overloading in typescript // have to turn this off to allow function overloading in typescript
"no-dupe-class-members": "off" "no-dupe-class-members": "off",
"no-redeclare": "off"
} }
}, },
{ {

View File

@ -162,6 +162,7 @@
max-height: 100%; max-height: 100%;
} }
.modal-inner-wrapper { .modal-inner-wrapper {
padding: 40px;
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -176,7 +177,6 @@
border: 2px solid var(--spectrum-global-color-gray-200); border: 2px solid var(--spectrum-global-color-gray-200);
overflow: visible; overflow: visible;
max-height: none; max-height: none;
margin: 40px 0;
transform: none; transform: none;
--spectrum-dialog-confirm-border-radius: var( --spectrum-dialog-confirm-border-radius: var(
--spectrum-global-dimension-size-100 --spectrum-global-dimension-size-100

View File

@ -16,6 +16,8 @@
DatePicker, DatePicker,
DrawerContent, DrawerContent,
Toggle, Toggle,
Icon,
Divider,
} from "@budibase/bbui" } from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation, tables } from "stores/builder" import { automationStore, selectedAutomation, tables } from "stores/builder"
@ -89,6 +91,8 @@
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])] ? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
: [] : []
let testDataRowVisibility = {}
const getInputData = (testData, blockInputs) => { const getInputData = (testData, blockInputs) => {
// Test data is not cloned for reactivity // Test data is not cloned for reactivity
let newInputData = testData || cloneDeep(blockInputs) let newInputData = testData || cloneDeep(blockInputs)
@ -196,7 +200,8 @@
(automation.trigger?.event === "row:update" || (automation.trigger?.event === "row:update" ||
automation.trigger?.event === "row:save") 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 */ /* End special cases for generating custom schemas based on triggers */
@ -372,7 +377,11 @@
function getFieldLabel(key, value) { function getFieldLabel(key, value) {
const requiredSuffix = requiredProperties.includes(key) ? "*" : "" 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) { function handleAttachmentParams(keyValueObj) {
@ -607,20 +616,48 @@
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
/> />
{:else if value.customType === "row"} {:else if value.customType === "row"}
<RowSelector {#if isTestModal}
value={inputData[key]} <div class="align-horizontally">
meta={inputData["meta"] || {}} <Icon
on:change={e => { name={testDataRowVisibility[key] ? "Remove" : "Add"}
if (e.detail?.key) { hoverable
onChange(e, e.detail.key) on:click={() => toggleTestDataRowVisibility(key)}
} else { />
onChange(e, key) <Label size="XL">{label}</Label>
} </div>
}} {#if testDataRowVisibility[key]}
{bindings} <RowSelector
{isTestModal} value={inputData[key]}
{isUpdateRow} 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"} {:else if value.customType === "webhookUrl"}
<WebhookDisplay <WebhookDisplay
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
@ -736,6 +773,12 @@
width: 320px; width: 320px;
} }
.align-horizontally {
display: flex;
gap: var(--spacing-s);
align-items: center;
}
.fields { .fields {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -0,0 +1,8 @@
<div class="root">This action doesn't require any settings.</div>
<style>
.root {
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -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>

View File

@ -21,5 +21,7 @@ export { default as ShowNotification } from "./ShowNotification.svelte"
export { default as PromptUser } from "./PromptUser.svelte" export { default as PromptUser } from "./PromptUser.svelte"
export { default as OpenSidePanel } from "./OpenSidePanel.svelte" export { default as OpenSidePanel } from "./OpenSidePanel.svelte"
export { default as CloseSidePanel } from "./CloseSidePanel.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 ClearRowSelection } from "./ClearRowSelection.svelte"
export { default as DownloadFile } from "./DownloadFile.svelte" export { default as DownloadFile } from "./DownloadFile.svelte"

View File

@ -157,6 +157,18 @@
"component": "CloseSidePanel", "component": "CloseSidePanel",
"dependsOnFeature": "sidePanel" "dependsOnFeature": "sidePanel"
}, },
{
"name": "Open Modal",
"type": "application",
"component": "OpenModal",
"dependsOnFeature": "modal"
},
{
"name": "Close Modal",
"type": "application",
"component": "CloseModal",
"dependsOnFeature": "modal"
},
{ {
"name": "Clear Row Selection", "name": "Clear Row Selection",
"type": "data", "type": "data",

View File

@ -59,7 +59,14 @@
// Build up list of illegal children from ancestors // Build up list of illegal children from ancestors
let illegalChildren = definition.illegalChildren || [] let illegalChildren = definition.illegalChildren || []
path.forEach(ancestor => { 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 = [] illegalChildren = []
} }
const def = componentStore.getDefinition(ancestor._component) const def = componentStore.getDefinition(ancestor._component)

View File

@ -14,7 +14,7 @@
{ {
"name": "Layout", "name": "Layout",
"icon": "ClassicGridView", "icon": "ClassicGridView",
"children": ["container", "section", "sidepanel"] "children": ["container", "section", "sidepanel", "modal"]
}, },
{ {
"name": "Data", "name": "Data",

View File

@ -125,7 +125,14 @@ export class ScreenStore extends BudiStore {
return 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 = [] illegalChildren = []
} }

View File

@ -11,6 +11,7 @@
"continueIfAction": true, "continueIfAction": true,
"showNotificationAction": true, "showNotificationAction": true,
"sidePanel": true, "sidePanel": true,
"modal": true,
"skeletonLoader": true "skeletonLoader": true
}, },
"typeSupportPresets": { "typeSupportPresets": {
@ -6975,7 +6976,7 @@
"name": "Side Panel", "name": "Side Panel",
"icon": "RailRight", "icon": "RailRight",
"hasChildren": true, "hasChildren": true,
"illegalChildren": ["section", "sidepanel"], "illegalChildren": ["section", "sidepanel", "modal"],
"showEmptyState": false, "showEmptyState": false,
"draggable": false, "draggable": false,
"info": "Side panels are hidden by default. They will only be revealed when triggered by the 'Open Side Panel' action.", "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": { "rowexplorer": {
"block": true, "block": true,
"name": "Row Explorer Block", "name": "Row Explorer Block",

View File

@ -20,6 +20,7 @@
devToolsEnabled, devToolsEnabled,
environmentStore, environmentStore,
sidePanelStore, sidePanelStore,
modalStore,
} from "stores" } from "stores"
import NotificationDisplay from "components/overlay/NotificationDisplay.svelte" import NotificationDisplay from "components/overlay/NotificationDisplay.svelte"
import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte" import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte"
@ -104,10 +105,15 @@
}) })
} }
const handleHashChange = () => { const handleHashChange = () => {
const { open } = $sidePanelStore const { open: sidePanelOpen } = $sidePanelStore
if (open) { if (sidePanelOpen) {
sidePanelStore.actions.close() sidePanelStore.actions.close()
} }
const { open: modalOpen } = $modalStore
if (modalOpen) {
modalStore.actions.close()
}
} }
window.addEventListener("hashchange", handleHashChange) window.addEventListener("hashchange", handleHashChange)
return () => { return () => {

View File

@ -12,6 +12,7 @@
linkable, linkable,
builderStore, builderStore,
sidePanelStore, sidePanelStore,
modalStore,
appStore, appStore,
} = sdk } = sdk
const context = getContext("context") const context = getContext("context")
@ -77,6 +78,7 @@
!$builderStore.inBuilder && !$builderStore.inBuilder &&
$sidePanelStore.open && $sidePanelStore.open &&
!$sidePanelStore.ignoreClicksOutside !$sidePanelStore.ignoreClicksOutside
$: screenId = $builderStore.inBuilder $: screenId = $builderStore.inBuilder
? `${$builderStore.screen?._id}-screen` ? `${$builderStore.screen?._id}-screen`
: "screen" : "screen"
@ -198,6 +200,7 @@
const handleClickLink = () => { const handleClickLink = () => {
mobileOpen = false mobileOpen = false
sidePanelStore.actions.close() sidePanelStore.actions.close()
modalStore.actions.close()
} }
</script> </script>

View File

@ -1,7 +1,7 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
const { linkable, styleable, builderStore, sidePanelStore } = const { linkable, styleable, builderStore, sidePanelStore, modalStore } =
getContext("sdk") getContext("sdk")
const component = getContext("component") const component = getContext("component")
@ -29,6 +29,11 @@
// overrides the color when it's passed as inline style. // overrides the color when it's passed as inline style.
$: styles = enrichStyles($component.styles, color) $: styles = enrichStyles($component.styles, color)
const handleUrlChange = () => {
sidePanelStore.actions.close()
modalStore.actions.close()
}
const getSanitizedUrl = (url, externalLink, newTab) => { const getSanitizedUrl = (url, externalLink, newTab) => {
if (!url) { if (!url) {
return externalLink || newTab ? "#/" : "/" return externalLink || newTab ? "#/" : "/"
@ -109,7 +114,7 @@
class:italic class:italic
class:underline class:underline
class="align--{align || 'left'} size--{size || 'M'}" class="align--{align || 'left'} size--{size || 'M'}"
on:click={sidePanelStore.actions.close} on:click={handleUrlChange}
> >
{componentText} {componentText}
</a> </a>

View File

@ -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>

View File

@ -31,41 +31,23 @@
let schema let schema
$: formattedFields = convertOldFieldFormat(fields)
$: fieldsOrDefault = getDefaultFields(formattedFields, schema)
$: fetchSchema(dataSource) $: fetchSchema(dataSource)
$: id = $component.id $: id = $component.id
// We could simply spread $$props into the inner form and append our $: formattedFields = convertOldFieldFormat(fields)
// additions, but that would create svelte warnings about unused props and $: fieldsOrDefault = getDefaultFields(formattedFields, schema)
// make maintenance in future more confusing as we typically always have a $: buttonsOrDefault =
// proper mapping of schema settings to component exports, without having to buttons ||
// search multiple files Utils.buildFormBlockButtonConfig({
$: innerProps = { _id: id,
dataSource, showDeleteButton,
actionUrl, showSaveButton,
actionType, saveButtonLabel,
size, deleteButtonLabel,
disabled, notificationOverride,
fields: fieldsOrDefault, actionType,
title, actionUrl,
description, dataSource,
schema, })
notificationOverride,
buttons:
buttons ||
Utils.buildFormBlockButtonConfig({
_id: id,
showDeleteButton,
showSaveButton,
saveButtonLabel,
deleteButtonLabel,
notificationOverride,
actionType,
actionUrl,
dataSource,
}),
buttonPosition: buttons ? buttonPosition : "top",
}
// Provide additional data context for live binding eval // Provide additional data context for live binding eval
export const getAdditionalDataContext = () => { export const getAdditionalDataContext = () => {
@ -123,5 +105,18 @@
</script> </script>
<FormBlockWrapper {actionType} {dataSource} {rowId} {noRowsMessage}> <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> </FormBlockWrapper>

View File

@ -91,15 +91,13 @@
{#if description} {#if description}
<BlockComponent type="text" props={{ text: description }} order={1} /> <BlockComponent type="text" props={{ text: description }} order={1} />
{/if} {/if}
{#key fields} <BlockComponent type="container">
<BlockComponent type="container"> <div class="form-block fields" class:mobile={$context.device.mobile}>
<div class="form-block fields" class:mobile={$context.device.mobile}> {#each fields as field, idx}
{#each fields as field, idx} <FormBlockComponent {field} {schema} order={idx} />
<FormBlockComponent {field} {schema} order={idx} /> {/each}
{/each} </div>
</div> </BlockComponent>
</BlockComponent>
{/key}
</BlockComponent> </BlockComponent>
{#if buttonPosition === "bottom"} {#if buttonPosition === "bottom"}
<BlockComponent <BlockComponent

View File

@ -37,6 +37,7 @@ export { default as markdownviewer } from "./MarkdownViewer.svelte"
export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte" export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte"
export { default as grid } from "./Grid.svelte" export { default as grid } from "./Grid.svelte"
export { default as sidepanel } from "./SidePanel.svelte" export { default as sidepanel } from "./SidePanel.svelte"
export { default as modal } from "./Modal.svelte"
export { default as gridblock } from "./GridBlock.svelte" export { default as gridblock } from "./GridBlock.svelte"
export * from "./charts" export * from "./charts"
export * from "./forms" export * from "./forms"

View File

@ -57,7 +57,9 @@
return return
} }
nextState.indicators[idx].visible = 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) { if (++callbackCount === observers.length) {
state = nextState state = nextState
updating = false updating = false
@ -139,6 +141,7 @@
height: elBounds.height + 4, height: elBounds.height + 4,
visible: false, visible: false,
insideSidePanel: !!child.closest(".side-panel"), insideSidePanel: !!child.closest(".side-panel"),
insideModal: !!child.closest(".modal-content"),
}) })
}) })
} }

View File

@ -11,6 +11,7 @@ import {
currentRole, currentRole,
environmentStore, environmentStore,
sidePanelStore, sidePanelStore,
modalStore,
dndIsDragging, dndIsDragging,
confirmationStore, confirmationStore,
roleStore, roleStore,
@ -53,6 +54,7 @@ export default {
componentStore, componentStore,
environmentStore, environmentStore,
sidePanelStore, sidePanelStore,
modalStore,
dndIsDragging, dndIsDragging,
currentRole, currentRole,
confirmationStore, confirmationStore,

View File

@ -27,6 +27,7 @@ export {
dndIsDragging, dndIsDragging,
} from "./dnd" } from "./dnd"
export { sidePanelStore } from "./sidePanel" export { sidePanelStore } from "./sidePanel"
export { modalStore } from "./modal"
export { hoverStore } from "./hover" export { hoverStore } from "./hover"
// Context stores are layered and duplicated, so it is not a singleton // Context stores are layered and duplicated, so it is not a singleton

View File

@ -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()

View File

@ -12,6 +12,7 @@ import {
uploadStore, uploadStore,
rowSelectionStore, rowSelectionStore,
sidePanelStore, sidePanelStore,
modalStore,
} from "stores" } from "stores"
import { API } from "api" import { API } from "api"
import { ActionTypes } from "constants" import { ActionTypes } from "constants"
@ -436,6 +437,17 @@ const closeSidePanelHandler = () => {
sidePanelStore.actions.close() 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 downloadFileHandler = async action => {
const { url, fileName } = action.parameters const { url, fileName } = action.parameters
try { try {
@ -499,6 +511,8 @@ const handlerMap = {
["Prompt User"]: promptUserHandler, ["Prompt User"]: promptUserHandler,
["Open Side Panel"]: openSidePanelHandler, ["Open Side Panel"]: openSidePanelHandler,
["Close Side Panel"]: closeSidePanelHandler, ["Close Side Panel"]: closeSidePanelHandler,
["Open Modal"]: openModalHandler,
["Close Modal"]: closeModalHandler,
["Download File"]: downloadFileHandler, ["Download File"]: downloadFileHandler,
} }

View File

@ -161,6 +161,9 @@ export const buildFormBlockButtonConfig = props => {
{ {
"##eventHandlerType": "Close Side Panel", "##eventHandlerType": "Close Side Panel",
}, },
{
"##eventHandlerType": "Close Modal",
},
// Clear a create form once submitted // Clear a create form once submitted
...(actionType !== "Create" ...(actionType !== "Create"
? [] ? []

View File

@ -39,9 +39,10 @@ export async function handleRequest<T extends Operation>(
export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) { export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
const tableId = utils.getTableId(ctx) 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 table = await sdk.tables.getTable(tableId)
const { row: dataToUpdate } = await inputProcessing( const { row: dataToUpdate } = await inputProcessing(
ctx.user?._id, ctx.user?._id,
cloneDeep(table), cloneDeep(table),
@ -79,6 +80,7 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
...response, ...response,
row: enrichedRow, row: enrichedRow,
table, table,
oldRow: beforeRow,
} }
} }

View File

@ -55,13 +55,13 @@ export async function patch(
return save(ctx) return save(ctx)
} }
try { try {
const { row, table } = await pickApi(tableId).patch(ctx) const { row, table, oldRow } = await pickApi(tableId).patch(ctx)
if (!row) { if (!row) {
ctx.throw(404, "Row not found") ctx.throw(404, "Row not found")
} }
ctx.status = 200 ctx.status = 200
ctx.eventEmitter && 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.message = `${table.name} updated successfully.`
ctx.body = row ctx.body = row
gridSocket?.emitRowUpdate(ctx, row) gridSocket?.emitRowUpdate(ctx, row)

View File

@ -85,13 +85,15 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
// the row has been updated, need to put it into the ctx // the row has been updated, need to put it into the ctx
ctx.request.body = row as any ctx.request.body = row as any
await userController.updateMetadata(ctx 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, oldTable: dbTable,
updateFormula: true, updateFormula: true,
}) })
return { ...result, oldRow }
} }
export async function find(ctx: UserCtx): Promise<Row> { export async function find(ctx: UserCtx): Promise<Row> {

View File

@ -13,6 +13,7 @@ import { events } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { Automation } from "@budibase/types" import { Automation } from "@budibase/types"
import { mocks } from "@budibase/backend-core/tests" import { mocks } from "@budibase/backend-core/tests"
import { FilterConditions } from "../../../automations/steps/filter"
const MAX_RETRIES = 4 const MAX_RETRIES = 4
let { let {
@ -21,6 +22,7 @@ let {
automationTrigger, automationTrigger,
automationStep, automationStep,
collectAutomation, collectAutomation,
filterAutomation,
} = setup.structures } = setup.structures
describe("/automations", () => { describe("/automations", () => {
@ -155,7 +157,12 @@ describe("/automations", () => {
automation.appId = config.appId automation.appId = config.appId
automation = await config.createAutomation(automation) automation = await config.createAutomation(automation)
await setup.delay(500) 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) 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 // 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 // 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) 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)
}
)
})
}) })

View File

@ -1,6 +1,7 @@
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
import tk from "timekeeper" import tk from "timekeeper"
import emitter from "../../../../src/events"
import { outputProcessing } from "../../../utilities/rowProcessor" import { outputProcessing } from "../../../utilities/rowProcessor"
import * as setup from "./utilities" import * as setup from "./utilities"
import { context, InternalTable, tenancy } from "@budibase/backend-core" import { context, InternalTable, tenancy } from "@budibase/backend-core"
@ -24,6 +25,7 @@ import {
StaticQuotaName, StaticQuotaName,
Table, Table,
TableSourceType, TableSourceType,
UpdatedRowEventEmitter,
} from "@budibase/types" } from "@budibase/types"
import { generator, mocks } from "@budibase/backend-core/tests" import { generator, mocks } from "@budibase/backend-core/tests"
import _, { merge } from "lodash" import _, { merge } from "lodash"
@ -31,6 +33,28 @@ import * as uuid from "uuid"
const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString() const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString()
tk.freeze(timestamp) 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([ describe.each([
["internal", undefined], ["internal", undefined],
@ -608,6 +632,31 @@ describe.each([
await assertRowUsage(rowUsage) 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 () => { it("should throw an error when given improper types", async () => {
const existing = await config.api.row.save(table._id!, {}) const existing = await config.api.row.save(table._id!, {})
const rowUsage = await getRowUsage() const rowUsage = await getRowUsage()

View File

@ -158,15 +158,16 @@ export const getDB = () => {
return context.getAppDB() 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 runRequest(automation.appId, async () => {
return await config.request return await config.request
.post(`/api/automations/${automation._id}/test`) .post(`/api/automations/${automation._id}/test`)
.send({ .send({
row: { ...triggerInputs,
name: "Test",
description: "TEST",
},
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect("Content-Type", /json/) .expect("Content-Type", /json/)

View File

@ -27,10 +27,17 @@ export const definition: AutomationTriggerSchema = {
}, },
outputs: { outputs: {
properties: { properties: {
row: { oldRow: {
type: AutomationIOType.OBJECT, type: AutomationIOType.OBJECT,
customType: AutomationCustomIOType.ROW, customType: AutomationCustomIOType.ROW,
description: "The row that was updated", 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: { id: {
type: AutomationIOType.STRING, type: AutomationIOType.STRING,

View File

@ -8,7 +8,13 @@ import { checkTestFlag } from "../utilities/redis"
import * as utils from "./utils" import * as utils from "./utils"
import env from "../environment" import env from "../environment"
import { context, db as dbCore } from "@budibase/backend-core" 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" import { executeInThread } from "../threads/automation"
export const TRIGGER_DEFINITIONS = definitions 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 */ /* istanbul ignore next */
if (!event || !event.row || !event.row.tableId) { if (!event || !event.row || !event.row.tableId) {
return return

View File

@ -13,8 +13,14 @@ import { Table, Row } from "@budibase/types"
* This is specifically quite important for template strings used in automations. * This is specifically quite important for template strings used in automations.
*/ */
class BudibaseEmitter extends EventEmitter { class BudibaseEmitter extends EventEmitter {
emitRow(eventName: string, appId: string, row: Row, table?: Table) { emitRow(
rowEmission({ emitter: this, eventName, appId, row, table }) 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) { emitTable(eventName: string, appId: string, table?: Table) {

View File

@ -7,6 +7,7 @@ type BBEventOpts = {
appId: string appId: string
table?: Table table?: Table
row?: Row row?: Row
oldRow?: Row
metadata?: any metadata?: any
} }
@ -18,6 +19,7 @@ type BBEvent = {
appId: string appId: string
tableId?: string tableId?: string
row?: Row row?: Row
oldRow?: Row
table?: BBEventTable table?: BBEventTable
id?: string id?: string
revision?: string revision?: string
@ -31,9 +33,11 @@ export function rowEmission({
row, row,
table, table,
metadata, metadata,
oldRow,
}: BBEventOpts) { }: BBEventOpts) {
let event: BBEvent = { let event: BBEvent = {
row, row,
oldRow,
appId, appId,
tableId: row?.tableId, tableId: row?.tableId,
} }

View File

@ -359,6 +359,36 @@ export function collectAutomation(tableId?: string): Automation {
return automation as 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( export function basicAutomationResults(
automationId: string automationId: string
): AutomationResults { ): AutomationResults {

View File

@ -2,6 +2,8 @@ import { Document } from "../document"
import { EventEmitter } from "events" import { EventEmitter } from "events"
import { User } from "../global" import { User } from "../global"
import { ReadStream } from "fs" import { ReadStream } from "fs"
import { Row } from "./row"
import { Table } from "./table"
export enum AutomationIOType { export enum AutomationIOType {
OBJECT = "object", OBJECT = "object",
@ -252,3 +254,10 @@ export type BucketedContent = AutomationAttachmentContent & {
bucket: string bucket: string
path: string path: string
} }
export type UpdatedRowEventEmitter = {
row: Row
oldRow: Row
table: Table
appId: string
}