diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index c90ab10c9a..848dd4405a 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -451,7 +451,7 @@ export const getFrontendStore = () => { ...extras, } }, - create: async (componentName, presetProps) => { + create: async (componentName, presetProps, parent, index) => { const state = get(store) const componentInstance = store.actions.components.createInstance( componentName, @@ -461,48 +461,62 @@ export const getFrontendStore = () => { return } - // Patch selected screen - await store.actions.screens.patch(screen => { - // Find the selected component - const currentComponent = findComponent( - screen.props, - state.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 + // 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 { - // Otherwise we need to use the parent of this component - parentComponent = findComponentParent( - screen.props, - currentComponent._id - ) + parentComponent._children.splice(index, 0, componentInstance) } - } 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) - }) + // Otherwise we work out where this component should be inserted + else { + await store.actions.screens.patch(screen => { + // Find the selected component + const currentComponent = findComponent( + screen.props, + state.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 => { @@ -990,6 +1004,19 @@ export const getFrontendStore = () => { })) }, }, + dnd: { + start: component => { + store.actions.preview.sendEvent("dragging-new-component", { + dragging: true, + component, + }) + }, + stop: () => { + store.actions.preview.sendEvent("dragging-new-component", { + dragging: false, + }) + }, + }, } return store diff --git a/packages/builder/src/helpers/urlStateSync.js b/packages/builder/src/helpers/urlStateSync.js index f6357ae5d9..7618ee5155 100644 --- a/packages/builder/src/helpers/urlStateSync.js +++ b/packages/builder/src/helpers/urlStateSync.js @@ -10,6 +10,7 @@ export const syncURLToState = options => { fallbackUrl, store, routify, + beforeNavigate, } = options || {} if ( !urlParam || @@ -41,6 +42,15 @@ export const syncURLToState = options => { // Navigate to a certain URL const gotoUrl = (url, params) => { + if (beforeNavigate) { + const res = beforeNavigate(url, params) + if (res?.url) { + url = res.url + } + if (res?.params) { + params = res.params + } + } log("Navigating to", url, "with params", params) cachedGoto(url, params) } 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 309b676a70..a4c4c5b839 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 @@ -213,6 +213,9 @@ 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}`) } 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 427fd98775..5cb6d31345 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 @@ -76,6 +76,9 @@ const compDef = store.actions.components.getDefinition( $dndStore.source?._component ) + if (!compDef) { + return + } const compTypeName = compDef.name.toLowerCase() const path = findComponentPath(currentScreen.props, component._id) diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_layout.svelte index 530ef44452..e6cbbf71fe 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_layout.svelte @@ -7,6 +7,18 @@ import ComponentListPanel from "./_components/navigation/ComponentListPanel.svelte" import ComponentSettingsPanel from "./_components/settings/ComponentSettingsPanel.svelte" + const cleanUrl = url => { + // Strip trailing slashes + if (url?.endsWith("/index")) { + url = url.replace("/index", "") + } + // Hide new component panel whenever component ID changes + if (url?.endsWith("/new")) { + url = url.replace("/new", "") + } + return { url } + } + // Keep URL and state in sync for selected component ID const stopSyncing = syncURLToState({ urlParam: "componentId", @@ -15,6 +27,7 @@ fallbackUrl: "../", store, routify, + beforeNavigate: cleanUrl, }) onDestroy(stopSyncing) 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 8cef10fb26..778a14ffff 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 @@ -169,6 +169,14 @@ window.removeEventListener("keydown", handleKeyDown) } }) + + const onDragStart = component => { + store.actions.dnd.start(component) + } + + const onDragEnd = () => { + store.actions.dnd.stop() + }
@@ -206,6 +214,9 @@
{category.name}
{#each category.children as component}
onDragStart(component.component)} + on:dragend={onDragEnd} data-cy={`component-${component.name}`} class="component" class:selected={selectedIndex === @@ -229,8 +240,11 @@ {#each blocks as block}
addComponent(block.component)} + on:dragstart={() => onDragStart(block.component)} + on:dragend={onDragEnd} > {block.name} diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 0edb5c0f39..763ac46b3f 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -85,6 +85,10 @@ "icon": "Selection", "hasChildren": true, "showSettingsBar": true, + "size": { + "width": 400, + "height": 100 + }, "styles": [ "padding", "size", @@ -255,6 +259,10 @@ "section" ], "showEmptyState": false, + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "section", @@ -276,6 +284,10 @@ "icon": "Button", "editable": true, "showSettingsBar": true, + "size": { + "width": 105, + "height": 35 + }, "settings": [ { "type": "text", @@ -368,6 +380,10 @@ "illegalChildren": [ "section" ], + "size": { + "width": 400, + "height": 10 + }, "settings": [ { "type": "select", @@ -405,6 +421,10 @@ ], "hasChildren": true, "showSettingsBar": true, + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "dataProvider", @@ -584,6 +604,7 @@ ] }, "card": { + "deprecated": true, "name": "Vertical Card", "description": "A basic card component that can contain content and actions.", "icon": "ViewColumn", @@ -664,6 +685,10 @@ ], "showSettingsBar": true, "editable": true, + "size": { + "width": 400, + "height": 30 + }, "settings": [ { "type": "text", @@ -786,6 +811,10 @@ ], "showSettingsBar": true, "editable": true, + "size": { + "width": 400, + "height": 40 + }, "settings": [ { "type": "text", @@ -903,6 +932,10 @@ "name": "Tag", "icon": "Label", "showSettingsBar": true, + "size": { + "width": 100, + "height": 25 + }, "settings": [ { "type": "text", @@ -954,12 +987,13 @@ "name": "Image", "description": "A basic component for displaying images", "icon": "Image", - "illegalChildren": [ - "section" - ], "styles": [ "size" ], + "size": { + "width": 400, + "height": 300 + }, "settings": [ { "type": "text", @@ -976,9 +1010,10 @@ "styles": [ "size" ], - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 300 + }, "settings": [ { "type": "text", @@ -1036,9 +1071,10 @@ "name": "Icon", "description": "A basic component for displaying icons", "icon": "Shapes", - "illegalChildren": [ - "section" - ], + "size": { + "width": 25, + "height": 25 + }, "settings": [ { "type": "icon", @@ -1155,9 +1191,10 @@ "icon": "Link", "showSettingsBar": true, "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 200, + "height": 30 + }, "settings": [ { "type": "text", @@ -1267,12 +1304,10 @@ ] }, "cardhorizontal": { + "deprecated": true, "name": "Horizontal Card", "description": "A basic card component that can contain content and actions.", "icon": "ViewRow", - "illegalChildren": [ - "section" - ], "settings": [ { "type": "text", @@ -1363,27 +1398,31 @@ "name": "Stat Card", "description": "A card component for displaying numbers.", "icon": "Card", - "illegalChildren": [ - "section" - ], + "size": { + "width": 260, + "height": 143 + }, "settings": [ { "type": "text", "label": "Title", "key": "title", - "placeholder": "Total Revenue" + "placeholder": "Total Revenue", + "defaultValue": "Title" }, { "type": "text", "label": "Value", "key": "value", - "placeholder": "$1,981,983" + "placeholder": "$1,981,983", + "defaultValue": "Value" }, { "type": "text", "label": "Label", "key": "label", - "placeholder": "Stripe" + "placeholder": "Stripe", + "defaultValue": "Label" } ] }, @@ -1391,12 +1430,13 @@ "name": "Embed", "icon": "Code", "description": "Embed content from 3rd party sources", - "illegalChildren": [ - "section" - ], "styles": [ "size" ], + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "text", @@ -1410,9 +1450,10 @@ "name": "Bar Chart", "description": "Bar chart", "icon": "GraphBarVertical", - "illegalChildren": [ - "section" - ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -1571,9 +1612,10 @@ "name": "Line Chart", "description": "Line chart", "icon": "GraphTrend", - "illegalChildren": [ - "section" - ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -1731,9 +1773,10 @@ "name": "Area Chart", "description": "Line chart", "icon": "GraphAreaStacked", - "illegalChildren": [ - "section" - ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -1903,9 +1946,10 @@ "name": "Pie Chart", "description": "Pie chart", "icon": "GraphPie", - "illegalChildren": [ - "section" - ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -2031,9 +2075,10 @@ "name": "Donut Chart", "description": "Donut chart", "icon": "GraphDonut", - "illegalChildren": [ - "section" - ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -2159,9 +2204,10 @@ "name": "Candlestick Chart", "description": "Candlestick chart", "icon": "GraphBarVerticalStacked", - "illegalChildren": [ - "section" - ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -2266,6 +2312,10 @@ "styles": [ "size" ], + "size": { + "width": 400, + "height": 400 + }, "settings": [ { "type": "select", @@ -2352,6 +2402,10 @@ "styles": [ "size" ], + "size": { + "width": 400, + "height": 400 + }, "settings": [ { "type": "number", @@ -2372,6 +2426,10 @@ "size" ], "hasChildren": true, + "size": { + "width": 400, + "height": 400 + }, "settings": [ { "type": "select", @@ -2398,13 +2456,14 @@ "stringfield": { "name": "Text Field", "icon": "Text", - "illegalChildren": [ - "section" - ], "styles": [ "size" ], "editable": true, + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/string", @@ -2492,9 +2551,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/number", @@ -2548,9 +2608,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/string", @@ -2604,9 +2665,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/options", @@ -2771,9 +2833,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/array", @@ -2929,9 +2992,10 @@ "name": "Checkbox", "icon": "SelectBox", "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/boolean", @@ -3009,6 +3073,10 @@ "size" ], "editable": true, + "size": { + "width": 400, + "height": 150 + }, "settings": [ { "type": "field/longform", @@ -3084,9 +3152,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/datetime", @@ -3163,9 +3232,10 @@ "styles": [ "size" ], - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/barcode/qr", @@ -3214,29 +3284,27 @@ "size" ], "draggable": false, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 320 + }, "settings": [ { "type": "dataProvider", "label": "Provider", - "key": "dataProvider", - "required": true + "key": "dataProvider" }, { "type": "field", "label": "Latitude Key", "key": "latitudeKey", - "dependsOn": "dataProvider", - "required": true + "dependsOn": "dataProvider" }, { "type": "field", "label": "Longitude Key", "key": "longitudeKey", - "dependsOn": "dataProvider", - "required": true + "dependsOn": "dataProvider" }, { "type": "field", @@ -3330,9 +3398,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 200 + }, "settings": [ { "type": "field/attachment", @@ -3387,9 +3456,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/link", @@ -3449,6 +3519,10 @@ "size" ], "editable": true, + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "field/json", @@ -3497,6 +3571,10 @@ "size" ], "editable": true, + "size": { + "width": 400, + "height": 200 + }, "settings": [ { "type": "field/attachment", @@ -3559,6 +3637,10 @@ "actions": [ "RefreshDatasource" ], + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "dataSource", @@ -3639,6 +3721,10 @@ ], "hasChildren": true, "showEmptyState": false, + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "dataProvider", @@ -3737,6 +3823,10 @@ "size" ], "hasChildren": false, + "size": { + "width": 200, + "height": 50 + }, "settings": [ { "type": "dataProvider", @@ -3773,21 +3863,28 @@ "styles": [ "size" ], + "size": { + "width": 300, + "height": 120 + }, "settings": [ { "type": "text", "key": "title", - "label": "Title" + "label": "Title", + "defaultValue": "Title" }, { "type": "text", "key": "subtitle", - "label": "Subtitle" + "label": "Subtitle", + "defaultValue": "Subtitle" }, { "type": "text", "key": "description", - "label": "Description" + "label": "Description", + "defaultValue": "Description" }, { "type": "text", @@ -3831,6 +3928,10 @@ "name": "Dynamic Filter", "icon": "Filter", "showSettingsBar": true, + "size": { + "width": 100, + "height": 35 + }, "settings": [ { "type": "dataProvider", @@ -3878,6 +3979,10 @@ "styles": [ "size" ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -4043,6 +4148,10 @@ "styles": [ "size" ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -4101,19 +4210,22 @@ "type": "text", "key": "cardTitle", "label": "Title", - "nested": true + "nested": true, + "defaultValue": "Title" }, { "type": "text", "key": "cardSubtitle", "label": "Subtitle", - "nested": true + "nested": true, + "defaultValue": "Subtitle" }, { "type": "text", "key": "cardDescription", "label": "Description", - "nested": true + "nested": true, + "defaultValue": "Description" }, { "type": "text", @@ -4215,6 +4327,10 @@ ], "hasChildren": true, "showSettingsBar": true, + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "dataSource", @@ -4437,6 +4553,10 @@ "styles": [ "size" ], + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "text", @@ -4454,6 +4574,10 @@ ], "block": true, "info": "Form blocks are only compatible with internal or SQL tables", + "size": { + "width": 400, + "height": 400 + }, "settings": [ { "type": "select", diff --git a/packages/client/src/components/Block.svelte b/packages/client/src/components/Block.svelte index 05d92f208c..75474dfd6f 100644 --- a/packages/client/src/components/Block.svelte +++ b/packages/client/src/components/Block.svelte @@ -67,6 +67,9 @@ // any depth id: $component.id, + // Name can be used down the tree in placeholders + name: $component.name, + // We register block components with their raw props so that we can eject // blocks later on registerComponent: registerBlockComponent, diff --git a/packages/client/src/components/Component.svelte b/packages/client/src/components/Component.svelte index 2d586df24d..1885d22836 100644 --- a/packages/client/src/components/Component.svelte +++ b/packages/client/src/components/Component.svelte @@ -16,7 +16,14 @@ propsAreSame, getSettingsDefinition, } from "utils/componentProps" - import { builderStore, devToolsStore, componentStore, appStore } from "stores" + import { + builderStore, + devToolsStore, + componentStore, + appStore, + dndIsDragging, + dndComponentPath, + } from "stores" import { Helpers } from "@budibase/bbui" import { getActiveConditions, reduceConditionActions } from "utils/conditions" import Placeholder from "components/app/Placeholder.svelte" @@ -27,6 +34,7 @@ export let isLayout = false export let isScreen = false export let isBlock = false + export let parent = null // Get parent contexts const context = getContext("context") @@ -97,6 +105,7 @@ $builderStore.inBuilder && $builderStore.selectedComponentId === id $: inSelectedPath = $componentStore.selectedComponentPath?.includes(id) $: inDragPath = inSelectedPath && $builderStore.editMode + $: inDndPath = $dndComponentPath?.includes(id) // Derive definition properties which can all be optional, so need to be // coerced to booleans @@ -108,7 +117,7 @@ // Interactive components can be selected, dragged and highlighted inside // the builder preview $: builderInteractive = - $builderStore.inBuilder && insideScreenslot && !isBlock + $builderStore.inBuilder && insideScreenslot && !isBlock && !instance.static $: devToolsInteractive = $devToolsStore.allowSelection && !isBlock $: interactive = builderInteractive || devToolsInteractive $: editing = editable && selected && $builderStore.editMode @@ -118,7 +127,7 @@ !isLayout && !isScreen && definition?.draggable !== false - $: droppable = interactive && !isLayout && !isScreen + $: droppable = interactive $: builderHidden = $builderStore.inBuilder && $builderStore.hiddenComponentIds?.includes(id) @@ -126,8 +135,9 @@ // Empty states can be shown for these components, but can be disabled // in the component manifest. $: empty = - (interactive && !children.length && hasChildren) || - hasMissingRequiredSettings + !isBlock && + ((interactive && !children.length && hasChildren) || + hasMissingRequiredSettings) $: emptyState = empty && showEmptyState // Enrich component settings @@ -149,6 +159,12 @@ // Scroll the selected element into view $: selected && scrollIntoView() + // When dragging and dropping, pad components to allow dropping between + // nested layers. Only reset this when dragging stops. + let pad = false + $: pad = pad || (interactive && hasChildren && inDndPath) + $: $dndIsDragging, (pad = false) + // Update component context $: store.set({ id, @@ -405,6 +421,11 @@ } const scrollIntoView = () => { + // Don't scroll into view if we selected this component because we were + // starting dragging on it + if (get(dndIsDragging)) { + return + } const node = document.getElementsByClassName(id)?.[0]?.children[0] if (!node) { return @@ -452,17 +473,20 @@ class:empty class:interactive class:editing + class:pad + class:parent={hasChildren} class:block={isBlock} data-id={id} data-name={name} data-icon={icon} + data-parent={parent} > {#if hasMissingRequiredSettings} {:else if children.length} {#each children as child (child._id)} - + {/each} {:else if emptyState} {#if isScreen} @@ -481,16 +505,14 @@ .component { display: contents; } - - .interactive :global(*:hover) { - cursor: pointer; + .component.pad :global(> *) { + padding: var(--spacing-l) !important; + gap: var(--spacing-l) !important; + border: 2px dashed var(--spectrum-global-color-gray-400) !important; + border-radius: 4px !important; + transition: padding 260ms ease-out, border 260ms ease-out; } - - .draggable :global(*:hover) { - cursor: grab; - } - - .editing :global(*:hover) { - cursor: auto; + .interactive :global(*) { + cursor: default; } diff --git a/packages/client/src/components/app/Placeholder.svelte b/packages/client/src/components/app/Placeholder.svelte index 203071e0b1..54553cb934 100644 --- a/packages/client/src/components/app/Placeholder.svelte +++ b/packages/client/src/components/app/Placeholder.svelte @@ -3,13 +3,14 @@ const { builderStore } = getContext("sdk") const component = getContext("component") + const block = getContext("block") export let text {#if $builderStore.inBuilder}
- {text || $component.name || "Placeholder"} + {text || block?.name || $component.name || "Placeholder"}
{/if} diff --git a/packages/client/src/components/preview/DNDHandler.svelte b/packages/client/src/components/preview/DNDHandler.svelte index c37eb93afa..a9c52f0099 100644 --- a/packages/client/src/components/preview/DNDHandler.svelte +++ b/packages/client/src/components/preview/DNDHandler.svelte @@ -1,201 +1,298 @@ - - - +{#if $dndIsDragging} + +{/if} diff --git a/packages/client/src/components/preview/DNDPlaceholder.svelte b/packages/client/src/components/preview/DNDPlaceholder.svelte new file mode 100644 index 0000000000..3725f9e06e --- /dev/null +++ b/packages/client/src/components/preview/DNDPlaceholder.svelte @@ -0,0 +1,33 @@ + + +{#if style} +
+
+
+{/if} + + diff --git a/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte b/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte new file mode 100644 index 0000000000..6ed2df6a87 --- /dev/null +++ b/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte @@ -0,0 +1,47 @@ + + +{#if left != null && top != null && width && height} +
+{/if} + + diff --git a/packages/client/src/components/preview/DNDPositionIndicator.svelte b/packages/client/src/components/preview/DNDPositionIndicator.svelte deleted file mode 100644 index 4af4674126..0000000000 --- a/packages/client/src/components/preview/DNDPositionIndicator.svelte +++ /dev/null @@ -1,66 +0,0 @@ - - -{#key renderKey} - {#if dimensions && dropInfo?.mode !== "inside"} - - {/if} -{/key} diff --git a/packages/client/src/components/preview/HoverIndicator.svelte b/packages/client/src/components/preview/HoverIndicator.svelte index 1a9e6477ac..d5583ed3db 100644 --- a/packages/client/src/components/preview/HoverIndicator.svelte +++ b/packages/client/src/components/preview/HoverIndicator.svelte @@ -1,7 +1,7 @@ - import { builderStore } from "stores" + import { builderStore, dndIsDragging } from "stores" import IndicatorSet from "./IndicatorSet.svelte" $: color = $builderStore.editMode @@ -8,7 +8,7 @@ { diff --git a/packages/client/src/constants.js b/packages/client/src/constants.js index 1560552dc4..bd387c7f9d 100644 --- a/packages/client/src/constants.js +++ b/packages/client/src/constants.js @@ -30,3 +30,7 @@ export const ActionTypes = { ClearForm: "ClearForm", ChangeFormStep: "ChangeFormStep", } + +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 b671d5554a..706cb4fc9f 100644 --- a/packages/client/src/index.js +++ b/packages/client/src/index.js @@ -6,6 +6,7 @@ import { blockStore, componentStore, environmentStore, + dndStore, } from "./stores" import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-rollup.js" import { get } from "svelte/store" @@ -25,6 +26,7 @@ let app const loadBudibase = async () => { // Update builder store with any builder flags builderStore.set({ + ...get(builderStore), inBuilder: !!window["##BUDIBASE_IN_BUILDER##"], layout: window["##BUDIBASE_PREVIEW_LAYOUT##"], screen: window["##BUDIBASE_PREVIEW_SCREEN##"], @@ -59,6 +61,15 @@ const loadBudibase = async () => { if (name === "eject-block") { const block = blockStore.actions.getBlock(payload) block?.eject() + } else if (name === "dragging-new-component") { + const { dragging, component } = payload + if (dragging) { + const definition = + componentStore.actions.getComponentDefinition(component) + dndStore.actions.startDraggingNewComponent({ component, definition }) + } else { + dndStore.actions.reset() + } } } diff --git a/packages/client/src/stores/builder.js b/packages/client/src/stores/builder.js index ba753a259e..b937b7f696 100644 --- a/packages/client/src/stores/builder.js +++ b/packages/client/src/stores/builder.js @@ -16,7 +16,6 @@ const createBuilderStore = () => { theme: null, customTheme: null, previewDevice: "desktop", - isDragging: false, navigation: null, hiddenComponentIds: [], usedPlugins: null, @@ -67,11 +66,12 @@ const createBuilderStore = () => { mode, }) }, - setDragging: dragging => { - if (dragging === get(store).isDragging) { - return - } - store.update(state => ({ ...state, isDragging: dragging })) + dropNewComponent: (component, parent, index) => { + dispatchEvent("drop-new-component", { + component, + parent, + index, + }) }, setEditMode: enabled => { if (enabled === get(store).editMode) { diff --git a/packages/client/src/stores/components.js b/packages/client/src/stores/components.js index 98edfaddae..b34dfe375d 100644 --- a/packages/client/src/stores/components.js +++ b/packages/client/src/stores/components.js @@ -5,7 +5,9 @@ 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" const budibasePrefix = "@budibase/standard-components/" @@ -18,26 +20,21 @@ const createComponentStore = () => { const derivedStore = derived( [store, builderStore, devToolsStore, screenStore], - ([$store, $builderState, $devToolsState, $screenState]) => { + ([$store, $builderStore, $devToolsStore, $screenStore]) => { + const { inBuilder, selectedComponentId } = $builderStore + // Avoid any of this logic if we aren't in the builder preview - if (!$builderState.inBuilder && !$devToolsState.visible) { + if (!inBuilder && !$devToolsStore.visible) { return {} } - // Derive the selected component instance and definition - let asset - const { screen, selectedComponentId } = $builderState - if ($builderState.inBuilder) { - asset = screen - } else { - asset = $screenState.activeScreen - } - const component = findComponentById(asset?.props, selectedComponentId) + const root = $screenStore.activeScreen?.props + const component = findComponentById(root, selectedComponentId) const definition = getComponentDefinition(component?._component) // Derive the selected component path - const path = - findComponentPathById(asset?.props, selectedComponentId) || [] + const selectedPath = + findComponentPathById(root, selectedComponentId) || [] return { customComponentManifest: $store.customComponentManifest, @@ -45,9 +42,8 @@ const createComponentStore = () => { $store.mountedComponents[selectedComponentId], selectedComponent: component, selectedComponentDefinition: definition, - selectedComponentPath: path?.map(component => component._id), + selectedComponentPath: selectedPath?.map(component => component._id), mountedComponentCount: Object.keys($store.mountedComponents).length, - currentAsset: asset, } } ) @@ -95,8 +91,8 @@ const createComponentStore = () => { } const getComponentById = id => { - const asset = get(derivedStore).currentAsset - return findComponentById(asset?.props, id) + const root = get(screenStore).activeScreen?.props + return findComponentById(root, id) } const getComponentDefinition = type => { @@ -105,8 +101,10 @@ const createComponentStore = () => { } // Screenslot is an edge case - if (type === "screenslot") { + if (type === ScreenslotType) { type = `${budibasePrefix}${type}` + } else if (type === DNDPlaceholderType) { + return {} } // Handle built-in components @@ -124,8 +122,10 @@ const createComponentStore = () => { if (!type) { return null } - if (type === "screenslot") { + if (type === ScreenslotType) { return Router + } else if (type === DNDPlaceholderType) { + return DNDPlaceholder } // Handle budibase components diff --git a/packages/client/src/stores/derived/currentRole.js b/packages/client/src/stores/derived/currentRole.js new file mode 100644 index 0000000000..28287e1ea4 --- /dev/null +++ b/packages/client/src/stores/derived/currentRole.js @@ -0,0 +1,11 @@ +import { derived } from "svelte/store" +import { devToolsStore } from "../devTools.js" +import { authStore } from "../auth.js" + +// Derive the current role of the logged-in user +export const currentRole = derived( + [devToolsStore, authStore], + ([$devToolsStore, $authStore]) => { + return ($devToolsStore.enabled && $devToolsStore.role) || $authStore?.roleId + } +) diff --git a/packages/client/src/stores/derived/dndComponentPath.js b/packages/client/src/stores/derived/dndComponentPath.js new file mode 100644 index 0000000000..58fb395dd6 --- /dev/null +++ b/packages/client/src/stores/derived/dndComponentPath.js @@ -0,0 +1,13 @@ +import { derived } from "svelte/store" +import { findComponentPathById } from "utils/components.js" +import { dndParent } from "../dnd.js" +import { screenStore } from "../screens.js" + +export const dndComponentPath = derived( + [dndParent, screenStore], + ([$dndParent, $screenStore]) => { + const root = $screenStore.activeScreen?.props + const path = findComponentPathById(root, $dndParent) || [] + return path?.map(component => component._id) + } +) diff --git a/packages/client/src/stores/derived/index.js b/packages/client/src/stores/derived/index.js new file mode 100644 index 0000000000..4f6a6ab91d --- /dev/null +++ b/packages/client/src/stores/derived/index.js @@ -0,0 +1,5 @@ +// These derived stores are pulled out from their parent stores to avoid +// dependency loops. By inverting store dependencies and extracting them +// separately we can keep our actual stores lean and performant. +export { currentRole } from "./currentRole.js" +export { dndComponentPath } from "./dndComponentPath.js" diff --git a/packages/client/src/stores/dnd.js b/packages/client/src/stores/dnd.js new file mode 100644 index 0000000000..a1f85af92c --- /dev/null +++ b/packages/client/src/stores/dnd.js @@ -0,0 +1,90 @@ +import { writable, derived } from "svelte/store" + +const createDndStore = () => { + const initialState = { + // Info about the dragged component + source: null, + + // Info about the target component being hovered over + target: null, + + // Info about where the component would be dropped + drop: null, + } + const store = writable(initialState) + + const startDraggingExistingComponent = ({ id, parent, bounds, index }) => { + store.set({ + ...initialState, + source: { id, parent, bounds, index }, + }) + } + + const startDraggingNewComponent = ({ component, definition }) => { + if (!component) { + return + } + + // Get size of new component so we can show a properly sized placeholder + const width = definition?.size?.width || 128 + const height = definition?.size?.height || 64 + + store.set({ + ...initialState, + source: { + id: null, + parent: null, + bounds: { height, width }, + index: null, + newComponentType: component, + }, + }) + } + + const updateTarget = ({ id, parent, node, empty, acceptsChildren }) => { + store.update(state => { + state.target = { id, parent, node, empty, acceptsChildren } + return state + }) + } + + const updateDrop = ({ parent, index }) => { + store.update(state => { + state.drop = { parent, index } + return state + }) + } + + const reset = () => { + store.set(initialState) + } + + return { + subscribe: store.subscribe, + actions: { + startDraggingExistingComponent, + startDraggingNewComponent, + updateTarget, + updateDrop, + reset, + }, + } +} + +export const dndStore = createDndStore() + +// The DND store is updated extremely frequently, so we can greatly improve +// 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( + dndStore, + $dndStore => $dndStore.source?.bounds +) +export const dndIsNewComponent = derived( + dndStore, + $dndStore => $dndStore.source?.newComponentType != null +) diff --git a/packages/client/src/stores/index.js b/packages/client/src/stores/index.js index 5b77762223..c431302d43 100644 --- a/packages/client/src/stores/index.js +++ b/packages/client/src/stores/index.js @@ -1,7 +1,3 @@ -import { derived } from "svelte/store" -import { devToolsStore } from "./devTools.js" -import { authStore } from "./auth.js" - export { authStore } from "./auth" export { appStore } from "./app" export { notificationStore } from "./notification" @@ -19,6 +15,14 @@ export { uploadStore } from "./uploads.js" export { rowSelectionStore } from "./rowSelection.js" export { blockStore } from "./blocks.js" export { environmentStore } from "./environment" +export { + dndStore, + dndIndex, + dndParent, + dndBounds, + dndIsNewComponent, + dndIsDragging, +} from "./dnd" // Context stores are layered and duplicated, so it is not a singleton export { createContextStore } from "./context" @@ -26,10 +30,5 @@ export { createContextStore } from "./context" // Initialises an app by loading screens and routes export { initialise } from "./initialise" -// Derive the current role of the logged-in user -export const currentRole = derived( - [devToolsStore, authStore], - ([$devToolsStore, $authStore]) => { - return ($devToolsStore.enabled && $devToolsStore.role) || $authStore?.roleId - } -) +// Derived state +export * from "./derived" diff --git a/packages/client/src/stores/screens.js b/packages/client/src/stores/screens.js index 84cd4000c1..0787610d80 100644 --- a/packages/client/src/stores/screens.js +++ b/packages/client/src/stores/screens.js @@ -2,18 +2,36 @@ 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 { RoleUtils } from "@budibase/frontend-core" +import { findComponentById, findComponentParent } from "../utils/components.js" +import { Helpers } from "@budibase/bbui" +import { DNDPlaceholderID, DNDPlaceholderType } from "constants" const createScreenStore = () => { const store = derived( - [appStore, routeStore, builderStore], - ([$appStore, $routeStore, $builderStore]) => { + [ + appStore, + routeStore, + builderStore, + dndParent, + dndIndex, + dndIsNewComponent, + ], + ([ + $appStore, + $routeStore, + $builderStore, + $dndParent, + $dndIndex, + $dndIsNewComponent, + ]) => { let activeLayout, activeScreen let screens if ($builderStore.inBuilder) { // Use builder defined definitions if inside the builder preview - activeScreen = $builderStore.screen + activeScreen = Helpers.cloneDeep($builderStore.screen) screens = [activeScreen] // Legacy - allow the builder to specify a layout @@ -24,8 +42,10 @@ const createScreenStore = () => { // Find the correct screen by matching the current route screens = $appStore.screens || [] if ($routeStore.activeRoute) { - activeScreen = screens.find( - screen => screen._id === $routeStore.activeRoute.screenId + activeScreen = Helpers.cloneDeep( + screens.find( + screen => screen._id === $routeStore.activeRoute.screenId + ) ) } @@ -40,6 +60,37 @@ const createScreenStore = () => { } } + // Insert DND placeholder if required + if (activeScreen && $dndParent && $dndIndex != null) { + // Remove selected component from tree if we are moving an existing + // component + const { selectedComponentId } = $builderStore + if (!$dndIsNewComponent) { + let selectedParent = findComponentParent( + activeScreen.props, + selectedComponentId + ) + if (selectedParent) { + selectedParent._children = selectedParent._children?.filter( + x => x._id !== selectedComponentId + ) + } + } + + // Insert placeholder component + const placeholder = { + _component: DNDPlaceholderID, + _id: DNDPlaceholderType, + static: true, + } + let parent = findComponentById(activeScreen.props, $dndParent) + if (!parent._children?.length) { + parent._children = [placeholder] + } else { + parent._children.splice($dndIndex, 0, placeholder) + } + } + // Assign ranks to screens, preferring higher roles and home screens screens.forEach(screen => { const roleId = screen.routing.roleId diff --git a/packages/client/src/utils/components.js b/packages/client/src/utils/components.js index 4b1b8a7ada..1812175c2c 100644 --- a/packages/client/src/utils/components.js +++ b/packages/client/src/utils/components.js @@ -60,3 +60,25 @@ export const findChildrenByType = (component, type, children = []) => { findChildrenByType(child, type, children) }) } + +/** + * Recursively searches for the parent component of a specific component ID + */ +export const findComponentParent = (rootComponent, id, parentComponent) => { + if (!rootComponent || !id) { + return null + } + if (rootComponent._id === id) { + return parentComponent + } + if (!rootComponent._children) { + return null + } + for (const child of rootComponent._children) { + const childResult = findComponentParent(child, id, rootComponent) + if (childResult) { + return childResult + } + } + return null +} diff --git a/packages/client/src/utils/styleable.js b/packages/client/src/utils/styleable.js index b07a3213d9..9ad17ceff0 100644 --- a/packages/client/src/utils/styleable.js +++ b/packages/client/src/utils/styleable.js @@ -27,7 +27,7 @@ export const styleable = (node, styles = {}) => { const setupStyles = (newStyles = {}) => { let baseStyles = {} if (newStyles.empty) { - baseStyles.border = "2px dashed var(--spectrum-global-color-gray-600)" + baseStyles.border = "2px dashed var(--spectrum-global-color-gray-400)" baseStyles.padding = "var(--spacing-l)" baseStyles.overflow = "hidden" } diff --git a/packages/frontend-core/src/utils/utils.js b/packages/frontend-core/src/utils/utils.js index 587d057351..8aa49392fb 100644 --- a/packages/frontend-core/src/utils/utils.js +++ b/packages/frontend-core/src/utils/utils.js @@ -40,3 +40,37 @@ export const debounce = (callback, minDelay = 1000) => { }) } } + +/** + * Utility to throttle invocations of a synchronous function. This is better + * than a simple debounce invocation for a number of reasons. Features include: + * - First invocation is immediate (no initial delay) + * - Every invocation has the latest params (no stale params) + * - There will always be a final invocation with the last params (no missing + * final update) + * @param callback + * @param minDelay + * @returns {Function} a throttled version function + */ +export const throttle = (callback, minDelay = 1000) => { + let lastParams + let stalled = false + let pending = false + const invoke = (...params) => { + lastParams = params + if (stalled) { + pending = true + return + } + callback(...lastParams) + stalled = true + setTimeout(() => { + stalled = false + if (pending) { + pending = false + invoke(...lastParams) + } + }, minDelay) + } + return invoke +}