forked from tsai/budibase
114 changed files with 8914 additions and 1633 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.
@ -0,0 +1,42 @@ |
|||
export const CAPTURE_VAR_INSIDE_MUSTACHE = /{{([^}]+)}}/g |
|||
|
|||
export function readableToRuntimeBinding(bindableProperties, textWithBindings) { |
|||
// Find all instances of mustasche
|
|||
const boundValues = textWithBindings.match(CAPTURE_VAR_INSIDE_MUSTACHE) |
|||
|
|||
let result = textWithBindings |
|||
// Replace readableBindings with runtimeBindings
|
|||
boundValues && |
|||
boundValues.forEach(boundValue => { |
|||
const binding = bindableProperties.find(({ readableBinding }) => { |
|||
return boundValue === `{{ ${readableBinding} }}` |
|||
}) |
|||
if (binding) { |
|||
result = textWithBindings.replace( |
|||
boundValue, |
|||
`{{ ${binding.runtimeBinding} }}` |
|||
) |
|||
} |
|||
}) |
|||
return result |
|||
} |
|||
|
|||
export function runtimeToReadableBinding(bindableProperties, textWithBindings) { |
|||
let temp = textWithBindings |
|||
const boundValues = |
|||
(typeof textWithBindings === "string" && |
|||
textWithBindings.match(CAPTURE_VAR_INSIDE_MUSTACHE)) || |
|||
[] |
|||
|
|||
// Replace runtimeBindings with readableBindings:
|
|||
boundValues.forEach(v => { |
|||
const binding = bindableProperties.find(({ runtimeBinding }) => { |
|||
return v === `{{ ${runtimeBinding} }}` |
|||
}) |
|||
if (binding) { |
|||
temp = temp.replace(v, `{{ ${binding.readableBinding} }}`) |
|||
} |
|||
}) |
|||
|
|||
return temp |
|||
} |
|||
@ -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", |
|||
} |
|||
|
|||
|
After Width: | Height: | Size: 412 B |
@ -1,4 +0,0 @@ |
|||
import feather from "feather-icons" |
|||
const getIcon = (icon, size) => |
|||
feather.icons[icon].toSvg({ height: size || "16", width: size || "16" }) |
|||
export default getIcon |
|||
@ -1,168 +1,220 @@ |
|||
<script> |
|||
import { store } from "builderStore" |
|||
import { Button, Select } from "@budibase/bbui" |
|||
import HandlerSelector from "./HandlerSelector.svelte" |
|||
import ActionButton from "../../common/ActionButton.svelte" |
|||
import getIcon from "../../common/icon" |
|||
import { CloseIcon } from "components/common/Icons/" |
|||
|
|||
import { TextButton, Button, Heading, DropdownMenu } from "@budibase/bbui" |
|||
import { AddIcon, ArrowDownIcon } from "components/common/Icons/" |
|||
import { EVENT_TYPE_MEMBER_NAME } from "../../common/eventHandlers" |
|||
import actionTypes from "./actions" |
|||
import { createEventDispatcher } from "svelte" |
|||
|
|||
const dispatch = createEventDispatcher() |
|||
|
|||
export let event |
|||
export let eventOptions = [] |
|||
export let onClose |
|||
|
|||
let eventType = "" |
|||
let addActionButton |
|||
let addActionDropdown |
|||
let selectedAction |
|||
|
|||
let draftEventHandler = { parameters: [] } |
|||
|
|||
$: eventData = event || { handlers: [] } |
|||
$: if (!eventOptions.includes(eventType) && eventOptions.length > 0) |
|||
eventType = eventOptions[0].name |
|||
$: actions = event || [] |
|||
$: selectedActionComponent = |
|||
selectedAction && |
|||
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_MEMBER_NAME]) |
|||
.component |
|||
|
|||
const closeModal = () => { |
|||
onClose() |
|||
dispatch("close") |
|||
draftEventHandler = { parameters: [] } |
|||
eventData = { handlers: [] } |
|||
actions = [] |
|||
} |
|||
|
|||
const updateEventHandler = (updatedHandler, index) => { |
|||
eventData.handlers[index] = updatedHandler |
|||
} |
|||
|
|||
const updateDraftEventHandler = updatedHandler => { |
|||
draftEventHandler = updatedHandler |
|||
actions[index] = updatedHandler |
|||
} |
|||
|
|||
const deleteEventHandler = index => { |
|||
eventData.handlers.splice(index, 1) |
|||
eventData = eventData |
|||
const deleteAction = index => { |
|||
actions.splice(index, 1) |
|||
actions = actions |
|||
} |
|||
|
|||
const createNewEventHandler = handler => { |
|||
const newHandler = handler || { |
|||
const addAction = actionType => () => { |
|||
const newAction = { |
|||
parameters: {}, |
|||
[EVENT_TYPE_MEMBER_NAME]: "", |
|||
[EVENT_TYPE_MEMBER_NAME]: actionType.name, |
|||
} |
|||
eventData.handlers.push(newHandler) |
|||
eventData = eventData |
|||
actions.push(newAction) |
|||
selectedAction = newAction |
|||
actions = actions |
|||
addActionDropdown.hide() |
|||
} |
|||
|
|||
const deleteEvent = () => { |
|||
store.setComponentProp(eventType, []) |
|||
closeModal() |
|||
const selectAction = action => () => { |
|||
selectedAction = action |
|||
} |
|||
|
|||
const saveEventData = () => { |
|||
store.setComponentProp(eventType, eventData.handlers) |
|||
dispatch("change", actions) |
|||
closeModal() |
|||
} |
|||
</script> |
|||
|
|||
<div class="container"> |
|||
<div class="body"> |
|||
<div class="heading"> |
|||
<h3> |
|||
{eventData.name ? `${eventData.name} Event` : 'Create a New Component Event'} |
|||
</h3> |
|||
<div class="root"> |
|||
|
|||
<div class="header"> |
|||
<Heading small dark>Actions</Heading> |
|||
<div bind:this={addActionButton}> |
|||
<TextButton text small blue on:click={addActionDropdown.show}> |
|||
Add Action |
|||
<div style="height: 20px; width: 20px;"> |
|||
<AddIcon /> |
|||
</div> |
|||
</TextButton> |
|||
</div> |
|||
<div class="event-options"> |
|||
<div class="section"> |
|||
<h4>Event Type</h4> |
|||
<Select bind:value={eventType}> |
|||
{#each eventOptions as option} |
|||
<option value={option.name}>{option.name}</option> |
|||
{/each} |
|||
</Select> |
|||
<DropdownMenu |
|||
bind:this={addActionDropdown} |
|||
anchor={addActionButton} |
|||
align="right"> |
|||
<div class="available-actions-container"> |
|||
{#each actionTypes as actionType} |
|||
<div class="available-action" on:click={addAction(actionType)}> |
|||
<span>{actionType.name}</span> |
|||
</div> |
|||
{/each} |
|||
</div> |
|||
</div> |
|||
</DropdownMenu> |
|||
</div> |
|||
|
|||
<div class="section"> |
|||
<h4>Event Action(s)</h4> |
|||
<HandlerSelector |
|||
newHandler |
|||
onChanged={updateDraftEventHandler} |
|||
onCreate={() => { |
|||
createNewEventHandler(draftEventHandler) |
|||
draftEventHandler = { parameters: [] } |
|||
}} |
|||
handler={draftEventHandler} /> |
|||
</div> |
|||
{#if eventData} |
|||
{#each eventData.handlers as handler, index} |
|||
<HandlerSelector |
|||
{index} |
|||
onChanged={updateEventHandler} |
|||
onRemoved={() => deleteEventHandler(index)} |
|||
{handler} /> |
|||
<div class="actions-container"> |
|||
{#if actions && actions.length > 0} |
|||
{#each actions as action, index} |
|||
<div class="action-container"> |
|||
<div class="action-header" on:click={selectAction(action)}> |
|||
<p |
|||
class="bb-body bb-body--small bb-body--color-dark" |
|||
style="margin: var(--spacing-s) 0;"> |
|||
{index + 1}. {action[EVENT_TYPE_MEMBER_NAME]} |
|||
</p> |
|||
<div class="row-expander" class:rotate={action !== selectedAction}> |
|||
<ArrowDownIcon /> |
|||
</div> |
|||
</div> |
|||
{#if action === selectedAction} |
|||
<div class="selected-action-container"> |
|||
<svelte:component |
|||
this={selectedActionComponent} |
|||
parameters={selectedAction.parameters} /> |
|||
<div class="delete-action-button"> |
|||
<TextButton text medium on:click={() => deleteAction(index)}> |
|||
Delete |
|||
</TextButton> |
|||
</div> |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
{/each} |
|||
{/if} |
|||
|
|||
</div> |
|||
|
|||
<div class="footer"> |
|||
{#if eventData.name} |
|||
<Button |
|||
outline |
|||
on:click={deleteEvent} |
|||
disabled={eventData.handlers.length === 0}> |
|||
Delete |
|||
</Button> |
|||
{/if} |
|||
<div class="save"> |
|||
<Button |
|||
primary |
|||
on:click={saveEventData} |
|||
disabled={eventData.handlers.length === 0}> |
|||
Save |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
<div class="close-button" on:click={closeModal}> |
|||
<CloseIcon /> |
|||
<a href="https://docs.budibase.com">Learn more about Actions</a> |
|||
<Button secondary on:click={closeModal}>Cancel</Button> |
|||
<Button primary on:click={saveEventData}>Save</Button> |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
.container { |
|||
position: relative; |
|||
.root { |
|||
max-height: 50vh; |
|||
width: 700px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
} |
|||
.heading { |
|||
margin-bottom: 20px; |
|||
|
|||
.header { |
|||
display: flex; |
|||
flex-direction: row; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
padding: var(--spacing-xl); |
|||
padding-bottom: 0; |
|||
} |
|||
|
|||
.close-button { |
|||
.action-header { |
|||
display: flex; |
|||
flex-direction: row; |
|||
align-items: center; |
|||
} |
|||
|
|||
.action-header > p { |
|||
flex: 1; |
|||
} |
|||
|
|||
.row-expander { |
|||
height: 30px; |
|||
width: 30px; |
|||
} |
|||
|
|||
.available-action { |
|||
padding: var(--spacing-s); |
|||
font-size: var(--font-size-m); |
|||
cursor: pointer; |
|||
position: absolute; |
|||
top: 20px; |
|||
right: 20px; |
|||
} |
|||
.close-button :global(svg) { |
|||
width: 24px; |
|||
height: 24px; |
|||
|
|||
.available-action:hover { |
|||
background: var(--grey-2); |
|||
} |
|||
|
|||
h4 { |
|||
margin-bottom: 10px; |
|||
.actions-container { |
|||
flex: 1; |
|||
min-height: 0px; |
|||
padding-bottom: var(--spacing-s); |
|||
padding-top: 0; |
|||
border: var(--border-light); |
|||
border-width: 0 0 1px 0; |
|||
overflow-y: auto; |
|||
} |
|||
|
|||
h3 { |
|||
margin: 0; |
|||
font-size: 24px; |
|||
font-weight: bold; |
|||
.action-container { |
|||
border: var(--border-light); |
|||
border-width: 1px 0 0 0; |
|||
padding-left: var(--spacing-xl); |
|||
padding-right: var(--spacing-xl); |
|||
padding-top: 0; |
|||
padding-bottom: 0; |
|||
} |
|||
.body { |
|||
padding: 40px; |
|||
display: grid; |
|||
grid-gap: 20px; |
|||
|
|||
.selected-action-container { |
|||
padding-bottom: var(--spacing-s); |
|||
padding-top: var(--spacing-s); |
|||
} |
|||
.footer { |
|||
|
|||
.delete-action-button { |
|||
padding-top: var(--spacing-l); |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
padding: 30px 40px; |
|||
border-bottom-left-radius: 5px; |
|||
border-bottom-right-radius: 50px; |
|||
background-color: var(--grey-1); |
|||
flex-direction: row; |
|||
} |
|||
.save { |
|||
margin-left: 20px; |
|||
|
|||
.footer { |
|||
display: flex; |
|||
flex-direction: row; |
|||
gap: var(--spacing-s); |
|||
padding: var(--spacing-xl); |
|||
padding-top: var(--spacing-m); |
|||
} |
|||
|
|||
.footer > a { |
|||
flex: 1; |
|||
color: var(--grey-5); |
|||
font-size: var(--font-size-s); |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.footer > a:hover { |
|||
color: var(--blue); |
|||
} |
|||
|
|||
.rotate :global(svg) { |
|||
transform: rotate(90deg); |
|||
} |
|||
</style> |
|||
|
|||
@ -0,0 +1,75 @@ |
|||
<script> |
|||
import { Select, Label } from "@budibase/bbui" |
|||
import { store, backendUiStore } from "builderStore" |
|||
import fetchBindableProperties from "builderStore/fetchBindableProperties" |
|||
import SaveFields from "./SaveFields.svelte" |
|||
|
|||
export let parameters |
|||
|
|||
$: bindableProperties = fetchBindableProperties({ |
|||
componentInstanceId: $store.currentComponentInfo._id, |
|||
components: $store.components, |
|||
screen: $store.currentPreviewItem, |
|||
models: $backendUiStore.models, |
|||
}) |
|||
|
|||
// just wraps binding in {{ ... }} |
|||
const toBindingExpression = bindingPath => `{{ ${bindingPath} }}` |
|||
|
|||
const modelFields = modelId => { |
|||
const model = $backendUiStore.models.find(m => m._id === modelId) |
|||
|
|||
return Object.keys(model.schema).map(k => ({ |
|||
name: k, |
|||
type: model.schema[k].type, |
|||
})) |
|||
} |
|||
|
|||
$: schemaFields = |
|||
parameters && parameters.modelId ? modelFields(parameters.modelId) : [] |
|||
|
|||
const onFieldsChanged = e => { |
|||
parameters.fields = e.detail |
|||
} |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
<Label size="m" color="dark">Table</Label> |
|||
<Select secondary bind:value={parameters.modelId}> |
|||
<option value="" /> |
|||
{#each $backendUiStore.models as model} |
|||
<option value={model._id}>{model.name}</option> |
|||
{/each} |
|||
</Select> |
|||
|
|||
{#if parameters.modelId} |
|||
<SaveFields |
|||
parameterFields={parameters.fields} |
|||
{schemaFields} |
|||
on:fieldschanged={onFieldsChanged} /> |
|||
{/if} |
|||
|
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
display: grid; |
|||
column-gap: var(--spacing-s); |
|||
row-gap: var(--spacing-s); |
|||
grid-template-columns: auto 1fr auto 1fr auto; |
|||
align-items: baseline; |
|||
} |
|||
|
|||
.root :global(.relative:nth-child(2)) { |
|||
grid-column-start: 2; |
|||
grid-column-end: 6; |
|||
} |
|||
|
|||
.cannot-use { |
|||
color: var(--red); |
|||
font-size: var(--font-size-s); |
|||
text-align: center; |
|||
width: 70%; |
|||
margin: auto; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,29 @@ |
|||
<script> |
|||
import { DataList, Label } from "@budibase/bbui" |
|||
import { store } from "builderStore" |
|||
|
|||
export let parameters |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
<Label size="m" color="dark">Screen</Label> |
|||
<DataList secondary bind:value={parameters.url}> |
|||
<option value="" /> |
|||
{#each $store.screens as screen} |
|||
<option value={screen.route}>{screen.props._instanceName}</option> |
|||
{/each} |
|||
</DataList> |
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
display: flex; |
|||
flex-direction: row; |
|||
align-items: baseline; |
|||
} |
|||
|
|||
.root :global(.relative) { |
|||
flex: 1; |
|||
margin-left: var(--spacing-l); |
|||
} |
|||
</style> |
|||
@ -0,0 +1,115 @@ |
|||
<script> |
|||
// accepts an array of field names, and outputs an object of { FieldName: value } |
|||
import { DataList, Label, TextButton, Spacer, Select } from "@budibase/bbui" |
|||
import { store, backendUiStore } from "builderStore" |
|||
import fetchBindableProperties from "builderStore/fetchBindableProperties" |
|||
import { CloseCircleIcon, AddIcon } from "components/common/Icons" |
|||
import { |
|||
readableToRuntimeBinding, |
|||
runtimeToReadableBinding, |
|||
} from "builderStore/replaceBindings" |
|||
import { createEventDispatcher } from "svelte" |
|||
|
|||
const dispatch = createEventDispatcher() |
|||
|
|||
export let parameterFields |
|||
export let schemaFields |
|||
|
|||
const emptyField = () => ({ name: "", value: "" }) |
|||
|
|||
// this statement initialises fields from parameters.fields |
|||
$: fields = |
|||
fields || |
|||
Object.keys(parameterFields || { "": "" }).map(name => ({ |
|||
name, |
|||
value: |
|||
(parameterFields && |
|||
runtimeToReadableBinding( |
|||
bindableProperties, |
|||
parameterFields[name].value |
|||
)) || |
|||
"", |
|||
})) |
|||
|
|||
$: bindableProperties = fetchBindableProperties({ |
|||
componentInstanceId: $store.currentComponentInfo._id, |
|||
components: $store.components, |
|||
screen: $store.currentPreviewItem, |
|||
models: $backendUiStore.models, |
|||
}) |
|||
|
|||
const addField = () => { |
|||
const newFields = fields.filter(f => f.name) |
|||
newFields.push(emptyField()) |
|||
fields = newFields |
|||
rebuildParameters() |
|||
} |
|||
|
|||
const removeField = field => () => { |
|||
fields = fields.filter(f => f !== field) |
|||
rebuildParameters() |
|||
} |
|||
|
|||
const rebuildParameters = () => { |
|||
// rebuilds paramters.fields every time a field name or value is added |
|||
// as UI below is bound to "fields" array, but we need to output a { key: value } |
|||
const newParameterFields = {} |
|||
for (let field of fields) { |
|||
if (field.name) { |
|||
// value and type is needed by the client, so it can parse |
|||
// a string into a correct type |
|||
newParameterFields[field.name] = { |
|||
type: schemaFields.find(f => f.name === field.name).type, |
|||
value: readableToRuntimeBinding(bindableProperties, field.value), |
|||
} |
|||
} |
|||
} |
|||
dispatch("fieldschanged", newParameterFields) |
|||
} |
|||
|
|||
// just wraps binding in {{ ... }} |
|||
const toBindingExpression = bindingPath => `{{ ${bindingPath} }}` |
|||
</script> |
|||
|
|||
{#if fields} |
|||
{#each fields as field} |
|||
<Label size="m" color="dark">Field</Label> |
|||
<Select secondary bind:value={field.name} on:blur={rebuildParameters}> |
|||
<option value="" /> |
|||
{#each schemaFields as schemaField} |
|||
<option value={schemaField.name}>{schemaField.name}</option> |
|||
{/each} |
|||
</Select> |
|||
<Label size="m" color="dark">Value</Label> |
|||
<DataList secondary bind:value={field.value} on:blur={rebuildParameters}> |
|||
<option value="" /> |
|||
{#each bindableProperties as bindableProp} |
|||
<option value={toBindingExpression(bindableProp.readableBinding)}> |
|||
{bindableProp.readableBinding} |
|||
</option> |
|||
{/each} |
|||
</DataList> |
|||
<div class="remove-field-container"> |
|||
<TextButton text small on:click={removeField(field)}> |
|||
<CloseCircleIcon /> |
|||
</TextButton> |
|||
</div> |
|||
{/each} |
|||
|
|||
<div> |
|||
<Spacer small /> |
|||
|
|||
<TextButton text small blue on:click={addField}> |
|||
Add Field |
|||
<div style="height: 20px; width: 20px;"> |
|||
<AddIcon /> |
|||
</div> |
|||
</TextButton> |
|||
</div> |
|||
{/if} |
|||
|
|||
<style> |
|||
.remove-field-container :global(button) { |
|||
vertical-align: bottom; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,134 @@ |
|||
<script> |
|||
import { Select, Label } from "@budibase/bbui" |
|||
import { store, backendUiStore } from "builderStore" |
|||
import fetchBindableProperties from "builderStore/fetchBindableProperties" |
|||
import SaveFields from "./SaveFields.svelte" |
|||
import { |
|||
readableToRuntimeBinding, |
|||
runtimeToReadableBinding, |
|||
} from "builderStore/replaceBindings" |
|||
|
|||
export let parameters |
|||
|
|||
$: bindableProperties = fetchBindableProperties({ |
|||
componentInstanceId: $store.currentComponentInfo._id, |
|||
components: $store.components, |
|||
screen: $store.currentPreviewItem, |
|||
models: $backendUiStore.models, |
|||
}) |
|||
|
|||
let idFields |
|||
let recordId |
|||
$: { |
|||
idFields = bindableProperties.filter( |
|||
bindable => |
|||
bindable.type === "context" && bindable.runtimeBinding.endsWith("._id") |
|||
) |
|||
// ensure recordId is always defaulted - there is usually only one option |
|||
if (idFields.length > 0 && !parameters._id) { |
|||
recordId = idFields[0].runtimeBinding |
|||
parameters = parameters |
|||
} else if (!recordId && parameters._id) { |
|||
recordId = parameters._id |
|||
.replace("{{", "") |
|||
.replace("}}", "") |
|||
.trim() |
|||
} |
|||
} |
|||
|
|||
$: parameters._id = `{{ ${recordId} }}` |
|||
|
|||
// just wraps binding in {{ ... }} |
|||
const toBindingExpression = bindingPath => `{{ ${bindingPath} }}` |
|||
|
|||
// finds the selected idBinding, then reads the table/view |
|||
// from the component instance that it belongs to. |
|||
// then returns the field names for that schema |
|||
const schemaFromIdBinding = recordId => { |
|||
if (!recordId) return [] |
|||
|
|||
const idBinding = bindableProperties.find( |
|||
prop => prop.runtimeBinding === recordId |
|||
) |
|||
if (!idBinding) return [] |
|||
|
|||
const { instance } = idBinding |
|||
|
|||
const component = $store.components[instance._component] |
|||
|
|||
// component.context is the name of the prop that holds the modelId |
|||
const modelInfo = instance[component.context] |
|||
|
|||
if (!modelInfo) return [] |
|||
|
|||
const model = $backendUiStore.models.find(m => m._id === modelInfo.modelId) |
|||
parameters.modelId = modelInfo.modelId |
|||
return Object.keys(model.schema).map(k => ({ |
|||
name: k, |
|||
type: model.schema[k].type, |
|||
})) |
|||
} |
|||
|
|||
let schemaFields |
|||
$: { |
|||
if (parameters && recordId) { |
|||
schemaFields = schemaFromIdBinding(recordId) |
|||
} else { |
|||
schemaFields = [] |
|||
} |
|||
} |
|||
|
|||
const onFieldsChanged = e => { |
|||
parameters.fields = e.detail |
|||
} |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
{#if idFields.length === 0} |
|||
<div class="cannot-use"> |
|||
Update record can only be used within a component that provides data, such |
|||
as a List |
|||
</div> |
|||
{:else} |
|||
<Label size="m" color="dark">Record Id</Label> |
|||
<Select secondary bind:value={recordId}> |
|||
<option value="" /> |
|||
{#each idFields as idField} |
|||
<option value={idField.runtimeBinding}> |
|||
{idField.readableBinding} |
|||
</option> |
|||
{/each} |
|||
</Select> |
|||
{/if} |
|||
|
|||
{#if recordId} |
|||
<SaveFields |
|||
parameterFields={parameters.fields} |
|||
{schemaFields} |
|||
on:fieldschanged={onFieldsChanged} /> |
|||
{/if} |
|||
|
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
display: grid; |
|||
column-gap: var(--spacing-s); |
|||
row-gap: var(--spacing-s); |
|||
grid-template-columns: auto 1fr auto 1fr auto; |
|||
align-items: baseline; |
|||
} |
|||
|
|||
.root :global(.relative:nth-child(2)) { |
|||
grid-column-start: 2; |
|||
grid-column-end: 6; |
|||
} |
|||
|
|||
.cannot-use { |
|||
color: var(--red); |
|||
font-size: var(--font-size-s); |
|||
text-align: center; |
|||
width: 70%; |
|||
margin: auto; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,23 @@ |
|||
import NavigateTo from "./NavigateTo.svelte" |
|||
import UpdateRecord from "./UpdateRecord.svelte" |
|||
import CreateRecord from "./CreateRecord.svelte" |
|||
|
|||
// defines what actions are available, when adding a new one
|
|||
// the component is the setup panel for the action
|
|||
// NOTE that the "name" is used by the client library,
|
|||
// so if you want to change it, you must change it client lib too
|
|||
|
|||
export default [ |
|||
{ |
|||
name: "Create Record", |
|||
component: CreateRecord, |
|||
}, |
|||
{ |
|||
name: "Navigate To", |
|||
component: NavigateTo, |
|||
}, |
|||
{ |
|||
name: "Update Record", |
|||
component: UpdateRecord, |
|||
}, |
|||
] |
|||
@ -1 +0,0 @@ |
|||
export { default } from "./EventsEditor.svelte" |
|||
@ -0,0 +1,293 @@ |
|||
<script> |
|||
import { DropdownMenu, Button, Input } from "@budibase/bbui" |
|||
import { createEventDispatcher, tick } from "svelte" |
|||
|
|||
import icons from "./icons.js" |
|||
|
|||
const dispatch = createEventDispatcher() |
|||
|
|||
export let value = "" |
|||
export let maxIconsPerPage = 30 |
|||
|
|||
let searchTerm = "" |
|||
let selectedLetter = "A" |
|||
|
|||
let currentPage = 1 |
|||
let filteredIcons = findIconByTerm(selectedLetter) |
|||
|
|||
$: dispatch("change", value) |
|||
|
|||
const alphabet = [ |
|||
"A", |
|||
"B", |
|||
"C", |
|||
"D", |
|||
"E", |
|||
"F", |
|||
"G", |
|||
"H", |
|||
"I", |
|||
"J", |
|||
"K", |
|||
"L", |
|||
"M", |
|||
"N", |
|||
"O", |
|||
"P", |
|||
"Q", |
|||
"R", |
|||
"S", |
|||
"T", |
|||
"U", |
|||
"V", |
|||
"W", |
|||
"X", |
|||
"Y", |
|||
"Z", |
|||
] |
|||
let buttonAnchor, dropdown |
|||
let loading = false |
|||
|
|||
function findIconByTerm(term) { |
|||
const r = new RegExp(`\^${term}`, "i") |
|||
return icons.filter(i => r.test(i.label)) |
|||
} |
|||
|
|||
async function switchLetter(letter) { |
|||
currentPage = 1 |
|||
searchTerm = "" |
|||
loading = true |
|||
selectedLetter = letter |
|||
filteredIcons = findIconByTerm(letter) |
|||
await tick() //svg icons do not update without tick |
|||
loading = false |
|||
} |
|||
|
|||
async function findIconOnPage() { |
|||
loading = true |
|||
const iconIdx = filteredIcons.findIndex(i => i.value === value) |
|||
if (iconIdx !== -1) { |
|||
currentPage = Math.ceil(iconIdx / maxIconsPerPage) |
|||
} |
|||
await tick() //svg icons do not update without tick |
|||
loading = false |
|||
} |
|||
|
|||
async function setSelectedUI() { |
|||
if (value) { |
|||
const letter = displayValue.substring(0, 1) |
|||
await switchLetter(letter) |
|||
await findIconOnPage() |
|||
} |
|||
} |
|||
|
|||
async function pageClick(next) { |
|||
loading = true |
|||
if (next && currentPage < totalPages) { |
|||
currentPage++ |
|||
} else if (!next && currentPage > 1) { |
|||
currentPage-- |
|||
} |
|||
await tick() //svg icons do not update without tick |
|||
loading = false |
|||
} |
|||
|
|||
async function searchForIcon(e) { |
|||
currentPage = 1 |
|||
loading = true |
|||
filteredIcons = findIconByTerm(searchTerm) |
|||
await tick() //svg icons do not update without tick |
|||
loading = false |
|||
} |
|||
|
|||
$: displayValue = value ? value.substring(7) : "Pick Icon" |
|||
|
|||
$: totalPages = Math.ceil(filteredIcons.length / maxIconsPerPage) |
|||
$: pageEndIdx = maxIconsPerPage * currentPage |
|||
$: pagedIcons = filteredIcons.slice(pageEndIdx - maxIconsPerPage, pageEndIdx) |
|||
|
|||
$: pagerText = `Page ${currentPage} of ${totalPages}` |
|||
</script> |
|||
|
|||
<div bind:this={buttonAnchor}> |
|||
<Button secondary on:click={dropdown.show}>{displayValue}</Button> |
|||
</div> |
|||
<DropdownMenu |
|||
bind:this={dropdown} |
|||
on:open={setSelectedUI} |
|||
anchor={buttonAnchor}> |
|||
<div class="container"> |
|||
<div class="search-area"> |
|||
<div class="alphabet-area"> |
|||
{#each alphabet as letter, idx} |
|||
<span |
|||
class="letter" |
|||
class:letter-selected={letter === selectedLetter} |
|||
on:click={() => switchLetter(letter)}> |
|||
{letter} |
|||
</span> |
|||
{#if idx !== alphabet.length - 1} |
|||
<span>-</span> |
|||
{/if} |
|||
{/each} |
|||
</div> |
|||
<div class="search-input"> |
|||
<div class="input-wrapper"> |
|||
<Input bind:value={searchTerm} thin placeholder="Search Icon" /> |
|||
</div> |
|||
<Button secondary on:click={searchForIcon}>Search</Button> |
|||
</div> |
|||
<div class="page-area"> |
|||
<div class="pager"> |
|||
<span on:click={() => pageClick(false)}> |
|||
<i class="page-btn fas fa-chevron-left" /> |
|||
</span> |
|||
<span>{pagerText}</span> |
|||
<span on:click={() => pageClick(true)}> |
|||
<i class="page-btn fas fa-chevron-right" /> |
|||
</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
{#if pagedIcons.length > 0} |
|||
<div class="icon-area"> |
|||
{#if !loading} |
|||
{#each pagedIcons as icon} |
|||
<div |
|||
class="icon-container" |
|||
class:selected={value === icon.value} |
|||
on:click={() => (value = icon.value)}> |
|||
<div class="icon-preview"> |
|||
<i class={`${icon.value} fa-3x`} /> |
|||
</div> |
|||
<div class="icon-label">{icon.label}</div> |
|||
</div> |
|||
{/each} |
|||
{/if} |
|||
</div> |
|||
{:else} |
|||
<div class="no-icons"> |
|||
<h5> |
|||
{`There is no icons for this ${searchTerm ? 'search' : 'page'}`} |
|||
</h5> |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
</DropdownMenu> |
|||
|
|||
<style> |
|||
.container { |
|||
width: 610px; |
|||
height: 350px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
padding: 10px 0px 10px 15px; |
|||
overflow-x: hidden; |
|||
} |
|||
|
|||
.search-area { |
|||
flex: 0 0 80px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
} |
|||
|
|||
.icon-area { |
|||
flex: 1; |
|||
display: grid; |
|||
grid-template-columns: repeat(5, 1fr); |
|||
grid-gap: 5px; |
|||
justify-content: flex-start; |
|||
overflow-y: auto; |
|||
overflow-x: hidden; |
|||
padding-right: 10px; |
|||
} |
|||
|
|||
.no-icons { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
} |
|||
|
|||
.alphabet-area { |
|||
display: flex; |
|||
flex-flow: row wrap; |
|||
padding-bottom: 10px; |
|||
padding-right: 15px; |
|||
justify-content: space-around; |
|||
} |
|||
|
|||
.loading-container { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
} |
|||
|
|||
.search-input { |
|||
display: flex; |
|||
flex-flow: row nowrap; |
|||
width: 100%; |
|||
padding-right: 15px; |
|||
} |
|||
|
|||
.input-wrapper { |
|||
width: 510px; |
|||
margin-right: 5px; |
|||
} |
|||
|
|||
.page-area { |
|||
padding: 10px; |
|||
display: flex; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.letter { |
|||
color: var(--blue); |
|||
} |
|||
|
|||
.letter:hover { |
|||
cursor: pointer; |
|||
text-decoration: underline; |
|||
} |
|||
|
|||
.letter-selected { |
|||
text-decoration: underline; |
|||
} |
|||
|
|||
.icon-container { |
|||
height: 100px; |
|||
display: flex; |
|||
justify-content: center; |
|||
flex-direction: column; |
|||
border: var(--border-dark); |
|||
} |
|||
|
|||
.icon-container:hover { |
|||
cursor: pointer; |
|||
background: var(--grey-2); |
|||
} |
|||
|
|||
.selected { |
|||
background: var(--grey-3); |
|||
} |
|||
|
|||
.icon-preview { |
|||
flex: 1; |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
} |
|||
|
|||
.icon-label { |
|||
flex: 0 0 20px; |
|||
text-align: center; |
|||
font-size: 12px; |
|||
} |
|||
|
|||
.page-btn { |
|||
color: var(--blue); |
|||
} |
|||
|
|||
.page-btn:hover { |
|||
cursor: pointer; |
|||
} |
|||
</style> |
|||
File diff suppressed because it is too large
@ -0,0 +1,3 @@ |
|||
import "@fortawesome/fontawesome-free/js/all.js" |
|||
|
|||
export { default as IconSelect } from "./IconSelect.svelte" |
|||
@ -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,20 +1,38 @@ |
|||
import api from "../api" |
|||
import renderTemplateString from "./renderTemplateString" |
|||
|
|||
export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType" |
|||
|
|||
export const eventHandlers = routeTo => { |
|||
const handler = (parameters, execute) => ({ |
|||
execute, |
|||
parameters, |
|||
}) |
|||
const handlers = { |
|||
"Navigate To": param => routeTo(param && param.url), |
|||
} |
|||
|
|||
return { |
|||
"Navigate To": handler(["url"], param => routeTo(param && param.url)), |
|||
"Trigger Workflow": handler(["workflow"], api.triggerWorkflow), |
|||
// when an event is called, this is what gets run
|
|||
const runEventActions = async (actions, state) => { |
|||
if (!actions) return |
|||
// calls event handlers sequentially
|
|||
for (let action of actions) { |
|||
const handler = handlers[action[EVENT_TYPE_MEMBER_NAME]] |
|||
const parameters = createParameters(action.parameters, state) |
|||
if (handler) { |
|||
await handler(parameters) |
|||
} |
|||
} |
|||
} |
|||
|
|||
return runEventActions |
|||
} |
|||
|
|||
export const isEventType = prop => |
|||
Array.isArray(prop) && |
|||
prop.length > 0 && |
|||
!prop[0][EVENT_TYPE_MEMBER_NAME] === undefined |
|||
// this will take a parameters obj, iterate all keys, and do a mustache render
|
|||
// for every string. It will work recursively if it encounnters an {}
|
|||
const createParameters = (parameterTemplateObj, state) => { |
|||
const parameters = {} |
|||
for (let key in parameterTemplateObj) { |
|||
if (typeof parameterTemplateObj[key] === "string") { |
|||
parameters[key] = renderTemplateString(parameterTemplateObj[key], state) |
|||
} else if (typeof parameterTemplateObj[key] === "object") { |
|||
parameters[key] = createParameters(parameterTemplateObj[key], state) |
|||
} |
|||
} |
|||
return parameters |
|||
} |
|||
|
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue