Merge pull request #607 from Budibase/server-workflows

Server workflows
This commit is contained in:
Michael Drury 2020-09-15 09:09:55 +01:00 committed by GitHub
commit 1930aa2296
54 changed files with 1278 additions and 1021 deletions

View File

@ -1,46 +1,52 @@
context('Create a workflow', () => {
context("Create a workflow", () => {
before(() => {
cy.server()
cy.visit('localhost:4001/_builder')
cy.visit("localhost:4001/_builder")
cy.createApp('Workflow Test App', 'This app is used to test that workflows do in fact work!')
cy.createApp(
"Workflow Test App",
"This app is used to test that workflows do in fact work!"
)
})
// https://on.cypress.io/interacting-with-elements
it('should create a workflow', () => {
it("should create a workflow", () => {
cy.createTestTableWithData()
cy.contains('workflow').click()
cy.contains('Create New Workflow').click()
cy.get('input').type('Add Record')
cy.contains('Save').click()
cy.contains("workflow").click()
cy.contains("Create New Workflow").click()
cy.get("input").type("Add Record")
cy.contains("Save").click()
// Add trigger
cy.get('[data-cy=add-workflow-component]').click()
cy.get('[data-cy=RECORD_SAVED]').click()
cy.get('.budibase__input').select('dog')
cy.get("[data-cy=add-workflow-component]").click()
cy.get("[data-cy=RECORD_SAVED]").click()
cy.get(".budibase__input").select("dog")
// Create action
cy.get('[data-cy=SAVE_RECORD]').click()
cy.get('.container input').first().type('goodboy')
cy.get('.container input').eq(1).type('11')
cy.get("[data-cy=SAVE_RECORD]").click()
cy.get(".budibase__input").select("dog")
cy.get(".container input")
.first()
.type("goodboy")
cy.get(".container input")
.eq(1)
.type("11")
// Save
cy.contains('Save Workflow').click()
cy.contains("Save Workflow").click()
// Activate Workflow
cy.get('[data-cy=activate-workflow]').click()
cy.get("[data-cy=activate-workflow]").click()
cy.contains("Add Record").should("be.visible")
cy.get(".stop-button.highlighted").should("be.visible")
})
it('should add record when a new record is added', () => {
cy.contains('backend').click()
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')
cy.contains("goodboy").should("have.text", "goodboy")
})
})

View File

@ -234,7 +234,7 @@ export default {
// Watch the `dist` directory and refresh the
// browser on changes when not in production
!production && livereload(outputpath),
!production && livereload({ watch: outputpath, delay: 500 }),
// If we're building for production (npm run build
// instead of npm run dev), minify

View File

@ -1,5 +1,3 @@
import mustache from "mustache"
import blockDefinitions from "components/workflow/WorkflowPanel/blockDefinitions"
import { generate } from "shortid"
/**
@ -18,27 +16,31 @@ export default class Workflow {
addBlock(block) {
// Make sure to add trigger if doesn't exist
if (!this.hasTrigger() && block.type === "TRIGGER") {
this.workflow.definition.trigger = { id: generate(), ...block }
return
const trigger = { id: generate(), ...block }
this.workflow.definition.trigger = trigger
return trigger
}
this.workflow.definition.steps.push({
id: generate(),
...block,
})
const newBlock = { id: generate(), ...block }
this.workflow.definition.steps = [
...this.workflow.definition.steps,
newBlock,
]
return newBlock
}
updateBlock(updatedBlock, id) {
const { steps, trigger } = this.workflow.definition
if (trigger && trigger.id === id) {
this.workflow.definition.trigger = null
this.workflow.definition.trigger = updatedBlock
return
}
const stepIdx = steps.findIndex(step => step.id === id)
if (stepIdx < 0) throw new Error("Block not found.")
steps.splice(stepIdx, 1, updatedBlock)
this.workflow.definition.steps = steps
}
deleteBlock(id) {
@ -52,44 +54,6 @@ export default class Workflow {
const stepIdx = steps.findIndex(step => step.id === id)
if (stepIdx < 0) throw new Error("Block not found.")
steps.splice(stepIdx, 1)
}
createUiTree() {
if (!this.workflow.definition) return []
return Workflow.buildUiTree(this.workflow.definition)
}
static buildUiTree(definition) {
const steps = []
if (definition.trigger) steps.push(definition.trigger)
return [...steps, ...definition.steps].map(step => {
// The client side display definition for the block
const definition = blockDefinitions[step.type][step.actionId]
if (!definition) {
throw new Error(
`No block definition exists for the chosen block. Check there's an entry in the block definitions for ${step.actionId}`
)
}
if (!definition.params) {
throw new Error(
`Blocks should always have parameters. Ensure that the block definition is correct for ${step.actionId}`
)
}
const tagline = definition.tagline || ""
const args = step.args || {}
return {
id: step.id,
type: step.type,
params: step.params,
args,
heading: step.actionId,
body: mustache.render(tagline, args),
name: definition.name,
}
})
this.workflow.definition.steps = steps
}
}

View File

@ -1,14 +1,22 @@
import { writable } from "svelte/store"
import api from "../../api"
import Workflow from "./Workflow"
import { cloneDeep } from "lodash/fp"
const workflowActions = store => ({
fetch: async () => {
const WORKFLOWS_URL = `/api/workflows`
const workflowResponse = await api.get(WORKFLOWS_URL)
const json = await workflowResponse.json()
const responses = await Promise.all([
api.get(`/api/workflows`),
api.get(`/api/workflows/definitions/list`),
])
const jsonResponses = await Promise.all(responses.map(x => x.json()))
store.update(state => {
state.workflows = json
state.workflows = jsonResponses[0]
state.blockDefinitions = {
TRIGGER: jsonResponses[1].trigger,
ACTION: jsonResponses[1].action,
LOGIC: jsonResponses[1].logic,
}
return state
})
},
@ -23,8 +31,8 @@ const workflowActions = store => ({
const response = await api.post(CREATE_WORKFLOW_URL, workflow)
const json = await response.json()
store.update(state => {
state.workflows = state.workflows.concat(json.workflow)
state.currentWorkflow = new Workflow(json.workflow)
state.workflows = [...state.workflows, json.workflow]
store.actions.select(json.workflow)
return state
})
},
@ -38,20 +46,7 @@ const workflowActions = store => ({
)
state.workflows.splice(existingIdx, 1, json.workflow)
state.workflows = state.workflows
state.currentWorkflow = new Workflow(json.workflow)
return state
})
},
update: async ({ workflow }) => {
const UPDATE_WORKFLOW_URL = `/api/workflows`
const response = await api.put(UPDATE_WORKFLOW_URL, workflow)
const json = await response.json()
store.update(state => {
const existingIdx = state.workflows.findIndex(
existing => existing._id === workflow._id
)
state.workflows.splice(existingIdx, 1, json.workflow)
state.workflows = state.workflows
store.actions.select(json.workflow)
return state
})
},
@ -66,28 +61,49 @@ const workflowActions = store => ({
)
state.workflows.splice(existingIdx, 1)
state.workflows = state.workflows
state.currentWorkflow = null
state.selectedWorkflow = null
state.selectedBlock = null
return state
})
},
trigger: async ({ workflow }) => {
const { _id } = workflow
const TRIGGER_WORKFLOW_URL = `/api/workflows/${_id}/trigger`
return await api.post(TRIGGER_WORKFLOW_URL)
},
select: workflow => {
store.update(state => {
state.currentWorkflow = new Workflow(workflow)
state.selectedWorkflowBlock = null
state.selectedWorkflow = new Workflow(cloneDeep(workflow))
state.selectedBlock = null
return state
})
},
addBlockToWorkflow: block => {
store.update(state => {
state.currentWorkflow.addBlock(block)
state.selectedWorkflowBlock = block
const newBlock = state.selectedWorkflow.addBlock(cloneDeep(block))
state.selectedBlock = newBlock
return state
})
},
deleteWorkflowBlock: block => {
store.update(state => {
state.currentWorkflow.deleteBlock(block.id)
state.selectedWorkflowBlock = null
const idx = state.selectedWorkflow.workflow.definition.steps.findIndex(
x => x.id === block.id
)
state.selectedWorkflow.deleteBlock(block.id)
// Select next closest step
const steps = state.selectedWorkflow.workflow.definition.steps
let nextSelectedBlock
if (steps[idx] != null) {
nextSelectedBlock = steps[idx]
} else if (steps[idx - 1] != null) {
nextSelectedBlock = steps[idx - 1]
} else {
nextSelectedBlock =
state.selectedWorkflow.workflow.definition.trigger || null
}
state.selectedBlock = nextSelectedBlock
return state
})
},
@ -96,11 +112,14 @@ const workflowActions = store => ({
export const getWorkflowStore = () => {
const INITIAL_WORKFLOW_STATE = {
workflows: [],
blockDefinitions: {
TRIGGER: [],
ACTION: [],
LOGIC: [],
},
selectedWorkflow: null,
}
const store = writable(INITIAL_WORKFLOW_STATE)
store.actions = workflowActions(store)
return store
}

View File

@ -1,44 +1,37 @@
import Workflow from "../Workflow";
import TEST_WORKFLOW from "./testWorkflow";
import Workflow from "../Workflow"
import TEST_WORKFLOW from "./testWorkflow"
const TEST_BLOCK = {
id: "VFWeZcIPx",
name: "Update UI State",
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>",
icon: "ri-refresh-line",
description: "Update your User Interface with some data.",
environment: "CLIENT",
params: {
path: "string",
value: "longText",
},
args: {
path: "foo",
value: "started...",
},
actionId: "SET_STATE",
type: "ACTION",
id: "AUXJQGZY7",
name: "Delay",
icon: "ri-time-fill",
tagline: "Delay for <b>{{time}}</b> milliseconds",
description: "Delay the workflow until an amount of time has passed.",
params: { time: "number" },
type: "LOGIC",
args: { time: "5000" },
stepId: "DELAY",
}
describe("Workflow Data Object", () => {
let workflow
beforeEach(() => {
workflow = new Workflow({ ...TEST_WORKFLOW });
});
workflow = new Workflow({ ...TEST_WORKFLOW })
})
it("adds a workflow block to the workflow", () => {
workflow.addBlock(TEST_BLOCK);
workflow.addBlock(TEST_BLOCK)
expect(workflow.workflow.definition)
})
it("updates a workflow block with new attributes", () => {
const firstBlock = workflow.workflow.definition.steps[0];
const firstBlock = workflow.workflow.definition.steps[0]
const updatedBlock = {
...firstBlock,
name: "UPDATED"
};
workflow.updateBlock(updatedBlock, firstBlock.id);
name: "UPDATED",
}
workflow.updateBlock(updatedBlock, firstBlock.id)
expect(workflow.workflow.definition.steps[0]).toEqual(updatedBlock)
})
@ -46,12 +39,10 @@ describe("Workflow Data Object", () => {
const { steps } = workflow.workflow.definition
const originalLength = steps.length
const lastBlock = steps[steps.length - 1];
workflow.deleteBlock(lastBlock.id);
expect(workflow.workflow.definition.steps.length).toBeLessThan(originalLength);
})
it("builds a tree that gets rendered in the flowchart builder", () => {
expect(Workflow.buildUiTree(TEST_WORKFLOW.definition)).toMatchSnapshot();
const lastBlock = steps[steps.length - 1]
workflow.deleteBlock(lastBlock.id)
expect(workflow.workflow.definition.steps.length).toBeLessThan(
originalLength
)
})
})

View File

@ -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",
},
]
`;

View File

@ -1,63 +1,78 @@
export default {
_id: "53b6148c65d1429c987e046852d11611",
_rev: "4-02c6659734934895812fa7be0215ee59",
name: "Test Workflow",
name: "Test workflow",
definition: {
steps: [
{
id: "VFWeZcIPx",
name: "Update UI State",
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>",
icon: "ri-refresh-line",
description: "Update your User Interface with some data.",
environment: "CLIENT",
id: "ANBDINAPS",
description: "Send an email.",
tagline: "Send email to <b>{{to}}</b>",
icon: "ri-mail-open-fill",
name: "Send Email",
params: {
path: "string",
value: "longText",
to: "string",
from: "string",
subject: "longText",
text: "longText",
},
args: {
path: "foo",
value: "started...",
},
actionId: "SET_STATE",
type: "ACTION",
},
{
id: "zJQcZUgDS",
name: "Delay",
icon: "ri-time-fill",
tagline: "Delay for <b>{{time}}</b> milliseconds",
description: "Delay the workflow until an amount of time has passed.",
environment: "CLIENT",
params: {
time: "number",
},
args: {
time: 3000,
text: "A user was created!",
subject: "New Budibase User",
from: "budimaster@budibase.com",
to: "test@test.com",
},
actionId: "DELAY",
type: "LOGIC",
},
{
id: "3RSTO7BMB",
name: "Update UI State",
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>",
icon: "ri-refresh-line",
description: "Update your User Interface with some data.",
environment: "CLIENT",
params: {
path: "string",
value: "longText",
},
args: {
path: "foo",
value: "finished",
},
actionId: "SET_STATE",
type: "ACTION",
stepId: "SEND_EMAIL",
},
],
trigger: {
id: "iRzYMOqND",
name: "Record Saved",
event: "record:save",
icon: "ri-save-line",
tagline: "Record is added to <b>{{model.name}}</b>",
description: "Fired when a record is saved to your database.",
params: { model: "model" },
type: "TRIGGER",
args: {
model: {
type: "model",
views: {},
name: "users",
schema: {
name: {
type: "string",
constraints: {
type: "string",
length: { maximum: 123 },
presence: { allowEmpty: false },
},
name: "name",
},
age: {
type: "number",
constraints: {
type: "number",
presence: { allowEmpty: false },
numericality: {
greaterThanOrEqualTo: "",
lessThanOrEqualTo: "",
},
},
name: "age",
},
},
_id: "c6b4e610cd984b588837bca27188a451",
_rev: "7-b8aa1ce0b53e88928bb88fc11bdc0aff",
},
},
stepId: "RECORD_SAVED",
},
},
type: "workflow",
live: true,
ok: true,
id: "b384f861f4754e1693835324a7fcca62",
rev: "1-aa1c2cbd868ef02e26f8fad531dd7e37",
live: false,
_id: "b384f861f4754e1693835324a7fcca62",
_rev: "108-4116829ec375e0481d0ecab9e83a2caf",
}

View File

@ -13,7 +13,7 @@
async function deleteWorkflow() {
await workflowStore.actions.delete({
instanceId,
workflow: $workflowStore.currentWorkflow.workflow,
workflow: $workflowStore.selectedWorkflow.workflow,
})
onClosed()
notifier.danger("Workflow deleted.")

View File

@ -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>

View File

@ -2,13 +2,22 @@
import { backendUiStore } from "builderStore"
export let value
$: modelId = value ? value._id : ""
function onChange(e) {
value = $backendUiStore.models.find(model => model._id === e.target.value)
}
</script>
<div class="bb-margin-xl block-field">
<select class="budibase__input" bind:value>
<option value="" />
<div class="block-field">
<select
class="budibase__input"
value={modelId}
on:blur={onChange}
on:change={onChange}>
<option value="">Choose an option</option>
{#each $backendUiStore.models as model}
<option value={model}>{model.name}</option>
<option value={model._id}>{model.name}</option>
{/each}
</select>
</div>

View File

@ -3,6 +3,14 @@
import { Input, Label } from "@budibase/bbui"
export let value
$: modelId = value && value.model ? value.model._id : ""
$: schemaFields = Object.keys(value && value.model ? value.model.schema : {})
function onChangeModel(e) {
value.model = $backendUiStore.models.find(
model => model._id === e.target.value
)
}
function setParsedValue(evt, field) {
const fieldSchema = value.model.schema[field]
@ -10,23 +18,27 @@
value[field] = parseInt(evt.target.value)
return
}
value[field] = evt.target.value
}
</script>
<div class="bb-margin-xl block-field">
<select class="budibase__input" bind:value={value.model}>
<div class="block-field">
<select
class="budibase__input"
value={modelId}
on:blur={onChangeModel}
on:change={onChangeModel}>
<option value="">Choose an option</option>
{#each $backendUiStore.models as model}
<option value={model}>{model.name}</option>
<option value={model._id}>{model.name}</option>
{/each}
</select>
</div>
{#if value.model}
{#if schemaFields.length}
<div class="bb-margin-xl block-field">
<Label small forAttr={'fields'}>Fields</Label>
{#each Object.keys(value.model.schema) as field}
{#each schemaFields as field}
<div class="bb-margin-xl">
<Input
thin

View File

@ -1,6 +1,5 @@
<script>
import { fade } from "svelte/transition"
import { onMount, getContext } from "svelte"
import { getContext } from "svelte"
import { backendUiStore, workflowStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import WorkflowBlockSetup from "./WorkflowBlockSetup.svelte"
@ -9,49 +8,35 @@
const { open, close } = getContext("simple-modal")
const ACCESS_LEVELS = [
{
name: "Admin",
key: "ADMIN",
canExecute: true,
editable: false,
},
{
name: "Power User",
key: "POWER_USER",
canExecute: true,
editable: false,
},
]
let selectedTab = "SETUP"
let testResult
$: workflow =
$workflowStore.currentWorkflow && $workflowStore.currentWorkflow.workflow
$: workflowBlock = $workflowStore.selectedWorkflowBlock
$workflowStore.selectedWorkflow && $workflowStore.selectedWorkflow.workflow
function deleteWorkflow() {
open(
DeleteWorkflowModal,
{
onClosed: close,
},
{ onClosed: close },
{ styleContent: { padding: "0" } }
)
}
function deleteWorkflowBlock() {
workflowStore.actions.deleteWorkflowBlock(workflowBlock)
notifier.info("Workflow block deleted.")
workflowStore.actions.deleteWorkflowBlock($workflowStore.selectedBlock)
}
function testWorkflow() {
testResult = "PASSED"
async function testWorkflow() {
const result = await workflowStore.actions.trigger({
workflow: $workflowStore.selectedWorkflow.workflow,
})
if (result.status === 200) {
notifier.success(`Workflow ${workflow.name} triggered successfully.`)
} else {
notifier.danger(`Failed to trigger workflow ${workflow.name}.`)
}
}
async function saveWorkflow() {
const workflow = $workflowStore.currentWorkflow.workflow
await workflowStore.actions.save({
instanceId: $backendUiStore.selectedDatabase._id,
workflow,
@ -65,68 +50,27 @@
<span
class="hoverable"
class:selected={selectedTab === 'SETUP'}
on:click={() => {
selectedTab = 'SETUP'
testResult = null
}}>
on:click={() => (selectedTab = 'SETUP')}>
Setup
</span>
{#if !workflowBlock}
<span
class="test-tab"
class:selected={selectedTab === 'TEST'}
on:click={() => (selectedTab = 'TEST')}>
Test
</span>
{/if}
</header>
{#if selectedTab === 'TEST'}
<div class="bb-margin-m">
{#if testResult}
<button
transition:fade
class:passed={testResult === 'PASSED'}
class:failed={testResult === 'FAILED'}
class="test-result">
{testResult}
</button>
{/if}
<Button secondary wide on:click={testWorkflow}>Test Workflow</Button>
</div>
{/if}
{#if selectedTab === 'SETUP'}
{#if workflowBlock}
<WorkflowBlockSetup {workflowBlock} />
{#if $workflowStore.selectedBlock}
<WorkflowBlockSetup bind:block={$workflowStore.selectedBlock} />
<div class="buttons">
<Button
green
wide
data-cy="save-workflow-setup"
on:click={saveWorkflow}>
<Button green wide data-cy="save-workflow-setup" on:click={saveWorkflow}>
Save Workflow
</Button>
<Button red wide on:click={deleteWorkflowBlock}>Delete Block</Button>
</div>
{:else if $workflowStore.currentWorkflow}
{:else if $workflowStore.selectedWorkflow}
<div class="panel">
<div class="panel-body">
<div class="block-label">Workflow: {workflow.name}</div>
<div class="config-item">
<Label small forAttr={'useraccess'}>User Access</Label>
<div class="access-levels">
{#each ACCESS_LEVELS as level}
<span class="access-level">
<label>{level.name}</label>
<input
type="checkbox"
disabled={!level.editable}
bind:checked={level.canExecute} />
</span>
{/each}
</div>
<div class="block-label">
Workflow
<b>{workflow.name}</b>
</div>
</div>
<Button secondary wide on:click={testWorkflow}>Test Workflow</Button>
<div class="buttons">
<Button
green
@ -139,7 +83,6 @@
</div>
</div>
{/if}
{/if}
</section>
<style>
@ -181,10 +124,6 @@
margin-bottom: 20px;
}
.config-item {
margin-bottom: 20px;
}
header > span {
color: var(--grey-5);
margin-right: 20px;
@ -205,35 +144,8 @@
gap: 12px;
}
.access-level {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20px;
}
.access-level label {
font-weight: normal;
color: var(--ink);
}
.test-result {
border: none;
width: 100%;
border-radius: 3px;
height: 32px;
font-size: 14px;
font-weight: 500;
color: var(--white);
text-align: center;
margin-bottom: 10px;
}
.passed {
background: var(--green);
}
.failed {
background: var(--red);
}
</style>

View File

@ -1,55 +1,45 @@
<script>
import { backendUiStore, store } from "builderStore"
import ComponentSelector from "./ParamInputs/ComponentSelector.svelte"
import ModelSelector from "./ParamInputs/ModelSelector.svelte"
import RecordSelector from "./ParamInputs/RecordSelector.svelte"
import { Input, TextArea, Select } from "@budibase/bbui"
export let workflowBlock
let params
$: workflowParams = workflowBlock.params
? Object.entries(workflowBlock.params)
: []
export let block
$: params = block.params ? Object.entries(block.params) : []
</script>
<label class="selected-label">{workflowBlock.type}: {workflowBlock.name}</label>
{#each workflowParams as [parameter, type]}
<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={workflowBlock.args[parameter]} thin>
<Select bind:value={block.args[parameter]} thin secondary>
<option value="">Choose an option</option>
{#each type as option}
<option value={option}>{option}</option>
{/each}
</Select>
{:else if type === 'component'}
<ComponentSelector bind:value={workflowBlock.args[parameter]} />
{:else if type === 'accessLevel'}
<Select bind:value={workflowBlock.args[parameter]} thin>
<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={workflowBlock.args[parameter]} />
<Input type="password" thin bind:value={block.args[parameter]} />
{:else if type === 'number'}
<Input type="number" thin bind:value={workflowBlock.args[parameter]} />
<Input type="number" thin bind:value={block.args[parameter]} />
{:else if type === 'longText'}
<TextArea
type="text"
thin
bind:value={workflowBlock.args[parameter]}
label="" />
<TextArea type="text" thin bind:value={block.args[parameter]} />
{:else if type === 'model'}
<ModelSelector bind:value={workflowBlock.args[parameter]} />
<ModelSelector bind:value={block.args[parameter]} />
{:else if type === 'record'}
<RecordSelector value={workflowBlock.args[parameter]} />
<RecordSelector bind:value={block.args[parameter]} />
{:else if type === 'string'}
<Input type="text" thin bind:value={workflowBlock.args[parameter]} />
<Input type="text" thin bind:value={block.args[parameter]} />
{/if}
</div>
{/each}
</div>
<style>
.block-field {
@ -57,16 +47,19 @@
}
label {
text-transform: capitalize;
font-size: 14px;
font-family: sans-serif;
font-weight: 500;
color: var(--ink);
margin-bottom: 12px;
text-transform: capitalize;
margin-top: 20px;
}
.selected-label {
text-transform: capitalize;
font-size: 14px;
font-weight: 500;
font-size: 14px;
color: var(--grey-7);
}
textarea {

View File

@ -1,30 +1,22 @@
<script>
import { onMount } from "svelte"
import { afterUpdate } from "svelte"
import { workflowStore, backendUiStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import Flowchart from "./flowchart/FlowChart.svelte"
let selectedWorkflow
let uiTree
let instanceId = $backendUiStore.selectedDatabase._id
$: selectedWorkflow = $workflowStore.currentWorkflow
$: workflowLive = selectedWorkflow && selectedWorkflow.workflow.live
$: uiTree = selectedWorkflow ? selectedWorkflow.createUiTree() : []
$: workflow =
$workflowStore.selectedWorkflow && $workflowStore.selectedWorkflow.workflow
$: workflowLive = workflow && workflow.live
$: instanceId = $backendUiStore.selectedDatabase._id
function onSelect(block) {
workflowStore.update(state => {
state.selectedWorkflowBlock = block
state.selectedBlock = block
return state
})
}
function setWorkflowLive(live) {
const { workflow } = selectedWorkflow
workflow.live = live
workflowStore.actions.save({ instanceId, workflow })
if (live) {
@ -36,9 +28,10 @@
</script>
<section>
<Flowchart blocks={uiTree} {onSelect} />
<Flowchart {workflow} {onSelect} />
</section>
<footer>
{#if selectedWorkflow}
{#if workflow}
<button
class:highlighted={workflowLive}
class:hoverable={workflowLive}
@ -55,13 +48,22 @@
</button>
{/if}
</footer>
</section>
<style>
section {
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
overflow: auto;
height: 100%;
position: relative;
}
footer {
position: absolute;
bottom: 0;
right: 0;
bottom: 20px;
right: 30px;
display: flex;
align-items: flex-end;
}
@ -77,7 +79,9 @@
display: flex;
align-items: center;
justify-content: center;
margin-right: 24px;
}
footer > button:first-child {
margin-right: 20px;
}
.play-button.highlighted {

View File

@ -7,3 +7,9 @@
<path d="M5.0625 70H9L4.5 75L0 70H3.9375V65H5.0625V70Z" fill="#ADAEC4" />
<rect x="4" width="1" height="65" fill="#ADAEC4" />
</svg>
<style>
svg {
margin: 8px 0;
}
</style>

Before

Width:  |  Height:  |  Size: 241 B

After

Width:  |  Height:  |  Size: 290 B

View File

@ -1,24 +1,52 @@
<script>
import FlowItem from "./FlowItem.svelte"
import Arrow from "./Arrow.svelte"
import { flip } from "svelte/animate"
import { fade, fly } from "svelte/transition"
export let blocks = []
export let workflow
export let onSelect
let blocks
$: {
blocks = []
if (workflow) {
if (workflow.definition.trigger) {
blocks.push(workflow.definition.trigger)
}
blocks = blocks.concat(workflow.definition.steps || [])
}
}
</script>
<section class="canvas">
{#each blocks as block, idx}
{#each blocks as block, idx (block.id)}
<div
class="block"
animate:flip={{ duration: 600 }}
in:fade|local
out:fly|local={{ x: 100 }}>
<FlowItem {onSelect} {block} />
{#if idx !== blocks.length - 1}
<Arrow />
{/if}
</div>
{/each}
</section>
<style>
.canvas {
section {
position: absolute;
padding: 20px 40px;
display: flex;
align-items: center;
flex-direction: column;
}
.block {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
}
</style>

View File

@ -1,15 +1,21 @@
<script>
import { fade } from "svelte/transition"
import mustache from "mustache"
import { workflowStore } from "builderStore"
export let onSelect
export let block
let selected
$: selected =
$workflowStore.selectedBlock != null &&
$workflowStore.selectedBlock.id === block.id
function selectBlock() {
onSelect(block)
}
</script>
<div transition:fade class={`${block.type} hoverable`} on:click={selectBlock}>
<div class={`${block.type} hoverable`} class:selected on:click={selectBlock}>
<header>
{#if block.type === 'TRIGGER'}
<i class="ri-lightbulb-fill" />
@ -24,7 +30,7 @@
</header>
<hr />
<p>
{@html block.body}
{@html mustache.render(block.tagline, block.args)}
</p>
</div>
@ -32,8 +38,8 @@
div {
width: 320px;
padding: 20px;
border-radius: 5px;
transition: 0.3s all;
border-radius: var(--border-radius-m);
transition: 0.3s all ease;
box-shadow: 0 4px 30px 0 rgba(57, 60, 68, 0.08);
background-color: var(--ink);
font-size: 16px;
@ -69,9 +75,12 @@
p {
color: inherit;
margin-bottom: 0;
}
div.selected,
div:hover {
transform: scale(1.05);
transform: scale(1.1);
box-shadow: 0 4px 30px 0 rgba(57, 60, 68, 0.15);
}
</style>

View File

@ -1,82 +1,39 @@
<script>
import { onMount } from "svelte"
import { backendUiStore, workflowStore } from "builderStore"
import { WorkflowList } from "../"
import { workflowStore } from "builderStore"
import WorkflowBlock from "./WorkflowBlock.svelte"
import blockDefinitions from "../blockDefinitions"
import FlatButtonGroup from "components/userInterface/FlatButtonGroup.svelte"
let selectedTab = "TRIGGER"
let definitions = []
$: definitions = Object.entries(blockDefinitions[selectedTab])
let buttonProps = []
$: blocks = Object.entries($workflowStore.blockDefinitions[selectedTab])
$: {
if (
$workflowStore.currentWorkflow.hasTrigger() &&
selectedTab === "TRIGGER"
) {
if ($workflowStore.selectedWorkflow.hasTrigger()) {
buttonProps = [
{ value: "ACTION", text: "Action" },
{ value: "LOGIC", text: "Logic" },
]
if (selectedTab === "TRIGGER") {
selectedTab = "ACTION"
}
} else {
buttonProps = [{ value: "TRIGGER", text: "Trigger" }]
if (selectedTab !== "TRIGGER") {
selectedTab = "TRIGGER"
}
}
}
function onChangeTab(tab) {
selectedTab = tab
}
</script>
<section>
<div class="subtabs">
{#if !$workflowStore.currentWorkflow.hasTrigger()}
<span
class="hoverable"
class:selected={'TRIGGER' === selectedTab}
on:click={() => (selectedTab = 'TRIGGER')}>
Triggers
</span>
{/if}
<span
class="hoverable"
class:selected={'ACTION' === selectedTab}
on:click={() => (selectedTab = 'ACTION')}>
Actions
</span>
<span
class="hoverable"
class:selected={'LOGIC' === selectedTab}
on:click={() => (selectedTab = 'LOGIC')}>
Logic
</span>
</div>
<FlatButtonGroup value={selectedTab} {buttonProps} onChange={onChangeTab} />
<div id="blocklist">
{#each definitions as [actionId, blockDefinition]}
<WorkflowBlock {blockDefinition} {actionId} blockType={selectedTab} />
{#each blocks as [stepId, blockDefinition]}
<WorkflowBlock {blockDefinition} {stepId} blockType={selectedTab} />
{/each}
</div>
</section>
<style>
.subtabs {
margin-top: 20px;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr 1fr 1fr;
margin-bottom: 12px;
}
.subtabs span {
transition: 0.3s all;
text-align: center;
color: var(--grey-7);
font-weight: 400;
padding: 8px 16px;
text-rendering: optimizeLegibility;
border: none !important;
outline: none;
}
.subtabs span.selected {
background: var(--grey-3);
color: var(--ink);
border-radius: 5px;
}
.subtabs span:not(.selected) {
color: var(--ink);
}
</style>

View File

@ -1,15 +1,15 @@
<script>
import { workflowStore } from "builderStore"
export let blockType
export let blockDefinition
export let actionId
export let stepId
export let blockType
function addBlockToWorkflow() {
workflowStore.actions.addBlockToWorkflow({
...blockDefinition,
args: blockDefinition.args || {},
actionId,
stepId,
type: blockType,
})
}
@ -18,7 +18,7 @@
<div
class="workflow-block hoverable"
on:click={addBlockToWorkflow}
data-cy={actionId}>
data-cy={stepId}>
<div>
<i class={blockDefinition.icon} />
</div>
@ -31,11 +31,11 @@
<style>
.workflow-block {
display: grid;
grid-template-columns: 40px auto;
grid-template-columns: 20px auto;
align-items: center;
margin-top: 16px;
padding: 16px 0px;
border-radius: var(--border);
padding: 12px;
border-radius: var(--border-radius-m);
}
.workflow-block:hover {
@ -43,7 +43,7 @@
}
.workflow-text {
margin-left: 12px;
margin-left: 16px;
}
.icon {
@ -64,6 +64,7 @@
font-size: 14px;
font-weight: 500;
margin-bottom: 5px;
margin-top: 0;
}
p {

View File

@ -8,9 +8,9 @@
const { open, close } = getContext("simple-modal")
$: currentWorkflowId =
$workflowStore.currentWorkflow &&
$workflowStore.currentWorkflow.workflow._id
$: selectedWorkflowId =
$workflowStore.selectedWorkflow &&
$workflowStore.selectedWorkflow.workflow._id
function newWorkflow() {
open(
@ -33,7 +33,7 @@
{#each $workflowStore.workflows as workflow}
<li
class="workflow-item"
class:selected={workflow._id === currentWorkflowId}
class:selected={workflow._id === selectedWorkflowId}
on:click={() => workflowStore.actions.select(workflow)}>
<i class="ri-stackshare-line" class:live={workflow.live} />
{workflow.name}

View File

@ -1,11 +1,9 @@
<script>
import { onMount } from "svelte"
import { backendUiStore, workflowStore } from "builderStore"
import { WorkflowList, BlockList } from "./"
import blockDefinitions from "./blockDefinitions"
import { workflowStore } from "builderStore"
import WorkflowList from "./WorkflowList/WorkflowList.svelte"
import BlockList from "./BlockList/BlockList.svelte"
let selectedTab = "WORKFLOWS"
let definitions = []
</script>
<header>
@ -16,7 +14,7 @@
on:click={() => (selectedTab = 'WORKFLOWS')}>
Workflows
</span>
{#if $workflowStore.currentWorkflow}
{#if $workflowStore.selectedWorkflow}
<span
data-cy="add-workflow-component"
class="hoverable"

View File

@ -12,11 +12,13 @@
<div class="content">
<slot />
</div>
{#if $workflowStore.selectedWorkflow}
<div class="nav">
<div class="inner">
<SetupPanel />
</div>
</div>
{/if}
</div>
<style>
@ -35,13 +37,11 @@
.content {
flex: 1 1 auto;
margin: 20px 40px;
}
.nav {
overflow: auto;
width: 300px;
border-right: 1px solid var(--grey-2);
background: var(--white);
}

View File

@ -1,5 +1,4 @@
import { authenticate } from "./authenticate"
import { triggerWorkflow } from "./workflow"
import appStore from "../state/store"
const apiCall = method => async ({ url, body }) => {
@ -96,7 +95,6 @@ const makeRecordRequestBody = parameters => {
export default {
authenticate: authenticate(apiOpts),
triggerWorkflow: triggerWorkflow(apiOpts),
createRecord,
updateRecord,
}

View File

@ -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
}
},
}

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -1,4 +1,3 @@
import api from "../api"
import renderTemplateString from "./renderTemplateString"
export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType"
@ -6,9 +5,6 @@ export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType"
export const eventHandlers = routeTo => {
const handlers = {
"Navigate To": param => routeTo(param && param.url),
"Create Record": api.createRecord,
"Update Record": api.updateRecord,
"Trigger Workflow": api.triggerWorkflow,
}
// when an event is called, this is what gets run

View File

@ -4,6 +4,7 @@ WORKDIR /app
ENV CLOUD=1
ENV COUCH_DB_URL=https://couchdb.budi.live:5984
env BUDIBASE_ENVIRONMENT=PRODUCTION
# copy files and install dependencies
COPY . ./

View File

@ -55,6 +55,7 @@
"electron-updater": "^4.3.1",
"fix-path": "^3.0.0",
"fs-extra": "^8.1.0",
"joi": "^17.2.1",
"jsonwebtoken": "^8.5.1",
"koa": "^2.7.0",
"koa-body": "^4.1.0",
@ -73,6 +74,7 @@
"tar-fs": "^2.1.0",
"uuid": "^3.3.2",
"validate.js": "^0.13.1",
"worker-farm": "^1.7.0",
"yargs": "^13.2.4",
"zlib": "^1.0.5"
},

View File

@ -16,7 +16,7 @@ exports.authenticate = async ctx => {
const { clientId } = await masterDb.get(ctx.user.appId)
if (!clientId) {
ctx.throw(400, "ClientId not suplied")
ctx.throw(400, "ClientId not supplied")
}
// find the instance that the user is associated with
const db = new CouchDB(ClientDb.name(clientId))

View File

@ -2,6 +2,16 @@ const CouchDB = require("../../db")
const validateJs = require("validate.js")
const newid = require("../../db/newid")
function emitEvent(eventType, ctx, record) {
ctx.eventEmitter &&
ctx.eventEmitter.emit(eventType, {
args: {
record,
},
instanceId: ctx.user.instanceId,
})
}
validateJs.extend(validateJs.validators.datetime, {
parse: function(value) {
return new Date(value).getTime()
@ -110,13 +120,7 @@ exports.save = async function(ctx) {
}
}
ctx.eventEmitter &&
ctx.eventEmitter.emit(`record:save`, {
args: {
record,
},
instanceId: ctx.user.instanceId,
})
emitEvent(`record:save`, ctx, record)
ctx.body = record
ctx.status = 200
ctx.message = `${model.name} created successfully`
@ -179,7 +183,7 @@ exports.destroy = async function(ctx) {
return
}
ctx.body = await db.remove(ctx.params.recordId, ctx.params.revId)
ctx.eventEmitter && ctx.eventEmitter.emit(`record:delete`, record)
emitEvent(`record:delete`, ctx, record)
}
exports.validate = async function(ctx) {

View File

@ -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,
}
}
}

View File

@ -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,
}
}
}

View File

@ -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,
}
}
}

View File

@ -1,85 +1,81 @@
const ACTION = {
SET_STATE: {
name: "Update UI State",
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>",
icon: "ri-refresh-line",
description: "Update your User Interface with some data.",
environment: "CLIENT",
params: {
path: "string",
value: "longText",
},
},
NAVIGATE: {
name: "Navigate",
tagline: "Navigate to <b>{{url}}</b>",
icon: "ri-navigation-line",
description: "Navigate to another page.",
environment: "CLIENT",
params: {
url: "string",
},
},
SAVE_RECORD: {
name: "Save Record",
tagline: "<b>Save</b> a <b>{{record.model.name}}</b> record",
icon: "ri-save-3-fill",
description: "Save a record to your database.",
environment: "SERVER",
params: {
record: "record",
},
args: {
record: {},
},
type: "ACTION",
},
DELETE_RECORD: {
description: "Delete a record from your database.",
icon: "ri-delete-bin-line",
name: "Delete Record",
tagline: "<b>Delete</b> a <b>{{record.model.name}}</b> record",
environment: "SERVER",
params: {
record: "record",
params: {},
args: {},
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: {
description: "Create a new user.",
tagline: "Create user <b>{{username}}</b>",
icon: "ri-user-add-fill",
name: "Create User",
environment: "SERVER",
params: {
username: "string",
password: "password",
accessLevelId: "accessLevel",
},
args: {
accessLevelId: "POWER_USER",
},
type: "ACTION",
},
SEND_EMAIL: {
description: "Send an email.",
tagline: "Send email to <b>{{to}}</b>",
icon: "ri-mail-open-fill",
name: "Send Email",
environment: "SERVER",
params: {
to: "string",
from: "string",
subject: "longText",
text: "longText",
},
type: "ACTION",
},
}
const 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",
},
}
@ -89,11 +85,11 @@ const TRIGGER = {
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",
description: "Fired when a record is saved to your database.",
params: {
model: "model",
},
type: "TRIGGER",
},
RECORD_DELETED: {
name: "Record Deleted",
@ -101,70 +97,17 @@ const TRIGGER = {
icon: "ri-delete-bin-line",
tagline: "Record is deleted from <b>{{model.name}}</b>",
description: "Fired when a record is deleted from your database.",
environment: "SERVER",
params: {
model: "model",
},
},
// CLICK: {
// name: "Click",
// icon: "ri-cursor-line",
// tagline: "{{component}} is clicked",
// description: "Trigger when you click on an element in the UI.",
// environment: "CLIENT",
// params: {
// component: "component"
// }
// },
// LOAD: {
// name: "Load",
// icon: "ri-loader-line",
// tagline: "{{component}} is loaded",
// description: "Trigger an element has finished loading.",
// environment: "CLIENT",
// params: {
// component: "component"
// }
// },
// INPUT: {
// name: "Input",
// icon: "ri-text",
// tagline: "Text entered into {{component}",
// description: "Trigger when you type into an input box.",
// environment: "CLIENT",
// params: {
// component: "component"
// }
// },
}
const LOGIC = {
FILTER: {
name: "Filter",
tagline: "{{field}} <b>{{condition}}</b> {{value}}",
icon: "ri-git-branch-line",
description: "Filter any workflows which do not meet certain conditions.",
environment: "CLIENT",
params: {
filter: "string",
condition: ["equals"],
value: "string",
},
},
DELAY: {
name: "Delay",
icon: "ri-time-fill",
tagline: "Delay for <b>{{time}}</b> milliseconds",
description: "Delay the workflow until an amount of time has passed.",
environment: "CLIENT",
params: {
time: "number",
},
type: "TRIGGER",
},
}
export default {
// This contains the definitions for the steps and triggers that make up a workflow, a workflow comprises
// of many steps and a single trigger
module.exports = {
ACTION,
TRIGGER,
LOGIC,
TRIGGER,
}

View File

@ -1,5 +1,13 @@
const CouchDB = require("../../../db")
const newid = require("../../../db/newid")
const blockDefinitions = require("./blockDefinitions")
const triggers = require("../../../workflows/triggers")
/*************************
* *
* BUILDER FUNCTIONS *
* *
*************************/
exports.create = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
@ -53,22 +61,47 @@ exports.find = async function(ctx) {
ctx.body = await db.get(ctx.params.id)
}
exports.executeAction = async function(ctx) {
const { args, action } = ctx.request.body
const workflowAction = require(`./actions/${action}`)
const response = await workflowAction({
args,
instanceId: ctx.user.instanceId,
})
ctx.body = response
}
exports.fetchActionScript = async function(ctx) {
const workflowAction = require(`./actions/${ctx.action}`)
ctx.body = workflowAction
}
exports.destroy = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
ctx.body = await db.remove(ctx.params.id, ctx.params.rev)
}
exports.getActionList = async function(ctx) {
ctx.body = blockDefinitions.ACTION
}
exports.getTriggerList = async function(ctx) {
ctx.body = blockDefinitions.TRIGGER
}
exports.getLogicList = async function(ctx) {
ctx.body = blockDefinitions.LOGIC
}
module.exports.getDefinitionList = async function(ctx) {
ctx.body = {
logic: blockDefinitions.LOGIC,
trigger: blockDefinitions.TRIGGER,
action: blockDefinitions.ACTION,
}
}
/*********************
* *
* API FUNCTIONS *
* *
*********************/
exports.trigger = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
let workflow = await db.get(ctx.params.id)
await triggers.externalTrigger(workflow, {
...ctx.request.body,
instanceId: ctx.user.instanceId,
})
ctx.status = 200
ctx.body = {
message: `Workflow ${workflow._id} has been triggered.`,
workflow,
}
}

View File

@ -37,7 +37,7 @@ describe("/accesslevels", () => {
beforeEach(async () => {
instanceId = (await createInstance(request, appId))._id
model = await createModel(request, appId, instanceId)
view = await createView(request, appId, instanceId)
view = await createView(request, appId, instanceId, model._id)
})
describe("create", () => {

View File

@ -67,9 +67,10 @@ exports.createModel = async (request, appId, instanceId, model) => {
return res.body
}
exports.createView = async (request, appId, instanceId, view) => {
exports.createView = async (request, appId, instanceId, modelId, view) => {
view = view || {
map: "function(doc) { emit(doc[doc.key], doc._id); } ",
modelId: modelId,
}
const res = await request

View File

@ -120,6 +120,10 @@ describe("/models", () => {
testModel = await createModel(request, app._id, instance._id, testModel)
});
afterEach(() => {
delete testModel._rev
});
it("returns all the models for that instance in the response body", done => {
request
.get(`/api/models`)

View File

@ -23,7 +23,7 @@ const TEST_WORKFLOW = {
],
next: {
actionId: "abc123",
stepId: "abc123",
type: "SERVER",
conditions: {
}

View File

@ -1,21 +1,77 @@
const Router = require("@koa/router")
const controller = require("../controllers/workflow")
const authorized = require("../../middleware/authorized")
const joiValidator = require("../../middleware/joi-validator")
const { BUILDER } = require("../../utilities/accessLevels")
const Joi = require("joi")
const router = Router()
// prettier-ignore
function generateStepSchema(allowStepTypes) {
return Joi.object({
stepId: Joi.string().required(),
id: Joi.string().required(),
description: Joi.string().required(),
name: Joi.string().required(),
tagline: Joi.string().required(),
icon: Joi.string().required(),
params: Joi.object(),
// TODO: validate args a bit more deeply
args: Joi.object(),
type: Joi.string().required().valid(...allowStepTypes),
}).unknown(true)
}
// prettier-ignore
const workflowValidator = joiValidator.body(Joi.object({
live: Joi.bool(),
id: Joi.string().required(),
rev: Joi.string().required(),
name: Joi.string().required(),
type: Joi.string().valid("workflow").required(),
definition: Joi.object({
steps: Joi.array().required().items(generateStepSchema(["ACTION", "LOGIC"])),
trigger: generateStepSchema(["TRIGGER"]).required(),
}).required().unknown(true),
}).unknown(true))
router
.get(
"/api/workflows/trigger/list",
authorized(BUILDER),
controller.getTriggerList
)
.get(
"/api/workflows/action/list",
authorized(BUILDER),
controller.getActionList
)
.get(
"/api/workflows/logic/list",
authorized(BUILDER),
controller.getLogicList
)
.get(
"/api/workflows/definitions/list",
authorized(BUILDER),
controller.getDefinitionList
)
.get("/api/workflows", authorized(BUILDER), controller.fetch)
.get("/api/workflows/:id", authorized(BUILDER), controller.find)
.get(
"/api/workflows/:id/:action",
.put(
"/api/workflows",
authorized(BUILDER),
controller.fetchActionScript
workflowValidator,
controller.update
)
.put("/api/workflows", authorized(BUILDER), controller.update)
.post("/api/workflows", authorized(BUILDER), controller.create)
.post("/api/workflows/action", controller.executeAction)
.post(
"/api/workflows",
authorized(BUILDER),
workflowValidator,
controller.create
)
.post("/api/workflows/:id/trigger", controller.trigger)
.delete("/api/workflows/:id/:rev", authorized(BUILDER), controller.destroy)
module.exports = router

View File

@ -6,6 +6,7 @@ const http = require("http")
const api = require("./api")
const env = require("./environment")
const eventEmitter = require("./events")
const workflows = require("./workflows/index")
const Sentry = require("@sentry/node")
const app = new Koa()
@ -49,4 +50,5 @@ process.on("SIGINT", () => process.exit(1))
module.exports = server.listen(env.PORT || 4001, () => {
console.log(`Budibase running on ${JSON.stringify(server.address())}`)
workflows.init()
})

View File

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

View File

@ -1,33 +1,11 @@
const EventEmitter = require("events").EventEmitter
const CouchDB = require("../db")
const { Orchestrator, serverStrategy } = require("./workflow")
/**
* keeping event emitter in one central location as it might be used for things other than
* workflows (what it was for originally) - having a central emitter will be useful in the
* future.
*/
const emitter = new EventEmitter()
async function executeRelevantWorkflows(event, eventType) {
const db = new CouchDB(event.instanceId)
const workflowsToTrigger = await db.query("database/by_workflow_trigger", {
key: [eventType],
include_docs: true,
})
const workflows = workflowsToTrigger.rows.map(wf => wf.doc)
// Create orchestrator
const workflowOrchestrator = new Orchestrator()
workflowOrchestrator.strategy = serverStrategy
for (let workflow of workflows) {
workflowOrchestrator.execute(workflow, event)
}
}
emitter.on("record:save", async function(event) {
await executeRelevantWorkflows(event, "record:save")
})
emitter.on("record:delete", async function(event) {
await executeRelevantWorkflows(event, "record:delete")
})
module.exports = emitter

View File

@ -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,
}
}
},
})

View File

@ -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")
}

View File

@ -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
}

View File

@ -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)
}
})
}

View File

@ -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]
}
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -0,0 +1,38 @@
const CouchDB = require("../db")
const emitter = require("../events/index")
const InMemoryQueue = require("./queue/inMemoryQueue")
let workflowQueue = new InMemoryQueue()
async function queueRelevantWorkflows(event, eventType) {
if (event.instanceId == null) {
throw `No instanceId specified for ${eventType} - check event emitters.`
}
const db = new CouchDB(event.instanceId)
const workflowsToTrigger = await db.query("database/by_workflow_trigger", {
key: [eventType],
include_docs: true,
})
const workflows = workflowsToTrigger.rows.map(wf => wf.doc)
for (let workflow of workflows) {
if (!workflow.live) {
continue
}
workflowQueue.add({ workflow, event })
}
}
emitter.on("record:save", async function(event) {
await queueRelevantWorkflows(event, "record:save")
})
emitter.on("record:delete", async function(event) {
await queueRelevantWorkflows(event, "record:delete")
})
module.exports.externalTrigger = async function(workflow, params) {
workflowQueue.add({ workflow, event: params })
}
module.exports.workflowQueue = workflowQueue

View File

@ -172,6 +172,21 @@
lodash "^4.17.13"
to-fast-properties "^2.0.0"
"@budibase/client@^0.1.19":
version "0.1.19"
resolved "https://registry.yarnpkg.com/@budibase/client/-/client-0.1.19.tgz#3906781423ab4626118c981657ecf7a4578c547c"
integrity sha512-crxnLgebsh6CR0aMleDahY/1vFPbveG6JuSS/EVZeoBmckzK8hwiUQYQhIlf68nZfzWsCE/M9TX7SJxsrKY3bQ==
dependencies:
"@nx-js/compiler-util" "^2.0.0"
bcryptjs "^2.4.3"
deep-equal "^2.0.1"
lodash "^4.17.15"
lunr "^2.3.5"
mustache "^4.0.1"
regexparam "^1.3.0"
shortid "^2.2.8"
svelte "^3.9.2"
"@cnakazawa/watch@^1.0.3":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a"
@ -201,10 +216,39 @@
global-agent "^2.0.2"
global-tunnel-ng "^2.7.1"
"@hapi/address@^4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-4.1.0.tgz#d60c5c0d930e77456fdcde2598e77302e2955e1d"
integrity sha512-SkszZf13HVgGmChdHo/PxchnSaCJ6cetVqLzyciudzZRT0jcOouIF/Q93mgjw8cce+D+4F4C1Z/WrfFN+O3VHQ==
dependencies:
"@hapi/hoek" "^9.0.0"
"@hapi/bourne@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-2.0.0.tgz#5bb2193eb685c0007540ca61d166d4e1edaf918d"
"@hapi/formula@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-2.0.0.tgz#edade0619ed58c8e4f164f233cda70211e787128"
integrity sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A==
"@hapi/hoek@^9.0.0":
version "9.1.0"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.1.0.tgz#6c9eafc78c1529248f8f4d92b0799a712b6052c6"
integrity sha512-i9YbZPN3QgfighY/1X1Pu118VUz2Fmmhd6b2n0/O8YVgGGfw0FbUYoA97k7FkpGJ+pLCFEDLUmAPPV4D1kpeFw==
"@hapi/pinpoint@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@hapi/pinpoint/-/pinpoint-2.0.0.tgz#805b40d4dbec04fc116a73089494e00f073de8df"
integrity sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw==
"@hapi/topo@^5.0.0":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.0.0.tgz#c19af8577fa393a06e9c77b60995af959be721e7"
integrity sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw==
dependencies:
"@hapi/hoek" "^9.0.0"
"@jest/console@^24.7.1", "@jest/console@^24.9.0":
version "24.9.0"
resolved "https://registry.yarnpkg.com/@jest/console/-/console-24.9.0.tgz#79b1bc06fb74a8cfb01cbdedf945584b1b9707f0"
@ -354,6 +398,11 @@
path-to-regexp "1.x"
urijs "^1.19.2"
"@nx-js/compiler-util@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@nx-js/compiler-util/-/compiler-util-2.0.0.tgz#c74c12165fa2f017a292bb79af007e8fce0af297"
integrity sha512-AxSQbwj9zqt8DYPZ6LwZdytqnwfiOEdcFdq4l8sdjkZmU2clTht7RDLCI8xvkp7KqgcNaOGlTeCM55TULWruyQ==
"@sendgrid/client@^7.1.1":
version "7.1.1"
resolved "https://registry.yarnpkg.com/@sendgrid/client/-/client-7.1.1.tgz#09a25e58ac7e5321d66807e7110ff0fb61bb534f"
@ -811,6 +860,11 @@ array-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93"
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-unique@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
@ -869,6 +923,13 @@ atomic-sleep@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b"
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"
aws-sdk@^2.706.0:
version "2.706.0"
resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.706.0.tgz#09f65e9a91ecac5a635daf934082abae30eca953"
@ -1537,6 +1598,26 @@ decompress-response@^3.3.0:
dependencies:
mimic-response "^1.0.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"
is-arguments "^1.0.4"
is-date-object "^1.0.2"
is-regex "^1.0.5"
isarray "^2.0.5"
object-is "^1.1.2"
object-keys "^1.1.1"
object.assign "^4.1.0"
regexp.prototype.flags "^1.3.0"
side-channel "^1.0.2"
which-boxed-primitive "^1.0.1"
which-collection "^1.0.1"
which-typed-array "^1.1.2"
deep-equal@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
@ -1831,7 +1912,7 @@ env-paths@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.0.tgz#cdca557dc009152917d6166e2febe1f039685e43"
errno@~0.1.1:
errno@~0.1.1, errno@~0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618"
dependencies:
@ -1863,6 +1944,54 @@ es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.5:
string.prototype.trimleft "^2.1.1"
string.prototype.trimright "^2.1.1"
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"
has "^1.0.3"
has-symbols "^1.0.1"
is-callable "^1.2.0"
is-regex "^1.1.0"
object-inspect "^1.7.0"
object-keys "^1.1.1"
object.assign "^4.1.0"
string.prototype.trimend "^1.0.1"
string.prototype.trimstart "^1.0.1"
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"
has "^1.0.3"
has-symbols "^1.0.1"
is-callable "^1.2.0"
is-negative-zero "^2.0.0"
is-regex "^1.1.1"
object-inspect "^1.8.0"
object-keys "^1.1.1"
object.assign "^4.1.0"
string.prototype.trimend "^1.0.1"
string.prototype.trimstart "^1.0.1"
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"
is-arguments "^1.0.4"
is-map "^2.0.1"
is-set "^2.0.1"
is-string "^1.0.5"
isarray "^2.0.5"
es-to-primitive@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
@ -2753,16 +2882,31 @@ is-accessor-descriptor@^1.0.0:
dependencies:
kind-of "^6.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"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
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"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
dependencies:
binary-extensions "^2.0.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"
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
@ -2771,6 +2915,11 @@ is-callable@^1.1.4, is-callable@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab"
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"
resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c"
@ -2793,7 +2942,7 @@ is-data-descriptor@^1.0.0:
dependencies:
kind-of "^6.0.0"
is-date-object@^1.0.1:
is-date-object@^1.0.1, is-date-object@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e"
@ -2856,10 +3005,25 @@ is-installed-globally@^0.3.1:
global-dirs "^2.0.1"
is-path-inside "^3.0.1"
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-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-npm@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d"
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"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
@ -2890,10 +3054,27 @@ is-regex@^1.0.5:
dependencies:
has "^1.0.3"
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"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
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"
resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937"
@ -2908,10 +3089,30 @@ is-type-of@^1.0.0:
is-class-hotfix "~0.0.6"
isstream "~0.1.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"
foreach "^2.0.5"
has-symbols "^1.0.1"
is-typedarray@^1.0.0, is-typedarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
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"
resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
@ -2932,6 +3133,11 @@ isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
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==
isbinaryfile@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.6.tgz#edcb62b224e2b4710830b67498c8e4e5a4d2610b"
@ -3333,6 +3539,17 @@ jmespath@0.15.0, jmespath@^0.15.0:
version "0.15.0"
resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217"
joi@^17.2.1:
version "17.2.1"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.2.1.tgz#e5140fdf07e8fecf9bc977c2832d1bdb1e3f2a0a"
integrity sha512-YT3/4Ln+5YRpacdmfEfrrKh50/kkgX3LgBltjqnlMPIYiZ4hxXZuVJcxmsvxsdeHg9soZfE3qXxHC2tMpCCBOA==
dependencies:
"@hapi/address" "^4.1.0"
"@hapi/formula" "^2.0.0"
"@hapi/hoek" "^9.0.0"
"@hapi/pinpoint" "^2.0.0"
"@hapi/topo" "^5.0.0"
joycon@^2.2.5:
version "2.2.5"
resolved "https://registry.yarnpkg.com/joycon/-/joycon-2.2.5.tgz#8d4cf4cbb2544d7b7583c216fcdfec19f6be1615"
@ -3871,6 +4088,11 @@ ltgt@~2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ltgt/-/ltgt-2.1.3.tgz#10851a06d9964b971178441c23c9e52698eece34"
lunr@^2.3.5:
version "2.3.9"
resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1"
integrity sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==
make-dir@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
@ -4035,6 +4257,11 @@ nan@^2.12.1:
version "2.14.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
nanoid@^2.1.0:
version "2.1.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280"
integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==
nanomatch@^1.2.9:
version "1.2.13"
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
@ -4182,6 +4409,19 @@ object-inspect@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67"
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"
object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.0.6, object-keys@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
@ -4828,6 +5068,19 @@ regex-not@^1.0.0, regex-not@^1.0.2:
extend-shallow "^3.0.2"
safe-regex "^1.1.0"
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"
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==
regexpp@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f"
@ -5123,6 +5376,21 @@ shellwords@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b"
shortid@^2.2.8:
version "2.2.15"
resolved "https://registry.yarnpkg.com/shortid/-/shortid-2.2.15.tgz#2b902eaa93a69b11120373cd42a1f1fe4437c122"
integrity sha512-5EaCy2mx2Jgc/Fdn9uuDuNIIfWBpzY4XIlhoqtXF6qsf+/+SGZ+FxDdX/ZsMZiWupIWNqAEmiNY4RC+LSmCeOw==
dependencies:
nanoid "^2.1.0"
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"
signal-exit@^3.0.0, signal-exit@^3.0.2:
version "3.0.3"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
@ -5339,7 +5607,7 @@ string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0:
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.0"
string.prototype.trimend@^1.0.0:
string.prototype.trimend@^1.0.0, string.prototype.trimend@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913"
dependencies:
@ -5362,7 +5630,7 @@ string.prototype.trimright@^2.1.1:
es-abstract "^1.17.5"
string.prototype.trimend "^1.0.0"
string.prototype.trimstart@^1.0.0:
string.prototype.trimstart@^1.0.0, string.prototype.trimstart@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54"
dependencies:
@ -5480,6 +5748,11 @@ supports-color@^7.1.0:
dependencies:
has-flag "^4.0.0"
svelte@^3.9.2:
version "3.24.1"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.24.1.tgz#aca364937dd1df27fe131e2a4c234acb6061db4b"
integrity sha512-OX/IBVUJSFo1rnznXdwf9rv6LReJ3qQ0PwRjj76vfUWyTfbHbR9OXqJBnUrpjyis2dwYcbT2Zm1DFjOOF1ZbbQ==
symbol-tree@^3.2.2:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
@ -5912,10 +6185,43 @@ whatwg-url@^7.0.0:
tr46 "^1.0.1"
webidl-conversions "^4.0.2"
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"
is-number-object "^1.0.3"
is-string "^1.0.4"
is-symbol "^1.0.2"
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"
is-weakmap "^2.0.1"
is-weakset "^2.0.1"
which-module@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
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"
foreach "^2.0.5"
function-bind "^1.1.1"
has-symbols "^1.0.1"
is-typed-array "^1.1.3"
which@^1.2.9, which@^1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
@ -5932,6 +6238,13 @@ word-wrap@~1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
worker-farm@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8"
integrity sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==
dependencies:
errno "~0.1.7"
wrap-ansi@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09"