Merge pull request #14219 from Budibase/feat/row-actions

Row actions automation creation
This commit is contained in:
Adria Navarro 2024-07-25 15:36:29 +02:00 committed by GitHub
commit afd9ad9d8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 583 additions and 107 deletions

View File

@ -3,6 +3,7 @@
automationStore, automationStore,
selectedAutomation, selectedAutomation,
permissions, permissions,
selectedAutomationDisplayData,
} from "stores/builder" } from "stores/builder"
import { import {
Icon, Icon,
@ -14,6 +15,7 @@
notifications, notifications,
Label, Label,
AbsTooltip, AbsTooltip,
InlineAlert,
} from "@budibase/bbui" } from "@budibase/bbui"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte" import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
@ -49,6 +51,8 @@
$: isAppAction && setPermissions(role) $: isAppAction && setPermissions(role)
$: isAppAction && getPermissions(automationId) $: isAppAction && getPermissions(automationId)
$: triggerInfo = $selectedAutomationDisplayData?.triggerInfo
async function setPermissions(role) { async function setPermissions(role) {
if (!role || !automationId) { if (!role || !automationId) {
return return
@ -183,6 +187,12 @@
{block} {block}
{webhookModal} {webhookModal}
/> />
{#if isTrigger && triggerInfo}
<InlineAlert
header={triggerInfo.type}
message={`This trigger is tied to the row action ${triggerInfo.rowAction.name} on your ${triggerInfo.table.name} table`}
/>
{/if}
{#if lastStep} {#if lastStep}
<Button on:click={() => testDataModal.show()} cta> <Button on:click={() => testDataModal.show()} cta>
Finish and test automation Finish and test automation

View File

@ -81,7 +81,7 @@
// Check the schema to see if required fields have been entered // Check the schema to see if required fields have been entered
$: isError = $: isError =
!isTriggerValid(trigger) || !isTriggerValid(trigger) ||
!trigger.schema.outputs.required?.every( !(trigger.schema.outputs.required || []).every(
required => $memoTestData?.[required] || required !== "row" required => $memoTestData?.[required] || required !== "row"
) )

View File

@ -6,6 +6,7 @@
contextMenuStore, contextMenuStore,
} from "stores/builder" } from "stores/builder"
import { notifications, Icon } from "@budibase/bbui" import { notifications, Icon } from "@budibase/bbui"
import { sdk } from "@budibase/shared-core"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import UpdateAutomationModal from "components/automation/AutomationPanel/UpdateAutomationModal.svelte" import UpdateAutomationModal from "components/automation/AutomationPanel/UpdateAutomationModal.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
@ -35,45 +36,53 @@
} }
const getContextMenuItems = () => { const getContextMenuItems = () => {
return [ const isRowAction = sdk.automations.isRowAction(automation)
{ const result = []
icon: "Delete", if (!isRowAction) {
name: "Delete", result.push(
keyBind: null, ...[
visible: true, {
disabled: false, icon: "Delete",
callback: confirmDeleteDialog.show, name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: confirmDeleteDialog.show,
},
{
icon: "Edit",
name: "Edit",
keyBind: null,
visible: true,
disabled: false,
callback: updateAutomationDialog.show,
},
{
icon: "Duplicate",
name: "Duplicate",
keyBind: null,
visible: true,
disabled: automation.definition.trigger.name === "Webhook",
callback: duplicateAutomation,
},
]
)
}
result.push({
icon: automation.disabled ? "CheckmarkCircle" : "Cancel",
name: automation.disabled ? "Activate" : "Pause",
keyBind: null,
visible: true,
disabled: false,
callback: () => {
automationStore.actions.toggleDisabled(
automation._id,
automation.disabled
)
}, },
{ })
icon: "Edit", return result
name: "Edit",
keyBind: null,
visible: true,
disabled: false,
callback: updateAutomationDialog.show,
},
{
icon: "Duplicate",
name: "Duplicate",
keyBind: null,
visible: true,
disabled: automation.definition.trigger.name === "Webhook",
callback: duplicateAutomation,
},
{
icon: automation.disabled ? "CheckmarkCircle" : "Cancel",
name: automation.disabled ? "Activate" : "Pause",
keyBind: null,
visible: true,
disabled: false,
callback: () => {
automationStore.actions.toggleDisabled(
automation._id,
automation.disabled
)
},
},
]
} }
const openContextMenu = e => { const openContextMenu = e => {
@ -89,7 +98,7 @@
on:contextmenu={openContextMenu} on:contextmenu={openContextMenu}
{icon} {icon}
iconColor={"var(--spectrum-global-color-gray-900)"} iconColor={"var(--spectrum-global-color-gray-900)"}
text={automation.name} text={automation.displayName}
selected={automation._id === $selectedAutomation?._id} selected={automation._id === $selectedAutomation?._id}
hovering={automation._id === $contextMenuStore.id} hovering={automation._id === $contextMenuStore.id}
on:click={() => automationStore.actions.select(automation._id)} on:click={() => automationStore.actions.select(automation._id)}

View File

@ -17,9 +17,15 @@
automation.name.toLowerCase().includes(searchString.toLowerCase()) automation.name.toLowerCase().includes(searchString.toLowerCase())
) )
}) })
.map(automation => ({
...automation,
displayName:
$automationStore.automationDisplayData[automation._id].displayName ||
automation.name,
}))
.sort((a, b) => { .sort((a, b) => {
const lowerA = a.name.toLowerCase() const lowerA = a.displayName.toLowerCase()
const lowerB = b.name.toLowerCase() const lowerB = b.displayName.toLowerCase()
return lowerA > lowerB ? 1 : -1 return lowerA > lowerB ? 1 : -1
}) })

View File

@ -876,6 +876,7 @@
options={value.enum} options={value.enum}
getOptionLabel={(x, idx) => getOptionLabel={(x, idx) =>
value.pretty ? value.pretty[idx] : x} value.pretty ? value.pretty[idx] : x}
disabled={value.readonly}
/> />
{:else if value.type === "json"} {:else if value.type === "json"}
<Editor <Editor
@ -884,6 +885,7 @@
mode="json" mode="json"
value={inputData[key]?.value} value={inputData[key]?.value}
on:change={e => onChange({ [key]: e.detail })} on:change={e => onChange({ [key]: e.detail })}
readOnly={value.readonly}
/> />
{:else if value.type === "boolean"} {:else if value.type === "boolean"}
<div style="margin-top: 10px"> <div style="margin-top: 10px">
@ -891,6 +893,7 @@
text={value.title} text={value.title}
value={inputData[key]} value={inputData[key]}
on:change={e => onChange({ [key]: e.detail })} on:change={e => onChange({ [key]: e.detail })}
disabled={value.readonly}
/> />
</div> </div>
{:else if value.type === "date"} {:else if value.type === "date"}
@ -904,6 +907,7 @@
allowJS={true} allowJS={true}
updateOnChange={false} updateOnChange={false}
drawerLeft="260px" drawerLeft="260px"
disabled={value.readonly}
> >
<DatePicker <DatePicker
value={inputData[key]} value={inputData[key]}
@ -915,6 +919,7 @@
on:change={e => onChange({ [key]: e.detail })} on:change={e => onChange({ [key]: e.detail })}
value={inputData[key]} value={inputData[key]}
options={Object.keys(table?.schema || {})} options={Object.keys(table?.schema || {})}
disabled={value.readonly}
/> />
{:else if value.type === "attachment" || value.type === "signature_single"} {:else if value.type === "attachment" || value.type === "signature_single"}
<div class="attachment-field-wrapper"> <div class="attachment-field-wrapper">
@ -1028,6 +1033,7 @@
{isTrigger} {isTrigger}
value={inputData[key]} value={inputData[key]}
on:change={e => onChange({ [key]: e.detail })} on:change={e => onChange({ [key]: e.detail })}
disabled={value.readonly}
/> />
{:else if value.customType === "webhookUrl"} {:else if value.customType === "webhookUrl"}
<WebhookDisplay value={inputData[key]} /> <WebhookDisplay value={inputData[key]} />

View File

@ -15,6 +15,7 @@ const initialAutomationState = {
ACTION: [], ACTION: [],
}, },
selectedAutomationId: null, selectedAutomationId: null,
automationDisplayData: {},
} }
// If this functions, remove the actions elements // If this functions, remove the actions elements
@ -58,18 +59,19 @@ const automationActions = store => ({
return response return response
}, },
fetch: async () => { fetch: async () => {
const responses = await Promise.all([ const [automationResponse, definitions] = await Promise.all([
API.getAutomations(), API.getAutomations({ enrich: true }),
API.getAutomationDefinitions(), API.getAutomationDefinitions(),
]) ])
store.update(state => { store.update(state => {
state.automations = responses[0] state.automations = automationResponse.automations
state.automations.sort((a, b) => { state.automations.sort((a, b) => {
return a.name < b.name ? -1 : 1 return a.name < b.name ? -1 : 1
}) })
state.automationDisplayData = automationResponse.builderData
state.blockDefinitions = { state.blockDefinitions = {
TRIGGER: responses[1].trigger, TRIGGER: definitions.trigger,
ACTION: responses[1].action, ACTION: definitions.action,
} }
return state return state
}) })
@ -102,19 +104,8 @@ const automationActions = store => ({
}, },
save: async automation => { save: async automation => {
const response = await API.updateAutomation(automation) const response = await API.updateAutomation(automation)
store.update(state => {
const updatedAutomation = response.automation await store.actions.fetch()
const existingIdx = state.automations.findIndex(
existing => existing._id === automation._id
)
if (existingIdx !== -1) {
state.automations.splice(existingIdx, 1, updatedAutomation)
return state
} else {
state.automations = [...state.automations, updatedAutomation]
}
return state
})
return response.automation return response.automation
}, },
delete: async automation => { delete: async automation => {
@ -308,7 +299,9 @@ const automationActions = store => ({
if (!automation) { if (!automation) {
return return
} }
delete newAutomation.definition.stepNames[blockId] if (newAutomation.definition.stepNames) {
delete newAutomation.definition.stepNames[blockId]
}
await store.actions.save(newAutomation) await store.actions.save(newAutomation)
}, },
@ -384,3 +377,13 @@ export const selectedAutomation = derived(automationStore, $automationStore => {
x => x._id === $automationStore.selectedAutomationId x => x._id === $automationStore.selectedAutomationId
) )
}) })
export const selectedAutomationDisplayData = derived(
[automationStore, selectedAutomation],
([$automationStore, $selectedAutomation]) => {
if (!$selectedAutomation._id) {
return null
}
return $automationStore.automationDisplayData[$selectedAutomation._id]
}
)

View File

@ -11,6 +11,7 @@ import {
automationStore, automationStore,
selectedAutomation, selectedAutomation,
automationHistoryStore, automationHistoryStore,
selectedAutomationDisplayData,
} from "./automations.js" } from "./automations.js"
import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js" import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js"
import { deploymentStore } from "./deployments.js" import { deploymentStore } from "./deployments.js"
@ -44,6 +45,7 @@ export {
previewStore, previewStore,
automationStore, automationStore,
selectedAutomation, selectedAutomation,
selectedAutomationDisplayData,
automationHistoryStore, automationHistoryStore,
sortedScreens, sortedScreens,
userStore, userStore,

View File

@ -26,9 +26,14 @@ export const buildAutomationEndpoints = API => ({
/** /**
* Gets a list of all automations. * Gets a list of all automations.
*/ */
getAutomations: async () => { getAutomations: async ({ enrich }) => {
const params = new URLSearchParams()
if (enrich) {
params.set("enrich", true)
}
return await API.get({ return await API.get({
url: "/api/automations", url: `/api/automations?${params.toString()}`,
}) })
}, },

View File

@ -1,4 +1,5 @@
import * as triggers from "../../automations/triggers" import * as triggers from "../../automations/triggers"
import { sdk as coreSdk } from "@budibase/shared-core"
import { DocumentType } from "../../db/utils" import { DocumentType } from "../../db/utils"
import { updateTestHistory, removeDeprecated } from "../../automations/utils" import { updateTestHistory, removeDeprecated } from "../../automations/utils"
import { setTestFlag, clearTestFlag } from "../../utilities/redis" import { setTestFlag, clearTestFlag } from "../../utilities/redis"
@ -11,6 +12,7 @@ import {
AutomationResults, AutomationResults,
UserCtx, UserCtx,
DeleteAutomationResponse, DeleteAutomationResponse,
FetchAutomationResponse,
} from "@budibase/types" } from "@budibase/types"
import { getActionDefinitions as actionDefs } from "../../automations/actions" import { getActionDefinitions as actionDefs } from "../../automations/actions"
import sdk from "../../sdk" import sdk from "../../sdk"
@ -73,8 +75,17 @@ export async function update(ctx: UserCtx) {
builderSocket?.emitAutomationUpdate(ctx, automation) builderSocket?.emitAutomationUpdate(ctx, automation)
} }
export async function fetch(ctx: UserCtx) { export async function fetch(ctx: UserCtx<void, FetchAutomationResponse>) {
ctx.body = await sdk.automations.fetch() const query: { enrich?: string } = ctx.request.query || {}
const enrich = query.enrich === "true"
const automations = await sdk.automations.fetch()
ctx.body = { automations }
if (enrich) {
ctx.body.builderData = await sdk.automations.utils.getBuilderData(
automations
)
}
} }
export async function find(ctx: UserCtx) { export async function find(ctx: UserCtx) {
@ -84,6 +95,11 @@ export async function find(ctx: UserCtx) {
export async function destroy(ctx: UserCtx<void, DeleteAutomationResponse>) { export async function destroy(ctx: UserCtx<void, DeleteAutomationResponse>) {
const automationId = ctx.params.id const automationId = ctx.params.id
const automation = await sdk.automations.get(ctx.params.id)
if (coreSdk.automations.isRowAction(automation)) {
ctx.throw("Row actions automations cannot be deleted", 422)
}
ctx.body = await sdk.automations.remove(automationId, ctx.params.rev) ctx.body = await sdk.automations.remove(automationId, ctx.params.rev)
builderSocket?.emitAutomationDeletion(ctx, automationId) builderSocket?.emitAutomationDeletion(ctx, automationId)
} }

View File

@ -31,7 +31,12 @@ export async function find(ctx: Ctx<void, RowActionsResponse>) {
actions: Object.entries(actions).reduce<Record<string, RowActionResponse>>( actions: Object.entries(actions).reduce<Record<string, RowActionResponse>>(
(acc, [key, action]) => ({ (acc, [key, action]) => ({
...acc, ...acc,
[key]: { id: key, tableId: table._id!, ...action }, [key]: {
id: key,
tableId: table._id!,
name: action.name,
automationId: action.automationId,
},
}), }),
{} {}
), ),
@ -50,7 +55,9 @@ export async function create(
ctx.body = { ctx.body = {
tableId: table._id!, tableId: table._id!,
...createdAction, id: createdAction.id,
name: createdAction.name,
automationId: createdAction.automationId,
} }
ctx.status = 201 ctx.status = 201
} }
@ -61,13 +68,15 @@ export async function update(
const table = await getTable(ctx) const table = await getTable(ctx)
const { actionId } = ctx.params const { actionId } = ctx.params
const actions = await sdk.rowActions.update(table._id!, actionId, { const action = await sdk.rowActions.update(table._id!, actionId, {
name: ctx.request.body.name, name: ctx.request.body.name,
}) })
ctx.body = { ctx.body = {
tableId: table._id!, tableId: table._id!,
...actions, id: action.id,
name: action.name,
automationId: action.automationId,
} }
} }

View File

@ -398,7 +398,9 @@ describe("/automations", () => {
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body[0]).toEqual(expect.objectContaining(autoConfig)) expect(res.body.automations[0]).toEqual(
expect.objectContaining(autoConfig)
)
}) })
it("should apply authorization to endpoint", async () => { it("should apply authorization to endpoint", async () => {
@ -423,6 +425,22 @@ describe("/automations", () => {
expect(events.automation.deleted).toHaveBeenCalledTimes(1) expect(events.automation.deleted).toHaveBeenCalledTimes(1)
}) })
it("cannot delete a row action automation", async () => {
const automation = await config.createAutomation(
setup.structures.rowActionAutomation()
)
await request
.delete(`/api/automations/${automation._id}/${automation._rev}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(422, {
message: "Row actions automations cannot be deleted",
status: 422,
})
expect(events.automation.deleted).not.toHaveBeenCalled()
})
it("should apply authorization to endpoint", async () => { it("should apply authorization to endpoint", async () => {
const automation = await config.createAutomation() const automation = await config.createAutomation()
await checkBuilderEndpoint({ await checkBuilderEndpoint({

View File

@ -1,10 +1,17 @@
import _ from "lodash" import _ from "lodash"
import tk from "timekeeper" import tk from "timekeeper"
import { CreateRowActionRequest, RowActionResponse } from "@budibase/types" import {
CreateRowActionRequest,
DocumentType,
RowActionResponse,
} from "@budibase/types"
import * as setup from "./utilities" import * as setup from "./utilities"
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
const expectAutomationId = () =>
expect.stringMatching(`^${DocumentType.AUTOMATION}_.+`)
describe("/rowsActions", () => { describe("/rowsActions", () => {
const config = setup.getConfig() const config = setup.getConfig()
@ -79,17 +86,19 @@ describe("/rowsActions", () => {
}) })
expect(res).toEqual({ expect(res).toEqual({
name: rowAction.name,
id: expect.stringMatching(/^row_action_\w+/), id: expect.stringMatching(/^row_action_\w+/),
tableId: tableId, tableId: tableId,
...rowAction, automationId: expectAutomationId(),
}) })
expect(await config.api.rowAction.find(tableId)).toEqual({ expect(await config.api.rowAction.find(tableId)).toEqual({
actions: { actions: {
[res.id]: { [res.id]: {
...rowAction, name: rowAction.name,
id: res.id, id: res.id,
tableId: tableId, tableId: tableId,
automationId: expectAutomationId(),
}, },
}, },
}) })
@ -97,19 +106,13 @@ describe("/rowsActions", () => {
it("trims row action names", async () => { it("trims row action names", async () => {
const name = " action name " const name = " action name "
const res = await createRowAction( const res = await createRowAction(tableId, { name }, { status: 201 })
tableId,
{ name },
{
status: 201,
}
)
expect(res).toEqual({ expect(res).toEqual(
id: expect.stringMatching(/^row_action_\w+/), expect.objectContaining({
tableId: tableId, name: "action name",
name: "action name", })
}) )
expect(await config.api.rowAction.find(tableId)).toEqual({ expect(await config.api.rowAction.find(tableId)).toEqual({
actions: { actions: {
@ -129,9 +132,24 @@ describe("/rowsActions", () => {
expect(await config.api.rowAction.find(tableId)).toEqual({ expect(await config.api.rowAction.find(tableId)).toEqual({
actions: { actions: {
[responses[0].id]: { ...rowActions[0], id: responses[0].id, tableId }, [responses[0].id]: {
[responses[1].id]: { ...rowActions[1], id: responses[1].id, tableId }, name: rowActions[0].name,
[responses[2].id]: { ...rowActions[2], id: responses[2].id, tableId }, id: responses[0].id,
tableId,
automationId: expectAutomationId(),
},
[responses[1].id]: {
name: rowActions[1].name,
id: responses[1].id,
tableId,
automationId: expectAutomationId(),
},
[responses[2].id]: {
name: rowActions[2].name,
id: responses[2].id,
tableId,
automationId: expectAutomationId(),
},
}, },
}) })
}) })
@ -152,7 +170,7 @@ describe("/rowsActions", () => {
it("ignores not valid row action data", async () => { it("ignores not valid row action data", async () => {
const rowAction = createRowActionRequest() const rowAction = createRowActionRequest()
const dirtyRowAction = { const dirtyRowAction = {
...rowAction, name: rowAction.name,
id: generator.guid(), id: generator.guid(),
valueToIgnore: generator.string(), valueToIgnore: generator.string(),
} }
@ -161,17 +179,19 @@ describe("/rowsActions", () => {
}) })
expect(res).toEqual({ expect(res).toEqual({
name: rowAction.name,
id: expect.any(String), id: expect.any(String),
tableId, tableId,
...rowAction, automationId: expectAutomationId(),
}) })
expect(await config.api.rowAction.find(tableId)).toEqual({ expect(await config.api.rowAction.find(tableId)).toEqual({
actions: { actions: {
[res.id]: { [res.id]: {
name: rowAction.name,
id: res.id, id: res.id,
tableId: tableId, tableId: tableId,
...rowAction, automationId: expectAutomationId(),
}, },
}, },
}) })
@ -213,6 +233,17 @@ describe("/rowsActions", () => {
await createRowAction(otherTable._id!, { name: action.name }) await createRowAction(otherTable._id!, { name: action.name })
}) })
it("an automation is created when creating a new row action", async () => {
const action1 = await createRowAction(tableId, createRowActionRequest())
const action2 = await createRowAction(tableId, createRowActionRequest())
for (const automationId of [action1.automationId, action2.automationId]) {
expect(
await config.api.automation.get(automationId, { status: 200 })
).toEqual(expect.objectContaining({ _id: automationId }))
}
})
}) })
describe("find", () => { describe("find", () => {
@ -264,7 +295,6 @@ describe("/rowsActions", () => {
const updatedName = generator.string() const updatedName = generator.string()
const res = await config.api.rowAction.update(tableId, actionId, { const res = await config.api.rowAction.update(tableId, actionId, {
...actionData,
name: updatedName, name: updatedName,
}) })
@ -272,14 +302,17 @@ describe("/rowsActions", () => {
id: actionId, id: actionId,
tableId, tableId,
name: updatedName, name: updatedName,
automationId: actionData.automationId,
}) })
expect(await config.api.rowAction.find(tableId)).toEqual( expect(await config.api.rowAction.find(tableId)).toEqual(
expect.objectContaining({ expect.objectContaining({
actions: expect.objectContaining({ actions: expect.objectContaining({
[actionId]: { [actionId]: {
...actionData,
name: updatedName, name: updatedName,
id: actionData.id,
tableId: actionData.tableId,
automationId: actionData.automationId,
}, },
}), }),
}) })
@ -296,7 +329,6 @@ describe("/rowsActions", () => {
) )
const res = await config.api.rowAction.update(tableId, rowAction.id, { const res = await config.api.rowAction.update(tableId, rowAction.id, {
...rowAction,
name: " action name ", name: " action name ",
}) })
@ -408,5 +440,26 @@ describe("/rowsActions", () => {
status: 400, status: 400,
}) })
}) })
it("deletes the linked automation", async () => {
const actions: RowActionResponse[] = []
for (const rowAction of createRowActionRequests(3)) {
actions.push(await createRowAction(tableId, rowAction))
}
const actionToDelete = _.sample(actions)!
await config.api.rowAction.delete(tableId, actionToDelete.id, {
status: 204,
})
await config.api.automation.get(actionToDelete.automationId, {
status: 404,
})
for (const action of actions.filter(a => a.id !== actionToDelete.id)) {
await config.api.automation.get(action.automationId, {
status: 200,
})
}
})
}) })
}) })

View File

@ -54,7 +54,7 @@ export const clearAllApps = async (
} }
export const clearAllAutomations = async (config: TestConfiguration) => { export const clearAllAutomations = async (config: TestConfiguration) => {
const automations = await config.getAllAutomations() const { automations } = await config.getAllAutomations()
for (let auto of automations) { for (let auto of automations) {
await context.doInAppContext(config.getAppId(), async () => { await context.doInAppContext(config.getAppId(), async () => {
await config.deleteAutomation(auto) await config.deleteAutomation(auto)

View File

@ -1,15 +1,24 @@
import {
AutomationTriggerSchema,
AutomationTriggerStepId,
} from "@budibase/types"
import * as app from "./app" import * as app from "./app"
import * as cron from "./cron" import * as cron from "./cron"
import * as rowDeleted from "./rowDeleted" import * as rowDeleted from "./rowDeleted"
import * as rowSaved from "./rowSaved" import * as rowSaved from "./rowSaved"
import * as rowUpdated from "./rowUpdated" import * as rowUpdated from "./rowUpdated"
import * as webhook from "./webhook" import * as webhook from "./webhook"
import * as rowAction from "./rowAction"
export const definitions = { export const definitions: Record<
keyof typeof AutomationTriggerStepId,
AutomationTriggerSchema
> = {
ROW_SAVED: rowSaved.definition, ROW_SAVED: rowSaved.definition,
ROW_UPDATED: rowUpdated.definition, ROW_UPDATED: rowUpdated.definition,
ROW_DELETED: rowDeleted.definition, ROW_DELETED: rowDeleted.definition,
WEBHOOK: webhook.definition, WEBHOOK: webhook.definition,
APP: app.definition, APP: app.definition,
CRON: cron.definition, CRON: cron.definition,
ROW_ACTION: rowAction.definition,
} }

View File

@ -0,0 +1,35 @@
import {
AutomationCustomIOType,
AutomationIOType,
AutomationStepType,
AutomationTriggerSchema,
AutomationTriggerStepId,
AutomationEventType,
} from "@budibase/types"
export const definition: AutomationTriggerSchema = {
type: AutomationStepType.TRIGGER,
tagline:
"Row action triggered in {{inputs.enriched.table.name}} by {{inputs.enriched.row._id}}",
name: "Row Action",
description: "TODO description",
icon: "Workflow",
stepId: AutomationTriggerStepId.ROW_ACTION,
inputs: {},
schema: {
inputs: {
properties: {
tableId: {
type: AutomationIOType.STRING,
customType: AutomationCustomIOType.TABLE,
title: "Table",
readonly: true,
},
},
required: ["tableId"],
},
outputs: { properties: {} },
},
event: AutomationEventType.ROW_SAVE,
}

View File

@ -1,3 +1,4 @@
import { sdk } from "@budibase/shared-core"
import { import {
Automation, Automation,
RequiredKeys, RequiredKeys,
@ -16,6 +17,11 @@ import {
import { definitions } from "../../../automations/triggerInfo" import { definitions } from "../../../automations/triggerInfo"
import automations from "." import automations from "."
export interface PersistedAutomation extends Automation {
_id: string
_rev: string
}
function getDb() { function getDb() {
return context.getAppDB() return context.getAppDB()
} }
@ -76,7 +82,7 @@ async function handleStepEvents(
export async function fetch() { export async function fetch() {
const db = getDb() const db = getDb()
const response = await db.allDocs<Automation>( const response = await db.allDocs<PersistedAutomation>(
getAutomationParams(null, { getAutomationParams(null, {
include_docs: true, include_docs: true,
}) })
@ -89,7 +95,7 @@ export async function fetch() {
export async function get(automationId: string) { export async function get(automationId: string) {
const db = getDb() const db = getDb()
const result = await db.get<Automation>(automationId) const result = await db.get<PersistedAutomation>(automationId)
return trimUnexpectedObjectFields(result) return trimUnexpectedObjectFields(result)
} }
@ -127,6 +133,9 @@ export async function update(automation: Automation) {
const db = getDb() const db = getDb()
const oldAutomation = await db.get<Automation>(automation._id) const oldAutomation = await db.get<Automation>(automation._id)
guardInvalidUpdatesAndThrow(automation, oldAutomation)
automation = cleanAutomationInputs(automation) automation = cleanAutomationInputs(automation)
automation = await checkForWebhooks({ automation = await checkForWebhooks({
oldAuto: oldAutomation, oldAuto: oldAutomation,
@ -254,6 +263,41 @@ async function checkForWebhooks({ oldAuto, newAuto }: any) {
return newAuto return newAuto
} }
function guardInvalidUpdatesAndThrow(
automation: Automation,
oldAutomation: Automation
) {
const stepDefinitions = [
automation.definition.trigger,
...automation.definition.steps,
]
const oldStepDefinitions = [
oldAutomation.definition.trigger,
...oldAutomation.definition.steps,
]
for (const step of stepDefinitions) {
const readonlyFields = Object.keys(
step.schema.inputs.properties || {}
).filter(k => step.schema.inputs.properties[k].readonly)
readonlyFields.forEach(readonlyField => {
const oldStep = oldStepDefinitions.find(i => i.id === step.id)
if (step.inputs[readonlyField] !== oldStep?.inputs[readonlyField]) {
throw new HTTPError(
`Field ${readonlyField} is readonly and it cannot be modified`,
400
)
}
})
}
if (
sdk.automations.isRowAction(automation) &&
automation.name !== oldAutomation.name
) {
throw new Error("Row actions cannot be renamed")
}
}
function trimUnexpectedObjectFields<T extends Automation>(automation: T): T { function trimUnexpectedObjectFields<T extends Automation>(automation: T): T {
// This will ensure all the automation fields (and nothing else) is mapped to the result // This will ensure all the automation fields (and nothing else) is mapped to the result
const allRequired: RequiredKeys<Automation> = { const allRequired: RequiredKeys<Automation> = {

View File

@ -0,0 +1,88 @@
import { sample } from "lodash/fp"
import { Automation, AutomationTriggerStepId } from "@budibase/types"
import { generator } from "@budibase/backend-core/tests"
import automationSdk from "../"
import { structures } from "../../../../api/routes/tests/utilities"
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
describe("automation sdk", () => {
const config = new TestConfiguration()
beforeAll(async () => {
await config.init()
})
describe("update", () => {
it("can rename existing automations", async () => {
await config.doInContext(config.getAppId(), async () => {
const automation = structures.newAutomation()
const response = await automationSdk.create(automation)
const newName = generator.guid()
const update = { ...response, name: newName }
const result = await automationSdk.update(update)
expect(result.name).toEqual(newName)
})
})
it("cannot rename row action automations", async () => {
await config.doInContext(config.getAppId(), async () => {
const automation = structures.newAutomation({
trigger: {
...structures.automationTrigger(),
stepId: AutomationTriggerStepId.ROW_ACTION,
},
})
const response = await automationSdk.create(automation)
const newName = generator.guid()
const update = { ...response, name: newName }
await expect(automationSdk.update(update)).rejects.toThrow(
"Row actions cannot be renamed"
)
})
})
it.each([
["trigger", (a: Automation) => a.definition.trigger],
["step", (a: Automation) => a.definition.steps[0]],
])("can update input fields (for a %s)", async (_, getStep) => {
await config.doInContext(config.getAppId(), async () => {
const automation = structures.newAutomation()
const keyToUse = sample(Object.keys(getStep(automation).inputs))!
getStep(automation).inputs[keyToUse] = "anyValue"
const response = await automationSdk.create(automation)
const update = { ...response }
getStep(update).inputs[keyToUse] = "anyUpdatedValue"
const result = await automationSdk.update(update)
expect(getStep(result).inputs[keyToUse]).toEqual("anyUpdatedValue")
})
})
it.each([
["trigger", (a: Automation) => a.definition.trigger],
["step", (a: Automation) => a.definition.steps[0]],
])("cannot update readonly fields (for a %s)", async (_, getStep) => {
await config.doInContext(config.getAppId(), async () => {
const automation = structures.newAutomation()
getStep(automation).schema.inputs.properties["readonlyProperty"] = {
readonly: true,
}
getStep(automation).inputs["readonlyProperty"] = "anyValue"
const response = await automationSdk.create(automation)
const update = { ...response }
getStep(update).inputs["readonlyProperty"] = "anyUpdatedValue"
await expect(automationSdk.update(update)).rejects.toThrow(
"Field readonlyProperty is readonly and it cannot be modified"
)
})
})
})
})

View File

@ -1,7 +1,67 @@
import { Automation, AutomationActionStepId } from "@budibase/types" import {
Automation,
AutomationActionStepId,
AutomationBuilderData,
TableRowActions,
} from "@budibase/types"
import { sdk as coreSdk } from "@budibase/shared-core"
export function checkForCollectStep(automation: Automation) { export function checkForCollectStep(automation: Automation) {
return automation.definition.steps.some( return automation.definition.steps.some(
(step: any) => step.stepId === AutomationActionStepId.COLLECT (step: any) => step.stepId === AutomationActionStepId.COLLECT
) )
} }
export async function getBuilderData(
automations: Automation[]
): Promise<Record<string, AutomationBuilderData>> {
const sdk = (await import("../../../sdk")).default
const tableNameCache: Record<string, string> = {}
async function getTableName(tableId: string) {
if (!tableNameCache[tableId]) {
const table = await sdk.tables.getTable(tableId)
tableNameCache[tableId] = table.name
}
return tableNameCache[tableId]
}
const rowActionNameCache: Record<string, TableRowActions> = {}
async function getRowActionName(tableId: string, rowActionId: string) {
if (!rowActionNameCache[tableId]) {
const rowActions = await sdk.rowActions.get(tableId)
rowActionNameCache[tableId] = rowActions
}
return rowActionNameCache[tableId].actions[rowActionId]?.name
}
const result: Record<string, AutomationBuilderData> = {}
for (const automation of automations) {
const isRowAction = coreSdk.automations.isRowAction(automation)
if (!isRowAction) {
result[automation._id!] = { displayName: automation.name }
continue
}
const { tableId, rowActionId } = automation.definition.trigger.inputs
const tableName = await getTableName(tableId)
const rowActionName = await getRowActionName(tableId, rowActionId)
result[automation._id!] = {
displayName: `${tableName}: ${automation.name}`,
triggerInfo: {
type: "Automation trigger",
table: { id: tableId, name: tableName },
rowAction: {
id: rowActionId,
name: rowActionName,
},
},
}
}
return result
}

View File

@ -6,6 +6,8 @@ import {
TableRowActions, TableRowActions,
VirtualDocumentType, VirtualDocumentType,
} from "@budibase/types" } from "@budibase/types"
import automations from "./automations"
import { definitions as TRIGGER_DEFINITIONS } from "../../automations/triggerInfo"
function ensureUniqueAndThrow( function ensureUniqueAndThrow(
doc: TableRowActions, doc: TableRowActions,
@ -41,13 +43,40 @@ export async function create(tableId: string, rowAction: { name: string }) {
ensureUniqueAndThrow(doc, action.name) ensureUniqueAndThrow(doc, action.name)
const newId = `${VirtualDocumentType.ROW_ACTION}${SEPARATOR}${utils.newid()}` const appId = context.getAppId()
doc.actions[newId] = action if (!appId) {
throw new Error("Could not get the current appId")
}
const newRowActionId = `${
VirtualDocumentType.ROW_ACTION
}${SEPARATOR}${utils.newid()}`
const automation = await automations.create({
name: action.name,
appId,
definition: {
trigger: {
id: "trigger",
...TRIGGER_DEFINITIONS.ROW_ACTION,
inputs: {
tableId,
rowActionId: newRowActionId,
},
},
steps: [],
},
})
doc.actions[newRowActionId] = {
name: action.name,
automationId: automation._id!,
}
await db.put(doc) await db.put(doc)
return { return {
id: newId, id: newRowActionId,
...action, ...doc.actions[newRowActionId],
} }
} }
@ -81,27 +110,34 @@ export async function update(
ensureUniqueAndThrow(actionsDoc, action.name, rowActionId) ensureUniqueAndThrow(actionsDoc, action.name, rowActionId)
actionsDoc.actions[rowActionId] = action actionsDoc.actions[rowActionId] = {
automationId: actionsDoc.actions[rowActionId].automationId,
...action,
}
const db = context.getAppDB() const db = context.getAppDB()
await db.put(actionsDoc) await db.put(actionsDoc)
return { return {
id: rowActionId, id: rowActionId,
...action, ...actionsDoc.actions[rowActionId],
} }
} }
export async function remove(tableId: string, rowActionId: string) { export async function remove(tableId: string, rowActionId: string) {
const actionsDoc = await get(tableId) const actionsDoc = await get(tableId)
if (!actionsDoc.actions[rowActionId]) { const rowAction = actionsDoc.actions[rowActionId]
if (!rowAction) {
throw new HTTPError( throw new HTTPError(
`Row action '${rowActionId}' not found in '${tableId}'`, `Row action '${rowActionId}' not found in '${tableId}'`,
400 400
) )
} }
const { automationId } = rowAction
const automation = await automations.get(automationId)
await automations.remove(automation._id, automation._rev)
delete actionsDoc.actions[rowActionId] delete actionsDoc.actions[rowActionId]
const db = context.getAppDB() const db = context.getAppDB()

View File

@ -0,0 +1,17 @@
import { Automation } from "@budibase/types"
import { Expectations, TestAPI } from "./base"
export class AutomationAPI extends TestAPI {
get = async (
automationId: string,
expectations?: Expectations
): Promise<Automation> => {
const result = await this._get<Automation>(
`/api/automations/${automationId}`,
{
expectations,
}
)
return result
}
}

View File

@ -14,6 +14,7 @@ import { QueryAPI } from "./query"
import { RoleAPI } from "./role" import { RoleAPI } from "./role"
import { TemplateAPI } from "./template" import { TemplateAPI } from "./template"
import { RowActionAPI } from "./rowAction" import { RowActionAPI } from "./rowAction"
import { AutomationAPI } from "./automation"
export default class API { export default class API {
table: TableAPI table: TableAPI
@ -31,6 +32,7 @@ export default class API {
roles: RoleAPI roles: RoleAPI
templates: TemplateAPI templates: TemplateAPI
rowAction: RowActionAPI rowAction: RowActionAPI
automation: AutomationAPI
constructor(config: TestConfiguration) { constructor(config: TestConfiguration) {
this.table = new TableAPI(config) this.table = new TableAPI(config)
@ -48,5 +50,6 @@ export default class API {
this.roles = new RoleAPI(config) this.roles = new RoleAPI(config)
this.templates = new TemplateAPI(config) this.templates = new TemplateAPI(config)
this.rowAction = new RowActionAPI(config) this.rowAction = new RowActionAPI(config)
this.automation = new AutomationAPI(config)
} }
} }

View File

@ -158,7 +158,10 @@ export function automationTrigger(
} }
} }
export function newAutomation({ steps, trigger }: any = {}) { export function newAutomation({
steps,
trigger,
}: { steps?: AutomationStep[]; trigger?: AutomationTrigger } = {}) {
const automation = basicAutomation() const automation = basicAutomation()
if (trigger) { if (trigger) {
@ -176,6 +179,16 @@ export function newAutomation({ steps, trigger }: any = {}) {
return automation return automation
} }
export function rowActionAutomation() {
const automation = newAutomation({
trigger: {
...automationTrigger(),
stepId: AutomationTriggerStepId.ROW_ACTION,
},
})
return automation
}
export function basicAutomation(appId?: string): Automation { export function basicAutomation(appId?: string): Automation {
return { return {
name: "My Automation", name: "My Automation",

View File

@ -0,0 +1,7 @@
import { Automation, AutomationTriggerStepId } from "@budibase/types"
export function isRowAction(automation: Automation) {
const result =
automation.definition.trigger.stepId === AutomationTriggerStepId.ROW_ACTION
return result
}

View File

@ -1,2 +1,3 @@
export * as applications from "./applications" export * as applications from "./applications"
export * as automations from "./automations"
export * as users from "./users" export * as users from "./users"

View File

@ -7,6 +7,7 @@ export interface UpdateRowActionRequest extends RowActionData {}
export interface RowActionResponse extends RowActionData { export interface RowActionResponse extends RowActionData {
id: string id: string
tableId: string tableId: string
automationId: string
} }
export interface RowActionsResponse { export interface RowActionsResponse {

View File

@ -1,3 +1,24 @@
import { DocumentDestroyResponse } from "@budibase/nano" import { DocumentDestroyResponse } from "@budibase/nano"
import { Automation } from "../../documents"
export interface DeleteAutomationResponse extends DocumentDestroyResponse {} export interface DeleteAutomationResponse extends DocumentDestroyResponse {}
export interface AutomationBuilderData {
displayName: string
triggerInfo?: {
type: string
table: {
id: string
name: string
}
rowAction: {
id: string
name: string
}
}
}
export interface FetchAutomationResponse {
automations: Automation[]
builderData?: Record<string, AutomationBuilderData> // The key will be the automationId
}

View File

@ -45,6 +45,7 @@ export enum AutomationTriggerStepId {
WEBHOOK = "WEBHOOK", WEBHOOK = "WEBHOOK",
APP = "APP", APP = "APP",
CRON = "CRON", CRON = "CRON",
ROW_ACTION = "ROW_ACTION",
} }
export enum AutomationStepType { export enum AutomationStepType {
@ -152,6 +153,7 @@ interface BaseIOStructure {
[key: string]: BaseIOStructure [key: string]: BaseIOStructure
} }
required?: string[] required?: string[]
readonly?: true
} }
export interface InputOutputBlock { export interface InputOutputBlock {
@ -192,6 +194,7 @@ export interface AutomationStep extends AutomationStepSchema {
} }
export interface AutomationTriggerSchema extends AutomationStepSchema { export interface AutomationTriggerSchema extends AutomationStepSchema {
type: AutomationStepType.TRIGGER
event?: string event?: string
cronJobId?: string cronJobId?: string
} }

View File

@ -6,6 +6,7 @@ export interface TableRowActions extends Document {
string, string,
{ {
name: string name: string
automationId: string
} }
> >
} }