diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 848dd4405a..fc8b1b8427 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -182,7 +182,70 @@ export const getFrontendStore = () => { 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 + } + + 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 => { + store.actions.screens.validate(screen) const state = get(store) const creatingNewScreen = screen._id === undefined const savedScreen = await API.saveScreen(screen) @@ -445,7 +508,11 @@ export const getFrontendStore = () => { return { _id: Helpers.uuid(), _component: definition.component, - _styles: { normal: {}, hover: {}, active: {} }, + _styles: { + normal: {}, + hover: {}, + active: {}, + }, _instanceName: `New ${definition.friendlyName || definition.name}`, ...cloneDeep(props), ...extras, @@ -533,12 +600,11 @@ export const getFrontendStore = () => { }, patch: async (patchFn, componentId, screenId) => { // Use selected component by default - if (!componentId && !screenId) { + if (!componentId || !screenId) { const state = get(store) - componentId = state.selectedComponentId - screenId = state.selectedScreenId + componentId = componentId || state.selectedComponentId + screenId = screenId || state.selectedScreenId } - // Invalid if only a screen or component ID provided if (!componentId || !screenId || !patchFn) { return } @@ -601,16 +667,14 @@ export const getFrontendStore = () => { }) // Select the parent if cutting - if (cut) { + if (cut && selectParent) { const screen = get(selectedScreen) const parent = findComponentParent(screen?.props, component._id) if (parent) { - if (selectParent) { - store.update(state => { - state.selectedComponentId = parent._id - return state - }) - } + store.update(state => { + state.selectedComponentId = parent._id + return state + }) } } }, @@ -621,16 +685,24 @@ export const getFrontendStore = () => { } 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 + return false } - const cut = state.componentToPaste.isCut - const originalId = state.componentToPaste._id - let componentToPaste = cloneDeep(state.componentToPaste) + const cut = componentToPaste.isCut + const originalId = componentToPaste._id delete componentToPaste.isCut // Make new component unique if copying @@ -685,11 +757,8 @@ export const getFrontendStore = () => { const targetScreenId = targetScreen?._id || state.selectedScreenId await store.actions.screens.patch(patch, targetScreenId) + // Select the new component store.update(state => { - // Remove copied component if cutting - if (state.componentToPaste.isCut) { - delete state.componentToPaste - } state.selectedScreenId = targetScreenId state.selectedComponentId = newComponentId return state @@ -893,6 +962,15 @@ export const getFrontendStore = () => { } }) }, + 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 diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte index a4c4c5b839..0399b6375b 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte @@ -86,7 +86,11 @@ : [], isBudibaseEvent: true, usedPlugins: $store.usedPlugins, - location: window.location, + location: { + protocol: window.location.protocol, + hostname: window.location.hostname, + port: window.location.port, + }, } // Refresh the preview when required @@ -99,7 +103,7 @@ ) // Register handler to send custom to the preview - $: store.actions.preview.registerEventHandler((name, payload) => { + $: sendPreviewEvent = (name, payload) => { iframe?.contentWindow.postMessage( JSON.stringify({ name, @@ -108,120 +112,116 @@ runtimeEvent: true, }) ) - }) + } + $: store.actions.preview.registerEventHandler(sendPreviewEvent) // Update the iframe with the builder info to render the correct preview const refreshContent = message => { iframe?.contentWindow.postMessage(message) } - const receiveMessage = message => { - const handlers = { - [MessageTypes.READY]: () => { - // Initialise the app when mounted - if ($store.clientFeatures.messagePassing) { - if (!loading) return - } - - // Display preview immediately if the intelligent loading feature - // is not supported - if (!$store.clientFeatures.intelligentLoading) { - loading = false - } - refreshContent(json) - }, - [MessageTypes.ERROR]: event => { - // Catch any app errors - loading = false - error = event.error || "An unknown error occurred" - }, - } - - const messageHandler = handlers[message.data.type] || handleBudibaseEvent - messageHandler(message) - } - - const handleBudibaseEvent = async event => { - const { type, data } = event.data || event.detail - if (!type) { + const receiveMessage = async message => { + if (!message?.data?.type) { return } + // Await the event handler try { - if (type === "select-component" && data.id) { - $store.selectedComponentId = data.id - if (!$isActive("./components")) { - $goto("./components") - } - } else if (type === "update-prop") { - await store.actions.components.updateSetting(data.prop, data.value) - } else if (type === "delete-component" && data.id) { - // Legacy type, can be deleted in future - confirmDeleteComponent(data.id) - } else if (type === "key-down") { - const { key, ctrlKey } = data - document.dispatchEvent(new KeyboardEvent("keydown", { key, ctrlKey })) - } else if (type === "duplicate-component" && data.id) { - const rootComponent = get(currentAsset).props - const component = findComponent(rootComponent, data.id) - store.actions.components.copy(component) - await store.actions.components.paste(component) - } else if (type === "preview-loaded") { - // Wait for this event to show the client library if intelligent - // loading is supported - loading = false - } else if (type === "move-component") { - const { componentId, destinationComponentId } = data - const rootComponent = get(currentAsset).props - - // Get source and destination components - const source = findComponent(rootComponent, componentId) - const destination = findComponent(rootComponent, destinationComponentId) - - // Stop if the target is a child of source - const path = findComponentPath(source, destinationComponentId) - const ids = path.map(component => component._id) - if (ids.includes(data.destinationComponentId)) { - return - } - - // Cut and paste the component to the new destination - if (source && destination) { - store.actions.components.copy(source, true) - await store.actions.components.paste(destination, data.mode) - } - } else if (type === "click-nav") { - if (!$isActive("./navigation")) { - $goto("./navigation") - } - } else if (type === "request-add-component") { - toggleAddComponent() - } else if (type === "highlight-setting") { - store.actions.settings.highlight(data.setting) - - // Also scroll setting into view - const selector = `[data-cy="${data.setting}-prop-control"` - const element = document.querySelector(selector)?.parentElement - if (element) { - element.scrollIntoView({ - behavior: "smooth", - block: "center", - }) - } - } else if (type === "eject-block") { - const { id, definition } = data - await store.actions.components.handleEjectBlock(id, definition) - } else if (type === "reload-plugin") { - await store.actions.components.refreshDefinitions() - } else if (type === "drop-new-component") { - const { component, parent, index } = data - await store.actions.components.create(component, null, parent, index) - } else { - console.warn(`Client sent unknown event type: ${type}`) - } + await handleBudibaseEvent(message) } catch (error) { - console.warn(error) - notifications.error("Error handling event from app preview") + notifications.error(error || "Error handling event from app preview") + } + + // Reply that the event has been completed + if (message.data?.id) { + sendPreviewEvent("event-completed", message.data?.id) + } + } + + const handleBudibaseEvent = async event => { + const { type, data } = event.data + if (type === MessageTypes.READY) { + // Initialise the app when mounted + if (!loading) { + return + } + refreshContent(json) + } else if (type === MessageTypes.ERROR) { + // Catch any app errors + loading = false + error = event.error || "An unknown error occurred" + } else if (type === "select-component" && data.id) { + $store.selectedComponentId = data.id + if (!$isActive("./components")) { + $goto("./components") + } + } else if (type === "update-prop") { + await store.actions.components.updateSetting(data.prop, data.value) + } else if (type === "update-styles") { + await store.actions.components.updateStyles(data.styles, data.id) + } else if (type === "delete-component" && data.id) { + // Legacy type, can be deleted in future + confirmDeleteComponent(data.id) + } else if (type === "key-down") { + const { key, ctrlKey } = data + document.dispatchEvent(new KeyboardEvent("keydown", { key, ctrlKey })) + } else if (type === "duplicate-component" && data.id) { + const rootComponent = get(currentAsset).props + const component = findComponent(rootComponent, data.id) + store.actions.components.copy(component) + await store.actions.components.paste(component) + } else if (type === "preview-loaded") { + // Wait for this event to show the client library if intelligent + // loading is supported + loading = false + } else if (type === "move-component") { + const { componentId, destinationComponentId } = data + const rootComponent = get(currentAsset).props + + // Get source and destination components + const source = findComponent(rootComponent, componentId) + const destination = findComponent(rootComponent, destinationComponentId) + + // Stop if the target is a child of source + const path = findComponentPath(source, destinationComponentId) + const ids = path.map(component => component._id) + if (ids.includes(data.destinationComponentId)) { + return + } + + // Cut and paste the component to the new destination + if (source && destination) { + store.actions.components.copy(source, true, false) + await store.actions.components.paste(destination, data.mode) + } + } else if (type === "click-nav") { + if (!$isActive("./navigation")) { + $goto("./navigation") + } + } else if (type === "request-add-component") { + toggleAddComponent() + } else if (type === "highlight-setting") { + store.actions.settings.highlight(data.setting) + + // Also scroll setting into view + const selector = `[data-cy="${data.setting}-prop-control"` + const element = document.querySelector(selector)?.parentElement + if (element) { + element.scrollIntoView({ + behavior: "smooth", + block: "center", + }) + } + } else if (type === "eject-block") { + const { id, definition } = data + await store.actions.components.handleEjectBlock(id, definition) + } else if (type === "reload-plugin") { + await store.actions.components.refreshDefinitions() + } else if (type === "drop-new-component") { + const { component, parent, index } = data + await store.actions.components.create(component, null, parent, index) + } else { + console.warn(`Client sent unknown event type: ${type}`) } } @@ -254,42 +254,10 @@ onMount(() => { window.addEventListener("message", receiveMessage) - if (!$store.clientFeatures.messagePassing) { - // Legacy - remove in later versions of BB - iframe.contentWindow.addEventListener( - "ready", - () => { - receiveMessage({ data: { type: MessageTypes.READY } }) - }, - { once: true } - ) - iframe.contentWindow.addEventListener( - "error", - event => { - receiveMessage({ - data: { type: MessageTypes.ERROR, error: event.detail }, - }) - }, - { once: true } - ) - // Add listener for events sent by client library in preview - iframe.contentWindow.addEventListener("bb-event", handleBudibaseEvent) - } }) - // Remove all iframe event listeners on component destroy onDestroy(() => { window.removeEventListener("message", receiveMessage) - - if (iframe.contentWindow) { - if (!$store.clientFeatures.messagePassing) { - // Legacy - remove in later versions of BB - iframe.contentWindow.removeEventListener( - "bb-event", - handleBudibaseEvent - ) - } - } }) diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_components/navigation/ComponentKeyHandler.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_components/navigation/ComponentKeyHandler.svelte index 7a71c9253a..f83e5d6194 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_components/navigation/ComponentKeyHandler.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_components/navigation/ComponentKeyHandler.svelte @@ -80,10 +80,9 @@ event.preventDefault() event.stopPropagation() } - return handler(component) + return await handler(component) } catch (error) { - console.error(error) - notifications.error("Error handling key press") + notifications.error(error || "Error handling key press") } } diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_components/navigation/ComponentTree.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_components/navigation/ComponentTree.svelte index 5cb6d31345..00234edc79 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_components/navigation/ComponentTree.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_components/navigation/ComponentTree.svelte @@ -70,34 +70,12 @@ closedNodes = closedNodes } - const onDrop = async (e, component) => { + const onDrop = async e => { e.stopPropagation() try { - const compDef = store.actions.components.getDefinition( - $dndStore.source?._component - ) - if (!compDef) { - return - } - const compTypeName = compDef.name.toLowerCase() - const path = findComponentPath(currentScreen.props, component._id) - - for (let pathComp of path) { - const pathCompDef = store.actions.components.getDefinition( - pathComp?._component - ) - if (pathCompDef?.illegalChildren?.indexOf(compTypeName) > -1) { - notifications.warning( - `${compDef.name} cannot be a child of ${pathCompDef.name} (${pathComp._instanceName})` - ) - return - } - } - await dndStore.actions.drop() } catch (error) { - console.error(error) - notifications.error("Error saving component") + notifications.error(error || "Error saving component") } } @@ -137,9 +115,7 @@ on:dragstart={() => dndStore.actions.dragstart(component)} on:dragover={dragover(component, index)} on:iconClick={() => toggleNodeOpen(component._id)} - on:drop={e => { - onDrop(e, component) - }} + on:drop={onDrop} text={getComponentText(component)} icon={getComponentIcon(component)} withArrow={componentHasChildren(component)} diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/NewComponentPanel.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/NewComponentPanel.svelte index 778a14ffff..0610be6d0a 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/NewComponentPanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/NewComponentPanel.svelte @@ -11,9 +11,10 @@ notifications, } from "@budibase/bbui" import structure from "./componentStructure.json" - import { store, selectedComponent } from "builderStore" + import { store, selectedComponent, selectedScreen } from "builderStore" import { onMount } from "svelte" import { fly } from "svelte/transition" + import { findComponentPath } from "builderStore/componentUtils" let section = "components" let searchString @@ -21,8 +22,10 @@ let selectedIndex let componentList = [] - $: currentDefinition = store.actions.components.getDefinition( - $selectedComponent?._component + $: allowedComponents = getAllowedComponents( + $store.components, + $selectedScreen, + $selectedComponent ) $: enrichedStructure = enrichStructure( structure, @@ -31,13 +34,50 @@ ) $: filteredStructure = filterStructure( enrichedStructure, - section, - currentDefinition, + allowedComponents, searchString ) $: blocks = enrichedStructure.find(x => x.name === "Blocks").children $: orderMap = createComponentOrderMap(componentList) + const getAllowedComponents = (allComponents, screen, component) => { + const path = findComponentPath(screen?.props, component?._id) + if (!path?.length) { + return [] + } + + // Get initial set of allowed components + let allowedComponents = [] + const definition = store.actions.components.getDefinition( + component?._component + ) + if (definition.legalDirectChildren?.length) { + allowedComponents = definition.legalDirectChildren.map(x => { + return `@budibase/standard-components/${x}` + }) + } else { + allowedComponents = Object.keys(allComponents) + } + + // Build up list of illegal children from ancestors + let illegalChildren = definition.illegalChildren || [] + path.forEach(ancestor => { + const def = store.actions.components.getDefinition(ancestor._component) + const blacklist = def?.illegalChildren?.map(x => { + return `@budibase/standard-components/${x}` + }) + illegalChildren = [...illegalChildren, ...(blacklist || [])] + }) + illegalChildren = [...new Set(illegalChildren)] + + // Filter out illegal children from allowed components + allowedComponents = allowedComponents.filter(x => { + return !illegalChildren.includes(x) + }) + + return allowedComponents + } + // Creates a simple lookup map from an array, so we can find the selected // component much faster const createComponentOrderMap = list => { @@ -90,7 +130,7 @@ return enrichedStructure } - const filterStructure = (structure, section, currentDefinition, search) => { + const filterStructure = (structure, allowedComponents, search) => { selectedIndex = search ? 0 : null componentList = [] if (!structure?.length) { @@ -114,7 +154,7 @@ } // Check if the component is allowed as a child - return !currentDefinition?.illegalChildren?.includes(name) + return allowedComponents.includes(child.component) }) if (matchedChildren.length) { filteredStructure.push({ @@ -138,7 +178,7 @@ await store.actions.components.create(component) $goto("../") } catch (error) { - notifications.error("Error creating component") + notifications.error(error || "Error creating component") } } diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/componentStructure.json b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/componentStructure.json index 3100c7467c..381ceeac20 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/componentStructure.json +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/componentStructure.json @@ -15,7 +15,8 @@ "icon": "ClassicGridView", "children": [ "container", - "section" + "section", + "grid" ] }, { diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 93a53c15a3..c9a8cc399d 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -87,7 +87,7 @@ "showSettingsBar": true, "size": { "width": 400, - "height": 100 + "height": 200 }, "styles": [ "padding", @@ -5037,6 +5037,45 @@ } ] }, + "grid": { + "name": "Grid (Beta)", + "icon": "ViewGrid", + "hasChildren": true, + "styles": [ + "size" + ], + "illegalChildren": ["section", "grid"], + "legalDirectChildren": [ + "container", + "tableblock", + "cardsblock", + "repeaterblock", + "formblock" + ], + "size": { + "width": 800, + "height": 400 + }, + "showEmptyState": false, + "settings": [ + { + "type": "number", + "label": "Rows", + "key": "rows", + "defaultValue": 12, + "min": 1, + "max": 32 + }, + { + "type": "number", + "label": "Columns", + "key": "cols", + "defaultValue": 12, + "min": 1, + "max": 32 + } + ] + }, "formblock": { "name": "Form Block", "icon": "Form", diff --git a/packages/client/src/components/ClientApp.svelte b/packages/client/src/components/ClientApp.svelte index 537e963ff3..2fb0a92dab 100644 --- a/packages/client/src/components/ClientApp.svelte +++ b/packages/client/src/components/ClientApp.svelte @@ -30,6 +30,7 @@ import HoverIndicator from "components/preview/HoverIndicator.svelte" import CustomThemeWrapper from "./CustomThemeWrapper.svelte" import DNDHandler from "components/preview/DNDHandler.svelte" + import GridDNDHandler from "components/preview/GridDNDHandler.svelte" import KeyboardManager from "components/preview/KeyboardManager.svelte" import DevToolsHeader from "components/devtools/DevToolsHeader.svelte" import DevTools from "components/devtools/DevTools.svelte" @@ -196,6 +197,7 @@ {/if} {#if $builderStore.inBuilder} + {/if} diff --git a/packages/client/src/components/Component.svelte b/packages/client/src/components/Component.svelte index 1885d22836..18b6488189 100644 --- a/packages/client/src/components/Component.svelte +++ b/packages/client/src/components/Component.svelte @@ -21,8 +21,8 @@ devToolsStore, componentStore, appStore, - dndIsDragging, dndComponentPath, + dndIsDragging, } from "stores" import { Helpers } from "@budibase/bbui" import { getActiveConditions, reduceConditionActions } from "utils/conditions" @@ -90,6 +90,10 @@ let settingsDefinitionMap let missingRequiredSettings = false + // Temporary styles which can be added in the app preview for things like DND. + // We clear these whenever a new instance is received. + let ephemeralStyles + // Set up initial state for each new component instance $: initialise(instance) @@ -171,6 +175,10 @@ children: children.length, styles: { ...instance._styles, + normal: { + ...instance._styles?.normal, + ...ephemeralStyles, + }, custom: customCSS, id, empty: emptyState, @@ -449,6 +457,7 @@ getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }), getDataContext: () => get(context), reload: () => initialise(instance, true), + setEphemeralStyles: styles => (ephemeralStyles = styles), }) } }) diff --git a/packages/client/src/components/app/Grid.svelte b/packages/client/src/components/app/Grid.svelte new file mode 100644 index 0000000000..01861718f0 --- /dev/null +++ b/packages/client/src/components/app/Grid.svelte @@ -0,0 +1,102 @@ + + +
+ {#if $builderStore.inBuilder} +
+ {#each coords as coord} +
+ {/each} +
+ {/if} + +
+ + diff --git a/packages/client/src/components/app/index.js b/packages/client/src/components/app/index.js index 1c0d868433..b64e074115 100644 --- a/packages/client/src/components/app/index.js +++ b/packages/client/src/components/app/index.js @@ -34,6 +34,7 @@ export { default as spectrumcard } from "./SpectrumCard.svelte" export { default as tag } from "./Tag.svelte" export { default as markdownviewer } from "./MarkdownViewer.svelte" export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte" +export { default as grid } from "./Grid.svelte" export * from "./charts" export * from "./forms" export * from "./table" diff --git a/packages/client/src/components/preview/DNDHandler.svelte b/packages/client/src/components/preview/DNDHandler.svelte index a9c52f0099..1855166e0c 100644 --- a/packages/client/src/components/preview/DNDHandler.svelte +++ b/packages/client/src/components/preview/DNDHandler.svelte @@ -22,6 +22,18 @@ $: target = $dndStore.target $: drop = $dndStore.drop + // Local flag for whether we are awaiting an async drop event + let dropping = false + + // Util to check if a DND event originates from a grid (or inside a grid). + // This is important as we do not handle grid DND in this handler. + const isGridEvent = e => { + return e.target + ?.closest?.(".component") + ?.parentNode?.closest?.(".component") + ?.childNodes[0]?.classList.contains("grid") + } + // Util to get the inner DOM node by a component ID const getDOMNode = id => { const component = document.getElementsByClassName(id)[0] @@ -41,6 +53,10 @@ // Callback when drag stops (whether dropped or not) const stopDragging = () => { + if (dropping) { + return + } + // Reset listener if (source?.id) { const component = document.getElementsByClassName(source?.id)[0] @@ -55,6 +71,9 @@ // Callback when initially starting a drag on a draggable component const onDragStart = e => { + if (isGridEvent(e)) { + return + } const component = e.target.closest(".component") if (!component?.classList.contains("draggable")) { return @@ -99,9 +118,9 @@ // Core logic for handling drop events and determining where to render the // drop target placeholder - const processEvent = (mouseX, mouseY) => { + const processEvent = Utils.throttle((mouseX, mouseY) => { if (!target) { - return null + return } let { id, parent, node, acceptsChildren, empty } = target @@ -201,15 +220,15 @@ parent: id, index: idx, }) - } - const throttledProcessEvent = Utils.throttle(processEvent, ThrottleRate) + }, ThrottleRate) const handleEvent = e => { e.preventDefault() - throttledProcessEvent(e.clientX, e.clientY) + e.stopPropagation() + processEvent(e.clientX, e.clientY) } - // Callback when on top of a component + // Callback when on top of a component. const onDragOver = e => { if (!source || !target) { return @@ -241,18 +260,21 @@ } // Callback when dropping a drag on top of some component - const onDrop = () => { + const onDrop = async () => { if (!source || !drop?.parent || drop?.index == null) { return } // Check if we're adding a new component rather than moving one if (source.newComponentType) { - builderStore.actions.dropNewComponent( + dropping = true + await builderStore.actions.dropNewComponent( source.newComponentType, drop.parent, drop.index ) + dropping = false + stopDragging() return } @@ -289,11 +311,14 @@ } if (legacyDropTarget && legacyDropMode) { - builderStore.actions.moveComponent( + dropping = true + await builderStore.actions.moveComponent( source.id, legacyDropTarget, legacyDropMode ) + dropping = false + stopDragging() } } diff --git a/packages/client/src/components/preview/DNDPlaceholder.svelte b/packages/client/src/components/preview/DNDPlaceholder.svelte deleted file mode 100644 index 3725f9e06e..0000000000 --- a/packages/client/src/components/preview/DNDPlaceholder.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - -{#if style} -
-
-
-{/if} - - diff --git a/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte b/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte index 6ed2df6a87..0be7faff1b 100644 --- a/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte +++ b/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte @@ -6,7 +6,8 @@ let left, top, height, width const updatePosition = () => { - const node = document.getElementById(DNDPlaceholderID) + const node = + document.getElementsByClassName(DNDPlaceholderID)[0]?.childNodes[0] if (!node) { height = 0 width = 0 diff --git a/packages/client/src/components/preview/GridDNDHandler.svelte b/packages/client/src/components/preview/GridDNDHandler.svelte new file mode 100644 index 0000000000..46e2e575b0 --- /dev/null +++ b/packages/client/src/components/preview/GridDNDHandler.svelte @@ -0,0 +1,227 @@ + diff --git a/packages/client/src/components/preview/HoverIndicator.svelte b/packages/client/src/components/preview/HoverIndicator.svelte index d5583ed3db..f976e2cffb 100644 --- a/packages/client/src/components/preview/HoverIndicator.svelte +++ b/packages/client/src/components/preview/HoverIndicator.svelte @@ -4,11 +4,25 @@ import { builderStore, dndIsDragging } from "stores" let componentId + $: zIndex = componentId === $builderStore.selectedComponentId ? 900 : 920 const onMouseOver = e => { - const element = e.target.closest(".interactive.component") - const newId = element?.dataset?.id + // Ignore if dragging + if (e.buttons > 0) { + return + } + + let newId + if (e.target.classList.contains("anchor")) { + // Handle resize anchors + newId = e.target.dataset.id + } else { + // Handle normal components + const element = e.target.closest(".interactive.component") + newId = element?.dataset?.id + } + if (newId !== componentId) { componentId = newId } @@ -34,4 +48,5 @@ color="var(--spectrum-global-color-static-blue-200)" transition {zIndex} + allowResizeAnchors /> diff --git a/packages/client/src/components/preview/Indicator.svelte b/packages/client/src/components/preview/Indicator.svelte index 0b5b95e00b..04eaa49d6d 100644 --- a/packages/client/src/components/preview/Indicator.svelte +++ b/packages/client/src/components/preview/Indicator.svelte @@ -10,9 +10,22 @@ export let icon export let color export let zIndex + export let componentId export let transition = false export let line = false export let alignRight = false + export let showResizeAnchors = false + + const AnchorSides = [ + "right", + "left", + "top", + "bottom", + "bottom-right", + "bottom-left", + "top-right", + "top-left", + ] $: flipped = top < 24 @@ -40,6 +53,18 @@ {/if}
{/if} + {#if showResizeAnchors} + {#each AnchorSides as side} +
+
+
+ {/each} + {/if}
diff --git a/packages/client/src/components/preview/IndicatorSet.svelte b/packages/client/src/components/preview/IndicatorSet.svelte index 662741d100..51a60ce981 100644 --- a/packages/client/src/components/preview/IndicatorSet.svelte +++ b/packages/client/src/components/preview/IndicatorSet.svelte @@ -9,11 +9,13 @@ export let transition export let zIndex export let prefix = null + export let allowResizeAnchors = false let indicators = [] let interval let text let icon + let insideGrid = false $: visibleIndicators = indicators.filter(x => x.visible) $: offset = $builderStore.inBuilder ? 0 : 2 @@ -23,6 +25,20 @@ let callbackCount = 0 let nextIndicators = [] + const checkInsideGrid = id => { + const component = document.getElementsByClassName(id)[0] + const domNode = component?.children[0] + + // Ignore grid itself + if (domNode?.classList.contains("grid")) { + return false + } + + return component?.parentNode + ?.closest?.(".component") + ?.childNodes[0]?.classList.contains("grid") + } + const createIntersectionCallback = idx => entries => { if (callbackCount >= observers.length) { return @@ -52,6 +68,11 @@ observers = [] nextIndicators = [] + // Check if we're inside a grid + if (allowResizeAnchors) { + insideGrid = checkInsideGrid(componentId) + } + // Determine next set of indicators const parents = document.getElementsByClassName(componentId) if (parents.length) { @@ -127,6 +148,8 @@ height={indicator.height} text={idx === 0 ? text : null} icon={idx === 0 ? icon : null} + showResizeAnchors={allowResizeAnchors && insideGrid} + {componentId} {transition} {zIndex} {color} diff --git a/packages/client/src/components/preview/SelectionIndicator.svelte b/packages/client/src/components/preview/SelectionIndicator.svelte index 49341a7976..bca0341628 100644 --- a/packages/client/src/components/preview/SelectionIndicator.svelte +++ b/packages/client/src/components/preview/SelectionIndicator.svelte @@ -1,5 +1,5 @@ diff --git a/packages/client/src/components/preview/SettingsBar.svelte b/packages/client/src/components/preview/SettingsBar.svelte index 757d8b88db..332409986a 100644 --- a/packages/client/src/components/preview/SettingsBar.svelte +++ b/packages/client/src/components/preview/SettingsBar.svelte @@ -17,6 +17,11 @@ $: definition = $componentStore.selectedComponentDefinition $: showBar = definition?.showSettingsBar && !$dndIsDragging + $: { + if (!showBar) { + measured = false + } + } $: settings = getBarSettings(definition) const getBarSettings = definition => { diff --git a/packages/client/src/constants.js b/packages/client/src/constants.js index bd387c7f9d..6c882c9792 100644 --- a/packages/client/src/constants.js +++ b/packages/client/src/constants.js @@ -32,5 +32,4 @@ export const ActionTypes = { } export const DNDPlaceholderID = "dnd-placeholder" -export const DNDPlaceholderType = "dnd-placeholder" export const ScreenslotType = "screenslot" diff --git a/packages/client/src/index.js b/packages/client/src/index.js index 706cb4fc9f..951c55270b 100644 --- a/packages/client/src/index.js +++ b/packages/client/src/index.js @@ -7,6 +7,7 @@ import { componentStore, environmentStore, dndStore, + eventStore, } from "./stores" import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-rollup.js" import { get } from "svelte/store" @@ -46,7 +47,9 @@ const loadBudibase = async () => { appStore.actions.setAppId(window["##BUDIBASE_APP_ID##"]) // Fetch environment info - await environmentStore.actions.fetchEnvironment() + if (!get(environmentStore)?.loaded) { + await environmentStore.actions.fetchEnvironment() + } // Enable dev tools or not. We need to be using a dev app and not inside // the builder preview to enable them. @@ -54,15 +57,17 @@ const loadBudibase = async () => { devToolsStore.actions.setEnabled(enableDevTools) // Register handler for runtime events from the builder - window.handleBuilderRuntimeEvent = (name, payload) => { + window.handleBuilderRuntimeEvent = (type, data) => { if (!window["##BUDIBASE_IN_BUILDER##"]) { return } - if (name === "eject-block") { - const block = blockStore.actions.getBlock(payload) + if (type === "event-completed") { + eventStore.actions.resolveEvent(data) + } else if (type === "eject-block") { + const block = blockStore.actions.getBlock(data) block?.eject() - } else if (name === "dragging-new-component") { - const { dragging, component } = payload + } else if (type === "dragging-new-component") { + const { dragging, component } = data if (dragging) { const definition = componentStore.actions.getComponentDefinition(component) diff --git a/packages/client/src/stores/builder.js b/packages/client/src/stores/builder.js index b937b7f696..1ddcaf82b9 100644 --- a/packages/client/src/stores/builder.js +++ b/packages/client/src/stores/builder.js @@ -1,10 +1,7 @@ import { writable, get } from "svelte/store" import { API } from "api" import { devToolsStore } from "./devTools.js" - -const dispatchEvent = (type, data = {}) => { - window.parent.postMessage({ type, data }) -} +import { eventStore } from "./events.js" const createBuilderStore = () => { const initialState = { @@ -19,6 +16,7 @@ const createBuilderStore = () => { navigation: null, hiddenComponentIds: [], usedPlugins: null, + eventResolvers: {}, // Legacy - allow the builder to specify a layout layout: null, @@ -35,22 +33,25 @@ const createBuilderStore = () => { selectedComponentId: id, })) devToolsStore.actions.setAllowSelection(false) - dispatchEvent("select-component", { id }) + eventStore.actions.dispatchEvent("select-component", { id }) }, updateProp: (prop, value) => { - dispatchEvent("update-prop", { prop, value }) + eventStore.actions.dispatchEvent("update-prop", { prop, value }) + }, + updateStyles: async (styles, id) => { + await eventStore.actions.dispatchEvent("update-styles", { styles, id }) }, keyDown: (key, ctrlKey) => { - dispatchEvent("key-down", { key, ctrlKey }) + eventStore.actions.dispatchEvent("key-down", { key, ctrlKey }) }, duplicateComponent: id => { - dispatchEvent("duplicate-component", { id }) + eventStore.actions.dispatchEvent("duplicate-component", { id }) }, deleteComponent: id => { - dispatchEvent("delete-component", { id }) + eventStore.actions.dispatchEvent("delete-component", { id }) }, notifyLoaded: () => { - dispatchEvent("preview-loaded") + eventStore.actions.dispatchEvent("preview-loaded") }, analyticsPing: async () => { try { @@ -59,15 +60,15 @@ const createBuilderStore = () => { // Do nothing } }, - moveComponent: (componentId, destinationComponentId, mode) => { - dispatchEvent("move-component", { + moveComponent: async (componentId, destinationComponentId, mode) => { + await eventStore.actions.dispatchEvent("move-component", { componentId, destinationComponentId, mode, }) }, dropNewComponent: (component, parent, index) => { - dispatchEvent("drop-new-component", { + eventStore.actions.dispatchEvent("drop-new-component", { component, parent, index, @@ -80,16 +81,16 @@ const createBuilderStore = () => { store.update(state => ({ ...state, editMode: enabled })) }, clickNav: () => { - dispatchEvent("click-nav") + eventStore.actions.dispatchEvent("click-nav") }, requestAddComponent: () => { - dispatchEvent("request-add-component") + eventStore.actions.dispatchEvent("request-add-component") }, highlightSetting: setting => { - dispatchEvent("highlight-setting", { setting }) + eventStore.actions.dispatchEvent("highlight-setting", { setting }) }, ejectBlock: (id, definition) => { - dispatchEvent("eject-block", { id, definition }) + eventStore.actions.dispatchEvent("eject-block", { id, definition }) }, updateUsedPlugin: (name, hash) => { // Check if we used this plugin @@ -106,7 +107,7 @@ const createBuilderStore = () => { } // Notify the builder so we can reload component definitions - dispatchEvent("reload-plugin") + eventStore.actions.dispatchEvent("reload-plugin") }, } return { diff --git a/packages/client/src/stores/components.js b/packages/client/src/stores/components.js index b34dfe375d..d30ffaa2bb 100644 --- a/packages/client/src/stores/components.js +++ b/packages/client/src/stores/components.js @@ -5,9 +5,8 @@ import { devToolsStore } from "./devTools" import { screenStore } from "./screens" import { builderStore } from "./builder" import Router from "../components/Router.svelte" -import DNDPlaceholder from "../components/preview/DNDPlaceholder.svelte" import * as AppComponents from "../components/app/index.js" -import { DNDPlaceholderType, ScreenslotType } from "../constants.js" +import { ScreenslotType } from "../constants.js" const budibasePrefix = "@budibase/standard-components/" @@ -49,6 +48,9 @@ const createComponentStore = () => { ) const registerInstance = (id, instance) => { + if (!id) { + return + } store.update(state => { // If this is a custom component, flag it so we can reload this component // later if required @@ -68,6 +70,9 @@ const createComponentStore = () => { } const unregisterInstance = id => { + if (!id) { + return + } store.update(state => { // Remove from custom component map if required const component = state.mountedComponents[id]?.instance?.component @@ -103,8 +108,6 @@ const createComponentStore = () => { // Screenslot is an edge case if (type === ScreenslotType) { type = `${budibasePrefix}${type}` - } else if (type === DNDPlaceholderType) { - return {} } // Handle built-in components @@ -124,8 +127,6 @@ const createComponentStore = () => { } if (type === ScreenslotType) { return Router - } else if (type === DNDPlaceholderType) { - return DNDPlaceholder } // Handle budibase components @@ -140,6 +141,13 @@ const createComponentStore = () => { return customComponentManifest?.[type]?.Component } + const getComponentInstance = id => { + if (!id) { + return null + } + return get(store).mountedComponents[id] + } + const registerCustomComponent = ({ Component, schema, version }) => { if (!Component || !schema?.schema?.name || !version) { return @@ -171,6 +179,7 @@ const createComponentStore = () => { getComponentById, getComponentDefinition, getComponentConstructor, + getComponentInstance, registerCustomComponent, }, } diff --git a/packages/client/src/stores/dnd.js b/packages/client/src/stores/dnd.js index a1f85af92c..1bdd510eb7 100644 --- a/packages/client/src/stores/dnd.js +++ b/packages/client/src/stores/dnd.js @@ -1,4 +1,5 @@ -import { writable, derived } from "svelte/store" +import { writable } from "svelte/store" +import { computed } from "../utils/computed.js" const createDndStore = () => { const initialState = { @@ -77,14 +78,11 @@ export const dndStore = createDndStore() // performance by deriving any state that needs to be externally observed. // By doing this and using primitives, we can avoid invalidating other stores // or components which depend on DND state unless values actually change. -export const dndIsDragging = derived(dndStore, $dndStore => !!$dndStore.source) -export const dndParent = derived(dndStore, $dndStore => $dndStore.drop?.parent) -export const dndIndex = derived(dndStore, $dndStore => $dndStore.drop?.index) -export const dndBounds = derived( +export const dndParent = computed(dndStore, x => x.drop?.parent) +export const dndIndex = computed(dndStore, x => x.drop?.index) +export const dndBounds = computed(dndStore, x => x.source?.bounds) +export const dndIsDragging = computed(dndStore, x => !!x.source) +export const dndIsNewComponent = computed( dndStore, - $dndStore => $dndStore.source?.bounds -) -export const dndIsNewComponent = derived( - dndStore, - $dndStore => $dndStore.source?.newComponentType != null + x => x.source?.newComponentType != null ) diff --git a/packages/client/src/stores/environment.js b/packages/client/src/stores/environment.js index ebeb67c622..bcc1493cd9 100644 --- a/packages/client/src/stores/environment.js +++ b/packages/client/src/stores/environment.js @@ -2,6 +2,7 @@ import { API } from "api" import { writable } from "svelte/store" const initialState = { + loaded: false, cloud: false, } @@ -15,6 +16,7 @@ const createEnvironmentStore = () => { store.set({ ...initialState, ...environment, + loaded: true, }) } catch (error) { store.set(initialState) diff --git a/packages/client/src/stores/events.js b/packages/client/src/stores/events.js new file mode 100644 index 0000000000..6a67554247 --- /dev/null +++ b/packages/client/src/stores/events.js @@ -0,0 +1,31 @@ +import { writable, get } from "svelte/store" + +const createEventStore = () => { + const initialState = { + eventResolvers: {}, + } + const store = writable(initialState) + + const actions = { + dispatchEvent: (type, data) => { + const id = Math.random() + return new Promise(resolve => { + window.parent.postMessage({ type, data, id }) + store.update(state => { + state.eventResolvers[id] = resolve + return state + }) + }) + }, + resolveEvent: data => { + get(store).eventResolvers[data]?.() + }, + } + + return { + subscribe: store.subscribe, + actions, + } +} + +export const eventStore = createEventStore() diff --git a/packages/client/src/stores/index.js b/packages/client/src/stores/index.js index c431302d43..173f3ad4fe 100644 --- a/packages/client/src/stores/index.js +++ b/packages/client/src/stores/index.js @@ -15,6 +15,7 @@ export { uploadStore } from "./uploads.js" export { rowSelectionStore } from "./rowSelection.js" export { blockStore } from "./blocks.js" export { environmentStore } from "./environment" +export { eventStore } from "./events.js" export { dndStore, dndIndex, diff --git a/packages/client/src/stores/screens.js b/packages/client/src/stores/screens.js index 0787610d80..6ef164dc9d 100644 --- a/packages/client/src/stores/screens.js +++ b/packages/client/src/stores/screens.js @@ -2,11 +2,11 @@ import { derived } from "svelte/store" import { routeStore } from "./routes" import { builderStore } from "./builder" import { appStore } from "./app" -import { dndIndex, dndParent, dndIsNewComponent } from "./dnd.js" +import { dndIndex, dndParent, dndIsNewComponent, dndBounds } from "./dnd.js" import { RoleUtils } from "@budibase/frontend-core" import { findComponentById, findComponentParent } from "../utils/components.js" import { Helpers } from "@budibase/bbui" -import { DNDPlaceholderID, DNDPlaceholderType } from "constants" +import { DNDPlaceholderID } from "constants" const createScreenStore = () => { const store = derived( @@ -17,6 +17,7 @@ const createScreenStore = () => { dndParent, dndIndex, dndIsNewComponent, + dndBounds, ], ([ $appStore, @@ -25,6 +26,7 @@ const createScreenStore = () => { $dndParent, $dndIndex, $dndIsNewComponent, + $dndBounds, ]) => { let activeLayout, activeScreen let screens @@ -62,32 +64,43 @@ const createScreenStore = () => { // Insert DND placeholder if required if (activeScreen && $dndParent && $dndIndex != null) { + const { selectedComponentId } = $builderStore + + // Extract and save the selected component as we need a reference to it + // later, and we may be removing it + let selectedParent = findComponentParent( + activeScreen.props, + selectedComponentId + ) + // Remove selected component from tree if we are moving an existing // component - const { selectedComponentId } = $builderStore - if (!$dndIsNewComponent) { - let selectedParent = findComponentParent( - activeScreen.props, - selectedComponentId + if (!$dndIsNewComponent && selectedParent) { + selectedParent._children = selectedParent._children?.filter( + x => x._id !== selectedComponentId ) - if (selectedParent) { - selectedParent._children = selectedParent._children?.filter( - x => x._id !== selectedComponentId - ) - } } // Insert placeholder component - const placeholder = { - _component: DNDPlaceholderID, - _id: DNDPlaceholderType, + const componentToInsert = { + _component: "@budibase/standard-components/container", + _id: DNDPlaceholderID, + _styles: { + normal: { + width: `${$dndBounds?.width || 400}px`, + height: `${$dndBounds?.height || 200}px`, + opacity: 0, + }, + }, static: true, } let parent = findComponentById(activeScreen.props, $dndParent) - if (!parent._children?.length) { - parent._children = [placeholder] - } else { - parent._children.splice($dndIndex, 0, placeholder) + if (parent) { + if (!parent._children?.length) { + parent._children = [componentToInsert] + } else { + parent._children.splice($dndIndex, 0, componentToInsert) + } } } diff --git a/packages/client/src/utils/computed.js b/packages/client/src/utils/computed.js new file mode 100644 index 0000000000..aa89e7ad1b --- /dev/null +++ b/packages/client/src/utils/computed.js @@ -0,0 +1,38 @@ +import { writable } from "svelte/store" + +/** + * Extension of Svelte's built in "derived" stores, which the addition of deep + * comparison of non-primitives. Falls back to using shallow comparison for + * primitive types to avoid performance penalties. + * Useful for instances where a deep comparison is cheaper than an additional + * store invalidation. + * @param store the store to observer + * @param deriveValue the derivation function + * @returns {Writable<*>} a derived svelte store containing just the derived value + */ +export const computed = (store, deriveValue) => { + const initialValue = deriveValue(store) + const computedStore = writable(initialValue) + let lastKey = getKey(initialValue) + + store.subscribe(state => { + const value = deriveValue(state) + const key = getKey(value) + if (key !== lastKey) { + lastKey = key + computedStore.set(value) + } + }) + + return computedStore +} + +// Helper function to serialise any value into a primitive which can be cheaply +// and shallowly compared +const getKey = value => { + if (value == null || typeof value !== "object") { + return value + } else { + return JSON.stringify(value) + } +} diff --git a/packages/frontend-core/src/utils/utils.js b/packages/frontend-core/src/utils/utils.js index 8aa49392fb..d4cf579af1 100644 --- a/packages/frontend-core/src/utils/utils.js +++ b/packages/frontend-core/src/utils/utils.js @@ -6,17 +6,29 @@ */ export const sequential = fn => { let queue = [] - return async (...params) => { - queue.push(async () => { - await fn(...params) - queue.shift() - if (queue.length) { - await queue[0]() + return (...params) => { + return new Promise((resolve, reject) => { + queue.push(async () => { + let data, error + try { + data = await fn(...params) + } catch (err) { + error = err + } + queue.shift() + if (queue.length) { + queue[0]() + } + if (error) { + reject(error) + } else { + resolve(data) + } + }) + if (queue.length === 1) { + queue[0]() } }) - if (queue.length === 1) { - await queue[0]() - } } }