From 5ab905067f29f589c6b1b489452118a6f98e81c2 Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Thu, 13 Aug 2020 11:50:12 +0100 Subject: [PATCH] merge from master --- packages/builder/package.json | 6 +- .../builderStore/fetchBindableProperties.js | 156 +++++++++++++ .../src/builderStore/getNewComponentName.js | 39 ++++ .../builder/src/builderStore/store/index.js | 6 +- .../builder/src/builderStore/storeUtils.js | 4 +- packages/builder/src/builderStore/uuid.js | 4 +- .../ComponentPropertiesPanel.svelte | 4 - .../EventsEditor/EventEditorModal.svelte | 95 ++------ .../EventsEditor/EventPropertyControl.svelte | 26 +++ .../EventsEditor/EventsEditor.svelte | 161 -------------- .../EventsEditor/HandlerSelector.svelte | 7 +- .../userInterface/SettingsView.svelte | 52 ++++- .../userInterface/temporaryPanelStructure.js | 2 + .../tests/fetchBindableProperties.spec.js | 200 +++++++++++++++++ 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 | 9 +- packages/client/src/render/attachChildren.js | 65 ++++-- .../src/render/prepareRenderComponent.js | 93 ++++---- 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 + .../client/src/state/renderTemplateString.js | 17 ++ .../src/state/setBindableComponentProp.js | 13 ++ packages/client/src/state/setState.js | 11 - packages/client/src/state/stateManager.js | 31 +-- 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 ++ packages/standard-components/components.json | 7 +- packages/standard-components/src/Input.svelte | 3 +- .../standard-components/src/Select.svelte | 2 +- 39 files changed, 1129 insertions(+), 551 deletions(-) create mode 100644 packages/builder/src/builderStore/fetchBindableProperties.js create mode 100644 packages/builder/src/builderStore/getNewComponentName.js create mode 100644 packages/builder/src/components/userInterface/EventsEditor/EventPropertyControl.svelte delete mode 100644 packages/builder/src/components/userInterface/EventsEditor/EventsEditor.svelte create mode 100644 packages/builder/tests/fetchBindableProperties.spec.js 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/renderTemplateString.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/package.json b/packages/builder/package.json index 75faa8671f..206df9d326 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -17,7 +17,7 @@ "cy:test": "start-server-and-test cy:setup http://localhost:4001/_builder cy:run", "cy:ci": "start-server-and-test cy:setup http://localhost:4001/_builder cy:run:ci" }, - "jest": { + "jest": { "globals": { "GLOBALS": { "client": "web" @@ -93,14 +93,14 @@ "@sveltech/routify": "1.7.11", "@testing-library/jest-dom": "^5.11.0", "@testing-library/svelte": "^3.0.0", - "babel-jest": "^24.8.0", + "babel-jest": "^26.2.2", "browser-sync": "^2.26.7", "cypress": "^4.8.0", "cypress-terminal-report": "^1.4.1", "eslint-plugin-cypress": "^2.11.1", "http-proxy-middleware": "^0.19.1", "identity-obj-proxy": "^3.0.0", - "jest": "^24.8.0", + "jest": "^26.2.2", "ncp": "^2.0.0", "npm-run-all": "^4.1.5", "rimraf": "^3.0.2", diff --git a/packages/builder/src/builderStore/fetchBindableProperties.js b/packages/builder/src/builderStore/fetchBindableProperties.js new file mode 100644 index 0000000000..93407fb941 --- /dev/null +++ b/packages/builder/src/builderStore/fetchBindableProperties.js @@ -0,0 +1,156 @@ +import { cloneDeep, difference } from "lodash" + +/** + * parameter for fetchBindableProperties function + * @typedef {Object} fetchBindablePropertiesParameter + * @property {string} componentInstanceId - an _id of a component that has been added to a screen, whihc you want to fetch bindable props for + * @propperty {Object} screen - current screen - where componentInstanceId lives + * @property {Object} components - dictionary of component definitions + * @property {Array} models - array of all models + */ + +/** + * + * @typedef {Object} BindableProperty + * @property {string} type - either "instance" (binding to a component instance) or "context" (binding to data in context e.g. List Item) + * @property {Object} instance - relevant component instance. If "context" type, this instance is the component that provides the context... e.g. the List + * @property {string} runtimeBinding - a binding string that is a) saved against the string, and b) used at runtime to read/write the value + * @property {string} readableBinding - a binding string that is displayed to the user, in the builder + */ + +/** + * Generates all allowed bindings from within any particular component instance + * @param {fetchBindablePropertiesParameter} param + * @returns {Array.} + */ +export default function({ componentInstanceId, screen, components, models }) { + const walkResult = walk({ + // cloning so we are free to mutate props (e.g. by adding _contexts) + instance: cloneDeep(screen.props), + targetId: componentInstanceId, + components, + models, + }) + + return [ + ...walkResult.bindableInstances + .filter(isInstanceInSharedContext(walkResult)) + .map(componentInstanceToBindable(walkResult)), + + ...walkResult.target._contexts.map(contextToBindables(walkResult)).flat(), + ] +} + +const isInstanceInSharedContext = walkResult => i => + // should cover + // - neither are in any context + // - both in same context + // - instance is in ancestor context of target + i.instance._contexts.length <= walkResult.target._contexts.length && + difference(i.instance._contexts, walkResult.target._contexts).length === 0 + +// turns a component instance prop into binding expressions +// used by the UI +const componentInstanceToBindable = walkResult => i => { + const lastContext = + i.instance._contexts.length && + i.instance._contexts[i.instance._contexts.length - 1] + const contextParentPath = lastContext + ? getParentPath(walkResult, lastContext) + : "" + + return { + type: "instance", + instance: i.instance, + // how the binding expression persists, and is used in the app at runtime + runtimeBinding: `${contextParentPath}${i.instance._id}.${i.prop}`, + // how the binding exressions looks to the user of the builder + readableBinding: `${i.instance._instanceName}`, + } +} + +const contextToBindables = walkResult => c => { + const contextParentPath = getParentPath(walkResult, c) + + return Object.keys(c.model.schema).map(k => ({ + type: "context", + instance: c.instance, + // how the binding expression persists, and is used in the app at runtime + runtimeBinding: `${contextParentPath}data.${k}`, + // how the binding exressions looks to the user of the builder + readableBinding: `${c.instance._instanceName}.${c.model.name}.${k}`, + })) +} + +const getParentPath = (walkResult, context) => { + // describes the number of "parent" in the path + // clone array first so original array is not mtated + const contextParentNumber = [...walkResult.target._contexts] + .reverse() + .indexOf(context) + + return ( + new Array(contextParentNumber).fill("parent").join(".") + + // trailing . if has parents + (contextParentNumber ? "." : "") + ) +} + +const walk = ({ instance, targetId, components, models, result }) => { + if (!result) { + result = { + target: null, + bindableInstances: [], + allContexts: [], + currentContexts: [], + } + } + + if (!instance._contexts) instance._contexts = [] + + // "component" is the component definition (object in component.json) + const component = components[instance._component] + + if (instance._id === targetId) { + // found it + result.target = instance + } else { + if (component.bindable) { + // pushing all components in here initially + // but this will not be correct, as some of + // these components will be in another context + // but we dont know this until the end of the walk + // so we will filter in another metod + result.bindableInstances.push({ + instance, + prop: component.bindable, + }) + } + } + + // a component that provides context to it's children + const contextualInstance = component.context && instance[component.context] + + if (contextualInstance) { + // add to currentContexts (ancestory of context) + // before walking children + const model = models.find(m => m._id === instance[component.context]) + result.currentContexts.push({ instance, model }) + } + + const currentContexts = [...result.currentContexts] + for (let child of instance._children || []) { + // attaching _contexts of components, for eas comparison later + // these have been deep cloned above, so shouln't modify the + // original component instances + child._contexts = currentContexts + walk({ instance: child, targetId, components, models, result }) + } + + if (contextualInstance) { + // child walk done, remove from currentContexts + result.currentContexts.pop() + } + + return result +} diff --git a/packages/builder/src/builderStore/getNewComponentName.js b/packages/builder/src/builderStore/getNewComponentName.js new file mode 100644 index 0000000000..b3ddc4e953 --- /dev/null +++ b/packages/builder/src/builderStore/getNewComponentName.js @@ -0,0 +1,39 @@ +import { walkProps } from "./storeUtils" +import { get_capitalised_name } from "../helpers" + +export default function(component, state) { + const capitalised = get_capitalised_name(component) + + const matchingComponents = [] + + const findMatches = props => { + walkProps(props, c => { + if ((c._instanceName || "").startsWith(capitalised)) { + matchingComponents.push(c._instanceName) + } + }) + } + + // check page first + findMatches(state.pages[state.currentPageName].props) + + // if viewing screen, check current screen for duplicate + if (state.currentFrontEndType === "screen") { + findMatches(state.currentPreviewItem.props) + } else { + // viewing master page - need to find against all screens + for (let screen of state.screens) { + findMatches(screen.props) + } + } + + let index = 1 + let name + while (!name) { + const tryName = `${capitalised} ${index}` + if (!matchingComponents.includes(tryName)) name = tryName + index++ + } + + return name +} diff --git a/packages/builder/src/builderStore/store/index.js b/packages/builder/src/builderStore/store/index.js index f5849c480a..3c1ef7ad05 100644 --- a/packages/builder/src/builderStore/store/index.js +++ b/packages/builder/src/builderStore/store/index.js @@ -1,5 +1,5 @@ import { values, cloneDeep } from "lodash/fp" -import { get_capitalised_name } from "../../helpers" +import getNewComponentName from "../getNewComponentName" import { backendUiStore } from "builderStore" import * as backendStoreActions from "./backend" import { writable, get } from "svelte/store" @@ -281,7 +281,7 @@ const addChildComponent = store => (componentToAdd, presetProps = {}) => { const component = getComponentDefinition(state, componentToAdd) const instanceId = get(backendUiStore).selectedDatabase._id - const instanceName = get_capitalised_name(componentToAdd) + const instanceName = getNewComponentName(componentToAdd, state) const newComponent = createProps( component, @@ -487,7 +487,7 @@ const pasteComponent = store => (targetComponent, mode) => { // in case we paste a second time s.componentToPaste.isCut = false } else { - generateNewIdsForComponent(componentToPaste) + generateNewIdsForComponent(componentToPaste, s) } delete componentToPaste.isCut diff --git a/packages/builder/src/builderStore/storeUtils.js b/packages/builder/src/builderStore/storeUtils.js index 1630d118e0..b88bda17b2 100644 --- a/packages/builder/src/builderStore/storeUtils.js +++ b/packages/builder/src/builderStore/storeUtils.js @@ -2,6 +2,7 @@ import { makePropsSafe } from "components/userInterface/pagesParsing/createProps import api from "./api" import { generate_screen_css } from "./generate_css" import { uuid } from "./uuid" +import getNewComponentName from "./getNewComponentName" export const selectComponent = (state, component) => { const componentDef = component._component.startsWith("##") @@ -81,7 +82,8 @@ export const regenerateCssForCurrentScreen = state => { return state } -export const generateNewIdsForComponent = c => +export const generateNewIdsForComponent = (c, state) => walkProps(c, p => { p._id = uuid() + p._instanceName = getNewComponentName(p._component, state) }) 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/builder/src/components/userInterface/ComponentPropertiesPanel.svelte b/packages/builder/src/components/userInterface/ComponentPropertiesPanel.svelte index 4528ab8c8c..e82a1b0bb8 100644 --- a/packages/builder/src/components/userInterface/ComponentPropertiesPanel.svelte +++ b/packages/builder/src/components/userInterface/ComponentPropertiesPanel.svelte @@ -13,7 +13,6 @@ } from "components/common/Icons/" import CodeEditor from "./CodeEditor.svelte" import LayoutEditor from "./LayoutEditor.svelte" - import EventsEditor from "./EventsEditor" import panelStructure from "./temporaryPanelStructure.js" import CategoryTab from "./CategoryTab.svelte" import DesignView from "./DesignView.svelte" @@ -25,7 +24,6 @@ let categories = [ { value: "settings", name: "Settings" }, { value: "design", name: "Design" }, - { value: "events", name: "Events" }, ] let selectedCategory = categories[0] @@ -113,8 +111,6 @@ displayNameField={displayName} onChange={onPropChanged} screenOrPageInstance={$store.currentView !== 'component' && $store.currentPreviewItem} /> - {:else if selectedCategory.value === 'events'} - {/if} diff --git a/packages/builder/src/components/userInterface/EventsEditor/EventEditorModal.svelte b/packages/builder/src/components/userInterface/EventsEditor/EventEditorModal.svelte index 8132d502d1..0b880df480 100644 --- a/packages/builder/src/components/userInterface/EventsEditor/EventEditorModal.svelte +++ b/packages/builder/src/components/userInterface/EventsEditor/EventEditorModal.svelte @@ -1,37 +1,28 @@
-

- {eventData.name ? `${eventData.name} Event` : 'Create a New Component Event'} -

-
-
-
-

Event Type

- -
+

{eventType} Event

@@ -92,35 +61,16 @@ }} handler={draftEventHandler} />
- {#if eventData} - {#each eventData.handlers as handler, index} - deleteEventHandler(index)} - {handler} /> - {/each} - {/if} + {#each handlers as handler, index} + deleteEventHandler(index)} + {handler} /> + {/each}
- +
@@ -129,6 +79,7 @@ diff --git a/packages/builder/src/components/userInterface/EventsEditor/EventsEditor.svelte b/packages/builder/src/components/userInterface/EventsEditor/EventsEditor.svelte deleted file mode 100644 index 6a29fd3344..0000000000 --- a/packages/builder/src/components/userInterface/EventsEditor/EventsEditor.svelte +++ /dev/null @@ -1,161 +0,0 @@ - - - - -
-
- {#each events as event, index} - {#if event.handlers.length > 0} -
openModal({ ...event, index })}> - {event.name} - EDIT -
- {/if} - {/each} -
-
- - diff --git a/packages/builder/src/components/userInterface/EventsEditor/HandlerSelector.svelte b/packages/builder/src/components/userInterface/EventsEditor/HandlerSelector.svelte index 1537f20e83..249340c517 100644 --- a/packages/builder/src/components/userInterface/EventsEditor/HandlerSelector.svelte +++ b/packages/builder/src/components/userInterface/EventsEditor/HandlerSelector.svelte @@ -1,8 +1,5 @@ {#if screenOrPageInstance} @@ -54,7 +94,10 @@ label="Name" key="_instanceName" value={componentInstance._instanceName} - {onChange} /> + onChange={onInstanceNameChange} /> + {#if duplicateName} + Name must be unique + {/if} {/if} {#if panelDefinition && panelDefinition.length > 0} @@ -79,4 +122,11 @@ div { text-align: center; } + + .duplicate-name { + color: var(--red); + font-size: var(--font-size-xs); + position: relative; + top: -10px; + } diff --git a/packages/builder/src/components/userInterface/temporaryPanelStructure.js b/packages/builder/src/components/userInterface/temporaryPanelStructure.js index f7bb352d4b..7245947aef 100644 --- a/packages/builder/src/components/userInterface/temporaryPanelStructure.js +++ b/packages/builder/src/components/userInterface/temporaryPanelStructure.js @@ -2,6 +2,7 @@ import Input from "../common/Input.svelte" import OptionSelect from "./OptionSelect.svelte" import Checkbox from "../common/Checkbox.svelte" import ModelSelect from "components/userInterface/ModelSelect.svelte" +import Event from "components/userInterface/EventsEditor/EventPropertyControl.svelte" import { all } from "./propertyCategories.js" /* @@ -201,6 +202,7 @@ export default { valueKey: "checked", control: Checkbox, }, + { label: "onClick", key: "onClick", control: Event }, ], }, }, diff --git a/packages/builder/tests/fetchBindableProperties.spec.js b/packages/builder/tests/fetchBindableProperties.spec.js new file mode 100644 index 0000000000..c8372b0255 --- /dev/null +++ b/packages/builder/tests/fetchBindableProperties.spec.js @@ -0,0 +1,200 @@ +import fetchBindableProperties from "../src/builderStore/fetchBindableProperties" +describe("fetch bindable properties", () => { + + it("should return bindable properties from screen components", () => { + const result = fetchBindableProperties({ + componentInstanceId: "heading-id", + ...testData() + }) + const componentBinding = result.find(r => r.instance._id === "search-input-id" && r.type === "instance") + expect(componentBinding).toBeDefined() + expect(componentBinding.type).toBe("instance") + expect(componentBinding.runtimeBinding).toBe("search-input-id.value") + }) + + it("should not return bindable components when not in their context", () => { + const result = fetchBindableProperties({ + componentInstanceId: "heading-id", + ...testData() + }) + const componentBinding = result.find(r => r.instance._id === "list-item-input-id") + expect(componentBinding).not.toBeDefined() + }) + + it("should return model schema, when inside a context", () => { + const result = fetchBindableProperties({ + componentInstanceId: "list-item-input-id", + ...testData() + }) + const contextBindings = result.filter(r => r.instance._id === "list-id" && r.type==="context") + expect(contextBindings.length).toBe(2) + + const namebinding = contextBindings.find(b => b.runtimeBinding === "data.name") + expect(namebinding).toBeDefined() + expect(namebinding.readableBinding).toBe("list-name.Test Model.name") + + const descriptionbinding = contextBindings.find(b => b.runtimeBinding === "data.description") + expect(descriptionbinding).toBeDefined() + expect(descriptionbinding.readableBinding).toBe("list-name.Test Model.description") + }) + + it("should return model schema, for grantparent context", () => { + const result = fetchBindableProperties({ + componentInstanceId: "child-list-item-input-id", + ...testData() + }) + const contextBindings = result.filter(r => r.type==="context") + expect(contextBindings.length).toBe(4) + + const namebinding_parent = contextBindings.find(b => b.runtimeBinding === "parent.data.name") + expect(namebinding_parent).toBeDefined() + expect(namebinding_parent.readableBinding).toBe("list-name.Test Model.name") + + const descriptionbinding_parent = contextBindings.find(b => b.runtimeBinding === "parent.data.description") + expect(descriptionbinding_parent).toBeDefined() + expect(descriptionbinding_parent.readableBinding).toBe("list-name.Test Model.description") + + const namebinding_own = contextBindings.find(b => b.runtimeBinding === "data.name") + expect(namebinding_own).toBeDefined() + expect(namebinding_own.readableBinding).toBe("child-list-name.Test Model.name") + + const descriptionbinding_own = contextBindings.find(b => b.runtimeBinding === "data.description") + expect(descriptionbinding_own).toBeDefined() + expect(descriptionbinding_own.readableBinding).toBe("child-list-name.Test Model.description") + }) + + it("should return bindable component props, from components in same context", () => { + const result = fetchBindableProperties({ + componentInstanceId: "list-item-heading-id", + ...testData() + }) + const componentBinding = result.find(r => r.instance._id === "list-item-input-id" && r.type === "instance") + expect(componentBinding).toBeDefined() + expect(componentBinding.runtimeBinding).toBe("list-item-input-id.value") + }) + + it("should not return components from child context", () => { + const result = fetchBindableProperties({ + componentInstanceId: "list-item-heading-id", + ...testData() + }) + const componentBinding = result.find(r => r.instance._id === "child-list-item-input-id" && r.type === "instance") + expect(componentBinding).not.toBeDefined() + }) + + it("should return bindable component props, from components in same context (when nested context)", () => { + const result = fetchBindableProperties({ + componentInstanceId: "child-list-item-heading-id", + ...testData() + }) + const componentBinding = result.find(r => r.instance._id === "child-list-item-input-id" && r.type === "instance") + expect(componentBinding).toBeDefined() + }) + +}) + +const testData = () => { + + const screen = { + instanceName: "test screen", + name: "screen-id", + route: "/", + props: { + _id:"screent-root-id", + _component: "@budibase/standard-components/container", + _children: [ + { + _id: "heading-id", + _instanceName: "list item heading", + _component: "@budibase/standard-components/heading", + text: "Screen Title" + }, + { + _id: "search-input-id", + _instanceName: "Search Input", + _component: "@budibase/standard-components/input", + value: "search phrase" + }, + { + _id: "list-id", + _component: "@budibase/standard-components/list", + _instanceName: "list-name", + model: "test-model-id", + _children: [ + { + _id: "list-item-heading-id", + _instanceName: "list item heading", + _component: "@budibase/standard-components/heading", + text: "hello" + }, + { + _id: "list-item-input-id", + _instanceName: "List Item Input", + _component: "@budibase/standard-components/input", + value: "list item" + }, + { + _id: "child-list-id", + _component: "@budibase/standard-components/list", + _instanceName: "child-list-name", + model: "test-model-id", + _children: [ + { + _id: "child-list-item-heading-id", + _instanceName: "child list item heading", + _component: "@budibase/standard-components/heading", + text: "hello" + }, + { + _id: "child-list-item-input-id", + _instanceName: "Child List Item Input", + _component: "@budibase/standard-components/input", + value: "child list item" + }, + ] + }, + ] + }, + ] + } + } + + const models = [{ + _id: "test-model-id", + name: "Test Model", + schema: { + name: { + type: "string" + }, + description: { + type: "string" + } + } + }] + + const components = { + "@budibase/standard-components/container" : { + props: {}, + }, + "@budibase/standard-components/list" : { + context: "model", + props: { + model: "string" + }, + }, + "@budibase/standard-components/input" : { + bindable: "value", + props: { + value: "string" + }, + }, + "@budibase/standard-components/heading" : { + props: { + text: "string" + }, + }, + } + + return { screen, models, components } + +} \ No newline at end of file diff --git a/packages/client/package.json b/packages/client/package.json index 49d598fb03..e1083ed842 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..4a15d04772 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 renderTemplateString from "../../state/renderTemplateString" +import appStore from "../../state/store" import Orchestrator from "./orchestrator" import clientActions from "./actions" @@ -18,9 +17,9 @@ export const clientStrategy = ({ api }) => ({ if (typeof argValue !== "string") continue // Render the string with values from the workflow context and state - mappedArgs[arg] = mustache.render(argValue, { + mappedArgs[arg] = renderTemplateString(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..5d0d34d003 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 renderTemplateString from "../state/renderTemplateString" +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] = renderTemplateString(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/renderTemplateString.js b/packages/client/src/state/renderTemplateString.js new file mode 100644 index 0000000000..c872bffc63 --- /dev/null +++ b/packages/client/src/state/renderTemplateString.js @@ -0,0 +1,17 @@ +import mustache from "mustache" + +// this is a much more liberal version of mustache's escape function +// ...just ignoring < and > to prevent tags from user input +// original version here https://github.com/janl/mustache.js/blob/4b7908f5c9fec469a11cfaed2f2bed23c84e1c5c/mustache.js#L78 + +const entityMap = { + "<": "<", + ">": ">", +} + +mustache.escape = text => + String(text).replace(/[&<>"'`=/]/g, function fromEntityMap(s) { + return entityMap[s] + }) + +export default mustache.render 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..fbfb39b7a4 100644 --- a/packages/client/src/state/stateManager.js +++ b/packages/client/src/state/stateManager.js @@ -4,9 +4,9 @@ import { EVENT_TYPE_MEMBER_NAME, } from "./eventHandlers" import { bbFactory } from "./bbComponentApi" -import mustache from "mustache" -import { get } from "svelte/store" -import { appStore } from "./store" +import renderTemplateString from "./renderTemplateString" +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] = renderTemplateString(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 = { @@ -90,10 +84,7 @@ const _setup = ({ handlerTypes, getCurrentState, bb, store }) => node => { for (let paramName in handlerInfo.parameters) { const paramValue = handlerInfo.parameters[paramName] resolvedParams[paramName] = () => - mustache.render(paramValue, { - state: getCurrentState(), - context, - }) + renderTemplateString(paramValue, state) } handlerInfo.parameters = resolvedParams @@ -113,7 +104,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 @@ + + +
diff --git a/packages/standard-components/components.json b/packages/standard-components/components.json index 78d6a4f140..92eb5dad99 100644 --- a/packages/standard-components/components.json +++ b/packages/standard-components/components.json @@ -131,6 +131,7 @@ }, "select": { "name": "Select", + "bindable": "value", "description": "An HTML