mirror of https://github.com/Budibase/budibase.git
80 changed files with 1401 additions and 1098 deletions
@ -1,46 +1,52 @@ |
|||
context('Create a workflow', () => { |
|||
|
|||
before(() => { |
|||
cy.server() |
|||
cy.visit('localhost:4001/_builder') |
|||
|
|||
cy.createApp('Workflow Test App', 'This app is used to test that workflows do in fact work!') |
|||
}) |
|||
|
|||
// https://on.cypress.io/interacting-with-elements
|
|||
it('should create a workflow', () => { |
|||
cy.createTestTableWithData() |
|||
|
|||
cy.contains('workflow').click() |
|||
cy.contains('Create New Workflow').click() |
|||
cy.get('input').type('Add Record') |
|||
cy.contains('Save').click() |
|||
|
|||
// Add trigger
|
|||
cy.get('[data-cy=add-workflow-component]').click() |
|||
cy.get('[data-cy=RECORD_SAVED]').click() |
|||
cy.get('.budibase__input').select('dog') |
|||
|
|||
// Create action
|
|||
cy.get('[data-cy=SAVE_RECORD]').click() |
|||
cy.get('.container input').first().type('goodboy') |
|||
cy.get('.container input').eq(1).type('11') |
|||
|
|||
// Save
|
|||
cy.contains('Save Workflow').click() |
|||
|
|||
// Activate Workflow
|
|||
cy.get('[data-cy=activate-workflow]').click() |
|||
cy.contains("Add Record").should("be.visible") |
|||
cy.get(".stop-button.highlighted").should("be.visible") |
|||
}) |
|||
|
|||
it('should add record when a new record is added', () => { |
|||
cy.contains('backend').click() |
|||
|
|||
cy.addRecord(["Rover", 15]) |
|||
cy.reload() |
|||
cy.contains('goodboy').should('have.text', 'goodboy') |
|||
|
|||
}) |
|||
}) |
|||
context("Create a workflow", () => { |
|||
before(() => { |
|||
cy.server() |
|||
cy.visit("localhost:4001/_builder") |
|||
|
|||
cy.createApp( |
|||
"Workflow Test App", |
|||
"This app is used to test that workflows do in fact work!" |
|||
) |
|||
}) |
|||
|
|||
// https://on.cypress.io/interacting-with-elements
|
|||
it("should create a workflow", () => { |
|||
cy.createTestTableWithData() |
|||
|
|||
cy.contains("workflow").click() |
|||
cy.contains("Create New Workflow").click() |
|||
cy.get("input").type("Add Record") |
|||
cy.contains("Save").click() |
|||
|
|||
// Add trigger
|
|||
cy.get("[data-cy=add-workflow-component]").click() |
|||
cy.get("[data-cy=RECORD_SAVED]").click() |
|||
cy.get(".budibase__input").select("dog") |
|||
|
|||
// Create action
|
|||
cy.get("[data-cy=SAVE_RECORD]").click() |
|||
cy.get(".budibase__input").select("dog") |
|||
cy.get(".container input") |
|||
.first() |
|||
.type("goodboy") |
|||
cy.get(".container input") |
|||
.eq(1) |
|||
.type("11") |
|||
|
|||
// Save
|
|||
cy.contains("Save Workflow").click() |
|||
|
|||
// Activate Workflow
|
|||
cy.get("[data-cy=activate-workflow]").click() |
|||
cy.contains("Add Record").should("be.visible") |
|||
cy.get(".stop-button.highlighted").should("be.visible") |
|||
}) |
|||
|
|||
it("should add record when a new record is added", () => { |
|||
cy.contains("backend").click() |
|||
|
|||
cy.addRecord(["Rover", 15]) |
|||
cy.reload() |
|||
cy.contains("goodboy").should("have.text", "goodboy") |
|||
}) |
|||
}) |
|||
|
|||
Binary file not shown.
@ -1,57 +1,48 @@ |
|||
import Workflow from "../Workflow"; |
|||
import TEST_WORKFLOW from "./testWorkflow"; |
|||
import Workflow from "../Workflow" |
|||
import TEST_WORKFLOW from "./testWorkflow" |
|||
|
|||
const TEST_BLOCK = { |
|||
id: "VFWeZcIPx", |
|||
name: "Update UI State", |
|||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>", |
|||
icon: "ri-refresh-line", |
|||
description: "Update your User Interface with some data.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
path: "string", |
|||
value: "longText", |
|||
}, |
|||
args: { |
|||
path: "foo", |
|||
value: "started...", |
|||
}, |
|||
actionId: "SET_STATE", |
|||
type: "ACTION", |
|||
id: "AUXJQGZY7", |
|||
name: "Delay", |
|||
icon: "ri-time-fill", |
|||
tagline: "Delay for <b>{{time}}</b> milliseconds", |
|||
description: "Delay the workflow until an amount of time has passed.", |
|||
params: { time: "number" }, |
|||
type: "LOGIC", |
|||
args: { time: "5000" }, |
|||
stepId: "DELAY", |
|||
} |
|||
|
|||
describe("Workflow Data Object", () => { |
|||
let workflow |
|||
|
|||
beforeEach(() => { |
|||
workflow = new Workflow({ ...TEST_WORKFLOW }); |
|||
}); |
|||
workflow = new Workflow({ ...TEST_WORKFLOW }) |
|||
}) |
|||
|
|||
it("adds a workflow block to the workflow", () => { |
|||
workflow.addBlock(TEST_BLOCK); |
|||
workflow.addBlock(TEST_BLOCK) |
|||
expect(workflow.workflow.definition) |
|||
}) |
|||
|
|||
it("updates a workflow block with new attributes", () => { |
|||
const firstBlock = workflow.workflow.definition.steps[0]; |
|||
const firstBlock = workflow.workflow.definition.steps[0] |
|||
const updatedBlock = { |
|||
...firstBlock, |
|||
name: "UPDATED" |
|||
}; |
|||
workflow.updateBlock(updatedBlock, firstBlock.id); |
|||
name: "UPDATED", |
|||
} |
|||
workflow.updateBlock(updatedBlock, firstBlock.id) |
|||
expect(workflow.workflow.definition.steps[0]).toEqual(updatedBlock) |
|||
}) |
|||
|
|||
it("deletes a workflow block successfully", () => { |
|||
const { steps } = workflow.workflow.definition |
|||
const originalLength = steps.length |
|||
|
|||
const lastBlock = steps[steps.length - 1]; |
|||
workflow.deleteBlock(lastBlock.id); |
|||
expect(workflow.workflow.definition.steps.length).toBeLessThan(originalLength); |
|||
}) |
|||
const originalLength = steps.length |
|||
|
|||
it("builds a tree that gets rendered in the flowchart builder", () => { |
|||
expect(Workflow.buildUiTree(TEST_WORKFLOW.definition)).toMatchSnapshot(); |
|||
const lastBlock = steps[steps.length - 1] |
|||
workflow.deleteBlock(lastBlock.id) |
|||
expect(workflow.workflow.definition.steps.length).toBeLessThan( |
|||
originalLength |
|||
) |
|||
}) |
|||
}) |
|||
|
|||
@ -1,49 +0,0 @@ |
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
|||
|
|||
exports[`Workflow Data Object builds a tree that gets rendered in the flowchart builder 1`] = ` |
|||
Array [ |
|||
Object { |
|||
"args": Object { |
|||
"time": 3000, |
|||
}, |
|||
"body": "Delay for <b>3000</b> milliseconds", |
|||
"heading": "DELAY", |
|||
"id": "zJQcZUgDS", |
|||
"name": "Delay", |
|||
"params": Object { |
|||
"time": "number", |
|||
}, |
|||
"type": "LOGIC", |
|||
}, |
|||
Object { |
|||
"args": Object { |
|||
"path": "foo", |
|||
"value": "finished", |
|||
}, |
|||
"body": "Update <b>foo</b> to <b>finished</b>", |
|||
"heading": "SET_STATE", |
|||
"id": "3RSTO7BMB", |
|||
"name": "Update UI State", |
|||
"params": Object { |
|||
"path": "string", |
|||
"value": "longText", |
|||
}, |
|||
"type": "ACTION", |
|||
}, |
|||
Object { |
|||
"args": Object { |
|||
"path": "foo", |
|||
"value": "started...", |
|||
}, |
|||
"body": "Update <b>foo</b> to <b>started...</b>", |
|||
"heading": "SET_STATE", |
|||
"id": "VFWeZcIPx", |
|||
"name": "Update UI State", |
|||
"params": Object { |
|||
"path": "string", |
|||
"value": "longText", |
|||
}, |
|||
"type": "ACTION", |
|||
}, |
|||
] |
|||
`; |
|||
@ -1,63 +1,78 @@ |
|||
export default { |
|||
_id: "53b6148c65d1429c987e046852d11611", |
|||
_rev: "4-02c6659734934895812fa7be0215ee59", |
|||
name: "Test Workflow", |
|||
name: "Test workflow", |
|||
definition: { |
|||
steps: [ |
|||
{ |
|||
id: "VFWeZcIPx", |
|||
name: "Update UI State", |
|||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>", |
|||
icon: "ri-refresh-line", |
|||
description: "Update your User Interface with some data.", |
|||
environment: "CLIENT", |
|||
id: "ANBDINAPS", |
|||
description: "Send an email.", |
|||
tagline: "Send email to <b>{{to}}</b>", |
|||
icon: "ri-mail-open-fill", |
|||
name: "Send Email", |
|||
params: { |
|||
path: "string", |
|||
value: "longText", |
|||
to: "string", |
|||
from: "string", |
|||
subject: "longText", |
|||
text: "longText", |
|||
}, |
|||
args: { |
|||
path: "foo", |
|||
value: "started...", |
|||
}, |
|||
actionId: "SET_STATE", |
|||
type: "ACTION", |
|||
}, |
|||
{ |
|||
id: "zJQcZUgDS", |
|||
name: "Delay", |
|||
icon: "ri-time-fill", |
|||
tagline: "Delay for <b>{{time}}</b> milliseconds", |
|||
description: "Delay the workflow until an amount of time has passed.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
time: "number", |
|||
}, |
|||
args: { |
|||
time: 3000, |
|||
text: "A user was created!", |
|||
subject: "New Budibase User", |
|||
from: "budimaster@budibase.com", |
|||
to: "test@test.com", |
|||
}, |
|||
actionId: "DELAY", |
|||
type: "LOGIC", |
|||
stepId: "SEND_EMAIL", |
|||
}, |
|||
{ |
|||
id: "3RSTO7BMB", |
|||
name: "Update UI State", |
|||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>", |
|||
icon: "ri-refresh-line", |
|||
description: "Update your User Interface with some data.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
path: "string", |
|||
value: "longText", |
|||
}, |
|||
args: { |
|||
path: "foo", |
|||
value: "finished", |
|||
], |
|||
trigger: { |
|||
id: "iRzYMOqND", |
|||
name: "Record Saved", |
|||
event: "record:save", |
|||
icon: "ri-save-line", |
|||
tagline: "Record is added to <b>{{model.name}}</b>", |
|||
description: "Fired when a record is saved to your database.", |
|||
params: { model: "model" }, |
|||
type: "TRIGGER", |
|||
args: { |
|||
model: { |
|||
type: "model", |
|||
views: {}, |
|||
name: "users", |
|||
schema: { |
|||
name: { |
|||
type: "string", |
|||
constraints: { |
|||
type: "string", |
|||
length: { maximum: 123 }, |
|||
presence: { allowEmpty: false }, |
|||
}, |
|||
name: "name", |
|||
}, |
|||
age: { |
|||
type: "number", |
|||
constraints: { |
|||
type: "number", |
|||
presence: { allowEmpty: false }, |
|||
numericality: { |
|||
greaterThanOrEqualTo: "", |
|||
lessThanOrEqualTo: "", |
|||
}, |
|||
}, |
|||
name: "age", |
|||
}, |
|||
}, |
|||
_id: "c6b4e610cd984b588837bca27188a451", |
|||
_rev: "7-b8aa1ce0b53e88928bb88fc11bdc0aff", |
|||
}, |
|||
actionId: "SET_STATE", |
|||
type: "ACTION", |
|||
}, |
|||
], |
|||
stepId: "RECORD_SAVED", |
|||
}, |
|||
}, |
|||
type: "workflow", |
|||
live: true, |
|||
ok: true, |
|||
id: "b384f861f4754e1693835324a7fcca62", |
|||
rev: "1-aa1c2cbd868ef02e26f8fad531dd7e37", |
|||
live: false, |
|||
_id: "b384f861f4754e1693835324a7fcca62", |
|||
_rev: "108-4116829ec375e0481d0ecab9e83a2caf", |
|||
} |
|||
|
|||
@ -1,42 +0,0 @@ |
|||
<script> |
|||
import { store } from "builderStore" |
|||
import deepmerge from "deepmerge" |
|||
import { Label } from "@budibase/bbui" |
|||
|
|||
export let value |
|||
|
|||
let pages = [] |
|||
let components = [] |
|||
let pageName |
|||
|
|||
let selectedPage |
|||
let selectedScreen |
|||
|
|||
$: pages = $store.pages |
|||
$: selectedPage = pages[pageName] |
|||
$: screens = selectedPage ? selectedPage._screens : [] |
|||
$: if (selectedPage) { |
|||
let result = selectedPage |
|||
for (screen of screens) { |
|||
result = deepmerge(result, screen) |
|||
} |
|||
components = result.props._children |
|||
} |
|||
</script> |
|||
|
|||
<div class="bb-margin-xl block-field"> |
|||
<Label small forAttr={'page'}>Page</Label> |
|||
<select class="budibase__input" bind:value={pageName}> |
|||
{#each Object.keys(pages) as page} |
|||
<option value={page}>{page}</option> |
|||
{/each} |
|||
</select> |
|||
{#if components.length > 0} |
|||
<Label small forAttr={'component'}>Component</Label> |
|||
<select class="budibase__input" bind:value> |
|||
{#each components as component} |
|||
<option value={component._id}>{component._id}</option> |
|||
{/each} |
|||
</select> |
|||
{/if} |
|||
</div> |
|||
|
Before Width: | Height: | Size: 241 B After Width: | Height: | Size: 290 B |
@ -1,24 +1,52 @@ |
|||
<script> |
|||
import FlowItem from "./FlowItem.svelte" |
|||
import Arrow from "./Arrow.svelte" |
|||
import { flip } from "svelte/animate" |
|||
import { fade, fly } from "svelte/transition" |
|||
|
|||
export let blocks = [] |
|||
export let workflow |
|||
export let onSelect |
|||
let blocks |
|||
|
|||
$: { |
|||
blocks = [] |
|||
if (workflow) { |
|||
if (workflow.definition.trigger) { |
|||
blocks.push(workflow.definition.trigger) |
|||
} |
|||
blocks = blocks.concat(workflow.definition.steps || []) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<section class="canvas"> |
|||
{#each blocks as block, idx} |
|||
<FlowItem {onSelect} {block} /> |
|||
{#if idx !== blocks.length - 1} |
|||
<Arrow /> |
|||
{/if} |
|||
{#each blocks as block, idx (block.id)} |
|||
<div |
|||
class="block" |
|||
animate:flip={{ duration: 600 }} |
|||
in:fade|local |
|||
out:fly|local={{ x: 100 }}> |
|||
<FlowItem {onSelect} {block} /> |
|||
{#if idx !== blocks.length - 1} |
|||
<Arrow /> |
|||
{/if} |
|||
</div> |
|||
{/each} |
|||
</section> |
|||
|
|||
<style> |
|||
.canvas { |
|||
section { |
|||
position: absolute; |
|||
padding: 20px 40px; |
|||
display: flex; |
|||
align-items: center; |
|||
flex-direction: column; |
|||
} |
|||
|
|||
.block { |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: flex-start; |
|||
align-items: center; |
|||
} |
|||
</style> |
|||
|
|||
@ -1,18 +0,0 @@ |
|||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) |
|||
|
|||
export default { |
|||
NAVIGATE: () => { |
|||
// TODO client navigation
|
|||
}, |
|||
DELAY: async ({ args }) => await delay(args.time), |
|||
FILTER: ({ args }) => { |
|||
const { field, condition, value } = args |
|||
switch (condition) { |
|||
case "equals": |
|||
if (field !== value) return |
|||
break |
|||
default: |
|||
return |
|||
} |
|||
}, |
|||
} |
|||
@ -1,68 +0,0 @@ |
|||
import renderTemplateString from "../../state/renderTemplateString" |
|||
import appStore from "../../state/store" |
|||
import Orchestrator from "./orchestrator" |
|||
import clientActions from "./actions" |
|||
|
|||
// Execute a workflow from a running budibase app
|
|||
export const clientStrategy = ({ api }) => ({ |
|||
context: {}, |
|||
bindContextArgs: function(args) { |
|||
const mappedArgs = { ...args } |
|||
|
|||
// bind the workflow action args to the workflow context, if required
|
|||
for (let arg in args) { |
|||
const argValue = args[arg] |
|||
|
|||
// We don't want to render mustache templates on non-strings
|
|||
if (typeof argValue !== "string") continue |
|||
|
|||
// Render the string with values from the workflow context and state
|
|||
mappedArgs[arg] = renderTemplateString(argValue, { |
|||
context: this.context, |
|||
state: appStore.get(), |
|||
}) |
|||
} |
|||
|
|||
return mappedArgs |
|||
}, |
|||
run: async function(workflow) { |
|||
for (let block of workflow.steps) { |
|||
// This code gets run in the browser
|
|||
if (block.environment === "CLIENT") { |
|||
const action = clientActions[block.actionId] |
|||
await action({ |
|||
context: this.context, |
|||
args: this.bindContextArgs(block.args), |
|||
id: block.id, |
|||
}) |
|||
} |
|||
|
|||
// this workflow block gets executed on the server
|
|||
if (block.environment === "SERVER") { |
|||
const EXECUTE_WORKFLOW_URL = `/api/workflows/action` |
|||
const response = await api.post({ |
|||
url: EXECUTE_WORKFLOW_URL, |
|||
body: { |
|||
action: block.actionId, |
|||
args: this.bindContextArgs(block.args, api), |
|||
}, |
|||
}) |
|||
|
|||
this.context = { |
|||
...this.context, |
|||
[block.actionId]: response, |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
}) |
|||
|
|||
export const triggerWorkflow = api => async ({ workflow }) => { |
|||
const workflowOrchestrator = new Orchestrator(api) |
|||
workflowOrchestrator.strategy = clientStrategy |
|||
|
|||
const EXECUTE_WORKFLOW_URL = `/api/workflows/${workflow}` |
|||
const workflowDefinition = await api.get({ url: EXECUTE_WORKFLOW_URL }) |
|||
|
|||
workflowOrchestrator.execute(workflowDefinition) |
|||
} |
|||
@ -1,22 +0,0 @@ |
|||
/** |
|||
* The workflow orchestrator is a class responsible for executing workflows. |
|||
* It relies on the strategy pattern, which allows composable behaviour to be |
|||
* passed into its execute() function. This allows custom execution behaviour based |
|||
* on where the orchestrator is run. |
|||
* |
|||
*/ |
|||
export default class Orchestrator { |
|||
constructor(api) { |
|||
this.api = api |
|||
} |
|||
|
|||
set strategy(strategy) { |
|||
this._strategy = strategy({ api: this.api }) |
|||
} |
|||
|
|||
async execute(workflow) { |
|||
if (workflow.live) { |
|||
this._strategy.run(workflow.definition) |
|||
} |
|||
} |
|||
} |
|||
@ -1,24 +0,0 @@ |
|||
const userController = require("../../user") |
|||
|
|||
module.exports = async function createUser({ args, instanceId }) { |
|||
const ctx = { |
|||
params: { |
|||
instanceId, |
|||
}, |
|||
request: { |
|||
body: args.user, |
|||
}, |
|||
} |
|||
|
|||
try { |
|||
const response = await userController.create(ctx) |
|||
return { |
|||
user: response, |
|||
} |
|||
} catch (err) { |
|||
console.error(err) |
|||
return { |
|||
user: null, |
|||
} |
|||
} |
|||
} |
|||
@ -1,29 +0,0 @@ |
|||
const recordController = require("../../record") |
|||
|
|||
module.exports = async function saveRecord({ args, context }) { |
|||
const { model, ...record } = args.record |
|||
|
|||
const ctx = { |
|||
params: { |
|||
instanceId: context.instanceId, |
|||
modelId: model._id, |
|||
}, |
|||
request: { |
|||
body: record, |
|||
}, |
|||
user: { instanceId: context.instanceId }, |
|||
} |
|||
|
|||
try { |
|||
await recordController.save(ctx) |
|||
return { |
|||
record: ctx.body, |
|||
} |
|||
} catch (err) { |
|||
console.error(err) |
|||
return { |
|||
record: null, |
|||
error: err.message, |
|||
} |
|||
} |
|||
} |
|||
@ -1,26 +0,0 @@ |
|||
const sgMail = require("@sendgrid/mail") |
|||
|
|||
sgMail.setApiKey(process.env.SENDGRID_API_KEY) |
|||
|
|||
module.exports = async function sendEmail({ args }) { |
|||
const msg = { |
|||
to: args.to, |
|||
from: args.from, |
|||
subject: args.subject, |
|||
text: args.text, |
|||
} |
|||
|
|||
try { |
|||
await sgMail.send(msg) |
|||
return { |
|||
success: true, |
|||
...args, |
|||
} |
|||
} catch (err) { |
|||
console.error(err) |
|||
return { |
|||
success: false, |
|||
error: err.message, |
|||
} |
|||
} |
|||
} |
|||
@ -1,170 +1,113 @@ |
|||
const ACTION = { |
|||
SET_STATE: { |
|||
name: "Update UI State", |
|||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>", |
|||
icon: "ri-refresh-line", |
|||
description: "Update your User Interface with some data.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
path: "string", |
|||
value: "longText", |
|||
}, |
|||
}, |
|||
NAVIGATE: { |
|||
name: "Navigate", |
|||
tagline: "Navigate to <b>{{url}}</b>", |
|||
icon: "ri-navigation-line", |
|||
description: "Navigate to another page.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
url: "string", |
|||
}, |
|||
}, |
|||
SAVE_RECORD: { |
|||
name: "Save Record", |
|||
tagline: "<b>Save</b> a <b>{{record.model.name}}</b> record", |
|||
icon: "ri-save-3-fill", |
|||
description: "Save a record to your database.", |
|||
environment: "SERVER", |
|||
params: { |
|||
record: "record", |
|||
}, |
|||
args: { |
|||
record: {}, |
|||
}, |
|||
type: "ACTION", |
|||
}, |
|||
DELETE_RECORD: { |
|||
description: "Delete a record from your database.", |
|||
icon: "ri-delete-bin-line", |
|||
name: "Delete Record", |
|||
tagline: "<b>Delete</b> a <b>{{record.model.name}}</b> record", |
|||
environment: "SERVER", |
|||
params: { |
|||
record: "record", |
|||
}, |
|||
args: { |
|||
record: {}, |
|||
}, |
|||
params: {}, |
|||
args: {}, |
|||
type: "ACTION", |
|||
}, |
|||
// FIND_RECORD: {
|
|||
// description: "Find a record in your database.",
|
|||
// tagline: "<b>Find</b> a <b>{{record.model.name}}</b> record",
|
|||
// icon: "ri-search-line",
|
|||
// name: "Find Record",
|
|||
// environment: "SERVER",
|
|||
// params: {
|
|||
// record: "string",
|
|||
// },
|
|||
// },
|
|||
CREATE_USER: { |
|||
description: "Create a new user.", |
|||
tagline: "Create user <b>{{username}}</b>", |
|||
icon: "ri-user-add-fill", |
|||
name: "Create User", |
|||
environment: "SERVER", |
|||
params: { |
|||
username: "string", |
|||
password: "password", |
|||
accessLevelId: "accessLevel", |
|||
}, |
|||
args: { |
|||
accessLevelId: "POWER_USER", |
|||
}, |
|||
type: "ACTION", |
|||
}, |
|||
SEND_EMAIL: { |
|||
description: "Send an email.", |
|||
tagline: "Send email to <b>{{to}}</b>", |
|||
icon: "ri-mail-open-fill", |
|||
name: "Send Email", |
|||
environment: "SERVER", |
|||
params: { |
|||
to: "string", |
|||
from: "string", |
|||
subject: "longText", |
|||
text: "longText", |
|||
}, |
|||
type: "ACTION", |
|||
}, |
|||
} |
|||
|
|||
const TRIGGER = { |
|||
RECORD_SAVED: { |
|||
name: "Record Saved", |
|||
event: "record:save", |
|||
icon: "ri-save-line", |
|||
tagline: "Record is added to <b>{{model.name}}</b>", |
|||
description: "Save a record to your database.", |
|||
environment: "SERVER", |
|||
params: { |
|||
model: "model", |
|||
}, |
|||
}, |
|||
RECORD_DELETED: { |
|||
name: "Record Deleted", |
|||
event: "record:delete", |
|||
icon: "ri-delete-bin-line", |
|||
tagline: "Record is deleted from <b>{{model.name}}</b>", |
|||
description: "Fired when a record is deleted from your database.", |
|||
environment: "SERVER", |
|||
params: { |
|||
model: "model", |
|||
}, |
|||
}, |
|||
// CLICK: {
|
|||
// name: "Click",
|
|||
// icon: "ri-cursor-line",
|
|||
// tagline: "{{component}} is clicked",
|
|||
// description: "Trigger when you click on an element in the UI.",
|
|||
// environment: "CLIENT",
|
|||
// params: {
|
|||
// component: "component"
|
|||
// }
|
|||
// },
|
|||
// LOAD: {
|
|||
// name: "Load",
|
|||
// icon: "ri-loader-line",
|
|||
// tagline: "{{component}} is loaded",
|
|||
// description: "Trigger an element has finished loading.",
|
|||
// environment: "CLIENT",
|
|||
// params: {
|
|||
// component: "component"
|
|||
// }
|
|||
// },
|
|||
// INPUT: {
|
|||
// name: "Input",
|
|||
// icon: "ri-text",
|
|||
// tagline: "Text entered into {{component}",
|
|||
// description: "Trigger when you type into an input box.",
|
|||
// environment: "CLIENT",
|
|||
// params: {
|
|||
// component: "component"
|
|||
// }
|
|||
// },
|
|||
} |
|||
|
|||
const LOGIC = { |
|||
FILTER: { |
|||
name: "Filter", |
|||
tagline: "{{field}} <b>{{condition}}</b> {{value}}", |
|||
tagline: "{{filter}} <b>{{condition}}</b> {{value}}", |
|||
icon: "ri-git-branch-line", |
|||
description: "Filter any workflows which do not meet certain conditions.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
filter: "string", |
|||
condition: ["equals"], |
|||
value: "string", |
|||
}, |
|||
args: { |
|||
condition: "equals", |
|||
}, |
|||
type: "LOGIC", |
|||
}, |
|||
DELAY: { |
|||
name: "Delay", |
|||
icon: "ri-time-fill", |
|||
tagline: "Delay for <b>{{time}}</b> milliseconds", |
|||
description: "Delay the workflow until an amount of time has passed.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
time: "number", |
|||
}, |
|||
type: "LOGIC", |
|||
}, |
|||
} |
|||
|
|||
export default { |
|||
const TRIGGER = { |
|||
RECORD_SAVED: { |
|||
name: "Record Saved", |
|||
event: "record:save", |
|||
icon: "ri-save-line", |
|||
tagline: "Record is added to <b>{{model.name}}</b>", |
|||
description: "Fired when a record is saved to your database.", |
|||
params: { |
|||
model: "model", |
|||
}, |
|||
type: "TRIGGER", |
|||
}, |
|||
RECORD_DELETED: { |
|||
name: "Record Deleted", |
|||
event: "record:delete", |
|||
icon: "ri-delete-bin-line", |
|||
tagline: "Record is deleted from <b>{{model.name}}</b>", |
|||
description: "Fired when a record is deleted from your database.", |
|||
params: { |
|||
model: "model", |
|||
}, |
|||
type: "TRIGGER", |
|||
}, |
|||
} |
|||
|
|||
// This contains the definitions for the steps and triggers that make up a workflow, a workflow comprises
|
|||
// of many steps and a single trigger
|
|||
module.exports = { |
|||
ACTION, |
|||
TRIGGER, |
|||
LOGIC, |
|||
TRIGGER, |
|||
} |
|||
@ -1,21 +1,77 @@ |
|||
const Router = require("@koa/router") |
|||
const controller = require("../controllers/workflow") |
|||
const authorized = require("../../middleware/authorized") |
|||
const joiValidator = require("../../middleware/joi-validator") |
|||
const { BUILDER } = require("../../utilities/accessLevels") |
|||
const Joi = require("joi") |
|||
|
|||
const router = Router() |
|||
|
|||
// prettier-ignore
|
|||
function generateStepSchema(allowStepTypes) { |
|||
return Joi.object({ |
|||
stepId: Joi.string().required(), |
|||
id: Joi.string().required(), |
|||
description: Joi.string().required(), |
|||
name: Joi.string().required(), |
|||
tagline: Joi.string().required(), |
|||
icon: Joi.string().required(), |
|||
params: Joi.object(), |
|||
// TODO: validate args a bit more deeply
|
|||
args: Joi.object(), |
|||
type: Joi.string().required().valid(...allowStepTypes), |
|||
}).unknown(true) |
|||
} |
|||
|
|||
// prettier-ignore
|
|||
const workflowValidator = joiValidator.body(Joi.object({ |
|||
live: Joi.bool(), |
|||
id: Joi.string().required(), |
|||
rev: Joi.string().required(), |
|||
name: Joi.string().required(), |
|||
type: Joi.string().valid("workflow").required(), |
|||
definition: Joi.object({ |
|||
steps: Joi.array().required().items(generateStepSchema(["ACTION", "LOGIC"])), |
|||
trigger: generateStepSchema(["TRIGGER"]).required(), |
|||
}).required().unknown(true), |
|||
}).unknown(true)) |
|||
|
|||
router |
|||
.get( |
|||
"/api/workflows/trigger/list", |
|||
authorized(BUILDER), |
|||
controller.getTriggerList |
|||
) |
|||
.get( |
|||
"/api/workflows/action/list", |
|||
authorized(BUILDER), |
|||
controller.getActionList |
|||
) |
|||
.get( |
|||
"/api/workflows/logic/list", |
|||
authorized(BUILDER), |
|||
controller.getLogicList |
|||
) |
|||
.get( |
|||
"/api/workflows/definitions/list", |
|||
authorized(BUILDER), |
|||
controller.getDefinitionList |
|||
) |
|||
.get("/api/workflows", authorized(BUILDER), controller.fetch) |
|||
.get("/api/workflows/:id", authorized(BUILDER), controller.find) |
|||
.get( |
|||
"/api/workflows/:id/:action", |
|||
.put( |
|||
"/api/workflows", |
|||
authorized(BUILDER), |
|||
workflowValidator, |
|||
controller.update |
|||
) |
|||
.post( |
|||
"/api/workflows", |
|||
authorized(BUILDER), |
|||
controller.fetchActionScript |
|||
workflowValidator, |
|||
controller.create |
|||
) |
|||
.put("/api/workflows", authorized(BUILDER), controller.update) |
|||
.post("/api/workflows", authorized(BUILDER), controller.create) |
|||
.post("/api/workflows/action", controller.executeAction) |
|||
.post("/api/workflows/:id/trigger", controller.trigger) |
|||
.delete("/api/workflows/:id/:rev", authorized(BUILDER), controller.destroy) |
|||
|
|||
module.exports = router |
|||
|
|||
@ -1,33 +1,11 @@ |
|||
const EventEmitter = require("events").EventEmitter |
|||
const CouchDB = require("../db") |
|||
const { Orchestrator, serverStrategy } = require("./workflow") |
|||
|
|||
const emitter = new EventEmitter() |
|||
|
|||
async function executeRelevantWorkflows(event, eventType) { |
|||
const db = new CouchDB(event.instanceId) |
|||
const workflowsToTrigger = await db.query("database/by_workflow_trigger", { |
|||
key: [eventType], |
|||
include_docs: true, |
|||
}) |
|||
|
|||
const workflows = workflowsToTrigger.rows.map(wf => wf.doc) |
|||
|
|||
// Create orchestrator
|
|||
const workflowOrchestrator = new Orchestrator() |
|||
workflowOrchestrator.strategy = serverStrategy |
|||
/** |
|||
* keeping event emitter in one central location as it might be used for things other than |
|||
* workflows (what it was for originally) - having a central emitter will be useful in the |
|||
* future. |
|||
*/ |
|||
|
|||
for (let workflow of workflows) { |
|||
workflowOrchestrator.execute(workflow, event) |
|||
} |
|||
} |
|||
|
|||
emitter.on("record:save", async function(event) { |
|||
await executeRelevantWorkflows(event, "record:save") |
|||
}) |
|||
|
|||
emitter.on("record:delete", async function(event) { |
|||
await executeRelevantWorkflows(event, "record:delete") |
|||
}) |
|||
const emitter = new EventEmitter() |
|||
|
|||
module.exports = emitter |
|||
|
|||
@ -1,54 +0,0 @@ |
|||
const mustache = require("mustache") |
|||
|
|||
/** |
|||
* The workflow orchestrator is a class responsible for executing workflows. |
|||
* It relies on the strategy pattern, which allows composable behaviour to be |
|||
* passed into its execute() function. This allows custom execution behaviour based |
|||
* on where the orchestrator is run. |
|||
* |
|||
*/ |
|||
exports.Orchestrator = class Orchestrator { |
|||
set strategy(strategy) { |
|||
this._strategy = strategy() |
|||
} |
|||
|
|||
async execute(workflow, context) { |
|||
if (workflow.live) { |
|||
this._strategy.run(workflow.definition, context) |
|||
} |
|||
} |
|||
} |
|||
|
|||
exports.serverStrategy = () => ({ |
|||
context: {}, |
|||
bindContextArgs: function(args) { |
|||
const mappedArgs = { ...args } |
|||
|
|||
// bind the workflow action args to the workflow context, if required
|
|||
for (let arg in args) { |
|||
const argValue = args[arg] |
|||
// We don't want to render mustache templates on non-strings
|
|||
if (typeof argValue !== "string") continue |
|||
|
|||
mappedArgs[arg] = mustache.render(argValue, { context: this.context }) |
|||
} |
|||
|
|||
return mappedArgs |
|||
}, |
|||
run: async function(workflow, context) { |
|||
for (let block of workflow.steps) { |
|||
if (block.type === "CLIENT") continue |
|||
|
|||
const action = require(`../api/controllers/workflow/actions/${block.actionId}`) |
|||
const response = await action({ |
|||
args: this.bindContextArgs(block.args), |
|||
context, |
|||
}) |
|||
|
|||
this.context = { |
|||
...this.context, |
|||
[block.id]: response, |
|||
} |
|||
} |
|||
}, |
|||
}) |
|||
@ -0,0 +1,16 @@ |
|||
function validate(schema, property) { |
|||
// Return a Koa middleware function
|
|||
return (ctx, next) => { |
|||
if (schema) { |
|||
const { error } = schema.validate(ctx[property]) |
|||
if (error) { |
|||
ctx.throw(400, `Invalid ${property} - ${error.message}`) |
|||
} |
|||
} |
|||
return next() |
|||
} |
|||
} |
|||
|
|||
module.exports.body = schema => { |
|||
return validate(schema, "body") |
|||
} |
|||
@ -0,0 +1,112 @@ |
|||
const userController = require("../api/controllers/user") |
|||
const recordController = require("../api/controllers/record") |
|||
const sgMail = require("@sendgrid/mail") |
|||
|
|||
sgMail.setApiKey(process.env.SENDGRID_API_KEY) |
|||
|
|||
let BUILTIN_ACTIONS = { |
|||
CREATE_USER: async function({ args, context }) { |
|||
const { username, password, accessLevelId } = args |
|||
const ctx = { |
|||
user: { |
|||
instanceId: context.instanceId, |
|||
}, |
|||
request: { |
|||
body: { username, password, accessLevelId }, |
|||
}, |
|||
} |
|||
|
|||
try { |
|||
const response = await userController.create(ctx) |
|||
return { |
|||
user: response, |
|||
} |
|||
} catch (err) { |
|||
console.error(err) |
|||
return { |
|||
user: null, |
|||
} |
|||
} |
|||
}, |
|||
SAVE_RECORD: async function({ args, context }) { |
|||
const { model, ...record } = args.record |
|||
|
|||
const ctx = { |
|||
params: { |
|||
instanceId: context.instanceId, |
|||
modelId: model._id, |
|||
}, |
|||
request: { |
|||
body: record, |
|||
}, |
|||
user: { instanceId: context.instanceId }, |
|||
} |
|||
|
|||
try { |
|||
await recordController.save(ctx) |
|||
return { |
|||
record: ctx.body, |
|||
} |
|||
} catch (err) { |
|||
console.error(err) |
|||
return { |
|||
record: null, |
|||
error: err.message, |
|||
} |
|||
} |
|||
}, |
|||
SEND_EMAIL: async function({ args }) { |
|||
const msg = { |
|||
to: args.to, |
|||
from: args.from, |
|||
subject: args.subject, |
|||
text: args.text, |
|||
} |
|||
|
|||
try { |
|||
await sgMail.send(msg) |
|||
return { |
|||
success: true, |
|||
...args, |
|||
} |
|||
} catch (err) { |
|||
console.error(err) |
|||
return { |
|||
success: false, |
|||
error: err.message, |
|||
} |
|||
} |
|||
}, |
|||
DELETE_RECORD: async function({ args, context }) { |
|||
const { model, ...record } = args.record |
|||
// TODO: better logging of when actions are missed due to missing parameters
|
|||
if (record.recordId == null || record.revId == null) { |
|||
return |
|||
} |
|||
let ctx = { |
|||
params: { |
|||
modelId: model._id, |
|||
recordId: record.recordId, |
|||
revId: record.revId, |
|||
}, |
|||
user: { instanceId: context.instanceId }, |
|||
} |
|||
|
|||
try { |
|||
await recordController.destroy(ctx) |
|||
} catch (err) { |
|||
console.error(err) |
|||
return { |
|||
record: null, |
|||
error: err.message, |
|||
} |
|||
} |
|||
}, |
|||
} |
|||
|
|||
module.exports.getAction = async function(actionName) { |
|||
if (BUILTIN_ACTIONS[actionName] != null) { |
|||
return BUILTIN_ACTIONS[actionName] |
|||
} |
|||
// TODO: load async actions here
|
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
const triggers = require("./triggers") |
|||
const environment = require("../environment") |
|||
const workerFarm = require("worker-farm") |
|||
const singleThread = require("./thread") |
|||
|
|||
let workers = workerFarm(require.resolve("./thread")) |
|||
|
|||
function runWorker(job) { |
|||
return new Promise((resolve, reject) => { |
|||
workers(job, err => { |
|||
if (err) { |
|||
reject(err) |
|||
} else { |
|||
resolve() |
|||
} |
|||
}) |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* This module is built purely to kick off the worker farm and manage the inputs/outputs |
|||
*/ |
|||
module.exports.init = function() { |
|||
triggers.workflowQueue.process(async job => { |
|||
if (environment.BUDIBASE_ENVIRONMENT === "PRODUCTION") { |
|||
await runWorker(job) |
|||
} else { |
|||
await singleThread(job) |
|||
} |
|||
}) |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) |
|||
|
|||
let LOGIC = { |
|||
DELAY: async function delay({ args }) { |
|||
await wait(args.time) |
|||
}, |
|||
|
|||
FILTER: async function filter({ args }) { |
|||
const { field, condition, value } = args |
|||
switch (condition) { |
|||
case "equals": |
|||
if (field !== value) return |
|||
break |
|||
default: |
|||
return |
|||
} |
|||
}, |
|||
} |
|||
|
|||
module.exports.getLogic = function(logicName) { |
|||
if (LOGIC[logicName] != null) { |
|||
return LOGIC[logicName] |
|||
} |
|||
} |
|||
@ -0,0 +1,44 @@ |
|||
let events = require("events") |
|||
|
|||
// Bull works with a Job wrapper around all messages that contains a lot more information about
|
|||
// the state of the message, implement this for the sake of maintaining API consistency
|
|||
function newJob(queue, message) { |
|||
return { |
|||
timestamp: Date.now(), |
|||
queue: queue, |
|||
data: message, |
|||
} |
|||
} |
|||
|
|||
// designed to replicate Bull (https://github.com/OptimalBits/bull) in memory as a sort of mock
|
|||
class InMemoryQueue { |
|||
// opts is not used by this as there is no real use case when in memory, but is the same API as Bull
|
|||
constructor(name, opts) { |
|||
this._name = name |
|||
this._opts = opts |
|||
this._messages = [] |
|||
this._emitter = new events.EventEmitter() |
|||
} |
|||
|
|||
// same API as bull, provide a callback and it will respond when messages are available
|
|||
process(func) { |
|||
this._emitter.on("message", async () => { |
|||
if (this._messages.length <= 0) { |
|||
return |
|||
} |
|||
let msg = this._messages.shift() |
|||
let resp = func(msg) |
|||
if (resp.then != null) { |
|||
await resp |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// simply puts a message to the queue and emits to the queue for processing
|
|||
add(msg) { |
|||
this._messages.push(newJob(this._name, msg)) |
|||
this._emitter.emit("message") |
|||
} |
|||
} |
|||
|
|||
module.exports = InMemoryQueue |
|||
@ -0,0 +1,68 @@ |
|||
const mustache = require("mustache") |
|||
const actions = require("./actions") |
|||
const logic = require("./logic") |
|||
|
|||
/** |
|||
* The workflow orchestrator is a class responsible for executing workflows. |
|||
* It handles the context of the workflow and makes sure each step gets the correct |
|||
* inputs and handles any outputs. |
|||
*/ |
|||
class Orchestrator { |
|||
constructor(workflow) { |
|||
this._context = {} |
|||
this._workflow = workflow |
|||
} |
|||
|
|||
async getStep(type, stepId) { |
|||
let step = null |
|||
if (type === "ACTION") { |
|||
step = await actions.getAction(stepId) |
|||
} else if (type === "LOGIC") { |
|||
step = logic.getLogic(stepId) |
|||
} |
|||
if (step == null) { |
|||
throw `Cannot find workflow step by name ${stepId}` |
|||
} |
|||
return step |
|||
} |
|||
|
|||
async execute(context) { |
|||
let workflow = this._workflow |
|||
for (let block of workflow.definition.steps) { |
|||
let step = await this.getStep(block.type, block.stepId) |
|||
let args = { ...block.args } |
|||
// bind the workflow action args to the workflow context, if required
|
|||
for (let arg of Object.keys(args)) { |
|||
const argValue = args[arg] |
|||
// We don't want to render mustache templates on non-strings
|
|||
if (typeof argValue !== "string") continue |
|||
|
|||
args[arg] = mustache.render(argValue, { context: this._context }) |
|||
} |
|||
const response = await step({ |
|||
args, |
|||
context, |
|||
}) |
|||
|
|||
this._context = { |
|||
...this._context, |
|||
[block.id]: response, |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// callback is required for worker-farm to state that the worker thread has completed
|
|||
module.exports = async (job, cb = null) => { |
|||
try { |
|||
const workflowOrchestrator = new Orchestrator(job.data.workflow) |
|||
await workflowOrchestrator.execute(job.data.event) |
|||
if (cb) { |
|||
cb() |
|||
} |
|||
} catch (err) { |
|||
if (cb) { |
|||
cb(err) |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
const CouchDB = require("../db") |
|||
const emitter = require("../events/index") |
|||
const InMemoryQueue = require("./queue/inMemoryQueue") |
|||
|
|||
let workflowQueue = new InMemoryQueue() |
|||
|
|||
async function queueRelevantWorkflows(event, eventType) { |
|||
if (event.instanceId == null) { |
|||
throw `No instanceId specified for ${eventType} - check event emitters.` |
|||
} |
|||
const db = new CouchDB(event.instanceId) |
|||
const workflowsToTrigger = await db.query("database/by_workflow_trigger", { |
|||
key: [eventType], |
|||
include_docs: true, |
|||
}) |
|||
|
|||
const workflows = workflowsToTrigger.rows.map(wf => wf.doc) |
|||
for (let workflow of workflows) { |
|||
if (!workflow.live) { |
|||
continue |
|||
} |
|||
workflowQueue.add({ workflow, event }) |
|||
} |
|||
} |
|||
|
|||
emitter.on("record:save", async function(event) { |
|||
await queueRelevantWorkflows(event, "record:save") |
|||
}) |
|||
|
|||
emitter.on("record:delete", async function(event) { |
|||
await queueRelevantWorkflows(event, "record:delete") |
|||
}) |
|||
|
|||
module.exports.externalTrigger = async function(workflow, params) { |
|||
workflowQueue.add({ workflow, event: params }) |
|||
} |
|||
|
|||
module.exports.workflowQueue = workflowQueue |
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in new issue