Merge pull request #14219 from Budibase/feat/row-actions
Row actions automation creation
This commit is contained in:
commit
afd9ad9d8d
|
@ -3,6 +3,7 @@
|
|||
automationStore,
|
||||
selectedAutomation,
|
||||
permissions,
|
||||
selectedAutomationDisplayData,
|
||||
} from "stores/builder"
|
||||
import {
|
||||
Icon,
|
||||
|
@ -14,6 +15,7 @@
|
|||
notifications,
|
||||
Label,
|
||||
AbsTooltip,
|
||||
InlineAlert,
|
||||
} from "@budibase/bbui"
|
||||
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
|
||||
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
|
||||
|
@ -49,6 +51,8 @@
|
|||
$: isAppAction && setPermissions(role)
|
||||
$: isAppAction && getPermissions(automationId)
|
||||
|
||||
$: triggerInfo = $selectedAutomationDisplayData?.triggerInfo
|
||||
|
||||
async function setPermissions(role) {
|
||||
if (!role || !automationId) {
|
||||
return
|
||||
|
@ -183,6 +187,12 @@
|
|||
{block}
|
||||
{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}
|
||||
<Button on:click={() => testDataModal.show()} cta>
|
||||
Finish and test automation
|
||||
|
|
|
@ -81,7 +81,7 @@
|
|||
// Check the schema to see if required fields have been entered
|
||||
$: isError =
|
||||
!isTriggerValid(trigger) ||
|
||||
!trigger.schema.outputs.required?.every(
|
||||
!(trigger.schema.outputs.required || []).every(
|
||||
required => $memoTestData?.[required] || required !== "row"
|
||||
)
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
contextMenuStore,
|
||||
} from "stores/builder"
|
||||
import { notifications, Icon } from "@budibase/bbui"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
import UpdateAutomationModal from "components/automation/AutomationPanel/UpdateAutomationModal.svelte"
|
||||
import NavItem from "components/common/NavItem.svelte"
|
||||
|
@ -35,45 +36,53 @@
|
|||
}
|
||||
|
||||
const getContextMenuItems = () => {
|
||||
return [
|
||||
{
|
||||
icon: "Delete",
|
||||
name: "Delete",
|
||||
keyBind: null,
|
||||
visible: true,
|
||||
disabled: false,
|
||||
callback: confirmDeleteDialog.show,
|
||||
const isRowAction = sdk.automations.isRowAction(automation)
|
||||
const result = []
|
||||
if (!isRowAction) {
|
||||
result.push(
|
||||
...[
|
||||
{
|
||||
icon: "Delete",
|
||||
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",
|
||||
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
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
const openContextMenu = e => {
|
||||
|
@ -89,7 +98,7 @@
|
|||
on:contextmenu={openContextMenu}
|
||||
{icon}
|
||||
iconColor={"var(--spectrum-global-color-gray-900)"}
|
||||
text={automation.name}
|
||||
text={automation.displayName}
|
||||
selected={automation._id === $selectedAutomation?._id}
|
||||
hovering={automation._id === $contextMenuStore.id}
|
||||
on:click={() => automationStore.actions.select(automation._id)}
|
||||
|
|
|
@ -17,9 +17,15 @@
|
|||
automation.name.toLowerCase().includes(searchString.toLowerCase())
|
||||
)
|
||||
})
|
||||
.map(automation => ({
|
||||
...automation,
|
||||
displayName:
|
||||
$automationStore.automationDisplayData[automation._id].displayName ||
|
||||
automation.name,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const lowerA = a.name.toLowerCase()
|
||||
const lowerB = b.name.toLowerCase()
|
||||
const lowerA = a.displayName.toLowerCase()
|
||||
const lowerB = b.displayName.toLowerCase()
|
||||
return lowerA > lowerB ? 1 : -1
|
||||
})
|
||||
|
||||
|
|
|
@ -876,6 +876,7 @@
|
|||
options={value.enum}
|
||||
getOptionLabel={(x, idx) =>
|
||||
value.pretty ? value.pretty[idx] : x}
|
||||
disabled={value.readonly}
|
||||
/>
|
||||
{:else if value.type === "json"}
|
||||
<Editor
|
||||
|
@ -884,6 +885,7 @@
|
|||
mode="json"
|
||||
value={inputData[key]?.value}
|
||||
on:change={e => onChange({ [key]: e.detail })}
|
||||
readOnly={value.readonly}
|
||||
/>
|
||||
{:else if value.type === "boolean"}
|
||||
<div style="margin-top: 10px">
|
||||
|
@ -891,6 +893,7 @@
|
|||
text={value.title}
|
||||
value={inputData[key]}
|
||||
on:change={e => onChange({ [key]: e.detail })}
|
||||
disabled={value.readonly}
|
||||
/>
|
||||
</div>
|
||||
{:else if value.type === "date"}
|
||||
|
@ -904,6 +907,7 @@
|
|||
allowJS={true}
|
||||
updateOnChange={false}
|
||||
drawerLeft="260px"
|
||||
disabled={value.readonly}
|
||||
>
|
||||
<DatePicker
|
||||
value={inputData[key]}
|
||||
|
@ -915,6 +919,7 @@
|
|||
on:change={e => onChange({ [key]: e.detail })}
|
||||
value={inputData[key]}
|
||||
options={Object.keys(table?.schema || {})}
|
||||
disabled={value.readonly}
|
||||
/>
|
||||
{:else if value.type === "attachment" || value.type === "signature_single"}
|
||||
<div class="attachment-field-wrapper">
|
||||
|
@ -1028,6 +1033,7 @@
|
|||
{isTrigger}
|
||||
value={inputData[key]}
|
||||
on:change={e => onChange({ [key]: e.detail })}
|
||||
disabled={value.readonly}
|
||||
/>
|
||||
{:else if value.customType === "webhookUrl"}
|
||||
<WebhookDisplay value={inputData[key]} />
|
||||
|
|
|
@ -15,6 +15,7 @@ const initialAutomationState = {
|
|||
ACTION: [],
|
||||
},
|
||||
selectedAutomationId: null,
|
||||
automationDisplayData: {},
|
||||
}
|
||||
|
||||
// If this functions, remove the actions elements
|
||||
|
@ -58,18 +59,19 @@ const automationActions = store => ({
|
|||
return response
|
||||
},
|
||||
fetch: async () => {
|
||||
const responses = await Promise.all([
|
||||
API.getAutomations(),
|
||||
const [automationResponse, definitions] = await Promise.all([
|
||||
API.getAutomations({ enrich: true }),
|
||||
API.getAutomationDefinitions(),
|
||||
])
|
||||
store.update(state => {
|
||||
state.automations = responses[0]
|
||||
state.automations = automationResponse.automations
|
||||
state.automations.sort((a, b) => {
|
||||
return a.name < b.name ? -1 : 1
|
||||
})
|
||||
state.automationDisplayData = automationResponse.builderData
|
||||
state.blockDefinitions = {
|
||||
TRIGGER: responses[1].trigger,
|
||||
ACTION: responses[1].action,
|
||||
TRIGGER: definitions.trigger,
|
||||
ACTION: definitions.action,
|
||||
}
|
||||
return state
|
||||
})
|
||||
|
@ -102,19 +104,8 @@ const automationActions = store => ({
|
|||
},
|
||||
save: async automation => {
|
||||
const response = await API.updateAutomation(automation)
|
||||
store.update(state => {
|
||||
const updatedAutomation = response.automation
|
||||
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
|
||||
})
|
||||
|
||||
await store.actions.fetch()
|
||||
return response.automation
|
||||
},
|
||||
delete: async automation => {
|
||||
|
@ -308,7 +299,9 @@ const automationActions = store => ({
|
|||
if (!automation) {
|
||||
return
|
||||
}
|
||||
delete newAutomation.definition.stepNames[blockId]
|
||||
if (newAutomation.definition.stepNames) {
|
||||
delete newAutomation.definition.stepNames[blockId]
|
||||
}
|
||||
|
||||
await store.actions.save(newAutomation)
|
||||
},
|
||||
|
@ -384,3 +377,13 @@ export const selectedAutomation = derived(automationStore, $automationStore => {
|
|||
x => x._id === $automationStore.selectedAutomationId
|
||||
)
|
||||
})
|
||||
|
||||
export const selectedAutomationDisplayData = derived(
|
||||
[automationStore, selectedAutomation],
|
||||
([$automationStore, $selectedAutomation]) => {
|
||||
if (!$selectedAutomation._id) {
|
||||
return null
|
||||
}
|
||||
return $automationStore.automationDisplayData[$selectedAutomation._id]
|
||||
}
|
||||
)
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
automationStore,
|
||||
selectedAutomation,
|
||||
automationHistoryStore,
|
||||
selectedAutomationDisplayData,
|
||||
} from "./automations.js"
|
||||
import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js"
|
||||
import { deploymentStore } from "./deployments.js"
|
||||
|
@ -44,6 +45,7 @@ export {
|
|||
previewStore,
|
||||
automationStore,
|
||||
selectedAutomation,
|
||||
selectedAutomationDisplayData,
|
||||
automationHistoryStore,
|
||||
sortedScreens,
|
||||
userStore,
|
||||
|
|
|
@ -26,9 +26,14 @@ export const buildAutomationEndpoints = API => ({
|
|||
/**
|
||||
* 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({
|
||||
url: "/api/automations",
|
||||
url: `/api/automations?${params.toString()}`,
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import * as triggers from "../../automations/triggers"
|
||||
import { sdk as coreSdk } from "@budibase/shared-core"
|
||||
import { DocumentType } from "../../db/utils"
|
||||
import { updateTestHistory, removeDeprecated } from "../../automations/utils"
|
||||
import { setTestFlag, clearTestFlag } from "../../utilities/redis"
|
||||
|
@ -11,6 +12,7 @@ import {
|
|||
AutomationResults,
|
||||
UserCtx,
|
||||
DeleteAutomationResponse,
|
||||
FetchAutomationResponse,
|
||||
} from "@budibase/types"
|
||||
import { getActionDefinitions as actionDefs } from "../../automations/actions"
|
||||
import sdk from "../../sdk"
|
||||
|
@ -73,8 +75,17 @@ export async function update(ctx: UserCtx) {
|
|||
builderSocket?.emitAutomationUpdate(ctx, automation)
|
||||
}
|
||||
|
||||
export async function fetch(ctx: UserCtx) {
|
||||
ctx.body = await sdk.automations.fetch()
|
||||
export async function fetch(ctx: UserCtx<void, FetchAutomationResponse>) {
|
||||
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) {
|
||||
|
@ -84,6 +95,11 @@ export async function find(ctx: UserCtx) {
|
|||
export async function destroy(ctx: UserCtx<void, DeleteAutomationResponse>) {
|
||||
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)
|
||||
builderSocket?.emitAutomationDeletion(ctx, automationId)
|
||||
}
|
||||
|
|
|
@ -31,7 +31,12 @@ export async function find(ctx: Ctx<void, RowActionsResponse>) {
|
|||
actions: Object.entries(actions).reduce<Record<string, RowActionResponse>>(
|
||||
(acc, [key, action]) => ({
|
||||
...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 = {
|
||||
tableId: table._id!,
|
||||
...createdAction,
|
||||
id: createdAction.id,
|
||||
name: createdAction.name,
|
||||
automationId: createdAction.automationId,
|
||||
}
|
||||
ctx.status = 201
|
||||
}
|
||||
|
@ -61,13 +68,15 @@ export async function update(
|
|||
const table = await getTable(ctx)
|
||||
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,
|
||||
})
|
||||
|
||||
ctx.body = {
|
||||
tableId: table._id!,
|
||||
...actions,
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
automationId: action.automationId,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -398,7 +398,9 @@ describe("/automations", () => {
|
|||
.expect("Content-Type", /json/)
|
||||
.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 () => {
|
||||
|
@ -423,6 +425,22 @@ describe("/automations", () => {
|
|||
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 () => {
|
||||
const automation = await config.createAutomation()
|
||||
await checkBuilderEndpoint({
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
import _ from "lodash"
|
||||
import tk from "timekeeper"
|
||||
|
||||
import { CreateRowActionRequest, RowActionResponse } from "@budibase/types"
|
||||
import {
|
||||
CreateRowActionRequest,
|
||||
DocumentType,
|
||||
RowActionResponse,
|
||||
} from "@budibase/types"
|
||||
import * as setup from "./utilities"
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
|
||||
const expectAutomationId = () =>
|
||||
expect.stringMatching(`^${DocumentType.AUTOMATION}_.+`)
|
||||
|
||||
describe("/rowsActions", () => {
|
||||
const config = setup.getConfig()
|
||||
|
||||
|
@ -79,17 +86,19 @@ describe("/rowsActions", () => {
|
|||
})
|
||||
|
||||
expect(res).toEqual({
|
||||
name: rowAction.name,
|
||||
id: expect.stringMatching(/^row_action_\w+/),
|
||||
tableId: tableId,
|
||||
...rowAction,
|
||||
automationId: expectAutomationId(),
|
||||
})
|
||||
|
||||
expect(await config.api.rowAction.find(tableId)).toEqual({
|
||||
actions: {
|
||||
[res.id]: {
|
||||
...rowAction,
|
||||
name: rowAction.name,
|
||||
id: res.id,
|
||||
tableId: tableId,
|
||||
automationId: expectAutomationId(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -97,19 +106,13 @@ describe("/rowsActions", () => {
|
|||
|
||||
it("trims row action names", async () => {
|
||||
const name = " action name "
|
||||
const res = await createRowAction(
|
||||
tableId,
|
||||
{ name },
|
||||
{
|
||||
status: 201,
|
||||
}
|
||||
)
|
||||
const res = await createRowAction(tableId, { name }, { status: 201 })
|
||||
|
||||
expect(res).toEqual({
|
||||
id: expect.stringMatching(/^row_action_\w+/),
|
||||
tableId: tableId,
|
||||
name: "action name",
|
||||
})
|
||||
expect(res).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "action name",
|
||||
})
|
||||
)
|
||||
|
||||
expect(await config.api.rowAction.find(tableId)).toEqual({
|
||||
actions: {
|
||||
|
@ -129,9 +132,24 @@ describe("/rowsActions", () => {
|
|||
|
||||
expect(await config.api.rowAction.find(tableId)).toEqual({
|
||||
actions: {
|
||||
[responses[0].id]: { ...rowActions[0], id: responses[0].id, tableId },
|
||||
[responses[1].id]: { ...rowActions[1], id: responses[1].id, tableId },
|
||||
[responses[2].id]: { ...rowActions[2], id: responses[2].id, tableId },
|
||||
[responses[0].id]: {
|
||||
name: rowActions[0].name,
|
||||
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 () => {
|
||||
const rowAction = createRowActionRequest()
|
||||
const dirtyRowAction = {
|
||||
...rowAction,
|
||||
name: rowAction.name,
|
||||
id: generator.guid(),
|
||||
valueToIgnore: generator.string(),
|
||||
}
|
||||
|
@ -161,17 +179,19 @@ describe("/rowsActions", () => {
|
|||
})
|
||||
|
||||
expect(res).toEqual({
|
||||
name: rowAction.name,
|
||||
id: expect.any(String),
|
||||
tableId,
|
||||
...rowAction,
|
||||
automationId: expectAutomationId(),
|
||||
})
|
||||
|
||||
expect(await config.api.rowAction.find(tableId)).toEqual({
|
||||
actions: {
|
||||
[res.id]: {
|
||||
name: rowAction.name,
|
||||
id: res.id,
|
||||
tableId: tableId,
|
||||
...rowAction,
|
||||
automationId: expectAutomationId(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -213,6 +233,17 @@ describe("/rowsActions", () => {
|
|||
|
||||
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", () => {
|
||||
|
@ -264,7 +295,6 @@ describe("/rowsActions", () => {
|
|||
const updatedName = generator.string()
|
||||
|
||||
const res = await config.api.rowAction.update(tableId, actionId, {
|
||||
...actionData,
|
||||
name: updatedName,
|
||||
})
|
||||
|
||||
|
@ -272,14 +302,17 @@ describe("/rowsActions", () => {
|
|||
id: actionId,
|
||||
tableId,
|
||||
name: updatedName,
|
||||
automationId: actionData.automationId,
|
||||
})
|
||||
|
||||
expect(await config.api.rowAction.find(tableId)).toEqual(
|
||||
expect.objectContaining({
|
||||
actions: expect.objectContaining({
|
||||
[actionId]: {
|
||||
...actionData,
|
||||
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, {
|
||||
...rowAction,
|
||||
name: " action name ",
|
||||
})
|
||||
|
||||
|
@ -408,5 +440,26 @@ describe("/rowsActions", () => {
|
|||
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,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -54,7 +54,7 @@ export const clearAllApps = async (
|
|||
}
|
||||
|
||||
export const clearAllAutomations = async (config: TestConfiguration) => {
|
||||
const automations = await config.getAllAutomations()
|
||||
const { automations } = await config.getAllAutomations()
|
||||
for (let auto of automations) {
|
||||
await context.doInAppContext(config.getAppId(), async () => {
|
||||
await config.deleteAutomation(auto)
|
||||
|
|
|
@ -1,15 +1,24 @@
|
|||
import {
|
||||
AutomationTriggerSchema,
|
||||
AutomationTriggerStepId,
|
||||
} from "@budibase/types"
|
||||
import * as app from "./app"
|
||||
import * as cron from "./cron"
|
||||
import * as rowDeleted from "./rowDeleted"
|
||||
import * as rowSaved from "./rowSaved"
|
||||
import * as rowUpdated from "./rowUpdated"
|
||||
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_UPDATED: rowUpdated.definition,
|
||||
ROW_DELETED: rowDeleted.definition,
|
||||
WEBHOOK: webhook.definition,
|
||||
APP: app.definition,
|
||||
CRON: cron.definition,
|
||||
ROW_ACTION: rowAction.definition,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
import { sdk } from "@budibase/shared-core"
|
||||
import {
|
||||
Automation,
|
||||
RequiredKeys,
|
||||
|
@ -16,6 +17,11 @@ import {
|
|||
import { definitions } from "../../../automations/triggerInfo"
|
||||
import automations from "."
|
||||
|
||||
export interface PersistedAutomation extends Automation {
|
||||
_id: string
|
||||
_rev: string
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
return context.getAppDB()
|
||||
}
|
||||
|
@ -76,7 +82,7 @@ async function handleStepEvents(
|
|||
|
||||
export async function fetch() {
|
||||
const db = getDb()
|
||||
const response = await db.allDocs<Automation>(
|
||||
const response = await db.allDocs<PersistedAutomation>(
|
||||
getAutomationParams(null, {
|
||||
include_docs: true,
|
||||
})
|
||||
|
@ -89,7 +95,7 @@ export async function fetch() {
|
|||
|
||||
export async function get(automationId: string) {
|
||||
const db = getDb()
|
||||
const result = await db.get<Automation>(automationId)
|
||||
const result = await db.get<PersistedAutomation>(automationId)
|
||||
return trimUnexpectedObjectFields(result)
|
||||
}
|
||||
|
||||
|
@ -127,6 +133,9 @@ export async function update(automation: Automation) {
|
|||
const db = getDb()
|
||||
|
||||
const oldAutomation = await db.get<Automation>(automation._id)
|
||||
|
||||
guardInvalidUpdatesAndThrow(automation, oldAutomation)
|
||||
|
||||
automation = cleanAutomationInputs(automation)
|
||||
automation = await checkForWebhooks({
|
||||
oldAuto: oldAutomation,
|
||||
|
@ -254,6 +263,41 @@ async function checkForWebhooks({ oldAuto, newAuto }: any) {
|
|||
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 {
|
||||
// This will ensure all the automation fields (and nothing else) is mapped to the result
|
||||
const allRequired: RequiredKeys<Automation> = {
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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) {
|
||||
return automation.definition.steps.some(
|
||||
(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
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@ import {
|
|||
TableRowActions,
|
||||
VirtualDocumentType,
|
||||
} from "@budibase/types"
|
||||
import automations from "./automations"
|
||||
import { definitions as TRIGGER_DEFINITIONS } from "../../automations/triggerInfo"
|
||||
|
||||
function ensureUniqueAndThrow(
|
||||
doc: TableRowActions,
|
||||
|
@ -41,13 +43,40 @@ export async function create(tableId: string, rowAction: { name: string }) {
|
|||
|
||||
ensureUniqueAndThrow(doc, action.name)
|
||||
|
||||
const newId = `${VirtualDocumentType.ROW_ACTION}${SEPARATOR}${utils.newid()}`
|
||||
doc.actions[newId] = action
|
||||
const appId = context.getAppId()
|
||||
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)
|
||||
|
||||
return {
|
||||
id: newId,
|
||||
...action,
|
||||
id: newRowActionId,
|
||||
...doc.actions[newRowActionId],
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,27 +110,34 @@ export async function update(
|
|||
|
||||
ensureUniqueAndThrow(actionsDoc, action.name, rowActionId)
|
||||
|
||||
actionsDoc.actions[rowActionId] = action
|
||||
actionsDoc.actions[rowActionId] = {
|
||||
automationId: actionsDoc.actions[rowActionId].automationId,
|
||||
...action,
|
||||
}
|
||||
|
||||
const db = context.getAppDB()
|
||||
await db.put(actionsDoc)
|
||||
|
||||
return {
|
||||
id: rowActionId,
|
||||
...action,
|
||||
...actionsDoc.actions[rowActionId],
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(tableId: string, rowActionId: string) {
|
||||
const actionsDoc = await get(tableId)
|
||||
|
||||
if (!actionsDoc.actions[rowActionId]) {
|
||||
const rowAction = actionsDoc.actions[rowActionId]
|
||||
if (!rowAction) {
|
||||
throw new HTTPError(
|
||||
`Row action '${rowActionId}' not found in '${tableId}'`,
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const { automationId } = rowAction
|
||||
const automation = await automations.get(automationId)
|
||||
await automations.remove(automation._id, automation._rev)
|
||||
delete actionsDoc.actions[rowActionId]
|
||||
|
||||
const db = context.getAppDB()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -14,6 +14,7 @@ import { QueryAPI } from "./query"
|
|||
import { RoleAPI } from "./role"
|
||||
import { TemplateAPI } from "./template"
|
||||
import { RowActionAPI } from "./rowAction"
|
||||
import { AutomationAPI } from "./automation"
|
||||
|
||||
export default class API {
|
||||
table: TableAPI
|
||||
|
@ -31,6 +32,7 @@ export default class API {
|
|||
roles: RoleAPI
|
||||
templates: TemplateAPI
|
||||
rowAction: RowActionAPI
|
||||
automation: AutomationAPI
|
||||
|
||||
constructor(config: TestConfiguration) {
|
||||
this.table = new TableAPI(config)
|
||||
|
@ -48,5 +50,6 @@ export default class API {
|
|||
this.roles = new RoleAPI(config)
|
||||
this.templates = new TemplateAPI(config)
|
||||
this.rowAction = new RowActionAPI(config)
|
||||
this.automation = new AutomationAPI(config)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
||||
if (trigger) {
|
||||
|
@ -176,6 +179,16 @@ export function newAutomation({ steps, trigger }: any = {}) {
|
|||
return automation
|
||||
}
|
||||
|
||||
export function rowActionAutomation() {
|
||||
const automation = newAutomation({
|
||||
trigger: {
|
||||
...automationTrigger(),
|
||||
stepId: AutomationTriggerStepId.ROW_ACTION,
|
||||
},
|
||||
})
|
||||
return automation
|
||||
}
|
||||
|
||||
export function basicAutomation(appId?: string): Automation {
|
||||
return {
|
||||
name: "My Automation",
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
export * as applications from "./applications"
|
||||
export * as automations from "./automations"
|
||||
export * as users from "./users"
|
||||
|
|
|
@ -7,6 +7,7 @@ export interface UpdateRowActionRequest extends RowActionData {}
|
|||
export interface RowActionResponse extends RowActionData {
|
||||
id: string
|
||||
tableId: string
|
||||
automationId: string
|
||||
}
|
||||
|
||||
export interface RowActionsResponse {
|
||||
|
|
|
@ -1,3 +1,24 @@
|
|||
import { DocumentDestroyResponse } from "@budibase/nano"
|
||||
import { Automation } from "../../documents"
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -45,6 +45,7 @@ export enum AutomationTriggerStepId {
|
|||
WEBHOOK = "WEBHOOK",
|
||||
APP = "APP",
|
||||
CRON = "CRON",
|
||||
ROW_ACTION = "ROW_ACTION",
|
||||
}
|
||||
|
||||
export enum AutomationStepType {
|
||||
|
@ -152,6 +153,7 @@ interface BaseIOStructure {
|
|||
[key: string]: BaseIOStructure
|
||||
}
|
||||
required?: string[]
|
||||
readonly?: true
|
||||
}
|
||||
|
||||
export interface InputOutputBlock {
|
||||
|
@ -192,6 +194,7 @@ export interface AutomationStep extends AutomationStepSchema {
|
|||
}
|
||||
|
||||
export interface AutomationTriggerSchema extends AutomationStepSchema {
|
||||
type: AutomationStepType.TRIGGER
|
||||
event?: string
|
||||
cronJobId?: string
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ export interface TableRowActions extends Document {
|
|||
string,
|
||||
{
|
||||
name: string
|
||||
automationId: string
|
||||
}
|
||||
>
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue