From 8d644c5a1035127940774fcc37b68cbd6b851073 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 24 Oct 2022 09:02:50 +0100 Subject: [PATCH] Refactor app preview event sending to support async callbacks in client library --- .../[screenId]/_components/AppPreview.svelte | 236 ++++++++---------- .../src/components/preview/DNDHandler.svelte | 24 +- packages/client/src/index.js | 19 +- packages/client/src/stores/builder.js | 36 ++- packages/client/src/stores/dnd.js | 12 - packages/client/src/stores/events.js | 31 +++ packages/client/src/stores/index.js | 2 +- packages/client/src/stores/screens.js | 55 ++-- 8 files changed, 199 insertions(+), 216 deletions(-) create mode 100644 packages/client/src/stores/events.js 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 2a606233c2..9cbb283fef 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 @@ -103,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, @@ -112,37 +112,30 @@ 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 receiveMessage = async message => { + if (!message?.data?.type) { + return } - const messageHandler = handlers[message.data.type] || handleBudibaseEvent - messageHandler(message) + // Await the event handler + try { + await handleBudibaseEvent(message) + } catch (error) { + 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 => { @@ -150,83 +143,94 @@ if (!type) { return } - - 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 === "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}`) + if (type === MessageTypes.READY) { + // Initialise the app when mounted + if ($store.clientFeatures.messagePassing) { + if (!loading) return } - } catch (error) { - notifications.error(error || "Error handling event from app preview") + + // Display preview immediately if the intelligent loading feature + // is not supported + if (!$store.clientFeatures.intelligentLoading) { + loading = false + } + 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}`) } } @@ -259,42 +263,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/client/src/components/preview/DNDHandler.svelte b/packages/client/src/components/preview/DNDHandler.svelte index e04bd81750..42a2fafbdd 100644 --- a/packages/client/src/components/preview/DNDHandler.svelte +++ b/packages/client/src/components/preview/DNDHandler.svelte @@ -22,6 +22,9 @@ $: target = $dndStore.target $: drop = $dndStore.drop + // Local flag for whether we are awaiting an async drop event + let dropping = false + const insideGrid = e => { return e.target ?.closest?.(".component") @@ -48,6 +51,10 @@ // Callback when drag stops (whether dropped or not) const stopDragging = () => { + if (dropping) { + return + } + // Reset listener if (source?.id) { const component = document.getElementsByClassName(source?.id)[0] @@ -57,9 +64,7 @@ } // Reset state - if (!$dndStore.dropped) { - dndStore.actions.reset() - } + dndStore.actions.reset() } // Callback when initially starting a drag on a draggable component @@ -253,18 +258,21 @@ } // Callback when dropping a drag on top of some component - const onDrop = () => { + const onDrop = async () => { if (!source || !drop?.parent || drop?.index == null) { return } // Check if we're adding a new component rather than moving one if (source.newComponentType) { - builderStore.actions.dropNewComponent( + dropping = true + await builderStore.actions.dropNewComponent( source.newComponentType, drop.parent, drop.index ) + dropping = false + stopDragging() return } @@ -301,12 +309,14 @@ } if (legacyDropTarget && legacyDropMode) { - dndStore.actions.markDropped() - builderStore.actions.moveComponent( + dropping = true + await builderStore.actions.moveComponent( source.id, legacyDropTarget, legacyDropMode ) + dropping = false + stopDragging() } } diff --git a/packages/client/src/index.js b/packages/client/src/index.js index e61df37d95..80f8754e20 100644 --- a/packages/client/src/index.js +++ b/packages/client/src/index.js @@ -7,6 +7,7 @@ import { componentStore, environmentStore, dndStore, + eventStore, } from "./stores" import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-rollup.js" import { get } from "svelte/store" @@ -42,9 +43,9 @@ const loadBudibase = async () => { }) // Reset DND state if we completed a successful drop - if (get(dndStore).dropped) { - dndStore.actions.reset() - } + // if (get(dndStore).dropped) { + // dndStore.actions.reset() + // } // Set app ID - this window flag is set by both the preview and the real // server rendered app HTML @@ -59,15 +60,17 @@ const loadBudibase = async () => { devToolsStore.actions.setEnabled(enableDevTools) // Register handler for runtime events from the builder - window.handleBuilderRuntimeEvent = (name, payload) => { + window.handleBuilderRuntimeEvent = (type, data) => { if (!window["##BUDIBASE_IN_BUILDER##"]) { return } - if (name === "eject-block") { - const block = blockStore.actions.getBlock(payload) + if (type === "event-completed") { + eventStore.actions.resolveEvent(data) + } else if (type === "eject-block") { + const block = blockStore.actions.getBlock(data) block?.eject() - } else if (name === "dragging-new-component") { - const { dragging, component } = payload + } else if (type === "dragging-new-component") { + const { dragging, component } = data if (dragging) { const definition = componentStore.actions.getComponentDefinition(component) diff --git a/packages/client/src/stores/builder.js b/packages/client/src/stores/builder.js index 30fcc39bad..fdad9a5f88 100644 --- a/packages/client/src/stores/builder.js +++ b/packages/client/src/stores/builder.js @@ -1,10 +1,7 @@ import { writable, get } from "svelte/store" import { API } from "api" import { devToolsStore } from "./devTools.js" - -const dispatchEvent = (type, data = {}) => { - window.parent.postMessage({ type, data }) -} +import { eventStore } from "./events.js" const createBuilderStore = () => { const initialState = { @@ -19,6 +16,7 @@ const createBuilderStore = () => { navigation: null, hiddenComponentIds: [], usedPlugins: null, + eventResolvers: {}, // Legacy - allow the builder to specify a layout layout: null, @@ -35,25 +33,25 @@ const createBuilderStore = () => { selectedComponentId: id, })) devToolsStore.actions.setAllowSelection(false) - dispatchEvent("select-component", { id }) + eventStore.actions.dispatchEvent("select-component", { id }) }, updateProp: (prop, value) => { - dispatchEvent("update-prop", { prop, value }) + eventStore.actions.dispatchEvent("update-prop", { prop, value }) }, updateStyles: (styles, id) => { - dispatchEvent("update-styles", { styles, id }) + eventStore.actions.dispatchEvent("update-styles", { styles, id }) }, keyDown: (key, ctrlKey) => { - dispatchEvent("key-down", { key, ctrlKey }) + eventStore.actions.dispatchEvent("key-down", { key, ctrlKey }) }, duplicateComponent: id => { - dispatchEvent("duplicate-component", { id }) + eventStore.actions.dispatchEvent("duplicate-component", { id }) }, deleteComponent: id => { - dispatchEvent("delete-component", { id }) + eventStore.actions.dispatchEvent("delete-component", { id }) }, notifyLoaded: () => { - dispatchEvent("preview-loaded") + eventStore.actions.dispatchEvent("preview-loaded") }, analyticsPing: async () => { try { @@ -62,15 +60,15 @@ const createBuilderStore = () => { // Do nothing } }, - moveComponent: (componentId, destinationComponentId, mode) => { - dispatchEvent("move-component", { + moveComponent: async (componentId, destinationComponentId, mode) => { + await eventStore.actions.dispatchEvent("move-component", { componentId, destinationComponentId, mode, }) }, dropNewComponent: (component, parent, index) => { - dispatchEvent("drop-new-component", { + eventStore.actions.dispatchEvent("drop-new-component", { component, parent, index, @@ -83,16 +81,16 @@ const createBuilderStore = () => { store.update(state => ({ ...state, editMode: enabled })) }, clickNav: () => { - dispatchEvent("click-nav") + eventStore.actions.dispatchEvent("click-nav") }, requestAddComponent: () => { - dispatchEvent("request-add-component") + eventStore.actions.dispatchEvent("request-add-component") }, highlightSetting: setting => { - dispatchEvent("highlight-setting", { setting }) + eventStore.actions.dispatchEvent("highlight-setting", { setting }) }, ejectBlock: (id, definition) => { - dispatchEvent("eject-block", { id, definition }) + eventStore.actions.dispatchEvent("eject-block", { id, definition }) }, updateUsedPlugin: (name, hash) => { // Check if we used this plugin @@ -109,7 +107,7 @@ const createBuilderStore = () => { } // Notify the builder so we can reload component definitions - dispatchEvent("reload-plugin") + eventStore.actions.dispatchEvent("reload-plugin") }, } return { diff --git a/packages/client/src/stores/dnd.js b/packages/client/src/stores/dnd.js index 532dea0ad5..1bdd510eb7 100644 --- a/packages/client/src/stores/dnd.js +++ b/packages/client/src/stores/dnd.js @@ -11,9 +11,6 @@ const createDndStore = () => { // Info about where the component would be dropped drop: null, - - // Whether the current drop has been completed successfully - dropped: false, } const store = writable(initialState) @@ -63,13 +60,6 @@ const createDndStore = () => { store.set(initialState) } - const markDropped = () => { - store.update(state => { - state.dropped = true - return state - }) - } - return { subscribe: store.subscribe, actions: { @@ -78,7 +68,6 @@ const createDndStore = () => { updateTarget, updateDrop, reset, - markDropped, }, } } @@ -91,7 +80,6 @@ export const dndStore = createDndStore() // or components which depend on DND state unless values actually change. export const dndParent = computed(dndStore, x => x.drop?.parent) export const dndIndex = computed(dndStore, x => x.drop?.index) -export const dndDropped = computed(dndStore, x => x.dropped) export const dndBounds = computed(dndStore, x => x.source?.bounds) export const dndIsDragging = computed(dndStore, x => !!x.source) export const dndIsNewComponent = computed( diff --git a/packages/client/src/stores/events.js b/packages/client/src/stores/events.js new file mode 100644 index 0000000000..6a67554247 --- /dev/null +++ b/packages/client/src/stores/events.js @@ -0,0 +1,31 @@ +import { writable, get } from "svelte/store" + +const createEventStore = () => { + const initialState = { + eventResolvers: {}, + } + const store = writable(initialState) + + const actions = { + dispatchEvent: (type, data) => { + const id = Math.random() + return new Promise(resolve => { + window.parent.postMessage({ type, data, id }) + store.update(state => { + state.eventResolvers[id] = resolve + return state + }) + }) + }, + resolveEvent: data => { + get(store).eventResolvers[data]?.() + }, + } + + return { + subscribe: store.subscribe, + actions, + } +} + +export const eventStore = createEventStore() diff --git a/packages/client/src/stores/index.js b/packages/client/src/stores/index.js index 7a06e55a67..173f3ad4fe 100644 --- a/packages/client/src/stores/index.js +++ b/packages/client/src/stores/index.js @@ -15,6 +15,7 @@ export { uploadStore } from "./uploads.js" export { rowSelectionStore } from "./rowSelection.js" export { blockStore } from "./blocks.js" export { environmentStore } from "./environment" +export { eventStore } from "./events.js" export { dndStore, dndIndex, @@ -22,7 +23,6 @@ export { dndBounds, dndIsNewComponent, dndIsDragging, - dndDropped, } from "./dnd" // Context stores are layered and duplicated, so it is not a singleton diff --git a/packages/client/src/stores/screens.js b/packages/client/src/stores/screens.js index d9d91ad51a..6ef164dc9d 100644 --- a/packages/client/src/stores/screens.js +++ b/packages/client/src/stores/screens.js @@ -2,13 +2,7 @@ import { derived } from "svelte/store" import { routeStore } from "./routes" import { builderStore } from "./builder" import { appStore } from "./app" -import { - dndIndex, - dndParent, - dndIsNewComponent, - dndBounds, - dndDropped, -} from "./dnd.js" +import { dndIndex, dndParent, dndIsNewComponent, dndBounds } from "./dnd.js" import { RoleUtils } from "@budibase/frontend-core" import { findComponentById, findComponentParent } from "../utils/components.js" import { Helpers } from "@budibase/bbui" @@ -24,7 +18,6 @@ const createScreenStore = () => { dndIndex, dndIsNewComponent, dndBounds, - dndDropped, ], ([ $appStore, @@ -34,7 +27,6 @@ const createScreenStore = () => { $dndIndex, $dndIsNewComponent, $dndBounds, - $dndDropped, ]) => { let activeLayout, activeScreen let screens @@ -80,9 +72,6 @@ const createScreenStore = () => { activeScreen.props, selectedComponentId ) - const selectedComponent = selectedParent?._children?.find( - x => x._id === selectedComponentId - ) // Remove selected component from tree if we are moving an existing // component @@ -92,33 +81,25 @@ const createScreenStore = () => { ) } - // Insert placeholder component if dragging, or artificially insert - // the dropped component in the new location if the drop completed - let componentToInsert - if ($dndDropped && !$dndIsNewComponent) { - componentToInsert = selectedComponent - } else { - componentToInsert = { - _component: "@budibase/standard-components/container", - _id: DNDPlaceholderID, - _styles: { - normal: { - width: `${$dndBounds?.width || 400}px`, - height: `${$dndBounds?.height || 200}px`, - opacity: 0, - }, + // Insert placeholder component + const componentToInsert = { + _component: "@budibase/standard-components/container", + _id: DNDPlaceholderID, + _styles: { + normal: { + width: `${$dndBounds?.width || 400}px`, + height: `${$dndBounds?.height || 200}px`, + opacity: 0, }, - static: true, - } + }, + static: true, } - if (componentToInsert) { - let parent = findComponentById(activeScreen.props, $dndParent) - if (parent) { - if (!parent._children?.length) { - parent._children = [componentToInsert] - } else { - parent._children.splice($dndIndex, 0, componentToInsert) - } + let parent = findComponentById(activeScreen.props, $dndParent) + if (parent) { + if (!parent._children?.length) { + parent._children = [componentToInsert] + } else { + parent._children.splice($dndIndex, 0, componentToInsert) } } }