merge with master

This commit is contained in:
Martin McKeaveney 2020-09-17 16:40:09 +01:00
commit e609414f57
114 changed files with 8911 additions and 1630 deletions

View File

@ -4,8 +4,6 @@ on:
# Trigger the workflow on push with tags,
# but only for the master branch
push:
branches:
- master
tags:
- 'v*'

View File

@ -1,5 +1,5 @@
{
"version": "0.1.19",
"version": "0.1.21",
"npmClient": "yarn",
"packages": [
"packages/*"

View File

@ -21,12 +21,15 @@
"publishdev": "lerna run publishdev",
"publishnpm": "yarn build && lerna publish --force-publish",
"clean": "lerna clean",
"dev": "node ./scripts/symlinkDev.js && lerna run --parallel --stream dev:builder",
"dev": "node ./scripts/symlinkDev.js && lerna run --parallel dev:builder",
"test": "lerna run test",
"lint": "eslint packages",
"lint:fix": "eslint --fix packages",
"format": "prettier --write \"{,!(node_modules)/**/}*.{js,jsx,svelte}\"",
"test:e2e": "lerna run cy:test",
"test:e2e:ci": "lerna run cy:ci"
},
"dependencies": {
"@fortawesome/fontawesome": "^1.1.8"
}
}

View File

@ -5,4 +5,5 @@ package-lock.json
release/
dist/
cypress/screenshots
cypress/videos
routify

View File

@ -1,46 +1,52 @@
context('Create a workflow', () => {
context("Create a workflow", () => {
before(() => {
cy.server()
cy.visit('localhost:4001/_builder')
cy.visit("localhost:4001/_builder")
cy.createApp('Workflow Test App', 'This app is used to test that workflows do in fact work!')
cy.createApp(
"Workflow Test App",
"This app is used to test that workflows do in fact work!"
)
})
// https://on.cypress.io/interacting-with-elements
it('should create a workflow', () => {
it("should create a workflow", () => {
cy.createTestTableWithData()
cy.contains('workflow').click()
cy.contains('Create New Workflow').click()
cy.get('input').type('Add Record')
cy.contains('Save').click()
cy.contains("workflow").click()
cy.contains("Create New Workflow").click()
cy.get("input").type("Add Record")
cy.contains("Save").click()
// Add trigger
cy.get('[data-cy=add-workflow-component]').click()
cy.get('[data-cy=RECORD_SAVED]').click()
cy.get('.budibase__input').select('dog')
cy.get("[data-cy=add-workflow-component]").click()
cy.get("[data-cy=RECORD_SAVED]").click()
cy.get(".budibase__input").select("dog")
// Create action
cy.get('[data-cy=SAVE_RECORD]').click()
cy.get('.container input').first().type('goodboy')
cy.get('.container input').eq(1).type('11')
cy.get("[data-cy=SAVE_RECORD]").click()
cy.get(".budibase__input").select("dog")
cy.get(".container input")
.first()
.type("goodboy")
cy.get(".container input")
.eq(1)
.type("11")
// Save
cy.contains('Save Workflow').click()
cy.contains("Save Workflow").click()
// Activate Workflow
cy.get('[data-cy=activate-workflow]').click()
cy.get("[data-cy=activate-workflow]").click()
cy.contains("Add Record").should("be.visible")
cy.get(".stop-button.highlighted").should("be.visible")
})
it('should add record when a new record is added', () => {
cy.contains('backend').click()
it("should add record when a new record is added", () => {
cy.contains("backend").click()
cy.addRecord(["Rover", 15])
cy.reload()
cy.contains('goodboy').should('have.text', 'goodboy')
cy.contains("goodboy").should("have.text", "goodboy")
})
})

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "0.1.19",
"version": "0.1.21",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@ -63,16 +63,16 @@
}
},
"dependencies": {
"@budibase/bbui": "^1.32.0",
"@budibase/client": "^0.1.19",
"@budibase/bbui": "^1.33.0",
"@budibase/client": "^0.1.21",
"@budibase/colorpicker": "^1.0.1",
"@fortawesome/fontawesome-free": "^5.14.0",
"@sentry/browser": "5.19.1",
"@svelteschool/svelte-forms": "^0.7.0",
"britecharts": "^2.16.0",
"d3-selection": "^1.4.1",
"deepmerge": "^4.2.2",
"fast-sort": "^2.2.0",
"feather-icons": "^4.21.0",
"lodash": "^4.17.13",
"mustache": "^4.0.1",
"posthog-js": "1.3.1",

View File

@ -234,7 +234,7 @@ export default {
// Watch the `dist` directory and refresh the
// browser on changes when not in production
!production && livereload(outputpath),
!production && livereload({ watch: outputpath, delay: 500 }),
// If we're building for production (npm run build
// instead of npm run dev), minify

View File

@ -1,3 +1,4 @@
// Array.flat needs polyfilled in < Node 11
if (!Array.prototype.flat) {
Object.defineProperty(Array.prototype, "flat", {
configurable: true,

View File

@ -37,7 +37,9 @@ export default function({ componentInstanceId, screen, components, models }) {
.filter(isInstanceInSharedContext(walkResult))
.map(componentInstanceToBindable(walkResult)),
...walkResult.target._contexts.map(contextToBindables(walkResult)).flat(),
...walkResult.target._contexts
.map(contextToBindables(models, walkResult))
.flat(),
]
}
@ -69,17 +71,31 @@ const componentInstanceToBindable = walkResult => i => {
}
}
const contextToBindables = walkResult => context => {
const contextToBindables = (models, walkResult) => context => {
const contextParentPath = getParentPath(walkResult, context)
return Object.keys(context.model.schema).map(k => ({
const newBindable = key => ({
type: "context",
instance: context.instance,
// how the binding expression persists, and is used in the app at runtime
runtimeBinding: `${contextParentPath}data.${k}`,
runtimeBinding: `${contextParentPath}data.${key}`,
// how the binding exressions looks to the user of the builder
readableBinding: `${context.instance._instanceName}.${context.model.name}.${k}`,
}))
readableBinding: `${context.instance._instanceName}.${context.model.label}.${key}`,
})
// see ModelViewSelect.svelte for the format of context.model
// ... this allows us to bind to Model scheams, or View schemas
const model = models.find(m => m._id === context.model.modelId)
const schema = context.model.isModel
? model.schema
: model.views[context.model.name].schema
return (
Object.keys(schema)
.map(newBindable)
// add _id and _rev fields - not part of schema, but always valid
.concat([newBindable("_id"), newBindable("_rev")])
)
}
const getParentPath = (walkResult, context) => {
@ -135,7 +151,7 @@ const walk = ({ instance, targetId, components, models, result }) => {
if (contextualInstance) {
// add to currentContexts (ancestory of context)
// before walking children
const model = models.find(m => m._id === instance[component.context])
const model = instance[component.context]
result.currentContexts.push({ instance, model })
}

View File

@ -0,0 +1,42 @@
export const CAPTURE_VAR_INSIDE_MUSTACHE = /{{([^}]+)}}/g
export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
// Find all instances of mustasche
const boundValues = textWithBindings.match(CAPTURE_VAR_INSIDE_MUSTACHE)
let result = textWithBindings
// Replace readableBindings with runtimeBindings
boundValues &&
boundValues.forEach(boundValue => {
const binding = bindableProperties.find(({ readableBinding }) => {
return boundValue === `{{ ${readableBinding} }}`
})
if (binding) {
result = textWithBindings.replace(
boundValue,
`{{ ${binding.runtimeBinding} }}`
)
}
})
return result
}
export function runtimeToReadableBinding(bindableProperties, textWithBindings) {
let temp = textWithBindings
const boundValues =
(typeof textWithBindings === "string" &&
textWithBindings.match(CAPTURE_VAR_INSIDE_MUSTACHE)) ||
[]
// Replace runtimeBindings with readableBindings:
boundValues.forEach(v => {
const binding = bindableProperties.find(({ runtimeBinding }) => {
return v === `{{ ${runtimeBinding} }}`
})
if (binding) {
temp = temp.replace(v, `{{ ${binding.readableBinding} }}`)
}
})
return temp
}

View File

@ -1,5 +1,3 @@
import mustache from "mustache"
import blockDefinitions from "components/workflow/WorkflowPanel/blockDefinitions"
import { generate } from "shortid"
/**
@ -18,27 +16,31 @@ export default class Workflow {
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
const trigger = { id: generate(), ...block }
this.workflow.definition.trigger = trigger
return trigger
}
this.workflow.definition.steps.push({
id: generate(),
...block,
})
const newBlock = { id: generate(), ...block }
this.workflow.definition.steps = [
...this.workflow.definition.steps,
newBlock,
]
return newBlock
}
updateBlock(updatedBlock, id) {
const { steps, trigger } = this.workflow.definition
if (trigger && trigger.id === id) {
this.workflow.definition.trigger = null
this.workflow.definition.trigger = updatedBlock
return
}
const stepIdx = steps.findIndex(step => step.id === id)
if (stepIdx < 0) throw new Error("Block not found.")
steps.splice(stepIdx, 1, updatedBlock)
this.workflow.definition.steps = steps
}
deleteBlock(id) {
@ -52,44 +54,6 @@ export default class Workflow {
const stepIdx = steps.findIndex(step => step.id === id)
if (stepIdx < 0) throw new Error("Block not found.")
steps.splice(stepIdx, 1)
}
createUiTree() {
if (!this.workflow.definition) return []
return Workflow.buildUiTree(this.workflow.definition)
}
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,
}
})
this.workflow.definition.steps = steps
}
}

View File

@ -1,14 +1,22 @@
import { writable } from "svelte/store"
import api from "../../api"
import Workflow from "./Workflow"
import { cloneDeep } from "lodash/fp"
const workflowActions = store => ({
fetch: async () => {
const WORKFLOWS_URL = `/api/workflows`
const workflowResponse = await api.get(WORKFLOWS_URL)
const json = await workflowResponse.json()
const responses = await Promise.all([
api.get(`/api/workflows`),
api.get(`/api/workflows/definitions/list`),
])
const jsonResponses = await Promise.all(responses.map(x => x.json()))
store.update(state => {
state.workflows = json
state.workflows = jsonResponses[0]
state.blockDefinitions = {
TRIGGER: jsonResponses[1].trigger,
ACTION: jsonResponses[1].action,
LOGIC: jsonResponses[1].logic,
}
return state
})
},
@ -23,8 +31,8 @@ const workflowActions = store => ({
const response = await api.post(CREATE_WORKFLOW_URL, workflow)
const json = await response.json()
store.update(state => {
state.workflows = state.workflows.concat(json.workflow)
state.currentWorkflow = new Workflow(json.workflow)
state.workflows = [...state.workflows, json.workflow]
store.actions.select(json.workflow)
return state
})
},
@ -38,20 +46,7 @@ const workflowActions = store => ({
)
state.workflows.splice(existingIdx, 1, json.workflow)
state.workflows = state.workflows
state.currentWorkflow = new Workflow(json.workflow)
return state
})
},
update: async ({ workflow }) => {
const UPDATE_WORKFLOW_URL = `/api/workflows`
const response = await api.put(UPDATE_WORKFLOW_URL, workflow)
const json = await response.json()
store.update(state => {
const existingIdx = state.workflows.findIndex(
existing => existing._id === workflow._id
)
state.workflows.splice(existingIdx, 1, json.workflow)
state.workflows = state.workflows
store.actions.select(json.workflow)
return state
})
},
@ -66,28 +61,49 @@ const workflowActions = store => ({
)
state.workflows.splice(existingIdx, 1)
state.workflows = state.workflows
state.currentWorkflow = null
state.selectedWorkflow = null
state.selectedBlock = null
return state
})
},
trigger: async ({ workflow }) => {
const { _id } = workflow
const TRIGGER_WORKFLOW_URL = `/api/workflows/${_id}/trigger`
return await api.post(TRIGGER_WORKFLOW_URL)
},
select: workflow => {
store.update(state => {
state.currentWorkflow = new Workflow(workflow)
state.selectedWorkflowBlock = null
state.selectedWorkflow = new Workflow(cloneDeep(workflow))
state.selectedBlock = null
return state
})
},
addBlockToWorkflow: block => {
store.update(state => {
state.currentWorkflow.addBlock(block)
state.selectedWorkflowBlock = block
const newBlock = state.selectedWorkflow.addBlock(cloneDeep(block))
state.selectedBlock = newBlock
return state
})
},
deleteWorkflowBlock: block => {
store.update(state => {
state.currentWorkflow.deleteBlock(block.id)
state.selectedWorkflowBlock = null
const idx = state.selectedWorkflow.workflow.definition.steps.findIndex(
x => x.id === block.id
)
state.selectedWorkflow.deleteBlock(block.id)
// Select next closest step
const steps = state.selectedWorkflow.workflow.definition.steps
let nextSelectedBlock
if (steps[idx] != null) {
nextSelectedBlock = steps[idx]
} else if (steps[idx - 1] != null) {
nextSelectedBlock = steps[idx - 1]
} else {
nextSelectedBlock =
state.selectedWorkflow.workflow.definition.trigger || null
}
state.selectedBlock = nextSelectedBlock
return state
})
},
@ -96,11 +112,14 @@ const workflowActions = store => ({
export const getWorkflowStore = () => {
const INITIAL_WORKFLOW_STATE = {
workflows: [],
blockDefinitions: {
TRIGGER: [],
ACTION: [],
LOGIC: [],
},
selectedWorkflow: null,
}
const store = writable(INITIAL_WORKFLOW_STATE)
store.actions = workflowActions(store)
return store
}

View File

@ -1,44 +1,37 @@
import Workflow from "../Workflow";
import TEST_WORKFLOW from "./testWorkflow";
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",
id: "AUXJQGZY7",
name: "Delay",
icon: "ri-time-fill",
tagline: "Delay for <b>{{time}}</b> milliseconds",
description: "Delay the workflow until an amount of time has passed.",
params: { time: "number" },
type: "LOGIC",
args: { time: "5000" },
stepId: "DELAY",
}
describe("Workflow Data Object", () => {
let workflow
beforeEach(() => {
workflow = new Workflow({ ...TEST_WORKFLOW });
});
workflow = new Workflow({ ...TEST_WORKFLOW })
})
it("adds a workflow block to the workflow", () => {
workflow.addBlock(TEST_BLOCK);
workflow.addBlock(TEST_BLOCK)
expect(workflow.workflow.definition)
})
it("updates a workflow block with new attributes", () => {
const firstBlock = workflow.workflow.definition.steps[0];
const firstBlock = workflow.workflow.definition.steps[0]
const updatedBlock = {
...firstBlock,
name: "UPDATED"
};
workflow.updateBlock(updatedBlock, firstBlock.id);
name: "UPDATED",
}
workflow.updateBlock(updatedBlock, firstBlock.id)
expect(workflow.workflow.definition.steps[0]).toEqual(updatedBlock)
})
@ -46,12 +39,10 @@ describe("Workflow Data Object", () => {
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();
const lastBlock = steps[steps.length - 1]
workflow.deleteBlock(lastBlock.id)
expect(workflow.workflow.definition.steps.length).toBeLessThan(
originalLength
)
})
})

View File

@ -1,49 +0,0 @@
// 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

@ -1,63 +1,78 @@
export default {
_id: "53b6148c65d1429c987e046852d11611",
_rev: "4-02c6659734934895812fa7be0215ee59",
name: "Test Workflow",
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",
id: "ANBDINAPS",
description: "Send an email.",
tagline: "Send email to <b>{{to}}</b>",
icon: "ri-mail-open-fill",
name: "Send Email",
params: {
path: "string",
value: "longText",
to: "string",
from: "string",
subject: "longText",
text: "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,
text: "A user was created!",
subject: "New Budibase User",
from: "budimaster@budibase.com",
to: "test@test.com",
},
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",
stepId: "SEND_EMAIL",
},
],
trigger: {
id: "iRzYMOqND",
name: "Record Saved",
event: "record:save",
icon: "ri-save-line",
tagline: "Record is added to <b>{{model.name}}</b>",
description: "Fired when a record is saved to your database.",
params: { model: "model" },
type: "TRIGGER",
args: {
model: {
type: "model",
views: {},
name: "users",
schema: {
name: {
type: "string",
constraints: {
type: "string",
length: { maximum: 123 },
presence: { allowEmpty: false },
},
name: "name",
},
age: {
type: "number",
constraints: {
type: "number",
presence: { allowEmpty: false },
numericality: {
greaterThanOrEqualTo: "",
lessThanOrEqualTo: "",
},
},
name: "age",
},
},
_id: "c6b4e610cd984b588837bca27188a451",
_rev: "7-b8aa1ce0b53e88928bb88fc11bdc0aff",
},
},
stepId: "RECORD_SAVED",
},
},
type: "workflow",
live: true,
ok: true,
id: "b384f861f4754e1693835324a7fcca62",
rev: "1-aa1c2cbd868ef02e26f8fad531dd7e37",
live: false,
_id: "b384f861f4754e1693835324a7fcca62",
_rev: "108-4116829ec375e0481d0ecab9e83a2caf",
}

View File

@ -12,6 +12,5 @@
</script>
<div class="bb-margin-m">
<Label small forAttr={'datepicker-label'}>{label}</Label>
<DatePicker placeholder={label} on:change={onChange} {value} />
</div>

View File

@ -0,0 +1,12 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24">
<path fill="none" d="M0 0h24v24H0z" />
<path
fill="currentColor"
d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10
10zm0-11.414L9.172 7.757 7.757 9.172 10.586 12l-2.829 2.828 1.415 1.415L12
13.414l2.828 2.829 1.415-1.415L13.414 12l2.829-2.828-1.415-1.415L12 10.586z" />
</svg>

After

Width:  |  Height:  |  Size: 412 B

View File

@ -32,3 +32,4 @@ export { default as TwitterIcon } from "./Twitter.svelte"
export { default as InfoIcon } from "./Info.svelte"
export { default as CloseIcon } from "./Close.svelte"
export { default as MoreIcon } from "./More.svelte"
export { default as CloseCircleIcon } from "./CloseCircle.svelte"

View File

@ -1,4 +0,0 @@
import feather from "feather-icons"
const getIcon = (icon, size) =>
feather.icons[icon].toSvg({ height: size || "16", width: size || "16" })
export default getIcon

View File

@ -37,14 +37,14 @@
}
}
$: paginatedData = data
? data.slice(
$: sort = $backendUiStore.sort
$: sorted = sort ? fsort(data)[sort.direction](sort.column) : data
$: paginatedData = sorted
? sorted.slice(
currentPage * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE + ITEMS_PER_PAGE
)
: []
$: sort = $backendUiStore.sort
$: sorted = sort ? fsort(data)[sort.direction](sort.column) : data
$: headers = Object.keys($backendUiStore.selectedModel.schema)
.sort()
@ -79,10 +79,10 @@
</tr>
</thead>
<tbody>
{#if sorted.length === 0}
{#if paginatedData.length === 0}
<div class="no-data">No Data.</div>
{/if}
{#each sorted as row}
{#each paginatedData as row}
<tr>
<td>
<EditRowPopover {row} />
@ -103,7 +103,7 @@
<TablePagination
{data}
bind:currentPage
pageItemCount={data.length}
pageItemCount={paginatedData.length}
{ITEMS_PER_PAGE} />
</section>

View File

@ -26,15 +26,15 @@
$: columns = schema ? Object.keys(schema) : []
$: sort = $backendUiStore.sort
$: sorted = sort ? fsort(data)[sort.direction](sort.column) : data
$: paginatedData =
data && data.length
? data.slice(
sorted && sorted.length
? sorted.slice(
currentPage * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE + ITEMS_PER_PAGE
)
: []
$: sort = $backendUiStore.sort
$: sorted = sort ? fsort(data)[sort.direction](sort.column) : data
</script>
<section>
@ -72,7 +72,7 @@
<TablePagination
{data}
bind:currentPage
pageItemCount={data.length}
pageItemCount={paginatedData.length}
{ITEMS_PER_PAGE} />
</section>

View File

@ -71,7 +71,16 @@
}
function addFilter() {
view.filters = [...view.filters, {}]
view.filters.push({})
view.filters = view.filters
}
function isMultipleChoice(field) {
return (
viewModel.schema[field].constraints &&
viewModel.schema[field].constraints.inclusion &&
viewModel.schema[field].constraints.inclusion.length
)
}
</script>
@ -108,10 +117,18 @@
<option value={condition.key}>{condition.name}</option>
{/each}
</Select>
{#if filter.key && isMultipleChoice(filter.key)}
<Select secondary thin bind:value={filter.value}>
{#each viewModel.schema[filter.key].constraints.inclusion as option}
<option value={option}>{option}</option>
{/each}
</Select>
{:else}
<Input
thin
placeholder={filter.key || fields[0]}
bind:value={filter.value} />
{/if}
<i class="ri-close-circle-fill" on:click={() => removeFilter(idx)} />
{/each}
</div>

View File

@ -9,7 +9,6 @@
CircleIndicator,
EventsIcon,
} from "components/common/Icons/"
import EventsEditor from "./EventsEditor"
import panelStructure from "./temporaryPanelStructure.js"
import CategoryTab from "./CategoryTab.svelte"
import DesignView from "./DesignView.svelte"
@ -21,7 +20,6 @@
let categories = [
{ value: "settings", name: "Settings" },
{ value: "design", name: "Design" },
{ value: "events", name: "Events" },
]
let selectedCategory = categories[0]
@ -37,8 +35,6 @@
c => c._component === componentInstance._component
) || {}
let panelDefinition = {}
$: panelDefinition =
componentPropDefinition.properties &&
componentPropDefinition.properties[selectedCategory.value]
@ -109,8 +105,6 @@
displayNameField={displayName}
onChange={onPropChanged}
screenOrPageInstance={$store.currentView !== 'component' && $store.currentPreviewItem} />
{:else if selectedCategory.value === 'events'}
<EventsEditor component={componentInstance} />
{/if}
</div>

View File

@ -1,168 +1,220 @@
<script>
import { store } from "builderStore"
import { Button, Select } from "@budibase/bbui"
import HandlerSelector from "./HandlerSelector.svelte"
import ActionButton from "../../common/ActionButton.svelte"
import getIcon from "../../common/icon"
import { CloseIcon } from "components/common/Icons/"
import { TextButton, Button, Heading, DropdownMenu } from "@budibase/bbui"
import { AddIcon, ArrowDownIcon } from "components/common/Icons/"
import { EVENT_TYPE_MEMBER_NAME } from "../../common/eventHandlers"
import actionTypes from "./actions"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let event
export let eventOptions = []
export let onClose
let eventType = ""
let addActionButton
let addActionDropdown
let selectedAction
let draftEventHandler = { parameters: [] }
$: eventData = event || { handlers: [] }
$: if (!eventOptions.includes(eventType) && eventOptions.length > 0)
eventType = eventOptions[0].name
$: actions = event || []
$: selectedActionComponent =
selectedAction &&
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_MEMBER_NAME])
.component
const closeModal = () => {
onClose()
dispatch("close")
draftEventHandler = { parameters: [] }
eventData = { handlers: [] }
actions = []
}
const updateEventHandler = (updatedHandler, index) => {
eventData.handlers[index] = updatedHandler
actions[index] = updatedHandler
}
const updateDraftEventHandler = updatedHandler => {
draftEventHandler = updatedHandler
const deleteAction = index => {
actions.splice(index, 1)
actions = actions
}
const deleteEventHandler = index => {
eventData.handlers.splice(index, 1)
eventData = eventData
}
const createNewEventHandler = handler => {
const newHandler = handler || {
const addAction = actionType => () => {
const newAction = {
parameters: {},
[EVENT_TYPE_MEMBER_NAME]: "",
[EVENT_TYPE_MEMBER_NAME]: actionType.name,
}
eventData.handlers.push(newHandler)
eventData = eventData
actions.push(newAction)
selectedAction = newAction
actions = actions
addActionDropdown.hide()
}
const deleteEvent = () => {
store.setComponentProp(eventType, [])
closeModal()
const selectAction = action => () => {
selectedAction = action
}
const saveEventData = () => {
store.setComponentProp(eventType, eventData.handlers)
dispatch("change", actions)
closeModal()
}
</script>
<div class="container">
<div class="body">
<div class="heading">
<h3>
{eventData.name ? `${eventData.name} Event` : 'Create a New Component Event'}
</h3>
<div class="root">
<div class="header">
<Heading small dark>Actions</Heading>
<div bind:this={addActionButton}>
<TextButton text small blue on:click={addActionDropdown.show}>
Add Action
<div style="height: 20px; width: 20px;">
<AddIcon />
</div>
</TextButton>
</div>
<DropdownMenu
bind:this={addActionDropdown}
anchor={addActionButton}
align="right">
<div class="available-actions-container">
{#each actionTypes as actionType}
<div class="available-action" on:click={addAction(actionType)}>
<span>{actionType.name}</span>
</div>
<div class="event-options">
<div class="section">
<h4>Event Type</h4>
<Select bind:value={eventType}>
{#each eventOptions as option}
<option value={option.name}>{option.name}</option>
{/each}
</Select>
</div>
</DropdownMenu>
</div>
<div class="section">
<h4>Event Action(s)</h4>
<HandlerSelector
newHandler
onChanged={updateDraftEventHandler}
onCreate={() => {
createNewEventHandler(draftEventHandler)
draftEventHandler = { parameters: [] }
}}
handler={draftEventHandler} />
<div class="actions-container">
{#if actions && actions.length > 0}
{#each actions as action, index}
<div class="action-container">
<div class="action-header" on:click={selectAction(action)}>
<p
class="bb-body bb-body--small bb-body--color-dark"
style="margin: var(--spacing-s) 0;">
{index + 1}. {action[EVENT_TYPE_MEMBER_NAME]}
</p>
<div class="row-expander" class:rotate={action !== selectedAction}>
<ArrowDownIcon />
</div>
{#if eventData}
{#each eventData.handlers as handler, index}
<HandlerSelector
{index}
onChanged={updateEventHandler}
onRemoved={() => deleteEventHandler(index)}
{handler} />
{/each}
{/if}
</div>
<div class="footer">
{#if eventData.name}
<Button
outline
on:click={deleteEvent}
disabled={eventData.handlers.length === 0}>
{#if action === selectedAction}
<div class="selected-action-container">
<svelte:component
this={selectedActionComponent}
parameters={selectedAction.parameters} />
<div class="delete-action-button">
<TextButton text medium on:click={() => deleteAction(index)}>
Delete
</Button>
</TextButton>
</div>
</div>
{/if}
<div class="save">
<Button
primary
on:click={saveEventData}
disabled={eventData.handlers.length === 0}>
Save
</Button>
</div>
{/each}
{/if}
</div>
<div class="close-button" on:click={closeModal}>
<CloseIcon />
<div class="footer">
<a href="https://docs.budibase.com">Learn more about Actions</a>
<Button secondary on:click={closeModal}>Cancel</Button>
<Button primary on:click={saveEventData}>Save</Button>
</div>
</div>
<style>
.container {
position: relative;
}
.heading {
margin-bottom: 20px;
.root {
max-height: 50vh;
width: 700px;
display: flex;
flex-direction: column;
}
.close-button {
.header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: var(--spacing-xl);
padding-bottom: 0;
}
.action-header {
display: flex;
flex-direction: row;
align-items: center;
}
.action-header > p {
flex: 1;
}
.row-expander {
height: 30px;
width: 30px;
}
.available-action {
padding: var(--spacing-s);
font-size: var(--font-size-m);
cursor: pointer;
position: absolute;
top: 20px;
right: 20px;
}
.close-button :global(svg) {
width: 24px;
height: 24px;
}
h4 {
margin-bottom: 10px;
.available-action:hover {
background: var(--grey-2);
}
h3 {
margin: 0;
font-size: 24px;
font-weight: bold;
.actions-container {
flex: 1;
min-height: 0px;
padding-bottom: var(--spacing-s);
padding-top: 0;
border: var(--border-light);
border-width: 0 0 1px 0;
overflow-y: auto;
}
.body {
padding: 40px;
display: grid;
grid-gap: 20px;
.action-container {
border: var(--border-light);
border-width: 1px 0 0 0;
padding-left: var(--spacing-xl);
padding-right: var(--spacing-xl);
padding-top: 0;
padding-bottom: 0;
}
.footer {
.selected-action-container {
padding-bottom: var(--spacing-s);
padding-top: var(--spacing-s);
}
.delete-action-button {
padding-top: var(--spacing-l);
display: flex;
justify-content: flex-end;
padding: 30px 40px;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 50px;
background-color: var(--grey-1);
flex-direction: row;
}
.save {
margin-left: 20px;
.footer {
display: flex;
flex-direction: row;
gap: var(--spacing-s);
padding: var(--spacing-xl);
padding-top: var(--spacing-m);
}
.footer > a {
flex: 1;
color: var(--grey-5);
font-size: var(--font-size-s);
text-decoration: none;
}
.footer > a:hover {
color: var(--blue);
}
.rotate :global(svg) {
transform: rotate(90deg);
}
</style>

View File

@ -1,25 +1,24 @@
<script>
import { Button, DropdownMenu } from "@budibase/bbui"
import { Button, Modal } from "@budibase/bbui"
import EventEditorModal from "./EventEditorModal.svelte"
import { getContext } from "svelte"
import { createEventDispatcher, onMount } from "svelte"
const dispatch = createEventDispatcher()
export let value
export let name
let button
let dropdown
let eventsModal
</script>
<div bind:this={button}>
<Button secondary small on:click={dropdown.show}>Define Actions</Button>
</div>
<DropdownMenu bind:this={dropdown} align="right" anchor={button}>
<Button secondary small on:click={eventsModal.show}>Define Actions</Button>
<Modal bind:this={eventsModal} maxWidth="100vw" hideCloseButton padding="0">
<EventEditorModal
event={value}
eventType={name}
on:change
on:close={dropdown.hide} />
</DropdownMenu>
on:close={eventsModal.hide} />
</Modal>
<style>

View File

@ -1,5 +1,5 @@
<script>
import { Input, Select } from "@budibase/bbui"
import { Input, DataList, Select } from "@budibase/bbui"
import { find, map, keys, reduce, keyBy } from "lodash/fp"
import { pipe } from "components/common/core"
import { EVENT_TYPE_MEMBER_NAME } from "components/common/eventHandlers"
@ -29,12 +29,12 @@
{/each}
</Select>
{:else if parameter.name === 'url'}
<Select editable on:change bind:value={parameter.value}>
<DataList on:change bind:value={parameter.value}>
<option value="" />
{#each $store.allScreens as screen}
<option value={screen.route}>{screen.props._instanceName}</option>
{/each}
</Select>
</DataList>
{:else}
<Input
name={parameter.name}

View File

@ -0,0 +1,75 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { store, backendUiStore } from "builderStore"
import fetchBindableProperties from "builderStore/fetchBindableProperties"
import SaveFields from "./SaveFields.svelte"
export let parameters
$: bindableProperties = fetchBindableProperties({
componentInstanceId: $store.currentComponentInfo._id,
components: $store.components,
screen: $store.currentPreviewItem,
models: $backendUiStore.models,
})
// just wraps binding in {{ ... }}
const toBindingExpression = bindingPath => `{{ ${bindingPath} }}`
const modelFields = modelId => {
const model = $backendUiStore.models.find(m => m._id === modelId)
return Object.keys(model.schema).map(k => ({
name: k,
type: model.schema[k].type,
}))
}
$: schemaFields =
parameters && parameters.modelId ? modelFields(parameters.modelId) : []
const onFieldsChanged = e => {
parameters.fields = e.detail
}
</script>
<div class="root">
<Label size="m" color="dark">Table</Label>
<Select secondary bind:value={parameters.modelId}>
<option value="" />
{#each $backendUiStore.models as model}
<option value={model._id}>{model.name}</option>
{/each}
</Select>
{#if parameters.modelId}
<SaveFields
parameterFields={parameters.fields}
{schemaFields}
on:fieldschanged={onFieldsChanged} />
{/if}
</div>
<style>
.root {
display: grid;
column-gap: var(--spacing-s);
row-gap: var(--spacing-s);
grid-template-columns: auto 1fr auto 1fr auto;
align-items: baseline;
}
.root :global(.relative:nth-child(2)) {
grid-column-start: 2;
grid-column-end: 6;
}
.cannot-use {
color: var(--red);
font-size: var(--font-size-s);
text-align: center;
width: 70%;
margin: auto;
}
</style>

View File

@ -0,0 +1,29 @@
<script>
import { DataList, Label } from "@budibase/bbui"
import { store } from "builderStore"
export let parameters
</script>
<div class="root">
<Label size="m" color="dark">Screen</Label>
<DataList secondary bind:value={parameters.url}>
<option value="" />
{#each $store.screens as screen}
<option value={screen.route}>{screen.props._instanceName}</option>
{/each}
</DataList>
</div>
<style>
.root {
display: flex;
flex-direction: row;
align-items: baseline;
}
.root :global(.relative) {
flex: 1;
margin-left: var(--spacing-l);
}
</style>

View File

@ -0,0 +1,115 @@
<script>
// accepts an array of field names, and outputs an object of { FieldName: value }
import { DataList, Label, TextButton, Spacer, Select } from "@budibase/bbui"
import { store, backendUiStore } from "builderStore"
import fetchBindableProperties from "builderStore/fetchBindableProperties"
import { CloseCircleIcon, AddIcon } from "components/common/Icons"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/replaceBindings"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let parameterFields
export let schemaFields
const emptyField = () => ({ name: "", value: "" })
// this statement initialises fields from parameters.fields
$: fields =
fields ||
Object.keys(parameterFields || { "": "" }).map(name => ({
name,
value:
(parameterFields &&
runtimeToReadableBinding(
bindableProperties,
parameterFields[name].value
)) ||
"",
}))
$: bindableProperties = fetchBindableProperties({
componentInstanceId: $store.currentComponentInfo._id,
components: $store.components,
screen: $store.currentPreviewItem,
models: $backendUiStore.models,
})
const addField = () => {
const newFields = fields.filter(f => f.name)
newFields.push(emptyField())
fields = newFields
rebuildParameters()
}
const removeField = field => () => {
fields = fields.filter(f => f !== field)
rebuildParameters()
}
const rebuildParameters = () => {
// rebuilds paramters.fields every time a field name or value is added
// as UI below is bound to "fields" array, but we need to output a { key: value }
const newParameterFields = {}
for (let field of fields) {
if (field.name) {
// value and type is needed by the client, so it can parse
// a string into a correct type
newParameterFields[field.name] = {
type: schemaFields.find(f => f.name === field.name).type,
value: readableToRuntimeBinding(bindableProperties, field.value),
}
}
}
dispatch("fieldschanged", newParameterFields)
}
// just wraps binding in {{ ... }}
const toBindingExpression = bindingPath => `{{ ${bindingPath} }}`
</script>
{#if fields}
{#each fields as field}
<Label size="m" color="dark">Field</Label>
<Select secondary bind:value={field.name} on:blur={rebuildParameters}>
<option value="" />
{#each schemaFields as schemaField}
<option value={schemaField.name}>{schemaField.name}</option>
{/each}
</Select>
<Label size="m" color="dark">Value</Label>
<DataList secondary bind:value={field.value} on:blur={rebuildParameters}>
<option value="" />
{#each bindableProperties as bindableProp}
<option value={toBindingExpression(bindableProp.readableBinding)}>
{bindableProp.readableBinding}
</option>
{/each}
</DataList>
<div class="remove-field-container">
<TextButton text small on:click={removeField(field)}>
<CloseCircleIcon />
</TextButton>
</div>
{/each}
<div>
<Spacer small />
<TextButton text small blue on:click={addField}>
Add Field
<div style="height: 20px; width: 20px;">
<AddIcon />
</div>
</TextButton>
</div>
{/if}
<style>
.remove-field-container :global(button) {
vertical-align: bottom;
}
</style>

View File

@ -0,0 +1,134 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { store, backendUiStore } from "builderStore"
import fetchBindableProperties from "builderStore/fetchBindableProperties"
import SaveFields from "./SaveFields.svelte"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/replaceBindings"
export let parameters
$: bindableProperties = fetchBindableProperties({
componentInstanceId: $store.currentComponentInfo._id,
components: $store.components,
screen: $store.currentPreviewItem,
models: $backendUiStore.models,
})
let idFields
let recordId
$: {
idFields = bindableProperties.filter(
bindable =>
bindable.type === "context" && bindable.runtimeBinding.endsWith("._id")
)
// ensure recordId is always defaulted - there is usually only one option
if (idFields.length > 0 && !parameters._id) {
recordId = idFields[0].runtimeBinding
parameters = parameters
} else if (!recordId && parameters._id) {
recordId = parameters._id
.replace("{{", "")
.replace("}}", "")
.trim()
}
}
$: parameters._id = `{{ ${recordId} }}`
// just wraps binding in {{ ... }}
const toBindingExpression = bindingPath => `{{ ${bindingPath} }}`
// finds the selected idBinding, then reads the table/view
// from the component instance that it belongs to.
// then returns the field names for that schema
const schemaFromIdBinding = recordId => {
if (!recordId) return []
const idBinding = bindableProperties.find(
prop => prop.runtimeBinding === recordId
)
if (!idBinding) return []
const { instance } = idBinding
const component = $store.components[instance._component]
// component.context is the name of the prop that holds the modelId
const modelInfo = instance[component.context]
if (!modelInfo) return []
const model = $backendUiStore.models.find(m => m._id === modelInfo.modelId)
parameters.modelId = modelInfo.modelId
return Object.keys(model.schema).map(k => ({
name: k,
type: model.schema[k].type,
}))
}
let schemaFields
$: {
if (parameters && recordId) {
schemaFields = schemaFromIdBinding(recordId)
} else {
schemaFields = []
}
}
const onFieldsChanged = e => {
parameters.fields = e.detail
}
</script>
<div class="root">
{#if idFields.length === 0}
<div class="cannot-use">
Update record can only be used within a component that provides data, such
as a List
</div>
{:else}
<Label size="m" color="dark">Record Id</Label>
<Select secondary bind:value={recordId}>
<option value="" />
{#each idFields as idField}
<option value={idField.runtimeBinding}>
{idField.readableBinding}
</option>
{/each}
</Select>
{/if}
{#if recordId}
<SaveFields
parameterFields={parameters.fields}
{schemaFields}
on:fieldschanged={onFieldsChanged} />
{/if}
</div>
<style>
.root {
display: grid;
column-gap: var(--spacing-s);
row-gap: var(--spacing-s);
grid-template-columns: auto 1fr auto 1fr auto;
align-items: baseline;
}
.root :global(.relative:nth-child(2)) {
grid-column-start: 2;
grid-column-end: 6;
}
.cannot-use {
color: var(--red);
font-size: var(--font-size-s);
text-align: center;
width: 70%;
margin: auto;
}
</style>

View File

@ -0,0 +1,23 @@
import NavigateTo from "./NavigateTo.svelte"
import UpdateRecord from "./UpdateRecord.svelte"
import CreateRecord from "./CreateRecord.svelte"
// defines what actions are available, when adding a new one
// the component is the setup panel for the action
// NOTE that the "name" is used by the client library,
// so if you want to change it, you must change it client lib too
export default [
{
name: "Create Record",
component: CreateRecord,
},
{
name: "Navigate To",
component: NavigateTo,
},
{
name: "Update Record",
component: UpdateRecord,
},
]

View File

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

View File

@ -0,0 +1,293 @@
<script>
import { DropdownMenu, Button, Input } from "@budibase/bbui"
import { createEventDispatcher, tick } from "svelte"
import icons from "./icons.js"
const dispatch = createEventDispatcher()
export let value = ""
export let maxIconsPerPage = 30
let searchTerm = ""
let selectedLetter = "A"
let currentPage = 1
let filteredIcons = findIconByTerm(selectedLetter)
$: dispatch("change", value)
const alphabet = [
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
]
let buttonAnchor, dropdown
let loading = false
function findIconByTerm(term) {
const r = new RegExp(`\^${term}`, "i")
return icons.filter(i => r.test(i.label))
}
async function switchLetter(letter) {
currentPage = 1
searchTerm = ""
loading = true
selectedLetter = letter
filteredIcons = findIconByTerm(letter)
await tick() //svg icons do not update without tick
loading = false
}
async function findIconOnPage() {
loading = true
const iconIdx = filteredIcons.findIndex(i => i.value === value)
if (iconIdx !== -1) {
currentPage = Math.ceil(iconIdx / maxIconsPerPage)
}
await tick() //svg icons do not update without tick
loading = false
}
async function setSelectedUI() {
if (value) {
const letter = displayValue.substring(0, 1)
await switchLetter(letter)
await findIconOnPage()
}
}
async function pageClick(next) {
loading = true
if (next && currentPage < totalPages) {
currentPage++
} else if (!next && currentPage > 1) {
currentPage--
}
await tick() //svg icons do not update without tick
loading = false
}
async function searchForIcon(e) {
currentPage = 1
loading = true
filteredIcons = findIconByTerm(searchTerm)
await tick() //svg icons do not update without tick
loading = false
}
$: displayValue = value ? value.substring(7) : "Pick Icon"
$: totalPages = Math.ceil(filteredIcons.length / maxIconsPerPage)
$: pageEndIdx = maxIconsPerPage * currentPage
$: pagedIcons = filteredIcons.slice(pageEndIdx - maxIconsPerPage, pageEndIdx)
$: pagerText = `Page ${currentPage} of ${totalPages}`
</script>
<div bind:this={buttonAnchor}>
<Button secondary on:click={dropdown.show}>{displayValue}</Button>
</div>
<DropdownMenu
bind:this={dropdown}
on:open={setSelectedUI}
anchor={buttonAnchor}>
<div class="container">
<div class="search-area">
<div class="alphabet-area">
{#each alphabet as letter, idx}
<span
class="letter"
class:letter-selected={letter === selectedLetter}
on:click={() => switchLetter(letter)}>
{letter}
</span>
{#if idx !== alphabet.length - 1}
<span>-</span>
{/if}
{/each}
</div>
<div class="search-input">
<div class="input-wrapper">
<Input bind:value={searchTerm} thin placeholder="Search Icon" />
</div>
<Button secondary on:click={searchForIcon}>Search</Button>
</div>
<div class="page-area">
<div class="pager">
<span on:click={() => pageClick(false)}>
<i class="page-btn fas fa-chevron-left" />
</span>
<span>{pagerText}</span>
<span on:click={() => pageClick(true)}>
<i class="page-btn fas fa-chevron-right" />
</span>
</div>
</div>
</div>
{#if pagedIcons.length > 0}
<div class="icon-area">
{#if !loading}
{#each pagedIcons as icon}
<div
class="icon-container"
class:selected={value === icon.value}
on:click={() => (value = icon.value)}>
<div class="icon-preview">
<i class={`${icon.value} fa-3x`} />
</div>
<div class="icon-label">{icon.label}</div>
</div>
{/each}
{/if}
</div>
{:else}
<div class="no-icons">
<h5>
{`There is no icons for this ${searchTerm ? 'search' : 'page'}`}
</h5>
</div>
{/if}
</div>
</DropdownMenu>
<style>
.container {
width: 610px;
height: 350px;
display: flex;
flex-direction: column;
padding: 10px 0px 10px 15px;
overflow-x: hidden;
}
.search-area {
flex: 0 0 80px;
display: flex;
flex-direction: column;
}
.icon-area {
flex: 1;
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-gap: 5px;
justify-content: flex-start;
overflow-y: auto;
overflow-x: hidden;
padding-right: 10px;
}
.no-icons {
display: flex;
justify-content: center;
align-items: center;
}
.alphabet-area {
display: flex;
flex-flow: row wrap;
padding-bottom: 10px;
padding-right: 15px;
justify-content: space-around;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
}
.search-input {
display: flex;
flex-flow: row nowrap;
width: 100%;
padding-right: 15px;
}
.input-wrapper {
width: 510px;
margin-right: 5px;
}
.page-area {
padding: 10px;
display: flex;
justify-content: center;
}
.letter {
color: var(--blue);
}
.letter:hover {
cursor: pointer;
text-decoration: underline;
}
.letter-selected {
text-decoration: underline;
}
.icon-container {
height: 100px;
display: flex;
justify-content: center;
flex-direction: column;
border: var(--border-dark);
}
.icon-container:hover {
cursor: pointer;
background: var(--grey-2);
}
.selected {
background: var(--grey-3);
}
.icon-preview {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
.icon-label {
flex: 0 0 20px;
text-align: center;
font-size: 12px;
}
.page-btn {
color: var(--blue);
}
.page-btn:hover {
cursor: pointer;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
import "@fortawesome/fontawesome-free/js/all.js"
export { default as IconSelect } from "./IconSelect.svelte"

View File

@ -3,6 +3,11 @@
import Input from "./PropertyPanelControls/Input.svelte"
import { store, backendUiStore } from "builderStore"
import fetchBindableProperties from "builderStore/fetchBindableProperties"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
CAPTURE_VAR_INSIDE_MUSTACHE,
} from "builderStore/replaceBindings"
import { DropdownMenu } from "@budibase/bbui"
import BindingDropdown from "components/userInterface/BindingDropdown.svelte"
import { onMount, getContext } from "svelte"
@ -36,25 +41,12 @@
})
}
const CAPTURE_VAR_INSIDE_MUSTACHE = /{{([^}]+)}}/g
function replaceBindings(textWithBindings) {
getBindableProperties()
// Find all instances of mustasche
const boundValues = textWithBindings.match(CAPTURE_VAR_INSIDE_MUSTACHE)
// Replace with names:
boundValues &&
boundValues.forEach(boundValue => {
const binding = bindableProperties.find(({ readableBinding }) => {
return boundValue === `{{ ${readableBinding} }}`
})
if (binding) {
textWithBindings = textWithBindings.replace(
boundValue,
`{{ ${binding.runtimeBinding} }}`
textWithBindings = readableToRuntimeBinding(
bindableProperties,
textWithBindings
)
}
})
onChange(key, textWithBindings)
}
@ -76,22 +68,10 @@
const safeValue = () => {
getBindableProperties()
let temp = value
const boundValues =
(typeof value === "string" && value.match(CAPTURE_VAR_INSIDE_MUSTACHE)) ||
[]
// Replace with names:
boundValues.forEach(v => {
const binding = bindableProperties.find(({ runtimeBinding }) => {
return v === `{{ ${runtimeBinding} }}`
})
if (binding) {
temp = temp.replace(v, `{{ ${binding.readableBinding} }}`)
}
})
// console.log(temp)
return value === undefined && props.defaultValue !== undefined
let temp = runtimeToReadableBinding(bindableProperties, value)
return !value && props.defaultValue !== undefined
? props.defaultValue
: temp
}
@ -113,7 +93,7 @@
{...props}
name={key} />
</div>
{#if control == Input}
{#if control === Input && !key.startsWith('_')}
<button data-cy={`${key}-binding-button`} on:click={dropdown.show}>
<Icon name="edit" />
</button>

View File

@ -1,5 +1,5 @@
<script>
import { Select } from "@budibase/bbui"
import { DataList } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { store } from "builderStore"
@ -10,9 +10,9 @@
const handleBlur = () => dispatch("change", value)
</script>
<Select editable secondary on:blur={handleBlur} on:change bind:value>
<DataList editable secondary on:blur={handleBlur} on:change bind:value>
<option value="" />
{#each $store.allScreens as screen}
<option value={screen.route}>{screen.props._instanceName}</option>
{/each}
</Select>
</DataList>

View File

@ -116,7 +116,7 @@
control={definition.control}
label={definition.label}
key={definition.key}
value={componentInstance[definition.key]}
value={componentInstance[definition.key] || componentInstance[definition.key].defaultValue}
{componentInstance}
{onChange}
props={{ ...excludeProps(definition, ['control', 'label']) }} />

View File

@ -6,6 +6,8 @@ import ModelViewSelect from "components/userInterface/ModelViewSelect.svelte"
import ModelViewFieldSelect from "components/userInterface/ModelViewFieldSelect.svelte"
import Event from "components/userInterface/EventsEditor/EventPropertyControl.svelte"
import ScreenSelect from "components/userInterface/ScreenSelect.svelte"
import { IconSelect } from "components/userInterface/IconSelect"
import Colorpicker from "@budibase/colorpicker"
import { all } from "./propertyCategories.js"
/*
@ -220,16 +222,41 @@ export default {
settings: [{ label: "URL", key: "url", control: Input }],
},
},
// {
// _component: "@budibase/standard-components/icon",
// name: "Icon",
// description: "A basic component for displaying icons",
// icon: "ri-sun-fill",
// children: [],
// properties: {
// design: { ...all },
// },
// },
{
_component: "@budibase/standard-components/icon",
name: "Icon",
description: "A basic component for displaying icons",
icon: "ri-sun-fill",
children: [],
properties: {
design: {},
settings: [
{ label: "Icon", key: "icon", control: IconSelect },
{
label: "Size",
key: "size",
control: OptionSelect,
defaultValue: "fa-lg",
options: [
{ value: "fa-xs", label: "xs" },
{ value: "fa-sm", label: "sm" },
{ value: "fa-lg", label: "lg" },
{ value: "fa-2x", label: "2x" },
{ value: "fa-3x", label: "3x" },
{ value: "fa-5x", label: "5x" },
{ value: "fa-7x", label: "7x" },
{ value: "fa-10x", label: "10x" },
],
},
{
label: "Color",
key: "color",
control: Colorpicker,
defaultValue: "#000",
},
],
},
},
{
_component: "@budibase/standard-components/link",
name: "Link",
@ -515,10 +542,30 @@ export default {
key: "datasource",
control: ModelViewSelect,
},
{ label: "Stripe Color", key: "stripeColor", control: Input },
{ label: "Border Color", key: "borderColor", control: Input },
{ label: "TH Color", key: "backgroundColor", control: Input },
{ label: "TH Font Color", key: "color", control: Input },
{
label: "Stripe Color",
key: "stripeColor",
control: Colorpicker,
defaultValue: "#FFFFFF",
},
{
label: "Border Color",
key: "borderColor",
control: Colorpicker,
defaultValue: "#FFFFFF",
},
{
label: "TH Color",
key: "backgroundColor",
control: Colorpicker,
defaultValue: "#FFFFFF",
},
{
label: "TH Font Color",
key: "color",
control: Colorpicker,
defaultValue: "#FFFFFF",
},
{ label: "Table", key: "model", control: ModelSelect },
],
},

View File

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

View File

@ -1,42 +0,0 @@
<script>
import { store } from "builderStore"
import deepmerge from "deepmerge"
import { Label } from "@budibase/bbui"
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="bb-margin-xl block-field">
<Label small forAttr={'page'}>Page</Label>
<select class="budibase__input" bind:value={pageName}>
{#each Object.keys(pages) as page}
<option value={page}>{page}</option>
{/each}
</select>
{#if components.length > 0}
<Label small forAttr={'component'}>Component</Label>
<select class="budibase__input" bind:value>
{#each components as component}
<option value={component._id}>{component._id}</option>
{/each}
</select>
{/if}
</div>

View File

@ -2,13 +2,22 @@
import { backendUiStore } from "builderStore"
export let value
$: modelId = value ? value._id : ""
function onChange(e) {
value = $backendUiStore.models.find(model => model._id === e.target.value)
}
</script>
<div class="bb-margin-xl block-field">
<select class="budibase__input" bind:value>
<option value="" />
<div class="block-field">
<select
class="budibase__input"
value={modelId}
on:blur={onChange}
on:change={onChange}>
<option value="">Choose an option</option>
{#each $backendUiStore.models as model}
<option value={model}>{model.name}</option>
<option value={model._id}>{model.name}</option>
{/each}
</select>
</div>

View File

@ -3,6 +3,14 @@
import { Input, Label } from "@budibase/bbui"
export let value
$: modelId = value && value.model ? value.model._id : ""
$: schemaFields = Object.keys(value && value.model ? value.model.schema : {})
function onChangeModel(e) {
value.model = $backendUiStore.models.find(
model => model._id === e.target.value
)
}
function setParsedValue(evt, field) {
const fieldSchema = value.model.schema[field]
@ -10,23 +18,27 @@
value[field] = parseInt(evt.target.value)
return
}
value[field] = evt.target.value
}
</script>
<div class="bb-margin-xl block-field">
<select class="budibase__input" bind:value={value.model}>
<div class="block-field">
<select
class="budibase__input"
value={modelId}
on:blur={onChangeModel}
on:change={onChangeModel}>
<option value="">Choose an option</option>
{#each $backendUiStore.models as model}
<option value={model}>{model.name}</option>
<option value={model._id}>{model.name}</option>
{/each}
</select>
</div>
{#if value.model}
{#if schemaFields.length}
<div class="bb-margin-xl block-field">
<Label small forAttr={'fields'}>Fields</Label>
{#each Object.keys(value.model.schema) as field}
{#each schemaFields as field}
<div class="bb-margin-xl">
<Input
thin

View File

@ -1,6 +1,5 @@
<script>
import { fade } from "svelte/transition"
import { onMount, getContext } from "svelte"
import { getContext } from "svelte"
import { backendUiStore, workflowStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import WorkflowBlockSetup from "./WorkflowBlockSetup.svelte"
@ -9,49 +8,35 @@
const { open, close } = getContext("simple-modal")
const ACCESS_LEVELS = [
{
name: "Admin",
key: "ADMIN",
canExecute: true,
editable: false,
},
{
name: "Power User",
key: "POWER_USER",
canExecute: true,
editable: false,
},
]
let selectedTab = "SETUP"
let testResult
$: workflow =
$workflowStore.currentWorkflow && $workflowStore.currentWorkflow.workflow
$: workflowBlock = $workflowStore.selectedWorkflowBlock
$workflowStore.selectedWorkflow && $workflowStore.selectedWorkflow.workflow
function deleteWorkflow() {
open(
DeleteWorkflowModal,
{
onClosed: close,
},
{ onClosed: close },
{ styleContent: { padding: "0" } }
)
}
function deleteWorkflowBlock() {
workflowStore.actions.deleteWorkflowBlock(workflowBlock)
notifier.info("Workflow block deleted.")
workflowStore.actions.deleteWorkflowBlock($workflowStore.selectedBlock)
}
function testWorkflow() {
testResult = "PASSED"
async function testWorkflow() {
const result = await workflowStore.actions.trigger({
workflow: $workflowStore.selectedWorkflow.workflow,
})
if (result.status === 200) {
notifier.success(`Workflow ${workflow.name} triggered successfully.`)
} else {
notifier.danger(`Failed to trigger workflow ${workflow.name}.`)
}
}
async function saveWorkflow() {
const workflow = $workflowStore.currentWorkflow.workflow
await workflowStore.actions.save({
instanceId: $backendUiStore.selectedDatabase._id,
workflow,
@ -65,68 +50,27 @@
<span
class="hoverable"
class:selected={selectedTab === 'SETUP'}
on:click={() => {
selectedTab = 'SETUP'
testResult = null
}}>
on:click={() => (selectedTab = 'SETUP')}>
Setup
</span>
{#if !workflowBlock}
<span
class="test-tab"
class:selected={selectedTab === 'TEST'}
on:click={() => (selectedTab = 'TEST')}>
Test
</span>
{/if}
</header>
{#if selectedTab === 'TEST'}
<div class="bb-margin-m">
{#if testResult}
<button
transition:fade
class:passed={testResult === 'PASSED'}
class:failed={testResult === 'FAILED'}
class="test-result">
{testResult}
</button>
{/if}
<Button secondary wide on:click={testWorkflow}>Test Workflow</Button>
</div>
{/if}
{#if selectedTab === 'SETUP'}
{#if workflowBlock}
<WorkflowBlockSetup {workflowBlock} />
{#if $workflowStore.selectedBlock}
<WorkflowBlockSetup bind:block={$workflowStore.selectedBlock} />
<div class="buttons">
<Button
green
wide
data-cy="save-workflow-setup"
on:click={saveWorkflow}>
<Button green wide data-cy="save-workflow-setup" on:click={saveWorkflow}>
Save Workflow
</Button>
<Button red wide on:click={deleteWorkflowBlock}>Delete Block</Button>
</div>
{:else if $workflowStore.currentWorkflow}
{:else if $workflowStore.selectedWorkflow}
<div class="panel">
<div class="panel-body">
<div class="block-label">Workflow: {workflow.name}</div>
<div class="config-item">
<Label small forAttr={'useraccess'}>User Access</Label>
<div class="access-levels">
{#each ACCESS_LEVELS as level}
<span class="access-level">
<label>{level.name}</label>
<input
type="checkbox"
disabled={!level.editable}
bind:checked={level.canExecute} />
</span>
{/each}
</div>
<div class="block-label">
Workflow
<b>{workflow.name}</b>
</div>
</div>
<Button secondary wide on:click={testWorkflow}>Test Workflow</Button>
<div class="buttons">
<Button
green
@ -139,7 +83,6 @@
</div>
</div>
{/if}
{/if}
</section>
<style>
@ -181,10 +124,6 @@
margin-bottom: 20px;
}
.config-item {
margin-bottom: 20px;
}
header > span {
color: var(--grey-5);
margin-right: 20px;
@ -205,35 +144,8 @@
gap: 12px;
}
.access-level {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20px;
}
.access-level label {
font-weight: normal;
color: var(--ink);
}
.test-result {
border: none;
width: 100%;
border-radius: 3px;
height: 32px;
font-size: 14px;
font-weight: 500;
color: var(--white);
text-align: center;
margin-bottom: 10px;
}
.passed {
background: var(--green);
}
.failed {
background: var(--red);
}
</style>

View File

@ -1,55 +1,45 @@
<script>
import { backendUiStore, store } from "builderStore"
import ComponentSelector from "./ParamInputs/ComponentSelector.svelte"
import ModelSelector from "./ParamInputs/ModelSelector.svelte"
import RecordSelector from "./ParamInputs/RecordSelector.svelte"
import { Input, TextArea, Select } from "@budibase/bbui"
export let workflowBlock
let params
$: workflowParams = workflowBlock.params
? Object.entries(workflowBlock.params)
: []
export let block
$: params = block.params ? Object.entries(block.params) : []
</script>
<label class="selected-label">{workflowBlock.type}: {workflowBlock.name}</label>
{#each workflowParams as [parameter, type]}
<div class="container">
<div class="selected-label">{block.name}</div>
{#each params as [parameter, type]}
<div class="block-field">
<label class="label">{parameter}</label>
{#if Array.isArray(type)}
<Select bind:value={workflowBlock.args[parameter]} thin>
<Select bind:value={block.args[parameter]} thin secondary>
<option value="">Choose an option</option>
{#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 bind:value={workflowBlock.args[parameter]} thin>
<Select bind:value={block.args[parameter]} thin secondary>
<option value="ADMIN">Admin</option>
<option value="POWER_USER">Power User</option>
</Select>
{:else if type === 'password'}
<Input type="password" thin bind:value={workflowBlock.args[parameter]} />
<Input type="password" thin bind:value={block.args[parameter]} />
{:else if type === 'number'}
<Input type="number" thin bind:value={workflowBlock.args[parameter]} />
<Input type="number" thin bind:value={block.args[parameter]} />
{:else if type === 'longText'}
<TextArea
type="text"
thin
bind:value={workflowBlock.args[parameter]}
label="" />
<TextArea type="text" thin bind:value={block.args[parameter]} />
{:else if type === 'model'}
<ModelSelector bind:value={workflowBlock.args[parameter]} />
<ModelSelector bind:value={block.args[parameter]} />
{:else if type === 'record'}
<RecordSelector value={workflowBlock.args[parameter]} />
<RecordSelector bind:value={block.args[parameter]} />
{:else if type === 'string'}
<Input type="text" thin bind:value={workflowBlock.args[parameter]} />
<Input type="text" thin bind:value={block.args[parameter]} />
{/if}
</div>
{/each}
{/each}
</div>
<style>
.block-field {
@ -57,16 +47,19 @@
}
label {
text-transform: capitalize;
font-size: 14px;
font-family: sans-serif;
font-weight: 500;
color: var(--ink);
margin-bottom: 12px;
text-transform: capitalize;
margin-top: 20px;
}
.selected-label {
text-transform: capitalize;
font-size: 14px;
font-weight: 500;
font-size: 14px;
color: var(--grey-7);
}
textarea {

View File

@ -1,30 +1,22 @@
<script>
import { onMount } from "svelte"
import { afterUpdate } from "svelte"
import { workflowStore, backendUiStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import Flowchart from "./flowchart/FlowChart.svelte"
let selectedWorkflow
let uiTree
let instanceId = $backendUiStore.selectedDatabase._id
$: selectedWorkflow = $workflowStore.currentWorkflow
$: workflowLive = selectedWorkflow && selectedWorkflow.workflow.live
$: uiTree = selectedWorkflow ? selectedWorkflow.createUiTree() : []
$: workflow =
$workflowStore.selectedWorkflow && $workflowStore.selectedWorkflow.workflow
$: workflowLive = workflow && workflow.live
$: instanceId = $backendUiStore.selectedDatabase._id
function onSelect(block) {
workflowStore.update(state => {
state.selectedWorkflowBlock = block
state.selectedBlock = block
return state
})
}
function setWorkflowLive(live) {
const { workflow } = selectedWorkflow
workflow.live = live
workflowStore.actions.save({ instanceId, workflow })
if (live) {
@ -36,9 +28,10 @@
</script>
<section>
<Flowchart blocks={uiTree} {onSelect} />
<footer>
{#if selectedWorkflow}
<Flowchart {workflow} {onSelect} />
</section>
<footer>
{#if workflow}
<button
class:highlighted={workflowLive}
class:hoverable={workflowLive}
@ -54,14 +47,23 @@
<i class="ri-play-fill" />
</button>
{/if}
</footer>
</section>
</footer>
<style>
section {
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
overflow: auto;
height: 100%;
position: relative;
}
footer {
position: absolute;
bottom: 0;
right: 0;
bottom: 20px;
right: 30px;
display: flex;
align-items: flex-end;
}
@ -77,7 +79,9 @@
display: flex;
align-items: center;
justify-content: center;
margin-right: 24px;
}
footer > button:first-child {
margin-right: 20px;
}
.play-button.highlighted {

View File

@ -7,3 +7,9 @@
<path d="M5.0625 70H9L4.5 75L0 70H3.9375V65H5.0625V70Z" fill="#ADAEC4" />
<rect x="4" width="1" height="65" fill="#ADAEC4" />
</svg>
<style>
svg {
margin: 8px 0;
}
</style>

Before

Width:  |  Height:  |  Size: 241 B

After

Width:  |  Height:  |  Size: 290 B

View File

@ -1,24 +1,52 @@
<script>
import FlowItem from "./FlowItem.svelte"
import Arrow from "./Arrow.svelte"
import { flip } from "svelte/animate"
import { fade, fly } from "svelte/transition"
export let blocks = []
export let workflow
export let onSelect
let blocks
$: {
blocks = []
if (workflow) {
if (workflow.definition.trigger) {
blocks.push(workflow.definition.trigger)
}
blocks = blocks.concat(workflow.definition.steps || [])
}
}
</script>
<section class="canvas">
{#each blocks as block, idx}
{#each blocks as block, idx (block.id)}
<div
class="block"
animate:flip={{ duration: 600 }}
in:fade|local
out:fly|local={{ x: 100 }}>
<FlowItem {onSelect} {block} />
{#if idx !== blocks.length - 1}
<Arrow />
{/if}
</div>
{/each}
</section>
<style>
.canvas {
section {
position: absolute;
padding: 20px 40px;
display: flex;
align-items: center;
flex-direction: column;
}
.block {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
}
</style>

View File

@ -1,15 +1,21 @@
<script>
import { fade } from "svelte/transition"
import mustache from "mustache"
import { workflowStore } from "builderStore"
export let onSelect
export let block
let selected
$: selected =
$workflowStore.selectedBlock != null &&
$workflowStore.selectedBlock.id === block.id
function selectBlock() {
onSelect(block)
}
</script>
<div transition:fade class={`${block.type} hoverable`} on:click={selectBlock}>
<div class={`${block.type} hoverable`} class:selected on:click={selectBlock}>
<header>
{#if block.type === 'TRIGGER'}
<i class="ri-lightbulb-fill" />
@ -24,7 +30,7 @@
</header>
<hr />
<p>
{@html block.body}
{@html mustache.render(block.tagline, block.args)}
</p>
</div>
@ -32,8 +38,8 @@
div {
width: 320px;
padding: 20px;
border-radius: 5px;
transition: 0.3s all;
border-radius: var(--border-radius-m);
transition: 0.3s all ease;
box-shadow: 0 4px 30px 0 rgba(57, 60, 68, 0.08);
background-color: var(--ink);
font-size: 16px;
@ -69,9 +75,12 @@
p {
color: inherit;
margin-bottom: 0;
}
div.selected,
div:hover {
transform: scale(1.05);
transform: scale(1.1);
box-shadow: 0 4px 30px 0 rgba(57, 60, 68, 0.15);
}
</style>

View File

@ -1,82 +1,39 @@
<script>
import { onMount } from "svelte"
import { backendUiStore, workflowStore } from "builderStore"
import { WorkflowList } from "../"
import { workflowStore } from "builderStore"
import WorkflowBlock from "./WorkflowBlock.svelte"
import blockDefinitions from "../blockDefinitions"
import FlatButtonGroup from "components/userInterface/FlatButtonGroup.svelte"
let selectedTab = "TRIGGER"
let definitions = []
$: definitions = Object.entries(blockDefinitions[selectedTab])
let buttonProps = []
$: blocks = Object.entries($workflowStore.blockDefinitions[selectedTab])
$: {
if (
$workflowStore.currentWorkflow.hasTrigger() &&
selectedTab === "TRIGGER"
) {
if ($workflowStore.selectedWorkflow.hasTrigger()) {
buttonProps = [
{ value: "ACTION", text: "Action" },
{ value: "LOGIC", text: "Logic" },
]
if (selectedTab === "TRIGGER") {
selectedTab = "ACTION"
}
} else {
buttonProps = [{ value: "TRIGGER", text: "Trigger" }]
if (selectedTab !== "TRIGGER") {
selectedTab = "TRIGGER"
}
}
}
function onChangeTab(tab) {
selectedTab = tab
}
</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>
<FlatButtonGroup value={selectedTab} {buttonProps} onChange={onChangeTab} />
<div id="blocklist">
{#each definitions as [actionId, blockDefinition]}
<WorkflowBlock {blockDefinition} {actionId} blockType={selectedTab} />
{#each blocks as [stepId, blockDefinition]}
<WorkflowBlock {blockDefinition} {stepId} blockType={selectedTab} />
{/each}
</div>
</section>
<style>
.subtabs {
margin-top: 20px;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr 1fr 1fr;
margin-bottom: 12px;
}
.subtabs span {
transition: 0.3s all;
text-align: center;
color: var(--grey-7);
font-weight: 400;
padding: 8px 16px;
text-rendering: optimizeLegibility;
border: none !important;
outline: none;
}
.subtabs span.selected {
background: var(--grey-3);
color: var(--ink);
border-radius: 5px;
}
.subtabs span:not(.selected) {
color: var(--ink);
}
</style>

View File

@ -1,15 +1,15 @@
<script>
import { workflowStore } from "builderStore"
export let blockType
export let blockDefinition
export let actionId
export let stepId
export let blockType
function addBlockToWorkflow() {
workflowStore.actions.addBlockToWorkflow({
...blockDefinition,
args: blockDefinition.args || {},
actionId,
stepId,
type: blockType,
})
}
@ -18,7 +18,7 @@
<div
class="workflow-block hoverable"
on:click={addBlockToWorkflow}
data-cy={actionId}>
data-cy={stepId}>
<div>
<i class={blockDefinition.icon} />
</div>
@ -31,11 +31,11 @@
<style>
.workflow-block {
display: grid;
grid-template-columns: 40px auto;
grid-template-columns: 20px auto;
align-items: center;
margin-top: 16px;
padding: 16px 0px;
border-radius: var(--border);
padding: 12px;
border-radius: var(--border-radius-m);
}
.workflow-block:hover {
@ -43,7 +43,7 @@
}
.workflow-text {
margin-left: 12px;
margin-left: 16px;
}
.icon {
@ -64,6 +64,7 @@
font-size: 14px;
font-weight: 500;
margin-bottom: 5px;
margin-top: 0;
}
p {

View File

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

View File

@ -1,11 +1,9 @@
<script>
import { onMount } from "svelte"
import { backendUiStore, workflowStore } from "builderStore"
import { WorkflowList, BlockList } from "./"
import blockDefinitions from "./blockDefinitions"
import { workflowStore } from "builderStore"
import WorkflowList from "./WorkflowList/WorkflowList.svelte"
import BlockList from "./BlockList/BlockList.svelte"
let selectedTab = "WORKFLOWS"
let definitions = []
</script>
<header>
@ -16,7 +14,7 @@
on:click={() => (selectedTab = 'WORKFLOWS')}>
Workflows
</span>
{#if $workflowStore.currentWorkflow}
{#if $workflowStore.selectedWorkflow}
<span
data-cy="add-workflow-component"
class="hoverable"

View File

@ -12,11 +12,13 @@
<div class="content">
<slot />
</div>
{#if $workflowStore.selectedWorkflow}
<div class="nav">
<div class="inner">
<SetupPanel />
</div>
</div>
{/if}
</div>
<style>
@ -35,13 +37,11 @@
.content {
flex: 1 1 auto;
margin: 20px 40px;
}
.nav {
overflow: auto;
width: 300px;
border-right: 1px solid var(--grey-2);
background: var(--white);
}

View File

@ -28,7 +28,8 @@ describe("fetch bindable properties", () => {
...testData()
})
const contextBindings = result.filter(r => r.instance._id === "list-id" && r.type==="context")
expect(contextBindings.length).toBe(2)
// 2 fields + _id + _rev
expect(contextBindings.length).toBe(4)
const namebinding = contextBindings.find(b => b.runtimeBinding === "data.name")
expect(namebinding).toBeDefined()
@ -37,6 +38,10 @@ describe("fetch bindable properties", () => {
const descriptionbinding = contextBindings.find(b => b.runtimeBinding === "data.description")
expect(descriptionbinding).toBeDefined()
expect(descriptionbinding.readableBinding).toBe("list-name.Test Model.description")
const idbinding = contextBindings.find(b => b.runtimeBinding === "data._id")
expect(idbinding).toBeDefined()
expect(idbinding.readableBinding).toBe("list-name.Test Model._id")
})
it("should return model schema, for grantparent context", () => {
@ -45,7 +50,8 @@ describe("fetch bindable properties", () => {
...testData()
})
const contextBindings = result.filter(r => r.type==="context")
expect(contextBindings.length).toBe(4)
// 2 fields + _id + _rev ... x 2 models
expect(contextBindings.length).toBe(8)
const namebinding_parent = contextBindings.find(b => b.runtimeBinding === "parent.data.name")
expect(namebinding_parent).toBeDefined()
@ -120,7 +126,7 @@ const testData = () => {
_id: "list-id",
_component: "@budibase/standard-components/list",
_instanceName: "list-name",
model: "test-model-id",
model: { isModel: true, modelId: "test-model-id", label: "Test Model", name: "all_test-model-id" },
_children: [
{
_id: "list-item-heading-id",
@ -138,7 +144,7 @@ const testData = () => {
_id: "child-list-id",
_component: "@budibase/standard-components/list",
_instanceName: "child-list-name",
model: "test-model-id",
model: { isModel: true, modelId: "test-model-id", label: "Test Model", name: "all_test-model-id"},
_children: [
{
_id: "child-list-item-heading-id",

View File

@ -688,14 +688,15 @@
lodash "^4.17.13"
to-fast-properties "^2.0.0"
"@budibase/bbui@^1.32.0":
version "1.32.0"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.32.0.tgz#4b099e51cf8aebfc963a763bb9687994a2ee26a8"
"@budibase/bbui@^1.33.0":
version "1.33.0"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.33.0.tgz#216b24dd815f45880e9795e66b04848329b0390f"
integrity sha512-Rrt5eLbea014TIfAbT40kP0D0AWNUi8Q0kDr3UZO6Aq4UXgjc0f53ZuJ7Kb66YRDWrqiucjf1FtvOUs3/YaD6g==
dependencies:
sirv-cli "^0.4.6"
svelte-flatpickr "^2.4.0"
"@budibase/client@^0.1.19":
"@budibase/client@^0.1.21":
version "0.1.21"
resolved "https://registry.yarnpkg.com/@budibase/client/-/client-0.1.21.tgz#db414445c132b373f6c25e39d62628eb60cd8ac3"
integrity sha512-/ju0vYbWh9MUjmxkGNlOL4S/VQd4p5mbz5rHu0yt55ak9t/yyzI6PzBBxlucBeRbXYd9OFynFjy1pvYt1v+z9Q==
@ -756,6 +757,10 @@
debug "^3.1.0"
lodash.once "^4.1.1"
"@fortawesome/fontawesome-free@^5.14.0":
version "5.14.0"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.14.0.tgz#a371e91029ebf265015e64f81bfbf7d228c9681f"
"@hapi/address@^2.1.2":
version "2.1.4"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5"
@ -1382,7 +1387,6 @@ array-equal@^1.0.0:
array-filter@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-1.0.0.tgz#baf79e62e6ef4c2a4c0b831232daffec251f9d83"
integrity sha1-uveeYubvTCpMC4MSMtr/7CUfnYM=
array-union@^2.1.0:
version "2.1.0"
@ -1441,7 +1445,6 @@ atob@^2.1.2:
available-typed-arrays@^1.0.0, available-typed-arrays@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.2.tgz#6b098ca9d8039079ee3f77f7b783c4480ba513f5"
integrity sha512-XWX3OX8Onv97LMk/ftVyBibpGwY5a8SmuxZPzeOxqmuEqUCOM9ZE+uIaD1VNJ5QnvU2UQusvmKbuM1FR8QWGfQ==
dependencies:
array-filter "^1.0.0"
@ -1816,10 +1819,6 @@ class-utils@^0.3.5:
isobject "^3.0.0"
static-extend "^0.1.1"
classnames@^2.2.5:
version "2.2.6"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
cli-cursor@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
@ -1967,10 +1966,6 @@ core-js-pure@^3.0.0:
version "3.6.5"
resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813"
core-js@^3.1.3:
version "3.6.5"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a"
core-util-is@1.0.2, core-util-is@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@ -2405,7 +2400,6 @@ decode-uri-component@^0.2.0:
deep-equal@^2.0.1:
version "2.0.3"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.0.3.tgz#cad1c15277ad78a5c01c49c2dee0f54de8a6a7b0"
integrity sha512-Spqdl4H+ky45I9ByyJtXteOm9CaIrPmnIPmOhrkKGNYWeDgCvJ8jNYVCTjChxW4FqGuZnLHADc8EKRMX6+CgvA==
dependencies:
es-abstract "^1.17.5"
es-get-iterator "^1.1.0"
@ -2594,7 +2588,6 @@ es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.5:
es-abstract@^1.17.4:
version "1.17.6"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a"
integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==
dependencies:
es-to-primitive "^1.2.1"
function-bind "^1.1.1"
@ -2611,7 +2604,6 @@ es-abstract@^1.17.4:
es-abstract@^1.18.0-next.0:
version "1.18.0-next.0"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.0.tgz#b302834927e624d8e5837ed48224291f2c66e6fc"
integrity sha512-elZXTZXKn51hUBdJjSZGYRujuzilgXo8vSPQzjGYXLvSlGiCo8VO8ZGV3kjo9a0WNJJ57hENagwbtlRuHuzkcQ==
dependencies:
es-to-primitive "^1.2.1"
function-bind "^1.1.1"
@ -2629,7 +2621,6 @@ es-abstract@^1.18.0-next.0:
es-get-iterator@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.0.tgz#bb98ad9d6d63b31aacdc8f89d5d0ee57bcb5b4c8"
integrity sha512-UfrmHuWQlNMTs35e1ypnvikg6jCz3SK8v8ImvmDsh36fCVUR1MqoFDiyn0/k52C8NqO3YsO8Oe0azeesNuqSsQ==
dependencies:
es-abstract "^1.17.4"
has-symbols "^1.0.1"
@ -2889,13 +2880,6 @@ fd-slicer@~1.1.0:
dependencies:
pend "~1.2.0"
feather-icons@^4.21.0:
version "4.28.0"
resolved "https://registry.yarnpkg.com/feather-icons/-/feather-icons-4.28.0.tgz#e1892a401fe12c4559291770ff6e68b0168e760f"
dependencies:
classnames "^2.2.5"
core-js "^3.1.3"
figures@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
@ -3329,7 +3313,6 @@ is-accessor-descriptor@^1.0.0:
is-arguments@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3"
integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==
is-arrayish@^0.2.1:
version "0.2.1"
@ -3338,7 +3321,6 @@ is-arrayish@^0.2.1:
is-bigint@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.0.tgz#73da8c33208d00f130e9b5e15d23eac9215601c4"
integrity sha512-t5mGUXC/xRheCK431ylNiSkGGpBp8bHENBcENTkDT6ppwPzEVxNGZRvgvmOEfbWkFhA7D2GEuE2mmQTr78sl2g==
is-binary-path@~2.1.0:
version "2.1.0"
@ -3349,7 +3331,6 @@ is-binary-path@~2.1.0:
is-boolean-object@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.0.1.tgz#10edc0900dd127697a92f6f9807c7617d68ac48e"
integrity sha512-TqZuVwa/sppcrhUCAYkGBk7w0yxfQQnxq28fjkO53tnK9FQXmdwz2JS5+GjsWQ6RByES1K40nI+yDic5c9/aAQ==
is-buffer@^1.1.5:
version "1.1.6"
@ -3362,7 +3343,6 @@ is-callable@^1.1.4, is-callable@^1.1.5:
is-callable@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.1.tgz#4d1e21a4f437509d25ce55f8184350771421c96d"
integrity sha512-wliAfSzx6V+6WfMOmus1xy0XvSgf/dlStkvTfq7F0g4bOIW0PSUbnyse3NhDwdyYS1ozfUtAAySqTws3z9Eqgg==
is-ci@^2.0.0:
version "2.0.0"
@ -3450,7 +3430,6 @@ is-installed-globally@^0.3.2:
is-map@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.1.tgz#520dafc4307bb8ebc33b813de5ce7c9400d644a1"
integrity sha512-T/S49scO8plUiAOA2DBTBG3JHpn1yiw0kRp6dgiZ0v2/6twi5eiB0rHtHFH9ZIrvlWc6+4O+m4zg5+Z833aXgw==
is-module@^1.0.0:
version "1.0.0"
@ -3459,12 +3438,10 @@ is-module@^1.0.0:
is-negative-zero@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461"
integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE=
is-number-object@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197"
integrity sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw==
is-number@^3.0.0:
version "3.0.0"
@ -3525,14 +3502,12 @@ is-regex@^1.0.5:
is-regex@^1.1.0, is-regex@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9"
integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==
dependencies:
has-symbols "^1.0.1"
is-set@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43"
integrity sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA==
is-stream@^1.1.0:
version "1.1.0"
@ -3545,7 +3520,6 @@ is-stream@^2.0.0:
is-string@^1.0.4, is-string@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6"
integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==
is-symbol@^1.0.2:
version "1.0.3"
@ -3556,7 +3530,6 @@ is-symbol@^1.0.2:
is-typed-array@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.3.tgz#a4ff5a5e672e1a55f99c7f54e59597af5c1df04d"
integrity sha512-BSYUBOK/HJibQ30wWkWold5txYwMUXQct9YHAQJr8fSwvZoiglcqB0pd7vEN23+Tsi9IUEjztdOSzl4qLVYGTQ==
dependencies:
available-typed-arrays "^1.0.0"
es-abstract "^1.17.4"
@ -3570,12 +3543,10 @@ is-typedarray@~1.0.0:
is-weakmap@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.1.tgz#5008b59bdc43b698201d18f62b37b2ca243e8cf2"
integrity sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==
is-weakset@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.1.tgz#e9a0af88dbd751589f5e50d80f4c98b780884f83"
integrity sha512-pi4vhbhVHGLxohUw7PhGsueT4vRGFoXhP7+RGN0jKIv9+8PWYCQTqtADngrxOm2g46hoH0+g8uZZBzMrvVGDmw==
is-windows@^1.0.2:
version "1.0.2"
@ -3600,7 +3571,6 @@ isarray@1.0.0, isarray@~1.0.0:
isarray@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
isbuffer@~0.0.0:
version "0.0.0"
@ -4715,12 +4685,10 @@ object-inspect@^1.7.0:
object-inspect@^1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0"
integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==
object-is@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.2.tgz#c5d2e87ff9e119f78b7a088441519e2eec1573b6"
integrity sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ==
dependencies:
define-properties "^1.1.3"
es-abstract "^1.17.5"
@ -5238,7 +5206,6 @@ regex-not@^1.0.0, regex-not@^1.0.2:
regexp.prototype.flags@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz#7aba89b3c13a64509dabcf3ca8d9fbb9bdf5cb75"
integrity sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==
dependencies:
define-properties "^1.1.3"
es-abstract "^1.17.0-next.1"
@ -5246,7 +5213,6 @@ regexp.prototype.flags@^1.3.0:
regexparam@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/regexparam/-/regexparam-1.3.0.tgz#2fe42c93e32a40eff6235d635e0ffa344b92965f"
integrity sha512-6IQpFBv6e5vz1QAqI+V4k8P2e/3gRrqfCJ9FI+O1FLQTO+Uz6RXZEZOPmTJ6hlGj7gkERzY5BRCv09whKP96/g==
regexpu-core@^4.7.0:
version "4.7.0"
@ -5655,7 +5621,6 @@ shortid@^2.2.15:
side-channel@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.3.tgz#cdc46b057550bbab63706210838df5d4c19519c3"
integrity sha512-A6+ByhlLkksFoUepsGxfj5x1gTSrs+OydsRptUxeNCabQpCFUvcwIczgOigI8vhY/OJCnPnyE9rGiwgvr9cS1g==
dependencies:
es-abstract "^1.18.0-next.0"
object-inspect "^1.8.0"
@ -5996,11 +5961,6 @@ supports-color@^7.1.0:
dependencies:
has-flag "^4.0.0"
svelte-filepond@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/svelte-filepond/-/svelte-filepond-0.0.1.tgz#7c20379213dac746192499d3d1de649d6db51c4b"
integrity sha512-R5z/Gj/2VSdV70GxvW226ww4zrVyV2EFTXrsWIdn7sEB7uYWIJvvciUNQVTfYZfcAgGsa4gprGsuqWNG3auSKw==
svelte-flatpickr@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/svelte-flatpickr/-/svelte-flatpickr-2.4.0.tgz#190871fc3305956c8c8fd3601cd036b8ac71ef49"
@ -6363,7 +6323,6 @@ whatwg-url@^8.0.0:
which-boxed-primitive@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.1.tgz#cbe8f838ebe91ba2471bb69e9edbda67ab5a5ec1"
integrity sha512-7BT4TwISdDGBgaemWU0N0OU7FeAEJ9Oo2P1PHRm/FCWoEi2VLWC9b6xvxAA3C/NMpxg3HXVgi0sMmGbNUbNepQ==
dependencies:
is-bigint "^1.0.0"
is-boolean-object "^1.0.0"
@ -6374,7 +6333,6 @@ which-boxed-primitive@^1.0.1:
which-collection@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.1.tgz#70eab71ebbbd2aefaf32f917082fc62cdcb70906"
integrity sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==
dependencies:
is-map "^2.0.1"
is-set "^2.0.1"
@ -6388,7 +6346,6 @@ which-module@^2.0.0:
which-typed-array@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.2.tgz#e5f98e56bda93e3dac196b01d47c1156679c00b2"
integrity sha512-KT6okrd1tE6JdZAy3o2VhMoYPh3+J6EMZLyrxBQsZflI1QCZIxMrIYLkosd8Twf+YfknVIHmYQPgJt238p8dnQ==
dependencies:
available-typed-arrays "^1.0.2"
es-abstract "^1.17.5"

View File

@ -1,6 +1,6 @@
{
"name": "budibase",
"version": "0.1.19",
"version": "0.1.21",
"description": "Budibase CLI",
"repository": "https://github.com/Budibase/Budibase",
"homepage": "https://www.budibase.com",
@ -17,7 +17,7 @@
"author": "Budibase",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@budibase/server": "^0.1.19",
"@budibase/server": "^0.1.21",
"@inquirer/password": "^0.0.6-alpha.0",
"chalk": "^2.4.2",
"dotenv": "^8.2.0",

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/client",
"version": "0.1.19",
"version": "0.1.21",
"license": "MPL-2.0",
"main": "dist/budibase-client.js",
"module": "dist/budibase-client.esm.mjs",

View File

@ -1,5 +1,4 @@
import { authenticate } from "./authenticate"
import { triggerWorkflow } from "./workflow"
import appStore from "../state/store"
const apiCall = method => async ({ url, body }) => {
@ -53,7 +52,49 @@ const apiOpts = {
delete: del,
}
const createRecord = async params =>
await post({
url: `/api/${params.modelId}/records`,
body: makeRecordRequestBody(params),
})
const updateRecord = async params => {
const record = makeRecordRequestBody(params)
record._id = params._id
await patch({
url: `/api/${params.modelId}/records/${params._id}`,
body: record,
})
}
const makeRecordRequestBody = parameters => {
const body = {}
for (let fieldName in parameters.fields) {
const field = parameters.fields[fieldName]
// ensure fields sent are of the correct type
if (field.type === "boolean") {
if (field.value === "true") body[fieldName] = true
if (field.value === "false") body[fieldName] = false
} else if (field.type === "number") {
const val = parseFloat(field.value)
if (!isNaN(val)) {
body[fieldName] = val
}
} else if (field.type === "datetime") {
const date = new Date(field.value)
if (!isNaN(date.getTime())) {
body[fieldName] = date.toISOString()
}
} else {
body[fieldName] = field.value
}
}
return body
}
export default {
authenticate: authenticate(apiOpts),
triggerWorkflow: triggerWorkflow(apiOpts),
createRecord,
updateRecord,
}

View File

@ -1,18 +0,0 @@
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
export default {
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

@ -1,68 +0,0 @@
import renderTemplateString from "../../state/renderTemplateString"
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] = renderTemplateString(argValue, {
context: this.context,
state: appStore.get(),
})
}
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

@ -1,22 +0,0 @@
/**
* 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

@ -50,7 +50,6 @@ export const createApp = ({
treeNode,
onScreenSlotRendered,
setupState: stateManager.setup,
getCurrentState: stateManager.getCurrentState,
})
return getInitialiseParams

View File

@ -1,12 +1,13 @@
import setBindableComponentProp from "./setBindableComponentProp"
import { attachChildren } from "../render/attachChildren"
import store from "../state/store"
export const trimSlash = str => str.replace(/^\/+|\/+$/g, "")
export const bbFactory = ({
componentLibraries,
onScreenSlotRendered,
getCurrentState,
runEventActions,
}) => {
const apiCall = method => (url, body) => {
return fetch(url, {
@ -26,13 +27,6 @@ export const bbFactory = ({
delete: apiCall("DELETE"),
}
const safeCallEvent = (event, context) => {
const isFunction = obj =>
!!(obj && obj.constructor && obj.call && obj.apply)
if (isFunction(event)) event(context)
}
return (treeNode, setupState) => {
const attachParams = {
componentLibraries,
@ -44,12 +38,18 @@ export const bbFactory = ({
return {
attachChildren: attachChildren(attachParams),
props: treeNode.props,
call: safeCallEvent,
call: async eventName =>
eventName &&
(await runEventActions(
treeNode.props[eventName],
store.getState(treeNode.contextStoreKey)
)),
setBinding: setBindableComponentProp(treeNode),
api,
parent,
store: store.getStore(treeNode.contextStoreKey),
// these parameters are populated by screenRouter
routeParams: () => getCurrentState()["##routeParams"],
routeParams: () => store.getState()["##routeParams"],
}
}
}

View File

@ -1,20 +1,38 @@
import api from "../api"
import renderTemplateString from "./renderTemplateString"
export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType"
export const eventHandlers = routeTo => {
const handler = (parameters, execute) => ({
execute,
parameters,
})
return {
"Navigate To": handler(["url"], param => routeTo(param && param.url)),
"Trigger Workflow": handler(["workflow"], api.triggerWorkflow),
const handlers = {
"Navigate To": param => routeTo(param && param.url),
}
// when an event is called, this is what gets run
const runEventActions = async (actions, state) => {
if (!actions) return
// calls event handlers sequentially
for (let action of actions) {
const handler = handlers[action[EVENT_TYPE_MEMBER_NAME]]
const parameters = createParameters(action.parameters, state)
if (handler) {
await handler(parameters)
}
}
}
return runEventActions
}
export const isEventType = prop =>
Array.isArray(prop) &&
prop.length > 0 &&
!prop[0][EVENT_TYPE_MEMBER_NAME] === undefined
// this will take a parameters obj, iterate all keys, and do a mustache render
// for every string. It will work recursively if it encounnters an {}
const createParameters = (parameterTemplateObj, state) => {
const parameters = {}
for (let key in parameterTemplateObj) {
if (typeof parameterTemplateObj[key] === "string") {
parameters[key] = renderTemplateString(parameterTemplateObj[key], state)
} else if (typeof parameterTemplateObj[key] === "object") {
parameters[key] = createParameters(parameterTemplateObj[key], state)
}
}
return parameters
}

View File

@ -1,8 +1,4 @@
import {
isEventType,
eventHandlers,
EVENT_TYPE_MEMBER_NAME,
} from "./eventHandlers"
import { eventHandlers } from "./eventHandlers"
import { bbFactory } from "./bbComponentApi"
import renderTemplateString from "./renderTemplateString"
import appStore from "./store"
@ -25,33 +21,23 @@ export const createStateManager = ({
onScreenSlotRendered,
routeTo,
}) => {
let handlerTypes = eventHandlers(routeTo)
// creating a reference to the current state
// this avoids doing store.get() ... which is expensive on
// hot paths, according to the svelte docs.
// the state object reference never changes (although it's internals do)
// so this should work fine for us
let currentState
appStore.subscribe(s => (currentState = s))
const getCurrentState = () => currentState
let runEventActions = eventHandlers(routeTo)
const bb = bbFactory({
getCurrentState,
componentLibraries,
onScreenSlotRendered,
runEventActions,
})
const setup = _setup({ handlerTypes, getCurrentState, bb })
const setup = _setup(bb)
return {
setup,
destroy: () => {},
getCurrentState,
}
}
const _setup = ({ handlerTypes, getCurrentState, bb }) => node => {
const _setup = bb => node => {
const props = node.props
const initialProps = { ...props }
@ -70,53 +56,10 @@ const _setup = ({ handlerTypes, getCurrentState, bb }) => node => {
node.stateBound = true
}
}
if (isEventType(propValue)) {
const state = appStore.getState(node.contextStoreKey)
const handlersInfos = []
for (let event of propValue) {
const handlerInfo = {
handlerType: event[EVENT_TYPE_MEMBER_NAME],
parameters: event.parameters,
}
const resolvedParams = {}
for (let paramName in handlerInfo.parameters) {
const paramValue = handlerInfo.parameters[paramName]
resolvedParams[paramName] = () =>
renderTemplateString(paramValue, state)
}
handlerInfo.parameters = resolvedParams
handlersInfos.push(handlerInfo)
}
if (handlersInfos.length === 0) {
initialProps[propName] = doNothing
} else {
initialProps[propName] = async context => {
for (let handlerInfo of handlersInfos) {
const handler = makeHandler(handlerTypes, handlerInfo)
await handler(context)
}
}
}
}
}
const setup = _setup({ handlerTypes, getCurrentState, bb })
const setup = _setup(bb)
initialProps._bb = bb(node, setup)
return initialProps
}
const makeHandler = (handlerTypes, handlerInfo) => {
const handlerType = handlerTypes[handlerInfo.handlerType]
return async context => {
const parameters = {}
for (let paramName in handlerInfo.parameters) {
parameters[paramName] = handlerInfo.parameters[paramName](context)
}
await handlerType.execute(parameters)
}
}

View File

@ -11,6 +11,7 @@ const contextStoreKey = (dataProviderId, childIndex) =>
`${dataProviderId}${childIndex >= 0 ? ":" + childIndex : ""}`
// creates a store for a datacontext (e.g. each item in a list component)
// overrides store if already exists
const create = (data, dataProviderId, childIndex, parentContextStoreId) => {
const key = contextStoreKey(dataProviderId, childIndex)
const state = { data }
@ -22,14 +23,13 @@ const create = (data, dataProviderId, childIndex, parentContextStoreId) => {
? contextStores[parentContextStoreId].state
: rootState
if (!contextStores[key]) {
contextStores[key] = {
store: writable(state),
subscriberCount: 0,
state,
parentContextStoreId,
}
}
return key
}
@ -94,6 +94,9 @@ const set = (value, dataProviderId, childIndex) =>
const getState = contextStoreKey =>
contextStoreKey ? contextStores[contextStoreKey].state : rootState
const getStore = contextStoreKey =>
contextStoreKey ? contextStores[contextStoreKey] : rootStore
export default {
subscribe,
update,
@ -101,4 +104,5 @@ export default {
getState,
create,
contextStoreKey,
getStore,
}

View File

@ -181,8 +181,7 @@ const maketestlib = window => ({
currentProps = Object.assign(currentProps, props)
if (currentProps.onClick) {
node.addEventListener("click", () => {
const testText = currentProps.testText || "hello"
currentProps._bb.call(props.onClick, { testText })
currentProps._bb.call("onClick")
})
}
}

View File

@ -4,6 +4,7 @@ WORKDIR /app
ENV CLOUD=1
ENV COUCH_DB_URL=https://couchdb.budi.live:5984
env BUDIBASE_ENVIRONMENT=PRODUCTION
# copy files and install dependencies
COPY . ./

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/server",
"version": "0.1.19",
"version": "0.1.21",
"description": "Budibase Web Server",
"main": "src/electron.js",
"repository": {
@ -42,7 +42,7 @@
"author": "Michael Shanks",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@budibase/client": "^0.1.19",
"@budibase/client": "^0.1.21",
"@koa/router": "^8.0.0",
"@sendgrid/mail": "^7.1.1",
"@sentry/node": "^5.19.2",
@ -55,6 +55,7 @@
"electron-updater": "^4.3.1",
"fix-path": "^3.0.0",
"fs-extra": "^8.1.0",
"joi": "^17.2.1",
"jsonwebtoken": "^8.5.1",
"koa": "^2.7.0",
"koa-body": "^4.1.0",
@ -74,6 +75,7 @@
"tar-fs": "^2.1.0",
"uuid": "^3.3.2",
"validate.js": "^0.13.1",
"worker-farm": "^1.7.0",
"yargs": "^13.2.4",
"zlib": "^1.0.5"
},

View File

@ -16,7 +16,7 @@ exports.authenticate = async ctx => {
const { clientId } = await masterDb.get(ctx.user.appId)
if (!clientId) {
ctx.throw(400, "ClientId not suplied")
ctx.throw(400, "ClientId not supplied")
}
// find the instance that the user is associated with
const db = new CouchDB(ClientDb.name(clientId))

View File

@ -25,7 +25,7 @@ exports.save = async function(ctx) {
...ctx.request.body,
}
// update renamed record fields when model is updated
// rename record fields when table column is renamed
const { _rename } = modelToSave
if (_rename) {
const records = await db.query(`database/all_${modelToSave._id}`, {
@ -41,6 +41,15 @@ exports.save = async function(ctx) {
delete modelToSave._rename
}
// update schema of non-statistics views when new columns are added
for (let view in modelToSave.views) {
const modelView = modelToSave.views[view]
if (!modelView) continue
if (modelView.schema.group || modelView.schema.field) continue
modelView.schema = modelToSave.schema
}
const result = await db.post(modelToSave)
modelToSave._rev = result.rev

View File

@ -2,6 +2,16 @@ const CouchDB = require("../../db")
const validateJs = require("validate.js")
const newid = require("../../db/newid")
function emitEvent(eventType, ctx, record) {
ctx.eventEmitter &&
ctx.eventEmitter.emit(eventType, {
args: {
record,
},
instanceId: ctx.user.instanceId,
})
}
validateJs.extend(validateJs.validators.datetime, {
parse: function(value) {
return new Date(value).getTime()
@ -12,6 +22,40 @@ validateJs.extend(validateJs.validators.datetime, {
},
})
exports.patch = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
const record = await db.get(ctx.params.id)
const model = await db.get(record.modelId)
const patchfields = ctx.request.body
for (let key in patchfields) {
if (!model.schema[key]) continue
record[key] = patchfields[key]
}
const validateResult = await validate({
record,
model,
})
if (!validateResult.valid) {
ctx.status = 400
ctx.body = {
status: 400,
errors: validateResult.errors,
}
return
}
const response = await db.put(record)
record._rev = response.rev
record.type = "record"
ctx.body = record
ctx.status = 200
ctx.message = `${model.name} updated successfully.`
return
}
exports.save = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
const record = ctx.request.body
@ -76,13 +120,7 @@ exports.save = async function(ctx) {
}
}
ctx.eventEmitter &&
ctx.eventEmitter.emit(`record:save`, {
args: {
record,
},
instanceId: ctx.user.instanceId,
})
emitEvent(`record:save`, ctx, record)
ctx.body = record
ctx.status = 200
ctx.message = `${model.name} created successfully`
@ -145,7 +183,7 @@ exports.destroy = async function(ctx) {
return
}
ctx.body = await db.remove(ctx.params.recordId, ctx.params.revId)
ctx.eventEmitter && ctx.eventEmitter.emit(`record:delete`, record)
emitEvent(`record:delete`, ctx, record)
}
exports.validate = async function(ctx) {

View File

@ -87,7 +87,10 @@ exports.processLocalFileUpload = async function(ctx) {
pendingFileUploads = { _id: "_local/fileuploads", uploads: [] }
})
pendingFileUploads.uploads = [...filesToProcess, ...pendingFileUploads.uploads]
pendingFileUploads.uploads = [
...filesToProcess,
...pendingFileUploads.uploads,
]
await db.put(pendingFileUploads)
ctx.body = filesToProcess

View File

@ -103,9 +103,10 @@ function viewTemplate({ field, modelId, groupBy, filters = [], calculation }) {
let schema = null
if (calculation) {
schema = groupBy
? { ...GROUP_PROPERTY, ...SCHEMA_MAP[calculation] }
: { ...FIELD_PROPERTY, ...SCHEMA_MAP[calculation] }
schema = {
...(groupBy ? GROUP_PROPERTY : FIELD_PROPERTY),
...SCHEMA_MAP[calculation],
}
}
return {

View File

@ -1,24 +0,0 @@
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

@ -1,29 +0,0 @@
const recordController = require("../../record")
module.exports = async function saveRecord({ args, context }) {
const { model, ...record } = args.record
const ctx = {
params: {
instanceId: context.instanceId,
modelId: model._id,
},
request: {
body: record,
},
user: { instanceId: context.instanceId },
}
try {
await recordController.save(ctx)
return {
record: ctx.body,
}
} catch (err) {
console.error(err)
return {
record: null,
error: err.message,
}
}
}

View File

@ -1,26 +0,0 @@
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,85 +1,81 @@
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: {},
},
type: "ACTION",
},
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",
params: {},
args: {},
type: "ACTION",
},
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",
},
args: {
accessLevelId: "POWER_USER",
},
type: "ACTION",
},
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",
},
type: "ACTION",
},
}
const LOGIC = {
FILTER: {
name: "Filter",
tagline: "{{filter}} <b>{{condition}}</b> {{value}}",
icon: "ri-git-branch-line",
description: "Filter any workflows which do not meet certain conditions.",
params: {
filter: "string",
condition: ["equals"],
value: "string",
},
args: {
condition: "equals",
},
type: "LOGIC",
},
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.",
params: {
time: "number",
},
type: "LOGIC",
},
}
@ -89,11 +85,11 @@ const TRIGGER = {
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",
description: "Fired when a record is saved to your database.",
params: {
model: "model",
},
type: "TRIGGER",
},
RECORD_DELETED: {
name: "Record Deleted",
@ -101,70 +97,17 @@ const TRIGGER = {
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",
},
type: "TRIGGER",
},
}
export default {
// This contains the definitions for the steps and triggers that make up a workflow, a workflow comprises
// of many steps and a single trigger
module.exports = {
ACTION,
TRIGGER,
LOGIC,
TRIGGER,
}

View File

@ -1,5 +1,13 @@
const CouchDB = require("../../../db")
const newid = require("../../../db/newid")
const blockDefinitions = require("./blockDefinitions")
const triggers = require("../../../workflows/triggers")
/*************************
* *
* BUILDER FUNCTIONS *
* *
*************************/
exports.create = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
@ -53,22 +61,47 @@ exports.find = async function(ctx) {
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) {
const db = new CouchDB(ctx.user.instanceId)
ctx.body = await db.remove(ctx.params.id, ctx.params.rev)
}
exports.getActionList = async function(ctx) {
ctx.body = blockDefinitions.ACTION
}
exports.getTriggerList = async function(ctx) {
ctx.body = blockDefinitions.TRIGGER
}
exports.getLogicList = async function(ctx) {
ctx.body = blockDefinitions.LOGIC
}
module.exports.getDefinitionList = async function(ctx) {
ctx.body = {
logic: blockDefinitions.LOGIC,
trigger: blockDefinitions.TRIGGER,
action: blockDefinitions.ACTION,
}
}
/*********************
* *
* API FUNCTIONS *
* *
*********************/
exports.trigger = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
let workflow = await db.get(ctx.params.id)
await triggers.externalTrigger(workflow, {
...ctx.request.body,
instanceId: ctx.user.instanceId,
})
ctx.status = 200
ctx.body = {
message: `Workflow ${workflow._id} has been triggered.`,
workflow,
}
}

View File

@ -22,6 +22,11 @@ router
authorized(WRITE_MODEL, ctx => ctx.params.modelId),
recordController.save
)
.patch(
"/api/:modelId/records/:id",
authorized(WRITE_MODEL, ctx => ctx.params.modelId),
recordController.patch
)
.post(
"/api/:modelId/records/validate",
authorized(WRITE_MODEL, ctx => ctx.params.modelId),

View File

@ -37,7 +37,7 @@ describe("/accesslevels", () => {
beforeEach(async () => {
instanceId = (await createInstance(request, appId))._id
model = await createModel(request, appId, instanceId)
view = await createView(request, appId, instanceId)
view = await createView(request, appId, instanceId, model._id)
})
describe("create", () => {

View File

@ -51,6 +51,12 @@ exports.createModel = async (request, appId, instanceId, model) => {
type: "string",
},
},
description: {
type: "text",
constraints: {
type: "string",
},
},
},
}
@ -61,9 +67,10 @@ exports.createModel = async (request, appId, instanceId, model) => {
return res.body
}
exports.createView = async (request, appId, instanceId, view) => {
exports.createView = async (request, appId, instanceId, modelId, view) => {
view = view || {
map: "function(doc) { emit(doc[doc.key], doc._id); } ",
modelId: modelId,
}
const res = await request

View File

@ -120,6 +120,10 @@ describe("/models", () => {
testModel = await createModel(request, app._id, instance._id, testModel)
});
afterEach(() => {
delete testModel._rev
});
it("returns all the models for that instance in the response body", done => {
request
.get(`/api/models`)

View File

@ -30,13 +30,12 @@ describe("/records", () => {
model = await createModel(request, app._id, instance._id)
record = {
name: "Test Contact",
description: "original description",
status: "new",
modelId: model._id
}
})
describe("save, load, update, delete", () => {
const createRecord = async r =>
await request
.post(`/api/${model._id}/records`)
@ -45,6 +44,17 @@ describe("/records", () => {
.expect('Content-Type', /json/)
.expect(200)
const loadRecord = async id =>
await request
.get(`/api/${model._id}/records/${id}`)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
describe("save, load, update, delete", () => {
it("returns a success message when the record is created", async () => {
const res = await createRecord()
expect(res.res.statusMessage).toEqual(`${model.name} created successfully`)
@ -144,6 +154,35 @@ describe("/records", () => {
})
})
describe("patch", () => {
it("should update only the fields that are supplied", async () => {
const rec = await createRecord()
const existing = rec.body
const res = await request
.patch(`/api/${model._id}/records/${existing._id}`)
.send({
_id: existing._id,
_rev: existing._rev,
modelId: model._id,
name: "Updated Name",
})
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
expect(res.res.statusMessage).toEqual(`${model.name} updated successfully.`)
expect(res.body.name).toEqual("Updated Name")
expect(res.body.description).toEqual(existing.description)
const savedRecord = await loadRecord(res.body._id)
expect(savedRecord.body.description).toEqual(existing.description)
expect(savedRecord.body.name).toEqual("Updated Name")
})
})
describe("validate", () => {
it("should return no errors on valid record", async () => {
const result = await request

View File

@ -72,8 +72,14 @@ describe("/views", () => {
type: "text",
constraints: {
type: "string"
}
}
},
},
description: {
type: "text",
constraints: {
type: "string"
},
},
}
}
});

View File

@ -23,7 +23,7 @@ const TEST_WORKFLOW = {
],
next: {
actionId: "abc123",
stepId: "abc123",
type: "SERVER",
conditions: {
}

View File

@ -1,21 +1,77 @@
const Router = require("@koa/router")
const controller = require("../controllers/workflow")
const authorized = require("../../middleware/authorized")
const joiValidator = require("../../middleware/joi-validator")
const { BUILDER } = require("../../utilities/accessLevels")
const Joi = require("joi")
const router = Router()
// prettier-ignore
function generateStepSchema(allowStepTypes) {
return Joi.object({
stepId: Joi.string().required(),
id: Joi.string().required(),
description: Joi.string().required(),
name: Joi.string().required(),
tagline: Joi.string().required(),
icon: Joi.string().required(),
params: Joi.object(),
// TODO: validate args a bit more deeply
args: Joi.object(),
type: Joi.string().required().valid(...allowStepTypes),
}).unknown(true)
}
// prettier-ignore
const workflowValidator = joiValidator.body(Joi.object({
live: Joi.bool(),
id: Joi.string().required(),
rev: Joi.string().required(),
name: Joi.string().required(),
type: Joi.string().valid("workflow").required(),
definition: Joi.object({
steps: Joi.array().required().items(generateStepSchema(["ACTION", "LOGIC"])),
trigger: generateStepSchema(["TRIGGER"]).required(),
}).required().unknown(true),
}).unknown(true))
router
.get(
"/api/workflows/trigger/list",
authorized(BUILDER),
controller.getTriggerList
)
.get(
"/api/workflows/action/list",
authorized(BUILDER),
controller.getActionList
)
.get(
"/api/workflows/logic/list",
authorized(BUILDER),
controller.getLogicList
)
.get(
"/api/workflows/definitions/list",
authorized(BUILDER),
controller.getDefinitionList
)
.get("/api/workflows", authorized(BUILDER), controller.fetch)
.get("/api/workflows/:id", authorized(BUILDER), controller.find)
.get(
"/api/workflows/:id/:action",
.put(
"/api/workflows",
authorized(BUILDER),
controller.fetchActionScript
workflowValidator,
controller.update
)
.put("/api/workflows", authorized(BUILDER), controller.update)
.post("/api/workflows", authorized(BUILDER), controller.create)
.post("/api/workflows/action", controller.executeAction)
.post(
"/api/workflows",
authorized(BUILDER),
workflowValidator,
controller.create
)
.post("/api/workflows/:id/trigger", controller.trigger)
.delete("/api/workflows/:id/:rev", authorized(BUILDER), controller.destroy)
module.exports = router

View File

@ -6,6 +6,7 @@ const http = require("http")
const api = require("./api")
const env = require("./environment")
const eventEmitter = require("./events")
const workflows = require("./workflows/index")
const Sentry = require("@sentry/node")
const app = new Koa()
@ -49,4 +50,5 @@ process.on("SIGINT", () => process.exit(1))
module.exports = server.listen(env.PORT || 4001, () => {
console.log(`Budibase running on ${JSON.stringify(server.address())}`)
workflows.init()
})

View File

@ -7,4 +7,5 @@ module.exports = {
COUCH_DB_URL: process.env.COUCH_DB_URL,
SALT_ROUNDS: process.env.SALT_ROUNDS,
LOGGER: process.env.LOGGER,
BUDIBASE_ENVIRONMENT: process.env.BUDIBASE_ENVIRONMENT,
}

View File

@ -1,33 +1,11 @@
const EventEmitter = require("events").EventEmitter
const CouchDB = require("../db")
const { Orchestrator, serverStrategy } = require("./workflow")
/**
* keeping event emitter in one central location as it might be used for things other than
* workflows (what it was for originally) - having a central emitter will be useful in the
* future.
*/
const emitter = new EventEmitter()
async function executeRelevantWorkflows(event, eventType) {
const db = new CouchDB(event.instanceId)
const workflowsToTrigger = await db.query("database/by_workflow_trigger", {
key: [eventType],
include_docs: true,
})
const workflows = workflowsToTrigger.rows.map(wf => wf.doc)
// Create orchestrator
const workflowOrchestrator = new Orchestrator()
workflowOrchestrator.strategy = serverStrategy
for (let workflow of workflows) {
workflowOrchestrator.execute(workflow, event)
}
}
emitter.on("record:save", async function(event) {
await executeRelevantWorkflows(event, "record:save")
})
emitter.on("record:delete", async function(event) {
await executeRelevantWorkflows(event, "record:delete")
})
module.exports = emitter

View File

@ -1,54 +0,0 @@
const mustache = require("mustache")
/**
* 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.
*
*/
exports.Orchestrator = class Orchestrator {
set strategy(strategy) {
this._strategy = strategy()
}
async execute(workflow, context) {
if (workflow.live) {
this._strategy.run(workflow.definition, context)
}
}
}
exports.serverStrategy = () => ({
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
mappedArgs[arg] = mustache.render(argValue, { context: this.context })
}
return mappedArgs
},
run: async function(workflow, context) {
for (let block of workflow.steps) {
if (block.type === "CLIENT") continue
const action = require(`../api/controllers/workflow/actions/${block.actionId}`)
const response = await action({
args: this.bindContextArgs(block.args),
context,
})
this.context = {
...this.context,
[block.id]: response,
}
}
},
})

View File

@ -0,0 +1,16 @@
function validate(schema, property) {
// Return a Koa middleware function
return (ctx, next) => {
if (schema) {
const { error } = schema.validate(ctx[property])
if (error) {
ctx.throw(400, `Invalid ${property} - ${error.message}`)
}
}
return next()
}
}
module.exports.body = schema => {
return validate(schema, "body")
}

View File

@ -0,0 +1,112 @@
const userController = require("../api/controllers/user")
const recordController = require("../api/controllers/record")
const sgMail = require("@sendgrid/mail")
sgMail.setApiKey(process.env.SENDGRID_API_KEY)
let BUILTIN_ACTIONS = {
CREATE_USER: async function({ args, context }) {
const { username, password, accessLevelId } = args
const ctx = {
user: {
instanceId: context.instanceId,
},
request: {
body: { username, password, accessLevelId },
},
}
try {
const response = await userController.create(ctx)
return {
user: response,
}
} catch (err) {
console.error(err)
return {
user: null,
}
}
},
SAVE_RECORD: async function({ args, context }) {
const { model, ...record } = args.record
const ctx = {
params: {
instanceId: context.instanceId,
modelId: model._id,
},
request: {
body: record,
},
user: { instanceId: context.instanceId },
}
try {
await recordController.save(ctx)
return {
record: ctx.body,
}
} catch (err) {
console.error(err)
return {
record: null,
error: err.message,
}
}
},
SEND_EMAIL: async function({ 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,
}
}
},
DELETE_RECORD: async function({ args, context }) {
const { model, ...record } = args.record
// TODO: better logging of when actions are missed due to missing parameters
if (record.recordId == null || record.revId == null) {
return
}
let ctx = {
params: {
modelId: model._id,
recordId: record.recordId,
revId: record.revId,
},
user: { instanceId: context.instanceId },
}
try {
await recordController.destroy(ctx)
} catch (err) {
console.error(err)
return {
record: null,
error: err.message,
}
}
},
}
module.exports.getAction = async function(actionName) {
if (BUILTIN_ACTIONS[actionName] != null) {
return BUILTIN_ACTIONS[actionName]
}
// TODO: load async actions here
}

View File

@ -0,0 +1,31 @@
const triggers = require("./triggers")
const environment = require("../environment")
const workerFarm = require("worker-farm")
const singleThread = require("./thread")
let workers = workerFarm(require.resolve("./thread"))
function runWorker(job) {
return new Promise((resolve, reject) => {
workers(job, err => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
/**
* This module is built purely to kick off the worker farm and manage the inputs/outputs
*/
module.exports.init = function() {
triggers.workflowQueue.process(async job => {
if (environment.BUDIBASE_ENVIRONMENT === "PRODUCTION") {
await runWorker(job)
} else {
await singleThread(job)
}
})
}

View File

@ -0,0 +1,24 @@
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
let LOGIC = {
DELAY: async function delay({ args }) {
await wait(args.time)
},
FILTER: async function filter({ args }) {
const { field, condition, value } = args
switch (condition) {
case "equals":
if (field !== value) return
break
default:
return
}
},
}
module.exports.getLogic = function(logicName) {
if (LOGIC[logicName] != null) {
return LOGIC[logicName]
}
}

View File

@ -0,0 +1,44 @@
let events = require("events")
// Bull works with a Job wrapper around all messages that contains a lot more information about
// the state of the message, implement this for the sake of maintaining API consistency
function newJob(queue, message) {
return {
timestamp: Date.now(),
queue: queue,
data: message,
}
}
// designed to replicate Bull (https://github.com/OptimalBits/bull) in memory as a sort of mock
class InMemoryQueue {
// opts is not used by this as there is no real use case when in memory, but is the same API as Bull
constructor(name, opts) {
this._name = name
this._opts = opts
this._messages = []
this._emitter = new events.EventEmitter()
}
// same API as bull, provide a callback and it will respond when messages are available
process(func) {
this._emitter.on("message", async () => {
if (this._messages.length <= 0) {
return
}
let msg = this._messages.shift()
let resp = func(msg)
if (resp.then != null) {
await resp
}
})
}
// simply puts a message to the queue and emits to the queue for processing
add(msg) {
this._messages.push(newJob(this._name, msg))
this._emitter.emit("message")
}
}
module.exports = InMemoryQueue

View File

@ -0,0 +1,68 @@
const mustache = require("mustache")
const actions = require("./actions")
const logic = require("./logic")
/**
* The workflow orchestrator is a class responsible for executing workflows.
* It handles the context of the workflow and makes sure each step gets the correct
* inputs and handles any outputs.
*/
class Orchestrator {
constructor(workflow) {
this._context = {}
this._workflow = workflow
}
async getStep(type, stepId) {
let step = null
if (type === "ACTION") {
step = await actions.getAction(stepId)
} else if (type === "LOGIC") {
step = logic.getLogic(stepId)
}
if (step == null) {
throw `Cannot find workflow step by name ${stepId}`
}
return step
}
async execute(context) {
let workflow = this._workflow
for (let block of workflow.definition.steps) {
let step = await this.getStep(block.type, block.stepId)
let args = { ...block.args }
// bind the workflow action args to the workflow context, if required
for (let arg of Object.keys(args)) {
const argValue = args[arg]
// We don't want to render mustache templates on non-strings
if (typeof argValue !== "string") continue
args[arg] = mustache.render(argValue, { context: this._context })
}
const response = await step({
args,
context,
})
this._context = {
...this._context,
[block.id]: response,
}
}
}
}
// callback is required for worker-farm to state that the worker thread has completed
module.exports = async (job, cb = null) => {
try {
const workflowOrchestrator = new Orchestrator(job.data.workflow)
await workflowOrchestrator.execute(job.data.event)
if (cb) {
cb()
}
} catch (err) {
if (cb) {
cb(err)
}
}
}

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