From 053f3b4e6f54f3feb617ce90c71bdbbfc440ce41 Mon Sep 17 00:00:00 2001 From: Martin McKeaveney Date: Tue, 11 Aug 2020 17:20:55 +0100 Subject: [PATCH 01/18] small styling fix --- .../src/components/nav/ModelNavigator/CreateTable.svelte | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/builder/src/components/nav/ModelNavigator/CreateTable.svelte b/packages/builder/src/components/nav/ModelNavigator/CreateTable.svelte index 6125b0851b..9763ade62e 100644 --- a/packages/builder/src/components/nav/ModelNavigator/CreateTable.svelte +++ b/packages/builder/src/components/nav/ModelNavigator/CreateTable.svelte @@ -45,8 +45,7 @@ diff --git a/packages/builder/src/components/nav/ModelNavigator/ModelNavigator.svelte b/packages/builder/src/components/nav/ModelNavigator/ModelNavigator.svelte index 4b912de5b8..2bd81b2c54 100644 --- a/packages/builder/src/components/nav/ModelNavigator/ModelNavigator.svelte +++ b/packages/builder/src/components/nav/ModelNavigator/ModelNavigator.svelte @@ -11,21 +11,6 @@ const { open, close } = getContext("simple-modal") - let HEADINGS = [ - { - title: "Tables", - key: "TABLES", - }, - { - title: "Tables", - key: "NAVIGATE", - }, - { - title: "Add", - key: "ADD", - }, - ] - $: selectedTab = $backendUiStore.tabs.NAVIGATION_PANEL function selectModel(model, fieldId) { @@ -44,7 +29,7 @@ {#if $backendUiStore.selectedDatabase && $backendUiStore.selectedDatabase._id}
-

Tables

+

Tables

{#each $backendUiStore.models as model} @@ -63,6 +48,10 @@
diff --git a/packages/standard-components/src/CardHorizontal.Svelte b/packages/standard-components/src/CardHorizontal.Svelte new file mode 100644 index 0000000000..492c794c2d --- /dev/null +++ b/packages/standard-components/src/CardHorizontal.Svelte @@ -0,0 +1,104 @@ + + +
+ {#if showImage} + + {/if} +
+
+

{heading}

+

{description}

+
+ +
+
+ + diff --git a/packages/standard-components/src/index.js b/packages/standard-components/src/index.js index 071ffa1bcf..598f372988 100644 --- a/packages/standard-components/src/index.js +++ b/packages/standard-components/src/index.js @@ -23,5 +23,7 @@ export { default as list } from "./List.svelte" export { default as datasearch } from "./DataSearch.svelte" export { default as embed } from "./Embed.svelte" export { default as stackedlist } from "./StackedList.svelte" +export { default as card } from "./Card.svelte" +export { default as cardhorizontal } from "./CardHorizontal.svelte" export { default as recorddetail } from "./RecordDetail.svelte" export * from "./Chart" From 25b71c4e862539e99daef8c91ac59171eacacfdb Mon Sep 17 00:00:00 2001 From: Joe <49767913+joebudi@users.noreply.github.com> Date: Thu, 13 Aug 2020 09:05:22 +0100 Subject: [PATCH 07/18] Formatting and Linting --- .../userInterface/ComponentsHierarchy.svelte | 15 ++++++-------- .../userInterface/temporaryPanelStructure.js | 20 +++++++++++++------ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/builder/src/components/userInterface/ComponentsHierarchy.svelte b/packages/builder/src/components/userInterface/ComponentsHierarchy.svelte index 7de9670b0e..6997b146eb 100644 --- a/packages/builder/src/components/userInterface/ComponentsHierarchy.svelte +++ b/packages/builder/src/components/userInterface/ComponentsHierarchy.svelte @@ -16,15 +16,12 @@ const joinPath = join("/") const normalizedName = name => - pipe( - name, - [ - trimCharsStart("./"), - trimCharsStart("~/"), - trimCharsStart("../"), - trimChars(" "), - ] - ) + pipe(name, [ + trimCharsStart("./"), + trimCharsStart("~/"), + trimCharsStart("../"), + trimChars(" "), + ]) const changeScreen = screen => { store.setCurrentScreen(screen.props._instanceName) diff --git a/packages/builder/src/components/userInterface/temporaryPanelStructure.js b/packages/builder/src/components/userInterface/temporaryPanelStructure.js index f9cd2740dd..51e228a643 100644 --- a/packages/builder/src/components/userInterface/temporaryPanelStructure.js +++ b/packages/builder/src/components/userInterface/temporaryPanelStructure.js @@ -380,7 +380,7 @@ export default { label: "Card Width", key: "cardWidth", control: OptionSelect, - options: [ "16rem", "20rem", "24rem"], + options: ["16rem", "20rem", "24rem"], placeholder: "Card Width", }, ], @@ -448,29 +448,37 @@ export default { label: "Card Width", key: "cardWidth", control: OptionSelect, - options: [ "24rem", "28rem", "32rem", "40rem", "48rem", "60rem", "100%"], + options: [ + "24rem", + "28rem", + "32rem", + "40rem", + "48rem", + "60rem", + "100%", + ], placeholder: "Card Height", }, { label: "Image Width", key: "imageWidth", control: OptionSelect, - options: [ "8rem", "12rem", "16rem"], + options: ["8rem", "12rem", "16rem"], placeholder: "Image Width", }, { label: "Image Height", key: "imageHeight", control: OptionSelect, - options: [ "8rem", "12rem", "16rem", "auto"], + options: ["8rem", "12rem", "16rem", "auto"], placeholder: "Image Height", }, ], }, }, - ] + ], }, - + { _component: "@budibase/materialdesign-components/BasicCard", name: "Card", From b6e6a1ef4b614b3dc68f269df0bae61b2419a52b Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Thu, 13 Aug 2020 10:15:09 +0100 Subject: [PATCH 08/18] changes from code review --- .../userInterface/ComponentsHierarchy.svelte | 4 +++- .../ComponentsHierarchyChildren.svelte | 15 +++------------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/builder/src/components/userInterface/ComponentsHierarchy.svelte b/packages/builder/src/components/userInterface/ComponentsHierarchy.svelte index db4ea2fc27..b7fc60f347 100644 --- a/packages/builder/src/components/userInterface/ComponentsHierarchy.svelte +++ b/packages/builder/src/components/userInterface/ComponentsHierarchy.svelte @@ -11,6 +11,8 @@ export let screens = [] + const dragDropStore = writable({}) + let confirmDeleteDialog let componentToDelete = "" @@ -62,7 +64,7 @@ + {dragDropStore} /> {/if} {/each} diff --git a/packages/builder/src/components/userInterface/ComponentsHierarchyChildren.svelte b/packages/builder/src/components/userInterface/ComponentsHierarchyChildren.svelte index 7d24a42a8e..e23d34bc3c 100644 --- a/packages/builder/src/components/userInterface/ComponentsHierarchyChildren.svelte +++ b/packages/builder/src/components/userInterface/ComponentsHierarchyChildren.svelte @@ -107,7 +107,7 @@ {#each components as component, index (component._id)}
  • selectComponent(component)}> - {#if $dragDropStore && $dragDropStore.targetComponent === component && $dragDropStore.dropPosition == 'above'} + {#if $dragDropStore && $dragDropStore.targetComponent === component && $dragDropStore.dropPosition === 'above'}
    {/if} - {#if $dragDropStore && $dragDropStore.targetComponent === component && $dragDropStore.dropPosition == 'inside'} + {#if $dragDropStore && $dragDropStore.targetComponent === component && ($dragDropStore.dropPosition === 'inside' || $dragDropStore.dropPosition === 'below')}
    - {/if} - - {#if $dragDropStore && $dragDropStore.targetComponent === component && $dragDropStore.dropPosition == 'below'} -
    + style="margin-left: {(level + ($dragDropStore.dropPosition === 'inside' ? 2 : 0)) * 20 + 40}px" /> {/if}
  • {/each} From 49edb74db4180080b4fe06173805d3f044613f1d Mon Sep 17 00:00:00 2001 From: Michael Shanks Date: Thu, 13 Aug 2020 10:15:37 +0100 Subject: [PATCH 09/18] fix: added dnd to master screen --- .../builder/src/components/userInterface/PageLayout.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/builder/src/components/userInterface/PageLayout.svelte b/packages/builder/src/components/userInterface/PageLayout.svelte index d64ab670ba..b5596f2ae6 100644 --- a/packages/builder/src/components/userInterface/PageLayout.svelte +++ b/packages/builder/src/components/userInterface/PageLayout.svelte @@ -16,12 +16,14 @@ import { pipe } from "components/common/core" import { store } from "builderStore" import { ArrowDownIcon, GridIcon } from "components/common/Icons/" + import { writable } from "svelte/store" export let layout let confirmDeleteDialog let componentToDelete = "" + const dragDropStore = writable({}) const joinPath = join("/") const lastPartOfName = c => @@ -57,7 +59,8 @@ + currentComponent={$store.currentComponentInfo} + {dragDropStore} /> {/if} 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