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"
/**
@ -6,9 +5,8 @@ import { generate } from "shortid"
* Workflow definitions are stored in linked lists.
*/
export default class Workflow {
constructor(workflow, blockDefinitions) {
constructor(workflow) {
this.workflow = workflow
this.blockDefinitions = blockDefinitions
}
hasTrigger() {
@ -22,23 +20,26 @@ export default class Workflow {
return
}
this.workflow.definition.steps.push({
id: generate(),
...block,
})
const newBlock = { id: generate(), ...block }
this.workflow.definition.steps = [
...this.workflow.definition.steps,
newBlock,
]
return newBlock
}
updateBlock(updatedBlock, id) {
const { steps, trigger } = this.workflow.definition
if (trigger && trigger.id === id) {
this.workflow.definition.trigger = null
this.workflow.definition.trigger = updatedBlock
return
}
const stepIdx = steps.findIndex(step => step.id === id)
if (stepIdx < 0) throw new Error("Block not found.")
steps.splice(stepIdx, 1, updatedBlock)
this.workflow.definition.steps = steps
}
deleteBlock(id) {
@ -52,46 +53,6 @@ export default class Workflow {
const stepIdx = steps.findIndex(step => step.id === id)
if (stepIdx < 0) throw new Error("Block not found.")
steps.splice(stepIdx, 1)
}
createUiTree() {
if (!this.workflow.definition) return []
return Workflow.buildUiTree(this.workflow.definition, 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,
}
})
this.workflow.definition.steps = steps
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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