1
0
Fork 0
mirror of synced 2024-06-27 02:20:35 +12:00
This commit is contained in:
Martin McKeaveney 2020-09-24 15:52:09 +01:00
commit 9768a23ad4
105 changed files with 3481 additions and 2082 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

@ -4,6 +4,6 @@ node_modules_win
package-lock.json
release/
dist/
cypress/screenshots
cypress/videos
routify
cypress/videos
cypress/screenshots

View file

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

View file

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

View file

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

@ -1,11 +1,11 @@
import { getStore } from "./store"
import { getBackendUiStore } from "./store/backend"
import { getWorkflowStore } from "./store/workflow/"
import { getAutomationStore } from "./store/automation/"
import analytics from "../analytics"
export const store = getStore()
export const backendUiStore = getBackendUiStore()
export const workflowStore = getWorkflowStore()
export const automationStore = getAutomationStore()
export const initialise = async () => {
try {

View file

@ -1,59 +1,59 @@
import { generate } from "shortid"
/**
* Class responsible for the traversing of the workflow definition.
* Workflow definitions are stored in linked lists.
* Class responsible for the traversing of the automation definition.
* Automation definitions are stored in linked lists.
*/
export default class Workflow {
constructor(workflow) {
this.workflow = workflow
export default class Automation {
constructor(automation) {
this.automation = automation
}
hasTrigger() {
return this.workflow.definition.trigger
return this.automation.definition.trigger
}
addBlock(block) {
// Make sure to add trigger if doesn't exist
if (!this.hasTrigger() && block.type === "TRIGGER") {
const trigger = { id: generate(), ...block }
this.workflow.definition.trigger = trigger
this.automation.definition.trigger = trigger
return trigger
}
const newBlock = { id: generate(), ...block }
this.workflow.definition.steps = [
...this.workflow.definition.steps,
this.automation.definition.steps = [
...this.automation.definition.steps,
newBlock,
]
return newBlock
}
updateBlock(updatedBlock, id) {
const { steps, trigger } = this.workflow.definition
const { steps, trigger } = this.automation.definition
if (trigger && trigger.id === id) {
this.workflow.definition.trigger = updatedBlock
this.automation.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
this.automation.definition.steps = steps
}
deleteBlock(id) {
const { steps, trigger } = this.workflow.definition
const { steps, trigger } = this.automation.definition
if (trigger && trigger.id === id) {
this.workflow.definition.trigger = null
this.automation.definition.trigger = null
return
}
const stepIdx = steps.findIndex(step => step.id === id)
if (stepIdx < 0) throw new Error("Block not found.")
steps.splice(stepIdx, 1)
this.workflow.definition.steps = steps
this.automation.definition.steps = steps
}
}

View file

@ -0,0 +1,126 @@
import { writable } from "svelte/store"
import api from "../../api"
import Automation from "./Automation"
import { cloneDeep } from "lodash/fp"
const automationActions = store => ({
fetch: async () => {
const responses = await Promise.all([
api.get(`/api/automations`),
api.get(`/api/automations/definitions/list`),
])
const jsonResponses = await Promise.all(responses.map(x => x.json()))
store.update(state => {
state.automations = jsonResponses[0]
state.blockDefinitions = {
TRIGGER: jsonResponses[1].trigger,
ACTION: jsonResponses[1].action,
LOGIC: jsonResponses[1].logic,
}
return state
})
},
create: async ({ name }) => {
const automation = {
name,
type: "automation",
definition: {
steps: [],
},
}
const CREATE_AUTOMATION_URL = `/api/automations`
const response = await api.post(CREATE_AUTOMATION_URL, automation)
const json = await response.json()
store.update(state => {
state.automations = [...state.automations, json.automation]
store.actions.select(json.automation)
return state
})
},
save: async ({ automation }) => {
const UPDATE_AUTOMATION_URL = `/api/automations`
const response = await api.put(UPDATE_AUTOMATION_URL, automation)
const json = await response.json()
store.update(state => {
const existingIdx = state.automations.findIndex(
existing => existing._id === automation._id
)
state.automations.splice(existingIdx, 1, json.automation)
state.automations = state.automations
store.actions.select(json.automation)
return state
})
},
delete: async ({ automation }) => {
const { _id, _rev } = automation
const DELETE_AUTOMATION_URL = `/api/automations/${_id}/${_rev}`
await api.delete(DELETE_AUTOMATION_URL)
store.update(state => {
const existingIdx = state.automations.findIndex(
existing => existing._id === _id
)
state.automations.splice(existingIdx, 1)
state.automations = state.automations
state.selectedAutomation = null
state.selectedBlock = null
return state
})
},
trigger: async ({ automation }) => {
const { _id } = automation
const TRIGGER_AUTOMATION_URL = `/api/automations/${_id}/trigger`
return await api.post(TRIGGER_AUTOMATION_URL)
},
select: automation => {
store.update(state => {
state.selectedAutomation = new Automation(cloneDeep(automation))
state.selectedBlock = null
return state
})
},
addBlockToAutomation: block => {
store.update(state => {
const newBlock = state.selectedAutomation.addBlock(cloneDeep(block))
state.selectedBlock = newBlock
return state
})
},
deleteAutomationBlock: block => {
store.update(state => {
const idx = state.selectedAutomation.automation.definition.steps.findIndex(
x => x.id === block.id
)
state.selectedAutomation.deleteBlock(block.id)
// Select next closest step
const steps = state.selectedAutomation.automation.definition.steps
let nextSelectedBlock
if (steps[idx] != null) {
nextSelectedBlock = steps[idx]
} else if (steps[idx - 1] != null) {
nextSelectedBlock = steps[idx - 1]
} else {
nextSelectedBlock =
state.selectedAutomation.automation.definition.trigger || null
}
state.selectedBlock = nextSelectedBlock
return state
})
},
})
export const getAutomationStore = () => {
const INITIAL_AUTOMATION_STATE = {
automations: [],
blockDefinitions: {
TRIGGER: [],
ACTION: [],
LOGIC: [],
},
selectedAutomation: null,
}
const store = writable(INITIAL_AUTOMATION_STATE)
store.actions = automationActions(store)
return store
}

View file

@ -0,0 +1,48 @@
import Automation from "../Automation"
import TEST_AUTOMATION from "./testAutomation"
const TEST_BLOCK = {
id: "AUXJQGZY7",
name: "Delay",
icon: "ri-time-fill",
tagline: "Delay for <b>{{time}}</b> milliseconds",
description: "Delay the automation until an amount of time has passed.",
params: { time: "number" },
type: "LOGIC",
args: { time: "5000" },
stepId: "DELAY",
}
describe("Automation Data Object", () => {
let automation
beforeEach(() => {
automation = new Automation({ ...TEST_AUTOMATION })
})
it("adds a automation block to the automation", () => {
automation.addBlock(TEST_BLOCK)
expect(automation.automation.definition)
})
it("updates a automation block with new attributes", () => {
const firstBlock = automation.automation.definition.steps[0]
const updatedBlock = {
...firstBlock,
name: "UPDATED",
}
automation.updateBlock(updatedBlock, firstBlock.id)
expect(automation.automation.definition.steps[0]).toEqual(updatedBlock)
})
it("deletes a automation block successfully", () => {
const { steps } = automation.automation.definition
const originalLength = steps.length
const lastBlock = steps[steps.length - 1]
automation.deleteBlock(lastBlock.id)
expect(automation.automation.definition.steps.length).toBeLessThan(
originalLength
)
})
})

View file

@ -1,5 +1,5 @@
export default {
name: "Test workflow",
name: "Test automation",
definition: {
steps: [
{
@ -68,7 +68,7 @@ export default {
stepId: "RECORD_SAVED",
},
},
type: "workflow",
type: "automation",
ok: true,
id: "b384f861f4754e1693835324a7fcca62",
rev: "1-aa1c2cbd868ef02e26f8fad531dd7e37",

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

@ -1,125 +0,0 @@
import { writable } from "svelte/store"
import api from "../../api"
import Workflow from "./Workflow"
import { cloneDeep } from "lodash/fp"
const workflowActions = store => ({
fetch: async () => {
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 = jsonResponses[0]
state.blockDefinitions = {
TRIGGER: jsonResponses[1].trigger,
ACTION: jsonResponses[1].action,
LOGIC: jsonResponses[1].logic,
}
return state
})
},
create: async ({ name }) => {
const workflow = {
name,
definition: {
steps: [],
},
}
const CREATE_WORKFLOW_URL = `/api/workflows`
const response = await api.post(CREATE_WORKFLOW_URL, workflow)
const json = await response.json()
store.update(state => {
state.workflows = [...state.workflows, json.workflow]
store.actions.select(json.workflow)
return state
})
},
save: 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
})
},
delete: async ({ workflow }) => {
const { _id, _rev } = workflow
const DELETE_WORKFLOW_URL = `/api/workflows/${_id}/${_rev}`
await api.delete(DELETE_WORKFLOW_URL)
store.update(state => {
const existingIdx = state.workflows.findIndex(
existing => existing._id === _id
)
state.workflows.splice(existingIdx, 1)
state.workflows = state.workflows
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.selectedWorkflow = new Workflow(cloneDeep(workflow))
state.selectedBlock = null
return state
})
},
addBlockToWorkflow: block => {
store.update(state => {
const newBlock = state.selectedWorkflow.addBlock(cloneDeep(block))
state.selectedBlock = newBlock
return state
})
},
deleteWorkflowBlock: block => {
store.update(state => {
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
})
},
})
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,48 +0,0 @@
import Workflow from "../Workflow"
import TEST_WORKFLOW from "./testWorkflow"
const TEST_BLOCK = {
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 })
})
it("adds a workflow block to the workflow", () => {
workflow.addBlock(TEST_BLOCK)
expect(workflow.workflow.definition)
})
it("updates a workflow block with new attributes", () => {
const firstBlock = workflow.workflow.definition.steps[0]
const updatedBlock = {
...firstBlock,
name: "UPDATED",
}
workflow.updateBlock(updatedBlock, firstBlock.id)
expect(workflow.workflow.definition.steps[0]).toEqual(updatedBlock)
})
it("deletes a workflow block successfully", () => {
const { steps } = workflow.workflow.definition
const originalLength = steps.length
const lastBlock = steps[steps.length - 1]
workflow.deleteBlock(lastBlock.id)
expect(workflow.workflow.definition.steps.length).toBeLessThan(
originalLength
)
})
})

View file

@ -1,49 +1,48 @@
<script>
import { afterUpdate } from "svelte"
import { workflowStore, backendUiStore } from "builderStore"
import { automationStore, backendUiStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import Flowchart from "./flowchart/FlowChart.svelte"
$: workflow =
$workflowStore.selectedWorkflow && $workflowStore.selectedWorkflow.workflow
$: workflowLive = workflow && workflow.live
$: automation = $automationStore.selectedAutomation?.automation
$: automationLive = automation?.live
$: instanceId = $backendUiStore.selectedDatabase._id
function onSelect(block) {
workflowStore.update(state => {
automationStore.update(state => {
state.selectedBlock = block
return state
})
}
function setWorkflowLive(live) {
workflow.live = live
workflowStore.actions.save({ instanceId, workflow })
function setAutomationLive(live) {
automation.live = live
automationStore.actions.save({ instanceId, automation })
if (live) {
notifier.info(`Workflow ${workflow.name} enabled.`)
notifier.info(`Automation ${automation.name} enabled.`)
} else {
notifier.danger(`Workflow ${workflow.name} disabled.`)
notifier.danger(`Automation ${automation.name} disabled.`)
}
}
</script>
<section>
<Flowchart {workflow} {onSelect} />
<Flowchart {automation} {onSelect} />
</section>
<footer>
{#if workflow}
{#if automation}
<button
class:highlighted={workflowLive}
class:hoverable={workflowLive}
class:highlighted={automationLive}
class:hoverable={automationLive}
class="stop-button hoverable">
<i class="ri-stop-fill" on:click={() => setWorkflowLive(false)} />
<i class="ri-stop-fill" on:click={() => setAutomationLive(false)} />
</button>
<button
class:highlighted={!workflowLive}
class:hoverable={!workflowLive}
class:highlighted={!automationLive}
class:hoverable={!automationLive}
class="play-button hoverable"
data-cy="activate-workflow"
on:click={() => setWorkflowLive(true)}>
data-cy="activate-automation"
on:click={() => setAutomationLive(true)}>
<i class="ri-play-fill" />
</button>
{/if}
@ -62,7 +61,7 @@
footer {
position: absolute;
bottom: 20px;
bottom: var(--spacing-xl);
right: 30px;
display: flex;
align-items: flex-end;
@ -81,7 +80,7 @@
justify-content: center;
}
footer > button:first-child {
margin-right: 20px;
margin-right: var(--spacing-m);
}
.play-button.highlighted {

View file

@ -10,6 +10,6 @@
<style>
svg {
margin: 8px 0;
margin: var(--spacing-m) 0;
}
</style>

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 303 B

View file

@ -0,0 +1,78 @@
<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
let inputPaths = formattedTagline.match(/{{\s*\S+\s*}}/g) || []
inputPaths = inputPaths.map(path => path.replace(/[{}]/g, "").trim())
const schemaPaths = inputPaths.map(path =>
path.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

@ -4,17 +4,17 @@
import { flip } from "svelte/animate"
import { fade, fly } from "svelte/transition"
export let workflow
export let automation
export let onSelect
let blocks
$: {
blocks = []
if (workflow) {
if (workflow.definition.trigger) {
blocks.push(workflow.definition.trigger)
if (automation) {
if (automation.definition.trigger) {
blocks.push(automation.definition.trigger)
}
blocks = blocks.concat(workflow.definition.steps || [])
blocks = blocks.concat(automation.definition.steps || [])
}
}
</script>
@ -37,7 +37,7 @@
<style>
section {
position: absolute;
padding: 20px 40px;
padding: 40px;
display: flex;
align-items: center;
flex-direction: column;

View file

@ -0,0 +1,103 @@
<script>
import { automationStore } from "builderStore"
import AutomationBlockTagline from "./AutomationBlockTagline.svelte"
export let onSelect
export let block
let selected
$: selected = $automationStore.selectedBlock?.id === block.id
$: steps =
$automationStore.selectedAutomation?.automation?.definition?.steps ?? []
$: blockIdx = steps.findIndex(step => step.id === block.id)
</script>
<div
class={`block ${block.type} hoverable`}
class:selected
on:click={() => onSelect(block)}>
<header>
{#if block.type === 'TRIGGER'}
<i class="ri-lightbulb-fill" />
<span>When this happens...</span>
{:else if block.type === 'ACTION'}
<i class="ri-flashlight-fill" />
<span>Do this...</span>
{:else if block.type === 'LOGIC'}
<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>
<AutomationBlockTagline {block} />
</p>
</div>
<style>
.block {
width: 360px;
padding: 20px;
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;
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;
}
.ACTION {
background-color: var(--white);
color: var(--ink);
}
.TRIGGER {
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);
color: var(--ink);
}
p {
color: inherit;
margin-bottom: 0;
}
</style>

View file

@ -0,0 +1,88 @@
<script>
import Modal from "svelte-simple-modal"
import { notifier } from "builderStore/store/notifications"
import { onMount, getContext } from "svelte"
import { backendUiStore, automationStore } from "builderStore"
import CreateAutomationModal from "./CreateAutomationModal.svelte"
import { Button } from "@budibase/bbui"
const { open, close } = getContext("simple-modal")
$: selectedAutomationId = $automationStore.selectedAutomation?.automation?._id
function newAutomation() {
open(
CreateAutomationModal,
{
onClosed: close,
},
{ styleContent: { padding: "0" } }
)
}
onMount(() => {
automationStore.actions.fetch()
})
</script>
<section>
<Button purple wide on:click={newAutomation}>Create New Automation</Button>
<ul>
{#each $automationStore.automations as automation}
<li
class="automation-item"
class:selected={automation._id === selectedAutomationId}
on:click={() => automationStore.actions.select(automation)}>
<i class="ri-stackshare-line" class:live={automation.live} />
{automation.name}
</li>
{/each}
</ul>
</section>
<style>
section {
display: flex;
flex-direction: column;
}
ul {
list-style-type: none;
padding: 0;
margin: var(--spacing-xl) 0 0 0;
flex: 1;
}
i {
color: var(--grey-6);
}
i.live {
color: var(--purple);
}
li {
font-size: 14px;
}
.automation-item {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
border-radius: var(--border-radius-m);
padding: var(--spacing-s) var(--spacing-m);
margin-bottom: var(--spacing-xs);
color: var(--ink);
}
.automation-item i {
font-size: 24px;
margin-right: var(--spacing-m);
}
.automation-item:hover {
cursor: pointer;
background: var(--grey-1);
}
.automation-item.selected {
background: var(--grey-2);
}
</style>

View file

@ -0,0 +1,95 @@
<script>
import { store, backendUiStore, automationStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import ActionButton from "components/common/ActionButton.svelte"
import { Input } from "@budibase/bbui"
export let onClosed
let name
$: valid = !!name
$: instanceId = $backendUiStore.selectedDatabase._id
$: appId = $store.appId
async function createAutomation() {
await automationStore.actions.create({
name,
instanceId,
})
onClosed()
notifier.success(`Automation ${name} created.`)
}
</script>
<div class="container">
<header>
<i class="ri-stackshare-line" />
Create Automation
</header>
<div class="content">
<Input bind:value={name} label="Name" />
</div>
<footer>
<a href="https://docs.budibase.com">
<i class="ri-information-line" />
<span>Learn about automations</span>
</a>
<ActionButton secondary on:click={onClosed}>Cancel</ActionButton>
<ActionButton disabled={!valid} on:click={createAutomation}>
Save
</ActionButton>
</footer>
</div>
<style>
.container {
padding: var(--spacing-xl);
}
header {
font-size: var(--font-size-xl);
color: var(--ink);
font-weight: bold;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
header i {
margin-right: var(--spacing-m);
font-size: 20px;
background: var(--purple);
color: var(--white);
padding: var(--spacing-s);
border-radius: var(--border-radius-m);
display: inline-block;
}
.content {
padding: var(--spacing-xl) 0;
}
footer {
display: grid;
grid-auto-flow: column;
grid-gap: var(--spacing-m);
grid-auto-columns: 3fr 1fr 1fr;
}
footer a {
color: var(--ink);
font-size: 14px;
vertical-align: middle;
display: flex;
align-items: center;
text-decoration: none;
}
footer a span {
text-decoration: underline;
}
footer i {
font-size: 20px;
margin-right: var(--spacing-m);
text-decoration: none;
}
</style>

View file

@ -0,0 +1,54 @@
<script>
import { automationStore } from "builderStore"
import AutomationList from "./AutomationList/AutomationList.svelte"
import BlockList from "./BlockList/BlockList.svelte"
let selectedTab = "AUTOMATIONS"
</script>
<header>
<span
data-cy="automation-list"
class="hoverable automation-header"
class:selected={selectedTab === 'AUTOMATIONS'}
on:click={() => (selectedTab = 'AUTOMATIONS')}>
Automations
</span>
{#if $automationStore.selectedAutomation}
<span
data-cy="add-automation-component"
class="hoverable"
class:selected={selectedTab === 'ADD'}
on:click={() => (selectedTab = 'ADD')}>
Steps
</span>
{/if}
</header>
{#if selectedTab === 'AUTOMATIONS'}
<AutomationList />
{:else if selectedTab === 'ADD'}
<BlockList />
{/if}
<style>
header {
font-size: 18px;
font-weight: 600;
background: none;
display: flex;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.automation-header {
margin-right: var(--spacing-xl);
}
span:not(.selected) {
color: var(--grey-5);
}
span:not(.selected):hover {
color: var(--ink);
}
</style>

View file

@ -1,14 +1,14 @@
<script>
import { workflowStore } from "builderStore"
import { automationStore } from "builderStore"
export let blockDefinition
export let stepId
export let blockType
function addBlockToWorkflow() {
workflowStore.actions.addBlockToWorkflow({
function addBlockToAutomation() {
automationStore.actions.addBlockToAutomation({
...blockDefinition,
args: blockDefinition.args || {},
inputs: blockDefinition.inputs || {},
stepId,
type: blockType,
})
@ -16,43 +16,32 @@
</script>
<div
class="workflow-block hoverable"
on:click={addBlockToWorkflow}
class="automation-block hoverable"
on:click={addBlockToAutomation}
data-cy={stepId}>
<div>
<i class={blockDefinition.icon} />
</div>
<div class="workflow-text">
<div class="automation-text">
<h4>{blockDefinition.name}</h4>
<p>{blockDefinition.description}</p>
</div>
</div>
<style>
.workflow-block {
.automation-block {
display: grid;
grid-template-columns: 20px auto;
align-items: center;
margin-top: 16px;
padding: 12px;
margin-top: var(--spacing-s);
padding: var(--spacing-m);
border-radius: var(--border-radius-m);
}
.workflow-block:hover {
.automation-block:hover {
background-color: var(--grey-1);
}
.workflow-text {
margin-left: 16px;
}
.icon {
height: 40px;
width: 40px;
background: var(--blue-light);
display: flex;
align-items: center;
justify-content: center;
.automation-block:first-child {
margin-top: 0;
}
i {
@ -60,14 +49,16 @@
font-size: 20px;
}
h4 {
.automation-text {
margin-left: 16px;
}
.automation-text h4 {
font-size: 14px;
font-weight: 500;
margin-bottom: 5px;
margin-top: 0;
}
p {
.automation-text p {
font-size: 12px;
color: var(--grey-7);
margin: 0;

View file

@ -1,14 +1,17 @@
<script>
import { workflowStore } from "builderStore"
import WorkflowBlock from "./WorkflowBlock.svelte"
import { sortBy } from "lodash/fp"
import { automationStore } from "builderStore"
import AutomationBlock from "./AutomationBlock.svelte"
import FlatButtonGroup from "components/userInterface/FlatButtonGroup.svelte"
let selectedTab = "TRIGGER"
let buttonProps = []
$: blocks = Object.entries($workflowStore.blockDefinitions[selectedTab])
$: blocks = sortBy(entry => entry[1].name)(
Object.entries($automationStore.blockDefinitions[selectedTab])
)
$: {
if ($workflowStore.selectedWorkflow.hasTrigger()) {
if ($automationStore.selectedAutomation.hasTrigger()) {
buttonProps = [
{ value: "ACTION", text: "Action" },
{ value: "LOGIC", text: "Logic" },
@ -33,7 +36,13 @@
<FlatButtonGroup value={selectedTab} {buttonProps} onChange={onChangeTab} />
<div id="blocklist">
{#each blocks as [stepId, blockDefinition]}
<WorkflowBlock {blockDefinition} {stepId} blockType={selectedTab} />
<AutomationBlock {blockDefinition} {stepId} blockType={selectedTab} />
{/each}
</div>
</section>
<style>
#blocklist {
margin-top: var(--spacing-xl);
}
</style>

View file

@ -0,0 +1,3 @@
export { default as AutomationPanel } from "./AutomationPanel.svelte"
export { default as BlockList } from "./BlockList/BlockList.svelte"
export { default as AutomationList } from "./AutomationList/AutomationList.svelte"

View file

@ -0,0 +1,104 @@
<script>
import ModelSelector from "./ParamInputs/ModelSelector.svelte"
import RecordSelector from "./ParamInputs/RecordSelector.svelte"
import { Input, TextArea, Select, Label } from "@budibase/bbui"
import { automationStore } from "builderStore"
import BindableInput from "../../userInterface/BindableInput.svelte"
export let block
$: inputs = Object.entries(block.schema?.inputs?.properties || {})
$: bindings = getAvailableBindings(
block,
$automationStore.selectedAutomation?.automation?.definition
)
function getAvailableBindings(block, automation) {
if (!block || !automation) {
return []
}
// Find previous steps to the selected one
let allSteps = [...automation.steps]
if (automation.trigger) {
allSteps = [automation.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" data-cy="automation-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 value.enum as option, idx}
<option value={option}>
{value.pretty ? value.pretty[idx] : option}
</option>
{/each}
</Select>
{: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="string"
thin
bind:value={block.inputs[key]}
{bindings} />
{/if}
</div>
{/each}
</div>
<style>
.block-field {
display: grid;
}
.field-label {
color: var(--ink);
margin-bottom: 12px;
display: flex;
font-size: 14px;
font-weight: 500;
font-family: sans-serif;
}
.block-label {
font-weight: 500;
font-size: 14px;
color: var(--grey-7);
}
textarea {
min-height: 150px;
font-family: inherit;
padding: 12px;
margin-top: 8px;
}
</style>

View file

@ -1,5 +1,5 @@
<script>
import { store, backendUiStore, workflowStore } from "builderStore"
import { store, backendUiStore, automationStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import ActionButton from "components/common/ActionButton.svelte"
@ -10,32 +10,33 @@
$: valid = !!name
$: instanceId = $backendUiStore.selectedDatabase._id
async function deleteWorkflow() {
await workflowStore.actions.delete({
async function deleteAutomation() {
await automationStore.actions.delete({
instanceId,
workflow: $workflowStore.selectedWorkflow.workflow,
automation: $automationStore.selectedAutomation.automation,
})
onClosed()
notifier.danger("Workflow deleted.")
notifier.danger("Automation deleted.")
}
</script>
<header>
<i class="ri-stackshare-line" />
Delete Workflow
Delete Automation
</header>
<div>
<p>
Are you sure you want to delete this workflow? This action can't be undone.
Are you sure you want to delete this automation? This action can't be
undone.
</p>
</div>
<footer>
<a href="https://docs.budibase.com">
<i class="ri-information-line" />
Learn about workflows
Learn about automations
</a>
<ActionButton on:click={onClosed}>Cancel</ActionButton>
<ActionButton alert on:click={deleteWorkflow}>Delete</ActionButton>
<ActionButton alert on:click={deleteAutomation}>Delete</ActionButton>
</footer>
<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

@ -0,0 +1,69 @@
<script>
import { backendUiStore } from "builderStore"
import { Input, Select, Label } from "@budibase/bbui"
import BindableInput from "../../../userInterface/BindableInput.svelte"
export let value
export let bindings
$: model = $backendUiStore.models.find(model => model._id === value?.modelId)
$: schemaFields = Object.entries(model?.schema ?? {})
// 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 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>
</div>
{#if schemaFields.length}
<div class="bb-margin-xl block-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="string"
{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

@ -0,0 +1,144 @@
<script>
import { getContext } from "svelte"
import { backendUiStore, automationStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import AutomationBlockSetup from "./AutomationBlockSetup.svelte"
import DeleteAutomationModal from "./DeleteAutomationModal.svelte"
import { Button, Input, Label } from "@budibase/bbui"
const { open, close } = getContext("simple-modal")
let selectedTab = "SETUP"
$: automation = $automationStore.selectedAutomation?.automation
$: allowDeleteBlock =
$automationStore.selectedBlock?.type !== "TRIGGER" ||
!automation?.definition?.steps?.length
function deleteAutomation() {
open(
DeleteAutomationModal,
{ onClosed: close },
{ styleContent: { padding: "0" } }
)
}
function deleteAutomationBlock() {
automationStore.actions.deleteAutomationBlock(
$automationStore.selectedBlock
)
}
async function testAutomation() {
const result = await automationStore.actions.trigger({
automation: $automationStore.selectedAutomation.automation,
})
if (result.status === 200) {
notifier.success(`Automation ${automation.name} triggered successfully.`)
} else {
notifier.danger(`Failed to trigger automation ${automation.name}.`)
}
}
async function saveAutomation() {
await automationStore.actions.save({
instanceId: $backendUiStore.selectedDatabase._id,
automation,
})
notifier.success(`Automation ${automation.name} saved.`)
}
</script>
<section>
<header>
<span
class="hoverable"
class:selected={selectedTab === 'SETUP'}
on:click={() => (selectedTab = 'SETUP')}>
Setup
</span>
</header>
<div class="content">
{#if $automationStore.selectedBlock}
<AutomationBlockSetup bind:block={$automationStore.selectedBlock} />
{:else if $automationStore.selectedAutomation}
<div class="block-label">
Automation
<b>{automation.name}</b>
</div>
<Button secondary wide on:click={testAutomation}>Test Automation</Button>
{/if}
</div>
<div class="buttons">
{#if $automationStore.selectedBlock}
<Button
green
wide
data-cy="save-automation-setup"
on:click={saveAutomation}>
Save Automation
</Button>
<Button
disabled={!allowDeleteBlock}
red
wide
on:click={deleteAutomationBlock}>
Delete Step
</Button>
{:else if $automationStore.selectedAutomation}
<Button
green
wide
data-cy="save-automation-setup"
on:click={saveAutomation}>
Save Automation
</Button>
<Button red wide on:click={deleteAutomation}>Delete Automation</Button>
{/if}
</div>
</section>
<style>
section {
display: flex;
flex-direction: column;
height: 100%;
justify-content: flex-start;
align-items: stretch;
}
header {
font-size: 18px;
font-weight: 600;
font-family: inter, sans-serif;
display: flex;
align-items: center;
margin-bottom: var(--spacing-xl);
color: var(--ink);
}
header > span {
color: var(--grey-5);
margin-right: var(--spacing-xl);
cursor: pointer;
}
.selected {
color: var(--ink);
}
.block-label {
font-weight: 500;
font-size: 14px;
color: var(--grey-7);
margin-bottom: var(--spacing-xl);
}
.content {
flex: 1 0 auto;
}
.buttons {
display: grid;
gap: var(--spacing-m);
}
</style>

View file

@ -0,0 +1,3 @@
export { default as AutomationBuilder } from "./AutomationBuilder/AutomationBuilder.svelte"
export { default as SetupPanel } from "./SetupPanel/SetupPanel.svelte"
export { default as AutomationPanel } from "./AutomationPanel/AutomationPanel.svelte"

View file

@ -1,7 +1,7 @@
<script>
import { writable } from "svelte/store"
import { store, workflowStore, backendUiStore } from "builderStore"
import { store, automationStore, backendUiStore } from "builderStore"
import { string, object } from "yup"
import api, { get } from "builderStore/api"
import Form from "@svelteschool/svelte-forms"
@ -133,7 +133,7 @@
if (applicationPkg.ok) {
backendUiStore.actions.reset()
await store.setPackage(pkg)
workflowStore.actions.fetch()
automationStore.actions.fetch()
} else {
throw new Error(pkg)
}

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

@ -3,7 +3,7 @@
import { find, map, keys, reduce, keyBy } from "lodash/fp"
import { pipe } from "components/common/core"
import { EVENT_TYPE_MEMBER_NAME } from "components/common/eventHandlers"
import { store, workflowStore } from "builderStore"
import { store, automationStore } from "builderStore"
import { ArrowDownIcon } from "components/common/Icons/"
import { createEventDispatcher } from "svelte"
@ -18,14 +18,14 @@
</script>
<div class="handler-option">
{#if parameter.name === 'workflow'}
{#if parameter.name === 'automation'}
<span>{parameter.name}</span>
{/if}
{#if parameter.name === 'workflow'}
{#if parameter.name === 'automation'}
<Select on:change bind:value={parameter.value}>
<option value="" />
{#each $workflowStore.workflows.filter(wf => wf.live) as workflow}
<option value={workflow._id}>{workflow.name}</option>
{#each $automationStore.automations.filter(wf => wf.live) as automation}
<option value={automation._id}>{automation.name}</option>
{/each}
</Select>
{:else if parameter.name === 'url'}

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,51 +0,0 @@
<script>
import { backendUiStore } from "builderStore"
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]
if (fieldSchema.type === "number") {
value[field] = parseInt(evt.target.value)
return
}
value[field] = evt.target.value
}
</script>
<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._id}>{model.name}</option>
{/each}
</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)} />
</div>
{/each}
</div>
{/if}

View file

@ -1,151 +0,0 @@
<script>
import { getContext } from "svelte"
import { backendUiStore, workflowStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import WorkflowBlockSetup from "./WorkflowBlockSetup.svelte"
import DeleteWorkflowModal from "./DeleteWorkflowModal.svelte"
import { Button, Input, Label } from "@budibase/bbui"
const { open, close } = getContext("simple-modal")
let selectedTab = "SETUP"
$: workflow =
$workflowStore.selectedWorkflow && $workflowStore.selectedWorkflow.workflow
function deleteWorkflow() {
open(
DeleteWorkflowModal,
{ onClosed: close },
{ styleContent: { padding: "0" } }
)
}
function deleteWorkflowBlock() {
workflowStore.actions.deleteWorkflowBlock($workflowStore.selectedBlock)
}
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() {
await workflowStore.actions.save({
instanceId: $backendUiStore.selectedDatabase._id,
workflow,
})
notifier.success(`Workflow ${workflow.name} saved.`)
}
</script>
<section>
<header>
<span
class="hoverable"
class:selected={selectedTab === 'SETUP'}
on:click={() => (selectedTab = 'SETUP')}>
Setup
</span>
</header>
{#if $workflowStore.selectedBlock}
<WorkflowBlockSetup bind:block={$workflowStore.selectedBlock} />
<div class="buttons">
<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.selectedWorkflow}
<div class="panel">
<div class="panel-body">
<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
wide
data-cy="save-workflow-setup"
on:click={saveWorkflow}>
Save Workflow
</Button>
<Button red wide on:click={deleteWorkflow}>Delete Workflow</Button>
</div>
</div>
{/if}
</section>
<style>
section {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
.panel-body {
flex: 1;
}
.panel {
display: flex;
flex-direction: column;
justify-content: space-between;
}
header {
font-size: 18px;
font-weight: 600;
font-family: inter;
display: flex;
align-items: center;
margin-bottom: 20px;
color: var(--ink);
}
.selected {
color: var(--ink);
}
.block-label {
font-weight: 500;
font-size: 14px;
color: var(--grey-7);
margin-bottom: 20px;
}
header > span {
color: var(--grey-5);
margin-right: 20px;
cursor: pointer;
}
label {
font-weight: 500;
font-size: 14px;
color: var(--ink);
}
.buttons {
position: absolute;
bottom: 20px;
display: grid;
width: 260px;
gap: 12px;
}
.access-level label {
font-weight: normal;
color: var(--ink);
}
</style>

View file

@ -1,71 +0,0 @@
<script>
import ModelSelector from "./ParamInputs/ModelSelector.svelte"
import RecordSelector from "./ParamInputs/RecordSelector.svelte"
import { Input, TextArea, Select } from "@budibase/bbui"
export let block
$: params = block.params ? Object.entries(block.params) : []
</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>
<option value="">Choose an option</option>
{#each type as option}
<option value={option}>{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]} />
{/if}
</div>
{/each}
</div>
<style>
.block-field {
display: grid;
}
label {
font-size: 14px;
font-family: sans-serif;
font-weight: 500;
color: var(--ink);
margin-bottom: 12px;
text-transform: capitalize;
margin-top: 20px;
}
.selected-label {
font-weight: 500;
font-size: 14px;
color: var(--grey-7);
}
textarea {
min-height: 150px;
font-family: inherit;
padding: 12px;
margin-top: 8px;
}
</style>

View file

@ -1,86 +0,0 @@
<script>
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 class={`${block.type} hoverable`} class:selected on:click={selectBlock}>
<header>
{#if block.type === 'TRIGGER'}
<i class="ri-lightbulb-fill" />
When this happens...
{:else if block.type === 'ACTION'}
<i class="ri-flashlight-fill" />
Do this...
{:else if block.type === 'LOGIC'}
<i class="ri-pause-fill" />
Only continue if...
{/if}
</header>
<hr />
<p>
{@html mustache.render(block.tagline, block.args)}
</p>
</div>
<style>
div {
width: 320px;
padding: 20px;
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;
color: var(--white);
}
header {
font-size: 16px;
font-weight: 500;
display: flex;
align-items: center;
}
header i {
font-size: 20px;
margin-right: 5px;
}
.ACTION {
background-color: var(--white);
color: var(--ink);
}
.TRIGGER {
background-color: var(--ink);
color: var(--white);
}
.LOGIC {
background-color: var(--blue-light);
color: var(--ink);
}
p {
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

@ -1,88 +0,0 @@
<script>
import { store, backendUiStore, workflowStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import ActionButton from "components/common/ActionButton.svelte"
import { Input } from "@budibase/bbui"
export let onClosed
let name
$: valid = !!name
$: instanceId = $backendUiStore.selectedDatabase._id
$: appId = $store.appId
async function createWorkflow() {
await workflowStore.actions.create({
name,
instanceId,
})
onClosed()
notifier.success(`Workflow ${name} created.`)
}
</script>
<header>
<i class="ri-stackshare-line" />
Create Workflow
</header>
<div>
<Input bind:value={name} label="Name" />
</div>
<footer>
<a href="https://docs.budibase.com">
<i class="ri-information-line" />
Learn about workflows
</a>
<ActionButton secondary on:click={onClosed}>Cancel</ActionButton>
<ActionButton disabled={!valid} on:click={createWorkflow}>Save</ActionButton>
</footer>
<style>
header {
font-size: 24px;
color: var(--ink);
font-weight: bold;
padding: 30px;
}
header i {
margin-right: 10px;
font-size: 20px;
background: var(--blue-light);
color: var(--grey-4);
padding: 8px;
}
div {
padding: 0 30px 30px 30px;
}
label {
font-size: 18px;
font-weight: 500;
}
footer {
display: grid;
grid-auto-flow: column;
grid-gap: 5px;
grid-auto-columns: 3fr 1fr 1fr;
padding: 20px;
background: var(--grey-1);
border-radius: 0.5rem;
}
footer a {
color: var(--primary);
font-size: 14px;
vertical-align: middle;
display: flex;
align-items: center;
}
footer i {
font-size: 20px;
margin-right: 10px;
}
</style>

View file

@ -1,122 +0,0 @@
<script>
import Modal from "svelte-simple-modal"
import { notifier } from "builderStore/store/notifications"
import { onMount, getContext } from "svelte"
import { backendUiStore, workflowStore } from "builderStore"
import CreateWorkflowModal from "./CreateWorkflowModal.svelte"
import { Button } from "@budibase/bbui"
const { open, close } = getContext("simple-modal")
$: selectedWorkflowId =
$workflowStore.selectedWorkflow &&
$workflowStore.selectedWorkflow.workflow._id
function newWorkflow() {
open(
CreateWorkflowModal,
{
onClosed: close,
},
{ styleContent: { padding: "0" } }
)
}
onMount(() => {
workflowStore.actions.fetch()
})
</script>
<section>
<Button purple wide on:click={newWorkflow}>Create New Workflow</Button>
<ul>
{#each $workflowStore.workflows as workflow}
<li
class="workflow-item"
class:selected={workflow._id === selectedWorkflowId}
on:click={() => workflowStore.actions.select(workflow)}>
<i class="ri-stackshare-line" class:live={workflow.live} />
{workflow.name}
</li>
{/each}
</ul>
</section>
<style>
section {
display: flex;
flex-direction: column;
}
i {
color: #adaec4;
}
i:hover {
cursor: pointer;
}
ul {
list-style-type: none;
padding: 0;
flex: 1;
}
.live {
color: var(--primary);
}
li {
font-size: 14px;
}
.workflow-item {
display: flex;
border-radius: 5px;
padding-left: 12px;
align-items: center;
height: 36px;
margin-bottom: 4px;
color: var(--ink);
}
.workflow-item i {
font-size: 24px;
margin-right: 10px;
}
.workflow-item:hover {
cursor: pointer;
background: var(--grey-1);
}
.workflow-item.selected {
background: var(--grey-2);
}
.new-workflow-button {
cursor: pointer;
border: 1px solid var(--grey-4);
border-radius: 3px;
width: 100%;
padding: 8px 16px;
display: flex;
justify-content: center;
align-items: center;
background: white;
color: var(--ink);
font-size: 14px;
font-weight: 500;
transition: all 2ms;
}
.new-workflow-button:hover {
background: var(--grey-1);
}
.icon {
color: var(--ink);
font-size: 16px;
margin-right: 4px;
}
</style>

View file

@ -1,54 +0,0 @@
<script>
import { workflowStore } from "builderStore"
import WorkflowList from "./WorkflowList/WorkflowList.svelte"
import BlockList from "./BlockList/BlockList.svelte"
let selectedTab = "WORKFLOWS"
</script>
<header>
<span
data-cy="workflow-list"
class="hoverable workflow-header"
class:selected={selectedTab === 'WORKFLOWS'}
on:click={() => (selectedTab = 'WORKFLOWS')}>
Workflows
</span>
{#if $workflowStore.selectedWorkflow}
<span
data-cy="add-workflow-component"
class="hoverable"
class:selected={selectedTab === 'ADD'}
on:click={() => (selectedTab = 'ADD')}>
Add
</span>
{/if}
</header>
{#if selectedTab === 'WORKFLOWS'}
<WorkflowList />
{:else if selectedTab === 'ADD'}
<BlockList />
{/if}
<style>
header {
font-size: 18px;
font-weight: 600;
background: none;
display: flex;
align-items: center;
margin-bottom: 20px;
}
.workflow-header {
margin-right: 20px;
}
span:not(.selected) {
color: var(--grey-5);
}
span:not(.selected):hover {
color: var(--ink);
}
</style>

View file

@ -1,3 +0,0 @@
export { default as WorkflowPanel } from "./WorkflowPanel.svelte"
export { default as BlockList } from "./BlockList/BlockList.svelte"
export { default as WorkflowList } from "./WorkflowList/WorkflowList.svelte"

View file

@ -1,3 +0,0 @@
export { default as WorkflowBuilder } from "./WorkflowBuilder/WorkflowBuilder.svelte"
export { default as SetupPanel } from "./SetupPanel/SetupPanel.svelte"
export { default as WorkflowPanel } from "./WorkflowPanel/WorkflowPanel.svelte"

View file

@ -1,6 +1,6 @@
<script>
import Modal from "svelte-simple-modal"
import { store, workflowStore, backendUiStore } from "builderStore"
import { store, automationStore, backendUiStore } from "builderStore"
import SettingsLink from "components/settings/Link.svelte"
import { get } from "builderStore/api"
@ -21,17 +21,17 @@
if (res.ok) {
backendUiStore.actions.reset()
await store.setPackage(pkg)
workflowStore.actions.fetch()
await automationStore.actions.fetch()
return pkg
} else {
throw new Error(pkg)
}
}
// handles navigation between frontend, backend, workflow.
// handles navigation between frontend, backend, automation.
// this remembers your last place on each of the sections
// e.g. if one of your screens is selected on front end, then
// you browse to backend, when you click fronend, you will be
// you browse to backend, when you click frontend, you will be
// brought back to the same screen
const topItemNavigate = path => () => {
const activeTopNav = $layout.children.find(c => $isActive(c.path))

View file

@ -0,0 +1,39 @@
<script>
import { automationStore } from "builderStore"
import { AutomationPanel, SetupPanel } from "components/automation"
</script>
<!-- routify:options index=3 -->
<div class="root">
<div class="nav">
<AutomationPanel />
</div>
<div class="content">
<slot />
</div>
{#if $automationStore.selectedAutomation}
<div class="nav">
<SetupPanel />
</div>
{/if}
</div>
<style>
.content {
position: relative;
}
.root {
height: calc(100% - 60px);
display: grid;
grid-template-columns: 300px minmax(510px, 1fr) 300px;
background: var(--grey-1);
line-height: 1;
}
.nav {
overflow-y: auto;
background: var(--white);
padding: var(--spacing-xl);
}
</style>

View file

@ -0,0 +1,5 @@
<script>
import { AutomationBuilder } from "components/automation"
</script>
<AutomationBuilder />

View file

@ -5,6 +5,7 @@
import ModelNavigator from "components/nav/ModelNavigator/ModelNavigator.svelte"
</script>
<!-- routify:options index=1 -->
<div class="root">
<div class="nav">
<ModelNavigator />

View file

@ -37,6 +37,7 @@
const lastPartOfName = c => (c ? last(c.split("/")) : "")
</script>
<!-- routify:options index=1 -->
<div class="root">
<div class="ui-nav">

View file

@ -1,51 +0,0 @@
<script>
import { workflowStore } from "builderStore"
import { WorkflowPanel, SetupPanel } from "components/workflow"
</script>
<div class="root">
<div class="nav">
<div class="inner">
<WorkflowPanel />
</div>
</div>
<div class="content">
<slot />
</div>
{#if $workflowStore.selectedWorkflow}
<div class="nav">
<div class="inner">
<SetupPanel />
</div>
</div>
{/if}
</div>
<style>
.content {
position: relative;
background: var(--grey-1);
}
.root {
height: 100%;
display: grid;
grid-template-columns: 300px minmax(0, 1fr) 300px;
background: var(--grey-1);
line-height: 1;
}
.content {
flex: 1 1 auto;
}
.nav {
overflow: auto;
width: 300px;
background: var(--white);
}
.inner {
padding: 20px;
}
</style>

View file

@ -1,5 +0,0 @@
<script>
import { WorkflowBuilder } from "components/workflow"
</script>
<WorkflowBuilder />

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

@ -50,6 +50,7 @@
"bcryptjs": "^2.4.3",
"chmodr": "^1.2.0",
"dotenv": "^8.2.0",
"download": "^8.0.0",
"electron-is-dev": "^1.2.0",
"electron-unhandled": "^3.0.2",
"electron-updater": "^4.3.1",

View file

@ -0,0 +1,130 @@
const CouchDB = require("../../db")
const newid = require("../../db/newid")
const actions = require("../../automations/actions")
const logic = require("../../automations/logic")
const triggers = require("../../automations/triggers")
/*************************
* *
* BUILDER FUNCTIONS *
* *
*************************/
function cleanAutomationInputs(automation) {
if (automation == null) {
return automation
}
let steps = automation.definition.steps
let trigger = automation.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 automation
}
exports.create = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
let automation = ctx.request.body
automation._id = newid()
automation.type = "automation"
automation = cleanAutomationInputs(automation)
const response = await db.post(automation)
automation._rev = response.rev
ctx.status = 200
ctx.body = {
message: "Automation created successfully",
automation: {
...automation,
...response,
},
}
}
exports.update = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
let automation = ctx.request.body
automation = cleanAutomationInputs(automation)
const response = await db.put(automation)
automation._rev = response.rev
ctx.status = 200
ctx.body = {
message: `Automation ${automation._id} updated successfully.`,
automation: {
...automation,
_rev: response.rev,
_id: response.id,
},
}
}
exports.fetch = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
const response = await db.query(`database/by_type`, {
key: ["automation"],
include_docs: true,
})
ctx.body = response.rows.map(row => row.doc)
}
exports.find = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
ctx.body = await db.get(ctx.params.id)
}
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 = actions.DEFINITIONS
}
exports.getTriggerList = async function(ctx) {
ctx.body = triggers.BUILTIN_DEFINITIONS
}
exports.getLogicList = async function(ctx) {
ctx.body = logic.BUILTIN_DEFINITIONS
}
module.exports.getDefinitionList = async function(ctx) {
ctx.body = {
logic: logic.BUILTIN_DEFINITIONS,
trigger: triggers.BUILTIN_DEFINITIONS,
action: actions.DEFINITIONS,
}
}
/*********************
* *
* API FUNCTIONS *
* *
*********************/
exports.trigger = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
let automation = await db.get(ctx.params.id)
await triggers.externalTrigger(automation, {
...ctx.request.body,
instanceId: ctx.user.instanceId,
})
ctx.status = 200
ctx.body = {
message: `Automation ${automation._id} has been triggered.`,
automation,
}
}

View file

@ -31,9 +31,9 @@ exports.create = async function(ctx) {
emit([doc.type], doc._id)
}.toString(),
},
by_workflow_trigger: {
by_automation_trigger: {
map: function(doc) {
if (doc.type === "workflow") {
if (doc.type === "automation") {
const trigger = doc.definition.trigger
if (trigger) {
emit([trigger.event], trigger)

View file

@ -3,13 +3,18 @@ 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,
})
let event = {
record,
instanceId: ctx.user.instanceId,
}
// add syntactic sugar for mustache later
if (record._id) {
event.id = record._id
}
if (record._rev) {
event.revision = record._rev
}
ctx.eventEmitter && ctx.eventEmitter.emit(eventType, event)
}
validateJs.extend(validateJs.validators.datetime, {
@ -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 automations
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

@ -12,7 +12,7 @@ const controller = {
!name.startsWith("all") &&
name !== "by_type" &&
name !== "by_username" &&
name !== "by_workflow_trigger"
name !== "by_automation_trigger"
) {
response.push({
name,

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

@ -1,107 +0,0 @@
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)
const workflow = ctx.request.body
workflow._id = newid()
workflow.type = "workflow"
const response = await db.post(workflow)
workflow._rev = response.rev
ctx.status = 200
ctx.body = {
message: "Workflow created successfully",
workflow: {
...workflow,
...response,
},
}
}
exports.update = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
const workflow = ctx.request.body
const response = await db.put(workflow)
workflow._rev = response.rev
ctx.status = 200
ctx.body = {
message: `Workflow ${workflow._id} updated successfully.`,
workflow: {
...workflow,
_rev: response.rev,
_id: response.id,
},
}
}
exports.fetch = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
const response = await db.query(`database/by_type`, {
key: ["workflow"],
include_docs: true,
})
ctx.body = response.rows.map(row => row.doc)
}
exports.find = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
ctx.body = await db.get(ctx.params.id)
}
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

@ -16,7 +16,7 @@ const {
viewRoutes,
staticRoutes,
componentRoutes,
workflowRoutes,
automationRoutes,
accesslevelRoutes,
apiKeysRoutes,
} = require("./routes")
@ -84,8 +84,8 @@ router.use(userRoutes.allowedMethods())
router.use(instanceRoutes.routes())
router.use(instanceRoutes.allowedMethods())
router.use(workflowRoutes.routes())
router.use(workflowRoutes.allowedMethods())
router.use(automationRoutes.routes())
router.use(automationRoutes.allowedMethods())
router.use(deployRoutes.routes())
router.use(deployRoutes.allowedMethods())

View file

@ -1,5 +1,5 @@
const Router = require("@koa/router")
const controller = require("../controllers/workflow")
const controller = require("../controllers/automation")
const authorized = require("../../middleware/authorized")
const joiValidator = require("../../middleware/joi-validator")
const { BUILDER } = require("../../utilities/accessLevels")
@ -23,55 +23,57 @@ 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("automation").required(),
definition: Joi.object({
steps: Joi.array().required().items(generateStepSchema(["ACTION", "LOGIC"])),
trigger: generateStepSchema(["TRIGGER"]),
}).required().unknown(true),
}).unknown(true))
}
router
.get(
"/api/workflows/trigger/list",
"/api/automations/trigger/list",
authorized(BUILDER),
controller.getTriggerList
)
.get(
"/api/workflows/action/list",
"/api/automations/action/list",
authorized(BUILDER),
controller.getActionList
)
.get(
"/api/workflows/logic/list",
"/api/automations/logic/list",
authorized(BUILDER),
controller.getLogicList
)
.get(
"/api/workflows/definitions/list",
"/api/automations/definitions/list",
authorized(BUILDER),
controller.getDefinitionList
)
.get("/api/workflows", authorized(BUILDER), controller.fetch)
.get("/api/workflows/:id", authorized(BUILDER), controller.find)
.get("/api/automations", authorized(BUILDER), controller.fetch)
.get("/api/automations/:id", authorized(BUILDER), controller.find)
.put(
"/api/workflows",
"/api/automations",
authorized(BUILDER),
workflowValidator,
generateValidator(true),
controller.update
)
.post(
"/api/workflows",
"/api/automations",
authorized(BUILDER),
workflowValidator,
generateValidator(false),
controller.create
)
.post("/api/workflows/:id/trigger", controller.trigger)
.delete("/api/workflows/:id/:rev", authorized(BUILDER), controller.destroy)
.post("/api/automations/:id/trigger", controller.trigger)
.delete("/api/automations/:id/:rev", authorized(BUILDER), controller.destroy)
module.exports = router

View file

@ -9,7 +9,7 @@ const recordRoutes = require("./record")
const viewRoutes = require("./view")
const staticRoutes = require("./static")
const componentRoutes = require("./component")
const workflowRoutes = require("./workflow")
const automationRoutes = require("./automation")
const accesslevelRoutes = require("./accesslevel")
const deployRoutes = require("./deploy")
const apiKeysRoutes = require("./apikeys")
@ -27,7 +27,7 @@ module.exports = {
viewRoutes,
staticRoutes,
componentRoutes,
workflowRoutes,
automationRoutes,
accesslevelRoutes,
apiKeysRoutes,
}

View file

@ -0,0 +1,260 @@
const {
createClientDatabase,
createApplication,
createInstance,
createModel,
getAllFromModel,
defaultHeaders,
supertest,
insertDocument,
destroyDocument,
builderEndpointShouldBlockNormalUsers
} = require("./couchTestUtils")
const { delay } = require("./testUtils")
const MAX_RETRIES = 4
const TEST_AUTOMATION = {
_id: "Test Automation",
name: "My Automation",
pageId: "123123123",
screenId: "kasdkfldsafkl",
live: true,
uiTree: {
},
definition: {
trigger: {},
steps: [
],
},
type: "automation",
}
let ACTION_DEFINITIONS = {}
let TRIGGER_DEFINITIONS = {}
let LOGIC_DEFINITIONS = {}
describe("/automations", () => {
let request
let server
let app
let instance
let automation
let automationId
beforeAll(async () => {
({ request, server } = await supertest())
await createClientDatabase(request)
app = await createApplication(request)
})
beforeEach(async () => {
if (automation) await destroyDocument(automation.id)
instance = await createInstance(request, app._id)
})
afterAll(async () => {
server.close()
})
const createAutomation = async () => {
automation = await insertDocument(instance._id, {
type: "automation",
...TEST_AUTOMATION
})
automation = { ...automation, ...TEST_AUTOMATION }
}
const triggerWorkflow = async (automationId) => {
return await request
.post(`/api/automations/${automationId}/trigger`)
.send({ name: "Test", description: "TEST" })
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
}
describe("get definitions", () => {
it("returns a list of definitions for actions", async () => {
const res = await request
.get(`/api/automations/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/automations/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/automations/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/automations/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 automation 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_AUTOMATION.definition.steps.push(saveAction)
TEST_AUTOMATION.definition.trigger = trigger
})
it("returns a success message when the automation is successfully created", async () => {
const res = await request
.post(`/api/automations`)
.set(defaultHeaders(app._id, instance._id))
.send(TEST_AUTOMATION)
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.message).toEqual("Automation created successfully")
expect(res.body.automation.name).toEqual("My Automation")
expect(res.body.automation._id).not.toEqual(null)
automationId = res.body.automation._id
})
it("should apply authorization to endpoint", async () => {
await builderEndpointShouldBlockNormalUsers({
request,
method: "POST",
url: `/api/automations`,
instanceId: instance._id,
appId: app._id,
body: TEST_AUTOMATION
})
})
})
describe("trigger", () => {
it("trigger the automation successfully", async () => {
let model = await createModel(request, app._id, instance._id)
TEST_AUTOMATION.definition.trigger.inputs.modelId = model._id
TEST_AUTOMATION.definition.steps[0].inputs.record.modelId = model._id
await createAutomation()
// this looks a bit mad but we don't actually have a way to wait for a response from the automation to
// know that it has finished all of its actions - this is currently the best way
// also when this runs in CI it is very temper-mental so for now trying to make run stable by repeating until it works
// TODO: update when workflow logs are a thing
for (let tries = 0; tries < MAX_RETRIES; tries++) {
const res = await triggerWorkflow(automation._id)
expect(res.body.message).toEqual(`Automation ${automation._id} has been triggered.`)
expect(res.body.automation.name).toEqual(TEST_AUTOMATION.name)
await delay(500)
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
}
}
throw "Failed to find the records"
})
})
describe("update", () => {
it("updates a automations data", async () => {
await createAutomation()
automation._id = automation.id
automation._rev = automation.rev
automation.name = "Updated Name"
automation.type = "automation"
const res = await request
.put(`/api/automations`)
.set(defaultHeaders(app._id, instance._id))
.send(automation)
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.message).toEqual("Automation Test Automation updated successfully.")
expect(res.body.automation.name).toEqual("Updated Name")
})
})
describe("fetch", () => {
it("return all the automations for an instance", async () => {
await createAutomation()
const res = await request
.get(`/api/automations`)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
expect(res.body[0]).toEqual(expect.objectContaining(TEST_AUTOMATION))
})
it("should apply authorization to endpoint", async () => {
await builderEndpointShouldBlockNormalUsers({
request,
method: "GET",
url: `/api/automations`,
instanceId: instance._id,
appId: app._id,
})
})
})
describe("destroy", () => {
it("deletes a automation by its ID", async () => {
await createAutomation()
const res = await request
.delete(`/api/automations/${automation.id}/${automation.rev}`)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.id).toEqual(TEST_AUTOMATION._id)
})
it("should apply authorization to endpoint", async () => {
await createAutomation()
await builderEndpointShouldBlockNormalUsers({
request,
method: "DELETE",
url: `/api/automations/${automation.id}/${automation._rev}`,
instanceId: instance._id,
appId: app._id,
})
})
})
})

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

@ -1,153 +0,0 @@
const {
createClientDatabase,
createApplication,
createInstance,
defaultHeaders,
supertest,
insertDocument,
destroyDocument,
builderEndpointShouldBlockNormalUsers
} = require("./couchTestUtils")
const TEST_WORKFLOW = {
_id: "Test Workflow",
name: "My Workflow",
pageId: "123123123",
screenId: "kasdkfldsafkl",
live: true,
uiTree: {
},
definition: {
triggers: [
],
next: {
stepId: "abc123",
type: "SERVER",
conditions: {
}
}
}
}
describe("/workflows", () => {
let request
let server
let app
let instance
let workflow
beforeAll(async () => {
({ request, server } = await supertest())
await createClientDatabase(request)
app = await createApplication(request)
})
beforeEach(async () => {
instance = await createInstance(request, app._id)
if (workflow) await destroyDocument(workflow.id);
})
afterAll(async () => {
server.close()
})
const createWorkflow = async () => {
workflow = await insertDocument(instance._id, {
type: "workflow",
...TEST_WORKFLOW
});
}
describe("create", () => {
it("returns a success message when the workflow is successfully created", async () => {
const res = await request
.post(`/api/workflows`)
.set(defaultHeaders(app._id, instance._id))
.send(TEST_WORKFLOW)
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.message).toEqual("Workflow created successfully");
expect(res.body.workflow.name).toEqual("My Workflow");
})
it("should apply authorization to endpoint", async () => {
await builderEndpointShouldBlockNormalUsers({
request,
method: "POST",
url: `/api/workflows`,
instanceId: instance._id,
appId: app._id,
body: TEST_WORKFLOW
})
})
})
describe("update", () => {
it("updates a workflows data", async () => {
await createWorkflow();
workflow._id = workflow.id
workflow._rev = workflow.rev
workflow.name = "Updated Name";
const res = await request
.put(`/api/workflows`)
.set(defaultHeaders(app._id, instance._id))
.send(workflow)
.expect('Content-Type', /json/)
.expect(200)
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();
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));
})
it("should apply authorization to endpoint", async () => {
await builderEndpointShouldBlockNormalUsers({
request,
method: "GET",
url: `/api/workflows`,
instanceId: instance._id,
appId: app._id,
})
})
})
describe("destroy", () => {
it("deletes a workflow by its ID", async () => {
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);
})
it("should apply authorization to endpoint", async () => {
await createWorkflow();
await builderEndpointShouldBlockNormalUsers({
request,
method: "DELETE",
url: `/api/workflows/${workflow.id}/${workflow._rev}`,
instanceId: instance._id,
appId: app._id,
})
})
})
});

View file

@ -6,7 +6,7 @@ const http = require("http")
const api = require("./api")
const env = require("./environment")
const eventEmitter = require("./events")
const workflows = require("./workflows/index")
const automations = require("./automations/index")
const Sentry = require("@sentry/node")
const app = new Koa()
@ -50,5 +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()
automations.init()
})

View file

@ -0,0 +1,91 @@
const sendEmail = require("./steps/sendEmail")
const saveRecord = require("./steps/saveRecord")
const updateRecord = require("./steps/updateRecord")
const deleteRecord = require("./steps/deleteRecord")
const createUser = require("./steps/createUser")
const environment = require("../environment")
const download = require("download")
const fetch = require("node-fetch")
const path = require("path")
const os = require("os")
const fs = require("fs")
const Sentry = require("@sentry/node")
const DEFAULT_BUCKET =
"https://prod-budi-automations.s3-eu-west-1.amazonaws.com"
const DEFAULT_DIRECTORY = ".budibase-automations"
const AUTOMATION_MANIFEST = "manifest.json"
const BUILTIN_ACTIONS = {
SEND_EMAIL: sendEmail.run,
SAVE_RECORD: saveRecord.run,
UPDATE_RECORD: updateRecord.run,
DELETE_RECORD: deleteRecord.run,
CREATE_USER: createUser.run,
}
const BUILTIN_DEFINITIONS = {
SEND_EMAIL: sendEmail.definition,
SAVE_RECORD: saveRecord.definition,
UPDATE_RECORD: updateRecord.definition,
DELETE_RECORD: deleteRecord.definition,
CREATE_USER: createUser.definition,
}
let AUTOMATION_BUCKET = environment.AUTOMATION_BUCKET
let AUTOMATION_DIRECTORY = environment.AUTOMATION_DIRECTORY
let MANIFEST = null
function buildBundleName(pkgName, version) {
return `${pkgName}@${version}.min.js`
}
async function downloadPackage(name, version, bundleName) {
await download(
`${AUTOMATION_BUCKET}/${name}/${version}/${bundleName}`,
AUTOMATION_DIRECTORY
)
return require(path.join(AUTOMATION_DIRECTORY, bundleName))
}
module.exports.getAction = async function(actionName) {
if (BUILTIN_ACTIONS[actionName] != null) {
return BUILTIN_ACTIONS[actionName]
}
// env setup to get async packages
if (!MANIFEST || !MANIFEST.packages || !MANIFEST.packages[actionName]) {
return null
}
const pkg = MANIFEST.packages[actionName]
const bundleName = buildBundleName(pkg.stepId, pkg.version)
try {
return require(path.join(AUTOMATION_DIRECTORY, bundleName))
} catch (err) {
return downloadPackage(pkg.stepId, pkg.version, bundleName)
}
}
module.exports.init = async function() {
// set defaults
if (!AUTOMATION_DIRECTORY) {
AUTOMATION_DIRECTORY = path.join(os.homedir(), DEFAULT_DIRECTORY)
}
if (!AUTOMATION_BUCKET) {
AUTOMATION_BUCKET = DEFAULT_BUCKET
}
if (!fs.existsSync(AUTOMATION_DIRECTORY)) {
fs.mkdirSync(AUTOMATION_DIRECTORY, { recursive: true })
}
// env setup to get async packages
try {
let response = await fetch(`${AUTOMATION_BUCKET}/${AUTOMATION_MANIFEST}`)
MANIFEST = await response.json()
module.exports.DEFINITIONS =
MANIFEST && MANIFEST.packages
? Object.assign(MANIFEST.packages, BUILTIN_DEFINITIONS)
: BUILTIN_DEFINITIONS
} catch (err) {
Sentry.captureException(err)
}
}
module.exports.DEFINITIONS = BUILTIN_DEFINITIONS
module.exports.BUILTIN_DEFINITIONS = BUILTIN_DEFINITIONS

View file

@ -0,0 +1,116 @@
const CouchDB = require("../db")
/**
* When running mustache statements to execute on the context of the automation it possible user's may input mustache
* in a few different forms, some of which are invalid but are logically valid. An example of this would be the mustache
* statement "{{steps[0].revision}}" here it is obvious the user is attempting to access an array or object using array
* like operators. These are not supported by Mustache and therefore the statement will fail. This function will clean up
* the mustache statement so it instead reads as "{{steps.0.revision}}" which is valid and will work. It may also be expanded
* to include any other mustache statement cleanup that has been deemed necessary for the system.
*
* @param {string} string The string which *may* contain mustache statements, it is OK if it does not contain any.
* @returns {string} The string that was input with cleaned up mustache statements as required.
*/
module.exports.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
}
/**
* When values are input to the system generally they will be of type string as this is required for mustache. This can
* generate some odd scenarios as the Schema of the automation requires a number but the builder might supply a string
* with mustache syntax to get the number from the rest of the context. To support this the server has to make sure that
* the post mustache statement can be cast into the correct type, this function does this for numbers and booleans.
*
* @param {object} inputs An object of inputs, please note this will not recurse down into any objects within, it simply
* cleanses the top level inputs, however it can be used by recursively calling it deeper into the object structures if
* the schema is known.
* @param {object} schema The defined schema of the inputs, in the form of JSON schema. The schema definition of an
* automation is the likely use case of this, however validate.js syntax can be converted closely enough to use this by
* wrapping the schema properties in a top level "properties" object.
* @returns {object} The inputs object which has had all the various types supported by this function converted to their
* primitive types.
*/
module.exports.cleanInputValues = (inputs, schema) => {
if (schema == null) {
return inputs
}
for (let inputKey of Object.keys(inputs)) {
let input = inputs[inputKey]
if (typeof input !== "string") {
continue
}
let propSchema = schema.properties[inputKey]
if (!propSchema) {
continue
}
if (propSchema.type === "boolean") {
let lcInput = input.toLowerCase()
if (lcInput === "true") {
inputs[inputKey] = true
}
if (lcInput === "false") {
inputs[inputKey] = false
}
}
if (propSchema.type === "number") {
let floatInput = parseFloat(input)
if (!isNaN(floatInput)) {
inputs[inputKey] = floatInput
}
}
}
return inputs
}
/**
* Given a record input like a save or update record we need to clean the inputs against a schema that is not part of
* the automation but is instead part of the Table/Model. This function will get the model schema and use it to instead
* perform the cleanInputValues function on the input record.
*
* @param {string} instanceId The instance which the Table/Model is contained under.
* @param {string} modelId The ID of the Table/Model which the schema is to be retrieved for.
* @param {object} record The input record structure which requires clean-up after having been through mustache statements.
* @returns {Promise<Object>} The cleaned up records object, will should now have all the required primitive types.
*/
module.exports.cleanUpRecord = async (instanceId, modelId, record) => {
const db = new CouchDB(instanceId)
const model = await db.get(modelId)
return module.exports.cleanInputValues(record, { properties: model.schema })
}
/**
* A utility function for the cleanUpRecord, which can be used if only the record ID is known (not the model ID) to clean
* up a record after mustache statements have been replaced. This is specifically useful for the update record action.
*
* @param {string} instanceId The instance which the Table/Model is contained under.
* @param {string} recordId The ID of the record from which the modelId will be extracted, to get the Table/Model schema.
* @param {object} record The input record structure which requires clean-up after having been through mustache statements.
* @returns {Promise<Object>} The cleaned up records object, which will now have all the required primitive types.
*/
module.exports.cleanUpRecordById = async (instanceId, recordId, record) => {
const db = new CouchDB(instanceId)
const foundRecord = await db.get(recordId)
return module.exports.cleanUpRecord(instanceId, foundRecord.modelId, record)
}

View file

@ -1,4 +1,5 @@
const triggers = require("./triggers")
const actions = require("./actions")
const environment = require("../environment")
const workerFarm = require("worker-farm")
const singleThread = require("./thread")
@ -21,11 +22,13 @@ function runWorker(job) {
* 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)
}
actions.init().then(() => {
triggers.automationQueue.process(async job => {
if (environment.BUDIBASE_ENVIRONMENT === "PRODUCTION") {
await runWorker(job)
} else {
await singleThread(job)
}
})
})
}

View file

@ -0,0 +1,20 @@
let filter = require("./steps/filter")
let delay = require("./steps/delay")
let BUILTIN_LOGIC = {
DELAY: delay.run,
FILTER: filter.run,
}
let BUILTIN_DEFINITIONS = {
DELAY: delay.definition,
FILTER: filter.definition,
}
module.exports.getLogic = function(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 automation 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 automations 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,96 @@
const recordController = require("../../api/controllers/record")
const automationUtils = require("../automationUtils")
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
}
inputs.record = await automationUtils.cleanUpRecord(
instanceId,
inputs.record.modelId,
inputs.record
)
// 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

@ -0,0 +1,100 @@
const recordController = require("../../api/controllers/record")
const automationUtils = require("../automationUtils")
module.exports.definition = {
name: "Update Record",
tagline: "Update a {{inputs.enriched.model.name}} record",
icon: "ri-refresh-fill",
description: "Update a record to your database",
type: "ACTION",
stepId: "UPDATE_RECORD",
inputs: {},
schema: {
inputs: {
properties: {
record: {
type: "object",
customType: "record",
title: "Record",
},
recordId: {
type: "string",
title: "Record ID",
},
},
required: ["record", "recordId"],
},
outputs: {
properties: {
record: {
type: "object",
customType: "record",
description: "The updated 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 updated record",
},
revision: {
type: "string",
description: "The revision of the updated record",
},
},
required: ["success", "id", "revision"],
},
},
}
module.exports.run = async function({ inputs, instanceId }) {
if (inputs.recordId == null || inputs.record == null) {
return
}
inputs.record = await automationUtils.cleanUpRecordById(
instanceId,
inputs.recordId,
inputs.record
)
// clear any falsy properties so that they aren't updated
for (let propKey of Object.keys(inputs.record)) {
if (!inputs.record[propKey] || inputs.record[propKey] === "") {
delete inputs.record[propKey]
}
}
// have to clean up the record, remove the model from it
const ctx = {
params: {
id: inputs.recordId,
},
request: {
body: inputs.record,
},
user: { instanceId },
}
try {
await recordController.patch(ctx)
return {
record: ctx.body,
response: ctx.message,
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,93 @@
const mustache = require("mustache")
const actions = require("./actions")
const logic = require("./logic")
const automationUtils = require("./automationUtils")
const FILTER_STEP_ID = logic.BUILTIN_DEFINITIONS.FILTER.stepId
function recurseMustache(inputs, context) {
for (let key of Object.keys(inputs)) {
let val = inputs[key]
if (typeof val === "string") {
val = automationUtils.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 automation orchestrator is a class responsible for executing automations.
* It handles the context of the automation and makes sure each step gets the correct
* inputs and handles any outputs.
*/
class Orchestrator {
constructor(automation, 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._automation = automation
}
async getStepFunctionality(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 automation step by name ${stepId}`
}
return step
}
async execute() {
let automation = this._automation
for (let step of automation.definition.steps) {
let stepFn = await this.getStepFunctionality(step.type, step.stepId)
step.inputs = recurseMustache(step.inputs, this._context)
step.inputs = automationUtils.cleanInputValues(
step.inputs,
step.schema.inputs
)
// instanceId is always passed
try {
const outputs = await stepFn({
inputs: step.inputs,
instanceId: this._instanceId,
})
if (step.stepId === FILTER_STEP_ID && !outputs.success) {
break
}
this._context.steps.push(outputs)
} catch (err) {
console.error(`Automation error - ${step.stepId} - ${err}`)
}
}
}
}
// callback is required for worker-farm to state that the worker thread has completed
module.exports = async (job, cb = null) => {
try {
const automationOrchestrator = new Orchestrator(
job.data.automation,
job.data.event
)
await automationOrchestrator.execute()
if (cb) {
cb()
}
} catch (err) {
if (cb) {
cb(err)
}
}
}

View file

@ -0,0 +1,178 @@
const CouchDB = require("../db")
const emitter = require("../events/index")
const InMemoryQueue = require("./queue/inMemoryQueue")
let automationQueue = new InMemoryQueue()
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",
},
id: {
type: "string",
description: "Record ID - can be used for updating",
},
revision: {
type: "string",
description: "Revision of record",
},
},
required: ["record", "id"],
},
},
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", "id"],
},
},
type: "TRIGGER",
},
}
async function queueRelevantRecordAutomations(event, eventType) {
if (event.instanceId == null) {
throw `No instanceId specified for ${eventType} - check event emitters.`
}
const db = new CouchDB(event.instanceId)
const automationsToTrigger = await db.query(
"database/by_automation_trigger",
{
key: [eventType],
include_docs: true,
}
)
const automations = automationsToTrigger.rows.map(wf => wf.doc)
for (let automation of automations) {
let automationDef = automation.definition
let automationTrigger = automationDef ? automationDef.trigger : {}
if (
!automation.live ||
!automationTrigger.inputs ||
automationTrigger.inputs.modelId !== event.record.modelId
) {
continue
}
automationQueue.add({ automation, event })
}
}
emitter.on("record:save", async function(event) {
if (!event || !event.record || !event.record.modelId) {
return
}
await queueRelevantRecordAutomations(event, "record:save")
})
emitter.on("record:delete", async function(event) {
if (!event || !event.record || !event.record.modelId) {
return
}
await queueRelevantRecordAutomations(event, "record:delete")
})
async function fillRecordOutput(automation, params) {
let triggerSchema = automation.definition.trigger
let modelId = triggerSchema.inputs.modelId
const db = new CouchDB(params.instanceId)
try {
let model = await db.get(modelId)
let record = {}
for (let schemaKey of Object.keys(model.schema)) {
if (params[schemaKey] != null) {
continue
}
let propSchema = model.schema[schemaKey]
switch (propSchema.constraints.type) {
case "string":
record[schemaKey] = FAKE_STRING
break
case "boolean":
record[schemaKey] = FAKE_BOOL
break
case "number":
record[schemaKey] = FAKE_NUMBER
break
case "datetime":
record[schemaKey] = FAKE_DATETIME
break
}
}
params.record = record
} catch (err) {
throw "Failed to find model for trigger"
}
return params
}
module.exports.externalTrigger = async function(automation, params) {
// TODO: replace this with allowing user in builder to input values in future
if (
automation.definition != null &&
automation.definition.trigger != null &&
automation.definition.trigger.inputs.modelId != null
) {
params = await fillRecordOutput(automation, params)
}
automationQueue.add({ automation, event: params })
}
module.exports.automationQueue = automationQueue
module.exports.BUILTIN_DEFINITIONS = BUILTIN_DEFINITIONS

View file

@ -7,5 +7,8 @@ module.exports = {
COUCH_DB_URL: process.env.COUCH_DB_URL,
SALT_ROUNDS: process.env.SALT_ROUNDS,
LOGGER: process.env.LOGGER,
AUTOMATION_DIRECTORY: process.env.AUTOMATION_DIRECTORY,
AUTOMATION_BUCKET: process.env.AUTOMATION_BUCKET,
BUDIBASE_ENVIRONMENT: process.env.BUDIBASE_ENVIRONMENT,
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
}

View file

@ -2,7 +2,7 @@ const EventEmitter = require("events").EventEmitter
/**
* 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
* automations (what it was for originally) - having a central emitter will be useful in the
* future.
*/

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_AUTOMATION = "execute-automation"
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 automationController = require("../api/controllers/automation")
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 fetchAutomationsCtx = {
user: {
instanceId,
},
}
await automationController.fetch(fetchAutomationsCtx)
const automations = fetchAutomationsCtx.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 executeAutomationPermissions = automations.map(w => ({
itemId: w._id,
name: accessLevels.EXECUTE_AUTOMATION,
}))
return [
...readModelPermissions,
...writeModelPermissions,
...viewPermissions,
...executeAutomationPermissions,
{ name: accessLevels.LIST_USERS },
]
}
module.exports.generateAdminPermissions = generateAdminPermissions
module.exports.generatePowerUserPermissions = generatePowerUserPermissions

View file

@ -1,112 +0,0 @@
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
}

Some files were not shown because too many files have changed in this diff Show more