Merge pull request #11830 from Budibase/global-bindings

Global bindings
This commit is contained in:
deanhannigan 2024-01-22 10:32:33 +00:00 committed by GitHub
commit 7241a8ae34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 688 additions and 507 deletions

View File

@ -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,

View File

@ -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,32 +213,57 @@ 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 (!map[component._id]) {
map[component._id] = {
component,
definition: def,
contexts: [],
}
// 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
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)
}
/**
* Gets all data provider components above a component.
*/
@ -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,33 +349,28 @@ 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]
// Create bindings for each context block provided by this data provider
contexts.forEach(context => {
if (!context?.type) {
return
@ -406,11 +433,6 @@ const getProviderContextBindings = (asset, dataProviders) => {
if (runtimeSuffix) {
providerId += `-${runtimeSuffix}`
}
if (!filterCategoryByContext(component, context)) {
return
}
const safeComponentId = makePropSafe(providerId)
// Create bindable properties for each schema field
@ -428,17 +450,21 @@ const getProviderContextBindings = (asset, dataProviders) => {
}
readableBinding += `.${fieldSchema.name || key}`
// Determine which category this binding belongs in
const bindingCategory = getComponentBindingCategory(
component,
context,
def
definition
)
// Temporarily append scope for debugging
const scope = `[${(context.scope || "global").toUpperCase()}]`
// Create the binding object
bindings.push({
type: "context",
runtimeBinding,
readableBinding,
readableBinding: `${scope} ${readableBinding}`,
// Field schema and provider are required to construct relationship
// datasource options, based on bindable properties
fieldSchema,
@ -449,36 +475,48 @@ const getProviderContextBindings = (asset, dataProviders) => {
category: bindingCategory.category,
icon: bindingCategory.icon,
display: {
name: fieldSchema.name || key,
name: `${scope} ${fieldSchema.name || key}`,
type: fieldSchema.type,
},
})
})
})
})
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 {

View File

@ -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,
}

View File

@ -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)
}

View File

@ -1,15 +1,16 @@
<script>
import { Select } from "@budibase/bbui"
import { makePropSafe } from "@budibase/string-templates"
import { currentAsset, store } from "builderStore"
import { findComponentPath } from "builderStore/componentUtils"
import { currentAsset } from "builderStore"
import { findAllMatchingComponents } from "builderStore/componentUtils"
export let value
const getValue = component => `{{ literal ${makePropSafe(component._id)} }}`
$: path = findComponentPath($currentAsset?.props, $store.selectedComponentId)
$: providers = path.filter(c => c._component?.endsWith("/dataprovider"))
$: providers = findAllMatchingComponents($currentAsset?.props, c =>
c._component?.endsWith("/dataprovider")
)
</script>
<Select

View File

@ -1,6 +1,5 @@
<script>
import {
getContextProviderComponents,
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/dataBinding"
@ -30,6 +29,7 @@
import BindingBuilder from "components/integration/QueryBindingBuilder.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
import { findAllComponents } from "builderStore/componentUtils"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import DataSourceCategory from "components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte"
import { API } from "api"
@ -75,12 +75,13 @@
...query,
type: "query",
}))
$: contextProviders = getContextProviderComponents(
$currentAsset,
$store.selectedComponentId
$: dataProviders = findAllComponents($currentAsset.props)
.filter(component => {
return (
component._component?.endsWith("/dataprovider") &&
component._id !== $store.selectedComponentId
)
$: dataProviders = contextProviders
.filter(component => component._component?.endsWith("/dataprovider"))
})
.map(provider => ({
label: provider._instanceName,
name: provider._instanceName,

View File

@ -573,7 +573,6 @@
"description": "A configurable data list that attaches to your backend tables.",
"icon": "JourneyData",
"illegalChildren": ["section"],
"requiredAncestors": ["dataprovider"],
"hasChildren": true,
"size": {
"width": 400,
@ -711,10 +710,12 @@
],
"context": [
{
"type": "schema"
"type": "schema",
"scope": "local"
},
{
"type": "static",
"scope": "local",
"values": [
{
"label": "Row index",
@ -1564,7 +1565,6 @@
"name": "Bar Chart",
"description": "Bar chart",
"icon": "GraphBarVertical",
"requiredAncestors": ["dataprovider"],
"size": {
"width": 600,
"height": 400
@ -1727,7 +1727,6 @@
"width": 600,
"height": 400
},
"requiredAncestors": ["dataprovider"],
"settings": [
{
"type": "text",
@ -1881,7 +1880,6 @@
"width": 600,
"height": 400
},
"requiredAncestors": ["dataprovider"],
"settings": [
{
"type": "text",
@ -2047,7 +2045,6 @@
"width": 600,
"height": 400
},
"requiredAncestors": ["dataprovider"],
"settings": [
{
"type": "text",
@ -2177,7 +2174,6 @@
"width": 600,
"height": 400
},
"requiredAncestors": ["dataprovider"],
"settings": [
{
"type": "text",
@ -2307,7 +2303,6 @@
"width": 600,
"height": 400
},
"requiredAncestors": ["dataprovider"],
"settings": [
{
"type": "text",
@ -4081,7 +4076,6 @@
"width": 400,
"height": 320
},
"requiredAncestors": ["dataprovider"],
"settings": [
{
"type": "dataProvider",
@ -4637,7 +4631,6 @@
"name": "Table",
"icon": "Table",
"illegalChildren": ["section"],
"requiredAncestors": ["dataprovider"],
"hasChildren": true,
"showEmptyState": false,
"size": {
@ -4728,7 +4721,6 @@
"name": "Date Range",
"icon": "Calendar",
"styles": ["size"],
"requiredAncestors": ["dataprovider"],
"hasChildren": false,
"size": {
"width": 200,
@ -4836,7 +4828,6 @@
"width": 100,
"height": 35
},
"requiredAncestors": ["dataprovider"],
"settings": [
{
"type": "dataProvider",
@ -5611,7 +5602,38 @@
}
]
}
],
"context": {
"type": "static",
"suffix": "provider",
"values": [
{
"label": "Rows",
"key": "rows",
"type": "array"
},
{
"label": "Extra Info",
"key": "info",
"type": "string"
},
{
"label": "Rows Length",
"key": "rowsLength",
"type": "number"
},
{
"label": "Schema",
"key": "schema",
"type": "object"
},
{
"label": "Page Number",
"key": "pageNumber",
"type": "number"
}
]
}
},
"cardsblock": {
"block": true,
@ -6014,7 +6036,8 @@
},
{
"type": "schema",
"suffix": "repeater"
"suffix": "repeater",
"scope": "local"
}
]
},
@ -6160,6 +6183,10 @@
"type": "form",
"suffix": "form"
},
{
"type": "schema",
"suffix": "repeater"
},
{
"type": "static",
"suffix": "form",
@ -6476,6 +6503,23 @@
"suffix": "repeater"
}
},
"grid": {
"name": "Grid",
"icon": "ViewGrid",
"hasChildren": true,
"settings": [
{
"type": "number",
"key": "cols",
"label": "Columns"
},
{
"type": "number",
"key": "rows",
"label": "Rows"
}
]
},
"gridblock": {
"name": "Grid Block",
"icon": "Table",

View File

@ -9,7 +9,7 @@
</script>
<script>
import { getContext, setContext, onMount, onDestroy } from "svelte"
import { getContext, setContext, onMount } from "svelte"
import { writable, get } from "svelte/store"
import {
enrichProps,
@ -30,6 +30,15 @@
import ScreenPlaceholder from "components/app/ScreenPlaceholder.svelte"
import ComponentErrorState from "components/error-states/ComponentErrorState.svelte"
import { BudibasePrefix } from "../stores/components.js"
import {
decodeJSBinding,
findHBSBlocks,
isJSBinding,
} from "@budibase/string-templates"
import {
getActionContextKey,
getActionDependentContextKeys,
} from "../utils/buttonActions.js"
export let instance = {}
export let isLayout = false
@ -81,7 +90,6 @@
// Keep track of stringified representations of context and instance
// to avoid enriching bindings as much as possible
let lastContextKey
let lastInstanceKey
// Visibility flag used by conditional UI
@ -98,6 +106,13 @@
// We clear these whenever a new instance is received.
let ephemeralStyles
// Single string of all HBS blocks, used to check if we use a certain binding
// or not
let bindingString = ""
// List of context keys which we use inside bindings
let knownContextKeyMap = {}
// Set up initial state for each new component instance
$: initialise(instance)
@ -155,9 +170,6 @@
hasMissingRequiredSettings)
$: emptyState = empty && showEmptyState
// Enrich component settings
$: enrichComponentSettings($context, settingsDefinitionMap)
// Evaluate conditional UI settings and store any component setting changes
// which need to be made
$: evaluateConditions(conditions)
@ -206,6 +218,7 @@
errorState,
parent: id,
ancestors: [...($component?.ancestors ?? []), instance._component],
path: [...($component?.path ?? []), id],
})
const initialise = (instance, force = false) => {
@ -214,7 +227,8 @@
}
// Ensure we're processing a new instance
const instanceKey = Helpers.hashString(JSON.stringify(instance))
const stringifiedInstance = JSON.stringify(instance)
const instanceKey = Helpers.hashString(stringifiedInstance)
if (instanceKey === lastInstanceKey && !force) {
return
} else {
@ -274,13 +288,54 @@
return missing
})
// When considering bindings we can ignore children, so we remove that
// before storing the reference stringified version
const noChildren = JSON.stringify({ ...instance, _children: null })
const bindings = findHBSBlocks(noChildren).map(binding => {
let sanitizedBinding = binding.replace(/\\"/g, '"')
if (isJSBinding(sanitizedBinding)) {
return decodeJSBinding(sanitizedBinding)
} else {
return sanitizedBinding
}
})
// The known context key map is built up at runtime, as changes to keys are
// encountered. We manually seed this to the required action keys as these
// are not encountered at runtime and so need computed in advance.
knownContextKeyMap = generateActionKeyMap(instance, settingsDefinition)
bindingString = bindings.join(" ")
// Run any migrations
runMigrations(instance, settingsDefinition)
// Force an initial enrichment of the new settings
enrichComponentSettings(get(context), settingsDefinitionMap, {
force: true,
enrichComponentSettings(get(context), settingsDefinitionMap)
}
// Extracts a map of all context keys which are required by action settings
// to provide the functions to evaluate at runtime. This needs done manually
// as the action definitions themselves do not specify bindings for action
// keys, meaning we cannot do this while doing the other normal bindings.
const generateActionKeyMap = (instance, settingsDefinition) => {
let map = {}
settingsDefinition.forEach(setting => {
if (setting.type === "event") {
instance[setting.key]?.forEach(action => {
// We depend on the actual action key
const actionKey = getActionContextKey(action)
if (actionKey) {
map[actionKey] = true
}
// We also depend on any manually declared context keys
getActionDependentContextKeys(action)?.forEach(key => {
map[key] = true
})
})
}
})
return map
}
const runMigrations = (instance, settingsDefinition) => {
@ -381,17 +436,7 @@
}
// Enriches any string component props using handlebars
const enrichComponentSettings = (
context,
settingsDefinitionMap,
options = { force: false }
) => {
const contextChanged = context.key !== lastContextKey
if (!contextChanged && !options?.force) {
return
}
lastContextKey = context.key
const enrichComponentSettings = (context, settingsDefinitionMap) => {
// Record the timestamp so we can reference it after enrichment
latestUpdateTime = Date.now()
const enrichmentTime = latestUpdateTime
@ -506,11 +551,26 @@
})
}
const handleContextChange = key => {
// Check if we already know if this key is used
let used = knownContextKeyMap[key]
// If we don't know, check and cache
if (used == null) {
used = bindingString.indexOf(`[${key}]`) !== -1
knownContextKeyMap[key] = used
}
// Enrich settings if we use this key
if (used) {
enrichComponentSettings($context, settingsDefinitionMap)
}
}
// Register an unregister component instance
onMount(() => {
if (
$appStore.isDevApp &&
!componentStore.actions.isComponentRegistered(id)
) {
if ($appStore.isDevApp) {
if (!componentStore.actions.isComponentRegistered(id)) {
componentStore.actions.registerInstance(id, {
component: instance._component,
getSettings: () => cachedSettings,
@ -521,16 +581,16 @@
state: store,
})
}
})
onDestroy(() => {
if (
$appStore.isDevApp &&
componentStore.actions.isComponentRegistered(id)
) {
return () => {
if (componentStore.actions.isComponentRegistered(id)) {
componentStore.actions.unregisterInstance(id)
}
}
}
})
// Observe changes to context
onMount(() => context.actions.observeChanges(handleContextChange))
</script>
{#if constructor && initialSettings && (visible || inSelectedPath) && !builderHidden}

View File

@ -71,7 +71,7 @@
datasource: dataSource || {},
schema,
rowsLength: $fetch.rows.length,
pageNumber: $fetch.pageNumber + 1,
// Undocumented properties. These aren't supposed to be used in builder
// bindings, but are used internally by other components
id: $component?.id,

View File

@ -1,6 +1,5 @@
<script>
import { getContext } from "svelte"
import { Icon } from "@budibase/bbui"
const component = getContext("component")
const { builderStore, componentStore } = getContext("sdk")
@ -10,15 +9,7 @@
{#if $builderStore.inBuilder}
<div class="component-placeholder">
<Icon name="Help" color="var(--spectrum-global-color-blue-600)" />
<span
class="spectrum-Link"
on:click={() => {
builderStore.actions.requestAddComponent()
}}
>
Add components inside your {definition?.name || $component.type}
</span>
{$component.name || definition?.name || "Component"}
</div>
{/if}
@ -32,14 +23,4 @@
font-size: var(--font-size-s);
gap: var(--spacing-s);
}
/* Common styles for all error states to use */
.component-placeholder :global(mark) {
background-color: var(--spectrum-global-color-gray-400);
padding: 0 4px;
border-radius: 2px;
}
.component-placeholder :global(.spectrum-Link) {
cursor: pointer;
}
</style>

View File

@ -19,7 +19,36 @@
export let onRowClick = null
export let buttons = null
// parses columns to fix older formats
const context = getContext("context")
const component = getContext("component")
const {
styleable,
API,
builderStore,
notificationStore,
enrichButtonActions,
ActionTypes,
createContextStore,
Provider,
} = getContext("sdk")
let grid
$: columnWhitelist = parsedColumns
?.filter(col => col.active)
?.map(col => col.field)
$: schemaOverrides = getSchemaOverrides(parsedColumns)
$: enrichedButtons = enrichButtons(buttons)
$: parsedColumns = getParsedColumns(columns)
$: actions = [
{
type: ActionTypes.RefreshDatasource,
callback: () => grid?.getContext()?.rows.actions.refreshData(),
metadata: { dataSource: table },
},
]
// Parses columns to fix older formats
const getParsedColumns = columns => {
// If the first element has an active key all elements should be in the new format
if (columns?.length && columns[0]?.active !== undefined) {
@ -33,28 +62,6 @@
}))
}
$: parsedColumns = getParsedColumns(columns)
const context = getContext("context")
const component = getContext("component")
const {
styleable,
API,
builderStore,
notificationStore,
enrichButtonActions,
ActionTypes,
createContextStore,
} = getContext("sdk")
let grid
$: columnWhitelist = parsedColumns
?.filter(col => col.active)
?.map(col => col.field)
$: schemaOverrides = getSchemaOverrides(parsedColumns)
$: enrichedButtons = enrichButtons(buttons)
const getSchemaOverrides = columns => {
let overrides = {}
columns?.forEach(column => {
@ -78,11 +85,6 @@
const id = get(component).id
const gridContext = createContextStore(context)
gridContext.actions.provideData(id, row)
gridContext.actions.provideAction(
id,
ActionTypes.RefreshDatasource,
() => grid?.getContext()?.rows.actions.refreshData()
)
const fn = enrichButtonActions(settings.onClick, get(gridContext))
return await fn?.({ row })
},
@ -94,6 +96,7 @@
use:styleable={$component.styles}
class:in-builder={$builderStore.inBuilder}
>
<Provider {actions}>
<Grid
bind:this={grid}
datasource={table}
@ -117,6 +120,7 @@
buttons={enrichedButtons}
on:rowclick={e => onRowClick?.({ row: e.detail })}
/>
</Provider>
</div>
<style>

View File

@ -2,6 +2,7 @@
import { getContext } from "svelte"
import Placeholder from "./Placeholder.svelte"
import Container from "./Container.svelte"
import { ContextScopes } from "constants"
export let dataProvider
export let noRowsMessage
@ -9,6 +10,7 @@
export let hAlign
export let vAlign
export let gap
export let scope = ContextScopes.Local
const { Provider } = getContext("sdk")
const component = getContext("component")
@ -22,7 +24,7 @@
<Placeholder />
{:else if rows.length > 0}
{#each rows as row, index}
<Provider data={{ ...row, index }}>
<Provider data={{ ...row, index }} {scope}>
<slot />
</Provider>
{/each}

View File

@ -1,5 +1,6 @@
<script>
import BlockComponent from "components/BlockComponent.svelte"
import { Helpers } from "@budibase/bbui"
import { getContext, setContext } from "svelte"
import { builderStore } from "stores"
import { Utils } from "@budibase/frontend-core"
@ -41,7 +42,7 @@
let schema
$: fetchSchema(dataSource)
$: enrichedSteps = enrichSteps(steps, schema, $component.id)
$: enrichedSteps = enrichSteps(steps, schema, $component.id, $currentStep)
$: updateCurrentStep(enrichedSteps, $builderStore, $component)
const updateCurrentStep = (steps, builderStore, component) => {
@ -115,6 +116,7 @@
dataSource,
})
return {
_stepId: Helpers.uuid(),
fields: getDefaultFields(fields || [], schema),
title: title ?? defaultProps.title,
desc,
@ -142,7 +144,7 @@
},
}}
>
{#each enrichedSteps as step, stepIdx}
{#each enrichedSteps as step, stepIdx (step._stepId)}
<BlockComponent
type="formstep"
props={{ step: stepIdx + 1, _instanceName: `Step ${stepIdx + 1}` }}
@ -186,12 +188,13 @@
</BlockComponent>
</BlockComponent>
<BlockComponent type="text" props={{ text: step.desc }} order={1} />
<BlockComponent type="container" order={2}>
<div
class="form-block fields"
class:mobile={$context.device.mobile}
>
{#each step.fields as field, fieldIdx (`${field.field || field.name}_${stepIdx}_${fieldIdx}`)}
{#each step.fields as field, fieldIdx (`${field.field || field.name}_${fieldIdx}`)}
{#if getComponentForField(field)}
<BlockComponent
type={getComponentForField(field)}

View File

@ -231,6 +231,7 @@
paginate,
limit: rowCount,
}}
context="provider"
order={1}
>
<BlockComponent

View File

@ -10,6 +10,7 @@
export let noRowsMessage
const component = getContext("component")
const { ContextScopes } = getContext("sdk")
$: providerId = `${$component.id}-provider`
$: dataProvider = `{{ literal ${safe(providerId)} }}`
@ -55,6 +56,7 @@
noRowsMessage: noRowsMessage || "We couldn't find a row to display",
direction: "column",
hAlign: "center",
scope: ContextScopes.Global,
}}
>
<slot />

View File

@ -21,6 +21,7 @@
export let editAutoColumns = false
const context = getContext("context")
const component = getContext("component")
const { API, fetchDatasourceSchema } = getContext("sdk")
const getInitialFormStep = () => {
@ -38,28 +39,47 @@
$: fetchSchema(dataSource)
$: schemaKey = generateSchemaKey(schema)
$: initialValues = getInitialValues(actionType, dataSource, $context)
$: initialValues = getInitialValues(
actionType,
dataSource,
$component.path,
$context
)
$: resetKey = Helpers.hashString(
schemaKey + JSON.stringify(initialValues) + disabled + readonly
)
// Returns the closes data context which isn't a built in context
const getInitialValues = (type, dataSource, context) => {
const getInitialValues = (type, dataSource, path, context) => {
// Only inherit values for update forms
if (type !== "Update") {
return {}
}
// Only inherit values for forms targeting internal tables
if (!dataSource?.tableId) {
const dsType = dataSource?.type
if (dsType !== "table" && dsType !== "viewV2") {
return {}
}
// Don't inherit values representing built in contexts
if (["user", "url"].includes(context.closestComponentId)) {
return {}
// Look up the component tree and find something that is provided by an
// ancestor that matches our datasource. This is for backwards compatibility
// as previously we could use the "closest" context.
for (let id of path.reverse().slice(1)) {
// Check for matching view datasource
if (
dataSource.type === "viewV2" &&
context[id]?._viewId === dataSource.id
) {
return context[id]
}
// Always inherit the closest datasource
const closestContext = context[`${context.closestComponentId}`] || {}
return closestContext || {}
// Check for matching table datasource
if (
dataSource.type === "table" &&
context[id]?.tableId === dataSource.tableId
) {
return context[id]
}
}
return {}
}
// Fetches the form schema from this form's dataSource

View File

@ -1,21 +1,24 @@
<script>
import { getContext, setContext, onDestroy } from "svelte"
import { dataSourceStore, createContextStore } from "stores"
import { ActionTypes } from "constants"
import { ActionTypes, ContextScopes } from "constants"
import { generate } from "shortid"
export let data
export let actions
export let key
export let scope = ContextScopes.Global
// Clone and create new data context for this component tree
const context = getContext("context")
let context = getContext("context")
const component = getContext("component")
const newContext = createContextStore(context)
setContext("context", newContext)
const providerKey = key || $component.id
// Create a new layer of context if we are only locally scoped
if (scope === ContextScopes.Local) {
context = createContextStore(context)
setContext("context", context)
}
// Generate a permanent unique ID for this component and use it to register
// any datasource actions
const instanceId = generate()
@ -30,7 +33,7 @@
const provideData = newData => {
const dataKey = JSON.stringify(newData)
if (dataKey !== lastDataKey) {
newContext.actions.provideData(providerKey, newData)
context.actions.provideData(providerKey, newData, scope)
lastDataKey = dataKey
}
}
@ -40,7 +43,7 @@
if (actionsKey !== lastActionsKey) {
lastActionsKey = actionsKey
newActions?.forEach(({ type, callback, metadata }) => {
newContext.actions.provideAction(providerKey, type, callback)
context.actions.provideAction(providerKey, type, callback, scope)
// Register any "refresh datasource" actions with a singleton store
// so we can easily refresh data at all levels for any datasource

View File

@ -12,5 +12,10 @@ export const ActionTypes = {
ScrollTo: "ScrollTo",
}
export const ContextScopes = {
Local: "local",
Global: "global",
}
export const DNDPlaceholderID = "dnd-placeholder"
export const ScreenslotType = "screenslot"

View File

@ -23,7 +23,7 @@ import { getAction } from "utils/getAction"
import Provider from "components/context/Provider.svelte"
import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte"
import { ActionTypes } from "./constants"
import { ActionTypes, ContextScopes } from "./constants"
import { fetchDatasourceSchema } from "./utils/schema.js"
import { getAPIKey } from "./utils/api.js"
import { enrichButtonActions } from "./utils/buttonActions.js"
@ -54,6 +54,7 @@ export default {
linkable,
getAction,
fetchDatasourceSchema,
ContextScopes,
getAPIKey,
enrichButtonActions,
processStringSync,

View File

@ -1,59 +1,98 @@
import { writable, derived } from "svelte/store"
import { Helpers } from "@budibase/bbui"
import { ContextScopes } from "constants"
export const createContextStore = oldContext => {
const newContext = writable({})
const contexts = oldContext ? [oldContext, newContext] : [newContext]
export const createContextStore = parentContext => {
const context = writable({})
let observers = []
// Derive the total context state at this point in the tree
const contexts = parentContext ? [parentContext, context] : [context]
const totalContext = derived(contexts, $contexts => {
// The key is the serialized representation of context
let key = ""
for (let i = 0; i < $contexts.length - 1; i++) {
key += $contexts[i].key
}
key = Helpers.hashString(
key + JSON.stringify($contexts[$contexts.length - 1])
)
// Reduce global state
const reducer = (total, context) => ({ ...total, ...context })
const context = $contexts.reduce(reducer, {})
return {
...context,
key,
}
return $contexts.reduce((total, context) => ({ ...total, ...context }), {})
})
// Adds a data context layer to the tree
const provideData = (providerId, data) => {
// Subscribe to updates in the parent context, so that we can proxy on any
// change messages to our own subscribers
if (parentContext) {
parentContext.actions.observeChanges(key => {
broadcastChange(key)
})
}
// Provide some data in context
const provideData = (providerId, data, scope = ContextScopes.Global) => {
if (!providerId || data === undefined) {
return
}
newContext.update(state => {
state[providerId] = data
// Keep track of the closest component ID so we can later hydrate a "data" prop.
// This is only required for legacy bindings that used "data" rather than a
// component ID.
state.closestComponentId = providerId
return state
})
// Proxy message up the chain if we have a parent and are providing global
// context
if (scope === ContextScopes.Global && parentContext) {
parentContext.actions.provideData(providerId, data, scope)
}
// Adds an action context layer to the tree
const provideAction = (providerId, actionType, callback) => {
// Otherwise this is either the context root, or we're providing a local
// context override, so we need to update the local context instead
else {
context.update(state => {
state[providerId] = data
return state
})
broadcastChange(providerId)
}
}
// Provides some action in context
const provideAction = (
providerId,
actionType,
callback,
scope = ContextScopes.Global
) => {
if (!providerId || !actionType) {
return
}
newContext.update(state => {
state[`${providerId}_${actionType}`] = callback
// Proxy message up the chain if we have a parent and are providing global
// context
if (scope === ContextScopes.Global && parentContext) {
parentContext.actions.provideAction(
providerId,
actionType,
callback,
scope
)
}
// Otherwise this is either the context root, or we're providing a local
// context override, so we need to update the local context instead
else {
const key = `${providerId}_${actionType}`
context.update(state => {
state[key] = callback
return state
})
broadcastChange(key)
}
}
const observeChanges = callback => {
observers.push(callback)
return () => {
observers = observers.filter(cb => cb !== callback)
}
}
const broadcastChange = key => {
observers.forEach(cb => cb(key))
}
return {
subscribe: totalContext.subscribe,
actions: { provideData, provideAction },
actions: {
provideData,
provideAction,
observeChanges,
},
}
}

View File

@ -17,6 +17,54 @@ import { ActionTypes } from "constants"
import { enrichDataBindings } from "./enrichDataBinding"
import { Helpers } from "@budibase/bbui"
// Default action handler, which extracts an action from context that was
// provided by another component and executes it with all action parameters
const contextActionHandler = async (action, context) => {
const key = getActionContextKey(action)
const fn = context[key]
if (fn) {
return await fn(action.parameters)
}
}
// Generates the context key, which is the key that this action depends on in
// context to provide the function it will run. This is broken out as a util
// because we reuse this inside the core Component.svelte file to determine
// what the required action context keys are for all action settings.
export const getActionContextKey = action => {
const type = action?.["##eventHandlerType"]
const key = (componentId, type) => `${componentId}_${type}`
switch (type) {
case "Scroll To Field":
return key(action.parameters.componentId, ActionTypes.ScrollTo)
case "Update Field Value":
return key(action.parameters.componentId, ActionTypes.UpdateFieldValue)
case "Validate Form":
return key(action.parameters.componentId, ActionTypes.ValidateForm)
case "Refresh Data Provider":
return key(action.parameters.componentId, ActionTypes.RefreshDatasource)
case "Clear Form":
return key(action.parameters.componentId, ActionTypes.ClearForm)
case "Change Form Step":
return key(action.parameters.componentId, ActionTypes.ChangeFormStep)
default:
return null
}
}
// If button actions depend on context, they must declare which keys they need
export const getActionDependentContextKeys = action => {
const type = action?.["##eventHandlerType"]
switch (type) {
case "Save Row":
case "Duplicate Row":
if (action.parameters?.providerId) {
return [action.parameters.providerId]
}
}
return []
}
const saveRowHandler = async (action, context) => {
const { fields, providerId, tableId, notificationOverride } =
action.parameters
@ -32,20 +80,21 @@ const saveRowHandler = async (action, context) => {
}
}
if (tableId) {
if (tableId.startsWith("view")) {
payload._viewId = tableId
} else {
payload.tableId = tableId
}
}
try {
const row = await API.saveRow(payload)
if (!notificationOverride) {
notificationStore.actions.success("Row saved")
}
// Refresh related datasources
await dataSourceStore.actions.invalidateDataSource(tableId, {
invalidateRelationships: true,
})
return { row }
} catch (error) {
// Abort next actions
@ -64,8 +113,12 @@ const duplicateRowHandler = async (action, context) => {
}
}
if (tableId) {
if (tableId.startsWith("view")) {
payload._viewId = tableId
} else {
payload.tableId = tableId
}
}
delete payload._id
delete payload._rev
try {
@ -73,12 +126,10 @@ const duplicateRowHandler = async (action, context) => {
if (!notificationOverride) {
notificationStore.actions.success("Row saved")
}
// Refresh related datasources
await dataSourceStore.actions.invalidateDataSource(tableId, {
invalidateRelationships: true,
})
return { row }
} catch (error) {
// Abort next actions
@ -190,17 +241,6 @@ const navigationHandler = action => {
routeStore.actions.navigate(url, peek, externalNewTab)
}
const scrollHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.ScrollTo,
{
field: action.parameters.field,
}
)
}
const queryExecutionHandler = async action => {
const { datasourceId, queryId, queryParams, notificationOverride } =
action.parameters
@ -236,47 +276,6 @@ const queryExecutionHandler = async action => {
}
}
const executeActionHandler = async (
context,
componentId,
actionType,
params
) => {
const fn = context[`${componentId}_${actionType}`]
if (fn) {
return await fn(params)
}
}
const updateFieldValueHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.UpdateFieldValue,
{
type: action.parameters.type,
field: action.parameters.field,
value: action.parameters.value,
}
)
}
const validateFormHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.ValidateForm
)
}
const refreshDataProviderHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.RefreshDatasource
)
}
const logoutHandler = async action => {
await authStore.actions.logOut()
let redirectUrl = "/builder/auth/login"
@ -293,23 +292,6 @@ const logoutHandler = async action => {
}
}
const clearFormHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.ClearForm
)
}
const changeFormStepHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.ChangeFormStep,
action.parameters
)
}
const closeScreenModalHandler = action => {
let url
if (action?.parameters) {
@ -417,16 +399,10 @@ const handlerMap = {
["Duplicate Row"]: duplicateRowHandler,
["Delete Row"]: deleteRowHandler,
["Navigate To"]: navigationHandler,
["Scroll To Field"]: scrollHandler,
["Execute Query"]: queryExecutionHandler,
["Trigger Automation"]: triggerAutomationHandler,
["Validate Form"]: validateFormHandler,
["Update Field Value"]: updateFieldValueHandler,
["Refresh Data Provider"]: refreshDataProviderHandler,
["Log Out"]: logoutHandler,
["Clear Form"]: clearFormHandler,
["Close Screen Modal"]: closeScreenModalHandler,
["Change Form Step"]: changeFormStepHandler,
["Update State"]: updateStateHandler,
["Upload File to S3"]: s3UploadHandler,
["Export Data"]: exportDataHandler,
@ -461,7 +437,12 @@ export const enrichButtonActions = (actions, context) => {
return actions
}
const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
// Get handlers for each action. If no bespoke handler is configured, fall
// back to simply executing this action from context.
const handlers = actions.map(def => {
return handlerMap[def["##eventHandlerType"]] || contextActionHandler
})
return async eventContext => {
// Button context is built up as actions are executed.
// Inherit any previous button context which may have come from actions

View File

@ -23,16 +23,6 @@ export const propsAreSame = (a, b) => {
* Data bindings are enriched, and button actions are enriched.
*/
export const enrichProps = (props, context, settingsDefinitionMap) => {
// Create context of all bindings and data contexts
// Duplicate the closest context as "data" which the builder requires
const totalContext = {
...context,
// This is only required for legacy bindings that used "data" rather than a
// component ID.
data: context[context.closestComponentId],
}
// We want to exclude any button actions from enrichment at this stage.
// Extract top level button action settings.
let normalProps = { ...props }
@ -49,13 +39,13 @@ export const enrichProps = (props, context, settingsDefinitionMap) => {
let rawConditions = normalProps._conditions
// Enrich all props except button actions
let enrichedProps = enrichDataBindings(normalProps, totalContext)
let enrichedProps = enrichDataBindings(normalProps, context)
// Enrich button actions.
// Actions are enriched into a function at this stage, but actual data
// binding enrichment is done dynamically at runtime.
Object.keys(actionProps).forEach(prop => {
enrichedProps[prop] = enrichButtonActions(actionProps[prop], totalContext)
enrichedProps[prop] = enrichButtonActions(actionProps[prop], context)
})
// Conditions
@ -66,7 +56,7 @@ export const enrichProps = (props, context, settingsDefinitionMap) => {
// action
condition.settingValue = enrichButtonActions(
rawConditions[idx].settingValue,
totalContext
context
)
// Since we can't compare functions, we need to assume that conditions

View File

@ -19,11 +19,12 @@ export const buildRowEndpoints = API => ({
* @param suppressErrors whether or not to suppress error notifications
*/
saveRow: async (row, suppressErrors = false) => {
if (!row?.tableId) {
const resourceId = row?._viewId || row?.tableId
if (!resourceId) {
return
}
return await API.post({
url: `/api/${row._viewId || row.tableId}/rows`,
url: `/api/${resourceId}/rows`,
body: row,
suppressErrors,
})
@ -35,11 +36,12 @@ export const buildRowEndpoints = API => ({
* @param suppressErrors whether or not to suppress error notifications
*/
patchRow: async (row, suppressErrors = false) => {
if (!row?.tableId && !row?._viewId) {
const resourceId = row?._viewId || row?.tableId
if (!resourceId) {
return
}
return await API.patch({
url: `/api/${row._viewId || row.tableId}/rows`,
url: `/api/${resourceId}/rows`,
body: row,
suppressErrors,
})