budibase/packages/builder/src/dataBinding.js

1423 lines
41 KiB
JavaScript

import { cloneDeep } from "lodash/fp"
import { get } from "svelte/store"
import {
buildContextTreeLookupMap,
findAllComponents,
findAllMatchingComponents,
findComponent,
findComponentPath,
} from "helpers/components"
import {
componentStore,
screenStore,
appStore,
layoutStore,
queries as queriesStores,
tables as tablesStore,
roles as rolesStore,
selectedScreen,
} from "stores/builder"
import {
makePropSafe,
isJSBinding,
decodeJSBinding,
encodeJSBinding,
} from "@budibase/string-templates"
import { TableNames } from "./constants"
import { JSONUtils, Constants } from "@budibase/frontend-core"
import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json"
import { environment, licensing } from "stores/portal"
import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils"
const { ContextScopes } = Constants
// Regex to match all instances of template strings
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
const CAPTURE_VAR_INSIDE_JS = /\$\("([^")]+)"\)/g
const CAPTURE_HBS_TEMPLATE = /{{[\S\s]*?}}/g
const UpdateReferenceAction = {
ADD: "add",
DELETE: "delete",
MOVE: "move",
}
/**
* Gets all bindable data context fields and instance fields.
*/
export const getBindableProperties = (asset, componentId) => {
const contextBindings = getContextBindings(asset, componentId)
const userBindings = getUserBindings()
const urlBindings = getUrlBindings(asset)
const deviceBindings = getDeviceBindings()
const stateBindings = getStateBindings()
const selectedRowsBindings = getSelectedRowsBindings(asset)
const roleBindings = getRoleBindings()
return [
...contextBindings,
...urlBindings,
...stateBindings,
...userBindings,
...deviceBindings,
...selectedRowsBindings,
...roleBindings,
]
}
/**
* Gets all rest bindable data fields
*/
export const getRestBindings = () => {
const environmentVariablesEnabled = get(licensing).environmentVariablesEnabled
const userBindings = getUserBindings()
return [
...userBindings,
...getAuthBindings(),
...(environmentVariablesEnabled ? getEnvironmentBindings() : []),
]
}
/**
* Gets all rest bindable auth fields
*/
export const getAuthBindings = () => {
let bindings = []
const safeUser = makePropSafe("user")
const safeOAuth2 = makePropSafe("oauth2")
const safeAccessToken = makePropSafe("accessToken")
const authBindings = [
{
runtime: `${safeUser}.${safeOAuth2}.${safeAccessToken}`,
readable: `Current User.OAuthToken`,
key: "accessToken",
display: { name: "OAuthToken", type: "text" },
},
]
bindings = authBindings.map(fieldBinding => {
return {
type: "context",
runtimeBinding: fieldBinding.runtime,
readableBinding: fieldBinding.readable,
fieldSchema: { type: "string", name: fieldBinding.key },
providerId: "user",
category: "Current User",
display: fieldBinding.display,
}
})
return bindings
}
/**
* Gets all bindings for environment variables
*/
export const getEnvironmentBindings = () => {
let envVars = get(environment).variables
return envVars.map(variable => {
return {
type: "context",
runtimeBinding: `env.${makePropSafe(variable.name)}`,
readableBinding: `env.${variable.name}`,
category: "Environment",
icon: "Key",
display: { type: "string", name: variable.name },
}
})
}
/**
* Utility - convert a key/value map to an array of custom 'context' bindings
* @param {object} valueMap Key/value pairings
* @param {string} prefix A contextual string prefix/path for a user readable binding
* @return {object[]} An array containing readable/runtime binding objects
*/
export const toBindingsArray = (valueMap, prefix, category) => {
if (!valueMap) {
return []
}
return Object.keys(valueMap).reduce((acc, binding) => {
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 to covert a map of readable bindings to runtime
*/
export const readableToRuntimeMap = (bindings, ctx) => {
if (!bindings || !ctx) {
return {}
}
return Object.keys(ctx).reduce((acc, key) => {
acc[key] = readableToRuntimeBinding(bindings, ctx[key])
return acc
}, {})
}
/**
* Utility to covert a map of runtime bindings to readable bindings
*/
export const runtimeToReadableMap = (bindings, ctx) => {
if (!bindings || !ctx) {
return {}
}
return Object.keys(ctx).reduce((acc, key) => {
acc[key] = runtimeToReadableBinding(bindings, ctx[key])
return acc
}, {})
}
/**
* Gets the bindable properties exposed by a certain component.
*/
export const getComponentBindableProperties = (asset, componentId) => {
if (!asset || !componentId) {
return []
}
// Ensure that the component exists and exposes context
const component = findComponent(asset.props, componentId)
const def = componentStore.getDefinition(component?._component)
if (!def?.context) {
return []
}
const contexts = Array.isArray(def.context) ? def.context : [def.context]
// Get the bindings for the 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.
*/
export const getComponentContexts = (
asset,
componentId,
type,
options = { includeSelf: false }
) => {
if (!asset || !componentId) {
return []
}
let map = {}
const componentPath = findComponentPath(asset.props, componentId)
const componentPathIds = componentPath.map(component => component._id)
const contextTreeLookupMap = buildContextTreeLookupMap(asset.props)
// Processes all contexts exposed by a component
const processContexts = scope => component => {
const def = componentStore.getDefinition(component._component)
if (!def?.context) {
return
}
// Filter out global contexts not in the same branch.
// Global contexts are only valid if their branch root is an ancestor of
// this component.
const branch = contextTreeLookupMap[component._id]
if (branch !== "root" && !componentPathIds.includes(branch)) {
return
}
// Process all contexts provided by this component
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 || ContextScopes.Global
if (contextScope !== scope) {
return
}
// Ensure the context is compatible with the component's current settings
if (!isContextCompatibleWithComponent(context, component)) {
return
}
if (!map[component._id]) {
map[component._id] = {
component,
definition: def,
contexts: [],
}
}
map[component._id].contexts.push(context)
})
}
// Process all global contexts
const allComponents = findAllComponents(asset.props)
allComponents.forEach(processContexts(ContextScopes.Global))
// Process all local contexts in the immediate tree
componentPath.forEach(processContexts(ContextScopes.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)
}
/**
* Gets all data provider components above a component.
*/
export const getActionProviders = (
asset,
componentId,
actionType,
options = { includeSelf: false }
) => {
if (!asset) {
return []
}
// Get all components
const components = findAllComponents(asset.props)
// Find matching contexts and generate bindings
let providers = []
components.forEach(component => {
if (!options?.includeSelf && component._id === componentId) {
return
}
const def = componentStore.getDefinition(component._component)
const actions = (def?.actions || []).map(action => {
return typeof action === "string" ? { type: action } : action
})
const action = actions.find(x => x.type === actionType)
if (action) {
let runtimeBinding = component._id
if (action.suffix) {
runtimeBinding += `-${action.suffix}`
}
providers.push({
readableBinding: component._instanceName,
runtimeBinding,
})
}
})
return providers
}
/**
* Gets a datasource object for a certain data provider component
*/
export const getDatasourceForProvider = (asset, component) => {
const settings = componentStore.getComponentSettings(component?._component)
// If this component has a dataProvider setting, go up the stack and use it
const dataProviderSetting = settings.find(setting => {
return setting.type === "dataProvider"
})
if (dataProviderSetting) {
const settingValue = component[dataProviderSetting.key]
const providerId = extractLiteralHandlebarsID(settingValue)
const provider = findComponent(asset?.props, providerId)
return getDatasourceForProvider(asset, provider)
}
// Extract datasource from component instance
const validSettingTypes = ["dataSource", "table", "schema"]
const datasourceSetting = settings.find(setting => {
return validSettingTypes.includes(setting.type)
})
if (!datasourceSetting) {
return null
}
// For legacy compatibility, we need to be able to handle datasources that are
// just strings. These are not generated any more, so could be removed in
// future.
// TODO: remove at some point
const datasource = component[datasourceSetting?.key]
if (typeof datasource === "string") {
return {
tableId: datasource,
type: "table",
}
}
return datasource
}
/**
* Gets all bindable data properties from component data contexts.
*/
const getContextBindings = (asset, componentId) => {
// Get all available contexts for this component
const componentContexts = getComponentContexts(asset, componentId)
// Generate bindings for each context
return componentContexts
.map(componentContext => {
return generateComponentContextBindings(asset, componentContext)
})
.flat()
}
/**
* Generates a set of bindings for a given component context
*/
const generateComponentContextBindings = (asset, componentContext) => {
const { component, definition, contexts } = componentContext
if (!component || !definition || !contexts?.length) {
return []
}
// Create bindings for each data provider
let bindings = []
contexts.forEach(context => {
if (!context?.type) {
return
}
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" || datasource.type === "queryarray") {
// 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}`
}
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
)
// Create the binding object
bindings.push({
type: "context",
runtimeBinding,
readableBinding: `${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,
},
})
})
})
return bindings
}
/**
* 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 (
(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`
icon = "Form"
} else if (context.type === "schema") {
category = `${component._instanceName} - Row`
icon = "Data"
}
}
return {
icon,
category,
}
}
/**
* Gets all bindable properties from the logged-in user.
*/
export const getUserBindings = () => {
let bindings = []
const { schema } = getSchemaForDatasourcePlus(TableNames.USERS)
const keys = Object.keys(schema).sort()
const safeUser = makePropSafe("user")
bindings = keys.reduce((acc, key) => {
const fieldSchema = schema[key]
acc.push({
type: "context",
runtimeBinding: `${safeUser}.${makePropSafe(key)}`,
readableBinding: `Current User.${key}`,
// Field schema and provider are required to construct relationship
// datasource options, based on bindable properties
fieldSchema,
providerId: "user",
category: "Current User",
icon: "User",
display: {
name: key,
},
})
return acc
}, [])
return bindings
}
/**
* Gets all device bindings that are globally available.
*/
const getDeviceBindings = () => {
let bindings = []
if (get(appStore).clientFeatures?.deviceAwareness) {
const safeDevice = makePropSafe("device")
bindings = [
{
type: "context",
runtimeBinding: `${safeDevice}.${makePropSafe("mobile")}`,
readableBinding: `Device.Mobile`,
category: "Device",
icon: "DevicePhone",
display: { type: "boolean", name: "mobile" },
},
{
type: "context",
runtimeBinding: `${safeDevice}.${makePropSafe("tablet")}`,
readableBinding: `Device.Tablet`,
category: "Device",
icon: "DevicePhone",
display: { type: "boolean", name: "tablet" },
},
{
type: "context",
runtimeBinding: `${safeDevice}.${makePropSafe("theme")}`,
readableBinding: `App.Theme`,
category: "Device",
icon: "DevicePhone",
display: { type: "string", name: "App Theme" },
},
]
}
return bindings
}
/**
* 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
* DEPRECATED
*/
const getSelectedRowsBindings = asset => {
let bindings = []
if (get(appStore).clientFeatures?.rowSelection) {
// Add bindings for table components
let tables = findAllMatchingComponents(asset?.props, component =>
component._component.endsWith("table")
)
const safeState = makePropSafe("rowSelection")
bindings = bindings.concat(
tables.map(table => ({
type: "context",
runtimeBinding: `${safeState}.${makePropSafe(table._id)}.${makePropSafe(
"selectedRows"
)}`,
readableBinding: `${table._instanceName}.Selected Row IDs (deprecated)`,
category: "Selected Row IDs (deprecated)",
icon: "ViewRow",
display: { name: table._instanceName },
}))
)
// Add bindings for table blocks
let tableBlocks = findAllMatchingComponents(asset?.props, component =>
component._component.endsWith("tableblock")
)
bindings = bindings.concat(
tableBlocks.map(block => ({
type: "context",
runtimeBinding: `${safeState}.${makePropSafe(
block._id + "-table"
)}.${makePropSafe("selectedRows")}`,
readableBinding: `${block._instanceName}.Selected Row IDs (deprecated)`,
category: "Selected Row IDs (deprecated)",
icon: "ViewRow",
display: { name: block._instanceName },
}))
)
}
return bindings
}
/**
* Generates a state binding for a certain key name
*/
export const makeStateBinding = key => {
return {
type: "context",
runtimeBinding: `${makePropSafe("state")}.${makePropSafe(key)}`,
readableBinding: `State.${key}`,
category: "State",
icon: "AutomatedSegment",
display: { name: key },
}
}
/**
* Gets all state bindings that are globally available.
*/
const getStateBindings = () => {
let bindings = []
if (get(appStore).clientFeatures?.state) {
bindings = getAllStateVariables().map(makeStateBinding)
}
return bindings
}
/**
* Gets all bindable properties from URL parameters.
*/
const getUrlBindings = asset => {
const url = asset?.routing?.route ?? ""
const split = url.split("/")
let params = []
split.forEach(part => {
if (part.startsWith(":") && part.length > 1) {
params.push(part.replace(/:/g, "").replace(/\?/g, ""))
}
})
const safeURL = makePropSafe("url")
const urlParamBindings = params.map(param => ({
type: "context",
runtimeBinding: `${safeURL}.${makePropSafe(param)}`,
readableBinding: `URL.${param}`,
category: "URL",
icon: "RailTop",
display: { type: "string", name: param },
}))
const queryParamsBinding = {
type: "context",
runtimeBinding: makePropSafe("query"),
readableBinding: "Query params",
category: "URL",
icon: "RailTop",
display: { type: "object", name: "Query params" },
}
return urlParamBindings.concat([queryParamsBinding])
}
/**
* Generates all bindings for role IDs
*/
const getRoleBindings = () => {
return (get(rolesStore) || []).map(role => {
return {
type: "context",
runtimeBinding: `trim "${role._id}"`,
readableBinding: `Role.${role.name}`,
category: "Role",
icon: "UserGroup",
display: { type: "string", name: role.name },
}
})
}
/**
* Gets all bindable event context properties provided in the component
* setting
*/
export const getEventContextBindings = ({
settingKey,
componentInstance,
componentId,
componentDefinition,
asset,
}) => {
let bindings = []
asset = asset ?? get(selectedScreen)
// Check if any context bindings are provided by the component for this
// setting
const component = componentInstance ?? findComponent(asset.props, componentId)
if (!component) {
return bindings
}
const definition =
componentDefinition ?? componentStore.getDefinition(component?._component)
const settings = componentStore.getComponentSettings(component?._component)
const eventSetting = settings.find(setting => setting.key === settingKey)
if (eventSetting?.context?.length) {
eventSetting.context.forEach(contextEntry => {
bindings.push({
readableBinding: contextEntry.label,
runtimeBinding: `${makePropSafe("eventContext")}.${makePropSafe(
contextEntry.key
)}`,
category: component._instanceName,
icon: definition.icon,
display: {
name: contextEntry.label,
},
})
})
}
return bindings
}
/**
* Gets all bindable properties exposed in an event action flow up until
* the specified action ID, as well as context provided for the action
* setting as a whole by the component.
*/
export const getActionBindings = (actions, actionId) => {
let bindings = []
// Get the steps leading up to this value
const index = actions?.findIndex(action => action.id === actionId)
if (index == null || index === -1) {
return bindings
}
const prevActions = actions.slice(0, index)
// Generate bindings for any steps which provide context
prevActions.forEach((action, idx) => {
const def = ActionDefinitions.actions.find(
x => x.name === action["##eventHandlerType"]
)
if (def.context) {
def.context.forEach(contextValue => {
bindings.push({
readableBinding: `Action ${idx + 1}.${contextValue.label}`,
runtimeBinding: `actions.${idx}.${contextValue.value}`,
category: "Actions",
icon: "JourneyAction",
display: {
name: contextValue.label,
},
})
})
}
})
return bindings
}
/**
* Gets the schema for a certain datasource plus.
* The options which can be passed in are:
* formSchema: whether the schema is for a form
* searchableSchema: whether to generate a searchable schema, which may have
* fewer fields than a readable schema
* @param resourceId the DS+ resource ID
* @param options options for generating the schema
* @return {{schema: Object, table: Object}}
*/
export const getSchemaForDatasourcePlus = (resourceId, options) => {
const isViewV2 = resourceId?.includes("view_")
const datasource = isViewV2
? {
type: "viewV2",
id: resourceId,
tableId: resourceId.split("_").slice(1, 3).join("_"),
}
: { type: "table", tableId: resourceId }
return getSchemaForDatasource(null, datasource, options)
}
/**
* Gets a schema for a datasource object.
* The options which can be passed in are:
* formSchema: whether the schema is for a form
* searchableSchema: whether to generate a searchable schema, which may have
* fewer fields than a readable schema
* @param asset the current root client app asset (layout or screen). This is
* optional and only needed for "provider" datasource types.
* @param datasource the datasource definition
* @param options options for generating the schema
* @return {{schema: Object, table: Object}}
*/
export const getSchemaForDatasource = (asset, datasource, options) => {
options = options || {}
let schema, table
if (datasource) {
const { type } = datasource
const tables = get(tablesStore).list
// Determine the entity which backs this datasource.
// "provider" datasources are those targeting another data provider
if (type === "provider") {
const component = findComponent(asset?.props, datasource.providerId)
const source = getDatasourceForProvider(asset, component)
return getSchemaForDatasource(asset, source, options)
}
// "query" datasources are those targeting non-plus datasources or
// custom queries
else if (type === "query") {
const queries = get(queriesStores).list
table = queries.find(query => query._id === datasource._id)
}
// "field" datasources are array-like fields of rows, such as attachments
// or multi-select fields
else if (type === "field") {
table = { name: datasource.fieldName }
const { fieldType } = datasource
if (fieldType === "attachment") {
schema = {
url: {
type: "string",
},
name: {
type: "string",
},
}
} else if (fieldType === "array") {
schema = {
value: {
type: "string",
},
}
}
}
// "jsonarray" datasources are arrays inside JSON fields
else if (type === "jsonarray") {
table = tables.find(table => table._id === datasource.tableId)
let tableSchema = table?.schema
schema = JSONUtils.getJSONArrayDatasourceSchema(tableSchema, datasource)
}
// "queryarray" datasources are arrays inside JSON responses
else if (type === "queryarray") {
const queries = get(queriesStores).list
table = queries.find(query => query._id === datasource.tableId)
let tableSchema = table?.schema
let nestedSchemaFields = table?.nestedSchemaFields
schema = JSONUtils.generateQueryArraySchemas(
tableSchema,
nestedSchemaFields
)
schema = JSONUtils.getJSONArrayDatasourceSchema(schema, datasource)
}
// Otherwise we assume we're targeting an internal table or a plus
// datasource, and we can treat it as a table with a schema
else {
table = tables.find(table => table._id === datasource.tableId)
}
// Determine the schema from the backing entity if not already determined
if (table && !schema) {
if (type === "view") {
// Old views
schema = cloneDeep(table.views?.[datasource.name]?.schema)
} else if (type === "viewV2") {
// New views which are DS+
const view = Object.values(table.views || {}).find(
view => view.id === datasource.id
)
schema = cloneDeep(view?.schema)
// Strip hidden fields
Object.keys(schema || {}).forEach(field => {
if (!schema[field].visible) {
delete schema[field]
}
})
} else if (
type === "query" &&
(options.formSchema || options.searchableSchema)
) {
// For queries, if we are generating a schema for a form or a searchable
// schema then we want to use the query parameters rather than the
// query schema
schema = {}
const params = table.parameters || []
params.forEach(param => {
if (param?.name) {
schema[param.name] = { ...param, type: "string" }
}
})
} else {
// Otherwise we just want the schema of the table
schema = cloneDeep(table.schema)
}
}
// Check for any JSON fields so we can add any top level properties
if (schema) {
let jsonAdditions = {}
Object.keys(schema).forEach(fieldKey => {
const fieldSchema = schema[fieldKey]
if (fieldSchema?.type === "json") {
const jsonSchema = JSONUtils.convertJSONSchemaToTableSchema(
fieldSchema,
{
squashObjects: true,
}
)
Object.keys(jsonSchema).forEach(jsonKey => {
jsonAdditions[`${fieldKey}.${jsonKey}`] = {
type: jsonSchema[jsonKey].type,
nestedJSON: true,
}
})
}
})
schema = { ...schema, ...jsonAdditions }
}
// Determine if we should add ID and rev to the schema
const isInternal = table && !table.sql
const isDSPlus = ["table", "link", "viewV2"].includes(datasource.type)
// ID is part of the readable schema for all tables
// Rev is part of the readable schema for internal tables only
let addId = isDSPlus
let addRev = isDSPlus && isInternal
// Don't add ID or rev for form schemas
if (options.formSchema) {
addId = false
addRev = false
}
// ID is only searchable for internal tables
else if (options.searchableSchema) {
addId = isDSPlus && isInternal
}
// Add schema properties if required
if (schema) {
if (addId) {
schema["_id"] = { type: "string" }
}
if (addRev) {
schema["_rev"] = { type: "string" }
}
}
// Ensure there are "name" properties for all fields and that field schema
// are objects
let fixedSchema = {}
Object.entries(schema || {}).forEach(([fieldName, fieldSchema]) => {
if (typeof fieldSchema === "string") {
fixedSchema[fieldName] = {
type: fieldSchema,
name: fieldName,
}
} else {
fixedSchema[fieldName] = {
...fieldSchema,
name: fieldName,
}
}
})
schema = fixedSchema
}
return { schema, table }
}
/**
* Builds a form schema given a form component.
* A form schema is a schema of all the fields nested anywhere within a form.
*/
export const buildFormSchema = (component, asset) => {
let schema = {}
if (!component) {
return schema
}
if (component._component.endsWith("formblock")) {
let schema = {}
const datasource = getDatasourceForProvider(asset, component)
const info = getSchemaForDatasource(component, datasource)
if (!info?.schema) {
return schema
}
if (!component.fields) {
Object.values(info.schema)
.filter(
({ autocolumn, name }) =>
!autocolumn && !["_rev", "_id"].includes(name)
)
.forEach(({ name }) => {
schema[name] = { type: info?.schema[name].type }
})
} else {
// Field conversion
const patched = convertOldFieldFormat(component.fields || [])
patched?.forEach(({ field, active }) => {
if (!active) return
if (info?.schema[field]) {
schema[field] = { type: info?.schema[field].type }
}
})
}
return schema
}
// Otherwise find all field component children
const settings = componentStore.getComponentSettings(component._component)
const fieldSetting = settings.find(
setting => setting.key === "field" && setting.type.startsWith("field/")
)
if (fieldSetting && component.field) {
const type = fieldSetting.type.split("field/")[1]
if (type) {
schema[component.field] = { type }
}
}
component._children?.forEach(child => {
const childSchema = buildFormSchema(child, asset)
schema = { ...schema, ...childSchema }
})
return schema
}
/**
* Returns an array of the keys of any state variables which are set anywhere
* in the app.
*/
export const getAllStateVariables = () => {
// Find all button action settings in all components
let eventSettings = []
getAllAssets().forEach(asset => {
findAllMatchingComponents(asset.props, component => {
const settings = componentStore.getComponentSettings(component._component)
const parseEventSettings = (settings, comp) => {
settings
.filter(setting => setting.type === "event")
.forEach(setting => {
eventSettings.push(comp[setting.key])
})
}
const parseComponentSettings = (settings, component) => {
// Parse the nested button configurations
settings
.filter(setting => setting.type === "buttonConfiguration")
.forEach(setting => {
const buttonConfig = component[setting.key]
if (Array.isArray(buttonConfig)) {
buttonConfig.forEach(button => {
const nestedSettings = componentStore.getComponentSettings(
button._component
)
parseEventSettings(nestedSettings, button)
})
}
})
parseEventSettings(settings, component)
}
// Parse the base component settings
parseComponentSettings(settings, component)
// Parse step configuration
const stepSetting = settings.find(
setting => setting.type === "stepConfiguration"
)
const steps = stepSetting ? component[stepSetting.key] : []
const stepDefinition = componentStore.getComponentSettings(
"@budibase/standard-components/multistepformblockstep"
)
steps?.forEach(step => {
parseComponentSettings(stepDefinition, step)
})
})
})
// Add on load settings from screens
get(screenStore).screens.forEach(screen => {
if (screen.onLoad) {
eventSettings.push(screen.onLoad)
}
})
// Extract all state keys from any "update state" actions in each setting
let bindingSet = new Set()
eventSettings.forEach(setting => {
if (!Array.isArray(setting)) {
return
}
setting.forEach(action => {
if (
action["##eventHandlerType"] === "Update State" &&
action.parameters?.type === "set" &&
action.parameters?.key &&
action.parameters?.value
) {
bindingSet.add(action.parameters.key)
}
})
})
return Array.from(bindingSet)
}
export const getAllAssets = () => {
// Get all component containing assets
let allAssets = []
allAssets = allAssets.concat(get(layoutStore).layouts || [])
allAssets = allAssets.concat(get(screenStore).screens || [])
return allAssets
}
/**
* Recurses the input object to remove any instances of bindings.
*/
export const removeBindings = (obj, replacement = "Invalid binding") => {
for (let [key, value] of Object.entries(obj)) {
if (value && typeof value === "object") {
obj[key] = removeBindings(value, replacement)
} else if (typeof value === "string") {
obj[key] = value.replace(CAPTURE_HBS_TEMPLATE, replacement)
}
}
return obj
}
/**
* When converting from readable to runtime it can sometimes add too many square brackets,
* this makes sure that doesn't happen.
*/
const shouldReplaceBinding = (currentValue, from, convertTo, binding) => {
if (!currentValue?.includes(from)) {
return false
}
if (convertTo === "readableBinding") {
// Dont replace if the value already matches the readable binding
return currentValue.indexOf(binding.readableBinding) === -1
}
// remove all the spaces, if the input is surrounded by spaces e.g. [ Auto ID ] then
// this makes sure it is detected
const noSpaces = currentValue.replace(/\s+/g, "")
const fromNoSpaces = from.replace(/\s+/g, "")
const invalids = [
`[${fromNoSpaces}]`,
`"${fromNoSpaces}"`,
`'${fromNoSpaces}'`,
]
return !invalids.find(invalid => noSpaces?.includes(invalid))
}
/**
* Utility function which replaces a string between given indices.
*/
const replaceBetween = (string, start, end, replacement) => {
return string.substring(0, start) + replacement + string.substring(end)
}
/**
* Utility function for the readableToRuntimeBinding and runtimeToReadableBinding.
*/
const bindingReplacement = (
bindableProperties,
textWithBindings,
convertTo
) => {
// Decide from base64 if using JS
const isJS = isJSBinding(textWithBindings)
if (isJS) {
textWithBindings = decodeJSBinding(textWithBindings)
}
// Determine correct regex to find bindings to replace
const regex = isJS ? CAPTURE_VAR_INSIDE_JS : CAPTURE_VAR_INSIDE_TEMPLATE
const convertFrom =
convertTo === "runtimeBinding" ? "readableBinding" : "runtimeBinding"
if (typeof textWithBindings !== "string") {
return textWithBindings
}
// work from longest to shortest
const convertFromProps = bindableProperties
.map(el => el[convertFrom])
.sort((a, b) => {
return b.length - a.length
})
const boundValues = textWithBindings.match(regex) || []
let result = textWithBindings
for (let boundValue of boundValues) {
let newBoundValue = boundValue
// we use a search string, where any time we replace something we blank it out
// in the search, working from longest to shortest so always use best match first
let searchString = newBoundValue
for (let from of convertFromProps) {
const binding = bindableProperties.find(el => el[convertFrom] === from)
if (
isJS ||
shouldReplaceBinding(newBoundValue, from, convertTo, binding)
) {
let idx
do {
// see if any instances of this binding exist in the search string
idx = searchString.indexOf(from)
if (idx !== -1) {
let end = idx + from.length,
searchReplace = Array(binding[convertTo].length + 1).join("*")
// blank out parts of the search string
searchString = replaceBetween(searchString, idx, end, searchReplace)
newBoundValue = replaceBetween(
newBoundValue,
idx,
end,
binding[convertTo]
)
}
} while (idx !== -1)
}
}
result = result.replace(boundValue, newBoundValue)
}
// Re-encode to base64 if using JS
if (isJS) {
result = encodeJSBinding(result)
}
return result
}
/**
* Extracts a component ID from a handlebars expression setting of
* {{ literal [componentId] }}
*/
const extractLiteralHandlebarsID = value => {
if (!value || typeof value !== "string") {
return null
}
return value.match(/{{\s*literal\s*\[+([^\]]+)].*}}/)?.[1]
}
/**
* Converts a readable data binding into a runtime data binding
*/
export const readableToRuntimeBinding = (
bindableProperties,
textWithBindings
) => {
return bindingReplacement(
bindableProperties,
textWithBindings,
"runtimeBinding"
)
}
/**
* Converts a runtime data binding into a readable data binding
*/
export const runtimeToReadableBinding = (
bindableProperties,
textWithBindings
) => {
return bindingReplacement(
bindableProperties,
textWithBindings,
"readableBinding"
)
}
/**
* Used to update binding references for automation or action steps
*
* @param obj - The object to be updated
* @param originalIndex - The original index of the step being moved. Not applicable to add/delete.
* @param modifiedIndex - The new index of the step being modified
* @param action - Used to determine if a step is being added, deleted or moved
* @param label - The binding text that describes the steps
*/
export const updateReferencesInObject = ({
obj,
modifiedIndex,
action,
label,
originalIndex,
}) => {
const stepIndexRegex = new RegExp(`{{\\s*${label}\\.(\\d+)\\.`, "g")
const updateActionStep = (str, index, replaceWith) =>
str.replace(`{{ ${label}.${index}.`, `{{ ${label}.${replaceWith}.`)
for (const key in obj) {
if (typeof obj[key] === "string") {
let matches
while ((matches = stepIndexRegex.exec(obj[key])) !== null) {
const referencedStep = parseInt(matches[1])
if (
action === UpdateReferenceAction.ADD &&
referencedStep >= modifiedIndex
) {
obj[key] = updateActionStep(
obj[key],
referencedStep,
referencedStep + 1
)
} else if (
action === UpdateReferenceAction.DELETE &&
referencedStep > modifiedIndex
) {
obj[key] = updateActionStep(
obj[key],
referencedStep,
referencedStep - 1
)
} else if (action === UpdateReferenceAction.MOVE) {
if (referencedStep === originalIndex) {
obj[key] = updateActionStep(obj[key], referencedStep, modifiedIndex)
} else if (
modifiedIndex <= referencedStep &&
modifiedIndex < originalIndex
) {
obj[key] = updateActionStep(
obj[key],
referencedStep,
referencedStep + 1
)
} else if (
modifiedIndex >= referencedStep &&
modifiedIndex > originalIndex
) {
obj[key] = updateActionStep(
obj[key],
referencedStep,
referencedStep - 1
)
}
}
}
} else if (typeof obj[key] === "object" && obj[key] !== null) {
updateReferencesInObject({
obj: obj[key],
modifiedIndex,
action,
label,
originalIndex,
})
}
}
}