From 071a80864dd39b3385217aac12a66df03ae3c3dc Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 12 Sep 2023 17:09:53 +0100 Subject: [PATCH] Update data binding generation to support both local and global binding scopes --- .../src/builderStore/componentUtils.js | 7 + .../builder/src/builderStore/dataBinding.js | 378 ++++++++++-------- packages/client/manifest.json | 4 +- packages/pro | 2 +- yarn.lock | 37 -- 5 files changed, 218 insertions(+), 210 deletions(-) diff --git a/packages/builder/src/builderStore/componentUtils.js b/packages/builder/src/builderStore/componentUtils.js index 16b972058e..da27a43c7a 100644 --- a/packages/builder/src/builderStore/componentUtils.js +++ b/packages/builder/src/builderStore/componentUtils.js @@ -89,6 +89,13 @@ export const findAllMatchingComponents = (rootComponent, selector) => { return components.reverse() } +/** + * Recurses through the component tree and finds all components. + */ +export const findAllComponents = rootComponent => { + return findAllMatchingComponents(rootComponent, () => true) +} + /** * Finds the closes parent component which matches certain criteria */ diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index 6e0223b86a..194d1b2e39 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -1,6 +1,7 @@ import { cloneDeep } from "lodash/fp" import { get } from "svelte/store" import { + findAllComponents, findAllMatchingComponents, findComponent, findComponentPath, @@ -156,7 +157,7 @@ export const readableToRuntimeMap = (bindings, ctx) => { } /** - * Utility - coverting a map of runtime bindings to readable + * Utility to covert a map of runtime bindings to readable bindings */ export const runtimeToReadableMap = (bindings, ctx) => { if (!bindings || !ctx) { @@ -182,9 +183,69 @@ export const getComponentBindableProperties = (asset, componentId) => { if (!def?.context) { return [] } + const contexts = Array.isArray(def.context) ? def.context : [def.context] // Get the bindings for the component - return getProviderContextBindings(asset, component) + const componentContext = { + component, + definition: def, + contexts, + } + return generateComponentContextBindings(asset, componentContext) +} + +/** + * Gets all component contexts available to a certain component. This handles + * both global and local bindings, taking into account a component's position + * in the component tree. + */ +const getComponentContexts = (asset, componentId, type) => { + if (!asset || !componentId) { + return [] + } + let map = {} + + // Processes all contexts exposed by a component + const processContexts = scope => component => { + const def = store.actions.components.getDefinition(component._component) + if (!def?.context) { + return + } + if (!map[component._id]) { + map[component._id] = { + component, + definition: def, + contexts: [], + } + } + const contexts = Array.isArray(def.context) ? def.context : [def.context] + contexts.forEach(context => { + // Ensure type matches + if (type && context.type !== type) { + return + } + // Ensure scope matches + let contextScope = context.scope || "global" + if (contextScope !== scope) { + return + } + // Ensure the context is compatible with the component's current settings + if (!isContextCompatibleWithComponent(context, component)) { + return + } + map[component._id].contexts.push(context) + }) + } + + // Process all global contexts + const allComponents = findAllComponents(asset.props) + allComponents.forEach(processContexts("global")) + + // Process all local contexts + const localComponents = findComponentPath(asset.props, componentId) + localComponents.forEach(processContexts("local")) + + return Object.values(map) } /** @@ -196,49 +257,17 @@ export const getContextProviderComponents = ( type, options = { includeSelf: false } ) => { - if (!asset || !componentId) { - return [] + let componentContexts = getComponentContexts(asset, componentId, type) + + // Exclude self if required + if (!options?.includeSelf) { + componentContexts = componentContexts.filter( + entry => entry.component._id !== componentId + ) } - return findAllMatchingComponents(asset.props, component => { - const def = store.actions.components.getDefinition(component._component) - if (!def?.context) { - return false - } - - // If no type specified, return anything that exposes context - if (!type) { - return true - } - - // Otherwise only match components with the specific context type - const contexts = Array.isArray(def.context) ? def.context : [def.context] - return contexts.find(context => context.type === type) != null - }) - // - // // Get the component tree leading up to this component, ignoring the component - // // itself - // const path = findComponentPath(asset.props, componentId) - // if (!options?.includeSelf) { - // path.pop() - // } - // - // // Filter by only data provider components - // return path.filter(component => { - // const def = store.actions.components.getDefinition(component._component) - // if (!def?.context) { - // return false - // } - // - // // If no type specified, return anything that exposes context - // if (!type) { - // return true - // } - // - // // Otherwise only match components with the specific context type - // const contexts = Array.isArray(def.context) ? def.context : [def.context] - // return contexts.find(context => context.type === type) != null - // }) + // Ignore contexts and just return the component instances + return componentContexts.map(entry => entry.component) } /** @@ -305,142 +334,132 @@ export const getDatasourceForProvider = (asset, component) => { * Gets all bindable data properties from component data contexts. */ const getContextBindings = (asset, componentId) => { - // Extract any components which provide data contexts - const dataProviders = getContextProviderComponents(asset, componentId) + // Get all available contexts for this component + const componentContexts = getComponentContexts(asset, componentId) - // Generate bindings for all matching components - return getProviderContextBindings(asset, dataProviders) + // Generate bindings for each context + return componentContexts + .map(componentContext => { + return generateComponentContextBindings(asset, componentContext) + }) + .flat() } -/** - * Gets the context bindings exposed by a set of data provider components. - */ -const getProviderContextBindings = (asset, dataProviders) => { - if (!asset || !dataProviders) { +const generateComponentContextBindings = (asset, componentContext) => { + const { component, definition, contexts } = componentContext + if (!component || !definition || !contexts?.length) { return [] } - // Ensure providers is an array - if (!Array.isArray(dataProviders)) { - dataProviders = [dataProviders] - } - // Create bindings for each data provider let bindings = [] - dataProviders.forEach(component => { - const def = store.actions.components.getDefinition(component._component) - const contexts = Array.isArray(def.context) ? def.context : [def.context] + contexts.forEach(context => { + if (!context?.type) { + return + } - // Create bindings for each context block provided by this data provider - contexts.forEach(context => { - if (!context?.type) { + let schema + let table + let readablePrefix + let runtimeSuffix = context.suffix + + if (context.type === "form") { + // Forms do not need table schemas + // Their schemas are built from their component field names + schema = buildFormSchema(component, asset) + readablePrefix = "Fields" + } else if (context.type === "static") { + // Static contexts are fully defined by the components + schema = {} + const values = context.values || [] + values.forEach(value => { + schema[value.key] = { + name: value.label, + type: value.type || "string", + } + }) + } else if (context.type === "schema") { + // Schema contexts are generated dynamically depending on their data + const datasource = getDatasourceForProvider(asset, component) + if (!datasource) { return } + const info = getSchemaForDatasource(asset, datasource) + schema = info.schema + table = info.table - let schema - let table - let readablePrefix - let runtimeSuffix = context.suffix - - if (context.type === "form") { - // Forms do not need table schemas - // Their schemas are built from their component field names - schema = buildFormSchema(component, asset) - readablePrefix = "Fields" - } else if (context.type === "static") { - // Static contexts are fully defined by the components - schema = {} - const values = context.values || [] - values.forEach(value => { - schema[value.key] = { - name: value.label, - type: value.type || "string", - } - }) - } else if (context.type === "schema") { - // Schema contexts are generated dynamically depending on their data - const datasource = getDatasourceForProvider(asset, component) - if (!datasource) { - return - } - const info = getSchemaForDatasource(asset, datasource) - schema = info.schema - table = info.table - - // Determine what to prefix bindings with - if (datasource.type === "jsonarray") { - // For JSON arrays, use the array name as the readable prefix - const split = datasource.label.split(".") - readablePrefix = split[split.length - 1] - } else if (datasource.type === "viewV2") { - // For views, use the view name - const view = Object.values(table?.views || {}).find( - view => view.id === datasource.id - ) - readablePrefix = view?.name - } else { - // Otherwise use the table name - readablePrefix = info.table?.name - } - } - if (!schema) { - return - } - - const keys = Object.keys(schema).sort() - - // Generate safe unique runtime prefix - let providerId = component._id - if (runtimeSuffix) { - providerId += `-${runtimeSuffix}` - } - - if (!filterCategoryByContext(component, context)) { - return - } - - const safeComponentId = makePropSafe(providerId) - - // Create bindable properties for each schema field - keys.forEach(key => { - const fieldSchema = schema[key] - - // Make safe runtime binding - const safeKey = key.split(".").map(makePropSafe).join(".") - const runtimeBinding = `${safeComponentId}.${safeKey}` - - // Optionally use a prefix with readable bindings - let readableBinding = component._instanceName - if (readablePrefix) { - readableBinding += `.${readablePrefix}` - } - readableBinding += `.${fieldSchema.name || key}` - - const bindingCategory = getComponentBindingCategory( - component, - context, - def + // Determine what to prefix bindings with + if (datasource.type === "jsonarray") { + // For JSON arrays, use the array name as the readable prefix + const split = datasource.label.split(".") + readablePrefix = split[split.length - 1] + } else if (datasource.type === "viewV2") { + // For views, use the view name + const view = Object.values(table?.views || {}).find( + view => view.id === datasource.id ) + readablePrefix = view?.name + } else { + // Otherwise use the table name + readablePrefix = info.table?.name + } + } + if (!schema) { + return + } - // Create the binding object - bindings.push({ - type: "context", - runtimeBinding, - readableBinding, - // Field schema and provider are required to construct relationship - // datasource options, based on bindable properties - fieldSchema, - providerId, - // Table ID is used by JSON fields to know what table the field is in - tableId: table?._id, - component: component._component, - category: bindingCategory.category, - icon: bindingCategory.icon, - display: { - name: fieldSchema.name || key, - type: fieldSchema.type, - }, - }) + const keys = Object.keys(schema).sort() + + // Generate safe unique runtime prefix + let providerId = component._id + if (runtimeSuffix) { + providerId += `-${runtimeSuffix}` + } + const safeComponentId = makePropSafe(providerId) + + // Create bindable properties for each schema field + keys.forEach(key => { + const fieldSchema = schema[key] + + // Make safe runtime binding + const safeKey = key.split(".").map(makePropSafe).join(".") + const runtimeBinding = `${safeComponentId}.${safeKey}` + + // Optionally use a prefix with readable bindings + let readableBinding = component._instanceName + if (readablePrefix) { + readableBinding += `.${readablePrefix}` + } + readableBinding += `.${fieldSchema.name || key}` + + // Determine which category this binding belongs in + const bindingCategory = getComponentBindingCategory( + component, + context, + definition + ) + + // Temporarily append scope for debugging + const scope = `[${(context.scope || "global").toUpperCase()}]` + + // Create the binding object + bindings.push({ + type: "context", + runtimeBinding, + readableBinding: `${scope} ${readableBinding}`, + // Field schema and provider are required to construct relationship + // datasource options, based on bindable properties + fieldSchema, + providerId, + // Table ID is used by JSON fields to know what table the field is in + tableId: table?._id, + component: component._component, + category: bindingCategory.category, + icon: bindingCategory.icon, + display: { + name: `${scope} ${fieldSchema.name || key}`, + type: fieldSchema.type, + }, }) }) }) @@ -448,24 +467,40 @@ const getProviderContextBindings = (asset, dataProviders) => { return bindings } -// Exclude a data context based on the component settings -const filterCategoryByContext = (component, context) => { - const { _component } = component +/** + * Checks if a certain data context is compatible with a certain instance of a + * configured component. + */ +const isContextCompatibleWithComponent = (context, component) => { + if (!component) { + return false + } + const { _component, actionType } = component + const { type } = context + + // Certain types of form blocks only allow certain contexts if (_component.endsWith("formblock")) { if ( - (component.actionType == "Create" && context.type === "schema") || - (component.actionType == "View" && context.type === "form") + (actionType === "Create" && type === "schema") || + (actionType === "View" && type === "form") ) { return false } } + + // Allow the context by default return true } +/** + * Determines the correct category for a given binding. + */ const getComponentBindingCategory = (component, context, def) => { + // Default category to component name let icon = def.icon let category = component._instanceName + // Form block edge case if (component._component.endsWith("formblock")) { let contextCategorySuffix = { form: "Fields", @@ -476,6 +511,7 @@ const getComponentBindingCategory = (component, context, def) => { }` icon = context.type === "form" ? "Form" : "Data" } + return { icon, category, @@ -483,7 +519,7 @@ const getComponentBindingCategory = (component, context, def) => { } /** - * Gets all bindable properties from the logged in user. + * Gets all bindable properties from the logged-in user. */ export const getUserBindings = () => { let bindings = [] diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 1fe42f7ab8..83b701360f 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -531,10 +531,12 @@ ], "context": [ { - "type": "schema" + "type": "schema", + "scope": "local" }, { "type": "static", + "scope": "local", "values": [ { "label": "Row index", diff --git a/packages/pro b/packages/pro index cf3bef2aad..4638ae916e 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit cf3bef2aad9c739111b306fd0712397adc363f81 +Subproject commit 4638ae916e55ce89166095578cbd01745d0ee9ee diff --git a/yarn.lock b/yarn.lock index e6d75f4158..00f4dd547c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3610,13 +3610,6 @@ strip-ansi "^6.0.0" v8-to-istanbul "^9.0.1" -"@jest/schemas@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-28.1.3.tgz#ad8b86a66f11f33619e3d7e1dcddd7f2d40ff905" - integrity sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg== - dependencies: - "@sinclair/typebox" "^0.24.1" - "@jest/schemas@^29.4.3": version "29.4.3" resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.4.3.tgz#39cf1b8469afc40b6f5a2baaa146e332c4151788" @@ -3703,18 +3696,6 @@ "@types/yargs" "^16.0.0" chalk "^4.0.0" -"@jest/types@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-28.1.3.tgz#b05de80996ff12512bc5ceb1d208285a7d11748b" - integrity sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ== - dependencies: - "@jest/schemas" "^28.1.3" - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^17.0.8" - chalk "^4.0.0" - "@jest/types@^29.4.3": version "29.4.3" resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.4.3.tgz#9069145f4ef09adf10cec1b2901b2d390031431f" @@ -5139,11 +5120,6 @@ make-fetch-happen "^11.0.1" tuf-js "^1.1.3" -"@sinclair/typebox@^0.24.1": - version "0.24.51" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f" - integrity sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA== - "@sinclair/typebox@^0.25.16": version "0.25.24" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718" @@ -6293,14 +6269,6 @@ "@types/tedious" "*" tarn "^3.0.1" -"@types/node-fetch@2.6.1": - version "2.6.1" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.1.tgz#8f127c50481db65886800ef496f20bbf15518975" - integrity sha512-oMqjURCaxoSIsHSr1E47QHzbmzNR5rK8McHuNb11BOM9cHcIK3Avy0s/b2JlXHoQGTYS3NsvWzV1M0iK7l0wbA== - dependencies: - "@types/node" "*" - form-data "^3.0.0" - "@types/node-fetch@2.6.4": version "2.6.4" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.4.tgz#1bc3a26de814f6bf466b25aeb1473fa1afe6a660" @@ -6322,11 +6290,6 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f" integrity sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA== -"@types/node@14.18.20": - version "14.18.20" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.20.tgz#268f028b36eaf51181c3300252f605488c4f0650" - integrity sha512-Q8KKwm9YqEmUBRsqJ2GWJDtXltBDxTdC4m5vTdXBolu2PeQh8LX+f6BTwU+OuXPu37fLxoN6gidqBmnky36FXA== - "@types/node@16.9.1": version "16.9.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708"