1
0
Fork 0
mirror of synced 2024-08-15 10:01:34 +12:00

Merge remote-tracking branch 'origin/develop' into feature/app-user-onboarding-ux

This commit is contained in:
Dean 2023-02-27 09:12:07 +00:00
commit c135a029f9
67 changed files with 1504 additions and 1013 deletions

View file

@ -68,83 +68,6 @@ jobs:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
deploy-to-release-env:
needs: [release-images]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Get the current budibase release version
id: version
run: |
release_version=$(cat lerna.json | jq -r '.version')
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-1
- name: Pull values.yaml from budibase-infra
run: |
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \
-H 'Accept: application/vnd.github.v3.raw' \
-o values.release.yaml \
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/budibase-release/values.yaml
wc -l values.release.yaml
- name: Deploy to Release Environment
uses: glopezep/helm@v1.7.1
with:
release: budibase-release
namespace: budibase
chart: charts/budibase
token: ${{ github.token }}
helm: helm3
values: |
globals:
appVersion: develop
ingress:
enabled: true
nginx: true
value-files: >-
[
"values.release.yaml"
]
env:
KUBECONFIG_FILE: '${{ secrets.RELEASE_KUBECONFIG }}'
- name: Re roll app-service
uses: actions-hub/kubectl@master
env:
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
with:
args: rollout restart deployment app-service -n budibase
- name: Re roll proxy-service
uses: actions-hub/kubectl@master
env:
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
with:
args: rollout restart deployment proxy-service -n budibase
- name: Re roll worker-service
uses: actions-hub/kubectl@master
env:
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
with:
args: rollout restart deployment worker-service -n budibase
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v4.0.0
with:
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
content: "Release Env Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Release Env."
embed-title: ${{ env.RELEASE_VERSION }}
release-helm-chart: release-helm-chart:
needs: [release-images] needs: [release-images]
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -1,2 +1,2 @@
nodejs 14.19.3 nodejs 14.19.3
python 3.11.1 python 3.10.0

View file

@ -1,5 +1,5 @@
{ {
"version": "2.3.18-alpha.6", "version": "2.3.18-alpha.12",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/backend-core", "name": "@budibase/backend-core",
"version": "2.3.18-alpha.6", "version": "2.3.18-alpha.12",
"description": "Budibase backend core libraries used in server and worker", "description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js", "main": "dist/src/index.js",
"types": "dist/src/index.d.ts", "types": "dist/src/index.d.ts",
@ -24,7 +24,7 @@
"dependencies": { "dependencies": {
"@budibase/nano": "10.1.1", "@budibase/nano": "10.1.1",
"@budibase/pouchdb-replication-stream": "1.2.10", "@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/types": "2.3.18-alpha.6", "@budibase/types": "2.3.18-alpha.12",
"@shopify/jest-koa-mocks": "5.0.1", "@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2", "@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0", "aws-cloudfront-sign": "2.2.0",

View file

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "2.3.18-alpha.6", "version": "2.3.18-alpha.12",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",
@ -38,7 +38,7 @@
], ],
"dependencies": { "dependencies": {
"@adobe/spectrum-css-workflow-icons": "1.2.1", "@adobe/spectrum-css-workflow-icons": "1.2.1",
"@budibase/string-templates": "2.3.18-alpha.6", "@budibase/string-templates": "2.3.18-alpha.12",
"@spectrum-css/accordion": "3.0.24", "@spectrum-css/accordion": "3.0.24",
"@spectrum-css/actionbutton": "1.0.1", "@spectrum-css/actionbutton": "1.0.1",
"@spectrum-css/actiongroup": "1.0.1", "@spectrum-css/actiongroup": "1.0.1",

View file

@ -67,6 +67,9 @@
color: var(--spectrum-alias-icon-color-selected-hover) !important; color: var(--spectrum-alias-icon-color-selected-hover) !important;
cursor: pointer; cursor: pointer;
} }
svg.hoverable:active {
color: var(--spectrum-global-color-blue-400) !important;
}
svg.disabled { svg.disabled {
color: var(--spectrum-global-color-gray-500) !important; color: var(--spectrum-global-color-gray-500) !important;

View file

@ -57,5 +57,7 @@
--spectrum-semantic-negative-icon-color: #e34850; --spectrum-semantic-negative-icon-color: #e34850;
min-width: 100px; min-width: 100px;
margin: 0; margin: 0;
border-color: var(--spectrum-global-color-gray-400);
border-width: 1px;
} }
</style> </style>

View file

@ -21,7 +21,7 @@
label { label {
padding: 0; padding: 0;
white-space: nowrap; white-space: nowrap;
color: var(--spectrum-global-color-gray-600); color: var(--spectrum-global-color-gray-700);
} }
.muted { .muted {

View file

@ -1,7 +1,7 @@
<script> <script>
import "@spectrum-css/modal/dist/index-vars.css" import "@spectrum-css/modal/dist/index-vars.css"
import "@spectrum-css/underlay/dist/index-vars.css" import "@spectrum-css/underlay/dist/index-vars.css"
import { createEventDispatcher, setContext, tick } from "svelte" import { createEventDispatcher, setContext, tick, onMount } from "svelte"
import { fade, fly } from "svelte/transition" import { fade, fly } from "svelte/transition"
import Portal from "svelte-portal" import Portal from "svelte-portal"
import Context from "../context" import Context from "../context"
@ -62,9 +62,14 @@
} }
setContext(Context.Modal, { show, hide, cancel }) setContext(Context.Modal, { show, hide, cancel })
</script>
<svelte:window on:keydown={handleKey} /> onMount(() => {
document.addEventListener("keydown", handleKey)
return () => {
document.removeEventListener("keydown", handleKey)
}
})
</script>
{#if inline} {#if inline}
{#if visible} {#if visible}

View file

@ -104,6 +104,9 @@ export const deepSet = (obj, key, value) => {
* @param obj the object to clone * @param obj the object to clone
*/ */
export const cloneDeep = obj => { export const cloneDeep = obj => {
if (!obj) {
return obj
}
return JSON.parse(JSON.stringify(obj)) return JSON.parse(JSON.stringify(obj))
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "2.3.18-alpha.6", "version": "2.3.18-alpha.12",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -58,10 +58,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "2.3.18-alpha.6", "@budibase/bbui": "2.3.18-alpha.12",
"@budibase/client": "2.3.18-alpha.6", "@budibase/client": "2.3.18-alpha.12",
"@budibase/frontend-core": "2.3.18-alpha.6", "@budibase/frontend-core": "2.3.18-alpha.12",
"@budibase/string-templates": "2.3.18-alpha.6", "@budibase/string-templates": "2.3.18-alpha.12",
"@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1", "@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1",
@ -72,6 +72,7 @@
"codemirror": "^5.59.0", "codemirror": "^5.59.0",
"dayjs": "^1.11.2", "dayjs": "^1.11.2",
"downloadjs": "1.4.7", "downloadjs": "1.4.7",
"fast-json-patch": "^3.1.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"posthog-js": "^1.36.0", "posthog-js": "^1.36.0",
"remixicon": "2.5.0", "remixicon": "2.5.0",

View file

@ -5,12 +5,47 @@ import { getThemeStore } from "./store/theme"
import { derived } from "svelte/store" import { derived } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils" import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { createHistoryStore } from "builderStore/store/history"
import { get } from "svelte/store"
export const store = getFrontendStore() export const store = getFrontendStore()
export const automationStore = getAutomationStore() export const automationStore = getAutomationStore()
export const themeStore = getThemeStore() export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore() export const temporalStore = getTemporalStore()
// Setup history for screens
export const screenHistoryStore = createHistoryStore({
getDoc: id => get(store).screens?.find(screen => screen._id === id),
selectDoc: store.actions.screens.select,
afterAction: () => {
// Ensure a valid component is selected
if (!get(selectedComponent)) {
store.update(state => ({
...state,
selectedComponentId: get(selectedScreen)?.props._id,
}))
}
},
})
store.actions.screens.save = screenHistoryStore.wrapSaveDoc(
store.actions.screens.save
)
store.actions.screens.delete = screenHistoryStore.wrapDeleteDoc(
store.actions.screens.delete
)
// Setup history for automations
export const automationHistoryStore = createHistoryStore({
getDoc: automationStore.actions.getDefinition,
selectDoc: automationStore.actions.select,
})
automationStore.actions.save = automationHistoryStore.wrapSaveDoc(
automationStore.actions.save
)
automationStore.actions.delete = automationHistoryStore.wrapDeleteDoc(
automationStore.actions.delete
)
export const selectedScreen = derived(store, $store => { export const selectedScreen = derived(store, $store => {
return $store.screens.find(screen => screen._id === $store.selectedScreenId) return $store.screens.find(screen => screen._id === $store.selectedScreenId)
}) })
@ -71,3 +106,13 @@ export const selectedComponentPath = derived(
).map(component => component._id) ).map(component => component._id)
} }
) )
// Derived automation state
export const selectedAutomation = derived(automationStore, $automationStore => {
if (!$automationStore.selectedAutomationId) {
return null
}
return $automationStore.automations?.find(
x => x._id === $automationStore.selectedAutomationId
)
})

View file

@ -1,69 +0,0 @@
import { generate } from "shortid"
/**
* Class responsible for the traversing of the automation definition.
* Automation definitions are stored in linked lists.
*/
export default class Automation {
constructor(automation) {
this.automation = automation
}
hasTrigger() {
return this.automation.definition.trigger
}
addTestData(data) {
this.automation.testData = { ...this.automation.testData, ...data }
}
addBlock(block, idx) {
// Make sure to add trigger if doesn't exist
if (!this.hasTrigger() && block.type === "TRIGGER") {
const trigger = { id: generate(), ...block }
this.automation.definition.trigger = trigger
return trigger
}
const newBlock = { id: generate(), ...block }
this.automation.definition.steps.splice(idx, 0, newBlock)
return newBlock
}
updateBlock(updatedBlock, id) {
const { steps, trigger } = this.automation.definition
if (trigger && trigger.id === id) {
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.automation.definition.steps = steps
}
deleteBlock(id) {
const { steps, trigger } = this.automation.definition
if (trigger && trigger.id === id) {
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.automation.definition.steps = steps
}
constructBlock(type, stepId, blockDefinition) {
return {
...blockDefinition,
inputs: blockDefinition.inputs || {},
stepId,
type,
}
}
}

View file

@ -1,16 +1,18 @@
import { writable } from "svelte/store" import { writable, get } from "svelte/store"
import { API } from "api" import { API } from "api"
import Automation from "./Automation"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { generate } from "shortid"
import { selectedAutomation } from "builderStore"
const initialAutomationState = { const initialAutomationState = {
automations: [], automations: [],
testResults: null,
showTestPanel: false, showTestPanel: false,
blockDefinitions: { blockDefinitions: {
TRIGGER: [], TRIGGER: [],
ACTION: [], ACTION: [],
}, },
selectedAutomation: null, selectedAutomationId: null,
} }
export const getAutomationStore = () => { export const getAutomationStore = () => {
@ -37,49 +39,41 @@ const automationActions = store => ({
API.getAutomationDefinitions(), API.getAutomationDefinitions(),
]) ])
store.update(state => { store.update(state => {
let selected = state.selectedAutomation?.automation
state.automations = responses[0] state.automations = responses[0]
state.automations.sort((a, b) => {
return a.name < b.name ? -1 : 1
})
state.blockDefinitions = { state.blockDefinitions = {
TRIGGER: responses[1].trigger, TRIGGER: responses[1].trigger,
ACTION: responses[1].action, ACTION: responses[1].action,
} }
// If previously selected find the new obj and select it
if (selected) {
selected = responses[0].filter(
automation => automation._id === selected._id
)
state.selectedAutomation = new Automation(selected[0])
}
return state return state
}) })
}, },
create: async ({ name }) => { create: async (name, trigger) => {
const automation = { const automation = {
name, name,
type: "automation", type: "automation",
definition: { definition: {
steps: [], steps: [],
trigger,
}, },
} }
const response = await API.createAutomation(automation) const response = await store.actions.save(automation)
store.update(state => { await store.actions.fetch()
state.automations = [...state.automations, response.automation] store.actions.select(response._id)
store.actions.select(response.automation) return response
return state
})
}, },
duplicate: async automation => { duplicate: async automation => {
const response = await API.createAutomation({ const response = await store.actions.save({
...automation, ...automation,
name: `${automation.name} - copy`, name: `${automation.name} - copy`,
_id: undefined, _id: undefined,
_ref: undefined, _ref: undefined,
}) })
store.update(state => { await store.actions.fetch()
state.automations = [...state.automations, response.automation] store.actions.select(response._id)
store.actions.select(response.automation) return response
return state
})
}, },
save: async automation => { save: async automation => {
const response = await API.updateAutomation(automation) const response = await API.updateAutomation(automation)
@ -90,11 +84,13 @@ const automationActions = store => ({
) )
if (existingIdx !== -1) { if (existingIdx !== -1) {
state.automations.splice(existingIdx, 1, updatedAutomation) state.automations.splice(existingIdx, 1, updatedAutomation)
state.automations = [...state.automations]
store.actions.select(updatedAutomation)
return state return state
} else {
state.automations = [...state.automations, updatedAutomation]
} }
return state
}) })
return response.automation
}, },
delete: async automation => { delete: async automation => {
await API.deleteAutomation({ await API.deleteAutomation({
@ -102,34 +98,83 @@ const automationActions = store => ({
automationRev: automation?._rev, automationRev: automation?._rev,
}) })
store.update(state => { store.update(state => {
const existingIdx = state.automations.findIndex( // Remove the automation
existing => existing._id === automation?._id state.automations = state.automations.filter(
x => x._id !== automation._id
) )
state.automations.splice(existingIdx, 1) // Select a new automation if required
state.automations = [...state.automations] if (automation._id === state.selectedAutomationId) {
state.selectedAutomation = null store.actions.select(state.automations[0]?._id)
state.selectedBlock = null }
return state return state
}) })
await store.actions.fetch()
},
updateBlockInputs: async (block, data) => {
// Create new modified block
let newBlock = {
...block,
inputs: {
...block.inputs,
...data,
},
}
// Remove any nullish or empty string values
Object.keys(newBlock.inputs).forEach(key => {
const val = newBlock.inputs[key]
if (val == null || val === "") {
delete newBlock.inputs[key]
}
})
// Create new modified automation
const automation = get(selectedAutomation)
const newAutomation = store.actions.getUpdatedDefinition(
automation,
newBlock
)
// Don't save if no changes were made
if (JSON.stringify(newAutomation) === JSON.stringify(automation)) {
return
}
await store.actions.save(newAutomation)
}, },
test: async (automation, testData) => { test: async (automation, testData) => {
store.update(state => {
state.selectedAutomation.testResults = null
return state
})
const result = await API.testAutomation({ const result = await API.testAutomation({
automationId: automation?._id, automationId: automation?._id,
testData, testData,
}) })
if (!result?.trigger && !result?.steps?.length) {
throw "Something went wrong testing your automation"
}
store.update(state => { store.update(state => {
state.selectedAutomation.testResults = result state.testResults = result
return state return state
}) })
}, },
select: automation => { getDefinition: id => {
return get(store).automations?.find(x => x._id === id)
},
getUpdatedDefinition: (automation, block) => {
let newAutomation = cloneDeep(automation)
if (automation.definition.trigger?.id === block.id) {
newAutomation.definition.trigger = block
} else {
const idx = automation.definition.steps.findIndex(x => x.id === block.id)
newAutomation.definition.steps.splice(idx, 1, block)
}
return newAutomation
},
select: id => {
if (!id || id === get(store).selectedAutomationId) {
return
}
store.update(state => { store.update(state => {
state.selectedAutomation = new Automation(cloneDeep(automation)) state.selectedAutomationId = id
state.selectedBlock = null state.testResults = null
state.showTestPanel = false
return state return state
}) })
}, },
@ -147,48 +192,57 @@ const automationActions = store => ({
appId, appId,
}) })
}, },
addTestDataToAutomation: data => { addTestDataToAutomation: async data => {
store.update(state => { let newAutomation = cloneDeep(get(selectedAutomation))
state.selectedAutomation.addTestData(data) newAutomation.testData = {
return state ...newAutomation.testData,
}) ...data,
}
await store.actions.save(newAutomation)
}, },
addBlockToAutomation: (block, blockIdx) => { constructBlock(type, stepId, blockDefinition) {
store.update(state => { return {
state.selectedBlock = state.selectedAutomation.addBlock( ...blockDefinition,
cloneDeep(block), inputs: blockDefinition.inputs || {},
blockIdx stepId,
) type,
return state id: generate(),
}) }
}, },
toggleFieldControl: value => { addBlockToAutomation: async (block, blockIdx) => {
store.update(state => { const automation = get(selectedAutomation)
state.selectedBlock.rowControl = value let newAutomation = cloneDeep(automation)
return state if (!automation) {
}) return
}
newAutomation.definition.steps.splice(blockIdx, 0, block)
await store.actions.save(newAutomation)
}, },
deleteAutomationBlock: block => { /**
store.update(state => { * "rowControl" appears to be the name of the flag used to determine whether
const idx = * a certain automation block uses values or bindings as inputs
state.selectedAutomation.automation.definition.steps.findIndex( */
x => x.id === block.id toggleRowControl: async (block, rowControl) => {
) const newBlock = { ...block, rowControl }
state.selectedAutomation.deleteBlock(block.id) const newAutomation = store.actions.getUpdatedDefinition(
get(selectedAutomation),
newBlock
)
await store.actions.save(newAutomation)
},
deleteAutomationBlock: async block => {
const automation = get(selectedAutomation)
let newAutomation = cloneDeep(automation)
// Select next closest step // Delete trigger if required
const steps = state.selectedAutomation.automation.definition.steps if (newAutomation.definition.trigger?.id === block.id) {
let nextSelectedBlock delete newAutomation.definition.trigger
if (steps[idx] != null) { } else {
nextSelectedBlock = steps[idx] // Otherwise remove step
} else if (steps[idx - 1] != null) { newAutomation.definition.steps = newAutomation.definition.steps.filter(
nextSelectedBlock = steps[idx - 1] step => step.id !== block.id
} else { )
nextSelectedBlock = }
state.selectedAutomation.automation.definition.trigger || null await store.actions.save(newAutomation)
}
state.selectedBlock = nextSelectedBlock
return state
})
}, },
}) })

View file

@ -1,48 +0,0 @@
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
)
})
})

View file

@ -1,78 +0,0 @@
export default {
name: "Test automation",
definition: {
steps: [
{
id: "ANBDINAPS",
description: "Send an email.",
tagline: "Send email to <b>{{to}}</b>",
icon: "ri-mail-open-fill",
name: "Send Email",
params: {
to: "string",
from: "string",
subject: "longText",
text: "longText",
},
type: "ACTION",
args: {
text: "A user was created!",
subject: "New Budibase User",
from: "budimaster@budibase.com",
to: "test@test.com",
},
stepId: "SEND_EMAIL",
},
],
trigger: {
id: "iRzYMOqND",
name: "Row Saved",
event: "row:save",
icon: "ri-save-line",
tagline: "Row is added to <b>{{table.name}}</b>",
description: "Fired when a row is saved to your database.",
params: { table: "table" },
type: "TRIGGER",
args: {
table: {
type: "table",
views: {},
name: "users",
schema: {
name: {
type: "string",
constraints: {
type: "string",
length: { maximum: 123 },
presence: { allowEmpty: false },
},
name: "name",
},
age: {
type: "number",
constraints: {
type: "number",
presence: { allowEmpty: false },
numericality: {
greaterThanOrEqualTo: "",
lessThanOrEqualTo: "",
},
},
name: "age",
},
},
_id: "c6b4e610cd984b588837bca27188a451",
_rev: "7-b8aa1ce0b53e88928bb88fc11bdc0aff",
},
},
stepId: "ROW_SAVED",
},
},
type: "automation",
ok: true,
id: "b384f861f4754e1693835324a7fcca62",
rev: "1-aa1c2cbd868ef02e26f8fad531dd7e37",
live: false,
_id: "b384f861f4754e1693835324a7fcca62",
_rev: "108-4116829ec375e0481d0ecab9e83a2caf",
}

View file

@ -1,6 +1,11 @@
import { get, writable } from "svelte/store" import { get, writable } from "svelte/store"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { selectedScreen, selectedComponent } from "builderStore" import {
selectedScreen,
selectedComponent,
screenHistoryStore,
automationHistoryStore,
} from "builderStore"
import { import {
datasources, datasources,
integrations, integrations,
@ -124,6 +129,8 @@ export const getFrontendStore = () => {
navigation: application.navigation || {}, navigation: application.navigation || {},
usedPlugins: application.usedPlugins || [], usedPlugins: application.usedPlugins || [],
})) }))
screenHistoryStore.reset()
automationHistoryStore.reset()
// Initialise backend stores // Initialise backend stores
database.set(application.instance) database.set(application.instance)
@ -181,10 +188,7 @@ export const getFrontendStore = () => {
} }
// Check screen isn't already selected // Check screen isn't already selected
if ( if (state.selectedScreenId === screen._id) {
state.selectedScreenId === screen._id &&
state.selectedComponentId === screen.props?._id
) {
return return
} }
@ -258,7 +262,7 @@ export const getFrontendStore = () => {
} }
}, },
save: async screen => { save: async screen => {
/* /*
Temporarily disabled to accomodate migration issues. Temporarily disabled to accomodate migration issues.
store.actions.screens.validate(screen) store.actions.screens.validate(screen)
*/ */
@ -349,6 +353,7 @@ export const getFrontendStore = () => {
return state return state
}) })
return null
}, },
updateSetting: async (screen, name, value) => { updateSetting: async (screen, name, value) => {
if (!screen || !name) { if (!screen || !name) {

View file

@ -0,0 +1,319 @@
import * as jsonpatch from "fast-json-patch/index.mjs"
import { writable, derived, get } from "svelte/store"
const Operations = {
Add: "Add",
Delete: "Delete",
Change: "Change",
}
const initialState = {
history: [],
position: 0,
loading: false,
}
export const createHistoryStore = ({
getDoc,
selectDoc,
beforeAction,
afterAction,
}) => {
// Use a derived store to check if we are able to undo or redo any operations
const store = writable(initialState)
const derivedStore = derived(store, $store => {
return {
...$store,
canUndo: $store.position > 0,
canRedo: $store.position < $store.history.length,
}
})
// Wrapped versions of essential functions which we call ourselves when using
// undo and redo
let saveFn
let deleteFn
/**
* Internal util to set the loading flag
*/
const startLoading = () => {
store.update(state => {
state.loading = true
return state
})
}
/**
* Internal util to unset the loading flag
*/
const stopLoading = () => {
store.update(state => {
state.loading = false
return state
})
}
/**
* Resets history state
*/
const reset = () => {
store.set(initialState)
}
/**
* Adds or updates an operation in history.
* For internal use only.
* @param operation the operation to save
*/
const saveOperation = operation => {
store.update(state => {
// Update history
let history = state.history
let position = state.position
if (!operation.id) {
// Every time a new operation occurs we discard any redo potential
operation.id = Math.random()
history = [...history.slice(0, state.position), operation]
position += 1
} else {
// If this is a redo/undo of an existing operation, just update history
// to replace the doc object as revisions may have changed
const idx = history.findIndex(op => op.id === operation.id)
history[idx].doc = operation.doc
}
return { history, position }
})
}
/**
* Wraps the save function, which asynchronously updates a doc.
* The returned function is an enriched version of the real save function so
* that we can control history.
* @param fn the save function
* @returns {function} a wrapped version of the save function
*/
const wrapSaveDoc = fn => {
saveFn = async (doc, operationId) => {
// Only works on a single doc at a time
if (!doc || Array.isArray(doc)) {
return
}
startLoading()
try {
const oldDoc = getDoc(doc._id)
const newDoc = jsonpatch.deepClone(await fn(doc))
// Store the change
if (!oldDoc) {
// If no old doc, this is an add operation
saveOperation({
type: Operations.Add,
doc: newDoc,
id: operationId,
})
} else {
// Otherwise this is a change operation
saveOperation({
type: Operations.Change,
forwardPatch: jsonpatch.compare(oldDoc, doc),
backwardsPatch: jsonpatch.compare(doc, oldDoc),
doc: newDoc,
id: operationId,
})
}
stopLoading()
return newDoc
} catch (error) {
// We want to allow errors to propagate up to normal handlers, but we
// want to stop loading first
stopLoading()
throw error
}
}
return saveFn
}
/**
* Wraps the delete function, which asynchronously deletes a doc.
* The returned function is an enriched version of the real delete function so
* that we can control history.
* @param fn the delete function
* @returns {function} a wrapped version of the delete function
*/
const wrapDeleteDoc = fn => {
deleteFn = async (doc, operationId) => {
// Only works on a single doc at a time
if (!doc || Array.isArray(doc)) {
return
}
startLoading()
try {
const oldDoc = jsonpatch.deepClone(doc)
await fn(doc)
saveOperation({
type: Operations.Delete,
doc: oldDoc,
id: operationId,
})
stopLoading()
} catch (error) {
// We want to allow errors to propagate up to normal handlers, but we
// want to stop loading first
stopLoading()
throw error
}
}
return deleteFn
}
/**
* Asynchronously undoes the previous operation.
* Optionally selects the changed document so that changes are visible.
* @returns {Promise<void>}
*/
const undo = async () => {
// Sanity checks
const { canUndo, history, position, loading } = get(derivedStore)
if (!canUndo || loading) {
return
}
const operation = history[position - 1]
if (!operation) {
return
}
startLoading()
// Before hook
await beforeAction?.(operation)
// Update state immediately to prevent further clicks and to prevent bad
// history in the event of an update failing
store.update(state => {
return {
...state,
position: state.position - 1,
}
})
// Undo the operation
try {
// Undo ADD
if (operation.type === Operations.Add) {
// Try to get the latest doc version to delete
const latestDoc = getDoc(operation.doc._id)
const doc = latestDoc || operation.doc
await deleteFn(doc, operation.id)
}
// Undo DELETE
else if (operation.type === Operations.Delete) {
// Delete the _rev from the deleted doc so that we can save it as a new
// doc again without conflicts
let doc = jsonpatch.deepClone(operation.doc)
delete doc._rev
const created = await saveFn(doc, operation.id)
selectDoc?.(created?._id || doc._id)
}
// Undo CHANGE
else {
// Get the current doc and apply the backwards patch on top of it
let doc = jsonpatch.deepClone(getDoc(operation.doc._id))
if (doc) {
jsonpatch.applyPatch(
doc,
jsonpatch.deepClone(operation.backwardsPatch)
)
await saveFn(doc, operation.id)
selectDoc?.(doc._id)
}
}
stopLoading()
} catch (error) {
stopLoading()
throw error
}
// After hook
await afterAction?.(operation)
}
/**
* Asynchronously redoes the previous undo.
* Optionally selects the changed document so that changes are visible.
* @returns {Promise<void>}
*/
const redo = async () => {
// Sanity checks
const { canRedo, history, position, loading } = get(derivedStore)
if (!canRedo || loading) {
return
}
const operation = history[position]
if (!operation) {
return
}
startLoading()
// Before hook
await beforeAction?.(operation)
// Update state immediately to prevent further clicks and to prevent bad
// history in the event of an update failing
store.update(state => {
return {
...state,
position: state.position + 1,
}
})
// Redo the operation
try {
// Redo ADD
if (operation.type === Operations.Add) {
// Delete the _rev from the deleted doc so that we can save it as a new
// doc again without conflicts
let doc = jsonpatch.deepClone(operation.doc)
delete doc._rev
const created = await saveFn(doc, operation.id)
selectDoc?.(created?._id || doc._id)
}
// Redo DELETE
else if (operation.type === Operations.Delete) {
// Try to get the latest doc version to delete
const latestDoc = getDoc(operation.doc._id)
const doc = latestDoc || operation.doc
await deleteFn(doc, operation.id)
}
// Redo CHANGE
else {
// Get the current doc and apply the forwards patch on top of it
let doc = jsonpatch.deepClone(getDoc(operation.doc._id))
if (doc) {
jsonpatch.applyPatch(doc, jsonpatch.deepClone(operation.forwardPatch))
await saveFn(doc, operation.id)
selectDoc?.(doc._id)
}
}
stopLoading()
} catch (error) {
stopLoading()
throw error
}
// After hook
await afterAction?.(operation)
}
return {
subscribe: derivedStore.subscribe,
wrapSaveDoc,
wrapDeleteDoc,
reset,
undo,
redo,
}
}

View file

@ -1,10 +1,10 @@
<script> <script>
import { automationStore } from "builderStore" import { selectedAutomation } from "builderStore"
import Flowchart from "./FlowChart/FlowChart.svelte" import Flowchart from "./FlowChart/FlowChart.svelte"
$: automation = $automationStore.selectedAutomation?.automation
</script> </script>
{#if automation} {#if $selectedAutomation}
<Flowchart {automation} /> {#key $selectedAutomation._id}
<Flowchart automation={$selectedAutomation} />
{/key}
{/if} {/if}

View file

@ -5,7 +5,6 @@
Detail, Detail,
Body, Body,
Icon, Icon,
Tooltip,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { automationStore } from "builderStore" import { automationStore } from "builderStore"
@ -13,7 +12,6 @@
import { externalActions } from "./ExternalActions" import { externalActions } from "./ExternalActions"
export let blockIdx export let blockIdx
export let blockComplete
const disabled = { const disabled = {
SEND_EMAIL_SMTP: { SEND_EMAIL_SMTP: {
@ -50,15 +48,12 @@
async function addBlockToAutomation() { async function addBlockToAutomation() {
try { try {
const newBlock = $automationStore.selectedAutomation.constructBlock( const newBlock = automationStore.actions.constructBlock(
"ACTION", "ACTION",
actionVal.stepId, actionVal.stepId,
actionVal actionVal
) )
automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1) await automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1)
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
} catch (error) { } catch (error) {
notifications.error("Error saving automation") notifications.error("Error saving automation")
} }
@ -66,20 +61,14 @@
</script> </script>
<ModalContent <ModalContent
title="Create Automation" title="Add automation step"
confirmText="Save" confirmText="Save"
size="M" size="M"
disabled={!selectedAction} disabled={!selectedAction}
onConfirm={() => { onConfirm={addBlockToAutomation}
blockComplete = true
addBlockToAutomation()
}}
> >
<Body size="XS">Select an app or event.</Body> <Layout noPadding gap="XS">
<Detail size="S">Apps</Detail>
<Layout noPadding>
<Body size="S">Apps</Body>
<div class="item-list"> <div class="item-list">
{#each Object.entries(external) as [idx, action]} {#each Object.entries(external) as [idx, action]}
<div <div
@ -95,64 +84,45 @@
alt="zapier" alt="zapier"
/> />
<span class="icon-spacing"> <span class="icon-spacing">
<Body size="XS">{idx.charAt(0).toUpperCase() + idx.slice(1)}</Body <Body size="XS">
></span {idx.charAt(0).toUpperCase() + idx.slice(1)}
> </Body>
</span>
</div> </div>
</div> </div>
{/each} {/each}
</div> </div>
</Layout>
<Layout noPadding gap="XS">
<Detail size="S">Actions</Detail> <Detail size="S">Actions</Detail>
<div class="item-list"> <div class="item-list">
{#each Object.entries(internal) as [idx, action]} {#each Object.entries(internal) as [idx, action]}
{#if disabled[idx] && disabled[idx].disabled} {@const isDisabled = disabled[idx] && disabled[idx].disabled}
<Tooltip text={disabled[idx].message} direction="bottom"> <div
<div class="item"
class="item" class:disabled={isDisabled}
class:selected={selectedAction === action.name} class:selected={selectedAction === action.name}
class:disabled={true} on:click={isDisabled ? null : () => selectAction(action)}
on:click={() => selectAction(action)} >
> <div class="item-body">
<div class="item-body"> <Icon name={action.icon} />
<Icon name={action.icon} /> <Body size="XS">{action.name}</Body>
<span class="icon-spacing"> {#if isDisabled}
<Body size="XS">{action.name}</Body></span <Icon name="Help" tooltip={disabled[idx].message} />
> {/if}
</div>
</div>
</Tooltip>
{:else}
<div
class="item"
class:selected={selectedAction === action.name}
on:click={() => selectAction(action)}
>
<div class="item-body">
<Icon name={action.icon} />
<span class="icon-spacing">
<Body size="XS">{action.name}</Body></span
>
</div>
</div> </div>
{/if} </div>
{/each} {/each}
</div> </div>
</Layout> </Layout>
</ModalContent> </ModalContent>
<style> <style>
.disabled {
opacity: 0.3;
pointer-events: none;
}
.icon-spacing {
margin-left: var(--spacing-m);
}
.item-body { .item-body {
display: flex; display: flex;
margin-left: var(--spacing-m); margin-left: var(--spacing-m);
gap: var(--spacing-m);
} }
.item-list { .item-list {
display: grid; display: grid;
@ -171,8 +141,15 @@
box-sizing: border-box; box-sizing: border-box;
border-width: 2px; border-width: 2px;
} }
.item:hover, .item:not(.disabled):hover,
.selected { .selected {
background: var(--spectrum-alias-background-color-tertiary); background: var(--spectrum-alias-background-color-tertiary);
} }
.disabled {
background: var(--spectrum-global-color-gray-200);
color: var(--spectrum-global-color-gray-500);
}
.disabled :global(.spectrum-Body) {
color: var(--spectrum-global-color-gray-600);
}
</style> </style>

View file

@ -1,5 +1,5 @@
<script> <script>
import { automationStore } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import FlowItem from "./FlowItem.svelte" import FlowItem from "./FlowItem.svelte"
import TestDataModal from "./TestDataModal.svelte" import TestDataModal from "./TestDataModal.svelte"
@ -13,27 +13,28 @@
Modal, Modal,
} from "@budibase/bbui" } from "@budibase/bbui"
import { ActionStepID } from "constants/backend/automations" import { ActionStepID } from "constants/backend/automations"
import UndoRedoControl from "components/common/UndoRedoControl.svelte"
import { automationHistoryStore } from "builderStore"
export let automation export let automation
let testDataModal let testDataModal
let blocks
let confirmDeleteDialog let confirmDeleteDialog
$: { $: blocks = getBlocks(automation)
blocks = []
if (automation) { const getBlocks = automation => {
if (automation.definition.trigger) { let blocks = []
blocks.push(automation.definition.trigger) if (automation.definition.trigger) {
} blocks.push(automation.definition.trigger)
blocks = blocks.concat(automation.definition.steps || [])
} }
blocks = blocks.concat(automation.definition.steps || [])
return blocks
} }
async function deleteAutomation() { async function deleteAutomation() {
try { try {
await automationStore.actions.delete( await automationStore.actions.delete($selectedAutomation)
$automationStore.selectedAutomation?.automation
)
} catch (error) { } catch (error) {
notifications.error("Error deleting automation") notifications.error("Error deleting automation")
} }
@ -41,20 +42,17 @@
</script> </script>
<div class="canvas"> <div class="canvas">
<div style="float: left; padding-left: var(--spacing-xl);"> <div class="header">
<Heading size="S">{automation.name}</Heading> <Heading size="S">{automation.name}</Heading>
</div> <div class="controls">
<div style="float: right; padding-right: var(--spacing-xl);" class="title"> <UndoRedoControl store={automationHistoryStore} />
<div class="subtitle"> <Icon
<div style="display:flex; align-items: center;"> on:click={confirmDeleteDialog.show}
<div class="icon"> hoverable
<Icon size="M"
on:click={confirmDeleteDialog.show} name="DeleteOutline"
hoverable />
size="M" <div class="buttons">
name="DeleteOutline"
/>
</div>
<ActionButton <ActionButton
on:click={() => { on:click={() => {
testDataModal.show() testDataModal.show()
@ -62,15 +60,13 @@
icon="MultipleCheck" icon="MultipleCheck"
size="M">Run test</ActionButton size="M">Run test</ActionButton
> >
<div style="padding-left: var(--spacing-m);"> <ActionButton
<ActionButton disabled={!$automationStore.testResults}
disabled={!$automationStore.selectedAutomation?.testResults} on:click={() => {
on:click={() => { $automationStore.showTestPanel = true
$automationStore.showTestPanel = true }}
}} size="M">Test Details</ActionButton
size="M">Test Details</ActionButton >
>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -80,7 +76,7 @@
<div <div
class="block" class="block"
animate:flip={{ duration: 500 }} animate:flip={{ duration: 500 }}
in:fly|local={{ x: 500, duration: 500 }} in:fly={{ x: 500, duration: 500 }}
out:fly|local={{ x: 500, duration: 500 }} out:fly|local={{ x: 500, duration: 500 }}
> >
{#if block.stepId !== ActionStepID.LOOP} {#if block.stepId !== ActionStepID.LOOP}
@ -105,6 +101,9 @@
</Modal> </Modal>
<style> <style>
.canvas {
padding: var(--spacing-l) var(--spacing-xl);
}
/* Fix for firefox not respecting bottom padding in scrolling containers */ /* Fix for firefox not respecting bottom padding in scrolling containers */
.canvas > *:last-child { .canvas > *:last-child {
padding-bottom: 40px; padding-bottom: 40px;
@ -122,18 +121,19 @@
text-align: left; text-align: left;
} }
.title { .header {
padding-bottom: var(--spacing-xl);
}
.subtitle {
padding-bottom: var(--spacing-xl);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.icon { .controls,
cursor: pointer; .buttons {
padding-right: var(--spacing-m); display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-xl);
}
.buttons {
gap: var(--spacing-s);
} }
</style> </style>

View file

@ -1,5 +1,5 @@
<script> <script>
import { automationStore } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
import { import {
Icon, Icon,
Divider, Divider,
@ -23,36 +23,26 @@
export let block export let block
export let testDataModal export let testDataModal
export let idx export let idx
let selected let selected
let webhookModal let webhookModal
let actionModal let actionModal
let blockComplete let open = true
let showLooping = false let showLooping = false
let role let role
$: automationId = $automationStore.selectedAutomation?.automation._id $: automationId = $selectedAutomation?._id
$: showBindingPicker = $: showBindingPicker =
block.stepId === ActionStepID.CREATE_ROW || block.stepId === ActionStepID.CREATE_ROW ||
block.stepId === ActionStepID.UPDATE_ROW block.stepId === ActionStepID.UPDATE_ROW
$: isTrigger = block.type === "TRIGGER" $: isTrigger = block.type === "TRIGGER"
$: steps = $selectedAutomation?.definition?.steps ?? []
$: selected = $automationStore.selectedBlock?.id === block.id
$: steps =
$automationStore.selectedAutomation?.automation?.definition?.steps ?? []
$: blockIdx = steps.findIndex(step => step.id === block.id) $: blockIdx = steps.findIndex(step => step.id === block.id)
$: lastStep = !isTrigger && blockIdx + 1 === steps.length $: lastStep = !isTrigger && blockIdx + 1 === steps.length
$: totalBlocks = $selectedAutomation?.definition?.steps.length + 1
$: totalBlocks = $: loopBlock = $selectedAutomation?.definition.steps.find(
$automationStore.selectedAutomation?.automation?.definition?.steps.length + x => x.blockToLoop === block.id
1 )
$: loopingSelected =
$automationStore.selectedAutomation?.automation.definition.steps.find(
x => x.blockToLoop === block.id
)
$: isAppAction = block?.stepId === TriggerStepID.APP $: isAppAction = block?.stepId === TriggerStepID.APP
$: isAppAction && setPermissions(role) $: isAppAction && setPermissions(role)
$: isAppAction && getPermissions(automationId) $: isAppAction && getPermissions(automationId)
@ -81,76 +71,54 @@
} }
async function removeLooping() { async function removeLooping() {
loopingSelected = false let loopBlock = $selectedAutomation?.definition.steps.find(
let loopBlock = x => x.blockToLoop === block.id
$automationStore.selectedAutomation?.automation.definition.steps.find(
x => x.blockToLoop === block.id
)
automationStore.actions.deleteAutomationBlock(loopBlock)
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
) )
try {
await automationStore.actions.deleteAutomationBlock(loopBlock)
} catch (error) {
notifications.error("Error saving automation")
}
} }
async function deleteStep() { async function deleteStep() {
let loopBlock = let loopBlock = $selectedAutomation?.definition.steps.find(
$automationStore.selectedAutomation?.automation.definition.steps.find( x => x.blockToLoop === block.id
x => x.blockToLoop === block.id )
)
try { try {
if (loopBlock) { if (loopBlock) {
automationStore.actions.deleteAutomationBlock(loopBlock) await automationStore.actions.deleteAutomationBlock(loopBlock)
} }
automationStore.actions.deleteAutomationBlock(block) await automationStore.actions.deleteAutomationBlock(block)
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
} catch (error) { } catch (error) {
notifications.error("Error saving notification") notifications.error("Error saving automation")
} }
} }
function toggleFieldControl(evt) {
onSelect(block) /**
let rowControl * "rowControl" appears to be the name of the flag used to determine whether
if (evt.detail === "Use values") { * a certain automation block uses values or bindings as inputs
rowControl = false */
} else { function toggleRowControl(evt) {
rowControl = true const rowControl = evt.detail !== "Use values"
} automationStore.actions.toggleRowControl(block, rowControl)
automationStore.actions.toggleFieldControl(rowControl)
automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
} }
async function addLooping() { async function addLooping() {
loopingSelected = true
const loopDefinition = $automationStore.blockDefinitions.ACTION.LOOP const loopDefinition = $automationStore.blockDefinitions.ACTION.LOOP
const loopBlock = automationStore.actions.constructBlock(
const loopBlock = $automationStore.selectedAutomation.constructBlock(
"ACTION", "ACTION",
"LOOP", "LOOP",
loopDefinition loopDefinition
) )
loopBlock.blockToLoop = block.id loopBlock.blockToLoop = block.id
block.loopBlock = loopBlock.id await automationStore.actions.addBlockToAutomation(loopBlock, blockIdx)
automationStore.actions.addBlockToAutomation(loopBlock, blockIdx)
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
}
async function onSelect(block) {
await automationStore.update(state => {
state.selectedBlock = block
return state
})
} }
</script> </script>
<div class={`block ${block.type} hoverable`} class:selected on:click={() => {}}> <div class={`block ${block.type} hoverable`} class:selected on:click={() => {}}>
{#if loopingSelected} {#if loopBlock}
<div class="blockSection"> <div class="blockSection">
<div <div
on:click={() => { on:click={() => {
@ -174,13 +142,8 @@
</div> </div>
<div class="blockTitle"> <div class="blockTitle">
<div <div style="margin-left: 10px;" on:click={() => {}}>
style="margin-left: 10px;" <Icon hoverable name={showLooping ? "ChevronDown" : "ChevronUp"} />
on:click={() => {
onSelect(block)
}}
>
<Icon name={showLooping ? "ChevronUp" : "ChevronDown"} />
</div> </div>
</div> </div>
</div> </div>
@ -198,9 +161,7 @@
$automationStore.blockDefinitions.ACTION.LOOP.schema.inputs $automationStore.blockDefinitions.ACTION.LOOP.schema.inputs
.properties .properties
)} )}
block={$automationStore.selectedAutomation?.automation.definition.steps.find( block={loopBlock}
x => x.blockToLoop === block.id
)}
{webhookModal} {webhookModal}
/> />
</Layout> </Layout>
@ -209,22 +170,28 @@
{/if} {/if}
{/if} {/if}
<FlowItemHeader bind:blockComplete {block} {testDataModal} {idx} /> <FlowItemHeader
{#if !blockComplete} {open}
{block}
{testDataModal}
{idx}
on:toggle={() => (open = !open)}
/>
{#if open}
<Divider noMargin /> <Divider noMargin />
<div class="blockSection"> <div class="blockSection">
<Layout noPadding gap="S"> <Layout noPadding gap="S">
{#if !isTrigger} {#if !isTrigger}
<div> <div>
<div class="block-options"> <div class="block-options">
{#if !loopingSelected} {#if !loopBlock}
<ActionButton on:click={() => addLooping()} icon="Reuse" <ActionButton on:click={() => addLooping()} icon="Reuse">
>Add Looping</ActionButton Add Looping
> </ActionButton>
{/if} {/if}
{#if showBindingPicker} {#if showBindingPicker}
<Select <Select
on:change={toggleFieldControl} on:change={toggleRowControl}
defaultValue="Use values" defaultValue="Use values"
autoWidth autoWidth
value={block.rowControl ? "Use bindings" : "Use values"} value={block.rowControl ? "Use bindings" : "Use values"}
@ -250,16 +217,16 @@
{webhookModal} {webhookModal}
/> />
{#if lastStep} {#if lastStep}
<Button on:click={() => testDataModal.show()} cta <Button on:click={() => testDataModal.show()} cta>
>Finish and test automation</Button Finish and test automation
> </Button>
{/if} {/if}
</Layout> </Layout>
</div> </div>
{/if} {/if}
<Modal bind:this={actionModal} width="30%"> <Modal bind:this={actionModal} width="30%">
<ActionModal {blockIdx} bind:blockComplete /> <ActionModal {blockIdx} />
</Modal> </Modal>
<Modal bind:this={webhookModal} width="30%"> <Modal bind:this={webhookModal} width="30%">

View file

@ -2,21 +2,22 @@
import { automationStore } from "builderStore" import { automationStore } from "builderStore"
import { Icon, Body, Detail, StatusLight } from "@budibase/bbui" import { Icon, Body, Detail, StatusLight } from "@budibase/bbui"
import { externalActions } from "./ExternalActions" import { externalActions } from "./ExternalActions"
import { createEventDispatcher } from "svelte"
export let block export let block
export let blockComplete export let open
export let showTestStatus = false export let showTestStatus = false
export let showParameters = {}
export let testResult export let testResult
export let isTrigger export let isTrigger
export let idx export let idx
const dispatch = createEventDispatcher()
$: { $: {
if (!testResult) { if (!testResult) {
testResult = testResult = $automationStore.testResults?.steps?.filter(step =>
$automationStore.selectedAutomation?.testResults?.steps.filter(step => block.id ? step.id === block.id : step.stepId === block.stepId
block.id ? step.id === block.id : step.stepId === block.stepId )?.[0]
)[0]
} }
} }
$: isTrigger = isTrigger || block.type === "TRIGGER" $: isTrigger = isTrigger || block.type === "TRIGGER"
@ -45,13 +46,7 @@
</script> </script>
<div class="blockSection"> <div class="blockSection">
<div <div on:click={() => dispatch("toggle")} class="splitHeader">
on:click={() => {
blockComplete = !blockComplete
showParameters[block.id] = blockComplete
}}
class="splitHeader"
>
<div class="center-items"> <div class="center-items">
{#if externalActions[block.stepId]} {#if externalActions[block.stepId]}
<img <img
@ -99,7 +94,7 @@
onSelect(block) onSelect(block)
}} }}
> >
<Icon hoverable name={blockComplete ? "ChevronUp" : "ChevronDown"} /> <Icon hoverable name={open ? "ChevronUp" : "ChevronDown"} />
</div> </div>
</div> </div>
</div> </div>

View file

@ -7,7 +7,7 @@
Label, Label,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { automationStore } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte" import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
@ -17,9 +17,7 @@
$: { $: {
// clone the trigger so we're not mutating the reference // clone the trigger so we're not mutating the reference
trigger = cloneDeep( trigger = cloneDeep($selectedAutomation.definition.trigger)
$automationStore.selectedAutomation.automation.definition.trigger
)
// get the outputs so we can define the fields // get the outputs so we can define the fields
let schema = Object.entries(trigger.schema?.outputs?.properties || {}) let schema = Object.entries(trigger.schema?.outputs?.properties || {})
@ -32,7 +30,7 @@
} }
// check to see if there is existing test data in the store // check to see if there is existing test data in the store
$: testData = $automationStore.selectedAutomation.automation.testData || {} $: testData = $selectedAutomation.testData || {}
// Check the schema to see if required fields have been entered // Check the schema to see if required fields have been entered
$: isError = !trigger.schema.outputs.required.every( $: isError = !trigger.schema.outputs.required.every(
@ -51,10 +49,7 @@
const testAutomation = async () => { const testAutomation = async () => {
try { try {
await automationStore.actions.test( await automationStore.actions.test($selectedAutomation, testData)
$automationStore.selectedAutomation?.automation,
testData
)
$automationStore.showTestPanel = true $automationStore.showTestPanel = true
} catch (error) { } catch (error) {
notifications.error("Error testing automation") notifications.error("Error testing automation")
@ -70,8 +65,8 @@
onConfirm={testAutomation} onConfirm={testAutomation}
cancelText="Cancel" cancelText="Cancel"
> >
<Tabs selected="Form" quiet <Tabs selected="Form" quiet>
><Tab icon="Form" title="Form"> <Tab icon="Form" title="Form">
<div class="tab-content-padding"> <div class="tab-content-padding">
<AutomationBlockSetup <AutomationBlockSetup
{testData} {testData}
@ -86,11 +81,7 @@
<Label>JSON</Label> <Label>JSON</Label>
<div class="text-area-container"> <div class="text-area-container">
<TextArea <TextArea
value={JSON.stringify( value={JSON.stringify($selectedAutomation.testData, null, 2)}
$automationStore.selectedAutomation.automation.testData,
null,
2
)}
error={failedParse} error={failedParse}
on:change={e => parseTestJSON(e)} on:change={e => parseTestJSON(e)}
/> />

View file

@ -7,7 +7,7 @@
export let testResults export let testResults
export let width = "400px" export let width = "400px"
let showParameters let openBlocks = {}
let blocks let blocks
function prepTestResults(results) { function prepTestResults(results) {
@ -48,14 +48,15 @@
<div class="block" style={width ? `width: ${width}` : ""}> <div class="block" style={width ? `width: ${width}` : ""}>
{#if block.stepId !== ActionStepID.LOOP} {#if block.stepId !== ActionStepID.LOOP}
<FlowItemHeader <FlowItemHeader
showTestStatus={true} open={!!openBlocks[block.id]}
bind:showParameters on:toggle={() => (openBlocks[block.id] = !openBlocks[block.id])}
{block}
isTrigger={idx === 0} isTrigger={idx === 0}
{idx}
testResult={filteredResults?.[idx]} testResult={filteredResults?.[idx]}
showTestStatus
{block}
{idx}
/> />
{#if showParameters && showParameters[block.id]} {#if openBlocks[block.id]}
<Divider noMargin /> <Divider noMargin />
{#if filteredResults?.[idx]?.outputs.iterations} {#if filteredResults?.[idx]?.outputs.iterations}
<div style="display: flex; padding: 10px 10px 0px 12px;"> <div style="display: flex; padding: 10px 10px 0px 12px;">

View file

@ -2,26 +2,8 @@
import { Icon, Divider } from "@budibase/bbui" import { Icon, Divider } from "@budibase/bbui"
import TestDisplay from "./TestDisplay.svelte" import TestDisplay from "./TestDisplay.svelte"
import { automationStore } from "builderStore" import { automationStore } from "builderStore"
import { ActionStepID } from "constants/backend/automations"
export let automation export let automation
let blocks, testResults
$: {
blocks = []
if (automation) {
if (automation.definition.trigger) {
blocks.push(automation.definition.trigger)
}
blocks = blocks
.concat(automation.definition.steps || [])
.filter(x => x.stepId !== ActionStepID.LOOP)
} else if ($automationStore.selectedAutomation) {
automation = $automationStore.selectedAutomation
}
}
$: testResults = $automationStore.selectedAutomation?.testResults
</script> </script>
<div class="title"> <div class="title">
@ -42,7 +24,7 @@
<Divider /> <Divider />
<TestDisplay {automation} {testResults} /> <TestDisplay {automation} testResults={$automationStore.testResults} />
<style> <style>
.title { .title {

View file

@ -1,12 +1,11 @@
<script> <script>
import { onMount } from "svelte" import { onMount } from "svelte"
import { goto } from "@roxi/routify" import { automationStore, selectedAutomation } from "builderStore"
import { automationStore } from "builderStore"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import EditAutomationPopover from "./EditAutomationPopover.svelte" import EditAutomationPopover from "./EditAutomationPopover.svelte"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
$: selectedAutomationId = $automationStore.selectedAutomation?.automation?._id $: selectedAutomationId = $selectedAutomation?._id
onMount(async () => { onMount(async () => {
try { try {
@ -16,9 +15,8 @@
} }
}) })
function selectAutomation(automation) { function selectAutomation(id) {
automationStore.actions.select(automation) automationStore.actions.select(id)
$goto(`./${automation._id}`)
} }
</script> </script>
@ -29,7 +27,7 @@
icon="ShareAndroid" icon="ShareAndroid"
text={automation.name} text={automation.name}
selected={automation._id === selectedAutomationId} selected={automation._id === selectedAutomationId}
on:click={() => selectAutomation(automation)} on:click={() => selectAutomation(automation._id)}
> >
<EditAutomationPopover {automation} /> <EditAutomationPopover {automation} />
</NavItem> </NavItem>
@ -42,5 +40,6 @@
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
margin: 0 calc(-1 * var(--spacing-xl));
} }
</style> </style>

View file

@ -1,36 +1,20 @@
<script> <script>
import AutomationList from "./AutomationList.svelte" import AutomationList from "./AutomationList.svelte"
import CreateAutomationModal from "./CreateAutomationModal.svelte" import CreateAutomationModal from "./CreateAutomationModal.svelte"
import { Modal, Tabs, Tab, Button, Layout } from "@budibase/bbui" import { Modal, Button, Layout } from "@budibase/bbui"
import Panel from "components/design/Panel.svelte"
export let modal export let modal
export let webhookModal export let webhookModal
</script> </script>
<div class="nav"> <Panel title="Automations" borderRight>
<Tabs selected="Automations"> <Layout paddingX="L" paddingY="XL" gap="S">
<Tab title="Automations"> <Button cta on:click={modal.show}>Add automation</Button>
<Layout paddingX="L" paddingY="L" gap="S"> <AutomationList />
<Button cta wide on:click={modal.show}>Add automation</Button> </Layout>
</Layout> </Panel>
<AutomationList />
<Modal bind:this={modal}>
<CreateAutomationModal {webhookModal} />
</Modal>
</Tab>
</Tabs>
</div>
<style> <Modal bind:this={modal}>
.nav { <CreateAutomationModal {webhookModal} />
overflow-y: auto; </Modal>
background: var(--background);
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
position: relative;
border-right: var(--border-light);
padding-bottom: 60px;
}
</style>

View file

@ -1,6 +1,4 @@
<script> <script>
import { goto } from "@roxi/routify"
import { database } from "stores/backend"
import { automationStore } from "builderStore" import { automationStore } from "builderStore"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { import {
@ -10,48 +8,37 @@
Layout, Layout,
Body, Body,
Icon, Icon,
Label,
} from "@budibase/bbui" } from "@budibase/bbui"
import { TriggerStepID } from "constants/backend/automations" import { TriggerStepID } from "constants/backend/automations"
export let webhookModal
let name let name
let selectedTrigger let selectedTrigger
let nameTouched = false let nameTouched = false
let triggerVal let triggerVal
export let webhookModal
$: instanceId = $database._id
$: nameError = $: nameError =
nameTouched && !name ? "Please specify a name for the automation." : null nameTouched && !name ? "Please specify a name for the automation." : null
$: triggers = Object.entries($automationStore.blockDefinitions.TRIGGER)
async function createAutomation() { async function createAutomation() {
try { try {
await automationStore.actions.create({ const trigger = automationStore.actions.constructBlock(
name,
instanceId,
})
const newBlock = $automationStore.selectedAutomation.constructBlock(
"TRIGGER", "TRIGGER",
triggerVal.stepId, triggerVal.stepId,
triggerVal triggerVal
) )
await automationStore.actions.create(name, trigger)
automationStore.actions.addBlockToAutomation(newBlock)
if (triggerVal.stepId === TriggerStepID.WEBHOOK) { if (triggerVal.stepId === TriggerStepID.WEBHOOK) {
webhookModal.show webhookModal.show()
} }
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
notifications.success(`Automation ${name} created`) notifications.success(`Automation ${name} created`)
$goto(`./${$automationStore.selectedAutomation.automation._id}`)
} catch (error) { } catch (error) {
notifications.error("Error creating automation") notifications.error("Error creating automation")
} }
} }
$: triggers = Object.entries($automationStore.blockDefinitions.TRIGGER)
const selectTrigger = trigger => { const selectTrigger = trigger => {
triggerVal = trigger triggerVal = trigger
@ -70,9 +57,9 @@
header="You must publish your app to activate your automations." header="You must publish your app to activate your automations."
message="To test your automation before publishing, you can use the 'Run Test' functionality on the next screen." message="To test your automation before publishing, you can use the 'Run Test' functionality on the next screen."
/> />
<Body size="XS" <Body size="S">
>Please name your automation, then select a trigger. Every automation must Please name your automation, then select a trigger.<br />
start with a trigger. Every automation must start with a trigger.
</Body> </Body>
<Input <Input
bind:value={name} bind:value={name}
@ -81,9 +68,8 @@
label="Name" label="Name"
/> />
<Layout noPadding> <Layout noPadding gap="XS">
<Body size="S">Triggers</Body> <Label size="S">Trigger</Label>
<div class="item-list"> <div class="item-list">
{#each triggers as [idx, trigger]} {#each triggers as [idx, trigger]}
<div <div

View file

@ -1,5 +1,4 @@
<script> <script>
import { goto } from "@roxi/routify"
import { automationStore } from "builderStore" import { automationStore } from "builderStore"
import { ActionMenu, MenuItem, notifications, Icon } from "@budibase/bbui" import { ActionMenu, MenuItem, notifications, Icon } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -14,7 +13,6 @@
try { try {
await automationStore.actions.delete(automation) await automationStore.actions.delete(automation)
notifications.success("Automation deleted successfully") notifications.success("Automation deleted successfully")
$goto("../automate")
} catch (error) { } catch (error) {
notifications.error("Error deleting automation") notifications.error("Error deleting automation")
} }
@ -24,7 +22,6 @@
try { try {
await automationStore.actions.duplicate(automation) await automationStore.actions.duplicate(automation)
notifications.success("Automation has been duplicated successfully") notifications.success("Automation has been duplicated successfully")
$goto(`./${$automationStore.selectedAutomation.automation._id}`)
} catch (error) { } catch (error) {
notifications.error("Error duplicating automation") notifications.error("Error duplicating automation")
} }

View file

@ -3,13 +3,13 @@
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { Icon, Input, ModalContent, Modal } from "@budibase/bbui" import { Icon, Input, ModalContent, Modal } from "@budibase/bbui"
export let automation
export let onCancel = undefined
let name let name
let error = "" let error = ""
let modal let modal
export let automation
export let onCancel = undefined
export const show = () => { export const show = () => {
name = automation?.name name = automation?.name
modal.show() modal.show()

View file

@ -15,8 +15,7 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation } from "builderStore"
import { automationStore } from "builderStore"
import { tables } from "stores/backend" import { tables } from "stores/backend"
import { environment, licensing } from "stores/portal" import { environment, licensing } from "stores/portal"
import WebhookDisplay from "../Shared/WebhookDisplay.svelte" import WebhookDisplay from "../Shared/WebhookDisplay.svelte"
@ -50,22 +49,8 @@
$: filters = lookForFilters(schemaProperties) || [] $: filters = lookForFilters(schemaProperties) || []
$: tempFilters = filters $: tempFilters = filters
$: stepId = block.stepId $: stepId = block.stepId
$: bindings = getAvailableBindings( $: bindings = getAvailableBindings(block, $selectedAutomation?.definition)
block || $automationStore.selectedBlock,
$automationStore.selectedAutomation?.automation?.definition
)
$: getInputData(testData, block.inputs) $: getInputData(testData, block.inputs)
const getInputData = (testData, blockInputs) => {
let newInputData = testData || blockInputs
if (block.event === "app:trigger" && !newInputData?.fields) {
newInputData = cloneDeep(blockInputs)
}
inputData = newInputData
}
$: tableId = inputData ? inputData.tableId : null $: tableId = inputData ? inputData.tableId : null
$: table = tableId $: table = tableId
? $tables.list.find(table => table._id === inputData.tableId) ? $tables.list.find(table => table._id === inputData.tableId)
@ -76,39 +61,48 @@
$: isTrigger = block?.type === "TRIGGER" $: isTrigger = block?.type === "TRIGGER"
$: isUpdateRow = stepId === ActionStepID.UPDATE_ROW $: isUpdateRow = stepId === ActionStepID.UPDATE_ROW
const getInputData = (testData, blockInputs) => {
let newInputData = testData || blockInputs
if (block.event === "app:trigger" && !newInputData?.fields) {
newInputData = cloneDeep(blockInputs)
}
inputData = newInputData
}
const onChange = Utils.sequential(async (e, key) => { const onChange = Utils.sequential(async (e, key) => {
// We need to cache the schema as part of the definition because it is
// used in the server to detect relationships. It would be far better to
// instead fetch the schema in the backend at runtime.
let schema
if (e.detail?.tableId) { if (e.detail?.tableId) {
const tableSchema = getSchemaForTable(e.detail.tableId, { schema = getSchemaForTable(e.detail.tableId, {
searchableSchema: true, searchableSchema: true,
}).schema }).schema
if (isTestModal) {
testData.schema = tableSchema
} else {
block.inputs.schema = tableSchema
}
} }
try { try {
if (isTestModal) { if (isTestModal) {
let newTestData = { schema }
// Special case for webhook, as it requires a body, but the schema already brings back the body's contents // Special case for webhook, as it requires a body, but the schema already brings back the body's contents
if (stepId === TriggerStepID.WEBHOOK) { if (stepId === TriggerStepID.WEBHOOK) {
automationStore.actions.addTestDataToAutomation({ newTestData = {
...newTestData,
body: { body: {
[key]: e.detail, [key]: e.detail,
...$automationStore.selectedAutomation.automation.testData?.body, ...$selectedAutomation.testData?.body,
}, },
}) }
} }
automationStore.actions.addTestDataToAutomation({ newTestData = {
...newTestData,
[key]: e.detail, [key]: e.detail,
}) }
testData[key] = e.detail await automationStore.actions.addTestDataToAutomation(newTestData)
} else { } else {
block.inputs[key] = e.detail const data = { schema, [key]: e.detail }
await automationStore.actions.updateBlockInputs(block, data)
} }
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
} catch (error) { } catch (error) {
notifications.error("Error saving automation") notifications.error("Error saving automation")
} }

View file

@ -5,7 +5,11 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
export let value export let value
const onChange = e => { const onChange = e => {
if (e.detail === value) {
return
}
value = e.detail value = e.detail
dispatch("change", e.detail) dispatch("change", e.detail)
} }
@ -43,7 +47,12 @@
</script> </script>
<div class="block-field"> <div class="block-field">
<Input on:change={onChange} {value} on:blur={() => (touched = true)} /> <Input
on:change={onChange}
{value}
on:blur={() => (touched = true)}
updateOnChange={false}
/>
{#if touched && !value} {#if touched && !value}
<Label><div class="error">Please specify a CRON expression</div></Label> <Label><div class="error">Please specify a CRON expression</div></Label>
{/if} {/if}

View file

@ -1,17 +1,18 @@
<script> <script>
import { Icon, notifications } from "@budibase/bbui" import { Icon, notifications } from "@budibase/bbui"
import { automationStore } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
import WebhookDisplay from "./WebhookDisplay.svelte" import WebhookDisplay from "./WebhookDisplay.svelte"
import { ModalContent } from "@budibase/bbui" import { ModalContent } from "@budibase/bbui"
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
const POLL_RATE_MS = 2500 const POLL_RATE_MS = 2500
let interval let interval
let finished = false let finished = false
let schemaURL let schemaURL
let propCount = 0 let propCount = 0
$: automation = $automationStore.selectedAutomation?.automation $: automation = $selectedAutomation
onMount(async () => { onMount(async () => {
if (!automation?.definition?.trigger?.inputs.schemaUrl) { if (!automation?.definition?.trigger?.inputs.schemaUrl) {

View file

@ -0,0 +1,57 @@
<script>
import { Icon } from "@budibase/bbui"
import { onMount } from "svelte"
export let store
const handleKeyPress = e => {
if (!(e.ctrlKey || e.metaKey)) {
return
}
if (e.shiftKey && e.key === "Z") {
store.redo()
} else if (e.key === "z") {
store.undo()
}
}
onMount(() => {
document.addEventListener("keydown", handleKeyPress)
return () => {
document.removeEventListener("keydown", handleKeyPress)
}
})
</script>
<div class="undo-redo">
<Icon
name="Undo"
hoverable
on:click={store.undo}
disabled={!$store.canUndo}
tooltip="Undo latest change"
/>
<Icon
name="Redo"
hoverable
on:click={store.redo}
disabled={!$store.canRedo}
tooltip="Redo latest undo"
/>
</div>
<style>
.undo-redo {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xs);
padding-right: var(--spacing-xl);
border-right: var(--border-light);
}
.undo-redo :global(svg) {
padding: 6px;
}
</style>

View file

@ -42,29 +42,22 @@
return return
} }
try { try {
await automationStore.actions.create({ let trigger = automationStore.actions.constructBlock(
name: parameters.newAutomationName,
})
const appActionDefinition = $automationStore.blockDefinitions.TRIGGER.APP
const newBlock = $automationStore.selectedAutomation.constructBlock(
"TRIGGER", "TRIGGER",
"APP", "APP",
appActionDefinition $automationStore.blockDefinitions.TRIGGER.APP
) )
trigger.inputs = {
newBlock.inputs = {
fields: Object.keys(parameters.fields ?? {}).reduce((fields, key) => { fields: Object.keys(parameters.fields ?? {}).reduce((fields, key) => {
fields[key] = "string" fields[key] = "string"
return fields return fields
}, {}), }, {}),
} }
const automation = await automationStore.actions.create(
automationStore.actions.addBlockToAutomation(newBlock) parameters.newAutomationName,
await automationStore.actions.save( trigger
$automationStore.selectedAutomation?.automation
) )
parameters.automationId = parameters.automationId = automation._id
$automationStore.selectedAutomation.automation._id
delete parameters.newAutomationName delete parameters.newAutomationName
} catch (error) { } catch (error) {
notifications.error("Error creating automation") notifications.error("Error creating automation")

View file

@ -28,10 +28,10 @@
const validation = createValidationStore() const validation = createValidationStore()
$: { $: {
const { name, url } = $values const { url } = $values
validation.check({ validation.check({
name, ...$values,
url: url?.[0] === "/" ? url.substring(1, url.length) : url, url: url?.[0] === "/" ? url.substring(1, url.length) : url,
}) })
} }
@ -95,9 +95,9 @@
appValidation.url(validation, { apps: applications }) appValidation.url(validation, { apps: applications })
appValidation.file(validation, { template }) appValidation.file(validation, { template })
// init validation // init validation
const { name, url } = $values const { url } = $values
validation.check({ validation.check({
name, ...$values,
url: url?.[0] === "/" ? url.substring(1, url.length) : url, url: url?.[0] === "/" ? url.substring(1, url.length) : url,
}) })
} }

View file

@ -24,10 +24,10 @@
const validation = createValidationStore() const validation = createValidationStore()
$: { $: {
const { name, url } = $values const { url } = $values
validation.check({ validation.check({
name, ...$values,
url: url?.[0] === "/" ? url.substring(1, url.length) : url, url: url?.[0] === "/" ? url.substring(1, url.length) : url,
}) })
} }
@ -37,9 +37,9 @@
appValidation.name(validation, { apps: applications, currentApp: app }) appValidation.name(validation, { apps: applications, currentApp: app })
appValidation.url(validation, { apps: applications, currentApp: app }) appValidation.url(validation, { apps: applications, currentApp: app })
// init validation // init validation
const { name, url } = $values const { url } = $values
validation.check({ validation.check({
name, ...$values,
url: url?.[0] === "/" ? url.substring(1, url.length) : url, url: url?.[0] === "/" ? url.substring(1, url.length) : url,
}) })
} }

View file

@ -149,6 +149,7 @@
<Layout gap="XS" noPadding justifyItems="center"> <Layout gap="XS" noPadding justifyItems="center">
<Button <Button
cta cta
size="L"
disabled={Object.keys(errors).length > 0 || submitted} disabled={Object.keys(errors).length > 0 || submitted}
on:click={save} on:click={save}
> >

View file

@ -1,30 +1,40 @@
<script> <script>
import { Heading, Body, Layout, Button, Modal } from "@budibase/bbui" import { Heading, Body, Layout, Button, Modal } from "@budibase/bbui"
import { automationStore } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
import AutomationPanel from "components/automation/AutomationPanel/AutomationPanel.svelte" import AutomationPanel from "components/automation/AutomationPanel/AutomationPanel.svelte"
import CreateAutomationModal from "components/automation/AutomationPanel/CreateAutomationModal.svelte" import CreateAutomationModal from "components/automation/AutomationPanel/CreateAutomationModal.svelte"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import TestPanel from "components/automation/AutomationBuilder/TestPanel.svelte" import TestPanel from "components/automation/AutomationBuilder/TestPanel.svelte"
import { onMount } from "svelte" import { onDestroy, onMount } from "svelte"
import { syncURLToState } from "helpers/urlStateSync"
import * as routify from "@roxi/routify"
$: automation = // Keep URL and state in sync for selected screen ID
$automationStore.selectedAutomation?.automation || const stopSyncing = syncURLToState({
$automationStore.automations[0] urlParam: "automationId",
stateKey: "selectedAutomationId",
validate: id => $automationStore.automations.some(x => x._id === id),
fallbackUrl: "./index",
store: automationStore,
up: automationStore.actions.select,
routify,
})
let modal let modal
let webhookModal let webhookModal
onMount(() => { onMount(() => {
$automationStore.showTestPanel = false $automationStore.showTestPanel = false
}) })
onDestroy(stopSyncing)
</script> </script>
<!-- routify:options index=3 --> <!-- routify:options index=3 -->
<div class="root"> <div class="root">
<div class="nav"> <AutomationPanel {modal} {webhookModal} />
<AutomationPanel {modal} {webhookModal} />
</div>
<div class="content"> <div class="content">
{#if automation} {#if $automationStore.automations?.length}
<slot /> <slot />
{:else} {:else}
<div class="centered"> <div class="centered">
@ -40,9 +50,9 @@
</svg> </svg>
<Heading size="M">You have no automations</Heading> <Heading size="M">You have no automations</Heading>
<Body size="M">Let's fix that. Call the bots!</Body> <Body size="M">Let's fix that. Call the bots!</Body>
<Button on:click={() => modal.show()} size="M" cta <Button on:click={() => modal.show()} size="M" cta>
>Create automation</Button Create automation
> </Button>
</Layout> </Layout>
</div> </div>
</div> </div>
@ -51,7 +61,7 @@
{#if $automationStore.showTestPanel} {#if $automationStore.showTestPanel}
<div class="setup"> <div class="setup">
<TestPanel {automation} /> <TestPanel automation={$selectedAutomation} />
</div> </div>
{/if} {/if}
<Modal bind:this={modal}> <Modal bind:this={modal}>
@ -71,22 +81,8 @@
grid-template-columns: 260px minmax(510px, 1fr) fit-content(500px); grid-template-columns: 260px minmax(510px, 1fr) fit-content(500px);
overflow: hidden; overflow: hidden;
} }
.nav {
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
border-right: var(--border-light);
background-color: var(--background);
padding-bottom: 60px;
overflow: hidden;
}
.content { .content {
position: relative; position: relative;
padding-top: var(--spacing-l);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;

View file

@ -1,17 +1,10 @@
<script> <script>
import { redirect, leftover } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { onMount } from "svelte"
import { automationStore } from "builderStore" import { automationStore } from "builderStore"
onMount(async () => { $: {
// navigate to first automation in list, if not already selected if ($automationStore.automations?.length) {
if (
!$leftover &&
$automationStore.automations.length > 0 &&
(!$automationStore.selectedAutomation ||
!$automationStore.selectedAutomation?.automation?._id)
) {
$redirect(`./${$automationStore.automations[0]._id}`) $redirect(`./${$automationStore.automations[0]._id}`)
} }
}) }
</script> </script>

View file

@ -1,9 +1,11 @@
<script> <script>
import DevicePreviewSelect from "./DevicePreviewSelect.svelte" import DevicePreviewSelect from "./DevicePreviewSelect.svelte"
import AppPreview from "./AppPreview.svelte" import AppPreview from "./AppPreview.svelte"
import { store, sortedScreens } from "builderStore" import { store, sortedScreens, screenHistoryStore } from "builderStore"
import { Select } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import UndoRedoControl from "components/common/UndoRedoControl.svelte"
import { isActive } from "@roxi/routify"
</script> </script>
<div class="app-panel"> <div class="app-panel">
@ -22,6 +24,9 @@
/> />
</div> </div>
<div class="header-right"> <div class="header-right">
{#if $isActive("./screens") || $isActive("./components")}
<UndoRedoControl store={screenHistoryStore} />
{/if}
{#if $store.clientFeatures.devicePreview} {#if $store.clientFeatures.devicePreview}
<DevicePreviewSelect /> <DevicePreviewSelect />
{/if} {/if}
@ -52,6 +57,7 @@
align-items: flex-start; align-items: flex-start;
gap: var(--spacing-l); gap: var(--spacing-l);
margin: 0 2px; margin: 0 2px;
z-index: 1;
} }
.header-left, .header-left,
.header-right { .header-right {
@ -59,7 +65,7 @@
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: var(--spacing-l); gap: var(--spacing-xl);
} }
.header-left { .header-left {
flex: 1 1 auto; flex: 1 1 auto;

View file

@ -12,30 +12,30 @@
let componentToEject let componentToEject
const keyHandlers = { const keyHandlers = {
["^ArrowUp"]: async component => { ["Ctrl+ArrowUp"]: async component => {
await store.actions.components.moveUp(component) await store.actions.components.moveUp(component)
}, },
["^ArrowDown"]: async component => { ["Ctrl+ArrowDown"]: async component => {
await store.actions.components.moveDown(component) await store.actions.components.moveDown(component)
}, },
["^c"]: component => { ["Ctrl+c"]: component => {
store.actions.components.copy(component, false) store.actions.components.copy(component, false)
}, },
["^x"]: component => { ["Ctrl+x"]: component => {
store.actions.components.copy(component, true) store.actions.components.copy(component, true)
}, },
["^v"]: async component => { ["Ctrl+v"]: async component => {
await store.actions.components.paste(component, "inside") await store.actions.components.paste(component, "inside")
}, },
["^d"]: async component => { ["Ctrl+d"]: async component => {
store.actions.components.copy(component) store.actions.components.copy(component)
await store.actions.components.paste(component, "below") await store.actions.components.paste(component, "below")
}, },
["^e"]: component => { ["Ctrl+e"]: component => {
componentToEject = component componentToEject = component
confirmEjectDialog.show() confirmEjectDialog.show()
}, },
["^Enter"]: () => { ["Ctrl+Enter"]: () => {
$goto("./new") $goto("./new")
}, },
["Delete"]: component => { ["Delete"]: component => {
@ -53,14 +53,19 @@
store.actions.components.selectNext() store.actions.components.selectNext()
}, },
["Escape"]: () => { ["Escape"]: () => {
if (!$isActive("/new")) { if ($isActive("./new")) {
return false $goto("./")
} }
$goto("./")
}, },
} }
const handleKeyAction = async (event, component, key, ctrlKey = false) => { const handleKeyAction = async ({
event,
component,
key,
ctrlKey = false,
shiftKey = false,
}) => {
if (!component || !key) { if (!component || !key) {
return false return false
} }
@ -69,9 +74,12 @@
if (key === "Backspace") { if (key === "Backspace") {
key = "Delete" key = "Delete"
} }
// Prefix key with a caret for ctrl modifier // Prefix keys for modifiers
if (shiftKey) {
key = "Shift+" + key
}
if (ctrlKey) { if (ctrlKey) {
key = "^" + key key = "Ctrl+" + key
} }
const handler = keyHandlers[key] const handler = keyHandlers[key]
if (!handler) { if (!handler) {
@ -97,19 +105,26 @@
return return
} }
// Key events are always for the selected component // Key events are always for the selected component
return await handleKeyAction( return await handleKeyAction({
e, event: e,
$selectedComponent, component: $selectedComponent,
e.key, key: e.key,
e.ctrlKey || e.metaKey ctrlKey: e.ctrlKey || e.metaKey,
) shiftKey: e.shiftKey,
})
} }
const handleComponentMenu = async e => { const handleComponentMenu = async e => {
// Menu events can be for any component // Menu events can be for any component
const { id, key, ctrlKey } = e.detail const { id, key, ctrlKey, shiftKey } = e.detail
const component = findComponent($selectedScreen.props, id) const component = findComponent($selectedScreen.props, id)
return await handleKeyAction(null, component, key, ctrlKey) return await handleKeyAction({
event: null,
component,
key,
ctrlKey,
shiftKey,
})
} }
onMount(() => { onMount(() => {

View file

@ -1,11 +1,13 @@
<script> <script>
import { tables } from "stores/backend" import { tables } from "stores/backend"
import { ModalContent, Body, Layout, Icon, Heading } from "@budibase/bbui" import { ModalContent, Body, Layout, Icon, Heading } from "@budibase/bbui"
import blankScreenPreview from "./blankScreenPreview.png"
import listScreenPreview from "./listScreenPreview.png"
export let onConfirm export let onConfirm
export let onCancel export let onCancel
let autoCreateModeKey = "autoCreate" let listScreenModeKey = "autoCreate"
let blankScreenModeKey = "blankScreen" let blankScreenModeKey = "blankScreen"
let selectedScreenMode let selectedScreenMode
@ -23,61 +25,77 @@
onConfirm={confirmScreenSelection} onConfirm={confirmScreenSelection}
{onCancel} {onCancel}
disabled={!selectedScreenMode} disabled={!selectedScreenMode}
size="L" size="M"
> >
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<div <div
class="screen-type item" class="screen-type item blankView"
class:selected={selectedScreenMode == blankScreenModeKey} class:selected={selectedScreenMode == blankScreenModeKey}
on:click={() => { on:click={() => {
selectedScreenMode = blankScreenModeKey selectedScreenMode = blankScreenModeKey
}} }}
> >
<div class="content screen-type-wrap"> <div class="content screen-type-wrap">
<Icon name="WebPage" /> <img
alt="blank screen preview"
class="preview"
src={blankScreenPreview}
/>
<div class="screen-type-text"> <div class="screen-type-text">
<Heading size="XS">Blank screen</Heading> <Heading size="XS">Blank screen</Heading>
<Body size="S">Add a blank screen</Body> <Body size="S">Add an empty blank screen</Body>
</div> </div>
</div> </div>
<div <div
style="color: var(--spectrum-global-color-green-600); float: right" style="color: var(--spectrum-global-color-green-600); float: right"
> >
{#if selectedScreenMode == blankScreenModeKey} <div
<div class="checkmark-spacing"> class={`checkmark-spacing ${
<Icon size="S" name="CheckmarkCircle" /> selectedScreenMode == blankScreenModeKey ? "visible" : ""
</div> }`}
{/if} >
<Icon size="S" name="CheckmarkCircle" />
</div>
</div> </div>
</div> </div>
<div class="listViewTitle">
<Heading size="XS">Quickly create a screen from your data</Heading>
</div>
<div <div
class="screen-type item" class="screen-type item"
class:selected={selectedScreenMode == autoCreateModeKey} class:selected={selectedScreenMode == listScreenModeKey}
on:click={() => { on:click={() => {
selectedScreenMode = autoCreateModeKey selectedScreenMode = listScreenModeKey
}} }}
class:disabled={!$tables.list.filter(table => table._id !== "ta_users") class:disabled={!$tables.list.filter(table => table._id !== "ta_users")
.length} .length}
> >
<div class="content screen-type-wrap"> <div class="content screen-type-wrap">
<Icon name="WebPages" /> <img
alt="list screen preview"
class="preview"
src={listScreenPreview}
/>
<div class="screen-type-text"> <div class="screen-type-text">
<Heading size="XS">Autogenerated screens</Heading> <Heading size="XS">List view</Heading>
<Body size="S"> <Body size="S">
Add autogenerated screens with CRUD functionality to get a working Create, edit and view your data in a list view screen with side
app quickly! (Requires a datasource) panel
</Body> </Body>
</div> </div>
</div> </div>
<div <div
style="color: var(--spectrum-global-color-green-600); float: right" style="color: var(--spectrum-global-color-green-600); float: right"
> >
{#if selectedScreenMode == autoCreateModeKey} <div
<div class="checkmark-spacing"> class={`checkmark-spacing ${
<Icon size="S" name="CheckmarkCircle" /> selectedScreenMode == listScreenModeKey ? "visible" : ""
</div> }`}
{/if} >
<Icon size="S" name="CheckmarkCircle" />
</div>
</div> </div>
</div> </div>
</Layout> </Layout>
@ -85,9 +103,6 @@
</div> </div>
<style> <style>
.screen-type.item {
padding: var(--spectrum-alias-item-padding-xl);
}
.screen-type-wrap { .screen-type-wrap {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -99,6 +114,7 @@
} }
.checkmark-spacing { .checkmark-spacing {
margin-right: var(--spacing-m); margin-right: var(--spacing-m);
opacity: 0;
} }
.content { .content {
letter-spacing: 0px; letter-spacing: 0px;
@ -106,7 +122,6 @@
.item { .item {
cursor: pointer; cursor: pointer;
grid-gap: var(--spectrum-alias-grid-margin-xsmall); grid-gap: var(--spectrum-alias-grid-margin-xsmall);
padding: var(--spectrum-alias-item-padding-s);
background: var(--spectrum-alias-background-color-secondary); background: var(--spectrum-alias-background-color-secondary);
transition: 0.3s all; transition: 0.3s all;
border: 1px solid var(--spectrum-global-color-gray-300); border: 1px solid var(--spectrum-global-color-gray-300);
@ -132,4 +147,19 @@
.screen-type-wrap :global(.spectrum-Heading) { .screen-type-wrap :global(.spectrum-Heading) {
padding-bottom: var(--spectrum-alias-item-padding-s); padding-bottom: var(--spectrum-alias-item-padding-s);
} }
.preview {
width: 140px;
}
.listViewTitle {
margin-top: 35px;
}
.blankView {
margin-top: 10px;
}
.visible {
opacity: 1;
}
</style> </style>

View file

@ -25,7 +25,7 @@
let errors = {} let errors = {}
const routeTaken = url => { const routeTaken = url => {
const roleId = get(selectedScreen)?.routing.roleId || "BASIC" const roleId = get(selectedScreen).routing.roleId || "BASIC"
return get(store).screens.some( return get(store).screens.some(
screen => screen =>
screen.routing.route.toLowerCase() === url.toLowerCase() && screen.routing.route.toLowerCase() === url.toLowerCase() &&
@ -34,7 +34,7 @@
} }
const roleTaken = roleId => { const roleTaken = roleId => {
const url = get(selectedScreen)?.routing.route const url = get(selectedScreen).routing.route
return get(store).screens.some( return get(store).screens.some(
screen => screen =>
screen.routing.route.toLowerCase() === url.toLowerCase() && screen.routing.route.toLowerCase() === url.toLowerCase() &&
@ -95,7 +95,7 @@
return sanitizeUrl(val) return sanitizeUrl(val)
}, },
validate: route => { validate: route => {
const existingRoute = get(selectedScreen)?.routing.route const existingRoute = get(selectedScreen).routing.route
if (route !== existingRoute && routeTaken(route)) { if (route !== existingRoute && routeTaken(route)) {
return "That URL is already in use for this role" return "That URL is already in use for this role"
} }
@ -107,7 +107,7 @@
label: "Access", label: "Access",
control: RoleSelect, control: RoleSelect,
validate: role => { validate: role => {
const existingRole = get(selectedScreen)?.routing.roleId const existingRole = get(selectedScreen).routing.roleId
if (role !== existingRole && roleTaken(role)) { if (role !== existingRole && roleTaken(role)) {
return "That role is already in use for this URL" return "That role is already in use for this URL"
} }
@ -146,7 +146,7 @@
</script> </script>
<Panel <Panel
title={$selectedScreen?.routing.route} title={$selectedScreen.routing.route}
icon={$selectedScreen.routing.route === "/" ? "Home" : "WebPage"} icon={$selectedScreen.routing.route === "/" ? "Home" : "WebPage"}
borderLeft borderLeft
> >

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View file

@ -5,6 +5,8 @@
</script> </script>
<ScreenListPanel /> <ScreenListPanel />
{#key $selectedScreen?._id} {#if $selectedScreen}
<ScreenSettingsPanel /> {#key $selectedScreen._id}
{/key} <ScreenSettingsPanel />
{/key}
{/if}

View file

@ -3178,6 +3178,11 @@ fast-glob@^3.0.3:
merge2 "^1.3.0" merge2 "^1.3.0"
micromatch "^4.0.4" micromatch "^4.0.4"
fast-json-patch@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/fast-json-patch/-/fast-json-patch-3.1.1.tgz#85064ea1b1ebf97a3f7ad01e23f9337e72c66947"
integrity sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==
fast-json-stable-stringify@^2.0.0: fast-json-stable-stringify@^2.0.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "2.3.18-alpha.6", "version": "2.3.18-alpha.12",
"description": "Budibase CLI, for developers, self hosting and migrations.", "description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js", "main": "src/index.js",
"bin": { "bin": {
@ -26,9 +26,9 @@
"outputPath": "build" "outputPath": "build"
}, },
"dependencies": { "dependencies": {
"@budibase/backend-core": "2.3.18-alpha.6", "@budibase/backend-core": "2.3.18-alpha.12",
"@budibase/string-templates": "2.3.18-alpha.6", "@budibase/string-templates": "2.3.18-alpha.12",
"@budibase/types": "2.3.18-alpha.6", "@budibase/types": "2.3.18-alpha.12",
"axios": "0.21.2", "axios": "0.21.2",
"chalk": "4.1.0", "chalk": "4.1.0",
"cli-progress": "3.11.2", "cli-progress": "3.11.2",

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "2.3.18-alpha.6", "version": "2.3.18-alpha.12",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "2.3.18-alpha.6", "@budibase/bbui": "2.3.18-alpha.12",
"@budibase/frontend-core": "2.3.18-alpha.6", "@budibase/frontend-core": "2.3.18-alpha.12",
"@budibase/string-templates": "2.3.18-alpha.6", "@budibase/string-templates": "2.3.18-alpha.12",
"@spectrum-css/button": "^3.0.3", "@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3", "@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3", "@spectrum-css/divider": "^1.0.3",

View file

@ -1,12 +1,12 @@
{ {
"name": "@budibase/frontend-core", "name": "@budibase/frontend-core",
"version": "2.3.18-alpha.6", "version": "2.3.18-alpha.12",
"description": "Budibase frontend core libraries used in builder and client", "description": "Budibase frontend core libraries used in builder and client",
"author": "Budibase", "author": "Budibase",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"dependencies": { "dependencies": {
"@budibase/bbui": "2.3.18-alpha.6", "@budibase/bbui": "2.3.18-alpha.12",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"svelte": "^3.46.2" "svelte": "^3.46.2"
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/sdk", "name": "@budibase/sdk",
"version": "2.3.18-alpha.6", "version": "2.3.18-alpha.12",
"description": "Budibase Public API SDK", "description": "Budibase Public API SDK",
"author": "Budibase", "author": "Budibase",
"license": "MPL-2.0", "license": "MPL-2.0",

View file

@ -1,7 +1,7 @@
{ {
"name": "@budibase/server", "name": "@budibase/server",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "2.3.18-alpha.6", "version": "2.3.18-alpha.12",
"description": "Budibase Web Server", "description": "Budibase Web Server",
"main": "src/index.ts", "main": "src/index.ts",
"repository": { "repository": {
@ -43,11 +43,11 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "10.0.3", "@apidevtools/swagger-parser": "10.0.3",
"@budibase/backend-core": "2.3.18-alpha.6", "@budibase/backend-core": "2.3.18-alpha.12",
"@budibase/client": "2.3.18-alpha.6", "@budibase/client": "2.3.18-alpha.12",
"@budibase/pro": "2.3.18-alpha.6", "@budibase/pro": "2.3.18-alpha.12",
"@budibase/string-templates": "2.3.18-alpha.6", "@budibase/string-templates": "2.3.18-alpha.12",
"@budibase/types": "2.3.18-alpha.6", "@budibase/types": "2.3.18-alpha.12",
"@bull-board/api": "3.7.0", "@bull-board/api": "3.7.0",
"@bull-board/koa": "3.9.4", "@bull-board/koa": "3.9.4",
"@elastic/elasticsearch": "7.10.0", "@elastic/elasticsearch": "7.10.0",

View file

@ -65,10 +65,14 @@ export async function create(ctx: BBContext) {
// call through to update if already exists // call through to update if already exists
if (automation._id && automation._rev) { if (automation._id && automation._rev) {
return update(ctx) await update(ctx)
return
} }
automation._id = generateAutomationID() // Respect existing IDs if recreating a deleted automation
if (!automation._id) {
automation._id = generateAutomationID()
}
automation.type = "automation" automation.type = "automation"
automation = cleanAutomationInputs(automation) automation = cleanAutomationInputs(automation)
@ -126,6 +130,13 @@ export async function update(ctx: BBContext) {
const db = context.getAppDB() const db = context.getAppDB()
let automation = ctx.request.body let automation = ctx.request.body
automation.appId = ctx.appId automation.appId = ctx.appId
// Call through to create if it doesn't exist
if (!automation._id || !automation._rev) {
await create(ctx)
return
}
const oldAutomation = await db.get(automation._id) const oldAutomation = await db.get(automation._id)
automation = cleanAutomationInputs(automation) automation = cleanAutomationInputs(automation)
automation = await checkForWebhooks({ automation = await checkForWebhooks({

View file

@ -142,7 +142,11 @@ function cleanupConfig(config: RunConfig, table: Table): RunConfig {
return config return config
} }
function generateIdForRow(row: Row | undefined, table: Table): string { function generateIdForRow(
row: Row | undefined,
table: Table,
isLinked: boolean = false
): string {
const primary = table.primary const primary = table.primary
if (!row || !primary) { if (!row || !primary) {
return "" return ""
@ -150,8 +154,12 @@ function generateIdForRow(row: Row | undefined, table: Table): string {
// build id array // build id array
let idParts = [] let idParts = []
for (let field of primary) { for (let field of primary) {
// need to handle table name + field or just field, depending on if relationships used let fieldValue = extractFieldValue({
const fieldValue = row[`${table.name}.${field}`] || row[field] row,
tableName: table.name,
fieldName: field,
isLinked,
})
if (fieldValue) { if (fieldValue) {
idParts.push(fieldValue) idParts.push(fieldValue)
} }
@ -174,18 +182,52 @@ function getEndpoint(tableId: string | undefined, operation: string) {
} }
} }
function basicProcessing(row: Row, table: Table): Row { // need to handle table name + field or just field, depending on if relationships used
function extractFieldValue({
row,
tableName,
fieldName,
isLinked,
}: {
row: Row
tableName: string
fieldName: string
isLinked: boolean
}) {
let value = row[`${tableName}.${fieldName}`]
if (value == null && !isLinked) {
value = row[fieldName]
}
return value
}
function basicProcessing({
row,
table,
isLinked,
}: {
row: Row
table: Table
isLinked: boolean
}): Row {
const thisRow: Row = {} const thisRow: Row = {}
// filter the row down to what is actually the row (not joined) // filter the row down to what is actually the row (not joined)
for (let fieldName of Object.keys(table.schema)) { for (let field of Object.values(table.schema)) {
const pathValue = row[`${table.name}.${fieldName}`] const fieldName = field.name
const value = pathValue != null ? pathValue : row[fieldName]
const value = extractFieldValue({
row,
tableName: table.name,
fieldName,
isLinked,
})
// all responses include "select col as table.col" so that overlaps are handled // all responses include "select col as table.col" so that overlaps are handled
if (value != null) { if (value != null) {
thisRow[fieldName] = value thisRow[fieldName] = value
} }
} }
thisRow._id = generateIdForRow(row, table) thisRow._id = generateIdForRow(row, table, isLinked)
thisRow.tableId = table._id thisRow.tableId = table._id
thisRow._rev = "rev" thisRow._rev = "rev"
return processFormulas(table, thisRow) return processFormulas(table, thisRow)
@ -293,7 +335,7 @@ export class ExternalRequest {
// we're not inserting a doc, will be a bunch of update calls // we're not inserting a doc, will be a bunch of update calls
const otherKey: string = field.throughFrom || linkTablePrimary const otherKey: string = field.throughFrom || linkTablePrimary
const thisKey: string = field.throughTo || tablePrimary const thisKey: string = field.throughTo || tablePrimary
row[key].map((relationship: any) => { row[key].forEach((relationship: any) => {
manyRelationships.push({ manyRelationships.push({
tableId: field.through || field.tableId, tableId: field.through || field.tableId,
isUpdate: false, isUpdate: false,
@ -309,7 +351,7 @@ export class ExternalRequest {
const thisKey: string = "id" const thisKey: string = "id"
// @ts-ignore // @ts-ignore
const otherKey: string = field.fieldName const otherKey: string = field.fieldName
row[key].map((relationship: any) => { row[key].forEach((relationship: any) => {
manyRelationships.push({ manyRelationships.push({
tableId: field.tableId, tableId: field.tableId,
isUpdate: true, isUpdate: true,
@ -379,7 +421,8 @@ export class ExternalRequest {
) { ) {
continue continue
} }
let linked = basicProcessing(row, linkedTable)
let linked = basicProcessing({ row, table: linkedTable, isLinked: true })
if (!linked._id) { if (!linked._id) {
continue continue
} }
@ -427,7 +470,10 @@ export class ExternalRequest {
) )
continue continue
} }
const thisRow = fixArrayTypes(basicProcessing(row, table), table) const thisRow = fixArrayTypes(
basicProcessing({ row, table, isLinked: false }),
table
)
if (thisRow._id == null) { if (thisRow._id == null) {
throw "Unable to generate row ID for SQL rows" throw "Unable to generate row ID for SQL rows"
} }
@ -567,19 +613,41 @@ export class ExternalRequest {
const { key, tableId, isUpdate, id, ...rest } = relationship const { key, tableId, isUpdate, id, ...rest } = relationship
const body: { [key: string]: any } = processObjectSync(rest, row, {}) const body: { [key: string]: any } = processObjectSync(rest, row, {})
const linkTable = this.getTable(tableId) const linkTable = this.getTable(tableId)
// @ts-ignore const relationshipPrimary = linkTable?.primary || []
const linkPrimary = linkTable?.primary[0] const linkPrimary = relationshipPrimary[0]
if (!linkTable || !linkPrimary) { if (!linkTable || !linkPrimary) {
return return
} }
const linkSecondary = relationshipPrimary[1]
const rows = related[key]?.rows || [] const rows = related[key]?.rows || []
const found = rows.find(
(row: { [key: string]: any }) => function relationshipMatchPredicate({
row,
linkPrimary,
linkSecondary,
}: {
row: { [key: string]: any }
linkPrimary: string
linkSecondary?: string
}) {
const matchesPrimaryLink =
row[linkPrimary] === relationship.id || row[linkPrimary] === relationship.id ||
row[linkPrimary] === body?.[linkPrimary] row[linkPrimary] === body?.[linkPrimary]
if (!matchesPrimaryLink || !linkSecondary) {
return matchesPrimaryLink
}
const matchesSecondayLink = row[linkSecondary] === body?.[linkSecondary]
return matchesPrimaryLink && matchesSecondayLink
}
const existingRelationship = rows.find((row: { [key: string]: any }) =>
relationshipMatchPredicate({ row, linkPrimary, linkSecondary })
) )
const operation = isUpdate ? Operation.UPDATE : Operation.CREATE const operation = isUpdate ? Operation.UPDATE : Operation.CREATE
if (!found) { if (!existingRelationship) {
promises.push( promises.push(
getDatasourceAndQuery({ getDatasourceAndQuery({
endpoint: getEndpoint(tableId, operation), endpoint: getEndpoint(tableId, operation),
@ -590,7 +658,7 @@ export class ExternalRequest {
) )
} else { } else {
// remove the relationship from cache so it isn't adjusted again // remove the relationship from cache so it isn't adjusted again
rows.splice(rows.indexOf(found), 1) rows.splice(rows.indexOf(existingRelationship), 1)
} }
} }
// finally cleanup anything that needs to be removed // finally cleanup anything that needs to be removed
@ -629,10 +697,7 @@ export class ExternalRequest {
* Creating the specific list of fields that we desire, and excluding the ones that are no use to us * Creating the specific list of fields that we desire, and excluding the ones that are no use to us
* is more performant and has the added benefit of protecting against this scenario. * is more performant and has the added benefit of protecting against this scenario.
*/ */
buildFields( buildFields(table: Table, includeRelations: boolean) {
table: Table,
includeRelations: IncludeRelationship = IncludeRelationship.INCLUDE
) {
function extractRealFields(table: Table, existing: string[] = []) { function extractRealFields(table: Table, existing: string[] = []) {
return Object.entries(table.schema) return Object.entries(table.schema)
.filter( .filter(
@ -691,6 +756,10 @@ export class ExternalRequest {
} }
filters = buildFilters(id, filters || {}, table) filters = buildFilters(id, filters || {}, table)
const relationships = this.buildRelationships(table) const relationships = this.buildRelationships(table)
const includeSqlRelationships =
config.includeSqlRelationships === IncludeRelationship.INCLUDE
// clean up row on ingress using schema // clean up row on ingress using schema
const processed = this.inputProcessing(row, table) const processed = this.inputProcessing(row, table)
row = processed.row row = processed.row
@ -708,9 +777,7 @@ export class ExternalRequest {
}, },
resource: { resource: {
// have to specify the fields to avoid column overlap (for SQL) // have to specify the fields to avoid column overlap (for SQL)
fields: isSql fields: isSql ? this.buildFields(table, includeSqlRelationships) : [],
? this.buildFields(table, config.includeSqlRelationships)
: [],
}, },
filters, filters,
sort, sort,
@ -725,6 +792,7 @@ export class ExternalRequest {
table, table,
}, },
} }
// can't really use response right now // can't really use response right now
const response = await getDatasourceAndQuery(json) const response = await getDatasourceAndQuery(json)
// handle many to many relationships now if we know the ID (could be auto increment) // handle many to many relationships now if we know the ID (could be auto increment)

View file

@ -58,7 +58,7 @@ export async function patch(ctx: BBContext) {
return handleRequest(Operation.UPDATE, tableId, { return handleRequest(Operation.UPDATE, tableId, {
id: breakRowIdField(id), id: breakRowIdField(id),
row: inputs, row: inputs,
includeSqlRelationships: IncludeRelationship.EXCLUDE, includeSqlRelationships: IncludeRelationship.INCLUDE,
}) })
} }

View file

@ -38,7 +38,7 @@ router
"/api/automations", "/api/automations",
bodyResource("_id"), bodyResource("_id"),
authorized(permissions.BUILDER), authorized(permissions.BUILDER),
automationValidator(true), automationValidator(false),
controller.update controller.update
) )
.post( .post(

View file

@ -27,7 +27,9 @@ describe("row api - postgres", () => {
let makeRequest: MakeRequestResponse, let makeRequest: MakeRequestResponse,
postgresDatasource: Datasource, postgresDatasource: Datasource,
primaryPostgresTable: Table, primaryPostgresTable: Table,
auxPostgresTable: Table oneToManyRelationshipInfo: ForeignTableInfo,
manyToOneRelationshipInfo: ForeignTableInfo,
manyToManyRelationshipInfo: ForeignTableInfo
let host: string let host: string
let port: number let port: number
@ -67,37 +69,58 @@ describe("row api - postgres", () => {
}, },
}) })
auxPostgresTable = await config.createTable({ async function createAuxTable(prefix: string) {
name: generator.word({ length: 10 }), return await config.createTable({
type: "external", name: `${prefix}_${generator.word({ length: 6 })}`,
primary: ["id"], type: "external",
schema: { primary: ["id"],
id: { primaryDisplay: "title",
name: "id", schema: {
type: FieldType.AUTO, id: {
constraints: { name: "id",
presence: true, type: FieldType.AUTO,
autocolumn: true,
constraints: {
presence: true,
},
},
title: {
name: "title",
type: FieldType.STRING,
constraints: {
presence: true,
},
}, },
}, },
title: { sourceId: postgresDatasource._id,
name: "title", })
type: FieldType.STRING, }
constraints: {
presence: true, oneToManyRelationshipInfo = {
}, table: await createAuxTable("o2m"),
}, fieldName: "oneToManyRelation",
}, relationshipType: RelationshipTypes.ONE_TO_MANY,
sourceId: postgresDatasource._id, }
}) manyToOneRelationshipInfo = {
table: await createAuxTable("m2o"),
fieldName: "manyToOneRelation",
relationshipType: RelationshipTypes.MANY_TO_ONE,
}
manyToManyRelationshipInfo = {
table: await createAuxTable("m2m"),
fieldName: "manyToManyRelation",
relationshipType: RelationshipTypes.MANY_TO_MANY,
}
primaryPostgresTable = await config.createTable({ primaryPostgresTable = await config.createTable({
name: generator.word({ length: 10 }), name: `p_${generator.word({ length: 6 })}`,
type: "external", type: "external",
primary: ["id"], primary: ["id"],
schema: { schema: {
id: { id: {
name: "id", name: "id",
type: FieldType.AUTO, type: FieldType.AUTO,
autocolumn: true,
constraints: { constraints: {
presence: true, presence: true,
}, },
@ -117,25 +140,48 @@ describe("row api - postgres", () => {
name: "value", name: "value",
type: FieldType.NUMBER, type: FieldType.NUMBER,
}, },
linkedField: { oneToManyRelation: {
type: FieldType.LINK, type: FieldType.LINK,
constraints: { constraints: {
type: "array", type: "array",
presence: false, presence: false,
}, },
fieldName: "foreignField", fieldName: oneToManyRelationshipInfo.fieldName,
name: "linkedField", name: "oneToManyRelation",
relationshipType: RelationshipTypes.ONE_TO_MANY, relationshipType: RelationshipTypes.ONE_TO_MANY,
tableId: auxPostgresTable._id, tableId: oneToManyRelationshipInfo.table._id,
main: true,
},
manyToOneRelation: {
type: FieldType.LINK,
constraints: {
type: "array",
presence: false,
},
fieldName: manyToOneRelationshipInfo.fieldName,
name: "manyToOneRelation",
relationshipType: RelationshipTypes.MANY_TO_ONE,
tableId: manyToOneRelationshipInfo.table._id,
main: true,
},
manyToManyRelation: {
type: FieldType.LINK,
constraints: {
type: "array",
presence: false,
},
fieldName: manyToManyRelationshipInfo.fieldName,
name: "manyToManyRelation",
relationshipType: RelationshipTypes.MANY_TO_MANY,
tableId: manyToManyRelationshipInfo.table._id,
main: true,
}, },
}, },
sourceId: postgresDatasource._id, sourceId: postgresDatasource._id,
}) })
}) })
afterAll(async () => { afterAll(config.end)
await config.end()
})
function generateRandomPrimaryRowData() { function generateRandomPrimaryRowData() {
return { return {
@ -151,22 +197,99 @@ describe("row api - postgres", () => {
value: number value: number
} }
type ForeignTableInfo = {
table: Table
fieldName: string
relationshipType: RelationshipTypes
}
type ForeignRowsInfo = {
row: Row
relationshipType: RelationshipTypes
}
async function createPrimaryRow(opts: { async function createPrimaryRow(opts: {
rowData: PrimaryRowData rowData: PrimaryRowData
createForeignRow?: boolean createForeignRows?: {
createOneToMany?: boolean
createManyToOne?: number
createManyToMany?: number
}
}) { }) {
let { rowData } = opts let { rowData } = opts as any
let foreignRow: Row | undefined let foreignRows: ForeignRowsInfo[] = []
if (opts?.createForeignRow) {
foreignRow = await config.createRow({ async function createForeignRow(tableInfo: ForeignTableInfo) {
tableId: auxPostgresTable._id, const foreignKey = `fk_${tableInfo.table.name}_${tableInfo.fieldName}`
const foreignRow = await config.createRow({
tableId: tableInfo.table._id,
title: generator.name(), title: generator.name(),
}) })
rowData = { rowData = {
...rowData, ...rowData,
[`fk_${auxPostgresTable.name}_foreignField`]: foreignRow.id, [foreignKey]: foreignRow.id,
} }
foreignRows.push({
row: foreignRow,
relationshipType: tableInfo.relationshipType,
})
}
if (opts?.createForeignRows?.createOneToMany) {
const foreignKey = `fk_${oneToManyRelationshipInfo.table.name}_${oneToManyRelationshipInfo.fieldName}`
const foreignRow = await config.createRow({
tableId: oneToManyRelationshipInfo.table._id,
title: generator.name(),
})
rowData = {
...rowData,
[foreignKey]: foreignRow.id,
}
foreignRows.push({
row: foreignRow,
relationshipType: oneToManyRelationshipInfo.relationshipType,
})
}
for (let i = 0; i < (opts?.createForeignRows?.createManyToOne || 0); i++) {
const foreignRow = await config.createRow({
tableId: manyToOneRelationshipInfo.table._id,
title: generator.name(),
})
rowData = {
...rowData,
[manyToOneRelationshipInfo.fieldName]:
rowData[manyToOneRelationshipInfo.fieldName] || [],
}
rowData[manyToOneRelationshipInfo.fieldName].push(foreignRow._id)
foreignRows.push({
row: foreignRow,
relationshipType: RelationshipTypes.MANY_TO_ONE,
})
}
for (let i = 0; i < (opts?.createForeignRows?.createManyToMany || 0); i++) {
const foreignRow = await config.createRow({
tableId: manyToManyRelationshipInfo.table._id,
title: generator.name(),
})
rowData = {
...rowData,
[manyToManyRelationshipInfo.fieldName]:
rowData[manyToManyRelationshipInfo.fieldName] || [],
}
rowData[manyToManyRelationshipInfo.fieldName].push(foreignRow._id)
foreignRows.push({
row: foreignRow,
relationshipType: RelationshipTypes.MANY_TO_MANY,
})
} }
const row = await config.createRow({ const row = await config.createRow({
@ -174,7 +297,7 @@ describe("row api - postgres", () => {
...rowData, ...rowData,
}) })
return { row, foreignRow } return { row, foreignRows }
} }
async function createDefaultPgTable() { async function createDefaultPgTable() {
@ -198,7 +321,9 @@ describe("row api - postgres", () => {
async function populatePrimaryRows( async function populatePrimaryRows(
count: number, count: number,
opts?: { opts?: {
createForeignRow?: boolean createOneToMany?: boolean
createManyToOne?: number
createManyToMany?: number
} }
) { ) {
return await Promise.all( return await Promise.all(
@ -210,7 +335,7 @@ describe("row api - postgres", () => {
rowData, rowData,
...(await createPrimaryRow({ ...(await createPrimaryRow({
rowData, rowData,
createForeignRow: opts?.createForeignRow, createForeignRows: opts,
})), })),
} }
}) })
@ -295,7 +420,7 @@ describe("row api - postgres", () => {
describe("given than a row exists", () => { describe("given than a row exists", () => {
let row: Row let row: Row
beforeEach(async () => { beforeEach(async () => {
let rowResponse = _.sample(await populatePrimaryRows(10))! let rowResponse = _.sample(await populatePrimaryRows(1))!
row = rowResponse.row row = rowResponse.row
}) })
@ -403,7 +528,7 @@ describe("row api - postgres", () => {
let rows: { row: Row; rowData: PrimaryRowData }[] let rows: { row: Row; rowData: PrimaryRowData }[]
beforeEach(async () => { beforeEach(async () => {
rows = await populatePrimaryRows(10) rows = await populatePrimaryRows(5)
}) })
it("a single row can be retrieved successfully", async () => { it("a single row can be retrieved successfully", async () => {
@ -419,34 +544,136 @@ describe("row api - postgres", () => {
describe("given a row with relation data", () => { describe("given a row with relation data", () => {
let row: Row let row: Row
let foreignRow: Row let rowData: {
beforeEach(async () => { name: string
let [createdRow] = await populatePrimaryRows(1, { description: string
createForeignRow: true, value: number
}
let foreignRows: ForeignRowsInfo[]
describe("with all relationship types", () => {
beforeEach(async () => {
let [createdRow] = await populatePrimaryRows(1, {
createOneToMany: true,
createManyToOne: 3,
createManyToMany: 2,
})
row = createdRow.row
rowData = createdRow.rowData
foreignRows = createdRow.foreignRows
})
it("only one to many foreign keys are retrieved", async () => {
const res = await getRow(primaryPostgresTable._id, row.id)
expect(res.status).toBe(200)
const one2ManyForeignRows = foreignRows.filter(
x => x.relationshipType === RelationshipTypes.ONE_TO_MANY
)
expect(one2ManyForeignRows).toHaveLength(1)
expect(res.body).toEqual({
...rowData,
id: row.id,
tableId: row.tableId,
_id: expect.any(String),
_rev: expect.any(String),
[`fk_${oneToManyRelationshipInfo.table.name}_${oneToManyRelationshipInfo.fieldName}`]:
one2ManyForeignRows[0].row.id,
})
expect(res.body[oneToManyRelationshipInfo.fieldName]).toBeUndefined()
}) })
row = createdRow.row
foreignRow = createdRow.foreignRow!
}) })
it("only foreign keys are retrieved", async () => { describe("with only one to many", () => {
const res = await getRow(primaryPostgresTable._id, row.id) beforeEach(async () => {
let [createdRow] = await populatePrimaryRows(1, {
expect(res.status).toBe(200) createOneToMany: true,
})
expect(res.body).toEqual({ row = createdRow.row
...row, rowData = createdRow.rowData
_id: expect.any(String), foreignRows = createdRow.foreignRows
_rev: expect.any(String),
}) })
expect(res.body.foreignField).toBeUndefined() it("only one to many foreign keys are retrieved", async () => {
const res = await getRow(primaryPostgresTable._id, row.id)
expect( expect(res.status).toBe(200)
res.body[`fk_${auxPostgresTable.name}_foreignField`]
).toBeDefined() expect(foreignRows).toHaveLength(1)
expect(res.body[`fk_${auxPostgresTable.name}_foreignField`]).toBe(
foreignRow.id expect(res.body).toEqual({
) ...rowData,
id: row.id,
tableId: row.tableId,
_id: expect.any(String),
_rev: expect.any(String),
[`fk_${oneToManyRelationshipInfo.table.name}_${oneToManyRelationshipInfo.fieldName}`]:
foreignRows[0].row.id,
})
expect(res.body[oneToManyRelationshipInfo.fieldName]).toBeUndefined()
})
})
describe("with only many to one", () => {
beforeEach(async () => {
let [createdRow] = await populatePrimaryRows(1, {
createManyToOne: 3,
})
row = createdRow.row
rowData = createdRow.rowData
foreignRows = createdRow.foreignRows
})
it("only one to many foreign keys are retrieved", async () => {
const res = await getRow(primaryPostgresTable._id, row.id)
expect(res.status).toBe(200)
expect(foreignRows).toHaveLength(3)
expect(res.body).toEqual({
...rowData,
id: row.id,
tableId: row.tableId,
_id: expect.any(String),
_rev: expect.any(String),
})
expect(res.body[oneToManyRelationshipInfo.fieldName]).toBeUndefined()
})
})
describe("with only many to many", () => {
beforeEach(async () => {
let [createdRow] = await populatePrimaryRows(1, {
createManyToMany: 2,
})
row = createdRow.row
rowData = createdRow.rowData
foreignRows = createdRow.foreignRows
})
it("only one to many foreign keys are retrieved", async () => {
const res = await getRow(primaryPostgresTable._id, row.id)
expect(res.status).toBe(200)
expect(foreignRows).toHaveLength(2)
expect(res.body).toEqual({
...rowData,
id: row.id,
tableId: row.tableId,
_id: expect.any(String),
_rev: expect.any(String),
})
expect(res.body[oneToManyRelationshipInfo.fieldName]).toBeUndefined()
})
}) })
}) })
}) })
@ -667,31 +894,74 @@ describe("row api - postgres", () => {
const getAll = (tableId: string | undefined, rowId: string | undefined) => const getAll = (tableId: string | undefined, rowId: string | undefined) =>
makeRequest("get", `/api/${tableId}/${rowId}/enrich`) makeRequest("get", `/api/${tableId}/${rowId}/enrich`)
describe("given a row with relation data", () => { describe("given a row with relation data", () => {
let row: Row, foreignRow: Row | undefined let row: Row, rowData: PrimaryRowData, foreignRows: ForeignRowsInfo[]
beforeEach(async () => { describe("with all relationship types", () => {
const rowsInfo = await createPrimaryRow({ beforeEach(async () => {
rowData: generateRandomPrimaryRowData(), rowData = generateRandomPrimaryRowData()
createForeignRow: true, const rowsInfo = await createPrimaryRow({
rowData,
createForeignRows: {
createOneToMany: true,
createManyToOne: 3,
createManyToMany: 2,
},
})
row = rowsInfo.row
foreignRows = rowsInfo.foreignRows
}) })
row = rowsInfo.row it("enrich populates the foreign fields", async () => {
foreignRow = rowsInfo.foreignRow const res = await getAll(primaryPostgresTable._id, row.id)
})
it("enrich populates the foreign field", async () => { expect(res.status).toBe(200)
const res = await getAll(primaryPostgresTable._id, row.id)
expect(res.status).toBe(200) const foreignRowsByType = _.groupBy(
foreignRows,
expect(foreignRow).toBeDefined() x => x.relationshipType
expect(res.body).toEqual({ )
...row, expect(res.body).toEqual({
linkedField: [ ...rowData,
{ [`fk_${oneToManyRelationshipInfo.table.name}_${oneToManyRelationshipInfo.fieldName}`]:
...foreignRow, foreignRowsByType[RelationshipTypes.ONE_TO_MANY][0].row.id,
}, [oneToManyRelationshipInfo.fieldName]: [
], {
...foreignRowsByType[RelationshipTypes.ONE_TO_MANY][0].row,
_id: expect.any(String),
_rev: expect.any(String),
},
],
[manyToOneRelationshipInfo.fieldName]: [
{
...foreignRowsByType[RelationshipTypes.MANY_TO_ONE][0].row,
[`fk_${manyToOneRelationshipInfo.table.name}_${manyToOneRelationshipInfo.fieldName}`]:
row.id,
},
{
...foreignRowsByType[RelationshipTypes.MANY_TO_ONE][1].row,
[`fk_${manyToOneRelationshipInfo.table.name}_${manyToOneRelationshipInfo.fieldName}`]:
row.id,
},
{
...foreignRowsByType[RelationshipTypes.MANY_TO_ONE][2].row,
[`fk_${manyToOneRelationshipInfo.table.name}_${manyToOneRelationshipInfo.fieldName}`]:
row.id,
},
],
[manyToManyRelationshipInfo.fieldName]: [
{
...foreignRowsByType[RelationshipTypes.MANY_TO_MANY][0].row,
},
{
...foreignRowsByType[RelationshipTypes.MANY_TO_MANY][1].row,
},
],
id: row.id,
tableId: row.tableId,
_id: expect.any(String),
_rev: expect.any(String),
})
}) })
}) })
}) })
@ -714,7 +984,7 @@ describe("row api - postgres", () => {
const rowsCount = 6 const rowsCount = 6
let rows: { let rows: {
row: Row row: Row
foreignRow: Row | undefined foreignRows: ForeignRowsInfo[]
rowData: PrimaryRowData rowData: PrimaryRowData
}[] }[]
beforeEach(async () => { beforeEach(async () => {

View file

@ -415,9 +415,7 @@ class InternalBuilder {
if (opts.disableReturning) { if (opts.disableReturning) {
return query.insert(parsedBody) return query.insert(parsedBody)
} else { } else {
return query return query.insert(parsedBody).returning("*")
.insert(parsedBody)
.returning(generateSelectStatement(json, knex))
} }
} }
@ -502,9 +500,7 @@ class InternalBuilder {
if (opts.disableReturning) { if (opts.disableReturning) {
return query.update(parsedBody) return query.update(parsedBody)
} else { } else {
return query return query.update(parsedBody).returning("*")
.update(parsedBody)
.returning(generateSelectStatement(json, knex))
} }
} }

View file

@ -1278,14 +1278,14 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@budibase/backend-core@2.3.18-alpha.6": "@budibase/backend-core@2.3.18-alpha.12":
version "2.3.18-alpha.6" version "2.3.18-alpha.12"
resolved "https://registry.yarnpkg.com/@budibase/backend-core/-/backend-core-2.3.18-alpha.6.tgz#e6b3304e96b9469f3ca0f4fcfda8cf234c37e2d7" resolved "https://registry.yarnpkg.com/@budibase/backend-core/-/backend-core-2.3.18-alpha.12.tgz#ad1b16be64b78b596af2b5f75647c32e8f6f101a"
integrity sha512-To0kFbB9nZ6p0UO4ScS4PJ0gbqI1PrMWRXJLTv/6GU3PxnsqvH1tbpcleLMz2zeE04e5xdwt6W1oPViELom2gg== integrity sha512-E1NEO+/sNkkRqn/xk9XQmFBO9/dl27w9EB0QGztti/16JV9NgxyDQCJIdGwlD08s1y/lUwOKk0TkSZJs+CTYDw==
dependencies: dependencies:
"@budibase/nano" "10.1.1" "@budibase/nano" "10.1.1"
"@budibase/pouchdb-replication-stream" "1.2.10" "@budibase/pouchdb-replication-stream" "1.2.10"
"@budibase/types" "2.3.18-alpha.6" "@budibase/types" "2.3.18-alpha.12"
"@shopify/jest-koa-mocks" "5.0.1" "@shopify/jest-koa-mocks" "5.0.1"
"@techpass/passport-openidconnect" "0.3.2" "@techpass/passport-openidconnect" "0.3.2"
aws-cloudfront-sign "2.2.0" aws-cloudfront-sign "2.2.0"
@ -1392,13 +1392,13 @@
pouchdb-promise "^6.0.4" pouchdb-promise "^6.0.4"
through2 "^2.0.0" through2 "^2.0.0"
"@budibase/pro@2.3.18-alpha.6": "@budibase/pro@2.3.18-alpha.12":
version "2.3.18-alpha.6" version "2.3.18-alpha.12"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.3.18-alpha.6.tgz#7c5c221da7da79af79605a00aacac5c60f209bf6" resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.3.18-alpha.12.tgz#be552b3a9f5850e746081540d6586aae69147bec"
integrity sha512-YWPxmZn+z3tm5GZ+2UZSkOAhamlue/dmki+FCML5pIp3dCw8KsXnpzYXHRc1F8yXMTmA/8KBb/YkjQ2WK3Rk7A== integrity sha512-M3b0njzSi47KH6uaQfYPoA2KWrjPiwcU3ONyaVWXHIktVrIKtYaFwOLBr/dmWGfMrL2297SSqg7V4DTaLyAhnw==
dependencies: dependencies:
"@budibase/backend-core" "2.3.18-alpha.6" "@budibase/backend-core" "2.3.18-alpha.12"
"@budibase/types" "2.3.18-alpha.6" "@budibase/types" "2.3.18-alpha.12"
"@koa/router" "8.0.8" "@koa/router" "8.0.8"
bull "4.10.1" bull "4.10.1"
joi "17.6.0" joi "17.6.0"
@ -1424,10 +1424,10 @@
svelte-apexcharts "^1.0.2" svelte-apexcharts "^1.0.2"
svelte-flatpickr "^3.1.0" svelte-flatpickr "^3.1.0"
"@budibase/types@2.3.18-alpha.6": "@budibase/types@2.3.18-alpha.12":
version "2.3.18-alpha.6" version "2.3.18-alpha.12"
resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.3.18-alpha.6.tgz#9438ee64008668bbcb3d688b189cc649e03dfd60" resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.3.18-alpha.12.tgz#a63eb978ccc7e55c209b3e9d71f9aecf7facc0d1"
integrity sha512-16YtXwSODS8UDhdxCP2piGDWELP05EZuPbwLsOUFLX3Gt0+Wwkme+XWw4pTPE+GoK/mTVkDxzSc4cvuXWtfxxA== integrity sha512-27o2BmI/HXIR3frZ8FtqHgAe1hd8jPIzgPaEhKrQiYJ/opUVccqupx9ld75Hyk9E6cdXu0UF0/+LxPpUmMugag==
"@bull-board/api@3.7.0": "@bull-board/api@3.7.0":
version "3.7.0" version "3.7.0"

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/string-templates", "name": "@budibase/string-templates",
"version": "2.3.18-alpha.6", "version": "2.3.18-alpha.12",
"description": "Handlebars wrapper for Budibase templating.", "description": "Handlebars wrapper for Budibase templating.",
"main": "src/index.cjs", "main": "src/index.cjs",
"module": "dist/bundle.mjs", "module": "dist/bundle.mjs",

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/types", "name": "@budibase/types",
"version": "2.3.18-alpha.6", "version": "2.3.18-alpha.12",
"description": "Budibase types", "description": "Budibase types",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View file

@ -1,7 +1,7 @@
{ {
"name": "@budibase/worker", "name": "@budibase/worker",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "2.3.18-alpha.6", "version": "2.3.18-alpha.12",
"description": "Budibase background service", "description": "Budibase background service",
"main": "src/index.ts", "main": "src/index.ts",
"repository": { "repository": {
@ -36,10 +36,10 @@
"author": "Budibase", "author": "Budibase",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@budibase/backend-core": "2.3.18-alpha.6", "@budibase/backend-core": "2.3.18-alpha.12",
"@budibase/pro": "2.3.18-alpha.6", "@budibase/pro": "2.3.18-alpha.12",
"@budibase/string-templates": "2.3.18-alpha.6", "@budibase/string-templates": "2.3.18-alpha.12",
"@budibase/types": "2.3.18-alpha.6", "@budibase/types": "2.3.18-alpha.12",
"@koa/router": "8.0.8", "@koa/router": "8.0.8",
"@sentry/node": "6.17.7", "@sentry/node": "6.17.7",
"@techpass/passport-openidconnect": "0.3.2", "@techpass/passport-openidconnect": "0.3.2",

View file

@ -475,14 +475,14 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@budibase/backend-core@2.3.18-alpha.6": "@budibase/backend-core@2.3.18-alpha.12":
version "2.3.18-alpha.6" version "2.3.18-alpha.12"
resolved "https://registry.yarnpkg.com/@budibase/backend-core/-/backend-core-2.3.18-alpha.6.tgz#e6b3304e96b9469f3ca0f4fcfda8cf234c37e2d7" resolved "https://registry.yarnpkg.com/@budibase/backend-core/-/backend-core-2.3.18-alpha.12.tgz#ad1b16be64b78b596af2b5f75647c32e8f6f101a"
integrity sha512-To0kFbB9nZ6p0UO4ScS4PJ0gbqI1PrMWRXJLTv/6GU3PxnsqvH1tbpcleLMz2zeE04e5xdwt6W1oPViELom2gg== integrity sha512-E1NEO+/sNkkRqn/xk9XQmFBO9/dl27w9EB0QGztti/16JV9NgxyDQCJIdGwlD08s1y/lUwOKk0TkSZJs+CTYDw==
dependencies: dependencies:
"@budibase/nano" "10.1.1" "@budibase/nano" "10.1.1"
"@budibase/pouchdb-replication-stream" "1.2.10" "@budibase/pouchdb-replication-stream" "1.2.10"
"@budibase/types" "2.3.18-alpha.6" "@budibase/types" "2.3.18-alpha.12"
"@shopify/jest-koa-mocks" "5.0.1" "@shopify/jest-koa-mocks" "5.0.1"
"@techpass/passport-openidconnect" "0.3.2" "@techpass/passport-openidconnect" "0.3.2"
aws-cloudfront-sign "2.2.0" aws-cloudfront-sign "2.2.0"
@ -539,13 +539,13 @@
pouchdb-promise "^6.0.4" pouchdb-promise "^6.0.4"
through2 "^2.0.0" through2 "^2.0.0"
"@budibase/pro@2.3.18-alpha.6": "@budibase/pro@2.3.18-alpha.12":
version "2.3.18-alpha.6" version "2.3.18-alpha.12"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.3.18-alpha.6.tgz#7c5c221da7da79af79605a00aacac5c60f209bf6" resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.3.18-alpha.12.tgz#be552b3a9f5850e746081540d6586aae69147bec"
integrity sha512-YWPxmZn+z3tm5GZ+2UZSkOAhamlue/dmki+FCML5pIp3dCw8KsXnpzYXHRc1F8yXMTmA/8KBb/YkjQ2WK3Rk7A== integrity sha512-M3b0njzSi47KH6uaQfYPoA2KWrjPiwcU3ONyaVWXHIktVrIKtYaFwOLBr/dmWGfMrL2297SSqg7V4DTaLyAhnw==
dependencies: dependencies:
"@budibase/backend-core" "2.3.18-alpha.6" "@budibase/backend-core" "2.3.18-alpha.12"
"@budibase/types" "2.3.18-alpha.6" "@budibase/types" "2.3.18-alpha.12"
"@koa/router" "8.0.8" "@koa/router" "8.0.8"
bull "4.10.1" bull "4.10.1"
joi "17.6.0" joi "17.6.0"
@ -553,10 +553,10 @@
lru-cache "^7.14.1" lru-cache "^7.14.1"
node-fetch "^2.6.1" node-fetch "^2.6.1"
"@budibase/types@2.3.18-alpha.6": "@budibase/types@2.3.18-alpha.12":
version "2.3.18-alpha.6" version "2.3.18-alpha.12"
resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.3.18-alpha.6.tgz#9438ee64008668bbcb3d688b189cc649e03dfd60" resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.3.18-alpha.12.tgz#a63eb978ccc7e55c209b3e9d71f9aecf7facc0d1"
integrity sha512-16YtXwSODS8UDhdxCP2piGDWELP05EZuPbwLsOUFLX3Gt0+Wwkme+XWw4pTPE+GoK/mTVkDxzSc4cvuXWtfxxA== integrity sha512-27o2BmI/HXIR3frZ8FtqHgAe1hd8jPIzgPaEhKrQiYJ/opUVccqupx9ld75Hyk9E6cdXu0UF0/+LxPpUmMugag==
"@cspotcode/source-map-support@^0.8.0": "@cspotcode/source-map-support@^0.8.0":
version "0.8.1" version "0.8.1"