forked from tsai/budibase
committed by
GitHub
115 changed files with 3299 additions and 1973 deletions
@ -0,0 +1,95 @@ |
|||||
|
import mustache from "mustache" |
||||
|
import blockDefinitions from "components/workflow/WorkflowPanel/blockDefinitions" |
||||
|
import { generate } from "shortid" |
||||
|
|
||||
|
/** |
||||
|
* Class responsible for the traversing of the workflow definition. |
||||
|
* Workflow definitions are stored in linked lists. |
||||
|
*/ |
||||
|
export default class Workflow { |
||||
|
constructor(workflow) { |
||||
|
this.workflow = workflow |
||||
|
} |
||||
|
|
||||
|
hasTrigger() { |
||||
|
return this.workflow.definition.trigger |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
this.workflow.definition.steps.push({ |
||||
|
id: generate(), |
||||
|
...block, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
updateBlock(updatedBlock, id) { |
||||
|
const { steps, trigger } = this.workflow.definition |
||||
|
|
||||
|
if (trigger && trigger.id === id) { |
||||
|
this.workflow.definition.trigger = null |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
const stepIdx = steps.findIndex(step => step.id === id) |
||||
|
if (stepIdx < 0) throw new Error("Block not found.") |
||||
|
steps.splice(stepIdx, 1, updatedBlock) |
||||
|
} |
||||
|
|
||||
|
deleteBlock(id) { |
||||
|
const { steps, trigger } = this.workflow.definition |
||||
|
|
||||
|
if (trigger && trigger.id === id) { |
||||
|
this.workflow.definition.trigger = null |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
const stepIdx = steps.findIndex(step => step.id === id) |
||||
|
if (stepIdx < 0) throw new Error("Block not found.") |
||||
|
steps.splice(stepIdx, 1) |
||||
|
} |
||||
|
|
||||
|
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, |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,106 @@ |
|||||
|
import { writable } from "svelte/store" |
||||
|
import api from "../../api" |
||||
|
import Workflow from "./Workflow" |
||||
|
|
||||
|
const workflowActions = store => ({ |
||||
|
fetch: async instanceId => { |
||||
|
const WORKFLOWS_URL = `/api/${instanceId}/workflows` |
||||
|
const workflowResponse = await api.get(WORKFLOWS_URL) |
||||
|
const json = await workflowResponse.json() |
||||
|
store.update(state => { |
||||
|
state.workflows = json |
||||
|
return state |
||||
|
}) |
||||
|
}, |
||||
|
create: async ({ instanceId, name }) => { |
||||
|
const workflow = { |
||||
|
name, |
||||
|
definition: { |
||||
|
steps: [], |
||||
|
}, |
||||
|
} |
||||
|
const CREATE_WORKFLOW_URL = `/api/${instanceId}/workflows` |
||||
|
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) |
||||
|
return state |
||||
|
}) |
||||
|
}, |
||||
|
save: async ({ instanceId, workflow }) => { |
||||
|
const UPDATE_WORKFLOW_URL = `/api/${instanceId}/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 |
||||
|
state.currentWorkflow = new Workflow(json.workflow) |
||||
|
return state |
||||
|
}) |
||||
|
}, |
||||
|
update: async ({ instanceId, workflow }) => { |
||||
|
const UPDATE_WORKFLOW_URL = `/api/${instanceId}/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 |
||||
|
return state |
||||
|
}) |
||||
|
}, |
||||
|
delete: async ({ instanceId, workflow }) => { |
||||
|
const { _id, _rev } = workflow |
||||
|
const DELETE_WORKFLOW_URL = `/api/${instanceId}/workflows/${_id}/${_rev}` |
||||
|
await api.delete(DELETE_WORKFLOW_URL) |
||||
|
|
||||
|
store.update(state => { |
||||
|
const existingIdx = state.workflows.findIndex( |
||||
|
existing => existing._id === _id |
||||
|
) |
||||
|
state.workflows.splice(existingIdx, 1) |
||||
|
state.workflows = state.workflows |
||||
|
state.currentWorkflow = null |
||||
|
return state |
||||
|
}) |
||||
|
}, |
||||
|
select: workflow => { |
||||
|
store.update(state => { |
||||
|
state.currentWorkflow = new Workflow(workflow) |
||||
|
state.selectedWorkflowBlock = null |
||||
|
return state |
||||
|
}) |
||||
|
}, |
||||
|
addBlockToWorkflow: block => { |
||||
|
store.update(state => { |
||||
|
state.currentWorkflow.addBlock(block) |
||||
|
state.selectedWorkflowBlock = block |
||||
|
return state |
||||
|
}) |
||||
|
}, |
||||
|
deleteWorkflowBlock: block => { |
||||
|
store.update(state => { |
||||
|
state.currentWorkflow.deleteBlock(block.id) |
||||
|
state.selectedWorkflowBlock = null |
||||
|
return state |
||||
|
}) |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
export const getWorkflowStore = () => { |
||||
|
const INITIAL_WORKFLOW_STATE = { |
||||
|
workflows: [], |
||||
|
} |
||||
|
|
||||
|
const store = writable(INITIAL_WORKFLOW_STATE) |
||||
|
|
||||
|
store.actions = workflowActions(store) |
||||
|
|
||||
|
return store |
||||
|
} |
||||
@ -0,0 +1,57 @@ |
|||||
|
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", |
||||
|
} |
||||
|
|
||||
|
describe("Workflow Data Object", () => { |
||||
|
let workflow |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
workflow = new Workflow({ ...TEST_WORKFLOW }); |
||||
|
}); |
||||
|
|
||||
|
it("adds a workflow block to the workflow", () => { |
||||
|
workflow.addBlock(TEST_BLOCK); |
||||
|
expect(workflow.workflow.definition) |
||||
|
}) |
||||
|
|
||||
|
it("updates a workflow block with new attributes", () => { |
||||
|
const firstBlock = workflow.workflow.definition.steps[0]; |
||||
|
const updatedBlock = { |
||||
|
...firstBlock, |
||||
|
name: "UPDATED" |
||||
|
}; |
||||
|
workflow.updateBlock(updatedBlock, firstBlock.id); |
||||
|
expect(workflow.workflow.definition.steps[0]).toEqual(updatedBlock) |
||||
|
}) |
||||
|
|
||||
|
it("deletes a workflow block successfully", () => { |
||||
|
const { steps } = workflow.workflow.definition |
||||
|
const originalLength = steps.length |
||||
|
|
||||
|
const lastBlock = steps[steps.length - 1]; |
||||
|
workflow.deleteBlock(lastBlock.id); |
||||
|
expect(workflow.workflow.definition.steps.length).toBeLessThan(originalLength); |
||||
|
}) |
||||
|
|
||||
|
it("builds a tree that gets rendered in the flowchart builder", () => { |
||||
|
expect(Workflow.buildUiTree(TEST_WORKFLOW.definition)).toMatchSnapshot(); |
||||
|
}) |
||||
|
}) |
||||
@ -0,0 +1,49 @@ |
|||||
|
// 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", |
||||
|
}, |
||||
|
] |
||||
|
`; |
||||
@ -0,0 +1,63 @@ |
|||||
|
export default { |
||||
|
_id: "53b6148c65d1429c987e046852d11611", |
||||
|
_rev: "4-02c6659734934895812fa7be0215ee59", |
||||
|
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", |
||||
|
params: { |
||||
|
path: "string", |
||||
|
value: "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, |
||||
|
}, |
||||
|
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", |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
type: "workflow", |
||||
|
live: true, |
||||
|
} |
||||
@ -1,31 +0,0 @@ |
|||||
import { isString } from "lodash/fp" |
|
||||
|
|
||||
import { |
|
||||
BB_STATE_BINDINGPATH, |
|
||||
BB_STATE_FALLBACK, |
|
||||
BB_STATE_BINDINGSOURCE, |
|
||||
isBound, |
|
||||
parseBinding, |
|
||||
} from "@budibase/client/src/state/parseBinding" |
|
||||
|
|
||||
export const isBinding = isBound |
|
||||
|
|
||||
export const setBinding = ({ path, fallback, source }, binding = {}) => { |
|
||||
if (isNonEmptyString(path)) binding[BB_STATE_BINDINGPATH] = path |
|
||||
if (isNonEmptyString(fallback)) binding[BB_STATE_FALLBACK] = fallback |
|
||||
binding[BB_STATE_BINDINGSOURCE] = source || "store" |
|
||||
return binding |
|
||||
} |
|
||||
|
|
||||
export const getBinding = val => { |
|
||||
const binding = parseBinding(val) |
|
||||
return binding |
|
||||
? binding |
|
||||
: { |
|
||||
path: "", |
|
||||
source: "store", |
|
||||
fallback: "", |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const isNonEmptyString = s => isString(s) && s.length > 0 |
|
||||
@ -1,121 +0,0 @@ |
|||||
<script> |
|
||||
import { ArrowDownIcon } from "components/common/Icons/" |
|
||||
import { store } from "builderStore" |
|
||||
import { buildStateOrigins } from "builderStore/buildStateOrigins" |
|
||||
import { isBinding, getBinding, setBinding } from "components/common/binding" |
|
||||
import StateBindingOptions from "./StateBindingOptions.svelte" |
|
||||
|
|
||||
export let onChanged = () => {} |
|
||||
export let value = "" |
|
||||
|
|
||||
let isOpen = false |
|
||||
let stateBindings = [] |
|
||||
|
|
||||
let bindingPath = "" |
|
||||
let bindingFallbackValue = "" |
|
||||
let bindingSource = "store" |
|
||||
let bindingValue = "" |
|
||||
|
|
||||
const bindValueToSource = (path, fallback, source) => { |
|
||||
if (!path) { |
|
||||
onChanged(fallback) |
|
||||
return |
|
||||
} |
|
||||
const binding = setBinding({ path, fallback, source }) |
|
||||
onChanged(binding) |
|
||||
} |
|
||||
|
|
||||
const setBindingPath = value => |
|
||||
bindValueToSource(value, bindingFallbackValue, bindingSource) |
|
||||
|
|
||||
const setBindingFallback = value => |
|
||||
bindValueToSource(bindingPath, value, bindingSource) |
|
||||
|
|
||||
const setBindingSource = source => |
|
||||
bindValueToSource(bindingPath, bindingFallbackValue, source) |
|
||||
|
|
||||
$: { |
|
||||
const binding = getBinding(value) |
|
||||
if (bindingPath !== binding.path) isOpen = false |
|
||||
bindingPath = binding.path |
|
||||
bindingValue = typeof value === "object" ? "" : value |
|
||||
bindingFallbackValue = binding.fallback || bindingValue |
|
||||
|
|
||||
const currentScreen = $store.screens.find( |
|
||||
({ name }) => name === $store.currentPreviewItem.name |
|
||||
) |
|
||||
stateBindings = currentScreen |
|
||||
? Object.keys(buildStateOrigins(currentScreen)) |
|
||||
: [] |
|
||||
} |
|
||||
</script> |
|
||||
|
|
||||
<div class="cascader"> |
|
||||
<div class="input-box"> |
|
||||
<input |
|
||||
class:bold={!bindingFallbackValue && bindingPath} |
|
||||
class="uk-input uk-form-small" |
|
||||
value={bindingFallbackValue || bindingPath} |
|
||||
on:change={e => { |
|
||||
setBindingFallback(e.target.value) |
|
||||
onChanged(e.target.value) |
|
||||
}} /> |
|
||||
<button on:click={() => (isOpen = !isOpen)}> |
|
||||
<div |
|
||||
class="icon" |
|
||||
class:highlighted={bindingPath} |
|
||||
style={`transform: rotate(${isOpen ? 0 : 90}deg);`}> |
|
||||
<ArrowDownIcon size={36} /> |
|
||||
</div> |
|
||||
</button> |
|
||||
</div> |
|
||||
{#if isOpen} |
|
||||
<StateBindingOptions |
|
||||
onSelect={option => { |
|
||||
onChanged(option) |
|
||||
isOpen = false |
|
||||
}} /> |
|
||||
{/if} |
|
||||
</div> |
|
||||
|
|
||||
<style> |
|
||||
.bold { |
|
||||
font-weight: bold; |
|
||||
} |
|
||||
|
|
||||
.highlighted { |
|
||||
color: rgba(0, 85, 255, 0.8); |
|
||||
} |
|
||||
|
|
||||
button { |
|
||||
cursor: pointer; |
|
||||
outline: none; |
|
||||
border: none; |
|
||||
border-radius: 3px; |
|
||||
font-size: 1.6rem; |
|
||||
font-weight: 700; |
|
||||
color: rgba(22, 48, 87, 1); |
|
||||
} |
|
||||
|
|
||||
.cascader { |
|
||||
position: relative; |
|
||||
width: 100%; |
|
||||
} |
|
||||
|
|
||||
.input-box { |
|
||||
display: flex; |
|
||||
align-items: center; |
|
||||
} |
|
||||
|
|
||||
input { |
|
||||
margin-right: 5px; |
|
||||
border: 1px solid #dbdbdb; |
|
||||
border-radius: 2px; |
|
||||
opacity: 0.5; |
|
||||
height: 40px; |
|
||||
} |
|
||||
|
|
||||
.icon { |
|
||||
width: 24px; |
|
||||
} |
|
||||
</style> |
|
||||
@ -1,63 +0,0 @@ |
|||||
<script> |
|
||||
export let onSelect = () => {} |
|
||||
|
|
||||
let options = [ |
|
||||
{ |
|
||||
name: "state", |
|
||||
description: "Front-end client state.", |
|
||||
}, |
|
||||
{ |
|
||||
name: "context", |
|
||||
description: "The component context object.", |
|
||||
}, |
|
||||
{ |
|
||||
name: "event", |
|
||||
description: "DOM event handler arguments.", |
|
||||
}, |
|
||||
] |
|
||||
</script> |
|
||||
|
|
||||
<ul class="options"> |
|
||||
{#each options as option} |
|
||||
<li on:click={() => onSelect(`${option.name}.`)}> |
|
||||
<span class="name">{option.name}</span> |
|
||||
<span class="description">{option.description}</span> |
|
||||
</li> |
|
||||
{/each} |
|
||||
</ul> |
|
||||
|
|
||||
<style> |
|
||||
.options { |
|
||||
width: 172px; |
|
||||
margin: 0; |
|
||||
position: absolute; |
|
||||
top: 35px; |
|
||||
padding: 10px; |
|
||||
z-index: 1; |
|
||||
background: rgba(249, 249, 249, 1); |
|
||||
min-height: 50px; |
|
||||
border-radius: 2px; |
|
||||
} |
|
||||
|
|
||||
.description { |
|
||||
font-size: 0.8em; |
|
||||
} |
|
||||
|
|
||||
.name { |
|
||||
color: rgba(22, 48, 87, 0.6); |
|
||||
font-size: 13px; |
|
||||
font-weight: bold; |
|
||||
text-transform: uppercase; |
|
||||
margin-top: 5px; |
|
||||
display: block; |
|
||||
} |
|
||||
|
|
||||
.name:hover { |
|
||||
cursor: pointer; |
|
||||
font-weight: 800; |
|
||||
} |
|
||||
|
|
||||
li { |
|
||||
list-style-type: none; |
|
||||
} |
|
||||
</style> |
|
||||
@ -1 +0,0 @@ |
|||||
export { default } from "./PropertyCascader.svelte" |
|
||||
@ -0,0 +1,89 @@ |
|||||
|
<script> |
||||
|
import { store, backendUiStore, workflowStore } from "builderStore" |
||||
|
import { notifier } from "@beyonk/svelte-notifications" |
||||
|
import api from "builderStore/api" |
||||
|
import ActionButton from "components/common/ActionButton.svelte" |
||||
|
|
||||
|
export let onClosed |
||||
|
|
||||
|
let name |
||||
|
|
||||
|
$: valid = !!name |
||||
|
$: instanceId = $backendUiStore.selectedDatabase._id |
||||
|
|
||||
|
async function deleteWorkflow() { |
||||
|
await workflowStore.actions.delete({ |
||||
|
instanceId, |
||||
|
workflow: $workflowStore.currentWorkflow.workflow, |
||||
|
}) |
||||
|
onClosed() |
||||
|
notifier.danger("Workflow deleted.") |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<header> |
||||
|
<i class="ri-stackshare-line" /> |
||||
|
Delete Workflow |
||||
|
</header> |
||||
|
<div> |
||||
|
<p> |
||||
|
Are you sure you want to delete this workflow? This action can't be undone. |
||||
|
</p> |
||||
|
</div> |
||||
|
<footer> |
||||
|
<a href="https://docs.budibase.com"> |
||||
|
<i class="ri-information-line" /> |
||||
|
Learn about workflows |
||||
|
</a> |
||||
|
<ActionButton on:click={onClosed}>Cancel</ActionButton> |
||||
|
<ActionButton alert on:click={deleteWorkflow}>Delete</ActionButton> |
||||
|
</footer> |
||||
|
|
||||
|
<style> |
||||
|
header { |
||||
|
font-size: 24px; |
||||
|
color: var(--font); |
||||
|
font-weight: bold; |
||||
|
padding: 30px; |
||||
|
} |
||||
|
|
||||
|
header i { |
||||
|
margin-right: 10px; |
||||
|
font-size: 20px; |
||||
|
background: var(--secondary); |
||||
|
color: var(--dark-grey); |
||||
|
padding: 8px; |
||||
|
} |
||||
|
|
||||
|
div { |
||||
|
padding: 0 30px 30px 30px; |
||||
|
} |
||||
|
|
||||
|
label { |
||||
|
font-size: 18px; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
footer { |
||||
|
display: grid; |
||||
|
grid-auto-flow: column; |
||||
|
grid-gap: 5px; |
||||
|
grid-auto-columns: 3fr 1fr 1fr; |
||||
|
padding: 20px; |
||||
|
background: #fafafa; |
||||
|
border-radius: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
footer a { |
||||
|
color: var(--primary); |
||||
|
font-size: 14px; |
||||
|
vertical-align: middle; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
footer i { |
||||
|
font-size: 20px; |
||||
|
margin-right: 10px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,45 @@ |
|||||
|
<script> |
||||
|
import { store } from "builderStore" |
||||
|
import deepmerge from "deepmerge" |
||||
|
|
||||
|
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="uk-margin block-field"> |
||||
|
<label class="uk-form-label">Page</label> |
||||
|
<div class="uk-form-controls"> |
||||
|
<select class="budibase__input" bind:value={pageName}> |
||||
|
{#each Object.keys(pages) as page} |
||||
|
<option value={page}>{page}</option> |
||||
|
{/each} |
||||
|
</select> |
||||
|
</div> |
||||
|
{#if components.length > 0} |
||||
|
<label class="uk-form-label">Component</label> |
||||
|
<div class="uk-form-controls"> |
||||
|
<select class="budibase__input" bind:value> |
||||
|
{#each components as component} |
||||
|
<option value={component._id}>{component._id}</option> |
||||
|
{/each} |
||||
|
</select> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
@ -0,0 +1,16 @@ |
|||||
|
<script> |
||||
|
import { backendUiStore } from "builderStore" |
||||
|
|
||||
|
export let value |
||||
|
</script> |
||||
|
|
||||
|
<div class="uk-margin block-field"> |
||||
|
<label class="uk-form-label">Model</label> |
||||
|
<div class="uk-form-controls"> |
||||
|
<select class="budibase__input" bind:value> |
||||
|
{#each $backendUiStore.models as model} |
||||
|
<option value={model}>{model.name}</option> |
||||
|
{/each} |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,33 @@ |
|||||
|
<script> |
||||
|
import { backendUiStore } from "builderStore" |
||||
|
|
||||
|
export let value |
||||
|
</script> |
||||
|
|
||||
|
<div class="uk-margin block-field"> |
||||
|
<div class="uk-form-controls"> |
||||
|
<select class="budibase__input" bind:value={value.model}> |
||||
|
{#each $backendUiStore.models as model} |
||||
|
<option value={model}>{model.name}</option> |
||||
|
{/each} |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{#if value.model} |
||||
|
<div class="uk-margin block-field"> |
||||
|
<label class="uk-form-label fields">Fields</label> |
||||
|
{#each Object.keys(value.model.schema) as field} |
||||
|
<div class="uk-form-controls uk-margin"> |
||||
|
<label class="uk-form-label">{field}</label> |
||||
|
<input type="text" class="budibase__input" bind:value={value[field]} /> |
||||
|
</div> |
||||
|
{/each} |
||||
|
</div> |
||||
|
{/if} |
||||
|
|
||||
|
<style> |
||||
|
.fields { |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,207 @@ |
|||||
|
<script> |
||||
|
import { fade } from "svelte/transition" |
||||
|
import { onMount, getContext } from "svelte" |
||||
|
import { backendUiStore, workflowStore } from "builderStore" |
||||
|
import { notifier } from "@beyonk/svelte-notifications" |
||||
|
import api from "builderStore/api" |
||||
|
import WorkflowBlockSetup from "./WorkflowBlockSetup.svelte" |
||||
|
import DeleteWorkflowModal from "./DeleteWorkflowModal.svelte" |
||||
|
|
||||
|
const { open, close } = getContext("simple-modal") |
||||
|
|
||||
|
const ACCESS_LEVELS = [ |
||||
|
{ |
||||
|
name: "Admin", |
||||
|
key: "ADMIN" |
||||
|
}, |
||||
|
{ |
||||
|
name: "Power User", |
||||
|
key: "POWER_USER" |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
let selectedTab = "SETUP" |
||||
|
let testResult |
||||
|
|
||||
|
$: workflow = |
||||
|
$workflowStore.currentWorkflow && $workflowStore.currentWorkflow.workflow |
||||
|
$: workflowBlock = $workflowStore.selectedWorkflowBlock |
||||
|
|
||||
|
function deleteWorkflow() { |
||||
|
open( |
||||
|
DeleteWorkflowModal, |
||||
|
{ |
||||
|
onClosed: close, |
||||
|
}, |
||||
|
{ styleContent: { padding: "0" } } |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function deleteWorkflowBlock() { |
||||
|
workflowStore.actions.deleteWorkflowBlock(workflowBlock) |
||||
|
notifier.info("Workflow block deleted.") |
||||
|
} |
||||
|
|
||||
|
function testWorkflow() { |
||||
|
testResult = "PASSED" |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<section> |
||||
|
<header> |
||||
|
<span |
||||
|
class="hoverable" |
||||
|
class:selected={selectedTab === 'SETUP'} |
||||
|
on:click={() => { |
||||
|
selectedTab = 'SETUP' |
||||
|
testResult = null |
||||
|
}}> |
||||
|
Setup |
||||
|
</span> |
||||
|
{#if !workflowBlock} |
||||
|
<span |
||||
|
class="hoverable" |
||||
|
class:selected={selectedTab === 'TEST'} |
||||
|
on:click={() => (selectedTab = 'TEST')}> |
||||
|
Test |
||||
|
</span> |
||||
|
{/if} |
||||
|
</header> |
||||
|
{#if selectedTab === 'TEST'} |
||||
|
<div class="uk-margin config-item"> |
||||
|
{#if testResult} |
||||
|
<button |
||||
|
transition:fade |
||||
|
class:passed={testResult === 'PASSED'} |
||||
|
class:failed={testResult === 'FAILED'} |
||||
|
class="test-result"> |
||||
|
{testResult} |
||||
|
</button> |
||||
|
{/if} |
||||
|
<button class="workflow-button hoverable" on:click={testWorkflow}> |
||||
|
Test |
||||
|
</button> |
||||
|
</div> |
||||
|
{/if} |
||||
|
{#if selectedTab === 'SETUP'} |
||||
|
{#if workflowBlock} |
||||
|
<WorkflowBlockSetup {workflowBlock} /> |
||||
|
<button class="workflow-button hoverable" on:click={deleteWorkflowBlock}> |
||||
|
Delete Block |
||||
|
</button> |
||||
|
{:else if $workflowStore.currentWorkflow} |
||||
|
<div class="panel-body"> |
||||
|
<label class="uk-form-label">Workflow: {workflow.name}</label> |
||||
|
<div class="uk-margin config-item"> |
||||
|
<label class="uk-form-label">Name</label> |
||||
|
<div class="uk-form-controls"> |
||||
|
<input |
||||
|
type="text" |
||||
|
class="budibase__input" |
||||
|
bind:value={workflow.name} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="uk-margin config-item"> |
||||
|
<label class="uk-form-label">User Access</label> |
||||
|
<div class="access-levels"> |
||||
|
{#each ACCESS_LEVELS as { name, key }} |
||||
|
<span class="access-level"> |
||||
|
<label>{name}</label> |
||||
|
<input class="uk-checkbox" type="checkbox" /> |
||||
|
</span> |
||||
|
{/each} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<button class="workflow-button hoverable" on:click={deleteWorkflow}> |
||||
|
Delete Workflow |
||||
|
</button> |
||||
|
{/if} |
||||
|
{/if} |
||||
|
</section> |
||||
|
|
||||
|
<style> |
||||
|
section { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
.panel-body { |
||||
|
flex: 1; |
||||
|
} |
||||
|
|
||||
|
header { |
||||
|
font-size: 20px; |
||||
|
font-weight: bold; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
margin-bottom: 20px; |
||||
|
} |
||||
|
|
||||
|
.selected { |
||||
|
color: var(--font); |
||||
|
} |
||||
|
|
||||
|
.config-item { |
||||
|
padding: 20px; |
||||
|
background: var(--light-grey); |
||||
|
} |
||||
|
|
||||
|
header > span { |
||||
|
color: var(--dark-grey); |
||||
|
margin-right: 20px; |
||||
|
} |
||||
|
|
||||
|
label { |
||||
|
font-weight: 500; |
||||
|
font-size: 14px; |
||||
|
color: var(--font); |
||||
|
} |
||||
|
|
||||
|
.workflow-button { |
||||
|
font-family: Roboto; |
||||
|
width: 100%; |
||||
|
border: solid 1px #f2f2f2; |
||||
|
border-radius: 2px; |
||||
|
background: var(--white); |
||||
|
height: 32px; |
||||
|
font-size: 12px; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.workflow-button:hover { |
||||
|
background: var(--light-grey); |
||||
|
} |
||||
|
|
||||
|
.access-level { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
margin-top: 20px; |
||||
|
} |
||||
|
|
||||
|
.access-level label { |
||||
|
font-weight: normal; |
||||
|
} |
||||
|
|
||||
|
.test-result { |
||||
|
border: none; |
||||
|
width: 100%; |
||||
|
border-radius: 2px; |
||||
|
height: 32px; |
||||
|
font-size: 12px; |
||||
|
font-weight: 500; |
||||
|
color: var(--white); |
||||
|
text-align: center; |
||||
|
margin-bottom: 10px; |
||||
|
} |
||||
|
|
||||
|
.passed { |
||||
|
background: #84c991; |
||||
|
} |
||||
|
|
||||
|
.failed { |
||||
|
background: var(--coral); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,85 @@ |
|||||
|
<script> |
||||
|
import { backendUiStore, store } from "builderStore" |
||||
|
import ComponentSelector from "./ParamInputs/ComponentSelector.svelte" |
||||
|
import ModelSelector from "./ParamInputs/ModelSelector.svelte" |
||||
|
import RecordSelector from "./ParamInputs/RecordSelector.svelte" |
||||
|
|
||||
|
export let workflowBlock |
||||
|
|
||||
|
let params |
||||
|
|
||||
|
$: workflowParams = workflowBlock.params |
||||
|
? Object.entries(workflowBlock.params) |
||||
|
: [] |
||||
|
</script> |
||||
|
|
||||
|
<label class="uk-form-label">{workflowBlock.type}: {workflowBlock.name}</label> |
||||
|
{#each workflowParams as [parameter, type]} |
||||
|
<div class="uk-margin block-field"> |
||||
|
<label class="uk-form-label">{parameter}</label> |
||||
|
<div class="uk-form-controls"> |
||||
|
{#if Array.isArray(type)} |
||||
|
<select |
||||
|
class="budibase__input" |
||||
|
bind:value={workflowBlock.args[parameter]}> |
||||
|
{#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 |
||||
|
class="budibase__input" |
||||
|
bind:value={workflowBlock.args[parameter]}> |
||||
|
<option value="ADMIN">Admin</option> |
||||
|
<option value="POWER_USER">Power User</option> |
||||
|
</select> |
||||
|
{:else if type === 'password'} |
||||
|
<input |
||||
|
type="password" |
||||
|
class="budibase__input" |
||||
|
bind:value={workflowBlock.args[parameter]} /> |
||||
|
{:else if type === 'number'} |
||||
|
<input |
||||
|
type="number" |
||||
|
class="budibase__input" |
||||
|
bind:value={workflowBlock.args[parameter]} /> |
||||
|
{:else if type === 'longText'} |
||||
|
<textarea |
||||
|
type="text" |
||||
|
class="budibase__input" |
||||
|
bind:value={workflowBlock.args[parameter]} /> |
||||
|
{:else if type === 'model'} |
||||
|
<ModelSelector bind:value={workflowBlock.args[parameter]} /> |
||||
|
{:else if type === 'record'} |
||||
|
<RecordSelector bind:value={workflowBlock.args[parameter]} /> |
||||
|
{:else if type === 'string'} |
||||
|
<input |
||||
|
type="text" |
||||
|
class="budibase__input" |
||||
|
bind:value={workflowBlock.args[parameter]} /> |
||||
|
{/if} |
||||
|
</div> |
||||
|
</div> |
||||
|
{/each} |
||||
|
|
||||
|
<style> |
||||
|
.block-field { |
||||
|
border-radius: 3px; |
||||
|
background: var(--light-grey); |
||||
|
padding: 20px; |
||||
|
} |
||||
|
|
||||
|
label { |
||||
|
text-transform: capitalize; |
||||
|
font-size: 14px; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
textarea { |
||||
|
min-height: 150px; |
||||
|
font-family: inherit; |
||||
|
padding: 5px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1 @@ |
|||||
|
export { default as SetupPanel } from "./SetupPanel.svelte" |
||||
@ -0,0 +1,90 @@ |
|||||
|
<script> |
||||
|
import { onMount } from "svelte" |
||||
|
import { workflowStore, backendUiStore } from "builderStore" |
||||
|
import { notifier } from "@beyonk/svelte-notifications" |
||||
|
import Flowchart from "./flowchart/FlowChart.svelte" |
||||
|
import api from "builderStore/api" |
||||
|
|
||||
|
let selectedWorkflow |
||||
|
let uiTree |
||||
|
let instanceId = $backendUiStore.selectedDatabase._id |
||||
|
|
||||
|
$: selectedWorkflow = $workflowStore.currentWorkflow |
||||
|
|
||||
|
$: workflowLive = selectedWorkflow && selectedWorkflow.workflow.live |
||||
|
|
||||
|
$: uiTree = selectedWorkflow ? selectedWorkflow.createUiTree() : [] |
||||
|
|
||||
|
$: instanceId = $backendUiStore.selectedDatabase._id |
||||
|
|
||||
|
function onSelect(block) { |
||||
|
workflowStore.update(state => { |
||||
|
state.selectedWorkflowBlock = block |
||||
|
return state |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
function setWorkflowLive(live) { |
||||
|
const { workflow } = selectedWorkflow |
||||
|
workflow.live = live |
||||
|
workflowStore.actions.save({ instanceId, workflow }) |
||||
|
if (live) { |
||||
|
notifier.info(`Workflow ${workflow.name} enabled.`) |
||||
|
} else { |
||||
|
notifier.danger(`Workflow ${workflow.name} disabled.`) |
||||
|
} |
||||
|
} |
||||
|
</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" |
||||
|
on:click={() => setWorkflowLive(true)}> |
||||
|
<i class="ri-play-fill" /> |
||||
|
</button> |
||||
|
{/if} |
||||
|
</footer> |
||||
|
</section> |
||||
|
|
||||
|
<style> |
||||
|
footer { |
||||
|
position: absolute; |
||||
|
bottom: 0; |
||||
|
right: 0; |
||||
|
display: flex; |
||||
|
align-items: flex-end; |
||||
|
} |
||||
|
|
||||
|
footer > button { |
||||
|
border-radius: 100%; |
||||
|
color: var(--white); |
||||
|
width: 76px; |
||||
|
height: 76px; |
||||
|
border: none; |
||||
|
background: #adaec4; |
||||
|
font-size: 45px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
margin-right: 24px; |
||||
|
} |
||||
|
|
||||
|
.play-button.highlighted { |
||||
|
background: var(--primary); |
||||
|
} |
||||
|
|
||||
|
.stop-button.highlighted { |
||||
|
background: var(--coral); |
||||
|
} |
||||
|
</style> |
||||
|
After Width: | Height: | Size: 241 B |
@ -0,0 +1,24 @@ |
|||||
|
<script> |
||||
|
import FlowItem from "./FlowItem.svelte" |
||||
|
import Arrow from "./Arrow.svelte" |
||||
|
|
||||
|
export let blocks = [] |
||||
|
export let onSelect |
||||
|
</script> |
||||
|
|
||||
|
<section class="canvas"> |
||||
|
{#each blocks as block, idx} |
||||
|
<FlowItem {onSelect} {block} /> |
||||
|
{#if idx !== blocks.length - 1} |
||||
|
<Arrow /> |
||||
|
{/if} |
||||
|
{/each} |
||||
|
</section> |
||||
|
|
||||
|
<style> |
||||
|
.canvas { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
flex-direction: column; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,73 @@ |
|||||
|
<script> |
||||
|
import { fade } from "svelte/transition" |
||||
|
|
||||
|
export let onSelect |
||||
|
export let block |
||||
|
|
||||
|
function selectBlock() { |
||||
|
onSelect(block) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div transition:fade class={`${block.type} hoverable`} on:click={selectBlock}> |
||||
|
<header> |
||||
|
{#if block.type === 'TRIGGER'} |
||||
|
<i class="ri-lightbulb-fill" /> |
||||
|
When this happens... |
||||
|
{:else if block.type === 'ACTION'} |
||||
|
<i class="ri-flashlight-fill" /> |
||||
|
Do this... |
||||
|
{:else if block.type === 'LOGIC'} |
||||
|
<i class="ri-pause-fill" /> |
||||
|
Only continue if... |
||||
|
{/if} |
||||
|
</header> |
||||
|
<hr /> |
||||
|
<p> |
||||
|
{@html block.body} |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
div { |
||||
|
width: 320px; |
||||
|
padding: 20px; |
||||
|
border-radius: 5px; |
||||
|
transition: 0.3s all; |
||||
|
box-shadow: 0 4px 30px 0 rgba(57, 60, 68, 0.08); |
||||
|
background-color: var(--font); |
||||
|
font-size: 16px; |
||||
|
color: var(--white); |
||||
|
} |
||||
|
|
||||
|
header { |
||||
|
font-size: 16px; |
||||
|
font-weight: 500; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
header i { |
||||
|
font-size: 20px; |
||||
|
margin-right: 5px; |
||||
|
} |
||||
|
|
||||
|
.ACTION { |
||||
|
background-color: var(--white); |
||||
|
color: var(--font); |
||||
|
} |
||||
|
|
||||
|
.TRIGGER { |
||||
|
background-color: var(--font); |
||||
|
color: var(--white); |
||||
|
} |
||||
|
|
||||
|
.LOGIC { |
||||
|
background-color: var(--secondary); |
||||
|
color: var(--font); |
||||
|
} |
||||
|
|
||||
|
div:hover { |
||||
|
transform: scale(1.05); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,81 @@ |
|||||
|
<script> |
||||
|
import { onMount } from "svelte" |
||||
|
import { backendUiStore, workflowStore } from "builderStore" |
||||
|
import { WorkflowList } from "../" |
||||
|
import WorkflowBlock from "./WorkflowBlock.svelte" |
||||
|
import api from "builderStore/api" |
||||
|
import blockDefinitions from "../blockDefinitions" |
||||
|
|
||||
|
let selectedTab = "TRIGGER" |
||||
|
let definitions = [] |
||||
|
|
||||
|
$: definitions = Object.entries(blockDefinitions[selectedTab]) |
||||
|
|
||||
|
$: { |
||||
|
if ( |
||||
|
$workflowStore.currentWorkflow.hasTrigger() && |
||||
|
selectedTab === "TRIGGER" |
||||
|
) { |
||||
|
selectedTab = "ACTION" |
||||
|
} |
||||
|
} |
||||
|
</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> |
||||
|
<div id="blocklist"> |
||||
|
{#each definitions as [actionId, blockDefinition]} |
||||
|
<WorkflowBlock {blockDefinition} {actionId} blockType={selectedTab} /> |
||||
|
{/each} |
||||
|
</div> |
||||
|
</section> |
||||
|
|
||||
|
<style> |
||||
|
.subtabs { |
||||
|
margin-top: 27px; |
||||
|
display: grid; |
||||
|
grid-gap: 5px; |
||||
|
grid-auto-flow: column; |
||||
|
grid-auto-columns: 1fr 1fr 1fr; |
||||
|
margin-bottom: 10px; |
||||
|
} |
||||
|
|
||||
|
.subtabs span { |
||||
|
transition: 0.3s all; |
||||
|
text-align: center; |
||||
|
color: var(--dark-grey); |
||||
|
font-weight: 500; |
||||
|
padding: 10px; |
||||
|
} |
||||
|
|
||||
|
.subtabs span.selected { |
||||
|
background: var(--dark-grey); |
||||
|
color: var(--white); |
||||
|
border-radius: 2px; |
||||
|
} |
||||
|
|
||||
|
.subtabs span:not(.selected) { |
||||
|
color: var(--dark-grey); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,56 @@ |
|||||
|
<script> |
||||
|
import { workflowStore } from "builderStore" |
||||
|
|
||||
|
export let blockType |
||||
|
export let blockDefinition |
||||
|
export let actionId |
||||
|
|
||||
|
function addBlockToWorkflow() { |
||||
|
workflowStore.actions.addBlockToWorkflow({ |
||||
|
...blockDefinition, |
||||
|
args: blockDefinition.args || {}, |
||||
|
actionId, |
||||
|
type: blockType, |
||||
|
}) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div class="workflow-block hoverable" on:click={addBlockToWorkflow}> |
||||
|
<div> |
||||
|
<i class={blockDefinition.icon} /> |
||||
|
</div> |
||||
|
<div class="workflow-text"> |
||||
|
<h4>{blockDefinition.name}</h4> |
||||
|
<p>{blockDefinition.description}</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.workflow-block { |
||||
|
display: flex; |
||||
|
padding: 20px; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
.workflow-text { |
||||
|
margin-left: 10px; |
||||
|
} |
||||
|
|
||||
|
i { |
||||
|
background: var(--secondary); |
||||
|
color: var(--dark-grey); |
||||
|
padding: 10px; |
||||
|
} |
||||
|
|
||||
|
h4 { |
||||
|
font-size: 14px; |
||||
|
font-weight: 500; |
||||
|
margin-bottom: 5px; |
||||
|
} |
||||
|
|
||||
|
p { |
||||
|
font-size: 12px; |
||||
|
color: var(--dark-grey); |
||||
|
margin: 0; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,89 @@ |
|||||
|
<script> |
||||
|
import { store, backendUiStore, workflowStore } from "builderStore" |
||||
|
import { notifier } from "@beyonk/svelte-notifications" |
||||
|
import api from "builderStore/api" |
||||
|
import ActionButton from "components/common/ActionButton.svelte" |
||||
|
|
||||
|
export let onClosed |
||||
|
|
||||
|
let name |
||||
|
|
||||
|
$: valid = !!name |
||||
|
$: instanceId = $backendUiStore.selectedDatabase._id |
||||
|
$: appId = $store.appId |
||||
|
|
||||
|
async function createWorkflow() { |
||||
|
await workflowStore.actions.create({ |
||||
|
name, |
||||
|
instanceId, |
||||
|
}) |
||||
|
onClosed() |
||||
|
notifier.success(`Workflow ${name} created.`) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<header> |
||||
|
<i class="ri-stackshare-line" /> |
||||
|
Create Workflow |
||||
|
</header> |
||||
|
<div> |
||||
|
<label class="uk-form-label" for="form-stacked-text">Name</label> |
||||
|
<input class="uk-input" type="text" bind:value={name} /> |
||||
|
</div> |
||||
|
<footer> |
||||
|
<a href="https://docs.budibase.com"> |
||||
|
<i class="ri-information-line" /> |
||||
|
Learn about workflows |
||||
|
</a> |
||||
|
<ActionButton alert on:click={onClosed}>Cancel</ActionButton> |
||||
|
<ActionButton disabled={!valid} on:click={createWorkflow}>Save</ActionButton> |
||||
|
</footer> |
||||
|
|
||||
|
<style> |
||||
|
header { |
||||
|
font-size: 24px; |
||||
|
color: var(--font); |
||||
|
font-weight: bold; |
||||
|
padding: 30px; |
||||
|
} |
||||
|
|
||||
|
header i { |
||||
|
margin-right: 10px; |
||||
|
font-size: 20px; |
||||
|
background: var(--secondary); |
||||
|
color: var(--dark-grey); |
||||
|
padding: 8px; |
||||
|
} |
||||
|
|
||||
|
div { |
||||
|
padding: 0 30px 30px 30px; |
||||
|
} |
||||
|
|
||||
|
label { |
||||
|
font-size: 18px; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
footer { |
||||
|
display: grid; |
||||
|
grid-auto-flow: column; |
||||
|
grid-gap: 5px; |
||||
|
grid-auto-columns: 3fr 1fr 1fr; |
||||
|
padding: 20px; |
||||
|
background: #fafafa; |
||||
|
border-radius: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
footer a { |
||||
|
color: var(--primary); |
||||
|
font-size: 14px; |
||||
|
vertical-align: middle; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
footer i { |
||||
|
font-size: 20px; |
||||
|
margin-right: 10px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,126 @@ |
|||||
|
<script> |
||||
|
import Modal from "svelte-simple-modal" |
||||
|
import { notifier } from "@beyonk/svelte-notifications" |
||||
|
import { onMount, getContext } from "svelte" |
||||
|
import { backendUiStore, workflowStore } from "builderStore" |
||||
|
import api from "builderStore/api" |
||||
|
import CreateWorkflowModal from "./CreateWorkflowModal.svelte" |
||||
|
|
||||
|
const { open, close } = getContext("simple-modal") |
||||
|
|
||||
|
$: currentWorkflowId = |
||||
|
$workflowStore.currentWorkflow && |
||||
|
$workflowStore.currentWorkflow.workflow._id |
||||
|
|
||||
|
function newWorkflow() { |
||||
|
open( |
||||
|
CreateWorkflowModal, |
||||
|
{ |
||||
|
onClosed: close, |
||||
|
}, |
||||
|
{ styleContent: { padding: "0" } } |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
onMount(() => { |
||||
|
workflowStore.actions.fetch($backendUiStore.selectedDatabase._id) |
||||
|
}) |
||||
|
|
||||
|
async function saveWorkflow() { |
||||
|
const workflow = $workflowStore.currentWorkflow.workflow |
||||
|
await workflowStore.actions.save({ |
||||
|
instanceId: $backendUiStore.selectedDatabase._id, |
||||
|
workflow, |
||||
|
}) |
||||
|
notifier.success(`Workflow ${workflow.name} saved.`) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<section> |
||||
|
<button class="new-workflow-button hoverable" on:click={newWorkflow}> |
||||
|
Create New Workflow |
||||
|
</button> |
||||
|
<ul> |
||||
|
{#each $workflowStore.workflows as workflow} |
||||
|
<li |
||||
|
class="workflow-item" |
||||
|
class:selected={workflow._id === currentWorkflowId} |
||||
|
on:click={() => workflowStore.actions.select(workflow)}> |
||||
|
<i class="ri-stackshare-line" class:live={workflow.live} /> |
||||
|
{workflow.name} |
||||
|
</li> |
||||
|
{/each} |
||||
|
</ul> |
||||
|
{#if $workflowStore.currentWorkflow} |
||||
|
<button class="new-workflow-button hoverable" on:click={saveWorkflow}> |
||||
|
Save Workflow |
||||
|
</button> |
||||
|
{/if} |
||||
|
</section> |
||||
|
|
||||
|
<style> |
||||
|
section { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
} |
||||
|
|
||||
|
i { |
||||
|
color: #adaec4; |
||||
|
} |
||||
|
|
||||
|
i:hover { |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
ul { |
||||
|
list-style-type: none; |
||||
|
padding: 0; |
||||
|
flex: 1; |
||||
|
} |
||||
|
|
||||
|
.live { |
||||
|
color: var(--primary); |
||||
|
} |
||||
|
|
||||
|
li { |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
|
||||
|
.workflow-item { |
||||
|
padding: 20px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
border-radius: 3px; |
||||
|
height: 32px; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.workflow-item i { |
||||
|
font-size: 24px; |
||||
|
margin-right: 10px; |
||||
|
} |
||||
|
|
||||
|
.workflow-item:hover { |
||||
|
cursor: pointer; |
||||
|
background: var(--secondary); |
||||
|
} |
||||
|
|
||||
|
.workflow-item.selected { |
||||
|
background: var(--secondary); |
||||
|
} |
||||
|
|
||||
|
.new-workflow-button { |
||||
|
font-family: Roboto; |
||||
|
width: 100%; |
||||
|
border: solid 1px #f2f2f2; |
||||
|
border-radius: 2px; |
||||
|
background: var(--white); |
||||
|
height: 32px; |
||||
|
font-size: 12px; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.new-workflow-button:hover { |
||||
|
background: var(--light-grey); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,50 @@ |
|||||
|
<script> |
||||
|
import { onMount } from "svelte" |
||||
|
import { backendUiStore, workflowStore } from "builderStore" |
||||
|
import { WorkflowList, BlockList } from "./" |
||||
|
import api from "builderStore/api" |
||||
|
import blockDefinitions from "./blockDefinitions" |
||||
|
|
||||
|
let selectedTab = "WORKFLOWS" |
||||
|
let definitions = [] |
||||
|
</script> |
||||
|
|
||||
|
<header> |
||||
|
<span |
||||
|
class="hoverable workflow-header" |
||||
|
class:selected={selectedTab === 'WORKFLOWS'} |
||||
|
on:click={() => (selectedTab = 'WORKFLOWS')}> |
||||
|
Workflows |
||||
|
</span> |
||||
|
{#if $workflowStore.currentWorkflow} |
||||
|
<span |
||||
|
class="hoverable" |
||||
|
class:selected={selectedTab === 'ADD'} |
||||
|
on:click={() => (selectedTab = 'ADD')}> |
||||
|
Add |
||||
|
</span> |
||||
|
{/if} |
||||
|
</header> |
||||
|
{#if selectedTab === 'WORKFLOWS'} |
||||
|
<WorkflowList /> |
||||
|
{:else if selectedTab === 'ADD'} |
||||
|
<BlockList /> |
||||
|
{/if} |
||||
|
|
||||
|
<style> |
||||
|
header { |
||||
|
font-size: 20px; |
||||
|
font-weight: bold; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
margin-bottom: 20px; |
||||
|
} |
||||
|
|
||||
|
.workflow-header { |
||||
|
margin-right: 20px; |
||||
|
} |
||||
|
|
||||
|
span:not(.selected) { |
||||
|
color: var(--dark-grey); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,170 @@ |
|||||
|
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: {}, |
||||
|
}, |
||||
|
}, |
||||
|
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: {}, |
||||
|
}, |
||||
|
}, |
||||
|
// 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", |
||||
|
}, |
||||
|
}, |
||||
|
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", |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
const TRIGGER = { |
||||
|
RECORD_SAVED: { |
||||
|
name: "Record Saved", |
||||
|
event: "record:save", |
||||
|
icon: "ri-save-line", |
||||
|
tagline: "Record is added to <b>{{model.name}}</b>", |
||||
|
description: "Save a record to your database.", |
||||
|
environment: "SERVER", |
||||
|
params: { |
||||
|
model: "model", |
||||
|
}, |
||||
|
}, |
||||
|
RECORD_DELETED: { |
||||
|
name: "Record Deleted", |
||||
|
event: "record:delete", |
||||
|
icon: "ri-delete-bin-line", |
||||
|
tagline: "Record is deleted from <b>{{model.name}}</b>", |
||||
|
description: "Fired when a record is deleted from your database.", |
||||
|
environment: "SERVER", |
||||
|
params: { |
||||
|
model: "model", |
||||
|
}, |
||||
|
}, |
||||
|
// CLICK: {
|
||||
|
// name: "Click",
|
||||
|
// icon: "ri-cursor-line",
|
||||
|
// tagline: "{{component}} is clicked",
|
||||
|
// description: "Trigger when you click on an element in the UI.",
|
||||
|
// environment: "CLIENT",
|
||||
|
// params: {
|
||||
|
// component: "component"
|
||||
|
// }
|
||||
|
// },
|
||||
|
// LOAD: {
|
||||
|
// name: "Load",
|
||||
|
// icon: "ri-loader-line",
|
||||
|
// tagline: "{{component}} is loaded",
|
||||
|
// description: "Trigger an element has finished loading.",
|
||||
|
// environment: "CLIENT",
|
||||
|
// params: {
|
||||
|
// component: "component"
|
||||
|
// }
|
||||
|
// },
|
||||
|
// INPUT: {
|
||||
|
// name: "Input",
|
||||
|
// icon: "ri-text",
|
||||
|
// tagline: "Text entered into {{component}",
|
||||
|
// description: "Trigger when you type into an input box.",
|
||||
|
// environment: "CLIENT",
|
||||
|
// params: {
|
||||
|
// component: "component"
|
||||
|
// }
|
||||
|
// },
|
||||
|
} |
||||
|
|
||||
|
const LOGIC = { |
||||
|
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", |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
export default { |
||||
|
ACTION, |
||||
|
TRIGGER, |
||||
|
LOGIC, |
||||
|
} |
||||
@ -0,0 +1,3 @@ |
|||||
|
export { default as WorkflowPanel } from "./WorkflowPanel.svelte" |
||||
|
export { default as BlockList } from "./BlockList/BlockList.svelte" |
||||
|
export { default as WorkflowList } from "./WorkflowList/WorkflowList.svelte" |
||||
@ -0,0 +1,3 @@ |
|||||
|
export { default as WorkflowBuilder } from "./WorkflowBuilder/WorkflowBuilder.svelte" |
||||
|
export { default as SetupPanel } from "./SetupPanel/SetupPanel.svelte" |
||||
|
export { default as WorkflowPanel } from "./WorkflowPanel/WorkflowPanel.svelte" |
||||
@ -0,0 +1,647 @@ |
|||||
|
body, html { |
||||
|
margin: 0px; |
||||
|
padding: 0px; |
||||
|
overflow: hidden; |
||||
|
background-repeat: repeat; |
||||
|
background-size: 30px 30px; |
||||
|
background-color: #FBFBFB; |
||||
|
height: 100%; |
||||
|
} |
||||
|
#navigation { |
||||
|
height: 71px; |
||||
|
background-color: #FFF; |
||||
|
border: 1px solid #E8E8EF; |
||||
|
width: 100%; |
||||
|
display: table; |
||||
|
box-sizing: border-box; |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
z-index: 9 |
||||
|
} |
||||
|
#back { |
||||
|
width: 40px; |
||||
|
height: 40px; |
||||
|
border-radius: 100px; |
||||
|
background-color: #F1F4FC; |
||||
|
text-align: center; |
||||
|
display: inline-block; |
||||
|
vertical-align: top; |
||||
|
margin-top: 12px; |
||||
|
margin-right: 10px |
||||
|
} |
||||
|
#back img { |
||||
|
margin-top: 13px; |
||||
|
} |
||||
|
#names { |
||||
|
display: inline-block; |
||||
|
vertical-align: top; |
||||
|
} |
||||
|
#title { |
||||
|
font-family: Roboto; |
||||
|
font-weight: 500; |
||||
|
font-size: 16px; |
||||
|
color: #393C44; |
||||
|
margin-bottom: 0px; |
||||
|
} |
||||
|
#subtitle { |
||||
|
font-family: Roboto; |
||||
|
color: #808292; |
||||
|
font-size: 14px; |
||||
|
margin-top: 5px; |
||||
|
} |
||||
|
#leftside { |
||||
|
display: inline-block; |
||||
|
vertical-align: middle; |
||||
|
margin-left: 20px; |
||||
|
} |
||||
|
#centerswitch { |
||||
|
position: absolute; |
||||
|
width: 222px; |
||||
|
left: 50%; |
||||
|
margin-left: -111px; |
||||
|
top: 15px; |
||||
|
} |
||||
|
#leftswitch { |
||||
|
border: 1px solid #E8E8EF; |
||||
|
background-color: #FBFBFB; |
||||
|
width: 111px; |
||||
|
height: 39px; |
||||
|
line-height: 39px; |
||||
|
border-radius: 5px 0px 0px 5px; |
||||
|
font-family: Roboto; |
||||
|
color: #393C44; |
||||
|
display: inline-block; |
||||
|
font-size: 14px; |
||||
|
text-align: center; |
||||
|
} |
||||
|
#rightswitch { |
||||
|
font-family: Roboto; |
||||
|
color: #808292; |
||||
|
border-radius: 0px 5px 5px 0px; |
||||
|
border: 1px solid #E8E8EF; |
||||
|
height: 39px; |
||||
|
width: 102px; |
||||
|
display: inline-block; |
||||
|
font-size: 14px; |
||||
|
line-height: 39px; |
||||
|
text-align: center; |
||||
|
margin-left: -5px; |
||||
|
} |
||||
|
#discard { |
||||
|
font-family: Roboto; |
||||
|
font-weight: 500; |
||||
|
font-size: 14px; |
||||
|
color: #A6A6B3; |
||||
|
width: 95px; |
||||
|
height: 38px; |
||||
|
border: 1px solid #E8E8EF; |
||||
|
border-radius: 5px; |
||||
|
text-align: center; |
||||
|
line-height: 38px; |
||||
|
display: inline-block; |
||||
|
vertical-align: top; |
||||
|
transition: all .2s cubic-bezier(.05,.03,.35,1); |
||||
|
} |
||||
|
#discard:hover { |
||||
|
cursor: pointer; |
||||
|
opacity: .7; |
||||
|
} |
||||
|
#publish { |
||||
|
font-family: Roboto; |
||||
|
font-weight: 500; |
||||
|
font-size: 14px; |
||||
|
color: #FFF; |
||||
|
background-color: #217CE8; |
||||
|
border-radius: 5px; |
||||
|
width: 143px; |
||||
|
height: 38px; |
||||
|
margin-left: 10px; |
||||
|
display: inline-block; |
||||
|
vertical-align: top; |
||||
|
text-align: center; |
||||
|
line-height: 38px; |
||||
|
margin-right: 20px; |
||||
|
transition: all .2s cubic-bezier(.05,.03,.35,1); |
||||
|
} |
||||
|
#publish:hover { |
||||
|
cursor: pointer; |
||||
|
opacity: .7; |
||||
|
} |
||||
|
#buttonsright { |
||||
|
float: right; |
||||
|
margin-top: 15px; |
||||
|
} |
||||
|
#leftcard { |
||||
|
width: 363px; |
||||
|
background-color: #FFF; |
||||
|
border: 1px solid #E8E8EF; |
||||
|
box-sizing: border-box; |
||||
|
padding-top: 85px; |
||||
|
padding-left: 20px; |
||||
|
height: 100%; |
||||
|
position: absolute; |
||||
|
z-index: 2; |
||||
|
} |
||||
|
#search input { |
||||
|
width: 318px; |
||||
|
height: 40px; |
||||
|
background-color: #FFF; |
||||
|
border: 1px solid #E8E8EF; |
||||
|
box-sizing: border-box; |
||||
|
box-shadow: 0px 2px 8px rgba(34,34,87,0.05); |
||||
|
border-radius: 5px; |
||||
|
text-indent: 35px; |
||||
|
font-family: Roboto; |
||||
|
font-size: 16px; |
||||
|
} |
||||
|
::-webkit-input-placeholder { /* Edge */ |
||||
|
color: #C9C9D5; |
||||
|
} |
||||
|
|
||||
|
:-ms-input-placeholder { /* Internet Explorer 10-11 */ |
||||
|
color: #C9C9D5 |
||||
|
} |
||||
|
|
||||
|
::placeholder { |
||||
|
color: #C9C9D5; |
||||
|
} |
||||
|
#search img { |
||||
|
position: absolute; |
||||
|
margin-top: 10px; |
||||
|
width: 18px; |
||||
|
margin-left: 12px; |
||||
|
} |
||||
|
#header { |
||||
|
font-size: 20px; |
||||
|
font-family: Roboto; |
||||
|
font-weight: bold; |
||||
|
color: #393C44; |
||||
|
} |
||||
|
#subnav { |
||||
|
border-bottom: 1px solid #E8E8EF; |
||||
|
width: calc(100% + 20px); |
||||
|
margin-left: -20px; |
||||
|
margin-top: 10px; |
||||
|
} |
||||
|
.navdisabled { |
||||
|
transition: all .3s cubic-bezier(.05,.03,.35,1); |
||||
|
} |
||||
|
.navdisabled:hover { |
||||
|
cursor: pointer; |
||||
|
opacity: .5; |
||||
|
} |
||||
|
.navactive { |
||||
|
color: #393C44!important; |
||||
|
} |
||||
|
#triggers { |
||||
|
margin-left: 20px; |
||||
|
font-family: Roboto; |
||||
|
font-weight: 500; |
||||
|
font-size: 14px; |
||||
|
text-align: center; |
||||
|
color: #808292; |
||||
|
width: calc(88% / 3); |
||||
|
height: 48px; |
||||
|
line-height: 48px; |
||||
|
display: inline-block; |
||||
|
float: left; |
||||
|
} |
||||
|
.navactive:after { |
||||
|
display: block; |
||||
|
content: ""; |
||||
|
width: 100%; |
||||
|
height: 4px; |
||||
|
background-color: #217CE8; |
||||
|
margin-top: -4px; |
||||
|
} |
||||
|
#actions { |
||||
|
display: inline-block; |
||||
|
font-family: Roboto; |
||||
|
font-weight: 500; |
||||
|
color: #808292; |
||||
|
font-size: 14px; |
||||
|
height: 48px; |
||||
|
line-height: 48px; |
||||
|
width: calc(88% / 3); |
||||
|
text-align: center; |
||||
|
float: left; |
||||
|
} |
||||
|
#loggers { |
||||
|
width: calc(88% / 3); |
||||
|
display: inline-block; |
||||
|
font-family: Roboto; |
||||
|
font-weight: 500; |
||||
|
color: #808292; |
||||
|
font-size: 14px; |
||||
|
height: 48px; |
||||
|
line-height: 48px; |
||||
|
text-align: center; |
||||
|
} |
||||
|
#footer { |
||||
|
position: absolute; |
||||
|
left: 0; |
||||
|
padding-left: 20px; |
||||
|
line-height: 40px; |
||||
|
bottom: 0; |
||||
|
width: 362px; |
||||
|
border: 1px solid #E8E8EF; |
||||
|
height: 67px; |
||||
|
box-sizing: border-box; |
||||
|
background-color: #FFF; |
||||
|
font-family: Roboto; |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
#footer a { |
||||
|
text-decoration: none; |
||||
|
color: #393C44; |
||||
|
transition: all .2s cubic-bezier(.05,.03,.35,1); |
||||
|
} |
||||
|
#footer a:hover { |
||||
|
opacity: .5; |
||||
|
} |
||||
|
#footer span { |
||||
|
color: #808292; |
||||
|
} |
||||
|
#footer p { |
||||
|
display: inline-block; |
||||
|
color: #808292; |
||||
|
} |
||||
|
#footer img { |
||||
|
margin-left: 5px; |
||||
|
margin-right: 5px; |
||||
|
} |
||||
|
.blockelem:first-child { |
||||
|
margin-top: 20px |
||||
|
} |
||||
|
.blockelem { |
||||
|
padding-top: 10px; |
||||
|
width: 318px; |
||||
|
border: 1px solid transparent; |
||||
|
transition-property: box-shadow, height; |
||||
|
transition-duration: .2s; |
||||
|
transition-timing-function: cubic-bezier(.05,.03,.35,1); |
||||
|
border-radius: 5px; |
||||
|
box-shadow: 0px 0px 30px rgba(22, 33, 74, 0); |
||||
|
box-sizing: border-box; |
||||
|
} |
||||
|
.blockelem:hover { |
||||
|
box-shadow: 0px 4px 30px rgba(22, 33, 74, 0.08); |
||||
|
border-radius: 5px; |
||||
|
background-color: #FFF; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
.grabme, .blockico { |
||||
|
display: inline-block; |
||||
|
} |
||||
|
|
||||
|
.grabme { |
||||
|
margin-top: 10px; |
||||
|
margin-left: 10px; |
||||
|
margin-bottom: -14px; |
||||
|
width: 15px; |
||||
|
} |
||||
|
#blocklist { |
||||
|
height: calc(100% - 220px); |
||||
|
overflow: auto; |
||||
|
} |
||||
|
#proplist { |
||||
|
height: calc(100% - 305px); |
||||
|
overflow: auto; |
||||
|
margin-top: -30px; |
||||
|
padding-top: 30px; |
||||
|
} |
||||
|
.blockin { |
||||
|
display: inline-block; |
||||
|
vertical-align: top; |
||||
|
margin-left: 12px; |
||||
|
} |
||||
|
.blockico { |
||||
|
width: 36px; |
||||
|
height: 36px; |
||||
|
background-color: #F1F4FC; |
||||
|
border-radius: 5px; |
||||
|
text-align: center; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
|
||||
|
.blockico i { |
||||
|
font-size: 24px; |
||||
|
color: var(--dark-grey); |
||||
|
} |
||||
|
|
||||
|
.blockico span { |
||||
|
height: 100%; |
||||
|
width: 0px; |
||||
|
display: inline-block; |
||||
|
vertical-align: middle; |
||||
|
} |
||||
|
.blockico img { |
||||
|
vertical-align: middle; |
||||
|
margin-left: auto; |
||||
|
margin-right: auto; |
||||
|
display: inline-block; |
||||
|
} |
||||
|
.blocktext { |
||||
|
display: inline-block; |
||||
|
width: 220px; |
||||
|
vertical-align: top; |
||||
|
margin-left: 12px |
||||
|
} |
||||
|
.blocktitle { |
||||
|
margin: 0px!important; |
||||
|
padding: 0px!important; |
||||
|
font-family: Roboto; |
||||
|
font-weight: 500; |
||||
|
font-size: 16px; |
||||
|
color: #393C44; |
||||
|
} |
||||
|
.blockdesc { |
||||
|
margin-top: 5px; |
||||
|
font-family: Roboto; |
||||
|
color: #808292; |
||||
|
font-size: 14px; |
||||
|
line-height: 21px; |
||||
|
} |
||||
|
.blockdisabled { |
||||
|
background-color: #F0F2F9; |
||||
|
opacity: .5; |
||||
|
} |
||||
|
#closecard { |
||||
|
position: absolute; |
||||
|
margin-left: 340px; |
||||
|
background-color: #FFF; |
||||
|
border-radius: 0px 5px 5px 0px; |
||||
|
border-bottom: 1px solid #E8E8EF; |
||||
|
border-right: 1px solid #E8E8EF; |
||||
|
border-top: 1px solid #E8E8EF; |
||||
|
width: 53px; |
||||
|
height: 53px; |
||||
|
text-align: center; |
||||
|
z-index: 10; |
||||
|
} |
||||
|
#closecard img { |
||||
|
margin-top: 15px |
||||
|
} |
||||
|
#canvas { |
||||
|
border: 1px solid green; |
||||
|
position: absolute; |
||||
|
width: calc(100% - 361px); |
||||
|
height: calc(100% - 71px); |
||||
|
top: 71px; |
||||
|
left: 361px; |
||||
|
z-index: 0; |
||||
|
overflow: auto; |
||||
|
} |
||||
|
#propwrap { |
||||
|
position: absolute; |
||||
|
right: 0; |
||||
|
top: 0; |
||||
|
width: 311px; |
||||
|
height: 100%; |
||||
|
padding-left: 20px; |
||||
|
overflow: hidden; |
||||
|
z-index: -2; |
||||
|
} |
||||
|
#properties { |
||||
|
position: absolute; |
||||
|
height: 100%; |
||||
|
width: 311px; |
||||
|
background-color: #FFF; |
||||
|
right: -150px; |
||||
|
opacity: 0; |
||||
|
z-index: 2; |
||||
|
top: 0px; |
||||
|
box-shadow: -4px 0px 40px rgba(26, 26, 73, 0); |
||||
|
padding-left: 20px; |
||||
|
transition: all .25s cubic-bezier(.05,.03,.35,1); |
||||
|
} |
||||
|
.itson { |
||||
|
z-index: 2!important; |
||||
|
} |
||||
|
.expanded { |
||||
|
right: 0!important; |
||||
|
opacity: 1!important; |
||||
|
box-shadow: -4px 0px 40px rgba(26, 26, 73, 0.05); |
||||
|
z-index: 2; |
||||
|
} |
||||
|
#header2 { |
||||
|
font-size: 20px; |
||||
|
font-family: Roboto; |
||||
|
font-weight: bold; |
||||
|
color: #393C44; |
||||
|
margin-top: 101px; |
||||
|
} |
||||
|
#close { |
||||
|
margin-top: 100px; |
||||
|
position: absolute; |
||||
|
right: 20px; |
||||
|
z-index: 9999; |
||||
|
transition: all .25s cubic-bezier(.05,.03,.35,1); |
||||
|
} |
||||
|
#close:hover { |
||||
|
cursor: pointer; |
||||
|
opacity: .7; |
||||
|
} |
||||
|
#propswitch { |
||||
|
border-bottom: 1px solid #E8E8EF; |
||||
|
width: 331px; |
||||
|
margin-top: 10px; |
||||
|
margin-left: -20px; |
||||
|
margin-bottom: 30px; |
||||
|
} |
||||
|
#dataprop { |
||||
|
font-family: Roboto; |
||||
|
font-weight: 500; |
||||
|
font-size: 14px; |
||||
|
text-align: center; |
||||
|
color: #393C44; |
||||
|
width: calc(88% / 3); |
||||
|
height: 48px; |
||||
|
line-height: 48px; |
||||
|
display: inline-block; |
||||
|
float: left; |
||||
|
margin-left: 20px; |
||||
|
} |
||||
|
#dataprop:after { |
||||
|
display: block; |
||||
|
content: ""; |
||||
|
width: 100%; |
||||
|
height: 4px; |
||||
|
background-color: #217CE8; |
||||
|
margin-top: -4px; |
||||
|
} |
||||
|
#alertprop { |
||||
|
display: inline-block; |
||||
|
font-family: Roboto; |
||||
|
font-weight: 500; |
||||
|
color: #808292; |
||||
|
font-size: 14px; |
||||
|
height: 48px; |
||||
|
line-height: 48px; |
||||
|
width: calc(88% / 3); |
||||
|
text-align: center; |
||||
|
float: left; |
||||
|
} |
||||
|
#logsprop { |
||||
|
width: calc(88% / 3); |
||||
|
display: inline-block; |
||||
|
font-family: Roboto; |
||||
|
font-weight: 500; |
||||
|
color: #808292; |
||||
|
font-size: 14px; |
||||
|
height: 48px; |
||||
|
line-height: 48px; |
||||
|
text-align: center; |
||||
|
} |
||||
|
.inputlabel { |
||||
|
font-family: Roboto; |
||||
|
font-size: 14px; |
||||
|
color: #253134; |
||||
|
} |
||||
|
.dropme { |
||||
|
background-color: #FFF; |
||||
|
border-radius: 5px; |
||||
|
border: 1px solid #E8E8EF; |
||||
|
box-shadow: 0px 2px 8px rgba(34, 34, 87, 0.05); |
||||
|
font-family: Roboto; |
||||
|
font-size: 14px; |
||||
|
color: #253134; |
||||
|
text-indent: 20px; |
||||
|
height: 40px; |
||||
|
line-height: 40px; |
||||
|
width: 287px; |
||||
|
margin-bottom: 25px; |
||||
|
} |
||||
|
.dropme img { |
||||
|
margin-top: 17px; |
||||
|
float: right; |
||||
|
margin-right: 15px; |
||||
|
} |
||||
|
.checkus { |
||||
|
margin-bottom: 10px; |
||||
|
} |
||||
|
.checkus img { |
||||
|
display: inline-block; |
||||
|
vertical-align: middle; |
||||
|
} |
||||
|
.checkus p { |
||||
|
display: inline-block; |
||||
|
font-family: Roboto; |
||||
|
font-size: 14px; |
||||
|
vertical-align: middle; |
||||
|
margin-left: 10px; |
||||
|
} |
||||
|
#divisionthing { |
||||
|
height: 1px; |
||||
|
width: 100%; |
||||
|
background-color: #E8E8EF; |
||||
|
position: absolute; |
||||
|
right: 0px; |
||||
|
bottom: 80; |
||||
|
} |
||||
|
#removeblock { |
||||
|
border-radius: 5px; |
||||
|
position: absolute; |
||||
|
bottom: 20px; |
||||
|
font-family: Roboto; |
||||
|
font-size: 14px; |
||||
|
text-align: center; |
||||
|
width: 287px; |
||||
|
height: 38px; |
||||
|
line-height: 38px; |
||||
|
color: #253134; |
||||
|
border: 1px solid #E8E8EF; |
||||
|
transition: all .3s cubic-bezier(.05,.03,.35,1); |
||||
|
} |
||||
|
#removeblock:hover { |
||||
|
cursor: pointer; |
||||
|
opacity: .5; |
||||
|
} |
||||
|
.noselect { |
||||
|
-webkit-touch-callout: none; /* iOS Safari */ |
||||
|
-webkit-user-select: none; /* Safari */ |
||||
|
-khtml-user-select: none; /* Konqueror HTML */ |
||||
|
-moz-user-select: none; /* Old versions of Firefox */ |
||||
|
-ms-user-select: none; /* Internet Explorer/Edge */ |
||||
|
user-select: none; /* Non-prefixed version, currently |
||||
|
supported by Chrome, Opera and Firefox */ |
||||
|
} |
||||
|
.blockyname { |
||||
|
font-family: Roboto; |
||||
|
font-weight: 500; |
||||
|
color: #253134; |
||||
|
display: inline-block; |
||||
|
vertical-align: middle; |
||||
|
margin-left: 8px; |
||||
|
font-size: 16px; |
||||
|
} |
||||
|
.blockyleft img { |
||||
|
display: inline-block; |
||||
|
vertical-align: middle; |
||||
|
} |
||||
|
.blockyright { |
||||
|
display: inline-block; |
||||
|
float: right; |
||||
|
vertical-align: middle; |
||||
|
margin-right: 20px; |
||||
|
margin-top: 10px; |
||||
|
width: 28px; |
||||
|
height: 28px; |
||||
|
border-radius: 5px; |
||||
|
text-align: center; |
||||
|
background-color: #FFF; |
||||
|
transition: all .3s cubic-bezier(.05,.03,.35,1); |
||||
|
z-index: 10; |
||||
|
} |
||||
|
.blockyright:hover { |
||||
|
background-color: #F1F4FC; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
.blockyright img { |
||||
|
margin-top: 12px; |
||||
|
} |
||||
|
.blockyleft { |
||||
|
display: inline-block; |
||||
|
margin-left: 20px; |
||||
|
} |
||||
|
.blockydiv { |
||||
|
width: 100%; |
||||
|
height: 1px; |
||||
|
background-color: #E9E9EF; |
||||
|
} |
||||
|
.blockyinfo { |
||||
|
font-family: Roboto; |
||||
|
font-size: 14px; |
||||
|
color: #808292; |
||||
|
margin-top: 15px; |
||||
|
text-indent: 20px; |
||||
|
margin-bottom: 20px; |
||||
|
} |
||||
|
.blockyinfo span { |
||||
|
color: #253134; |
||||
|
font-weight: 500; |
||||
|
display: inline-block; |
||||
|
border-bottom: 1px solid #D3DCEA; |
||||
|
line-height: 20px; |
||||
|
text-indent: 0px; |
||||
|
} |
||||
|
.block { |
||||
|
background-color: #FFF; |
||||
|
margin-top: 0px!important; |
||||
|
box-shadow: 0px 4px 30px rgba(22, 33, 74, 0.05); |
||||
|
} |
||||
|
.selectedblock { |
||||
|
border: 2px solid #217CE8; |
||||
|
box-shadow: 0px 4px 30px rgba(22, 33, 74, 0.08); |
||||
|
} |
||||
|
|
||||
|
@media only screen and (max-width: 832px) { |
||||
|
#centerswitch { |
||||
|
display: none; |
||||
|
} |
||||
|
} |
||||
|
@media only screen and (max-width: 560px) { |
||||
|
#names { |
||||
|
display: none; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,3 @@ |
|||||
|
<script> |
||||
|
store.setCurrentPage($params.page) |
||||
|
</script> |
||||
@ -0,0 +1,42 @@ |
|||||
|
<script> |
||||
|
import { workflowStore } from "builderStore" |
||||
|
import { WorkflowPanel, SetupPanel } from "components/workflow" |
||||
|
</script> |
||||
|
|
||||
|
<div class="root"> |
||||
|
<div class="nav"> |
||||
|
<WorkflowPanel /> |
||||
|
</div> |
||||
|
<div class="content"> |
||||
|
<slot /> |
||||
|
</div> |
||||
|
<div class="nav"> |
||||
|
<SetupPanel /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.content { |
||||
|
position: relative; |
||||
|
background: var(--background); |
||||
|
} |
||||
|
|
||||
|
.root { |
||||
|
height: 100%; |
||||
|
display: flex; |
||||
|
background: #fafafa; |
||||
|
} |
||||
|
|
||||
|
.content { |
||||
|
flex: 1 1 auto; |
||||
|
margin: 20px 40px; |
||||
|
} |
||||
|
|
||||
|
.nav { |
||||
|
padding: 20px; |
||||
|
overflow: auto; |
||||
|
width: 275px; |
||||
|
border: 1px solid var(--medium-grey); |
||||
|
background: var(--white); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,5 @@ |
|||||
|
<script> |
||||
|
import { WorkflowBuilder } from "components/workflow" |
||||
|
</script> |
||||
|
|
||||
|
<WorkflowBuilder /> |
||||
@ -1,19 +0,0 @@ |
|||||
import { trimSlash } from "../common/trimSlash" |
|
||||
|
|
||||
export const listRecords = api => async ({ indexKey, statePath }) => { |
|
||||
if (!indexKey) { |
|
||||
api.error("Load Record: record key not set") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if (!statePath) { |
|
||||
api.error("Load Record: state path not set") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
const records = await api.get({ |
|
||||
url: `/api/listRecords/${trimSlash(indexKey)}`, |
|
||||
}) |
|
||||
|
|
||||
if (api.isSuccess(records)) api.setState(statePath, records) |
|
||||
} |
|
||||
@ -1,19 +0,0 @@ |
|||||
import { trimSlash } from "../common/trimSlash" |
|
||||
|
|
||||
export const loadRecord = api => async ({ recordKey, statePath }) => { |
|
||||
if (!recordKey) { |
|
||||
api.error("Load Record: record key not set") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if (!statePath) { |
|
||||
api.error("Load Record: state path not set") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
const record = await api.get({ |
|
||||
url: `/api/record/${trimSlash(recordKey)}`, |
|
||||
}) |
|
||||
|
|
||||
if (api.isSuccess(record)) api.setState(statePath, record) |
|
||||
} |
|
||||
@ -1,29 +0,0 @@ |
|||||
import { trimSlash } from "../common/trimSlash" |
|
||||
|
|
||||
export const saveRecord = api => async ({ statePath }) => { |
|
||||
if (!statePath) { |
|
||||
api.error("Load Record: state path not set") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
const recordtoSave = api.getState(statePath) |
|
||||
|
|
||||
if (!recordtoSave) { |
|
||||
api.error(`there is no record in state: ${statePath}`) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if (!recordtoSave.key) { |
|
||||
api.error( |
|
||||
`item in state does not appear to be a record - it has no key (${statePath})` |
|
||||
) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
const savedRecord = await api.post({ |
|
||||
url: `/api/record/${trimSlash(recordtoSave.key)}`, |
|
||||
body: recordtoSave, |
|
||||
}) |
|
||||
|
|
||||
if (api.isSuccess(savedRecord)) api.setState(statePath, savedRecord) |
|
||||
} |
|
||||
@ -0,0 +1,28 @@ |
|||||
|
import { setState } from "../../state/setState" |
||||
|
|
||||
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) |
||||
|
|
||||
|
export default { |
||||
|
SET_STATE: ({ context, args, id }) => { |
||||
|
setState(...Object.values(args)) |
||||
|
context = { |
||||
|
...context, |
||||
|
[id]: args, |
||||
|
} |
||||
|
return context |
||||
|
}, |
||||
|
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 |
||||
|
} |
||||
|
}, |
||||
|
} |
||||
@ -0,0 +1,69 @@ |
|||||
|
import { get } from "svelte/store" |
||||
|
import mustache from "mustache" |
||||
|
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] = mustache.render(argValue, { |
||||
|
context: this.context, |
||||
|
state: get(appStore), |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
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) |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
/** |
||||
|
* The workflow orchestrator is a class responsible for executing workflows. |
||||
|
* It relies on the strategy pattern, which allows composable behaviour to be |
||||
|
* passed into its execute() function. This allows custom execution behaviour based |
||||
|
* on where the orchestrator is run. |
||||
|
* |
||||
|
*/ |
||||
|
export default class Orchestrator { |
||||
|
constructor(api) { |
||||
|
this.api = api |
||||
|
} |
||||
|
|
||||
|
set strategy(strategy) { |
||||
|
this._strategy = strategy({ api: this.api }) |
||||
|
} |
||||
|
|
||||
|
async execute(workflow) { |
||||
|
if (workflow.live) { |
||||
|
this._strategy.run(workflow.definition) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1 +0,0 @@ |
|||||
export const trimSlash = str => str.replace(/^\/+|\/+$/g, "") |
|
||||
@ -1,71 +0,0 @@ |
|||||
import { ERROR } from "./standardState" |
|
||||
|
|
||||
export const getNewChildRecordToState = (coreApi, setState) => ({ |
|
||||
recordKey, |
|
||||
collectionName, |
|
||||
childRecordType, |
|
||||
statePath, |
|
||||
}) => { |
|
||||
const error = errorHandler(setState) |
|
||||
try { |
|
||||
if (!recordKey) { |
|
||||
error("getNewChild > recordKey not set") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if (!collectionName) { |
|
||||
error("getNewChild > collectionName not set") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if (!childRecordType) { |
|
||||
error("getNewChild > childRecordType not set") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if (!statePath) { |
|
||||
error("getNewChild > statePath not set") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
const rec = coreApi.recordApi.getNewChild( |
|
||||
recordKey, |
|
||||
collectionName, |
|
||||
childRecordType |
|
||||
) |
|
||||
setState(statePath, rec) |
|
||||
} catch (e) { |
|
||||
error(e.message) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export const getNewRecordToState = (coreApi, setState) => ({ |
|
||||
collectionKey, |
|
||||
childRecordType, |
|
||||
statePath, |
|
||||
}) => { |
|
||||
const error = errorHandler(setState) |
|
||||
try { |
|
||||
if (!collectionKey) { |
|
||||
error("getNewChild > collectionKey not set") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if (!childRecordType) { |
|
||||
error("getNewChild > childRecordType not set") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if (!statePath) { |
|
||||
error("getNewChild > statePath not set") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
const rec = coreApi.recordApi.getNew(collectionKey, childRecordType) |
|
||||
setState(statePath, rec) |
|
||||
} catch (e) { |
|
||||
error(e.message) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const errorHandler = setState => message => setState(ERROR, message) |
|
||||
@ -1,46 +1,9 @@ |
|||||
import { isUndefined, isObject } from "lodash/fp" |
import { get } from "svelte/store" |
||||
import { parseBinding, isStoreBinding } from "./parseBinding" |
import getOr from "lodash/fp/getOr" |
||||
|
import { appStore } from "./store" |
||||
|
|
||||
export const getState = (s, path, fallback) => { |
export const getState = (path, fallback) => { |
||||
if (!s) return fallback |
|
||||
if (!path || path.length === 0) return fallback |
if (!path || path.length === 0) return fallback |
||||
|
|
||||
if (path === "$") return s |
return getOr(fallback, path, get(appStore)) |
||||
|
|
||||
const pathParts = path.split(".") |
|
||||
const safeGetPath = (obj, currentPartIndex = 0) => { |
|
||||
const currentKey = pathParts[currentPartIndex] |
|
||||
|
|
||||
if (pathParts.length - 1 == currentPartIndex) { |
|
||||
const value = obj[currentKey] |
|
||||
if (isUndefined(value)) return fallback |
|
||||
else return value |
|
||||
} |
|
||||
|
|
||||
if ( |
|
||||
obj[currentKey] === null || |
|
||||
obj[currentKey] === undefined || |
|
||||
!isObject(obj[currentKey]) |
|
||||
) { |
|
||||
return fallback |
|
||||
} |
|
||||
|
|
||||
return safeGetPath(obj[currentKey], currentPartIndex + 1) |
|
||||
} |
|
||||
|
|
||||
return safeGetPath(s) |
|
||||
} |
|
||||
|
|
||||
export const getStateOrValue = (globalState, prop, currentContext) => { |
|
||||
if (!prop) return prop |
|
||||
|
|
||||
const binding = parseBinding(prop) |
|
||||
|
|
||||
if (binding) { |
|
||||
const stateToUse = isStoreBinding(binding) ? globalState : currentContext |
|
||||
|
|
||||
return getState(stateToUse, binding.path, binding.fallback) |
|
||||
} |
|
||||
|
|
||||
return prop |
|
||||
} |
} |
||||
|
|||||
@ -1,67 +0,0 @@ |
|||||
export const BB_STATE_BINDINGPATH = "##bbstate" |
|
||||
export const BB_STATE_BINDINGSOURCE = "##bbsource" |
|
||||
export const BB_STATE_FALLBACK = "##bbstatefallback" |
|
||||
|
|
||||
export const isBound = prop => !!parseBinding(prop) |
|
||||
|
|
||||
/** |
|
||||
* |
|
||||
* @param {object|string|number} prop - component property to parse for a dynamic state binding |
|
||||
* @returns {object|boolean} |
|
||||
*/ |
|
||||
export const parseBinding = prop => { |
|
||||
if (!prop) return false |
|
||||
|
|
||||
if (isBindingExpression(prop)) { |
|
||||
return parseBindingExpression(prop) |
|
||||
} |
|
||||
|
|
||||
if (isAlreadyBinding(prop)) { |
|
||||
return { |
|
||||
path: prop.path, |
|
||||
source: prop.source || "store", |
|
||||
fallback: prop.fallback, |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if (hasBindingObject(prop)) { |
|
||||
return { |
|
||||
path: prop[BB_STATE_BINDINGPATH], |
|
||||
fallback: prop[BB_STATE_FALLBACK] || "", |
|
||||
source: prop[BB_STATE_BINDINGSOURCE] || "store", |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export const isStoreBinding = binding => binding && binding.source === "store" |
|
||||
export const isContextBinding = binding => |
|
||||
binding && binding.source === "context" |
|
||||
export const isEventBinding = binding => binding && binding.source === "event" |
|
||||
|
|
||||
const hasBindingObject = prop => |
|
||||
typeof prop === "object" && prop[BB_STATE_BINDINGPATH] !== undefined |
|
||||
|
|
||||
const isAlreadyBinding = prop => typeof prop === "object" && prop.path |
|
||||
|
|
||||
const isBindingExpression = prop => |
|
||||
typeof prop === "string" && |
|
||||
(prop.startsWith("state.") || |
|
||||
prop.startsWith("context.") || |
|
||||
prop.startsWith("event.") || |
|
||||
prop.startsWith("route.")) |
|
||||
|
|
||||
const parseBindingExpression = prop => { |
|
||||
let [source, ...rest] = prop.split(".") |
|
||||
let path = rest.join(".") |
|
||||
|
|
||||
if (source === "route") { |
|
||||
source = "state" |
|
||||
path = `##routeParams.${path}` |
|
||||
} |
|
||||
|
|
||||
return { |
|
||||
fallback: "", // TODO: provide fallback support
|
|
||||
source, |
|
||||
path, |
|
||||
} |
|
||||
} |
|
||||
@ -1 +0,0 @@ |
|||||
export const ERROR = "##error_message" |
|
||||
@ -1,283 +0,0 @@ |
|||||
import { |
|
||||
isEventType, |
|
||||
eventHandlers, |
|
||||
EVENT_TYPE_MEMBER_NAME, |
|
||||
} from "./eventHandlers" |
|
||||
import { bbFactory } from "./bbComponentApi" |
|
||||
import { getState } from "./getState" |
|
||||
import { attachChildren } from "../render/attachChildren" |
|
||||
|
|
||||
import { parseBinding } from "./parseBinding" |
|
||||
|
|
||||
const doNothing = () => {} |
|
||||
doNothing.isPlaceholder = true |
|
||||
|
|
||||
const isMetaProp = propName => |
|
||||
propName === "_component" || |
|
||||
propName === "_children" || |
|
||||
propName === "_id" || |
|
||||
propName === "_style" || |
|
||||
propName === "_code" || |
|
||||
propName === "_codeMeta" |
|
||||
|
|
||||
export const createStateManager = ({ |
|
||||
store, |
|
||||
coreApi, |
|
||||
rootPath, |
|
||||
frontendDefinition, |
|
||||
componentLibraries, |
|
||||
uiFunctions, |
|
||||
onScreenSlotRendered, |
|
||||
routeTo, |
|
||||
}) => { |
|
||||
let handlerTypes = eventHandlers(store, coreApi, rootPath, routeTo) |
|
||||
let currentState |
|
||||
|
|
||||
// any nodes that have props that are bound to the store
|
|
||||
let nodesBoundByProps = [] |
|
||||
|
|
||||
// any node whose children depend on code, that uses the store
|
|
||||
let nodesWithCodeBoundChildren = [] |
|
||||
|
|
||||
const getCurrentState = () => currentState |
|
||||
const registerBindings = _registerBindings( |
|
||||
nodesBoundByProps, |
|
||||
nodesWithCodeBoundChildren |
|
||||
) |
|
||||
const bb = bbFactory({ |
|
||||
store, |
|
||||
getCurrentState, |
|
||||
frontendDefinition, |
|
||||
componentLibraries, |
|
||||
uiFunctions, |
|
||||
onScreenSlotRendered, |
|
||||
}) |
|
||||
|
|
||||
const setup = _setup(handlerTypes, getCurrentState, registerBindings, bb) |
|
||||
|
|
||||
const unsubscribe = store.subscribe( |
|
||||
onStoreStateUpdated({ |
|
||||
setCurrentState: s => (currentState = s), |
|
||||
getCurrentState, |
|
||||
nodesWithCodeBoundChildren, |
|
||||
nodesBoundByProps, |
|
||||
uiFunctions, |
|
||||
componentLibraries, |
|
||||
onScreenSlotRendered, |
|
||||
setupState: setup, |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
return { |
|
||||
setup, |
|
||||
destroy: () => unsubscribe(), |
|
||||
getCurrentState, |
|
||||
store, |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const onStoreStateUpdated = ({ |
|
||||
setCurrentState, |
|
||||
getCurrentState, |
|
||||
nodesWithCodeBoundChildren, |
|
||||
nodesBoundByProps, |
|
||||
uiFunctions, |
|
||||
componentLibraries, |
|
||||
onScreenSlotRendered, |
|
||||
setupState, |
|
||||
}) => s => { |
|
||||
setCurrentState(s) |
|
||||
|
|
||||
// the original array gets changed by components' destroy()
|
|
||||
// so we make a clone and check if they are still in the original
|
|
||||
const nodesWithBoundChildren_clone = [...nodesWithCodeBoundChildren] |
|
||||
for (let node of nodesWithBoundChildren_clone) { |
|
||||
if (!nodesWithCodeBoundChildren.includes(node)) continue |
|
||||
attachChildren({ |
|
||||
uiFunctions, |
|
||||
componentLibraries, |
|
||||
treeNode: node, |
|
||||
onScreenSlotRendered, |
|
||||
setupState, |
|
||||
getCurrentState, |
|
||||
})(node.rootElement, { hydrate: true, force: true }) |
|
||||
} |
|
||||
|
|
||||
for (let node of nodesBoundByProps) { |
|
||||
setNodeState(s, node) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const _registerBindings = (nodesBoundByProps, nodesWithCodeBoundChildren) => ( |
|
||||
node, |
|
||||
bindings |
|
||||
) => { |
|
||||
if (bindings.length > 0) { |
|
||||
node.bindings = bindings |
|
||||
nodesBoundByProps.push(node) |
|
||||
const onDestroy = () => { |
|
||||
nodesBoundByProps = nodesBoundByProps.filter(n => n === node) |
|
||||
node.onDestroy = node.onDestroy.filter(d => d === onDestroy) |
|
||||
} |
|
||||
node.onDestroy.push(onDestroy) |
|
||||
} |
|
||||
if ( |
|
||||
node.props._children && |
|
||||
node.props._children.filter(c => c._codeMeta && c._codeMeta.dependsOnStore) |
|
||||
.length > 0 |
|
||||
) { |
|
||||
nodesWithCodeBoundChildren.push(node) |
|
||||
const onDestroy = () => { |
|
||||
nodesWithCodeBoundChildren = nodesWithCodeBoundChildren.filter( |
|
||||
n => n === node |
|
||||
) |
|
||||
node.onDestroy = node.onDestroy.filter(d => d === onDestroy) |
|
||||
} |
|
||||
node.onDestroy.push(onDestroy) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const setNodeState = (storeState, node) => { |
|
||||
if (!node.component) return |
|
||||
const newProps = { ...node.bindings.initialProps } |
|
||||
|
|
||||
for (let binding of node.bindings) { |
|
||||
const val = getState(storeState, binding.path, binding.fallback) |
|
||||
|
|
||||
if (val === undefined && newProps[binding.propName] !== undefined) { |
|
||||
delete newProps[binding.propName] |
|
||||
} |
|
||||
|
|
||||
if (val !== undefined) { |
|
||||
newProps[binding.propName] = val |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
node.component.$set(newProps) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Bind a components event handler parameters to state, context or the event itself. |
|
||||
* @param {Array} eventHandlerProp - event handler array from component definition |
|
||||
*/ |
|
||||
function bindComponentEventHandlers( |
|
||||
eventHandlerProp, |
|
||||
context, |
|
||||
getCurrentState |
|
||||
) { |
|
||||
const boundEventHandlers = [] |
|
||||
for (let event of eventHandlerProp) { |
|
||||
const boundEventHandler = { |
|
||||
handlerType: event[EVENT_TYPE_MEMBER_NAME], |
|
||||
parameters: event.parameters, |
|
||||
} |
|
||||
|
|
||||
const boundParameters = {} |
|
||||
for (let paramName in boundEventHandler.parameters) { |
|
||||
const paramValue = boundEventHandler.parameters[paramName] |
|
||||
const paramBinding = parseBinding(paramValue) |
|
||||
if (!paramBinding) { |
|
||||
boundParameters[paramName] = () => paramValue |
|
||||
continue |
|
||||
} |
|
||||
|
|
||||
let paramValueSource |
|
||||
|
|
||||
if (paramBinding.source === "context") paramValueSource = context |
|
||||
if (paramBinding.source === "state") paramValueSource = getCurrentState() |
|
||||
|
|
||||
// The new dynamic event parameter bound to the relevant source
|
|
||||
boundParameters[paramName] = eventContext => |
|
||||
getState( |
|
||||
paramBinding.source === "event" ? eventContext : paramValueSource, |
|
||||
paramBinding.path, |
|
||||
paramBinding.fallback |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
boundEventHandler.parameters = boundParameters |
|
||||
boundEventHandlers.push(boundEventHandlers) |
|
||||
|
|
||||
return boundEventHandlers |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const _setup = ( |
|
||||
handlerTypes, |
|
||||
getCurrentState, |
|
||||
registerBindings, |
|
||||
bb |
|
||||
) => node => { |
|
||||
const props = node.props |
|
||||
const context = node.context || {} |
|
||||
const initialProps = { ...props } |
|
||||
const storeBoundProps = [] |
|
||||
const currentStoreState = getCurrentState() |
|
||||
|
|
||||
for (let propName in props) { |
|
||||
if (isMetaProp(propName)) continue |
|
||||
|
|
||||
const propValue = props[propName] |
|
||||
|
|
||||
const binding = parseBinding(propValue) |
|
||||
const isBound = !!binding |
|
||||
|
|
||||
if (isBound) binding.propName = propName |
|
||||
|
|
||||
if (isBound && binding.source === "state") { |
|
||||
storeBoundProps.push(binding) |
|
||||
|
|
||||
initialProps[propName] = !currentStoreState |
|
||||
? binding.fallback |
|
||||
: getState( |
|
||||
currentStoreState, |
|
||||
binding.path, |
|
||||
binding.fallback, |
|
||||
binding.source |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
if (isBound && binding.source === "context") { |
|
||||
initialProps[propName] = !context |
|
||||
? propValue |
|
||||
: getState(context, binding.path, binding.fallback, binding.source) |
|
||||
} |
|
||||
|
|
||||
if (isEventType(propValue)) { |
|
||||
const boundEventHandlers = bindComponentEventHandlers( |
|
||||
propValue, |
|
||||
context, |
|
||||
getCurrentState |
|
||||
) |
|
||||
|
|
||||
if (boundEventHandlers.length === 0) { |
|
||||
initialProps[propName] = doNothing |
|
||||
} else { |
|
||||
initialProps[propName] = async context => { |
|
||||
for (let handlerInfo of boundEventHandlers) { |
|
||||
const handler = makeHandler(handlerTypes, handlerInfo) |
|
||||
await handler(context) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
registerBindings(node, storeBoundProps) |
|
||||
|
|
||||
const setup = _setup(handlerTypes, getCurrentState, registerBindings, bb) |
|
||||
initialProps._bb = bb(node, setup) |
|
||||
|
|
||||
return initialProps |
|
||||
} |
|
||||
|
|
||||
const makeHandler = (handlerTypes, handlerInfo) => { |
|
||||
const handlerType = handlerTypes[handlerInfo.handlerType] |
|
||||
return context => { |
|
||||
const parameters = {} |
|
||||
for (let paramName in handlerInfo.parameters) { |
|
||||
parameters[paramName] = handlerInfo.parameters[paramName](context) |
|
||||
} |
|
||||
handlerType.execute(parameters) |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,9 @@ |
|||||
|
import { writable } from "svelte/store" |
||||
|
|
||||
|
const appStore = writable({}) |
||||
|
appStore.actions = {} |
||||
|
|
||||
|
const routerStore = writable({}) |
||||
|
routerStore.actions = {} |
||||
|
|
||||
|
export { appStore, routerStore } |
||||
@ -1,363 +0,0 @@ |
|||||
import { load, makePage, makeScreen } from "./testAppDef" |
|
||||
import { EVENT_TYPE_MEMBER_NAME } from "../src/state/eventHandlers" |
|
||||
|
|
||||
describe("initialiseApp (binding)", () => { |
|
||||
it("should populate root element prop from store value", async () => { |
|
||||
const { dom } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/div", |
|
||||
className: { |
|
||||
"##bbstate": "divClassName", |
|
||||
"##bbsource": "state", |
|
||||
"##bbstatefallback": "default", |
|
||||
}, |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
const rootDiv = dom.window.document.body.children[0] |
|
||||
expect(rootDiv.className.includes("default")).toBe(true) |
|
||||
}) |
|
||||
|
|
||||
it("should update root element from store", async () => { |
|
||||
const { dom, app } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/div", |
|
||||
className: { |
|
||||
"##bbstate": "divClassName", |
|
||||
"##bbsource": "state", |
|
||||
"##bbstatefallback": "default", |
|
||||
}, |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
app.pageStore().update(s => { |
|
||||
s.divClassName = "newvalue" |
|
||||
return s |
|
||||
}) |
|
||||
|
|
||||
const rootDiv = dom.window.document.body.children[0] |
|
||||
expect(rootDiv.className.includes("newvalue")).toBe(true) |
|
||||
}) |
|
||||
|
|
||||
it("should update root element from store, using binding expression", async () => { |
|
||||
const { dom, app } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/div", |
|
||||
className: "state.divClassName", |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
app.pageStore().update(s => { |
|
||||
s.divClassName = "newvalue" |
|
||||
return s |
|
||||
}) |
|
||||
|
|
||||
const rootDiv = dom.window.document.body.children[0] |
|
||||
expect(rootDiv.className.includes("newvalue")).toBe(true) |
|
||||
}) |
|
||||
|
|
||||
it("should populate child component with store value", async () => { |
|
||||
const { dom } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/div", |
|
||||
_children: [ |
|
||||
{ |
|
||||
_component: "testlib/h1", |
|
||||
text: { |
|
||||
"##bbstate": "headerOneText", |
|
||||
"##bbsource": "state", |
|
||||
"##bbstatefallback": "header one", |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
_component: "testlib/h1", |
|
||||
text: { |
|
||||
"##bbstate": "headerTwoText", |
|
||||
"##bbsource": "state", |
|
||||
"##bbstatefallback": "header two", |
|
||||
}, |
|
||||
}, |
|
||||
], |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
const rootDiv = dom.window.document.body.children[0] |
|
||||
|
|
||||
expect(rootDiv.children.length).toBe(2) |
|
||||
expect(rootDiv.children[0].tagName).toBe("H1") |
|
||||
expect(rootDiv.children[0].innerText).toBe("header one") |
|
||||
expect(rootDiv.children[1].tagName).toBe("H1") |
|
||||
expect(rootDiv.children[1].innerText).toBe("header two") |
|
||||
}) |
|
||||
|
|
||||
it("should populate child component with store value", async () => { |
|
||||
const { dom, app } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/div", |
|
||||
_children: [ |
|
||||
{ |
|
||||
_component: "testlib/h1", |
|
||||
text: { |
|
||||
"##bbstate": "headerOneText", |
|
||||
"##bbsource": "state", |
|
||||
"##bbstatefallback": "header one", |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
_component: "testlib/h1", |
|
||||
text: { |
|
||||
"##bbstate": "headerTwoText", |
|
||||
"##bbsource": "state", |
|
||||
"##bbstatefallback": "header two", |
|
||||
}, |
|
||||
}, |
|
||||
], |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
app.pageStore().update(s => { |
|
||||
s.headerOneText = "header 1 - new val" |
|
||||
s.headerTwoText = "header 2 - new val" |
|
||||
return s |
|
||||
}) |
|
||||
|
|
||||
const rootDiv = dom.window.document.body.children[0] |
|
||||
|
|
||||
expect(rootDiv.children.length).toBe(2) |
|
||||
expect(rootDiv.children[0].tagName).toBe("H1") |
|
||||
expect(rootDiv.children[0].innerText).toBe("header 1 - new val") |
|
||||
expect(rootDiv.children[1].tagName).toBe("H1") |
|
||||
expect(rootDiv.children[1].innerText).toBe("header 2 - new val") |
|
||||
}) |
|
||||
|
|
||||
it("should populate screen child with store value", async () => { |
|
||||
const { dom, app } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/div", |
|
||||
_children: [ |
|
||||
{ |
|
||||
_component: "##builtin/screenslot", |
|
||||
text: "header one", |
|
||||
}, |
|
||||
], |
|
||||
}), |
|
||||
[ |
|
||||
makeScreen("/", { |
|
||||
_component: "testlib/div", |
|
||||
className: "screen-class", |
|
||||
_children: [ |
|
||||
{ |
|
||||
_component: "testlib/h1", |
|
||||
text: { |
|
||||
"##bbstate": "headerOneText", |
|
||||
"##bbsource": "state", |
|
||||
"##bbstatefallback": "header one", |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
_component: "testlib/h1", |
|
||||
text: { |
|
||||
"##bbstate": "headerTwoText", |
|
||||
"##bbsource": "state", |
|
||||
"##bbstatefallback": "header two", |
|
||||
}, |
|
||||
}, |
|
||||
], |
|
||||
}), |
|
||||
] |
|
||||
) |
|
||||
|
|
||||
app.screenStore().update(s => { |
|
||||
s.headerOneText = "header 1 - new val" |
|
||||
s.headerTwoText = "header 2 - new val" |
|
||||
return s |
|
||||
}) |
|
||||
|
|
||||
const rootDiv = dom.window.document.body.children[0] |
|
||||
expect(rootDiv.children.length).toBe(1) |
|
||||
|
|
||||
const screenRoot = rootDiv.children[0] |
|
||||
|
|
||||
expect(screenRoot.children.length).toBe(1) |
|
||||
expect(screenRoot.children[0].children.length).toBe(2) |
|
||||
expect(screenRoot.children[0].children[0].innerText).toBe( |
|
||||
"header 1 - new val" |
|
||||
) |
|
||||
expect(screenRoot.children[0].children[1].innerText).toBe( |
|
||||
"header 2 - new val" |
|
||||
) |
|
||||
}) |
|
||||
|
|
||||
it("should fire events", async () => { |
|
||||
const { dom, app } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/button", |
|
||||
onClick: [ |
|
||||
event("Set State", { |
|
||||
path: "address", |
|
||||
value: "123 Main Street", |
|
||||
}), |
|
||||
], |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
const button = dom.window.document.body.children[0] |
|
||||
expect(button.tagName).toBe("BUTTON") |
|
||||
|
|
||||
let storeAddress |
|
||||
app.pageStore().subscribe(s => { |
|
||||
storeAddress = s.address |
|
||||
}) |
|
||||
button.dispatchEvent(new dom.window.Event("click")) |
|
||||
expect(storeAddress).toBe("123 Main Street") |
|
||||
}) |
|
||||
|
|
||||
it("should alter event parameters based on store values", async () => { |
|
||||
const { dom, app } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/button", |
|
||||
onClick: [ |
|
||||
event("Set State", { |
|
||||
path: "address", |
|
||||
value: { |
|
||||
"##bbstate": "sourceaddress", |
|
||||
"##bbsource": "state", |
|
||||
"##bbstatefallback": "fallback address", |
|
||||
}, |
|
||||
}), |
|
||||
], |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
const button = dom.window.document.body.children[0] |
|
||||
expect(button.tagName).toBe("BUTTON") |
|
||||
|
|
||||
let storeAddress |
|
||||
app.pageStore().subscribe(s => { |
|
||||
storeAddress = s.address |
|
||||
}) |
|
||||
|
|
||||
button.dispatchEvent(new dom.window.Event("click")) |
|
||||
expect(storeAddress).toBe("fallback address") |
|
||||
|
|
||||
app.pageStore().update(s => { |
|
||||
s.sourceaddress = "new address" |
|
||||
return s |
|
||||
}) |
|
||||
|
|
||||
button.dispatchEvent(new dom.window.Event("click")) |
|
||||
expect(storeAddress).toBe("new address") |
|
||||
}) |
|
||||
|
|
||||
it("should take event parameters from context values", async () => { |
|
||||
const { dom, app } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/button", |
|
||||
_id: "with_context", |
|
||||
onClick: [ |
|
||||
event("Set State", { |
|
||||
path: "address", |
|
||||
value: { |
|
||||
"##bbstate": "testKey", |
|
||||
"##bbsource": "context", |
|
||||
"##bbstatefallback": "fallback address", |
|
||||
}, |
|
||||
}), |
|
||||
], |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
const button = dom.window.document.body.children[0] |
|
||||
expect(button.tagName).toBe("BUTTON") |
|
||||
|
|
||||
let storeAddress |
|
||||
app.pageStore().subscribe(s => { |
|
||||
storeAddress = s.address |
|
||||
}) |
|
||||
|
|
||||
button.dispatchEvent(new dom.window.Event("click")) |
|
||||
expect(storeAddress).toBe("test value") |
|
||||
}) |
|
||||
}) |
|
||||
|
|
||||
it("should rerender components when their code is bound to the store ", async () => { |
|
||||
const { dom, app } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/div", |
|
||||
_children: [ |
|
||||
{ |
|
||||
_component: "testlib/div", |
|
||||
_id: "n_clones_based_on_store", |
|
||||
className: "child_div", |
|
||||
}, |
|
||||
], |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
const rootDiv = dom.window.document.body.children[0] |
|
||||
expect(rootDiv.tagName).toBe("DIV") |
|
||||
expect(rootDiv.children.length).toBe(0) |
|
||||
|
|
||||
app.pageStore().update(s => { |
|
||||
s.componentCount = 3 |
|
||||
return s |
|
||||
}) |
|
||||
|
|
||||
expect(rootDiv.children.length).toBe(3) |
|
||||
expect(rootDiv.children[0].className.includes("child_div")).toBe(true) |
|
||||
|
|
||||
app.pageStore().update(s => { |
|
||||
s.componentCount = 5 |
|
||||
return s |
|
||||
}) |
|
||||
|
|
||||
expect(rootDiv.children.length).toBe(5) |
|
||||
expect(rootDiv.children[0].className.includes("child_div")).toBe(true) |
|
||||
|
|
||||
app.pageStore().update(s => { |
|
||||
s.componentCount = 0 |
|
||||
return s |
|
||||
}) |
|
||||
|
|
||||
expect(rootDiv.children.length).toBe(0) |
|
||||
}) |
|
||||
|
|
||||
it("should be able to read value from context, passed fromm parent, through code", async () => { |
|
||||
const { dom, app } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/div", |
|
||||
_children: [ |
|
||||
{ |
|
||||
_component: "testlib/div", |
|
||||
_id: "n_clones_based_on_store", |
|
||||
className: { |
|
||||
"##bbstate": "index", |
|
||||
"##bbsource": "context", |
|
||||
"##bbstatefallback": "nothing", |
|
||||
}, |
|
||||
}, |
|
||||
], |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
const rootDiv = dom.window.document.body.children[0] |
|
||||
expect(rootDiv.tagName).toBe("DIV") |
|
||||
expect(rootDiv.children.length).toBe(0) |
|
||||
|
|
||||
app.pageStore().update(s => { |
|
||||
s.componentCount = 3 |
|
||||
return s |
|
||||
}) |
|
||||
|
|
||||
expect(rootDiv.children.length).toBe(3) |
|
||||
expect(rootDiv.children[0].className.includes("index_0")).toBe(true) |
|
||||
expect(rootDiv.children[1].className.includes("index_1")).toBe(true) |
|
||||
expect(rootDiv.children[2].className.includes("index_2")).toBe(true) |
|
||||
}) |
|
||||
|
|
||||
const event = (handlerType, parameters) => { |
|
||||
const e = {} |
|
||||
e[EVENT_TYPE_MEMBER_NAME] = handlerType |
|
||||
e.parameters = parameters |
|
||||
return e |
|
||||
} |
|
||||
@ -1,74 +0,0 @@ |
|||||
import { load, makePage } from "./testAppDef" |
|
||||
|
|
||||
describe("controlFlow", () => { |
|
||||
it("should display simple div, with always true render function", async () => { |
|
||||
const { dom } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/div", |
|
||||
className: "my-test-class", |
|
||||
_id: "always_render", |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
expect(dom.window.document.body.children.length).toBe(1) |
|
||||
const child = dom.window.document.body.children[0] |
|
||||
expect(child.className.includes("my-test-class")).toBeTruthy() |
|
||||
}) |
|
||||
|
|
||||
it("should not display div, with always false render function", async () => { |
|
||||
const { dom } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/div", |
|
||||
className: "my-test-class", |
|
||||
_id: "never_render", |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
expect(dom.window.document.body.children.length).toBe(0) |
|
||||
}) |
|
||||
|
|
||||
it("should display 3 divs in a looped render function", async () => { |
|
||||
const { dom } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/div", |
|
||||
className: "my-test-class", |
|
||||
_id: "three_clones", |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
expect(dom.window.document.body.children.length).toBe(3) |
|
||||
|
|
||||
const child0 = dom.window.document.body.children[0] |
|
||||
expect(child0.className.includes("my-test-class")).toBeTruthy() |
|
||||
|
|
||||
const child1 = dom.window.document.body.children[1] |
|
||||
expect(child1.className.includes("my-test-class")).toBeTruthy() |
|
||||
|
|
||||
const child2 = dom.window.document.body.children[2] |
|
||||
expect(child2.className.includes("my-test-class")).toBeTruthy() |
|
||||
}) |
|
||||
|
|
||||
it("should display 3 div, in a looped render, as children", async () => { |
|
||||
const { dom } = await load( |
|
||||
makePage({ |
|
||||
_component: "testlib/div", |
|
||||
_children: [ |
|
||||
{ |
|
||||
_component: "testlib/div", |
|
||||
className: "my-test-class", |
|
||||
_id: "three_clones", |
|
||||
}, |
|
||||
], |
|
||||
}) |
|
||||
) |
|
||||
|
|
||||
expect(dom.window.document.body.children.length).toBe(1) |
|
||||
|
|
||||
const rootDiv = dom.window.document.body.children[0] |
|
||||
expect(rootDiv.children.length).toBe(3) |
|
||||
|
|
||||
expect(rootDiv.children[0].className.includes("my-test-class")).toBeTruthy() |
|
||||
expect(rootDiv.children[1].className.includes("my-test-class")).toBeTruthy() |
|
||||
expect(rootDiv.children[2].className.includes("my-test-class")).toBeTruthy() |
|
||||
}) |
|
||||
}) |
|
||||
@ -1,80 +0,0 @@ |
|||||
export default ({ indexes, helpers }) => |
|
||||
indexes.map(i => ({ |
|
||||
name: `Table based on view: ${i.name} `, |
|
||||
props: tableProps( |
|
||||
i, |
|
||||
helpers.indexSchema(i).filter(c => !excludedColumns.includes(c.name)) |
|
||||
), |
|
||||
})) |
|
||||
|
|
||||
const excludedColumns = ["id", "key", "sortKey", "type", "isNew"] |
|
||||
|
|
||||
const tableProps = (index, indexSchema) => ({ |
|
||||
_component: "@budibase/materialdesign-components/Datatable", |
|
||||
_children: [ |
|
||||
{ |
|
||||
_component: "@budibase/materialdesign-components/DatatableHead", |
|
||||
_children: [ |
|
||||
{ |
|
||||
_component: "@budibase/materialdesign-components/DatatableRow", |
|
||||
isHeader: true, |
|
||||
_children: columnHeaders(indexSchema), |
|
||||
}, |
|
||||
], |
|
||||
}, |
|
||||
{ |
|
||||
_component: "@budibase/materialdesign-components/DatatableBody", |
|
||||
_children: [ |
|
||||
{ |
|
||||
_code: rowCode(index), |
|
||||
_component: "@budibase/materialdesign-components/DatatableRow", |
|
||||
_children: dataCells(index, indexSchema), |
|
||||
}, |
|
||||
], |
|
||||
}, |
|
||||
], |
|
||||
onLoad: [ |
|
||||
{ |
|
||||
"##eventHandlerType": "List Records", |
|
||||
parameters: { |
|
||||
indexKey: index.nodeKey(), |
|
||||
statePath: index.name, |
|
||||
}, |
|
||||
}, |
|
||||
], |
|
||||
}) |
|
||||
|
|
||||
const columnHeaders = indexSchema => |
|
||||
indexSchema.map(col => ({ |
|
||||
_component: "@budibase/materialdesign-components/DatatableCell", |
|
||||
isHeader: true, |
|
||||
_children: [ |
|
||||
{ |
|
||||
_component: "@budibase/standard-components/text", |
|
||||
type: "none", |
|
||||
text: col.name, |
|
||||
formattingTag: "<b> - bold", |
|
||||
}, |
|
||||
], |
|
||||
})) |
|
||||
|
|
||||
const dataCells = (index, indexSchema) => |
|
||||
indexSchema.map(col => ({ |
|
||||
_component: "@budibase/materialdesign-components/DatatableCell", |
|
||||
_children: [ |
|
||||
{ |
|
||||
_component: "@budibase/standard-components/text", |
|
||||
type: "none", |
|
||||
text: `context.${dataItem(index)}.${col.name}`, |
|
||||
}, |
|
||||
], |
|
||||
})) |
|
||||
|
|
||||
const dataItem = index => `${index.name}_item` |
|
||||
const dataCollection = index => `state.${index.name}` |
|
||||
const rowCode = index => |
|
||||
` |
|
||||
if (!${dataCollection(index)}) return |
|
||||
|
|
||||
for (let ${dataItem(index)} of ${dataCollection(index)}) |
|
||||
render( { ${dataItem(index)} } )` |
|
||||
@ -1,149 +0,0 @@ |
|||||
export default ({ records }) => |
|
||||
records.map(r => ({ |
|
||||
name: `Form for Record: ${r.nodeName()}`, |
|
||||
props: outerContainer(r), |
|
||||
})) |
|
||||
|
|
||||
const outerContainer = record => ({ |
|
||||
_component: "@budibase/standard-components/container", |
|
||||
_code: "", |
|
||||
type: "div", |
|
||||
onLoad: [ |
|
||||
{ |
|
||||
"##eventHandlerType": "Get New Record", |
|
||||
parameters: { |
|
||||
collectionKey: record.collectionNodeKey(), |
|
||||
childRecordType: record.name, |
|
||||
statePath: record.name, |
|
||||
}, |
|
||||
}, |
|
||||
], |
|
||||
_children: [ |
|
||||
heading(record), |
|
||||
...record.fields.map(f => field(record, f)), |
|
||||
buttons(record), |
|
||||
], |
|
||||
}) |
|
||||
|
|
||||
const heading = record => ({ |
|
||||
_component: "@budibase/materialdesign-components/H3", |
|
||||
text: capitalize(record.name), |
|
||||
}) |
|
||||
|
|
||||
const field = (record, f) => { |
|
||||
if (f.type === "bool") return checkbox(record, f) |
|
||||
if ( |
|
||||
f.type === "string" && |
|
||||
f.typeOptions && |
|
||||
f.typeOptions.values && |
|
||||
f.typeOptions.values.length > 0 |
|
||||
) |
|
||||
return select(record, f) |
|
||||
return textField(record, f) |
|
||||
} |
|
||||
|
|
||||
const textField = (record, f) => ({ |
|
||||
_component: "@budibase/materialdesign-components/Textfield", |
|
||||
label: f.label, |
|
||||
variant: "filled", |
|
||||
disabled: false, |
|
||||
fullwidth: false, |
|
||||
colour: "primary", |
|
||||
maxLength: |
|
||||
f.typeOptions && f.typeOptions.maxLength ? f.typeOptions.maxLength : 0, |
|
||||
placeholder: f.label, |
|
||||
value: fieldValueBinding(record, f), |
|
||||
}) |
|
||||
|
|
||||
const checkbox = (record, f) => ({ |
|
||||
_component: "@budibase/materialdesign-components/Checkbox", |
|
||||
label: f.label, |
|
||||
checked: fieldValueBinding(record, f), |
|
||||
}) |
|
||||
|
|
||||
const select = (record, f) => ({ |
|
||||
_component: "@budibase/materialdesign-components/Select", |
|
||||
value: fieldValueBinding(record, f), |
|
||||
_children: f.typeOptions.values.map(val => ({ |
|
||||
_component: "@budibase/materialdesign-components/ListItem", |
|
||||
value: val, |
|
||||
text: val, |
|
||||
})), |
|
||||
}) |
|
||||
|
|
||||
const fieldValueBinding = (record, f) => `state.${record.name}.${f.name}` |
|
||||
|
|
||||
const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1) |
|
||||
|
|
||||
const buttons = record => ({ |
|
||||
_component: "@budibase/standard-components/container", |
|
||||
borderWidth: "1px 0px 0px 0px", |
|
||||
borderColor: "lightgray", |
|
||||
borderStyle: "solid", |
|
||||
_styles: { |
|
||||
position: { |
|
||||
column: ["", ""], |
|
||||
row: ["", ""], |
|
||||
margin: ["", "", "", ""], |
|
||||
padding: ["30px", "", "", ""], |
|
||||
height: [""], |
|
||||
width: [""], |
|
||||
zindex: [""], |
|
||||
}, |
|
||||
layout: { |
|
||||
templaterows: [""], |
|
||||
templatecolumns: [""], |
|
||||
}, |
|
||||
}, |
|
||||
_children: [ |
|
||||
{ |
|
||||
_component: "@budibase/materialdesign-components/Button", |
|
||||
onClick: [ |
|
||||
{ |
|
||||
"##eventHandlerType": "Save Record", |
|
||||
parameters: { |
|
||||
statePath: `${record.name}`, |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
"##eventHandlerType": "Navigate To", |
|
||||
parameters: { |
|
||||
url: `/${record.name}s`, |
|
||||
}, |
|
||||
}, |
|
||||
], |
|
||||
variant: "raised", |
|
||||
colour: "primary", |
|
||||
size: "medium", |
|
||||
text: `Save ${capitalize(record.name)}`, |
|
||||
}, |
|
||||
{ |
|
||||
_component: "@budibase/materialdesign-components/Button", |
|
||||
_styles: { |
|
||||
position: { |
|
||||
row: ["", ""], |
|
||||
column: ["", ""], |
|
||||
padding: ["", "", "", ""], |
|
||||
margin: ["", "", "", "10px"], |
|
||||
width: [""], |
|
||||
height: [""], |
|
||||
zindex: [""], |
|
||||
}, |
|
||||
layout: { |
|
||||
templatecolumns: [""], |
|
||||
templaterows: [""], |
|
||||
}, |
|
||||
}, |
|
||||
onClick: [ |
|
||||
{ |
|
||||
"##eventHandlerType": "Navigate To", |
|
||||
parameters: { |
|
||||
url: `/${record.name}s`, |
|
||||
}, |
|
||||
}, |
|
||||
], |
|
||||
colour: "secondary", |
|
||||
text: "Cancel", |
|
||||
}, |
|
||||
], |
|
||||
}) |
|
||||
@ -0,0 +1,24 @@ |
|||||
|
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, |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,29 @@ |
|||||
|
const recordController = require("../../record") |
||||
|
|
||||
|
module.exports = async function saveRecord({ args, instanceId }) { |
||||
|
const { model, ...record } = args.record |
||||
|
|
||||
|
const ctx = { |
||||
|
params: { |
||||
|
instanceId, |
||||
|
modelId: model._id, |
||||
|
}, |
||||
|
request: { |
||||
|
body: record, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
await recordController.save(ctx) |
||||
|
|
||||
|
try { |
||||
|
return { |
||||
|
record: ctx.body, |
||||
|
} |
||||
|
} catch (err) { |
||||
|
console.error(err) |
||||
|
return { |
||||
|
record: null, |
||||
|
error: err.message, |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
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, |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue