diff --git a/lerna.json b/lerna.json index 0e026aefc5..4940336b29 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.19.3", + "version": "2.19.4", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/package.json b/package.json index 499952a441..4407fd33f3 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "lint": "yarn run lint:eslint && yarn run lint:prettier", "lint:fix:eslint": "eslint --fix --max-warnings=0 packages qa-core", "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"", - "lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint", + "lint:fix": "yarn run lint:fix:eslint && yarn run lint:fix:prettier", "build:specs": "lerna run --stream specs", "build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild", "build:docker:airgap:single": "SINGLE_IMAGE=1 node hosting/scripts/airgapped/airgappedDockerBuild", diff --git a/packages/account-portal b/packages/account-portal index 1ba8414bed..8c446c4ba3 160000 --- a/packages/account-portal +++ b/packages/account-portal @@ -1 +1 @@ -Subproject commit 1ba8414bed14439512153cf851086146a80560f5 +Subproject commit 8c446c4ba385592127fa31755d3b64467b291882 diff --git a/packages/builder/assets/n8n_square.png b/packages/builder/assets/n8n_square.png new file mode 100644 index 0000000000..23b75ee688 Binary files /dev/null and b/packages/builder/assets/n8n_square.png differ diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js new file mode 100644 index 0000000000..fd492cca0b --- /dev/null +++ b/packages/builder/src/builderStore/store/frontend.js @@ -0,0 +1,1520 @@ +import { get, writable } from "svelte/store" +import { cloneDeep } from "lodash/fp" +import { + selectedScreen, + selectedComponent, + screenHistoryStore, + automationHistoryStore, +} from "builderStore" +import { + datasources, + integrations, + queries, + database, + tables, +} from "stores/backend" +import { API } from "api" +import analytics, { Events } from "analytics" +import { + findComponentParent, + findClosestMatchingComponent, + findAllMatchingComponents, + findComponent, + getComponentSettings, + makeComponentUnique, + findComponentPath, +} from "../componentUtils" +import { Helpers } from "@budibase/bbui" +import { Utils } from "@budibase/frontend-core" +import { + BUDIBASE_INTERNAL_DB_ID, + DB_TYPE_INTERNAL, + DB_TYPE_EXTERNAL, +} from "constants/backend" +import { + buildFormSchema, + getSchemaForDatasource, +} from "builderStore/dataBinding" +import { makePropSafe as safe } from "@budibase/string-templates" +import { getComponentFieldOptions } from "helpers/formFields" +import { createBuilderWebsocket } from "builderStore/websocket" +import { BuilderSocketEvent } from "@budibase/shared-core" +import componentTreeNodesStore from "stores/portal/componentTreeNodesStore" + +const INITIAL_FRONTEND_STATE = { + initialised: false, + apps: [], + name: "", + url: "", + description: "", + layouts: [], + screens: [], + components: [], + clientFeatures: { + spectrumThemes: false, + intelligentLoading: false, + deviceAwareness: false, + state: false, + rowSelection: false, + customThemes: false, + devicePreview: false, + messagePassing: false, + continueIfAction: false, + showNotificationAction: false, + sidePanel: false, + }, + features: { + componentValidation: false, + disableUserMetadata: false, + }, + errors: [], + hasAppPackage: false, + libraries: null, + appId: "", + routes: {}, + clientLibPath: "", + theme: "", + customTheme: {}, + previewDevice: "desktop", + highlightedSettingKey: null, + propertyFocus: null, + builderSidePanel: false, + hasLock: true, + showPreview: false, + + // URL params + selectedScreenId: null, + selectedComponentId: null, + selectedLayoutId: null, + + // Client state + selectedComponentInstance: null, + + // Onboarding + onboarding: false, + tourNodes: null, +} + +export const getFrontendStore = () => { + const store = writable({ ...INITIAL_FRONTEND_STATE }) + let websocket + + // This is a fake implementation of a "patch" API endpoint to try and prevent + // 409s. All screen doc mutations (aside from creation) use this function, + // which queues up invocations sequentially and ensures pending mutations are + // always applied to the most up-to-date doc revision. + // This is slightly better than just a traditional "patch" endpoint and this + // supports deeply mutating the current doc rather than just appending data. + const sequentialScreenPatch = Utils.sequential(async (patchFn, screenId) => { + const state = get(store) + const screen = state.screens.find(screen => screen._id === screenId) + if (!screen) { + return + } + let clone = cloneDeep(screen) + const result = patchFn(clone) + // An explicit false result means skip this change + if (result === false) { + return + } + return await store.actions.screens.save(clone) + }) + + store.actions = { + reset: () => { + store.set({ ...INITIAL_FRONTEND_STATE }) + websocket?.disconnect() + websocket = null + }, + initialise: async pkg => { + const { layouts, screens, application, clientLibPath, hasLock } = pkg + if (!websocket) { + websocket = createBuilderWebsocket(application.appId) + } + await store.actions.components.refreshDefinitions(application.appId) + + // Reset store state + store.update(state => ({ + ...state, + libraries: application.componentLibraries, + name: application.name, + description: application.description, + appId: application.appId, + url: application.url, + layouts: layouts || [], + screens: screens || [], + theme: application.theme || "spectrum--light", + customTheme: application.customTheme, + hasAppPackage: true, + appInstance: application.instance, + clientLibPath, + previousTopNavPath: {}, + version: application.version, + revertableVersion: application.revertableVersion, + upgradableVersion: application.upgradableVersion, + navigation: application.navigation || {}, + usedPlugins: application.usedPlugins || [], + hasLock, + features: { + ...INITIAL_FRONTEND_STATE.features, + ...application.features, + }, + automations: application.automations || {}, + icon: application.icon || {}, + initialised: true, + })) + screenHistoryStore.reset() + automationHistoryStore.reset() + + // Initialise backend stores + database.set(application.instance) + await datasources.init() + await integrations.init() + await queries.init() + await tables.init() + }, + theme: { + save: async theme => { + const appId = get(store).appId + const app = await API.saveAppMetadata({ + appId, + metadata: { theme }, + }) + store.update(state => { + state.theme = app.theme + return state + }) + }, + }, + customTheme: { + save: async customTheme => { + const appId = get(store).appId + const app = await API.saveAppMetadata({ + appId, + metadata: { customTheme }, + }) + store.update(state => { + state.customTheme = app.customTheme + return state + }) + }, + }, + navigation: { + save: async navigation => { + const appId = get(store).appId + const app = await API.saveAppMetadata({ + appId, + metadata: { navigation }, + }) + store.update(state => { + state.navigation = app.navigation + return state + }) + }, + }, + screens: { + select: screenId => { + // Check this screen exists + const state = get(store) + const screen = state.screens.find(screen => screen._id === screenId) + if (!screen) { + return + } + + // Check screen isn't already selected + if (state.selectedScreenId === screen._id) { + return + } + + // Select new screen + store.update(state => { + state.selectedScreenId = screen._id + return state + }) + }, + validate: screen => { + // Recursive function to find any illegal children in component trees + const findIllegalChild = ( + component, + illegalChildren = [], + legalDirectChildren = [] + ) => { + const type = component._component + + if (illegalChildren.includes(type)) { + return type + } + if ( + legalDirectChildren.length && + !legalDirectChildren.includes(type) + ) { + return type + } + if (!component?._children?.length) { + return + } + + if (type === "@budibase/standard-components/sidepanel") { + illegalChildren = [] + } + + const definition = store.actions.components.getDefinition( + component._component + ) + // Reset whitelist for direct children + legalDirectChildren = [] + if (definition?.legalDirectChildren?.length) { + legalDirectChildren = definition.legalDirectChildren.map(x => { + return `@budibase/standard-components/${x}` + }) + } + + // Append blacklisted components and remove duplicates + if (definition?.illegalChildren?.length) { + const blacklist = definition.illegalChildren.map(x => { + return `@budibase/standard-components/${x}` + }) + illegalChildren = [...new Set([...illegalChildren, ...blacklist])] + } + + // Recurse on all children + for (let child of component._children) { + const illegalChild = findIllegalChild( + child, + illegalChildren, + legalDirectChildren + ) + if (illegalChild) { + return illegalChild + } + } + } + + // Validate the entire tree and throw an error if an illegal child is + // found anywhere + const illegalChild = findIllegalChild(screen.props) + if (illegalChild) { + const def = store.actions.components.getDefinition(illegalChild) + throw `You can't place a ${def.name} here` + } + }, + save: async screen => { + const state = get(store) + + // Validate screen structure if the app supports it + if (state.features?.componentValidation) { + store.actions.screens.validate(screen) + } + + // Check screen definition for any component settings which need updated + store.actions.screens.enrichEmptySettings(screen) + + // Save screen + const creatingNewScreen = screen._id === undefined + const savedScreen = await API.saveScreen(screen) + const routesResponse = await API.fetchAppRoutes() + + // If plugins changed we need to fetch the latest app metadata + let usedPlugins = state.usedPlugins + if (savedScreen.pluginAdded) { + const { application } = await API.fetchAppPackage(state.appId) + usedPlugins = application.usedPlugins || [] + } + + // Update state + store.update(state => { + // Update screen object + const idx = state.screens.findIndex(x => x._id === savedScreen._id) + if (idx !== -1) { + state.screens.splice(idx, 1, savedScreen) + } else { + state.screens.push(savedScreen) + } + + // Select the new screen if creating a new one + if (creatingNewScreen) { + state.selectedScreenId = savedScreen._id + state.selectedComponentId = savedScreen.props._id + } + + // Update routes + state.routes = routesResponse.routes + + // Update used plugins + state.usedPlugins = usedPlugins + return state + }) + return savedScreen + }, + patch: async (patchFn, screenId) => { + // Default to the currently selected screen + if (!screenId) { + const state = get(store) + screenId = state.selectedScreenId + } + if (!screenId || !patchFn) { + return + } + return await sequentialScreenPatch(patchFn, screenId) + }, + replace: async (screenId, screen) => { + if (!screenId) { + return + } + if (!screen) { + // Screen deletion + store.update(state => ({ + ...state, + screens: state.screens.filter(x => x._id !== screenId), + })) + } else { + const index = get(store).screens.findIndex(x => x._id === screen._id) + if (index === -1) { + // Screen addition + store.update(state => ({ + ...state, + screens: [...state.screens, screen], + })) + } else { + // Screen update + store.update(state => { + state.screens[index] = screen + return state + }) + } + } + }, + delete: async screens => { + const screensToDelete = Array.isArray(screens) ? screens : [screens] + + // Build array of promises to speed up bulk deletions + let promises = [] + let deleteUrls = [] + screensToDelete.forEach(screen => { + // Delete the screen + promises.push( + API.deleteScreen({ + screenId: screen._id, + screenRev: screen._rev, + }) + ) + // Remove links to this screen + deleteUrls.push(screen.routing.route) + }) + + await Promise.all(promises) + await store.actions.links.delete(deleteUrls) + const deletedIds = screensToDelete.map(screen => screen._id) + const routesResponse = await API.fetchAppRoutes() + store.update(state => { + // Remove deleted screens from state + state.screens = state.screens.filter(screen => { + return !deletedIds.includes(screen._id) + }) + + // Deselect the current screen if it was deleted + if (deletedIds.includes(state.selectedScreenId)) { + state.selectedScreenId = null + state.selectedComponentId = null + } + + // Update routing + state.routes = routesResponse.routes + + return state + }) + return null + }, + updateSetting: async (screen, name, value) => { + if (!screen || !name) { + return + } + + // Apply setting update + const patch = screen => { + if (!screen) { + return false + } + // Skip update if the value is the same + if (Helpers.deepGet(screen, name) === value) { + return false + } + Helpers.deepSet(screen, name, value) + } + await store.actions.screens.patch(patch, screen._id) + + // Ensure we don't have more than one home screen for this new role. + // This could happen after updating multiple different settings. + const state = get(store) + const updatedScreen = state.screens.find(s => s._id === screen._id) + if (!updatedScreen) { + return + } + const otherHomeScreens = state.screens.filter(s => { + return ( + s.routing.roleId === updatedScreen.routing.roleId && + s.routing.homeScreen && + s._id !== screen._id + ) + }) + if (otherHomeScreens.length && updatedScreen.routing.homeScreen) { + const patch = screen => { + screen.routing.homeScreen = false + } + for (let otherHomeScreen of otherHomeScreens) { + await store.actions.screens.patch(patch, otherHomeScreen._id) + } + } + }, + removeCustomLayout: async screen => { + // Pull relevant settings from old layout, if required + const layout = get(store).layouts.find(x => x._id === screen.layoutId) + const patch = screen => { + screen.layoutId = null + screen.showNavigation = layout?.props.navigation !== "None" + screen.width = layout?.props.width || "Large" + } + await store.actions.screens.patch(patch, screen._id) + }, + enrichEmptySettings: screen => { + // Flatten the recursive component tree + const components = findAllMatchingComponents(screen.props, x => x) + + // Iterate over all components and run checks + components.forEach(component => { + store.actions.components.enrichEmptySettings(component, { + screen, + }) + }) + }, + }, + preview: { + setDevice: device => { + store.update(state => { + state.previewDevice = device + return state + }) + }, + sendEvent: (name, payload) => { + const { previewEventHandler } = get(store) + previewEventHandler?.(name, payload) + }, + registerEventHandler: handler => { + store.update(state => { + state.previewEventHandler = handler + return state + }) + }, + }, + layouts: { + select: layoutId => { + // Check this layout exists + const state = get(store) + const layout = state.layouts.find(layout => layout._id === layoutId) + if (!layout) { + return + } + + // Check layout isn't already selected + if ( + state.selectedLayoutId === layout._id && + state.selectedComponentId === layout.props?._id + ) { + return + } + + // Select new layout + store.update(state => { + state.selectedLayoutId = layout._id + state.selectedComponentId = layout.props?._id + return state + }) + }, + delete: async layout => { + if (!layout?._id) { + return + } + await API.deleteLayout({ + layoutId: layout._id, + layoutRev: layout._rev, + }) + store.update(state => { + state.layouts = state.layouts.filter(x => x._id !== layout._id) + return state + }) + }, + }, + components: { + refreshDefinitions: async appId => { + if (!appId) { + appId = get(store).appId + } + + // Fetch definitions and filter out custom component definitions so we + // can flag them + const components = await API.fetchComponentLibDefinitions(appId) + const customComponents = Object.keys(components).filter(name => + name.startsWith("plugin/") + ) + + // Update store + store.update(state => ({ + ...state, + components, + customComponents, + clientFeatures: { + ...INITIAL_FRONTEND_STATE.clientFeatures, + ...components.features, + }, + })) + }, + getDefinition: componentName => { + if (!componentName) { + return null + } + return get(store).components[componentName] + }, + getDefaultDatasource: () => { + // Ignore users table + const validTables = get(tables).list.filter(x => x._id !== "ta_users") + + // Try to use their own internal table first + let table = validTables.find(table => { + return ( + table.sourceId !== BUDIBASE_INTERNAL_DB_ID && + table.sourceType === DB_TYPE_INTERNAL + ) + }) + if (table) { + return table + } + + // Then try sample data + table = validTables.find(table => { + return ( + table.sourceId === BUDIBASE_INTERNAL_DB_ID && + table.sourceType === DB_TYPE_INTERNAL + ) + }) + if (table) { + return table + } + + // Finally try an external table + return validTables.find(table => table.sourceType === DB_TYPE_EXTERNAL) + }, + migrateSettings: enrichedComponent => { + const componentPrefix = "@budibase/standard-components" + let migrated = false + + if (enrichedComponent?._component == `${componentPrefix}/formblock`) { + // Use default config if the 'buttons' prop has never been initialised + if (!("buttons" in enrichedComponent)) { + enrichedComponent["buttons"] = + Utils.buildFormBlockButtonConfig(enrichedComponent) + migrated = true + } else if (enrichedComponent["buttons"] == null) { + // Ignore legacy config if 'buttons' has been reset by 'resetOn' + const { _id, actionType, dataSource } = enrichedComponent + enrichedComponent["buttons"] = Utils.buildFormBlockButtonConfig({ + _id, + actionType, + dataSource, + }) + migrated = true + } + + // Ensure existing Formblocks position their buttons at the top. + if (!("buttonPosition" in enrichedComponent)) { + enrichedComponent["buttonPosition"] = "top" + migrated = true + } + } + + return migrated + }, + enrichEmptySettings: (component, opts) => { + if (!component?._component) { + return + } + const defaultDS = store.actions.components.getDefaultDatasource() + const settings = getComponentSettings(component._component) + const { parent, screen, useDefaultValues } = opts || {} + const treeId = parent?._id || component._id + if (!screen) { + return + } + settings.forEach(setting => { + const value = component[setting.key] + + // Fill empty settings + if (value == null || value === "") { + if (setting.type === "multifield" && setting.selectAllFields) { + // Select all schema fields where required + component[setting.key] = Object.keys(defaultDS?.schema || {}) + } else if ( + (setting.type === "dataSource" || setting.type === "table") && + defaultDS + ) { + // Select default datasource where required + component[setting.key] = { + label: defaultDS.name, + tableId: defaultDS._id, + resourceId: defaultDS._id, + type: "table", + } + } else if (setting.type === "dataProvider") { + // Pick closest data provider where required + const path = findComponentPath(screen.props, treeId) + const providers = path.filter(component => + component._component?.endsWith("/dataprovider") + ) + if (providers.length) { + const id = providers[providers.length - 1]?._id + component[setting.key] = `{{ literal ${safe(id)} }}` + } + } else if (setting.type.startsWith("field/")) { + // Autofill form field names + // Get all available field names in this form schema + let fieldOptions = getComponentFieldOptions( + screen.props, + treeId, + setting.type, + false + ) + + // Get all currently used fields + const form = findClosestMatchingComponent( + screen.props, + treeId, + x => x._component === "@budibase/standard-components/form" + ) + const usedFields = Object.keys(buildFormSchema(form) || {}) + + // Filter out already used fields + fieldOptions = fieldOptions.filter(x => !usedFields.includes(x)) + + // Set field name and also assume we have a label setting + if (fieldOptions[0]) { + component[setting.key] = fieldOptions[0] + component.label = fieldOptions[0] + } + } else if (useDefaultValues && setting.defaultValue !== undefined) { + // Use default value where required + component[setting.key] = setting.defaultValue + } + } + // Validate non-empty settings + else { + if (setting.type === "dataProvider") { + // Validate data provider exists, or else clear it + const providers = findAllMatchingComponents( + screen?.props, + component => component._component?.endsWith("/dataprovider") + ) + // Validate non-empty values + const valid = providers?.some(dp => value.includes?.(dp._id)) + if (!valid) { + if (providers.length) { + const id = providers[providers.length - 1]?._id + component[setting.key] = `{{ literal ${safe(id)} }}` + } else { + delete component[setting.key] + } + } + } + } + }) + }, + createInstance: (componentName, presetProps, parent) => { + const definition = store.actions.components.getDefinition(componentName) + if (!definition) { + return null + } + + // Find all existing components of this type so that we can give this + // component a unique name + const screen = get(selectedScreen).props + const otherComponents = findAllMatchingComponents( + screen, + x => x._component === definition.component && x._id !== screen._id + ) + let name = definition.friendlyName || definition.name + name = `${name} ${otherComponents.length + 1}` + + // Generate basic component structure + let instance = { + _id: Helpers.uuid(), + _component: definition.component, + _styles: { + normal: {}, + hover: {}, + active: {}, + }, + _instanceName: name, + ...presetProps, + } + + // Enrich empty settings + store.actions.components.enrichEmptySettings(instance, { + parent, + screen: get(selectedScreen), + useDefaultValues: true, + }) + + // Migrate nested component settings + store.actions.components.migrateSettings(instance) + + // Add any extra properties the component needs + let extras = {} + if (definition.hasChildren) { + extras._children = [] + } + if (componentName.endsWith("/formstep")) { + const parentForm = findClosestMatchingComponent( + get(selectedScreen).props, + get(selectedComponent)._id, + component => component._component.endsWith("/form") + ) + const formSteps = findAllMatchingComponents(parentForm, component => + component._component.endsWith("/formstep") + ) + extras.step = formSteps.length + 1 + extras._instanceName = `Step ${formSteps.length + 1}` + } + return { + ...cloneDeep(instance), + ...extras, + } + }, + create: async (componentName, presetProps, parent, index) => { + const state = get(store) + const componentInstance = store.actions.components.createInstance( + componentName, + presetProps, + parent + ) + if (!componentInstance) { + return + } + + // Insert in position if specified + if (parent && index != null) { + await store.actions.screens.patch(screen => { + let parentComponent = findComponent(screen.props, parent) + if (!parentComponent._children?.length) { + parentComponent._children = [componentInstance] + } else { + parentComponent._children.splice(index, 0, componentInstance) + } + }) + } + + // Otherwise we work out where this component should be inserted + else { + await store.actions.screens.patch(screen => { + // Find the selected component + let selectedComponentId = state.selectedComponentId + if (selectedComponentId.startsWith(`${screen._id}-`)) { + selectedComponentId = screen?.props._id + } + const currentComponent = findComponent( + screen.props, + selectedComponentId + ) + if (!currentComponent) { + return false + } + + // Find parent node to attach this component to + let parentComponent + if (currentComponent) { + // Use selected component as parent if one is selected + const definition = store.actions.components.getDefinition( + currentComponent._component + ) + if (definition?.hasChildren) { + // Use selected component if it allows children + parentComponent = currentComponent + } else { + // Otherwise we need to use the parent of this component + parentComponent = findComponentParent( + screen.props, + currentComponent._id + ) + } + } else { + // Use screen or layout if no component is selected + parentComponent = screen.props + } + + // Attach new component + if (!parentComponent) { + return false + } + if (!parentComponent._children) { + parentComponent._children = [] + } + parentComponent._children.push(componentInstance) + }) + } + + // Select new component + store.update(state => { + state.selectedComponentId = componentInstance._id + return state + }) + + // Log event + analytics.captureEvent(Events.COMPONENT_CREATED, { + name: componentInstance._component, + }) + + return componentInstance + }, + patch: async (patchFn, componentId, screenId) => { + // Use selected component by default + if (!componentId || !screenId) { + const state = get(store) + componentId = componentId || state.selectedComponentId + screenId = screenId || state.selectedScreenId + } + if (!componentId || !screenId || !patchFn) { + return + } + const patchScreen = screen => { + // findComponent looks in the tree not comp.settings[0] + let component = findComponent(screen.props, componentId) + if (!component) { + return false + } + + // Mutates the fetched component with updates + const patchResult = patchFn(component, screen) + + // Mutates the component with any required settings updates + const migrated = store.actions.components.migrateSettings(component) + + // Returning an explicit false signifies that we should skip this + // update. If we migrated something, ensure we never skip. + return migrated ? null : patchResult + } + await store.actions.screens.patch(patchScreen, screenId) + }, + delete: async component => { + if (!component) { + return + } + + // Determine the next component to select after deletion + const state = get(store) + let nextSelectedComponentId + if (state.selectedComponentId === component._id) { + nextSelectedComponentId = store.actions.components.getNext() + if (!nextSelectedComponentId) { + nextSelectedComponentId = store.actions.components.getPrevious() + } + } + + // Patch screen + await store.actions.screens.patch(screen => { + // Check component exists + component = findComponent(screen.props, component._id) + if (!component) { + return false + } + + // Check component has a valid parent + const parent = findComponentParent(screen.props, component._id) + if (!parent) { + return false + } + parent._children = parent._children.filter( + child => child._id !== component._id + ) + }) + + // Update selected component if required + if (nextSelectedComponentId) { + store.update(state => { + state.selectedComponentId = nextSelectedComponentId + return state + }) + } + }, + copy: (component, cut = false, selectParent = true) => { + // Update store with copied component + store.update(state => { + state.componentToPaste = cloneDeep(component) + state.componentToPaste.isCut = cut + return state + }) + + // Select the parent if cutting + if (cut && selectParent) { + const screen = get(selectedScreen) + const parent = findComponentParent(screen?.props, component._id) + if (parent) { + store.update(state => { + state.selectedComponentId = parent._id + return state + }) + } + } + }, + paste: async (targetComponent, mode, targetScreen) => { + const state = get(store) + if (!state.componentToPaste) { + return + } + let newComponentId + + // Remove copied component if cutting, regardless if pasting works + let componentToPaste = cloneDeep(state.componentToPaste) + if (componentToPaste.isCut) { + store.update(state => { + delete state.componentToPaste + return state + }) + } + + // Patch screen + const patch = screen => { + // Get up to date ref to target + targetComponent = findComponent(screen.props, targetComponent._id) + if (!targetComponent) { + return false + } + const cut = componentToPaste.isCut + const originalId = componentToPaste._id + delete componentToPaste.isCut + + // Make new component unique if copying + if (!cut) { + componentToPaste = makeComponentUnique(componentToPaste) + } + newComponentId = componentToPaste._id + + // Delete old component if cutting + if (cut) { + const parent = findComponentParent(screen.props, originalId) + if (parent?._children) { + parent._children = parent._children.filter( + component => component._id !== originalId + ) + } + } + + // Check inside is valid + if (mode === "inside") { + const definition = store.actions.components.getDefinition( + targetComponent._component + ) + if (!definition.hasChildren) { + mode = "below" + } + } + + // Paste new component + if (mode === "inside") { + // Paste inside target component if chosen + if (!targetComponent._children) { + targetComponent._children = [] + } + targetComponent._children.push(componentToPaste) + } else { + // Otherwise paste in the correct order in the parent's children + const parent = findComponentParent( + screen.props, + targetComponent._id + ) + if (!parent?._children) { + return false + } + const targetIndex = parent._children.findIndex(component => { + return component._id === targetComponent._id + }) + const index = mode === "above" ? targetIndex : targetIndex + 1 + parent._children.splice(index, 0, componentToPaste) + } + } + const targetScreenId = targetScreen?._id || state.selectedScreenId + await store.actions.screens.patch(patch, targetScreenId) + + // Select the new component + store.update(state => { + state.selectedScreenId = targetScreenId + state.selectedComponentId = newComponentId + return state + }) + }, + getPrevious: () => { + const state = get(store) + const componentId = state.selectedComponentId + const screen = get(selectedScreen) + const parent = findComponentParent(screen.props, componentId) + const index = parent?._children.findIndex(x => x._id === componentId) + const componentTreeNodes = get(componentTreeNodesStore) + + // Check for screen and navigation component edge cases + const screenComponentId = `${screen._id}-screen` + const navComponentId = `${screen._id}-navigation` + if (componentId === screenComponentId) { + return null + } + if (componentId === navComponentId) { + return screenComponentId + } + if (parent._id === screen.props._id && index === 0) { + return navComponentId + } + + // If we have siblings above us, choose the sibling or a descendant + if (index > 0) { + // If sibling before us accepts children, select a descendant + const previousSibling = parent._children[index - 1] + if ( + previousSibling._children?.length && + componentTreeNodes[`nodeOpen-${previousSibling._id}`] + ) { + let target = previousSibling + while ( + target._children?.length && + componentTreeNodes[`nodeOpen-${target._id}`] + ) { + target = target._children[target._children.length - 1] + } + return target._id + } + + // Otherwise just select sibling + return previousSibling._id + } + + // If no siblings above us, select the parent + return parent._id + }, + getNext: () => { + const state = get(store) + const component = get(selectedComponent) + const componentId = component?._id + const screen = get(selectedScreen) + const parent = findComponentParent(screen.props, componentId) + const index = parent?._children.findIndex(x => x._id === componentId) + const componentTreeNodes = get(componentTreeNodesStore) + + // Check for screen and navigation component edge cases + const screenComponentId = `${screen._id}-screen` + const navComponentId = `${screen._id}-navigation` + if (state.selectedComponentId === screenComponentId) { + return navComponentId + } + + // If we have children, select first child + if ( + component._children?.length && + (state.selectedComponentId === navComponentId || + componentTreeNodes[`nodeOpen-${component._id}`]) + ) { + return component._children[0]._id + } else if (!parent) { + return null + } + + // Otherwise select the next sibling if we have one + if (index < parent._children.length - 1) { + const nextSibling = parent._children[index + 1] + return nextSibling._id + } + + // Last child, select our parents next sibling + let target = parent + let targetParent = findComponentParent(screen.props, target._id) + let targetIndex = targetParent?._children.findIndex( + child => child._id === target._id + ) + while ( + targetParent != null && + targetIndex === targetParent._children?.length - 1 + ) { + target = targetParent + targetParent = findComponentParent(screen.props, target._id) + targetIndex = targetParent?._children.findIndex( + child => child._id === target._id + ) + } + if (targetParent) { + return targetParent._children[targetIndex + 1]._id + } else { + return null + } + }, + selectPrevious: () => { + const previousId = store.actions.components.getPrevious() + if (previousId) { + store.update(state => { + state.selectedComponentId = previousId + return state + }) + } + }, + selectNext: () => { + const nextId = store.actions.components.getNext() + if (nextId) { + store.update(state => { + state.selectedComponentId = nextId + return state + }) + } + }, + moveUp: async component => { + await store.actions.screens.patch(screen => { + const componentId = component?._id + const parent = findComponentParent(screen.props, componentId) + + // Check we aren't right at the top of the tree + const index = parent?._children.findIndex(x => x._id === componentId) + if (!parent || (index === 0 && parent._id === screen.props._id)) { + return + } + + // Copy original component and remove it from the parent + const originalComponent = cloneDeep(parent._children[index]) + parent._children = parent._children.filter( + component => component._id !== componentId + ) + + // If we have siblings above us, move up + if (index > 0) { + // If sibling before us accepts children, move to last child of + // sibling + const previousSibling = parent._children[index - 1] + const definition = store.actions.components.getDefinition( + previousSibling._component + ) + if (definition.hasChildren) { + previousSibling._children.push(originalComponent) + } + + // Otherwise just move component above sibling + else { + parent._children.splice(index - 1, 0, originalComponent) + } + } + + // If no siblings above us, go above the parent as long as it isn't + // the screen + else if (parent._id !== screen.props._id) { + const grandParent = findComponentParent(screen.props, parent._id) + const parentIndex = grandParent._children.findIndex( + child => child._id === parent._id + ) + grandParent._children.splice(parentIndex, 0, originalComponent) + } + }) + }, + moveDown: async component => { + await store.actions.screens.patch(screen => { + const componentId = component?._id + const parent = findComponentParent(screen.props, componentId) + + // Sanity check parent is found + if (!parent?._children?.length) { + return false + } + + // Check we aren't right at the bottom of the tree + const index = parent._children.findIndex(x => x._id === componentId) + if ( + index === parent._children.length - 1 && + parent._id === screen.props._id + ) { + return + } + + // Copy the original component and remove from parent + const originalComponent = cloneDeep(parent._children[index]) + parent._children = parent._children.filter( + component => component._id !== componentId + ) + + // Move below the next sibling if we are not the last sibling + if (index < parent._children.length) { + // If the next sibling has children, become the first child + const nextSibling = parent._children[index] + const definition = store.actions.components.getDefinition( + nextSibling._component + ) + if (definition.hasChildren) { + nextSibling._children.splice(0, 0, originalComponent) + } + + // Otherwise move below next sibling + else { + parent._children.splice(index + 1, 0, originalComponent) + } + } + + // Last child, so move below our parent + else { + const grandParent = findComponentParent(screen.props, parent._id) + const parentIndex = grandParent._children.findIndex( + child => child._id === parent._id + ) + grandParent._children.splice(parentIndex + 1, 0, originalComponent) + } + }) + }, + updateStyle: async (name, value) => { + await store.actions.components.patch(component => { + if (value == null || value === "") { + delete component._styles.normal[name] + } else { + component._styles.normal[name] = value + } + }) + }, + updateStyles: async (styles, id) => { + const patchFn = component => { + component._styles.normal = { + ...component._styles.normal, + ...styles, + } + } + await store.actions.components.patch(patchFn, id) + }, + updateCustomStyle: async style => { + await store.actions.components.patch(component => { + component._styles.custom = style + }) + }, + updateConditions: async conditions => { + await store.actions.components.patch(component => { + component._conditions = conditions + }) + }, + updateSetting: async (name, value) => { + await store.actions.components.patch( + store.actions.components.updateComponentSetting(name, value) + ) + }, + updateComponentSetting: (name, value) => { + return component => { + if (!name || !component) { + return false + } + // Skip update if the value is the same + if (component[name] === value) { + return false + } + + const settings = getComponentSettings(component._component) + const updatedSetting = settings.find(setting => setting.key === name) + + // Reset dependent fields + settings.forEach(setting => { + const needsReset = + name === setting.resetOn || + (Array.isArray(setting.resetOn) && setting.resetOn.includes(name)) + if (needsReset) { + component[setting.key] = setting.defaultValue || null + } + }) + + if ( + updatedSetting?.type === "dataSource" || + updatedSetting?.type === "table" + ) { + const { schema } = getSchemaForDatasource(null, value) + const columnNames = Object.keys(schema || {}) + const multifieldKeysToSelectAll = settings + .filter(setting => { + return setting.type === "multifield" && setting.selectAllFields + }) + .map(setting => setting.key) + + multifieldKeysToSelectAll.forEach(key => { + component[key] = columnNames + }) + } + component[name] = value + return true + } + }, + requestEjectBlock: componentId => { + store.actions.preview.sendEvent("eject-block", componentId) + }, + handleEjectBlock: async (componentId, ejectedDefinition) => { + let nextSelectedComponentId + await store.actions.screens.patch(screen => { + const block = findComponent(screen.props, componentId) + const parent = findComponentParent(screen.props, componentId) + + // Sanity check + if (!block || !parent?._children?.length) { + return false + } + + // Log event + analytics.captureEvent(Events.BLOCK_EJECTED, { + block: block._component, + }) + + // Attach block children back into ejected definition, using the + // _containsSlot flag to know where to insert them + const slotContainer = findAllMatchingComponents( + ejectedDefinition, + x => x._containsSlot + )[0] + if (slotContainer) { + delete slotContainer._containsSlot + slotContainer._children = [ + ...(slotContainer._children || []), + ...(block._children || []), + ] + } + + // Replace block with ejected definition + ejectedDefinition = makeComponentUnique(ejectedDefinition) + const index = parent._children.findIndex(x => x._id === componentId) + parent._children[index] = ejectedDefinition + nextSelectedComponentId = ejectedDefinition._id + }) + + // Select new root component + if (nextSelectedComponentId) { + store.update(state => { + state.selectedComponentId = nextSelectedComponentId + return state + }) + } + }, + addParent: async (componentId, parentType) => { + if (!componentId || !parentType) { + return + } + + // Create new parent instance + const newParentDefinition = store.actions.components.createInstance( + parentType, + null, + parent + ) + if (!newParentDefinition) { + return + } + + // Replace component with a version wrapped in a new parent + await store.actions.screens.patch(screen => { + // Get this component definition and parent definition + let definition = findComponent(screen.props, componentId) + let oldParentDefinition = findComponentParent( + screen.props, + componentId + ) + if (!definition || !oldParentDefinition) { + return false + } + + // Replace component with parent + const index = oldParentDefinition._children.findIndex( + component => component._id === componentId + ) + if (index === -1) { + return false + } + oldParentDefinition._children[index] = { + ...newParentDefinition, + _children: [definition], + } + }) + + // Select the new parent + store.update(state => { + state.selectedComponentId = newParentDefinition._id + return state + }) + }, + }, + links: { + save: async (url, title) => { + const navigation = get(store).navigation + let links = [...(navigation?.links ?? [])] + + // Skip if we have an identical link + if (links.find(link => link.url === url && link.text === title)) { + return + } + + links.push({ + text: title, + url, + }) + await store.actions.navigation.save({ + ...navigation, + links: [...links], + }) + }, + delete: async urls => { + const navigation = get(store).navigation + let links = navigation?.links + if (!links?.length) { + return + } + + // Filter out the URLs to delete + urls = Array.isArray(urls) ? urls : [urls] + links = links.filter(link => !urls.includes(link.url)) + + await store.actions.navigation.save({ + ...navigation, + links, + }) + }, + }, + settings: { + highlight: key => { + store.update(state => ({ + ...state, + highlightedSettingKey: key, + })) + }, + propertyFocus: key => { + store.update(state => ({ + ...state, + propertyFocus: key, + })) + }, + }, + dnd: { + start: component => { + store.actions.preview.sendEvent("dragging-new-component", { + dragging: true, + component, + }) + }, + stop: () => { + store.actions.preview.sendEvent("dragging-new-component", { + dragging: false, + }) + }, + }, + websocket: { + selectResource: id => { + websocket.emit(BuilderSocketEvent.SelectResource, { + resourceId: id, + }) + }, + }, + metadata: { + replace: metadata => { + store.update(state => ({ + ...state, + ...metadata, + })) + }, + }, + } + + return store +} diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte index a8711d220b..72cedb2b21 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte @@ -128,10 +128,10 @@ >