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}