diff --git a/packages/builder/src/common/binding.js b/packages/builder/src/common/binding.js index a6b6c2ab87..f701a2b577 100644 --- a/packages/builder/src/common/binding.js +++ b/packages/builder/src/common/binding.js @@ -4,13 +4,11 @@ import { BB_STATE_BINDINGPATH, BB_STATE_FALLBACK, BB_STATE_BINDINGSOURCE, -} from "@budibase/client/src/state/isState" + isBound, + parseBinding, +} from "@budibase/client/src/state/parseBinding" -export const isBinding = value => - !isString(value) && - value && - isString(value[BB_STATE_BINDINGPATH]) && - value[BB_STATE_BINDINGPATH].length > 0 +export const isBinding = isBound export const setBinding = ({ path, fallback, source }, binding = {}) => { if (isNonEmptyString(path)) binding[BB_STATE_BINDINGPATH] = path @@ -19,10 +17,6 @@ export const setBinding = ({ path, fallback, source }, binding = {}) => { return binding } -export const getBinding = binding => ({ - path: binding[BB_STATE_BINDINGPATH] || "", - fallback: binding[BB_STATE_FALLBACK] || "", - source: binding[BB_STATE_BINDINGSOURCE] || "store", -}) +export const getBinding = parseBinding const isNonEmptyString = s => isString(s) && s.length > 0 diff --git a/packages/builder/src/userInterface/pagesParsing/types.js b/packages/builder/src/userInterface/pagesParsing/types.js index 17438814ca..ecc23dc6d0 100644 --- a/packages/builder/src/userInterface/pagesParsing/types.js +++ b/packages/builder/src/userInterface/pagesParsing/types.js @@ -14,7 +14,7 @@ import { EVENT_TYPE_MEMBER_NAME } from "../../common/eventHandlers" import { isBound, BB_STATE_BINDINGPATH, -} from "@budibase/client/src/state/isState" +} from "@budibase/client/src/state/parseBinding" const defaultDef = typeName => () => ({ type: typeName, diff --git a/packages/builder/tests/createProps.spec.js b/packages/builder/tests/createProps.spec.js index 29777334df..a26f626f40 100644 --- a/packages/builder/tests/createProps.spec.js +++ b/packages/builder/tests/createProps.spec.js @@ -1,6 +1,6 @@ import { createProps } from "../src/userInterface/pagesParsing/createProps" import { keys, some } from "lodash/fp" -import { BB_STATE_BINDINGPATH } from "@budibase/client/src/state/isState" +import { BB_STATE_BINDINGPATH } from "@budibase/client/src/state/parseBinding" import { stripStandardProps } from "./testData" describe("createDefaultProps", () => { diff --git a/packages/client/src/createApp.js b/packages/client/src/createApp.js index 0f4d54fdca..0a6e8fa380 100644 --- a/packages/client/src/createApp.js +++ b/packages/client/src/createApp.js @@ -31,6 +31,7 @@ export const createApp = ( componentLibraries, uiFunctions, onScreenSlotRendered: () => {}, + routeTo, }) const getAttchChildrenParams = attachChildrenParams(stateManager) screenSlotNode.props._children = [screen.props] @@ -69,6 +70,8 @@ export const createApp = ( componentLibraries, uiFunctions, onScreenSlotRendered, + // seems weird, but the routeTo variable may not be available at this point + routeTo: url => routeTo(url), }) const initialisePage = (page, target, urlPath) => { diff --git a/packages/client/src/state/bbComponentApi.js b/packages/client/src/state/bbComponentApi.js index affb4e1188..f51a7e2d24 100644 --- a/packages/client/src/state/bbComponentApi.js +++ b/packages/client/src/state/bbComponentApi.js @@ -1,7 +1,7 @@ import { getStateOrValue } from "./getState" import { setState, setStateFromBinding } from "./setState" import { trimSlash } from "../common/trimSlash" -import { isBound } from "./isState" +import { isBound } from "./parseBinding" import { attachChildren } from "../render/attachChildren" export const bbFactory = ({ diff --git a/packages/client/src/state/eventHandlers.js b/packages/client/src/state/eventHandlers.js index 8798836972..a281bef482 100644 --- a/packages/client/src/state/eventHandlers.js +++ b/packages/client/src/state/eventHandlers.js @@ -7,7 +7,7 @@ import { getNewChildRecordToState, getNewRecordToState } from "./coreHandlers" export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType" -export const eventHandlers = (store, coreApi, rootPath) => { +export const eventHandlers = (store, coreApi, rootPath, routeTo) => { const handler = (parameters, execute) => ({ execute, parameters, @@ -44,6 +44,8 @@ export const eventHandlers = (store, coreApi, rootPath) => { getNewRecordToState(coreApi, setStateWithStore) ), + "Navigate To": handler(["url"], param => routeTo(param && param.url)), + Authenticate: handler(["username", "password"], api.authenticate), } } diff --git a/packages/client/src/state/getState.js b/packages/client/src/state/getState.js index f4761114e6..a4c4b22a83 100644 --- a/packages/client/src/state/getState.js +++ b/packages/client/src/state/getState.js @@ -1,10 +1,5 @@ import { isUndefined, isObject } from "lodash/fp" -import { - isBound, - BB_STATE_BINDINGPATH, - BB_STATE_FALLBACK, - takeStateFromStore, -} from "./isState" +import { parseBinding, isStoreBinding } from "./parseBinding" export const getState = (s, path, fallback) => { if (!s) return fallback @@ -39,20 +34,12 @@ export const getState = (s, path, fallback) => { export const getStateOrValue = (globalState, prop, currentContext) => { if (!prop) return prop - if (isBound(prop)) { - const stateToUse = takeStateFromStore(prop) ? globalState : currentContext + const binding = parseBinding(prop) - return getState( - stateToUse, - prop[BB_STATE_BINDINGPATH], - prop[BB_STATE_FALLBACK] - ) - } + if (binding) { + const stateToUse = isStoreBinding(binding) ? globalState : currentContext - if (prop.path && prop.source) { - const stateToUse = prop.source === "store" ? globalState : currentContext - - return getState(stateToUse, prop.path, prop.fallback) + return getState(stateToUse, binding.path, binding.fallback) } return prop diff --git a/packages/client/src/state/isState.js b/packages/client/src/state/isState.js deleted file mode 100644 index 760f820366..0000000000 --- a/packages/client/src/state/isState.js +++ /dev/null @@ -1,16 +0,0 @@ -export const BB_STATE_BINDINGPATH = "##bbstate" -export const BB_STATE_BINDINGSOURCE = "##bbsource" -export const BB_STATE_FALLBACK = "##bbstatefallback" - -export const isBound = prop => - prop !== undefined && prop[BB_STATE_BINDINGPATH] !== undefined - -export const takeStateFromStore = prop => - prop[BB_STATE_BINDINGSOURCE] === undefined || - prop[BB_STATE_BINDINGSOURCE] === "store" - -export const takeStateFromContext = prop => - prop[BB_STATE_BINDINGSOURCE] === "context" - -export const takeStateFromEventParameters = prop => - prop[BB_STATE_BINDINGSOURCE] === "event" diff --git a/packages/client/src/state/parseBinding.js b/packages/client/src/state/parseBinding.js new file mode 100644 index 0000000000..532e87aae2 --- /dev/null +++ b/packages/client/src/state/parseBinding.js @@ -0,0 +1,59 @@ +export const BB_STATE_BINDINGPATH = "##bbstate" +export const BB_STATE_BINDINGSOURCE = "##bbsource" +export const BB_STATE_FALLBACK = "##bbstatefallback" + +export const isBound = prop => !!parseBinding(prop) + +export const parseBinding = prop => { + if (!prop) return false + if (isBindingExpression(prop)) { + return parseBindingExpression(prop) + } + + if (isAlreadyBinding(prop)) { + return { + path: prop.path, + source: prop.source || "store", + fallback: prop.fallback, + } + } + + if (hasBindingObject(prop)) { + return { + path: prop[BB_STATE_BINDINGPATH], + fallback: prop[BB_STATE_FALLBACK] || "", + source: prop[BB_STATE_BINDINGSOURCE] || "store", + } + } +} + +export const isStoreBinding = binding => binding && binding.source === "store" +export const isContextBinding = binding => binding && binding.source === "context" +export const isEventBinding = binding => binding && binding.source === "event" + +const hasBindingObject = prop => + typeof prop === "object" && prop[BB_STATE_BINDINGPATH] !== undefined + +const isAlreadyBinding = prop => typeof prop === "object" && prop.path + +const isBindingExpression = prop => + typeof prop === "string" && + (prop.startsWith("store.") || + prop.startsWith("context.") || + prop.startsWith("event.") || + prop.startsWith("route.")) + +const parseBindingExpression = prop => { + let source = prop.split(".")[0] + let path = prop.replace(`${source}.`, "") + if (source === "route") { + source = "store" + path = `##routeParams.${path}` + } + const fallback = "" + return { + fallback, + source, + path, + } +} diff --git a/packages/client/src/state/setState.js b/packages/client/src/state/setState.js index 9b0b878c30..6d55fdc419 100644 --- a/packages/client/src/state/setState.js +++ b/packages/client/src/state/setState.js @@ -1,5 +1,5 @@ import { isObject } from "lodash/fp" -import { BB_STATE_BINDINGPATH } from "./isState" +import { parseBinding } from "./parseBinding" export const setState = (store, path, value) => { if (!path || path.length === 0) return @@ -30,5 +30,8 @@ export const setState = (store, path, value) => { }) } -export const setStateFromBinding = (store, binding, value) => - setState(store, binding[BB_STATE_BINDINGPATH], value) +export const setStateFromBinding = (store, binding, value) => { + const parsedBinding = parseBinding(binding) + if (!parsedBinding) return + return setState(store, parsedBinding.path, value) +} diff --git a/packages/client/src/state/stateBinding.js b/packages/client/src/state/stateBinding.js deleted file mode 100644 index ca7d8d3a3f..0000000000 --- a/packages/client/src/state/stateBinding.js +++ /dev/null @@ -1,179 +0,0 @@ -import { - isEventType, - eventHandlers, - EVENT_TYPE_MEMBER_NAME, -} from "./eventHandlers" - -import { getState } from "./getState" - -import { - isBound, - takeStateFromStore, - takeStateFromContext, - takeStateFromEventParameters, - BB_STATE_FALLBACK, - BB_STATE_BINDINGPATH, - BB_STATE_BINDINGSOURCE, -} from "./isState" - -const doNothing = () => {} -doNothing.isPlaceholder = true - -const isMetaProp = propName => - propName === "_component" || - propName === "_children" || - propName === "_id" || - propName === "_style" || - propName === "_code" - -export const setupBinding = (store, rootProps, coreApi, context, rootPath) => { - const rootInitialProps = { ...rootProps } - - const getBindings = (props, initialProps) => { - const boundProps = [] - const contextBoundProps = [] - const componentEventHandlers = [] - - for (let propName in props) { - if (isMetaProp(propName)) continue - - const val = props[propName] - - if (isBound(val) && takeStateFromStore(val)) { - const binding = BindingPath(val) - const source = BindingSource(val) - const fallback = BindingFallback(val) - - boundProps.push({ - path: binding, - fallback, - propName, - source, - }) - - initialProps[propName] = fallback - } else if (isBound(val) && takeStateFromContext(val)) { - const binding = BindingPath(val) - const fallback = BindingFallback(val) - const source = BindingSource(val) - - contextBoundProps.push({ - path: binding, - fallback, - propName, - source, - }) - - initialProps[propName] = !context - ? val - : getState(context, binding, fallback, source) - } else if (isEventType(val)) { - const handlers = { propName, handlers: [] } - componentEventHandlers.push(handlers) - - for (let e of val) { - handlers.handlers.push({ - handlerType: e[EVENT_TYPE_MEMBER_NAME], - parameters: e.parameters, - }) - } - - initialProps[propName] = doNothing - } - } - - return { - contextBoundProps, - boundProps, - componentEventHandlers, - initialProps, - } - } - - const bind = rootBindings => component => { - if ( - rootBindings.boundProps.length === 0 && - rootBindings.componentEventHandlers.length === 0 - ) - return - - const handlerTypes = eventHandlers(store, coreApi, rootPath) - - const unsubscribe = store.subscribe(rootState => { - const getPropsFromBindings = (s, bindings) => { - const { boundProps, componentEventHandlers } = bindings - const newProps = { ...bindings.initialProps } - - for (let boundProp of boundProps) { - const val = getState(s, boundProp.path, boundProp.fallback) - - if (val === undefined && newProps[boundProp.propName] !== undefined) { - delete newProps[boundProp.propName] - } - - if (val !== undefined) { - newProps[boundProp.propName] = val - } - } - - for (let boundHandler of componentEventHandlers) { - const closuredHandlers = [] - for (let h of boundHandler.handlers) { - const handlerType = handlerTypes[h.handlerType] - closuredHandlers.push(eventContext => { - const parameters = {} - for (let pname in h.parameters) { - const p = h.parameters[pname] - parameters[pname] = !isBound(p) - ? p - : takeStateFromStore(p) - ? getState(s, p[BB_STATE_BINDINGPATH], p[BB_STATE_FALLBACK]) - : takeStateFromEventParameters(p) - ? getState( - eventContext, - p[BB_STATE_BINDINGPATH], - p[BB_STATE_FALLBACK] - ) - : takeStateFromContext(p) - ? getState( - context, - p[BB_STATE_BINDINGPATH], - p[BB_STATE_FALLBACK] - ) - : p[BB_STATE_FALLBACK] - } - handlerType.execute(parameters) - }) - } - - newProps[boundHandler.propName] = async context => { - for (let runHandler of closuredHandlers) { - await runHandler(context) - } - } - } - - return newProps - } - - const rootNewProps = getPropsFromBindings(rootState, rootBindings) - - component.$set(rootNewProps) - }) - - return unsubscribe - } - - const bindings = getBindings(rootProps, rootInitialProps) - - return { - initialProps: rootInitialProps, - bind: bind(bindings), - boundProps: bindings.boundProps, - contextBoundProps: bindings.contextBoundProps, - } -} - -const BindingPath = prop => prop[BB_STATE_BINDINGPATH] -const BindingFallback = prop => prop[BB_STATE_FALLBACK] -const BindingSource = prop => prop[BB_STATE_BINDINGSOURCE] diff --git a/packages/client/src/state/stateManager.js b/packages/client/src/state/stateManager.js index ba15f52448..520b1767d3 100644 --- a/packages/client/src/state/stateManager.js +++ b/packages/client/src/state/stateManager.js @@ -7,15 +7,7 @@ import { bbFactory } from "./bbComponentApi" import { getState } from "./getState" import { attachChildren } from "../render/attachChildren" -import { - isBound, - takeStateFromStore, - takeStateFromContext, - takeStateFromEventParameters, - BB_STATE_FALLBACK, - BB_STATE_BINDINGPATH, - BB_STATE_BINDINGSOURCE, -} from "./isState" +import { parseBinding } from "./parseBinding" const doNothing = () => {} doNothing.isPlaceholder = true @@ -36,8 +28,9 @@ export const createStateManager = ({ componentLibraries, uiFunctions, onScreenSlotRendered, + routeTo, }) => { - let handlerTypes = eventHandlers(store, coreApi, rootPath) + let handlerTypes = eventHandlers(store, coreApi, rootPath, routeTo) let currentState // any nodes that have props that are bound to the store @@ -180,35 +173,25 @@ const _setup = ( const val = props[propName] - if (isBound(val) && takeStateFromStore(val)) { - const path = BindingPath(val) - const source = BindingSource(val) - const fallback = BindingFallback(val) + const binding = parseBinding(val) + const isBound = !!binding + if (isBound) binding.propName = propName - storeBoundProps.push({ - path, - fallback, - propName, - source, - }) + if (isBound && binding.source === "store") { + storeBoundProps.push(binding) initialProps[propName] = !currentStoreState - ? fallback + ? binding.fallback : getState( currentStoreState, - BindingPath(val), - BindingFallback(val), - BindingSource(val) + binding.path, + binding.fallback, + binding.source ) - } else if (isBound(val) && takeStateFromContext(val)) { + } else if (isBound && binding.source === "context") { initialProps[propName] = !context ? val - : getState( - context, - BindingPath(val), - BindingFallback(val), - BindingSource(val) - ) + : getState(context, binding.path, binding.fallback, binding.source) } else if (isEventType(val)) { const handlersInfos = [] for (let e of val) { @@ -219,31 +202,28 @@ const _setup = ( const resolvedParams = {} for (let paramName in handlerInfo.parameters) { const paramValue = handlerInfo.parameters[paramName] - if (!isBound(paramValue)) { + const paramBinding = parseBinding(paramValue) + if (!paramBinding) { resolvedParams[paramName] = () => paramValue continue - } else if (takeStateFromContext(paramValue)) { + } else if (paramBinding.source === "context") { const val = getState( context, - paramValue[BB_STATE_BINDINGPATH], - paramValue[BB_STATE_FALLBACK] + paramBinding.path, + paramBinding.fallback ) resolvedParams[paramName] = () => val - } else if (takeStateFromStore(paramValue)) { + } else if (paramBinding.source === "store") { resolvedParams[paramName] = () => getState( getCurrentState(), - paramValue[BB_STATE_BINDINGPATH], - paramValue[BB_STATE_FALLBACK] + paramBinding.path, + paramBinding.fallback ) continue - } else if (takeStateFromEventParameters(paramValue)) { + } else if (paramBinding.source === "event") { resolvedParams[paramName] = eventContext => { - getState( - eventContext, - paramValue[BB_STATE_BINDINGPATH], - paramValue[BB_STATE_FALLBACK] - ) + getState(eventContext, paramBinding.path, paramBinding.fallback) } } } @@ -282,7 +262,3 @@ const makeHandler = (handlerTypes, handlerInfo) => { handlerType.execute(parameters) } } - -const BindingPath = prop => prop[BB_STATE_BINDINGPATH] -const BindingFallback = prop => prop[BB_STATE_FALLBACK] -const BindingSource = prop => prop[BB_STATE_BINDINGSOURCE] diff --git a/packages/client/tests/bindingDom.spec.js b/packages/client/tests/bindingDom.spec.js index c0b90a8a81..af93b1f0bf 100644 --- a/packages/client/tests/bindingDom.spec.js +++ b/packages/client/tests/bindingDom.spec.js @@ -39,6 +39,23 @@ describe("initialiseApp (binding)", () => { expect(rootDiv.className.includes("newvalue")).toBe(true) }) + it("should update root element from store, using binding expression", async () => { + const { dom, app } = await load( + makePage({ + _component: "testlib/div", + className: "store.divClassName", + }) + ) + + app.pageStore().update(s => { + s.divClassName = "newvalue" + return s + }) + + const rootDiv = dom.window.document.body.children[0] + expect(rootDiv.className.includes("newvalue")).toBe(true) + }) + it("should populate child component with store value", async () => { const { dom } = await load( makePage({ diff --git a/packages/client/tests/stateBinding.spec.js b/packages/client/tests/stateBinding.spec.js deleted file mode 100644 index b14e79933b..0000000000 --- a/packages/client/tests/stateBinding.spec.js +++ /dev/null @@ -1,177 +0,0 @@ -import { setupBinding } from "../src/state/stateBinding" -import { - BB_STATE_BINDINGPATH, - BB_STATE_FALLBACK, - BB_STATE_BINDINGSOURCE, -} from "../src/state/isState" -import { EVENT_TYPE_MEMBER_NAME } from "../src/state/eventHandlers" -import { writable } from "svelte/store" -import { isFunction } from "lodash/fp" - -describe("setupBinding", () => { - it("should correctly create initials props, including fallback values", () => { - const { store, props, component } = testSetup() - - const { initialProps } = testSetupBinding(store, props, component) - - expect(initialProps.boundWithFallback).toBe("Bob") - expect(initialProps.boundNoFallback).toBeUndefined() - expect(initialProps.unbound).toBe("hello") - - expect(isFunction(initialProps.eventBound)).toBeTruthy() - initialProps.eventBound() - }) - - it("should update component bound props when store is updated", () => { - const { component, store, props } = testSetup() - - const { bind } = testSetupBinding(store, props, component) - bind(component) - - store.update(s => { - s.FirstName = "Bobby" - s.LastName = "Thedog" - s.Customer = { - Name: "ACME inc", - Address: "", - } - s.addressToSet = "123 Main Street" - return s - }) - - expect(component.props.boundWithFallback).toBe("Bobby") - expect(component.props.boundNoFallback).toBe("Thedog") - expect(component.props.multiPartBound).toBe("ACME inc") - }) - - it("should not update unbound props when store is updated", () => { - const { component, store, props } = testSetup() - - const { bind } = testSetupBinding(store, props, component) - bind(component) - - store.update(s => { - s.FirstName = "Bobby" - s.LastName = "Thedog" - s.Customer = { - Name: "ACME inc", - Address: "", - } - s.addressToSet = "123 Main Street" - return s - }) - - expect(component.props.unbound).toBe("hello") - }) - - it("should update event handlers on state change", () => { - const { component, store, props } = testSetup() - - const { bind } = testSetupBinding(store, props, component) - bind(component) - - expect(component.props.boundToEventOutput).toBe("initial address") - component.props.eventBound() - expect(component.props.boundToEventOutput).toBe("event fallback address") - - store.update(s => { - s.addressToSet = "123 Main Street" - return s - }) - - component.props.eventBound() - expect(component.props.boundToEventOutput).toBe("123 Main Street") - }) - - it("event handlers should recognise event parameter", () => { - const { component, store, props } = testSetup() - - const { bind } = testSetupBinding(store, props, component) - bind(component) - - expect(component.props.boundToEventOutput).toBe("initial address") - component.props.eventBoundUsingEventParam({ - addressOverride: "Overridden Address", - }) - expect(component.props.boundToEventOutput).toBe("Overridden Address") - - store.update(s => { - s.addressToSet = "123 Main Street" - return s - }) - - component.props.eventBound() - expect(component.props.boundToEventOutput).toBe("123 Main Street") - - component.props.eventBoundUsingEventParam({ - addressOverride: "Overridden Address", - }) - expect(component.props.boundToEventOutput).toBe("Overridden Address") - }) - - it("should bind initial props to supplied context", () => { - const { component, store, props } = testSetup() - - const { bind } = testSetupBinding(store, props, component, { - ContextValue: "Real Context Value", - }) - bind(component) - - expect(component.props.boundToContext).toBe("Real Context Value") - }) -}) -const testSetupBinding = (store, props, component, context) => { - const setup = setupBinding(store, props, undefined, context) - component.props = setup.initialProps // svelte does this for us in real life - return setup -} -const testSetup = () => { - const c = {} - - c.props = {} - c.$set = propsToSet => { - for (let pname in propsToSet) c.props[pname] = propsToSet[pname] - } - - const binding = (path, fallback, source) => { - const b = {} - b[BB_STATE_BINDINGPATH] = path - b[BB_STATE_FALLBACK] = fallback - b[BB_STATE_BINDINGSOURCE] = source || "store" - return b - } - - const event = (handlerType, parameters) => { - const e = {} - e[EVENT_TYPE_MEMBER_NAME] = handlerType - e.parameters = parameters - return e - } - - const props = { - boundWithFallback: binding("FirstName", "Bob"), - boundNoFallback: binding("LastName"), - unbound: "hello", - multiPartBound: binding("Customer.Name", "ACME"), - boundToEventOutput: binding("Customer.Address", "initial address"), - boundToContext: binding("ContextValue", "context fallback", "context"), - eventBound: [ - event("Set State", { - path: "Customer.Address", - value: binding("addressToSet", "event fallback address"), - }), - ], - eventBoundUsingEventParam: [ - event("Set State", { - path: "Customer.Address", - value: binding("addressOverride", "", "event"), - }), - ], - } - - return { - component: c, - store: writable({}), - props, - } -} diff --git a/packages/materialdesign-components/src/Templates/indexDatatable.js b/packages/materialdesign-components/src/Templates/indexDatatable.js index 145e3f5eea..8c662adf37 100644 --- a/packages/materialdesign-components/src/Templates/indexDatatable.js +++ b/packages/materialdesign-components/src/Templates/indexDatatable.js @@ -1,9 +1,14 @@ export default ({ indexes, helpers }) => indexes.map(i => ({ name: `Table based on index: ${i.name} `, - props: tableProps(i, helpers.indexSchema(i)), + props: tableProps( + i, + helpers.indexSchema(i).filter(c => !excludedColumns.includes(c.name)) + ), })) +const excludedColumns = ["id", "key", "sortKey", "type", "isNew"] + const tableProps = (index, indexSchema) => ({ _component: "@budibase/materialdesign-components/Datatable", _children: [