forked from tsai/budibase
107 changed files with 8208 additions and 684 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,28 +0,0 @@ |
|||
/** |
|||
* buildStateOrigins |
|||
* |
|||
* Builds an object that details all the bound state in the application, and what updates it. |
|||
* |
|||
* @param screenDefinition - the screen definition metadata. |
|||
* @returns {Object} an object with the client state values and how they are managed. |
|||
*/ |
|||
export const buildStateOrigins = screenDefinition => { |
|||
const origins = {} |
|||
|
|||
function traverse(propValue) { |
|||
for (let key in propValue) { |
|||
if (!Array.isArray(propValue[key])) continue |
|||
|
|||
if (key === "_children") propValue[key].forEach(traverse) |
|||
|
|||
for (let element of propValue[key]) { |
|||
if (element["##eventHandlerType"] === "Set State") |
|||
origins[element.parameters.path] = element |
|||
} |
|||
} |
|||
} |
|||
|
|||
traverse(screenDefinition.props) |
|||
|
|||
return origins |
|||
} |
|||
@ -1,59 +1,59 @@ |
|||
import { generate } from "shortid" |
|||
|
|||
/** |
|||
* Class responsible for the traversing of the workflow definition. |
|||
* Workflow definitions are stored in linked lists. |
|||
* Class responsible for the traversing of the automation definition. |
|||
* Automation definitions are stored in linked lists. |
|||
*/ |
|||
export default class Workflow { |
|||
constructor(workflow) { |
|||
this.workflow = workflow |
|||
export default class Automation { |
|||
constructor(automation) { |
|||
this.automation = automation |
|||
} |
|||
|
|||
hasTrigger() { |
|||
return this.workflow.definition.trigger |
|||
return this.automation.definition.trigger |
|||
} |
|||
|
|||
addBlock(block) { |
|||
// Make sure to add trigger if doesn't exist
|
|||
if (!this.hasTrigger() && block.type === "TRIGGER") { |
|||
const trigger = { id: generate(), ...block } |
|||
this.workflow.definition.trigger = trigger |
|||
this.automation.definition.trigger = trigger |
|||
return trigger |
|||
} |
|||
|
|||
const newBlock = { id: generate(), ...block } |
|||
this.workflow.definition.steps = [ |
|||
...this.workflow.definition.steps, |
|||
this.automation.definition.steps = [ |
|||
...this.automation.definition.steps, |
|||
newBlock, |
|||
] |
|||
return newBlock |
|||
} |
|||
|
|||
updateBlock(updatedBlock, id) { |
|||
const { steps, trigger } = this.workflow.definition |
|||
const { steps, trigger } = this.automation.definition |
|||
|
|||
if (trigger && trigger.id === id) { |
|||
this.workflow.definition.trigger = updatedBlock |
|||
this.automation.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 |
|||
this.automation.definition.steps = steps |
|||
} |
|||
|
|||
deleteBlock(id) { |
|||
const { steps, trigger } = this.workflow.definition |
|||
const { steps, trigger } = this.automation.definition |
|||
|
|||
if (trigger && trigger.id === id) { |
|||
this.workflow.definition.trigger = null |
|||
this.automation.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) |
|||
this.workflow.definition.steps = steps |
|||
this.automation.definition.steps = steps |
|||
} |
|||
} |
|||
@ -0,0 +1,126 @@ |
|||
import { writable } from "svelte/store" |
|||
import api from "../../api" |
|||
import Automation from "./Automation" |
|||
import { cloneDeep } from "lodash/fp" |
|||
|
|||
const automationActions = store => ({ |
|||
fetch: async () => { |
|||
const responses = await Promise.all([ |
|||
api.get(`/api/automations`), |
|||
api.get(`/api/automations/definitions/list`), |
|||
]) |
|||
const jsonResponses = await Promise.all(responses.map(x => x.json())) |
|||
store.update(state => { |
|||
state.automations = jsonResponses[0] |
|||
state.blockDefinitions = { |
|||
TRIGGER: jsonResponses[1].trigger, |
|||
ACTION: jsonResponses[1].action, |
|||
LOGIC: jsonResponses[1].logic, |
|||
} |
|||
return state |
|||
}) |
|||
}, |
|||
create: async ({ name }) => { |
|||
const automation = { |
|||
name, |
|||
type: "automation", |
|||
definition: { |
|||
steps: [], |
|||
}, |
|||
} |
|||
const CREATE_AUTOMATION_URL = `/api/automations` |
|||
const response = await api.post(CREATE_AUTOMATION_URL, automation) |
|||
const json = await response.json() |
|||
store.update(state => { |
|||
state.automations = [...state.automations, json.automation] |
|||
store.actions.select(json.automation) |
|||
return state |
|||
}) |
|||
}, |
|||
save: async ({ automation }) => { |
|||
const UPDATE_AUTOMATION_URL = `/api/automations` |
|||
const response = await api.put(UPDATE_AUTOMATION_URL, automation) |
|||
const json = await response.json() |
|||
store.update(state => { |
|||
const existingIdx = state.automations.findIndex( |
|||
existing => existing._id === automation._id |
|||
) |
|||
state.automations.splice(existingIdx, 1, json.automation) |
|||
state.automations = state.automations |
|||
store.actions.select(json.automation) |
|||
return state |
|||
}) |
|||
}, |
|||
delete: async ({ automation }) => { |
|||
const { _id, _rev } = automation |
|||
const DELETE_AUTOMATION_URL = `/api/automations/${_id}/${_rev}` |
|||
await api.delete(DELETE_AUTOMATION_URL) |
|||
|
|||
store.update(state => { |
|||
const existingIdx = state.automations.findIndex( |
|||
existing => existing._id === _id |
|||
) |
|||
state.automations.splice(existingIdx, 1) |
|||
state.automations = state.automations |
|||
state.selectedAutomation = null |
|||
state.selectedBlock = null |
|||
return state |
|||
}) |
|||
}, |
|||
trigger: async ({ automation }) => { |
|||
const { _id } = automation |
|||
const TRIGGER_AUTOMATION_URL = `/api/automations/${_id}/trigger` |
|||
return await api.post(TRIGGER_AUTOMATION_URL) |
|||
}, |
|||
select: automation => { |
|||
store.update(state => { |
|||
state.selectedAutomation = new Automation(cloneDeep(automation)) |
|||
state.selectedBlock = null |
|||
return state |
|||
}) |
|||
}, |
|||
addBlockToAutomation: block => { |
|||
store.update(state => { |
|||
const newBlock = state.selectedAutomation.addBlock(cloneDeep(block)) |
|||
state.selectedBlock = newBlock |
|||
return state |
|||
}) |
|||
}, |
|||
deleteAutomationBlock: block => { |
|||
store.update(state => { |
|||
const idx = state.selectedAutomation.automation.definition.steps.findIndex( |
|||
x => x.id === block.id |
|||
) |
|||
state.selectedAutomation.deleteBlock(block.id) |
|||
|
|||
// Select next closest step
|
|||
const steps = state.selectedAutomation.automation.definition.steps |
|||
let nextSelectedBlock |
|||
if (steps[idx] != null) { |
|||
nextSelectedBlock = steps[idx] |
|||
} else if (steps[idx - 1] != null) { |
|||
nextSelectedBlock = steps[idx - 1] |
|||
} else { |
|||
nextSelectedBlock = |
|||
state.selectedAutomation.automation.definition.trigger || null |
|||
} |
|||
state.selectedBlock = nextSelectedBlock |
|||
return state |
|||
}) |
|||
}, |
|||
}) |
|||
|
|||
export const getAutomationStore = () => { |
|||
const INITIAL_AUTOMATION_STATE = { |
|||
automations: [], |
|||
blockDefinitions: { |
|||
TRIGGER: [], |
|||
ACTION: [], |
|||
LOGIC: [], |
|||
}, |
|||
selectedAutomation: null, |
|||
} |
|||
const store = writable(INITIAL_AUTOMATION_STATE) |
|||
store.actions = automationActions(store) |
|||
return store |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
import Automation from "../Automation" |
|||
import TEST_AUTOMATION from "./testAutomation" |
|||
|
|||
const TEST_BLOCK = { |
|||
id: "AUXJQGZY7", |
|||
name: "Delay", |
|||
icon: "ri-time-fill", |
|||
tagline: "Delay for <b>{{time}}</b> milliseconds", |
|||
description: "Delay the automation until an amount of time has passed.", |
|||
params: { time: "number" }, |
|||
type: "LOGIC", |
|||
args: { time: "5000" }, |
|||
stepId: "DELAY", |
|||
} |
|||
|
|||
describe("Automation Data Object", () => { |
|||
let automation |
|||
|
|||
beforeEach(() => { |
|||
automation = new Automation({ ...TEST_AUTOMATION }) |
|||
}) |
|||
|
|||
it("adds a automation block to the automation", () => { |
|||
automation.addBlock(TEST_BLOCK) |
|||
expect(automation.automation.definition) |
|||
}) |
|||
|
|||
it("updates a automation block with new attributes", () => { |
|||
const firstBlock = automation.automation.definition.steps[0] |
|||
const updatedBlock = { |
|||
...firstBlock, |
|||
name: "UPDATED", |
|||
} |
|||
automation.updateBlock(updatedBlock, firstBlock.id) |
|||
expect(automation.automation.definition.steps[0]).toEqual(updatedBlock) |
|||
}) |
|||
|
|||
it("deletes a automation block successfully", () => { |
|||
const { steps } = automation.automation.definition |
|||
const originalLength = steps.length |
|||
|
|||
const lastBlock = steps[steps.length - 1] |
|||
automation.deleteBlock(lastBlock.id) |
|||
expect(automation.automation.definition.steps.length).toBeLessThan( |
|||
originalLength |
|||
) |
|||
}) |
|||
}) |
|||
@ -1,126 +0,0 @@ |
|||
import { writable } from "svelte/store" |
|||
import api from "../../api" |
|||
import Workflow from "./Workflow" |
|||
import { cloneDeep } from "lodash/fp" |
|||
|
|||
const workflowActions = store => ({ |
|||
fetch: async () => { |
|||
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 = jsonResponses[0] |
|||
state.blockDefinitions = { |
|||
TRIGGER: jsonResponses[1].trigger, |
|||
ACTION: jsonResponses[1].action, |
|||
LOGIC: jsonResponses[1].logic, |
|||
} |
|||
return state |
|||
}) |
|||
}, |
|||
create: async ({ name }) => { |
|||
const workflow = { |
|||
name, |
|||
type: "workflow", |
|||
definition: { |
|||
steps: [], |
|||
}, |
|||
} |
|||
const CREATE_WORKFLOW_URL = `/api/workflows` |
|||
const response = await api.post(CREATE_WORKFLOW_URL, workflow) |
|||
const json = await response.json() |
|||
store.update(state => { |
|||
state.workflows = [...state.workflows, json.workflow] |
|||
store.actions.select(json.workflow) |
|||
return state |
|||
}) |
|||
}, |
|||
save: 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 |
|||
}) |
|||
}, |
|||
delete: async ({ workflow }) => { |
|||
const { _id, _rev } = workflow |
|||
const DELETE_WORKFLOW_URL = `/api/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.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.selectedWorkflow = new Workflow(cloneDeep(workflow)) |
|||
state.selectedBlock = null |
|||
return state |
|||
}) |
|||
}, |
|||
addBlockToWorkflow: block => { |
|||
store.update(state => { |
|||
const newBlock = state.selectedWorkflow.addBlock(cloneDeep(block)) |
|||
state.selectedBlock = newBlock |
|||
return state |
|||
}) |
|||
}, |
|||
deleteWorkflowBlock: block => { |
|||
store.update(state => { |
|||
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 |
|||
}) |
|||
}, |
|||
}) |
|||
|
|||
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 |
|||
} |
|||
@ -1,48 +0,0 @@ |
|||
import Workflow from "../Workflow" |
|||
import TEST_WORKFLOW from "./testWorkflow" |
|||
|
|||
const TEST_BLOCK = { |
|||
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 }) |
|||
}) |
|||
|
|||
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 |
|||
) |
|||
}) |
|||
}) |
|||
@ -1,48 +1,48 @@ |
|||
<script> |
|||
import { afterUpdate } from "svelte" |
|||
import { workflowStore, backendUiStore } from "builderStore" |
|||
import { automationStore, backendUiStore } from "builderStore" |
|||
import { notifier } from "builderStore/store/notifications" |
|||
import Flowchart from "./flowchart/FlowChart.svelte" |
|||
|
|||
$: workflow = $workflowStore.selectedWorkflow?.workflow |
|||
$: workflowLive = workflow?.live |
|||
$: automation = $automationStore.selectedAutomation?.automation |
|||
$: automationLive = automation?.live |
|||
$: instanceId = $backendUiStore.selectedDatabase._id |
|||
|
|||
function onSelect(block) { |
|||
workflowStore.update(state => { |
|||
automationStore.update(state => { |
|||
state.selectedBlock = block |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
function setWorkflowLive(live) { |
|||
workflow.live = live |
|||
workflowStore.actions.save({ instanceId, workflow }) |
|||
function setAutomationLive(live) { |
|||
automation.live = live |
|||
automationStore.actions.save({ instanceId, automation }) |
|||
if (live) { |
|||
notifier.info(`Workflow ${workflow.name} enabled.`) |
|||
notifier.info(`Automation ${automation.name} enabled.`) |
|||
} else { |
|||
notifier.danger(`Workflow ${workflow.name} disabled.`) |
|||
notifier.danger(`Automation ${automation.name} disabled.`) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<section> |
|||
<Flowchart {workflow} {onSelect} /> |
|||
<Flowchart {automation} {onSelect} /> |
|||
</section> |
|||
<footer> |
|||
{#if workflow} |
|||
{#if automation} |
|||
<button |
|||
class:highlighted={workflowLive} |
|||
class:hoverable={workflowLive} |
|||
class:highlighted={automationLive} |
|||
class:hoverable={automationLive} |
|||
class="stop-button hoverable"> |
|||
<i class="ri-stop-fill" on:click={() => setWorkflowLive(false)} /> |
|||
<i class="ri-stop-fill" on:click={() => setAutomationLive(false)} /> |
|||
</button> |
|||
<button |
|||
class:highlighted={!workflowLive} |
|||
class:hoverable={!workflowLive} |
|||
class:highlighted={!automationLive} |
|||
class:hoverable={!automationLive} |
|||
class="play-button hoverable" |
|||
data-cy="activate-workflow" |
|||
on:click={() => setWorkflowLive(true)}> |
|||
data-cy="activate-automation" |
|||
on:click={() => setAutomationLive(true)}> |
|||
<i class="ri-play-fill" /> |
|||
</button> |
|||
{/if} |
|||
|
Before Width: | Height: | Size: 290 B After Width: | Height: | Size: 290 B |
@ -0,0 +1,3 @@ |
|||
export { default as AutomationPanel } from "./AutomationPanel.svelte" |
|||
export { default as BlockList } from "./BlockList/BlockList.svelte" |
|||
export { default as AutomationList } from "./AutomationList/AutomationList.svelte" |
|||
@ -0,0 +1,3 @@ |
|||
export { default as AutomationBuilder } from "./AutomationBuilder/AutomationBuilder.svelte" |
|||
export { default as SetupPanel } from "./SetupPanel/SetupPanel.svelte" |
|||
export { default as AutomationPanel } from "./AutomationPanel/AutomationPanel.svelte" |
|||
@ -0,0 +1,299 @@ |
|||
<script> |
|||
import { notifier } from "builderStore/store/notifications" |
|||
import { Heading, Body, Button } from "@budibase/bbui" |
|||
import { FILE_TYPES } from "constants/backend" |
|||
import api from "builderStore/api" |
|||
|
|||
const BYTES_IN_KB = 1000 |
|||
const BYTES_IN_MB = 1000000 |
|||
|
|||
export let files = [] |
|||
export let fileSizeLimit = BYTES_IN_MB * 20 |
|||
|
|||
let selectedImageIdx = 0 |
|||
let fileDragged = false |
|||
|
|||
$: selectedImage = files[selectedImageIdx] |
|||
|
|||
function determineFileIcon(extension) { |
|||
const ext = extension.toLowerCase() |
|||
|
|||
if (FILE_TYPES.IMAGE.includes(ext)) return "ri-image-2-line" |
|||
if (FILE_TYPES.CODE.includes(ext)) return "ri-terminal-box-line" |
|||
|
|||
return "ri-file-line" |
|||
} |
|||
|
|||
async function processFiles(fileList) { |
|||
const fileArray = Array.from(fileList) |
|||
|
|||
if (fileArray.some(file => file.size >= fileSizeLimit)) { |
|||
notifier.danger( |
|||
`Files cannot exceed ${fileSizeLimit / |
|||
BYTES_IN_MB}MB. Please try again with smaller files.` |
|||
) |
|||
return |
|||
} |
|||
|
|||
const filesToProcess = fileArray.map(({ name, path, size }) => ({ |
|||
name, |
|||
path, |
|||
size, |
|||
})) |
|||
|
|||
const response = await api.post(`/api/attachments/process`, { |
|||
files: filesToProcess, |
|||
}) |
|||
const processedFiles = await response.json() |
|||
files = [...processedFiles, ...files] |
|||
selectedImageIdx = 0 |
|||
} |
|||
|
|||
async function removeFile() { |
|||
files.splice(selectedImageIdx, 1) |
|||
files = files |
|||
selectedImageIdx = 0 |
|||
} |
|||
|
|||
function navigateLeft() { |
|||
selectedImageIdx -= 1 |
|||
} |
|||
|
|||
function navigateRight() { |
|||
selectedImageIdx += 1 |
|||
} |
|||
|
|||
function handleFile(evt) { |
|||
processFiles(evt.target.files) |
|||
} |
|||
|
|||
function handleDragOver(evt) { |
|||
evt.preventDefault() |
|||
fileDragged = true |
|||
} |
|||
|
|||
function handleDragLeave(evt) { |
|||
evt.preventDefault() |
|||
fileDragged = false |
|||
} |
|||
|
|||
function handleDrop(evt) { |
|||
evt.preventDefault() |
|||
processFiles(evt.dataTransfer.files) |
|||
fileDragged = false |
|||
} |
|||
</script> |
|||
|
|||
<div |
|||
class="dropzone" |
|||
on:dragover={handleDragOver} |
|||
on:dragleave={handleDragLeave} |
|||
on:dragenter={handleDragOver} |
|||
on:drop={handleDrop} |
|||
class:fileDragged> |
|||
<ul> |
|||
{#if selectedImage} |
|||
<li> |
|||
<header> |
|||
<div> |
|||
<i |
|||
class={`file-icon ${determineFileIcon(selectedImage.extension)}`} /> |
|||
<span class="filename">{selectedImage.name}</span> |
|||
</div> |
|||
<p> |
|||
{#if selectedImage.size <= BYTES_IN_MB} |
|||
{selectedImage.size / BYTES_IN_KB}KB |
|||
{:else}{selectedImage.size / BYTES_IN_MB}MB{/if} |
|||
</p> |
|||
</header> |
|||
<div class="delete-button" on:click={removeFile}> |
|||
<i class="ri-close-line" /> |
|||
</div> |
|||
{#if selectedImageIdx !== 0} |
|||
<div class="nav left" on:click={navigateLeft}> |
|||
<i class="ri-arrow-left-line" /> |
|||
</div> |
|||
{/if} |
|||
<img src={selectedImage.url} /> |
|||
{#if selectedImageIdx !== files.length - 1} |
|||
<div class="nav right" on:click={navigateRight}> |
|||
<i class="ri-arrow-right-line" /> |
|||
</div> |
|||
{/if} |
|||
</li> |
|||
{/if} |
|||
</ul> |
|||
<i class="ri-folder-upload-line" /> |
|||
<input id="file-upload" type="file" multiple on:change={handleFile} /> |
|||
<label for="file-upload">Upload</label> |
|||
</div> |
|||
|
|||
<style> |
|||
.dropzone { |
|||
padding: var(--spacing-l); |
|||
border: 2px dashed var(--grey-7); |
|||
text-align: center; |
|||
display: flex; |
|||
align-items: center; |
|||
flex-direction: column; |
|||
border-radius: 10px; |
|||
transition: all 0.3s; |
|||
} |
|||
|
|||
.fileDragged { |
|||
border: 2px dashed var(--grey-7); |
|||
transform: scale(1.03); |
|||
background: var(--blue-light); |
|||
} |
|||
|
|||
input[type="file"] { |
|||
display: none; |
|||
} |
|||
|
|||
label { |
|||
font-family: var(--font-sans); |
|||
cursor: pointer; |
|||
font-weight: 600; |
|||
box-sizing: border-box; |
|||
overflow: hidden; |
|||
border-radius: var(--border-radius-s); |
|||
color: var(--white); |
|||
padding: var(--spacing-s) var(--spacing-l); |
|||
transition: all 0.2s ease 0s; |
|||
display: inline-flex; |
|||
text-rendering: optimizeLegibility; |
|||
min-width: auto; |
|||
outline: none; |
|||
font-feature-settings: "case" 1, "rlig" 1, "calt" 0; |
|||
-webkit-box-align: center; |
|||
user-select: none; |
|||
flex-shrink: 0; |
|||
align-items: center; |
|||
justify-content: center; |
|||
margin-top: 10px; |
|||
width: 100%; |
|||
border: solid 1.5px var(--ink); |
|||
background-color: var(--ink); |
|||
} |
|||
|
|||
div.nav { |
|||
position: absolute; |
|||
background: black; |
|||
color: var(--white); |
|||
display: flex; |
|||
align-items: center; |
|||
bottom: var(--spacing-s); |
|||
border-radius: 10px; |
|||
transition: 0.2s transform; |
|||
} |
|||
|
|||
.nav:hover { |
|||
cursor: pointer; |
|||
transform: scale(1.1); |
|||
} |
|||
|
|||
.left { |
|||
left: var(--spacing-s); |
|||
} |
|||
|
|||
.right { |
|||
right: var(--spacing-s); |
|||
} |
|||
|
|||
li { |
|||
position: relative; |
|||
height: 300px; |
|||
background: var(--grey-7); |
|||
display: flex; |
|||
justify-content: center; |
|||
border-radius: 10px; |
|||
} |
|||
|
|||
img { |
|||
border-radius: 10px; |
|||
width: 100%; |
|||
box-shadow: 0 var(--spacing-s) 12px rgba(0, 0, 0, 0.15); |
|||
object-fit: contain; |
|||
} |
|||
|
|||
i { |
|||
font-size: 3em; |
|||
} |
|||
|
|||
.file-icon { |
|||
color: var(--white); |
|||
font-size: 2em; |
|||
margin-right: var(--spacing-s); |
|||
} |
|||
|
|||
ul { |
|||
padding: 0; |
|||
display: grid; |
|||
grid-gap: var(--spacing-s); |
|||
list-style-type: none; |
|||
width: 100%; |
|||
} |
|||
|
|||
header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
position: absolute; |
|||
background: linear-gradient( |
|||
180deg, |
|||
rgba(12, 12, 12, 1), |
|||
rgba(60, 60, 60, 0) |
|||
); |
|||
width: 100%; |
|||
border-top-left-radius: 10px; |
|||
border-top-right-radius: 10px; |
|||
height: 60px; |
|||
} |
|||
|
|||
header > div { |
|||
color: var(--white); |
|||
display: flex; |
|||
align-items: center; |
|||
font-size: 15px; |
|||
margin-left: var(--spacing-m); |
|||
width: 60%; |
|||
overflow: hidden; |
|||
white-space: nowrap; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
.filename { |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
header > p { |
|||
color: var(--grey-5); |
|||
margin-right: var(--spacing-m); |
|||
} |
|||
|
|||
.delete-button { |
|||
position: absolute; |
|||
top: var(--spacing-s); |
|||
right: var(--spacing-s); |
|||
padding: var(--spacing-s); |
|||
border-radius: 10px; |
|||
opacity: 0; |
|||
transition: all 0.3s; |
|||
color: var(--white); |
|||
} |
|||
|
|||
.delete-button i { |
|||
font-size: 2em; |
|||
} |
|||
|
|||
.delete-button:hover { |
|||
opacity: 1; |
|||
cursor: pointer; |
|||
background: linear-gradient( |
|||
to top right, |
|||
rgba(60, 60, 60, 0), |
|||
rgba(255, 0, 0, 0.2) |
|||
); |
|||
} |
|||
</style> |
|||
@ -1,5 +1,7 @@ |
|||
<script> |
|||
import { Circle } from "svelte-loading-spinners" |
|||
|
|||
export let size = "60" |
|||
</script> |
|||
|
|||
<Circle size="60" color="#000000" unit="px" /> |
|||
<Circle {size} color="#000000" unit="px" /> |
|||
|
|||
@ -0,0 +1,64 @@ |
|||
<script> |
|||
import { FILE_TYPES } from "constants/backend" |
|||
|
|||
export let files |
|||
export let height = "70" |
|||
export let width = "70" |
|||
</script> |
|||
|
|||
<div class="file-list"> |
|||
{#each files as file} |
|||
<div class="file"> |
|||
{#if FILE_TYPES.IMAGE.includes(file.extension.toLowerCase())} |
|||
<img {width} {height} src={file.url} /> |
|||
{:else} |
|||
<i class="ri-file-line" /> |
|||
<span class="extension">.{file.extension}</span> |
|||
<span>{file.name}</span> |
|||
{/if} |
|||
</div> |
|||
{/each} |
|||
</div> |
|||
|
|||
<style> |
|||
.file-list { |
|||
display: grid; |
|||
grid-auto-flow: column; |
|||
grid-gap: var(--spacing-m); |
|||
grid-template-columns: repeat(10, 1fr); |
|||
} |
|||
|
|||
img { |
|||
object-fit: contain; |
|||
} |
|||
|
|||
i { |
|||
font-size: 36px; |
|||
margin-bottom: var(--spacing-m); |
|||
} |
|||
|
|||
.file { |
|||
position: relative; |
|||
height: 75px; |
|||
width: 75px; |
|||
border: 2px dashed var(--grey-7); |
|||
padding: var(--spacing-m); |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.extension { |
|||
position: absolute; |
|||
top: var(--spacing-s); |
|||
left: var(--spacing-s); |
|||
font-weight: 500; |
|||
} |
|||
|
|||
span { |
|||
width: 75px; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
</style> |
|||
@ -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
@ -0,0 +1,3 @@ |
|||
import "@fortawesome/fontawesome-free/js/all.js" |
|||
|
|||
export { default as IconSelect } from "./IconSelect.svelte" |
|||
@ -1,3 +0,0 @@ |
|||
export { default as WorkflowPanel } from "./WorkflowPanel.svelte" |
|||
export { default as BlockList } from "./BlockList/BlockList.svelte" |
|||
export { default as WorkflowList } from "./WorkflowList/WorkflowList.svelte" |
|||
@ -1,3 +0,0 @@ |
|||
export { default as WorkflowBuilder } from "./WorkflowBuilder/WorkflowBuilder.svelte" |
|||
export { default as SetupPanel } from "./SetupPanel/SetupPanel.svelte" |
|||
export { default as WorkflowPanel } from "./WorkflowPanel/WorkflowPanel.svelte" |
|||
@ -1,18 +1,19 @@ |
|||
<!-- routify:options index=3 --> |
|||
<script> |
|||
import { workflowStore } from "builderStore" |
|||
import { WorkflowPanel, SetupPanel } from "components/workflow" |
|||
import { automationStore } from "builderStore" |
|||
import { AutomationPanel, SetupPanel } from "components/automation" |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
<div class="nav"> |
|||
<div class="inner"> |
|||
<WorkflowPanel /> |
|||
<AutomationPanel /> |
|||
</div> |
|||
</div> |
|||
<div class="content"> |
|||
<slot /> |
|||
</div> |
|||
{#if $workflowStore.selectedWorkflow} |
|||
{#if $automationStore.selectedAutomation} |
|||
<div class="nav"> |
|||
<div class="inner"> |
|||
<SetupPanel /> |
|||
@ -0,0 +1,5 @@ |
|||
<script> |
|||
import { AutomationBuilder } from "components/automation" |
|||
</script> |
|||
|
|||
<AutomationBuilder /> |
|||
@ -1,5 +0,0 @@ |
|||
<script> |
|||
import { WorkflowBuilder } from "components/workflow" |
|||
</script> |
|||
|
|||
<WorkflowBuilder /> |
|||
@ -1,32 +0,0 @@ |
|||
import { buildStateOrigins } from "../src/builderStore/buildStateOrigins" |
|||
|
|||
it("builds the correct stateOrigins object from a screen definition with handlers", () => { |
|||
expect( |
|||
buildStateOrigins({ |
|||
name: "screen1", |
|||
description: "", |
|||
props: { |
|||
_component: "@budibase/standard-components/container", |
|||
className: "", |
|||
type: "div", |
|||
onClick: [ |
|||
{ |
|||
"##eventHandlerType": "Set State", |
|||
parameters: { |
|||
path: "testKey", |
|||
value: "value", |
|||
}, |
|||
}, |
|||
], |
|||
}, |
|||
}) |
|||
).toEqual({ |
|||
testKey: { |
|||
"##eventHandlerType": "Set State", |
|||
parameters: { |
|||
path: "testKey", |
|||
value: "value", |
|||
}, |
|||
}, |
|||
}) |
|||
}) |
|||
@ -0,0 +1,30 @@ |
|||
const fs = require("fs") |
|||
const sharp = require("sharp") |
|||
const fsPromises = fs.promises |
|||
|
|||
const FORMATS = { |
|||
IMAGES: ["png", "jpg", "jpeg", "gif", "svg", "tiff", "raw"], |
|||
} |
|||
|
|||
async function processImage(file) { |
|||
const imgMeta = await sharp(file.path) |
|||
.resize(300) |
|||
.toFile(file.outputPath) |
|||
|
|||
return { |
|||
...file, |
|||
...imgMeta, |
|||
} |
|||
} |
|||
|
|||
async function process(file) { |
|||
if (FORMATS.IMAGES.includes(file.extension.toLowerCase())) { |
|||
return await processImage(file) |
|||
} |
|||
|
|||
// No processing required
|
|||
await fsPromises.copyFile(file.path, file.outputPath) |
|||
return file |
|||
} |
|||
|
|||
exports.process = process |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue