Rewrite workflow editing state for better UI sync

This commit is contained in:
Andrew Kingston 2020-09-11 14:23:31 +01:00
parent b886c8f342
commit 494d38029f
12 changed files with 92 additions and 194 deletions

View File

@ -1,4 +1,3 @@
import mustache from "mustache"
import { generate } from "shortid" import { generate } from "shortid"
/** /**
@ -6,9 +5,8 @@ import { generate } from "shortid"
* Workflow definitions are stored in linked lists. * Workflow definitions are stored in linked lists.
*/ */
export default class Workflow { export default class Workflow {
constructor(workflow, blockDefinitions) { constructor(workflow) {
this.workflow = workflow this.workflow = workflow
this.blockDefinitions = blockDefinitions
} }
hasTrigger() { hasTrigger() {
@ -22,23 +20,26 @@ export default class Workflow {
return return
} }
this.workflow.definition.steps.push({ const newBlock = { id: generate(), ...block }
id: generate(), this.workflow.definition.steps = [
...block, ...this.workflow.definition.steps,
}) newBlock,
]
return newBlock
} }
updateBlock(updatedBlock, id) { updateBlock(updatedBlock, id) {
const { steps, trigger } = this.workflow.definition const { steps, trigger } = this.workflow.definition
if (trigger && trigger.id === id) { if (trigger && trigger.id === id) {
this.workflow.definition.trigger = null this.workflow.definition.trigger = updatedBlock
return return
} }
const stepIdx = steps.findIndex(step => step.id === id) const stepIdx = steps.findIndex(step => step.id === id)
if (stepIdx < 0) throw new Error("Block not found.") if (stepIdx < 0) throw new Error("Block not found.")
steps.splice(stepIdx, 1, updatedBlock) steps.splice(stepIdx, 1, updatedBlock)
this.workflow.definition.steps = steps
} }
deleteBlock(id) { deleteBlock(id) {
@ -52,46 +53,6 @@ export default class Workflow {
const stepIdx = steps.findIndex(step => step.id === id) const stepIdx = steps.findIndex(step => step.id === id)
if (stepIdx < 0) throw new Error("Block not found.") if (stepIdx < 0) throw new Error("Block not found.")
steps.splice(stepIdx, 1) steps.splice(stepIdx, 1)
} this.workflow.definition.steps = steps
createUiTree() {
if (!this.workflow.definition) return []
return Workflow.buildUiTree(this.workflow.definition, this.blockDefinitions)
}
static buildUiTree(definition, blockDefinitions) {
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.stepId]
if (!definition) {
throw new Error(
`No block definition exists for the chosen block. Check there's an entry in the block definitions for ${step.stepId}`
)
}
if (!definition.params) {
throw new Error(
`Blocks should always have parameters. Ensure that the block definition is correct for ${step.stepId}`
)
}
const tagline = definition.tagline || ""
const args = step.args || {}
return {
id: step.id,
type: step.type,
params: step.params,
args,
heading: step.stepId,
body: mustache.render(tagline, args),
name: definition.name,
}
})
} }
} }

View File

@ -1,6 +1,7 @@
import { writable } from "svelte/store" import { writable } from "svelte/store"
import api from "../../api" import api from "../../api"
import Workflow from "./Workflow" import Workflow from "./Workflow"
import { cloneDeep } from "lodash/fp"
const workflowActions = store => ({ const workflowActions = store => ({
fetch: async () => { fetch: async () => {
@ -32,11 +33,8 @@ const workflowActions = store => ({
const response = await api.post(CREATE_WORKFLOW_URL, workflow) const response = await api.post(CREATE_WORKFLOW_URL, workflow)
const json = await response.json() const json = await response.json()
store.update(state => { store.update(state => {
state.workflows = state.workflows.concat(json.workflow) state.workflows = [...state.workflows, json.workflow]
state.currentWorkflow = new Workflow( store.actions.select(json.workflow)
json.workflow,
state.blockDefinitions
)
return state return state
}) })
}, },
@ -50,23 +48,7 @@ const workflowActions = store => ({
) )
state.workflows.splice(existingIdx, 1, json.workflow) state.workflows.splice(existingIdx, 1, json.workflow)
state.workflows = state.workflows state.workflows = state.workflows
state.currentWorkflow = new Workflow( store.actions.select(json.workflow)
json.workflow,
state.blockDefinitions
)
return state
})
},
update: async ({ workflow }) => {
const UPDATE_WORKFLOW_URL = `/api/workflows`
const response = await api.put(UPDATE_WORKFLOW_URL, workflow)
const json = await response.json()
store.update(state => {
const existingIdx = state.workflows.findIndex(
existing => existing._id === workflow._id
)
state.workflows.splice(existingIdx, 1, json.workflow)
state.workflows = state.workflows
return state return state
}) })
}, },
@ -81,37 +63,34 @@ const workflowActions = store => ({
) )
state.workflows.splice(existingIdx, 1) state.workflows.splice(existingIdx, 1)
state.workflows = state.workflows state.workflows = state.workflows
state.currentWorkflow = null state.selectedWorkflow = null
state.selectedBlock = null
return state return state
}) })
}, },
select: workflow => { select: workflow => {
store.update(state => { store.update(state => {
state.currentWorkflow = new Workflow(workflow, state.blockDefinitions) state.selectedWorkflow = new Workflow(cloneDeep(workflow))
state.selectedWorkflowBlock = null state.selectedBlock = null
return state return state
}) })
}, },
addBlockToWorkflow: block => { addBlockToWorkflow: block => {
store.update(state => { store.update(state => {
state.currentWorkflow.addBlock(block) const newBlock = state.selectedWorkflow.addBlock(block)
const steps = state.currentWorkflow.workflow.definition.steps state.selectedBlock = newBlock
state.selectedWorkflowBlock = steps.length
? steps[steps.length - 1]
: state.currentWorkflow.workflow.definition.trigger
return state return state
}) })
}, },
deleteWorkflowBlock: block => { deleteWorkflowBlock: block => {
store.update(state => { store.update(state => {
console.log(state.currentWorkflow.workflow) const idx = state.selectedWorkflow.workflow.definition.steps.findIndex(
const idx = state.currentWorkflow.workflow.definition.steps.findIndex(
x => x.id === block.id x => x.id === block.id
) )
state.currentWorkflow.deleteBlock(block.id) state.selectedWorkflow.deleteBlock(block.id)
// Select next closest step // Select next closest step
const steps = state.currentWorkflow.workflow.definition.steps const steps = state.selectedWorkflow.workflow.definition.steps
let nextSelectedBlock let nextSelectedBlock
if (steps[idx] != null) { if (steps[idx] != null) {
nextSelectedBlock = steps[idx] nextSelectedBlock = steps[idx]
@ -119,9 +98,9 @@ const workflowActions = store => ({
nextSelectedBlock = steps[idx - 1] nextSelectedBlock = steps[idx - 1]
} else { } else {
nextSelectedBlock = nextSelectedBlock =
state.currentWorkflow.workflow.definition.trigger || null state.selectedWorkflow.workflow.definition.trigger || null
} }
state.selectedWorkflowBlock = nextSelectedBlock state.selectedBlock = nextSelectedBlock
return state return state
}) })
}, },
@ -135,8 +114,8 @@ export const getWorkflowStore = () => {
ACTION: [], ACTION: [],
LOGIC: [], LOGIC: [],
}, },
selectedWorkflow: null,
} }
const store = writable(INITIAL_WORKFLOW_STATE) const store = writable(INITIAL_WORKFLOW_STATE)
store.actions = workflowActions(store) store.actions = workflowActions(store)
return store return store

View File

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

View File

@ -2,13 +2,15 @@
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
export let value export let value
let modelId = value ? value._id : ""
$: value = $backendUiStore.models.find(x => x._id === modelId)
</script> </script>
<div class="block-field"> <div class="block-field">
<select class="budibase__input" bind:value> <select class="budibase__input" bind:value={modelId}>
<option value="" /> <option value="" />
{#each $backendUiStore.models as model} {#each $backendUiStore.models as model}
<option value={model}>{model.name}</option> <option value={model._id}>{model.name}</option>
{/each} {/each}
</select> </select>
</div> </div>

View File

@ -1,6 +1,6 @@
<script> <script>
import { fade } from "svelte/transition" import { fade } from "svelte/transition"
import { onMount, getContext } from "svelte" import { getContext } from "svelte"
import { backendUiStore, workflowStore } from "builderStore" import { backendUiStore, workflowStore } from "builderStore"
import { notifier } from "builderStore/store/notifications" import { notifier } from "builderStore/store/notifications"
import WorkflowBlockSetup from "./WorkflowBlockSetup.svelte" import WorkflowBlockSetup from "./WorkflowBlockSetup.svelte"
@ -9,40 +9,22 @@
const { open, close } = getContext("simple-modal") const { open, close } = getContext("simple-modal")
const ACCESS_LEVELS = [
{
name: "Admin",
key: "ADMIN",
canExecute: true,
editable: false,
},
{
name: "Power User",
key: "POWER_USER",
canExecute: true,
editable: false,
},
]
let selectedTab = "SETUP" let selectedTab = "SETUP"
let testResult let testResult
$: workflow = $: workflow =
$workflowStore.currentWorkflow && $workflowStore.currentWorkflow.workflow $workflowStore.selectedWorkflow && $workflowStore.selectedWorkflow.workflow
$: workflowBlock = $workflowStore.selectedWorkflowBlock
function deleteWorkflow() { function deleteWorkflow() {
open( open(
DeleteWorkflowModal, DeleteWorkflowModal,
{ { onClosed: close },
onClosed: close,
},
{ styleContent: { padding: "0" } } { styleContent: { padding: "0" } }
) )
} }
function deleteWorkflowBlock() { function deleteWorkflowBlock() {
workflowStore.actions.deleteWorkflowBlock(workflowBlock) workflowStore.actions.deleteWorkflowBlock($workflowStore.selectedBlock)
notifier.info("Workflow block deleted.") notifier.info("Workflow block deleted.")
} }
@ -51,7 +33,6 @@
} }
async function saveWorkflow() { async function saveWorkflow() {
const workflow = $workflowStore.currentWorkflow.workflow
await workflowStore.actions.save({ await workflowStore.actions.save({
instanceId: $backendUiStore.selectedDatabase._id, instanceId: $backendUiStore.selectedDatabase._id,
workflow, workflow,
@ -71,7 +52,7 @@
}}> }}>
Setup Setup
</span> </span>
{#if !workflowBlock} {#if !$workflowStore.selectedBlock}
<span <span
class="test-tab" class="test-tab"
class:selected={selectedTab === 'TEST'} class:selected={selectedTab === 'TEST'}
@ -95,8 +76,8 @@
</div> </div>
{/if} {/if}
{#if selectedTab === 'SETUP'} {#if selectedTab === 'SETUP'}
{#if workflowBlock} {#if $workflowStore.selectedBlock}
<WorkflowBlockSetup {workflowBlock} /> <WorkflowBlockSetup bind:block={$workflowStore.selectedBlock} />
<div class="buttons"> <div class="buttons">
<Button <Button
green green
@ -107,25 +88,10 @@
</Button> </Button>
<Button red wide on:click={deleteWorkflowBlock}>Delete Block</Button> <Button red wide on:click={deleteWorkflowBlock}>Delete Block</Button>
</div> </div>
{:else if $workflowStore.currentWorkflow} {:else if $workflowStore.selectedWorkflow}
<div class="panel"> <div class="panel">
<div class="panel-body"> <div class="panel-body">
<div class="block-label">Workflow: {workflow.name}</div> <div class="block-label">Workflow: {workflow.name}</div>
<div class="config-item">
<Label small forAttr={'useraccess'}>User Access</Label>
<div class="access-levels">
{#each ACCESS_LEVELS as level}
<span class="access-level">
<label>{level.name}</label>
<input
type="checkbox"
disabled={!level.editable}
bind:checked={level.canExecute} />
</span>
{/each}
</div>
</div>
</div> </div>
<div class="buttons"> <div class="buttons">
<Button <Button
@ -181,10 +147,6 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.config-item {
margin-bottom: 20px;
}
header > span { header > span {
color: var(--grey-5); color: var(--grey-5);
margin-right: 20px; margin-right: 20px;
@ -205,13 +167,6 @@
gap: 12px; gap: 12px;
} }
.access-level {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20px;
}
.access-level label { .access-level label {
font-weight: normal; font-weight: normal;
color: var(--ink); color: var(--ink);

View File

@ -1,51 +1,43 @@
<script> <script>
import { backendUiStore, store } from "builderStore"
import ComponentSelector from "./ParamInputs/ComponentSelector.svelte" import ComponentSelector from "./ParamInputs/ComponentSelector.svelte"
import ModelSelector from "./ParamInputs/ModelSelector.svelte" import ModelSelector from "./ParamInputs/ModelSelector.svelte"
import RecordSelector from "./ParamInputs/RecordSelector.svelte" import RecordSelector from "./ParamInputs/RecordSelector.svelte"
import { Input, TextArea, Select } from "@budibase/bbui" import { Input, TextArea, Select } from "@budibase/bbui"
export let workflowBlock export let block
let params $: params = block.params ? Object.entries(block.params) : []
$: workflowParams = workflowBlock.params
? Object.entries(workflowBlock.params)
: []
</script> </script>
<div class="container"> <div class="container">
{#each workflowParams as [parameter, type]} {#each params as [parameter, type]}
<div class="block-field"> <div class="block-field">
<label class="label">{parameter}</label> <label class="label">{parameter}</label>
{#if Array.isArray(type)} {#if Array.isArray(type)}
<Select bind:value={workflowBlock.args[parameter]} thin secondary> <Select bind:value={block.args[parameter]} thin secondary>
{#each type as option} {#each type as option}
<option value={option}>{option}</option> <option value={option}>{option}</option>
{/each} {/each}
</Select> </Select>
{:else if type === 'component'} {:else if type === 'component'}
<ComponentSelector bind:value={workflowBlock.args[parameter]} /> <ComponentSelector bind:value={block.args[parameter]} />
{:else if type === 'accessLevel'} {:else if type === 'accessLevel'}
<Select bind:value={workflowBlock.args[parameter]} thin secondary> <Select bind:value={block.args[parameter]} thin secondary>
<option value="ADMIN">Admin</option> <option value="ADMIN">Admin</option>
<option value="POWER_USER">Power User</option> <option value="POWER_USER">Power User</option>
</Select> </Select>
{:else if type === 'password'} {:else if type === 'password'}
<Input <Input type="password" thin bind:value={block.args[parameter]} />
type="password"
thin
bind:value={workflowBlock.args[parameter]} />
{:else if type === 'number'} {:else if type === 'number'}
<Input type="number" thin bind:value={workflowBlock.args[parameter]} /> <Input type="number" thin bind:value={block.args[parameter]} />
{:else if type === 'longText'} {:else if type === 'longText'}
<TextArea type="text" thin bind:value={workflowBlock.args[parameter]} /> <TextArea type="text" thin bind:value={block.args[parameter]} />
{:else if type === 'model'} {:else if type === 'model'}
<ModelSelector bind:value={workflowBlock.args[parameter]} /> <ModelSelector bind:value={block.args[parameter]} />
{:else if type === 'record'} {:else if type === 'record'}
<RecordSelector value={workflowBlock.args[parameter]} /> <RecordSelector value={block.args[parameter]} />
{:else if type === 'string'} {:else if type === 'string'}
<Input type="text" thin bind:value={workflowBlock.args[parameter]} /> <Input type="text" thin bind:value={block.args[parameter]} />
{/if} {/if}
</div> </div>
{/each} {/each}

View File

@ -1,30 +1,24 @@
<script> <script>
import { onMount } from "svelte" import { afterUpdate } from "svelte"
import { workflowStore, backendUiStore } from "builderStore" import { workflowStore, backendUiStore } from "builderStore"
import { notifier } from "builderStore/store/notifications" import { notifier } from "builderStore/store/notifications"
import Flowchart from "./flowchart/FlowChart.svelte" import Flowchart from "./flowchart/FlowChart.svelte"
let selectedWorkflow let section
let uiTree
let instanceId = $backendUiStore.selectedDatabase._id
$: selectedWorkflow = $workflowStore.currentWorkflow
$: workflowLive = selectedWorkflow && selectedWorkflow.workflow.live
$: uiTree = selectedWorkflow ? selectedWorkflow.createUiTree() : []
$: workflow =
$workflowStore.selectedWorkflow && $workflowStore.selectedWorkflow.workflow
$: workflowLive = workflow && workflow.live
$: instanceId = $backendUiStore.selectedDatabase._id $: instanceId = $backendUiStore.selectedDatabase._id
function onSelect(block) { function onSelect(block) {
workflowStore.update(state => { workflowStore.update(state => {
state.selectedWorkflowBlock = block state.selectedBlock = block
return state return state
}) })
} }
function setWorkflowLive(live) { function setWorkflowLive(live) {
const { workflow } = selectedWorkflow
workflow.live = live workflow.live = live
workflowStore.actions.save({ instanceId, workflow }) workflowStore.actions.save({ instanceId, workflow })
if (live) { if (live) {
@ -33,13 +27,17 @@
notifier.danger(`Workflow ${workflow.name} disabled.`) notifier.danger(`Workflow ${workflow.name} disabled.`)
} }
} }
afterUpdate(() => {
section.scrollTo(0, section.scrollHeight)
})
</script> </script>
<section> <section bind:this={section}>
<Flowchart blocks={uiTree} {onSelect} /> <Flowchart {workflow} {onSelect} />
</section> </section>
<footer> <footer>
{#if selectedWorkflow} {#if workflow}
<button <button
class:highlighted={workflowLive} class:highlighted={workflowLive}
class:hoverable={workflowLive} class:hoverable={workflowLive}

View File

@ -1,12 +1,25 @@
<script> <script>
import FlowItem from "./FlowItem.svelte" import FlowItem from "./FlowItem.svelte"
import Arrow from "./Arrow.svelte" import Arrow from "./Arrow.svelte"
import { flip } from "svelte/animate"
import { fade, fly } from "svelte/transition"
export let blocks = [] export let workflow
export let onSelect export let onSelect
let blocks
$: {
blocks = []
if (workflow) {
if (workflow.definition.trigger) {
blocks.push(workflow.definition.trigger)
}
blocks = blocks.concat(workflow.definition.steps || [])
}
}
</script> </script>
<section class="canvas"> <section>
{#each blocks as block, idx (block.id)} {#each blocks as block, idx (block.id)}
<FlowItem {onSelect} {block} /> <FlowItem {onSelect} {block} />
{#if idx !== blocks.length - 1} {#if idx !== blocks.length - 1}
@ -19,9 +32,6 @@
section { section {
position: absolute; position: absolute;
padding: 20px 40px; padding: 20px 40px;
}
.canvas {
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;

View File

@ -1,18 +1,19 @@
<script> <script>
import mustache from "mustache"
import { fade } from "svelte/transition" import { fade } from "svelte/transition"
import { workflowStore } from "builderStore" import { workflowStore } from "builderStore"
export let onSelect export let onSelect
export let block export let block
let selected = false let selected
$: selected =
$workflowStore.selectedBlock != null &&
$workflowStore.selectedBlock.id === block.id
function selectBlock() { function selectBlock() {
onSelect(block) onSelect(block)
} }
$: selected =
$workflowStore.selectedWorkflowBlock != null &&
$workflowStore.selectedWorkflowBlock.id === block.id
</script> </script>
<div <div
@ -34,7 +35,7 @@
</header> </header>
<hr /> <hr />
<p> <p>
{@html block.body} {@html mustache.render(block.tagline, block.args)}
</p> </p>
</div> </div>

View File

@ -8,7 +8,7 @@
$: blocks = Object.entries($workflowStore.blockDefinitions[selectedTab]) $: blocks = Object.entries($workflowStore.blockDefinitions[selectedTab])
$: { $: {
if ($workflowStore.currentWorkflow.hasTrigger()) { if ($workflowStore.selectedWorkflow.hasTrigger()) {
buttonProps = [ buttonProps = [
{ value: "ACTION", text: "Action" }, { value: "ACTION", text: "Action" },
{ value: "LOGIC", text: "Logic" }, { value: "LOGIC", text: "Logic" },

View File

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

View File

@ -14,7 +14,7 @@
on:click={() => (selectedTab = 'WORKFLOWS')}> on:click={() => (selectedTab = 'WORKFLOWS')}>
Workflows Workflows
</span> </span>
{#if $workflowStore.currentWorkflow} {#if $workflowStore.selectedWorkflow}
<span <span
data-cy="add-workflow-component" data-cy="add-workflow-component"
class="hoverable" class="hoverable"