Merge pull request #279 from Budibase/workflow-v1

Workflow v1
This commit is contained in:
Martin McKeaveney 2020-06-02 11:15:34 +01:00 committed by GitHub
commit 3f2cb90340
115 changed files with 3302 additions and 1976 deletions

View File

@ -38,16 +38,19 @@
] ]
}, },
"dependencies": { "dependencies": {
"@beyonk/svelte-notifications": "^2.0.3",
"@budibase/bbui": "^0.3.5", "@budibase/bbui": "^0.3.5",
"@budibase/client": "^0.0.32", "@budibase/client": "^0.0.32",
"@nx-js/compiler-util": "^2.0.0", "@nx-js/compiler-util": "^2.0.0",
"codemirror": "^5.51.0", "codemirror": "^5.51.0",
"date-fns": "^1.29.0", "date-fns": "^1.29.0",
"deepmerge": "^4.2.2",
"feather-icons": "^4.21.0", "feather-icons": "^4.21.0",
"flatpickr": "^4.5.7", "flatpickr": "^4.5.7",
"lodash": "^4.17.13", "lodash": "^4.17.13",
"logrocket": "^1.0.6", "logrocket": "^1.0.6",
"lunr": "^2.3.5", "lunr": "^2.3.5",
"mustache": "^4.0.1",
"safe-buffer": "^5.1.2", "safe-buffer": "^5.1.2",
"shortid": "^2.2.8", "shortid": "^2.2.8",
"string_decoder": "^1.2.0", "string_decoder": "^1.2.0",

View File

@ -7,6 +7,7 @@
import AppNotification, { import AppNotification, {
showAppNotification, showAppNotification,
} from "components/common/AppNotification.svelte" } from "components/common/AppNotification.svelte"
import { NotificationDisplay } from "@beyonk/svelte-notifications"
function showErrorBanner() { function showErrorBanner() {
showAppNotification({ showAppNotification({
@ -26,4 +27,7 @@
<AppNotification /> <AppNotification />
<!-- svelte-notifications -->
<NotificationDisplay />
<Router {routes} /> <Router {routes} />

View File

@ -77,7 +77,8 @@
} }
.budibase__input { .budibase__input {
width: 250px; width: 100%;
max-width: 250px;
height: 35px; height: 35px;
border-radius: 3px; border-radius: 3px;
border: 1px solid #DBDBDB; border: 1px solid #DBDBDB;

View File

@ -18,10 +18,12 @@ const post = apiCall("POST")
const get = apiCall("GET") const get = apiCall("GET")
const patch = apiCall("PATCH") const patch = apiCall("PATCH")
const del = apiCall("DELETE") const del = apiCall("DELETE")
const put = apiCall("PUT")
export default { export default {
post, post,
get, get,
patch, patch,
delete: del, delete: del,
put,
} }

View File

@ -1,9 +1,11 @@
import { getStore } from "./store" import { getStore } from "./store"
import { getBackendUiStore } from "./store/backend" import { getBackendUiStore } from "./store/backend"
import { getWorkflowStore } from "./store/workflow/"
import LogRocket from "logrocket" import LogRocket from "logrocket"
export const store = getStore() export const store = getStore()
export const backendUiStore = getBackendUiStore() export const backendUiStore = getBackendUiStore()
export const workflowStore = getWorkflowStore()
export const initialise = async () => { export const initialise = async () => {
try { try {

View File

@ -156,7 +156,6 @@ const createScreen = store => (screenName, route, layoutComponentName) => {
description: "", description: "",
url: "", url: "",
_css: "", _css: "",
uiFunctions: "",
props: createProps(rootComponent).props, props: createProps(rootComponent).props,
} }

View File

@ -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,
}
})
}
}

View File

@ -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
}

View File

@ -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();
})
})

View File

@ -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",
},
]
`;

View File

@ -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,
}

View File

@ -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

View File

@ -1,13 +1,8 @@
import { eventHandlers } from "../../../../client/src/state/eventHandlers" import { eventHandlers } from "../../../../client/src/state/eventHandlers"
import { writable } from "svelte/store"
export { EVENT_TYPE_MEMBER_NAME } from "../../../../client/src/state/eventHandlers" export { EVENT_TYPE_MEMBER_NAME } from "../../../../client/src/state/eventHandlers"
export const allHandlers = user => { export const allHandlers = () => {
const store = writable({ const handlersObj = eventHandlers()
_bbuser: user,
})
const handlersObj = eventHandlers(store)
const handlers = Object.keys(handlersObj).map(name => ({ const handlers = Object.keys(handlersObj).map(name => ({
name, name,

View File

@ -43,13 +43,6 @@
) )
} }
async function selectRecord(record) {
return await api.loadRecord(record.key, {
appname: $store.appname,
instanceId: $backendUiStore.selectedDatabase._id,
})
}
const ITEMS_PER_PAGE = 10 const ITEMS_PER_PAGE = 10
// Internal headers we want to hide from the user // Internal headers we want to hide from the user
const INTERNAL_HEADERS = ["_id", "_rev", "modelId", "type"] const INTERNAL_HEADERS = ["_id", "_rev", "modelId", "type"]

View File

@ -1,6 +1,6 @@
import api from "builderStore/api" import api from "builderStore/api"
export async function createUser(user, appId, instanceId) { export async function createUser(user, instanceId) {
const CREATE_USER_URL = `/api/${instanceId}/users` const CREATE_USER_URL = `/api/${instanceId}/users`
const response = await api.post(CREATE_USER_URL, user) const response = await api.post(CREATE_USER_URL, user)
return await response.json() return await response.json()
@ -28,7 +28,7 @@ export async function saveRecord(record, instanceId, modelId) {
} }
export async function fetchDataForView(viewName, instanceId) { export async function fetchDataForView(viewName, instanceId) {
const FETCH_RECORDS_URL = `/api/${instanceId}/${viewName}/records` const FETCH_RECORDS_URL = `/api/${instanceId}/views/${viewName}`
const response = await api.get(FETCH_RECORDS_URL) const response = await api.get(FETCH_RECORDS_URL)
return await response.json() return await response.json()

View File

@ -35,7 +35,7 @@
} }
</script> </script>
<heading> <header>
{#if !showFieldView} {#if !showFieldView}
<i class="ri-list-settings-line button--toggled" /> <i class="ri-list-settings-line button--toggled" />
<h3 class="budibase__title--3">Create / Edit Model</h3> <h3 class="budibase__title--3">Create / Edit Model</h3>
@ -43,7 +43,7 @@
<i class="ri-file-list-line button--toggled" /> <i class="ri-file-list-line button--toggled" />
<h3 class="budibase__title--3">Create / Edit Field</h3> <h3 class="budibase__title--3">Create / Edit Field</h3>
{/if} {/if}
</heading> </header>
{#if !showFieldView} {#if !showFieldView}
<div class="padding"> <div class="padding">
<h4 class="budibase__label--big">Settings</h4> <h4 class="budibase__label--big">Settings</h4>

View File

@ -64,7 +64,6 @@
<NumberBox label="Max Length" bind:value={constraints.length.maximum} /> <NumberBox label="Max Length" bind:value={constraints.length.maximum} />
<ValuesList label="Categories" bind:values={constraints.inclusion} /> <ValuesList label="Categories" bind:values={constraints.inclusion} />
{:else if type === 'datetime'} {:else if type === 'datetime'}
<!-- TODO: revisit and fix with JSON schema -->
<DatePicker <DatePicker
label="Min Value" label="Min Value"
bind:value={constraints.datetime.earliest} /> bind:value={constraints.datetime.earliest} />

View File

@ -7,14 +7,15 @@
let username let username
let password let password
let accessLevelId
$: valid = username && password $: valid = username && password && accessLevelId
$: instanceId = $backendUiStore.selectedDatabase._id $: instanceId = $backendUiStore.selectedDatabase._id
$: appId = $store.appId $: appId = $store.appId
async function createUser() { async function createUser() {
const user = { name: username, username, password } const user = { name: username, username, password, accessLevelId }
const response = await api.createUser(user, appId, instanceId) const response = await api.createUser(user, instanceId)
backendUiStore.actions.users.create(response) backendUiStore.actions.users.create(response)
onClosed() onClosed()
} }
@ -30,6 +31,14 @@
<label class="uk-form-label" for="form-stacked-text">Password</label> <label class="uk-form-label" for="form-stacked-text">Password</label>
<input class="uk-input" type="password" bind:value={password} /> <input class="uk-input" type="password" bind:value={password} />
</div> </div>
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Access Level</label>
<select class="uk-select" bind:value={accessLevelId}>
<option value="" />
<option value="POWER_USER">Power User</option>
<option value="ADMIN">Admin</option>
</select>
</div>
</div> </div>
<footer> <footer>
<ActionButton alert on:click={onClosed}>Cancel</ActionButton> <ActionButton alert on:click={onClosed}>Cancel</ActionButton>

View File

@ -117,7 +117,6 @@
selectedComponentType, selectedComponentType,
selectedComponentId, selectedComponentId,
frontendDefinition: JSON.stringify(frontendDefinition), frontendDefinition: JSON.stringify(frontendDefinition),
currentPageFunctions: $store.currentPageFunctions,
})} /> })} />
{/if} {/if}
</div> </div>

View File

@ -4,7 +4,6 @@ export default ({
selectedComponentType, selectedComponentType,
selectedComponentId, selectedComponentId,
frontendDefinition, frontendDefinition,
currentPageFunctions,
}) => `<html> }) => `<html>
<head> <head>
${stylesheetLinks} ${stylesheetLinks}
@ -36,7 +35,6 @@ export default ({
</style> </style>
<script> <script>
window["##BUDIBASE_FRONTEND_DEFINITION##"] = ${frontendDefinition}; window["##BUDIBASE_FRONTEND_DEFINITION##"] = ${frontendDefinition};
window["##BUDIBASE_FRONTEND_FUNCTIONS##"] = ${currentPageFunctions};
import('/_builder/budibase-client.esm.mjs') import('/_builder/budibase-client.esm.mjs')
.then(module => { .then(module => {

View File

@ -25,7 +25,7 @@
let categories = [ let categories = [
{ value: "design", name: "Design" }, { value: "design", name: "Design" },
{ value: "settings", name: "Settings" }, { value: "settings", name: "Settings" },
{ value: "actions", name: "Actions" }, { value: "events", name: "Events" },
] ]
let selectedCategory = categories[0] let selectedCategory = categories[0]
@ -93,6 +93,8 @@
{componentDefinition} {componentDefinition}
{panelDefinition} {panelDefinition}
onChange={onPropChanged} /> onChange={onPropChanged} />
{:else if selectedCategory.value === 'events'}
<EventsEditor component={componentInstance} />
{/if} {/if}
</div> </div>

View File

@ -25,16 +25,15 @@
export const EVENT_TYPE = "event" export const EVENT_TYPE = "event"
export let component export let component
export let components
let modalOpen = false let modalOpen = false
let events = [] let events = []
let selectedEvent = null let selectedEvent = null
$: { $: {
const componentDefinition = components[component._component] events = Object.keys(component)
events = Object.keys(componentDefinition.props) // TODO: use real events
.filter(propName => componentDefinition.props[propName] === EVENT_TYPE) .filter(propName => ["onChange", "onClick", "onLoad"].includes(propName))
.map(propName => ({ .map(propName => ({
name: propName, name: propName,
handlers: component[propName] || [], handlers: component[propName] || [],

View File

@ -5,12 +5,8 @@
import Input from "components/common/Input.svelte" import Input from "components/common/Input.svelte"
import { find, map, keys, reduce, keyBy } from "lodash/fp" import { find, map, keys, reduce, keyBy } from "lodash/fp"
import { pipe } from "components/common/core" import { pipe } from "components/common/core"
import { import { EVENT_TYPE_MEMBER_NAME } from "components/common/eventHandlers"
EVENT_TYPE_MEMBER_NAME, import { store, workflowStore } from "builderStore"
allHandlers,
} from "components/common/eventHandlers"
import { store } from "builderStore"
import StateBindingOptions from "../PropertyCascader/StateBindingOptions.svelte"
import { ArrowDownIcon } from "components/common/Icons/" import { ArrowDownIcon } from "components/common/Icons/"
export let parameter export let parameter
@ -22,18 +18,22 @@
<div class="handler-option"> <div class="handler-option">
<span>{parameter.name}</span> <span>{parameter.name}</span>
<div class="handler-input"> <div class="handler-input">
{#if parameter.name === 'workflow'}
<select
class="budibase__input"
on:change={onChange}
bind:value={parameter.value}>
{#each $workflowStore.workflows.filter(wf => wf.live) as workflow}
<option value={workflow._id}>{workflow.name}</option>
{/each}
</select>
{:else}
<Input on:change={onChange} value={parameter.value} /> <Input on:change={onChange} value={parameter.value} />
<button on:click={() => (isOpen = !isOpen)}> <button on:click={() => (isOpen = !isOpen)}>
<div class="icon" style={`transform: rotate(${isOpen ? 0 : 90}deg);`}> <div class="icon" style={`transform: rotate(${isOpen ? 0 : 90}deg);`}>
<ArrowDownIcon size={36} /> <ArrowDownIcon size={36} />
</div> </div>
</button> </button>
{#if isOpen}
<StateBindingOptions
onSelect={option => {
onChange(option)
isOpen = false
}} />
{/if} {/if}
</div> </div>
</div> </div>

View File

@ -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>

View File

@ -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>

View File

@ -1 +0,0 @@
export { default } from "./PropertyCascader.svelte"

View File

@ -2,8 +2,6 @@
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
import IconButton from "../common/IconButton.svelte" import IconButton from "../common/IconButton.svelte"
import Input from "../common/Input.svelte" import Input from "../common/Input.svelte"
import PropertyCascader from "./PropertyCascader"
import { isBinding, getBinding, setBinding } from "../common/binding"
import Colorpicker from "../common/Colorpicker.svelte" import Colorpicker from "../common/Colorpicker.svelte"
export let value = "" export let value = ""
@ -49,8 +47,6 @@
{/if} {/if}
{/each} {/each}
</select> </select>
{:else}
<PropertyCascader {onChanged} {value} />
{/if} {/if}
</div> </div>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1 @@
export { default as SetupPanel } from "./SetupPanel.svelte"

View File

@ -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>

View File

@ -0,0 +1,9 @@
<svg
width="9"
height="75"
viewBox="0 0 9 75"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M5.0625 70H9L4.5 75L0 70H3.9375V65H5.0625V70Z" fill="#ADAEC4" />
<rect x="4" width="1" height="65" fill="#ADAEC4" />
</svg>

After

Width:  |  Height:  |  Size: 241 B

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
}

View File

@ -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"

View File

@ -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"

View File

@ -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;
}
}

View File

@ -74,6 +74,17 @@
--background-button: #f9f9f9; --background-button: #f9f9f9;
--button-text: #0055ff; --button-text: #0055ff;
/* Budibase Styleguide Colors */
--primary: #0055ff;
--secondary: #f1f4fc;
--color: #393c44;
--light-grey: #fbfbfb;
--dark-grey: #808192;
--medium-grey: #e8e8ef;
--background: rgb(251, 251, 251);
--font: #393c44;
--coral: #eb5757;
} }
html, body { html, body {

View File

@ -87,6 +87,11 @@
flex-direction: column; flex-direction: column;
} }
a {
text-transform: none;
color: var(--ink-lighter);
}
.top-nav { .top-nav {
flex: 0 0 auto; flex: 0 0 auto;
height: 60px; height: 60px;

View File

@ -0,0 +1,3 @@
<script>
store.setCurrentPage($params.page)
</script>

View File

@ -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>

View File

@ -0,0 +1,5 @@
<script>
import { WorkflowBuilder } from "components/workflow"
</script>
<WorkflowBuilder />

View File

@ -1,6 +1,5 @@
import { createProps } from "../src/components/userInterface/pagesParsing/createProps" import { createProps } from "../src/components/userInterface/pagesParsing/createProps"
import { keys, some } from "lodash/fp" import { keys, some } from "lodash/fp"
import { BB_STATE_BINDINGPATH } from "@budibase/client/src/state/parseBinding"
import { stripStandardProps } from "./testData" import { stripStandardProps } from "./testData"
describe("createDefaultProps", () => { describe("createDefaultProps", () => {
@ -94,17 +93,6 @@ describe("createDefaultProps", () => {
expect(props.onClick).toEqual([]) expect(props.onClick).toEqual([])
}) })
it("should create a object with empty state when prop def is 'state' ", () => {
const comp = getcomponent()
comp.props.data = "state"
const { props, errors } = createProps(comp)
expect(errors).toEqual([])
expect(props.data[BB_STATE_BINDINGPATH]).toBeDefined()
expect(props.data[BB_STATE_BINDINGPATH]).toBe("")
})
it("should create a object children array when children == true ", () => { it("should create a object children array when children == true ", () => {
const comp = getcomponent() const comp = getcomponent()
comp.children = true comp.children = true

View File

@ -16,6 +16,5 @@
}, },
"_code": "" "_code": ""
}, },
"_css": "", "_css": ""
"uiFunctions": ""
} }

View File

@ -16,6 +16,5 @@
}, },
"_code": "" "_code": ""
}, },
"_css": "", "_css": ""
"uiFunctions": ""
} }

View File

@ -16,6 +16,5 @@
}, },
"_code": "" "_code": ""
}, },
"_css": "", "_css": ""
"uiFunctions": ""
} }

View File

@ -39,6 +39,7 @@
"deep-equal": "^2.0.1", "deep-equal": "^2.0.1",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"lunr": "^2.3.5", "lunr": "^2.3.5",
"mustache": "^4.0.1",
"regexparam": "^1.3.0", "regexparam": "^1.3.0",
"shortid": "^2.2.8", "shortid": "^2.2.8",
"svelte": "^3.9.2" "svelte": "^3.9.2"

View File

@ -1,43 +1,33 @@
import { ERROR } from "../state/standardState"
import { loadRecord } from "./loadRecord"
import { listRecords } from "./listRecords"
import { authenticate } from "./authenticate" import { authenticate } from "./authenticate"
import { saveRecord } from "./saveRecord" import { triggerWorkflow } from "./workflow"
export const createApi = ({ rootPath = "", setState, getState }) => { export const createApi = ({ rootPath = "", setState, getState }) => {
const apiCall = method => ({ const apiCall = method => async ({ url, body }) => {
url, const response = await fetch(`${rootPath}${url}`, {
body,
notFound,
badRequest,
forbidden,
}) => {
return fetch(`${rootPath}${url}`, {
method: method, method: method,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: body && JSON.stringify(body), body: body && JSON.stringify(body),
credentials: "same-origin", credentials: "same-origin",
}).then(r => {
switch (r.status) {
case 200:
return r.json()
case 404:
return error(notFound || `${url} Not found`)
case 400:
return error(badRequest || `${url} Bad Request`)
case 403:
return error(forbidden || `${url} Forbidden`)
default:
if (
r.status.toString().startsWith("2") ||
r.status.toString().startsWith("3")
)
return r.json()
else return error(`${url} - ${r.statusText}`)
}
}) })
switch (response.status) {
case 200:
return response.json()
case 404:
return error(`${url} Not found`)
case 400:
return error(`${url} Bad Request`)
case 403:
return error(`${url} Forbidden`)
default:
if (response.status >= 200 && response.status < 400) {
return response.json()
}
return error(`${url} - ${response.statusText}`)
}
} }
const post = apiCall("POST") const post = apiCall("POST")
@ -47,10 +37,9 @@ export const createApi = ({ rootPath = "", setState, getState }) => {
const ERROR_MEMBER = "##error" const ERROR_MEMBER = "##error"
const error = message => { const error = message => {
const e = {} const err = { [ERROR_MEMBER]: message }
e[ERROR_MEMBER] = message setState("##error_message", message)
setState(ERROR, message) return err
return e
} }
const isSuccess = obj => !obj || !obj[ERROR_MEMBER] const isSuccess = obj => !obj || !obj[ERROR_MEMBER]
@ -68,9 +57,7 @@ export const createApi = ({ rootPath = "", setState, getState }) => {
} }
return { return {
loadRecord: loadRecord(apiOpts),
listRecords: listRecords(apiOpts),
authenticate: authenticate(apiOpts), authenticate: authenticate(apiOpts),
saveRecord: saveRecord(apiOpts), triggerWorkflow: triggerWorkflow(apiOpts),
} }
} }

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}
},
}

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -1 +0,0 @@
export const trimSlash = str => str.replace(/^\/+|\/+$/g, "")

View File

@ -1,27 +1,22 @@
import { writable } from "svelte/store"
import { attachChildren } from "./render/attachChildren" import { attachChildren } from "./render/attachChildren"
import { createTreeNode } from "./render/prepareRenderComponent" import { createTreeNode } from "./render/prepareRenderComponent"
import { screenRouter } from "./render/screenRouter" import { screenRouter } from "./render/screenRouter"
import { createStateManager } from "./state/stateManager" import { createStateManager } from "./state/stateManager"
export const createApp = ( export const createApp = ({
componentLibraries, componentLibraries,
frontendDefinition, frontendDefinition,
user, window,
uiFunctions, }) => {
window
) => {
let routeTo let routeTo
let currentUrl let currentUrl
let screenStateManager let screenStateManager
const onScreenSlotRendered = screenSlotNode => { const onScreenSlotRendered = screenSlotNode => {
const onScreenSelected = (screen, store, url) => { const onScreenSelected = (screen, url) => {
const stateManager = createStateManager({ const stateManager = createStateManager({
store,
frontendDefinition, frontendDefinition,
componentLibraries, componentLibraries,
uiFunctions,
onScreenSlotRendered: () => {}, onScreenSlotRendered: () => {},
routeTo, routeTo,
appRootPath: frontendDefinition.appRootPath, appRootPath: frontendDefinition.appRootPath,
@ -38,11 +33,11 @@ export const createApp = (
currentUrl = url currentUrl = url
} }
routeTo = screenRouter( routeTo = screenRouter({
frontendDefinition.screens, screens: frontendDefinition.screens,
onScreenSelected, onScreenSelected,
frontendDefinition.appRootPath appRootPath: frontendDefinition.appRootPath,
) })
const fallbackPath = window.location.pathname.replace( const fallbackPath = window.location.pathname.replace(
frontendDefinition.appRootPath, frontendDefinition.appRootPath,
"" ""
@ -53,7 +48,6 @@ export const createApp = (
const attachChildrenParams = stateManager => { const attachChildrenParams = stateManager => {
const getInitialiseParams = treeNode => ({ const getInitialiseParams = treeNode => ({
componentLibraries, componentLibraries,
uiFunctions,
treeNode, treeNode,
onScreenSlotRendered, onScreenSlotRendered,
setupState: stateManager.setup, setupState: stateManager.setup,
@ -65,10 +59,8 @@ export const createApp = (
let rootTreeNode let rootTreeNode
const pageStateManager = createStateManager({ const pageStateManager = createStateManager({
store: writable({ _bbuser: user }),
frontendDefinition, frontendDefinition,
componentLibraries, componentLibraries,
uiFunctions,
onScreenSlotRendered, onScreenSlotRendered,
appRootPath: frontendDefinition.appRootPath, appRootPath: frontendDefinition.appRootPath,
// seems weird, but the routeTo variable may not be available at this point // seems weird, but the routeTo variable may not be available at this point
@ -82,7 +74,6 @@ export const createApp = (
rootTreeNode.props = { rootTreeNode.props = {
_children: [page.props], _children: [page.props],
} }
rootTreeNode.rootElement = target
const getInitialiseParams = attachChildrenParams(pageStateManager) const getInitialiseParams = attachChildrenParams(pageStateManager)
const initChildParams = getInitialiseParams(rootTreeNode) const initChildParams = getInitialiseParams(rootTreeNode)

View File

@ -10,9 +10,7 @@ export const loadBudibase = async opts => {
// const _localStorage = (opts && opts.localStorage) || localStorage // const _localStorage = (opts && opts.localStorage) || localStorage
const frontendDefinition = _window["##BUDIBASE_FRONTEND_DEFINITION##"] const frontendDefinition = _window["##BUDIBASE_FRONTEND_DEFINITION##"]
const uiFunctions = _window["##BUDIBASE_FRONTEND_FUNCTIONS##"]
// TODO: update
const user = {} const user = {}
const componentLibraryModules = (opts && opts.componentLibraries) || {} const componentLibraryModules = (opts && opts.componentLibraries) || {}
@ -36,14 +34,12 @@ export const loadBudibase = async opts => {
pageStore, pageStore,
routeTo, routeTo,
rootNode, rootNode,
} = createApp( } = createApp({
componentLibraryModules, componentLibraries: componentLibraryModules,
frontendDefinition, frontendDefinition,
user, user,
uiFunctions || {}, window,
_window, })
rootNode
)
const route = _window.location const route = _window.location
? _window.location.pathname.replace(frontendDefinition.appRootPath, "") ? _window.location.pathname.replace(frontendDefinition.appRootPath, "")

View File

@ -5,12 +5,10 @@ import deepEqual from "deep-equal"
export const attachChildren = initialiseOpts => (htmlElement, options) => { export const attachChildren = initialiseOpts => (htmlElement, options) => {
const { const {
uiFunctions,
componentLibraries, componentLibraries,
treeNode, treeNode,
onScreenSlotRendered, onScreenSlotRendered,
setupState, setupState,
getCurrentState,
} = initialiseOpts } = initialiseOpts
const anchor = options && options.anchor ? options.anchor : null const anchor = options && options.anchor ? options.anchor : null
@ -31,8 +29,6 @@ export const attachChildren = initialiseOpts => (htmlElement, options) => {
} }
} }
// htmlElement.classList.add(`lay-${treeNode.props._id}`)
const childNodes = [] const childNodes = []
for (let childProps of treeNode.props._children) { for (let childProps of treeNode.props._children) {
const { componentName, libName } = splitName(childProps._component) const { componentName, libName } = splitName(childProps._component)
@ -45,10 +41,8 @@ export const attachChildren = initialiseOpts => (htmlElement, options) => {
props: childProps, props: childProps,
parentNode: treeNode, parentNode: treeNode,
ComponentConstructor, ComponentConstructor,
uiFunctions,
htmlElement, htmlElement,
anchor, anchor,
getCurrentState,
}) })
for (let childNode of childNodesThisIteration) { for (let childNode of childNodesThisIteration) {

View File

@ -1,14 +1,13 @@
import { appStore } from "../state/store"
import mustache from "mustache"
export const prepareRenderComponent = ({ export const prepareRenderComponent = ({
ComponentConstructor, ComponentConstructor,
uiFunctions,
htmlElement, htmlElement,
anchor, anchor,
props, props,
parentNode, parentNode,
getCurrentState,
}) => { }) => {
const func = props._id ? uiFunctions[props._id] : undefined
const parentContext = (parentNode && parentNode.context) || {} const parentContext = (parentNode && parentNode.context) || {}
let nodesToRender = [] let nodesToRender = []
@ -39,16 +38,27 @@ export const prepareRenderComponent = ({
if (props._id && thisNode.rootElement) { if (props._id && thisNode.rootElement) {
thisNode.rootElement.classList.add(`${componentName}-${props._id}`) thisNode.rootElement.classList.add(`${componentName}-${props._id}`)
} }
// make this node listen to the store
if (thisNode.stateBound) {
const unsubscribe = appStore.subscribe(state => {
const storeBoundProps = { ...initialProps._bb.props }
for (let prop in storeBoundProps) {
const propValue = storeBoundProps[prop]
if (typeof propValue === "string") {
storeBoundProps[prop] = mustache.render(propValue, {
state,
})
}
}
thisNode.component.$set(storeBoundProps)
})
thisNode.unsubscribe = unsubscribe
}
} }
} }
if (func) {
const state = getCurrentState()
const routeParams = state["##routeParams"]
func(createNodeAndRender, parentContext, getCurrentState(), routeParams)
} else {
createNodeAndRender() createNodeAndRender()
}
return nodesToRender return nodesToRender
} }

View File

@ -1,7 +1,7 @@
import regexparam from "regexparam" import regexparam from "regexparam"
import { writable } from "svelte/store" import { routerStore } from "../state/store"
export const screenRouter = (screens, onScreenSelected, appRootPath) => { export const screenRouter = ({ screens, onScreenSelected, appRootPath }) => {
const makeRootedPath = url => { const makeRootedPath = url => {
if (appRootPath) { if (appRootPath) {
if (url) return `${appRootPath}${url.startsWith("/") ? "" : "/"}${url}` if (url) return `${appRootPath}${url.startsWith("/") ? "" : "/"}${url}`
@ -40,13 +40,14 @@ export const screenRouter = (screens, onScreenSelected, appRootPath) => {
}) })
} }
const storeInitial = {} routerStore.update(state => {
storeInitial["##routeParams"] = params state["##routeParams"] = params
const store = writable(storeInitial) return state
})
const screenIndex = current !== -1 ? current : fallback const screenIndex = current !== -1 ? current : fallback
onScreenSelected(screens[screenIndex], store, _url) onScreenSelected(screens[screenIndex], _url)
try { try {
!url.state && history.pushState(_url, null, _url) !url.state && history.pushState(_url, null, _url)
@ -55,29 +56,8 @@ export const screenRouter = (screens, onScreenSelected, appRootPath) => {
} }
} }
function click(e) {
const x = e.target.closest("a")
const y = x && x.getAttribute("href")
if (
e.ctrlKey ||
e.metaKey ||
e.altKey ||
e.shiftKey ||
e.button ||
e.defaultPrevented
)
return
if (!y || x.target || x.host !== location.host) return
e.preventDefault()
route(y)
}
addEventListener("popstate", route) addEventListener("popstate", route)
addEventListener("pushstate", route) addEventListener("pushstate", route)
addEventListener("click", click)
return route return route
} }

View File

@ -1,16 +1,13 @@
import { getStateOrValue } from "./getState" import { setState } from "./setState"
import { setState, setStateFromBinding } from "./setState"
import { trimSlash } from "../common/trimSlash"
import { isBound } from "./parseBinding"
import { attachChildren } from "../render/attachChildren" import { attachChildren } from "../render/attachChildren"
import { getContext, setContext } from "./getSetContext" import { getContext, setContext } from "./getSetContext"
export const trimSlash = str => str.replace(/^\/+|\/+$/g, "")
export const bbFactory = ({ export const bbFactory = ({
store, store,
getCurrentState,
frontendDefinition, frontendDefinition,
componentLibraries, componentLibraries,
uiFunctions,
onScreenSlotRendered, onScreenSlotRendered,
}) => { }) => {
const relativeUrl = url => { const relativeUrl = url => {
@ -51,11 +48,9 @@ export const bbFactory = ({
return (treeNode, setupState) => { return (treeNode, setupState) => {
const attachParams = { const attachParams = {
componentLibraries, componentLibraries,
uiFunctions,
treeNode, treeNode,
onScreenSlotRendered, onScreenSlotRendered,
setupState, setupState,
getCurrentState,
} }
return { return {
@ -63,17 +58,12 @@ export const bbFactory = ({
context: treeNode.context, context: treeNode.context,
props: treeNode.props, props: treeNode.props,
call: safeCallEvent, call: safeCallEvent,
setStateFromBinding: (binding, value) => setState,
setStateFromBinding(store, binding, value),
setState: (path, value) => setState(store, path, value),
getStateOrValue: (prop, currentContext) =>
getStateOrValue(getCurrentState(), prop, currentContext),
getContext: getContext(treeNode), getContext: getContext(treeNode),
setContext: setContext(treeNode), setContext: setContext(treeNode),
store: store, store: store,
relativeUrl, relativeUrl,
api, api,
isBound,
parent, parent,
} }
} }

View File

@ -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)

View File

@ -6,35 +6,24 @@ import { createApi } from "../api"
export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType" export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType"
export const eventHandlers = (store, rootPath, routeTo) => { export const eventHandlers = (rootPath, routeTo) => {
const handler = (parameters, execute) => ({ const handler = (parameters, execute) => ({
execute, execute,
parameters, parameters,
}) })
const setStateWithStore = (path, value) => setState(store, path, value)
let currentState
store.subscribe(state => {
currentState = state
})
const api = createApi({ const api = createApi({
rootPath, rootPath,
setState: setStateWithStore, setState,
getState: (path, fallback) => getState(currentState, path, fallback), getState: (path, fallback) => getState(path, fallback),
}) })
const setStateHandler = ({ path, value }) => setState(store, path, value) const setStateHandler = ({ path, value }) => setState(path, value)
return { return {
"Set State": handler(["path", "value"], setStateHandler), "Set State": handler(["path", "value"], setStateHandler),
"Load Record": handler(["recordKey", "statePath"], api.loadRecord),
"List Records": handler(["indexKey", "statePath"], api.listRecords),
"Save Record": handler(["statePath"], api.saveRecord),
"Navigate To": handler(["url"], param => routeTo(param && param.url)), "Navigate To": handler(["url"], param => routeTo(param && param.url)),
"Trigger Workflow": handler(["workflow"], api.triggerWorkflow),
Authenticate: handler(["username", "password"], api.authenticate),
} }
} }

View File

@ -1,46 +1,9 @@
import { isUndefined, isObject } from "lodash/fp" import { get } from "svelte/store"
import { parseBinding, isStoreBinding } from "./parseBinding" import getOr from "lodash/fp/getOr"
import { appStore } from "./store"
export const getState = (s, path, fallback) => { export const getState = (path, fallback) => {
if (!s) return fallback
if (!path || path.length === 0) return fallback if (!path || path.length === 0) return fallback
if (path === "$") return s return getOr(fallback, path, get(appStore))
const pathParts = path.split(".")
const safeGetPath = (obj, currentPartIndex = 0) => {
const currentKey = pathParts[currentPartIndex]
if (pathParts.length - 1 == currentPartIndex) {
const value = obj[currentKey]
if (isUndefined(value)) return fallback
else return value
}
if (
obj[currentKey] === null ||
obj[currentKey] === undefined ||
!isObject(obj[currentKey])
) {
return fallback
}
return safeGetPath(obj[currentKey], currentPartIndex + 1)
}
return safeGetPath(s)
}
export const getStateOrValue = (globalState, prop, currentContext) => {
if (!prop) return prop
const binding = parseBinding(prop)
if (binding) {
const stateToUse = isStoreBinding(binding) ? globalState : currentContext
return getState(stateToUse, binding.path, binding.fallback)
}
return prop
} }

View File

@ -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,
}
}

View File

@ -1,38 +1,11 @@
import { isObject } from "lodash/fp" import set from "lodash/fp/set"
import { parseBinding } from "./parseBinding" import { appStore } from "./store"
export const setState = (store, path, value) => { export const setState = (path, value) => {
if (!path || path.length === 0) return if (!path || path.length === 0) return
const pathParts = path.split(".") appStore.update(state => {
state = set(path, value, state)
const safeSetPath = (state, currentPartIndex = 0) => {
const currentKey = pathParts[currentPartIndex]
if (pathParts.length - 1 == currentPartIndex) {
state[currentKey] = value
return
}
if (
state[currentKey] === null ||
state[currentKey] === undefined ||
!isObject(state[currentKey])
) {
state[currentKey] = {}
}
safeSetPath(state[currentKey], currentPartIndex + 1)
}
store.update(state => {
safeSetPath(state)
return state return state
}) })
} }
export const setStateFromBinding = (store, binding, value) => {
const parsedBinding = parseBinding(binding)
if (!parsedBinding) return
return setState(store, parsedBinding.path, value)
}

View File

@ -1 +0,0 @@
export const ERROR = "##error_message"

View File

@ -4,10 +4,9 @@ import {
EVENT_TYPE_MEMBER_NAME, EVENT_TYPE_MEMBER_NAME,
} from "./eventHandlers" } from "./eventHandlers"
import { bbFactory } from "./bbComponentApi" import { bbFactory } from "./bbComponentApi"
import { getState } from "./getState" import mustache from "mustache"
import { attachChildren } from "../render/attachChildren" import { get } from "svelte/store"
import { appStore } from "./store"
import { parseBinding } from "./parseBinding"
const doNothing = () => {} const doNothing = () => {}
doNothing.isPlaceholder = true doNothing.isPlaceholder = true
@ -18,182 +17,62 @@ const isMetaProp = propName =>
propName === "_id" || propName === "_id" ||
propName === "_style" || propName === "_style" ||
propName === "_code" || propName === "_code" ||
propName === "_codeMeta" propName === "_codeMeta" ||
propName === "_styles"
export const createStateManager = ({ export const createStateManager = ({
store,
appRootPath, appRootPath,
frontendDefinition, frontendDefinition,
componentLibraries, componentLibraries,
uiFunctions,
onScreenSlotRendered, onScreenSlotRendered,
routeTo, routeTo,
}) => { }) => {
let handlerTypes = eventHandlers(store, appRootPath, routeTo) let handlerTypes = eventHandlers(appRootPath, routeTo)
let currentState 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 getCurrentState = () => currentState
const registerBindings = _registerBindings(
nodesBoundByProps,
nodesWithCodeBoundChildren
)
const bb = bbFactory({ const bb = bbFactory({
store, store: appStore,
getCurrentState, getCurrentState,
frontendDefinition, frontendDefinition,
componentLibraries, componentLibraries,
uiFunctions,
onScreenSlotRendered, onScreenSlotRendered,
}) })
const setup = _setup(handlerTypes, getCurrentState, registerBindings, bb) const setup = _setup({ handlerTypes, getCurrentState, bb, store: appStore })
const unsubscribe = store.subscribe(
onStoreStateUpdated({
setCurrentState: s => (currentState = s),
getCurrentState,
nodesWithCodeBoundChildren,
nodesBoundByProps,
uiFunctions,
componentLibraries,
onScreenSlotRendered,
setupState: setup,
})
)
return { return {
setup, setup,
destroy: () => unsubscribe(), destroy: () => {},
getCurrentState, getCurrentState,
store, store: appStore,
} }
} }
const onStoreStateUpdated = ({ const _setup = ({ handlerTypes, getCurrentState, bb, store }) => node => {
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)
}
const _setup = (
handlerTypes,
getCurrentState,
registerBindings,
bb
) => node => {
const props = node.props const props = node.props
const context = node.context || {} const context = node.context || {}
const initialProps = { ...props } const initialProps = { ...props }
const storeBoundProps = [] const currentStoreState = get(appStore)
const currentStoreState = getCurrentState()
for (let propName in props) { for (let propName in props) {
if (isMetaProp(propName)) continue if (isMetaProp(propName)) continue
const propValue = props[propName] const propValue = props[propName]
const binding = parseBinding(propValue) // A little bit of a hack - won't bind if the string doesn't start with {{
const isBound = !!binding const isBound = typeof propValue === "string" && propValue.startsWith("{{")
if (isBound) binding.propName = propName if (isBound) {
initialProps[propName] = mustache.render(propValue, {
state: currentStoreState,
context,
})
if (isBound && binding.source === "state") { if (!node.stateBound) {
storeBoundProps.push(binding) node.stateBound = true
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)) { if (isEventType(propValue)) {
@ -203,33 +82,24 @@ const _setup = (
handlerType: event[EVENT_TYPE_MEMBER_NAME], handlerType: event[EVENT_TYPE_MEMBER_NAME],
parameters: event.parameters, parameters: event.parameters,
} }
const resolvedParams = {} const resolvedParams = {}
for (let paramName in handlerInfo.parameters) { for (let paramName in handlerInfo.parameters) {
const paramValue = handlerInfo.parameters[paramName] const paramValue = handlerInfo.parameters[paramName]
const paramBinding = parseBinding(paramValue)
if (!paramBinding) {
resolvedParams[paramName] = () => paramValue
continue
}
let paramValueSource
if (paramBinding.source === "context") paramValueSource = context
if (paramBinding.source === "state")
paramValueSource = getCurrentState()
if (paramBinding.source === "context") paramValueSource = context
// The new dynamic event parameter bound to the relevant source
resolvedParams[paramName] = () => resolvedParams[paramName] = () =>
getState(paramValueSource, paramBinding.path, paramBinding.fallback) mustache.render(paramValue, {
state: getCurrentState(),
context,
})
} }
handlerInfo.parameters = resolvedParams handlerInfo.parameters = resolvedParams
handlersInfos.push(handlerInfo) handlersInfos.push(handlerInfo)
} }
if (handlersInfos.length === 0) initialProps[propName] = doNothing if (handlersInfos.length === 0) {
else { initialProps[propName] = doNothing
} else {
initialProps[propName] = async context => { initialProps[propName] = async context => {
for (let handlerInfo of handlersInfos) { for (let handlerInfo of handlersInfos) {
const handler = makeHandler(handlerTypes, handlerInfo) const handler = makeHandler(handlerTypes, handlerInfo)
@ -240,9 +110,7 @@ const _setup = (
} }
} }
registerBindings(node, storeBoundProps) const setup = _setup({ handlerTypes, getCurrentState, bb, store })
const setup = _setup(handlerTypes, getCurrentState, registerBindings, bb)
initialProps._bb = bb(node, setup) initialProps._bb = bb(node, setup)
return initialProps return initialProps

View File

@ -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)
}
}

View File

@ -0,0 +1,9 @@
import { writable } from "svelte/store"
const appStore = writable({})
appStore.actions = {}
const routerStore = writable({})
routerStore.actions = {}
export { appStore, routerStore }

View File

@ -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
}

View File

@ -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()
})
})

View File

@ -13,7 +13,7 @@ export const load = async (page, screens, url, appRootPath) => {
autoAssignIds(s.props) autoAssignIds(s.props)
} }
setAppDef(dom.window, page, screens) setAppDef(dom.window, page, screens)
addWindowGlobals(dom.window, page, screens, appRootPath, uiFunctions, { addWindowGlobals(dom.window, page, screens, appRootPath, {
hierarchy: {}, hierarchy: {},
actions: [], actions: [],
triggers: [], triggers: [],
@ -27,13 +27,12 @@ export const load = async (page, screens, url, appRootPath) => {
return { dom, app } return { dom, app }
} }
const addWindowGlobals = (window, page, screens, appRootPath, uiFunctions) => { const addWindowGlobals = (window, page, screens, appRootPath) => {
window["##BUDIBASE_FRONTEND_DEFINITION##"] = { window["##BUDIBASE_FRONTEND_DEFINITION##"] = {
page, page,
screens, screens,
appRootPath, appRootPath,
} }
window["##BUDIBASE_FRONTEND_FUNCTIONS##"] = uiFunctions
} }
export const makePage = props => ({ props }) export const makePage = props => ({ props })
@ -184,28 +183,3 @@ const maketestlib = window => ({
opts.target.appendChild(node) opts.target.appendChild(node)
}, },
}) })
const uiFunctions = {
never_render: () => {},
always_render: render => {
render()
},
three_clones: render => {
for (let i = 0; i < 3; i++) {
render()
}
},
with_context: render => {
render({ testKey: "test value" })
},
n_clones_based_on_store: (render, _, state) => {
const n = state.componentCount || 0
for (let i = 0; i < n; i++) {
render({ index: `index_${i}` })
}
},
}

View File

@ -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)} } )`

View File

@ -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",
},
],
})

View File

@ -15,8 +15,6 @@ export {
DatatableCell, DatatableCell,
DatatableRow, DatatableRow,
} from "./Datatable" } from "./Datatable"
export { default as indexDatatable } from "./Templates/indexDatatable"
export { default as recordForm } from "./Templates/recordForm"
export { List, ListItem } from "./List" export { List, ListItem } from "./List"
export { Menu } from "./Menu" export { Menu } from "./Menu"
export { Select } from "./Select" export { Select } from "./Select"

View File

@ -44,6 +44,7 @@
"@budibase/client": "^0.0.32", "@budibase/client": "^0.0.32",
"@budibase/core": "^0.0.32", "@budibase/core": "^0.0.32",
"@koa/router": "^8.0.0", "@koa/router": "^8.0.0",
"@sendgrid/mail": "^7.1.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"electron-is-dev": "^1.2.0", "electron-is-dev": "^1.2.0",
@ -59,6 +60,7 @@
"koa-session": "^5.12.0", "koa-session": "^5.12.0",
"koa-static": "^5.0.0", "koa-static": "^5.0.0",
"lodash": "^4.17.13", "lodash": "^4.17.13",
"mustache": "^4.0.1",
"pino-pretty": "^4.0.0", "pino-pretty": "^4.0.0",
"pouchdb": "^7.2.1", "pouchdb": "^7.2.1",
"pouchdb-all-dbs": "^1.0.2", "pouchdb-all-dbs": "^1.0.2",

View File

@ -24,14 +24,15 @@ exports.authenticate = async ctx => {
// Check the user exists in the instance DB by username // Check the user exists in the instance DB by username
const instanceDb = new CouchDB(instanceId) const instanceDb = new CouchDB(instanceId)
const { rows } = await instanceDb.query("database/by_username", {
include_docs: true,
username,
})
if (rows.length === 0) ctx.throw(500, `User does not exist.`) let dbUser
try {
const dbUser = rows[0].doc dbUser = await instanceDb.get(`user_${username}`)
} catch (_) {
// do not want to throw a 404 - as this could be
// used to dtermine valid usernames
ctx.throw(401, "Invalid Credentials")
}
// authenticate // authenticate
if (await bcrypt.compare(password, dbUser.password)) { if (await bcrypt.compare(password, dbUser.password)) {

View File

@ -29,6 +29,16 @@ exports.create = async function(ctx) {
emit([doc.type], doc._id) emit([doc.type], doc._id)
}.toString(), }.toString(),
}, },
by_workflow_trigger: {
map: function(doc) {
if (doc.type === "workflow") {
const trigger = doc.definition.trigger
if (trigger) {
emit([trigger.event], trigger)
}
}
}.toString(),
},
}, },
}) })

View File

@ -42,6 +42,12 @@ exports.save = async function(ctx) {
record.type = "record" record.type = "record"
const response = await db.post(record) const response = await db.post(record)
record._rev = response.rev record._rev = response.rev
ctx.eventEmitter &&
ctx.eventEmitter.emit(`record:save`, {
record,
instanceId: ctx.params.instanceId,
})
ctx.body = record ctx.body = record
ctx.status = 200 ctx.status = 200
ctx.message = `${model.name} created successfully` ctx.message = `${model.name} created successfully`
@ -81,6 +87,7 @@ exports.destroy = async function(ctx) {
return return
} }
ctx.body = await db.remove(ctx.params.recordId, ctx.params.revId) ctx.body = await db.remove(ctx.params.recordId, ctx.params.revId)
ctx.eventEmitter && ctx.eventEmitter.emit(`record:delete`, record)
} }
exports.validate = async function(ctx) { exports.validate = async function(ctx) {

View File

@ -11,7 +11,8 @@ const controller = {
if ( if (
!name.startsWith("all") && !name.startsWith("all") &&
name !== "by_type" && name !== "by_type" &&
name !== "by_username" name !== "by_username" &&
name !== "by_workflow_trigger"
) { ) {
response.push({ response.push({
name, name,

View File

@ -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,
}
}
}

View File

@ -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,
}
}
}

View File

@ -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,
}
}
}

View File

@ -1,5 +1,5 @@
const CouchDB = require("../../db") const CouchDB = require("../../../db")
const newid = require("../../db/newid") const newid = require("../../../db/newid")
exports.create = async function(ctx) { exports.create = async function(ctx) {
const db = new CouchDB(ctx.params.instanceId) const db = new CouchDB(ctx.params.instanceId)
@ -7,24 +7,6 @@ exports.create = async function(ctx) {
workflow._id = newid() workflow._id = newid()
// TODO: Possibly validate the workflow against a schema
// // validation with ajv
// const model = await db.get(record.modelId)
// const validate = ajv.compile({
// properties: model.schema,
// })
// const valid = validate(record)
// if (!valid) {
// ctx.status = 400
// ctx.body = {
// status: 400,
// errors: validate.errors,
// }
// return
// }
workflow.type = "workflow" workflow.type = "workflow"
const response = await db.post(workflow) const response = await db.post(workflow)
workflow._rev = response.rev workflow._rev = response.rev
@ -41,23 +23,51 @@ exports.create = async function(ctx) {
exports.update = async function(ctx) { exports.update = async function(ctx) {
const db = new CouchDB(ctx.params.instanceId) const db = new CouchDB(ctx.params.instanceId)
ctx.body = await db.get(ctx.params.recordId) const workflow = ctx.request.body
const response = await db.put(workflow)
workflow._rev = response.rev
ctx.status = 200
ctx.body = {
message: `Workflow ${workflow._id} updated successfully.`,
workflow: {
...workflow,
_rev: response.rev,
_id: response.id,
},
}
} }
exports.fetch = async function(ctx) { exports.fetch = async function(ctx) {
const db = new CouchDB(ctx.params.instanceId) const db = new CouchDB(ctx.params.instanceId)
const response = await db.query(`database/by_type`, { const response = await db.query(`database/by_type`, {
type: "workflow", key: ["workflow"],
include_docs: true, include_docs: true,
}) })
ctx.body = response.rows.map(row => row.doc) ctx.body = response.rows.map(row => row.doc)
} }
exports.find = async function(ctx) { exports.find = async function(ctx) {
const db = new CouchDB(ctx.params.instanceId) const db = new CouchDB(ctx.user.instanceId)
ctx.body = await db.get(ctx.params.id) ctx.body = await db.get(ctx.params.id)
} }
exports.executeAction = async function(ctx) {
const { args, action } = ctx.request.body
const workflowAction = require(`./actions/${action}`)
const response = await workflowAction({
args,
instanceId: ctx.user.instanceId,
})
ctx.body = response
}
exports.fetchActionScript = async function(ctx) {
const workflowAction = require(`./actions/${ctx.action}`)
ctx.body = workflowAction
}
exports.destroy = async function(ctx) { exports.destroy = async function(ctx) {
const db = new CouchDB(ctx.params.instanceId) const db = new CouchDB(ctx.params.instanceId)
ctx.body = await db.remove(ctx.params.id, ctx.params.rev) ctx.body = await db.remove(ctx.params.id, ctx.params.rev)

View File

@ -1,9 +1,11 @@
const { const {
createClientDatabase, createClientDatabase,
createApplication, createApplication,
createInstance,
destroyClientDatabase, destroyClientDatabase,
builderEndpointShouldBlockNormalUsers,
supertest, supertest,
defaultHeaders defaultHeaders,
} = require("./couchTestUtils") } = require("./couchTestUtils")
describe("/applications", () => { describe("/applications", () => {
@ -37,6 +39,18 @@ describe("/applications", () => {
expect(res.res.statusMessage).toEqual("Application My App created successfully") expect(res.res.statusMessage).toEqual("Application My App created successfully")
expect(res.body._id).toBeDefined() expect(res.body._id).toBeDefined()
}) })
it("should apply authorization to endpoint", async () => {
const otherApplication = await createApplication(request)
const instance = await createInstance(request, otherApplication._id)
await builderEndpointShouldBlockNormalUsers({
request,
method: "POST",
url: `/api/applications`,
instanceId: instance._id,
body: { name: "My App" }
})
})
}) })
describe("fetch", () => { describe("fetch", () => {
@ -53,6 +67,17 @@ describe("/applications", () => {
expect(res.body.length).toBe(2) expect(res.body.length).toBe(2)
}) })
it("should apply authorization to endpoint", async () => {
const otherApplication = await createApplication(request)
const instance = await createInstance(request, otherApplication._id)
await builderEndpointShouldBlockNormalUsers({
request,
method: "GET",
url: `/api/applications`,
instanceId: instance._id,
})
})
}) })
}) })

View File

@ -2,7 +2,10 @@ const CouchDB = require("../../../db")
const { create, destroy } = require("../../../db/clientDb") const { create, destroy } = require("../../../db/clientDb")
const supertest = require("supertest") const supertest = require("supertest")
const app = require("../../../app") const app = require("../../../app")
const { POWERUSER_LEVEL_ID } = require("../../../utilities/accessLevels") const {
POWERUSER_LEVEL_ID,
generateAdminPermissions,
} = require("../../../utilities/accessLevels")
const TEST_CLIENT_ID = "test-client-id" const TEST_CLIENT_ID = "test-client-id"
@ -82,8 +85,8 @@ exports.createInstance = async (request, appId) => {
exports.createUser = async ( exports.createUser = async (
request, request,
instanceId, instanceId,
username = "bill", username = "babs",
password = "bills_password" password = "babs_password"
) => { ) => {
const res = await request const res = await request
.post(`/api/${instanceId}/users`) .post(`/api/${instanceId}/users`)
@ -97,6 +100,150 @@ exports.createUser = async (
return res.body return res.body
} }
const createUserWithOnePermission = async (
request,
instanceId,
permName,
itemId
) => {
let permissions = await generateAdminPermissions(instanceId)
permissions = permissions.filter(
p => p.name === permName && p.itemId === itemId
)
return await createUserWithPermissions(
request,
instanceId,
permissions,
"onePermOnlyUser"
)
}
const createUserWithAdminPermissions = async (request, instanceId) => {
let permissions = await generateAdminPermissions(instanceId)
return await createUserWithPermissions(
request,
instanceId,
permissions,
"adminUser"
)
}
const createUserWithAllPermissionExceptOne = async (
request,
instanceId,
permName,
itemId
) => {
let permissions = await generateAdminPermissions(instanceId)
permissions = permissions.filter(
p => !(p.name === permName && p.itemId === itemId)
)
return await createUserWithPermissions(
request,
instanceId,
permissions,
"allPermsExceptOneUser"
)
}
const createUserWithPermissions = async (
request,
instanceId,
permissions,
username
) => {
const accessRes = await request
.post(`/api/${instanceId}/accesslevels`)
.send({ name: "TestLevel", permissions })
.set(exports.defaultHeaders)
const password = `password_${username}`
await request
.post(`/api/${instanceId}/users`)
.set(exports.defaultHeaders)
.send({
name: username,
username,
password,
accessLevelId: accessRes.body._id,
})
const db = new CouchDB(instanceId)
const designDoc = await db.get("_design/database")
const loginResult = await request
.post(`/api/authenticate`)
.set("Referer", `http://localhost:4001/${designDoc.metadata.applicationId}`)
.send({ username, password })
// returning necessary request headers
return {
Accept: "application/json",
Cookie: loginResult.headers["set-cookie"],
}
}
exports.testPermissionsForEndpoint = async ({
request,
method,
url,
body,
instanceId,
permissionName,
itemId,
}) => {
const headers = await createUserWithOnePermission(
request,
instanceId,
permissionName,
itemId
)
await createRequest(request, method, url, body)
.set(headers)
.expect(200)
const noPermsHeaders = await createUserWithAllPermissionExceptOne(
request,
instanceId,
permissionName,
itemId
)
await createRequest(request, method, url, body)
.set(noPermsHeaders)
.expect(403)
}
exports.builderEndpointShouldBlockNormalUsers = async ({
request,
method,
url,
body,
instanceId,
}) => {
const headers = await createUserWithAdminPermissions(request, instanceId)
await createRequest(request, method, url, body)
.set(headers)
.expect(403)
}
const createRequest = (request, method, url, body) => {
let req
if (method === "POST") req = request.post(url).send(body)
else if (method === "GET") req = request.get(url)
else if (method === "DELETE") req = request.delete(url)
else if (method === "PATCH") req = request.patch(url).send(body)
else if (method === "PUT") req = request.put(url).send(body)
return req
}
exports.insertDocument = async (databaseId, document) => { exports.insertDocument = async (databaseId, document) => {
const { id, ...documentFields } = document const { id, ...documentFields } = document
return await new CouchDB(databaseId).put({ _id: id, ...documentFields }) return await new CouchDB(databaseId).put({ _id: id, ...documentFields })

View File

@ -4,7 +4,8 @@ const {
supertest, supertest,
createClientDatabase, createClientDatabase,
createApplication , createApplication ,
defaultHeaders defaultHeaders,
builderEndpointShouldBlockNormalUsers
} = require("./couchTestUtils") } = require("./couchTestUtils")
describe("/models", () => { describe("/models", () => {
@ -48,6 +49,22 @@ describe("/models", () => {
done(); done();
}); });
}) })
it("should apply authorization to endpoint", async () => {
await builderEndpointShouldBlockNormalUsers({
request,
method: "POST",
url: `/api/${instance._id}/models`,
instanceId: instance._id,
body: {
name: "TestModel",
key: "name",
schema: {
name: { type: "string" }
}
}
})
})
}); });
describe("fetch", () => { describe("fetch", () => {
@ -71,6 +88,16 @@ describe("/models", () => {
done(); done();
}); });
}) })
it("should apply authorization to endpoint", async () => {
await builderEndpointShouldBlockNormalUsers({
request,
method: "GET",
url: `/api/${instance._id}/models`,
instanceId: instance._id,
})
})
}); });
describe("destroy", () => { describe("destroy", () => {
@ -92,5 +119,15 @@ describe("/models", () => {
done(); done();
}); });
}) })
it("should apply authorization to endpoint", async () => {
await builderEndpointShouldBlockNormalUsers({
request,
method: "DELETE",
url: `/api/${instance._id}/models/${testModel._id}/${testModel._rev}`,
instanceId: instance._id,
})
})
}); });
}); });

Some files were not shown because too many files have changed in this diff Show More