merge with master
This commit is contained in:
commit
e609414f57
|
@ -4,8 +4,6 @@ on:
|
|||
# Trigger the workflow on push with tags,
|
||||
# but only for the master branch
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*"
|
||||
|
|
|
@ -21,12 +21,15 @@
|
|||
"publishdev": "lerna run publishdev",
|
||||
"publishnpm": "yarn build && lerna publish --force-publish",
|
||||
"clean": "lerna clean",
|
||||
"dev": "node ./scripts/symlinkDev.js && lerna run --parallel --stream dev:builder",
|
||||
"dev": "node ./scripts/symlinkDev.js && lerna run --parallel dev:builder",
|
||||
"test": "lerna run test",
|
||||
"lint": "eslint packages",
|
||||
"lint:fix": "eslint --fix packages",
|
||||
"format": "prettier --write \"{,!(node_modules)/**/}*.{js,jsx,svelte}\"",
|
||||
"test:e2e": "lerna run cy:test",
|
||||
"test:e2e:ci": "lerna run cy:ci"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome": "^1.1.8"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,4 +5,5 @@ package-lock.json
|
|||
release/
|
||||
dist/
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
routify
|
||||
|
|
|
@ -1,46 +1,52 @@
|
|||
context('Create a workflow', () => {
|
||||
context("Create a workflow", () => {
|
||||
before(() => {
|
||||
cy.server()
|
||||
cy.visit("localhost:4001/_builder")
|
||||
|
||||
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!"
|
||||
)
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
// 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()
|
||||
|
||||
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")
|
||||
|
||||
// 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")
|
||||
|
||||
// Create action
|
||||
cy.get('[data-cy=SAVE_RECORD]').click()
|
||||
cy.get('.container input').first().type('goodboy')
|
||||
cy.get('.container input').eq(1).type('11')
|
||||
// Save
|
||||
cy.contains("Save Workflow").click()
|
||||
|
||||
// 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")
|
||||
})
|
||||
|
||||
// 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()
|
||||
|
||||
it('should add record when a new record is added', () => {
|
||||
cy.contains('backend').click()
|
||||
|
||||
cy.addRecord(["Rover", 15])
|
||||
cy.reload()
|
||||
cy.contains('goodboy').should('have.text', 'goodboy')
|
||||
|
||||
})
|
||||
})
|
||||
cy.addRecord(["Rover", 15])
|
||||
cy.reload()
|
||||
cy.contains("goodboy").should("have.text", "goodboy")
|
||||
})
|
||||
})
|
||||
|
|
Binary file not shown.
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/builder",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
@ -63,16 +63,16 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@budibase/bbui": "^1.32.0",
|
||||
"@budibase/client": "^0.1.19",
|
||||
"@budibase/bbui": "^1.33.0",
|
||||
"@budibase/client": "^0.1.21",
|
||||
"@budibase/colorpicker": "^1.0.1",
|
||||
"@fortawesome/fontawesome-free": "^5.14.0",
|
||||
"@sentry/browser": "5.19.1",
|
||||
"@svelteschool/svelte-forms": "^0.7.0",
|
||||
"britecharts": "^2.16.0",
|
||||
"d3-selection": "^1.4.1",
|
||||
"deepmerge": "^4.2.2",
|
||||
"fast-sort": "^2.2.0",
|
||||
"feather-icons": "^4.21.0",
|
||||
"lodash": "^4.17.13",
|
||||
"mustache": "^4.0.1",
|
||||
"posthog-js": "1.3.1",
|
||||
|
|
|
@ -234,7 +234,7 @@ export default {
|
|||
|
||||
// Watch the `dist` directory and refresh the
|
||||
// browser on changes when not in production
|
||||
!production && livereload(outputpath),
|
||||
!production && livereload({ watch: outputpath, delay: 500 }),
|
||||
|
||||
// If we're building for production (npm run build
|
||||
// instead of npm run dev), minify
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
// Array.flat needs polyfilled in < Node 11
|
||||
if (!Array.prototype.flat) {
|
||||
Object.defineProperty(Array.prototype, "flat", {
|
||||
configurable: true,
|
||||
|
|
|
@ -37,7 +37,9 @@ export default function({ componentInstanceId, screen, components, models }) {
|
|||
.filter(isInstanceInSharedContext(walkResult))
|
||||
.map(componentInstanceToBindable(walkResult)),
|
||||
|
||||
...walkResult.target._contexts.map(contextToBindables(walkResult)).flat(),
|
||||
...walkResult.target._contexts
|
||||
.map(contextToBindables(models, walkResult))
|
||||
.flat(),
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -69,17 +71,31 @@ const componentInstanceToBindable = walkResult => i => {
|
|||
}
|
||||
}
|
||||
|
||||
const contextToBindables = walkResult => context => {
|
||||
const contextToBindables = (models, walkResult) => context => {
|
||||
const contextParentPath = getParentPath(walkResult, context)
|
||||
|
||||
return Object.keys(context.model.schema).map(k => ({
|
||||
const newBindable = key => ({
|
||||
type: "context",
|
||||
instance: context.instance,
|
||||
// how the binding expression persists, and is used in the app at runtime
|
||||
runtimeBinding: `${contextParentPath}data.${k}`,
|
||||
runtimeBinding: `${contextParentPath}data.${key}`,
|
||||
// how the binding exressions looks to the user of the builder
|
||||
readableBinding: `${context.instance._instanceName}.${context.model.name}.${k}`,
|
||||
}))
|
||||
readableBinding: `${context.instance._instanceName}.${context.model.label}.${key}`,
|
||||
})
|
||||
|
||||
// see ModelViewSelect.svelte for the format of context.model
|
||||
// ... this allows us to bind to Model scheams, or View schemas
|
||||
const model = models.find(m => m._id === context.model.modelId)
|
||||
const schema = context.model.isModel
|
||||
? model.schema
|
||||
: model.views[context.model.name].schema
|
||||
|
||||
return (
|
||||
Object.keys(schema)
|
||||
.map(newBindable)
|
||||
// add _id and _rev fields - not part of schema, but always valid
|
||||
.concat([newBindable("_id"), newBindable("_rev")])
|
||||
)
|
||||
}
|
||||
|
||||
const getParentPath = (walkResult, context) => {
|
||||
|
@ -135,7 +151,7 @@ const walk = ({ instance, targetId, components, models, result }) => {
|
|||
if (contextualInstance) {
|
||||
// add to currentContexts (ancestory of context)
|
||||
// before walking children
|
||||
const model = models.find(m => m._id === instance[component.context])
|
||||
const model = instance[component.context]
|
||||
result.currentContexts.push({ instance, model })
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
export const CAPTURE_VAR_INSIDE_MUSTACHE = /{{([^}]+)}}/g
|
||||
|
||||
export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
|
||||
// Find all instances of mustasche
|
||||
const boundValues = textWithBindings.match(CAPTURE_VAR_INSIDE_MUSTACHE)
|
||||
|
||||
let result = textWithBindings
|
||||
// Replace readableBindings with runtimeBindings
|
||||
boundValues &&
|
||||
boundValues.forEach(boundValue => {
|
||||
const binding = bindableProperties.find(({ readableBinding }) => {
|
||||
return boundValue === `{{ ${readableBinding} }}`
|
||||
})
|
||||
if (binding) {
|
||||
result = textWithBindings.replace(
|
||||
boundValue,
|
||||
`{{ ${binding.runtimeBinding} }}`
|
||||
)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export function runtimeToReadableBinding(bindableProperties, textWithBindings) {
|
||||
let temp = textWithBindings
|
||||
const boundValues =
|
||||
(typeof textWithBindings === "string" &&
|
||||
textWithBindings.match(CAPTURE_VAR_INSIDE_MUSTACHE)) ||
|
||||
[]
|
||||
|
||||
// Replace runtimeBindings with readableBindings:
|
||||
boundValues.forEach(v => {
|
||||
const binding = bindableProperties.find(({ runtimeBinding }) => {
|
||||
return v === `{{ ${runtimeBinding} }}`
|
||||
})
|
||||
if (binding) {
|
||||
temp = temp.replace(v, `{{ ${binding.readableBinding} }}`)
|
||||
}
|
||||
})
|
||||
|
||||
return temp
|
||||
}
|
|
@ -1,5 +1,3 @@
|
|||
import mustache from "mustache"
|
||||
import blockDefinitions from "components/workflow/WorkflowPanel/blockDefinitions"
|
||||
import { generate } from "shortid"
|
||||
|
||||
/**
|
||||
|
@ -18,27 +16,31 @@ export default class Workflow {
|
|||
addBlock(block) {
|
||||
// Make sure to add trigger if doesn't exist
|
||||
if (!this.hasTrigger() && block.type === "TRIGGER") {
|
||||
this.workflow.definition.trigger = { id: generate(), ...block }
|
||||
return
|
||||
const trigger = { id: generate(), ...block }
|
||||
this.workflow.definition.trigger = trigger
|
||||
return trigger
|
||||
}
|
||||
|
||||
this.workflow.definition.steps.push({
|
||||
id: generate(),
|
||||
...block,
|
||||
})
|
||||
const newBlock = { id: generate(), ...block }
|
||||
this.workflow.definition.steps = [
|
||||
...this.workflow.definition.steps,
|
||||
newBlock,
|
||||
]
|
||||
return newBlock
|
||||
}
|
||||
|
||||
updateBlock(updatedBlock, id) {
|
||||
const { steps, trigger } = this.workflow.definition
|
||||
|
||||
if (trigger && trigger.id === id) {
|
||||
this.workflow.definition.trigger = null
|
||||
this.workflow.definition.trigger = updatedBlock
|
||||
return
|
||||
}
|
||||
|
||||
const stepIdx = steps.findIndex(step => step.id === id)
|
||||
if (stepIdx < 0) throw new Error("Block not found.")
|
||||
steps.splice(stepIdx, 1, updatedBlock)
|
||||
this.workflow.definition.steps = steps
|
||||
}
|
||||
|
||||
deleteBlock(id) {
|
||||
|
@ -52,44 +54,6 @@ export default class Workflow {
|
|||
const stepIdx = steps.findIndex(step => step.id === id)
|
||||
if (stepIdx < 0) throw new Error("Block not found.")
|
||||
steps.splice(stepIdx, 1)
|
||||
}
|
||||
|
||||
createUiTree() {
|
||||
if (!this.workflow.definition) return []
|
||||
return Workflow.buildUiTree(this.workflow.definition)
|
||||
}
|
||||
|
||||
static buildUiTree(definition) {
|
||||
const steps = []
|
||||
if (definition.trigger) steps.push(definition.trigger)
|
||||
|
||||
return [...steps, ...definition.steps].map(step => {
|
||||
// The client side display definition for the block
|
||||
const definition = blockDefinitions[step.type][step.actionId]
|
||||
if (!definition) {
|
||||
throw new Error(
|
||||
`No block definition exists for the chosen block. Check there's an entry in the block definitions for ${step.actionId}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!definition.params) {
|
||||
throw new Error(
|
||||
`Blocks should always have parameters. Ensure that the block definition is correct for ${step.actionId}`
|
||||
)
|
||||
}
|
||||
|
||||
const tagline = definition.tagline || ""
|
||||
const args = step.args || {}
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
params: step.params,
|
||||
args,
|
||||
heading: step.actionId,
|
||||
body: mustache.render(tagline, args),
|
||||
name: definition.name,
|
||||
}
|
||||
})
|
||||
this.workflow.definition.steps = steps
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,22 @@
|
|||
import { writable } from "svelte/store"
|
||||
import api from "../../api"
|
||||
import Workflow from "./Workflow"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
|
||||
const workflowActions = store => ({
|
||||
fetch: async () => {
|
||||
const WORKFLOWS_URL = `/api/workflows`
|
||||
const workflowResponse = await api.get(WORKFLOWS_URL)
|
||||
const json = await workflowResponse.json()
|
||||
const responses = await Promise.all([
|
||||
api.get(`/api/workflows`),
|
||||
api.get(`/api/workflows/definitions/list`),
|
||||
])
|
||||
const jsonResponses = await Promise.all(responses.map(x => x.json()))
|
||||
store.update(state => {
|
||||
state.workflows = json
|
||||
state.workflows = jsonResponses[0]
|
||||
state.blockDefinitions = {
|
||||
TRIGGER: jsonResponses[1].trigger,
|
||||
ACTION: jsonResponses[1].action,
|
||||
LOGIC: jsonResponses[1].logic,
|
||||
}
|
||||
return state
|
||||
})
|
||||
},
|
||||
|
@ -23,8 +31,8 @@ const workflowActions = store => ({
|
|||
const response = await api.post(CREATE_WORKFLOW_URL, workflow)
|
||||
const json = await response.json()
|
||||
store.update(state => {
|
||||
state.workflows = state.workflows.concat(json.workflow)
|
||||
state.currentWorkflow = new Workflow(json.workflow)
|
||||
state.workflows = [...state.workflows, json.workflow]
|
||||
store.actions.select(json.workflow)
|
||||
return state
|
||||
})
|
||||
},
|
||||
|
@ -38,20 +46,7 @@ const workflowActions = store => ({
|
|||
)
|
||||
state.workflows.splice(existingIdx, 1, json.workflow)
|
||||
state.workflows = state.workflows
|
||||
state.currentWorkflow = new Workflow(json.workflow)
|
||||
return state
|
||||
})
|
||||
},
|
||||
update: async ({ workflow }) => {
|
||||
const UPDATE_WORKFLOW_URL = `/api/workflows`
|
||||
const response = await api.put(UPDATE_WORKFLOW_URL, workflow)
|
||||
const json = await response.json()
|
||||
store.update(state => {
|
||||
const existingIdx = state.workflows.findIndex(
|
||||
existing => existing._id === workflow._id
|
||||
)
|
||||
state.workflows.splice(existingIdx, 1, json.workflow)
|
||||
state.workflows = state.workflows
|
||||
store.actions.select(json.workflow)
|
||||
return state
|
||||
})
|
||||
},
|
||||
|
@ -66,28 +61,49 @@ const workflowActions = store => ({
|
|||
)
|
||||
state.workflows.splice(existingIdx, 1)
|
||||
state.workflows = state.workflows
|
||||
state.currentWorkflow = null
|
||||
state.selectedWorkflow = null
|
||||
state.selectedBlock = null
|
||||
return state
|
||||
})
|
||||
},
|
||||
trigger: async ({ workflow }) => {
|
||||
const { _id } = workflow
|
||||
const TRIGGER_WORKFLOW_URL = `/api/workflows/${_id}/trigger`
|
||||
return await api.post(TRIGGER_WORKFLOW_URL)
|
||||
},
|
||||
select: workflow => {
|
||||
store.update(state => {
|
||||
state.currentWorkflow = new Workflow(workflow)
|
||||
state.selectedWorkflowBlock = null
|
||||
state.selectedWorkflow = new Workflow(cloneDeep(workflow))
|
||||
state.selectedBlock = null
|
||||
return state
|
||||
})
|
||||
},
|
||||
addBlockToWorkflow: block => {
|
||||
store.update(state => {
|
||||
state.currentWorkflow.addBlock(block)
|
||||
state.selectedWorkflowBlock = block
|
||||
const newBlock = state.selectedWorkflow.addBlock(cloneDeep(block))
|
||||
state.selectedBlock = newBlock
|
||||
return state
|
||||
})
|
||||
},
|
||||
deleteWorkflowBlock: block => {
|
||||
store.update(state => {
|
||||
state.currentWorkflow.deleteBlock(block.id)
|
||||
state.selectedWorkflowBlock = null
|
||||
const idx = state.selectedWorkflow.workflow.definition.steps.findIndex(
|
||||
x => x.id === block.id
|
||||
)
|
||||
state.selectedWorkflow.deleteBlock(block.id)
|
||||
|
||||
// Select next closest step
|
||||
const steps = state.selectedWorkflow.workflow.definition.steps
|
||||
let nextSelectedBlock
|
||||
if (steps[idx] != null) {
|
||||
nextSelectedBlock = steps[idx]
|
||||
} else if (steps[idx - 1] != null) {
|
||||
nextSelectedBlock = steps[idx - 1]
|
||||
} else {
|
||||
nextSelectedBlock =
|
||||
state.selectedWorkflow.workflow.definition.trigger || null
|
||||
}
|
||||
state.selectedBlock = nextSelectedBlock
|
||||
return state
|
||||
})
|
||||
},
|
||||
|
@ -96,11 +112,14 @@ const workflowActions = store => ({
|
|||
export const getWorkflowStore = () => {
|
||||
const INITIAL_WORKFLOW_STATE = {
|
||||
workflows: [],
|
||||
blockDefinitions: {
|
||||
TRIGGER: [],
|
||||
ACTION: [],
|
||||
LOGIC: [],
|
||||
},
|
||||
selectedWorkflow: null,
|
||||
}
|
||||
|
||||
const store = writable(INITIAL_WORKFLOW_STATE)
|
||||
|
||||
store.actions = workflowActions(store)
|
||||
|
||||
return store
|
||||
}
|
||||
|
|
|
@ -1,57 +1,48 @@
|
|||
import Workflow from "../Workflow";
|
||||
import TEST_WORKFLOW from "./testWorkflow";
|
||||
import Workflow from "../Workflow"
|
||||
import TEST_WORKFLOW from "./testWorkflow"
|
||||
|
||||
const TEST_BLOCK = {
|
||||
id: "VFWeZcIPx",
|
||||
name: "Update UI State",
|
||||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>",
|
||||
icon: "ri-refresh-line",
|
||||
description: "Update your User Interface with some data.",
|
||||
environment: "CLIENT",
|
||||
params: {
|
||||
path: "string",
|
||||
value: "longText",
|
||||
},
|
||||
args: {
|
||||
path: "foo",
|
||||
value: "started...",
|
||||
},
|
||||
actionId: "SET_STATE",
|
||||
type: "ACTION",
|
||||
id: "AUXJQGZY7",
|
||||
name: "Delay",
|
||||
icon: "ri-time-fill",
|
||||
tagline: "Delay for <b>{{time}}</b> milliseconds",
|
||||
description: "Delay the workflow until an amount of time has passed.",
|
||||
params: { time: "number" },
|
||||
type: "LOGIC",
|
||||
args: { time: "5000" },
|
||||
stepId: "DELAY",
|
||||
}
|
||||
|
||||
describe("Workflow Data Object", () => {
|
||||
let workflow
|
||||
|
||||
beforeEach(() => {
|
||||
workflow = new Workflow({ ...TEST_WORKFLOW });
|
||||
});
|
||||
workflow = new Workflow({ ...TEST_WORKFLOW })
|
||||
})
|
||||
|
||||
it("adds a workflow block to the workflow", () => {
|
||||
workflow.addBlock(TEST_BLOCK);
|
||||
workflow.addBlock(TEST_BLOCK)
|
||||
expect(workflow.workflow.definition)
|
||||
})
|
||||
|
||||
it("updates a workflow block with new attributes", () => {
|
||||
const firstBlock = workflow.workflow.definition.steps[0];
|
||||
const firstBlock = workflow.workflow.definition.steps[0]
|
||||
const updatedBlock = {
|
||||
...firstBlock,
|
||||
name: "UPDATED"
|
||||
};
|
||||
workflow.updateBlock(updatedBlock, firstBlock.id);
|
||||
name: "UPDATED",
|
||||
}
|
||||
workflow.updateBlock(updatedBlock, firstBlock.id)
|
||||
expect(workflow.workflow.definition.steps[0]).toEqual(updatedBlock)
|
||||
})
|
||||
|
||||
it("deletes a workflow block successfully", () => {
|
||||
const { steps } = workflow.workflow.definition
|
||||
const originalLength = steps.length
|
||||
const originalLength = steps.length
|
||||
|
||||
const lastBlock = steps[steps.length - 1];
|
||||
workflow.deleteBlock(lastBlock.id);
|
||||
expect(workflow.workflow.definition.steps.length).toBeLessThan(originalLength);
|
||||
})
|
||||
|
||||
it("builds a tree that gets rendered in the flowchart builder", () => {
|
||||
expect(Workflow.buildUiTree(TEST_WORKFLOW.definition)).toMatchSnapshot();
|
||||
const lastBlock = steps[steps.length - 1]
|
||||
workflow.deleteBlock(lastBlock.id)
|
||||
expect(workflow.workflow.definition.steps.length).toBeLessThan(
|
||||
originalLength
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Workflow Data Object builds a tree that gets rendered in the flowchart builder 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"args": Object {
|
||||
"time": 3000,
|
||||
},
|
||||
"body": "Delay for <b>3000</b> milliseconds",
|
||||
"heading": "DELAY",
|
||||
"id": "zJQcZUgDS",
|
||||
"name": "Delay",
|
||||
"params": Object {
|
||||
"time": "number",
|
||||
},
|
||||
"type": "LOGIC",
|
||||
},
|
||||
Object {
|
||||
"args": Object {
|
||||
"path": "foo",
|
||||
"value": "finished",
|
||||
},
|
||||
"body": "Update <b>foo</b> to <b>finished</b>",
|
||||
"heading": "SET_STATE",
|
||||
"id": "3RSTO7BMB",
|
||||
"name": "Update UI State",
|
||||
"params": Object {
|
||||
"path": "string",
|
||||
"value": "longText",
|
||||
},
|
||||
"type": "ACTION",
|
||||
},
|
||||
Object {
|
||||
"args": Object {
|
||||
"path": "foo",
|
||||
"value": "started...",
|
||||
},
|
||||
"body": "Update <b>foo</b> to <b>started...</b>",
|
||||
"heading": "SET_STATE",
|
||||
"id": "VFWeZcIPx",
|
||||
"name": "Update UI State",
|
||||
"params": Object {
|
||||
"path": "string",
|
||||
"value": "longText",
|
||||
},
|
||||
"type": "ACTION",
|
||||
},
|
||||
]
|
||||
`;
|
|
@ -1,63 +1,78 @@
|
|||
export default {
|
||||
_id: "53b6148c65d1429c987e046852d11611",
|
||||
_rev: "4-02c6659734934895812fa7be0215ee59",
|
||||
name: "Test Workflow",
|
||||
name: "Test workflow",
|
||||
definition: {
|
||||
steps: [
|
||||
{
|
||||
id: "VFWeZcIPx",
|
||||
name: "Update UI State",
|
||||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>",
|
||||
icon: "ri-refresh-line",
|
||||
description: "Update your User Interface with some data.",
|
||||
environment: "CLIENT",
|
||||
id: "ANBDINAPS",
|
||||
description: "Send an email.",
|
||||
tagline: "Send email to <b>{{to}}</b>",
|
||||
icon: "ri-mail-open-fill",
|
||||
name: "Send Email",
|
||||
params: {
|
||||
path: "string",
|
||||
value: "longText",
|
||||
to: "string",
|
||||
from: "string",
|
||||
subject: "longText",
|
||||
text: "longText",
|
||||
},
|
||||
args: {
|
||||
path: "foo",
|
||||
value: "started...",
|
||||
},
|
||||
actionId: "SET_STATE",
|
||||
type: "ACTION",
|
||||
},
|
||||
{
|
||||
id: "zJQcZUgDS",
|
||||
name: "Delay",
|
||||
icon: "ri-time-fill",
|
||||
tagline: "Delay for <b>{{time}}</b> milliseconds",
|
||||
description: "Delay the workflow until an amount of time has passed.",
|
||||
environment: "CLIENT",
|
||||
params: {
|
||||
time: "number",
|
||||
},
|
||||
args: {
|
||||
time: 3000,
|
||||
text: "A user was created!",
|
||||
subject: "New Budibase User",
|
||||
from: "budimaster@budibase.com",
|
||||
to: "test@test.com",
|
||||
},
|
||||
actionId: "DELAY",
|
||||
type: "LOGIC",
|
||||
},
|
||||
{
|
||||
id: "3RSTO7BMB",
|
||||
name: "Update UI State",
|
||||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>",
|
||||
icon: "ri-refresh-line",
|
||||
description: "Update your User Interface with some data.",
|
||||
environment: "CLIENT",
|
||||
params: {
|
||||
path: "string",
|
||||
value: "longText",
|
||||
},
|
||||
args: {
|
||||
path: "foo",
|
||||
value: "finished",
|
||||
},
|
||||
actionId: "SET_STATE",
|
||||
type: "ACTION",
|
||||
stepId: "SEND_EMAIL",
|
||||
},
|
||||
],
|
||||
trigger: {
|
||||
id: "iRzYMOqND",
|
||||
name: "Record Saved",
|
||||
event: "record:save",
|
||||
icon: "ri-save-line",
|
||||
tagline: "Record is added to <b>{{model.name}}</b>",
|
||||
description: "Fired when a record is saved to your database.",
|
||||
params: { model: "model" },
|
||||
type: "TRIGGER",
|
||||
args: {
|
||||
model: {
|
||||
type: "model",
|
||||
views: {},
|
||||
name: "users",
|
||||
schema: {
|
||||
name: {
|
||||
type: "string",
|
||||
constraints: {
|
||||
type: "string",
|
||||
length: { maximum: 123 },
|
||||
presence: { allowEmpty: false },
|
||||
},
|
||||
name: "name",
|
||||
},
|
||||
age: {
|
||||
type: "number",
|
||||
constraints: {
|
||||
type: "number",
|
||||
presence: { allowEmpty: false },
|
||||
numericality: {
|
||||
greaterThanOrEqualTo: "",
|
||||
lessThanOrEqualTo: "",
|
||||
},
|
||||
},
|
||||
name: "age",
|
||||
},
|
||||
},
|
||||
_id: "c6b4e610cd984b588837bca27188a451",
|
||||
_rev: "7-b8aa1ce0b53e88928bb88fc11bdc0aff",
|
||||
},
|
||||
},
|
||||
stepId: "RECORD_SAVED",
|
||||
},
|
||||
},
|
||||
type: "workflow",
|
||||
live: true,
|
||||
ok: true,
|
||||
id: "b384f861f4754e1693835324a7fcca62",
|
||||
rev: "1-aa1c2cbd868ef02e26f8fad531dd7e37",
|
||||
live: false,
|
||||
_id: "b384f861f4754e1693835324a7fcca62",
|
||||
_rev: "108-4116829ec375e0481d0ecab9e83a2caf",
|
||||
}
|
||||
|
|
|
@ -12,6 +12,5 @@
|
|||
</script>
|
||||
|
||||
<div class="bb-margin-m">
|
||||
<Label small forAttr={'datepicker-label'}>{label}</Label>
|
||||
<DatePicker placeholder={label} on:change={onChange} {value} />
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24">
|
||||
<path fill="none" d="M0 0h24v24H0z" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10
|
||||
10zm0-11.414L9.172 7.757 7.757 9.172 10.586 12l-2.829 2.828 1.415 1.415L12
|
||||
13.414l2.828 2.829 1.415-1.415L13.414 12l2.829-2.828-1.415-1.415L12 10.586z" />
|
||||
</svg>
|
After Width: | Height: | Size: 412 B |
|
@ -32,3 +32,4 @@ export { default as TwitterIcon } from "./Twitter.svelte"
|
|||
export { default as InfoIcon } from "./Info.svelte"
|
||||
export { default as CloseIcon } from "./Close.svelte"
|
||||
export { default as MoreIcon } from "./More.svelte"
|
||||
export { default as CloseCircleIcon } from "./CloseCircle.svelte"
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
import feather from "feather-icons"
|
||||
const getIcon = (icon, size) =>
|
||||
feather.icons[icon].toSvg({ height: size || "16", width: size || "16" })
|
||||
export default getIcon
|
|
@ -37,14 +37,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
$: paginatedData = data
|
||||
? data.slice(
|
||||
$: sort = $backendUiStore.sort
|
||||
$: sorted = sort ? fsort(data)[sort.direction](sort.column) : data
|
||||
$: paginatedData = sorted
|
||||
? sorted.slice(
|
||||
currentPage * ITEMS_PER_PAGE,
|
||||
currentPage * ITEMS_PER_PAGE + ITEMS_PER_PAGE
|
||||
)
|
||||
: []
|
||||
$: sort = $backendUiStore.sort
|
||||
$: sorted = sort ? fsort(data)[sort.direction](sort.column) : data
|
||||
|
||||
$: headers = Object.keys($backendUiStore.selectedModel.schema)
|
||||
.sort()
|
||||
|
@ -79,10 +79,10 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if sorted.length === 0}
|
||||
{#if paginatedData.length === 0}
|
||||
<div class="no-data">No Data.</div>
|
||||
{/if}
|
||||
{#each sorted as row}
|
||||
{#each paginatedData as row}
|
||||
<tr>
|
||||
<td>
|
||||
<EditRowPopover {row} />
|
||||
|
@ -103,7 +103,7 @@
|
|||
<TablePagination
|
||||
{data}
|
||||
bind:currentPage
|
||||
pageItemCount={data.length}
|
||||
pageItemCount={paginatedData.length}
|
||||
{ITEMS_PER_PAGE} />
|
||||
</section>
|
||||
|
||||
|
|
|
@ -26,15 +26,15 @@
|
|||
|
||||
$: columns = schema ? Object.keys(schema) : []
|
||||
|
||||
$: sort = $backendUiStore.sort
|
||||
$: sorted = sort ? fsort(data)[sort.direction](sort.column) : data
|
||||
$: paginatedData =
|
||||
data && data.length
|
||||
? data.slice(
|
||||
sorted && sorted.length
|
||||
? sorted.slice(
|
||||
currentPage * ITEMS_PER_PAGE,
|
||||
currentPage * ITEMS_PER_PAGE + ITEMS_PER_PAGE
|
||||
)
|
||||
: []
|
||||
$: sort = $backendUiStore.sort
|
||||
$: sorted = sort ? fsort(data)[sort.direction](sort.column) : data
|
||||
</script>
|
||||
|
||||
<section>
|
||||
|
@ -72,7 +72,7 @@
|
|||
<TablePagination
|
||||
{data}
|
||||
bind:currentPage
|
||||
pageItemCount={data.length}
|
||||
pageItemCount={paginatedData.length}
|
||||
{ITEMS_PER_PAGE} />
|
||||
</section>
|
||||
|
||||
|
|
|
@ -71,7 +71,16 @@
|
|||
}
|
||||
|
||||
function addFilter() {
|
||||
view.filters = [...view.filters, {}]
|
||||
view.filters.push({})
|
||||
view.filters = view.filters
|
||||
}
|
||||
|
||||
function isMultipleChoice(field) {
|
||||
return (
|
||||
viewModel.schema[field].constraints &&
|
||||
viewModel.schema[field].constraints.inclusion &&
|
||||
viewModel.schema[field].constraints.inclusion.length
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -108,10 +117,18 @@
|
|||
<option value={condition.key}>{condition.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
<Input
|
||||
thin
|
||||
placeholder={filter.key || fields[0]}
|
||||
bind:value={filter.value} />
|
||||
{#if filter.key && isMultipleChoice(filter.key)}
|
||||
<Select secondary thin bind:value={filter.value}>
|
||||
{#each viewModel.schema[filter.key].constraints.inclusion as option}
|
||||
<option value={option}>{option}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{:else}
|
||||
<Input
|
||||
thin
|
||||
placeholder={filter.key || fields[0]}
|
||||
bind:value={filter.value} />
|
||||
{/if}
|
||||
<i class="ri-close-circle-fill" on:click={() => removeFilter(idx)} />
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
CircleIndicator,
|
||||
EventsIcon,
|
||||
} from "components/common/Icons/"
|
||||
import EventsEditor from "./EventsEditor"
|
||||
import panelStructure from "./temporaryPanelStructure.js"
|
||||
import CategoryTab from "./CategoryTab.svelte"
|
||||
import DesignView from "./DesignView.svelte"
|
||||
|
@ -21,7 +20,6 @@
|
|||
let categories = [
|
||||
{ value: "settings", name: "Settings" },
|
||||
{ value: "design", name: "Design" },
|
||||
{ value: "events", name: "Events" },
|
||||
]
|
||||
let selectedCategory = categories[0]
|
||||
|
||||
|
@ -37,8 +35,6 @@
|
|||
c => c._component === componentInstance._component
|
||||
) || {}
|
||||
|
||||
let panelDefinition = {}
|
||||
|
||||
$: panelDefinition =
|
||||
componentPropDefinition.properties &&
|
||||
componentPropDefinition.properties[selectedCategory.value]
|
||||
|
@ -109,8 +105,6 @@
|
|||
displayNameField={displayName}
|
||||
onChange={onPropChanged}
|
||||
screenOrPageInstance={$store.currentView !== 'component' && $store.currentPreviewItem} />
|
||||
{:else if selectedCategory.value === 'events'}
|
||||
<EventsEditor component={componentInstance} />
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
|
|
@ -1,168 +1,220 @@
|
|||
<script>
|
||||
import { store } from "builderStore"
|
||||
import { Button, Select } from "@budibase/bbui"
|
||||
import HandlerSelector from "./HandlerSelector.svelte"
|
||||
import ActionButton from "../../common/ActionButton.svelte"
|
||||
import getIcon from "../../common/icon"
|
||||
import { CloseIcon } from "components/common/Icons/"
|
||||
|
||||
import { TextButton, Button, Heading, DropdownMenu } from "@budibase/bbui"
|
||||
import { AddIcon, ArrowDownIcon } from "components/common/Icons/"
|
||||
import { EVENT_TYPE_MEMBER_NAME } from "../../common/eventHandlers"
|
||||
import actionTypes from "./actions"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let event
|
||||
export let eventOptions = []
|
||||
export let onClose
|
||||
|
||||
let eventType = ""
|
||||
let addActionButton
|
||||
let addActionDropdown
|
||||
let selectedAction
|
||||
|
||||
let draftEventHandler = { parameters: [] }
|
||||
|
||||
$: eventData = event || { handlers: [] }
|
||||
$: if (!eventOptions.includes(eventType) && eventOptions.length > 0)
|
||||
eventType = eventOptions[0].name
|
||||
$: actions = event || []
|
||||
$: selectedActionComponent =
|
||||
selectedAction &&
|
||||
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_MEMBER_NAME])
|
||||
.component
|
||||
|
||||
const closeModal = () => {
|
||||
onClose()
|
||||
dispatch("close")
|
||||
draftEventHandler = { parameters: [] }
|
||||
eventData = { handlers: [] }
|
||||
actions = []
|
||||
}
|
||||
|
||||
const updateEventHandler = (updatedHandler, index) => {
|
||||
eventData.handlers[index] = updatedHandler
|
||||
actions[index] = updatedHandler
|
||||
}
|
||||
|
||||
const updateDraftEventHandler = updatedHandler => {
|
||||
draftEventHandler = updatedHandler
|
||||
const deleteAction = index => {
|
||||
actions.splice(index, 1)
|
||||
actions = actions
|
||||
}
|
||||
|
||||
const deleteEventHandler = index => {
|
||||
eventData.handlers.splice(index, 1)
|
||||
eventData = eventData
|
||||
}
|
||||
|
||||
const createNewEventHandler = handler => {
|
||||
const newHandler = handler || {
|
||||
const addAction = actionType => () => {
|
||||
const newAction = {
|
||||
parameters: {},
|
||||
[EVENT_TYPE_MEMBER_NAME]: "",
|
||||
[EVENT_TYPE_MEMBER_NAME]: actionType.name,
|
||||
}
|
||||
eventData.handlers.push(newHandler)
|
||||
eventData = eventData
|
||||
actions.push(newAction)
|
||||
selectedAction = newAction
|
||||
actions = actions
|
||||
addActionDropdown.hide()
|
||||
}
|
||||
|
||||
const deleteEvent = () => {
|
||||
store.setComponentProp(eventType, [])
|
||||
closeModal()
|
||||
const selectAction = action => () => {
|
||||
selectedAction = action
|
||||
}
|
||||
|
||||
const saveEventData = () => {
|
||||
store.setComponentProp(eventType, eventData.handlers)
|
||||
dispatch("change", actions)
|
||||
closeModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="body">
|
||||
<div class="heading">
|
||||
<h3>
|
||||
{eventData.name ? `${eventData.name} Event` : 'Create a New Component Event'}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="event-options">
|
||||
<div class="section">
|
||||
<h4>Event Type</h4>
|
||||
<Select bind:value={eventType}>
|
||||
{#each eventOptions as option}
|
||||
<option value={option.name}>{option.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="root">
|
||||
|
||||
<div class="section">
|
||||
<h4>Event Action(s)</h4>
|
||||
<HandlerSelector
|
||||
newHandler
|
||||
onChanged={updateDraftEventHandler}
|
||||
onCreate={() => {
|
||||
createNewEventHandler(draftEventHandler)
|
||||
draftEventHandler = { parameters: [] }
|
||||
}}
|
||||
handler={draftEventHandler} />
|
||||
<div class="header">
|
||||
<Heading small dark>Actions</Heading>
|
||||
<div bind:this={addActionButton}>
|
||||
<TextButton text small blue on:click={addActionDropdown.show}>
|
||||
Add Action
|
||||
<div style="height: 20px; width: 20px;">
|
||||
<AddIcon />
|
||||
</div>
|
||||
</TextButton>
|
||||
</div>
|
||||
{#if eventData}
|
||||
{#each eventData.handlers as handler, index}
|
||||
<HandlerSelector
|
||||
{index}
|
||||
onChanged={updateEventHandler}
|
||||
onRemoved={() => deleteEventHandler(index)}
|
||||
{handler} />
|
||||
<DropdownMenu
|
||||
bind:this={addActionDropdown}
|
||||
anchor={addActionButton}
|
||||
align="right">
|
||||
<div class="available-actions-container">
|
||||
{#each actionTypes as actionType}
|
||||
<div class="available-action" on:click={addAction(actionType)}>
|
||||
<span>{actionType.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div class="actions-container">
|
||||
{#if actions && actions.length > 0}
|
||||
{#each actions as action, index}
|
||||
<div class="action-container">
|
||||
<div class="action-header" on:click={selectAction(action)}>
|
||||
<p
|
||||
class="bb-body bb-body--small bb-body--color-dark"
|
||||
style="margin: var(--spacing-s) 0;">
|
||||
{index + 1}. {action[EVENT_TYPE_MEMBER_NAME]}
|
||||
</p>
|
||||
<div class="row-expander" class:rotate={action !== selectedAction}>
|
||||
<ArrowDownIcon />
|
||||
</div>
|
||||
</div>
|
||||
{#if action === selectedAction}
|
||||
<div class="selected-action-container">
|
||||
<svelte:component
|
||||
this={selectedActionComponent}
|
||||
parameters={selectedAction.parameters} />
|
||||
<div class="delete-action-button">
|
||||
<TextButton text medium on:click={() => deleteAction(index)}>
|
||||
Delete
|
||||
</TextButton>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="footer">
|
||||
{#if eventData.name}
|
||||
<Button
|
||||
outline
|
||||
on:click={deleteEvent}
|
||||
disabled={eventData.handlers.length === 0}>
|
||||
Delete
|
||||
</Button>
|
||||
{/if}
|
||||
<div class="save">
|
||||
<Button
|
||||
primary
|
||||
on:click={saveEventData}
|
||||
disabled={eventData.handlers.length === 0}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="close-button" on:click={closeModal}>
|
||||
<CloseIcon />
|
||||
<a href="https://docs.budibase.com">Learn more about Actions</a>
|
||||
<Button secondary on:click={closeModal}>Cancel</Button>
|
||||
<Button primary on:click={saveEventData}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
.heading {
|
||||
margin-bottom: 20px;
|
||||
.root {
|
||||
max-height: 50vh;
|
||||
width: 700px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-xl);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.action-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-header > p {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.row-expander {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.available-action {
|
||||
padding: var(--spacing-s);
|
||||
font-size: var(--font-size-m);
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
.close-button :global(svg) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 10px;
|
||||
.available-action:hover {
|
||||
background: var(--grey-2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
.actions-container {
|
||||
flex: 1;
|
||||
min-height: 0px;
|
||||
padding-bottom: var(--spacing-s);
|
||||
padding-top: 0;
|
||||
border: var(--border-light);
|
||||
border-width: 0 0 1px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.body {
|
||||
padding: 40px;
|
||||
display: grid;
|
||||
grid-gap: 20px;
|
||||
|
||||
.action-container {
|
||||
border: var(--border-light);
|
||||
border-width: 1px 0 0 0;
|
||||
padding-left: var(--spacing-xl);
|
||||
padding-right: var(--spacing-xl);
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.footer {
|
||||
|
||||
.selected-action-container {
|
||||
padding-bottom: var(--spacing-s);
|
||||
padding-top: var(--spacing-s);
|
||||
}
|
||||
|
||||
.delete-action-button {
|
||||
padding-top: var(--spacing-l);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 30px 40px;
|
||||
border-bottom-left-radius: 5px;
|
||||
border-bottom-right-radius: 50px;
|
||||
background-color: var(--grey-1);
|
||||
flex-direction: row;
|
||||
}
|
||||
.save {
|
||||
margin-left: 20px;
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-xl);
|
||||
padding-top: var(--spacing-m);
|
||||
}
|
||||
|
||||
.footer > a {
|
||||
flex: 1;
|
||||
color: var(--grey-5);
|
||||
font-size: var(--font-size-s);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer > a:hover {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.rotate :global(svg) {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,25 +1,24 @@
|
|||
<script>
|
||||
import { Button, DropdownMenu } from "@budibase/bbui"
|
||||
import { Button, Modal } from "@budibase/bbui"
|
||||
import EventEditorModal from "./EventEditorModal.svelte"
|
||||
import { getContext } from "svelte"
|
||||
import { createEventDispatcher, onMount } from "svelte"
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let value
|
||||
export let name
|
||||
|
||||
let button
|
||||
let dropdown
|
||||
let eventsModal
|
||||
</script>
|
||||
|
||||
<div bind:this={button}>
|
||||
<Button secondary small on:click={dropdown.show}>Define Actions</Button>
|
||||
</div>
|
||||
<DropdownMenu bind:this={dropdown} align="right" anchor={button}>
|
||||
<Button secondary small on:click={eventsModal.show}>Define Actions</Button>
|
||||
|
||||
<Modal bind:this={eventsModal} maxWidth="100vw" hideCloseButton padding="0">
|
||||
<EventEditorModal
|
||||
event={value}
|
||||
eventType={name}
|
||||
on:change
|
||||
on:close={dropdown.hide} />
|
||||
</DropdownMenu>
|
||||
on:close={eventsModal.hide} />
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { Input, Select } from "@budibase/bbui"
|
||||
import { Input, DataList, Select } from "@budibase/bbui"
|
||||
import { find, map, keys, reduce, keyBy } from "lodash/fp"
|
||||
import { pipe } from "components/common/core"
|
||||
import { EVENT_TYPE_MEMBER_NAME } from "components/common/eventHandlers"
|
||||
|
@ -29,12 +29,12 @@
|
|||
{/each}
|
||||
</Select>
|
||||
{:else if parameter.name === 'url'}
|
||||
<Select editable on:change bind:value={parameter.value}>
|
||||
<DataList on:change bind:value={parameter.value}>
|
||||
<option value="" />
|
||||
{#each $store.allScreens as screen}
|
||||
<option value={screen.route}>{screen.props._instanceName}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
</DataList>
|
||||
{:else}
|
||||
<Input
|
||||
name={parameter.name}
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
<script>
|
||||
import { Select, Label } from "@budibase/bbui"
|
||||
import { store, backendUiStore } from "builderStore"
|
||||
import fetchBindableProperties from "builderStore/fetchBindableProperties"
|
||||
import SaveFields from "./SaveFields.svelte"
|
||||
|
||||
export let parameters
|
||||
|
||||
$: bindableProperties = fetchBindableProperties({
|
||||
componentInstanceId: $store.currentComponentInfo._id,
|
||||
components: $store.components,
|
||||
screen: $store.currentPreviewItem,
|
||||
models: $backendUiStore.models,
|
||||
})
|
||||
|
||||
// just wraps binding in {{ ... }}
|
||||
const toBindingExpression = bindingPath => `{{ ${bindingPath} }}`
|
||||
|
||||
const modelFields = modelId => {
|
||||
const model = $backendUiStore.models.find(m => m._id === modelId)
|
||||
|
||||
return Object.keys(model.schema).map(k => ({
|
||||
name: k,
|
||||
type: model.schema[k].type,
|
||||
}))
|
||||
}
|
||||
|
||||
$: schemaFields =
|
||||
parameters && parameters.modelId ? modelFields(parameters.modelId) : []
|
||||
|
||||
const onFieldsChanged = e => {
|
||||
parameters.fields = e.detail
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<Label size="m" color="dark">Table</Label>
|
||||
<Select secondary bind:value={parameters.modelId}>
|
||||
<option value="" />
|
||||
{#each $backendUiStore.models as model}
|
||||
<option value={model._id}>{model.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
|
||||
{#if parameters.modelId}
|
||||
<SaveFields
|
||||
parameterFields={parameters.fields}
|
||||
{schemaFields}
|
||||
on:fieldschanged={onFieldsChanged} />
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: grid;
|
||||
column-gap: var(--spacing-s);
|
||||
row-gap: var(--spacing-s);
|
||||
grid-template-columns: auto 1fr auto 1fr auto;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.root :global(.relative:nth-child(2)) {
|
||||
grid-column-start: 2;
|
||||
grid-column-end: 6;
|
||||
}
|
||||
|
||||
.cannot-use {
|
||||
color: var(--red);
|
||||
font-size: var(--font-size-s);
|
||||
text-align: center;
|
||||
width: 70%;
|
||||
margin: auto;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,29 @@
|
|||
<script>
|
||||
import { DataList, Label } from "@budibase/bbui"
|
||||
import { store } from "builderStore"
|
||||
|
||||
export let parameters
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<Label size="m" color="dark">Screen</Label>
|
||||
<DataList secondary bind:value={parameters.url}>
|
||||
<option value="" />
|
||||
{#each $store.screens as screen}
|
||||
<option value={screen.route}>{screen.props._instanceName}</option>
|
||||
{/each}
|
||||
</DataList>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.root :global(.relative) {
|
||||
flex: 1;
|
||||
margin-left: var(--spacing-l);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,115 @@
|
|||
<script>
|
||||
// accepts an array of field names, and outputs an object of { FieldName: value }
|
||||
import { DataList, Label, TextButton, Spacer, Select } from "@budibase/bbui"
|
||||
import { store, backendUiStore } from "builderStore"
|
||||
import fetchBindableProperties from "builderStore/fetchBindableProperties"
|
||||
import { CloseCircleIcon, AddIcon } from "components/common/Icons"
|
||||
import {
|
||||
readableToRuntimeBinding,
|
||||
runtimeToReadableBinding,
|
||||
} from "builderStore/replaceBindings"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let parameterFields
|
||||
export let schemaFields
|
||||
|
||||
const emptyField = () => ({ name: "", value: "" })
|
||||
|
||||
// this statement initialises fields from parameters.fields
|
||||
$: fields =
|
||||
fields ||
|
||||
Object.keys(parameterFields || { "": "" }).map(name => ({
|
||||
name,
|
||||
value:
|
||||
(parameterFields &&
|
||||
runtimeToReadableBinding(
|
||||
bindableProperties,
|
||||
parameterFields[name].value
|
||||
)) ||
|
||||
"",
|
||||
}))
|
||||
|
||||
$: bindableProperties = fetchBindableProperties({
|
||||
componentInstanceId: $store.currentComponentInfo._id,
|
||||
components: $store.components,
|
||||
screen: $store.currentPreviewItem,
|
||||
models: $backendUiStore.models,
|
||||
})
|
||||
|
||||
const addField = () => {
|
||||
const newFields = fields.filter(f => f.name)
|
||||
newFields.push(emptyField())
|
||||
fields = newFields
|
||||
rebuildParameters()
|
||||
}
|
||||
|
||||
const removeField = field => () => {
|
||||
fields = fields.filter(f => f !== field)
|
||||
rebuildParameters()
|
||||
}
|
||||
|
||||
const rebuildParameters = () => {
|
||||
// rebuilds paramters.fields every time a field name or value is added
|
||||
// as UI below is bound to "fields" array, but we need to output a { key: value }
|
||||
const newParameterFields = {}
|
||||
for (let field of fields) {
|
||||
if (field.name) {
|
||||
// value and type is needed by the client, so it can parse
|
||||
// a string into a correct type
|
||||
newParameterFields[field.name] = {
|
||||
type: schemaFields.find(f => f.name === field.name).type,
|
||||
value: readableToRuntimeBinding(bindableProperties, field.value),
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatch("fieldschanged", newParameterFields)
|
||||
}
|
||||
|
||||
// just wraps binding in {{ ... }}
|
||||
const toBindingExpression = bindingPath => `{{ ${bindingPath} }}`
|
||||
</script>
|
||||
|
||||
{#if fields}
|
||||
{#each fields as field}
|
||||
<Label size="m" color="dark">Field</Label>
|
||||
<Select secondary bind:value={field.name} on:blur={rebuildParameters}>
|
||||
<option value="" />
|
||||
{#each schemaFields as schemaField}
|
||||
<option value={schemaField.name}>{schemaField.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
<Label size="m" color="dark">Value</Label>
|
||||
<DataList secondary bind:value={field.value} on:blur={rebuildParameters}>
|
||||
<option value="" />
|
||||
{#each bindableProperties as bindableProp}
|
||||
<option value={toBindingExpression(bindableProp.readableBinding)}>
|
||||
{bindableProp.readableBinding}
|
||||
</option>
|
||||
{/each}
|
||||
</DataList>
|
||||
<div class="remove-field-container">
|
||||
<TextButton text small on:click={removeField(field)}>
|
||||
<CloseCircleIcon />
|
||||
</TextButton>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div>
|
||||
<Spacer small />
|
||||
|
||||
<TextButton text small blue on:click={addField}>
|
||||
Add Field
|
||||
<div style="height: 20px; width: 20px;">
|
||||
<AddIcon />
|
||||
</div>
|
||||
</TextButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.remove-field-container :global(button) {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,134 @@
|
|||
<script>
|
||||
import { Select, Label } from "@budibase/bbui"
|
||||
import { store, backendUiStore } from "builderStore"
|
||||
import fetchBindableProperties from "builderStore/fetchBindableProperties"
|
||||
import SaveFields from "./SaveFields.svelte"
|
||||
import {
|
||||
readableToRuntimeBinding,
|
||||
runtimeToReadableBinding,
|
||||
} from "builderStore/replaceBindings"
|
||||
|
||||
export let parameters
|
||||
|
||||
$: bindableProperties = fetchBindableProperties({
|
||||
componentInstanceId: $store.currentComponentInfo._id,
|
||||
components: $store.components,
|
||||
screen: $store.currentPreviewItem,
|
||||
models: $backendUiStore.models,
|
||||
})
|
||||
|
||||
let idFields
|
||||
let recordId
|
||||
$: {
|
||||
idFields = bindableProperties.filter(
|
||||
bindable =>
|
||||
bindable.type === "context" && bindable.runtimeBinding.endsWith("._id")
|
||||
)
|
||||
// ensure recordId is always defaulted - there is usually only one option
|
||||
if (idFields.length > 0 && !parameters._id) {
|
||||
recordId = idFields[0].runtimeBinding
|
||||
parameters = parameters
|
||||
} else if (!recordId && parameters._id) {
|
||||
recordId = parameters._id
|
||||
.replace("{{", "")
|
||||
.replace("}}", "")
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
|
||||
$: parameters._id = `{{ ${recordId} }}`
|
||||
|
||||
// just wraps binding in {{ ... }}
|
||||
const toBindingExpression = bindingPath => `{{ ${bindingPath} }}`
|
||||
|
||||
// finds the selected idBinding, then reads the table/view
|
||||
// from the component instance that it belongs to.
|
||||
// then returns the field names for that schema
|
||||
const schemaFromIdBinding = recordId => {
|
||||
if (!recordId) return []
|
||||
|
||||
const idBinding = bindableProperties.find(
|
||||
prop => prop.runtimeBinding === recordId
|
||||
)
|
||||
if (!idBinding) return []
|
||||
|
||||
const { instance } = idBinding
|
||||
|
||||
const component = $store.components[instance._component]
|
||||
|
||||
// component.context is the name of the prop that holds the modelId
|
||||
const modelInfo = instance[component.context]
|
||||
|
||||
if (!modelInfo) return []
|
||||
|
||||
const model = $backendUiStore.models.find(m => m._id === modelInfo.modelId)
|
||||
parameters.modelId = modelInfo.modelId
|
||||
return Object.keys(model.schema).map(k => ({
|
||||
name: k,
|
||||
type: model.schema[k].type,
|
||||
}))
|
||||
}
|
||||
|
||||
let schemaFields
|
||||
$: {
|
||||
if (parameters && recordId) {
|
||||
schemaFields = schemaFromIdBinding(recordId)
|
||||
} else {
|
||||
schemaFields = []
|
||||
}
|
||||
}
|
||||
|
||||
const onFieldsChanged = e => {
|
||||
parameters.fields = e.detail
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
{#if idFields.length === 0}
|
||||
<div class="cannot-use">
|
||||
Update record can only be used within a component that provides data, such
|
||||
as a List
|
||||
</div>
|
||||
{:else}
|
||||
<Label size="m" color="dark">Record Id</Label>
|
||||
<Select secondary bind:value={recordId}>
|
||||
<option value="" />
|
||||
{#each idFields as idField}
|
||||
<option value={idField.runtimeBinding}>
|
||||
{idField.readableBinding}
|
||||
</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
|
||||
{#if recordId}
|
||||
<SaveFields
|
||||
parameterFields={parameters.fields}
|
||||
{schemaFields}
|
||||
on:fieldschanged={onFieldsChanged} />
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: grid;
|
||||
column-gap: var(--spacing-s);
|
||||
row-gap: var(--spacing-s);
|
||||
grid-template-columns: auto 1fr auto 1fr auto;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.root :global(.relative:nth-child(2)) {
|
||||
grid-column-start: 2;
|
||||
grid-column-end: 6;
|
||||
}
|
||||
|
||||
.cannot-use {
|
||||
color: var(--red);
|
||||
font-size: var(--font-size-s);
|
||||
text-align: center;
|
||||
width: 70%;
|
||||
margin: auto;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,23 @@
|
|||
import NavigateTo from "./NavigateTo.svelte"
|
||||
import UpdateRecord from "./UpdateRecord.svelte"
|
||||
import CreateRecord from "./CreateRecord.svelte"
|
||||
|
||||
// defines what actions are available, when adding a new one
|
||||
// the component is the setup panel for the action
|
||||
// NOTE that the "name" is used by the client library,
|
||||
// so if you want to change it, you must change it client lib too
|
||||
|
||||
export default [
|
||||
{
|
||||
name: "Create Record",
|
||||
component: CreateRecord,
|
||||
},
|
||||
{
|
||||
name: "Navigate To",
|
||||
component: NavigateTo,
|
||||
},
|
||||
{
|
||||
name: "Update Record",
|
||||
component: UpdateRecord,
|
||||
},
|
||||
]
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./EventsEditor.svelte"
|
|
@ -0,0 +1,293 @@
|
|||
<script>
|
||||
import { DropdownMenu, Button, Input } from "@budibase/bbui"
|
||||
import { createEventDispatcher, tick } from "svelte"
|
||||
|
||||
import icons from "./icons.js"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let value = ""
|
||||
export let maxIconsPerPage = 30
|
||||
|
||||
let searchTerm = ""
|
||||
let selectedLetter = "A"
|
||||
|
||||
let currentPage = 1
|
||||
let filteredIcons = findIconByTerm(selectedLetter)
|
||||
|
||||
$: dispatch("change", value)
|
||||
|
||||
const alphabet = [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"H",
|
||||
"I",
|
||||
"J",
|
||||
"K",
|
||||
"L",
|
||||
"M",
|
||||
"N",
|
||||
"O",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
"S",
|
||||
"T",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
]
|
||||
let buttonAnchor, dropdown
|
||||
let loading = false
|
||||
|
||||
function findIconByTerm(term) {
|
||||
const r = new RegExp(`\^${term}`, "i")
|
||||
return icons.filter(i => r.test(i.label))
|
||||
}
|
||||
|
||||
async function switchLetter(letter) {
|
||||
currentPage = 1
|
||||
searchTerm = ""
|
||||
loading = true
|
||||
selectedLetter = letter
|
||||
filteredIcons = findIconByTerm(letter)
|
||||
await tick() //svg icons do not update without tick
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function findIconOnPage() {
|
||||
loading = true
|
||||
const iconIdx = filteredIcons.findIndex(i => i.value === value)
|
||||
if (iconIdx !== -1) {
|
||||
currentPage = Math.ceil(iconIdx / maxIconsPerPage)
|
||||
}
|
||||
await tick() //svg icons do not update without tick
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function setSelectedUI() {
|
||||
if (value) {
|
||||
const letter = displayValue.substring(0, 1)
|
||||
await switchLetter(letter)
|
||||
await findIconOnPage()
|
||||
}
|
||||
}
|
||||
|
||||
async function pageClick(next) {
|
||||
loading = true
|
||||
if (next && currentPage < totalPages) {
|
||||
currentPage++
|
||||
} else if (!next && currentPage > 1) {
|
||||
currentPage--
|
||||
}
|
||||
await tick() //svg icons do not update without tick
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function searchForIcon(e) {
|
||||
currentPage = 1
|
||||
loading = true
|
||||
filteredIcons = findIconByTerm(searchTerm)
|
||||
await tick() //svg icons do not update without tick
|
||||
loading = false
|
||||
}
|
||||
|
||||
$: displayValue = value ? value.substring(7) : "Pick Icon"
|
||||
|
||||
$: totalPages = Math.ceil(filteredIcons.length / maxIconsPerPage)
|
||||
$: pageEndIdx = maxIconsPerPage * currentPage
|
||||
$: pagedIcons = filteredIcons.slice(pageEndIdx - maxIconsPerPage, pageEndIdx)
|
||||
|
||||
$: pagerText = `Page ${currentPage} of ${totalPages}`
|
||||
</script>
|
||||
|
||||
<div bind:this={buttonAnchor}>
|
||||
<Button secondary on:click={dropdown.show}>{displayValue}</Button>
|
||||
</div>
|
||||
<DropdownMenu
|
||||
bind:this={dropdown}
|
||||
on:open={setSelectedUI}
|
||||
anchor={buttonAnchor}>
|
||||
<div class="container">
|
||||
<div class="search-area">
|
||||
<div class="alphabet-area">
|
||||
{#each alphabet as letter, idx}
|
||||
<span
|
||||
class="letter"
|
||||
class:letter-selected={letter === selectedLetter}
|
||||
on:click={() => switchLetter(letter)}>
|
||||
{letter}
|
||||
</span>
|
||||
{#if idx !== alphabet.length - 1}
|
||||
<span>-</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="search-input">
|
||||
<div class="input-wrapper">
|
||||
<Input bind:value={searchTerm} thin placeholder="Search Icon" />
|
||||
</div>
|
||||
<Button secondary on:click={searchForIcon}>Search</Button>
|
||||
</div>
|
||||
<div class="page-area">
|
||||
<div class="pager">
|
||||
<span on:click={() => pageClick(false)}>
|
||||
<i class="page-btn fas fa-chevron-left" />
|
||||
</span>
|
||||
<span>{pagerText}</span>
|
||||
<span on:click={() => pageClick(true)}>
|
||||
<i class="page-btn fas fa-chevron-right" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if pagedIcons.length > 0}
|
||||
<div class="icon-area">
|
||||
{#if !loading}
|
||||
{#each pagedIcons as icon}
|
||||
<div
|
||||
class="icon-container"
|
||||
class:selected={value === icon.value}
|
||||
on:click={() => (value = icon.value)}>
|
||||
<div class="icon-preview">
|
||||
<i class={`${icon.value} fa-3x`} />
|
||||
</div>
|
||||
<div class="icon-label">{icon.label}</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-icons">
|
||||
<h5>
|
||||
{`There is no icons for this ${searchTerm ? 'search' : 'page'}`}
|
||||
</h5>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
width: 610px;
|
||||
height: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 0px 10px 15px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.search-area {
|
||||
flex: 0 0 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.icon-area {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-gap: 5px;
|
||||
justify-content: flex-start;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.no-icons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alphabet-area {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
padding-bottom: 10px;
|
||||
padding-right: 15px;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
width: 100%;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
width: 510px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.page-area {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.letter {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.letter:hover {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.letter-selected {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
height: 100px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
border: var(--border-dark);
|
||||
}
|
||||
|
||||
.icon-container:hover {
|
||||
cursor: pointer;
|
||||
background: var(--grey-2);
|
||||
}
|
||||
|
||||
.selected {
|
||||
background: var(--grey-3);
|
||||
}
|
||||
|
||||
.icon-preview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
flex: 0 0 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.page-btn:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,3 @@
|
|||
import "@fortawesome/fontawesome-free/js/all.js"
|
||||
|
||||
export { default as IconSelect } from "./IconSelect.svelte"
|
|
@ -3,6 +3,11 @@
|
|||
import Input from "./PropertyPanelControls/Input.svelte"
|
||||
import { store, backendUiStore } from "builderStore"
|
||||
import fetchBindableProperties from "builderStore/fetchBindableProperties"
|
||||
import {
|
||||
readableToRuntimeBinding,
|
||||
runtimeToReadableBinding,
|
||||
CAPTURE_VAR_INSIDE_MUSTACHE,
|
||||
} from "builderStore/replaceBindings"
|
||||
import { DropdownMenu } from "@budibase/bbui"
|
||||
import BindingDropdown from "components/userInterface/BindingDropdown.svelte"
|
||||
import { onMount, getContext } from "svelte"
|
||||
|
@ -36,25 +41,12 @@
|
|||
})
|
||||
}
|
||||
|
||||
const CAPTURE_VAR_INSIDE_MUSTACHE = /{{([^}]+)}}/g
|
||||
function replaceBindings(textWithBindings) {
|
||||
getBindableProperties()
|
||||
// Find all instances of mustasche
|
||||
const boundValues = textWithBindings.match(CAPTURE_VAR_INSIDE_MUSTACHE)
|
||||
|
||||
// Replace with names:
|
||||
boundValues &&
|
||||
boundValues.forEach(boundValue => {
|
||||
const binding = bindableProperties.find(({ readableBinding }) => {
|
||||
return boundValue === `{{ ${readableBinding} }}`
|
||||
})
|
||||
if (binding) {
|
||||
textWithBindings = textWithBindings.replace(
|
||||
boundValue,
|
||||
`{{ ${binding.runtimeBinding} }}`
|
||||
)
|
||||
}
|
||||
})
|
||||
textWithBindings = readableToRuntimeBinding(
|
||||
bindableProperties,
|
||||
textWithBindings
|
||||
)
|
||||
onChange(key, textWithBindings)
|
||||
}
|
||||
|
||||
|
@ -76,22 +68,10 @@
|
|||
|
||||
const safeValue = () => {
|
||||
getBindableProperties()
|
||||
let temp = value
|
||||
const boundValues =
|
||||
(typeof value === "string" && value.match(CAPTURE_VAR_INSIDE_MUSTACHE)) ||
|
||||
[]
|
||||
|
||||
// Replace with names:
|
||||
boundValues.forEach(v => {
|
||||
const binding = bindableProperties.find(({ runtimeBinding }) => {
|
||||
return v === `{{ ${runtimeBinding} }}`
|
||||
})
|
||||
if (binding) {
|
||||
temp = temp.replace(v, `{{ ${binding.readableBinding} }}`)
|
||||
}
|
||||
})
|
||||
// console.log(temp)
|
||||
return value === undefined && props.defaultValue !== undefined
|
||||
let temp = runtimeToReadableBinding(bindableProperties, value)
|
||||
|
||||
return !value && props.defaultValue !== undefined
|
||||
? props.defaultValue
|
||||
: temp
|
||||
}
|
||||
|
@ -113,7 +93,7 @@
|
|||
{...props}
|
||||
name={key} />
|
||||
</div>
|
||||
{#if control == Input}
|
||||
{#if control === Input && !key.startsWith('_')}
|
||||
<button data-cy={`${key}-binding-button`} on:click={dropdown.show}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { Select } from "@budibase/bbui"
|
||||
import { DataList } from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { store } from "builderStore"
|
||||
|
||||
|
@ -10,9 +10,9 @@
|
|||
const handleBlur = () => dispatch("change", value)
|
||||
</script>
|
||||
|
||||
<Select editable secondary on:blur={handleBlur} on:change bind:value>
|
||||
<DataList editable secondary on:blur={handleBlur} on:change bind:value>
|
||||
<option value="" />
|
||||
{#each $store.allScreens as screen}
|
||||
<option value={screen.route}>{screen.props._instanceName}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
</DataList>
|
||||
|
|
|
@ -116,7 +116,7 @@
|
|||
control={definition.control}
|
||||
label={definition.label}
|
||||
key={definition.key}
|
||||
value={componentInstance[definition.key]}
|
||||
value={componentInstance[definition.key] || componentInstance[definition.key].defaultValue}
|
||||
{componentInstance}
|
||||
{onChange}
|
||||
props={{ ...excludeProps(definition, ['control', 'label']) }} />
|
||||
|
|
|
@ -6,6 +6,8 @@ import ModelViewSelect from "components/userInterface/ModelViewSelect.svelte"
|
|||
import ModelViewFieldSelect from "components/userInterface/ModelViewFieldSelect.svelte"
|
||||
import Event from "components/userInterface/EventsEditor/EventPropertyControl.svelte"
|
||||
import ScreenSelect from "components/userInterface/ScreenSelect.svelte"
|
||||
import { IconSelect } from "components/userInterface/IconSelect"
|
||||
import Colorpicker from "@budibase/colorpicker"
|
||||
|
||||
import { all } from "./propertyCategories.js"
|
||||
/*
|
||||
|
@ -220,16 +222,41 @@ export default {
|
|||
settings: [{ label: "URL", key: "url", control: Input }],
|
||||
},
|
||||
},
|
||||
// {
|
||||
// _component: "@budibase/standard-components/icon",
|
||||
// name: "Icon",
|
||||
// description: "A basic component for displaying icons",
|
||||
// icon: "ri-sun-fill",
|
||||
// children: [],
|
||||
// properties: {
|
||||
// design: { ...all },
|
||||
// },
|
||||
// },
|
||||
{
|
||||
_component: "@budibase/standard-components/icon",
|
||||
name: "Icon",
|
||||
description: "A basic component for displaying icons",
|
||||
icon: "ri-sun-fill",
|
||||
children: [],
|
||||
properties: {
|
||||
design: {},
|
||||
settings: [
|
||||
{ label: "Icon", key: "icon", control: IconSelect },
|
||||
{
|
||||
label: "Size",
|
||||
key: "size",
|
||||
control: OptionSelect,
|
||||
defaultValue: "fa-lg",
|
||||
options: [
|
||||
{ value: "fa-xs", label: "xs" },
|
||||
{ value: "fa-sm", label: "sm" },
|
||||
{ value: "fa-lg", label: "lg" },
|
||||
{ value: "fa-2x", label: "2x" },
|
||||
{ value: "fa-3x", label: "3x" },
|
||||
{ value: "fa-5x", label: "5x" },
|
||||
{ value: "fa-7x", label: "7x" },
|
||||
{ value: "fa-10x", label: "10x" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Color",
|
||||
key: "color",
|
||||
control: Colorpicker,
|
||||
defaultValue: "#000",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
_component: "@budibase/standard-components/link",
|
||||
name: "Link",
|
||||
|
@ -515,10 +542,30 @@ export default {
|
|||
key: "datasource",
|
||||
control: ModelViewSelect,
|
||||
},
|
||||
{ label: "Stripe Color", key: "stripeColor", control: Input },
|
||||
{ label: "Border Color", key: "borderColor", control: Input },
|
||||
{ label: "TH Color", key: "backgroundColor", control: Input },
|
||||
{ label: "TH Font Color", key: "color", control: Input },
|
||||
{
|
||||
label: "Stripe Color",
|
||||
key: "stripeColor",
|
||||
control: Colorpicker,
|
||||
defaultValue: "#FFFFFF",
|
||||
},
|
||||
{
|
||||
label: "Border Color",
|
||||
key: "borderColor",
|
||||
control: Colorpicker,
|
||||
defaultValue: "#FFFFFF",
|
||||
},
|
||||
{
|
||||
label: "TH Color",
|
||||
key: "backgroundColor",
|
||||
control: Colorpicker,
|
||||
defaultValue: "#FFFFFF",
|
||||
},
|
||||
{
|
||||
label: "TH Font Color",
|
||||
key: "color",
|
||||
control: Colorpicker,
|
||||
defaultValue: "#FFFFFF",
|
||||
},
|
||||
{ label: "Table", key: "model", control: ModelSelect },
|
||||
],
|
||||
},
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
async function deleteWorkflow() {
|
||||
await workflowStore.actions.delete({
|
||||
instanceId,
|
||||
workflow: $workflowStore.currentWorkflow.workflow,
|
||||
workflow: $workflowStore.selectedWorkflow.workflow,
|
||||
})
|
||||
onClosed()
|
||||
notifier.danger("Workflow deleted.")
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
<script>
|
||||
import { store } from "builderStore"
|
||||
import deepmerge from "deepmerge"
|
||||
import { Label } from "@budibase/bbui"
|
||||
|
||||
export let value
|
||||
|
||||
let pages = []
|
||||
let components = []
|
||||
let pageName
|
||||
|
||||
let selectedPage
|
||||
let selectedScreen
|
||||
|
||||
$: pages = $store.pages
|
||||
$: selectedPage = pages[pageName]
|
||||
$: screens = selectedPage ? selectedPage._screens : []
|
||||
$: if (selectedPage) {
|
||||
let result = selectedPage
|
||||
for (screen of screens) {
|
||||
result = deepmerge(result, screen)
|
||||
}
|
||||
components = result.props._children
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bb-margin-xl block-field">
|
||||
<Label small forAttr={'page'}>Page</Label>
|
||||
<select class="budibase__input" bind:value={pageName}>
|
||||
{#each Object.keys(pages) as page}
|
||||
<option value={page}>{page}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if components.length > 0}
|
||||
<Label small forAttr={'component'}>Component</Label>
|
||||
<select class="budibase__input" bind:value>
|
||||
{#each components as component}
|
||||
<option value={component._id}>{component._id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
|
@ -2,13 +2,22 @@
|
|||
import { backendUiStore } from "builderStore"
|
||||
|
||||
export let value
|
||||
$: modelId = value ? value._id : ""
|
||||
|
||||
function onChange(e) {
|
||||
value = $backendUiStore.models.find(model => model._id === e.target.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bb-margin-xl block-field">
|
||||
<select class="budibase__input" bind:value>
|
||||
<option value="" />
|
||||
<div class="block-field">
|
||||
<select
|
||||
class="budibase__input"
|
||||
value={modelId}
|
||||
on:blur={onChange}
|
||||
on:change={onChange}>
|
||||
<option value="">Choose an option</option>
|
||||
{#each $backendUiStore.models as model}
|
||||
<option value={model}>{model.name}</option>
|
||||
<option value={model._id}>{model.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
|
|
@ -3,6 +3,14 @@
|
|||
import { Input, Label } from "@budibase/bbui"
|
||||
|
||||
export let value
|
||||
$: modelId = value && value.model ? value.model._id : ""
|
||||
$: schemaFields = Object.keys(value && value.model ? value.model.schema : {})
|
||||
|
||||
function onChangeModel(e) {
|
||||
value.model = $backendUiStore.models.find(
|
||||
model => model._id === e.target.value
|
||||
)
|
||||
}
|
||||
|
||||
function setParsedValue(evt, field) {
|
||||
const fieldSchema = value.model.schema[field]
|
||||
|
@ -10,23 +18,27 @@
|
|||
value[field] = parseInt(evt.target.value)
|
||||
return
|
||||
}
|
||||
|
||||
value[field] = evt.target.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bb-margin-xl block-field">
|
||||
<select class="budibase__input" bind:value={value.model}>
|
||||
<div class="block-field">
|
||||
<select
|
||||
class="budibase__input"
|
||||
value={modelId}
|
||||
on:blur={onChangeModel}
|
||||
on:change={onChangeModel}>
|
||||
<option value="">Choose an option</option>
|
||||
{#each $backendUiStore.models as model}
|
||||
<option value={model}>{model.name}</option>
|
||||
<option value={model._id}>{model.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if value.model}
|
||||
{#if schemaFields.length}
|
||||
<div class="bb-margin-xl block-field">
|
||||
<Label small forAttr={'fields'}>Fields</Label>
|
||||
{#each Object.keys(value.model.schema) as field}
|
||||
{#each schemaFields as field}
|
||||
<div class="bb-margin-xl">
|
||||
<Input
|
||||
thin
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script>
|
||||
import { fade } from "svelte/transition"
|
||||
import { onMount, getContext } from "svelte"
|
||||
import { getContext } from "svelte"
|
||||
import { backendUiStore, workflowStore } from "builderStore"
|
||||
import { notifier } from "builderStore/store/notifications"
|
||||
import WorkflowBlockSetup from "./WorkflowBlockSetup.svelte"
|
||||
|
@ -9,49 +8,35 @@
|
|||
|
||||
const { open, close } = getContext("simple-modal")
|
||||
|
||||
const ACCESS_LEVELS = [
|
||||
{
|
||||
name: "Admin",
|
||||
key: "ADMIN",
|
||||
canExecute: true,
|
||||
editable: false,
|
||||
},
|
||||
{
|
||||
name: "Power User",
|
||||
key: "POWER_USER",
|
||||
canExecute: true,
|
||||
editable: false,
|
||||
},
|
||||
]
|
||||
|
||||
let selectedTab = "SETUP"
|
||||
let testResult
|
||||
|
||||
$: workflow =
|
||||
$workflowStore.currentWorkflow && $workflowStore.currentWorkflow.workflow
|
||||
$: workflowBlock = $workflowStore.selectedWorkflowBlock
|
||||
$workflowStore.selectedWorkflow && $workflowStore.selectedWorkflow.workflow
|
||||
|
||||
function deleteWorkflow() {
|
||||
open(
|
||||
DeleteWorkflowModal,
|
||||
{
|
||||
onClosed: close,
|
||||
},
|
||||
{ onClosed: close },
|
||||
{ styleContent: { padding: "0" } }
|
||||
)
|
||||
}
|
||||
|
||||
function deleteWorkflowBlock() {
|
||||
workflowStore.actions.deleteWorkflowBlock(workflowBlock)
|
||||
notifier.info("Workflow block deleted.")
|
||||
workflowStore.actions.deleteWorkflowBlock($workflowStore.selectedBlock)
|
||||
}
|
||||
|
||||
function testWorkflow() {
|
||||
testResult = "PASSED"
|
||||
async function testWorkflow() {
|
||||
const result = await workflowStore.actions.trigger({
|
||||
workflow: $workflowStore.selectedWorkflow.workflow,
|
||||
})
|
||||
if (result.status === 200) {
|
||||
notifier.success(`Workflow ${workflow.name} triggered successfully.`)
|
||||
} else {
|
||||
notifier.danger(`Failed to trigger workflow ${workflow.name}.`)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWorkflow() {
|
||||
const workflow = $workflowStore.currentWorkflow.workflow
|
||||
await workflowStore.actions.save({
|
||||
instanceId: $backendUiStore.selectedDatabase._id,
|
||||
workflow,
|
||||
|
@ -65,38 +50,27 @@
|
|||
<span
|
||||
class="hoverable"
|
||||
class:selected={selectedTab === 'SETUP'}
|
||||
on:click={() => {
|
||||
selectedTab = 'SETUP'
|
||||
testResult = null
|
||||
}}>
|
||||
on:click={() => (selectedTab = 'SETUP')}>
|
||||
Setup
|
||||
</span>
|
||||
{#if !workflowBlock}
|
||||
<span
|
||||
class="test-tab"
|
||||
class:selected={selectedTab === 'TEST'}
|
||||
on:click={() => (selectedTab = 'TEST')}>
|
||||
Test
|
||||
</span>
|
||||
{/if}
|
||||
</header>
|
||||
{#if selectedTab === 'TEST'}
|
||||
<div class="bb-margin-m">
|
||||
{#if testResult}
|
||||
<button
|
||||
transition:fade
|
||||
class:passed={testResult === 'PASSED'}
|
||||
class:failed={testResult === 'FAILED'}
|
||||
class="test-result">
|
||||
{testResult}
|
||||
</button>
|
||||
{/if}
|
||||
<Button secondary wide on:click={testWorkflow}>Test Workflow</Button>
|
||||
{#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>
|
||||
{/if}
|
||||
{#if selectedTab === 'SETUP'}
|
||||
{#if workflowBlock}
|
||||
<WorkflowBlockSetup {workflowBlock} />
|
||||
{: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
|
||||
|
@ -105,40 +79,9 @@
|
|||
on:click={saveWorkflow}>
|
||||
Save Workflow
|
||||
</Button>
|
||||
<Button red wide on:click={deleteWorkflowBlock}>Delete Block</Button>
|
||||
<Button red wide on:click={deleteWorkflow}>Delete Workflow</Button>
|
||||
</div>
|
||||
{:else if $workflowStore.currentWorkflow}
|
||||
<div class="panel">
|
||||
<div class="panel-body">
|
||||
<div class="block-label">Workflow: {workflow.name}</div>
|
||||
<div class="config-item">
|
||||
<Label small forAttr={'useraccess'}>User Access</Label>
|
||||
<div class="access-levels">
|
||||
|
||||
{#each ACCESS_LEVELS as level}
|
||||
<span class="access-level">
|
||||
<label>{level.name}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!level.editable}
|
||||
bind:checked={level.canExecute} />
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
@ -181,10 +124,6 @@
|
|||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
header > span {
|
||||
color: var(--grey-5);
|
||||
margin-right: 20px;
|
||||
|
@ -205,35 +144,8 @@
|
|||
gap: 12px;
|
||||
}
|
||||
|
||||
.access-level {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.access-level label {
|
||||
font-weight: normal;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.test-result {
|
||||
border: none;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--white);
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.passed {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.failed {
|
||||
background: var(--red);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,55 +1,45 @@
|
|||
<script>
|
||||
import { backendUiStore, store } from "builderStore"
|
||||
import ComponentSelector from "./ParamInputs/ComponentSelector.svelte"
|
||||
import ModelSelector from "./ParamInputs/ModelSelector.svelte"
|
||||
import RecordSelector from "./ParamInputs/RecordSelector.svelte"
|
||||
import { Input, TextArea, Select } from "@budibase/bbui"
|
||||
|
||||
export let workflowBlock
|
||||
|
||||
let params
|
||||
|
||||
$: workflowParams = workflowBlock.params
|
||||
? Object.entries(workflowBlock.params)
|
||||
: []
|
||||
export let block
|
||||
$: params = block.params ? Object.entries(block.params) : []
|
||||
</script>
|
||||
|
||||
<label class="selected-label">{workflowBlock.type}: {workflowBlock.name}</label>
|
||||
{#each workflowParams as [parameter, type]}
|
||||
<div class="block-field">
|
||||
<label class="label">{parameter}</label>
|
||||
{#if Array.isArray(type)}
|
||||
<Select bind:value={workflowBlock.args[parameter]} thin>
|
||||
{#each type as option}
|
||||
<option value={option}>{option}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{:else if type === 'component'}
|
||||
<ComponentSelector bind:value={workflowBlock.args[parameter]} />
|
||||
{:else if type === 'accessLevel'}
|
||||
<Select bind:value={workflowBlock.args[parameter]} thin>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="POWER_USER">Power User</option>
|
||||
</Select>
|
||||
{:else if type === 'password'}
|
||||
<Input type="password" thin bind:value={workflowBlock.args[parameter]} />
|
||||
{:else if type === 'number'}
|
||||
<Input type="number" thin bind:value={workflowBlock.args[parameter]} />
|
||||
{:else if type === 'longText'}
|
||||
<TextArea
|
||||
type="text"
|
||||
thin
|
||||
bind:value={workflowBlock.args[parameter]}
|
||||
label="" />
|
||||
{:else if type === 'model'}
|
||||
<ModelSelector bind:value={workflowBlock.args[parameter]} />
|
||||
{:else if type === 'record'}
|
||||
<RecordSelector value={workflowBlock.args[parameter]} />
|
||||
{:else if type === 'string'}
|
||||
<Input type="text" thin bind:value={workflowBlock.args[parameter]} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<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 {
|
||||
|
@ -57,16 +47,19 @@
|
|||
}
|
||||
|
||||
label {
|
||||
text-transform: capitalize;
|
||||
font-size: 14px;
|
||||
font-family: sans-serif;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
margin-bottom: 12px;
|
||||
text-transform: capitalize;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.selected-label {
|
||||
text-transform: capitalize;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--grey-7);
|
||||
}
|
||||
|
||||
textarea {
|
||||
|
|
|
@ -1,30 +1,22 @@
|
|||
<script>
|
||||
import { onMount } from "svelte"
|
||||
import { afterUpdate } from "svelte"
|
||||
import { workflowStore, backendUiStore } from "builderStore"
|
||||
import { notifier } from "builderStore/store/notifications"
|
||||
import Flowchart from "./flowchart/FlowChart.svelte"
|
||||
|
||||
let selectedWorkflow
|
||||
let uiTree
|
||||
let instanceId = $backendUiStore.selectedDatabase._id
|
||||
|
||||
$: selectedWorkflow = $workflowStore.currentWorkflow
|
||||
|
||||
$: workflowLive = selectedWorkflow && selectedWorkflow.workflow.live
|
||||
|
||||
$: uiTree = selectedWorkflow ? selectedWorkflow.createUiTree() : []
|
||||
|
||||
$: workflow =
|
||||
$workflowStore.selectedWorkflow && $workflowStore.selectedWorkflow.workflow
|
||||
$: workflowLive = workflow && workflow.live
|
||||
$: instanceId = $backendUiStore.selectedDatabase._id
|
||||
|
||||
function onSelect(block) {
|
||||
workflowStore.update(state => {
|
||||
state.selectedWorkflowBlock = block
|
||||
state.selectedBlock = block
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
function setWorkflowLive(live) {
|
||||
const { workflow } = selectedWorkflow
|
||||
workflow.live = live
|
||||
workflowStore.actions.save({ instanceId, workflow })
|
||||
if (live) {
|
||||
|
@ -36,32 +28,42 @@
|
|||
</script>
|
||||
|
||||
<section>
|
||||
<Flowchart blocks={uiTree} {onSelect} />
|
||||
<footer>
|
||||
{#if selectedWorkflow}
|
||||
<button
|
||||
class:highlighted={workflowLive}
|
||||
class:hoverable={workflowLive}
|
||||
class="stop-button hoverable">
|
||||
<i class="ri-stop-fill" on:click={() => setWorkflowLive(false)} />
|
||||
</button>
|
||||
<button
|
||||
class:highlighted={!workflowLive}
|
||||
class:hoverable={!workflowLive}
|
||||
class="play-button hoverable"
|
||||
data-cy="activate-workflow"
|
||||
on:click={() => setWorkflowLive(true)}>
|
||||
<i class="ri-play-fill" />
|
||||
</button>
|
||||
{/if}
|
||||
</footer>
|
||||
<Flowchart {workflow} {onSelect} />
|
||||
</section>
|
||||
<footer>
|
||||
{#if workflow}
|
||||
<button
|
||||
class:highlighted={workflowLive}
|
||||
class:hoverable={workflowLive}
|
||||
class="stop-button hoverable">
|
||||
<i class="ri-stop-fill" on:click={() => setWorkflowLive(false)} />
|
||||
</button>
|
||||
<button
|
||||
class:highlighted={!workflowLive}
|
||||
class:hoverable={!workflowLive}
|
||||
class="play-button hoverable"
|
||||
data-cy="activate-workflow"
|
||||
on:click={() => setWorkflowLive(true)}>
|
||||
<i class="ri-play-fill" />
|
||||
</button>
|
||||
{/if}
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
bottom: 20px;
|
||||
right: 30px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
@ -77,7 +79,9 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 24px;
|
||||
}
|
||||
footer > button:first-child {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.play-button.highlighted {
|
||||
|
|
|
@ -7,3 +7,9 @@
|
|||
<path d="M5.0625 70H9L4.5 75L0 70H3.9375V65H5.0625V70Z" fill="#ADAEC4" />
|
||||
<rect x="4" width="1" height="65" fill="#ADAEC4" />
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
|
|
Before Width: | Height: | Size: 241 B After Width: | Height: | Size: 290 B |
|
@ -1,24 +1,52 @@
|
|||
<script>
|
||||
import FlowItem from "./FlowItem.svelte"
|
||||
import Arrow from "./Arrow.svelte"
|
||||
import { flip } from "svelte/animate"
|
||||
import { fade, fly } from "svelte/transition"
|
||||
|
||||
export let blocks = []
|
||||
export let workflow
|
||||
export let onSelect
|
||||
let blocks
|
||||
|
||||
$: {
|
||||
blocks = []
|
||||
if (workflow) {
|
||||
if (workflow.definition.trigger) {
|
||||
blocks.push(workflow.definition.trigger)
|
||||
}
|
||||
blocks = blocks.concat(workflow.definition.steps || [])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="canvas">
|
||||
{#each blocks as block, idx}
|
||||
<FlowItem {onSelect} {block} />
|
||||
{#if idx !== blocks.length - 1}
|
||||
<Arrow />
|
||||
{/if}
|
||||
{#each blocks as block, idx (block.id)}
|
||||
<div
|
||||
class="block"
|
||||
animate:flip={{ duration: 600 }}
|
||||
in:fade|local
|
||||
out:fly|local={{ x: 100 }}>
|
||||
<FlowItem {onSelect} {block} />
|
||||
{#if idx !== blocks.length - 1}
|
||||
<Arrow />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.canvas {
|
||||
section {
|
||||
position: absolute;
|
||||
padding: 20px 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,15 +1,21 @@
|
|||
<script>
|
||||
import { fade } from "svelte/transition"
|
||||
import mustache from "mustache"
|
||||
import { workflowStore } from "builderStore"
|
||||
|
||||
export let onSelect
|
||||
export let block
|
||||
let selected
|
||||
|
||||
$: selected =
|
||||
$workflowStore.selectedBlock != null &&
|
||||
$workflowStore.selectedBlock.id === block.id
|
||||
|
||||
function selectBlock() {
|
||||
onSelect(block)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div transition:fade class={`${block.type} hoverable`} on:click={selectBlock}>
|
||||
<div class={`${block.type} hoverable`} class:selected on:click={selectBlock}>
|
||||
<header>
|
||||
{#if block.type === 'TRIGGER'}
|
||||
<i class="ri-lightbulb-fill" />
|
||||
|
@ -24,7 +30,7 @@
|
|||
</header>
|
||||
<hr />
|
||||
<p>
|
||||
{@html block.body}
|
||||
{@html mustache.render(block.tagline, block.args)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
@ -32,8 +38,8 @@
|
|||
div {
|
||||
width: 320px;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
transition: 0.3s all;
|
||||
border-radius: var(--border-radius-m);
|
||||
transition: 0.3s all ease;
|
||||
box-shadow: 0 4px 30px 0 rgba(57, 60, 68, 0.08);
|
||||
background-color: var(--ink);
|
||||
font-size: 16px;
|
||||
|
@ -69,9 +75,12 @@
|
|||
|
||||
p {
|
||||
color: inherit;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
div.selected,
|
||||
div:hover {
|
||||
transform: scale(1.05);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 30px 0 rgba(57, 60, 68, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,82 +1,39 @@
|
|||
<script>
|
||||
import { onMount } from "svelte"
|
||||
import { backendUiStore, workflowStore } from "builderStore"
|
||||
import { WorkflowList } from "../"
|
||||
import { workflowStore } from "builderStore"
|
||||
import WorkflowBlock from "./WorkflowBlock.svelte"
|
||||
import blockDefinitions from "../blockDefinitions"
|
||||
import FlatButtonGroup from "components/userInterface/FlatButtonGroup.svelte"
|
||||
|
||||
let selectedTab = "TRIGGER"
|
||||
let definitions = []
|
||||
|
||||
$: definitions = Object.entries(blockDefinitions[selectedTab])
|
||||
let buttonProps = []
|
||||
$: blocks = Object.entries($workflowStore.blockDefinitions[selectedTab])
|
||||
|
||||
$: {
|
||||
if (
|
||||
$workflowStore.currentWorkflow.hasTrigger() &&
|
||||
selectedTab === "TRIGGER"
|
||||
) {
|
||||
selectedTab = "ACTION"
|
||||
if ($workflowStore.selectedWorkflow.hasTrigger()) {
|
||||
buttonProps = [
|
||||
{ value: "ACTION", text: "Action" },
|
||||
{ value: "LOGIC", text: "Logic" },
|
||||
]
|
||||
if (selectedTab === "TRIGGER") {
|
||||
selectedTab = "ACTION"
|
||||
}
|
||||
} else {
|
||||
buttonProps = [{ value: "TRIGGER", text: "Trigger" }]
|
||||
if (selectedTab !== "TRIGGER") {
|
||||
selectedTab = "TRIGGER"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeTab(tab) {
|
||||
selectedTab = tab
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div class="subtabs">
|
||||
{#if !$workflowStore.currentWorkflow.hasTrigger()}
|
||||
<span
|
||||
class="hoverable"
|
||||
class:selected={'TRIGGER' === selectedTab}
|
||||
on:click={() => (selectedTab = 'TRIGGER')}>
|
||||
Triggers
|
||||
</span>
|
||||
{/if}
|
||||
<span
|
||||
class="hoverable"
|
||||
class:selected={'ACTION' === selectedTab}
|
||||
on:click={() => (selectedTab = 'ACTION')}>
|
||||
Actions
|
||||
</span>
|
||||
<span
|
||||
class="hoverable"
|
||||
class:selected={'LOGIC' === selectedTab}
|
||||
on:click={() => (selectedTab = 'LOGIC')}>
|
||||
Logic
|
||||
</span>
|
||||
</div>
|
||||
<FlatButtonGroup value={selectedTab} {buttonProps} onChange={onChangeTab} />
|
||||
<div id="blocklist">
|
||||
{#each definitions as [actionId, blockDefinition]}
|
||||
<WorkflowBlock {blockDefinition} {actionId} blockType={selectedTab} />
|
||||
{#each blocks as [stepId, blockDefinition]}
|
||||
<WorkflowBlock {blockDefinition} {stepId} blockType={selectedTab} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.subtabs {
|
||||
margin-top: 20px;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr 1fr 1fr;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.subtabs span {
|
||||
transition: 0.3s all;
|
||||
text-align: center;
|
||||
color: var(--grey-7);
|
||||
font-weight: 400;
|
||||
padding: 8px 16px;
|
||||
text-rendering: optimizeLegibility;
|
||||
border: none !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.subtabs span.selected {
|
||||
background: var(--grey-3);
|
||||
color: var(--ink);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.subtabs span:not(.selected) {
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
<script>
|
||||
import { workflowStore } from "builderStore"
|
||||
|
||||
export let blockType
|
||||
export let blockDefinition
|
||||
export let actionId
|
||||
export let stepId
|
||||
export let blockType
|
||||
|
||||
function addBlockToWorkflow() {
|
||||
workflowStore.actions.addBlockToWorkflow({
|
||||
...blockDefinition,
|
||||
args: blockDefinition.args || {},
|
||||
actionId,
|
||||
stepId,
|
||||
type: blockType,
|
||||
})
|
||||
}
|
||||
|
@ -18,7 +18,7 @@
|
|||
<div
|
||||
class="workflow-block hoverable"
|
||||
on:click={addBlockToWorkflow}
|
||||
data-cy={actionId}>
|
||||
data-cy={stepId}>
|
||||
<div>
|
||||
<i class={blockDefinition.icon} />
|
||||
</div>
|
||||
|
@ -31,11 +31,11 @@
|
|||
<style>
|
||||
.workflow-block {
|
||||
display: grid;
|
||||
grid-template-columns: 40px auto;
|
||||
grid-template-columns: 20px auto;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding: 16px 0px;
|
||||
border-radius: var(--border);
|
||||
padding: 12px;
|
||||
border-radius: var(--border-radius-m);
|
||||
}
|
||||
|
||||
.workflow-block:hover {
|
||||
|
@ -43,7 +43,7 @@
|
|||
}
|
||||
|
||||
.workflow-text {
|
||||
margin-left: 12px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
@ -64,6 +64,7 @@
|
|||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
|
|
|
@ -8,9 +8,9 @@
|
|||
|
||||
const { open, close } = getContext("simple-modal")
|
||||
|
||||
$: currentWorkflowId =
|
||||
$workflowStore.currentWorkflow &&
|
||||
$workflowStore.currentWorkflow.workflow._id
|
||||
$: selectedWorkflowId =
|
||||
$workflowStore.selectedWorkflow &&
|
||||
$workflowStore.selectedWorkflow.workflow._id
|
||||
|
||||
function newWorkflow() {
|
||||
open(
|
||||
|
@ -33,7 +33,7 @@
|
|||
{#each $workflowStore.workflows as workflow}
|
||||
<li
|
||||
class="workflow-item"
|
||||
class:selected={workflow._id === currentWorkflowId}
|
||||
class:selected={workflow._id === selectedWorkflowId}
|
||||
on:click={() => workflowStore.actions.select(workflow)}>
|
||||
<i class="ri-stackshare-line" class:live={workflow.live} />
|
||||
{workflow.name}
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
<script>
|
||||
import { onMount } from "svelte"
|
||||
import { backendUiStore, workflowStore } from "builderStore"
|
||||
import { WorkflowList, BlockList } from "./"
|
||||
import blockDefinitions from "./blockDefinitions"
|
||||
import { workflowStore } from "builderStore"
|
||||
import WorkflowList from "./WorkflowList/WorkflowList.svelte"
|
||||
import BlockList from "./BlockList/BlockList.svelte"
|
||||
|
||||
let selectedTab = "WORKFLOWS"
|
||||
let definitions = []
|
||||
</script>
|
||||
|
||||
<header>
|
||||
|
@ -16,7 +14,7 @@
|
|||
on:click={() => (selectedTab = 'WORKFLOWS')}>
|
||||
Workflows
|
||||
</span>
|
||||
{#if $workflowStore.currentWorkflow}
|
||||
{#if $workflowStore.selectedWorkflow}
|
||||
<span
|
||||
data-cy="add-workflow-component"
|
||||
class="hoverable"
|
||||
|
|
|
@ -12,11 +12,13 @@
|
|||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="nav">
|
||||
<div class="inner">
|
||||
<SetupPanel />
|
||||
{#if $workflowStore.selectedWorkflow}
|
||||
<div class="nav">
|
||||
<div class="inner">
|
||||
<SetupPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
@ -35,13 +37,11 @@
|
|||
|
||||
.content {
|
||||
flex: 1 1 auto;
|
||||
margin: 20px 40px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
overflow: auto;
|
||||
width: 300px;
|
||||
border-right: 1px solid var(--grey-2);
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
|
|
|
@ -28,7 +28,8 @@ describe("fetch bindable properties", () => {
|
|||
...testData()
|
||||
})
|
||||
const contextBindings = result.filter(r => r.instance._id === "list-id" && r.type==="context")
|
||||
expect(contextBindings.length).toBe(2)
|
||||
// 2 fields + _id + _rev
|
||||
expect(contextBindings.length).toBe(4)
|
||||
|
||||
const namebinding = contextBindings.find(b => b.runtimeBinding === "data.name")
|
||||
expect(namebinding).toBeDefined()
|
||||
|
@ -37,6 +38,10 @@ describe("fetch bindable properties", () => {
|
|||
const descriptionbinding = contextBindings.find(b => b.runtimeBinding === "data.description")
|
||||
expect(descriptionbinding).toBeDefined()
|
||||
expect(descriptionbinding.readableBinding).toBe("list-name.Test Model.description")
|
||||
|
||||
const idbinding = contextBindings.find(b => b.runtimeBinding === "data._id")
|
||||
expect(idbinding).toBeDefined()
|
||||
expect(idbinding.readableBinding).toBe("list-name.Test Model._id")
|
||||
})
|
||||
|
||||
it("should return model schema, for grantparent context", () => {
|
||||
|
@ -45,7 +50,8 @@ describe("fetch bindable properties", () => {
|
|||
...testData()
|
||||
})
|
||||
const contextBindings = result.filter(r => r.type==="context")
|
||||
expect(contextBindings.length).toBe(4)
|
||||
// 2 fields + _id + _rev ... x 2 models
|
||||
expect(contextBindings.length).toBe(8)
|
||||
|
||||
const namebinding_parent = contextBindings.find(b => b.runtimeBinding === "parent.data.name")
|
||||
expect(namebinding_parent).toBeDefined()
|
||||
|
@ -120,7 +126,7 @@ const testData = () => {
|
|||
_id: "list-id",
|
||||
_component: "@budibase/standard-components/list",
|
||||
_instanceName: "list-name",
|
||||
model: "test-model-id",
|
||||
model: { isModel: true, modelId: "test-model-id", label: "Test Model", name: "all_test-model-id" },
|
||||
_children: [
|
||||
{
|
||||
_id: "list-item-heading-id",
|
||||
|
@ -138,7 +144,7 @@ const testData = () => {
|
|||
_id: "child-list-id",
|
||||
_component: "@budibase/standard-components/list",
|
||||
_instanceName: "child-list-name",
|
||||
model: "test-model-id",
|
||||
model: { isModel: true, modelId: "test-model-id", label: "Test Model", name: "all_test-model-id"},
|
||||
_children: [
|
||||
{
|
||||
_id: "child-list-item-heading-id",
|
||||
|
|
|
@ -688,14 +688,15 @@
|
|||
lodash "^4.17.13"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@budibase/bbui@^1.32.0":
|
||||
version "1.32.0"
|
||||
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.32.0.tgz#4b099e51cf8aebfc963a763bb9687994a2ee26a8"
|
||||
"@budibase/bbui@^1.33.0":
|
||||
version "1.33.0"
|
||||
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.33.0.tgz#216b24dd815f45880e9795e66b04848329b0390f"
|
||||
integrity sha512-Rrt5eLbea014TIfAbT40kP0D0AWNUi8Q0kDr3UZO6Aq4UXgjc0f53ZuJ7Kb66YRDWrqiucjf1FtvOUs3/YaD6g==
|
||||
dependencies:
|
||||
sirv-cli "^0.4.6"
|
||||
svelte-flatpickr "^2.4.0"
|
||||
|
||||
"@budibase/client@^0.1.19":
|
||||
"@budibase/client@^0.1.21":
|
||||
version "0.1.21"
|
||||
resolved "https://registry.yarnpkg.com/@budibase/client/-/client-0.1.21.tgz#db414445c132b373f6c25e39d62628eb60cd8ac3"
|
||||
integrity sha512-/ju0vYbWh9MUjmxkGNlOL4S/VQd4p5mbz5rHu0yt55ak9t/yyzI6PzBBxlucBeRbXYd9OFynFjy1pvYt1v+z9Q==
|
||||
|
@ -756,6 +757,10 @@
|
|||
debug "^3.1.0"
|
||||
lodash.once "^4.1.1"
|
||||
|
||||
"@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"
|
||||
|
||||
"@hapi/address@^2.1.2":
|
||||
version "2.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5"
|
||||
|
@ -1382,7 +1387,6 @@ 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"
|
||||
|
@ -1441,7 +1445,6 @@ 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"
|
||||
|
||||
|
@ -1816,10 +1819,6 @@ class-utils@^0.3.5:
|
|||
isobject "^3.0.0"
|
||||
static-extend "^0.1.1"
|
||||
|
||||
classnames@^2.2.5:
|
||||
version "2.2.6"
|
||||
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
|
||||
|
||||
cli-cursor@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
|
||||
|
@ -1967,10 +1966,6 @@ core-js-pure@^3.0.0:
|
|||
version "3.6.5"
|
||||
resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813"
|
||||
|
||||
core-js@^3.1.3:
|
||||
version "3.6.5"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a"
|
||||
|
||||
core-util-is@1.0.2, core-util-is@~1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
|
||||
|
@ -2405,7 +2400,6 @@ 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"
|
||||
|
@ -2594,7 +2588,6 @@ 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"
|
||||
|
@ -2611,7 +2604,6 @@ 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"
|
||||
|
@ -2629,7 +2621,6 @@ 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"
|
||||
|
@ -2889,13 +2880,6 @@ fd-slicer@~1.1.0:
|
|||
dependencies:
|
||||
pend "~1.2.0"
|
||||
|
||||
feather-icons@^4.21.0:
|
||||
version "4.28.0"
|
||||
resolved "https://registry.yarnpkg.com/feather-icons/-/feather-icons-4.28.0.tgz#e1892a401fe12c4559291770ff6e68b0168e760f"
|
||||
dependencies:
|
||||
classnames "^2.2.5"
|
||||
core-js "^3.1.3"
|
||||
|
||||
figures@^1.7.0:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
|
||||
|
@ -3329,7 +3313,6 @@ 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"
|
||||
|
@ -3338,7 +3321,6 @@ 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"
|
||||
|
@ -3349,7 +3331,6 @@ 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"
|
||||
|
@ -3362,7 +3343,6 @@ 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"
|
||||
|
@ -3450,7 +3430,6 @@ 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"
|
||||
|
@ -3459,12 +3438,10 @@ 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"
|
||||
|
@ -3525,14 +3502,12 @@ 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"
|
||||
|
@ -3545,7 +3520,6 @@ 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"
|
||||
|
@ -3556,7 +3530,6 @@ 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"
|
||||
|
@ -3570,12 +3543,10 @@ 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"
|
||||
|
@ -3600,7 +3571,6 @@ 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"
|
||||
|
@ -4715,12 +4685,10 @@ 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"
|
||||
|
@ -5238,7 +5206,6 @@ 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"
|
||||
|
@ -5246,7 +5213,6 @@ 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"
|
||||
|
@ -5655,7 +5621,6 @@ 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"
|
||||
|
@ -5996,11 +5961,6 @@ supports-color@^7.1.0:
|
|||
dependencies:
|
||||
has-flag "^4.0.0"
|
||||
|
||||
svelte-filepond@^0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/svelte-filepond/-/svelte-filepond-0.0.1.tgz#7c20379213dac746192499d3d1de649d6db51c4b"
|
||||
integrity sha512-R5z/Gj/2VSdV70GxvW226ww4zrVyV2EFTXrsWIdn7sEB7uYWIJvvciUNQVTfYZfcAgGsa4gprGsuqWNG3auSKw==
|
||||
|
||||
svelte-flatpickr@^2.4.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/svelte-flatpickr/-/svelte-flatpickr-2.4.0.tgz#190871fc3305956c8c8fd3601cd036b8ac71ef49"
|
||||
|
@ -6363,7 +6323,6 @@ 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"
|
||||
|
@ -6374,7 +6333,6 @@ 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"
|
||||
|
@ -6388,7 +6346,6 @@ 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"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "budibase",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"description": "Budibase CLI",
|
||||
"repository": "https://github.com/Budibase/Budibase",
|
||||
"homepage": "https://www.budibase.com",
|
||||
|
@ -17,7 +17,7 @@
|
|||
"author": "Budibase",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@budibase/server": "^0.1.19",
|
||||
"@budibase/server": "^0.1.21",
|
||||
"@inquirer/password": "^0.0.6-alpha.0",
|
||||
"chalk": "^2.4.2",
|
||||
"dotenv": "^8.2.0",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/client",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"license": "MPL-2.0",
|
||||
"main": "dist/budibase-client.js",
|
||||
"module": "dist/budibase-client.esm.mjs",
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { authenticate } from "./authenticate"
|
||||
import { triggerWorkflow } from "./workflow"
|
||||
import appStore from "../state/store"
|
||||
|
||||
const apiCall = method => async ({ url, body }) => {
|
||||
|
@ -53,7 +52,49 @@ const apiOpts = {
|
|||
delete: del,
|
||||
}
|
||||
|
||||
const createRecord = async params =>
|
||||
await post({
|
||||
url: `/api/${params.modelId}/records`,
|
||||
body: makeRecordRequestBody(params),
|
||||
})
|
||||
|
||||
const updateRecord = async params => {
|
||||
const record = makeRecordRequestBody(params)
|
||||
record._id = params._id
|
||||
await patch({
|
||||
url: `/api/${params.modelId}/records/${params._id}`,
|
||||
body: record,
|
||||
})
|
||||
}
|
||||
|
||||
const makeRecordRequestBody = parameters => {
|
||||
const body = {}
|
||||
for (let fieldName in parameters.fields) {
|
||||
const field = parameters.fields[fieldName]
|
||||
|
||||
// ensure fields sent are of the correct type
|
||||
if (field.type === "boolean") {
|
||||
if (field.value === "true") body[fieldName] = true
|
||||
if (field.value === "false") body[fieldName] = false
|
||||
} else if (field.type === "number") {
|
||||
const val = parseFloat(field.value)
|
||||
if (!isNaN(val)) {
|
||||
body[fieldName] = val
|
||||
}
|
||||
} else if (field.type === "datetime") {
|
||||
const date = new Date(field.value)
|
||||
if (!isNaN(date.getTime())) {
|
||||
body[fieldName] = date.toISOString()
|
||||
}
|
||||
} else {
|
||||
body[fieldName] = field.value
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
export default {
|
||||
authenticate: authenticate(apiOpts),
|
||||
triggerWorkflow: triggerWorkflow(apiOpts),
|
||||
createRecord,
|
||||
updateRecord,
|
||||
}
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
export default {
|
||||
NAVIGATE: () => {
|
||||
// TODO client navigation
|
||||
},
|
||||
DELAY: async ({ args }) => await delay(args.time),
|
||||
FILTER: ({ args }) => {
|
||||
const { field, condition, value } = args
|
||||
switch (condition) {
|
||||
case "equals":
|
||||
if (field !== value) return
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
|
@ -1,68 +0,0 @@
|
|||
import renderTemplateString from "../../state/renderTemplateString"
|
||||
import appStore from "../../state/store"
|
||||
import Orchestrator from "./orchestrator"
|
||||
import clientActions from "./actions"
|
||||
|
||||
// Execute a workflow from a running budibase app
|
||||
export const clientStrategy = ({ api }) => ({
|
||||
context: {},
|
||||
bindContextArgs: function(args) {
|
||||
const mappedArgs = { ...args }
|
||||
|
||||
// bind the workflow action args to the workflow context, if required
|
||||
for (let arg in args) {
|
||||
const argValue = args[arg]
|
||||
|
||||
// We don't want to render mustache templates on non-strings
|
||||
if (typeof argValue !== "string") continue
|
||||
|
||||
// Render the string with values from the workflow context and state
|
||||
mappedArgs[arg] = renderTemplateString(argValue, {
|
||||
context: this.context,
|
||||
state: appStore.get(),
|
||||
})
|
||||
}
|
||||
|
||||
return mappedArgs
|
||||
},
|
||||
run: async function(workflow) {
|
||||
for (let block of workflow.steps) {
|
||||
// This code gets run in the browser
|
||||
if (block.environment === "CLIENT") {
|
||||
const action = clientActions[block.actionId]
|
||||
await action({
|
||||
context: this.context,
|
||||
args: this.bindContextArgs(block.args),
|
||||
id: block.id,
|
||||
})
|
||||
}
|
||||
|
||||
// this workflow block gets executed on the server
|
||||
if (block.environment === "SERVER") {
|
||||
const EXECUTE_WORKFLOW_URL = `/api/workflows/action`
|
||||
const response = await api.post({
|
||||
url: EXECUTE_WORKFLOW_URL,
|
||||
body: {
|
||||
action: block.actionId,
|
||||
args: this.bindContextArgs(block.args, api),
|
||||
},
|
||||
})
|
||||
|
||||
this.context = {
|
||||
...this.context,
|
||||
[block.actionId]: response,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const triggerWorkflow = api => async ({ workflow }) => {
|
||||
const workflowOrchestrator = new Orchestrator(api)
|
||||
workflowOrchestrator.strategy = clientStrategy
|
||||
|
||||
const EXECUTE_WORKFLOW_URL = `/api/workflows/${workflow}`
|
||||
const workflowDefinition = await api.get({ url: EXECUTE_WORKFLOW_URL })
|
||||
|
||||
workflowOrchestrator.execute(workflowDefinition)
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
/**
|
||||
* The workflow orchestrator is a class responsible for executing workflows.
|
||||
* It relies on the strategy pattern, which allows composable behaviour to be
|
||||
* passed into its execute() function. This allows custom execution behaviour based
|
||||
* on where the orchestrator is run.
|
||||
*
|
||||
*/
|
||||
export default class Orchestrator {
|
||||
constructor(api) {
|
||||
this.api = api
|
||||
}
|
||||
|
||||
set strategy(strategy) {
|
||||
this._strategy = strategy({ api: this.api })
|
||||
}
|
||||
|
||||
async execute(workflow) {
|
||||
if (workflow.live) {
|
||||
this._strategy.run(workflow.definition)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -50,7 +50,6 @@ export const createApp = ({
|
|||
treeNode,
|
||||
onScreenSlotRendered,
|
||||
setupState: stateManager.setup,
|
||||
getCurrentState: stateManager.getCurrentState,
|
||||
})
|
||||
|
||||
return getInitialiseParams
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import setBindableComponentProp from "./setBindableComponentProp"
|
||||
import { attachChildren } from "../render/attachChildren"
|
||||
import store from "../state/store"
|
||||
|
||||
export const trimSlash = str => str.replace(/^\/+|\/+$/g, "")
|
||||
|
||||
export const bbFactory = ({
|
||||
componentLibraries,
|
||||
onScreenSlotRendered,
|
||||
getCurrentState,
|
||||
runEventActions,
|
||||
}) => {
|
||||
const apiCall = method => (url, body) => {
|
||||
return fetch(url, {
|
||||
|
@ -26,13 +27,6 @@ export const bbFactory = ({
|
|||
delete: apiCall("DELETE"),
|
||||
}
|
||||
|
||||
const safeCallEvent = (event, context) => {
|
||||
const isFunction = obj =>
|
||||
!!(obj && obj.constructor && obj.call && obj.apply)
|
||||
|
||||
if (isFunction(event)) event(context)
|
||||
}
|
||||
|
||||
return (treeNode, setupState) => {
|
||||
const attachParams = {
|
||||
componentLibraries,
|
||||
|
@ -44,12 +38,18 @@ export const bbFactory = ({
|
|||
return {
|
||||
attachChildren: attachChildren(attachParams),
|
||||
props: treeNode.props,
|
||||
call: safeCallEvent,
|
||||
call: async eventName =>
|
||||
eventName &&
|
||||
(await runEventActions(
|
||||
treeNode.props[eventName],
|
||||
store.getState(treeNode.contextStoreKey)
|
||||
)),
|
||||
setBinding: setBindableComponentProp(treeNode),
|
||||
api,
|
||||
parent,
|
||||
store: store.getStore(treeNode.contextStoreKey),
|
||||
// these parameters are populated by screenRouter
|
||||
routeParams: () => getCurrentState()["##routeParams"],
|
||||
routeParams: () => store.getState()["##routeParams"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,38 @@
|
|||
import api from "../api"
|
||||
import renderTemplateString from "./renderTemplateString"
|
||||
|
||||
export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType"
|
||||
|
||||
export const eventHandlers = routeTo => {
|
||||
const handler = (parameters, execute) => ({
|
||||
execute,
|
||||
parameters,
|
||||
})
|
||||
|
||||
return {
|
||||
"Navigate To": handler(["url"], param => routeTo(param && param.url)),
|
||||
"Trigger Workflow": handler(["workflow"], api.triggerWorkflow),
|
||||
const handlers = {
|
||||
"Navigate To": param => routeTo(param && param.url),
|
||||
}
|
||||
|
||||
// when an event is called, this is what gets run
|
||||
const runEventActions = async (actions, state) => {
|
||||
if (!actions) return
|
||||
// calls event handlers sequentially
|
||||
for (let action of actions) {
|
||||
const handler = handlers[action[EVENT_TYPE_MEMBER_NAME]]
|
||||
const parameters = createParameters(action.parameters, state)
|
||||
if (handler) {
|
||||
await handler(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return runEventActions
|
||||
}
|
||||
|
||||
export const isEventType = prop =>
|
||||
Array.isArray(prop) &&
|
||||
prop.length > 0 &&
|
||||
!prop[0][EVENT_TYPE_MEMBER_NAME] === undefined
|
||||
// this will take a parameters obj, iterate all keys, and do a mustache render
|
||||
// for every string. It will work recursively if it encounnters an {}
|
||||
const createParameters = (parameterTemplateObj, state) => {
|
||||
const parameters = {}
|
||||
for (let key in parameterTemplateObj) {
|
||||
if (typeof parameterTemplateObj[key] === "string") {
|
||||
parameters[key] = renderTemplateString(parameterTemplateObj[key], state)
|
||||
} else if (typeof parameterTemplateObj[key] === "object") {
|
||||
parameters[key] = createParameters(parameterTemplateObj[key], state)
|
||||
}
|
||||
}
|
||||
return parameters
|
||||
}
|
||||
|
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
isEventType,
|
||||
eventHandlers,
|
||||
EVENT_TYPE_MEMBER_NAME,
|
||||
} from "./eventHandlers"
|
||||
import { eventHandlers } from "./eventHandlers"
|
||||
import { bbFactory } from "./bbComponentApi"
|
||||
import renderTemplateString from "./renderTemplateString"
|
||||
import appStore from "./store"
|
||||
|
@ -25,33 +21,23 @@ export const createStateManager = ({
|
|||
onScreenSlotRendered,
|
||||
routeTo,
|
||||
}) => {
|
||||
let handlerTypes = eventHandlers(routeTo)
|
||||
|
||||
// creating a reference to the current state
|
||||
// this avoids doing store.get() ... which is expensive on
|
||||
// hot paths, according to the svelte docs.
|
||||
// the state object reference never changes (although it's internals do)
|
||||
// so this should work fine for us
|
||||
let currentState
|
||||
appStore.subscribe(s => (currentState = s))
|
||||
const getCurrentState = () => currentState
|
||||
let runEventActions = eventHandlers(routeTo)
|
||||
|
||||
const bb = bbFactory({
|
||||
getCurrentState,
|
||||
componentLibraries,
|
||||
onScreenSlotRendered,
|
||||
runEventActions,
|
||||
})
|
||||
|
||||
const setup = _setup({ handlerTypes, getCurrentState, bb })
|
||||
const setup = _setup(bb)
|
||||
|
||||
return {
|
||||
setup,
|
||||
destroy: () => {},
|
||||
getCurrentState,
|
||||
}
|
||||
}
|
||||
|
||||
const _setup = ({ handlerTypes, getCurrentState, bb }) => node => {
|
||||
const _setup = bb => node => {
|
||||
const props = node.props
|
||||
const initialProps = { ...props }
|
||||
|
||||
|
@ -70,53 +56,10 @@ const _setup = ({ handlerTypes, getCurrentState, bb }) => node => {
|
|||
node.stateBound = true
|
||||
}
|
||||
}
|
||||
|
||||
if (isEventType(propValue)) {
|
||||
const state = appStore.getState(node.contextStoreKey)
|
||||
const handlersInfos = []
|
||||
for (let event of propValue) {
|
||||
const handlerInfo = {
|
||||
handlerType: event[EVENT_TYPE_MEMBER_NAME],
|
||||
parameters: event.parameters,
|
||||
}
|
||||
|
||||
const resolvedParams = {}
|
||||
for (let paramName in handlerInfo.parameters) {
|
||||
const paramValue = handlerInfo.parameters[paramName]
|
||||
resolvedParams[paramName] = () =>
|
||||
renderTemplateString(paramValue, state)
|
||||
}
|
||||
|
||||
handlerInfo.parameters = resolvedParams
|
||||
handlersInfos.push(handlerInfo)
|
||||
}
|
||||
|
||||
if (handlersInfos.length === 0) {
|
||||
initialProps[propName] = doNothing
|
||||
} else {
|
||||
initialProps[propName] = async context => {
|
||||
for (let handlerInfo of handlersInfos) {
|
||||
const handler = makeHandler(handlerTypes, handlerInfo)
|
||||
await handler(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setup = _setup({ handlerTypes, getCurrentState, bb })
|
||||
const setup = _setup(bb)
|
||||
initialProps._bb = bb(node, setup)
|
||||
|
||||
return initialProps
|
||||
}
|
||||
|
||||
const makeHandler = (handlerTypes, handlerInfo) => {
|
||||
const handlerType = handlerTypes[handlerInfo.handlerType]
|
||||
return async context => {
|
||||
const parameters = {}
|
||||
for (let paramName in handlerInfo.parameters) {
|
||||
parameters[paramName] = handlerInfo.parameters[paramName](context)
|
||||
}
|
||||
await handlerType.execute(parameters)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ const contextStoreKey = (dataProviderId, childIndex) =>
|
|||
`${dataProviderId}${childIndex >= 0 ? ":" + childIndex : ""}`
|
||||
|
||||
// creates a store for a datacontext (e.g. each item in a list component)
|
||||
// overrides store if already exists
|
||||
const create = (data, dataProviderId, childIndex, parentContextStoreId) => {
|
||||
const key = contextStoreKey(dataProviderId, childIndex)
|
||||
const state = { data }
|
||||
|
@ -22,14 +23,13 @@ const create = (data, dataProviderId, childIndex, parentContextStoreId) => {
|
|||
? contextStores[parentContextStoreId].state
|
||||
: rootState
|
||||
|
||||
if (!contextStores[key]) {
|
||||
contextStores[key] = {
|
||||
store: writable(state),
|
||||
subscriberCount: 0,
|
||||
state,
|
||||
parentContextStoreId,
|
||||
}
|
||||
contextStores[key] = {
|
||||
store: writable(state),
|
||||
subscriberCount: 0,
|
||||
state,
|
||||
parentContextStoreId,
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
|
@ -94,6 +94,9 @@ const set = (value, dataProviderId, childIndex) =>
|
|||
const getState = contextStoreKey =>
|
||||
contextStoreKey ? contextStores[contextStoreKey].state : rootState
|
||||
|
||||
const getStore = contextStoreKey =>
|
||||
contextStoreKey ? contextStores[contextStoreKey] : rootStore
|
||||
|
||||
export default {
|
||||
subscribe,
|
||||
update,
|
||||
|
@ -101,4 +104,5 @@ export default {
|
|||
getState,
|
||||
create,
|
||||
contextStoreKey,
|
||||
getStore,
|
||||
}
|
||||
|
|
|
@ -181,8 +181,7 @@ const maketestlib = window => ({
|
|||
currentProps = Object.assign(currentProps, props)
|
||||
if (currentProps.onClick) {
|
||||
node.addEventListener("click", () => {
|
||||
const testText = currentProps.testText || "hello"
|
||||
currentProps._bb.call(props.onClick, { testText })
|
||||
currentProps._bb.call("onClick")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ WORKDIR /app
|
|||
|
||||
ENV CLOUD=1
|
||||
ENV COUCH_DB_URL=https://couchdb.budi.live:5984
|
||||
env BUDIBASE_ENVIRONMENT=PRODUCTION
|
||||
|
||||
# copy files and install dependencies
|
||||
COPY . ./
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/server",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.21",
|
||||
"description": "Budibase Web Server",
|
||||
"main": "src/electron.js",
|
||||
"repository": {
|
||||
|
@ -42,7 +42,7 @@
|
|||
"author": "Michael Shanks",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@budibase/client": "^0.1.19",
|
||||
"@budibase/client": "^0.1.21",
|
||||
"@koa/router": "^8.0.0",
|
||||
"@sendgrid/mail": "^7.1.1",
|
||||
"@sentry/node": "^5.19.2",
|
||||
|
@ -55,6 +55,7 @@
|
|||
"electron-updater": "^4.3.1",
|
||||
"fix-path": "^3.0.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"joi": "^17.2.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"koa": "^2.7.0",
|
||||
"koa-body": "^4.1.0",
|
||||
|
@ -74,6 +75,7 @@
|
|||
"tar-fs": "^2.1.0",
|
||||
"uuid": "^3.3.2",
|
||||
"validate.js": "^0.13.1",
|
||||
"worker-farm": "^1.7.0",
|
||||
"yargs": "^13.2.4",
|
||||
"zlib": "^1.0.5"
|
||||
},
|
||||
|
|
|
@ -16,7 +16,7 @@ exports.authenticate = async ctx => {
|
|||
const { clientId } = await masterDb.get(ctx.user.appId)
|
||||
|
||||
if (!clientId) {
|
||||
ctx.throw(400, "ClientId not suplied")
|
||||
ctx.throw(400, "ClientId not supplied")
|
||||
}
|
||||
// find the instance that the user is associated with
|
||||
const db = new CouchDB(ClientDb.name(clientId))
|
||||
|
|
|
@ -25,7 +25,7 @@ exports.save = async function(ctx) {
|
|||
...ctx.request.body,
|
||||
}
|
||||
|
||||
// update renamed record fields when model is updated
|
||||
// rename record fields when table column is renamed
|
||||
const { _rename } = modelToSave
|
||||
if (_rename) {
|
||||
const records = await db.query(`database/all_${modelToSave._id}`, {
|
||||
|
@ -41,6 +41,15 @@ exports.save = async function(ctx) {
|
|||
delete modelToSave._rename
|
||||
}
|
||||
|
||||
// update schema of non-statistics views when new columns are added
|
||||
for (let view in modelToSave.views) {
|
||||
const modelView = modelToSave.views[view]
|
||||
if (!modelView) continue
|
||||
|
||||
if (modelView.schema.group || modelView.schema.field) continue
|
||||
modelView.schema = modelToSave.schema
|
||||
}
|
||||
|
||||
const result = await db.post(modelToSave)
|
||||
modelToSave._rev = result.rev
|
||||
|
||||
|
|
|
@ -2,6 +2,16 @@ const CouchDB = require("../../db")
|
|||
const validateJs = require("validate.js")
|
||||
const newid = require("../../db/newid")
|
||||
|
||||
function emitEvent(eventType, ctx, record) {
|
||||
ctx.eventEmitter &&
|
||||
ctx.eventEmitter.emit(eventType, {
|
||||
args: {
|
||||
record,
|
||||
},
|
||||
instanceId: ctx.user.instanceId,
|
||||
})
|
||||
}
|
||||
|
||||
validateJs.extend(validateJs.validators.datetime, {
|
||||
parse: function(value) {
|
||||
return new Date(value).getTime()
|
||||
|
@ -12,6 +22,40 @@ validateJs.extend(validateJs.validators.datetime, {
|
|||
},
|
||||
})
|
||||
|
||||
exports.patch = async function(ctx) {
|
||||
const db = new CouchDB(ctx.user.instanceId)
|
||||
const record = await db.get(ctx.params.id)
|
||||
const model = await db.get(record.modelId)
|
||||
const patchfields = ctx.request.body
|
||||
|
||||
for (let key in patchfields) {
|
||||
if (!model.schema[key]) continue
|
||||
record[key] = patchfields[key]
|
||||
}
|
||||
|
||||
const validateResult = await validate({
|
||||
record,
|
||||
model,
|
||||
})
|
||||
|
||||
if (!validateResult.valid) {
|
||||
ctx.status = 400
|
||||
ctx.body = {
|
||||
status: 400,
|
||||
errors: validateResult.errors,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const response = await db.put(record)
|
||||
record._rev = response.rev
|
||||
record.type = "record"
|
||||
ctx.body = record
|
||||
ctx.status = 200
|
||||
ctx.message = `${model.name} updated successfully.`
|
||||
return
|
||||
}
|
||||
|
||||
exports.save = async function(ctx) {
|
||||
const db = new CouchDB(ctx.user.instanceId)
|
||||
const record = ctx.request.body
|
||||
|
@ -76,13 +120,7 @@ exports.save = async function(ctx) {
|
|||
}
|
||||
}
|
||||
|
||||
ctx.eventEmitter &&
|
||||
ctx.eventEmitter.emit(`record:save`, {
|
||||
args: {
|
||||
record,
|
||||
},
|
||||
instanceId: ctx.user.instanceId,
|
||||
})
|
||||
emitEvent(`record:save`, ctx, record)
|
||||
ctx.body = record
|
||||
ctx.status = 200
|
||||
ctx.message = `${model.name} created successfully`
|
||||
|
@ -145,7 +183,7 @@ exports.destroy = async function(ctx) {
|
|||
return
|
||||
}
|
||||
ctx.body = await db.remove(ctx.params.recordId, ctx.params.revId)
|
||||
ctx.eventEmitter && ctx.eventEmitter.emit(`record:delete`, record)
|
||||
emitEvent(`record:delete`, ctx, record)
|
||||
}
|
||||
|
||||
exports.validate = async function(ctx) {
|
||||
|
|
|
@ -87,7 +87,10 @@ exports.processLocalFileUpload = async function(ctx) {
|
|||
pendingFileUploads = { _id: "_local/fileuploads", uploads: [] }
|
||||
})
|
||||
|
||||
pendingFileUploads.uploads = [...filesToProcess, ...pendingFileUploads.uploads]
|
||||
pendingFileUploads.uploads = [
|
||||
...filesToProcess,
|
||||
...pendingFileUploads.uploads,
|
||||
]
|
||||
await db.put(pendingFileUploads)
|
||||
|
||||
ctx.body = filesToProcess
|
||||
|
|
|
@ -103,9 +103,10 @@ function viewTemplate({ field, modelId, groupBy, filters = [], calculation }) {
|
|||
let schema = null
|
||||
|
||||
if (calculation) {
|
||||
schema = groupBy
|
||||
? { ...GROUP_PROPERTY, ...SCHEMA_MAP[calculation] }
|
||||
: { ...FIELD_PROPERTY, ...SCHEMA_MAP[calculation] }
|
||||
schema = {
|
||||
...(groupBy ? GROUP_PROPERTY : FIELD_PROPERTY),
|
||||
...SCHEMA_MAP[calculation],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
const userController = require("../../user")
|
||||
|
||||
module.exports = async function createUser({ args, instanceId }) {
|
||||
const ctx = {
|
||||
params: {
|
||||
instanceId,
|
||||
},
|
||||
request: {
|
||||
body: args.user,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await userController.create(ctx)
|
||||
return {
|
||||
user: response,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return {
|
||||
user: null,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
const recordController = require("../../record")
|
||||
|
||||
module.exports = async function saveRecord({ args, context }) {
|
||||
const { model, ...record } = args.record
|
||||
|
||||
const ctx = {
|
||||
params: {
|
||||
instanceId: context.instanceId,
|
||||
modelId: model._id,
|
||||
},
|
||||
request: {
|
||||
body: record,
|
||||
},
|
||||
user: { instanceId: context.instanceId },
|
||||
}
|
||||
|
||||
try {
|
||||
await recordController.save(ctx)
|
||||
return {
|
||||
record: ctx.body,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return {
|
||||
record: null,
|
||||
error: err.message,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
const sgMail = require("@sendgrid/mail")
|
||||
|
||||
sgMail.setApiKey(process.env.SENDGRID_API_KEY)
|
||||
|
||||
module.exports = async function sendEmail({ args }) {
|
||||
const msg = {
|
||||
to: args.to,
|
||||
from: args.from,
|
||||
subject: args.subject,
|
||||
text: args.text,
|
||||
}
|
||||
|
||||
try {
|
||||
await sgMail.send(msg)
|
||||
return {
|
||||
success: true,
|
||||
...args,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,85 +1,81 @@
|
|||
const ACTION = {
|
||||
SET_STATE: {
|
||||
name: "Update UI State",
|
||||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>",
|
||||
icon: "ri-refresh-line",
|
||||
description: "Update your User Interface with some data.",
|
||||
environment: "CLIENT",
|
||||
params: {
|
||||
path: "string",
|
||||
value: "longText",
|
||||
},
|
||||
},
|
||||
NAVIGATE: {
|
||||
name: "Navigate",
|
||||
tagline: "Navigate to <b>{{url}}</b>",
|
||||
icon: "ri-navigation-line",
|
||||
description: "Navigate to another page.",
|
||||
environment: "CLIENT",
|
||||
params: {
|
||||
url: "string",
|
||||
},
|
||||
},
|
||||
SAVE_RECORD: {
|
||||
name: "Save Record",
|
||||
tagline: "<b>Save</b> a <b>{{record.model.name}}</b> record",
|
||||
icon: "ri-save-3-fill",
|
||||
description: "Save a record to your database.",
|
||||
environment: "SERVER",
|
||||
params: {
|
||||
record: "record",
|
||||
},
|
||||
args: {
|
||||
record: {},
|
||||
},
|
||||
type: "ACTION",
|
||||
},
|
||||
DELETE_RECORD: {
|
||||
description: "Delete a record from your database.",
|
||||
icon: "ri-delete-bin-line",
|
||||
name: "Delete Record",
|
||||
tagline: "<b>Delete</b> a <b>{{record.model.name}}</b> record",
|
||||
environment: "SERVER",
|
||||
params: {
|
||||
record: "record",
|
||||
},
|
||||
args: {
|
||||
record: {},
|
||||
},
|
||||
params: {},
|
||||
args: {},
|
||||
type: "ACTION",
|
||||
},
|
||||
// FIND_RECORD: {
|
||||
// description: "Find a record in your database.",
|
||||
// tagline: "<b>Find</b> a <b>{{record.model.name}}</b> record",
|
||||
// icon: "ri-search-line",
|
||||
// name: "Find Record",
|
||||
// environment: "SERVER",
|
||||
// params: {
|
||||
// record: "string",
|
||||
// },
|
||||
// },
|
||||
CREATE_USER: {
|
||||
description: "Create a new user.",
|
||||
tagline: "Create user <b>{{username}}</b>",
|
||||
icon: "ri-user-add-fill",
|
||||
name: "Create User",
|
||||
environment: "SERVER",
|
||||
params: {
|
||||
username: "string",
|
||||
password: "password",
|
||||
accessLevelId: "accessLevel",
|
||||
},
|
||||
args: {
|
||||
accessLevelId: "POWER_USER",
|
||||
},
|
||||
type: "ACTION",
|
||||
},
|
||||
SEND_EMAIL: {
|
||||
description: "Send an email.",
|
||||
tagline: "Send email to <b>{{to}}</b>",
|
||||
icon: "ri-mail-open-fill",
|
||||
name: "Send Email",
|
||||
environment: "SERVER",
|
||||
params: {
|
||||
to: "string",
|
||||
from: "string",
|
||||
subject: "longText",
|
||||
text: "longText",
|
||||
},
|
||||
type: "ACTION",
|
||||
},
|
||||
}
|
||||
|
||||
const LOGIC = {
|
||||
FILTER: {
|
||||
name: "Filter",
|
||||
tagline: "{{filter}} <b>{{condition}}</b> {{value}}",
|
||||
icon: "ri-git-branch-line",
|
||||
description: "Filter any workflows which do not meet certain conditions.",
|
||||
params: {
|
||||
filter: "string",
|
||||
condition: ["equals"],
|
||||
value: "string",
|
||||
},
|
||||
args: {
|
||||
condition: "equals",
|
||||
},
|
||||
type: "LOGIC",
|
||||
},
|
||||
DELAY: {
|
||||
name: "Delay",
|
||||
icon: "ri-time-fill",
|
||||
tagline: "Delay for <b>{{time}}</b> milliseconds",
|
||||
description: "Delay the workflow until an amount of time has passed.",
|
||||
params: {
|
||||
time: "number",
|
||||
},
|
||||
type: "LOGIC",
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -89,11 +85,11 @@ const TRIGGER = {
|
|||
event: "record:save",
|
||||
icon: "ri-save-line",
|
||||
tagline: "Record is added to <b>{{model.name}}</b>",
|
||||
description: "Save a record to your database.",
|
||||
environment: "SERVER",
|
||||
description: "Fired when a record is saved to your database.",
|
||||
params: {
|
||||
model: "model",
|
||||
},
|
||||
type: "TRIGGER",
|
||||
},
|
||||
RECORD_DELETED: {
|
||||
name: "Record Deleted",
|
||||
|
@ -101,70 +97,17 @@ const TRIGGER = {
|
|||
icon: "ri-delete-bin-line",
|
||||
tagline: "Record is deleted from <b>{{model.name}}</b>",
|
||||
description: "Fired when a record is deleted from your database.",
|
||||
environment: "SERVER",
|
||||
params: {
|
||||
model: "model",
|
||||
},
|
||||
},
|
||||
// CLICK: {
|
||||
// name: "Click",
|
||||
// icon: "ri-cursor-line",
|
||||
// tagline: "{{component}} is clicked",
|
||||
// description: "Trigger when you click on an element in the UI.",
|
||||
// environment: "CLIENT",
|
||||
// params: {
|
||||
// component: "component"
|
||||
// }
|
||||
// },
|
||||
// LOAD: {
|
||||
// name: "Load",
|
||||
// icon: "ri-loader-line",
|
||||
// tagline: "{{component}} is loaded",
|
||||
// description: "Trigger an element has finished loading.",
|
||||
// environment: "CLIENT",
|
||||
// params: {
|
||||
// component: "component"
|
||||
// }
|
||||
// },
|
||||
// INPUT: {
|
||||
// name: "Input",
|
||||
// icon: "ri-text",
|
||||
// tagline: "Text entered into {{component}",
|
||||
// description: "Trigger when you type into an input box.",
|
||||
// environment: "CLIENT",
|
||||
// params: {
|
||||
// component: "component"
|
||||
// }
|
||||
// },
|
||||
}
|
||||
|
||||
const LOGIC = {
|
||||
FILTER: {
|
||||
name: "Filter",
|
||||
tagline: "{{field}} <b>{{condition}}</b> {{value}}",
|
||||
icon: "ri-git-branch-line",
|
||||
description: "Filter any workflows which do not meet certain conditions.",
|
||||
environment: "CLIENT",
|
||||
params: {
|
||||
filter: "string",
|
||||
condition: ["equals"],
|
||||
value: "string",
|
||||
},
|
||||
},
|
||||
DELAY: {
|
||||
name: "Delay",
|
||||
icon: "ri-time-fill",
|
||||
tagline: "Delay for <b>{{time}}</b> milliseconds",
|
||||
description: "Delay the workflow until an amount of time has passed.",
|
||||
environment: "CLIENT",
|
||||
params: {
|
||||
time: "number",
|
||||
},
|
||||
type: "TRIGGER",
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
// This contains the definitions for the steps and triggers that make up a workflow, a workflow comprises
|
||||
// of many steps and a single trigger
|
||||
module.exports = {
|
||||
ACTION,
|
||||
TRIGGER,
|
||||
LOGIC,
|
||||
TRIGGER,
|
||||
}
|
|
@ -1,5 +1,13 @@
|
|||
const CouchDB = require("../../../db")
|
||||
const newid = require("../../../db/newid")
|
||||
const blockDefinitions = require("./blockDefinitions")
|
||||
const triggers = require("../../../workflows/triggers")
|
||||
|
||||
/*************************
|
||||
* *
|
||||
* BUILDER FUNCTIONS *
|
||||
* *
|
||||
*************************/
|
||||
|
||||
exports.create = async function(ctx) {
|
||||
const db = new CouchDB(ctx.user.instanceId)
|
||||
|
@ -53,22 +61,47 @@ exports.find = async function(ctx) {
|
|||
ctx.body = await db.get(ctx.params.id)
|
||||
}
|
||||
|
||||
exports.executeAction = async function(ctx) {
|
||||
const { args, action } = ctx.request.body
|
||||
const workflowAction = require(`./actions/${action}`)
|
||||
const response = await workflowAction({
|
||||
args,
|
||||
instanceId: ctx.user.instanceId,
|
||||
})
|
||||
ctx.body = response
|
||||
}
|
||||
|
||||
exports.fetchActionScript = async function(ctx) {
|
||||
const workflowAction = require(`./actions/${ctx.action}`)
|
||||
ctx.body = workflowAction
|
||||
}
|
||||
|
||||
exports.destroy = async function(ctx) {
|
||||
const db = new CouchDB(ctx.user.instanceId)
|
||||
ctx.body = await db.remove(ctx.params.id, ctx.params.rev)
|
||||
}
|
||||
|
||||
exports.getActionList = async function(ctx) {
|
||||
ctx.body = blockDefinitions.ACTION
|
||||
}
|
||||
|
||||
exports.getTriggerList = async function(ctx) {
|
||||
ctx.body = blockDefinitions.TRIGGER
|
||||
}
|
||||
|
||||
exports.getLogicList = async function(ctx) {
|
||||
ctx.body = blockDefinitions.LOGIC
|
||||
}
|
||||
|
||||
module.exports.getDefinitionList = async function(ctx) {
|
||||
ctx.body = {
|
||||
logic: blockDefinitions.LOGIC,
|
||||
trigger: blockDefinitions.TRIGGER,
|
||||
action: blockDefinitions.ACTION,
|
||||
}
|
||||
}
|
||||
|
||||
/*********************
|
||||
* *
|
||||
* API FUNCTIONS *
|
||||
* *
|
||||
*********************/
|
||||
|
||||
exports.trigger = async function(ctx) {
|
||||
const db = new CouchDB(ctx.user.instanceId)
|
||||
let workflow = await db.get(ctx.params.id)
|
||||
await triggers.externalTrigger(workflow, {
|
||||
...ctx.request.body,
|
||||
instanceId: ctx.user.instanceId,
|
||||
})
|
||||
ctx.status = 200
|
||||
ctx.body = {
|
||||
message: `Workflow ${workflow._id} has been triggered.`,
|
||||
workflow,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,11 @@ router
|
|||
authorized(WRITE_MODEL, ctx => ctx.params.modelId),
|
||||
recordController.save
|
||||
)
|
||||
.patch(
|
||||
"/api/:modelId/records/:id",
|
||||
authorized(WRITE_MODEL, ctx => ctx.params.modelId),
|
||||
recordController.patch
|
||||
)
|
||||
.post(
|
||||
"/api/:modelId/records/validate",
|
||||
authorized(WRITE_MODEL, ctx => ctx.params.modelId),
|
||||
|
|
|
@ -37,7 +37,7 @@ describe("/accesslevels", () => {
|
|||
beforeEach(async () => {
|
||||
instanceId = (await createInstance(request, appId))._id
|
||||
model = await createModel(request, appId, instanceId)
|
||||
view = await createView(request, appId, instanceId)
|
||||
view = await createView(request, appId, instanceId, model._id)
|
||||
})
|
||||
|
||||
describe("create", () => {
|
||||
|
@ -111,7 +111,7 @@ describe("/accesslevels", () => {
|
|||
await request
|
||||
.get(`/api/accesslevels/${customLevel._id}`)
|
||||
.set(defaultHeaders(appId, instanceId))
|
||||
.expect(404)
|
||||
.expect(404)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -51,6 +51,12 @@ exports.createModel = async (request, appId, instanceId, model) => {
|
|||
type: "string",
|
||||
},
|
||||
},
|
||||
description: {
|
||||
type: "text",
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -61,9 +67,10 @@ exports.createModel = async (request, appId, instanceId, model) => {
|
|||
return res.body
|
||||
}
|
||||
|
||||
exports.createView = async (request, appId, instanceId, view) => {
|
||||
exports.createView = async (request, appId, instanceId, modelId, view) => {
|
||||
view = view || {
|
||||
map: "function(doc) { emit(doc[doc.key], doc._id); } ",
|
||||
modelId: modelId,
|
||||
}
|
||||
|
||||
const res = await request
|
||||
|
|
|
@ -120,6 +120,10 @@ describe("/models", () => {
|
|||
testModel = await createModel(request, app._id, instance._id, testModel)
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete testModel._rev
|
||||
});
|
||||
|
||||
it("returns all the models for that instance in the response body", done => {
|
||||
request
|
||||
.get(`/api/models`)
|
||||
|
|
|
@ -30,20 +30,30 @@ describe("/records", () => {
|
|||
model = await createModel(request, app._id, instance._id)
|
||||
record = {
|
||||
name: "Test Contact",
|
||||
description: "original description",
|
||||
status: "new",
|
||||
modelId: model._id
|
||||
}
|
||||
})
|
||||
|
||||
const createRecord = async r =>
|
||||
await request
|
||||
.post(`/api/${model._id}/records`)
|
||||
.send(r || record)
|
||||
.set(defaultHeaders(app._id, instance._id))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
|
||||
const loadRecord = async id =>
|
||||
await request
|
||||
.get(`/api/${model._id}/records/${id}`)
|
||||
.set(defaultHeaders(app._id, instance._id))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
|
||||
|
||||
describe("save, load, update, delete", () => {
|
||||
|
||||
const createRecord = async r =>
|
||||
await request
|
||||
.post(`/api/${model._id}/records`)
|
||||
.send(r || record)
|
||||
.set(defaultHeaders(app._id, instance._id))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
|
||||
it("returns a success message when the record is created", async () => {
|
||||
const res = await createRecord()
|
||||
|
@ -144,6 +154,35 @@ describe("/records", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("patch", () => {
|
||||
it("should update only the fields that are supplied", async () => {
|
||||
const rec = await createRecord()
|
||||
const existing = rec.body
|
||||
|
||||
const res = await request
|
||||
.patch(`/api/${model._id}/records/${existing._id}`)
|
||||
.send({
|
||||
_id: existing._id,
|
||||
_rev: existing._rev,
|
||||
modelId: model._id,
|
||||
name: "Updated Name",
|
||||
})
|
||||
.set(defaultHeaders(app._id, instance._id))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(res.res.statusMessage).toEqual(`${model.name} updated successfully.`)
|
||||
expect(res.body.name).toEqual("Updated Name")
|
||||
expect(res.body.description).toEqual(existing.description)
|
||||
|
||||
const savedRecord = await loadRecord(res.body._id)
|
||||
|
||||
expect(savedRecord.body.description).toEqual(existing.description)
|
||||
expect(savedRecord.body.name).toEqual("Updated Name")
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
describe("validate", () => {
|
||||
it("should return no errors on valid record", async () => {
|
||||
const result = await request
|
||||
|
|
|
@ -72,8 +72,14 @@ describe("/views", () => {
|
|||
type: "text",
|
||||
constraints: {
|
||||
type: "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
description: {
|
||||
type: "text",
|
||||
constraints: {
|
||||
type: "string"
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -23,7 +23,7 @@ const TEST_WORKFLOW = {
|
|||
|
||||
],
|
||||
next: {
|
||||
actionId: "abc123",
|
||||
stepId: "abc123",
|
||||
type: "SERVER",
|
||||
conditions: {
|
||||
}
|
||||
|
|
|
@ -1,21 +1,77 @@
|
|||
const Router = require("@koa/router")
|
||||
const controller = require("../controllers/workflow")
|
||||
const authorized = require("../../middleware/authorized")
|
||||
const joiValidator = require("../../middleware/joi-validator")
|
||||
const { BUILDER } = require("../../utilities/accessLevels")
|
||||
const Joi = require("joi")
|
||||
|
||||
const router = Router()
|
||||
|
||||
// prettier-ignore
|
||||
function generateStepSchema(allowStepTypes) {
|
||||
return Joi.object({
|
||||
stepId: Joi.string().required(),
|
||||
id: Joi.string().required(),
|
||||
description: Joi.string().required(),
|
||||
name: Joi.string().required(),
|
||||
tagline: Joi.string().required(),
|
||||
icon: Joi.string().required(),
|
||||
params: Joi.object(),
|
||||
// TODO: validate args a bit more deeply
|
||||
args: Joi.object(),
|
||||
type: Joi.string().required().valid(...allowStepTypes),
|
||||
}).unknown(true)
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
const workflowValidator = joiValidator.body(Joi.object({
|
||||
live: Joi.bool(),
|
||||
id: Joi.string().required(),
|
||||
rev: Joi.string().required(),
|
||||
name: Joi.string().required(),
|
||||
type: Joi.string().valid("workflow").required(),
|
||||
definition: Joi.object({
|
||||
steps: Joi.array().required().items(generateStepSchema(["ACTION", "LOGIC"])),
|
||||
trigger: generateStepSchema(["TRIGGER"]).required(),
|
||||
}).required().unknown(true),
|
||||
}).unknown(true))
|
||||
|
||||
router
|
||||
.get(
|
||||
"/api/workflows/trigger/list",
|
||||
authorized(BUILDER),
|
||||
controller.getTriggerList
|
||||
)
|
||||
.get(
|
||||
"/api/workflows/action/list",
|
||||
authorized(BUILDER),
|
||||
controller.getActionList
|
||||
)
|
||||
.get(
|
||||
"/api/workflows/logic/list",
|
||||
authorized(BUILDER),
|
||||
controller.getLogicList
|
||||
)
|
||||
.get(
|
||||
"/api/workflows/definitions/list",
|
||||
authorized(BUILDER),
|
||||
controller.getDefinitionList
|
||||
)
|
||||
.get("/api/workflows", authorized(BUILDER), controller.fetch)
|
||||
.get("/api/workflows/:id", authorized(BUILDER), controller.find)
|
||||
.get(
|
||||
"/api/workflows/:id/:action",
|
||||
.put(
|
||||
"/api/workflows",
|
||||
authorized(BUILDER),
|
||||
controller.fetchActionScript
|
||||
workflowValidator,
|
||||
controller.update
|
||||
)
|
||||
.put("/api/workflows", authorized(BUILDER), controller.update)
|
||||
.post("/api/workflows", authorized(BUILDER), controller.create)
|
||||
.post("/api/workflows/action", controller.executeAction)
|
||||
.post(
|
||||
"/api/workflows",
|
||||
authorized(BUILDER),
|
||||
workflowValidator,
|
||||
controller.create
|
||||
)
|
||||
.post("/api/workflows/:id/trigger", controller.trigger)
|
||||
.delete("/api/workflows/:id/:rev", authorized(BUILDER), controller.destroy)
|
||||
|
||||
module.exports = router
|
||||
|
|
|
@ -6,6 +6,7 @@ const http = require("http")
|
|||
const api = require("./api")
|
||||
const env = require("./environment")
|
||||
const eventEmitter = require("./events")
|
||||
const workflows = require("./workflows/index")
|
||||
const Sentry = require("@sentry/node")
|
||||
|
||||
const app = new Koa()
|
||||
|
@ -49,4 +50,5 @@ process.on("SIGINT", () => process.exit(1))
|
|||
|
||||
module.exports = server.listen(env.PORT || 4001, () => {
|
||||
console.log(`Budibase running on ${JSON.stringify(server.address())}`)
|
||||
workflows.init()
|
||||
})
|
||||
|
|
|
@ -7,4 +7,5 @@ module.exports = {
|
|||
COUCH_DB_URL: process.env.COUCH_DB_URL,
|
||||
SALT_ROUNDS: process.env.SALT_ROUNDS,
|
||||
LOGGER: process.env.LOGGER,
|
||||
BUDIBASE_ENVIRONMENT: process.env.BUDIBASE_ENVIRONMENT,
|
||||
}
|
||||
|
|
|
@ -1,33 +1,11 @@
|
|||
const EventEmitter = require("events").EventEmitter
|
||||
const CouchDB = require("../db")
|
||||
const { Orchestrator, serverStrategy } = require("./workflow")
|
||||
|
||||
/**
|
||||
* keeping event emitter in one central location as it might be used for things other than
|
||||
* workflows (what it was for originally) - having a central emitter will be useful in the
|
||||
* future.
|
||||
*/
|
||||
|
||||
const emitter = new EventEmitter()
|
||||
|
||||
async function executeRelevantWorkflows(event, eventType) {
|
||||
const db = new CouchDB(event.instanceId)
|
||||
const workflowsToTrigger = await db.query("database/by_workflow_trigger", {
|
||||
key: [eventType],
|
||||
include_docs: true,
|
||||
})
|
||||
|
||||
const workflows = workflowsToTrigger.rows.map(wf => wf.doc)
|
||||
|
||||
// Create orchestrator
|
||||
const workflowOrchestrator = new Orchestrator()
|
||||
workflowOrchestrator.strategy = serverStrategy
|
||||
|
||||
for (let workflow of workflows) {
|
||||
workflowOrchestrator.execute(workflow, event)
|
||||
}
|
||||
}
|
||||
|
||||
emitter.on("record:save", async function(event) {
|
||||
await executeRelevantWorkflows(event, "record:save")
|
||||
})
|
||||
|
||||
emitter.on("record:delete", async function(event) {
|
||||
await executeRelevantWorkflows(event, "record:delete")
|
||||
})
|
||||
|
||||
module.exports = emitter
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
const mustache = require("mustache")
|
||||
|
||||
/**
|
||||
* The workflow orchestrator is a class responsible for executing workflows.
|
||||
* It relies on the strategy pattern, which allows composable behaviour to be
|
||||
* passed into its execute() function. This allows custom execution behaviour based
|
||||
* on where the orchestrator is run.
|
||||
*
|
||||
*/
|
||||
exports.Orchestrator = class Orchestrator {
|
||||
set strategy(strategy) {
|
||||
this._strategy = strategy()
|
||||
}
|
||||
|
||||
async execute(workflow, context) {
|
||||
if (workflow.live) {
|
||||
this._strategy.run(workflow.definition, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.serverStrategy = () => ({
|
||||
context: {},
|
||||
bindContextArgs: function(args) {
|
||||
const mappedArgs = { ...args }
|
||||
|
||||
// bind the workflow action args to the workflow context, if required
|
||||
for (let arg in args) {
|
||||
const argValue = args[arg]
|
||||
// We don't want to render mustache templates on non-strings
|
||||
if (typeof argValue !== "string") continue
|
||||
|
||||
mappedArgs[arg] = mustache.render(argValue, { context: this.context })
|
||||
}
|
||||
|
||||
return mappedArgs
|
||||
},
|
||||
run: async function(workflow, context) {
|
||||
for (let block of workflow.steps) {
|
||||
if (block.type === "CLIENT") continue
|
||||
|
||||
const action = require(`../api/controllers/workflow/actions/${block.actionId}`)
|
||||
const response = await action({
|
||||
args: this.bindContextArgs(block.args),
|
||||
context,
|
||||
})
|
||||
|
||||
this.context = {
|
||||
...this.context,
|
||||
[block.id]: response,
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
|
@ -0,0 +1,16 @@
|
|||
function validate(schema, property) {
|
||||
// Return a Koa middleware function
|
||||
return (ctx, next) => {
|
||||
if (schema) {
|
||||
const { error } = schema.validate(ctx[property])
|
||||
if (error) {
|
||||
ctx.throw(400, `Invalid ${property} - ${error.message}`)
|
||||
}
|
||||
}
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.body = schema => {
|
||||
return validate(schema, "body")
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
const userController = require("../api/controllers/user")
|
||||
const recordController = require("../api/controllers/record")
|
||||
const sgMail = require("@sendgrid/mail")
|
||||
|
||||
sgMail.setApiKey(process.env.SENDGRID_API_KEY)
|
||||
|
||||
let BUILTIN_ACTIONS = {
|
||||
CREATE_USER: async function({ args, context }) {
|
||||
const { username, password, accessLevelId } = args
|
||||
const ctx = {
|
||||
user: {
|
||||
instanceId: context.instanceId,
|
||||
},
|
||||
request: {
|
||||
body: { username, password, accessLevelId },
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await userController.create(ctx)
|
||||
return {
|
||||
user: response,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return {
|
||||
user: null,
|
||||
}
|
||||
}
|
||||
},
|
||||
SAVE_RECORD: async function({ args, context }) {
|
||||
const { model, ...record } = args.record
|
||||
|
||||
const ctx = {
|
||||
params: {
|
||||
instanceId: context.instanceId,
|
||||
modelId: model._id,
|
||||
},
|
||||
request: {
|
||||
body: record,
|
||||
},
|
||||
user: { instanceId: context.instanceId },
|
||||
}
|
||||
|
||||
try {
|
||||
await recordController.save(ctx)
|
||||
return {
|
||||
record: ctx.body,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return {
|
||||
record: null,
|
||||
error: err.message,
|
||||
}
|
||||
}
|
||||
},
|
||||
SEND_EMAIL: async function({ args }) {
|
||||
const msg = {
|
||||
to: args.to,
|
||||
from: args.from,
|
||||
subject: args.subject,
|
||||
text: args.text,
|
||||
}
|
||||
|
||||
try {
|
||||
await sgMail.send(msg)
|
||||
return {
|
||||
success: true,
|
||||
...args,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
}
|
||||
}
|
||||
},
|
||||
DELETE_RECORD: async function({ args, context }) {
|
||||
const { model, ...record } = args.record
|
||||
// TODO: better logging of when actions are missed due to missing parameters
|
||||
if (record.recordId == null || record.revId == null) {
|
||||
return
|
||||
}
|
||||
let ctx = {
|
||||
params: {
|
||||
modelId: model._id,
|
||||
recordId: record.recordId,
|
||||
revId: record.revId,
|
||||
},
|
||||
user: { instanceId: context.instanceId },
|
||||
}
|
||||
|
||||
try {
|
||||
await recordController.destroy(ctx)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return {
|
||||
record: null,
|
||||
error: err.message,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports.getAction = async function(actionName) {
|
||||
if (BUILTIN_ACTIONS[actionName] != null) {
|
||||
return BUILTIN_ACTIONS[actionName]
|
||||
}
|
||||
// TODO: load async actions here
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
const triggers = require("./triggers")
|
||||
const environment = require("../environment")
|
||||
const workerFarm = require("worker-farm")
|
||||
const singleThread = require("./thread")
|
||||
|
||||
let workers = workerFarm(require.resolve("./thread"))
|
||||
|
||||
function runWorker(job) {
|
||||
return new Promise((resolve, reject) => {
|
||||
workers(job, err => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* This module is built purely to kick off the worker farm and manage the inputs/outputs
|
||||
*/
|
||||
module.exports.init = function() {
|
||||
triggers.workflowQueue.process(async job => {
|
||||
if (environment.BUDIBASE_ENVIRONMENT === "PRODUCTION") {
|
||||
await runWorker(job)
|
||||
} else {
|
||||
await singleThread(job)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
let LOGIC = {
|
||||
DELAY: async function delay({ args }) {
|
||||
await wait(args.time)
|
||||
},
|
||||
|
||||
FILTER: async function filter({ args }) {
|
||||
const { field, condition, value } = args
|
||||
switch (condition) {
|
||||
case "equals":
|
||||
if (field !== value) return
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports.getLogic = function(logicName) {
|
||||
if (LOGIC[logicName] != null) {
|
||||
return LOGIC[logicName]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
let events = require("events")
|
||||
|
||||
// Bull works with a Job wrapper around all messages that contains a lot more information about
|
||||
// the state of the message, implement this for the sake of maintaining API consistency
|
||||
function newJob(queue, message) {
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
queue: queue,
|
||||
data: message,
|
||||
}
|
||||
}
|
||||
|
||||
// designed to replicate Bull (https://github.com/OptimalBits/bull) in memory as a sort of mock
|
||||
class InMemoryQueue {
|
||||
// opts is not used by this as there is no real use case when in memory, but is the same API as Bull
|
||||
constructor(name, opts) {
|
||||
this._name = name
|
||||
this._opts = opts
|
||||
this._messages = []
|
||||
this._emitter = new events.EventEmitter()
|
||||
}
|
||||
|
||||
// same API as bull, provide a callback and it will respond when messages are available
|
||||
process(func) {
|
||||
this._emitter.on("message", async () => {
|
||||
if (this._messages.length <= 0) {
|
||||
return
|
||||
}
|
||||
let msg = this._messages.shift()
|
||||
let resp = func(msg)
|
||||
if (resp.then != null) {
|
||||
await resp
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// simply puts a message to the queue and emits to the queue for processing
|
||||
add(msg) {
|
||||
this._messages.push(newJob(this._name, msg))
|
||||
this._emitter.emit("message")
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = InMemoryQueue
|
|
@ -0,0 +1,68 @@
|
|||
const mustache = require("mustache")
|
||||
const actions = require("./actions")
|
||||
const logic = require("./logic")
|
||||
|
||||
/**
|
||||
* The workflow orchestrator is a class responsible for executing workflows.
|
||||
* It handles the context of the workflow and makes sure each step gets the correct
|
||||
* inputs and handles any outputs.
|
||||
*/
|
||||
class Orchestrator {
|
||||
constructor(workflow) {
|
||||
this._context = {}
|
||||
this._workflow = workflow
|
||||
}
|
||||
|
||||
async getStep(type, stepId) {
|
||||
let step = null
|
||||
if (type === "ACTION") {
|
||||
step = await actions.getAction(stepId)
|
||||
} else if (type === "LOGIC") {
|
||||
step = logic.getLogic(stepId)
|
||||
}
|
||||
if (step == null) {
|
||||
throw `Cannot find workflow step by name ${stepId}`
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
async execute(context) {
|
||||
let workflow = this._workflow
|
||||
for (let block of workflow.definition.steps) {
|
||||
let step = await this.getStep(block.type, block.stepId)
|
||||
let args = { ...block.args }
|
||||
// bind the workflow action args to the workflow context, if required
|
||||
for (let arg of Object.keys(args)) {
|
||||
const argValue = args[arg]
|
||||
// We don't want to render mustache templates on non-strings
|
||||
if (typeof argValue !== "string") continue
|
||||
|
||||
args[arg] = mustache.render(argValue, { context: this._context })
|
||||
}
|
||||
const response = await step({
|
||||
args,
|
||||
context,
|
||||
})
|
||||
|
||||
this._context = {
|
||||
...this._context,
|
||||
[block.id]: response,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// callback is required for worker-farm to state that the worker thread has completed
|
||||
module.exports = async (job, cb = null) => {
|
||||
try {
|
||||
const workflowOrchestrator = new Orchestrator(job.data.workflow)
|
||||
await workflowOrchestrator.execute(job.data.event)
|
||||
if (cb) {
|
||||
cb()
|
||||
}
|
||||
} catch (err) {
|
||||
if (cb) {
|
||||
cb(err)
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue