Refactor app preview event sending to support async callbacks in client library

This commit is contained in:
Andrew Kingston 2022-10-24 09:02:50 +01:00
parent 986141b8b1
commit 8d644c5a10
8 changed files with 199 additions and 216 deletions

View File

@ -103,7 +103,7 @@
) )
// Register handler to send custom to the preview // Register handler to send custom to the preview
$: store.actions.preview.registerEventHandler((name, payload) => { $: sendPreviewEvent = (name, payload) => {
iframe?.contentWindow.postMessage( iframe?.contentWindow.postMessage(
JSON.stringify({ JSON.stringify({
name, name,
@ -112,37 +112,30 @@
runtimeEvent: true, runtimeEvent: true,
}) })
) )
}) }
$: store.actions.preview.registerEventHandler(sendPreviewEvent)
// Update the iframe with the builder info to render the correct preview // Update the iframe with the builder info to render the correct preview
const refreshContent = message => { const refreshContent = message => {
iframe?.contentWindow.postMessage(message) iframe?.contentWindow.postMessage(message)
} }
const receiveMessage = message => { const receiveMessage = async message => {
const handlers = { if (!message?.data?.type) {
[MessageTypes.READY]: () => { return
// 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 // Await the event handler
messageHandler(message) 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 => { const handleBudibaseEvent = async event => {
@ -150,83 +143,94 @@
if (!type) { if (!type) {
return return
} }
if (type === MessageTypes.READY) {
try { // Initialise the app when mounted
if (type === "select-component" && data.id) { if ($store.clientFeatures.messagePassing) {
$store.selectedComponentId = data.id if (!loading) return
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}`)
} }
} 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(() => { onMount(() => {
window.addEventListener("message", receiveMessage) 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(() => { onDestroy(() => {
window.removeEventListener("message", receiveMessage) window.removeEventListener("message", receiveMessage)
if (iframe.contentWindow) {
if (!$store.clientFeatures.messagePassing) {
// Legacy - remove in later versions of BB
iframe.contentWindow.removeEventListener(
"bb-event",
handleBudibaseEvent
)
}
}
}) })
</script> </script>

View File

@ -22,6 +22,9 @@
$: target = $dndStore.target $: target = $dndStore.target
$: drop = $dndStore.drop $: drop = $dndStore.drop
// Local flag for whether we are awaiting an async drop event
let dropping = false
const insideGrid = e => { const insideGrid = e => {
return e.target return e.target
?.closest?.(".component") ?.closest?.(".component")
@ -48,6 +51,10 @@
// Callback when drag stops (whether dropped or not) // Callback when drag stops (whether dropped or not)
const stopDragging = () => { const stopDragging = () => {
if (dropping) {
return
}
// Reset listener // Reset listener
if (source?.id) { if (source?.id) {
const component = document.getElementsByClassName(source?.id)[0] const component = document.getElementsByClassName(source?.id)[0]
@ -57,9 +64,7 @@
} }
// Reset state // Reset state
if (!$dndStore.dropped) { dndStore.actions.reset()
dndStore.actions.reset()
}
} }
// Callback when initially starting a drag on a draggable component // Callback when initially starting a drag on a draggable component
@ -253,18 +258,21 @@
} }
// Callback when dropping a drag on top of some component // Callback when dropping a drag on top of some component
const onDrop = () => { const onDrop = async () => {
if (!source || !drop?.parent || drop?.index == null) { if (!source || !drop?.parent || drop?.index == null) {
return return
} }
// Check if we're adding a new component rather than moving one // Check if we're adding a new component rather than moving one
if (source.newComponentType) { if (source.newComponentType) {
builderStore.actions.dropNewComponent( dropping = true
await builderStore.actions.dropNewComponent(
source.newComponentType, source.newComponentType,
drop.parent, drop.parent,
drop.index drop.index
) )
dropping = false
stopDragging()
return return
} }
@ -301,12 +309,14 @@
} }
if (legacyDropTarget && legacyDropMode) { if (legacyDropTarget && legacyDropMode) {
dndStore.actions.markDropped() dropping = true
builderStore.actions.moveComponent( await builderStore.actions.moveComponent(
source.id, source.id,
legacyDropTarget, legacyDropTarget,
legacyDropMode legacyDropMode
) )
dropping = false
stopDragging()
} }
} }

View File

@ -7,6 +7,7 @@ import {
componentStore, componentStore,
environmentStore, environmentStore,
dndStore, dndStore,
eventStore,
} from "./stores" } from "./stores"
import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-rollup.js" import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-rollup.js"
import { get } from "svelte/store" import { get } from "svelte/store"
@ -42,9 +43,9 @@ const loadBudibase = async () => {
}) })
// Reset DND state if we completed a successful drop // Reset DND state if we completed a successful drop
if (get(dndStore).dropped) { // if (get(dndStore).dropped) {
dndStore.actions.reset() // dndStore.actions.reset()
} // }
// Set app ID - this window flag is set by both the preview and the real // Set app ID - this window flag is set by both the preview and the real
// server rendered app HTML // server rendered app HTML
@ -59,15 +60,17 @@ const loadBudibase = async () => {
devToolsStore.actions.setEnabled(enableDevTools) devToolsStore.actions.setEnabled(enableDevTools)
// Register handler for runtime events from the builder // Register handler for runtime events from the builder
window.handleBuilderRuntimeEvent = (name, payload) => { window.handleBuilderRuntimeEvent = (type, data) => {
if (!window["##BUDIBASE_IN_BUILDER##"]) { if (!window["##BUDIBASE_IN_BUILDER##"]) {
return return
} }
if (name === "eject-block") { if (type === "event-completed") {
const block = blockStore.actions.getBlock(payload) eventStore.actions.resolveEvent(data)
} else if (type === "eject-block") {
const block = blockStore.actions.getBlock(data)
block?.eject() block?.eject()
} else if (name === "dragging-new-component") { } else if (type === "dragging-new-component") {
const { dragging, component } = payload const { dragging, component } = data
if (dragging) { if (dragging) {
const definition = const definition =
componentStore.actions.getComponentDefinition(component) componentStore.actions.getComponentDefinition(component)

View File

@ -1,10 +1,7 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { API } from "api" import { API } from "api"
import { devToolsStore } from "./devTools.js" import { devToolsStore } from "./devTools.js"
import { eventStore } from "./events.js"
const dispatchEvent = (type, data = {}) => {
window.parent.postMessage({ type, data })
}
const createBuilderStore = () => { const createBuilderStore = () => {
const initialState = { const initialState = {
@ -19,6 +16,7 @@ const createBuilderStore = () => {
navigation: null, navigation: null,
hiddenComponentIds: [], hiddenComponentIds: [],
usedPlugins: null, usedPlugins: null,
eventResolvers: {},
// Legacy - allow the builder to specify a layout // Legacy - allow the builder to specify a layout
layout: null, layout: null,
@ -35,25 +33,25 @@ const createBuilderStore = () => {
selectedComponentId: id, selectedComponentId: id,
})) }))
devToolsStore.actions.setAllowSelection(false) devToolsStore.actions.setAllowSelection(false)
dispatchEvent("select-component", { id }) eventStore.actions.dispatchEvent("select-component", { id })
}, },
updateProp: (prop, value) => { updateProp: (prop, value) => {
dispatchEvent("update-prop", { prop, value }) eventStore.actions.dispatchEvent("update-prop", { prop, value })
}, },
updateStyles: (styles, id) => { updateStyles: (styles, id) => {
dispatchEvent("update-styles", { styles, id }) eventStore.actions.dispatchEvent("update-styles", { styles, id })
}, },
keyDown: (key, ctrlKey) => { keyDown: (key, ctrlKey) => {
dispatchEvent("key-down", { key, ctrlKey }) eventStore.actions.dispatchEvent("key-down", { key, ctrlKey })
}, },
duplicateComponent: id => { duplicateComponent: id => {
dispatchEvent("duplicate-component", { id }) eventStore.actions.dispatchEvent("duplicate-component", { id })
}, },
deleteComponent: id => { deleteComponent: id => {
dispatchEvent("delete-component", { id }) eventStore.actions.dispatchEvent("delete-component", { id })
}, },
notifyLoaded: () => { notifyLoaded: () => {
dispatchEvent("preview-loaded") eventStore.actions.dispatchEvent("preview-loaded")
}, },
analyticsPing: async () => { analyticsPing: async () => {
try { try {
@ -62,15 +60,15 @@ const createBuilderStore = () => {
// Do nothing // Do nothing
} }
}, },
moveComponent: (componentId, destinationComponentId, mode) => { moveComponent: async (componentId, destinationComponentId, mode) => {
dispatchEvent("move-component", { await eventStore.actions.dispatchEvent("move-component", {
componentId, componentId,
destinationComponentId, destinationComponentId,
mode, mode,
}) })
}, },
dropNewComponent: (component, parent, index) => { dropNewComponent: (component, parent, index) => {
dispatchEvent("drop-new-component", { eventStore.actions.dispatchEvent("drop-new-component", {
component, component,
parent, parent,
index, index,
@ -83,16 +81,16 @@ const createBuilderStore = () => {
store.update(state => ({ ...state, editMode: enabled })) store.update(state => ({ ...state, editMode: enabled }))
}, },
clickNav: () => { clickNav: () => {
dispatchEvent("click-nav") eventStore.actions.dispatchEvent("click-nav")
}, },
requestAddComponent: () => { requestAddComponent: () => {
dispatchEvent("request-add-component") eventStore.actions.dispatchEvent("request-add-component")
}, },
highlightSetting: setting => { highlightSetting: setting => {
dispatchEvent("highlight-setting", { setting }) eventStore.actions.dispatchEvent("highlight-setting", { setting })
}, },
ejectBlock: (id, definition) => { ejectBlock: (id, definition) => {
dispatchEvent("eject-block", { id, definition }) eventStore.actions.dispatchEvent("eject-block", { id, definition })
}, },
updateUsedPlugin: (name, hash) => { updateUsedPlugin: (name, hash) => {
// Check if we used this plugin // Check if we used this plugin
@ -109,7 +107,7 @@ const createBuilderStore = () => {
} }
// Notify the builder so we can reload component definitions // Notify the builder so we can reload component definitions
dispatchEvent("reload-plugin") eventStore.actions.dispatchEvent("reload-plugin")
}, },
} }
return { return {

View File

@ -11,9 +11,6 @@ const createDndStore = () => {
// Info about where the component would be dropped // Info about where the component would be dropped
drop: null, drop: null,
// Whether the current drop has been completed successfully
dropped: false,
} }
const store = writable(initialState) const store = writable(initialState)
@ -63,13 +60,6 @@ const createDndStore = () => {
store.set(initialState) store.set(initialState)
} }
const markDropped = () => {
store.update(state => {
state.dropped = true
return state
})
}
return { return {
subscribe: store.subscribe, subscribe: store.subscribe,
actions: { actions: {
@ -78,7 +68,6 @@ const createDndStore = () => {
updateTarget, updateTarget,
updateDrop, updateDrop,
reset, reset,
markDropped,
}, },
} }
} }
@ -91,7 +80,6 @@ export const dndStore = createDndStore()
// or components which depend on DND state unless values actually change. // or components which depend on DND state unless values actually change.
export const dndParent = computed(dndStore, x => x.drop?.parent) export const dndParent = computed(dndStore, x => x.drop?.parent)
export const dndIndex = computed(dndStore, x => x.drop?.index) 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 dndBounds = computed(dndStore, x => x.source?.bounds)
export const dndIsDragging = computed(dndStore, x => !!x.source) export const dndIsDragging = computed(dndStore, x => !!x.source)
export const dndIsNewComponent = computed( export const dndIsNewComponent = computed(

View File

@ -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()

View File

@ -15,6 +15,7 @@ export { uploadStore } from "./uploads.js"
export { rowSelectionStore } from "./rowSelection.js" export { rowSelectionStore } from "./rowSelection.js"
export { blockStore } from "./blocks.js" export { blockStore } from "./blocks.js"
export { environmentStore } from "./environment" export { environmentStore } from "./environment"
export { eventStore } from "./events.js"
export { export {
dndStore, dndStore,
dndIndex, dndIndex,
@ -22,7 +23,6 @@ export {
dndBounds, dndBounds,
dndIsNewComponent, dndIsNewComponent,
dndIsDragging, dndIsDragging,
dndDropped,
} from "./dnd" } from "./dnd"
// Context stores are layered and duplicated, so it is not a singleton // Context stores are layered and duplicated, so it is not a singleton

View File

@ -2,13 +2,7 @@ import { derived } from "svelte/store"
import { routeStore } from "./routes" import { routeStore } from "./routes"
import { builderStore } from "./builder" import { builderStore } from "./builder"
import { appStore } from "./app" import { appStore } from "./app"
import { import { dndIndex, dndParent, dndIsNewComponent, dndBounds } from "./dnd.js"
dndIndex,
dndParent,
dndIsNewComponent,
dndBounds,
dndDropped,
} from "./dnd.js"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { findComponentById, findComponentParent } from "../utils/components.js" import { findComponentById, findComponentParent } from "../utils/components.js"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
@ -24,7 +18,6 @@ const createScreenStore = () => {
dndIndex, dndIndex,
dndIsNewComponent, dndIsNewComponent,
dndBounds, dndBounds,
dndDropped,
], ],
([ ([
$appStore, $appStore,
@ -34,7 +27,6 @@ const createScreenStore = () => {
$dndIndex, $dndIndex,
$dndIsNewComponent, $dndIsNewComponent,
$dndBounds, $dndBounds,
$dndDropped,
]) => { ]) => {
let activeLayout, activeScreen let activeLayout, activeScreen
let screens let screens
@ -80,9 +72,6 @@ const createScreenStore = () => {
activeScreen.props, activeScreen.props,
selectedComponentId selectedComponentId
) )
const selectedComponent = selectedParent?._children?.find(
x => x._id === selectedComponentId
)
// Remove selected component from tree if we are moving an existing // Remove selected component from tree if we are moving an existing
// component // component
@ -92,33 +81,25 @@ const createScreenStore = () => {
) )
} }
// Insert placeholder component if dragging, or artificially insert // Insert placeholder component
// the dropped component in the new location if the drop completed const componentToInsert = {
let componentToInsert _component: "@budibase/standard-components/container",
if ($dndDropped && !$dndIsNewComponent) { _id: DNDPlaceholderID,
componentToInsert = selectedComponent _styles: {
} else { normal: {
componentToInsert = { width: `${$dndBounds?.width || 400}px`,
_component: "@budibase/standard-components/container", height: `${$dndBounds?.height || 200}px`,
_id: DNDPlaceholderID, opacity: 0,
_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)
let parent = findComponentById(activeScreen.props, $dndParent) if (parent) {
if (parent) { if (!parent._children?.length) {
if (!parent._children?.length) { parent._children = [componentToInsert]
parent._children = [componentToInsert] } else {
} else { parent._children.splice($dndIndex, 0, componentToInsert)
parent._children.splice($dndIndex, 0, componentToInsert)
}
} }
} }
} }