forked from tsai/budibase
114 changed files with 8914 additions and 1633 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.
@ -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 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", |
||||
} |
} |
||||
|
|||||
|
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> |
<script> |
||||
import { store } from "builderStore" |
import { store } from "builderStore" |
||||
import { Button, Select } from "@budibase/bbui" |
import { TextButton, Button, Heading, DropdownMenu } from "@budibase/bbui" |
||||
import HandlerSelector from "./HandlerSelector.svelte" |
import { AddIcon, ArrowDownIcon } from "components/common/Icons/" |
||||
import ActionButton from "../../common/ActionButton.svelte" |
|
||||
import getIcon from "../../common/icon" |
|
||||
import { CloseIcon } from "components/common/Icons/" |
|
||||
|
|
||||
import { EVENT_TYPE_MEMBER_NAME } from "../../common/eventHandlers" |
import { EVENT_TYPE_MEMBER_NAME } from "../../common/eventHandlers" |
||||
|
import actionTypes from "./actions" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
|
||||
export let event |
export let event |
||||
export let eventOptions = [] |
|
||||
export let onClose |
|
||||
|
|
||||
let eventType = "" |
let addActionButton |
||||
|
let addActionDropdown |
||||
|
let selectedAction |
||||
|
|
||||
let draftEventHandler = { parameters: [] } |
let draftEventHandler = { parameters: [] } |
||||
|
|
||||
$: eventData = event || { handlers: [] } |
$: actions = event || [] |
||||
$: if (!eventOptions.includes(eventType) && eventOptions.length > 0) |
$: selectedActionComponent = |
||||
eventType = eventOptions[0].name |
selectedAction && |
||||
|
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_MEMBER_NAME]) |
||||
|
.component |
||||
|
|
||||
const closeModal = () => { |
const closeModal = () => { |
||||
onClose() |
dispatch("close") |
||||
draftEventHandler = { parameters: [] } |
draftEventHandler = { parameters: [] } |
||||
eventData = { handlers: [] } |
actions = [] |
||||
} |
} |
||||
|
|
||||
const updateEventHandler = (updatedHandler, index) => { |
const updateEventHandler = (updatedHandler, index) => { |
||||
eventData.handlers[index] = updatedHandler |
actions[index] = updatedHandler |
||||
} |
|
||||
|
|
||||
const updateDraftEventHandler = updatedHandler => { |
|
||||
draftEventHandler = updatedHandler |
|
||||
} |
} |
||||
|
|
||||
const deleteEventHandler = index => { |
const deleteAction = index => { |
||||
eventData.handlers.splice(index, 1) |
actions.splice(index, 1) |
||||
eventData = eventData |
actions = actions |
||||
} |
} |
||||
|
|
||||
const createNewEventHandler = handler => { |
const addAction = actionType => () => { |
||||
const newHandler = handler || { |
const newAction = { |
||||
parameters: {}, |
parameters: {}, |
||||
[EVENT_TYPE_MEMBER_NAME]: "", |
[EVENT_TYPE_MEMBER_NAME]: actionType.name, |
||||
} |
} |
||||
eventData.handlers.push(newHandler) |
actions.push(newAction) |
||||
eventData = eventData |
selectedAction = newAction |
||||
|
actions = actions |
||||
|
addActionDropdown.hide() |
||||
} |
} |
||||
|
|
||||
const deleteEvent = () => { |
const selectAction = action => () => { |
||||
store.setComponentProp(eventType, []) |
selectedAction = action |
||||
closeModal() |
|
||||
} |
} |
||||
|
|
||||
const saveEventData = () => { |
const saveEventData = () => { |
||||
store.setComponentProp(eventType, eventData.handlers) |
dispatch("change", actions) |
||||
closeModal() |
closeModal() |
||||
} |
} |
||||
</script> |
</script> |
||||
|
|
||||
<div class="container"> |
<div class="root"> |
||||
<div class="body"> |
|
||||
<div class="heading"> |
<div class="header"> |
||||
<h3> |
<Heading small dark>Actions</Heading> |
||||
{eventData.name ? `${eventData.name} Event` : 'Create a New Component Event'} |
<div bind:this={addActionButton}> |
||||
</h3> |
<TextButton text small blue on:click={addActionDropdown.show}> |
||||
|
Add Action |
||||
|
<div style="height: 20px; width: 20px;"> |
||||
|
<AddIcon /> |
||||
|
</div> |
||||
|
</TextButton> |
||||
</div> |
</div> |
||||
<div class="event-options"> |
<DropdownMenu |
||||
<div class="section"> |
bind:this={addActionDropdown} |
||||
<h4>Event Type</h4> |
anchor={addActionButton} |
||||
<Select bind:value={eventType}> |
align="right"> |
||||
{#each eventOptions as option} |
<div class="available-actions-container"> |
||||
<option value={option.name}>{option.name}</option> |
{#each actionTypes as actionType} |
||||
{/each} |
<div class="available-action" on:click={addAction(actionType)}> |
||||
</Select> |
<span>{actionType.name}</span> |
||||
|
</div> |
||||
|
{/each} |
||||
</div> |
</div> |
||||
</div> |
</DropdownMenu> |
||||
|
</div> |
||||
|
|
||||
<div class="section"> |
<div class="actions-container"> |
||||
<h4>Event Action(s)</h4> |
{#if actions && actions.length > 0} |
||||
<HandlerSelector |
{#each actions as action, index} |
||||
newHandler |
<div class="action-container"> |
||||
onChanged={updateDraftEventHandler} |
<div class="action-header" on:click={selectAction(action)}> |
||||
onCreate={() => { |
<p |
||||
createNewEventHandler(draftEventHandler) |
class="bb-body bb-body--small bb-body--color-dark" |
||||
draftEventHandler = { parameters: [] } |
style="margin: var(--spacing-s) 0;"> |
||||
}} |
{index + 1}. {action[EVENT_TYPE_MEMBER_NAME]} |
||||
handler={draftEventHandler} /> |
</p> |
||||
</div> |
<div class="row-expander" class:rotate={action !== selectedAction}> |
||||
{#if eventData} |
<ArrowDownIcon /> |
||||
{#each eventData.handlers as handler, index} |
</div> |
||||
<HandlerSelector |
</div> |
||||
{index} |
{#if action === selectedAction} |
||||
onChanged={updateEventHandler} |
<div class="selected-action-container"> |
||||
onRemoved={() => deleteEventHandler(index)} |
<svelte:component |
||||
{handler} /> |
this={selectedActionComponent} |
||||
|
parameters={selectedAction.parameters} /> |
||||
|
<div class="delete-action-button"> |
||||
|
<TextButton text medium on:click={() => deleteAction(index)}> |
||||
|
Delete |
||||
|
</TextButton> |
||||
|
</div> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
{/each} |
{/each} |
||||
{/if} |
{/if} |
||||
|
|
||||
</div> |
</div> |
||||
|
|
||||
<div class="footer"> |
<div class="footer"> |
||||
{#if eventData.name} |
<a href="https://docs.budibase.com">Learn more about Actions</a> |
||||
<Button |
<Button secondary on:click={closeModal}>Cancel</Button> |
||||
outline |
<Button primary on:click={saveEventData}>Save</Button> |
||||
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 /> |
|
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
|
|
||||
<style> |
<style> |
||||
.container { |
.root { |
||||
position: relative; |
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; |
cursor: pointer; |
||||
position: absolute; |
|
||||
top: 20px; |
|
||||
right: 20px; |
|
||||
} |
} |
||||
.close-button :global(svg) { |
|
||||
width: 24px; |
.available-action:hover { |
||||
height: 24px; |
background: var(--grey-2); |
||||
} |
} |
||||
|
|
||||
h4 { |
.actions-container { |
||||
margin-bottom: 10px; |
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 { |
.action-container { |
||||
margin: 0; |
border: var(--border-light); |
||||
font-size: 24px; |
border-width: 1px 0 0 0; |
||||
font-weight: bold; |
padding-left: var(--spacing-xl); |
||||
|
padding-right: var(--spacing-xl); |
||||
|
padding-top: 0; |
||||
|
padding-bottom: 0; |
||||
} |
} |
||||
.body { |
|
||||
padding: 40px; |
.selected-action-container { |
||||
display: grid; |
padding-bottom: var(--spacing-s); |
||||
grid-gap: 20px; |
padding-top: var(--spacing-s); |
||||
} |
} |
||||
.footer { |
|
||||
|
.delete-action-button { |
||||
|
padding-top: var(--spacing-l); |
||||
display: flex; |
display: flex; |
||||
justify-content: flex-end; |
justify-content: flex-end; |
||||
padding: 30px 40px; |
flex-direction: row; |
||||
border-bottom-left-radius: 5px; |
|
||||
border-bottom-right-radius: 50px; |
|
||||
background-color: var(--grey-1); |
|
||||
} |
} |
||||
.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> |
</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> |
<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,20 +1,38 @@ |
|||||
import api from "../api" |
import renderTemplateString from "./renderTemplateString" |
||||
|
|
||||
export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType" |
export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType" |
||||
|
|
||||
export const eventHandlers = routeTo => { |
export const eventHandlers = routeTo => { |
||||
const handler = (parameters, execute) => ({ |
const handlers = { |
||||
execute, |
"Navigate To": param => routeTo(param && param.url), |
||||
parameters, |
} |
||||
}) |
|
||||
|
|
||||
return { |
// when an event is called, this is what gets run
|
||||
"Navigate To": handler(["url"], param => routeTo(param && param.url)), |
const runEventActions = async (actions, state) => { |
||||
"Trigger Workflow": handler(["workflow"], api.triggerWorkflow), |
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 => |
// this will take a parameters obj, iterate all keys, and do a mustache render
|
||||
Array.isArray(prop) && |
// for every string. It will work recursively if it encounnters an {}
|
||||
prop.length > 0 && |
const createParameters = (parameterTemplateObj, state) => { |
||||
!prop[0][EVENT_TYPE_MEMBER_NAME] === undefined |
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 = { |
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) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue