Merge pull request #621 from Budibase/contextual-workflows

Contextual workflows
This commit is contained in:
Michael Drury 2020-09-22 11:35:38 +01:00 committed by GitHub
commit f07ddef370
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1533 additions and 577 deletions

View File

@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
node-version: [10.x]
node-version: [12.x]
steps:
- uses: actions/checkout@v2

View File

@ -15,7 +15,7 @@ jobs:
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
node-version: [10.x]
node-version: [12.x]
steps:
- uses: actions/checkout@v2

View File

@ -21,17 +21,25 @@ context("Create a workflow", () => {
// Add trigger
cy.get("[data-cy=add-workflow-component]").click()
cy.get("[data-cy=RECORD_SAVED]").click()
cy.get(".budibase__input").select("dog")
cy.get("[data-cy=workflow-block-setup]").within(() => {
cy.get("select")
.first()
.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")
cy.get("[data-cy=workflow-block-setup]").within(() => {
cy.get("select")
.first()
.select("dog")
cy.get("input")
.first()
.type("goodboy")
cy.get("input")
.eq(1)
.type("11")
})
// Save
cy.contains("Save Workflow").click()
@ -44,7 +52,6 @@ context("Create a workflow", () => {
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")

View File

@ -63,7 +63,7 @@
}
},
"dependencies": {
"@budibase/bbui": "^1.33.0",
"@budibase/bbui": "^1.34.2",
"@budibase/client": "^0.1.21",
"@budibase/colorpicker": "^1.0.1",
"@fortawesome/fontawesome-free": "^5.14.0",
@ -100,7 +100,7 @@
"jest": "^24.8.0",
"ncp": "^2.0.0",
"rimraf": "^3.0.2",
"rollup": "^1.12.0",
"rollup": "^2.11.2",
"rollup-plugin-alias": "^1.5.2",
"rollup-plugin-commonjs": "^10.0.0",
"rollup-plugin-copy": "^3.0.0",
@ -110,10 +110,10 @@
"rollup-plugin-node-globals": "^1.4.0",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-svelte": "^5.0.3",
"rollup-plugin-terser": "^4.0.4",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-url": "^2.2.2",
"start-server-and-test": "^1.11.0",
"svelte": "3.23.x",
"svelte": "^3.24.1",
"svelte-jester": "^1.0.6"
},
"gitHead": "115189f72a850bfb52b65ec61d932531bf327072"

View File

@ -87,7 +87,7 @@ export const getBackendUiStore = () => {
state.models = state.models.filter(
existing => existing._id !== model._id
)
state.selectedModel = state.models[0] || {}
state.selectedModel = {}
return state
})
},

View File

@ -23,6 +23,7 @@ const workflowActions = store => ({
create: async ({ name }) => {
const workflow = {
name,
type: "workflow",
definition: {
steps: [],
},

View File

@ -0,0 +1,58 @@
<script>
import GenericBindingPopover from "./GenericBindingPopover.svelte"
import { Input, Icon } from "@budibase/bbui"
export let bindings = []
export let value
let anchor
let popover = undefined
let enrichedValue
let inputProps
// Extract all other props to pass to input component
$: {
let { bindings, ...otherProps } = $$props
inputProps = otherProps
}
</script>
<div class="container" bind:this={anchor}>
<Input {...inputProps} bind:value />
<button on:click={popover.show}>
<Icon name="edit" />
</button>
</div>
<GenericBindingPopover
{anchor}
{bindings}
bind:value
bind:popover
align="right" />
<style>
.container {
position: relative;
}
button {
position: absolute;
background: none;
border: none;
border-radius: 50%;
height: 24px;
width: 24px;
background: var(--grey-4);
right: 7px;
bottom: 7px;
}
button:hover {
background: var(--grey-5);
cursor: pointer;
}
button :global(svg) {
position: absolute;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%) !important;
}
</style>

View File

@ -0,0 +1,129 @@
<script>
import groupBy from "lodash/fp/groupBy"
import { TextArea, Label, Body, Button, Popover } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let value = ""
export let bindings = []
export let anchor
export let align
export let popover = null
$: categories = Object.entries(groupBy("category", bindings))
function onClickBinding(binding) {
value += `{{ ${binding.path} }}`
}
</script>
<Popover {anchor} {align} bind:this={popover}>
<div class="container">
<div class="bindings">
<Label large>Available bindings</Label>
<div class="bindings__wrapper">
<div class="bindings__list">
{#each categories as [categoryName, bindings]}
<Label small>{categoryName}</Label>
{#each bindings as binding}
<div class="binding" on:click={() => onClickBinding(binding)}>
<span class="binding__label">{binding.label}</span>
<span class="binding__type">{binding.type}</span>
<br />
<div class="binding__description">{binding.description}</div>
</div>
{/each}
{/each}
</div>
</div>
</div>
<div class="editor">
<Label large>Data binding</Label>
<Body small>
Binding connects one piece of data to another and makes it dynamic.
Click the objects on the left to add them to the textbox.
</Body>
<TextArea thin bind:value placeholder="..." />
<div class="controls">
<a href="#">
<Body small>Learn more about binding</Body>
</a>
<Button on:click={popover.hide} primary>Done</Button>
</div>
</div>
</div>
</Popover>
<style>
.container {
display: grid;
grid-template-columns: 280px 1fr;
width: 800px;
}
.bindings {
border-right: 1px solid var(--grey-4);
flex: 0 0 240px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
padding-right: var(--spacing-l);
}
.bindings :global(label) {
margin: var(--spacing-m) 0;
}
.bindings :global(label:first-child) {
margin-top: 0;
}
.bindings__wrapper {
overflow-y: auto;
position: relative;
flex: 1 1 auto;
}
.bindings__list {
position: absolute;
width: 100%;
}
.binding {
font-size: 12px;
padding: var(--spacing-s);
border-radius: var(--border-radius-m);
}
.binding:hover {
background-color: var(--grey-2);
cursor: pointer;
}
.binding__label {
font-weight: 500;
text-transform: capitalize;
}
.binding__description {
color: var(--grey-8);
margin-top: 2px;
}
.binding__type {
font-family: monospace;
background-color: var(--grey-2);
border-radius: var(--border-radius-m);
padding: 2px;
margin-left: 2px;
font-weight: 500;
}
.editor {
padding-left: var(--spacing-l);
}
.editor :global(textarea) {
min-height: 60px;
}
.controls {
display: grid;
grid-template-columns: 1fr auto;
grid-gap: var(--spacing-l);
align-items: center;
margin-top: var(--spacing-m);
}
</style>

View File

@ -1,23 +1,15 @@
<script>
import { backendUiStore } from "builderStore"
import { Select } from "@budibase/bbui"
export let value
$: modelId = value ? value._id : ""
function onChange(e) {
value = $backendUiStore.models.find(model => model._id === e.target.value)
}
</script>
<div class="block-field">
<select
class="budibase__input"
value={modelId}
on:blur={onChange}
on:change={onChange}>
<Select bind:value secondary thin>
<option value="">Choose an option</option>
{#each $backendUiStore.models as model}
<option value={model._id}>{model.name}</option>
{/each}
</select>
</Select>
</div>

View File

@ -1,51 +1,69 @@
<script>
import { backendUiStore } from "builderStore"
import { Input, Label } from "@budibase/bbui"
import { Input, Select, Label } from "@budibase/bbui"
import BindableInput from "../../../userInterface/BindableInput.svelte"
export let value
$: modelId = value && value.model ? value.model._id : ""
$: schemaFields = Object.keys(value && value.model ? value.model.schema : {})
export let bindings
function onChangeModel(e) {
value.model = $backendUiStore.models.find(
model => model._id === e.target.value
)
}
$: model = $backendUiStore.models.find(model => model._id === value?.modelId)
$: schemaFields = Object.entries(model?.schema ?? {})
function setParsedValue(evt, field) {
const fieldSchema = value.model.schema[field]
if (fieldSchema.type === "number") {
value[field] = parseInt(evt.target.value)
return
}
value[field] = evt.target.value
// Ensure any nullish modelId values get set to empty string so
// that the select works
$: if (value?.modelId == null) value = { modelId: "" }
function schemaHasOptions(schema) {
return !!schema.constraints?.inclusion?.length
}
</script>
<div class="block-field">
<select
class="budibase__input"
value={modelId}
on:blur={onChangeModel}
on:change={onChangeModel}>
<Select bind:value={value.modelId} thin secondary>
<option value="">Choose an option</option>
{#each $backendUiStore.models as model}
<option value={model._id}>{model.name}</option>
{/each}
</select>
</Select>
</div>
{#if schemaFields.length}
<div class="bb-margin-xl block-field">
<Label small forAttr={'fields'}>Fields</Label>
{#each schemaFields as field}
<div class="bb-margin-xl">
<Input
thin
value={value[field]}
label={field}
on:change={e => setParsedValue(e, field)} />
{#each schemaFields as [field, schema]}
<div class="bb-margin-xl capitalise">
{#if schemaHasOptions(schema)}
<div class="field-label">{field}</div>
<Select thin secondary bind:value={value[field]}>
<option value="">Choose an option</option>
{#each schema.constraints.inclusion as option}
<option value={option}>{option}</option>
{/each}
</Select>
{:else if schema.type === 'string' || schema.type === 'number'}
<BindableInput
thin
bind:value={value[field]}
label={field}
type={schema.type}
{bindings} />
{/if}
</div>
{/each}
</div>
{/if}
<style>
.field-label {
color: var(--ink);
margin-bottom: 12px;
display: flex;
font-size: 14px;
font-weight: 500;
font-family: sans-serif;
}
.capitalise :global(label),
.field-label {
text-transform: capitalize;
}
</style>

View File

@ -10,8 +10,10 @@
let selectedTab = "SETUP"
$: workflow =
$workflowStore.selectedWorkflow && $workflowStore.selectedWorkflow.workflow
$: workflow = $workflowStore.selectedWorkflow?.workflow
$: allowDeleteBlock =
$workflowStore.selectedBlock?.type !== "TRIGGER" ||
!workflow?.definition?.steps?.length
function deleteWorkflow() {
open(
@ -60,7 +62,13 @@
<Button green wide data-cy="save-workflow-setup" on:click={saveWorkflow}>
Save Workflow
</Button>
<Button red wide on:click={deleteWorkflowBlock}>Delete Block</Button>
<Button
disabled={!allowDeleteBlock}
red
wide
on:click={deleteWorkflowBlock}>
Delete Block
</Button>
</div>
{:else if $workflowStore.selectedWorkflow}
<div class="panel">

View File

@ -1,41 +1,75 @@
<script>
import ModelSelector from "./ParamInputs/ModelSelector.svelte"
import RecordSelector from "./ParamInputs/RecordSelector.svelte"
import { Input, TextArea, Select } from "@budibase/bbui"
import { Input, TextArea, Select, Label } from "@budibase/bbui"
import { workflowStore } from "builderStore"
import BindableInput from "../../userInterface/BindableInput.svelte"
export let block
$: params = block.params ? Object.entries(block.params) : []
$: inputs = Object.entries(block.schema?.inputs?.properties || {})
$: bindings = getAvailableBindings(
block,
$workflowStore.selectedWorkflow?.workflow?.definition
)
function getAvailableBindings(block, workflow) {
if (!block || !workflow) {
return []
}
// Find previous steps to the selected one
let allSteps = [...workflow.steps]
if (workflow.trigger) {
allSteps = [workflow.trigger, ...allSteps]
}
const blockIdx = allSteps.findIndex(step => step.id === block.id)
// Extract all outputs from all previous steps as available bindings
let bindings = []
for (let idx = 0; idx < blockIdx; idx++) {
const outputs = Object.entries(
allSteps[idx].schema?.outputs?.properties ?? {}
)
bindings = bindings.concat(
outputs.map(([name, value]) => ({
label: name,
type: value.type,
description: value.description,
category: idx === 0 ? "Trigger outputs" : `Step ${idx} outputs`,
path: idx === 0 ? `trigger.${name}` : `steps.${idx}.${name}`,
}))
)
}
return bindings
}
</script>
<div class="container">
<div class="selected-label">{block.name}</div>
{#each params as [parameter, type]}
<div class="block-field">
<label class="label">{parameter}</label>
{#if Array.isArray(type)}
<Select bind:value={block.args[parameter]} thin secondary>
<div class="container" data-cy="workflow-block-setup">
<div class="block-label">{block.name}</div>
{#each inputs as [key, value]}
<div class="bb-margin-xl block-field">
<div class="field-label">{value.title}</div>
{#if value.type === 'string' && value.enum}
<Select bind:value={block.inputs[key]} thin secondary>
<option value="">Choose an option</option>
{#each type as option}
<option value={option}>{option}</option>
{#each value.enum as option, idx}
<option value={option}>
{value.pretty ? value.pretty[idx] : option}
</option>
{/each}
</Select>
{:else if type === 'accessLevel'}
<Select bind:value={block.args[parameter]} thin secondary>
<option value="ADMIN">Admin</option>
<option value="POWER_USER">Power User</option>
</Select>
{:else if type === 'password'}
<Input type="password" thin bind:value={block.args[parameter]} />
{:else if type === 'number'}
<Input type="number" thin bind:value={block.args[parameter]} />
{:else if type === 'longText'}
<TextArea type="text" thin bind:value={block.args[parameter]} />
{:else if type === 'model'}
<ModelSelector bind:value={block.args[parameter]} />
{:else if type === 'record'}
<RecordSelector bind:value={block.args[parameter]} />
{:else if type === 'string'}
<Input type="text" thin bind:value={block.args[parameter]} />
{:else if value.customType === 'password'}
<Input type="password" thin bind:value={block.inputs[key]} />
{:else if value.customType === 'model'}
<ModelSelector bind:value={block.inputs[key]} />
{:else if value.customType === 'record'}
<RecordSelector bind:value={block.inputs[key]} {bindings} />
{:else if value.type === 'string' || value.type === 'number'}
<BindableInput
type={value.type}
thin
bind:value={block.inputs[key]}
{bindings} />
{/if}
</div>
{/each}
@ -46,17 +80,16 @@
display: grid;
}
label {
font-size: 14px;
font-family: sans-serif;
font-weight: 500;
.field-label {
color: var(--ink);
margin-bottom: 12px;
text-transform: capitalize;
margin-top: 20px;
display: flex;
font-size: 14px;
font-weight: 500;
font-family: sans-serif;
}
.selected-label {
.block-label {
font-weight: 500;
font-size: 14px;
color: var(--grey-7);

View File

@ -4,9 +4,8 @@
import { notifier } from "builderStore/store/notifications"
import Flowchart from "./flowchart/FlowChart.svelte"
$: workflow =
$workflowStore.selectedWorkflow && $workflowStore.selectedWorkflow.workflow
$: workflowLive = workflow && workflow.live
$: workflow = $workflowStore.selectedWorkflow?.workflow
$: workflowLive = workflow?.live
$: instanceId = $backendUiStore.selectedDatabase._id
function onSelect(block) {

View File

@ -1,42 +1,44 @@
<script>
import mustache from "mustache"
import { workflowStore } from "builderStore"
import WorkflowBlockTagline from "./WorkflowBlockTagline.svelte"
export let onSelect
export let block
let selected
$: selected =
$workflowStore.selectedBlock != null &&
$workflowStore.selectedBlock.id === block.id
function selectBlock() {
onSelect(block)
}
$: selected = $workflowStore.selectedBlock?.id === block.id
$: steps = $workflowStore.selectedWorkflow?.workflow?.definition?.steps ?? []
$: blockIdx = steps.findIndex(step => step.id === block.id)
</script>
<div class={`${block.type} hoverable`} class:selected on:click={selectBlock}>
<div
class={`block ${block.type} hoverable`}
class:selected
on:click={() => onSelect(block)}>
<header>
{#if block.type === 'TRIGGER'}
<i class="ri-lightbulb-fill" />
When this happens...
<span>When this happens...</span>
{:else if block.type === 'ACTION'}
<i class="ri-flashlight-fill" />
Do this...
<span>Do this...</span>
{:else if block.type === 'LOGIC'}
<i class="ri-pause-fill" />
Only continue if...
<i class="ri-git-branch-line" />
<span>Only continue if...</span>
{/if}
<div class="label">
{#if block.type === 'TRIGGER'}Trigger{:else}Step {blockIdx + 1}{/if}
</div>
</header>
<hr />
<p>
{@html mustache.render(block.tagline, block.args)}
<WorkflowBlockTagline {block} />
</p>
</div>
<style>
div {
width: 320px;
.block {
width: 360px;
padding: 20px;
border-radius: var(--border-radius-m);
transition: 0.3s all ease;
@ -45,14 +47,30 @@
font-size: 16px;
color: var(--white);
}
.block.selected,
.block:hover {
transform: scale(1.1);
box-shadow: 0 4px 30px 0 rgba(57, 60, 68, 0.15);
}
header {
font-size: 16px;
font-weight: 500;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
header span {
flex: 1 1 auto;
}
header .label {
font-size: 14px;
padding: var(--spacing-s);
color: var(--grey-8);
border-radius: var(--border-radius-m);
background-color: rgba(0, 0, 0, 0.05);
}
header i {
font-size: 20px;
margin-right: 5px;
@ -67,6 +85,10 @@
background-color: var(--ink);
color: var(--white);
}
.TRIGGER header .label {
background-color: var(--grey-9);
color: var(--grey-5);
}
.LOGIC {
background-color: var(--blue-light);
@ -77,10 +99,4 @@
color: inherit;
margin-bottom: 0;
}
div.selected,
div:hover {
transform: scale(1.1);
box-shadow: 0 4px 30px 0 rgba(57, 60, 68, 0.15);
}
</style>

View File

@ -0,0 +1,77 @@
<script>
import mustache from "mustache"
import { get } from "lodash/fp"
import { backendUiStore } from "builderStore"
export let block
$: inputs = enrichInputs(block.inputs)
$: tagline = formatTagline(block.tagline, block.schema, inputs)
$: html = (tagline || "")
.replace(/{{\s*/g, "<span>")
.replace(/\s*}}/g, "</span>")
function enrichInputs(inputs) {
let enrichedInputs = { ...inputs, enriched: {} }
const modelId = inputs.modelId || inputs.record?.modelId
if (modelId) {
enrichedInputs.enriched.model = $backendUiStore.models.find(
model => model._id === modelId
)
}
return enrichedInputs
}
function formatTagline(tagline, schema, inputs) {
// Add bold tags around inputs
let formattedTagline = tagline
.replace(/{{/g, "<b>{{")
.replace(/}}/, "}}</b>")
// Extract schema paths for any input bindings
const inputPaths = formattedTagline
.match(/{{\s*\S+\s*}}/g)
.map(x => x.replace(/[{}]/g, "").trim())
const schemaPaths = inputPaths.map(x => x.replace(/\./g, ".properties."))
// Replace any enum bindings with their pretty equivalents
schemaPaths.forEach((path, idx) => {
const prettyValues = get(`${path}.pretty`, schema)
if (prettyValues) {
const enumValues = get(`${path}.enum`, schema)
const inputPath = inputPaths[idx]
const value = get(inputPath, { inputs })
const valueIdx = enumValues.indexOf(value)
const prettyValue = prettyValues[valueIdx]
if (prettyValue == null) {
return
}
formattedTagline = formattedTagline.replace(
new RegExp(`{{\s*${inputPath}\s*}}`),
prettyValue
)
}
})
// Fill in bindings with mustache
return mustache.render(formattedTagline, { inputs })
}
</script>
<div>
{@html html}
</div>
<style>
div {
line-height: 1.25;
}
div :global(span) {
font-size: 0.9em;
background-color: var(--purple-light);
padding: var(--spacing-xs);
border-radius: var(--border-radius-m);
display: inline-block;
margin: 1px;
}
</style>

View File

@ -8,9 +8,7 @@
const { open, close } = getContext("simple-modal")
$: selectedWorkflowId =
$workflowStore.selectedWorkflow &&
$workflowStore.selectedWorkflow.workflow._id
$: selectedWorkflowId = $workflowStore.selectedWorkflow?.workflow?._id
function newWorkflow() {
open(

View File

@ -20,7 +20,7 @@
class="hoverable"
class:selected={selectedTab === 'ADD'}
on:click={() => (selectedTab = 'ADD')}>
Add
Add step
</span>
{/if}
</header>

View File

@ -8,6 +8,13 @@
dependencies:
"@babel/highlight" "^7.8.3"
"@babel/code-frame@^7.10.4":
version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a"
integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==
dependencies:
"@babel/highlight" "^7.10.4"
"@babel/compat-data@^7.9.6":
version "7.9.6"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.9.6.tgz#3f604c40e420131affe6f2c8052e9a275ae2049b"
@ -184,6 +191,11 @@
dependencies:
"@babel/types" "^7.8.3"
"@babel/helper-validator-identifier@^7.10.4":
version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2"
integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==
"@babel/helper-validator-identifier@^7.9.0", "@babel/helper-validator-identifier@^7.9.5":
version "7.9.5"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz#90977a8e6fbf6b431a7dc31752eee233bf052d80"
@ -205,6 +217,15 @@
"@babel/traverse" "^7.9.6"
"@babel/types" "^7.9.6"
"@babel/highlight@^7.10.4":
version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143"
integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==
dependencies:
"@babel/helper-validator-identifier" "^7.10.4"
chalk "^2.0.0"
js-tokens "^4.0.0"
"@babel/highlight@^7.8.3":
version "7.9.0"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.9.0.tgz#4e9b45ccb82b79607271b2979ad82c7b68163079"
@ -688,10 +709,10 @@
lodash "^4.17.13"
to-fast-properties "^2.0.0"
"@budibase/bbui@^1.33.0":
version "1.33.0"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.33.0.tgz#216b24dd815f45880e9795e66b04848329b0390f"
integrity sha512-Rrt5eLbea014TIfAbT40kP0D0AWNUi8Q0kDr3UZO6Aq4UXgjc0f53ZuJ7Kb66YRDWrqiucjf1FtvOUs3/YaD6g==
"@budibase/bbui@^1.34.2":
version "1.34.2"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.34.2.tgz#e4fcc728dc8d51a918f8ebd5c3f0b0afacfa4047"
integrity sha512-6RusGPZnEpHx1gtGcjk/lFLgMgFdDpSIxB8v2MiA+kp+uP1pFlzegbaDh+/JXyqFwK7HO91I0yXXBoPjibi7Aw==
dependencies:
sirv-cli "^0.4.6"
svelte-flatpickr "^2.4.0"
@ -760,6 +781,7 @@
"@fortawesome/fontawesome-free@^5.14.0":
version "5.14.0"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.14.0.tgz#a371e91029ebf265015e64f81bfbf7d228c9681f"
integrity sha512-OfdMsF+ZQgdKHP9jUbmDcRrP0eX90XXrsXIdyjLbkmSBzmMXPABB8eobUJtivaupucYaByz6WNe1PI1JuYm3qA==
"@hapi/address@^2.1.2":
version "2.1.4"
@ -1137,10 +1159,6 @@
version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
"@types/estree@*":
version "0.0.44"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.44.tgz#980cc5a29a3ef3bea6ff1f7d021047d7ea575e21"
"@types/estree@0.0.39":
version "0.0.39"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
@ -1283,7 +1301,7 @@ acorn@^6.0.1:
version "6.4.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474"
acorn@^7.1.0, acorn@^7.1.1:
acorn@^7.1.1:
version "7.2.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.2.0.tgz#17ea7e40d7c8640ff54a694c889c26f31704effe"
@ -1387,6 +1405,7 @@ array-equal@^1.0.0:
array-filter@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-1.0.0.tgz#baf79e62e6ef4c2a4c0b831232daffec251f9d83"
integrity sha1-uveeYubvTCpMC4MSMtr/7CUfnYM=
array-union@^2.1.0:
version "2.1.0"
@ -1445,6 +1464,7 @@ atob@^2.1.2:
available-typed-arrays@^1.0.0, available-typed-arrays@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.2.tgz#6b098ca9d8039079ee3f77f7b783c4480ba513f5"
integrity sha512-XWX3OX8Onv97LMk/ftVyBibpGwY5a8SmuxZPzeOxqmuEqUCOM9ZE+uIaD1VNJ5QnvU2UQusvmKbuM1FR8QWGfQ==
dependencies:
array-filter "^1.0.0"
@ -1908,7 +1928,7 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
dependencies:
delayed-stream "~1.0.0"
commander@2, commander@^2.19.0:
commander@2, commander@^2.20.0:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
@ -2400,6 +2420,7 @@ decode-uri-component@^0.2.0:
deep-equal@^2.0.1:
version "2.0.3"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.0.3.tgz#cad1c15277ad78a5c01c49c2dee0f54de8a6a7b0"
integrity sha512-Spqdl4H+ky45I9ByyJtXteOm9CaIrPmnIPmOhrkKGNYWeDgCvJ8jNYVCTjChxW4FqGuZnLHADc8EKRMX6+CgvA==
dependencies:
es-abstract "^1.17.5"
es-get-iterator "^1.1.0"
@ -2588,6 +2609,7 @@ es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.5:
es-abstract@^1.17.4:
version "1.17.6"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a"
integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==
dependencies:
es-to-primitive "^1.2.1"
function-bind "^1.1.1"
@ -2604,6 +2626,7 @@ es-abstract@^1.17.4:
es-abstract@^1.18.0-next.0:
version "1.18.0-next.0"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.0.tgz#b302834927e624d8e5837ed48224291f2c66e6fc"
integrity sha512-elZXTZXKn51hUBdJjSZGYRujuzilgXo8vSPQzjGYXLvSlGiCo8VO8ZGV3kjo9a0WNJJ57hENagwbtlRuHuzkcQ==
dependencies:
es-to-primitive "^1.2.1"
function-bind "^1.1.1"
@ -2621,6 +2644,7 @@ es-abstract@^1.18.0-next.0:
es-get-iterator@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.0.tgz#bb98ad9d6d63b31aacdc8f89d5d0ee57bcb5b4c8"
integrity sha512-UfrmHuWQlNMTs35e1ypnvikg6jCz3SK8v8ImvmDsh36fCVUR1MqoFDiyn0/k52C8NqO3YsO8Oe0azeesNuqSsQ==
dependencies:
es-abstract "^1.17.4"
has-symbols "^1.0.1"
@ -3313,6 +3337,7 @@ is-accessor-descriptor@^1.0.0:
is-arguments@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3"
integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==
is-arrayish@^0.2.1:
version "0.2.1"
@ -3321,6 +3346,7 @@ is-arrayish@^0.2.1:
is-bigint@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.0.tgz#73da8c33208d00f130e9b5e15d23eac9215601c4"
integrity sha512-t5mGUXC/xRheCK431ylNiSkGGpBp8bHENBcENTkDT6ppwPzEVxNGZRvgvmOEfbWkFhA7D2GEuE2mmQTr78sl2g==
is-binary-path@~2.1.0:
version "2.1.0"
@ -3331,6 +3357,7 @@ is-binary-path@~2.1.0:
is-boolean-object@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.0.1.tgz#10edc0900dd127697a92f6f9807c7617d68ac48e"
integrity sha512-TqZuVwa/sppcrhUCAYkGBk7w0yxfQQnxq28fjkO53tnK9FQXmdwz2JS5+GjsWQ6RByES1K40nI+yDic5c9/aAQ==
is-buffer@^1.1.5:
version "1.1.6"
@ -3343,6 +3370,7 @@ is-callable@^1.1.4, is-callable@^1.1.5:
is-callable@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.1.tgz#4d1e21a4f437509d25ce55f8184350771421c96d"
integrity sha512-wliAfSzx6V+6WfMOmus1xy0XvSgf/dlStkvTfq7F0g4bOIW0PSUbnyse3NhDwdyYS1ozfUtAAySqTws3z9Eqgg==
is-ci@^2.0.0:
version "2.0.0"
@ -3430,6 +3458,7 @@ is-installed-globally@^0.3.2:
is-map@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.1.tgz#520dafc4307bb8ebc33b813de5ce7c9400d644a1"
integrity sha512-T/S49scO8plUiAOA2DBTBG3JHpn1yiw0kRp6dgiZ0v2/6twi5eiB0rHtHFH9ZIrvlWc6+4O+m4zg5+Z833aXgw==
is-module@^1.0.0:
version "1.0.0"
@ -3438,10 +3467,12 @@ is-module@^1.0.0:
is-negative-zero@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461"
integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE=
is-number-object@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197"
integrity sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw==
is-number@^3.0.0:
version "3.0.0"
@ -3502,12 +3533,14 @@ is-regex@^1.0.5:
is-regex@^1.1.0, is-regex@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9"
integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==
dependencies:
has-symbols "^1.0.1"
is-set@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43"
integrity sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA==
is-stream@^1.1.0:
version "1.1.0"
@ -3520,6 +3553,7 @@ is-stream@^2.0.0:
is-string@^1.0.4, is-string@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6"
integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==
is-symbol@^1.0.2:
version "1.0.3"
@ -3530,6 +3564,7 @@ is-symbol@^1.0.2:
is-typed-array@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.3.tgz#a4ff5a5e672e1a55f99c7f54e59597af5c1df04d"
integrity sha512-BSYUBOK/HJibQ30wWkWold5txYwMUXQct9YHAQJr8fSwvZoiglcqB0pd7vEN23+Tsi9IUEjztdOSzl4qLVYGTQ==
dependencies:
available-typed-arrays "^1.0.0"
es-abstract "^1.17.4"
@ -3543,10 +3578,12 @@ is-typedarray@~1.0.0:
is-weakmap@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.1.tgz#5008b59bdc43b698201d18f62b37b2ca243e8cf2"
integrity sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==
is-weakset@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.1.tgz#e9a0af88dbd751589f5e50d80f4c98b780884f83"
integrity sha512-pi4vhbhVHGLxohUw7PhGsueT4vRGFoXhP7+RGN0jKIv9+8PWYCQTqtADngrxOm2g46hoH0+g8uZZBzMrvVGDmw==
is-windows@^1.0.2:
version "1.0.2"
@ -3571,6 +3608,7 @@ isarray@1.0.0, isarray@~1.0.0:
isarray@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
isbuffer@~0.0.0:
version "0.0.0"
@ -3972,13 +4010,22 @@ jest-watcher@^24.9.0:
jest-util "^24.9.0"
string-length "^2.0.0"
jest-worker@^24.0.0, jest-worker@^24.6.0, jest-worker@^24.9.0:
jest-worker@^24.6.0, jest-worker@^24.9.0:
version "24.9.0"
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.9.0.tgz#5dbfdb5b2d322e98567898238a9697bcce67b3e5"
dependencies:
merge-stream "^2.0.0"
supports-color "^6.1.0"
jest-worker@^26.2.1:
version "26.3.0"
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.3.0.tgz#7c8a97e4f4364b4f05ed8bca8ca0c24de091871f"
integrity sha512-Vmpn2F6IASefL+DVBhPzI2J9/GJUsqzomdeN+P+dK8/jKxbh8R3BtFnx3FIta7wYlPU62cpJMJQo4kuOowcMnw==
dependencies:
"@types/node" "*"
merge-stream "^2.0.0"
supports-color "^7.0.0"
jest@^24.8.0:
version "24.9.0"
resolved "https://registry.yarnpkg.com/jest/-/jest-24.9.0.tgz#987d290c05a08b52c56188c1002e368edb007171"
@ -4685,10 +4732,12 @@ object-inspect@^1.7.0:
object-inspect@^1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0"
integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==
object-is@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.2.tgz#c5d2e87ff9e119f78b7a088441519e2eec1573b6"
integrity sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ==
dependencies:
define-properties "^1.1.3"
es-abstract "^1.17.5"
@ -5086,7 +5135,7 @@ ramda@~0.26.1:
version "0.26.1"
resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06"
randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5:
randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
dependencies:
@ -5206,6 +5255,7 @@ regex-not@^1.0.0, regex-not@^1.0.2:
regexp.prototype.flags@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz#7aba89b3c13a64509dabcf3ca8d9fbb9bdf5cb75"
integrity sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==
dependencies:
define-properties "^1.1.3"
es-abstract "^1.17.0-next.1"
@ -5213,6 +5263,7 @@ regexp.prototype.flags@^1.3.0:
regexparam@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/regexparam/-/regexparam-1.3.0.tgz#2fe42c93e32a40eff6235d635e0ffa344b92965f"
integrity sha512-6IQpFBv6e5vz1QAqI+V4k8P2e/3gRrqfCJ9FI+O1FLQTO+Uz6RXZEZOPmTJ6hlGj7gkERzY5BRCv09whKP96/g==
regexpu-core@^4.7.0:
version "4.7.0"
@ -5450,14 +5501,15 @@ rollup-plugin-svelte@^5.0.3:
rollup-pluginutils "^2.8.2"
sourcemap-codec "^1.4.8"
rollup-plugin-terser@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-4.0.4.tgz#6f661ef284fa7c27963d242601691dc3d23f994e"
rollup-plugin-terser@^7.0.2:
version "7.0.2"
resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d"
integrity sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==
dependencies:
"@babel/code-frame" "^7.0.0"
jest-worker "^24.0.0"
serialize-javascript "^1.6.1"
terser "^3.14.1"
"@babel/code-frame" "^7.10.4"
jest-worker "^26.2.1"
serialize-javascript "^4.0.0"
terser "^5.0.0"
rollup-plugin-url@^2.2.2:
version "2.2.4"
@ -5473,13 +5525,12 @@ rollup-pluginutils@^2.3.1, rollup-pluginutils@^2.8.1, rollup-pluginutils@^2.8.2:
dependencies:
estree-walker "^0.6.1"
rollup@^1.12.0:
version "1.32.1"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.32.1.tgz#4480e52d9d9e2ae4b46ba0d9ddeaf3163940f9c4"
dependencies:
"@types/estree" "*"
"@types/node" "*"
acorn "^7.1.0"
rollup@^2.11.2:
version "2.27.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.27.0.tgz#f2b70a8dd583bc3675b36686289aa9a51e27af4f"
integrity sha512-1WlbhNdzhLjdhh2wsf6CDxmuBAYG+5O53fYqCcGv8aJOoX/ymCfCY6oZnvllXZzaC/Ng+lPPwq9EMbHOKc5ozA==
optionalDependencies:
fsevents "~2.1.2"
rsvp@^4.8.4:
version "4.8.5"
@ -5563,9 +5614,12 @@ semver@~2.3.1:
version "2.3.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-2.3.2.tgz#b9848f25d6cf36333073ec9ef8856d42f1233e52"
serialize-javascript@^1.6.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.9.1.tgz#cfc200aef77b600c47da9bb8149c943e798c2fdb"
serialize-javascript@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa"
integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==
dependencies:
randombytes "^2.1.0"
set-blocking@^2.0.0:
version "2.0.0"
@ -5621,6 +5675,7 @@ shortid@^2.2.15:
side-channel@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.3.tgz#cdc46b057550bbab63706210838df5d4c19519c3"
integrity sha512-A6+ByhlLkksFoUepsGxfj5x1gTSrs+OydsRptUxeNCabQpCFUvcwIczgOigI8vhY/OJCnPnyE9rGiwgvr9cS1g==
dependencies:
es-abstract "^1.18.0-next.0"
object-inspect "^1.8.0"
@ -5701,7 +5756,7 @@ source-map-resolve@^0.5.0, source-map-resolve@^0.5.2:
source-map-url "^0.4.0"
urix "^0.1.0"
source-map-support@^0.5.6, source-map-support@~0.5.10:
source-map-support@^0.5.6, source-map-support@~0.5.12:
version "0.5.19"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
dependencies:
@ -5955,6 +6010,13 @@ supports-color@^6.1.0:
dependencies:
has-flag "^3.0.0"
supports-color@^7.0.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
dependencies:
has-flag "^4.0.0"
supports-color@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1"
@ -5985,9 +6047,10 @@ svelte-simple-modal@^0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/svelte-simple-modal/-/svelte-simple-modal-0.4.2.tgz#2cfe26ec8c0760b89813d65dfee836399620d6b2"
svelte@3.23.x:
version "3.23.0"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.23.0.tgz#bbcd6887cf588c24a975b14467455abfff9acd3f"
svelte@^3.24.1:
version "3.25.1"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.25.1.tgz#218def1243fea5a97af6eb60f5e232315bb57ac4"
integrity sha512-IbrVKTmuR0BvDw4ii8/gBNy8REu7nWTRy9uhUz+Yuae5lIjWgSGwKlWtJGC2Vg95s+UnXPqDu0Kk/sUwe0t2GQ==
symbol-observable@^1.1.0:
version "1.2.0"
@ -6001,13 +6064,14 @@ synchronous-promise@^2.0.13:
version "2.0.13"
resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.13.tgz#9d8c165ddee69c5a6542862b405bc50095926702"
terser@^3.14.1:
version "3.17.0"
resolved "https://registry.yarnpkg.com/terser/-/terser-3.17.0.tgz#f88ffbeda0deb5637f9d24b0da66f4e15ab10cb2"
terser@^5.0.0:
version "5.3.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.3.1.tgz#f50fe20ab48b15234fe9bdd86b10148ad5fca787"
integrity sha512-yD80f4hdwCWTH5mojzxe1q8bN1oJbsK/vfJGLcPZM/fl+/jItIVNKhFIHqqR71OipFWMLgj3Kc+GIp6CeIqfnA==
dependencies:
commander "^2.19.0"
commander "^2.20.0"
source-map "~0.6.1"
source-map-support "~0.5.10"
source-map-support "~0.5.12"
test-exclude@^5.2.3:
version "5.2.3"
@ -6323,6 +6387,7 @@ whatwg-url@^8.0.0:
which-boxed-primitive@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.1.tgz#cbe8f838ebe91ba2471bb69e9edbda67ab5a5ec1"
integrity sha512-7BT4TwISdDGBgaemWU0N0OU7FeAEJ9Oo2P1PHRm/FCWoEi2VLWC9b6xvxAA3C/NMpxg3HXVgi0sMmGbNUbNepQ==
dependencies:
is-bigint "^1.0.0"
is-boolean-object "^1.0.0"
@ -6333,6 +6398,7 @@ which-boxed-primitive@^1.0.1:
which-collection@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.1.tgz#70eab71ebbbd2aefaf32f917082fc62cdcb70906"
integrity sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==
dependencies:
is-map "^2.0.1"
is-set "^2.0.1"
@ -6346,6 +6412,7 @@ which-module@^2.0.0:
which-typed-array@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.2.tgz#e5f98e56bda93e3dac196b01d47c1156679c00b2"
integrity sha512-KT6okrd1tE6JdZAy3o2VhMoYPh3+J6EMZLyrxBQsZflI1QCZIxMrIYLkosd8Twf+YfknVIHmYQPgJt238p8dnQ==
dependencies:
available-typed-arrays "^1.0.2"
es-abstract "^1.17.5"

View File

@ -3,11 +3,16 @@ const validateJs = require("validate.js")
const newid = require("../../db/newid")
function emitEvent(eventType, ctx, record) {
// add syntactic sugar for mustache later
if (record._id) {
record.id = record._id
}
if (record._rev) {
record.revision = record._rev
}
ctx.eventEmitter &&
ctx.eventEmitter.emit(eventType, {
args: {
record,
},
record,
instanceId: ctx.user.instanceId,
})
}
@ -53,7 +58,6 @@ exports.patch = async function(ctx) {
ctx.body = record
ctx.status = 200
ctx.message = `${model.name} updated successfully.`
return
}
exports.save = async function(ctx) {
@ -179,10 +183,13 @@ exports.destroy = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
const record = await db.get(ctx.params.recordId)
if (record.modelId !== ctx.params.modelId) {
ctx.throw(400, "Supplied modelId doe not match the record's modelId")
ctx.throw(400, "Supplied modelId doesn't match the record's modelId")
return
}
ctx.body = await db.remove(ctx.params.recordId, ctx.params.revId)
ctx.status = 200
// for workflows
ctx.record = record
emitEvent(`record:delete`, ctx, record)
}

View File

@ -56,6 +56,7 @@ exports.create = async function(ctx) {
ctx.status = 200
ctx.message = "User created successfully."
ctx.userId = response._id
ctx.body = {
_rev: response.rev,
username,

View File

@ -1,7 +1,8 @@
const CouchDB = require("../../../db")
const newid = require("../../../db/newid")
const blockDefinitions = require("./blockDefinitions")
const triggers = require("../../../workflows/triggers")
const CouchDB = require("../../db")
const newid = require("../../db/newid")
const actions = require("../../workflows/actions")
const logic = require("../../workflows/logic")
const triggers = require("../../workflows/triggers")
/*************************
* *
@ -9,13 +10,34 @@ const triggers = require("../../../workflows/triggers")
* *
*************************/
function cleanWorkflowInputs(workflow) {
if (workflow == null) {
return workflow
}
let steps = workflow.definition.steps
let trigger = workflow.definition.trigger
let allSteps = [...steps, trigger]
for (let step of allSteps) {
if (step == null) {
continue
}
for (let inputName of Object.keys(step.inputs)) {
if (!step.inputs[inputName] || step.inputs[inputName] === "") {
delete step.inputs[inputName]
}
}
}
return workflow
}
exports.create = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
const workflow = ctx.request.body
let workflow = ctx.request.body
workflow._id = newid()
workflow.type = "workflow"
workflow = cleanWorkflowInputs(workflow)
const response = await db.post(workflow)
workflow._rev = response.rev
@ -31,8 +53,9 @@ exports.create = async function(ctx) {
exports.update = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
const workflow = ctx.request.body
let workflow = ctx.request.body
workflow = cleanWorkflowInputs(workflow)
const response = await db.put(workflow)
workflow._rev = response.rev
@ -67,22 +90,22 @@ exports.destroy = async function(ctx) {
}
exports.getActionList = async function(ctx) {
ctx.body = blockDefinitions.ACTION
ctx.body = actions.BUILTIN_DEFINITIONS
}
exports.getTriggerList = async function(ctx) {
ctx.body = blockDefinitions.TRIGGER
ctx.body = triggers.BUILTIN_DEFINITIONS
}
exports.getLogicList = async function(ctx) {
ctx.body = blockDefinitions.LOGIC
ctx.body = logic.BUILTIN_DEFINITIONS
}
module.exports.getDefinitionList = async function(ctx) {
ctx.body = {
logic: blockDefinitions.LOGIC,
trigger: blockDefinitions.TRIGGER,
action: blockDefinitions.ACTION,
logic: logic.BUILTIN_DEFINITIONS,
trigger: triggers.BUILTIN_DEFINITIONS,
action: actions.BUILTIN_DEFINITIONS,
}
}

View File

@ -1,113 +0,0 @@
const ACTION = {
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.",
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",
params: {},
args: {},
type: "ACTION",
},
CREATE_USER: {
description: "Create a new user.",
tagline: "Create user <b>{{username}}</b>",
icon: "ri-user-add-fill",
name: "Create User",
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",
params: {
to: "string",
from: "string",
subject: "longText",
text: "longText",
},
type: "ACTION",
},
}
const LOGIC = {
FILTER: {
name: "Filter",
tagline: "{{filter}} <b>{{condition}}</b> {{value}}",
icon: "ri-git-branch-line",
description: "Filter any workflows which do not meet certain conditions.",
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.",
params: {
time: "number",
},
type: "LOGIC",
},
}
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,
LOGIC,
TRIGGER,
}

View File

@ -67,6 +67,13 @@ exports.createModel = async (request, appId, instanceId, model) => {
return res.body
}
exports.getAllFromModel = async (request, appId, instanceId, modelId) => {
const res = await request
.get(`/api/${modelId}/records`)
.set(exports.defaultHeaders(appId, instanceId))
return res.body
}
exports.createView = async (request, appId, instanceId, modelId, view) => {
view = view || {
map: "function(doc) { emit(doc[doc.key], doc._id); } ",

View File

@ -0,0 +1 @@
module.exports.delay = ms => new Promise(resolve => setTimeout(resolve, ms))

View File

@ -2,6 +2,8 @@ const {
createClientDatabase,
createApplication,
createInstance,
createModel,
getAllFromModel,
defaultHeaders,
supertest,
insertDocument,
@ -9,6 +11,9 @@ const {
builderEndpointShouldBlockNormalUsers
} = require("./couchTestUtils")
const { delay } = require("./testUtils")
const MAX_RETRIES = 4
const TEST_WORKFLOW = {
_id: "Test Workflow",
name: "My Workflow",
@ -19,24 +24,24 @@ const TEST_WORKFLOW = {
},
definition: {
triggers: [
trigger: {},
steps: [
],
next: {
stepId: "abc123",
type: "SERVER",
conditions: {
}
}
}
},
type: "workflow",
}
let ACTION_DEFINITIONS = {}
let TRIGGER_DEFINITIONS = {}
let LOGIC_DEFINITIONS = {}
describe("/workflows", () => {
let request
let server
let app
let instance
let workflow
let workflowId
beforeAll(async () => {
({ request, server } = await supertest())
@ -45,8 +50,8 @@ describe("/workflows", () => {
})
beforeEach(async () => {
if (workflow) await destroyDocument(workflow.id)
instance = await createInstance(request, app._id)
if (workflow) await destroyDocument(workflow.id);
})
afterAll(async () => {
@ -57,10 +62,72 @@ describe("/workflows", () => {
workflow = await insertDocument(instance._id, {
type: "workflow",
...TEST_WORKFLOW
});
})
workflow = { ...workflow, ...TEST_WORKFLOW }
}
describe("get definitions", () => {
it("returns a list of definitions for actions", async () => {
const res = await request
.get(`/api/workflows/action/list`)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
expect(Object.keys(res.body).length).not.toEqual(0)
ACTION_DEFINITIONS = res.body
})
it("returns a list of definitions for triggers", async () => {
const res = await request
.get(`/api/workflows/trigger/list`)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
expect(Object.keys(res.body).length).not.toEqual(0)
TRIGGER_DEFINITIONS = res.body
})
it("returns a list of definitions for actions", async () => {
const res = await request
.get(`/api/workflows/logic/list`)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
expect(Object.keys(res.body).length).not.toEqual(0)
LOGIC_DEFINITIONS = res.body
})
it("returns all of the definitions in one", async () => {
const res = await request
.get(`/api/workflows/definitions/list`)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
expect(Object.keys(res.body.action).length).toEqual(Object.keys(ACTION_DEFINITIONS).length)
expect(Object.keys(res.body.trigger).length).toEqual(Object.keys(TRIGGER_DEFINITIONS).length)
expect(Object.keys(res.body.logic).length).toEqual(Object.keys(LOGIC_DEFINITIONS).length)
})
})
describe("create", () => {
it("should setup the workflow fully", () => {
let trigger = TRIGGER_DEFINITIONS["RECORD_SAVED"]
trigger.id = "wadiawdo34"
let saveAction = ACTION_DEFINITIONS["SAVE_RECORD"]
saveAction.inputs.record = {
name: "{{trigger.name}}",
description: "{{trigger.description}}"
}
saveAction.id = "awde444wk"
TEST_WORKFLOW.definition.steps.push(saveAction)
TEST_WORKFLOW.definition.trigger = trigger
})
it("returns a success message when the workflow is successfully created", async () => {
const res = await request
.post(`/api/workflows`)
@ -69,8 +136,10 @@ describe("/workflows", () => {
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.message).toEqual("Workflow created successfully");
expect(res.body.workflow.name).toEqual("My Workflow");
expect(res.body.message).toEqual("Workflow created successfully")
expect(res.body.workflow.name).toEqual("My Workflow")
expect(res.body.workflow._id).not.toEqual(null)
workflowId = res.body.workflow._id
})
it("should apply authorization to endpoint", async () => {
@ -85,12 +154,43 @@ describe("/workflows", () => {
})
})
describe("trigger", () => {
it("trigger the workflow successfully", async () => {
let model = await createModel(request, app._id, instance._id)
TEST_WORKFLOW.definition.trigger.inputs.modelId = model._id
TEST_WORKFLOW.definition.steps[0].inputs.record.modelId = model._id
await createWorkflow()
const res = await request
.post(`/api/workflows/${workflow._id}/trigger`)
.send({ name: "Test" })
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.message).toEqual(`Workflow ${workflow._id} has been triggered.`)
expect(res.body.workflow.name).toEqual(TEST_WORKFLOW.name)
// wait for workflow to complete in background
for (let tries = 0; tries < MAX_RETRIES; tries++) {
let elements = await getAllFromModel(request, app._id, instance._id, model._id)
// don't test it unless there are values to test
if (elements.length === 1) {
expect(elements.length).toEqual(1)
expect(elements[0].name).toEqual("Test")
expect(elements[0].description).toEqual("TEST")
return
}
await delay(500)
}
throw "Failed to find the records"
})
})
describe("update", () => {
it("updates a workflows data", async () => {
await createWorkflow();
await createWorkflow()
workflow._id = workflow.id
workflow._rev = workflow.rev
workflow.name = "Updated Name";
workflow.name = "Updated Name"
workflow.type = "workflow"
const res = await request
.put(`/api/workflows`)
@ -99,21 +199,21 @@ describe("/workflows", () => {
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.message).toEqual("Workflow Test Workflow updated successfully.");
expect(res.body.workflow.name).toEqual("Updated Name");
expect(res.body.message).toEqual("Workflow Test Workflow updated successfully.")
expect(res.body.workflow.name).toEqual("Updated Name")
})
})
describe("fetch", () => {
it("return all the workflows for an instance", async () => {
await createWorkflow();
await createWorkflow()
const res = await request
.get(`/api/workflows`)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
expect(res.body[0]).toEqual(expect.objectContaining(TEST_WORKFLOW));
expect(res.body[0]).toEqual(expect.objectContaining(TEST_WORKFLOW))
})
it("should apply authorization to endpoint", async () => {
@ -129,18 +229,18 @@ describe("/workflows", () => {
describe("destroy", () => {
it("deletes a workflow by its ID", async () => {
await createWorkflow();
await createWorkflow()
const res = await request
.delete(`/api/workflows/${workflow.id}/${workflow.rev}`)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.id).toEqual(TEST_WORKFLOW._id);
expect(res.body.id).toEqual(TEST_WORKFLOW._id)
})
it("should apply authorization to endpoint", async () => {
await createWorkflow();
await createWorkflow()
await builderEndpointShouldBlockNormalUsers({
request,
method: "DELETE",
@ -150,4 +250,4 @@ describe("/workflows", () => {
})
})
})
});
})

View File

@ -23,18 +23,20 @@ function generateStepSchema(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))
function generateValidator(existing = false) {
// prettier-ignore
return joiValidator.body(Joi.object({
live: Joi.bool(),
_id: existing ? Joi.string().required() : Joi.string(),
_rev: existing ? Joi.string().required() : Joi.string(),
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().unknown(true),
}).unknown(true))
}
router
.get(
@ -62,13 +64,13 @@ router
.put(
"/api/workflows",
authorized(BUILDER),
workflowValidator,
generateValidator(true),
controller.update
)
.post(
"/api/workflows",
authorized(BUILDER),
workflowValidator,
generateValidator(false),
controller.create
)
.post("/api/workflows/:id/trigger", controller.trigger)

View File

@ -8,4 +8,5 @@ module.exports = {
SALT_ROUNDS: process.env.SALT_ROUNDS,
LOGGER: process.env.LOGGER,
BUDIBASE_ENVIRONMENT: process.env.BUDIBASE_ENVIRONMENT,
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
}

View File

@ -1,11 +1,19 @@
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}`)
}
if (!schema) {
return next()
}
let params = null
if (ctx[property] != null) {
params = ctx[property]
} else if (ctx.request[property] != null) {
params = ctx.request[property]
}
const { error } = schema.validate(params)
if (error) {
ctx.throw(400, `Invalid ${property} - ${error.message}`)
return
}
return next()
}

View File

@ -1,100 +1,36 @@
const viewController = require("../api/controllers/view")
const modelController = require("../api/controllers/model")
const workflowController = require("../api/controllers/workflow")
// Access Level IDs
const ADMIN_LEVEL_ID = "ADMIN"
const POWERUSER_LEVEL_ID = "POWER_USER"
const BUILDER_LEVEL_ID = "BUILDER"
const ANON_LEVEL_ID = "ANON"
// Permissions
const READ_MODEL = "read-model"
const WRITE_MODEL = "write-model"
const READ_VIEW = "read-view"
const EXECUTE_WORKFLOW = "execute-workflow"
const USER_MANAGEMENT = "user-management"
const BUILDER = "builder"
const LIST_USERS = "list-users"
const adminPermissions = [
module.exports.READ_MODEL = "read-model"
module.exports.WRITE_MODEL = "write-model"
module.exports.READ_VIEW = "read-view"
module.exports.EXECUTE_WORKFLOW = "execute-workflow"
module.exports.USER_MANAGEMENT = "user-management"
module.exports.BUILDER = "builder"
module.exports.LIST_USERS = "list-users"
// Access Level IDs
module.exports.ADMIN_LEVEL_ID = "ADMIN"
module.exports.POWERUSER_LEVEL_ID = "POWER_USER"
module.exports.BUILDER_LEVEL_ID = "BUILDER"
module.exports.ANON_LEVEL_ID = "ANON"
module.exports.ACCESS_LEVELS = [
module.exports.ADMIN_LEVEL_ID,
module.exports.POWERUSER_LEVEL_ID,
module.exports.BUILDER_LEVEL_ID,
module.exports.ANON_LEVEL_ID,
]
module.exports.PRETTY_ACCESS_LEVELS = {
[module.exports.ADMIN_LEVEL_ID]: "Admin",
[module.exports.POWERUSER_LEVEL_ID]: "Power user",
[module.exports.BUILDER_LEVEL_ID]: "Builder",
[module.exports.ANON_LEVEL_ID]: "Anonymous",
}
module.exports.adminPermissions = [
{
name: USER_MANAGEMENT,
name: module.exports.USER_MANAGEMENT,
},
]
const generateAdminPermissions = async instanceId => [
...adminPermissions,
...(await generatePowerUserPermissions(instanceId)),
]
const generatePowerUserPermissions = async instanceId => {
const fetchModelsCtx = {
user: {
instanceId,
},
}
await modelController.fetch(fetchModelsCtx)
const models = fetchModelsCtx.body
const fetchViewsCtx = {
user: {
instanceId,
},
}
await viewController.fetch(fetchViewsCtx)
const views = fetchViewsCtx.body
const fetchWorkflowsCtx = {
user: {
instanceId,
},
}
await workflowController.fetch(fetchWorkflowsCtx)
const workflows = fetchWorkflowsCtx.body
const readModelPermissions = models.map(m => ({
itemId: m._id,
name: READ_MODEL,
}))
const writeModelPermissions = models.map(m => ({
itemId: m._id,
name: WRITE_MODEL,
}))
const viewPermissions = views.map(v => ({
itemId: v.name,
name: READ_VIEW,
}))
const executeWorkflowPermissions = workflows.map(w => ({
itemId: w._id,
name: EXECUTE_WORKFLOW,
}))
return [
...readModelPermissions,
...writeModelPermissions,
...viewPermissions,
...executeWorkflowPermissions,
{ name: LIST_USERS },
]
}
module.exports = {
ADMIN_LEVEL_ID,
POWERUSER_LEVEL_ID,
BUILDER_LEVEL_ID,
ANON_LEVEL_ID,
READ_MODEL,
WRITE_MODEL,
READ_VIEW,
EXECUTE_WORKFLOW,
USER_MANAGEMENT,
BUILDER,
LIST_USERS,
adminPermissions,
generateAdminPermissions,
generatePowerUserPermissions,
}
// to avoid circular dependencies this is included later, after exporting all enums
const permissions = require("./permissions")
module.exports.generateAdminPermissions = permissions.generateAdminPermissions
module.exports.generatePowerUserPermissions =
permissions.generatePowerUserPermissions

View File

@ -0,0 +1,66 @@
const viewController = require("../api/controllers/view")
const modelController = require("../api/controllers/model")
const workflowController = require("../api/controllers/workflow")
const accessLevels = require("./accessLevels")
// this has been broken out to reduce risk of circular dependency from utilities, no enums defined here
const generateAdminPermissions = async instanceId => [
...accessLevels.adminPermissions,
...(await generatePowerUserPermissions(instanceId)),
]
const generatePowerUserPermissions = async instanceId => {
const fetchModelsCtx = {
user: {
instanceId,
},
}
await modelController.fetch(fetchModelsCtx)
const models = fetchModelsCtx.body
const fetchViewsCtx = {
user: {
instanceId,
},
}
await viewController.fetch(fetchViewsCtx)
const views = fetchViewsCtx.body
const fetchWorkflowsCtx = {
user: {
instanceId,
},
}
await workflowController.fetch(fetchWorkflowsCtx)
const workflows = fetchWorkflowsCtx.body
const readModelPermissions = models.map(m => ({
itemId: m._id,
name: accessLevels.READ_MODEL,
}))
const writeModelPermissions = models.map(m => ({
itemId: m._id,
name: accessLevels.WRITE_MODEL,
}))
const viewPermissions = views.map(v => ({
itemId: v.name,
name: accessLevels.READ_VIEW,
}))
const executeWorkflowPermissions = workflows.map(w => ({
itemId: w._id,
name: accessLevels.EXECUTE_WORKFLOW,
}))
return [
...readModelPermissions,
...writeModelPermissions,
...viewPermissions,
...executeWorkflowPermissions,
{ name: accessLevels.LIST_USERS },
]
}
module.exports.generateAdminPermissions = generateAdminPermissions
module.exports.generatePowerUserPermissions = generatePowerUserPermissions

View File

@ -1,107 +1,20 @@
const userController = require("../api/controllers/user")
const recordController = require("../api/controllers/record")
const sgMail = require("@sendgrid/mail")
const sendEmail = require("./steps/sendEmail")
const saveRecord = require("./steps/saveRecord")
const deleteRecord = require("./steps/deleteRecord")
const createUser = require("./steps/createUser")
sgMail.setApiKey(process.env.SENDGRID_API_KEY)
const BUILTIN_ACTIONS = {
SEND_EMAIL: sendEmail.run,
SAVE_RECORD: saveRecord.run,
DELETE_RECORD: deleteRecord.run,
CREATE_USER: createUser.run,
}
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,
}
}
},
const BUILTIN_DEFINITIONS = {
SEND_EMAIL: sendEmail.definition,
SAVE_RECORD: saveRecord.definition,
DELETE_RECORD: deleteRecord.definition,
CREATE_USER: createUser.definition,
}
module.exports.getAction = async function(actionName) {
@ -110,3 +23,5 @@ module.exports.getAction = async function(actionName) {
}
// TODO: load async actions here
}
module.exports.BUILTIN_DEFINITIONS = BUILTIN_DEFINITIONS

View File

@ -25,6 +25,7 @@ module.exports.init = function() {
if (environment.BUDIBASE_ENVIRONMENT === "PRODUCTION") {
await runWorker(job)
} else {
console.log("Testing standard thread")
await singleThread(job)
}
})

View File

@ -1,24 +1,20 @@
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
let filter = require("./steps/filter")
let delay = require("./steps/delay")
let LOGIC = {
DELAY: async function delay({ args }) {
await wait(args.time)
},
let BUILTIN_LOGIC = {
DELAY: delay.run,
FILTER: filter.run,
}
FILTER: async function filter({ args }) {
const { field, condition, value } = args
switch (condition) {
case "equals":
if (field !== value) return
break
default:
return
}
},
let BUILTIN_DEFINITIONS = {
DELAY: delay.definition,
FILTER: filter.definition,
}
module.exports.getLogic = function(logicName) {
if (LOGIC[logicName] != null) {
return LOGIC[logicName]
if (BUILTIN_LOGIC[logicName] != null) {
return BUILTIN_LOGIC[logicName]
}
}
module.exports.BUILTIN_DEFINITIONS = BUILTIN_DEFINITIONS

View File

@ -0,0 +1,86 @@
const accessLevels = require("../../utilities/accessLevels")
const userController = require("../../api/controllers/user")
module.exports.definition = {
description: "Create a new user",
tagline: "Create user {{inputs.username}}",
icon: "ri-user-add-fill",
name: "Create User",
type: "ACTION",
stepId: "CREATE_USER",
inputs: {
accessLevelId: accessLevels.POWERUSER_LEVEL_ID,
},
schema: {
inputs: {
properties: {
username: {
type: "string",
title: "Username",
},
password: {
type: "string",
customType: "password",
title: "Password",
},
accessLevelId: {
type: "string",
title: "Access Level",
enum: accessLevels.ACCESS_LEVELS,
pretty: Object.values(accessLevels.PRETTY_ACCESS_LEVELS),
},
},
required: ["username", "password", "accessLevelId"],
},
outputs: {
properties: {
id: {
type: "string",
description: "The identifier of the new user",
},
revision: {
type: "string",
description: "The revision of the new user",
},
response: {
type: "object",
description: "The response from the user table",
},
success: {
type: "boolean",
description: "Whether the action was successful",
},
},
required: ["id", "revision", "success"],
},
},
}
module.exports.run = async function({ inputs, instanceId }) {
const { username, password, accessLevelId } = inputs
const ctx = {
user: {
instanceId: instanceId,
},
request: {
body: { username, password, accessLevelId },
},
}
try {
await userController.create(ctx)
return {
response: ctx.body,
// internal property not returned through the API
id: ctx.userId,
revision: ctx.body._rev,
success: ctx.status === 200,
}
} catch (err) {
console.error(err)
return {
success: false,
response: err,
}
}
}

View File

@ -0,0 +1,26 @@
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
module.exports.definition = {
name: "Delay",
icon: "ri-time-fill",
tagline: "Delay for {{inputs.time}} milliseconds",
description: "Delay the workflow until an amount of time has passed",
stepId: "DELAY",
inputs: {},
schema: {
inputs: {
properties: {
time: {
type: "number",
title: "Delay in milliseconds",
},
},
required: ["time"],
},
},
type: "LOGIC",
}
module.exports.run = async function delay({ inputs }) {
await wait(inputs.time)
}

View File

@ -0,0 +1,79 @@
const recordController = require("../../api/controllers/record")
module.exports.definition = {
description: "Delete a record from your database",
icon: "ri-delete-bin-line",
name: "Delete Record",
tagline: "Delete a {{inputs.enriched.model.name}} record",
type: "ACTION",
stepId: "DELETE_RECORD",
inputs: {},
schema: {
inputs: {
properties: {
modelId: {
type: "string",
customType: "model",
title: "Table",
},
id: {
type: "string",
title: "Record ID",
},
revision: {
type: "string",
title: "Record Revision",
},
},
required: ["modelId", "id", "revision"],
},
outputs: {
properties: {
record: {
type: "object",
customType: "record",
description: "The deleted record",
},
response: {
type: "object",
description: "The response from the table",
},
success: {
type: "boolean",
description: "Whether the action was successful",
},
},
required: ["record", "success"],
},
},
}
module.exports.run = async function({ inputs, instanceId }) {
// TODO: better logging of when actions are missed due to missing parameters
if (inputs.id == null || inputs.revision == null) {
return
}
let ctx = {
params: {
modelId: inputs.modelId,
recordId: inputs.id,
revId: inputs.revision,
},
user: { instanceId },
}
try {
await recordController.destroy(ctx)
return {
response: ctx.body,
record: ctx.record,
success: ctx.status === 200,
}
} catch (err) {
console.error(err)
return {
success: false,
response: err,
}
}
}

View File

@ -0,0 +1,81 @@
const LogicConditions = {
EQUAL: "EQUAL",
NOT_EQUAL: "NOT_EQUAL",
GREATER_THAN: "GREATER_THAN",
LESS_THAN: "LESS_THAN",
}
const PrettyLogicConditions = {
[LogicConditions.EQUAL]: "Equals",
[LogicConditions.NOT_EQUAL]: "Not equals",
[LogicConditions.GREATER_THAN]: "Greater than",
[LogicConditions.LESS_THAN]: "Less than",
}
module.exports.definition = {
name: "Filter",
tagline: "{{inputs.field}} {{inputs.condition}} {{inputs.value}}",
icon: "ri-git-branch-line",
description: "Filter any workflows which do not meet certain conditions",
type: "LOGIC",
stepId: "FILTER",
inputs: {
condition: LogicConditions.EQUALS,
},
schema: {
inputs: {
properties: {
field: {
type: "string",
title: "Reference Value",
},
condition: {
type: "string",
title: "Condition",
enum: Object.values(LogicConditions),
pretty: Object.values(PrettyLogicConditions),
},
value: {
type: "string",
title: "Comparison Value",
},
},
required: ["field", "condition", "value"],
},
outputs: {
properties: {
success: {
type: "boolean",
description: "Whether the logic block passed",
},
},
required: ["success"],
},
},
}
module.exports.run = async function filter({ inputs }) {
const { field, condition, value } = inputs
let success
if (typeof field !== "object" && typeof value !== "object") {
switch (condition) {
case LogicConditions.EQUAL:
success = field === value
break
case LogicConditions.NOT_EQUAL:
success = field !== value
break
case LogicConditions.GREATER_THAN:
success = field > value
break
case LogicConditions.LESS_THAN:
success = field < value
break
default:
return
}
} else {
success = false
}
return { success }
}

View File

@ -0,0 +1,90 @@
const recordController = require("../../api/controllers/record")
module.exports.definition = {
name: "Save Record",
tagline: "Save a {{inputs.enriched.model.name}} record",
icon: "ri-save-3-fill",
description: "Save a record to your database",
type: "ACTION",
stepId: "SAVE_RECORD",
inputs: {},
schema: {
inputs: {
properties: {
record: {
type: "object",
properties: {
modelId: {
type: "string",
customType: "model",
},
},
customType: "record",
title: "Table",
required: ["modelId"],
},
},
required: ["record"],
},
outputs: {
properties: {
record: {
type: "object",
customType: "record",
description: "The new record",
},
response: {
type: "object",
description: "The response from the table",
},
success: {
type: "boolean",
description: "Whether the action was successful",
},
id: {
type: "string",
description: "The identifier of the new record",
},
revision: {
type: "string",
description: "The revision of the new record",
},
},
required: ["success", "id", "revision"],
},
},
}
module.exports.run = async function({ inputs, instanceId }) {
// TODO: better logging of when actions are missed due to missing parameters
if (inputs.record == null || inputs.record.modelId == null) {
return
}
// have to clean up the record, remove the model from it
const ctx = {
params: {
modelId: inputs.record.modelId,
},
request: {
body: inputs.record,
},
user: { instanceId },
}
try {
await recordController.save(ctx)
return {
record: inputs.record,
response: ctx.body,
id: ctx.body._id,
revision: ctx.body._rev,
success: ctx.status === 200,
}
} catch (err) {
console.error(err)
return {
success: false,
response: err,
}
}
}

View File

@ -0,0 +1,72 @@
const environment = require("../../environment")
const sgMail = require("@sendgrid/mail")
sgMail.setApiKey(environment.SENDGRID_API_KEY)
module.exports.definition = {
description: "Send an email",
tagline: "Send email to {{inputs.to}}",
icon: "ri-mail-open-fill",
name: "Send Email",
type: "ACTION",
stepId: "SEND_EMAIL",
inputs: {},
schema: {
inputs: {
properties: {
to: {
type: "string",
title: "Send To",
},
from: {
type: "string",
title: "Send From",
},
subject: {
type: "string",
title: "Email Subject",
},
contents: {
type: "string",
title: "Email Contents",
},
},
required: ["to", "from", "subject", "contents"],
},
outputs: {
properties: {
success: {
type: "boolean",
description: "Whether the email was sent",
},
response: {
type: "object",
description: "A response from the email client, this may be an error",
},
},
required: ["success"],
},
},
}
module.exports.run = async function({ inputs }) {
const msg = {
to: inputs.to,
from: inputs.from,
subject: inputs.subject,
text: inputs.contents ? inputs.contents : "Empty",
}
try {
let response = await sgMail.send(msg)
return {
success: true,
response,
}
} catch (err) {
console.error(err)
return {
success: false,
response: err,
}
}
}

View File

@ -2,18 +2,64 @@ const mustache = require("mustache")
const actions = require("./actions")
const logic = require("./logic")
const FILTER_STEP_ID = logic.BUILTIN_DEFINITIONS.FILTER.stepId
function cleanMustache(string) {
let charToReplace = {
"[": ".",
"]": "",
}
let regex = new RegExp(/{{[^}}]*}}/g)
let matches = string.match(regex)
if (matches == null) {
return string
}
for (let match of matches) {
let baseIdx = string.indexOf(match)
for (let key of Object.keys(charToReplace)) {
let idxChar = match.indexOf(key)
if (idxChar !== -1) {
string =
string.slice(baseIdx, baseIdx + idxChar) +
charToReplace[key] +
string.slice(baseIdx + idxChar + 1)
}
}
}
return string
}
function recurseMustache(inputs, context) {
for (let key of Object.keys(inputs)) {
let val = inputs[key]
if (typeof val === "string") {
val = cleanMustache(inputs[key])
inputs[key] = mustache.render(val, context)
}
// this covers objects and arrays
else if (typeof val === "object") {
inputs[key] = recurseMustache(inputs[key], context)
}
}
return inputs
}
/**
* 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 = {}
constructor(workflow, triggerOutput) {
this._instanceId = triggerOutput.instanceId
// remove from context
delete triggerOutput.instanceId
// step zero is never used as the mustache is zero indexed for customer facing
this._context = { steps: [{}], trigger: triggerOutput }
this._workflow = workflow
}
async getStep(type, stepId) {
async getStepFunctionality(type, stepId) {
let step = null
if (type === "ACTION") {
step = await actions.getAction(stepId)
@ -26,28 +72,20 @@ class Orchestrator {
return step
}
async execute(context) {
async execute() {
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,
for (let step of workflow.definition.steps) {
let stepFn = await this.getStepFunctionality(step.type, step.stepId)
step.inputs = recurseMustache(step.inputs, this._context)
// instanceId is always passed
const outputs = await stepFn({
inputs: step.inputs,
instanceId: this._instanceId,
})
this._context = {
...this._context,
[block.id]: response,
if (step.stepId === FILTER_STEP_ID && !outputs.success) {
break
}
this._context.steps.push(outputs)
}
}
}
@ -55,8 +93,11 @@ class Orchestrator {
// 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)
const workflowOrchestrator = new Orchestrator(
job.data.workflow,
job.data.event
)
await workflowOrchestrator.execute()
if (cb) {
cb()
}

View File

@ -4,7 +4,79 @@ const InMemoryQueue = require("./queue/inMemoryQueue")
let workflowQueue = new InMemoryQueue()
async function queueRelevantWorkflows(event, eventType) {
const FAKE_STRING = "TEST"
const FAKE_BOOL = false
const FAKE_NUMBER = 1
const FAKE_DATETIME = "1970-01-01T00:00:00.000Z"
const BUILTIN_DEFINITIONS = {
RECORD_SAVED: {
name: "Record Saved",
event: "record:save",
icon: "ri-save-line",
tagline: "Record is added to {{inputs.enriched.model.name}}",
description: "Fired when a record is saved to your database",
stepId: "RECORD_SAVED",
inputs: {},
schema: {
inputs: {
properties: {
modelId: {
type: "string",
customType: "model",
title: "Table",
},
},
required: ["modelId"],
},
outputs: {
properties: {
record: {
type: "object",
customType: "record",
description: "The new record that was saved",
},
},
required: ["record"],
},
},
type: "TRIGGER",
},
RECORD_DELETED: {
name: "Record Deleted",
event: "record:delete",
icon: "ri-delete-bin-line",
tagline: "Record is deleted from {{inputs.enriched.model.name}}",
description: "Fired when a record is deleted from your database",
stepId: "RECORD_DELETED",
inputs: {},
schema: {
inputs: {
properties: {
modelId: {
type: "string",
customType: "model",
title: "Table",
},
},
required: ["modelId"],
},
outputs: {
properties: {
record: {
type: "object",
customType: "record",
description: "The record that was deleted",
},
},
required: ["record"],
},
},
type: "TRIGGER",
},
}
async function queueRelevantRecordWorkflows(event, eventType) {
if (event.instanceId == null) {
throw `No instanceId specified for ${eventType} - check event emitters.`
}
@ -16,7 +88,13 @@ async function queueRelevantWorkflows(event, eventType) {
const workflows = workflowsToTrigger.rows.map(wf => wf.doc)
for (let workflow of workflows) {
if (!workflow.live) {
let workflowDef = workflow.definition
let workflowTrigger = workflowDef ? workflowDef.trigger : {}
if (
!workflow.live ||
!workflowTrigger.inputs ||
workflowTrigger.inputs.modelId !== event.record.modelId
) {
continue
}
workflowQueue.add({ workflow, event })
@ -24,15 +102,64 @@ async function queueRelevantWorkflows(event, eventType) {
}
emitter.on("record:save", async function(event) {
await queueRelevantWorkflows(event, "record:save")
if (!event || !event.record || !event.record.modelId) {
return
}
await queueRelevantRecordWorkflows(event, "record:save")
})
emitter.on("record:delete", async function(event) {
await queueRelevantWorkflows(event, "record:delete")
if (!event || !event.record || !event.record.modelId) {
return
}
await queueRelevantRecordWorkflows(event, "record:delete")
})
async function fillRecordOutput(workflow, params) {
let triggerSchema = workflow.definition.trigger
let modelId = triggerSchema.inputs.modelId
const db = new CouchDB(params.instanceId)
try {
let model = await db.get(modelId)
for (let schemaKey of Object.keys(model.schema)) {
if (params[schemaKey] != null) {
continue
}
let propSchema = model.schema[schemaKey]
switch (propSchema.constraints.type) {
case "string":
params[schemaKey] = FAKE_STRING
break
case "boolean":
params[schemaKey] = FAKE_BOOL
break
case "number":
params[schemaKey] = FAKE_NUMBER
break
case "datetime":
params[schemaKey] = FAKE_DATETIME
break
}
}
} catch (err) {
throw "Failed to find model for trigger"
}
return params
}
module.exports.externalTrigger = async function(workflow, params) {
// TODO: replace this with allowing user in builder to input values in future
if (
workflow.definition != null &&
workflow.definition.trigger != null &&
workflow.definition.trigger.inputs.modelId != null
) {
params = await fillRecordOutput(workflow, params)
}
workflowQueue.add({ workflow, event: params })
}
module.exports.workflowQueue = workflowQueue
module.exports.BUILTIN_DEFINITIONS = BUILTIN_DEFINITIONS

View File

@ -36,8 +36,8 @@
"gitHead": "284cceb9b703c38566c6e6363c022f79a08d5691",
"dependencies": {
"@beyonk/svelte-googlemaps": "^2.2.0",
"@budibase/bbui": "^1.32.0",
"@fortawesome/fontawesome-free": "^5.14.0",
"@budibase/bbui": "^1.34.2",
"britecharts": "^2.16.1",
"d3-selection": "^1.4.2",
"fast-sort": "^2.2.0",