|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 105 KiB |
@ -0,0 +1,95 @@ |
|||
import mustache from "mustache" |
|||
import blockDefinitions from "components/workflow/WorkflowPanel/blockDefinitions" |
|||
import { generate } from "shortid" |
|||
|
|||
/** |
|||
* Class responsible for the traversing of the workflow definition. |
|||
* Workflow definitions are stored in linked lists. |
|||
*/ |
|||
export default class Workflow { |
|||
constructor(workflow) { |
|||
this.workflow = workflow |
|||
} |
|||
|
|||
hasTrigger() { |
|||
return this.workflow.definition.trigger |
|||
} |
|||
|
|||
addBlock(block) { |
|||
// Make sure to add trigger if doesn't exist
|
|||
if (!this.hasTrigger() && block.type === "TRIGGER") { |
|||
this.workflow.definition.trigger = { id: generate(), ...block } |
|||
return |
|||
} |
|||
|
|||
this.workflow.definition.steps.push({ |
|||
id: generate(), |
|||
...block, |
|||
}) |
|||
} |
|||
|
|||
updateBlock(updatedBlock, id) { |
|||
const { steps, trigger } = this.workflow.definition |
|||
|
|||
if (trigger && trigger.id === id) { |
|||
this.workflow.definition.trigger = null |
|||
return |
|||
} |
|||
|
|||
const stepIdx = steps.findIndex(step => step.id === id) |
|||
if (stepIdx < 0) throw new Error("Block not found.") |
|||
steps.splice(stepIdx, 1, updatedBlock) |
|||
} |
|||
|
|||
deleteBlock(id) { |
|||
const { steps, trigger } = this.workflow.definition |
|||
|
|||
if (trigger && trigger.id === id) { |
|||
this.workflow.definition.trigger = null |
|||
return |
|||
} |
|||
|
|||
const stepIdx = steps.findIndex(step => step.id === id) |
|||
if (stepIdx < 0) throw new Error("Block not found.") |
|||
steps.splice(stepIdx, 1) |
|||
} |
|||
|
|||
createUiTree() { |
|||
if (!this.workflow.definition) return [] |
|||
return Workflow.buildUiTree(this.workflow.definition) |
|||
} |
|||
|
|||
static buildUiTree(definition) { |
|||
const steps = [] |
|||
if (definition.trigger) steps.push(definition.trigger) |
|||
|
|||
return [...steps, ...definition.steps].map(step => { |
|||
// The client side display definition for the block
|
|||
const definition = blockDefinitions[step.type][step.actionId] |
|||
if (!definition) { |
|||
throw new Error( |
|||
`No block definition exists for the chosen block. Check there's an entry in the block definitions for ${step.actionId}` |
|||
) |
|||
} |
|||
|
|||
if (!definition.params) { |
|||
throw new Error( |
|||
`Blocks should always have parameters. Ensure that the block definition is correct for ${step.actionId}` |
|||
) |
|||
} |
|||
|
|||
const tagline = definition.tagline || "" |
|||
const args = step.args || {} |
|||
|
|||
return { |
|||
id: step.id, |
|||
type: step.type, |
|||
params: step.params, |
|||
args, |
|||
heading: step.actionId, |
|||
body: mustache.render(tagline, args), |
|||
name: definition.name, |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
@ -0,0 +1,106 @@ |
|||
import { writable } from "svelte/store" |
|||
import api from "../../api" |
|||
import Workflow from "./Workflow" |
|||
|
|||
const workflowActions = store => ({ |
|||
fetch: async instanceId => { |
|||
const WORKFLOWS_URL = `/api/${instanceId}/workflows` |
|||
const workflowResponse = await api.get(WORKFLOWS_URL) |
|||
const json = await workflowResponse.json() |
|||
store.update(state => { |
|||
state.workflows = json |
|||
return state |
|||
}) |
|||
}, |
|||
create: async ({ instanceId, name }) => { |
|||
const workflow = { |
|||
name, |
|||
definition: { |
|||
steps: [], |
|||
}, |
|||
} |
|||
const CREATE_WORKFLOW_URL = `/api/${instanceId}/workflows` |
|||
const response = await api.post(CREATE_WORKFLOW_URL, workflow) |
|||
const json = await response.json() |
|||
store.update(state => { |
|||
state.workflows = state.workflows.concat(json.workflow) |
|||
state.currentWorkflow = new Workflow(json.workflow) |
|||
return state |
|||
}) |
|||
}, |
|||
save: async ({ instanceId, workflow }) => { |
|||
const UPDATE_WORKFLOW_URL = `/api/${instanceId}/workflows` |
|||
const response = await api.put(UPDATE_WORKFLOW_URL, workflow) |
|||
const json = await response.json() |
|||
store.update(state => { |
|||
const existingIdx = state.workflows.findIndex( |
|||
existing => existing._id === workflow._id |
|||
) |
|||
state.workflows.splice(existingIdx, 1, json.workflow) |
|||
state.workflows = state.workflows |
|||
state.currentWorkflow = new Workflow(json.workflow) |
|||
return state |
|||
}) |
|||
}, |
|||
update: async ({ instanceId, workflow }) => { |
|||
const UPDATE_WORKFLOW_URL = `/api/${instanceId}/workflows` |
|||
const response = await api.put(UPDATE_WORKFLOW_URL, workflow) |
|||
const json = await response.json() |
|||
store.update(state => { |
|||
const existingIdx = state.workflows.findIndex( |
|||
existing => existing._id === workflow._id |
|||
) |
|||
state.workflows.splice(existingIdx, 1, json.workflow) |
|||
state.workflows = state.workflows |
|||
return state |
|||
}) |
|||
}, |
|||
delete: async ({ instanceId, workflow }) => { |
|||
const { _id, _rev } = workflow |
|||
const DELETE_WORKFLOW_URL = `/api/${instanceId}/workflows/${_id}/${_rev}` |
|||
await api.delete(DELETE_WORKFLOW_URL) |
|||
|
|||
store.update(state => { |
|||
const existingIdx = state.workflows.findIndex( |
|||
existing => existing._id === _id |
|||
) |
|||
state.workflows.splice(existingIdx, 1) |
|||
state.workflows = state.workflows |
|||
state.currentWorkflow = null |
|||
return state |
|||
}) |
|||
}, |
|||
select: workflow => { |
|||
store.update(state => { |
|||
state.currentWorkflow = new Workflow(workflow) |
|||
state.selectedWorkflowBlock = null |
|||
return state |
|||
}) |
|||
}, |
|||
addBlockToWorkflow: block => { |
|||
store.update(state => { |
|||
state.currentWorkflow.addBlock(block) |
|||
state.selectedWorkflowBlock = block |
|||
return state |
|||
}) |
|||
}, |
|||
deleteWorkflowBlock: block => { |
|||
store.update(state => { |
|||
state.currentWorkflow.deleteBlock(block.id) |
|||
state.selectedWorkflowBlock = null |
|||
return state |
|||
}) |
|||
}, |
|||
}) |
|||
|
|||
export const getWorkflowStore = () => { |
|||
const INITIAL_WORKFLOW_STATE = { |
|||
workflows: [], |
|||
} |
|||
|
|||
const store = writable(INITIAL_WORKFLOW_STATE) |
|||
|
|||
store.actions = workflowActions(store) |
|||
|
|||
return store |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
import Workflow from "../Workflow"; |
|||
import TEST_WORKFLOW from "./testWorkflow"; |
|||
|
|||
const TEST_BLOCK = { |
|||
id: "VFWeZcIPx", |
|||
name: "Update UI State", |
|||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>", |
|||
icon: "ri-refresh-line", |
|||
description: "Update your User Interface with some data.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
path: "string", |
|||
value: "longText", |
|||
}, |
|||
args: { |
|||
path: "foo", |
|||
value: "started...", |
|||
}, |
|||
actionId: "SET_STATE", |
|||
type: "ACTION", |
|||
} |
|||
|
|||
describe("Workflow Data Object", () => { |
|||
let workflow |
|||
|
|||
beforeEach(() => { |
|||
workflow = new Workflow({ ...TEST_WORKFLOW }); |
|||
}); |
|||
|
|||
it("adds a workflow block to the workflow", () => { |
|||
workflow.addBlock(TEST_BLOCK); |
|||
expect(workflow.workflow.definition) |
|||
}) |
|||
|
|||
it("updates a workflow block with new attributes", () => { |
|||
const firstBlock = workflow.workflow.definition.steps[0]; |
|||
const updatedBlock = { |
|||
...firstBlock, |
|||
name: "UPDATED" |
|||
}; |
|||
workflow.updateBlock(updatedBlock, firstBlock.id); |
|||
expect(workflow.workflow.definition.steps[0]).toEqual(updatedBlock) |
|||
}) |
|||
|
|||
it("deletes a workflow block successfully", () => { |
|||
const { steps } = workflow.workflow.definition |
|||
const originalLength = steps.length |
|||
|
|||
const lastBlock = steps[steps.length - 1]; |
|||
workflow.deleteBlock(lastBlock.id); |
|||
expect(workflow.workflow.definition.steps.length).toBeLessThan(originalLength); |
|||
}) |
|||
|
|||
it("builds a tree that gets rendered in the flowchart builder", () => { |
|||
expect(Workflow.buildUiTree(TEST_WORKFLOW.definition)).toMatchSnapshot(); |
|||
}) |
|||
}) |
|||
@ -0,0 +1,49 @@ |
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
|||
|
|||
exports[`Workflow Data Object builds a tree that gets rendered in the flowchart builder 1`] = ` |
|||
Array [ |
|||
Object { |
|||
"args": Object { |
|||
"time": 3000, |
|||
}, |
|||
"body": "Delay for <b>3000</b> milliseconds", |
|||
"heading": "DELAY", |
|||
"id": "zJQcZUgDS", |
|||
"name": "Delay", |
|||
"params": Object { |
|||
"time": "number", |
|||
}, |
|||
"type": "LOGIC", |
|||
}, |
|||
Object { |
|||
"args": Object { |
|||
"path": "foo", |
|||
"value": "finished", |
|||
}, |
|||
"body": "Update <b>foo</b> to <b>finished</b>", |
|||
"heading": "SET_STATE", |
|||
"id": "3RSTO7BMB", |
|||
"name": "Update UI State", |
|||
"params": Object { |
|||
"path": "string", |
|||
"value": "longText", |
|||
}, |
|||
"type": "ACTION", |
|||
}, |
|||
Object { |
|||
"args": Object { |
|||
"path": "foo", |
|||
"value": "started...", |
|||
}, |
|||
"body": "Update <b>foo</b> to <b>started...</b>", |
|||
"heading": "SET_STATE", |
|||
"id": "VFWeZcIPx", |
|||
"name": "Update UI State", |
|||
"params": Object { |
|||
"path": "string", |
|||
"value": "longText", |
|||
}, |
|||
"type": "ACTION", |
|||
}, |
|||
] |
|||
`; |
|||
@ -0,0 +1,63 @@ |
|||
export default { |
|||
_id: "53b6148c65d1429c987e046852d11611", |
|||
_rev: "4-02c6659734934895812fa7be0215ee59", |
|||
name: "Test Workflow", |
|||
definition: { |
|||
steps: [ |
|||
{ |
|||
id: "VFWeZcIPx", |
|||
name: "Update UI State", |
|||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>", |
|||
icon: "ri-refresh-line", |
|||
description: "Update your User Interface with some data.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
path: "string", |
|||
value: "longText", |
|||
}, |
|||
args: { |
|||
path: "foo", |
|||
value: "started...", |
|||
}, |
|||
actionId: "SET_STATE", |
|||
type: "ACTION", |
|||
}, |
|||
{ |
|||
id: "zJQcZUgDS", |
|||
name: "Delay", |
|||
icon: "ri-time-fill", |
|||
tagline: "Delay for <b>{{time}}</b> milliseconds", |
|||
description: "Delay the workflow until an amount of time has passed.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
time: "number", |
|||
}, |
|||
args: { |
|||
time: 3000, |
|||
}, |
|||
actionId: "DELAY", |
|||
type: "LOGIC", |
|||
}, |
|||
{ |
|||
id: "3RSTO7BMB", |
|||
name: "Update UI State", |
|||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>", |
|||
icon: "ri-refresh-line", |
|||
description: "Update your User Interface with some data.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
path: "string", |
|||
value: "longText", |
|||
}, |
|||
args: { |
|||
path: "foo", |
|||
value: "finished", |
|||
}, |
|||
actionId: "SET_STATE", |
|||
type: "ACTION", |
|||
}, |
|||
], |
|||
}, |
|||
type: "workflow", |
|||
live: true, |
|||
} |
|||
@ -0,0 +1,80 @@ |
|||
import { makePropsSafe } from "components/userInterface/pagesParsing/createProps" |
|||
import api from "./api" |
|||
import { generate_screen_css } from "./generate_css" |
|||
|
|||
export const selectComponent = (state, component) => { |
|||
const componentDef = component._component.startsWith("##") |
|||
? component |
|||
: state.components[component._component] |
|||
state.currentComponentInfo = makePropsSafe(componentDef, component) |
|||
state.currentView = "component" |
|||
return state |
|||
} |
|||
|
|||
export const getParent = (rootProps, child) => { |
|||
let parent |
|||
walkProps(rootProps, (p, breakWalk) => { |
|||
if ( |
|||
p._children && |
|||
(p._children.includes(child) || p._children.some(c => c._id === child)) |
|||
) { |
|||
parent = p |
|||
breakWalk() |
|||
} |
|||
}) |
|||
return parent |
|||
} |
|||
|
|||
export const saveCurrentPreviewItem = s => |
|||
s.currentFrontEndType === "page" |
|||
? savePage(s) |
|||
: saveScreenApi(s.currentPreviewItem, s) |
|||
|
|||
export const savePage = async s => { |
|||
const page = s.pages[s.currentPageName] |
|||
await api.post(`/_builder/api/${s.appId}/pages/${s.currentPageName}`, { |
|||
page: { componentLibraries: s.pages.componentLibraries, ...page }, |
|||
uiFunctions: s.currentPageFunctions, |
|||
screens: page._screens, |
|||
}) |
|||
} |
|||
|
|||
export const saveScreenApi = (screen, s) => { |
|||
api |
|||
.post(`/_builder/api/${s.appId}/pages/${s.currentPageName}/screen`, screen) |
|||
.then(() => savePage(s)) |
|||
} |
|||
|
|||
export const renameCurrentScreen = (newname, state) => { |
|||
const oldname = state.currentPreviewItem.name |
|||
state.currentPreviewItem.name = newname |
|||
api.patch( |
|||
`/_builder/api/${state.appId}/pages/${state.currentPageName}/screen`, |
|||
{ |
|||
oldname, |
|||
newname, |
|||
} |
|||
) |
|||
return state |
|||
} |
|||
|
|||
export const walkProps = (props, action, cancelToken = null) => { |
|||
cancelToken = cancelToken || { cancelled: false } |
|||
action(props, () => { |
|||
cancelToken.cancelled = true |
|||
}) |
|||
|
|||
if (props._children) { |
|||
for (let child of props._children) { |
|||
if (cancelToken.cancelled) return |
|||
walkProps(child, action, cancelToken) |
|||
} |
|||
} |
|||
} |
|||
|
|||
export const regenerateCssForCurrentScreen = state => { |
|||
state.currentPreviewItem._css = generate_screen_css([ |
|||
state.currentPreviewItem.props, |
|||
]) |
|||
return state |
|||
} |
|||
|
After Width: | Height: | Size: 419 B |
@ -1,30 +1,61 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import { buildStyle } from "../../helpers.js" |
|||
export let value = "" |
|||
export let width = "" |
|||
export let textAlign = "left" |
|||
export let width = "160px" |
|||
export let placeholder = "" |
|||
export let suffix = "" |
|||
export let onChange = val => {} |
|||
|
|||
let style = { width } |
|||
let centerPlaceholder = textAlign === "center" |
|||
|
|||
let style = buildStyle({ width, textAlign }) |
|||
|
|||
function handleChange(val) { |
|||
value = val |
|||
let _value = value !== "auto" ? value + suffix : value |
|||
onChange(_value) |
|||
} |
|||
|
|||
$: displayValue = |
|||
suffix && value && value.endsWith(suffix) |
|||
? value.replace(new RegExp(`${suffix}$`), "") |
|||
: value || "" |
|||
</script> |
|||
|
|||
<input type="text" style={`width: ${width};`} on:change bind:value /> |
|||
<input |
|||
class:centerPlaceholder |
|||
type="text" |
|||
value={displayValue} |
|||
{placeholder} |
|||
{style} |
|||
on:change={e => handleChange(e.target.value)} /> |
|||
|
|||
<style> |
|||
input { |
|||
display: block; |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
/* width: 32px; */ |
|||
height: 32px; |
|||
font-size: 12px; |
|||
font-weight: 700; |
|||
margin: 0px 0px 0px 1px; |
|||
color: var(--ink); |
|||
opacity: 0.7; |
|||
padding: 0px 4px; |
|||
line-height: 1.3; |
|||
padding: 12px; |
|||
/* padding: 12px; */ |
|||
width: 164px; |
|||
float: right; |
|||
max-width: 100%; |
|||
box-sizing: border-box; |
|||
margin: 0; |
|||
-moz-appearance: none; |
|||
-webkit-appearance: none; |
|||
appearance: none; |
|||
background: #fff; |
|||
border: 1px solid var(--grey-dark); |
|||
height: 32px; |
|||
border: 1px solid var(--grey); |
|||
border-radius: 2px; |
|||
outline: none; |
|||
} |
|||
|
|||
input::placeholder { |
|||
text-align: left; |
|||
} |
|||
|
|||
.centerPlaceholder::placeholder { |
|||
text-align: center; |
|||
} |
|||
</style> |
|||
|
|||
@ -1,74 +1,50 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import Input from "../Input.svelte" |
|||
|
|||
export let meta = [] |
|||
export let label = "" |
|||
export let value = [0, 0, 0, 0] |
|||
export let type = "number" |
|||
export let value = ["0", "0", "0", "0"] |
|||
export let suffix = "" |
|||
|
|||
export let onChange = () => {} |
|||
|
|||
function handleChange(val, idx) { |
|||
value.splice(idx, 1, val) |
|||
value.splice(idx, 1, val !== "auto" && suffix ? val + suffix : val) |
|||
|
|||
value = value |
|||
onChange(value) |
|||
let _value = value.map(v => |
|||
suffix && !v.endsWith(suffix) && v !== "auto" ? v + suffix : v |
|||
) |
|||
onChange(_value) |
|||
} |
|||
|
|||
$: displayValues = |
|||
value && suffix |
|||
? value.map(v => v.replace(new RegExp(`${suffix}$`), "")) |
|||
: value || [] |
|||
</script> |
|||
|
|||
<div class="input-container"> |
|||
<div class="label">{label}</div> |
|||
<div class="inputs"> |
|||
{#each meta as { placeholder }, i} |
|||
<input |
|||
{type} |
|||
placeholder={placeholder || ''} |
|||
value={!value || value[i] === 0 ? '' : value[i]} |
|||
on:change={e => handleChange(e.target.value || 0, i)} /> |
|||
<div class="inputs-group"> |
|||
{#each meta as m, i} |
|||
<Input |
|||
width="37px" |
|||
textAlign="center" |
|||
placeholder={m.placeholder || ''} |
|||
value={!displayValues || displayValues[i] === '0' ? '' : displayValues[i]} |
|||
onChange={value => handleChange(value || 0, i)} /> |
|||
{/each} |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
.input-container { |
|||
} |
|||
|
|||
.label { |
|||
flex: 0; |
|||
} |
|||
|
|||
.inputs { |
|||
.inputs-group { |
|||
flex: 1; |
|||
} |
|||
|
|||
input { |
|||
width: 40px; |
|||
height: 32px; |
|||
font-size: 12px; |
|||
font-weight: 700; |
|||
margin: 0px 0px 0px 1px; |
|||
text-align: center; |
|||
color: var(--ink); |
|||
opacity: 0.7; |
|||
padding: 0px 4px; |
|||
box-sizing: border-box; |
|||
border: 1px solid var(--grey); |
|||
border-radius: 2px; |
|||
outline: none; |
|||
float: right; |
|||
} |
|||
|
|||
input[type="number"]::-webkit-inner-spin-button, |
|||
input[type="number"]::-webkit-outer-spin-button { |
|||
-webkit-appearance: none; |
|||
-moz-appearance: none; |
|||
appearance: none; |
|||
margin: 0; |
|||
} |
|||
|
|||
input[type="number"] { |
|||
-moz-appearance: textfield; |
|||
} |
|||
|
|||
input::placeholder { |
|||
text-align: center; |
|||
} |
|||
</style> |
|||
|
|||
@ -0,0 +1,78 @@ |
|||
<script> |
|||
export let tabs = [] |
|||
export const selectTab = tabName => { |
|||
selected = tabName |
|||
selectedIndex = tabs.indexOf(selected) |
|||
} |
|||
|
|||
let selected = tabs.length > 0 && tabs[0] |
|||
let selectedIndex = 0 |
|||
|
|||
const isSelected = tab => selected === tab |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
|
|||
<div class="switcher"> |
|||
|
|||
{#each tabs as tab} |
|||
<button class:selected={selected === tab} on:click={() => selectTab(tab)}> |
|||
{tab} |
|||
</button> |
|||
{/each} |
|||
|
|||
</div> |
|||
|
|||
<div class="panel"> |
|||
{#if selectedIndex === 0} |
|||
<slot name="0" /> |
|||
{:else if selectedIndex === 1} |
|||
<slot name="1" /> |
|||
{:else if selectedIndex === 2} |
|||
<slot name="2" /> |
|||
{:else if selectedIndex === 3} |
|||
<slot name="3" /> |
|||
{/if} |
|||
</div> |
|||
|
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
height: 100%; |
|||
display: flex; |
|||
flex-direction: column; |
|||
padding: 20px 20px; |
|||
border-left: solid 1px var(--grey); |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
.switcher { |
|||
display: flex; |
|||
margin: 0px 20px 20px 0px; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
.switcher > button { |
|||
display: inline-block; |
|||
border: none; |
|||
margin: 0; |
|||
padding: 0; |
|||
cursor: pointer; |
|||
font-size: 18px; |
|||
font-weight: 700; |
|||
color: var(--ink-lighter); |
|||
margin-right: 20px; |
|||
background: none; |
|||
} |
|||
|
|||
.switcher > .selected { |
|||
color: var(--ink); |
|||
} |
|||
|
|||
.panel { |
|||
min-height: 0; |
|||
height: 100%; |
|||
overflow-y: auto; |
|||
} |
|||
</style> |
|||
@ -1,31 +0,0 @@ |
|||
import { isString } from "lodash/fp" |
|||
|
|||
import { |
|||
BB_STATE_BINDINGPATH, |
|||
BB_STATE_FALLBACK, |
|||
BB_STATE_BINDINGSOURCE, |
|||
isBound, |
|||
parseBinding, |
|||
} from "@budibase/client/src/state/parseBinding" |
|||
|
|||
export const isBinding = isBound |
|||
|
|||
export const setBinding = ({ path, fallback, source }, binding = {}) => { |
|||
if (isNonEmptyString(path)) binding[BB_STATE_BINDINGPATH] = path |
|||
if (isNonEmptyString(fallback)) binding[BB_STATE_FALLBACK] = fallback |
|||
binding[BB_STATE_BINDINGSOURCE] = source || "store" |
|||
return binding |
|||
} |
|||
|
|||
export const getBinding = val => { |
|||
const binding = parseBinding(val) |
|||
return binding |
|||
? binding |
|||
: { |
|||
path: "", |
|||
source: "store", |
|||
fallback: "", |
|||
} |
|||
} |
|||
|
|||
const isNonEmptyString = s => isString(s) && s.length > 0 |
|||
@ -0,0 +1,277 @@ |
|||
<script> |
|||
import { MoreIcon } from "components/common/Icons" |
|||
import { store } from "builderStore" |
|||
import { getComponentDefinition } from "builderStore/store" |
|||
import ConfirmDialog from "components/common/ConfirmDialog.svelte" |
|||
import { last, cloneDeep } from "lodash/fp" |
|||
import UIkit from "uikit" |
|||
import { |
|||
selectComponent, |
|||
getParent, |
|||
walkProps, |
|||
saveCurrentPreviewItem, |
|||
regenerateCssForCurrentScreen, |
|||
} from "builderStore/storeUtils" |
|||
import { uuid } from "builderStore/uuid" |
|||
|
|||
export let component |
|||
|
|||
let confirmDeleteDialog |
|||
let dropdownEl |
|||
|
|||
$: dropdown = UIkit.dropdown(dropdownEl, { |
|||
mode: "click", |
|||
offset: 0, |
|||
pos: "bottom-right", |
|||
"delay-hide": 0, |
|||
animation: false, |
|||
}) |
|||
$: dropdown && UIkit.util.on(dropdown, "shown", () => (hidden = false)) |
|||
$: noChildrenAllowed = |
|||
!component || |
|||
getComponentDefinition($store, component._component).children === false |
|||
$: noPaste = !$store.componentToPaste |
|||
|
|||
const lastPartOfName = c => (c ? last(c._component.split("/")) : "") |
|||
|
|||
const hideDropdown = () => { |
|||
dropdown.hide() |
|||
} |
|||
|
|||
const moveUpComponent = () => { |
|||
store.update(s => { |
|||
const parent = getParent(s.currentPreviewItem.props, component) |
|||
|
|||
if (parent) { |
|||
const currentIndex = parent._children.indexOf(component) |
|||
if (currentIndex === 0) return s |
|||
|
|||
const newChildren = parent._children.filter(c => c !== component) |
|||
newChildren.splice(currentIndex - 1, 0, component) |
|||
parent._children = newChildren |
|||
} |
|||
s.currentComponentInfo = component |
|||
saveCurrentPreviewItem(s) |
|||
|
|||
return s |
|||
}) |
|||
} |
|||
|
|||
const moveDownComponent = () => { |
|||
store.update(s => { |
|||
const parent = getParent(s.currentPreviewItem.props, component) |
|||
|
|||
if (parent) { |
|||
const currentIndex = parent._children.indexOf(component) |
|||
if (currentIndex === parent._children.length - 1) return s |
|||
|
|||
const newChildren = parent._children.filter(c => c !== component) |
|||
newChildren.splice(currentIndex + 1, 0, component) |
|||
parent._children = newChildren |
|||
} |
|||
s.currentComponentInfo = component |
|||
saveCurrentPreviewItem(s) |
|||
|
|||
return s |
|||
}) |
|||
} |
|||
|
|||
const copyComponent = () => { |
|||
store.update(s => { |
|||
const parent = getParent(s.currentPreviewItem.props, component) |
|||
const copiedComponent = cloneDeep(component) |
|||
walkProps(copiedComponent, p => { |
|||
p._id = uuid() |
|||
}) |
|||
parent._children = [...parent._children, copiedComponent] |
|||
saveCurrentPreviewItem(s) |
|||
s.currentComponentInfo = copiedComponent |
|||
regenerateCssForCurrentScreen(s) |
|||
return s |
|||
}) |
|||
} |
|||
|
|||
const deleteComponent = () => { |
|||
store.update(state => { |
|||
const parent = getParent(state.currentPreviewItem.props, component) |
|||
|
|||
if (parent) { |
|||
parent._children = parent._children.filter(c => c !== component) |
|||
} |
|||
|
|||
saveCurrentPreviewItem(state) |
|||
|
|||
return state |
|||
}) |
|||
} |
|||
|
|||
const generateNewIdsForComponent = c => |
|||
walkProps(c, p => { |
|||
p._id = uuid() |
|||
}) |
|||
|
|||
const storeComponentForCopy = (cut = false) => { |
|||
store.update(s => { |
|||
const copiedComponent = cloneDeep(component) |
|||
s.componentToPaste = copiedComponent |
|||
if (cut) { |
|||
const parent = getParent(s.currentPreviewItem.props, component._id) |
|||
parent._children = parent._children.filter(c => c._id !== component._id) |
|||
selectComponent(s, parent) |
|||
} |
|||
|
|||
return s |
|||
}) |
|||
} |
|||
|
|||
const pasteComponent = mode => { |
|||
store.update(s => { |
|||
if (!s.componentToPaste) return s |
|||
|
|||
const componentToPaste = cloneDeep(s.componentToPaste) |
|||
generateNewIdsForComponent(componentToPaste) |
|||
delete componentToPaste._cutId |
|||
|
|||
if (mode === "inside") { |
|||
component._children.push(componentToPaste) |
|||
return s |
|||
} |
|||
|
|||
const parent = getParent(s.currentPreviewItem.props, component) |
|||
|
|||
const targetIndex = parent._children.indexOf(component) |
|||
const index = mode === "above" ? targetIndex : targetIndex + 1 |
|||
parent._children.splice(index, 0, cloneDeep(componentToPaste)) |
|||
regenerateCssForCurrentScreen(s) |
|||
saveCurrentPreviewItem(s) |
|||
selectComponent(s, componentToPaste) |
|||
|
|||
return s |
|||
}) |
|||
} |
|||
</script> |
|||
|
|||
<div class="root boundary" on:click|stopPropagation={() => {}}> |
|||
<button> |
|||
<MoreIcon /> |
|||
</button> |
|||
<ul class="menu" bind:this={dropdownEl} on:click={hideDropdown}> |
|||
<li class="item" on:click={() => confirmDeleteDialog.show()}> |
|||
<i class="icon ri-delete-bin-2-line" /> |
|||
Delete |
|||
</li> |
|||
<li class="item" on:click={moveUpComponent}> |
|||
<i class="icon ri-arrow-up-line" /> |
|||
Move up |
|||
</li> |
|||
<li class="item" on:click={moveDownComponent}> |
|||
<i class="icon ri-arrow-down-line" /> |
|||
Move down |
|||
</li> |
|||
<li class="item" on:click={copyComponent}> |
|||
<i class="icon ri-repeat-one-line" /> |
|||
Duplicate |
|||
</li> |
|||
<li class="item" on:click={() => storeComponentForCopy(true)}> |
|||
<i class="icon ri-scissors-cut-line" /> |
|||
Cut |
|||
</li> |
|||
<li class="item" on:click={() => storeComponentForCopy(false)}> |
|||
<i class="icon ri-file-copy-line" /> |
|||
Copy |
|||
</li> |
|||
<hr class="hr-style" /> |
|||
<li |
|||
class="item" |
|||
class:disabled={noPaste} |
|||
on:click={() => pasteComponent('above')}> |
|||
<i class="icon ri-insert-row-top" /> |
|||
Paste above |
|||
</li> |
|||
<li |
|||
class="item" |
|||
class:disabled={noPaste} |
|||
on:click={() => pasteComponent('below')}> |
|||
<i class="icon ri-insert-row-bottom" /> |
|||
Paste below |
|||
</li> |
|||
<li |
|||
class="item" |
|||
class:disabled={noPaste || noChildrenAllowed} |
|||
on:click={() => pasteComponent('inside')}> |
|||
<i class="icon ri-insert-column-right" /> |
|||
Paste inside |
|||
</li> |
|||
</ul> |
|||
</div> |
|||
|
|||
<ConfirmDialog |
|||
bind:this={confirmDeleteDialog} |
|||
title="Confirm Delete" |
|||
body={`Are you sure you wish to delete this '${lastPartOfName(component)}' component?`} |
|||
okText="Delete Component" |
|||
onOk={deleteComponent} /> |
|||
|
|||
<style> |
|||
.root { |
|||
overflow: hidden; |
|||
z-index: 9; |
|||
} |
|||
|
|||
.root button { |
|||
border-style: none; |
|||
border-radius: 2px; |
|||
padding: 5px; |
|||
background: transparent; |
|||
cursor: pointer; |
|||
color: var(--ink); |
|||
outline: none; |
|||
} |
|||
|
|||
.menu { |
|||
z-index: 100000; |
|||
overflow: visible; |
|||
padding: 12px 0px; |
|||
border-radius: 5px; |
|||
} |
|||
|
|||
.menu li { |
|||
border-style: none; |
|||
background-color: transparent; |
|||
list-style-type: none; |
|||
padding: 4px 16px; |
|||
margin: 0; |
|||
width: 100%; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
.item { |
|||
display: flex; |
|||
align-items: center; |
|||
font-size: 14px; |
|||
} |
|||
|
|||
.icon { |
|||
margin-right: 8px; |
|||
} |
|||
|
|||
.menu li:not(.disabled) { |
|||
cursor: pointer; |
|||
color: var(--ink-light); |
|||
} |
|||
|
|||
.menu li:not(.disabled):hover { |
|||
color: var(--ink); |
|||
background-color: var(--grey-light); |
|||
} |
|||
|
|||
.disabled { |
|||
color: var(--grey-dark); |
|||
cursor: default; |
|||
} |
|||
|
|||
.hr-style { |
|||
margin: 8px 0; |
|||
color: var(--grey-dark); |
|||
} |
|||
</style> |
|||
@ -1,73 +1,52 @@ |
|||
<script> |
|||
import { Input } from "@budibase/bbui" |
|||
import IconButton from "components/common/IconButton.svelte" |
|||
import PlusButton from "components/common/PlusButton.svelte" |
|||
import Select from "components/common/Select.svelte" |
|||
import Input from "components/common/Input.svelte" |
|||
import { find, map, keys, reduce, keyBy } from "lodash/fp" |
|||
import { pipe } from "components/common/core" |
|||
import { |
|||
EVENT_TYPE_MEMBER_NAME, |
|||
allHandlers, |
|||
} from "components/common/eventHandlers" |
|||
import { store } from "builderStore" |
|||
import StateBindingOptions from "../PropertyCascader/StateBindingOptions.svelte" |
|||
import { EVENT_TYPE_MEMBER_NAME } from "components/common/eventHandlers" |
|||
import { store, workflowStore } from "builderStore" |
|||
import { ArrowDownIcon } from "components/common/Icons/" |
|||
|
|||
export let parameter |
|||
export let onChange |
|||
|
|||
let isOpen = false |
|||
|
|||
const capitalize = s => { |
|||
if (typeof s !== "string") return "" |
|||
return s.charAt(0).toUpperCase() + s.slice(1) |
|||
} |
|||
</script> |
|||
|
|||
<div class="handler-option"> |
|||
<span>{parameter.name}</span> |
|||
<div class="handler-input"> |
|||
<Input on:change={onChange} value={parameter.value} /> |
|||
<button on:click={() => (isOpen = !isOpen)}> |
|||
<div class="icon" style={`transform: rotate(${isOpen ? 0 : 90}deg);`}> |
|||
<ArrowDownIcon size={36} /> |
|||
</div> |
|||
</button> |
|||
{#if isOpen} |
|||
<StateBindingOptions |
|||
onSelect={option => { |
|||
onChange(option) |
|||
isOpen = false |
|||
}} /> |
|||
{/if} |
|||
</div> |
|||
{#if parameter.name === 'workflow'} |
|||
<span>{parameter.name}</span> |
|||
{/if} |
|||
{#if parameter.name === 'workflow'} |
|||
<Select on:change bind:value={parameter.value}> |
|||
{#each $workflowStore.workflows.filter(wf => wf.live) as workflow} |
|||
<option value={workflow._id}>{workflow.name}</option> |
|||
{/each} |
|||
</Select> |
|||
{:else} |
|||
<Input |
|||
name={parameter.name} |
|||
label={capitalize(parameter.name)} |
|||
on:change |
|||
value={parameter.value} /> |
|||
{/if} |
|||
</div> |
|||
|
|||
<style> |
|||
button { |
|||
cursor: pointer; |
|||
outline: none; |
|||
border: none; |
|||
border-radius: 5px; |
|||
background: rgba(249, 249, 249, 1); |
|||
|
|||
font-size: 1.6rem; |
|||
font-weight: 700; |
|||
color: rgba(22, 48, 87, 1); |
|||
margin-left: 5px; |
|||
} |
|||
|
|||
.icon { |
|||
width: 24px; |
|||
} |
|||
|
|||
.handler-option { |
|||
display: flex; |
|||
flex-direction: column; |
|||
} |
|||
|
|||
.handler-input { |
|||
position: relative; |
|||
display: flex; |
|||
} |
|||
|
|||
span { |
|||
font-size: 13px; |
|||
margin-bottom: 5px; |
|||
font-size: 18px; |
|||
margin-bottom: 10px; |
|||
font-weight: 500; |
|||
} |
|||
</style> |
|||
|
|||
@ -0,0 +1,57 @@ |
|||
<script> |
|||
import { store, backendUiStore } from "builderStore" |
|||
import ComponentsHierarchy from "components/userInterface/ComponentsHierarchy.svelte" |
|||
import PageLayout from "components/userInterface/PageLayout.svelte" |
|||
import PagesList from "components/userInterface/PagesList.svelte" |
|||
import NewScreen from "components/userInterface/NewScreen.svelte" |
|||
|
|||
const newScreen = () => { |
|||
newScreenPicker.show() |
|||
} |
|||
|
|||
let newScreenPicker |
|||
</script> |
|||
|
|||
<PagesList /> |
|||
|
|||
<button class="newscreen" on:click={newScreen}> |
|||
<i class="icon ri-add-circle-fill" /> |
|||
Create New Screen |
|||
</button> |
|||
|
|||
<PageLayout layout={$store.pages[$store.currentPageName]} /> |
|||
|
|||
<div class="nav-items-container"> |
|||
<ComponentsHierarchy screens={$store.screens} /> |
|||
</div> |
|||
|
|||
<NewScreen bind:this={newScreenPicker} /> |
|||
|
|||
<style> |
|||
.newscreen { |
|||
cursor: pointer; |
|||
border: 1px solid var(--grey-dark); |
|||
border-radius: 3px; |
|||
width: 100%; |
|||
padding: 8px 16px; |
|||
margin: 20px 0px 12px 0px; |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
background: white; |
|||
color: var(--ink); |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
transition: all 2ms; |
|||
} |
|||
|
|||
.newscreen:hover { |
|||
background: var(--grey-light); |
|||
} |
|||
|
|||
.icon { |
|||
color: var(--ink); |
|||
font-size: 16px; |
|||
margin-right: 4px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,16 @@ |
|||
<script> |
|||
import { backendUiStore } from "builderStore" |
|||
|
|||
export let value |
|||
</script> |
|||
|
|||
<div class="uk-margin block-field"> |
|||
<div class="uk-form-controls"> |
|||
<select class="budibase__input" on:change {value}> |
|||
<option value="" /> |
|||
{#each $backendUiStore.models as model} |
|||
<option value={model._id}>{model.name}</option> |
|||
{/each} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
@ -1,36 +1,223 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import { onMount, beforeUpdate } from "svelte" |
|||
import { buildStyle } from "../../helpers.js" |
|||
export let options = [] |
|||
export let value = "" |
|||
export let styleBindingProperty |
|||
export let onChange = value => {} |
|||
export let options = [] |
|||
export let initialValue = "" |
|||
export let styleBindingProperty = "" |
|||
|
|||
let open = null |
|||
let rotate = "" |
|||
let select |
|||
let selectMenu |
|||
let icon |
|||
|
|||
let selectYPosition = null |
|||
let availableSpace = 0 |
|||
|
|||
let positionSide = "top" |
|||
let maxHeight = null |
|||
let menuHeight |
|||
|
|||
const handleStyleBind = value => |
|||
!!styleBindingProperty ? { style: `${styleBindingProperty}: ${value}` } : {} |
|||
|
|||
$: isOptionsObject = options.every(o => typeof o === "object") |
|||
|
|||
onMount(() => { |
|||
if (!value && !!initialValue) { |
|||
value = initialValue |
|||
if (select) { |
|||
select.addEventListener("keydown", addSelectKeyEvents) |
|||
} |
|||
return () => { |
|||
select.removeEventListener("keydown", addSelectKeyEvents) |
|||
} |
|||
}) |
|||
|
|||
function checkPosition() { |
|||
const { bottom, top: spaceAbove } = select.getBoundingClientRect() |
|||
const spaceBelow = window.innerHeight - bottom |
|||
|
|||
if (spaceAbove > spaceBelow) { |
|||
positionSide = "bottom" |
|||
maxHeight = `${spaceAbove.toFixed(0) - 20}px` |
|||
} else { |
|||
positionSide = "top" |
|||
maxHeight = `${spaceBelow.toFixed(0) - 20}px` |
|||
} |
|||
} |
|||
|
|||
function addSelectKeyEvents(e) { |
|||
if (e.key === "Enter") { |
|||
if (!open) { |
|||
toggleSelect(true) |
|||
} |
|||
} else if (e.key === "Escape") { |
|||
if (open) { |
|||
toggleSelect(false) |
|||
} |
|||
} |
|||
} |
|||
|
|||
function toggleSelect(isOpen) { |
|||
checkPosition() |
|||
if (isOpen) { |
|||
icon.style.transform = "rotate(180deg)" |
|||
} else { |
|||
icon.style.transform = "rotate(0deg)" |
|||
} |
|||
open = isOpen |
|||
} |
|||
|
|||
function handleClick(val) { |
|||
value = val |
|||
onChange(value) |
|||
} |
|||
|
|||
$: menuStyle = buildStyle({ |
|||
"max-height": maxHeight, |
|||
"transform-origin": `center ${positionSide}`, |
|||
[positionSide]: "32px", |
|||
}) |
|||
|
|||
$: isOptionsObject = options.every(o => typeof o === "object") |
|||
|
|||
$: selectedOption = isOptionsObject |
|||
? options.find(o => o.value === value) |
|||
: {} |
|||
|
|||
$: displayLabel = |
|||
selectedOption && selectedOption.label ? selectedOption.label : value || "" |
|||
</script> |
|||
|
|||
<select |
|||
class="uk-select uk-form-small" |
|||
{value} |
|||
on:change={ev => onChange(ev.target.value)}> |
|||
{#if isOptionsObject} |
|||
{#each options as { value, label }} |
|||
<option {...handleStyleBind(value || label)} value={value || label}> |
|||
{label} |
|||
</option> |
|||
{/each} |
|||
{:else} |
|||
{#each options as value} |
|||
<option {...handleStyleBind(value)} {value}>{value}</option> |
|||
{/each} |
|||
{/if} |
|||
</select> |
|||
<div |
|||
tabindex="0" |
|||
bind:this={select} |
|||
class="bb-select-container" |
|||
on:click={() => toggleSelect(!open)}> |
|||
<div class="bb-select-anchor selected"> |
|||
<span>{displayLabel}</span> |
|||
<i bind:this={icon} class="ri-arrow-down-s-fill" /> |
|||
</div> |
|||
<div |
|||
bind:this={selectMenu} |
|||
style={menuStyle} |
|||
class="bb-select-menu" |
|||
class:open> |
|||
<ul> |
|||
{#if isOptionsObject} |
|||
{#each options as { value: v, label }} |
|||
<li |
|||
{...handleStyleBind(v)} |
|||
on:click|self={handleClick(v)} |
|||
class:selected={value === v}> |
|||
{label} |
|||
</li> |
|||
{/each} |
|||
{:else} |
|||
{#each options as v} |
|||
<li |
|||
{...handleStyleBind(v)} |
|||
on:click|self={handleClick(v)} |
|||
class:selected={value === v}> |
|||
{v} |
|||
</li> |
|||
{/each} |
|||
{/if} |
|||
</ul> |
|||
</div> |
|||
</div> |
|||
{#if open} |
|||
<div on:click|self={() => toggleSelect(false)} class="overlay" /> |
|||
{/if} |
|||
|
|||
<style> |
|||
.overlay { |
|||
position: absolute; |
|||
top: 0; |
|||
bottom: 0; |
|||
right: 0; |
|||
left: 0; |
|||
z-index: 1; |
|||
} |
|||
|
|||
.bb-select-container { |
|||
position: relative; |
|||
outline: none; |
|||
width: 160px; |
|||
height: 32px; |
|||
cursor: pointer; |
|||
font-size: 12px; |
|||
} |
|||
|
|||
.bb-select-anchor { |
|||
cursor: pointer; |
|||
display: flex; |
|||
padding: 5px 10px; |
|||
background-color: #f2f2f2; |
|||
border-radius: 2px; |
|||
border: 1px solid var(--grey-dark); |
|||
align-items: center; |
|||
} |
|||
|
|||
.bb-select-anchor > span { |
|||
color: #565a66; |
|||
font-weight: 500; |
|||
width: 140px; |
|||
overflow-x: hidden; |
|||
} |
|||
|
|||
.bb-select-anchor > i { |
|||
transition: transform 0.13s ease; |
|||
transform-origin: center; |
|||
width: 20px; |
|||
height: 20px; |
|||
text-align: center; |
|||
} |
|||
|
|||
.selected { |
|||
color: #565a66; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.bb-select-menu { |
|||
position: absolute; |
|||
display: flex; |
|||
box-sizing: border-box; |
|||
flex-direction: column; |
|||
opacity: 0; |
|||
width: 160px; |
|||
z-index: 2; |
|||
color: #808192; |
|||
font-weight: 500; |
|||
height: fit-content !important; |
|||
border-bottom-left-radius: 2px; |
|||
border-bottom-right-radius: 2px; |
|||
border-right: 1px solid var(--grey-dark); |
|||
border-left: 1px solid var(--grey-dark); |
|||
border-bottom: 1px solid var(--grey-dark); |
|||
background-color: #f2f2f2; |
|||
transform: scale(0); |
|||
transition: opacity 0.13s linear, transform 0.12s cubic-bezier(0, 0, 0.2, 1); |
|||
overflow-y: auto; |
|||
} |
|||
|
|||
.open { |
|||
transform: scale(1); |
|||
opacity: 1; |
|||
} |
|||
|
|||
ul { |
|||
list-style-type: none; |
|||
margin: 0; |
|||
padding: 5px 0px; |
|||
} |
|||
|
|||
li { |
|||
height: auto; |
|||
padding: 5px 0px; |
|||
cursor: pointer; |
|||
padding-left: 10px; |
|||
} |
|||
|
|||
li:hover { |
|||
background-color: #e6e6e6; |
|||
} |
|||
</style> |
|||
|
|||
@ -0,0 +1,33 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
export let value = "" |
|||
export let onChange = value => {} |
|||
export let options = [] |
|||
export let initialValue = "" |
|||
export let styleBindingProperty = "" |
|||
|
|||
const handleStyleBind = value => |
|||
!!styleBindingProperty ? { style: `${styleBindingProperty}: ${value}` } : {} |
|||
|
|||
$: isOptionsObject = options.every(o => typeof o === "object") |
|||
|
|||
onMount(() => { |
|||
if (!value && !!initialValue) { |
|||
value = initialValue |
|||
} |
|||
}) |
|||
</script> |
|||
|
|||
<select {value} on:change={ev => onChange(ev.target.value)}> |
|||
{#if isOptionsObject} |
|||
{#each options as { value, label }} |
|||
<option {...handleStyleBind(value || label)} value={value || label}> |
|||
{label} |
|||
</option> |
|||
{/each} |
|||
{:else} |
|||
{#each options as value} |
|||
<option {...handleStyleBind(value)} {value}>{value}</option> |
|||
{/each} |
|||
{/if} |
|||
</select> |
|||
@ -1,121 +0,0 @@ |
|||
<script> |
|||
import { ArrowDownIcon } from "components/common/Icons/" |
|||
import { store } from "builderStore" |
|||
import { buildStateOrigins } from "builderStore/buildStateOrigins" |
|||
import { isBinding, getBinding, setBinding } from "components/common/binding" |
|||
import StateBindingOptions from "./StateBindingOptions.svelte" |
|||
|
|||
export let onChanged = () => {} |
|||
export let value = "" |
|||
|
|||
let isOpen = false |
|||
let stateBindings = [] |
|||
|
|||
let bindingPath = "" |
|||
let bindingFallbackValue = "" |
|||
let bindingSource = "store" |
|||
let bindingValue = "" |
|||
|
|||
const bindValueToSource = (path, fallback, source) => { |
|||
if (!path) { |
|||
onChanged(fallback) |
|||
return |
|||
} |
|||
const binding = setBinding({ path, fallback, source }) |
|||
onChanged(binding) |
|||
} |
|||
|
|||
const setBindingPath = value => |
|||
bindValueToSource(value, bindingFallbackValue, bindingSource) |
|||
|
|||
const setBindingFallback = value => |
|||
bindValueToSource(bindingPath, value, bindingSource) |
|||
|
|||
const setBindingSource = source => |
|||
bindValueToSource(bindingPath, bindingFallbackValue, source) |
|||
|
|||
$: { |
|||
const binding = getBinding(value) |
|||
if (bindingPath !== binding.path) isOpen = false |
|||
bindingPath = binding.path |
|||
bindingValue = typeof value === "object" ? "" : value |
|||
bindingFallbackValue = binding.fallback || bindingValue |
|||
|
|||
const currentScreen = $store.screens.find( |
|||
({ name }) => name === $store.currentPreviewItem.name |
|||
) |
|||
stateBindings = currentScreen |
|||
? Object.keys(buildStateOrigins(currentScreen)) |
|||
: [] |
|||
} |
|||
</script> |
|||
|
|||
<div class="cascader"> |
|||
<div class="input-box"> |
|||
<input |
|||
class:bold={!bindingFallbackValue && bindingPath} |
|||
class="uk-input uk-form-small" |
|||
value={bindingFallbackValue || bindingPath} |
|||
on:change={e => { |
|||
setBindingFallback(e.target.value) |
|||
onChanged(e.target.value) |
|||
}} /> |
|||
<button on:click={() => (isOpen = !isOpen)}> |
|||
<div |
|||
class="icon" |
|||
class:highlighted={bindingPath} |
|||
style={`transform: rotate(${isOpen ? 0 : 90}deg);`}> |
|||
<ArrowDownIcon size={36} /> |
|||
</div> |
|||
</button> |
|||
</div> |
|||
{#if isOpen} |
|||
<StateBindingOptions |
|||
onSelect={option => { |
|||
onChanged(option) |
|||
isOpen = false |
|||
}} /> |
|||
{/if} |
|||
</div> |
|||
|
|||
<style> |
|||
.bold { |
|||
font-weight: bold; |
|||
} |
|||
|
|||
.highlighted { |
|||
color: rgba(0, 85, 255, 0.8); |
|||
} |
|||
|
|||
button { |
|||
cursor: pointer; |
|||
outline: none; |
|||
border: none; |
|||
border-radius: 3px; |
|||
font-size: 1.6rem; |
|||
font-weight: 700; |
|||
color: rgba(22, 48, 87, 1); |
|||
} |
|||
|
|||
.cascader { |
|||
position: relative; |
|||
width: 100%; |
|||
} |
|||
|
|||
.input-box { |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
|
|||
input { |
|||
margin-right: 5px; |
|||
border: 1px solid #dbdbdb; |
|||
border-radius: 2px; |
|||
opacity: 0.5; |
|||
height: 40px; |
|||
} |
|||
|
|||
.icon { |
|||
width: 24px; |
|||
} |
|||
</style> |
|||
@ -1,63 +0,0 @@ |
|||
<script> |
|||
export let onSelect = () => {} |
|||
|
|||
let options = [ |
|||
{ |
|||
name: "state", |
|||
description: "Front-end client state.", |
|||
}, |
|||
{ |
|||
name: "context", |
|||
description: "The component context object.", |
|||
}, |
|||
{ |
|||
name: "event", |
|||
description: "DOM event handler arguments.", |
|||
}, |
|||
] |
|||
</script> |
|||
|
|||
<ul class="options"> |
|||
{#each options as option} |
|||
<li on:click={() => onSelect(`${option.name}.`)}> |
|||
<span class="name">{option.name}</span> |
|||
<span class="description">{option.description}</span> |
|||
</li> |
|||
{/each} |
|||
</ul> |
|||
|
|||
<style> |
|||
.options { |
|||
width: 172px; |
|||
margin: 0; |
|||
position: absolute; |
|||
top: 35px; |
|||
padding: 10px; |
|||
z-index: 1; |
|||
background: rgba(249, 249, 249, 1); |
|||
min-height: 50px; |
|||
border-radius: 2px; |
|||
} |
|||
|
|||
.description { |
|||
font-size: 0.8em; |
|||
} |
|||
|
|||
.name { |
|||
color: rgba(22, 48, 87, 0.6); |
|||
font-size: 13px; |
|||
font-weight: bold; |
|||
text-transform: uppercase; |
|||
margin-top: 5px; |
|||
display: block; |
|||
} |
|||
|
|||
.name:hover { |
|||
cursor: pointer; |
|||
font-weight: 800; |
|||
} |
|||
|
|||
li { |
|||
list-style-type: none; |
|||
} |
|||
</style> |
|||
@ -1 +0,0 @@ |
|||
export { default } from "./PropertyCascader.svelte" |
|||
@ -0,0 +1,89 @@ |
|||
<script> |
|||
import { store, backendUiStore, workflowStore } from "builderStore" |
|||
import { notifier } from "@beyonk/svelte-notifications" |
|||
import api from "builderStore/api" |
|||
import ActionButton from "components/common/ActionButton.svelte" |
|||
|
|||
export let onClosed |
|||
|
|||
let name |
|||
|
|||
$: valid = !!name |
|||
$: instanceId = $backendUiStore.selectedDatabase._id |
|||
|
|||
async function deleteWorkflow() { |
|||
await workflowStore.actions.delete({ |
|||
instanceId, |
|||
workflow: $workflowStore.currentWorkflow.workflow, |
|||
}) |
|||
onClosed() |
|||
notifier.danger("Workflow deleted.") |
|||
} |
|||
</script> |
|||
|
|||
<header> |
|||
<i class="ri-stackshare-line" /> |
|||
Delete Workflow |
|||
</header> |
|||
<div> |
|||
<p> |
|||
Are you sure you want to delete this workflow? This action can't be undone. |
|||
</p> |
|||
</div> |
|||
<footer> |
|||
<a href="https://docs.budibase.com"> |
|||
<i class="ri-information-line" /> |
|||
Learn about workflows |
|||
</a> |
|||
<ActionButton on:click={onClosed}>Cancel</ActionButton> |
|||
<ActionButton alert on:click={deleteWorkflow}>Delete</ActionButton> |
|||
</footer> |
|||
|
|||
<style> |
|||
header { |
|||
font-size: 24px; |
|||
color: var(--font); |
|||
font-weight: bold; |
|||
padding: 30px; |
|||
} |
|||
|
|||
header i { |
|||
margin-right: 10px; |
|||
font-size: 20px; |
|||
background: var(--secondary); |
|||
color: var(--dark-grey); |
|||
padding: 8px; |
|||
} |
|||
|
|||
div { |
|||
padding: 0 30px 30px 30px; |
|||
} |
|||
|
|||
label { |
|||
font-size: 18px; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
footer { |
|||
display: grid; |
|||
grid-auto-flow: column; |
|||
grid-gap: 5px; |
|||
grid-auto-columns: 3fr 1fr 1fr; |
|||
padding: 20px; |
|||
background: #fafafa; |
|||
border-radius: 0.5rem; |
|||
} |
|||
|
|||
footer a { |
|||
color: var(--primary); |
|||
font-size: 14px; |
|||
vertical-align: middle; |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
|
|||
footer i { |
|||
font-size: 20px; |
|||
margin-right: 10px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,45 @@ |
|||
<script> |
|||
import { store } from "builderStore" |
|||
import deepmerge from "deepmerge" |
|||
|
|||
export let value |
|||
|
|||
let pages = [] |
|||
let components = [] |
|||
let pageName |
|||
|
|||
let selectedPage |
|||
let selectedScreen |
|||
|
|||
$: pages = $store.pages |
|||
$: selectedPage = pages[pageName] |
|||
$: screens = selectedPage ? selectedPage._screens : [] |
|||
$: if (selectedPage) { |
|||
let result = selectedPage |
|||
for (screen of screens) { |
|||
result = deepmerge(result, screen) |
|||
} |
|||
components = result.props._children |
|||
} |
|||
</script> |
|||
|
|||
<div class="uk-margin block-field"> |
|||
<label class="uk-form-label">Page</label> |
|||
<div class="uk-form-controls"> |
|||
<select class="budibase__input" bind:value={pageName}> |
|||
{#each Object.keys(pages) as page} |
|||
<option value={page}>{page}</option> |
|||
{/each} |
|||
</select> |
|||
</div> |
|||
{#if components.length > 0} |
|||
<label class="uk-form-label">Component</label> |
|||
<div class="uk-form-controls"> |
|||
<select class="budibase__input" bind:value> |
|||
{#each components as component} |
|||
<option value={component._id}>{component._id}</option> |
|||
{/each} |
|||
</select> |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
@ -0,0 +1,16 @@ |
|||
<script> |
|||
import { backendUiStore } from "builderStore" |
|||
|
|||
export let value |
|||
</script> |
|||
|
|||
<div class="uk-margin block-field"> |
|||
<div class="uk-form-controls"> |
|||
<select class="budibase__input" bind:value> |
|||
<option value="" /> |
|||
{#each $backendUiStore.models as model} |
|||
<option value={model}>{model.name}</option> |
|||
{/each} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,33 @@ |
|||
<script> |
|||
import { backendUiStore } from "builderStore" |
|||
|
|||
export let value |
|||
</script> |
|||
|
|||
<div class="uk-margin block-field"> |
|||
<div class="uk-form-controls"> |
|||
<select class="budibase__input" bind:value={value.model}> |
|||
{#each $backendUiStore.models as model} |
|||
<option value={model}>{model.name}</option> |
|||
{/each} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
{#if value.model} |
|||
<div class="uk-margin block-field"> |
|||
<label class="uk-form-label fields">Fields</label> |
|||
{#each Object.keys(value.model.schema) as field} |
|||
<div class="uk-form-controls uk-margin"> |
|||
<label class="uk-form-label">{field}</label> |
|||
<input type="text" class="budibase__input" bind:value={value[field]} /> |
|||
</div> |
|||
{/each} |
|||
</div> |
|||
{/if} |
|||
|
|||
<style> |
|||
.fields { |
|||
font-weight: 500; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,294 @@ |
|||
<script> |
|||
import { fade } from "svelte/transition" |
|||
import { onMount, getContext } from "svelte" |
|||
import { backendUiStore, workflowStore } from "builderStore" |
|||
import { notifier } from "@beyonk/svelte-notifications" |
|||
import api from "builderStore/api" |
|||
import WorkflowBlockSetup from "./WorkflowBlockSetup.svelte" |
|||
import DeleteWorkflowModal from "./DeleteWorkflowModal.svelte" |
|||
|
|||
const { open, close } = getContext("simple-modal") |
|||
|
|||
const ACCESS_LEVELS = [ |
|||
{ |
|||
name: "Admin", |
|||
key: "ADMIN", |
|||
}, |
|||
{ |
|||
name: "Power User", |
|||
key: "POWER_USER", |
|||
}, |
|||
] |
|||
|
|||
let selectedTab = "SETUP" |
|||
let testResult |
|||
|
|||
$: workflow = |
|||
$workflowStore.currentWorkflow && $workflowStore.currentWorkflow.workflow |
|||
$: workflowBlock = $workflowStore.selectedWorkflowBlock |
|||
|
|||
function deleteWorkflow() { |
|||
open( |
|||
DeleteWorkflowModal, |
|||
{ |
|||
onClosed: close, |
|||
}, |
|||
{ styleContent: { padding: "0" } } |
|||
) |
|||
} |
|||
|
|||
function deleteWorkflowBlock() { |
|||
workflowStore.actions.deleteWorkflowBlock(workflowBlock) |
|||
notifier.info("Workflow block deleted.") |
|||
} |
|||
|
|||
function testWorkflow() { |
|||
testResult = "PASSED" |
|||
} |
|||
|
|||
async function saveWorkflow() { |
|||
const workflow = $workflowStore.currentWorkflow.workflow |
|||
await workflowStore.actions.save({ |
|||
instanceId: $backendUiStore.selectedDatabase._id, |
|||
workflow, |
|||
}) |
|||
notifier.success(`Workflow ${workflow.name} saved.`) |
|||
} |
|||
</script> |
|||
|
|||
<section> |
|||
<header> |
|||
<span |
|||
class="hoverable" |
|||
class:selected={selectedTab === 'SETUP'} |
|||
on:click={() => { |
|||
selectedTab = 'SETUP' |
|||
testResult = null |
|||
}}> |
|||
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="uk-margin config-item"> |
|||
{#if testResult} |
|||
<button |
|||
transition:fade |
|||
class:passed={testResult === 'PASSED'} |
|||
class:failed={testResult === 'FAILED'} |
|||
class="test-result"> |
|||
{testResult} |
|||
</button> |
|||
{/if} |
|||
<button class="workflow-button hoverable" on:click={testWorkflow}> |
|||
Test |
|||
</button> |
|||
</div> |
|||
{/if} |
|||
{#if selectedTab === 'SETUP'} |
|||
{#if workflowBlock} |
|||
<WorkflowBlockSetup {workflowBlock} /> |
|||
<div class="buttons"> |
|||
<button class="workflow-button hoverable" on:click={saveWorkflow}> |
|||
Save Workflow |
|||
</button> |
|||
<button |
|||
class="delete-workflow-button hoverable" |
|||
on:click={deleteWorkflowBlock}> |
|||
Delete Block |
|||
</button> |
|||
</div> |
|||
{:else if $workflowStore.currentWorkflow} |
|||
<div class="panel"> |
|||
<div class="panel-body"> |
|||
<div class="block-label">Workflow: {workflow.name}</div> |
|||
<div class="config-item"> |
|||
<label>Name</label> |
|||
<div class="form"> |
|||
<input |
|||
type="text" |
|||
class="budibase_input" |
|||
bind:value={workflow.name} /> |
|||
</div> |
|||
</div> |
|||
<div class="config-item"> |
|||
<label class="uk-form-label">User Access</label> |
|||
<div class="access-levels"> |
|||
{#each ACCESS_LEVELS as { name, key }} |
|||
<span class="access-level"> |
|||
<label>{name}</label> |
|||
<input class="uk-checkbox" type="checkbox" /> |
|||
</span> |
|||
{/each} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="buttons"> |
|||
<button class="delete-workflow-button" on:click={deleteWorkflow}> |
|||
Delete Workflow |
|||
</button> |
|||
</div> |
|||
</div> |
|||
{/if} |
|||
{/if} |
|||
</section> |
|||
|
|||
<style> |
|||
section { |
|||
display: flex; |
|||
flex-direction: column; |
|||
height: 100%; |
|||
justify-content: space-between; |
|||
} |
|||
|
|||
.panel-body { |
|||
flex: 1; |
|||
} |
|||
|
|||
.panel { |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: space-between; |
|||
} |
|||
|
|||
header { |
|||
font-size: 20px; |
|||
font-weight: 700; |
|||
display: flex; |
|||
align-items: center; |
|||
margin-bottom: 18px; |
|||
color: var(--ink); |
|||
} |
|||
|
|||
.selected { |
|||
color: var(--ink); |
|||
} |
|||
|
|||
.block-label { |
|||
font-weight: 500; |
|||
font-size: 14px; |
|||
color: var(--ink); |
|||
margin: 0px 0px 16px 0px; |
|||
} |
|||
|
|||
.config-item { |
|||
margin: 0px 0px 4px 0px; |
|||
padding: 12px; |
|||
background: var(--light-grey); |
|||
} |
|||
|
|||
.budibase_input { |
|||
height: 35px; |
|||
width: 220px; |
|||
border-radius: 3px; |
|||
border: 1px solid var(--grey-dark); |
|||
text-align: left; |
|||
color: var(--ink); |
|||
font-size: 14px; |
|||
padding-left: 12px; |
|||
} |
|||
|
|||
header > span { |
|||
color: var(--ink-lighter); |
|||
margin-right: 20px; |
|||
} |
|||
|
|||
.form { |
|||
margin-top: 12px; |
|||
} |
|||
|
|||
label { |
|||
font-weight: 500; |
|||
font-size: 14px; |
|||
color: var(--ink); |
|||
} |
|||
|
|||
.buttons { |
|||
position: absolute; |
|||
bottom: 10px; |
|||
} |
|||
|
|||
.delete-workflow-button { |
|||
cursor: pointer; |
|||
border: 1px solid var(--red); |
|||
border-radius: 3px; |
|||
width: 260px; |
|||
padding: 8px 16px; |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
background: var(--white); |
|||
color: var(--red); |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
transition: all 2ms; |
|||
align-self: flex-end; |
|||
margin-bottom: 10px; |
|||
} |
|||
|
|||
.delete-workflow-button:hover { |
|||
background: var(--red); |
|||
border: 1px solid var(--red); |
|||
color: var(--white); |
|||
} |
|||
|
|||
.workflow-button { |
|||
cursor: pointer; |
|||
border: 1px solid var(--grey-dark); |
|||
border-radius: 3px; |
|||
width: 100%; |
|||
padding: 8px 16px; |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
background: white; |
|||
color: var(--ink); |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
transition: all 2ms; |
|||
margin-bottom: 10px; |
|||
} |
|||
|
|||
.workflow-button:hover { |
|||
background: var(--grey-light); |
|||
} |
|||
|
|||
.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: #84c991; |
|||
} |
|||
|
|||
.failed { |
|||
background: var(--red); |
|||
} |
|||
</style> |
|||
@ -0,0 +1,97 @@ |
|||
<script> |
|||
import { backendUiStore, store } from "builderStore" |
|||
import ComponentSelector from "./ParamInputs/ComponentSelector.svelte" |
|||
import ModelSelector from "./ParamInputs/ModelSelector.svelte" |
|||
import RecordSelector from "./ParamInputs/RecordSelector.svelte" |
|||
|
|||
export let workflowBlock |
|||
|
|||
let params |
|||
|
|||
$: workflowParams = workflowBlock.params |
|||
? Object.entries(workflowBlock.params) |
|||
: [] |
|||
</script> |
|||
|
|||
<label class="uk-form-label">{workflowBlock.type}: {workflowBlock.name}</label> |
|||
{#each workflowParams as [parameter, type]} |
|||
<div class="block-field"> |
|||
<label class="uk-form-label">{parameter}</label> |
|||
<div class="uk-form-controls"> |
|||
{#if Array.isArray(type)} |
|||
<select |
|||
class="budibase_input" |
|||
bind:value={workflowBlock.args[parameter]}> |
|||
{#each type as option} |
|||
<option value={option}>{option}</option> |
|||
{/each} |
|||
</select> |
|||
{:else if type === 'component'} |
|||
<ComponentSelector bind:value={workflowBlock.args[parameter]} /> |
|||
{:else if type === 'accessLevel'} |
|||
<select |
|||
class="budibase__input" |
|||
bind:value={workflowBlock.args[parameter]}> |
|||
<option value="ADMIN">Admin</option> |
|||
<option value="POWER_USER">Power User</option> |
|||
</select> |
|||
{:else if type === 'password'} |
|||
<input |
|||
type="password" |
|||
class="budibase__input" |
|||
bind:value={workflowBlock.args[parameter]} /> |
|||
{:else if type === 'number'} |
|||
<input |
|||
type="number" |
|||
class="budibase__input" |
|||
bind:value={workflowBlock.args[parameter]} /> |
|||
{:else if type === 'longText'} |
|||
<textarea |
|||
type="text" |
|||
class="budibase__input" |
|||
bind:value={workflowBlock.args[parameter]} /> |
|||
{:else if type === 'model'} |
|||
<ModelSelector bind:value={workflowBlock.args[parameter]} /> |
|||
{:else if type === 'record'} |
|||
<RecordSelector bind:value={workflowBlock.args[parameter]} /> |
|||
{:else if type === 'string'} |
|||
<input |
|||
type="text" |
|||
class="budibase__input" |
|||
bind:value={workflowBlock.args[parameter]} /> |
|||
{/if} |
|||
</div> |
|||
</div> |
|||
{/each} |
|||
|
|||
<style> |
|||
.block-field { |
|||
border-radius: 3px; |
|||
background: var(--grey-light); |
|||
padding: 12px; |
|||
margin: 0px 0px 4px 0px; |
|||
} |
|||
|
|||
.budibase_input { |
|||
height: 35px; |
|||
width: 220px; |
|||
border-radius: 3px; |
|||
border: 1px solid var(--grey-dark); |
|||
text-align: left; |
|||
color: var(--ink); |
|||
font-size: 14px; |
|||
padding-left: 12px; |
|||
} |
|||
|
|||
label { |
|||
text-transform: capitalize; |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
textarea { |
|||
min-height: 150px; |
|||
font-family: inherit; |
|||
padding: 5px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1 @@ |
|||
export { default as SetupPanel } from "./SetupPanel.svelte" |
|||
@ -0,0 +1,90 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import { workflowStore, backendUiStore } from "builderStore" |
|||
import { notifier } from "@beyonk/svelte-notifications" |
|||
import Flowchart from "./flowchart/FlowChart.svelte" |
|||
import api from "builderStore/api" |
|||
|
|||
let selectedWorkflow |
|||
let uiTree |
|||
let instanceId = $backendUiStore.selectedDatabase._id |
|||
|
|||
$: selectedWorkflow = $workflowStore.currentWorkflow |
|||
|
|||
$: workflowLive = selectedWorkflow && selectedWorkflow.workflow.live |
|||
|
|||
$: uiTree = selectedWorkflow ? selectedWorkflow.createUiTree() : [] |
|||
|
|||
$: instanceId = $backendUiStore.selectedDatabase._id |
|||
|
|||
function onSelect(block) { |
|||
workflowStore.update(state => { |
|||
state.selectedWorkflowBlock = block |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
function setWorkflowLive(live) { |
|||
const { workflow } = selectedWorkflow |
|||
workflow.live = live |
|||
workflowStore.actions.save({ instanceId, workflow }) |
|||
if (live) { |
|||
notifier.info(`Workflow ${workflow.name} enabled.`) |
|||
} else { |
|||
notifier.danger(`Workflow ${workflow.name} disabled.`) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<section> |
|||
<Flowchart blocks={uiTree} {onSelect} /> |
|||
<footer> |
|||
{#if selectedWorkflow} |
|||
<button |
|||
class:highlighted={workflowLive} |
|||
class:hoverable={workflowLive} |
|||
class="stop-button hoverable"> |
|||
<i class="ri-stop-fill" on:click={() => setWorkflowLive(false)} /> |
|||
</button> |
|||
<button |
|||
class:highlighted={!workflowLive} |
|||
class:hoverable={!workflowLive} |
|||
class="play-button hoverable" |
|||
on:click={() => setWorkflowLive(true)}> |
|||
<i class="ri-play-fill" /> |
|||
</button> |
|||
{/if} |
|||
</footer> |
|||
</section> |
|||
|
|||
<style> |
|||
footer { |
|||
position: absolute; |
|||
bottom: 0; |
|||
right: 0; |
|||
display: flex; |
|||
align-items: flex-end; |
|||
} |
|||
|
|||
footer > button { |
|||
border-radius: 100%; |
|||
color: var(--white); |
|||
width: 76px; |
|||
height: 76px; |
|||
border: none; |
|||
background: #adaec4; |
|||
font-size: 45px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
margin-right: 24px; |
|||
} |
|||
|
|||
.play-button.highlighted { |
|||
background: var(--primary); |
|||
} |
|||
|
|||
.stop-button.highlighted { |
|||
background: var(--coral); |
|||
} |
|||
</style> |
|||
|
After Width: | Height: | Size: 241 B |
@ -0,0 +1,24 @@ |
|||
<script> |
|||
import FlowItem from "./FlowItem.svelte" |
|||
import Arrow from "./Arrow.svelte" |
|||
|
|||
export let blocks = [] |
|||
export let onSelect |
|||
</script> |
|||
|
|||
<section class="canvas"> |
|||
{#each blocks as block, idx} |
|||
<FlowItem {onSelect} {block} /> |
|||
{#if idx !== blocks.length - 1} |
|||
<Arrow /> |
|||
{/if} |
|||
{/each} |
|||
</section> |
|||
|
|||
<style> |
|||
.canvas { |
|||
display: flex; |
|||
align-items: center; |
|||
flex-direction: column; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,73 @@ |
|||
<script> |
|||
import { fade } from "svelte/transition" |
|||
|
|||
export let onSelect |
|||
export let block |
|||
|
|||
function selectBlock() { |
|||
onSelect(block) |
|||
} |
|||
</script> |
|||
|
|||
<div transition:fade class={`${block.type} hoverable`} on:click={selectBlock}> |
|||
<header> |
|||
{#if block.type === 'TRIGGER'} |
|||
<i class="ri-lightbulb-fill" /> |
|||
When this happens... |
|||
{:else if block.type === 'ACTION'} |
|||
<i class="ri-flashlight-fill" /> |
|||
Do this... |
|||
{:else if block.type === 'LOGIC'} |
|||
<i class="ri-pause-fill" /> |
|||
Only continue if... |
|||
{/if} |
|||
</header> |
|||
<hr /> |
|||
<p> |
|||
{@html block.body} |
|||
</p> |
|||
</div> |
|||
|
|||
<style> |
|||
div { |
|||
width: 320px; |
|||
padding: 20px; |
|||
border-radius: 5px; |
|||
transition: 0.3s all; |
|||
box-shadow: 0 4px 30px 0 rgba(57, 60, 68, 0.08); |
|||
background-color: var(--font); |
|||
font-size: 16px; |
|||
color: var(--white); |
|||
} |
|||
|
|||
header { |
|||
font-size: 16px; |
|||
font-weight: 500; |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
|
|||
header i { |
|||
font-size: 20px; |
|||
margin-right: 5px; |
|||
} |
|||
|
|||
.ACTION { |
|||
background-color: var(--white); |
|||
color: var(--font); |
|||
} |
|||
|
|||
.TRIGGER { |
|||
background-color: var(--font); |
|||
color: var(--white); |
|||
} |
|||
|
|||
.LOGIC { |
|||
background-color: var(--secondary); |
|||
color: var(--font); |
|||
} |
|||
|
|||
div:hover { |
|||
transform: scale(1.05); |
|||
} |
|||
</style> |
|||
@ -0,0 +1,81 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import { backendUiStore, workflowStore } from "builderStore" |
|||
import { WorkflowList } from "../" |
|||
import WorkflowBlock from "./WorkflowBlock.svelte" |
|||
import api from "builderStore/api" |
|||
import blockDefinitions from "../blockDefinitions" |
|||
|
|||
let selectedTab = "TRIGGER" |
|||
let definitions = [] |
|||
|
|||
$: definitions = Object.entries(blockDefinitions[selectedTab]) |
|||
|
|||
$: { |
|||
if ( |
|||
$workflowStore.currentWorkflow.hasTrigger() && |
|||
selectedTab === "TRIGGER" |
|||
) { |
|||
selectedTab = "ACTION" |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<section> |
|||
<div class="subtabs"> |
|||
{#if !$workflowStore.currentWorkflow.hasTrigger()} |
|||
<span |
|||
class="hoverable" |
|||
class:selected={'TRIGGER' === selectedTab} |
|||
on:click={() => (selectedTab = 'TRIGGER')}> |
|||
Triggers |
|||
</span> |
|||
{/if} |
|||
<span |
|||
class="hoverable" |
|||
class:selected={'ACTION' === selectedTab} |
|||
on:click={() => (selectedTab = 'ACTION')}> |
|||
Actions |
|||
</span> |
|||
<span |
|||
class="hoverable" |
|||
class:selected={'LOGIC' === selectedTab} |
|||
on:click={() => (selectedTab = 'LOGIC')}> |
|||
Logic |
|||
</span> |
|||
</div> |
|||
<div id="blocklist"> |
|||
{#each definitions as [actionId, blockDefinition]} |
|||
<WorkflowBlock {blockDefinition} {actionId} blockType={selectedTab} /> |
|||
{/each} |
|||
</div> |
|||
</section> |
|||
|
|||
<style> |
|||
.subtabs { |
|||
margin-top: 27px; |
|||
display: grid; |
|||
grid-gap: 5px; |
|||
grid-auto-flow: column; |
|||
grid-auto-columns: 1fr 1fr 1fr; |
|||
margin-bottom: 10px; |
|||
} |
|||
|
|||
.subtabs span { |
|||
transition: 0.3s all; |
|||
text-align: center; |
|||
color: var(--dark-grey); |
|||
font-weight: 500; |
|||
padding: 10px; |
|||
} |
|||
|
|||
.subtabs span.selected { |
|||
background: var(--dark-grey); |
|||
color: var(--white); |
|||
border-radius: 2px; |
|||
} |
|||
|
|||
.subtabs span:not(.selected) { |
|||
color: var(--dark-grey); |
|||
} |
|||
</style> |
|||
@ -0,0 +1,56 @@ |
|||
<script> |
|||
import { workflowStore } from "builderStore" |
|||
|
|||
export let blockType |
|||
export let blockDefinition |
|||
export let actionId |
|||
|
|||
function addBlockToWorkflow() { |
|||
workflowStore.actions.addBlockToWorkflow({ |
|||
...blockDefinition, |
|||
args: blockDefinition.args || {}, |
|||
actionId, |
|||
type: blockType, |
|||
}) |
|||
} |
|||
</script> |
|||
|
|||
<div class="workflow-block hoverable" on:click={addBlockToWorkflow}> |
|||
<div> |
|||
<i class={blockDefinition.icon} /> |
|||
</div> |
|||
<div class="workflow-text"> |
|||
<h4>{blockDefinition.name}</h4> |
|||
<p>{blockDefinition.description}</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
.workflow-block { |
|||
display: flex; |
|||
padding: 20px; |
|||
align-items: center; |
|||
} |
|||
|
|||
.workflow-text { |
|||
margin-left: 10px; |
|||
} |
|||
|
|||
i { |
|||
background: var(--secondary); |
|||
color: var(--dark-grey); |
|||
padding: 10px; |
|||
} |
|||
|
|||
h4 { |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
margin-bottom: 5px; |
|||
} |
|||
|
|||
p { |
|||
font-size: 12px; |
|||
color: var(--dark-grey); |
|||
margin: 0; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,89 @@ |
|||
<script> |
|||
import { store, backendUiStore, workflowStore } from "builderStore" |
|||
import { notifier } from "@beyonk/svelte-notifications" |
|||
import api from "builderStore/api" |
|||
import ActionButton from "components/common/ActionButton.svelte" |
|||
|
|||
export let onClosed |
|||
|
|||
let name |
|||
|
|||
$: valid = !!name |
|||
$: instanceId = $backendUiStore.selectedDatabase._id |
|||
$: appId = $store.appId |
|||
|
|||
async function createWorkflow() { |
|||
await workflowStore.actions.create({ |
|||
name, |
|||
instanceId, |
|||
}) |
|||
onClosed() |
|||
notifier.success(`Workflow ${name} created.`) |
|||
} |
|||
</script> |
|||
|
|||
<header> |
|||
<i class="ri-stackshare-line" /> |
|||
Create Workflow |
|||
</header> |
|||
<div> |
|||
<label class="uk-form-label" for="form-stacked-text">Name</label> |
|||
<input class="uk-input" type="text" bind:value={name} /> |
|||
</div> |
|||
<footer> |
|||
<a href="https://docs.budibase.com"> |
|||
<i class="ri-information-line" /> |
|||
Learn about workflows |
|||
</a> |
|||
<ActionButton secondary on:click={onClosed}>Cancel</ActionButton> |
|||
<ActionButton disabled={!valid} on:click={createWorkflow}>Save</ActionButton> |
|||
</footer> |
|||
|
|||
<style> |
|||
header { |
|||
font-size: 24px; |
|||
color: var(--font); |
|||
font-weight: bold; |
|||
padding: 30px; |
|||
} |
|||
|
|||
header i { |
|||
margin-right: 10px; |
|||
font-size: 20px; |
|||
background: var(--secondary); |
|||
color: var(--dark-grey); |
|||
padding: 8px; |
|||
} |
|||
|
|||
div { |
|||
padding: 0 30px 30px 30px; |
|||
} |
|||
|
|||
label { |
|||
font-size: 18px; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
footer { |
|||
display: grid; |
|||
grid-auto-flow: column; |
|||
grid-gap: 5px; |
|||
grid-auto-columns: 3fr 1fr 1fr; |
|||
padding: 20px; |
|||
background: #fafafa; |
|||
border-radius: 0.5rem; |
|||
} |
|||
|
|||
footer a { |
|||
color: var(--primary); |
|||
font-size: 14px; |
|||
vertical-align: middle; |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
|
|||
footer i { |
|||
font-size: 20px; |
|||
margin-right: 10px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,125 @@ |
|||
<script> |
|||
import Modal from "svelte-simple-modal" |
|||
import { notifier } from "@beyonk/svelte-notifications" |
|||
import { onMount, getContext } from "svelte" |
|||
import { backendUiStore, workflowStore } from "builderStore" |
|||
import api from "builderStore/api" |
|||
import CreateWorkflowModal from "./CreateWorkflowModal.svelte" |
|||
|
|||
const { open, close } = getContext("simple-modal") |
|||
|
|||
$: currentWorkflowId = |
|||
$workflowStore.currentWorkflow && |
|||
$workflowStore.currentWorkflow.workflow._id |
|||
|
|||
function newWorkflow() { |
|||
open( |
|||
CreateWorkflowModal, |
|||
{ |
|||
onClosed: close, |
|||
}, |
|||
{ styleContent: { padding: "0" } } |
|||
) |
|||
} |
|||
|
|||
onMount(() => { |
|||
workflowStore.actions.fetch($backendUiStore.selectedDatabase._id) |
|||
}) |
|||
</script> |
|||
|
|||
<section> |
|||
<button class="new-workflow-button hoverable" on:click={newWorkflow}> |
|||
<i class="icon ri-add-circle-fill" /> |
|||
Create New Workflow |
|||
</button> |
|||
<ul> |
|||
{#each $workflowStore.workflows as workflow} |
|||
<li |
|||
class="workflow-item" |
|||
class:selected={workflow._id === currentWorkflowId} |
|||
on:click={() => workflowStore.actions.select(workflow)}> |
|||
<i class="ri-stackshare-line" class:live={workflow.live} /> |
|||
{workflow.name} |
|||
</li> |
|||
{/each} |
|||
</ul> |
|||
</section> |
|||
|
|||
<style> |
|||
section { |
|||
display: flex; |
|||
flex-direction: column; |
|||
} |
|||
|
|||
i { |
|||
color: #adaec4; |
|||
} |
|||
|
|||
i:hover { |
|||
cursor: pointer; |
|||
} |
|||
|
|||
ul { |
|||
list-style-type: none; |
|||
padding: 0; |
|||
flex: 1; |
|||
} |
|||
|
|||
.live { |
|||
color: var(--primary); |
|||
} |
|||
|
|||
li { |
|||
font-size: 14px; |
|||
} |
|||
|
|||
.workflow-item { |
|||
display: flex; |
|||
border-radius: 3px; |
|||
padding-left: 12px; |
|||
align-items: center; |
|||
height: 40px; |
|||
margin-bottom: 4px; |
|||
color: var(--ink); |
|||
} |
|||
|
|||
.workflow-item i { |
|||
font-size: 24px; |
|||
margin-right: 10px; |
|||
} |
|||
|
|||
.workflow-item:hover { |
|||
cursor: pointer; |
|||
background: var(--grey-light); |
|||
} |
|||
|
|||
.workflow-item.selected { |
|||
background: var(--blue-light); |
|||
} |
|||
|
|||
.new-workflow-button { |
|||
cursor: pointer; |
|||
border: 1px solid var(--grey-dark); |
|||
border-radius: 3px; |
|||
width: 100%; |
|||
padding: 8px 16px; |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
background: white; |
|||
color: var(--ink); |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
transition: all 2ms; |
|||
} |
|||
|
|||
.new-workflow-button:hover { |
|||
background: var(--grey-light); |
|||
} |
|||
|
|||
.icon { |
|||
color: var(--ink); |
|||
font-size: 16px; |
|||
margin-right: 4px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,55 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import { backendUiStore, workflowStore } from "builderStore" |
|||
import { WorkflowList, BlockList } from "./" |
|||
import api from "builderStore/api" |
|||
import blockDefinitions from "./blockDefinitions" |
|||
|
|||
let selectedTab = "WORKFLOWS" |
|||
let definitions = [] |
|||
</script> |
|||
|
|||
<header> |
|||
<span |
|||
class="hoverable workflow-header" |
|||
class:selected={selectedTab === 'WORKFLOWS'} |
|||
on:click={() => (selectedTab = 'WORKFLOWS')}> |
|||
Workflows |
|||
</span> |
|||
{#if $workflowStore.currentWorkflow} |
|||
<span |
|||
class="hoverable" |
|||
class:selected={selectedTab === 'ADD'} |
|||
on:click={() => (selectedTab = 'ADD')}> |
|||
Add |
|||
</span> |
|||
{/if} |
|||
</header> |
|||
{#if selectedTab === 'WORKFLOWS'} |
|||
<WorkflowList /> |
|||
{:else if selectedTab === 'ADD'} |
|||
<BlockList /> |
|||
{/if} |
|||
|
|||
<style> |
|||
header { |
|||
font-size: 18px; |
|||
font-weight: 700; |
|||
background: none; |
|||
display: flex; |
|||
align-items: center; |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.workflow-header { |
|||
margin-right: 20px; |
|||
} |
|||
|
|||
span:not(.selected) { |
|||
color: var(--ink-lighter); |
|||
} |
|||
|
|||
span:not(.selected):hover { |
|||
color: var(--ink); |
|||
} |
|||
</style> |
|||
@ -0,0 +1,170 @@ |
|||
const ACTION = { |
|||
SET_STATE: { |
|||
name: "Update UI State", |
|||
tagline: "Update <b>{{path}}</b> to <b>{{value}}</b>", |
|||
icon: "ri-refresh-line", |
|||
description: "Update your User Interface with some data.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
path: "string", |
|||
value: "longText", |
|||
}, |
|||
}, |
|||
NAVIGATE: { |
|||
name: "Navigate", |
|||
tagline: "Navigate to <b>{{url}}</b>", |
|||
icon: "ri-navigation-line", |
|||
description: "Navigate to another page.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
url: "string", |
|||
}, |
|||
}, |
|||
SAVE_RECORD: { |
|||
name: "Save Record", |
|||
tagline: "<b>Save</b> a <b>{{record.model.name}}</b> record", |
|||
icon: "ri-save-3-fill", |
|||
description: "Save a record to your database.", |
|||
environment: "SERVER", |
|||
params: { |
|||
record: "record", |
|||
}, |
|||
args: { |
|||
record: {}, |
|||
}, |
|||
}, |
|||
DELETE_RECORD: { |
|||
description: "Delete a record from your database.", |
|||
icon: "ri-delete-bin-line", |
|||
name: "Delete Record", |
|||
tagline: "<b>Delete</b> a <b>{{record.model.name}}</b> record", |
|||
environment: "SERVER", |
|||
params: { |
|||
record: "record", |
|||
}, |
|||
args: { |
|||
record: {}, |
|||
}, |
|||
}, |
|||
// FIND_RECORD: {
|
|||
// description: "Find a record in your database.",
|
|||
// tagline: "<b>Find</b> a <b>{{record.model.name}}</b> record",
|
|||
// icon: "ri-search-line",
|
|||
// name: "Find Record",
|
|||
// environment: "SERVER",
|
|||
// params: {
|
|||
// record: "string",
|
|||
// },
|
|||
// },
|
|||
CREATE_USER: { |
|||
description: "Create a new user.", |
|||
tagline: "Create user <b>{{username}}</b>", |
|||
icon: "ri-user-add-fill", |
|||
name: "Create User", |
|||
environment: "SERVER", |
|||
params: { |
|||
username: "string", |
|||
password: "password", |
|||
accessLevelId: "accessLevel", |
|||
}, |
|||
}, |
|||
SEND_EMAIL: { |
|||
description: "Send an email.", |
|||
tagline: "Send email to <b>{{to}}</b>", |
|||
icon: "ri-mail-open-fill", |
|||
name: "Send Email", |
|||
environment: "SERVER", |
|||
params: { |
|||
to: "string", |
|||
from: "string", |
|||
subject: "longText", |
|||
text: "longText", |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
const TRIGGER = { |
|||
RECORD_SAVED: { |
|||
name: "Record Saved", |
|||
event: "record:save", |
|||
icon: "ri-save-line", |
|||
tagline: "Record is added to <b>{{model.name}}</b>", |
|||
description: "Save a record to your database.", |
|||
environment: "SERVER", |
|||
params: { |
|||
model: "model", |
|||
}, |
|||
}, |
|||
RECORD_DELETED: { |
|||
name: "Record Deleted", |
|||
event: "record:delete", |
|||
icon: "ri-delete-bin-line", |
|||
tagline: "Record is deleted from <b>{{model.name}}</b>", |
|||
description: "Fired when a record is deleted from your database.", |
|||
environment: "SERVER", |
|||
params: { |
|||
model: "model", |
|||
}, |
|||
}, |
|||
// CLICK: {
|
|||
// name: "Click",
|
|||
// icon: "ri-cursor-line",
|
|||
// tagline: "{{component}} is clicked",
|
|||
// description: "Trigger when you click on an element in the UI.",
|
|||
// environment: "CLIENT",
|
|||
// params: {
|
|||
// component: "component"
|
|||
// }
|
|||
// },
|
|||
// LOAD: {
|
|||
// name: "Load",
|
|||
// icon: "ri-loader-line",
|
|||
// tagline: "{{component}} is loaded",
|
|||
// description: "Trigger an element has finished loading.",
|
|||
// environment: "CLIENT",
|
|||
// params: {
|
|||
// component: "component"
|
|||
// }
|
|||
// },
|
|||
// INPUT: {
|
|||
// name: "Input",
|
|||
// icon: "ri-text",
|
|||
// tagline: "Text entered into {{component}",
|
|||
// description: "Trigger when you type into an input box.",
|
|||
// environment: "CLIENT",
|
|||
// params: {
|
|||
// component: "component"
|
|||
// }
|
|||
// },
|
|||
} |
|||
|
|||
const LOGIC = { |
|||
FILTER: { |
|||
name: "Filter", |
|||
tagline: "{{field}} <b>{{condition}}</b> {{value}}", |
|||
icon: "ri-git-branch-line", |
|||
description: "Filter any workflows which do not meet certain conditions.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
filter: "string", |
|||
condition: ["equals"], |
|||
value: "string", |
|||
}, |
|||
}, |
|||
DELAY: { |
|||
name: "Delay", |
|||
icon: "ri-time-fill", |
|||
tagline: "Delay for <b>{{time}}</b> milliseconds", |
|||
description: "Delay the workflow until an amount of time has passed.", |
|||
environment: "CLIENT", |
|||
params: { |
|||
time: "number", |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
export default { |
|||
ACTION, |
|||
TRIGGER, |
|||
LOGIC, |
|||
} |
|||