From 4089b52c538781e4b1781f17dfdad851afeaa501 Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Tue, 18 Feb 2020 12:29:38 +0000 Subject: [PATCH] Client Api - New state binding method (#105) * new binding... - state manager - one store per screen - not passing * client lib binding - tests passing * binding fully working again post stateManager * bugfix with button component * Control flow ("code") now working, tests passing * Events List now reading from component definition * fix to button.svelte - missing props._children --- .../src/builderStore/insertCodeMetadata.js | 17 ++ packages/builder/src/builderStore/store.js | 3 + .../EventsEditor/EventEditorModal.svelte | 8 +- .../EventsEditor/EventsEditor.svelte | 14 +- packages/client/package.json | 1 + packages/client/src/createApp.js | 110 ++----- packages/client/src/index.js | 4 +- packages/client/src/render/attachChildren.js | 72 +++-- ...Component.js => prepareRenderComponent.js} | 53 ++-- packages/client/src/state/bbComponentApi.js | 70 +++++ packages/client/src/state/stateBinding.js | 3 +- packages/client/src/state/stateManager.js | 288 ++++++++++++++++++ packages/client/tests/bindingDom.spec.js | 174 +++++++++++ packages/client/tests/testAppDef.js | 58 +++- .../server/utilities/builder/buildPage.js | 3 + .../utilities/builder/deleteCodeMeta.js | 9 + packages/server/utilities/builder/index.js | 6 +- .../standard-components/src/button.svelte | 5 +- 18 files changed, 743 insertions(+), 155 deletions(-) create mode 100644 packages/builder/src/builderStore/insertCodeMetadata.js rename packages/client/src/render/{renderComponent.js => prepareRenderComponent.js} (50%) create mode 100644 packages/client/src/state/bbComponentApi.js create mode 100644 packages/client/src/state/stateManager.js create mode 100644 packages/server/utilities/builder/deleteCodeMeta.js diff --git a/packages/builder/src/builderStore/insertCodeMetadata.js b/packages/builder/src/builderStore/insertCodeMetadata.js new file mode 100644 index 0000000000..00c18901b5 --- /dev/null +++ b/packages/builder/src/builderStore/insertCodeMetadata.js @@ -0,0 +1,17 @@ +export const insertCodeMetadata = props => { + if (props._code && props._code.length > 0) { + props._codeMeta = codeMetaData(props._code) + } + + if (!props._children || props._children.length === 0) return + + for (let child of props._children) { + insertCodeMetadata(child) + } +} + +const codeMetaData = code => { + return { + dependsOnStore: RegExp(/(store.)/g).test(code), + } +} diff --git a/packages/builder/src/builderStore/store.js b/packages/builder/src/builderStore/store.js index b0bcaf31f9..c3dad0a867 100644 --- a/packages/builder/src/builderStore/store.js +++ b/packages/builder/src/builderStore/store.js @@ -39,6 +39,7 @@ import { } from "./loadComponentLibraries" import { buildCodeForScreens } from "./buildCodeForScreens" import { generate_screen_css } from "./generate_css" +import { insertCodeMetadata } from "./insertCodeMetadata" // import { uuid } from "./uuid" let appname = "" @@ -818,6 +819,8 @@ const setCurrentScreenFunctions = s => { s.currentPreviewItem === "screen" ? buildCodeForScreens([s.currentPreviewItem]) : "({});" + + insertCodeMetadata(s.currentPreviewItem.props) } const setScreenType = store => type => { diff --git a/packages/builder/src/userInterface/EventsEditor/EventEditorModal.svelte b/packages/builder/src/userInterface/EventsEditor/EventEditorModal.svelte index d0feb89bd0..56370d7a89 100644 --- a/packages/builder/src/userInterface/EventsEditor/EventEditorModal.svelte +++ b/packages/builder/src/userInterface/EventsEditor/EventEditorModal.svelte @@ -11,15 +11,17 @@ import { EVENT_TYPE_MEMBER_NAME } from "../../common/eventHandlers" export let event - export let eventOptions + export let eventOptions = [] export let open export let onClose export let onPropChanged - let eventType = "onClick" + let eventType = "" let draftEventHandler = { parameters: [] } $: eventData = event || { handlers: [] } + $: if (!eventOptions.includes(eventType) && eventOptions.length > 0) + eventType = eventOptions[0].name const closeModal = () => { onClose() @@ -74,7 +76,7 @@
Event Type
{@html getIcon('info', 20)} - {#each eventOptions as option} {/each} diff --git a/packages/builder/src/userInterface/EventsEditor/EventsEditor.svelte b/packages/builder/src/userInterface/EventsEditor/EventsEditor.svelte index 108fd383a2..c9074498ea 100644 --- a/packages/builder/src/userInterface/EventsEditor/EventsEditor.svelte +++ b/packages/builder/src/userInterface/EventsEditor/EventsEditor.svelte @@ -33,17 +33,11 @@ let events = [] let selectedEvent = null - $: { - events = Object.keys(component) - .filter(key => findType(key) === EVENT_TYPE) - .map(key => ({ name: key, handlers: component[key] })) - } - - function findType(propName) { - if (!component._component) return - return components.find(({ name }) => name === component._component) - .props[propName] + const componentDefinition = components.find(c => c.name === component._component) + events = Object.keys(componentDefinition.props) + .filter(propName => componentDefinition.props[propName].type === EVENT_TYPE) + .map(propName => ({ name: propName, handlers: (component[propName] || []) })) } const openModal = event => { diff --git a/packages/client/package.json b/packages/client/package.json index c08cc4affb..04c180beb5 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -36,6 +36,7 @@ "dependencies": { "@nx-js/compiler-util": "^2.0.0", "bcryptjs": "^2.4.3", + "deep-equal": "^2.0.1", "lodash": "^4.17.15", "lunr": "^2.3.5", "regexparam": "^1.3.0", diff --git a/packages/client/src/createApp.js b/packages/client/src/createApp.js index d97ca46896..0f4d54fdca 100644 --- a/packages/client/src/createApp.js +++ b/packages/client/src/createApp.js @@ -1,74 +1,46 @@ import { writable } from "svelte/store" import { createCoreApi } from "./core" -import { getStateOrValue } from "./state/getState" -import { setState, setStateFromBinding } from "./state/setState" -import { trimSlash } from "./common/trimSlash" -import { isBound } from "./state/isState" import { attachChildren } from "./render/attachChildren" -import { createTreeNode } from "./render/renderComponent" +import { createTreeNode } from "./render/prepareRenderComponent" import { screenRouter } from "./render/screenRouter" +import { createStateManager } from "./state/stateManager" export const createApp = ( - document, componentLibraries, frontendDefinition, backendDefinition, user, - uiFunctions + uiFunctions, + window ) => { const coreApi = createCoreApi(backendDefinition, user) backendDefinition.hierarchy = coreApi.templateApi.constructHierarchy( backendDefinition.hierarchy ) - const pageStore = writable({ - _bbuser: user, - }) - - const relativeUrl = url => - frontendDefinition.appRootPath - ? frontendDefinition.appRootPath + "/" + trimSlash(url) - : url - - const apiCall = method => (url, body) => - fetch(relativeUrl(url), { - method: method, - headers: { - "Content-Type": "application/json", - }, - body: body && JSON.stringify(body), - }) - - const api = { - post: apiCall("POST"), - get: apiCall("GET"), - patch: apiCall("PATCH"), - delete: apiCall("DELETE"), - } - - const safeCallEvent = (event, context) => { - const isFunction = obj => - !!(obj && obj.constructor && obj.call && obj.apply) - - if (isFunction(event)) event(context) - } let routeTo - let currentScreenStore - let currentScreenUbsubscribe let currentUrl + let screenStateManager const onScreenSlotRendered = screenSlotNode => { const onScreenSelected = (screen, store, url) => { - const { getInitialiseParams, unsubscribe } = attachChildrenParams(store) + const stateManager = createStateManager({ + store, + coreApi, + frontendDefinition, + componentLibraries, + uiFunctions, + onScreenSlotRendered: () => {}, + }) + const getAttchChildrenParams = attachChildrenParams(stateManager) screenSlotNode.props._children = [screen.props] - const initialiseChildParams = getInitialiseParams(screenSlotNode) + const initialiseChildParams = getAttchChildrenParams(screenSlotNode) attachChildren(initialiseChildParams)(screenSlotNode.rootElement, { hydrate: true, force: true, }) - if (currentScreenUbsubscribe) currentScreenUbsubscribe() - currentScreenUbsubscribe = unsubscribe - currentScreenStore = store + if (screenStateManager) screenStateManager.destroy() + screenStateManager = stateManager currentUrl = url } @@ -76,46 +48,28 @@ export const createApp = ( routeTo(currentUrl || window.location.pathname) } - const attachChildrenParams = store => { - let currentState = null - const unsubscribe = store.subscribe(s => { - currentState = s - }) - + const attachChildrenParams = stateManager => { const getInitialiseParams = treeNode => ({ - bb: getBbClientApi, - coreApi, - store, - document, componentLibraries, - frontendDefinition, uiFunctions, treeNode, onScreenSlotRendered, + setupState: stateManager.setup, + getCurrentState: stateManager.getCurrentState, }) - const getBbClientApi = (treeNode, componentProps) => { - return { - attachChildren: attachChildren(getInitialiseParams(treeNode)), - context: treeNode.context, - props: componentProps, - call: safeCallEvent, - setStateFromBinding: (binding, value) => - setStateFromBinding(store, binding, value), - setState: (path, value) => setState(store, path, value), - getStateOrValue: (prop, currentContext) => - getStateOrValue(currentState, prop, currentContext), - store, - relativeUrl, - api, - isBound, - parent, - } - } - return { getInitialiseParams, unsubscribe } + return getInitialiseParams } let rootTreeNode + const pageStateManager = createStateManager({ + store: writable({ _bbuser: user }), + coreApi, + frontendDefinition, + componentLibraries, + uiFunctions, + onScreenSlotRendered, + }) const initialisePage = (page, target, urlPath) => { currentUrl = urlPath @@ -125,7 +79,7 @@ export const createApp = ( _children: [page.props], } rootTreeNode.rootElement = target - const { getInitialiseParams } = attachChildrenParams(pageStore) + const getInitialiseParams = attachChildrenParams(pageStateManager) const initChildParams = getInitialiseParams(rootTreeNode) attachChildren(initChildParams)(target, { @@ -137,8 +91,8 @@ export const createApp = ( } return { initialisePage, - screenStore: () => currentScreenStore, - pageStore: () => pageStore, + screenStore: () => screenStateManager.store, + pageStore: () => pageStateManager.store, routeTo: () => routeTo, rootNode: () => rootTreeNode, } diff --git a/packages/client/src/index.js b/packages/client/src/index.js index a5644c9f49..54b5aecacc 100644 --- a/packages/client/src/index.js +++ b/packages/client/src/index.js @@ -43,12 +43,12 @@ export const loadBudibase = async (opts) => { componentLibraries[builtinLibName] = builtins(_window) const { initialisePage, screenStore, pageStore, routeTo, rootNode } = createApp( - _window.document, componentLibraries, frontendDefinition, backendDefinition, user, - uiFunctions || {} + uiFunctions || {}, + _window ) const route = _window.location diff --git a/packages/client/src/render/attachChildren.js b/packages/client/src/render/attachChildren.js index 0fe127dc7e..ef46271028 100644 --- a/packages/client/src/render/attachChildren.js +++ b/packages/client/src/render/attachChildren.js @@ -1,19 +1,17 @@ -import { setupBinding } from "../state/stateBinding" import { split, last } from "lodash/fp" import { $ } from "../core/common" -import { renderComponent } from "./renderComponent" +import { prepareRenderComponent } from "./prepareRenderComponent" import { isScreenSlot } from "./builtinComponents" +import deepEqual from "deep-equal" export const attachChildren = initialiseOpts => (htmlElement, options) => { const { uiFunctions, - bb, - coreApi, - store, componentLibraries, treeNode, - frontendDefinition, onScreenSlotRendered, + setupState, + getCurrentState, } = initialiseOpts const anchor = options && options.anchor ? options.anchor : null @@ -34,50 +32,46 @@ export const attachChildren = initialiseOpts => (htmlElement, options) => { htmlElement.classList.add(`lay-${treeNode.props._id}`) - const renderedComponents = [] + const childNodes = [] for (let childProps of treeNode.props._children) { const { componentName, libName } = splitName(childProps._component) if (!componentName || !libName) return - const { initialProps, bind } = setupBinding( - store, - childProps, - coreApi, - frontendDefinition.appRootPath - ) - const componentConstructor = componentLibraries[libName][componentName] - const renderedComponentsThisIteration = renderComponent({ + const childNodesThisIteration = prepareRenderComponent({ props: childProps, parentNode: treeNode, componentConstructor, uiFunctions, htmlElement, anchor, - initialProps, - bb, + getCurrentState }) - if ( - onScreenSlotRendered && - isScreenSlot(childProps._component) && - renderedComponentsThisIteration.length > 0 - ) { - // assuming there is only ever one screen slot - onScreenSlotRendered(renderedComponentsThisIteration[0]) - } - - for (let comp of renderedComponentsThisIteration) { - comp.unsubscribe = bind(comp.component) - renderedComponents.push(comp) + for (let childNode of childNodesThisIteration) { + childNodes.push(childNode) } } - treeNode.children = renderedComponents + if (areTreeNodesEqual(treeNode.children, childNodes)) return treeNode.children - return renderedComponents + for (let node of childNodes) { + const initialProps = setupState(node) + node.render(initialProps) + } + + const screenSlot = childNodes.find(n => isScreenSlot(n.props._component)) + + if (onScreenSlotRendered && screenSlot) { + // assuming there is only ever one screen slot + onScreenSlotRendered(screenSlot) + } + + treeNode.children = childNodes + + return childNodes } const splitName = fullname => { @@ -90,3 +84,19 @@ const splitName = fullname => { return { libName, componentName } } + +const areTreeNodesEqual = (children1, children2) => { + if (children1.length !== children2.length) return false + if (children1 === children2) return true + + let isEqual = false + for (let i = 0; i < children1.length; i++) { + isEqual = deepEqual(children1[i].context, children2[i].context) + if (!isEqual) return false + if (isScreenSlot(children1[i].parentNode.props._component)) { + isEqual = deepEqual(children1[i].props, children2[i].props) + } + if (!isEqual) return false + } + return true +} diff --git a/packages/client/src/render/renderComponent.js b/packages/client/src/render/prepareRenderComponent.js similarity index 50% rename from packages/client/src/render/renderComponent.js rename to packages/client/src/render/prepareRenderComponent.js index 962555f9a9..d79265093a 100644 --- a/packages/client/src/render/renderComponent.js +++ b/packages/client/src/render/prepareRenderComponent.js @@ -1,22 +1,21 @@ -export const renderComponent = ({ +export const prepareRenderComponent = ({ componentConstructor, uiFunctions, htmlElement, anchor, props, - initialProps, - bb, parentNode, + getCurrentState, }) => { - const func = initialProps._id ? uiFunctions[initialProps._id] : undefined + const func = props._id ? uiFunctions[props._id] : undefined const parentContext = (parentNode && parentNode.context) || {} - let renderedNodes = [] - const render = context => { + let nodesToRender = [] + const createNodeAndRender = context => { let componentContext = parentContext if (context) { - componentContext = { ...componentContext } + componentContext = { ...context } componentContext.$parent = parentContext } @@ -24,33 +23,31 @@ export const renderComponent = ({ thisNode.context = componentContext thisNode.parentNode = parentNode thisNode.props = props + nodesToRender.push(thisNode) - parentNode.children.push(thisNode) - renderedNodes.push(thisNode) + thisNode.render = initialProps => { + thisNode.component = new componentConstructor({ + target: htmlElement, + props: initialProps, + hydrate: false, + anchor, + }) + thisNode.rootElement = + htmlElement.children[htmlElement.children.length - 1] - initialProps._bb = bb(thisNode, props) - - thisNode.component = new componentConstructor({ - target: htmlElement, - props: initialProps, - hydrate: false, - anchor, - }) - - thisNode.rootElement = htmlElement.children[htmlElement.children.length - 1] - - if (initialProps._id && thisNode.rootElement) { - thisNode.rootElement.classList.add(`pos-${initialProps._id}`) + if (props._id && thisNode.rootElement) { + thisNode.rootElement.classList.add(`pos-${props._id}`) + } } } if (func) { - func(render, parentContext) + func(createNodeAndRender, parentContext, getCurrentState()) } else { - render() + createNodeAndRender() } - return renderedNodes + return nodesToRender } export const createTreeNode = () => ({ @@ -59,8 +56,10 @@ export const createTreeNode = () => ({ rootElement: null, parentNode: null, children: [], + bindings: [], component: null, unsubscribe: () => {}, + render: () => {}, get destroy() { const node = this return () => { @@ -71,6 +70,10 @@ export const createTreeNode = () => ({ child.destroy() } } + for (let onDestroyItem of node.onDestroy) { + onDestroyItem() + } } }, + onDestroy: [], }) diff --git a/packages/client/src/state/bbComponentApi.js b/packages/client/src/state/bbComponentApi.js new file mode 100644 index 0000000000..affb4e1188 --- /dev/null +++ b/packages/client/src/state/bbComponentApi.js @@ -0,0 +1,70 @@ +import { getStateOrValue } from "./getState" +import { setState, setStateFromBinding } from "./setState" +import { trimSlash } from "../common/trimSlash" +import { isBound } from "./isState" +import { attachChildren } from "../render/attachChildren" + +export const bbFactory = ({ + store, + getCurrentState, + frontendDefinition, + componentLibraries, + uiFunctions, + onScreenSlotRendered, +}) => { + const relativeUrl = url => + frontendDefinition.appRootPath + ? frontendDefinition.appRootPath + "/" + trimSlash(url) + : url + + const apiCall = method => (url, body) => + fetch(relativeUrl(url), { + method: method, + headers: { + "Content-Type": "application/json", + }, + body: body && JSON.stringify(body), + }) + + const api = { + post: apiCall("POST"), + get: apiCall("GET"), + patch: apiCall("PATCH"), + delete: apiCall("DELETE"), + } + + const safeCallEvent = (event, context) => { + const isFunction = obj => + !!(obj && obj.constructor && obj.call && obj.apply) + + if (isFunction(event)) event(context) + } + + return (treeNode, setupState) => { + const attachParams = { + componentLibraries, + uiFunctions, + treeNode, + onScreenSlotRendered, + setupState, + getCurrentState, + } + + return { + attachChildren: attachChildren(attachParams), + context: treeNode.context, + props: treeNode.props, + call: safeCallEvent, + setStateFromBinding: (binding, value) => + setStateFromBinding(store, binding, value), + setState: (path, value) => setState(store, path, value), + getStateOrValue: (prop, currentContext) => + getStateOrValue(getCurrentState(), prop, currentContext), + store: store, + relativeUrl, + api, + isBound, + parent, + } + } +} diff --git a/packages/client/src/state/stateBinding.js b/packages/client/src/state/stateBinding.js index e684a024af..ca7d8d3a3f 100644 --- a/packages/client/src/state/stateBinding.js +++ b/packages/client/src/state/stateBinding.js @@ -23,7 +23,8 @@ const isMetaProp = propName => propName === "_component" || propName === "_children" || propName === "_id" || - propName === "_style" + propName === "_style" || + propName === "_code" export const setupBinding = (store, rootProps, coreApi, context, rootPath) => { const rootInitialProps = { ...rootProps } diff --git a/packages/client/src/state/stateManager.js b/packages/client/src/state/stateManager.js new file mode 100644 index 0000000000..ba15f52448 --- /dev/null +++ b/packages/client/src/state/stateManager.js @@ -0,0 +1,288 @@ +import { + isEventType, + eventHandlers, + EVENT_TYPE_MEMBER_NAME, +} from "./eventHandlers" +import { bbFactory } from "./bbComponentApi" +import { getState } from "./getState" +import { attachChildren } from "../render/attachChildren" + +import { + isBound, + takeStateFromStore, + takeStateFromContext, + takeStateFromEventParameters, + BB_STATE_FALLBACK, + BB_STATE_BINDINGPATH, + BB_STATE_BINDINGSOURCE, +} from "./isState" + +const doNothing = () => {} +doNothing.isPlaceholder = true + +const isMetaProp = propName => + propName === "_component" || + propName === "_children" || + propName === "_id" || + propName === "_style" || + propName === "_code" || + propName === "_codeMeta" + +export const createStateManager = ({ + store, + coreApi, + rootPath, + frontendDefinition, + componentLibraries, + uiFunctions, + onScreenSlotRendered, +}) => { + let handlerTypes = eventHandlers(store, coreApi, rootPath) + let currentState + + // any nodes that have props that are bound to the store + let nodesBoundByProps = [] + + // any node whose children depend on code, that uses the store + let nodesWithCodeBoundChildren = [] + + const getCurrentState = () => currentState + const registerBindings = _registerBindings( + nodesBoundByProps, + nodesWithCodeBoundChildren + ) + const bb = bbFactory({ + store, + getCurrentState, + frontendDefinition, + componentLibraries, + uiFunctions, + onScreenSlotRendered, + }) + + const setup = _setup(handlerTypes, getCurrentState, registerBindings, bb) + + const unsubscribe = store.subscribe( + onStoreStateUpdated({ + setCurrentState: s => (currentState = s), + getCurrentState, + nodesWithCodeBoundChildren, + nodesBoundByProps, + uiFunctions, + componentLibraries, + onScreenSlotRendered, + setupState: setup, + }) + ) + + return { + setup, + destroy: () => unsubscribe(), + getCurrentState, + store, + } +} + +const onStoreStateUpdated = ({ + setCurrentState, + getCurrentState, + nodesWithCodeBoundChildren, + nodesBoundByProps, + uiFunctions, + componentLibraries, + onScreenSlotRendered, + setupState, +}) => s => { + setCurrentState(s) + + // the original array gets changed by components' destroy() + // so we make a clone and check if they are still in the original + const nodesWithBoundChildren_clone = [...nodesWithCodeBoundChildren] + for (let node of nodesWithBoundChildren_clone) { + if (!nodesWithCodeBoundChildren.includes(node)) continue + attachChildren({ + uiFunctions, + componentLibraries, + treeNode: node, + onScreenSlotRendered, + setupState, + getCurrentState, + })(node.rootElement, { hydrate: true, force: true }) + } + + for (let node of nodesBoundByProps) { + setNodeState(s, node) + } +} + +const _registerBindings = (nodesBoundByProps, nodesWithCodeBoundChildren) => ( + node, + bindings +) => { + if (bindings.length > 0) { + node.bindings = bindings + nodesBoundByProps.push(node) + const onDestroy = () => { + nodesBoundByProps = nodesBoundByProps.filter(n => n === node) + node.onDestroy = node.onDestroy.filter(d => d === onDestroy) + } + node.onDestroy.push(onDestroy) + } + if ( + node.props._children && + node.props._children.filter(c => c._codeMeta && c._codeMeta.dependsOnStore) + .length > 0 + ) { + nodesWithCodeBoundChildren.push(node) + const onDestroy = () => { + nodesWithCodeBoundChildren = nodesWithCodeBoundChildren.filter( + n => n === node + ) + node.onDestroy = node.onDestroy.filter(d => d === onDestroy) + } + node.onDestroy.push(onDestroy) + } +} + +const setNodeState = (storeState, node) => { + if (!node.component) return + const newProps = { ...node.bindings.initialProps } + + for (let binding of node.bindings) { + const val = getState(storeState, binding.path, binding.fallback) + + if (val === undefined && newProps[binding.propName] !== undefined) { + delete newProps[binding.propName] + } + + if (val !== undefined) { + newProps[binding.propName] = val + } + } + + node.component.$set(newProps) +} + +const _setup = ( + handlerTypes, + getCurrentState, + registerBindings, + bb +) => node => { + const props = node.props + const context = node.context || {} + const initialProps = { ...props } + const storeBoundProps = [] + const currentStoreState = getCurrentState() + + for (let propName in props) { + if (isMetaProp(propName)) continue + + const val = props[propName] + + if (isBound(val) && takeStateFromStore(val)) { + const path = BindingPath(val) + const source = BindingSource(val) + const fallback = BindingFallback(val) + + storeBoundProps.push({ + path, + fallback, + propName, + source, + }) + + initialProps[propName] = !currentStoreState + ? fallback + : getState( + currentStoreState, + BindingPath(val), + BindingFallback(val), + BindingSource(val) + ) + } else if (isBound(val) && takeStateFromContext(val)) { + initialProps[propName] = !context + ? val + : getState( + context, + BindingPath(val), + BindingFallback(val), + BindingSource(val) + ) + } else if (isEventType(val)) { + const handlersInfos = [] + for (let e of val) { + const handlerInfo = { + handlerType: e[EVENT_TYPE_MEMBER_NAME], + parameters: e.parameters, + } + const resolvedParams = {} + for (let paramName in handlerInfo.parameters) { + const paramValue = handlerInfo.parameters[paramName] + if (!isBound(paramValue)) { + resolvedParams[paramName] = () => paramValue + continue + } else if (takeStateFromContext(paramValue)) { + const val = getState( + context, + paramValue[BB_STATE_BINDINGPATH], + paramValue[BB_STATE_FALLBACK] + ) + resolvedParams[paramName] = () => val + } else if (takeStateFromStore(paramValue)) { + resolvedParams[paramName] = () => + getState( + getCurrentState(), + paramValue[BB_STATE_BINDINGPATH], + paramValue[BB_STATE_FALLBACK] + ) + continue + } else if (takeStateFromEventParameters(paramValue)) { + resolvedParams[paramName] = eventContext => { + getState( + eventContext, + paramValue[BB_STATE_BINDINGPATH], + paramValue[BB_STATE_FALLBACK] + ) + } + } + } + + handlerInfo.parameters = resolvedParams + handlersInfos.push(handlerInfo) + } + + if (handlersInfos.length === 0) initialProps[propName] = doNothing + else { + initialProps[propName] = async context => { + for (let handlerInfo of handlersInfos) { + const handler = makeHandler(handlerTypes, handlerInfo) + await handler(context) + } + } + } + } + } + + registerBindings(node, storeBoundProps) + + const setup = _setup(handlerTypes, getCurrentState, registerBindings, bb) + initialProps._bb = bb(node, setup) + + return initialProps +} + +const makeHandler = (handlerTypes, handlerInfo) => { + const handlerType = handlerTypes[handlerInfo.handlerType] + return context => { + const parameters = {} + for (let p in handlerInfo.parameters) { + parameters[p] = handlerInfo.parameters[p](context) + } + handlerType.execute(parameters) + } +} + +const BindingPath = prop => prop[BB_STATE_BINDINGPATH] +const BindingFallback = prop => prop[BB_STATE_FALLBACK] +const BindingSource = prop => prop[BB_STATE_BINDINGSOURCE] diff --git a/packages/client/tests/bindingDom.spec.js b/packages/client/tests/bindingDom.spec.js index 7d4501b177..c0b90a8a81 100644 --- a/packages/client/tests/bindingDom.spec.js +++ b/packages/client/tests/bindingDom.spec.js @@ -1,4 +1,5 @@ import { load, makePage, makeScreen } from "./testAppDef" +import { EVENT_TYPE_MEMBER_NAME } from "../src/state/eventHandlers" describe("initialiseApp (binding)", () => { it("should populate root element prop from store value", async () => { @@ -169,4 +170,177 @@ describe("initialiseApp (binding)", () => { "header 2 - new val" ) }) + + it("should fire events", async () => { + const { dom, app } = await load( + makePage({ + _component: "testlib/button", + onClick: [ + event("Set State", { + path: "address", + value: "123 Main Street", + }), + ], + }) + ) + + const button = dom.window.document.body.children[0] + expect(button.tagName).toBe("BUTTON") + + let storeAddress + app.pageStore().subscribe(s => { + storeAddress = s.address + }) + button.dispatchEvent(new dom.window.Event("click")) + expect(storeAddress).toBe("123 Main Street") + }) + + it("should alter event parameters based on store values", async () => { + const { dom, app } = await load( + makePage({ + _component: "testlib/button", + onClick: [ + event("Set State", { + path: "address", + value: { + "##bbstate": "sourceaddress", + "##bbsource": "store", + "##bbstatefallback": "fallback address", + }, + }), + ], + }) + ) + + const button = dom.window.document.body.children[0] + expect(button.tagName).toBe("BUTTON") + + let storeAddress + app.pageStore().subscribe(s => { + storeAddress = s.address + }) + + button.dispatchEvent(new dom.window.Event("click")) + expect(storeAddress).toBe("fallback address") + + app.pageStore().update(s => { + s.sourceaddress = "new address" + return s + }) + + button.dispatchEvent(new dom.window.Event("click")) + expect(storeAddress).toBe("new address") + }) + + it("should take event parameters from context values", async () => { + const { dom, app } = await load( + makePage({ + _component: "testlib/button", + _id: "with_context", + onClick: [ + event("Set State", { + path: "address", + value: { + "##bbstate": "testKey", + "##bbsource": "context", + "##bbstatefallback": "fallback address", + }, + }), + ], + }) + ) + + const button = dom.window.document.body.children[0] + expect(button.tagName).toBe("BUTTON") + + let storeAddress + app.pageStore().subscribe(s => { + storeAddress = s.address + }) + + button.dispatchEvent(new dom.window.Event("click")) + expect(storeAddress).toBe("test value") + }) }) + +it("should rerender components when their code is bound to the store ", async () => { + const { dom, app } = await load( + makePage({ + _component: "testlib/div", + _children: [ + { + _component: "testlib/div", + _id: "n_clones_based_on_store", + className: "child_div", + }, + ], + }) + ) + + const rootDiv = dom.window.document.body.children[0] + expect(rootDiv.tagName).toBe("DIV") + expect(rootDiv.children.length).toBe(0) + + app.pageStore().update(s => { + s.componentCount = 3 + return s + }) + + expect(rootDiv.children.length).toBe(3) + expect(rootDiv.children[0].className.includes("child_div")).toBe(true) + + app.pageStore().update(s => { + s.componentCount = 5 + return s + }) + + expect(rootDiv.children.length).toBe(5) + expect(rootDiv.children[0].className.includes("child_div")).toBe(true) + + app.pageStore().update(s => { + s.componentCount = 0 + return s + }) + + expect(rootDiv.children.length).toBe(0) +}) + +it("should be able to read value from context, passed fromm parent, through code", async () => { + const { dom, app } = await load( + makePage({ + _component: "testlib/div", + _children: [ + { + _component: "testlib/div", + _id: "n_clones_based_on_store", + className: { + "##bbstate": "index", + "##bbsource": "context", + "##bbstatefallback": "nothing", + }, + }, + ], + }) + ) + + const rootDiv = dom.window.document.body.children[0] + expect(rootDiv.tagName).toBe("DIV") + expect(rootDiv.children.length).toBe(0) + + app.pageStore().update(s => { + s.componentCount = 3 + return s + }) + + expect(rootDiv.children.length).toBe(3) + expect(rootDiv.children[0].className.includes("index_0")).toBe(true) + expect(rootDiv.children[1].className.includes("index_1")).toBe(true) + expect(rootDiv.children[2].className.includes("index_2")).toBe(true) +}) + +const event = (handlerType, parameters) => { + const e = {} + e[EVENT_TYPE_MEMBER_NAME] = handlerType + e.parameters = parameters + return e +} diff --git a/packages/client/tests/testAppDef.js b/packages/client/tests/testAppDef.js index ff128310aa..b9409f0653 100644 --- a/packages/client/tests/testAppDef.js +++ b/packages/client/tests/testAppDef.js @@ -15,6 +15,7 @@ export const load = async (page, screens = [], url = "/") => { actions: [], triggers: [], }) + setComponentCodeMeta(page, screens) const app = await loadBudibase({ componentLibraries: allLibs(dom.window), window: dom.window, @@ -47,8 +48,11 @@ export const timeout = ms => new Promise(resolve => setTimeout(resolve, ms)) export const walkComponentTree = (node, action) => { action(node) - if (node.children) { - for (let child of node.children) { + // works for nodes or props + const children = node.children || node._children + + if (children) { + for (let child of children) { walkComponentTree(child, action) } } @@ -68,6 +72,22 @@ const autoAssignIds = (props, count = 0) => { } } +// any component with an id that include "based_on_store" is +// assumed to have code that depends on store value +const setComponentCodeMeta = (page, screens) => { + const setComponentCodeMeta_single = props => { + walkComponentTree(props, c => { + if (c._id.indexOf("based_on_store") >= 0) { + c._codeMeta = { dependsOnStore: true } + } + }) + } + setComponentCodeMeta_single(page.props) + for (let s of screens || []) { + setComponentCodeMeta_single(s.props) + } +} + const setAppDef = (window, page, screens) => { window["##BUDIBASE_FRONTEND_DEFINITION##"] = { componentLibraries: [], @@ -148,6 +168,29 @@ const maketestlib = window => ({ set(opts.props) opts.target.appendChild(node) }, + + button: function(opts) { + const node = window.document.createElement("BUTTON") + + let currentProps = { ...opts.props } + + const set = props => { + currentProps = Object.assign(currentProps, props) + if (currentProps.onClick) { + node.addEventListener("click", () => { + const testText = currentProps.testText || "hello" + currentProps._bb.call(props.onClick, { testText }) + }) + } + } + + this.$destroy = () => opts.target.removeChild(node) + + this.$set = set + this._element = node + set(opts.props) + opts.target.appendChild(node) + }, }) const uiFunctions = { @@ -162,4 +205,15 @@ const uiFunctions = { render() } }, + + with_context: render => { + render({ testKey: "test value" }) + }, + + n_clones_based_on_store: (render, _, state) => { + const n = state.componentCount || 0 + for (let i = 0; i < n; i++) { + render({ index: `index_${i}` }) + } + }, } diff --git a/packages/server/utilities/builder/buildPage.js b/packages/server/utilities/builder/buildPage.js index e83380ba68..8aa32f32fa 100644 --- a/packages/server/utilities/builder/buildPage.js +++ b/packages/server/utilities/builder/buildPage.js @@ -14,6 +14,7 @@ const { join, resolve, dirname } = require("path") const sqrl = require("squirrelly") const { convertCssToFiles } = require("./convertCssToFiles") const publicPath = require("./publicPath") +const deleteCodeMeta = require("./deleteCodeMeta") module.exports = async (config, appname, pageName, pkg) => { const appPath = appPackageFolder(config, appname) @@ -155,6 +156,8 @@ const savePageJson = async (appPath, pageName, pkg) => { delete pkg.page._screens } + deleteCodeMeta(pkg.page.props) + await writeJSON(pageFile, pkg.page, { spaces: 2, }) diff --git a/packages/server/utilities/builder/deleteCodeMeta.js b/packages/server/utilities/builder/deleteCodeMeta.js new file mode 100644 index 0000000000..07a0bc0349 --- /dev/null +++ b/packages/server/utilities/builder/deleteCodeMeta.js @@ -0,0 +1,9 @@ +module.exports = props => { + if (props._codeMeta) { + delete props._codeMeta + } + + for (let child of props._children || []) { + module.exports(child) + } +} diff --git a/packages/server/utilities/builder/index.js b/packages/server/utilities/builder/index.js index 187f5aca88..063a5f454d 100644 --- a/packages/server/utilities/builder/index.js +++ b/packages/server/utilities/builder/index.js @@ -18,6 +18,7 @@ const buildPage = require("./buildPage") const getPages = require("./getPages") const listScreens = require("./listScreens") const saveBackend = require("./saveBackend") +const deleteCodeMeta = require("./deleteCodeMeta") module.exports.buildPage = buildPage module.exports.listScreens = listScreens @@ -58,12 +59,15 @@ module.exports.saveScreen = async (config, appname, pagename, screen) => { if (screen._css) { delete screen._css } + + deleteCodeMeta(screen.props) + await writeJSON(compPath, screen, { encoding: "utf8", flag: "w", spaces: 2, }) - return screen; + return screen } module.exports.renameScreen = async ( diff --git a/packages/standard-components/src/button.svelte b/packages/standard-components/src/button.svelte index 9220aac5ee..18572b91fc 100644 --- a/packages/standard-components/src/button.svelte +++ b/packages/standard-components/src/button.svelte @@ -34,7 +34,8 @@ return all } - $: if(_bb.props._children.length > 0) theButton && _bb.attachChildren(theButton) + $: if(_bb.props._children && _bb.props._children.length > 0) + theButton && _bb.attachChildren(theButton) $: { cssVariables = { @@ -73,7 +74,7 @@ disabled={disabled || false} on:click={clickHandler} style={buttonStyles}> - {#if _bb.props_children.length === 0}{contentText}{/if} + {#if !_bb.props._children || _bb.props._children.length === 0}{contentText}{/if}