From 53fafab3d9c14e388491dbd20c3c2c3e22818895 Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Mon, 3 Aug 2020 15:06:51 +0100 Subject: [PATCH 01/50] binding - backend initial --- .../builderStore/fetchBindableProperties.js | 111 ++++++++++++++++ packages/builder/tests/binding.spec.js | 118 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 packages/builder/src/builderStore/fetchBindableProperties.js create mode 100644 packages/builder/tests/binding.spec.js diff --git a/packages/builder/src/builderStore/fetchBindableProperties.js b/packages/builder/src/builderStore/fetchBindableProperties.js new file mode 100644 index 0000000000..04648c4fb2 --- /dev/null +++ b/packages/builder/src/builderStore/fetchBindableProperties.js @@ -0,0 +1,111 @@ +export default function({ componentInstanceId, screen, components, models }) { + const { target, targetAncestors, bindableInstances, bindableContexts } = walk( + { + instance: screen.props, + targetId: componentInstanceId, + components, + models, + } + ) + + return [ + ...bindableInstances + .filter(isComponentInstanceAvailable) + .map(componentInstanceToBindable), + ...bindableContexts.map(contextToBindables), + ] +} + +const isComponentInstanceAvailable = i => true + +// turns a component instance prop into binding expressions +// used by the UI +const componentInstanceToBindable = i => ({ + type: "instance", + instance: i.instance, + // how the binding expression persists, and is used in the app at runtime + runtimeBinding: `state.${i.instance._id}.${i.prop}`, + // how the binding exressions looks to the user of the builder + readableBinding: `${i.instance._instanceName}`, +}) + +const contextToBindables = c => { + const contextParentNumber = 0 + const contextParentPath = Array[contextParentNumber] + .map(() => "_parent") + .join(".") + return Object.keys(c.schema).map(k => ({ + type: "context", + instance: c.instance, + // how the binding expression persists, and is used in the app at runtime + runtimeBinding: `context.${contextParentPath}.${k}`, + // how the binding exressions looks to the user of the builder + readableBinding: `${c.instance._instanceName}.${c.schema.name}.${k}`, + })) +} + +const walk = ({ instance, targetId, components, models, result }) => { + if (!result) { + result = { + currentAncestors: [], + currentContexts: [], + target: null, + targetAncestors: [], + bindableInstances: [], + bindableContexts: [], + parentMap: {}, + } + } + + // "component" is the component definition (object in component.json) + const component = components[instance._component] + const parentInstance = + result.currentAncestors.length > 0 && + result.currentAncestors[result.currentAncestors.length - 1] + + if (instance._id === targetId) { + // set currentParents to be result parents + result.targetAncestors = result.currentAncestors + result.bindableContexts = result.currentContexts + // found it + result.target = instance + } else { + if (instance.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: instance.bindable, + }) + } + } + console.log(instance._component) + console.debug(components) + // 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 schema = models.find(m => m._id === instance[component.context]) + .schema + result.currentContexts.push({ instance, schema }) + } + + for (let child of instance._children || []) { + result.parentMap[child._id] = parentInstance._id + result.currentAncestors.push(instance) + walk({ instance, targetId, components, models, result }) + result.currentAncestors.pop() + } + + if (contextualInstance) { + // child walk done, remove from currentContexts + result.currentContexts.pop() + } + + return result +} diff --git a/packages/builder/tests/binding.spec.js b/packages/builder/tests/binding.spec.js new file mode 100644 index 0000000000..0666edb39e --- /dev/null +++ b/packages/builder/tests/binding.spec.js @@ -0,0 +1,118 @@ +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") + expect(componentBinding).toBeDefined() + expect(componentBinding.type).toBe("instance") + expect(componentBinding.runtimeBinding).toBe("state.search-input-id.value") + }) + + it("should not return bindable components when not in their context", () => { + + }) + + it("should return model schema, when inside a context", () => { + + }) + + it("should return model schema, for grantparent context", () => { + + }) + + it("should return bindable component props, from components in same context", () => { + + }) + + it("should not return model props from child context", () => { + + }) + + + +}) + +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" + } + ] + }, + ] + } + } + + 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 From 80dd9b94b1c5b7eaf805062efd47980d901400a9 Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Tue, 4 Aug 2020 11:10:02 +0100 Subject: [PATCH 02/50] stub bindings --- packages/builder/package.json | 37 ++----------------- .../builderStore/fetchBindableProperties.js | 28 +++++++++++--- packages/builder/tests/binding.spec.js | 24 +++++++++--- 3 files changed, 44 insertions(+), 45 deletions(-) diff --git a/packages/builder/package.json b/packages/builder/package.json index a93609b4ed..e058873c18 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -16,38 +16,7 @@ "cy:test": "start-server-and-test cy:setup http://localhost:4001/_builder cy:run" }, "jest": { - "globals": { - "GLOBALS": { - "client": "web" - } - }, - "testURL": "http://jest-breaks-if-this-does-not-exist", - "moduleNameMapper": { - "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/internals/mocks/fileMock.js", - "\\.(css|less|sass|scss)$": "identity-obj-proxy", - "components(.*)$": "/src/components$1", - "builderStore(.*)$": "/src/builderStore$1" - }, - "moduleFileExtensions": [ - "js", - "svelte" - ], - "moduleDirectories": [ - "node_modules" - ], - "transform": { - "^.+js$": "babel-jest", - "^.+.svelte$": "svelte-jester" - }, - "transformIgnorePatterns": [ - "/node_modules/(?!svelte).+\\.js$" - ], - "modulePathIgnorePatterns": [ - "/cypress/" - ], - "setupFilesAfterEnv": [ - "@testing-library/jest-dom/extend-expect" - ] + "testEnvironment": "node" }, "eslintConfig": { "extends": [ @@ -86,12 +55,12 @@ "@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", "eslint-plugin-cypress": "^2.11.1", "http-proxy-middleware": "^0.19.1", - "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 index 04648c4fb2..1f058cd90c 100644 --- a/packages/builder/src/builderStore/fetchBindableProperties.js +++ b/packages/builder/src/builderStore/fetchBindableProperties.js @@ -1,3 +1,23 @@ +const stubBindings = [ + { + // type: instance represents a bindable property of a component + type: "instance", + instance: {} /** a component instance **/, + // how the binding expression persists, and is used in the app at runtime + runtimeBinding: "state..", + // how the binding exressions looks to the user of the builder + readableBinding: "", + }, + { + type: "context", + instance: { /** a component instance **/}, + // how the binding expression persists, and is used in the app at runtime + runtimeBinding: "context._parent.", + // how the binding exressions looks to the user of the builder + readableBinding: "..", + }, +] + export default function({ componentInstanceId, screen, components, models }) { const { target, targetAncestors, bindableInstances, bindableContexts } = walk( { @@ -70,7 +90,7 @@ const walk = ({ instance, targetId, components, models, result }) => { // found it result.target = instance } else { - if (instance.bindable) { + 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 @@ -78,12 +98,10 @@ const walk = ({ instance, targetId, components, models, result }) => { // so we will filter in another metod result.bindableInstances.push({ instance, - prop: instance.bindable, + prop: component.bindable, }) } } - console.log(instance._component) - console.debug(components) // a component that provides context to it's children const contextualInstance = component.context && instance[component.context] @@ -98,7 +116,7 @@ const walk = ({ instance, targetId, components, models, result }) => { for (let child of instance._children || []) { result.parentMap[child._id] = parentInstance._id result.currentAncestors.push(instance) - walk({ instance, targetId, components, models, result }) + walk({ instance: child, targetId, components, models, result }) result.currentAncestors.pop() } diff --git a/packages/builder/tests/binding.spec.js b/packages/builder/tests/binding.spec.js index 0666edb39e..5d02171b92 100644 --- a/packages/builder/tests/binding.spec.js +++ b/packages/builder/tests/binding.spec.js @@ -1,20 +1,26 @@ -import fetchbindableProperties from "../src/builderStore/fetchBindableProperties" - +import fetchBindableProperties from "../src/builderStore/fetchBindableProperties" describe("fetch bindable properties", () => { it("should return bindable properties from screen components", () => { - const result = fetchbindableProperties({ + const result = fetchBindableProperties({ componentInstanceId: "heading-id", ...testData() }) const componentBinding = result.find(r => r.instance._id === "search-input-id") + console.debug(componentBinding) + console.debug("result:" + JSON.stringify(result)) expect(componentBinding).toBeDefined() expect(componentBinding.type).toBe("instance") expect(componentBinding.runtimeBinding).toBe("state.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", () => { @@ -70,7 +76,13 @@ const testData = () => { _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" + }, ] }, ] @@ -78,7 +90,7 @@ const testData = () => { } const models = [{ - id: "test-model-id", + _id: "test-model-id", name: "Test Model", schema: { name: { From 549327c8850d05c9872efbf3903079c821681311 Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Tue, 4 Aug 2020 11:17:44 +0100 Subject: [PATCH 03/50] reinstated jest config (removed accidently) --- packages/builder/package.json | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/builder/package.json b/packages/builder/package.json index e058873c18..30b9a2bdae 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -15,8 +15,39 @@ "cy:open": "cypress open", "cy:test": "start-server-and-test cy:setup http://localhost:4001/_builder cy:run" }, - "jest": { - "testEnvironment": "node" + "jest": { + "globals": { + "GLOBALS": { + "client": "web" + } + }, + "testURL": "http://jest-breaks-if-this-does-not-exist", + "moduleNameMapper": { + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/internals/mocks/fileMock.js", + "\\.(css|less|sass|scss)$": "identity-obj-proxy", + "components(.*)$": "/src/components$1", + "builderStore(.*)$": "/src/builderStore$1" + }, + "moduleFileExtensions": [ + "js", + "svelte" + ], + "moduleDirectories": [ + "node_modules" + ], + "transform": { + "^.+js$": "babel-jest", + "^.+.svelte$": "svelte-jester" + }, + "transformIgnorePatterns": [ + "/node_modules/(?!svelte).+\\.js$" + ], + "modulePathIgnorePatterns": [ + "/cypress/" + ], + "setupFilesAfterEnv": [ + "@testing-library/jest-dom/extend-expect" + ] }, "eslintConfig": { "extends": [ From e4bcd4020b045ece4923a9cc18f3919bba24ef60 Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Tue, 4 Aug 2020 16:11:46 +0100 Subject: [PATCH 04/50] fetchBindableProperties - complete --- .../builderStore/fetchBindableProperties.js | 123 +++++++++++------- packages/builder/tests/binding.spec.js | 88 +++++++++++-- 2 files changed, 156 insertions(+), 55 deletions(-) diff --git a/packages/builder/src/builderStore/fetchBindableProperties.js b/packages/builder/src/builderStore/fetchBindableProperties.js index 1f058cd90c..8f059bd6bd 100644 --- a/packages/builder/src/builderStore/fetchBindableProperties.js +++ b/packages/builder/src/builderStore/fetchBindableProperties.js @@ -1,3 +1,5 @@ +import { cloneDeep, difference, fill } from "lodash" + const stubBindings = [ { // type: instance represents a bindable property of a component @@ -10,7 +12,9 @@ const stubBindings = [ }, { type: "context", - instance: { /** a component instance **/}, + instance: { + /** a component instance **/ + }, // how the binding expression persists, and is used in the app at runtime runtimeBinding: "context._parent.", // how the binding exressions looks to the user of the builder @@ -19,74 +23,97 @@ const stubBindings = [ ] export default function({ componentInstanceId, screen, components, models }) { - const { target, targetAncestors, bindableInstances, bindableContexts } = walk( - { - instance: screen.props, - targetId: componentInstanceId, - 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 [ - ...bindableInstances - .filter(isComponentInstanceAvailable) - .map(componentInstanceToBindable), - ...bindableContexts.map(contextToBindables), + ...walkResult.bindableInstances + .filter(isInstanceInSharedContext(walkResult)) + .map(componentInstanceToBindable(walkResult)), + + ...walkResult.target._contexts.map(contextToBindables(walkResult)).flat(), ] } -const isComponentInstanceAvailable = i => true +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 = i => ({ - type: "instance", - instance: i.instance, - // how the binding expression persists, and is used in the app at runtime - runtimeBinding: `state.${i.instance._id}.${i.prop}`, - // how the binding exressions looks to the user of the builder - readableBinding: `${i.instance._instanceName}`, -}) +const componentInstanceToBindable = walkResult => i => { + const lastContext = + i.instance._contexts.length && + i.instance._contexts[i.instance._contexts.length - 1] + const contextParentPath = lastContext + ? getParentPath(walkResult, lastContext) + : "" -const contextToBindables = c => { - const contextParentNumber = 0 - const contextParentPath = Array[contextParentNumber] - .map(() => "_parent") - .join(".") - return Object.keys(c.schema).map(k => ({ + // if component is inside context, then the component lives + // in context at runtime (otherwise, in state) + const stateOrContext = lastContext ? "context" : "state" + return { + type: "instance", + instance: i.instance, + // how the binding expression persists, and is used in the app at runtime + runtimeBinding: `${stateOrContext}.${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: `context.${contextParentPath}.${k}`, + runtimeBinding: `context.${contextParentPath}data.${k}`, // how the binding exressions looks to the user of the builder - readableBinding: `${c.instance._instanceName}.${c.schema.name}.${k}`, + 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 = { - currentAncestors: [], - currentContexts: [], target: null, - targetAncestors: [], bindableInstances: [], - bindableContexts: [], - parentMap: {}, + allContexts: [], + currentContexts: [], } } + if (!instance._contexts) instance._contexts = [] + // "component" is the component definition (object in component.json) const component = components[instance._component] - const parentInstance = - result.currentAncestors.length > 0 && - result.currentAncestors[result.currentAncestors.length - 1] if (instance._id === targetId) { - // set currentParents to be result parents - result.targetAncestors = result.currentAncestors - result.bindableContexts = result.currentContexts // found it result.target = instance } else { @@ -102,22 +129,24 @@ const walk = ({ instance, targetId, components, models, result }) => { }) } } + // 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 schema = models.find(m => m._id === instance[component.context]) - .schema - result.currentContexts.push({ instance, schema }) + 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 || []) { - result.parentMap[child._id] = parentInstance._id - result.currentAncestors.push(instance) + // 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 }) - result.currentAncestors.pop() } if (contextualInstance) { diff --git a/packages/builder/tests/binding.spec.js b/packages/builder/tests/binding.spec.js index 5d02171b92..cfdbec6dbb 100644 --- a/packages/builder/tests/binding.spec.js +++ b/packages/builder/tests/binding.spec.js @@ -6,9 +6,7 @@ describe("fetch bindable properties", () => { componentInstanceId: "heading-id", ...testData() }) - const componentBinding = result.find(r => r.instance._id === "search-input-id") - console.debug(componentBinding) - console.debug("result:" + JSON.stringify(result)) + 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("state.search-input-id.value") @@ -24,19 +22,73 @@ describe("fetch bindable properties", () => { }) 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 === "context.data.name") + expect(namebinding).toBeDefined() + expect(namebinding.readableBinding).toBe("list-name.Test Model.name") + + const descriptionbinding = contextBindings.find(b => b.runtimeBinding === "context.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 === "context._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 === "context._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 === "context.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 === "context.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("context.list-item-input-id.value") }) - it("should not return model props from child context", () => { - + 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() }) @@ -83,6 +135,26 @@ const testData = () => { _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" + }, + ] + }, ] }, ] From 12e20e460d9984ad55b01c203efbf69ea58915da Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Tue, 4 Aug 2020 16:31:31 +0100 Subject: [PATCH 05/50] tidyup & JSDoc --- .../builderStore/fetchBindableProperties.js | 45 ++++++++++--------- packages/builder/tests/binding.spec.js | 4 +- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/packages/builder/src/builderStore/fetchBindableProperties.js b/packages/builder/src/builderStore/fetchBindableProperties.js index 8f059bd6bd..d2d775ccad 100644 --- a/packages/builder/src/builderStore/fetchBindableProperties.js +++ b/packages/builder/src/builderStore/fetchBindableProperties.js @@ -1,27 +1,28 @@ -import { cloneDeep, difference, fill } from "lodash" +import { cloneDeep, difference } from "lodash" -const stubBindings = [ - { - // type: instance represents a bindable property of a component - type: "instance", - instance: {} /** a component instance **/, - // how the binding expression persists, and is used in the app at runtime - runtimeBinding: "state..", - // how the binding exressions looks to the user of the builder - readableBinding: "", - }, - { - type: "context", - instance: { - /** a component instance **/ - }, - // how the binding expression persists, and is used in the app at runtime - runtimeBinding: "context._parent.", - // how the binding exressions looks to the user of the builder - readableBinding: "..", - }, -] +/** + * 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) diff --git a/packages/builder/tests/binding.spec.js b/packages/builder/tests/binding.spec.js index cfdbec6dbb..ac90faf9bf 100644 --- a/packages/builder/tests/binding.spec.js +++ b/packages/builder/tests/binding.spec.js @@ -89,9 +89,7 @@ describe("fetch bindable properties", () => { }) const componentBinding = result.find(r => r.instance._id === "child-list-item-input-id" && r.type === "instance") expect(componentBinding).toBeDefined() - }) - - + }) }) From a82c0dd44ea96cb59a031332a22d35c6cea36686 Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Thu, 6 Aug 2020 21:12:35 +0100 Subject: [PATCH 06/50] 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 @@ + + +
From cfa8970e2abb933198acc40371b354ec8ab41ccb Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Thu, 6 Aug 2020 21:27:26 +0100 Subject: [PATCH 07/50] updated binding paths (from client lib changes) --- .../src/builderStore/fetchBindableProperties.js | 11 ++++------- packages/builder/tests/binding.spec.js | 16 ++++++++-------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/builder/src/builderStore/fetchBindableProperties.js b/packages/builder/src/builderStore/fetchBindableProperties.js index d2d775ccad..93407fb941 100644 --- a/packages/builder/src/builderStore/fetchBindableProperties.js +++ b/packages/builder/src/builderStore/fetchBindableProperties.js @@ -59,14 +59,11 @@ const componentInstanceToBindable = walkResult => i => { ? getParentPath(walkResult, lastContext) : "" - // if component is inside context, then the component lives - // in context at runtime (otherwise, in state) - const stateOrContext = lastContext ? "context" : "state" return { type: "instance", instance: i.instance, // how the binding expression persists, and is used in the app at runtime - runtimeBinding: `${stateOrContext}.${contextParentPath}${i.instance._id}.${i.prop}`, + runtimeBinding: `${contextParentPath}${i.instance._id}.${i.prop}`, // how the binding exressions looks to the user of the builder readableBinding: `${i.instance._instanceName}`, } @@ -79,21 +76,21 @@ const contextToBindables = walkResult => c => { type: "context", instance: c.instance, // how the binding expression persists, and is used in the app at runtime - runtimeBinding: `context.${contextParentPath}data.${k}`, + 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 + // 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(".") + + new Array(contextParentNumber).fill("parent").join(".") + // trailing . if has parents (contextParentNumber ? "." : "") ) diff --git a/packages/builder/tests/binding.spec.js b/packages/builder/tests/binding.spec.js index ac90faf9bf..c8372b0255 100644 --- a/packages/builder/tests/binding.spec.js +++ b/packages/builder/tests/binding.spec.js @@ -9,7 +9,7 @@ describe("fetch bindable properties", () => { 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("state.search-input-id.value") + expect(componentBinding.runtimeBinding).toBe("search-input-id.value") }) it("should not return bindable components when not in their context", () => { @@ -29,11 +29,11 @@ describe("fetch bindable properties", () => { 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 === "context.data.name") + 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 === "context.data.description") + const descriptionbinding = contextBindings.find(b => b.runtimeBinding === "data.description") expect(descriptionbinding).toBeDefined() expect(descriptionbinding.readableBinding).toBe("list-name.Test Model.description") }) @@ -46,19 +46,19 @@ describe("fetch bindable properties", () => { const contextBindings = result.filter(r => r.type==="context") expect(contextBindings.length).toBe(4) - const namebinding_parent = contextBindings.find(b => b.runtimeBinding === "context._parent.data.name") + 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 === "context._parent.data.description") + 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 === "context.data.name") + 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 === "context.data.description") + 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") }) @@ -70,7 +70,7 @@ describe("fetch bindable properties", () => { }) const componentBinding = result.find(r => r.instance._id === "list-item-input-id" && r.type === "instance") expect(componentBinding).toBeDefined() - expect(componentBinding.runtimeBinding).toBe("context.list-item-input-id.value") + expect(componentBinding.runtimeBinding).toBe("list-item-input-id.value") }) it("should not return components from child context", () => { From 3c8f7822eb7c9a71670dbd29630c8c8413f9dccd Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Fri, 7 Aug 2020 10:13:43 +0100 Subject: [PATCH 08/50] renamed spec file --- .../tests/{binding.spec.js => fetchBindableProperties.spec.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/builder/tests/{binding.spec.js => fetchBindableProperties.spec.js} (100%) diff --git a/packages/builder/tests/binding.spec.js b/packages/builder/tests/fetchBindableProperties.spec.js similarity index 100% rename from packages/builder/tests/binding.spec.js rename to packages/builder/tests/fetchBindableProperties.spec.js From bd01717190b9e4a2221efd06d30bf3da7661c0d7 Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Fri, 7 Aug 2020 12:00:52 +0100 Subject: [PATCH 09/50] input binds to store --- packages/standard-components/src/Input.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/standard-components/src/Input.svelte b/packages/standard-components/src/Input.svelte index bb603ad143..2b6a117533 100644 --- a/packages/standard-components/src/Input.svelte +++ b/packages/standard-components/src/Input.svelte @@ -12,7 +12,7 @@ const onchange = ev => { if (_bb) { - _bb.setStateFromBinding(_bb.props.value, ev.target.value) + _bb.setBinding("value", ev.target.value) } } From 29ee500eea9bfa46402fdfc66cf23d379760078c Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Fri, 7 Aug 2020 12:01:16 +0100 Subject: [PATCH 10/50] new components get unique name --- .../src/builderStore/getNewComponentName.js | 34 +++++++++++++++++++ .../builder/src/builderStore/store/index.js | 4 +-- .../ComponentDropdownMenu.svelte | 10 +++--- 3 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 packages/builder/src/builderStore/getNewComponentName.js diff --git a/packages/builder/src/builderStore/getNewComponentName.js b/packages/builder/src/builderStore/getNewComponentName.js new file mode 100644 index 0000000000..3991decf67 --- /dev/null +++ b/packages/builder/src/builderStore/getNewComponentName.js @@ -0,0 +1,34 @@ +import { walkProps } from "./storeUtils" +import { get_capitalised_name } from "../helpers" + +export default function(component, state) { + const screen = + state.currentFrontEndType === "screen" ? state.currentPreviewItem : null + const page = state.pages[state.currentPageName] + const capitalised = get_capitalised_name(component) + + const matchingComponents = [] + + if (screen) + walkProps(screen.props, c => { + if ((c._instanceName || "").startsWith(capitalised)) { + matchingComponents.push(c._instanceName) + } + }) + + walkProps(page.props, c => { + if ((c._instanceName || "").startsWith(capitalised)) { + matchingComponents.push(c._instanceName) + } + }) + + let index = 0 + 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 c6ab194379..a5dfb85bf8 100644 --- a/packages/builder/src/builderStore/store/index.js +++ b/packages/builder/src/builderStore/store/index.js @@ -1,5 +1,5 @@ import { values } 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, presetName) => { const presetProps = presetName ? component.presets[presetName] : {} const instanceId = get(backendUiStore).selectedDatabase._id - const instanceName = get_capitalised_name(componentToAdd) + const instanceName = getNewComponentName(componentToAdd, state) const newComponent = createProps( component, diff --git a/packages/builder/src/components/userInterface/ComponentDropdownMenu.svelte b/packages/builder/src/components/userInterface/ComponentDropdownMenu.svelte index 28dd59c16f..8fb9fc0dd3 100644 --- a/packages/builder/src/components/userInterface/ComponentDropdownMenu.svelte +++ b/packages/builder/src/components/userInterface/ComponentDropdownMenu.svelte @@ -2,6 +2,7 @@ import { MoreIcon } from "components/common/Icons" import { store } from "builderStore" import { getComponentDefinition } from "builderStore/store" + import getNewComponentName from "builderStore/getNewComponentName" import ConfirmDialog from "components/common/ConfirmDialog.svelte" import { last, cloneDeep } from "lodash/fp" import UIkit from "uikit" @@ -80,9 +81,7 @@ store.update(s => { const parent = getParent(s.currentPreviewItem.props, component) const copiedComponent = cloneDeep(component) - walkProps(copiedComponent, p => { - p._id = uuid() - }) + generateNewIdsForComponent(copiedComponent, s) parent._children = [...parent._children, copiedComponent] saveCurrentPreviewItem(s) s.currentComponentInfo = copiedComponent @@ -105,9 +104,10 @@ }) } - const generateNewIdsForComponent = c => + const generateNewIdsForComponent = (c, state) => walkProps(c, p => { p._id = uuid() + p._instanceName = getNewComponentName(p._component, state) }) const storeComponentForCopy = (cut = false) => { @@ -129,7 +129,7 @@ if (!s.componentToPaste) return s const componentToPaste = cloneDeep(s.componentToPaste) - generateNewIdsForComponent(componentToPaste) + generateNewIdsForComponent(componentToPaste, s) delete componentToPaste._cutId if (mode === "inside") { From f71bda6d6af7a6389ea7974025a4a4cd38e65ace Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Fri, 7 Aug 2020 12:09:48 +0100 Subject: [PATCH 11/50] new component - start numbering at 1 --- packages/builder/src/builderStore/getNewComponentName.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/builderStore/getNewComponentName.js b/packages/builder/src/builderStore/getNewComponentName.js index 3991decf67..051df0f2ae 100644 --- a/packages/builder/src/builderStore/getNewComponentName.js +++ b/packages/builder/src/builderStore/getNewComponentName.js @@ -22,7 +22,7 @@ export default function(component, state) { } }) - let index = 0 + let index = 1 let name while (!name) { const tryName = `${capitalised} ${index}` From 8679c8bdbb2f8ded5e6c8e4da6dda402c6aefcd1 Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Fri, 7 Aug 2020 14:17:57 +0100 Subject: [PATCH 12/50] new comonent name - when on master unique across all screens --- .../src/builderStore/getNewComponentName.js | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/builder/src/builderStore/getNewComponentName.js b/packages/builder/src/builderStore/getNewComponentName.js index 051df0f2ae..b3ddc4e953 100644 --- a/packages/builder/src/builderStore/getNewComponentName.js +++ b/packages/builder/src/builderStore/getNewComponentName.js @@ -2,25 +2,30 @@ import { walkProps } from "./storeUtils" import { get_capitalised_name } from "../helpers" export default function(component, state) { - const screen = - state.currentFrontEndType === "screen" ? state.currentPreviewItem : null - const page = state.pages[state.currentPageName] const capitalised = get_capitalised_name(component) const matchingComponents = [] - if (screen) - walkProps(screen.props, c => { + const findMatches = props => { + walkProps(props, c => { if ((c._instanceName || "").startsWith(capitalised)) { matchingComponents.push(c._instanceName) } }) + } - walkProps(page.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 From 20ecd112f4e79aa78fc35776eaa0b4c1226849c0 Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Fri, 7 Aug 2020 14:18:17 +0100 Subject: [PATCH 13/50] duplicate name check when renaming component --- .../userInterface/SettingsView.svelte | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/builder/src/components/userInterface/SettingsView.svelte b/packages/builder/src/components/userInterface/SettingsView.svelte index 551f81bd2f..22f67e9f66 100644 --- a/packages/builder/src/components/userInterface/SettingsView.svelte +++ b/packages/builder/src/components/userInterface/SettingsView.svelte @@ -4,6 +4,8 @@ import Input from "../common/Input.svelte" import { goto } from "@sveltech/routify" import { excludeProps } from "./propertyCategories.js" + import { store } from "builderStore" + import { walkProps } from "builderStore/storeUtils" export let panelDefinition = [] export let componentDefinition = {} @@ -13,6 +15,7 @@ export let screenOrPageInstance let pageScreenProps = ["title", "favicon", "description", "route"] + let duplicateName = false const propExistsOnComponentDef = prop => pageScreenProps.includes(prop) || prop in componentDefinition.props @@ -33,6 +36,43 @@ $: isPage = screenOrPageInstance && screenOrPageInstance.favicon $: screenOrPageDefinition = isPage ? pageDefinition : screenDefinition + + const isDuplicateName = name => { + let duplicate = false + + const lookForDuplicate = rootPops => { + walkProps(rootPops, (inst, cancel) => { + if (inst._instanceName === name && inst._id !== componentInstance._id) { + duplicate = true + cancel() + } + }) + } + // check page first + lookForDuplicate($store.pages[$store.currentPageName].props) + if (duplicate) return true + + // if viwing screen, check current screen for duplicate + if ($store.currentFrontEndType === "screen") { + lookForDuplicate($store.currentPreviewItem.props) + } else { + // viewing master page - need to dedupe against all screens + for (let screen of $store.screens) { + lookForDuplicate(screen.props) + } + } + + return duplicate + } + + const onInstanceNameChange = (_, name) => { + if (isDuplicateName(name)) { + duplicateName = true + } else { + duplicateName = false + onChange("_instanceName", name) + } + } {#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,9 @@ div { text-align: center; } + + .duplicate-name { + color: var(--red); + font-size: var(--font-size-xs); + } From 76a9ababf1c89b7ae2956c8cdec6c705162a43f6 Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Fri, 7 Aug 2020 14:33:41 +0100 Subject: [PATCH 14/50] styling for dupe component name error --- .../builder/src/components/userInterface/SettingsView.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/builder/src/components/userInterface/SettingsView.svelte b/packages/builder/src/components/userInterface/SettingsView.svelte index 22f67e9f66..1966a4a176 100644 --- a/packages/builder/src/components/userInterface/SettingsView.svelte +++ b/packages/builder/src/components/userInterface/SettingsView.svelte @@ -126,5 +126,7 @@ .duplicate-name { color: var(--red); font-size: var(--font-size-xs); + position: relative; + top: -10px; } From 6e115df804f86c8fcdc992c015e103cec3450121 Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Mon, 10 Aug 2020 16:08:46 +0100 Subject: [PATCH 15/50] Moved events back into Settings & removed Events Panel --- .../ComponentPropertiesPanel.svelte | 4 - .../EventsEditor/EventEditorModal.svelte | 75 +++----- .../EventsEditor/EventPropertyControl.svelte | 39 +++++ .../EventsEditor/EventsEditor.svelte | 161 ------------------ .../userInterface/temporaryPanelStructure.js | 2 + 5 files changed, 67 insertions(+), 214 deletions(-) create mode 100644 packages/builder/src/components/userInterface/EventsEditor/EventPropertyControl.svelte delete mode 100644 packages/builder/src/components/userInterface/EventsEditor/EventsEditor.svelte diff --git a/packages/builder/src/components/userInterface/ComponentPropertiesPanel.svelte b/packages/builder/src/components/userInterface/ComponentPropertiesPanel.svelte index 3996add741..3b04e1b510 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..3a16170043 100644 --- a/packages/builder/src/components/userInterface/EventsEditor/EventEditorModal.svelte +++ b/packages/builder/src/components/userInterface/EventsEditor/EventEditorModal.svelte @@ -10,28 +10,25 @@ import Input from "../../common/Input.svelte" import getIcon from "../../common/icon" import { CloseIcon } from "components/common/Icons/" - import { EVENT_TYPE_MEMBER_NAME } from "../../common/eventHandlers" - export let event - export let eventOptions = [] + export let event = [] + export let eventType export let onClose + export let onChange - let eventType = "" let draftEventHandler = { parameters: [] } - $: eventData = event || { handlers: [] } - $: if (!eventOptions.includes(eventType) && eventOptions.length > 0) - eventType = eventOptions[0].name + $: handlers = event || [] const closeModal = () => { onClose() draftEventHandler = { parameters: [] } - eventData = { handlers: [] } + handlers = [] } const updateEventHandler = (updatedHandler, index) => { - eventData.handlers[index] = updatedHandler + handlers[index] = updatedHandler } const updateDraftEventHandler = updatedHandler => { @@ -39,8 +36,7 @@ } const deleteEventHandler = index => { - eventData.handlers.splice(index, 1) - eventData = eventData + handlers.splice(index, 1) } const createNewEventHandler = handler => { @@ -48,17 +44,18 @@ parameters: {}, [EVENT_TYPE_MEMBER_NAME]: "", } - eventData.handlers.push(newHandler) - eventData = eventData + handlers.push(newHandler) + handlers = handlers } const deleteEvent = () => { - store.setComponentProp(eventType, []) + handlers = [] + onChange([]) closeModal() } const saveEventData = () => { - store.setComponentProp(eventType, eventData.handlers) + onChange(handlers) closeModal() } @@ -66,19 +63,7 @@
-

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

-
-
-
-

Event Type

- -
+

{eventType} Event

@@ -92,31 +77,23 @@ }} handler={draftEventHandler} />
- {#if eventData} - {#each eventData.handlers as handler, index} - deleteEventHandler(index)} - {handler} /> - {/each} - {/if} + {#each handlers as handler, index} + deleteEventHandler(index)} + {handler} /> + {/each}
-
@@ -99,6 +79,7 @@ diff --git a/packages/builder/src/components/userInterface/PropertyControl.svelte b/packages/builder/src/components/userInterface/PropertyControl.svelte index a168ef9742..8fce04e6b1 100644 --- a/packages/builder/src/components/userInterface/PropertyControl.svelte +++ b/packages/builder/src/components/userInterface/PropertyControl.svelte @@ -1,4 +1,6 @@
-

- {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