From a82c0dd44ea96cb59a031332a22d35c6cea36686 Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Thu, 6 Aug 2020 21:12:35 +0100 Subject: [PATCH] client lib - new binding --- packages/builder/src/builderStore/uuid.js | 4 +- packages/client/package.json | 18 +- packages/client/rollup.config.js | 79 +------ packages/client/src/api/authenticate.js | 8 +- packages/client/src/api/index.js | 105 +++++---- packages/client/src/api/workflow/actions.js | 10 - packages/client/src/api/workflow/index.js | 5 +- packages/client/src/render/attachChildren.js | 65 ++++-- .../src/render/prepareRenderComponent.js | 91 ++++---- packages/client/src/render/screenRouter.js | 2 +- packages/client/src/state/bbComponentApi.js | 10 +- packages/client/src/state/eventHandlers.js | 18 +- packages/client/src/state/getState.js | 9 - packages/client/src/state/hasBinding.js | 1 + .../src/state/setBindableComponentProp.js | 13 ++ packages/client/src/state/setState.js | 11 - packages/client/src/state/stateManager.js | 30 +-- packages/client/src/state/store.js | 105 ++++++++- packages/client/tests/binding.spec.js | 209 ++++++++++++++++++ packages/client/tests/initialiseApp.spec.js | 34 +++ packages/client/tests/testAppDef.js | 43 ++++ .../tests/testComponents/container.svelte | 16 ++ 22 files changed, 591 insertions(+), 295 deletions(-) delete mode 100644 packages/client/src/state/getState.js create mode 100644 packages/client/src/state/hasBinding.js create mode 100644 packages/client/src/state/setBindableComponentProp.js delete mode 100644 packages/client/src/state/setState.js create mode 100644 packages/client/tests/binding.spec.js create mode 100644 packages/client/tests/testComponents/container.svelte diff --git a/packages/builder/src/builderStore/uuid.js b/packages/builder/src/builderStore/uuid.js index 5a1893b56a..5dbd9ccdbd 100644 --- a/packages/builder/src/builderStore/uuid.js +++ b/packages/builder/src/builderStore/uuid.js @@ -1,5 +1,7 @@ export function uuid() { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => { + // always want to make this start with a letter, as this makes it + // easier to use with mustache bindings in the client + return "cxxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0, v = c == "x" ? r : (r & 0x3) | 0x8 return v.toString(16) diff --git a/packages/client/package.json b/packages/client/package.json index 62d35fc0c7..8ec7300634 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -21,28 +21,24 @@ "\\.(css|less|sass|scss)$": "identity-obj-proxy" }, "moduleFileExtensions": [ - "js" + "js", + "svelte" ], "moduleDirectories": [ "node_modules" ], "transform": { - "^.+js$": "babel-jest" + "^.+js$": "babel-jest", + "^.+.svelte$": "svelte-jester" }, "transformIgnorePatterns": [ "/node_modules/(?!svelte).+\\.js$" ] }, "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", "mustache": "^4.0.1", - "regexparam": "^1.3.0", - "shortid": "^2.2.8", - "svelte": "^3.9.2" + "regexparam": "^1.3.0" }, "devDependencies": { "@babel/core": "^7.5.5", @@ -58,7 +54,9 @@ "rollup-plugin-node-builtins": "^2.1.2", "rollup-plugin-node-globals": "^1.4.0", "rollup-plugin-node-resolve": "^5.2.0", - "rollup-plugin-terser": "^4.0.4" + "rollup-plugin-terser": "^4.0.4", + "svelte": "3.23.x", + "svelte-jester": "^1.0.6" }, "gitHead": "e4e053cb6ff9a0ddc7115b44ccaa24b8ec41fb9a" } diff --git a/packages/client/rollup.config.js b/packages/client/rollup.config.js index 09936dcf69..adc3bb6337 100644 --- a/packages/client/rollup.config.js +++ b/packages/client/rollup.config.js @@ -3,74 +3,6 @@ import commonjs from "rollup-plugin-commonjs" import builtins from "rollup-plugin-node-builtins" import nodeglobals from "rollup-plugin-node-globals" -const lodash_fp_exports = [ - "find", - "compose", - "isUndefined", - "split", - "max", - "last", - "union", - "reduce", - "isObject", - "cloneDeep", - "some", - "isArray", - "map", - "filter", - "keys", - "isFunction", - "isEmpty", - "countBy", - "join", - "includes", - "flatten", - "constant", - "first", - "intersection", - "take", - "has", - "mapValues", - "isString", - "isBoolean", - "isNull", - "isNumber", - "isObjectLike", - "isDate", - "clone", - "values", - "keyBy", - "isNaN", - "isInteger", - "toNumber", -] - -const lodash_exports = [ - "flow", - "head", - "find", - "each", - "tail", - "findIndex", - "startsWith", - "dropRight", - "takeRight", - "trim", - "split", - "replace", - "merge", - "assign", -] - -const coreExternal = [ - "lodash", - "lodash/fp", - "lunr", - "safe-buffer", - "shortid", - "@nx-js/compiler-util", -] - export default { input: "src/index.js", output: [ @@ -90,17 +22,8 @@ export default { resolve({ preferBuiltins: true, browser: true, - dedupe: importee => { - return coreExternal.includes(importee) - }, - }), - commonjs({ - namedExports: { - "lodash/fp": lodash_fp_exports, - lodash: lodash_exports, - shortid: ["generate"], - }, }), + commonjs(), builtins(), nodeglobals(), ], diff --git a/packages/client/src/api/authenticate.js b/packages/client/src/api/authenticate.js index 0f9701528b..0b961a6fe6 100644 --- a/packages/client/src/api/authenticate.js +++ b/packages/client/src/api/authenticate.js @@ -1,3 +1,5 @@ +import appStore from "../state/store" + export const USER_STATE_PATH = "_bbuser" export const authenticate = api => async ({ username, password }) => { @@ -17,6 +19,10 @@ export const authenticate = api => async ({ username, password }) => { }) // set user even if error - so it is defined at least - api.setState(USER_STATE_PATH, user) + appStore.update(s => { + s[USER_STATE_PATH] = user + return s + }) + localStorage.setItem("budibase:user", JSON.stringify(user)) } diff --git a/packages/client/src/api/index.js b/packages/client/src/api/index.js index 373ac1809e..1b829f16c6 100644 --- a/packages/client/src/api/index.js +++ b/packages/client/src/api/index.js @@ -1,62 +1,59 @@ import { authenticate } from "./authenticate" import { triggerWorkflow } from "./workflow" +import appStore from "../state/store" -export const createApi = ({ setState, getState }) => { - const apiCall = method => async ({ url, body }) => { - const response = await fetch(url, { - method: method, - headers: { - "Content-Type": "application/json", - }, - body: body && JSON.stringify(body), - credentials: "same-origin", - }) +const apiCall = method => async ({ url, body }) => { + const response = await fetch(url, { + method: method, + headers: { + "Content-Type": "application/json", + }, + body: body && JSON.stringify(body), + credentials: "same-origin", + }) - switch (response.status) { - case 200: + switch (response.status) { + case 200: + return response.json() + case 404: + return error(`${url} Not found`) + case 400: + return error(`${url} Bad Request`) + case 403: + return error(`${url} Forbidden`) + default: + if (response.status >= 200 && response.status < 400) { return response.json() - case 404: - return error(`${url} Not found`) - case 400: - return error(`${url} Bad Request`) - case 403: - return error(`${url} Forbidden`) - default: - if (response.status >= 200 && response.status < 400) { - return response.json() - } + } - return error(`${url} - ${response.statusText}`) - } - } - - const post = apiCall("POST") - const get = apiCall("GET") - const patch = apiCall("PATCH") - const del = apiCall("DELETE") - - const ERROR_MEMBER = "##error" - const error = message => { - const err = { [ERROR_MEMBER]: message } - setState("##error_message", message) - return err - } - - const isSuccess = obj => !obj || !obj[ERROR_MEMBER] - - const apiOpts = { - setState, - getState, - isSuccess, - error, - post, - get, - patch, - delete: del, - } - - return { - authenticate: authenticate(apiOpts), - triggerWorkflow: triggerWorkflow(apiOpts), + return error(`${url} - ${response.statusText}`) } } + +const post = apiCall("POST") +const get = apiCall("GET") +const patch = apiCall("PATCH") +const del = apiCall("DELETE") + +const ERROR_MEMBER = "##error" +const error = message => { + const err = { [ERROR_MEMBER]: message } + appStore.update(s => s["##error_message"], message) + return err +} + +const isSuccess = obj => !obj || !obj[ERROR_MEMBER] + +const apiOpts = { + isSuccess, + error, + post, + get, + patch, + delete: del, +} + +export default { + authenticate: authenticate(apiOpts), + triggerWorkflow: triggerWorkflow(apiOpts), +} diff --git a/packages/client/src/api/workflow/actions.js b/packages/client/src/api/workflow/actions.js index 794cb184bf..09ba734231 100644 --- a/packages/client/src/api/workflow/actions.js +++ b/packages/client/src/api/workflow/actions.js @@ -1,16 +1,6 @@ -import { setState } from "../../state/setState" - const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) export default { - SET_STATE: ({ context, args, id }) => { - setState(...Object.values(args)) - context = { - ...context, - [id]: args, - } - return context - }, NAVIGATE: () => { // TODO client navigation }, diff --git a/packages/client/src/api/workflow/index.js b/packages/client/src/api/workflow/index.js index 4a2cac6fd8..f01a0fe62c 100644 --- a/packages/client/src/api/workflow/index.js +++ b/packages/client/src/api/workflow/index.js @@ -1,6 +1,5 @@ -import { get } from "svelte/store" import mustache from "mustache" -import { appStore } from "../../state/store" +import appStore from "../../state/store" import Orchestrator from "./orchestrator" import clientActions from "./actions" @@ -20,7 +19,7 @@ export const clientStrategy = ({ api }) => ({ // Render the string with values from the workflow context and state mappedArgs[arg] = mustache.render(argValue, { context: this.context, - state: get(appStore), + state: appStore.get(), }) } diff --git a/packages/client/src/render/attachChildren.js b/packages/client/src/render/attachChildren.js index ac27f2b5cf..ee6f313c33 100644 --- a/packages/client/src/render/attachChildren.js +++ b/packages/client/src/render/attachChildren.js @@ -1,7 +1,7 @@ -import { split, last, compose } from "lodash/fp" import { prepareRenderComponent } from "./prepareRenderComponent" import { isScreenSlot } from "./builtinComponents" import deepEqual from "deep-equal" +import appStore from "../state/store" export const attachChildren = initialiseOpts => (htmlElement, options) => { const { @@ -30,11 +30,28 @@ export const attachChildren = initialiseOpts => (htmlElement, options) => { } } - const contextArray = Array.isArray(context) ? context : [context] + const contextStoreKeys = [] + + // create new context if supplied + if (context) { + let childIndex = 0 + // if context is an array, map to new structure + const contextArray = Array.isArray(context) ? context : [context] + for (let ctx of contextArray) { + const key = appStore.create( + ctx, + treeNode.props._id, + childIndex, + treeNode.contextStoreKey + ) + contextStoreKeys.push(key) + childIndex++ + } + } const childNodes = [] - for (let context of contextArray) { + const createChildNodes = contextStoreKey => { for (let childProps of treeNode.props._children) { const { componentName, libName } = splitName(childProps._component) @@ -42,25 +59,33 @@ export const attachChildren = initialiseOpts => (htmlElement, options) => { const ComponentConstructor = componentLibraries[libName][componentName] - const prepareNodes = ctx => { - const childNodesThisIteration = prepareRenderComponent({ - props: childProps, - parentNode: treeNode, - ComponentConstructor, - htmlElement, - anchor, - context: ctx, - }) + const childNode = prepareRenderComponent({ + props: childProps, + parentNode: treeNode, + ComponentConstructor, + htmlElement, + anchor, + // in same context as parent, unless a new one was supplied + contextStoreKey, + }) - for (let childNode of childNodesThisIteration) { - childNodes.push(childNode) - } - } - - prepareNodes(context) + childNodes.push(childNode) } } + if (context) { + // if new context(s) is supplied, then create nodes + // with keys to new context stores + for (let contextStoreKey of contextStoreKeys) { + createChildNodes(contextStoreKey) + } + } else { + // otherwise, use same context store as parent + // which maybe undefined (therfor using the root state) + createChildNodes(treeNode.contextStoreKey) + } + + // if everything is equal, then don't re-render if (areTreeNodesEqual(treeNode.children, childNodes)) return treeNode.children for (let node of childNodes) { @@ -81,9 +106,9 @@ export const attachChildren = initialiseOpts => (htmlElement, options) => { } const splitName = fullname => { - const getComponentName = compose(last, split("/")) + const nameParts = fullname.split("/") - const componentName = getComponentName(fullname) + const componentName = nameParts[nameParts.length - 1] const libName = fullname.substring( 0, diff --git a/packages/client/src/render/prepareRenderComponent.js b/packages/client/src/render/prepareRenderComponent.js index 4458ec9e55..045de897f6 100644 --- a/packages/client/src/render/prepareRenderComponent.js +++ b/packages/client/src/render/prepareRenderComponent.js @@ -1,5 +1,6 @@ -import { appStore } from "../state/store" import mustache from "mustache" +import appStore from "../state/store" +import hasBinding from "../state/hasBinding" export const prepareRenderComponent = ({ ComponentConstructor, @@ -7,62 +8,54 @@ export const prepareRenderComponent = ({ anchor, props, parentNode, - context, + contextStoreKey, }) => { - const parentContext = (parentNode && parentNode.context) || {} + const thisNode = createTreeNode() + thisNode.parentNode = parentNode + thisNode.props = props + thisNode.contextStoreKey = contextStoreKey - let nodesToRender = [] - const createNodeAndRender = () => { - let componentContext = parentContext - if (context) { - componentContext = { ...context } - componentContext.$parent = parentContext + // the treeNode is first created (above), and then this + // render method is add. The treeNode is returned, and + // render is called later (in attachChildren) + thisNode.render = initialProps => { + thisNode.component = new ComponentConstructor({ + target: htmlElement, + props: initialProps, + hydrate: false, + anchor, + }) + + // finds the root element of the component, which was created by the contructor above + // we use this later to attach a className to. This is how styles + // are applied by the builder + thisNode.rootElement = htmlElement.children[htmlElement.children.length - 1] + + let [componentName] = props._component.match(/[a-z]*$/) + if (props._id && thisNode.rootElement) { + thisNode.rootElement.classList.add(`${componentName}-${props._id}`) } - const thisNode = createTreeNode() - thisNode.context = componentContext - thisNode.parentNode = parentNode - thisNode.props = props - nodesToRender.push(thisNode) - - thisNode.render = initialProps => { - thisNode.component = new ComponentConstructor({ - target: htmlElement, - props: initialProps, - hydrate: false, - anchor, - }) - thisNode.rootElement = - htmlElement.children[htmlElement.children.length - 1] - - let [componentName] = props._component.match(/[a-z]*$/) - if (props._id && thisNode.rootElement) { - thisNode.rootElement.classList.add(`${componentName}-${props._id}`) - } - - // make this node listen to the store - if (thisNode.stateBound) { - const unsubscribe = appStore.subscribe(state => { - const storeBoundProps = { ...initialProps._bb.props } - for (let prop in storeBoundProps) { - const propValue = storeBoundProps[prop] - if (typeof propValue === "string") { - storeBoundProps[prop] = mustache.render(propValue, { - state, - context: componentContext, - }) - } + // make this node listen to the store + if (thisNode.stateBound) { + const unsubscribe = appStore.subscribe(state => { + const storeBoundProps = Object.keys(initialProps._bb.props).filter(p => + hasBinding(initialProps._bb.props[p]) + ) + if (storeBoundProps.length > 0) { + const toSet = {} + for (let prop of storeBoundProps) { + const propValue = initialProps._bb.props[prop] + toSet[prop] = mustache.render(propValue, state) } - thisNode.component.$set(storeBoundProps) - }) - thisNode.unsubscribe = unsubscribe - } + thisNode.component.$set(toSet) + } + }, thisNode.contextStoreKey) + thisNode.unsubscribe = unsubscribe } } - createNodeAndRender() - - return nodesToRender + return thisNode } export const createTreeNode = () => ({ diff --git a/packages/client/src/render/screenRouter.js b/packages/client/src/render/screenRouter.js index ab9fda1a3b..fb84248d94 100644 --- a/packages/client/src/render/screenRouter.js +++ b/packages/client/src/render/screenRouter.js @@ -1,5 +1,5 @@ import regexparam from "regexparam" -import { appStore } from "../state/store" +import appStore from "../state/store" import { parseAppIdFromCookie } from "./getAppId" export const screenRouter = ({ screens, onScreenSelected, window }) => { diff --git a/packages/client/src/state/bbComponentApi.js b/packages/client/src/state/bbComponentApi.js index d426afac6e..44b39facbf 100644 --- a/packages/client/src/state/bbComponentApi.js +++ b/packages/client/src/state/bbComponentApi.js @@ -1,11 +1,9 @@ -import { setState } from "./setState" +import setBindableComponentProp from "./setBindableComponentProp" import { attachChildren } from "../render/attachChildren" -import { getContext, setContext } from "./getSetContext" export const trimSlash = str => str.replace(/^\/+|\/+$/g, "") export const bbFactory = ({ - store, componentLibraries, onScreenSlotRendered, getCurrentState, @@ -45,13 +43,9 @@ export const bbFactory = ({ return { attachChildren: attachChildren(attachParams), - context: treeNode.context, props: treeNode.props, call: safeCallEvent, - setState, - getContext: getContext(treeNode), - setContext: setContext(treeNode), - store: store, + setBinding: setBindableComponentProp(treeNode), api, parent, // these parameters are populated by screenRouter diff --git a/packages/client/src/state/eventHandlers.js b/packages/client/src/state/eventHandlers.js index cab825c24b..56e760e555 100644 --- a/packages/client/src/state/eventHandlers.js +++ b/packages/client/src/state/eventHandlers.js @@ -1,8 +1,4 @@ -import { setState } from "./setState" -import { getState } from "./getState" -import { isArray, isUndefined } from "lodash/fp" - -import { createApi } from "../api" +import api from "../api" export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType" @@ -12,21 +8,13 @@ export const eventHandlers = routeTo => { parameters, }) - const api = createApi({ - setState, - getState: (path, fallback) => getState(path, fallback), - }) - - const setStateHandler = ({ path, value }) => setState(path, value) - return { - "Set State": handler(["path", "value"], setStateHandler), "Navigate To": handler(["url"], param => routeTo(param && param.url)), "Trigger Workflow": handler(["workflow"], api.triggerWorkflow), } } export const isEventType = prop => - isArray(prop) && + Array.isArray(prop) && prop.length > 0 && - !isUndefined(prop[0][EVENT_TYPE_MEMBER_NAME]) + !prop[0][EVENT_TYPE_MEMBER_NAME] === undefined diff --git a/packages/client/src/state/getState.js b/packages/client/src/state/getState.js deleted file mode 100644 index 236b54c6dc..0000000000 --- a/packages/client/src/state/getState.js +++ /dev/null @@ -1,9 +0,0 @@ -import { get } from "svelte/store" -import getOr from "lodash/fp/getOr" -import { appStore } from "./store" - -export const getState = (path, fallback) => { - if (!path || path.length === 0) return fallback - - return getOr(fallback, path, get(appStore)) -} diff --git a/packages/client/src/state/hasBinding.js b/packages/client/src/state/hasBinding.js new file mode 100644 index 0000000000..ac6ae79074 --- /dev/null +++ b/packages/client/src/state/hasBinding.js @@ -0,0 +1 @@ +export default value => typeof value === "string" && value.includes("{{") diff --git a/packages/client/src/state/setBindableComponentProp.js b/packages/client/src/state/setBindableComponentProp.js new file mode 100644 index 0000000000..4fe9191fc4 --- /dev/null +++ b/packages/client/src/state/setBindableComponentProp.js @@ -0,0 +1,13 @@ +import appStore from "./store" + +export default treeNode => (propName, value) => { + if (!propName || propName.length === 0) return + if (!treeNode) return + const componentId = treeNode.props._id + + appStore.update(state => { + state[componentId] = state[componentId] || {} + state[componentId][propName] = value + return state + }, treeNode.contextStoreKey) +} diff --git a/packages/client/src/state/setState.js b/packages/client/src/state/setState.js deleted file mode 100644 index ac17b1a681..0000000000 --- a/packages/client/src/state/setState.js +++ /dev/null @@ -1,11 +0,0 @@ -import set from "lodash/fp/set" -import { appStore } from "./store" - -export const setState = (path, value) => { - if (!path || path.length === 0) return - - appStore.update(state => { - state = set(path, value, state) - return state - }) -} diff --git a/packages/client/src/state/stateManager.js b/packages/client/src/state/stateManager.js index 67437a73f7..778d028305 100644 --- a/packages/client/src/state/stateManager.js +++ b/packages/client/src/state/stateManager.js @@ -5,8 +5,8 @@ import { } from "./eventHandlers" import { bbFactory } from "./bbComponentApi" import mustache from "mustache" -import { get } from "svelte/store" -import { appStore } from "./store" +import appStore from "./store" +import hasBinding from "./hasBinding" const doNothing = () => {} doNothing.isPlaceholder = true @@ -37,41 +37,34 @@ export const createStateManager = ({ const getCurrentState = () => currentState const bb = bbFactory({ - store: appStore, getCurrentState, componentLibraries, onScreenSlotRendered, }) - const setup = _setup({ handlerTypes, getCurrentState, bb, store: appStore }) + const setup = _setup({ handlerTypes, getCurrentState, bb }) return { setup, destroy: () => {}, getCurrentState, - store: appStore, } } -const _setup = ({ handlerTypes, getCurrentState, bb, store }) => node => { +const _setup = ({ handlerTypes, getCurrentState, bb }) => node => { const props = node.props - const context = node.context || {} const initialProps = { ...props } - const currentStoreState = get(appStore) for (let propName in props) { if (isMetaProp(propName)) continue const propValue = props[propName] - // A little bit of a hack - won't bind if the string doesn't start with {{ - const isBound = typeof propValue === "string" && propValue.includes("{{") + const isBound = hasBinding(propValue) if (isBound) { - initialProps[propName] = mustache.render(propValue, { - state: currentStoreState, - context, - }) + const state = appStore.getState(node.contextStoreKey) + initialProps[propName] = mustache.render(propValue, state) if (!node.stateBound) { node.stateBound = true @@ -79,6 +72,7 @@ const _setup = ({ handlerTypes, getCurrentState, bb, store }) => node => { } if (isEventType(propValue)) { + const state = appStore.getState(node.contextStoreKey) const handlersInfos = [] for (let event of propValue) { const handlerInfo = { @@ -89,11 +83,7 @@ const _setup = ({ handlerTypes, getCurrentState, bb, store }) => node => { const resolvedParams = {} for (let paramName in handlerInfo.parameters) { const paramValue = handlerInfo.parameters[paramName] - resolvedParams[paramName] = () => - mustache.render(paramValue, { - state: getCurrentState(), - context, - }) + resolvedParams[paramName] = () => mustache.render(paramValue, state) } handlerInfo.parameters = resolvedParams @@ -113,7 +103,7 @@ const _setup = ({ handlerTypes, getCurrentState, bb, store }) => node => { } } - const setup = _setup({ handlerTypes, getCurrentState, bb, store }) + const setup = _setup({ handlerTypes, getCurrentState, bb }) initialProps._bb = bb(node, setup) return initialProps diff --git a/packages/client/src/state/store.js b/packages/client/src/state/store.js index bdf85c1ae6..ca8257b2a3 100644 --- a/packages/client/src/state/store.js +++ b/packages/client/src/state/store.js @@ -1,9 +1,104 @@ import { writable } from "svelte/store" -const appStore = writable({}) -appStore.actions = {} +// we assume that the reference to this state object +// will remain for the life of the application +const rootState = {} +const rootStore = writable(rootState) +const contextStores = {} -const routerStore = writable({}) -routerStore.actions = {} +// contextProviderId is the component id that provides the data for the context +const contextStoreKey = (dataProviderId, childIndex) => + `${dataProviderId}${childIndex >= 0 ? ":" + childIndex : ""}` -export { appStore, routerStore } +// creates a store for a datacontext (e.g. each item in a list component) +const create = (data, dataProviderId, childIndex, parentContextStoreId) => { + const key = contextStoreKey(dataProviderId, childIndex) + const state = { data } + + // add reference to parent state object, + // so we can use bindings like state.parent.parent + // (if no parent, then parent is rootState ) + state.parent = parentContextStoreId + ? contextStores[parentContextStoreId].state + : rootState + + if (!contextStores[key]) { + contextStores[key] = { + store: writable(state), + subscriberCount: 0, + state, + parentContextStoreId, + } + } + return key +} + +const subscribe = (subscription, storeKey) => { + if (!storeKey) { + return rootStore.subscribe(subscription) + } + const contextStore = contextStores[storeKey] + + // we are subscribing to multiple stores, + // we dont want to each subscription the first time + // as this could repeatedly run $set on the same component + // ... which already has its initial properties set properly + const ignoreFirstSubscription = () => { + let hasRunOnce = false + return () => { + if (hasRunOnce) subscription(contextStore.state) + hasRunOnce = true + } + } + + const unsubscribes = [rootStore.subscribe(ignoreFirstSubscription())] + + // we subscribe to all stores in the hierarchy + const ancestorSubscribe = ctxStore => { + // unsubscribe func returned by svelte store + const svelteUnsub = ctxStore.store.subscribe(ignoreFirstSubscription()) + + // we wrap the svelte unsubscribe, so we can + // cleanup stores when they are no longer subscribed to + const unsub = () => { + ctxStore.subscriberCount = contextStore.subscriberCount - 1 + // when no subscribers left, we delete the store + if (ctxStore.subscriberCount === 0) { + delete ctxStore[storeKey] + } + svelteUnsub() + } + unsubscribes.push(unsub) + if (ctxStore.parentContextStoreId) { + ancestorSubscribe(contextStores[ctxStore.parentContextStoreId]) + } + } + + ancestorSubscribe(contextStore) + + // our final unsubscribe function calls unsubscribe on all stores + return () => unsubscribes.forEach(u => u()) +} + +const findStore = (dataProviderId, childIndex) => + dataProviderId + ? contextStores[contextStoreKey(dataProviderId, childIndex)].store + : rootStore + +const update = (updatefunc, dataProviderId, childIndex) => + findStore(dataProviderId, childIndex).update(updatefunc) + +const set = (value, dataProviderId, childIndex) => + findStore(dataProviderId, childIndex).set(value) + +const getState = contextStoreKey => + contextStoreKey ? contextStores[contextStoreKey].state : rootState + +export default { + subscribe, + update, + set, + getState, + create, + contextStoreKey, +} diff --git a/packages/client/tests/binding.spec.js b/packages/client/tests/binding.spec.js new file mode 100644 index 0000000000..58cd2d6556 --- /dev/null +++ b/packages/client/tests/binding.spec.js @@ -0,0 +1,209 @@ +import { load, makePage, makeScreen } from "./testAppDef" + +describe("binding", () => { + + + it("should bind to data in context", async () => { + const { dom } = await load( + makePage({ + _component: "testlib/div", + _children: [ + { + _component: "##builtin/screenslot", + text: "header one", + }, + ], + }), + [ + makeScreen("/", { + _component: "testlib/list", + data: dataArray, + _children: [ + { + _component: "testlib/h1", + text: "{{data.name}}", + } + ], + }), + ] + ) + + const rootDiv = dom.window.document.body.children[0] + expect(rootDiv.children.length).toBe(1) + + const screenRoot = rootDiv.children[0] + + expect(screenRoot.children[0].children.length).toBe(2) + expect(screenRoot.children[0].children[0].innerText).toBe(dataArray[0].name) + expect(screenRoot.children[0].children[1].innerText).toBe(dataArray[1].name) + }) + + it("should bind to input in root", async () => { + const { dom } = await load( + makePage({ + _component: "testlib/div", + _children: [ + { + _component: "##builtin/screenslot", + text: "header one", + }, + ], + }), + [ + makeScreen("/", { + _component: "testlib/div", + _children: [ + { + _component: "testlib/h1", + text: "{{inputid.value}}", + }, + { + _id: "inputid", + _component: "testlib/input", + value: "hello" + } + ], + }), + ] + ) + + const rootDiv = dom.window.document.body.children[0] + expect(rootDiv.children.length).toBe(1) + + const screenRoot = rootDiv.children[0] + + expect(screenRoot.children[0].children.length).toBe(2) + expect(screenRoot.children[0].children[0].innerText).toBe("hello") + + // change value of input + const input = dom.window.document.getElementsByClassName("input-inputid")[0] + + changeInputValue(dom, input, "new value") + expect(screenRoot.children[0].children[0].innerText).toBe("new value") + + }) + + it("should bind to input in context", async () => { + const { dom } = await load( + makePage({ + _component: "testlib/div", + _children: [ + { + _component: "##builtin/screenslot", + text: "header one", + }, + ], + }), + [ + makeScreen("/", { + _component: "testlib/list", + data: dataArray, + _children: [ + { + _component: "testlib/h1", + text: "{{inputid.value}}", + }, + { + _id: "inputid", + _component: "testlib/input", + value: "hello" + } + ], + }), + ] + ) + + const rootDiv = dom.window.document.body.children[0] + expect(rootDiv.children.length).toBe(1) + + const screenRoot = rootDiv.children[0] + expect(screenRoot.children[0].children.length).toBe(4) + + const firstHeader = screenRoot.children[0].children[0] + const firstInput = screenRoot.children[0].children[1] + const secondHeader = screenRoot.children[0].children[2] + const secondInput = screenRoot.children[0].children[3] + + expect(firstHeader.innerText).toBe("hello") + expect(secondHeader.innerText).toBe("hello") + + changeInputValue(dom, firstInput, "first input value") + expect(firstHeader.innerText).toBe("first input value") + + changeInputValue(dom, secondInput, "second input value") + expect(secondHeader.innerText).toBe("second input value") + + }) + + it("should bind contextual component, to input in root context", async () => { + const { dom } = await load( + makePage({ + _component: "testlib/div", + _children: [ + { + _component: "##builtin/screenslot", + text: "header one", + }, + ], + }), + [ + makeScreen("/", { + _component: "testlib/div", + _children: [ + { + _id: "inputid", + _component: "testlib/input", + value: "hello" + }, + { + _component: "testlib/list", + data: dataArray, + _children: [ + { + _component: "testlib/h1", + text: "{{parent.inputid.value}}", + }, + ], + } + ] + }), + ] + ) + + const rootDiv = dom.window.document.body.children[0] + expect(rootDiv.children.length).toBe(1) + + const screenRoot = rootDiv.children[0] + expect(screenRoot.children[0].children.length).toBe(2) + + const input = screenRoot.children[0].children[0] + + const firstHeader = screenRoot.children[0].children[1].children[0] + const secondHeader = screenRoot.children[0].children[1].children[0] + + expect(firstHeader.innerText).toBe("hello") + expect(secondHeader.innerText).toBe("hello") + + changeInputValue(dom, input, "new input value") + expect(firstHeader.innerText).toBe("new input value") + expect(secondHeader.innerText).toBe("new input value") + + }) + + const changeInputValue = (dom, input, newValue) => { + var event = new dom.window.Event("change") + input.value = newValue + input.dispatchEvent(event) + } + + const dataArray = [ + { + name: "katherine", + age: 30, + }, + { + name: "steve", + age: 41, + }, + ] +}) diff --git a/packages/client/tests/initialiseApp.spec.js b/packages/client/tests/initialiseApp.spec.js index 7019339978..4601bc94c4 100644 --- a/packages/client/tests/initialiseApp.spec.js +++ b/packages/client/tests/initialiseApp.spec.js @@ -135,4 +135,38 @@ describe("initialiseApp", () => { expect(screenRoot.children[0].children[0].innerText).toBe("header one") expect(screenRoot.children[0].children[1].innerText).toBe("header two") }) + + it("should repeat elements that pass an array of contexts", async () => { + const { dom } = await load( + makePage({ + _component: "testlib/div", + _children: [ + { + _component: "##builtin/screenslot", + text: "header one", + }, + ], + }), + [ + makeScreen("/", { + _component: "testlib/list", + data: [1,2,3,4], + _children: [ + { + _component: "testlib/h1", + text: "header", + } + ], + }), + ] + ) + + const rootDiv = dom.window.document.body.children[0] + expect(rootDiv.children.length).toBe(1) + + const screenRoot = rootDiv.children[0] + + expect(screenRoot.children[0].children.length).toBe(4) + expect(screenRoot.children[0].children[0].innerText).toBe("header") + }) }) diff --git a/packages/client/tests/testAppDef.js b/packages/client/tests/testAppDef.js index 76bb3784b0..69fdf551dc 100644 --- a/packages/client/tests/testAppDef.js +++ b/packages/client/tests/testAppDef.js @@ -194,4 +194,47 @@ const maketestlib = window => ({ set(opts.props) opts.target.appendChild(node) }, + + list: function(opts) { + const node = window.document.createElement("DIV") + + let currentProps = { ...opts.props } + + const set = props => { + currentProps = Object.assign(currentProps, props) + if (currentProps._children && currentProps._children.length > 0) { + currentProps._bb.attachChildren(node, { + context: currentProps.data || {}, + }) + } + } + + this.$destroy = () => opts.target.removeChild(node) + + this.$set = set + this._element = node + set(opts.props) + opts.target.appendChild(node) + }, + + input: function(opts) { + const node = window.document.createElement("INPUT") + let currentProps = { ...opts.props } + + const set = props => { + currentProps = Object.assign(currentProps, props) + opts.props._bb.setBinding("value", props.value) + } + + node.addEventListener("change", e => { + opts.props._bb.setBinding("value", e.target.value) + }) + + this.$destroy = () => opts.target.removeChild(node) + + this.$set = set + this._element = node + set(opts.props) + opts.target.appendChild(node) + }, }) diff --git a/packages/client/tests/testComponents/container.svelte b/packages/client/tests/testComponents/container.svelte new file mode 100644 index 0000000000..a7f48b19e8 --- /dev/null +++ b/packages/client/tests/testComponents/container.svelte @@ -0,0 +1,16 @@ + + +