diff --git a/packages/builder/src/builderStore/componentUtils.js b/packages/builder/src/builderStore/componentUtils.js index a7ee3ff351..733ce0948e 100644 --- a/packages/builder/src/builderStore/componentUtils.js +++ b/packages/builder/src/builderStore/componentUtils.js @@ -92,7 +92,14 @@ export const findAllMatchingComponents = (rootComponent, selector) => { } /** - * Finds the closes parent component which matches certain criteria + * Recurses through the component tree and finds all components. + */ +export const findAllComponents = rootComponent => { + return findAllMatchingComponents(rootComponent, () => true) +} + +/** + * Finds the closest parent component which matches certain criteria */ export const findClosestMatchingComponent = ( rootComponent, diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index 52368a0723..85d5046d8c 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, @@ -102,6 +103,9 @@ export const getAuthBindings = () => { return bindings } +/** + * Gets all bindings for environment variables + */ export const getEnvironmentBindings = () => { let envVars = get(environment).variables return envVars.map(variable => { @@ -130,26 +134,22 @@ export const toBindingsArray = (valueMap, prefix, category) => { if (!binding) { return acc } - let config = { type: "context", runtimeBinding: binding, readableBinding: `${prefix}.${binding}`, icon: "Brackets", } - if (category) { config.category = category } - acc.push(config) - return acc }, []) } /** - * Utility - coverting a map of readable bindings to runtime + * Utility to covert a map of readable bindings to runtime */ export const readableToRuntimeMap = (bindings, ctx) => { if (!bindings || !ctx) { @@ -162,7 +162,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) { @@ -188,15 +188,23 @@ 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 data provider components above a component. + * 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. */ -export const getContextProviderComponents = ( +export const getComponentContexts = ( asset, componentId, type, @@ -205,30 +213,55 @@ export const getContextProviderComponents = ( if (!asset || !componentId) { return [] } + let map = {} - // 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 => { + // Processes all contexts exposed by a component + const processContexts = scope => component => { const def = store.actions.components.getDefinition(component._component) if (!def?.context) { - return false + return } - - // If no type specified, return anything that exposes context - if (!type) { - return true + if (!map[component._id]) { + map[component._id] = { + component, + definition: def, + contexts: [], + } } - - // 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 - }) + 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")) + + // Exclude self if required + if (!options?.includeSelf) { + delete map[componentId] + } + + // Only return components which provide at least 1 matching context + return Object.values(map).filter(x => x.contexts.length > 0) } /** @@ -240,20 +273,19 @@ export const getActionProviders = ( actionType, options = { includeSelf: false } ) => { - if (!asset || !componentId) { + if (!asset) { return [] } - // Get the component tree leading up to this component, ignoring the component - // itself - const path = findComponentPath(asset.props, componentId) - if (!options?.includeSelf) { - path.pop() - } + // Get all components + const components = findAllComponents(asset.props) // Find matching contexts and generate bindings let providers = [] - path.forEach(component => { + components.forEach(component => { + if (!options?.includeSelf && component._id === componentId) { + return + } const def = store.actions.components.getDefinition(component._component) const actions = (def?.actions || []).map(action => { return typeof action === "string" ? { type: action } : action @@ -317,142 +349,135 @@ 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. + * Generates a set of bindings for a given component context */ -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, + }, }) }) }) @@ -460,25 +485,38 @@ 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 } // Enrich binding category information for certain components 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")) { if (context.type === "form") { category = `${component._instanceName} - Fields` @@ -496,7 +534,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 = [] @@ -566,6 +604,7 @@ const getDeviceBindings = () => { /** * Gets all selected rows bindings for tables in the current asset. + * TODO: remove in future because we don't need a separate store for this */ const getSelectedRowsBindings = asset => { let bindings = [] @@ -608,6 +647,9 @@ const getSelectedRowsBindings = asset => { return bindings } +/** + * Generates a state binding for a certain key name + */ export const makeStateBinding = key => { return { type: "context", @@ -662,6 +704,9 @@ const getUrlBindings = asset => { return urlParamBindings.concat([queryParamsBinding]) } +/** + * Generates all bindings for role IDs + */ const getRoleBindings = () => { return (get(rolesStore) || []).map(role => { return { diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index b05b127b1c..ce2cac9781 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -709,10 +709,9 @@ export const getFrontendStore = () => { else { if (setting.type === "dataProvider") { // Validate data provider exists, or else clear it - const treeId = parent?._id || component._id - const path = findComponentPath(screen?.props, treeId) - const providers = path.filter(component => - component._component?.endsWith("/dataprovider") + const providers = findAllMatchingComponents( + screen?.props, + component => component._component?.endsWith("/dataprovider") ) // Validate non-empty values const valid = providers?.some(dp => value.includes?.(dp._id)) @@ -734,6 +733,16 @@ export const getFrontendStore = () => { return null } + // Find all existing components of this type so that we can give this + // component a unique name + const screen = get(selectedScreen).props + const otherComponents = findAllMatchingComponents( + screen, + x => x._component === definition.component && x._id !== screen._id + ) + let name = definition.friendlyName || definition.name + name = `${name} ${otherComponents.length + 1}` + // Generate basic component structure let instance = { _id: Helpers.uuid(), @@ -743,7 +752,7 @@ export const getFrontendStore = () => { hover: {}, active: {}, }, - _instanceName: `New ${definition.friendlyName || definition.name}`, + _instanceName: name, ...presetProps, } diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/utils.js b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/utils.js index aa076fdd3e..bdcd3a7838 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/utils.js +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/utils.js @@ -1,5 +1,4 @@ -import { getContextProviderComponents } from "builderStore/dataBinding" -import { store } from "builderStore" +import { getComponentContexts } from "builderStore/dataBinding" import { capitalise } from "helpers" // Generates bindings for all components that provider "datasource like" @@ -8,58 +7,49 @@ import { capitalise } from "helpers" // Some examples are saving rows or duplicating rows. export const getDatasourceLikeProviders = ({ asset, componentId, nested }) => { // Get all form context providers - const formComponents = getContextProviderComponents( + const formComponentContexts = getComponentContexts( asset, componentId, "form", - { includeSelf: nested } + { + includeSelf: nested, + } ) - // Get all schema context providers - const schemaComponents = getContextProviderComponents( + const schemaComponentContexts = getComponentContexts( asset, componentId, "schema", - { includeSelf: nested } + { + includeSelf: nested, + } ) - // Generate contexts for all form providers - const formContexts = formComponents.map(component => ({ - component, - context: extractComponentContext(component, "form"), - })) - - // Generate contexts for all schema providers - const schemaContexts = schemaComponents.map(component => ({ - component, - context: extractComponentContext(component, "schema"), - })) - // Check for duplicate contexts by the same component. In this case, attempt // to label contexts with their suffixes - schemaContexts.forEach(schemaContext => { + schemaComponentContexts.forEach(schemaContext => { // Check if we have a form context for this component const id = schemaContext.component._id - const existing = formContexts.find(x => x.component._id === id) + const existing = formComponentContexts.find(x => x.component._id === id) if (existing) { - if (existing.context.suffix) { - const suffix = capitalise(existing.context.suffix) + if (existing.contexts[0].suffix) { + const suffix = capitalise(existing.contexts[0].suffix) existing.readableSuffix = ` - ${suffix}` } - if (schemaContext.context.suffix) { - const suffix = capitalise(schemaContext.context.suffix) + if (schemaContext.contexts[0].suffix) { + const suffix = capitalise(schemaContext.contexts[0].suffix) schemaContext.readableSuffix = ` - ${suffix}` } } }) // Generate bindings for all contexts - const allContexts = formContexts.concat(schemaContexts) - return allContexts.map(({ component, context, readableSuffix }) => { + const allContexts = formComponentContexts.concat(schemaComponentContexts) + return allContexts.map(({ component, contexts, readableSuffix }) => { let readableBinding = component._instanceName let runtimeBinding = component._id - if (context.suffix) { - runtimeBinding += `-${context.suffix}` + if (contexts[0].suffix) { + runtimeBinding += `-${contexts[0].suffix}` } if (readableSuffix) { readableBinding += readableSuffix @@ -70,13 +60,3 @@ export const getDatasourceLikeProviders = ({ asset, componentId, nested }) => { } }) } - -// Gets a context definition of a certain type from a component definition -const extractComponentContext = (component, contextType) => { - const def = store.actions.components.getDefinition(component?._component) - if (!def) { - return null - } - const contexts = Array.isArray(def.context) ? def.context : [def.context] - return contexts.find(context => context?.type === contextType) -} diff --git a/packages/builder/src/components/design/settings/controls/DataProviderSelect.svelte b/packages/builder/src/components/design/settings/controls/DataProviderSelect.svelte index 83255ec325..9fd220e798 100644 --- a/packages/builder/src/components/design/settings/controls/DataProviderSelect.svelte +++ b/packages/builder/src/components/design/settings/controls/DataProviderSelect.svelte @@ -1,15 +1,16 @@