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 { parseBinding, isStoreBinding } from "./parseBinding" |
|||
import { get } from "svelte/store" |
|||
import getOr from "lodash/fp/getOr" |
|||
import { appStore } from "./store" |
|||
|
|||
export const getState = (s, path, fallback) => { |
|||
if (!s) return fallback |
|||
export const getState = (path, fallback) => { |
|||
if (!path || path.length === 0) return fallback |
|||
|
|||
if (path === "$") return s |
|||
|
|||
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 |
|||
return getOr(fallback, path, get(appStore)) |
|||
} |
|||
|
|||
@ -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