diff --git a/packages/builder/cypress/integration/createBinding.spec.js b/packages/builder/cypress/integration/createBinding.spec.js new file mode 100644 index 0000000000..64e6f33475 --- /dev/null +++ b/packages/builder/cypress/integration/createBinding.spec.js @@ -0,0 +1,18 @@ +context('Create a Binding', () => { + before(() => { + cy.visit('localhost:4001/_builder') + cy.createApp('Binding App', 'Binding App Description') + cy.navigateToFrontend() + }) + + it('add an input binding', () => { + cy.get(".nav-items-container").contains('Home').click() + cy.contains("Add").click() + cy.get("[data-cy=Input]").click() + cy.get("[data-cy=Textfield]").click() + cy.contains("Heading").click() + cy.get("[data-cy=text-binding-button]").click() + cy.get("[data-cy=binding-dropdown-modal]").contains('Input 1').click() + cy.get("[data-cy=binding-dropdown-modal] textarea").should('have.value', 'Home{{ Input 1 }}') + }) +}) diff --git a/packages/builder/package.json b/packages/builder/package.json index c42acbe121..68fbf9031a 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -49,6 +49,9 @@ ], "setupFilesAfterEnv": [ "@testing-library/jest-dom/extend-expect" + ], + "setupFiles": [ + "./scripts/jestSetup.js" ] }, "eslintConfig": { @@ -111,4 +114,4 @@ "svelte-jester": "^1.0.6" }, "gitHead": "115189f72a850bfb52b65ec61d932531bf327072" -} +} \ No newline at end of file diff --git a/packages/builder/scripts/jestSetup.js b/packages/builder/scripts/jestSetup.js new file mode 100644 index 0000000000..52053d4092 --- /dev/null +++ b/packages/builder/scripts/jestSetup.js @@ -0,0 +1,25 @@ +if (!Array.prototype.flat) { + Object.defineProperty(Array.prototype, "flat", { + configurable: true, + value: function flat() { + var depth = isNaN(arguments[0]) ? 1 : Number(arguments[0]) + + return depth + ? Array.prototype.reduce.call( + this, + function(acc, cur) { + if (Array.isArray(cur)) { + acc.push.apply(acc, flat.call(cur, depth - 1)) + } else { + acc.push(cur) + } + + return acc + }, + [] + ) + : Array.prototype.slice.call(this) + }, + writable: true, + }) +} diff --git a/packages/builder/src/builderStore/fetchBindableProperties.js b/packages/builder/src/builderStore/fetchBindableProperties.js new file mode 100644 index 0000000000..f36484fbdf --- /dev/null +++ b/packages/builder/src/builderStore/fetchBindableProperties.js @@ -0,0 +1,157 @@ +import { cloneDeep, difference } from "lodash/fp" + +/** + * parameter for fetchBindableProperties function + * @typedef {Object} fetchBindablePropertiesParameter + * @property {string} componentInstanceId - an _id of a component that has been added to a screen, which you want to fetch bindable props for + * @propperty {Object} screen - current screen - where componentInstanceId lives + * @property {Object} components - dictionary of component definitions + * @property {Array} models - array of all models + */ + +/** + * + * @typedef {Object} BindableProperty + * @property {string} type - either "instance" (binding to a component instance) or "context" (binding to data in context e.g. List Item) + * @property {Object} instance - relevant component instance. If "context" type, this instance is the component that provides the context... e.g. the List + * @property {string} runtimeBinding - a binding string that is a) saved against the string, and b) used at runtime to read/write the value + * @property {string} readableBinding - a binding string that is displayed to the user, in the builder + */ + +/** + * Generates all allowed bindings from within any particular component instance + * @param {fetchBindablePropertiesParameter} param + * @returns {Array.} + */ +export default function({ componentInstanceId, screen, components, models }) { + const walkResult = walk({ + // cloning so we are free to mutate props (e.g. by adding _contexts) + instance: cloneDeep(screen.props), + targetId: componentInstanceId, + components, + models, + }) + + return [ + ...walkResult.bindableInstances + .filter(isInstanceInSharedContext(walkResult)) + .map(componentInstanceToBindable(walkResult)), + + ...walkResult.target._contexts.map(contextToBindables(walkResult)).flat(), + ] +} + +const isInstanceInSharedContext = walkResult => i => + // should cover + // - neither are in any context + // - both in same context + // - instance is in ancestor context of target + i.instance._contexts.length <= walkResult.target._contexts.length && + difference(i.instance._contexts, walkResult.target._contexts).length === 0 + +// turns a component instance prop into binding expressions +// used by the UI +const componentInstanceToBindable = walkResult => i => { + const lastContext = + i.instance._contexts.length && + i.instance._contexts[i.instance._contexts.length - 1] + const contextParentPath = lastContext + ? getParentPath(walkResult, lastContext) + : "" + + return { + type: "instance", + instance: i.instance, + // how the binding expression persists, and is used in the app at runtime + runtimeBinding: `${contextParentPath}${i.instance._id}.${i.prop}`, + // how the binding exressions looks to the user of the builder + readableBinding: `${i.instance._instanceName}`, + } +} + +const contextToBindables = walkResult => context => { + const contextParentPath = getParentPath(walkResult, context) + + return Object.keys(context.model.schema).map(k => ({ + type: "context", + instance: context.instance, + // how the binding expression persists, and is used in the app at runtime + runtimeBinding: `${contextParentPath}data.${k}`, + // how the binding exressions looks to the user of the builder + readableBinding: `${context.instance._instanceName}.${context.model.name}.${k}`, + })) +} + +const getParentPath = (walkResult, context) => { + // describes the number of "parent" in the path + // clone array first so original array is not mtated + const contextParentNumber = [...walkResult.target._contexts] + .reverse() + .indexOf(context) + + return ( + new Array(contextParentNumber).fill("parent").join(".") + + // trailing . if has parents + (contextParentNumber ? "." : "") + ) +} + +const walk = ({ instance, targetId, components, models, result }) => { + if (!result) { + result = { + target: null, + bindableInstances: [], + allContexts: [], + currentContexts: [], + } + } + + if (!instance._contexts) instance._contexts = [] + + // "component" is the component definition (object in component.json) + const component = components[instance._component] + + if (instance._id === targetId) { + // found it + result.target = instance + } else { + if (component && component.bindable) { + // pushing all components in here initially + // but this will not be correct, as some of + // these components will be in another context + // but we dont know this until the end of the walk + // so we will filter in another method + result.bindableInstances.push({ + instance, + prop: component.bindable, + }) + } + } + + // a component that provides context to it's children + const contextualInstance = + component && component.context && instance[component.context] + + if (contextualInstance) { + // add to currentContexts (ancestory of context) + // before walking children + const model = models.find(m => m._id === instance[component.context]) + result.currentContexts.push({ instance, model }) + } + + const currentContexts = [...result.currentContexts] + for (let child of instance._children || []) { + // attaching _contexts of components, for eas comparison later + // these have been deep cloned above, so shouln't modify the + // original component instances + child._contexts = currentContexts + walk({ instance: child, targetId, components, models, result }) + } + + if (contextualInstance) { + // child walk done, remove from currentContexts + result.currentContexts.pop() + } + + return result +} diff --git a/packages/builder/src/builderStore/getNewComponentName.js b/packages/builder/src/builderStore/getNewComponentName.js new file mode 100644 index 0000000000..b3ddc4e953 --- /dev/null +++ b/packages/builder/src/builderStore/getNewComponentName.js @@ -0,0 +1,39 @@ +import { walkProps } from "./storeUtils" +import { get_capitalised_name } from "../helpers" + +export default function(component, state) { + const capitalised = get_capitalised_name(component) + + const matchingComponents = [] + + const findMatches = props => { + walkProps(props, c => { + if ((c._instanceName || "").startsWith(capitalised)) { + matchingComponents.push(c._instanceName) + } + }) + } + + // check page first + findMatches(state.pages[state.currentPageName].props) + + // if viewing screen, check current screen for duplicate + if (state.currentFrontEndType === "screen") { + findMatches(state.currentPreviewItem.props) + } else { + // viewing master page - need to find against all screens + for (let screen of state.screens) { + findMatches(screen.props) + } + } + + let index = 1 + let name + while (!name) { + const tryName = `${capitalised} ${index}` + if (!matchingComponents.includes(tryName)) name = tryName + index++ + } + + return name +} diff --git a/packages/builder/src/builderStore/store/index.js b/packages/builder/src/builderStore/store/index.js index 0fbe8be518..ac13c90dbe 100644 --- a/packages/builder/src/builderStore/store/index.js +++ b/packages/builder/src/builderStore/store/index.js @@ -1,5 +1,5 @@ import { values, cloneDeep } from "lodash/fp" -import { get_capitalised_name } from "../../helpers" +import getNewComponentName from "../getNewComponentName" import { backendUiStore } from "builderStore" import { writable, get } from "svelte/store" import api from "../api" @@ -276,7 +276,7 @@ const addChildComponent = store => (componentToAdd, presetProps = {}) => { const component = getComponentDefinition(state, componentToAdd) const instanceId = get(backendUiStore).selectedDatabase._id - const instanceName = get_capitalised_name(componentToAdd) + const instanceName = getNewComponentName(componentToAdd, state) const newComponent = createProps( component, @@ -482,7 +482,7 @@ const pasteComponent = store => (targetComponent, mode) => { // in case we paste a second time s.componentToPaste.isCut = false } else { - generateNewIdsForComponent(componentToPaste) + generateNewIdsForComponent(componentToPaste, s) } delete componentToPaste.isCut diff --git a/packages/builder/src/builderStore/storeUtils.js b/packages/builder/src/builderStore/storeUtils.js index a1974d81b9..2efffc9d4c 100644 --- a/packages/builder/src/builderStore/storeUtils.js +++ b/packages/builder/src/builderStore/storeUtils.js @@ -5,6 +5,7 @@ import { import api from "./api" import { generate_screen_css } from "./generate_css" import { uuid } from "./uuid" +import getNewComponentName from "./getNewComponentName" export const selectComponent = (state, component) => { const componentDef = component._component.startsWith("##") @@ -84,9 +85,10 @@ export const regenerateCssForCurrentScreen = state => { return state } -export const generateNewIdsForComponent = c => +export const generateNewIdsForComponent = (c, state) => walkProps(c, p => { p._id = uuid() + p._instanceName = getNewComponentName(p._component, state) }) export const getComponentDefinition = (state, name) => diff --git a/packages/builder/src/builderStore/uuid.js b/packages/builder/src/builderStore/uuid.js index 5a1893b56a..5dbd9ccdbd 100644 --- a/packages/builder/src/builderStore/uuid.js +++ b/packages/builder/src/builderStore/uuid.js @@ -1,5 +1,7 @@ export function uuid() { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => { + // always want to make this start with a letter, as this makes it + // easier to use with mustache bindings in the client + return "cxxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0, v = c == "x" ? r : (r & 0x3) | 0x8 return v.toString(16) diff --git a/packages/builder/src/components/userInterface/BindingDropdown.svelte b/packages/builder/src/components/userInterface/BindingDropdown.svelte new file mode 100644 index 0000000000..1c18e08dc7 --- /dev/null +++ b/packages/builder/src/components/userInterface/BindingDropdown.svelte @@ -0,0 +1,116 @@ + + +
+
+ + {#if context} + +
    + {#each context as { readableBinding }} +
  • addToText(readableBinding)}>{readableBinding}
  • + {/each} +
+ {/if} + {#if instance} + +
    + {#each instance as { readableBinding }} +
  • addToText(readableBinding)}>{readableBinding}
  • + {/each} +
+ {/if} +
+
+ + + Binding connects one piece of data to another and makes it dynamic. Click + the objects on the left, to add them to the textbox. + +