Merge pull request #3776 from Budibase/feature/json-backend
JSON data type, button action context, duplication action + more
This commit is contained in:
commit
2611dd04e8
|
@ -21,16 +21,8 @@
|
|||
}
|
||||
visible = false
|
||||
}
|
||||
|
||||
function handleKey(e) {
|
||||
if (visible && e.key === "Escape") {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKey} />
|
||||
|
||||
{#if visible}
|
||||
<Portal>
|
||||
<section class:fillWidth class="drawer" transition:slide>
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import CellRenderer from "./CellRenderer.svelte"
|
||||
import SelectEditRenderer from "./SelectEditRenderer.svelte"
|
||||
import { cloneDeep } from "lodash"
|
||||
import { deepGet } from "../utils/helpers"
|
||||
|
||||
/**
|
||||
* The expected schema is our normal couch schemas for our tables.
|
||||
|
@ -318,7 +319,7 @@
|
|||
{customRenderers}
|
||||
{row}
|
||||
schema={schema[field]}
|
||||
value={row[field]}
|
||||
value={deepGet(row, field)}
|
||||
on:clickrelationship
|
||||
>
|
||||
<slot />
|
||||
|
|
|
@ -77,3 +77,6 @@ export { default as clickOutside } from "./Actions/click_outside"
|
|||
|
||||
// Stores
|
||||
export { notifications, createNotificationStore } from "./Stores/notifications"
|
||||
|
||||
// Utils
|
||||
export * from "./utils/helpers"
|
||||
|
|
|
@ -6,3 +6,61 @@ export const generateID = () => {
|
|||
}
|
||||
|
||||
export const capitalise = s => s.substring(0, 1).toUpperCase() + s.substring(1)
|
||||
|
||||
/**
|
||||
* Gets a key within an object. The key supports dot syntax for retrieving deep
|
||||
* fields - e.g. "a.b.c".
|
||||
* Exact matches of keys with dots in them take precedence over nested keys of
|
||||
* the same path - e.g. getting "a.b" from { "a.b": "foo", a: { b: "bar" } }
|
||||
* will return "foo" over "bar".
|
||||
* @param obj the object
|
||||
* @param key the key
|
||||
* @return {*|null} the value or null if a value was not found for this key
|
||||
*/
|
||||
export const deepGet = (obj, key) => {
|
||||
if (!obj || !key) {
|
||||
return null
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
return obj[key]
|
||||
}
|
||||
const split = key.split(".")
|
||||
for (let i = 0; i < split.length; i++) {
|
||||
obj = obj?.[split[i]]
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a key within an object. The key supports dot syntax for retrieving deep
|
||||
* fields - e.g. "a.b.c".
|
||||
* Exact matches of keys with dots in them take precedence over nested keys of
|
||||
* the same path - e.g. setting "a.b" of { "a.b": "foo", a: { b: "bar" } }
|
||||
* will override the value "foo" rather than "bar".
|
||||
* If a deep path is specified and the parent keys don't exist then these will
|
||||
* be created.
|
||||
* @param obj the object
|
||||
* @param key the key
|
||||
* @param value the value
|
||||
*/
|
||||
export const deepSet = (obj, key, value) => {
|
||||
if (!obj || !key) {
|
||||
return
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
obj[key] = value
|
||||
return
|
||||
}
|
||||
const split = key.split(".")
|
||||
for (let i = 0; i < split.length - 1; i++) {
|
||||
const nextKey = split[i]
|
||||
if (obj && obj[nextKey] == null) {
|
||||
obj[nextKey] = {}
|
||||
}
|
||||
obj = obj?.[nextKey]
|
||||
}
|
||||
if (!obj) {
|
||||
return
|
||||
}
|
||||
obj[split[split.length - 1]] = value
|
||||
}
|
||||
|
|
|
@ -127,18 +127,37 @@ const searchComponentTree = (rootComponent, matchComponent) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Searches a component's definition for a setting matching a certin predicate.
|
||||
* Searches a component's definition for a setting matching a certain predicate.
|
||||
* These settings are cached because they cannot change at run time.
|
||||
*/
|
||||
let componentSettingCache = {}
|
||||
export const getComponentSettings = componentType => {
|
||||
const def = store.actions.components.getDefinition(componentType)
|
||||
if (!def) {
|
||||
if (!componentType) {
|
||||
return []
|
||||
}
|
||||
let settings = def.settings?.filter(setting => !setting.section) ?? []
|
||||
def.settings
|
||||
?.filter(setting => setting.section)
|
||||
.forEach(section => {
|
||||
settings = settings.concat(section.settings || [])
|
||||
})
|
||||
|
||||
// Ensure whole component name is used
|
||||
if (!componentType.startsWith("@budibase")) {
|
||||
componentType = `@budibase/standard-components/${componentType}`
|
||||
}
|
||||
|
||||
// Check if we have cached this type already
|
||||
if (componentSettingCache[componentType]) {
|
||||
return componentSettingCache[componentType]
|
||||
}
|
||||
|
||||
// Otherwise get the settings and cache them
|
||||
const def = store.actions.components.getDefinition(componentType)
|
||||
let settings = []
|
||||
if (def) {
|
||||
settings = def.settings?.filter(setting => !setting.section) ?? []
|
||||
def.settings
|
||||
?.filter(setting => setting.section)
|
||||
.forEach(section => {
|
||||
settings = settings.concat(section.settings || [])
|
||||
})
|
||||
}
|
||||
componentSettingCache[componentType] = settings
|
||||
|
||||
return settings
|
||||
}
|
|
@ -5,7 +5,7 @@ import {
|
|||
findComponent,
|
||||
findComponentPath,
|
||||
getComponentSettings,
|
||||
} from "./storeUtils"
|
||||
} from "./componentUtils"
|
||||
import { store } from "builderStore"
|
||||
import { queries as queriesStores, tables as tablesStore } from "stores/backend"
|
||||
import {
|
||||
|
@ -15,6 +15,11 @@ import {
|
|||
encodeJSBinding,
|
||||
} from "@budibase/string-templates"
|
||||
import { TableNames } from "../constants"
|
||||
import {
|
||||
convertJSONSchemaToTableSchema,
|
||||
getJSONArrayDatasourceSchema,
|
||||
} from "./jsonUtils"
|
||||
import ActionDefinitions from "components/design/PropertiesPanel/PropertyControls/ButtonActionEditor/manifest.json"
|
||||
|
||||
// Regex to match all instances of template strings
|
||||
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
|
||||
|
@ -186,6 +191,7 @@ const getProviderContextBindings = (asset, dataProviders) => {
|
|||
}
|
||||
|
||||
let schema
|
||||
let table
|
||||
let readablePrefix
|
||||
let runtimeSuffix = context.suffix
|
||||
|
||||
|
@ -209,7 +215,16 @@ const getProviderContextBindings = (asset, dataProviders) => {
|
|||
}
|
||||
const info = getSchemaForDatasource(asset, datasource)
|
||||
schema = info.schema
|
||||
readablePrefix = info.table?.name
|
||||
table = info.table
|
||||
|
||||
// For JSON arrays, use the array name as the readable prefix.
|
||||
// Otherwise use the table name
|
||||
if (datasource.type === "jsonarray") {
|
||||
const split = datasource.label.split(".")
|
||||
readablePrefix = split[split.length - 1]
|
||||
} else {
|
||||
readablePrefix = info.table?.name
|
||||
}
|
||||
}
|
||||
if (!schema) {
|
||||
return
|
||||
|
@ -229,7 +244,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
|
|||
const fieldSchema = schema[key]
|
||||
|
||||
// Make safe runtime binding
|
||||
const runtimeBinding = `${safeComponentId}.${makePropSafe(key)}`
|
||||
const safeKey = key.split(".").map(makePropSafe).join(".")
|
||||
const runtimeBinding = `${safeComponentId}.${safeKey}`
|
||||
|
||||
// Optionally use a prefix with readable bindings
|
||||
let readableBinding = component._instanceName
|
||||
|
@ -247,6 +263,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
|
|||
// 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -339,6 +357,36 @@ const getUrlBindings = asset => {
|
|||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all bindable properties exposed in a button actions flow up until
|
||||
* the specified action ID.
|
||||
*/
|
||||
export const getButtonContextBindings = (actions, actionId) => {
|
||||
// Get the steps leading up to this value
|
||||
const index = actions?.findIndex(action => action.id === actionId)
|
||||
if (index == null || index === -1) {
|
||||
return []
|
||||
}
|
||||
const prevActions = actions.slice(0, index)
|
||||
|
||||
// Generate bindings for any steps which provide context
|
||||
let bindings = []
|
||||
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}`,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
return bindings
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a schema for a datasource object.
|
||||
*/
|
||||
|
@ -347,16 +395,26 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
|
|||
|
||||
if (datasource) {
|
||||
const { type } = datasource
|
||||
const tables = get(tablesStore).list
|
||||
|
||||
// Determine the source table from the datasource type
|
||||
// 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, isForm)
|
||||
} else if (type === "query") {
|
||||
}
|
||||
|
||||
// "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)
|
||||
} else if (type === "field") {
|
||||
}
|
||||
|
||||
// "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") {
|
||||
|
@ -375,12 +433,22 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
|
|||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const tables = get(tablesStore).list
|
||||
}
|
||||
|
||||
// "jsonarray" datasources are arrays inside JSON fields
|
||||
else if (type === "jsonarray") {
|
||||
table = tables.find(table => table._id === datasource.tableId)
|
||||
let tableSchema = table?.schema
|
||||
schema = getJSONArrayDatasourceSchema(tableSchema, 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 table if not already determined
|
||||
// Determine the schema from the backing entity if not already determined
|
||||
if (table && !schema) {
|
||||
if (type === "view") {
|
||||
schema = cloneDeep(table.views?.[datasource.name]?.schema)
|
||||
|
@ -397,6 +465,26 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 = convertJSONSchemaToTableSchema(fieldSchema, {
|
||||
squashObjects: true,
|
||||
})
|
||||
Object.keys(jsonSchema).forEach(jsonKey => {
|
||||
jsonAdditions[`${fieldKey}.${jsonKey}`] = {
|
||||
type: jsonSchema[jsonKey].type,
|
||||
nestedJSON: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
schema = { ...schema, ...jsonAdditions }
|
||||
}
|
||||
|
||||
// Add _id and _rev fields for certain types
|
||||
if (schema && !isForm && ["table", "link"].includes(datasource.type)) {
|
||||
schema["_id"] = { type: "string" }
|
||||
|
@ -450,15 +538,58 @@ const buildFormSchema = component => {
|
|||
return schema
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of the keys of any state variables which are set anywhere
|
||||
* in the app.
|
||||
*/
|
||||
export const getAllStateVariables = () => {
|
||||
// Get all component containing assets
|
||||
let allAssets = []
|
||||
allAssets = allAssets.concat(get(store).layouts || [])
|
||||
allAssets = allAssets.concat(get(store).screens || [])
|
||||
|
||||
// Find all button action settings in all components
|
||||
let eventSettings = []
|
||||
allAssets.forEach(asset => {
|
||||
findAllMatchingComponents(asset.props, component => {
|
||||
const settings = getComponentSettings(component._component)
|
||||
settings
|
||||
.filter(setting => setting.type === "event")
|
||||
.forEach(setting => {
|
||||
eventSettings.push(component[setting.key])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recurses the input object to remove any instances of bindings.
|
||||
*/
|
||||
export function removeBindings(obj) {
|
||||
export const removeBindings = (obj, replacement = "Invalid binding") => {
|
||||
for (let [key, value] of Object.entries(obj)) {
|
||||
if (value && typeof value === "object") {
|
||||
obj[key] = removeBindings(value)
|
||||
obj[key] = removeBindings(value, replacement)
|
||||
} else if (typeof value === "string") {
|
||||
obj[key] = value.replace(CAPTURE_HBS_TEMPLATE, "Invalid binding")
|
||||
obj[key] = value.replace(CAPTURE_HBS_TEMPLATE, replacement)
|
||||
}
|
||||
}
|
||||
return obj
|
||||
|
@ -468,8 +599,8 @@ export function removeBindings(obj) {
|
|||
* When converting from readable to runtime it can sometimes add too many square brackets,
|
||||
* this makes sure that doesn't happen.
|
||||
*/
|
||||
function shouldReplaceBinding(currentValue, from, convertTo) {
|
||||
if (!currentValue?.includes(from)) {
|
||||
const shouldReplaceBinding = (currentValue, convertFrom, convertTo) => {
|
||||
if (!currentValue?.includes(convertFrom)) {
|
||||
return false
|
||||
}
|
||||
if (convertTo === "readableBinding") {
|
||||
|
@ -478,7 +609,7 @@ function shouldReplaceBinding(currentValue, from, convertTo) {
|
|||
// 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 fromNoSpaces = convertFrom.replace(/\s+/g, "")
|
||||
const invalids = [
|
||||
`[${fromNoSpaces}]`,
|
||||
`"${fromNoSpaces}"`,
|
||||
|
@ -487,14 +618,21 @@ function shouldReplaceBinding(currentValue, from, convertTo) {
|
|||
return !invalids.find(invalid => noSpaces?.includes(invalid))
|
||||
}
|
||||
|
||||
function replaceBetween(string, start, end, replacement) {
|
||||
/**
|
||||
* 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.
|
||||
* Utility function for the readableToRuntimeBinding and runtimeToReadableBinding.
|
||||
*/
|
||||
function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
|
||||
const bindingReplacement = (
|
||||
bindableProperties,
|
||||
textWithBindings,
|
||||
convertTo
|
||||
) => {
|
||||
// Decide from base64 if using JS
|
||||
const isJS = isJSBinding(textWithBindings)
|
||||
if (isJS) {
|
||||
|
@ -559,14 +697,17 @@ function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
|
|||
* Extracts a component ID from a handlebars expression setting of
|
||||
* {{ literal [componentId] }}
|
||||
*/
|
||||
function extractLiteralHandlebarsID(value) {
|
||||
const extractLiteralHandlebarsID = value => {
|
||||
return value?.match(/{{\s*literal\s*\[+([^\]]+)].*}}/)?.[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a readable data binding into a runtime data binding
|
||||
*/
|
||||
export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
|
||||
export const readableToRuntimeBinding = (
|
||||
bindableProperties,
|
||||
textWithBindings
|
||||
) => {
|
||||
return bindingReplacement(
|
||||
bindableProperties,
|
||||
textWithBindings,
|
||||
|
@ -577,56 +718,13 @@ export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
|
|||
/**
|
||||
* Converts a runtime data binding into a readable data binding
|
||||
*/
|
||||
export function runtimeToReadableBinding(bindableProperties, textWithBindings) {
|
||||
export const runtimeToReadableBinding = (
|
||||
bindableProperties,
|
||||
textWithBindings
|
||||
) => {
|
||||
return bindingReplacement(
|
||||
bindableProperties,
|
||||
textWithBindings,
|
||||
"readableBinding"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of the keys of any state variables which are set anywhere
|
||||
* in the app.
|
||||
*/
|
||||
export const getAllStateVariables = () => {
|
||||
let allComponents = []
|
||||
|
||||
// Find all onClick settings in all layouts
|
||||
get(store).layouts.forEach(layout => {
|
||||
const components = findAllMatchingComponents(
|
||||
layout.props,
|
||||
c => c.onClick != null
|
||||
)
|
||||
allComponents = allComponents.concat(components || [])
|
||||
})
|
||||
|
||||
// Find all onClick settings in all screens
|
||||
get(store).screens.forEach(screen => {
|
||||
const components = findAllMatchingComponents(
|
||||
screen.props,
|
||||
c => c.onClick != null
|
||||
)
|
||||
allComponents = allComponents.concat(components || [])
|
||||
})
|
||||
|
||||
// Add state bindings for all state actions
|
||||
let bindingSet = new Set()
|
||||
allComponents.forEach(component => {
|
||||
if (!Array.isArray(component.onClick)) {
|
||||
return
|
||||
}
|
||||
component.onClick.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)
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { getHostingStore } from "./store/hosting"
|
|||
import { getThemeStore } from "./store/theme"
|
||||
import { derived, writable } from "svelte/store"
|
||||
import { FrontendTypes, LAYOUT_NAMES } from "../constants"
|
||||
import { findComponent } from "./storeUtils"
|
||||
import { findComponent } from "./componentUtils"
|
||||
|
||||
export const store = getFrontendStore()
|
||||
export const automationStore = getAutomationStore()
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Gets the schema for a datasource which is targeting a JSON array, including
|
||||
* nested JSON arrays. The returned schema is a squashed, table-like schema
|
||||
* which is fully compatible with the rest of the platform.
|
||||
* @param tableSchema the full schema for the table this JSON field is in
|
||||
* @param datasource the datasource configuration
|
||||
*/
|
||||
export const getJSONArrayDatasourceSchema = (tableSchema, datasource) => {
|
||||
let jsonSchema = tableSchema
|
||||
let keysToSchema = []
|
||||
|
||||
// If we are already deep inside a JSON field then we need to account
|
||||
// for the keys that brought us here, so we can get the schema for the
|
||||
// depth we're actually at
|
||||
if (datasource.prefixKeys) {
|
||||
keysToSchema = datasource.prefixKeys.concat(["schema"])
|
||||
}
|
||||
|
||||
// We parse the label of the datasource to work out where we are inside
|
||||
// the structure. We can use this to know which part of the schema
|
||||
// is available underneath our current position.
|
||||
keysToSchema = keysToSchema.concat(datasource.label.split(".").slice(2))
|
||||
|
||||
// Follow the JSON key path until we reach the schema for the level
|
||||
// we are at
|
||||
for (let i = 0; i < keysToSchema.length; i++) {
|
||||
jsonSchema = jsonSchema?.[keysToSchema[i]]
|
||||
if (jsonSchema?.schema) {
|
||||
jsonSchema = jsonSchema.schema
|
||||
}
|
||||
}
|
||||
|
||||
// We need to convert the JSON schema into a more typical looking table
|
||||
// schema so that it works with the rest of the platform
|
||||
return convertJSONSchemaToTableSchema(jsonSchema, {
|
||||
squashObjects: true,
|
||||
prefixKeys: keysToSchema,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a JSON field schema (or sub-schema of a nested field) into a schema
|
||||
* that looks like a typical table schema.
|
||||
* @param jsonSchema the JSON field schema or sub-schema
|
||||
* @param options
|
||||
*/
|
||||
export const convertJSONSchemaToTableSchema = (jsonSchema, options) => {
|
||||
if (!jsonSchema) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Add default options
|
||||
options = { squashObjects: false, prefixKeys: null, ...options }
|
||||
|
||||
// Immediately strip the wrapper schema for objects, or wrap shallow values in
|
||||
// a fake "value" schema
|
||||
if (jsonSchema.schema) {
|
||||
jsonSchema = jsonSchema.schema
|
||||
} else {
|
||||
jsonSchema = {
|
||||
value: jsonSchema,
|
||||
}
|
||||
}
|
||||
|
||||
// Extract all deep keys from the schema
|
||||
const keys = extractJSONSchemaKeys(jsonSchema, options.squashObjects)
|
||||
|
||||
// Form a full schema from all the deep schema keys
|
||||
let schema = {}
|
||||
keys.forEach(({ key, type }) => {
|
||||
schema[key] = { type, name: key, prefixKeys: options.prefixKeys }
|
||||
})
|
||||
return schema
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively builds paths to all leaf fields in a JSON field schema structure,
|
||||
* stopping when leaf nodes or arrays are reached.
|
||||
* @param jsonSchema the JSON field schema or sub-schema
|
||||
* @param squashObjects whether to recurse into objects or not
|
||||
*/
|
||||
const extractJSONSchemaKeys = (jsonSchema, squashObjects = false) => {
|
||||
if (!jsonSchema || !Object.keys(jsonSchema).length) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Iterate through every schema key
|
||||
let keys = []
|
||||
Object.keys(jsonSchema).forEach(key => {
|
||||
const type = jsonSchema[key].type
|
||||
|
||||
// If we encounter an object, then only go deeper if we want to squash
|
||||
// object paths
|
||||
if (type === "json" && squashObjects) {
|
||||
// Find all keys within this objects schema
|
||||
const childKeys = extractJSONSchemaKeys(
|
||||
jsonSchema[key].schema,
|
||||
squashObjects
|
||||
)
|
||||
|
||||
// Append child paths onto the current path to build the full path
|
||||
keys = keys.concat(
|
||||
childKeys.map(childKey => ({
|
||||
key: `${key}.${childKey.key}`,
|
||||
type: childKey.type,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise add this as a lead node.
|
||||
// We transform array types from "array" into "jsonarray" here to avoid
|
||||
// confusion with the existing "array" type that represents a multi-select.
|
||||
else {
|
||||
keys.push({
|
||||
key,
|
||||
type: type === "array" ? "jsonarray" : type,
|
||||
})
|
||||
}
|
||||
})
|
||||
return keys
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { FIELDS } from "constants/backend"
|
||||
|
||||
function baseConversion(type) {
|
||||
if (type === "string") {
|
||||
return {
|
||||
type: FIELDS.STRING.type,
|
||||
}
|
||||
} else if (type === "boolean") {
|
||||
return {
|
||||
type: FIELDS.BOOLEAN.type,
|
||||
}
|
||||
} else if (type === "number") {
|
||||
return {
|
||||
type: FIELDS.NUMBER.type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function recurse(schemaLevel = {}, objectLevel) {
|
||||
if (!objectLevel) {
|
||||
return null
|
||||
}
|
||||
const baseType = typeof objectLevel
|
||||
if (baseType !== "object") {
|
||||
return baseConversion(baseType)
|
||||
}
|
||||
for (let [key, value] of Object.entries(objectLevel)) {
|
||||
const type = typeof value
|
||||
// check array first, since arrays are objects
|
||||
if (Array.isArray(value)) {
|
||||
const schema = recurse(schemaLevel[key], value[0])
|
||||
if (schema) {
|
||||
schemaLevel[key] = {
|
||||
type: FIELDS.ARRAY.type,
|
||||
schema,
|
||||
}
|
||||
}
|
||||
} else if (type === "object") {
|
||||
const schema = recurse(schemaLevel[key], objectLevel[key])
|
||||
if (schema) {
|
||||
schemaLevel[key] = schema
|
||||
}
|
||||
} else {
|
||||
schemaLevel[key] = baseConversion(type)
|
||||
}
|
||||
}
|
||||
if (!schemaLevel.type) {
|
||||
return { type: FIELDS.JSON.type, schema: schemaLevel }
|
||||
} else {
|
||||
return schemaLevel
|
||||
}
|
||||
}
|
||||
|
||||
export function generate(object) {
|
||||
return recurse({}, object).schema
|
||||
}
|
|
@ -26,7 +26,7 @@ import {
|
|||
findAllMatchingComponents,
|
||||
findComponent,
|
||||
getComponentSettings,
|
||||
} from "../storeUtils"
|
||||
} from "../componentUtils"
|
||||
import { uuid } from "../uuid"
|
||||
import { removeBindings } from "../dataBinding"
|
||||
|
||||
|
@ -329,12 +329,12 @@ export const getFrontendStore = () => {
|
|||
},
|
||||
components: {
|
||||
select: component => {
|
||||
if (!component) {
|
||||
const asset = get(currentAsset)
|
||||
if (!asset || !component) {
|
||||
return
|
||||
}
|
||||
|
||||
// If this is the root component, select the asset instead
|
||||
const asset = get(currentAsset)
|
||||
const parent = findComponentParent(asset.props, component._id)
|
||||
if (parent == null) {
|
||||
const state = get(store)
|
||||
|
@ -537,7 +537,7 @@ export const getFrontendStore = () => {
|
|||
|
||||
// immediately need to remove bindings, currently these aren't valid when pasted
|
||||
if (!cut && !preserveBindings) {
|
||||
state.componentToPaste = removeBindings(state.componentToPaste)
|
||||
state.componentToPaste = removeBindings(state.componentToPaste, "")
|
||||
}
|
||||
|
||||
// Clone the component to paste
|
||||
|
|
|
@ -137,6 +137,7 @@ const fieldTypeToComponentMap = {
|
|||
datetime: "datetimefield",
|
||||
attachment: "attachmentfield",
|
||||
link: "relationshipfield",
|
||||
json: "jsonfield",
|
||||
}
|
||||
|
||||
export function makeDatasourceFormComponents(datasource) {
|
||||
|
@ -146,7 +147,7 @@ export function makeDatasourceFormComponents(datasource) {
|
|||
fields.forEach(field => {
|
||||
const fieldSchema = schema[field]
|
||||
// skip autocolumns
|
||||
if (fieldSchema.autocolumn) {
|
||||
if (fieldSchema.autocolumn || fieldSchema.nestedJSON) {
|
||||
return
|
||||
}
|
||||
const fieldType =
|
||||
|
|
|
@ -6,16 +6,20 @@
|
|||
Toggle,
|
||||
TextArea,
|
||||
Multiselect,
|
||||
Label,
|
||||
} from "@budibase/bbui"
|
||||
import Dropzone from "components/common/Dropzone.svelte"
|
||||
import { capitalise } from "helpers"
|
||||
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
|
||||
import Editor from "../../integration/QueryEditor.svelte"
|
||||
|
||||
export let defaultValue
|
||||
export let meta
|
||||
export let value = defaultValue || (meta.type === "boolean" ? false : "")
|
||||
export let readonly
|
||||
|
||||
$: stringVal =
|
||||
typeof value === "object" ? JSON.stringify(value, null, 2) : value
|
||||
$: type = meta?.type
|
||||
$: label = meta.name ? capitalise(meta.name) : ""
|
||||
</script>
|
||||
|
@ -40,6 +44,14 @@
|
|||
<LinkedRowSelector bind:linkedRows={value} schema={meta} />
|
||||
{:else if type === "longform"}
|
||||
<TextArea {label} bind:value />
|
||||
{:else if type === "json"}
|
||||
<Label>{label}</Label>
|
||||
<Editor
|
||||
editorHeight="250"
|
||||
mode="json"
|
||||
on:change={({ detail }) => (value = detail.value)}
|
||||
value={stringVal}
|
||||
/>
|
||||
{:else}
|
||||
<Input
|
||||
{label}
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
DatePicker,
|
||||
ModalContent,
|
||||
Context,
|
||||
Modal,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
@ -32,12 +33,14 @@
|
|||
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
|
||||
import { getBindings } from "components/backend/DataTable/formula"
|
||||
import { getContext } from "svelte"
|
||||
import JSONSchemaModal from "./JSONSchemaModal.svelte"
|
||||
|
||||
const AUTO_TYPE = "auto"
|
||||
const FORMULA_TYPE = FIELDS.FORMULA.type
|
||||
const LINK_TYPE = FIELDS.LINK.type
|
||||
const STRING_TYPE = FIELDS.STRING.type
|
||||
const NUMBER_TYPE = FIELDS.NUMBER.type
|
||||
const JSON_TYPE = FIELDS.JSON.type
|
||||
const DATE_TYPE = FIELDS.DATETIME.type
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
@ -64,6 +67,7 @@
|
|||
let confirmDeleteDialog
|
||||
let deletion
|
||||
let deleteColName
|
||||
let jsonSchemaModal
|
||||
|
||||
$: checkConstraints(field)
|
||||
$: required = !!field?.constraints?.presence || primaryDisplay
|
||||
|
@ -79,10 +83,14 @@
|
|||
// used to select what different options can be displayed for column type
|
||||
$: canBeSearched =
|
||||
field.type !== LINK_TYPE &&
|
||||
field.type !== JSON_TYPE &&
|
||||
field.subtype !== AUTO_COLUMN_SUB_TYPES.CREATED_BY &&
|
||||
field.subtype !== AUTO_COLUMN_SUB_TYPES.UPDATED_BY &&
|
||||
field.type !== FORMULA_TYPE
|
||||
$: canBeDisplay = field.type !== LINK_TYPE && field.type !== AUTO_TYPE
|
||||
$: canBeDisplay =
|
||||
field.type !== LINK_TYPE &&
|
||||
field.type !== AUTO_TYPE &&
|
||||
field.type !== JSON_TYPE
|
||||
$: canBeRequired =
|
||||
field.type !== LINK_TYPE && !uneditable && field.type !== AUTO_TYPE
|
||||
$: relationshipOptions = getRelationshipOptions(field)
|
||||
|
@ -176,6 +184,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
function openJsonSchemaEditor() {
|
||||
jsonSchemaModal.show()
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
confirmDeleteDialog.show()
|
||||
deletion = true
|
||||
|
@ -430,6 +442,10 @@
|
|||
getOptionLabel={option => option[1].name}
|
||||
getOptionValue={option => option[0]}
|
||||
/>
|
||||
{:else if field.type === JSON_TYPE}
|
||||
<Button primary text on:click={openJsonSchemaEditor}
|
||||
>Open schema editor</Button
|
||||
>
|
||||
{/if}
|
||||
|
||||
<div slot="footer">
|
||||
|
@ -438,6 +454,16 @@
|
|||
{/if}
|
||||
</div>
|
||||
</ModalContent>
|
||||
<Modal bind:this={jsonSchemaModal}>
|
||||
<JSONSchemaModal
|
||||
schema={field.schema}
|
||||
json={field.json}
|
||||
on:save={({ detail }) => {
|
||||
field.schema = detail.schema
|
||||
field.json = detail.json
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<ConfirmDialog
|
||||
bind:this={confirmDeleteDialog}
|
||||
okText="Delete Column"
|
||||
|
|
|
@ -0,0 +1,129 @@
|
|||
<script>
|
||||
import Editor from "components/integration/QueryEditor.svelte"
|
||||
import {
|
||||
ModalContent,
|
||||
Tabs,
|
||||
Tab,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Body,
|
||||
Layout,
|
||||
} from "@budibase/bbui"
|
||||
import { onMount, createEventDispatcher } from "svelte"
|
||||
import { FIELDS } from "constants/backend"
|
||||
import { generate } from "builderStore/schemaGenerator"
|
||||
|
||||
export let schema = {}
|
||||
export let json
|
||||
|
||||
let dispatcher = createEventDispatcher()
|
||||
let mode = "Form"
|
||||
let fieldCount = 0
|
||||
let fieldKeys = {},
|
||||
fieldTypes = {}
|
||||
let keyValueOptions = [
|
||||
{ label: "String", value: FIELDS.STRING.type },
|
||||
{ label: "Number", value: FIELDS.NUMBER.type },
|
||||
{ label: "Boolean", value: FIELDS.BOOLEAN.type },
|
||||
{ label: "Object", value: FIELDS.JSON.type },
|
||||
{ label: "Array", value: FIELDS.ARRAY.type },
|
||||
]
|
||||
let invalid = false
|
||||
|
||||
async function onJsonUpdate({ detail }) {
|
||||
const input = detail.value
|
||||
json = input
|
||||
try {
|
||||
// check json valid first
|
||||
let inputJson = JSON.parse(input)
|
||||
schema = generate(inputJson)
|
||||
updateCounts()
|
||||
invalid = false
|
||||
} catch (err) {
|
||||
// json not currently valid
|
||||
invalid = true
|
||||
}
|
||||
}
|
||||
|
||||
function updateCounts() {
|
||||
if (!schema) {
|
||||
schema = {}
|
||||
}
|
||||
let i = 0
|
||||
for (let [key, value] of Object.entries(schema)) {
|
||||
fieldKeys[i] = key
|
||||
fieldTypes[i] = value.type
|
||||
i++
|
||||
}
|
||||
fieldCount = i
|
||||
}
|
||||
|
||||
function saveSchema() {
|
||||
for (let i of Object.keys(fieldKeys)) {
|
||||
const key = fieldKeys[i]
|
||||
// they were added to schema, rather than generated
|
||||
if (!schema[key]) {
|
||||
schema[key] = {
|
||||
type: fieldTypes[i],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatcher("save", { schema, json })
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateCounts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
title={"JSON Schema Editor"}
|
||||
confirmText="Save Column"
|
||||
onConfirm={saveSchema}
|
||||
bind:disabled={invalid}
|
||||
size="L"
|
||||
>
|
||||
<Tabs selected={mode} noPadding>
|
||||
<Tab title="Form">
|
||||
{#each Array(fieldCount) as _, i}
|
||||
<div class="horizontal">
|
||||
<Input outline label="Key" bind:value={fieldKeys[i]} />
|
||||
<Select
|
||||
label="Type"
|
||||
options={keyValueOptions}
|
||||
bind:value={fieldTypes[i]}
|
||||
getOptionValue={field => field.value}
|
||||
getOptionLabel={field => field.label}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<div class:add-field-btn={fieldCount !== 0}>
|
||||
<Button primary text on:click={() => fieldCount++}>Add Field</Button>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab title="JSON">
|
||||
<Layout noPadding gap="XS">
|
||||
<Body size="S">
|
||||
Provide a sample JSON blob here to automatically determine your
|
||||
schema.
|
||||
</Body>
|
||||
<Editor mode="json" on:change={onJsonUpdate} value={json} />
|
||||
</Layout>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</ModalContent>
|
||||
|
||||
<style>
|
||||
.horizontal {
|
||||
display: grid;
|
||||
grid-template-columns: 30% 1fr;
|
||||
grid-gap: var(--spacing-s);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-field-btn {
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
</style>
|
|
@ -14,7 +14,7 @@
|
|||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import ErrorSVG from "assets/error.svg?raw"
|
||||
import { findComponent, findComponentPath } from "builderStore/storeUtils"
|
||||
import { findComponent, findComponentPath } from "builderStore/componentUtils"
|
||||
|
||||
let iframe
|
||||
let layout
|
||||
|
@ -69,15 +69,7 @@
|
|||
previewDevice: $store.previewDevice,
|
||||
messagePassing: $store.clientFeatures.messagePassing,
|
||||
}
|
||||
|
||||
// Saving pages and screens to the DB causes them to have _revs.
|
||||
// These revisions change every time a save happens and causes
|
||||
// these reactive statements to fire, even though the actual
|
||||
// definition hasn't changed.
|
||||
// By deleting all _rev properties we can avoid this and increase
|
||||
// performance.
|
||||
$: json = JSON.stringify(previewData)
|
||||
$: strippedJson = json.replace(/"_rev":\s*"[^"]+"/g, `"_rev":""`)
|
||||
|
||||
// Update the iframe with the builder info to render the correct preview
|
||||
const refreshContent = message => {
|
||||
|
@ -87,7 +79,7 @@
|
|||
}
|
||||
|
||||
// Refresh the preview when required
|
||||
$: refreshContent(strippedJson)
|
||||
$: refreshContent(json)
|
||||
|
||||
function receiveMessage(message) {
|
||||
const handlers = {
|
||||
|
@ -102,7 +94,7 @@
|
|||
if (!$store.clientFeatures.intelligentLoading) {
|
||||
loading = false
|
||||
}
|
||||
refreshContent(strippedJson)
|
||||
refreshContent(json)
|
||||
},
|
||||
[MessageTypes.ERROR]: event => {
|
||||
// Catch any app errors
|
||||
|
|
|
@ -43,7 +43,8 @@
|
|||
"attachmentfield",
|
||||
"relationshipfield",
|
||||
"daterangepicker",
|
||||
"multifieldselect"
|
||||
"multifieldselect",
|
||||
"jsonfield"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@ -73,6 +74,7 @@
|
|||
"heading",
|
||||
"text",
|
||||
"button",
|
||||
"tag",
|
||||
"divider",
|
||||
"image",
|
||||
"backgroundimage",
|
||||
|
|
|
@ -49,6 +49,7 @@ export default `
|
|||
try {
|
||||
parsed = JSON.parse(event.data)
|
||||
} catch (error) {
|
||||
console.error("Client received invalid JSON")
|
||||
// Ignore
|
||||
}
|
||||
if (!parsed) {
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import { get } from "svelte/store"
|
||||
import { store, currentAsset } from "builderStore"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
import { findComponentParent } from "builderStore/storeUtils"
|
||||
import { findComponentParent } from "builderStore/componentUtils"
|
||||
import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui"
|
||||
|
||||
export let component
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { writable, get } from "svelte/store"
|
||||
import { store as frontendStore } from "builderStore"
|
||||
import { findComponentPath } from "builderStore/storeUtils"
|
||||
import { findComponentPath } from "builderStore/componentUtils"
|
||||
|
||||
export const DropEffect = {
|
||||
MOVE: "move",
|
||||
|
|
|
@ -63,7 +63,14 @@
|
|||
// If no specific value is depended upon, check if a value exists at all
|
||||
// for the dependent setting
|
||||
if (dependantValue == null) {
|
||||
return !isEmpty(componentInstance[dependantSetting])
|
||||
const currentValue = componentInstance[dependantSetting]
|
||||
if (currentValue === false) {
|
||||
return false
|
||||
}
|
||||
if (currentValue === true) {
|
||||
return true
|
||||
}
|
||||
return !isEmpty(currentValue)
|
||||
}
|
||||
|
||||
// Otherwise check the value matches
|
||||
|
|
|
@ -9,8 +9,9 @@
|
|||
ActionMenu,
|
||||
MenuItem,
|
||||
} from "@budibase/bbui"
|
||||
import { getAvailableActions } from "./actions"
|
||||
import { getAvailableActions } from "./index"
|
||||
import { generate } from "shortid"
|
||||
import { getButtonContextBindings } from "builderStore/dataBinding"
|
||||
|
||||
const flipDurationMs = 150
|
||||
const EVENT_TYPE_KEY = "##eventHandlerType"
|
||||
|
@ -19,7 +20,16 @@
|
|||
export let actions
|
||||
export let bindings = []
|
||||
|
||||
// dndzone needs an id on the array items, so this adds some temporary ones.
|
||||
let selectedAction = actions?.length ? actions[0] : null
|
||||
|
||||
// These are ephemeral bindings which only exist while executing actions
|
||||
$: buttonContextBindings = getButtonContextBindings(
|
||||
actions,
|
||||
selectedAction?.id
|
||||
)
|
||||
$: allBindings = buttonContextBindings.concat(bindings)
|
||||
|
||||
// Assign a unique ID to each action
|
||||
$: {
|
||||
if (actions) {
|
||||
actions.forEach(action => {
|
||||
|
@ -30,8 +40,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
let selectedAction = actions?.length ? actions[0] : null
|
||||
|
||||
$: selectedActionComponent =
|
||||
selectedAction &&
|
||||
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_KEY])?.component
|
||||
|
@ -122,7 +130,7 @@
|
|||
<svelte:component
|
||||
this={selectedActionComponent}
|
||||
parameters={selectedAction.parameters}
|
||||
{bindings}
|
||||
bindings={allBindings}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
|
@ -2,7 +2,7 @@
|
|||
import { ActionButton, Button, Drawer } from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { notifications } from "@budibase/bbui"
|
||||
import EventEditor from "./EventEditor.svelte"
|
||||
import ButtonActionDrawer from "./ButtonActionDrawer.svelte"
|
||||
import { automationStore } from "builderStore"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
|
||||
|
@ -67,7 +67,7 @@
|
|||
Define what actions to run.
|
||||
</svelte:fragment>
|
||||
<Button cta slot="buttons" on:click={saveEventData}>Save</Button>
|
||||
<EventEditor
|
||||
<ButtonActionDrawer
|
||||
slot="body"
|
||||
bind:actions={tmpValue}
|
||||
eventType={name}
|
|
@ -0,0 +1,152 @@
|
|||
<script>
|
||||
import { Select, Label, Body, Checkbox, Input } from "@budibase/bbui"
|
||||
import { store, currentAsset } from "builderStore"
|
||||
import { tables } from "stores/backend"
|
||||
import {
|
||||
getContextProviderComponents,
|
||||
getSchemaForDatasource,
|
||||
} from "builderStore/dataBinding"
|
||||
import SaveFields from "./SaveFields.svelte"
|
||||
|
||||
export let parameters
|
||||
export let bindings = []
|
||||
|
||||
$: formComponents = getContextProviderComponents(
|
||||
$currentAsset,
|
||||
$store.selectedComponentId,
|
||||
"form"
|
||||
)
|
||||
$: schemaComponents = getContextProviderComponents(
|
||||
$currentAsset,
|
||||
$store.selectedComponentId,
|
||||
"schema"
|
||||
)
|
||||
$: providerOptions = getProviderOptions(formComponents, schemaComponents)
|
||||
$: schemaFields = getSchemaFields($currentAsset, parameters?.tableId)
|
||||
$: tableOptions = $tables.list || []
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Gets options for valid context keys which provide valid data to submit
|
||||
const getProviderOptions = (formComponents, schemaComponents) => {
|
||||
const formContexts = formComponents.map(component => ({
|
||||
component,
|
||||
context: extractComponentContext(component, "form"),
|
||||
}))
|
||||
const schemaContexts = schemaComponents.map(component => ({
|
||||
component,
|
||||
context: extractComponentContext(component, "schema"),
|
||||
}))
|
||||
const allContexts = formContexts.concat(schemaContexts)
|
||||
|
||||
return allContexts.map(({ component, context }) => {
|
||||
let runtimeBinding = component._id
|
||||
if (context.suffix) {
|
||||
runtimeBinding += `-${context.suffix}`
|
||||
}
|
||||
return {
|
||||
label: component._instanceName,
|
||||
value: runtimeBinding,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getSchemaFields = (asset, tableId) => {
|
||||
const { schema } = getSchemaForDatasource(asset, { type: "table", tableId })
|
||||
delete schema._id
|
||||
delete schema._rev
|
||||
return Object.values(schema || {})
|
||||
}
|
||||
|
||||
const onFieldsChanged = e => {
|
||||
parameters.fields = e.detail
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<Body size="S">
|
||||
Choose the data source that provides the row you would like to duplicate.
|
||||
<br />
|
||||
You can always add or override fields manually.
|
||||
</Body>
|
||||
|
||||
<div class="params">
|
||||
<Label small>Data Source</Label>
|
||||
<Select
|
||||
bind:value={parameters.providerId}
|
||||
options={providerOptions}
|
||||
placeholder="None"
|
||||
/>
|
||||
|
||||
<Label small>Duplicate to Table</Label>
|
||||
<Select
|
||||
bind:value={parameters.tableId}
|
||||
options={tableOptions}
|
||||
getOptionLabel={option => option.name}
|
||||
getOptionValue={option => option._id}
|
||||
/>
|
||||
|
||||
<Label small />
|
||||
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
|
||||
|
||||
{#if parameters.confirm}
|
||||
<Label small>Confirm text</Label>
|
||||
<Input
|
||||
placeholder="Are you sure you want to duplicate this row?"
|
||||
bind:value={parameters.confirmText}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if parameters.tableId}
|
||||
<div class="fields">
|
||||
<SaveFields
|
||||
parameterFields={parameters.fields}
|
||||
{schemaFields}
|
||||
on:change={onFieldsChanged}
|
||||
{bindings}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.root :global(p) {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.params {
|
||||
display: grid;
|
||||
column-gap: var(--spacing-l);
|
||||
row-gap: var(--spacing-s);
|
||||
grid-template-columns: 100px 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
column-gap: var(--spacing-l);
|
||||
row-gap: var(--spacing-s);
|
||||
grid-template-columns: 100px 1fr auto 1fr auto;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,13 @@
|
|||
export { default as NavigateTo } from "./NavigateTo.svelte"
|
||||
export { default as SaveRow } from "./SaveRow.svelte"
|
||||
export { default as DeleteRow } from "./DeleteRow.svelte"
|
||||
export { default as ExecuteQuery } from "./ExecuteQuery.svelte"
|
||||
export { default as TriggerAutomation } from "./TriggerAutomation.svelte"
|
||||
export { default as ValidateForm } from "./ValidateForm.svelte"
|
||||
export { default as LogOut } from "./LogOut.svelte"
|
||||
export { default as ClearForm } from "./ClearForm.svelte"
|
||||
export { default as CloseScreenModal } from "./CloseScreenModal.svelte"
|
||||
export { default as ChangeFormStep } from "./ChangeFormStep.svelte"
|
||||
export { default as UpdateState } from "./UpdateState.svelte"
|
||||
export { default as RefreshDataProvider } from "./RefreshDataProvider.svelte"
|
||||
export { default as DuplicateRow } from "./DuplicateRow.svelte"
|
|
@ -0,0 +1,33 @@
|
|||
import * as ActionComponents from "./actions"
|
||||
import { get } from "svelte/store"
|
||||
import { store } from "builderStore"
|
||||
import ActionDefinitions from "./manifest.json"
|
||||
|
||||
// Defines which actions are available to configure in the front end.
|
||||
// Unfortunately the "name" property is used as the identifier so please don't
|
||||
// change them.
|
||||
// The client library removes any spaces when processing actions, so they can
|
||||
// be considered as camel case too.
|
||||
// There is technical debt here to sanitize all these and standardise them
|
||||
// across the packages but it's a breaking change to existing apps.
|
||||
export const getAvailableActions = (getAllActions = false) => {
|
||||
return ActionDefinitions.actions
|
||||
.filter(action => {
|
||||
// Filter down actions to those supported by the current client lib version
|
||||
if (getAllActions || !action.dependsOnFeature) {
|
||||
return true
|
||||
}
|
||||
return get(store).clientFeatures?.[action.dependsOnFeature] === true
|
||||
})
|
||||
.map(action => {
|
||||
// Then enrich the actions with real components
|
||||
return {
|
||||
...action,
|
||||
component: ActionComponents[action.component],
|
||||
}
|
||||
})
|
||||
.filter(action => {
|
||||
// Then strip any old actions for which we don't have constructors
|
||||
return action.component != null
|
||||
})
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
{
|
||||
"actions": [
|
||||
{
|
||||
"name": "Save Row",
|
||||
"component": "SaveRow",
|
||||
"context": [
|
||||
{
|
||||
"label": "Saved row",
|
||||
"value": "row"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Duplicate Row",
|
||||
"component": "DuplicateRow",
|
||||
"context": [
|
||||
{
|
||||
"label": "Duplicated row",
|
||||
"value": "row"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Delete Row",
|
||||
"component": "DeleteRow"
|
||||
},
|
||||
{
|
||||
"name": "Navigate To",
|
||||
"component": "NavigateTo"
|
||||
},
|
||||
{
|
||||
"name": "Execute Query",
|
||||
"component": "ExecuteQuery",
|
||||
"context": [
|
||||
{
|
||||
"label": "Query result",
|
||||
"value": "result"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Trigger Automation",
|
||||
"component": "TriggerAutomation"
|
||||
},
|
||||
{
|
||||
"name": "Validate Form",
|
||||
"component": "ValidateForm"
|
||||
},
|
||||
{
|
||||
"name": "Log Out",
|
||||
"component": "LogOut"
|
||||
},
|
||||
{
|
||||
"name": "Clear Form",
|
||||
"component": "ClearForm"
|
||||
},
|
||||
{
|
||||
"name": "Close Screen Modal",
|
||||
"component": "CloseScreenModal"
|
||||
},
|
||||
{
|
||||
"name": "Change Form Step",
|
||||
"component": "ChangeFormStep"
|
||||
},
|
||||
{
|
||||
"name": "Refresh Data Provider",
|
||||
"component": "RefreshDataProvider"
|
||||
},
|
||||
{
|
||||
"name": "Update State",
|
||||
"component": "UpdateState",
|
||||
"dependsOnFeature": "state"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -16,7 +16,7 @@
|
|||
import { selectedComponent } from "builderStore"
|
||||
import { getComponentForSettingType } from "./componentSettings"
|
||||
import PropertyControl from "./PropertyControl.svelte"
|
||||
import { getComponentSettings } from "builderStore/storeUtils"
|
||||
import { getComponentSettings } from "builderStore/componentUtils"
|
||||
|
||||
export let conditions = []
|
||||
export let bindings = []
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import { Select } from "@budibase/bbui"
|
||||
import { makePropSafe } from "@budibase/string-templates"
|
||||
import { currentAsset, store } from "builderStore"
|
||||
import { findComponentPath } from "builderStore/storeUtils"
|
||||
import { findComponentPath } from "builderStore/componentUtils"
|
||||
import { createEventDispatcher, onMount } from "svelte"
|
||||
|
||||
export let value
|
||||
|
|
|
@ -48,9 +48,7 @@
|
|||
return [...acc, ...viewsArr]
|
||||
}, [])
|
||||
$: queries = $queriesStore.list
|
||||
.filter(
|
||||
query => showAllQueries || query.queryVerb === "read" || query.readable
|
||||
)
|
||||
.filter(q => showAllQueries || q.queryVerb === "read" || q.readable)
|
||||
.map(query => ({
|
||||
label: query.name,
|
||||
name: query.name,
|
||||
|
@ -104,6 +102,22 @@
|
|||
value: `{{ literal ${runtimeBinding} }}`,
|
||||
}
|
||||
})
|
||||
$: jsonArrays = bindings
|
||||
.filter(x => x.fieldSchema?.type === "jsonarray")
|
||||
.map(binding => {
|
||||
const { providerId, readableBinding, runtimeBinding, tableId } = binding
|
||||
const { name, type, prefixKeys } = binding.fieldSchema
|
||||
return {
|
||||
providerId,
|
||||
label: readableBinding,
|
||||
fieldName: name,
|
||||
fieldType: type,
|
||||
tableId,
|
||||
prefixKeys,
|
||||
type: "jsonarray",
|
||||
value: `{{ literal ${runtimeBinding} }}`,
|
||||
}
|
||||
})
|
||||
|
||||
const handleSelected = selected => {
|
||||
dispatch("change", selected)
|
||||
|
@ -230,6 +244,17 @@
|
|||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if jsonArrays?.length}
|
||||
<Divider size="S" />
|
||||
<div class="title">
|
||||
<Heading size="XS">JSON Arrays</Heading>
|
||||
</div>
|
||||
<ul>
|
||||
{#each jsonArrays as field}
|
||||
<li on:click={() => handleSelected(field)}>{field.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if dataProviders?.length}
|
||||
<Divider size="S" />
|
||||
<div class="title">
|
||||
|
|
|
@ -1,80 +0,0 @@
|
|||
import { store } from "builderStore"
|
||||
import { get } from "svelte/store"
|
||||
|
||||
import NavigateTo from "./NavigateTo.svelte"
|
||||
import SaveRow from "./SaveRow.svelte"
|
||||
import DeleteRow from "./DeleteRow.svelte"
|
||||
import ExecuteQuery from "./ExecuteQuery.svelte"
|
||||
import TriggerAutomation from "./TriggerAutomation.svelte"
|
||||
import ValidateForm from "./ValidateForm.svelte"
|
||||
import LogOut from "./LogOut.svelte"
|
||||
import ClearForm from "./ClearForm.svelte"
|
||||
import CloseScreenModal from "./CloseScreenModal.svelte"
|
||||
import ChangeFormStep from "./ChangeFormStep.svelte"
|
||||
import UpdateStateStep from "./UpdateState.svelte"
|
||||
import RefreshDataProvider from "./RefreshDataProvider.svelte"
|
||||
|
||||
// Defines which actions are available to configure in the front end.
|
||||
// Unfortunately the "name" property is used as the identifier so please don't
|
||||
// change them.
|
||||
// The client library removes any spaces when processing actions, so they can
|
||||
// be considered as camel case too.
|
||||
// There is technical debt here to sanitize all these and standardise them
|
||||
// across the packages but it's a breaking change to existing apps.
|
||||
export const getAvailableActions = () => {
|
||||
let actions = [
|
||||
{
|
||||
name: "Save Row",
|
||||
component: SaveRow,
|
||||
},
|
||||
{
|
||||
name: "Delete Row",
|
||||
component: DeleteRow,
|
||||
},
|
||||
{
|
||||
name: "Navigate To",
|
||||
component: NavigateTo,
|
||||
},
|
||||
{
|
||||
name: "Execute Query",
|
||||
component: ExecuteQuery,
|
||||
},
|
||||
{
|
||||
name: "Trigger Automation",
|
||||
component: TriggerAutomation,
|
||||
},
|
||||
{
|
||||
name: "Validate Form",
|
||||
component: ValidateForm,
|
||||
},
|
||||
{
|
||||
name: "Log Out",
|
||||
component: LogOut,
|
||||
},
|
||||
{
|
||||
name: "Clear Form",
|
||||
component: ClearForm,
|
||||
},
|
||||
{
|
||||
name: "Close Screen Modal",
|
||||
component: CloseScreenModal,
|
||||
},
|
||||
{
|
||||
name: "Change Form Step",
|
||||
component: ChangeFormStep,
|
||||
},
|
||||
{
|
||||
name: "Refresh Data Provider",
|
||||
component: RefreshDataProvider,
|
||||
},
|
||||
]
|
||||
|
||||
if (get(store).clientFeatures?.state) {
|
||||
actions.push({
|
||||
name: "Update State",
|
||||
component: UpdateStateStep,
|
||||
})
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
import EventsEditor from "./EventPropertyControl.svelte"
|
||||
export default EventsEditor
|
|
@ -21,7 +21,7 @@
|
|||
export let panel = ClientBindingPanel
|
||||
export let allowBindings = true
|
||||
|
||||
const BannedTypes = ["link", "attachment", "formula"]
|
||||
const BannedTypes = ["link", "attachment", "formula", "json", "jsonarray"]
|
||||
|
||||
$: fieldOptions = (schemaFields ?? [])
|
||||
.filter(field => !BannedTypes.includes(field.type))
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
getSchemaForDatasource,
|
||||
} from "builderStore/dataBinding"
|
||||
import { currentAsset } from "builderStore"
|
||||
import { findClosestMatchingComponent } from "builderStore/storeUtils"
|
||||
import { findClosestMatchingComponent } from "builderStore/componentUtils"
|
||||
|
||||
export let componentInstance
|
||||
export let value
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { ActionButton } from "@budibase/bbui"
|
||||
import { currentAsset, store } from "builderStore"
|
||||
import { findClosestMatchingComponent } from "builderStore/storeUtils"
|
||||
import { findClosestMatchingComponent } from "builderStore/componentUtils"
|
||||
import { makeDatasourceFormComponents } from "builderStore/store/screenTemplates/utils/commonComponents"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
DatePicker,
|
||||
} from "@budibase/bbui"
|
||||
import { currentAsset, selectedComponent } from "builderStore"
|
||||
import { findClosestMatchingComponent } from "builderStore/storeUtils"
|
||||
import { findClosestMatchingComponent } from "builderStore/componentUtils"
|
||||
import { getSchemaForDatasource } from "builderStore/dataBinding"
|
||||
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
||||
import { generate } from "shortid"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Checkbox, Select, Stepper } from "@budibase/bbui"
|
||||
import DataSourceSelect from "./DataSourceSelect.svelte"
|
||||
import DataProviderSelect from "./DataProviderSelect.svelte"
|
||||
import EventsEditor from "./EventsEditor"
|
||||
import ButtonActionEditor from "./ButtonActionEditor/ButtonActionEditor.svelte"
|
||||
import TableSelect from "./TableSelect.svelte"
|
||||
import ColorPicker from "./ColorPicker.svelte"
|
||||
import { IconSelect } from "./IconSelect"
|
||||
|
@ -24,7 +24,7 @@ const componentMap = {
|
|||
dataProvider: DataProviderSelect,
|
||||
boolean: Checkbox,
|
||||
number: Stepper,
|
||||
event: EventsEditor,
|
||||
event: ButtonActionEditor,
|
||||
table: TableSelect,
|
||||
color: ColorPicker,
|
||||
icon: IconSelect,
|
||||
|
@ -45,6 +45,7 @@ const componentMap = {
|
|||
"field/attachment": FormFieldSelect,
|
||||
"field/link": FormFieldSelect,
|
||||
"field/array": FormFieldSelect,
|
||||
"field/json": FormFieldSelect,
|
||||
// Some validation types are the same as others, so not all types are
|
||||
// explicitly listed here. e.g. options uses string validation
|
||||
"validation/string": ValidationEditor,
|
||||
|
|
|
@ -89,6 +89,14 @@ export const FIELDS = {
|
|||
presence: false,
|
||||
},
|
||||
},
|
||||
JSON: {
|
||||
name: "JSON",
|
||||
type: "json",
|
||||
constraints: {
|
||||
type: "object",
|
||||
presence: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const AUTO_COLUMN_SUB_TYPES = {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { NoEmptyFilterStrings } from "../constants/lucene"
|
||||
import { deepGet } from "@budibase/bbui"
|
||||
|
||||
/**
|
||||
* Removes any fields that contain empty strings that would cause inconsistent
|
||||
|
@ -96,9 +97,13 @@ export const buildLuceneQuery = filter => {
|
|||
* @param query the JSON lucene query
|
||||
*/
|
||||
export const luceneQuery = (docs, query) => {
|
||||
if (!docs || !Array.isArray(docs)) {
|
||||
return []
|
||||
}
|
||||
if (!query) {
|
||||
return docs
|
||||
}
|
||||
|
||||
// make query consistent first
|
||||
query = cleanupQuery(query)
|
||||
|
||||
|
@ -106,7 +111,9 @@ export const luceneQuery = (docs, query) => {
|
|||
const match = (type, failFn) => doc => {
|
||||
const filters = Object.entries(query[type] || {})
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
if (failFn(filters[i][0], filters[i][1], doc)) {
|
||||
const [key, testValue] = filters[i]
|
||||
const docValue = deepGet(doc, key)
|
||||
if (failFn(docValue, testValue)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
@ -114,38 +121,38 @@ export const luceneQuery = (docs, query) => {
|
|||
}
|
||||
|
||||
// Process a string match (fails if the value does not start with the string)
|
||||
const stringMatch = match("string", (key, value, doc) => {
|
||||
return !doc[key] || !doc[key].startsWith(value)
|
||||
const stringMatch = match("string", (docValue, testValue) => {
|
||||
return !docValue || !docValue.startsWith(testValue)
|
||||
})
|
||||
|
||||
// Process a fuzzy match (treat the same as starts with when running locally)
|
||||
const fuzzyMatch = match("fuzzy", (key, value, doc) => {
|
||||
return !doc[key] || !doc[key].startsWith(value)
|
||||
const fuzzyMatch = match("fuzzy", (docValue, testValue) => {
|
||||
return !docValue || !docValue.startsWith(testValue)
|
||||
})
|
||||
|
||||
// Process a range match
|
||||
const rangeMatch = match("range", (key, value, doc) => {
|
||||
return !doc[key] || doc[key] < value.low || doc[key] > value.high
|
||||
const rangeMatch = match("range", (docValue, testValue) => {
|
||||
return !docValue || docValue < testValue.low || docValue > testValue.high
|
||||
})
|
||||
|
||||
// Process an equal match (fails if the value is different)
|
||||
const equalMatch = match("equal", (key, value, doc) => {
|
||||
return value != null && value !== "" && doc[key] !== value
|
||||
const equalMatch = match("equal", (docValue, testValue) => {
|
||||
return testValue != null && testValue !== "" && docValue !== testValue
|
||||
})
|
||||
|
||||
// Process a not-equal match (fails if the value is the same)
|
||||
const notEqualMatch = match("notEqual", (key, value, doc) => {
|
||||
return value != null && value !== "" && doc[key] === value
|
||||
const notEqualMatch = match("notEqual", (docValue, testValue) => {
|
||||
return testValue != null && testValue !== "" && docValue === testValue
|
||||
})
|
||||
|
||||
// Process an empty match (fails if the value is not empty)
|
||||
const emptyMatch = match("empty", (key, value, doc) => {
|
||||
return doc[key] != null && doc[key] !== ""
|
||||
const emptyMatch = match("empty", docValue => {
|
||||
return docValue != null && docValue !== ""
|
||||
})
|
||||
|
||||
// Process a not-empty match (fails is the value is empty)
|
||||
const notEmptyMatch = match("notEmpty", (key, value, doc) => {
|
||||
return doc[key] == null || doc[key] === ""
|
||||
const notEmptyMatch = match("notEmpty", docValue => {
|
||||
return docValue == null || docValue === ""
|
||||
})
|
||||
|
||||
// Match a document against all criteria
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
import FrontendNavigatePane from "components/design/NavigationPanel/FrontendNavigatePane.svelte"
|
||||
import { goto, leftover, params } from "@roxi/routify"
|
||||
import { FrontendTypes } from "constants"
|
||||
import { findComponent, findComponentPath } from "builderStore/storeUtils"
|
||||
import { findComponent, findComponentPath } from "builderStore/componentUtils"
|
||||
import { get } from "svelte/store"
|
||||
import AppThemeSelect from "components/design/AppPreview/AppThemeSelect.svelte"
|
||||
import ThemeEditor from "components/design/AppPreview/ThemeEditor.svelte"
|
||||
|
|
|
@ -500,9 +500,20 @@
|
|||
"defaultValue": "M"
|
||||
}
|
||||
],
|
||||
"context": {
|
||||
"type": "schema"
|
||||
}
|
||||
"context": [
|
||||
{
|
||||
"type": "schema"
|
||||
},
|
||||
{
|
||||
"type": "static",
|
||||
"values": [
|
||||
{
|
||||
"label": "Row Index",
|
||||
"key": "index"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"stackedlist": {
|
||||
"deprecated": true,
|
||||
|
@ -808,6 +819,57 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"tag": {
|
||||
"name": "Tag",
|
||||
"icon": "TextParagraph",
|
||||
"showSettingsBar": true,
|
||||
"settings": [
|
||||
{
|
||||
"type": "text",
|
||||
"label": "Text",
|
||||
"key": "text"
|
||||
},
|
||||
{
|
||||
"type": "select",
|
||||
"label": "Size",
|
||||
"key": "size",
|
||||
"defaultValue": "M",
|
||||
"showInBar": true,
|
||||
"barStyle": "picker",
|
||||
"options": [{
|
||||
"label": "Small",
|
||||
"value": "S"
|
||||
}, {
|
||||
"label": "Medium",
|
||||
"value": "M"
|
||||
}, {
|
||||
"label": "Large",
|
||||
"value": "L"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"type": "color",
|
||||
"label": "Color",
|
||||
"key": "color",
|
||||
"showInBar": true,
|
||||
"barSeparator": false
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"label": "Show delete icon",
|
||||
"key": "closable",
|
||||
"showInBar": true,
|
||||
"barIcon": "TagItalic",
|
||||
"barTitle": "Show delete icon"
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"label": "On click delete icon",
|
||||
"key": "onClick",
|
||||
"dependsOn": "closable"
|
||||
}
|
||||
]
|
||||
},
|
||||
"image": {
|
||||
"name": "Image",
|
||||
"description": "A basic component for displaying images",
|
||||
|
@ -1775,6 +1837,10 @@
|
|||
{
|
||||
"type": "static",
|
||||
"values": [
|
||||
{
|
||||
"label": "Value",
|
||||
"key": "__value"
|
||||
},
|
||||
{
|
||||
"label": "Valid",
|
||||
"key": "__valid"
|
||||
|
@ -2407,6 +2473,40 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"jsonfield": {
|
||||
"name": "JSON Field",
|
||||
"icon": "Brackets",
|
||||
"styles": ["size"],
|
||||
"editable": true,
|
||||
"settings": [
|
||||
{
|
||||
"type": "field/json",
|
||||
"label": "Field",
|
||||
"key": "field"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"label": "Label",
|
||||
"key": "label"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"label": "Placeholder",
|
||||
"key": "placeholder"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"label": "Default value",
|
||||
"key": "defaultValue"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"label": "Disabled",
|
||||
"key": "disabled",
|
||||
"defaultValue": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"dataprovider": {
|
||||
"name": "Data Provider",
|
||||
"info": "Pagination is only available for data stored in tables.",
|
||||
|
@ -3192,6 +3292,16 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "static",
|
||||
"suffix": "repeater",
|
||||
"values": [
|
||||
{
|
||||
"label": "Row Index",
|
||||
"key": "index"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "schema",
|
||||
"suffix": "repeater"
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"@spectrum-css/divider": "^1.0.3",
|
||||
"@spectrum-css/link": "^3.1.3",
|
||||
"@spectrum-css/page": "^3.0.1",
|
||||
"@spectrum-css/tag": "^3.1.4",
|
||||
"@spectrum-css/typography": "^3.0.2",
|
||||
"@spectrum-css/vars": "^3.0.1",
|
||||
"apexcharts": "^3.22.1",
|
||||
|
|
|
@ -4,6 +4,10 @@ import { fetchViewData } from "./views"
|
|||
import { fetchRelationshipData } from "./relationships"
|
||||
import { FieldTypes } from "../constants"
|
||||
import { executeQuery, fetchQueryDefinition } from "./queries"
|
||||
import {
|
||||
convertJSONSchemaToTableSchema,
|
||||
getJSONArrayDatasourceSchema,
|
||||
} from "builder/src/builderStore/jsonUtils"
|
||||
|
||||
/**
|
||||
* Fetches all rows for a particular Budibase data source.
|
||||
|
@ -49,16 +53,17 @@ export const fetchDatasourceSchema = async dataSource => {
|
|||
return null
|
||||
}
|
||||
const { type } = dataSource
|
||||
let schema
|
||||
|
||||
// Nested providers should already have exposed their own schema
|
||||
if (type === "provider") {
|
||||
return dataSource.value?.schema
|
||||
schema = dataSource.value?.schema
|
||||
}
|
||||
|
||||
// Field sources have their schema statically defined
|
||||
if (type === "field") {
|
||||
if (dataSource.fieldType === "attachment") {
|
||||
return {
|
||||
schema = {
|
||||
url: {
|
||||
type: "string",
|
||||
},
|
||||
|
@ -67,7 +72,7 @@ export const fetchDatasourceSchema = async dataSource => {
|
|||
},
|
||||
}
|
||||
} else if (dataSource.fieldType === "array") {
|
||||
return {
|
||||
schema = {
|
||||
value: {
|
||||
type: "string",
|
||||
},
|
||||
|
@ -75,20 +80,48 @@ export const fetchDatasourceSchema = async dataSource => {
|
|||
}
|
||||
}
|
||||
|
||||
// JSON arrays need their table definitions fetched.
|
||||
// We can then extract their schema as a subset of the table schema.
|
||||
if (type === "jsonarray") {
|
||||
const table = await fetchTableDefinition(dataSource.tableId)
|
||||
schema = getJSONArrayDatasourceSchema(table?.schema, dataSource)
|
||||
}
|
||||
|
||||
// Tables, views and links can be fetched by table ID
|
||||
if (
|
||||
(type === "table" || type === "view" || type === "link") &&
|
||||
dataSource.tableId
|
||||
) {
|
||||
const table = await fetchTableDefinition(dataSource.tableId)
|
||||
return table?.schema
|
||||
schema = table?.schema
|
||||
}
|
||||
|
||||
// Queries can be fetched by query ID
|
||||
if (type === "query" && dataSource._id) {
|
||||
const definition = await fetchQueryDefinition(dataSource._id)
|
||||
return definition?.schema
|
||||
schema = definition?.schema
|
||||
}
|
||||
|
||||
return null
|
||||
// Sanity check
|
||||
if (!schema) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check for any JSON fields so we can add any top level properties
|
||||
let jsonAdditions = {}
|
||||
Object.keys(schema).forEach(fieldKey => {
|
||||
const fieldSchema = schema[fieldKey]
|
||||
if (fieldSchema?.type === "json") {
|
||||
const jsonSchema = convertJSONSchemaToTableSchema(fieldSchema, {
|
||||
squashObjects: true,
|
||||
})
|
||||
Object.keys(jsonSchema).forEach(jsonKey => {
|
||||
jsonAdditions[`${fieldKey}.${jsonKey}`] = {
|
||||
type: jsonSchema[jsonKey].type,
|
||||
nestedJSON: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
return { ...schema, ...jsonAdditions }
|
||||
}
|
||||
|
|
|
@ -194,9 +194,12 @@
|
|||
// For providers referencing another provider, just use the rows it
|
||||
// provides
|
||||
allRows = dataSource?.value?.rows || []
|
||||
} else if (dataSource?.type === "field") {
|
||||
// Field sources will be available from context.
|
||||
// Enrich non object elements into object to ensure a valid schema.
|
||||
} else if (
|
||||
dataSource?.type === "field" ||
|
||||
dataSource?.type === "jsonarray"
|
||||
) {
|
||||
// These sources will be available directly from context.
|
||||
// Enrich non object elements into objects to ensure a valid schema.
|
||||
const data = dataSource?.value || []
|
||||
if (Array.isArray(data) && data[0] && typeof data[0] !== "object") {
|
||||
allRows = data.map(value => ({ value }))
|
||||
|
|
|
@ -21,8 +21,8 @@
|
|||
{#if $component.empty}
|
||||
<Placeholder />
|
||||
{:else if rows.length > 0}
|
||||
{#each rows as row}
|
||||
<Provider data={row}>
|
||||
{#each rows as row, index}
|
||||
<Provider data={{ ...row, index }}>
|
||||
<slot />
|
||||
</Provider>
|
||||
{/each}
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
<script>
|
||||
import "@spectrum-css/tag/dist/index-vars.css"
|
||||
import { ClearButton } from "@budibase/bbui"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
export let onClick
|
||||
export let text = ""
|
||||
export let color
|
||||
export let closable = false
|
||||
export let size = "M"
|
||||
|
||||
const component = getContext("component")
|
||||
const { styleable, builderStore } = getContext("sdk")
|
||||
|
||||
// Add color styles to main styles object, otherwise the styleable helper
|
||||
// overrides the color when it's passed as inline style.
|
||||
$: styles = enrichStyles($component.styles, color)
|
||||
$: componentText = getComponentText(text, $builderStore, $component)
|
||||
|
||||
const getComponentText = (text, builderState, componentState) => {
|
||||
if (!builderState.inBuilder || componentState.editing) {
|
||||
return text || " "
|
||||
}
|
||||
return text || componentState.name || "Placeholder text"
|
||||
}
|
||||
|
||||
const enrichStyles = (styles, color) => {
|
||||
if (!color) {
|
||||
return styles
|
||||
}
|
||||
return {
|
||||
...styles,
|
||||
normal: {
|
||||
...styles?.normal,
|
||||
"background-color": color,
|
||||
"border-color": color,
|
||||
color: "white",
|
||||
"--spectrum-clearbutton-medium-icon-color": "white",
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="spectrum-Tag spectrum-Tag--size{size}" use:styleable={styles}>
|
||||
<span class="spectrum-Tag-label">{componentText}</span>
|
||||
{#if closable}
|
||||
<ClearButton on:click={onClick} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spectrum-Tag--sizeS,
|
||||
.spectrum-Tag--sizeM {
|
||||
padding: 0 var(--spectrum-global-dimension-size-100);
|
||||
}
|
||||
.spectrum-Tag--sizeL {
|
||||
padding: 0 var(--spectrum-global-dimension-size-150);
|
||||
}
|
||||
</style>
|
|
@ -73,7 +73,7 @@
|
|||
enrichedFilter.push({
|
||||
field: column.name,
|
||||
operator: column.type === "string" ? "string" : "equal",
|
||||
type: "string",
|
||||
type: column.type === "string" ? "string" : "number",
|
||||
valueType: "Binding",
|
||||
value: `{{ [${formId}].[${column.name}] }}`,
|
||||
})
|
||||
|
|
|
@ -61,7 +61,7 @@
|
|||
enrichedFilter.push({
|
||||
field: column.name,
|
||||
operator: column.type === "string" ? "string" : "equal",
|
||||
type: "string",
|
||||
type: column.type === "string" ? "string" : "number",
|
||||
valueType: "Binding",
|
||||
value: `{{ ${safe(formId)}.${safe(column.name)} }}`,
|
||||
})
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
export let schemaFields
|
||||
export let filters = []
|
||||
|
||||
const BannedTypes = ["link", "attachment", "formula"]
|
||||
const BannedTypes = ["link", "attachment", "formula", "json"]
|
||||
|
||||
$: fieldOptions = (schemaFields ?? [])
|
||||
.filter(field => !BannedTypes.includes(field.type))
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
import { derived, get, writable } from "svelte/store"
|
||||
import { createValidatorFromConstraints } from "./validation"
|
||||
import { generateID } from "utils/helpers"
|
||||
import { deepGet, deepSet } from "@budibase/bbui"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
|
||||
export let dataSource
|
||||
export let disabled = false
|
||||
|
@ -49,6 +51,20 @@
|
|||
})
|
||||
}
|
||||
|
||||
// Derive value of whole form
|
||||
$: formValue = deriveFormValue(initialValues, $values, $enrichments)
|
||||
|
||||
// Create data context to provide
|
||||
$: dataContext = {
|
||||
...formValue,
|
||||
|
||||
// These static values are prefixed to avoid clashes with actual columns
|
||||
__value: formValue,
|
||||
__valid: valid,
|
||||
__currentStep: $currentStep,
|
||||
__currentStepValid: $currentStepValid,
|
||||
}
|
||||
|
||||
// Generates a derived store from an array of fields, comprised of a map of
|
||||
// extracted values from the field array
|
||||
const deriveFieldProperty = (fieldStores, getProp) => {
|
||||
|
@ -78,6 +94,35 @@
|
|||
})
|
||||
}
|
||||
|
||||
// Derive the overall form value and deeply set all field paths so that we
|
||||
// can support things like JSON fields.
|
||||
const deriveFormValue = (initialValues, values, enrichments) => {
|
||||
let formValue = cloneDeep(initialValues || {})
|
||||
|
||||
// We need to sort the keys to avoid a JSON field overwriting a nested field
|
||||
const sortedFields = Object.entries(values || {})
|
||||
.map(([key, value]) => {
|
||||
const field = getField(key)
|
||||
return {
|
||||
key,
|
||||
value,
|
||||
lastUpdate: get(field).fieldState?.lastUpdate || 0,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return a.lastUpdate > b.lastUpdate
|
||||
})
|
||||
|
||||
// Merge all values and enrichments into a single value
|
||||
sortedFields.forEach(({ key, value }) => {
|
||||
deepSet(formValue, key, value)
|
||||
})
|
||||
Object.entries(enrichments || {}).forEach(([key, value]) => {
|
||||
deepSet(formValue, key, value)
|
||||
})
|
||||
return formValue
|
||||
}
|
||||
|
||||
// Searches the field array for a certain field
|
||||
const getField = name => {
|
||||
return fields.find(field => get(field).name === name)
|
||||
|
@ -97,13 +142,20 @@
|
|||
}
|
||||
|
||||
// If we've already registered this field then keep some existing state
|
||||
let initialValue = initialValues[field] ?? defaultValue
|
||||
let initialValue = deepGet(initialValues, field) ?? defaultValue
|
||||
let fieldId = `id-${generateID()}`
|
||||
const existingField = getField(field)
|
||||
if (existingField) {
|
||||
const { fieldState } = get(existingField)
|
||||
initialValue = fieldState.value ?? initialValue
|
||||
fieldId = fieldState.fieldId
|
||||
|
||||
// Use new default value if default value changed,
|
||||
// otherwise use the current value if possible
|
||||
if (defaultValue !== fieldState.defaultValue) {
|
||||
initialValue = defaultValue
|
||||
} else {
|
||||
initialValue = fieldState.value ?? initialValue
|
||||
}
|
||||
}
|
||||
|
||||
// Auto columns are always disabled
|
||||
|
@ -130,6 +182,7 @@
|
|||
disabled: disabled || fieldDisabled || isAutoColumn,
|
||||
defaultValue,
|
||||
validator,
|
||||
lastUpdate: Date.now(),
|
||||
},
|
||||
fieldApi: makeFieldApi(field, defaultValue),
|
||||
fieldSchema: schema?.[field] ?? {},
|
||||
|
@ -204,6 +257,7 @@
|
|||
fieldInfo.update(state => {
|
||||
state.fieldState.value = value
|
||||
state.fieldState.error = error
|
||||
state.fieldState.lastUpdate = Date.now()
|
||||
return state
|
||||
})
|
||||
|
||||
|
@ -220,6 +274,7 @@
|
|||
fieldInfo.update(state => {
|
||||
state.fieldState.value = newValue
|
||||
state.fieldState.error = null
|
||||
state.fieldState.lastUpdate = Date.now()
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
@ -299,18 +354,6 @@
|
|||
{ type: ActionTypes.ClearForm, callback: formApi.clear },
|
||||
{ type: ActionTypes.ChangeFormStep, callback: formApi.changeStep },
|
||||
]
|
||||
|
||||
// Create data context to provide
|
||||
$: dataContext = {
|
||||
...initialValues,
|
||||
...$values,
|
||||
...$enrichments,
|
||||
|
||||
// These static values are prefixed to avoid clashes with actual columns
|
||||
__valid: valid,
|
||||
__currentStep: $currentStep,
|
||||
__currentStepValid: $currentStepValid,
|
||||
}
|
||||
</script>
|
||||
|
||||
<Provider {actions} data={dataContext}>
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
<script>
|
||||
import { CoreTextArea } from "@budibase/bbui"
|
||||
import Field from "./Field.svelte"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
export let field
|
||||
export let label
|
||||
export let placeholder
|
||||
export let disabled = false
|
||||
export let defaultValue = ""
|
||||
|
||||
const component = getContext("component")
|
||||
const validation = [
|
||||
{
|
||||
constraint: "json",
|
||||
type: "json",
|
||||
error: "JSON syntax is invalid",
|
||||
},
|
||||
]
|
||||
let fieldState
|
||||
let fieldApi
|
||||
|
||||
$: height = $component.styles?.normal?.height || "124px"
|
||||
|
||||
const serialiseValue = value => {
|
||||
return JSON.stringify(value || undefined, null, 4) || ""
|
||||
}
|
||||
|
||||
const parseValue = value => {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch (error) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field
|
||||
{label}
|
||||
{field}
|
||||
{disabled}
|
||||
{validation}
|
||||
{defaultValue}
|
||||
type="json"
|
||||
bind:fieldState
|
||||
bind:fieldApi
|
||||
>
|
||||
{#if fieldState}
|
||||
<div style="--height: {height};">
|
||||
<CoreTextArea
|
||||
value={serialiseValue(fieldState.value)}
|
||||
on:change={e => fieldApi.setValue(parseValue(e.detail))}
|
||||
disabled={fieldState.disabled}
|
||||
error={fieldState.error}
|
||||
id={fieldState.fieldId}
|
||||
{placeholder}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Field>
|
||||
|
||||
<style>
|
||||
:global(.spectrum-Form-itemField .spectrum-Textfield--multiline) {
|
||||
min-height: calc(var(--height) - 24px);
|
||||
}
|
||||
:global(.spectrum-Form--labelsAbove
|
||||
.spectrum-Form-itemField
|
||||
.spectrum-Textfield--multiline) {
|
||||
min-height: calc(var(--height) - 24px);
|
||||
}
|
||||
</style>
|
|
@ -1,6 +1,7 @@
|
|||
<script>
|
||||
import { CoreTextArea } from "@budibase/bbui"
|
||||
import Field from "./Field.svelte"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
export let field
|
||||
export let label
|
||||
|
@ -11,6 +12,9 @@
|
|||
|
||||
let fieldState
|
||||
let fieldApi
|
||||
|
||||
const component = getContext("component")
|
||||
$: height = $component.styles?.normal?.height || "124px"
|
||||
</script>
|
||||
|
||||
<Field
|
||||
|
@ -24,13 +28,26 @@
|
|||
bind:fieldApi
|
||||
>
|
||||
{#if fieldState}
|
||||
<CoreTextArea
|
||||
value={fieldState.value}
|
||||
on:change={e => fieldApi.setValue(e.detail)}
|
||||
disabled={fieldState.disabled}
|
||||
error={fieldState.error}
|
||||
id={fieldState.fieldId}
|
||||
{placeholder}
|
||||
/>
|
||||
<div style="--height: {height};">
|
||||
<CoreTextArea
|
||||
value={fieldState.value}
|
||||
on:change={e => fieldApi.setValue(e.detail)}
|
||||
disabled={fieldState.disabled}
|
||||
error={fieldState.error}
|
||||
id={fieldState.fieldId}
|
||||
{placeholder}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Field>
|
||||
|
||||
<style>
|
||||
:global(.spectrum-Form-itemField .spectrum-Textfield--multiline) {
|
||||
min-height: calc(var(--height) - 24px);
|
||||
}
|
||||
:global(.spectrum-Form--labelsAbove
|
||||
.spectrum-Form-itemField
|
||||
.spectrum-Textfield--multiline) {
|
||||
min-height: calc(var(--height) - 24px);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -11,3 +11,4 @@ export { default as attachmentfield } from "./AttachmentField.svelte"
|
|||
export { default as relationshipfield } from "./RelationshipField.svelte"
|
||||
export { default as passwordfield } from "./PasswordField.svelte"
|
||||
export { default as formstep } from "./FormStep.svelte"
|
||||
export { default as jsonfield } from "./JSONField.svelte"
|
||||
|
|
|
@ -206,6 +206,7 @@ const parseType = (value, type) => {
|
|||
return value
|
||||
}
|
||||
|
||||
// Parse array, treating no elements as null
|
||||
if (type === FieldTypes.ARRAY) {
|
||||
if (!Array.isArray(value) || !value.length) {
|
||||
return null
|
||||
|
@ -213,6 +214,12 @@ const parseType = (value, type) => {
|
|||
return value
|
||||
}
|
||||
|
||||
// For JSON we don't touch the value at all as we want to verify it in its
|
||||
// raw form
|
||||
if (type === FieldTypes.JSON) {
|
||||
return value
|
||||
}
|
||||
|
||||
// If some unknown type, treat as null to avoid breaking validators
|
||||
return null
|
||||
}
|
||||
|
@ -290,6 +297,19 @@ const notContainsHandler = (value, rule) => {
|
|||
return !containsHandler(value, rule)
|
||||
}
|
||||
|
||||
// Evaluates a constraint that the value must be a valid json object
|
||||
const jsonHandler = value => {
|
||||
if (typeof value !== "object" || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
JSON.parse(JSON.stringify(value))
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of constraint types to handlers.
|
||||
*/
|
||||
|
@ -306,6 +326,7 @@ const handlerMap = {
|
|||
notRegex: notRegexHandler,
|
||||
contains: containsHandler,
|
||||
notContains: notContainsHandler,
|
||||
json: jsonHandler,
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -29,6 +29,7 @@ export { default as backgroundimage } from "./BackgroundImage.svelte"
|
|||
export { default as daterangepicker } from "./DateRangePicker.svelte"
|
||||
export { default as cardstat } from "./CardStat.svelte"
|
||||
export { default as spectrumcard } from "./SpectrumCard.svelte"
|
||||
export { default as tag } from "./Tag.svelte"
|
||||
export * from "./charts"
|
||||
export * from "./forms"
|
||||
export * from "./table"
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
class:flipped
|
||||
class:line
|
||||
style="top: {top}px; left: {left}px; width: {width}px; height: {height}px; --color: {color}; --zIndex: {zIndex};"
|
||||
class:withText={!!text}
|
||||
>
|
||||
{#if text}
|
||||
<div class="text" class:flipped class:line class:right={alignRight}>
|
||||
|
@ -39,12 +40,12 @@
|
|||
z-index: var(--zIndex);
|
||||
border: 2px solid var(--color);
|
||||
pointer-events: none;
|
||||
border-top-right-radius: 4px;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.indicator.flipped {
|
||||
.indicator.withText {
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
.indicator.withText.flipped {
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
.indicator.line {
|
||||
|
@ -74,8 +75,7 @@
|
|||
border-radius: 4px;
|
||||
}
|
||||
.text.flipped {
|
||||
border-top-left-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-radius: 4px;
|
||||
transform: translateY(0%);
|
||||
top: -2px;
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ export const FieldTypes = {
|
|||
ATTACHMENT: "attachment",
|
||||
LINK: "link",
|
||||
FORMULA: "formula",
|
||||
JSON: "json",
|
||||
}
|
||||
|
||||
export const UnsortableTypes = [
|
||||
|
|
|
@ -61,7 +61,8 @@ export const createDataSourceStore = () => {
|
|||
|
||||
// Emit this as a window event, so parent screens which are iframing us in
|
||||
// can also invalidate the same datasource
|
||||
if (get(routeStore).queryParams?.peek) {
|
||||
const inModal = get(routeStore).queryParams?.peek
|
||||
if (inModal) {
|
||||
window.parent.postMessage({
|
||||
type: "invalidate-datasource",
|
||||
detail: { dataSourceId },
|
||||
|
|
|
@ -8,20 +8,49 @@ import {
|
|||
} from "stores"
|
||||
import { saveRow, deleteRow, executeQuery, triggerAutomation } from "api"
|
||||
import { ActionTypes } from "constants"
|
||||
import { enrichDataBindings } from "./enrichDataBinding"
|
||||
import { deepSet } from "@budibase/bbui"
|
||||
|
||||
const saveRowHandler = async (action, context) => {
|
||||
const { fields, providerId, tableId } = action.parameters
|
||||
let payload
|
||||
if (providerId) {
|
||||
let draft = context[providerId]
|
||||
payload = { ...context[providerId] }
|
||||
} else {
|
||||
payload = {}
|
||||
}
|
||||
if (fields) {
|
||||
for (let [field, value] of Object.entries(fields)) {
|
||||
deepSet(payload, field, value)
|
||||
}
|
||||
}
|
||||
if (tableId) {
|
||||
payload.tableId = tableId
|
||||
}
|
||||
const row = await saveRow(payload)
|
||||
return {
|
||||
row,
|
||||
}
|
||||
}
|
||||
|
||||
const duplicateRowHandler = async (action, context) => {
|
||||
const { fields, providerId, tableId } = action.parameters
|
||||
if (providerId) {
|
||||
let payload = { ...context[providerId] }
|
||||
if (fields) {
|
||||
for (let [field, value] of Object.entries(fields)) {
|
||||
draft[field] = value
|
||||
deepSet(payload, field, value)
|
||||
}
|
||||
}
|
||||
if (tableId) {
|
||||
draft.tableId = tableId
|
||||
payload.tableId = tableId
|
||||
}
|
||||
delete payload._id
|
||||
delete payload._rev
|
||||
const row = await saveRow(payload)
|
||||
return {
|
||||
row,
|
||||
}
|
||||
await saveRow(draft)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -46,11 +75,12 @@ const navigationHandler = action => {
|
|||
|
||||
const queryExecutionHandler = async action => {
|
||||
const { datasourceId, queryId, queryParams } = action.parameters
|
||||
await executeQuery({
|
||||
const result = await executeQuery({
|
||||
datasourceId,
|
||||
queryId,
|
||||
parameters: queryParams,
|
||||
})
|
||||
return { result }
|
||||
}
|
||||
|
||||
const executeActionHandler = async (
|
||||
|
@ -129,6 +159,7 @@ const updateStateHandler = action => {
|
|||
|
||||
const handlerMap = {
|
||||
["Save Row"]: saveRowHandler,
|
||||
["Duplicate Row"]: duplicateRowHandler,
|
||||
["Delete Row"]: deleteRowHandler,
|
||||
["Navigate To"]: navigationHandler,
|
||||
["Execute Query"]: queryExecutionHandler,
|
||||
|
@ -165,12 +196,27 @@ export const enrichButtonActions = (actions, context) => {
|
|||
return actions
|
||||
}
|
||||
|
||||
// Button context is built up as actions are executed.
|
||||
// Inherit any previous button context which may have come from actions
|
||||
// before a confirmable action since this breaks the chain.
|
||||
let buttonContext = context.actions || []
|
||||
|
||||
const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
|
||||
return async () => {
|
||||
for (let i = 0; i < handlers.length; i++) {
|
||||
try {
|
||||
const action = actions[i]
|
||||
const callback = async () => handlers[i](action, context)
|
||||
// Skip any non-existent action definitions
|
||||
if (!handlers[i]) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Built total context for this action
|
||||
const totalContext = { ...context, actions: buttonContext }
|
||||
|
||||
// Get and enrich this button action with the total context
|
||||
let action = actions[i]
|
||||
action = enrichDataBindings(action, totalContext)
|
||||
const callback = async () => handlers[i](action, totalContext)
|
||||
|
||||
// If this action is confirmable, show confirmation and await a
|
||||
// callback to execute further actions
|
||||
|
@ -185,7 +231,15 @@ export const enrichButtonActions = (actions, context) => {
|
|||
// then execute the rest of the actions in the chain
|
||||
const result = await callback()
|
||||
if (result !== false) {
|
||||
const next = enrichButtonActions(actions.slice(i + 1), context)
|
||||
// Generate a new total context to pass into the next enrichment
|
||||
buttonContext.push(result)
|
||||
const newContext = { ...context, actions: buttonContext }
|
||||
|
||||
// Enrich and call the next button action
|
||||
const next = enrichButtonActions(
|
||||
actions.slice(i + 1),
|
||||
newContext
|
||||
)
|
||||
await next()
|
||||
}
|
||||
}
|
||||
|
@ -201,6 +255,8 @@ export const enrichButtonActions = (actions, context) => {
|
|||
const result = await callback()
|
||||
if (result === false) {
|
||||
return
|
||||
} else {
|
||||
buttonContext.push(result)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
@ -32,35 +32,56 @@ export const enrichProps = (props, context) => {
|
|||
data: context[context.closestComponentId],
|
||||
}
|
||||
|
||||
// Enrich all data bindings in top level props
|
||||
let enrichedProps = enrichDataBindings(props, totalContext)
|
||||
|
||||
// Enrich click actions if they exist
|
||||
Object.keys(enrichedProps).forEach(prop => {
|
||||
// We want to exclude any button actions from enrichment at this stage.
|
||||
// Extract top level button action settings.
|
||||
let normalProps = { ...props }
|
||||
let actionProps = {}
|
||||
Object.keys(normalProps).forEach(prop => {
|
||||
if (prop?.toLowerCase().includes("onclick")) {
|
||||
enrichedProps[prop] = enrichButtonActions(
|
||||
enrichedProps[prop],
|
||||
totalContext
|
||||
)
|
||||
actionProps[prop] = normalProps[prop]
|
||||
delete normalProps[prop]
|
||||
}
|
||||
})
|
||||
|
||||
// Enrich any click actions in conditions
|
||||
if (enrichedProps._conditions) {
|
||||
enrichedProps._conditions.forEach(condition => {
|
||||
if (condition.setting?.toLowerCase().includes("onclick")) {
|
||||
condition.settingValue = enrichButtonActions(
|
||||
condition.settingValue,
|
||||
totalContext
|
||||
)
|
||||
// Handle conditional UI separately after normal settings
|
||||
let conditions = normalProps._conditions
|
||||
delete normalProps._conditions
|
||||
|
||||
// If there is an onclick function in here then it won't be serialised
|
||||
// properly, and therefore will not be updated properly.
|
||||
// The solution to this is add a rand which will ensure diffs happen
|
||||
// every time.
|
||||
condition.rand = Math.random()
|
||||
// Enrich all props except button actions
|
||||
let enrichedProps = enrichDataBindings(normalProps, totalContext)
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
// Conditions
|
||||
if (conditions?.length) {
|
||||
let enrichedConditions = []
|
||||
conditions.forEach(condition => {
|
||||
if (condition.setting?.toLowerCase().includes("onclick")) {
|
||||
// Copy and remove the setting value from the condition as it needs
|
||||
// enriched separately
|
||||
let toEnrich = { ...condition }
|
||||
delete toEnrich.settingValue
|
||||
|
||||
// Join the condition back together
|
||||
enrichedConditions.push({
|
||||
...enrichDataBindings(toEnrich, totalContext),
|
||||
settingValue: enrichButtonActions(
|
||||
condition.settingValue,
|
||||
totalContext
|
||||
),
|
||||
rand: Math.random(),
|
||||
})
|
||||
} else {
|
||||
// Normal condition
|
||||
enrichedConditions.push(enrichDataBindings(condition, totalContext))
|
||||
}
|
||||
})
|
||||
enrichedProps._conditions = enrichedConditions
|
||||
}
|
||||
|
||||
return enrichedProps
|
||||
|
|
|
@ -305,6 +305,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@spectrum-css/tabs/-/tabs-3.1.5.tgz#cc82e69c1fc721902345178231fb95d05938b983"
|
||||
integrity sha512-UtfW8bA1quYnJM6v/lp6AVYGnQFkiUix2FHAf/4VHVrk4mh7ydtLiXS0IR3Kx+t/S8FWdSdSQHDZ8tHbY1ZLZg==
|
||||
|
||||
"@spectrum-css/tag@^3.1.4":
|
||||
version "3.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@spectrum-css/tag/-/tag-3.1.4.tgz#334384dd789ddf0562679cae62ef763883480ac5"
|
||||
integrity sha512-9dYBMhCEkjy+p75XJIfCA2/zU4JAqsJrL7fkYIDXakS6/BzeVtIvAW/6JaIHtLIA9lrj0Sn4m+ZjceKnZNIv1w==
|
||||
|
||||
"@spectrum-css/tags@^3.0.2":
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@spectrum-css/tags/-/tags-3.0.3.tgz#fc76d2735cdc442de91b7eb3bee49a928c0767ac"
|
||||
|
|
|
@ -50,10 +50,10 @@ exports.validate = async ({ appId, tableId, row, table }) => {
|
|||
const errors = {}
|
||||
for (let fieldName of Object.keys(table.schema)) {
|
||||
const constraints = cloneDeep(table.schema[fieldName].constraints)
|
||||
const type = table.schema[fieldName].type
|
||||
// special case for options, need to always allow unselected (null)
|
||||
if (
|
||||
table.schema[fieldName].type ===
|
||||
(FieldTypes.OPTIONS || FieldTypes.ARRAY) &&
|
||||
(type === FieldTypes.OPTIONS || type === FieldTypes.ARRAY) &&
|
||||
constraints.inclusion
|
||||
) {
|
||||
constraints.inclusion.push(null)
|
||||
|
@ -61,17 +61,20 @@ exports.validate = async ({ appId, tableId, row, table }) => {
|
|||
let res
|
||||
|
||||
// Validate.js doesn't seem to handle array
|
||||
if (
|
||||
table.schema[fieldName].type === FieldTypes.ARRAY &&
|
||||
row[fieldName] &&
|
||||
row[fieldName].length
|
||||
) {
|
||||
if (type === FieldTypes.ARRAY && row[fieldName] && row[fieldName].length) {
|
||||
row[fieldName].map(val => {
|
||||
if (!constraints.inclusion.includes(val)) {
|
||||
errors[fieldName] = "Field not in list"
|
||||
}
|
||||
})
|
||||
} else if (table.schema[fieldName].type === FieldTypes.FORMULA) {
|
||||
} else if (type === FieldTypes.JSON && typeof row[fieldName] === "string") {
|
||||
// this should only happen if there is an error
|
||||
try {
|
||||
JSON.parse(row[fieldName])
|
||||
} catch (err) {
|
||||
errors[fieldName] = [`Contains invalid JSON`]
|
||||
}
|
||||
} else if (type === FieldTypes.FORMULA) {
|
||||
res = validateJs.single(
|
||||
processStringSync(table.schema[fieldName].formula, row),
|
||||
constraints
|
||||
|
|
|
@ -81,6 +81,18 @@ const TYPE_TRANSFORM_MAP = {
|
|||
[FieldTypes.AUTO]: {
|
||||
parse: () => undefined,
|
||||
},
|
||||
[FieldTypes.JSON]: {
|
||||
parse: input => {
|
||||
try {
|
||||
if (input === "") {
|
||||
return undefined
|
||||
}
|
||||
return JSON.parse(input)
|
||||
} catch (err) {
|
||||
return input
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
const { atob } = require("../utilities")
|
||||
const { cloneDeep } = require("lodash/fp")
|
||||
|
||||
// The method of executing JS scripts depends on the bundle being built.
|
||||
// This setter is used in the entrypoint (either index.cjs or index.mjs).
|
||||
|
@ -38,8 +39,12 @@ module.exports.processJS = (handlebars, context) => {
|
|||
// This is required to allow the final `return` statement to be valid.
|
||||
const js = `function run(){${atob(handlebars)}};run();`
|
||||
|
||||
// Our $ context function gets a value from context
|
||||
const sandboxContext = { $: path => getContextValue(path, context) }
|
||||
// Our $ context function gets a value from context.
|
||||
// We clone the context to avoid mutation in the binding affecting real
|
||||
// app context.
|
||||
const sandboxContext = {
|
||||
$: path => getContextValue(path, cloneDeep(context)),
|
||||
}
|
||||
|
||||
// Create a sandbox with out context and run the JS
|
||||
return runJS(js, sandboxContext)
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
const handlebars = require("handlebars")
|
||||
const { registerAll } = require("./helpers/index")
|
||||
const processors = require("./processors")
|
||||
const { removeHandlebarsStatements, atob, btoa } = require("./utilities")
|
||||
const { atob, btoa } = require("./utilities")
|
||||
const manifest = require("../manifest.json")
|
||||
|
||||
const hbsInstance = handlebars.create()
|
||||
registerAll(hbsInstance)
|
||||
const hbsInstanceNoHelpers = handlebars.create()
|
||||
const defaultOpts = { noHelpers: false }
|
||||
|
||||
/**
|
||||
* utility function to check if the object is valid
|
||||
|
@ -28,11 +29,7 @@ function testObject(object) {
|
|||
* @param {object} opts optional - specify some options for processing.
|
||||
* @returns {Promise<object|array>} The structure input, as fully updated as possible.
|
||||
*/
|
||||
module.exports.processObject = async (
|
||||
object,
|
||||
context,
|
||||
opts = { noHelpers: false }
|
||||
) => {
|
||||
module.exports.processObject = async (object, context, opts) => {
|
||||
testObject(object)
|
||||
for (let key of Object.keys(object || {})) {
|
||||
if (object[key] != null) {
|
||||
|
@ -63,11 +60,7 @@ module.exports.processObject = async (
|
|||
* @param {object} opts optional - specify some options for processing.
|
||||
* @returns {Promise<string>} The enriched string, all templates should have been replaced if they can be.
|
||||
*/
|
||||
module.exports.processString = async (
|
||||
string,
|
||||
context,
|
||||
opts = { noHelpers: false }
|
||||
) => {
|
||||
module.exports.processString = async (string, context, opts) => {
|
||||
// TODO: carry out any async calls before carrying out async call
|
||||
return module.exports.processStringSync(string, context, opts)
|
||||
}
|
||||
|
@ -81,11 +74,7 @@ module.exports.processString = async (
|
|||
* @param {object} opts optional - specify some options for processing.
|
||||
* @returns {object|array} The structure input, as fully updated as possible.
|
||||
*/
|
||||
module.exports.processObjectSync = (
|
||||
object,
|
||||
context,
|
||||
opts = { noHelpers: false }
|
||||
) => {
|
||||
module.exports.processObjectSync = (object, context, opts) => {
|
||||
testObject(object)
|
||||
for (let key of Object.keys(object || {})) {
|
||||
let val = object[key]
|
||||
|
@ -106,26 +95,20 @@ module.exports.processObjectSync = (
|
|||
* @param {object} opts optional - specify some options for processing.
|
||||
* @returns {string} The enriched string, all templates should have been replaced if they can be.
|
||||
*/
|
||||
module.exports.processStringSync = (
|
||||
string,
|
||||
context,
|
||||
opts = { noHelpers: false }
|
||||
) => {
|
||||
if (!exports.isValid(string)) {
|
||||
return string
|
||||
}
|
||||
// take a copy of input incase error
|
||||
module.exports.processStringSync = (string, context, opts) => {
|
||||
opts = { ...defaultOpts, ...opts }
|
||||
|
||||
// take a copy of input in case of error
|
||||
const input = string
|
||||
if (typeof string !== "string") {
|
||||
throw "Cannot process non-string types."
|
||||
}
|
||||
try {
|
||||
const noHelpers = opts && opts.noHelpers
|
||||
// finalising adds a helper, can't do this with no helpers
|
||||
const shouldFinalise = !noHelpers
|
||||
const shouldFinalise = !opts.noHelpers
|
||||
string = processors.preprocess(string, shouldFinalise)
|
||||
// this does not throw an error when template can't be fulfilled, have to try correct beforehand
|
||||
const instance = noHelpers ? hbsInstanceNoHelpers : hbsInstance
|
||||
const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
|
||||
const template = instance.compile(string, {
|
||||
strict: false,
|
||||
})
|
||||
|
@ -136,7 +119,7 @@ module.exports.processStringSync = (
|
|||
})
|
||||
)
|
||||
} catch (err) {
|
||||
return removeHandlebarsStatements(input)
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -155,7 +138,8 @@ module.exports.makePropSafe = property => {
|
|||
* @param opts optional - specify some options for processing.
|
||||
* @returns {boolean} Whether or not the input string is valid.
|
||||
*/
|
||||
module.exports.isValid = (string, opts = { noHelpers: false }) => {
|
||||
module.exports.isValid = (string, opts) => {
|
||||
opts = { ...defaultOpts, ...opts }
|
||||
const validCases = [
|
||||
"string",
|
||||
"number",
|
||||
|
@ -169,7 +153,7 @@ module.exports.isValid = (string, opts = { noHelpers: false }) => {
|
|||
// don't really need a real context to check if its valid
|
||||
const context = {}
|
||||
try {
|
||||
const instance = opts && opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
|
||||
const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
|
||||
instance.compile(processors.preprocess(string, false))(context)
|
||||
return true
|
||||
} catch (err) {
|
||||
|
|
|
@ -10,7 +10,10 @@ module.exports.swapStrings = (string, start, length, swap) => {
|
|||
return string.slice(0, start) + swap + string.slice(start + length)
|
||||
}
|
||||
|
||||
module.exports.removeHandlebarsStatements = string => {
|
||||
module.exports.removeHandlebarsStatements = (
|
||||
string,
|
||||
replacement = "Invalid binding"
|
||||
) => {
|
||||
let regexp = new RegExp(exports.FIND_HBS_REGEX)
|
||||
let matches = string.match(regexp)
|
||||
if (matches == null) {
|
||||
|
@ -18,7 +21,7 @@ module.exports.removeHandlebarsStatements = string => {
|
|||
}
|
||||
for (let match of matches) {
|
||||
const idx = string.indexOf(match)
|
||||
string = exports.swapStrings(string, idx, match.length, "Invalid Binding")
|
||||
string = exports.swapStrings(string, idx, match.length, replacement)
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
|
|
@ -13,10 +13,14 @@ describe("test the custom helpers we have applied", () => {
|
|||
|
||||
describe("test that it can run without helpers", () => {
|
||||
it("should be able to run without helpers", async () => {
|
||||
const output = await processString("{{ avg 1 1 1 }}", {}, { noHelpers: true })
|
||||
const output = await processString(
|
||||
"{{ avg 1 1 1 }}",
|
||||
{},
|
||||
{ noHelpers: true }
|
||||
)
|
||||
const valid = await processString("{{ avg 1 1 1 }}", {})
|
||||
expect(valid).toBe("1")
|
||||
expect(output).toBe("Invalid Binding")
|
||||
expect(output).toBe("{{ avg 1 1 1 }}")
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -185,17 +189,22 @@ describe("test the date helpers", () => {
|
|||
|
||||
it("should test the timezone capabilities", async () => {
|
||||
const date = new Date(1611577535000)
|
||||
const output = await processString("{{ date time 'HH-mm-ss Z' 'America/New_York' }}", {
|
||||
time: date.toUTCString(),
|
||||
})
|
||||
const formatted = new dayjs(date).tz("America/New_York").format("HH-mm-ss Z")
|
||||
const output = await processString(
|
||||
"{{ date time 'HH-mm-ss Z' 'America/New_York' }}",
|
||||
{
|
||||
time: date.toUTCString(),
|
||||
}
|
||||
)
|
||||
const formatted = new dayjs(date)
|
||||
.tz("America/New_York")
|
||||
.format("HH-mm-ss Z")
|
||||
expect(output).toBe(formatted)
|
||||
})
|
||||
|
||||
it("should guess the users timezone when not specified", async () => {
|
||||
const date = new Date()
|
||||
const output = await processString("{{ date time 'Z' }}", {
|
||||
time: date.toUTCString()
|
||||
time: date.toUTCString(),
|
||||
})
|
||||
const timezone = dayjs.tz.guess()
|
||||
const offset = new dayjs(date).tz(timezone).format("Z")
|
||||
|
@ -307,12 +316,12 @@ describe("test the comparison helpers", () => {
|
|||
describe("Test the object/array helper", () => {
|
||||
it("should allow plucking from an array of objects", async () => {
|
||||
const context = {
|
||||
items: [
|
||||
{ price: 20 },
|
||||
{ price: 30 },
|
||||
]
|
||||
items: [{ price: 20 }, { price: 30 }],
|
||||
}
|
||||
const output = await processString("{{ literal ( sum ( pluck items 'price' ) ) }}", context)
|
||||
const output = await processString(
|
||||
"{{ literal ( sum ( pluck items 'price' ) ) }}",
|
||||
context
|
||||
)
|
||||
expect(output).toBe(50)
|
||||
})
|
||||
|
||||
|
@ -442,15 +451,15 @@ describe("Cover a few complex use cases", () => {
|
|||
|
||||
it("should only invalidate a single string in an object", async () => {
|
||||
const input = {
|
||||
dataProvider:"{{ literal [c670254c9e74e40518ee5becff53aa5be] }}",
|
||||
theme:"spectrum--lightest",
|
||||
showAutoColumns:false,
|
||||
quiet:true,
|
||||
size:"spectrum--medium",
|
||||
rowCount:8,
|
||||
dataProvider: "{{ literal [c670254c9e74e40518ee5becff53aa5be] }}",
|
||||
theme: "spectrum--lightest",
|
||||
showAutoColumns: false,
|
||||
quiet: true,
|
||||
size: "spectrum--medium",
|
||||
rowCount: 8,
|
||||
}
|
||||
const output = await processObject(input, tableJson)
|
||||
expect(output.dataProvider).not.toBe("Invalid Binding")
|
||||
expect(output.dataProvider).not.toBe("Invalid binding")
|
||||
})
|
||||
|
||||
it("should be able to handle external ids", async () => {
|
||||
|
|
|
@ -8,7 +8,7 @@ jest.mock("nodemailer")
|
|||
const nodemailer = require("nodemailer")
|
||||
nodemailer.createTransport.mockReturnValue({
|
||||
sendMail: sendMailMock,
|
||||
verify: jest.fn()
|
||||
verify: jest.fn(),
|
||||
})
|
||||
|
||||
describe("/api/global/email", () => {
|
||||
|
@ -39,6 +39,6 @@ describe("/api/global/email", () => {
|
|||
expect(sendMailMock).toHaveBeenCalled()
|
||||
const emailCall = sendMailMock.mock.calls[0][0]
|
||||
expect(emailCall.subject).toBe("Hello!")
|
||||
expect(emailCall.html).not.toContain("Invalid Binding")
|
||||
expect(emailCall.html).not.toContain("Invalid binding")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue