diff --git a/.github/workflows/release-develop.yml b/.github/workflows/release-develop.yml index 16c6c37bbd..e986179cfc 100644 --- a/.github/workflows/release-develop.yml +++ b/.github/workflows/release-develop.yml @@ -68,83 +68,6 @@ jobs: DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} 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: needs: [release-images] runs-on: ubuntu-latest diff --git a/.tool-versions b/.tool-versions index 8a1af3c071..6ee8cc60be 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ nodejs 14.19.3 -python 3.11.1 \ No newline at end of file +python 3.10.0 \ No newline at end of file diff --git a/lerna.json b/lerna.json index 0a3d923d6c..5145222dfa 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.3.18-alpha.6", + "version": "2.3.18-alpha.12", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 479a54bd94..428d785a44 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "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", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -24,7 +24,7 @@ "dependencies": { "@budibase/nano": "10.1.1", "@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", "@techpass/passport-openidconnect": "0.3.2", "aws-cloudfront-sign": "2.2.0", diff --git a/packages/bbui/package.json b/packages/bbui/package.json index 6cfeb44a7b..b697a532ad 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/bbui", "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", "svelte": "src/index.js", "module": "dist/bbui.es.js", @@ -38,7 +38,7 @@ ], "dependencies": { "@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/actionbutton": "1.0.1", "@spectrum-css/actiongroup": "1.0.1", diff --git a/packages/bbui/src/Icon/Icon.svelte b/packages/bbui/src/Icon/Icon.svelte index 8290acd7cc..452a8c74a1 100644 --- a/packages/bbui/src/Icon/Icon.svelte +++ b/packages/bbui/src/Icon/Icon.svelte @@ -67,6 +67,9 @@ color: var(--spectrum-alias-icon-color-selected-hover) !important; cursor: pointer; } + svg.hoverable:active { + color: var(--spectrum-global-color-blue-400) !important; + } svg.disabled { color: var(--spectrum-global-color-gray-500) !important; diff --git a/packages/bbui/src/InlineAlert/InlineAlert.svelte b/packages/bbui/src/InlineAlert/InlineAlert.svelte index 57e7296234..bd873042b3 100644 --- a/packages/bbui/src/InlineAlert/InlineAlert.svelte +++ b/packages/bbui/src/InlineAlert/InlineAlert.svelte @@ -57,5 +57,7 @@ --spectrum-semantic-negative-icon-color: #e34850; min-width: 100px; margin: 0; + border-color: var(--spectrum-global-color-gray-400); + border-width: 1px; } diff --git a/packages/bbui/src/Label/Label.svelte b/packages/bbui/src/Label/Label.svelte index ee6d9adf76..261ca946ea 100644 --- a/packages/bbui/src/Label/Label.svelte +++ b/packages/bbui/src/Label/Label.svelte @@ -21,7 +21,7 @@ label { padding: 0; white-space: nowrap; - color: var(--spectrum-global-color-gray-600); + color: var(--spectrum-global-color-gray-700); } .muted { diff --git a/packages/bbui/src/Modal/Modal.svelte b/packages/bbui/src/Modal/Modal.svelte index 47420444a2..45081356c1 100644 --- a/packages/bbui/src/Modal/Modal.svelte +++ b/packages/bbui/src/Modal/Modal.svelte @@ -1,7 +1,7 @@ - + onMount(() => { + document.addEventListener("keydown", handleKey) + return () => { + document.removeEventListener("keydown", handleKey) + } + }) + {#if inline} {#if visible} diff --git a/packages/bbui/src/helpers.js b/packages/bbui/src/helpers.js index b02783e0bd..f2246fbb49 100644 --- a/packages/bbui/src/helpers.js +++ b/packages/bbui/src/helpers.js @@ -104,6 +104,9 @@ export const deepSet = (obj, key, value) => { * @param obj the object to clone */ export const cloneDeep = obj => { + if (!obj) { + return obj + } return JSON.parse(JSON.stringify(obj)) } diff --git a/packages/builder/package.json b/packages/builder/package.json index 77b09fdbf3..b3afb4de2a 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/builder", - "version": "2.3.18-alpha.6", + "version": "2.3.18-alpha.12", "license": "GPL-3.0", "private": true, "scripts": { @@ -58,10 +58,10 @@ } }, "dependencies": { - "@budibase/bbui": "2.3.18-alpha.6", - "@budibase/client": "2.3.18-alpha.6", - "@budibase/frontend-core": "2.3.18-alpha.6", - "@budibase/string-templates": "2.3.18-alpha.6", + "@budibase/bbui": "2.3.18-alpha.12", + "@budibase/client": "2.3.18-alpha.12", + "@budibase/frontend-core": "2.3.18-alpha.12", + "@budibase/string-templates": "2.3.18-alpha.12", "@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/free-brands-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1", @@ -72,6 +72,7 @@ "codemirror": "^5.59.0", "dayjs": "^1.11.2", "downloadjs": "1.4.7", + "fast-json-patch": "^3.1.1", "lodash": "4.17.21", "posthog-js": "^1.36.0", "remixicon": "2.5.0", diff --git a/packages/builder/src/builderStore/index.js b/packages/builder/src/builderStore/index.js index 69bca7eac3..d15cdb6e98 100644 --- a/packages/builder/src/builderStore/index.js +++ b/packages/builder/src/builderStore/index.js @@ -5,12 +5,47 @@ import { getThemeStore } from "./store/theme" import { derived } from "svelte/store" import { findComponent, findComponentPath } from "./componentUtils" import { RoleUtils } from "@budibase/frontend-core" +import { createHistoryStore } from "builderStore/store/history" +import { get } from "svelte/store" export const store = getFrontendStore() export const automationStore = getAutomationStore() export const themeStore = getThemeStore() 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 => { return $store.screens.find(screen => screen._id === $store.selectedScreenId) }) @@ -71,3 +106,13 @@ export const selectedComponentPath = derived( ).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 + ) +}) diff --git a/packages/builder/src/builderStore/store/automation/Automation.js b/packages/builder/src/builderStore/store/automation/Automation.js deleted file mode 100644 index af0c03cb5a..0000000000 --- a/packages/builder/src/builderStore/store/automation/Automation.js +++ /dev/null @@ -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, - } - } -} diff --git a/packages/builder/src/builderStore/store/automation/index.js b/packages/builder/src/builderStore/store/automation/index.js index af102ab694..dc1e2a2cc1 100644 --- a/packages/builder/src/builderStore/store/automation/index.js +++ b/packages/builder/src/builderStore/store/automation/index.js @@ -1,16 +1,18 @@ -import { writable } from "svelte/store" +import { writable, get } from "svelte/store" import { API } from "api" -import Automation from "./Automation" import { cloneDeep } from "lodash/fp" +import { generate } from "shortid" +import { selectedAutomation } from "builderStore" const initialAutomationState = { automations: [], + testResults: null, showTestPanel: false, blockDefinitions: { TRIGGER: [], ACTION: [], }, - selectedAutomation: null, + selectedAutomationId: null, } export const getAutomationStore = () => { @@ -37,49 +39,41 @@ const automationActions = store => ({ API.getAutomationDefinitions(), ]) store.update(state => { - let selected = state.selectedAutomation?.automation state.automations = responses[0] + state.automations.sort((a, b) => { + return a.name < b.name ? -1 : 1 + }) state.blockDefinitions = { TRIGGER: responses[1].trigger, 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 }) }, - create: async ({ name }) => { + create: async (name, trigger) => { const automation = { name, type: "automation", definition: { steps: [], + trigger, }, } - const response = await API.createAutomation(automation) - store.update(state => { - state.automations = [...state.automations, response.automation] - store.actions.select(response.automation) - return state - }) + const response = await store.actions.save(automation) + await store.actions.fetch() + store.actions.select(response._id) + return response }, duplicate: async automation => { - const response = await API.createAutomation({ + const response = await store.actions.save({ ...automation, name: `${automation.name} - copy`, _id: undefined, _ref: undefined, }) - store.update(state => { - state.automations = [...state.automations, response.automation] - store.actions.select(response.automation) - return state - }) + await store.actions.fetch() + store.actions.select(response._id) + return response }, save: async automation => { const response = await API.updateAutomation(automation) @@ -90,11 +84,13 @@ const automationActions = store => ({ ) if (existingIdx !== -1) { state.automations.splice(existingIdx, 1, updatedAutomation) - state.automations = [...state.automations] - store.actions.select(updatedAutomation) return state + } else { + state.automations = [...state.automations, updatedAutomation] } + return state }) + return response.automation }, delete: async automation => { await API.deleteAutomation({ @@ -102,34 +98,83 @@ const automationActions = store => ({ automationRev: automation?._rev, }) store.update(state => { - const existingIdx = state.automations.findIndex( - existing => existing._id === automation?._id + // Remove the automation + state.automations = state.automations.filter( + x => x._id !== automation._id ) - state.automations.splice(existingIdx, 1) - state.automations = [...state.automations] - state.selectedAutomation = null - state.selectedBlock = null + // Select a new automation if required + if (automation._id === state.selectedAutomationId) { + store.actions.select(state.automations[0]?._id) + } 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) => { - store.update(state => { - state.selectedAutomation.testResults = null - return state - }) const result = await API.testAutomation({ automationId: automation?._id, testData, }) + if (!result?.trigger && !result?.steps?.length) { + throw "Something went wrong testing your automation" + } store.update(state => { - state.selectedAutomation.testResults = result + state.testResults = result 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 => { - state.selectedAutomation = new Automation(cloneDeep(automation)) - state.selectedBlock = null + state.selectedAutomationId = id + state.testResults = null + state.showTestPanel = false return state }) }, @@ -147,48 +192,57 @@ const automationActions = store => ({ appId, }) }, - addTestDataToAutomation: data => { - store.update(state => { - state.selectedAutomation.addTestData(data) - return state - }) + addTestDataToAutomation: async data => { + let newAutomation = cloneDeep(get(selectedAutomation)) + newAutomation.testData = { + ...newAutomation.testData, + ...data, + } + await store.actions.save(newAutomation) }, - addBlockToAutomation: (block, blockIdx) => { - store.update(state => { - state.selectedBlock = state.selectedAutomation.addBlock( - cloneDeep(block), - blockIdx - ) - return state - }) + constructBlock(type, stepId, blockDefinition) { + return { + ...blockDefinition, + inputs: blockDefinition.inputs || {}, + stepId, + type, + id: generate(), + } }, - toggleFieldControl: value => { - store.update(state => { - state.selectedBlock.rowControl = value - return state - }) + addBlockToAutomation: async (block, blockIdx) => { + const automation = get(selectedAutomation) + let newAutomation = cloneDeep(automation) + if (!automation) { + return + } + newAutomation.definition.steps.splice(blockIdx, 0, block) + await store.actions.save(newAutomation) }, - deleteAutomationBlock: block => { - store.update(state => { - const idx = - state.selectedAutomation.automation.definition.steps.findIndex( - x => x.id === block.id - ) - state.selectedAutomation.deleteBlock(block.id) + /** + * "rowControl" appears to be the name of the flag used to determine whether + * a certain automation block uses values or bindings as inputs + */ + toggleRowControl: async (block, rowControl) => { + const newBlock = { ...block, rowControl } + 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 - const steps = state.selectedAutomation.automation.definition.steps - let nextSelectedBlock - if (steps[idx] != null) { - nextSelectedBlock = steps[idx] - } else if (steps[idx - 1] != null) { - nextSelectedBlock = steps[idx - 1] - } else { - nextSelectedBlock = - state.selectedAutomation.automation.definition.trigger || null - } - state.selectedBlock = nextSelectedBlock - return state - }) + // Delete trigger if required + if (newAutomation.definition.trigger?.id === block.id) { + delete newAutomation.definition.trigger + } else { + // Otherwise remove step + newAutomation.definition.steps = newAutomation.definition.steps.filter( + step => step.id !== block.id + ) + } + await store.actions.save(newAutomation) }, }) diff --git a/packages/builder/src/builderStore/store/automation/tests/Automation.spec.js b/packages/builder/src/builderStore/store/automation/tests/Automation.spec.js deleted file mode 100644 index 8378310c2e..0000000000 --- a/packages/builder/src/builderStore/store/automation/tests/Automation.spec.js +++ /dev/null @@ -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 {{time}} 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 - ) - }) -}) diff --git a/packages/builder/src/builderStore/store/automation/tests/testAutomation.js b/packages/builder/src/builderStore/store/automation/tests/testAutomation.js deleted file mode 100644 index 3fafbaf1d0..0000000000 --- a/packages/builder/src/builderStore/store/automation/tests/testAutomation.js +++ /dev/null @@ -1,78 +0,0 @@ -export default { - name: "Test automation", - definition: { - steps: [ - { - id: "ANBDINAPS", - description: "Send an email.", - tagline: "Send email to {{to}}", - 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 {{table.name}}", - 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", -} diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 7b01f57a53..51f88add27 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -1,6 +1,11 @@ import { get, writable } from "svelte/store" import { cloneDeep } from "lodash/fp" -import { selectedScreen, selectedComponent } from "builderStore" +import { + selectedScreen, + selectedComponent, + screenHistoryStore, + automationHistoryStore, +} from "builderStore" import { datasources, integrations, @@ -124,6 +129,8 @@ export const getFrontendStore = () => { navigation: application.navigation || {}, usedPlugins: application.usedPlugins || [], })) + screenHistoryStore.reset() + automationHistoryStore.reset() // Initialise backend stores database.set(application.instance) @@ -181,10 +188,7 @@ export const getFrontendStore = () => { } // Check screen isn't already selected - if ( - state.selectedScreenId === screen._id && - state.selectedComponentId === screen.props?._id - ) { + if (state.selectedScreenId === screen._id) { return } @@ -258,7 +262,7 @@ export const getFrontendStore = () => { } }, save: async screen => { - /* + /* Temporarily disabled to accomodate migration issues. store.actions.screens.validate(screen) */ @@ -349,6 +353,7 @@ export const getFrontendStore = () => { return state }) + return null }, updateSetting: async (screen, name, value) => { if (!screen || !name) { diff --git a/packages/builder/src/builderStore/store/history.js b/packages/builder/src/builderStore/store/history.js new file mode 100644 index 0000000000..0f21085c6a --- /dev/null +++ b/packages/builder/src/builderStore/store/history.js @@ -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} + */ + 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} + */ + 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, + } +} diff --git a/packages/builder/src/components/automation/AutomationBuilder/AutomationBuilder.svelte b/packages/builder/src/components/automation/AutomationBuilder/AutomationBuilder.svelte index e852ee1a0d..b80ba45086 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/AutomationBuilder.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/AutomationBuilder.svelte @@ -1,10 +1,10 @@ -{#if automation} - +{#if $selectedAutomation} + {#key $selectedAutomation._id} + + {/key} {/if} diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte index caf8835b86..f30b49eb39 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte @@ -5,7 +5,6 @@ Detail, Body, Icon, - Tooltip, notifications, } from "@budibase/bbui" import { automationStore } from "builderStore" @@ -13,7 +12,6 @@ import { externalActions } from "./ExternalActions" export let blockIdx - export let blockComplete const disabled = { SEND_EMAIL_SMTP: { @@ -50,15 +48,12 @@ async function addBlockToAutomation() { try { - const newBlock = $automationStore.selectedAutomation.constructBlock( + const newBlock = automationStore.actions.constructBlock( "ACTION", actionVal.stepId, actionVal ) - automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1) - await automationStore.actions.save( - $automationStore.selectedAutomation?.automation - ) + await automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1) } catch (error) { notifications.error("Error saving automation") } @@ -66,20 +61,14 @@ { - blockComplete = true - addBlockToAutomation() - }} + onConfirm={addBlockToAutomation} > - Select an app or event. - - - Apps - + + Apps
{#each Object.entries(external) as [idx, action]}
- {idx.charAt(0).toUpperCase() + idx.slice(1)} + + {idx.charAt(0).toUpperCase() + idx.slice(1)} + +
{/each} +
+ Actions -
{#each Object.entries(internal) as [idx, action]} - {#if disabled[idx] && disabled[idx].disabled} - -
selectAction(action)} - > -
- - - {action.name} -
-
-
- {:else} -
selectAction(action)} - > -
- - - {action.name} -
+ {@const isDisabled = disabled[idx] && disabled[idx].disabled} +
selectAction(action)} + > +
+ + {action.name} + {#if isDisabled} + + {/if}
- {/if} +
{/each}
diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte index 4b01616b54..63a3478ef3 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte @@ -1,5 +1,5 @@
-
+
{automation.name} -
-
-
-
-
- -
+
+ + +
{ testDataModal.show() @@ -62,15 +60,13 @@ icon="MultipleCheck" size="M">Run test -
- { - $automationStore.showTestPanel = true - }} - size="M">Test Details -
+ { + $automationStore.showTestPanel = true + }} + size="M">Test Details
@@ -80,7 +76,7 @@
{#if block.stepId !== ActionStepID.LOOP} @@ -105,6 +101,9 @@ diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte index d6e5fcb36d..7484a60502 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte @@ -1,5 +1,5 @@
{}}> - {#if loopingSelected} + {#if loopBlock}
{ @@ -174,13 +142,8 @@
-
{ - onSelect(block) - }} - > - +
{}}> +
@@ -198,9 +161,7 @@ $automationStore.blockDefinitions.ACTION.LOOP.schema.inputs .properties )} - block={$automationStore.selectedAutomation?.automation.definition.steps.find( - x => x.blockToLoop === block.id - )} + block={loopBlock} {webhookModal} /> @@ -209,22 +170,28 @@ {/if} {/if} - - {#if !blockComplete} + (open = !open)} + /> + {#if open}
{#if !isTrigger}
- {#if !loopingSelected} - addLooping()} icon="Reuse" - >Add Looping + {#if !loopBlock} + addLooping()} icon="Reuse"> + Add Looping + {/if} {#if showBindingPicker}