diff --git a/packages/builder/assets/MagicWand.svelte b/packages/builder/assets/MagicWand.svelte new file mode 100644 index 0000000000..14a06ebaec --- /dev/null +++ b/packages/builder/assets/MagicWand.svelte @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/packages/builder/package.json b/packages/builder/package.json index f9e6becbab..aec0b509f0 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -67,6 +67,7 @@ "@spectrum-css/vars": "^3.0.1", "@zerodevx/svelte-json-view": "^1.0.7", "codemirror": "^5.65.16", + "cron-parser": "^4.9.0", "dayjs": "^1.10.8", "downloadjs": "1.4.7", "fast-json-patch": "^3.1.1", diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 66fb8d9cba..bc65c234e9 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -1050,7 +1050,7 @@ {:else if value.customType === "cron"} onChange({ [key]: e.detail })} - value={inputData[key]} + cronExpression={inputData[key]} /> {:else if value.customType === "automationFields"} - import { Button, Select, Input, Label } from "@budibase/bbui" + import { + Select, + InlineAlert, + Input, + Label, + Layout, + notifications, + } from "@budibase/bbui" import { onMount, createEventDispatcher } from "svelte" import { flags } from "stores/builder" + import { licensing } from "stores/portal" + import { API } from "api" + import MagicWand from "../../../../assets/MagicWand.svelte" + import { helpers, REBOOT_CRON } from "@budibase/shared-core" const dispatch = createEventDispatcher() - export let value + export let cronExpression + let error + let nextExecutions + // AI prompt + let aiCronPrompt = "" + let loadingAICronExpression = false + + $: aiEnabled = + $licensing.customAIConfigsEnabled || $licensing.budibaseAIEnabled $: { - const exists = CRON_EXPRESSIONS.some(cron => cron.value === value) - const customIndex = CRON_EXPRESSIONS.findIndex( - cron => cron.label === "Custom" - ) - - if (!exists && customIndex === -1) { - CRON_EXPRESSIONS[0] = { label: "Custom", value: value } - } else if (exists && customIndex !== -1) { - CRON_EXPRESSIONS.splice(customIndex, 1) + if (cronExpression) { + try { + nextExecutions = helpers.cron + .getNextExecutionDates(cronExpression) + .join("\n") + } catch (err) { + nextExecutions = null + } } } const onChange = e => { - if (value !== REBOOT_CRON) { + if (e.detail !== REBOOT_CRON) { error = helpers.cron.validate(e.detail).err } - if (e.detail === value || error) { + if (e.detail === cronExpression || error) { return } - value = e.detail + cronExpression = e.detail dispatch("change", e.detail) } + const updatePreset = e => { + aiCronPrompt = "" + onChange(e) + } + + const updateCronExpression = e => { + aiCronPrompt = "" + cronExpression = null + nextExecutions = null + onChange(e) + } + let touched = false - let presets = false const CRON_EXPRESSIONS = [ { @@ -64,45 +93,130 @@ }) } }) + + async function generateAICronExpression() { + loadingAICronExpression = true + try { + const response = await API.generateCronExpression({ + prompt: aiCronPrompt, + }) + cronExpression = response.message + dispatch("change", response.message) + } catch (err) { + notifications.error(err.message) + } finally { + loadingAICronExpression = false + } + } - + + + + + {#if aiEnabled} + + + {#if aiCronPrompt} + + + + {/if} + + {/if} (touched = true)} updateOnChange={false} /> - {#if touched && !value} + {#if touched && !cronExpression} Please specify a CRON expression {/if} - - (presets = !presets)} - >{presets ? "Hide" : "Show"} Presets - {#if presets} - - {/if} - - + {#if nextExecutions} + + {/if} + diff --git a/packages/builder/src/components/backend/DataTable/modals/grid/GridCreateColumnModal.svelte b/packages/builder/src/components/backend/DataTable/modals/grid/GridCreateColumnModal.svelte index 2040f66706..d031e752cd 100644 --- a/packages/builder/src/components/backend/DataTable/modals/grid/GridCreateColumnModal.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/grid/GridCreateColumnModal.svelte @@ -2,7 +2,12 @@ import { getContext } from "svelte" import CreateEditColumn from "components/backend/DataTable/modals/CreateEditColumn.svelte" - const { datasource } = getContext("grid") + const { datasource, rows } = getContext("grid") + + const onUpdate = async () => { + await datasource.actions.refreshDefinition() + await rows.actions.refreshData() + } - + diff --git a/packages/frontend-core/src/api/ai.js b/packages/frontend-core/src/api/ai.js new file mode 100644 index 0000000000..7fa756a19e --- /dev/null +++ b/packages/frontend-core/src/api/ai.js @@ -0,0 +1,11 @@ +export const buildAIEndpoints = API => ({ + /** + * Generates a cron expression from a prompt + */ + generateCronExpression: async ({ prompt }) => { + return await API.post({ + url: "/api/ai/cron", + body: { prompt }, + }) + }, +}) diff --git a/packages/frontend-core/src/api/index.js b/packages/frontend-core/src/api/index.js index 066ab16f6e..f8315cbd2d 100644 --- a/packages/frontend-core/src/api/index.js +++ b/packages/frontend-core/src/api/index.js @@ -2,6 +2,7 @@ import { Helpers } from "@budibase/bbui" import { Header } from "@budibase/shared-core" import { ApiVersion } from "../constants" import { buildAnalyticsEndpoints } from "./analytics" +import { buildAIEndpoints } from "./ai" import { buildAppEndpoints } from "./app" import { buildAttachmentEndpoints } from "./attachments" import { buildAuthEndpoints } from "./auth" @@ -268,6 +269,7 @@ export const createAPIClient = config => { // Attach all endpoints return { ...API, + ...buildAIEndpoints(API), ...buildAnalyticsEndpoints(API), ...buildAppEndpoints(API), ...buildAttachmentEndpoints(API), diff --git a/packages/pro b/packages/pro index e2fe0f9cc8..aca9828117 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit e2fe0f9cc856b4ee1a97df96d623b2d87d4e8733 +Subproject commit aca9828117bb97f54f40ee359f1a3f6e259174e7 diff --git a/packages/server/src/api/routes/index.ts b/packages/server/src/api/routes/index.ts index 2079eb01fd..e05bc44893 100644 --- a/packages/server/src/api/routes/index.ts +++ b/packages/server/src/api/routes/index.ts @@ -33,6 +33,7 @@ import rowActionRoutes from "./rowAction" export { default as staticRoutes } from "./static" export { default as publicRoutes } from "./public" +const aiRoutes = pro.ai const appBackupRoutes = pro.appBackups const environmentVariableRoutes = pro.environmentVariables @@ -67,6 +68,7 @@ export const mainRoutes: Router[] = [ debugRoutes, environmentVariableRoutes, rowActionRoutes, + aiRoutes, // these need to be handled last as they still use /api/:tableId // this could be breaking as koa may recognise other routes as this tableRoutes, diff --git a/packages/server/src/automations/tests/scenarios/branching.spec.ts b/packages/server/src/automations/tests/scenarios/branching.spec.ts index ae89fc18b5..032b729e44 100644 --- a/packages/server/src/automations/tests/scenarios/branching.spec.ts +++ b/packages/server/src/automations/tests/scenarios/branching.spec.ts @@ -17,44 +17,65 @@ describe("Branching automations", () => { afterAll(setup.afterAll) it("should run a multiple nested branching automation", async () => { + const firstLogId = "11111111-1111-1111-1111-111111111111" + const branch1LogId = "22222222-2222-2222-2222-222222222222" + const branch2LogId = "33333333-3333-3333-3333-333333333333" + const branch2Id = "44444444-4444-4444-4444-444444444444" + const builder = createAutomationBuilder({ name: "Test Trigger with Loop and Create Row", }) const results = await builder .appAction({ fields: {} }) - .serverLog({ text: "Starting automation" }) + .serverLog( + { text: "Starting automation" }, + { stepName: "FirstLog", stepId: firstLogId } + ) .branch({ topLevelBranch1: { steps: stepBuilder => - stepBuilder.serverLog({ text: "Branch 1" }).branch({ - branch1: { - steps: stepBuilder => - stepBuilder.serverLog({ text: "Branch 1.1" }), - condition: { - equal: { "{{steps.1.success}}": true }, + stepBuilder + .serverLog( + { text: "Branch 1" }, + { stepId: "66666666-6666-6666-6666-666666666666" } + ) + .branch({ + branch1: { + steps: stepBuilder => + stepBuilder.serverLog( + { text: "Branch 1.1" }, + { stepId: branch1LogId } + ), + condition: { + equal: { [`{{ steps.${firstLogId}.success }}`]: true }, + }, }, - }, - branch2: { - steps: stepBuilder => - stepBuilder.serverLog({ text: "Branch 1.2" }), - condition: { - equal: { "{{steps.1.success}}": false }, + branch2: { + steps: stepBuilder => + stepBuilder.serverLog( + { text: "Branch 1.2" }, + { stepId: branch2LogId } + ), + condition: { + equal: { [`{{ steps.${firstLogId}.success }}`]: false }, + }, }, - }, - }), + }), condition: { - equal: { "{{steps.1.success}}": true }, + equal: { [`{{ steps.${firstLogId}.success }}`]: true }, }, }, topLevelBranch2: { - steps: stepBuilder => stepBuilder.serverLog({ text: "Branch 2" }), + steps: stepBuilder => + stepBuilder.serverLog({ text: "Branch 2" }, { stepId: branch2Id }), condition: { - equal: { "{{steps.1.success}}": false }, + equal: { [`{{ steps.${firstLogId}.success }}`]: false }, }, }, }) .run() + expect(results.steps[3].outputs.status).toContain("branch1 branch taken") expect(results.steps[4].outputs.message).toContain("Branch 1.1") }) diff --git a/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts b/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts index 2269f075b2..6aaf22cd6a 100644 --- a/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts +++ b/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts @@ -64,18 +64,18 @@ class BaseStepBuilder { stepId: TStep, stepSchema: Omit, inputs: AutomationStepInputs, - stepName?: string + opts?: { stepName?: string; stepId?: string } ): this { - const id = uuidv4() + const id = opts?.stepId || uuidv4() this.steps.push({ ...stepSchema, inputs: inputs as any, id, stepId, - name: stepName || stepSchema.name, + name: opts?.stepName || stepSchema.name, }) - if (stepName) { - this.stepNames[id] = stepName + if (opts?.stepName) { + this.stepNames[id] = opts.stepName } return this } @@ -95,7 +95,6 @@ class BaseStepBuilder { }) branchStepInputs.children![key] = stepBuilder.build() }) - const branchStep: AutomationStep = { ...definition, id: uuidv4(), @@ -106,80 +105,98 @@ class BaseStepBuilder { } // STEPS - createRow(inputs: CreateRowStepInputs, opts?: { stepName?: string }): this { + createRow( + inputs: CreateRowStepInputs, + opts?: { stepName?: string; stepId?: string } + ): this { return this.step( AutomationActionStepId.CREATE_ROW, BUILTIN_ACTION_DEFINITIONS.CREATE_ROW, inputs, - opts?.stepName + opts ) } - updateRow(inputs: UpdateRowStepInputs, opts?: { stepName?: string }): this { + updateRow( + inputs: UpdateRowStepInputs, + opts?: { stepName?: string; stepId?: string } + ): this { return this.step( AutomationActionStepId.UPDATE_ROW, BUILTIN_ACTION_DEFINITIONS.UPDATE_ROW, inputs, - opts?.stepName + opts ) } - deleteRow(inputs: DeleteRowStepInputs, opts?: { stepName?: string }): this { + deleteRow( + inputs: DeleteRowStepInputs, + opts?: { stepName?: string; stepId?: string } + ): this { return this.step( AutomationActionStepId.DELETE_ROW, BUILTIN_ACTION_DEFINITIONS.DELETE_ROW, inputs, - opts?.stepName + opts ) } sendSmtpEmail( inputs: SmtpEmailStepInputs, - opts?: { stepName?: string } + opts?: { stepName?: string; stepId?: string } ): this { return this.step( AutomationActionStepId.SEND_EMAIL_SMTP, BUILTIN_ACTION_DEFINITIONS.SEND_EMAIL_SMTP, inputs, - opts?.stepName + opts ) } executeQuery( inputs: ExecuteQueryStepInputs, - opts?: { stepName?: string } + opts?: { stepName?: string; stepId?: string } ): this { return this.step( AutomationActionStepId.EXECUTE_QUERY, BUILTIN_ACTION_DEFINITIONS.EXECUTE_QUERY, inputs, - opts?.stepName + opts ) } - queryRows(inputs: QueryRowsStepInputs, opts?: { stepName?: string }): this { + queryRows( + inputs: QueryRowsStepInputs, + opts?: { stepName?: string; stepId?: string } + ): this { return this.step( AutomationActionStepId.QUERY_ROWS, BUILTIN_ACTION_DEFINITIONS.QUERY_ROWS, inputs, - opts?.stepName + opts ) } - loop(inputs: LoopStepInputs, opts?: { stepName?: string }): this { + loop( + inputs: LoopStepInputs, + opts?: { stepName?: string; stepId?: string } + ): this { return this.step( AutomationActionStepId.LOOP, BUILTIN_ACTION_DEFINITIONS.LOOP, inputs, - opts?.stepName + opts ) } - serverLog(input: ServerLogStepInputs, opts?: { stepName?: string }): this { + serverLog( + input: ServerLogStepInputs, + opts?: { stepName?: string; stepId?: string } + ): this { return this.step( AutomationActionStepId.SERVER_LOG, BUILTIN_ACTION_DEFINITIONS.SERVER_LOG, input, - opts?.stepName + opts ) } diff --git a/packages/server/src/definitions/automations.ts b/packages/server/src/definitions/automations.ts index 6488e604e9..9433075da7 100644 --- a/packages/server/src/definitions/automations.ts +++ b/packages/server/src/definitions/automations.ts @@ -15,7 +15,8 @@ export interface TriggerOutput { export interface AutomationContext extends AutomationResults { steps: any[] - stepsByName?: Record + stepsById: Record + stepsByName: Record env?: Record trigger: any } diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts index e2a5a1c192..3b47634663 100644 --- a/packages/server/src/threads/automation.ts +++ b/packages/server/src/threads/automation.ts @@ -74,7 +74,7 @@ class Orchestrator { private job: Job private loopStepOutputs: LoopStep[] private stopped: boolean - private executionOutput: AutomationContext + private executionOutput: Omit constructor(job: AutomationJob) { let automation = job.data.automation @@ -91,6 +91,7 @@ class Orchestrator { // step zero is never used as the template string is zero indexed for customer facing this.context = { steps: [{}], + stepsById: {}, stepsByName: {}, trigger: triggerOutput, } @@ -457,8 +458,9 @@ class Orchestrator { inputs: steps[stepToLoopIndex].inputs, }) + this.context.stepsById[steps[stepToLoopIndex].id] = tempOutput const stepName = steps[stepToLoopIndex].name || steps[stepToLoopIndex].id - this.context.stepsByName![stepName] = tempOutput + this.context.stepsByName[stepName] = tempOutput this.context.steps[this.context.steps.length] = tempOutput this.context.steps = this.context.steps.filter( item => !item.hasOwnProperty.call(item, "currentItem") @@ -517,7 +519,10 @@ class Orchestrator { Object.entries(filter).forEach(([_, value]) => { Object.entries(value).forEach(([field, _]) => { const updatedField = field.replace("{{", "{{ literal ") - const fromContext = processStringSync(updatedField, this.context) + const fromContext = processStringSync( + updatedField, + this.processContext(this.context) + ) toFilter[field] = fromContext }) }) @@ -563,9 +568,9 @@ class Orchestrator { } const stepFn = await this.getStepFunctionality(step.stepId) - let inputs = await this.addContextAndProcess( + let inputs = await processObject( originalStepInput, - this.context + this.processContext(this.context) ) inputs = automationUtils.cleanInputValues(inputs, step.schema.inputs) @@ -594,16 +599,16 @@ class Orchestrator { return null } - private async addContextAndProcess(inputs: any, context: any) { + private processContext(context: AutomationContext) { const processContext = { ...context, steps: { ...context.steps, + ...context.stepsById, ...context.stepsByName, }, } - - return processObject(inputs, processContext) + return processContext } private handleStepOutput( @@ -623,6 +628,7 @@ class Orchestrator { } else { this.updateExecutionOutput(step.id, step.stepId, step.inputs, outputs) this.context.steps[this.context.steps.length] = outputs + this.context.stepsById![step.id] = outputs const stepName = step.name || step.id this.context.stepsByName![stepName] = outputs } diff --git a/packages/shared-core/src/helpers/cron.ts b/packages/shared-core/src/helpers/cron.ts index ca1f1badb7..15abda3483 100644 --- a/packages/shared-core/src/helpers/cron.ts +++ b/packages/shared-core/src/helpers/cron.ts @@ -1,4 +1,5 @@ import cronValidate from "cron-validate" +import cronParser from "cron-parser" const INPUT_CRON_START = "(Input cron: " const ERROR_SWAPS = { @@ -30,6 +31,19 @@ function improveErrors(errors: string[]): string[] { return finalErrors } +export function getNextExecutionDates( + cronExpression: string, + limit: number = 4 +): string[] { + const parsed = cronParser.parseExpression(cronExpression) + const nextRuns = [] + for (let i = 0; i < limit; i++) { + nextRuns.push(parsed.next().toString()) + } + + return nextRuns +} + export function validate( cronExpression: string ): { valid: false; err: string[] } | { valid: true } { diff --git a/yarn.lock b/yarn.lock index 46ac61d119..0cddbf981f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -817,23 +817,23 @@ tslib "^2.2.0" "@azure/msal-browser@^3.11.1": - version "3.23.0" - resolved "https://registry.yarnpkg.com/@azure/msal-browser/-/msal-browser-3.23.0.tgz#446aaf268247e5943f464f007d3aa3a04abfe95b" - integrity sha512-+QgdMvaeEpdtgRTD7AHHq9aw8uga7mXVHV1KshO1RQ2uI5B55xJ4aEpGlg/ga3H+0arEVcRfT4ZVmX7QLXiCVw== + version "3.24.0" + resolved "https://registry.yarnpkg.com/@azure/msal-browser/-/msal-browser-3.24.0.tgz#3208047672d0b0c943b0bef5f995d510d6582ae4" + integrity sha512-JGNV9hTYAa7lsum9IMIibn2kKczAojNihGo1hi7pG0kNrcKej530Fl6jxwM05A44/6I079CSn6WxYxbVhKUmWg== dependencies: - "@azure/msal-common" "14.14.2" + "@azure/msal-common" "14.15.0" -"@azure/msal-common@14.14.2": - version "14.14.2" - resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-14.14.2.tgz#583b4ac9c089953718d7a5e2f3b8df2d4dbb17f4" - integrity sha512-XV0P5kSNwDwCA/SjIxTe9mEAsKB0NqGNSuaVrkCCE2lAyBr/D6YtD80Vkdp4tjWnPFwjzkwldjr1xU/facOJog== +"@azure/msal-common@14.15.0": + version "14.15.0" + resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-14.15.0.tgz#0e27ac0bb88fe100f4f8d1605b64d5c268636a55" + integrity sha512-ImAQHxmpMneJ/4S8BRFhjt1MZ3bppmpRPYYNyzeQPeFN288YKbb8TmmISQEbtfkQ1BPASvYZU5doIZOPBAqENQ== "@azure/msal-node@^2.9.2": - version "2.13.1" - resolved "https://registry.yarnpkg.com/@azure/msal-node/-/msal-node-2.13.1.tgz#f144371275b7c3cbe564762b84772a9732457a47" - integrity sha512-sijfzPNorKt6+9g1/miHwhj6Iapff4mPQx1azmmZExgzUROqWTM1o3ACyxDja0g47VpowFy/sxTM/WsuCyXTiw== + version "2.14.0" + resolved "https://registry.yarnpkg.com/@azure/msal-node/-/msal-node-2.14.0.tgz#7881895d41b03d8b9b38a29550ba3bbb15f73b3c" + integrity sha512-rrfzIpG3Q1rHjVYZmHAEDidWAZZ2cgkxlIcMQ8dHebRISaZ2KCV33Q8Vs+uaV6lxweROabNxKFlR2lIKagZqYg== dependencies: - "@azure/msal-common" "14.14.2" + "@azure/msal-common" "14.15.0" jsonwebtoken "^9.0.0" uuid "^8.3.0" @@ -9167,6 +9167,13 @@ cron-parser@^4.2.1: dependencies: luxon "^3.2.1" +cron-parser@^4.9.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5" + integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q== + dependencies: + luxon "^3.2.1" + cron-validate@1.4.5: version "1.4.5" resolved "https://registry.yarnpkg.com/cron-validate/-/cron-validate-1.4.5.tgz#eceb221f7558e6302e5f84c7b3a454fdf4d064c3"