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', () => { |
context("Create a workflow", () => { |
||||
|
before(() => { |
||||
before(() => { |
cy.server() |
||||
cy.server() |
cy.visit("localhost:4001/_builder") |
||||
cy.visit('localhost:4001/_builder') |
|
||||
|
cy.createApp( |
||||
cy.createApp('Workflow Test App', 'This app is used to test that workflows do in fact work!') |
"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() |
// https://on.cypress.io/interacting-with-elements
|
||||
|
it("should create a workflow", () => { |
||||
cy.contains('workflow').click() |
cy.createTestTableWithData() |
||||
cy.contains('Create New Workflow').click() |
|
||||
cy.get('input').type('Add Record') |
cy.contains("workflow").click() |
||||
cy.contains('Save').click() |
cy.contains("Create New Workflow").click() |
||||
|
cy.get("input").type("Add Record") |
||||
// Add trigger
|
cy.contains("Save").click() |
||||
cy.get('[data-cy=add-workflow-component]').click() |
|
||||
cy.get('[data-cy=RECORD_SAVED]').click() |
// Add trigger
|
||||
cy.get('.budibase__input').select('dog') |
cy.get("[data-cy=add-workflow-component]").click() |
||||
|
cy.get("[data-cy=RECORD_SAVED]").click() |
||||
// Create action
|
cy.get(".budibase__input").select("dog") |
||||
cy.get('[data-cy=SAVE_RECORD]').click() |
|
||||
cy.get('.container input').first().type('goodboy') |
// Create action
|
||||
cy.get('.container input').eq(1).type('11') |
cy.get("[data-cy=SAVE_RECORD]").click() |
||||
|
cy.get(".budibase__input").select("dog") |
||||
// Save
|
cy.get(".container input") |
||||
cy.contains('Save Workflow').click() |
.first() |
||||
|
.type("goodboy") |
||||
// Activate Workflow
|
cy.get(".container input") |
||||
cy.get('[data-cy=activate-workflow]').click() |
.eq(1) |
||||
cy.contains("Add Record").should("be.visible") |
.type("11") |
||||
cy.get(".stop-button.highlighted").should("be.visible") |
|
||||
}) |
// Save
|
||||
|
cy.contains("Save Workflow").click() |
||||
it('should add record when a new record is added', () => { |
|
||||
cy.contains('backend').click() |
// Activate Workflow
|
||||
|
cy.get("[data-cy=activate-workflow]").click() |
||||
cy.addRecord(["Rover", 15]) |
cy.contains("Add Record").should("be.visible") |
||||
cy.reload() |
cy.get(".stop-button.highlighted").should("be.visible") |
||||
cy.contains('goodboy').should('have.text', 'goodboy') |
}) |
||||
|
|
||||
}) |
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 Workflow from "../Workflow" |
||||
import TEST_WORKFLOW from "./testWorkflow"; |
import TEST_WORKFLOW from "./testWorkflow" |
||||
|
|
||||
const TEST_BLOCK = { |
const TEST_BLOCK = { |
||||
id: "VFWeZcIPx", |
id: "AUXJQGZY7", |
||||
name: "Update UI State", |
name: "Delay", |
||||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>", |
icon: "ri-time-fill", |
||||
icon: "ri-refresh-line", |
tagline: "Delay for <b>{{time}}</b> milliseconds", |
||||
description: "Update your User Interface with some data.", |
description: "Delay the workflow until an amount of time has passed.", |
||||
environment: "CLIENT", |
params: { time: "number" }, |
||||
params: { |
type: "LOGIC", |
||||
path: "string", |
args: { time: "5000" }, |
||||
value: "longText", |
stepId: "DELAY", |
||||
}, |
|
||||
args: { |
|
||||
path: "foo", |
|
||||
value: "started...", |
|
||||
}, |
|
||||
actionId: "SET_STATE", |
|
||||
type: "ACTION", |
|
||||
} |
} |
||||
|
|
||||
describe("Workflow Data Object", () => { |
describe("Workflow Data Object", () => { |
||||
let workflow |
let workflow |
||||
|
|
||||
beforeEach(() => { |
beforeEach(() => { |
||||
workflow = new Workflow({ ...TEST_WORKFLOW }); |
workflow = new Workflow({ ...TEST_WORKFLOW }) |
||||
}); |
}) |
||||
|
|
||||
it("adds a workflow block to the workflow", () => { |
it("adds a workflow block to the workflow", () => { |
||||
workflow.addBlock(TEST_BLOCK); |
workflow.addBlock(TEST_BLOCK) |
||||
expect(workflow.workflow.definition) |
expect(workflow.workflow.definition) |
||||
}) |
}) |
||||
|
|
||||
it("updates a workflow block with new attributes", () => { |
it("updates a workflow block with new attributes", () => { |
||||
const firstBlock = workflow.workflow.definition.steps[0]; |
const firstBlock = workflow.workflow.definition.steps[0] |
||||
const updatedBlock = { |
const updatedBlock = { |
||||
...firstBlock, |
...firstBlock, |
||||
name: "UPDATED" |
name: "UPDATED", |
||||
}; |
} |
||||
workflow.updateBlock(updatedBlock, firstBlock.id); |
workflow.updateBlock(updatedBlock, firstBlock.id) |
||||
expect(workflow.workflow.definition.steps[0]).toEqual(updatedBlock) |
expect(workflow.workflow.definition.steps[0]).toEqual(updatedBlock) |
||||
}) |
}) |
||||
|
|
||||
it("deletes a workflow block successfully", () => { |
it("deletes a workflow block successfully", () => { |
||||
const { steps } = workflow.workflow.definition |
const { steps } = workflow.workflow.definition |
||||
const originalLength = steps.length |
const originalLength = steps.length |
||||
|
|
||||
const lastBlock = steps[steps.length - 1]; |
|
||||
workflow.deleteBlock(lastBlock.id); |
|
||||
expect(workflow.workflow.definition.steps.length).toBeLessThan(originalLength); |
|
||||
}) |
|
||||
|
|
||||
it("builds a tree that gets rendered in the flowchart builder", () => { |
const lastBlock = steps[steps.length - 1] |
||||
expect(Workflow.buildUiTree(TEST_WORKFLOW.definition)).toMatchSnapshot(); |
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 { |
export default { |
||||
_id: "53b6148c65d1429c987e046852d11611", |
name: "Test workflow", |
||||
_rev: "4-02c6659734934895812fa7be0215ee59", |
|
||||
name: "Test Workflow", |
|
||||
definition: { |
definition: { |
||||
steps: [ |
steps: [ |
||||
{ |
{ |
||||
id: "VFWeZcIPx", |
id: "ANBDINAPS", |
||||
name: "Update UI State", |
description: "Send an email.", |
||||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>", |
tagline: "Send email to <b>{{to}}</b>", |
||||
icon: "ri-refresh-line", |
icon: "ri-mail-open-fill", |
||||
description: "Update your User Interface with some data.", |
name: "Send Email", |
||||
environment: "CLIENT", |
|
||||
params: { |
params: { |
||||
path: "string", |
to: "string", |
||||
value: "longText", |
from: "string", |
||||
|
subject: "longText", |
||||
|
text: "longText", |
||||
}, |
}, |
||||
args: { |
|
||||
path: "foo", |
|
||||
value: "started...", |
|
||||
}, |
|
||||
actionId: "SET_STATE", |
|
||||
type: "ACTION", |
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: { |
args: { |
||||
time: 3000, |
text: "A user was created!", |
||||
|
subject: "New Budibase User", |
||||
|
from: "budimaster@budibase.com", |
||||
|
to: "test@test.com", |
||||
}, |
}, |
||||
actionId: "DELAY", |
stepId: "SEND_EMAIL", |
||||
type: "LOGIC", |
|
||||
}, |
}, |
||||
{ |
], |
||||
id: "3RSTO7BMB", |
trigger: { |
||||
name: "Update UI State", |
id: "iRzYMOqND", |
||||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>", |
name: "Record Saved", |
||||
icon: "ri-refresh-line", |
event: "record:save", |
||||
description: "Update your User Interface with some data.", |
icon: "ri-save-line", |
||||
environment: "CLIENT", |
tagline: "Record is added to <b>{{model.name}}</b>", |
||||
params: { |
description: "Fired when a record is saved to your database.", |
||||
path: "string", |
params: { model: "model" }, |
||||
value: "longText", |
type: "TRIGGER", |
||||
}, |
args: { |
||||
args: { |
model: { |
||||
path: "foo", |
type: "model", |
||||
value: "finished", |
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", |
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> |
<script> |
||||
import FlowItem from "./FlowItem.svelte" |
import FlowItem from "./FlowItem.svelte" |
||||
import Arrow from "./Arrow.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 |
export let onSelect |
||||
|
let blocks |
||||
|
|
||||
|
$: { |
||||
|
blocks = [] |
||||
|
if (workflow) { |
||||
|
if (workflow.definition.trigger) { |
||||
|
blocks.push(workflow.definition.trigger) |
||||
|
} |
||||
|
blocks = blocks.concat(workflow.definition.steps || []) |
||||
|
} |
||||
|
} |
||||
</script> |
</script> |
||||
|
|
||||
<section class="canvas"> |
<section class="canvas"> |
||||
{#each blocks as block, idx} |
{#each blocks as block, idx (block.id)} |
||||
<FlowItem {onSelect} {block} /> |
<div |
||||
{#if idx !== blocks.length - 1} |
class="block" |
||||
<Arrow /> |
animate:flip={{ duration: 600 }} |
||||
{/if} |
in:fade|local |
||||
|
out:fly|local={{ x: 100 }}> |
||||
|
<FlowItem {onSelect} {block} /> |
||||
|
{#if idx !== blocks.length - 1} |
||||
|
<Arrow /> |
||||
|
{/if} |
||||
|
</div> |
||||
{/each} |
{/each} |
||||
</section> |
</section> |
||||
|
|
||||
<style> |
<style> |
||||
.canvas { |
section { |
||||
|
position: absolute; |
||||
|
padding: 20px 40px; |
||||
display: flex; |
display: flex; |
||||
align-items: center; |
align-items: center; |
||||
flex-direction: column; |
flex-direction: column; |
||||
} |
} |
||||
|
|
||||
|
.block { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
justify-content: flex-start; |
||||
|
align-items: center; |
||||
|
} |
||||
</style> |
</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 = { |
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: { |
SAVE_RECORD: { |
||||
name: "Save Record", |
name: "Save Record", |
||||
tagline: "<b>Save</b> a <b>{{record.model.name}}</b> record", |
tagline: "<b>Save</b> a <b>{{record.model.name}}</b> record", |
||||
icon: "ri-save-3-fill", |
icon: "ri-save-3-fill", |
||||
description: "Save a record to your database.", |
description: "Save a record to your database.", |
||||
environment: "SERVER", |
|
||||
params: { |
params: { |
||||
record: "record", |
record: "record", |
||||
}, |
}, |
||||
args: { |
args: { |
||||
record: {}, |
record: {}, |
||||
}, |
}, |
||||
|
type: "ACTION", |
||||
}, |
}, |
||||
DELETE_RECORD: { |
DELETE_RECORD: { |
||||
description: "Delete a record from your database.", |
description: "Delete a record from your database.", |
||||
icon: "ri-delete-bin-line", |
icon: "ri-delete-bin-line", |
||||
name: "Delete Record", |
name: "Delete Record", |
||||
tagline: "<b>Delete</b> a <b>{{record.model.name}}</b> record", |
tagline: "<b>Delete</b> a <b>{{record.model.name}}</b> record", |
||||
environment: "SERVER", |
params: {}, |
||||
params: { |
args: {}, |
||||
record: "record", |
type: "ACTION", |
||||
}, |
|
||||
args: { |
|
||||
record: {}, |
|
||||
}, |
|
||||
}, |
}, |
||||
// 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: { |
CREATE_USER: { |
||||
description: "Create a new user.", |
description: "Create a new user.", |
||||
tagline: "Create user <b>{{username}}</b>", |
tagline: "Create user <b>{{username}}</b>", |
||||
icon: "ri-user-add-fill", |
icon: "ri-user-add-fill", |
||||
name: "Create User", |
name: "Create User", |
||||
environment: "SERVER", |
|
||||
params: { |
params: { |
||||
username: "string", |
username: "string", |
||||
password: "password", |
password: "password", |
||||
accessLevelId: "accessLevel", |
accessLevelId: "accessLevel", |
||||
}, |
}, |
||||
|
args: { |
||||
|
accessLevelId: "POWER_USER", |
||||
|
}, |
||||
|
type: "ACTION", |
||||
}, |
}, |
||||
SEND_EMAIL: { |
SEND_EMAIL: { |
||||
description: "Send an email.", |
description: "Send an email.", |
||||
tagline: "Send email to <b>{{to}}</b>", |
tagline: "Send email to <b>{{to}}</b>", |
||||
icon: "ri-mail-open-fill", |
icon: "ri-mail-open-fill", |
||||
name: "Send Email", |
name: "Send Email", |
||||
environment: "SERVER", |
|
||||
params: { |
params: { |
||||
to: "string", |
to: "string", |
||||
from: "string", |
from: "string", |
||||
subject: "longText", |
subject: "longText", |
||||
text: "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 = { |
const LOGIC = { |
||||
FILTER: { |
FILTER: { |
||||
name: "Filter", |
name: "Filter", |
||||
tagline: "{{field}} <b>{{condition}}</b> {{value}}", |
tagline: "{{filter}} <b>{{condition}}</b> {{value}}", |
||||
icon: "ri-git-branch-line", |
icon: "ri-git-branch-line", |
||||
description: "Filter any workflows which do not meet certain conditions.", |
description: "Filter any workflows which do not meet certain conditions.", |
||||
environment: "CLIENT", |
|
||||
params: { |
params: { |
||||
filter: "string", |
filter: "string", |
||||
condition: ["equals"], |
condition: ["equals"], |
||||
value: "string", |
value: "string", |
||||
}, |
}, |
||||
|
args: { |
||||
|
condition: "equals", |
||||
|
}, |
||||
|
type: "LOGIC", |
||||
}, |
}, |
||||
DELAY: { |
DELAY: { |
||||
name: "Delay", |
name: "Delay", |
||||
icon: "ri-time-fill", |
icon: "ri-time-fill", |
||||
tagline: "Delay for <b>{{time}}</b> milliseconds", |
tagline: "Delay for <b>{{time}}</b> milliseconds", |
||||
description: "Delay the workflow until an amount of time has passed.", |
description: "Delay the workflow until an amount of time has passed.", |
||||
environment: "CLIENT", |
|
||||
params: { |
params: { |
||||
time: "number", |
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, |
ACTION, |
||||
TRIGGER, |
|
||||
LOGIC, |
LOGIC, |
||||
|
TRIGGER, |
||||
} |
} |
||||
@ -1,21 +1,77 @@ |
|||||
const Router = require("@koa/router") |
const Router = require("@koa/router") |
||||
const controller = require("../controllers/workflow") |
const controller = require("../controllers/workflow") |
||||
const authorized = require("../../middleware/authorized") |
const authorized = require("../../middleware/authorized") |
||||
|
const joiValidator = require("../../middleware/joi-validator") |
||||
const { BUILDER } = require("../../utilities/accessLevels") |
const { BUILDER } = require("../../utilities/accessLevels") |
||||
|
const Joi = require("joi") |
||||
|
|
||||
const router = Router() |
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 |
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", authorized(BUILDER), controller.fetch) |
||||
.get("/api/workflows/:id", authorized(BUILDER), controller.find) |
.get("/api/workflows/:id", authorized(BUILDER), controller.find) |
||||
.get( |
.put( |
||||
"/api/workflows/:id/:action", |
"/api/workflows", |
||||
|
authorized(BUILDER), |
||||
|
workflowValidator, |
||||
|
controller.update |
||||
|
) |
||||
|
.post( |
||||
|
"/api/workflows", |
||||
authorized(BUILDER), |
authorized(BUILDER), |
||||
controller.fetchActionScript |
workflowValidator, |
||||
|
controller.create |
||||
) |
) |
||||
.put("/api/workflows", authorized(BUILDER), controller.update) |
.post("/api/workflows/:id/trigger", controller.trigger) |
||||
.post("/api/workflows", authorized(BUILDER), controller.create) |
|
||||
.post("/api/workflows/action", controller.executeAction) |
|
||||
.delete("/api/workflows/:id/:rev", authorized(BUILDER), controller.destroy) |
.delete("/api/workflows/:id/:rev", authorized(BUILDER), controller.destroy) |
||||
|
|
||||
module.exports = router |
module.exports = router |
||||
|
|||||
@ -1,33 +1,11 @@ |
|||||
const EventEmitter = require("events").EventEmitter |
const EventEmitter = require("events").EventEmitter |
||||
const CouchDB = require("../db") |
|
||||
const { Orchestrator, serverStrategy } = require("./workflow") |
|
||||
|
|
||||
const emitter = new EventEmitter() |
/** |
||||
|
* keeping event emitter in one central location as it might be used for things other than |
||||
async function executeRelevantWorkflows(event, eventType) { |
* workflows (what it was for originally) - having a central emitter will be useful in the |
||||
const db = new CouchDB(event.instanceId) |
* future. |
||||
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 |
|
||||
|
|
||||
for (let workflow of workflows) { |
const emitter = new EventEmitter() |
||||
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") |
|
||||
}) |
|
||||
|
|
||||
module.exports = emitter |
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