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
+}