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
|
visible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKey(e) {
|
|
||||||
if (visible && e.key === "Escape") {
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={handleKey} />
|
|
||||||
|
|
||||||
{#if visible}
|
{#if visible}
|
||||||
<Portal>
|
<Portal>
|
||||||
<section class:fillWidth class="drawer" transition:slide>
|
<section class:fillWidth class="drawer" transition:slide>
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
import CellRenderer from "./CellRenderer.svelte"
|
import CellRenderer from "./CellRenderer.svelte"
|
||||||
import SelectEditRenderer from "./SelectEditRenderer.svelte"
|
import SelectEditRenderer from "./SelectEditRenderer.svelte"
|
||||||
import { cloneDeep } from "lodash"
|
import { cloneDeep } from "lodash"
|
||||||
|
import { deepGet } from "../utils/helpers"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The expected schema is our normal couch schemas for our tables.
|
* The expected schema is our normal couch schemas for our tables.
|
||||||
|
@ -318,7 +319,7 @@
|
||||||
{customRenderers}
|
{customRenderers}
|
||||||
{row}
|
{row}
|
||||||
schema={schema[field]}
|
schema={schema[field]}
|
||||||
value={row[field]}
|
value={deepGet(row, field)}
|
||||||
on:clickrelationship
|
on:clickrelationship
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
|
@ -77,3 +77,6 @@ export { default as clickOutside } from "./Actions/click_outside"
|
||||||
|
|
||||||
// Stores
|
// Stores
|
||||||
export { notifications, createNotificationStore } from "./Stores/notifications"
|
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)
|
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 => {
|
export const getComponentSettings = componentType => {
|
||||||
const def = store.actions.components.getDefinition(componentType)
|
if (!componentType) {
|
||||||
if (!def) {
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
let settings = def.settings?.filter(setting => !setting.section) ?? []
|
|
||||||
|
// 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
|
def.settings
|
||||||
?.filter(setting => setting.section)
|
?.filter(setting => setting.section)
|
||||||
.forEach(section => {
|
.forEach(section => {
|
||||||
settings = settings.concat(section.settings || [])
|
settings = settings.concat(section.settings || [])
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
componentSettingCache[componentType] = settings
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
}
|
}
|
|
@ -5,7 +5,7 @@ import {
|
||||||
findComponent,
|
findComponent,
|
||||||
findComponentPath,
|
findComponentPath,
|
||||||
getComponentSettings,
|
getComponentSettings,
|
||||||
} from "./storeUtils"
|
} from "./componentUtils"
|
||||||
import { store } from "builderStore"
|
import { store } from "builderStore"
|
||||||
import { queries as queriesStores, tables as tablesStore } from "stores/backend"
|
import { queries as queriesStores, tables as tablesStore } from "stores/backend"
|
||||||
import {
|
import {
|
||||||
|
@ -15,6 +15,11 @@ import {
|
||||||
encodeJSBinding,
|
encodeJSBinding,
|
||||||
} from "@budibase/string-templates"
|
} from "@budibase/string-templates"
|
||||||
import { TableNames } from "../constants"
|
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
|
// Regex to match all instances of template strings
|
||||||
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
|
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
|
||||||
|
@ -186,6 +191,7 @@ const getProviderContextBindings = (asset, dataProviders) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
let schema
|
let schema
|
||||||
|
let table
|
||||||
let readablePrefix
|
let readablePrefix
|
||||||
let runtimeSuffix = context.suffix
|
let runtimeSuffix = context.suffix
|
||||||
|
|
||||||
|
@ -209,8 +215,17 @@ const getProviderContextBindings = (asset, dataProviders) => {
|
||||||
}
|
}
|
||||||
const info = getSchemaForDatasource(asset, datasource)
|
const info = getSchemaForDatasource(asset, datasource)
|
||||||
schema = info.schema
|
schema = info.schema
|
||||||
|
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
|
readablePrefix = info.table?.name
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -229,7 +244,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
|
||||||
const fieldSchema = schema[key]
|
const fieldSchema = schema[key]
|
||||||
|
|
||||||
// Make safe runtime binding
|
// 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
|
// Optionally use a prefix with readable bindings
|
||||||
let readableBinding = component._instanceName
|
let readableBinding = component._instanceName
|
||||||
|
@ -247,6 +263,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
|
||||||
// datasource options, based on bindable properties
|
// datasource options, based on bindable properties
|
||||||
fieldSchema,
|
fieldSchema,
|
||||||
providerId,
|
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.
|
* Gets a schema for a datasource object.
|
||||||
*/
|
*/
|
||||||
|
@ -347,16 +395,26 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
|
||||||
|
|
||||||
if (datasource) {
|
if (datasource) {
|
||||||
const { type } = 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") {
|
if (type === "provider") {
|
||||||
const component = findComponent(asset.props, datasource.providerId)
|
const component = findComponent(asset.props, datasource.providerId)
|
||||||
const source = getDatasourceForProvider(asset, component)
|
const source = getDatasourceForProvider(asset, component)
|
||||||
return getSchemaForDatasource(asset, source, isForm)
|
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
|
const queries = get(queriesStores).list
|
||||||
table = queries.find(query => query._id === datasource._id)
|
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 }
|
table = { name: datasource.fieldName }
|
||||||
const { fieldType } = datasource
|
const { fieldType } = datasource
|
||||||
if (fieldType === "attachment") {
|
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)
|
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 (table && !schema) {
|
||||||
if (type === "view") {
|
if (type === "view") {
|
||||||
schema = cloneDeep(table.views?.[datasource.name]?.schema)
|
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
|
// Add _id and _rev fields for certain types
|
||||||
if (schema && !isForm && ["table", "link"].includes(datasource.type)) {
|
if (schema && !isForm && ["table", "link"].includes(datasource.type)) {
|
||||||
schema["_id"] = { type: "string" }
|
schema["_id"] = { type: "string" }
|
||||||
|
@ -450,15 +538,58 @@ const buildFormSchema = component => {
|
||||||
return schema
|
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.
|
* 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)) {
|
for (let [key, value] of Object.entries(obj)) {
|
||||||
if (value && typeof value === "object") {
|
if (value && typeof value === "object") {
|
||||||
obj[key] = removeBindings(value)
|
obj[key] = removeBindings(value, replacement)
|
||||||
} else if (typeof value === "string") {
|
} else if (typeof value === "string") {
|
||||||
obj[key] = value.replace(CAPTURE_HBS_TEMPLATE, "Invalid binding")
|
obj[key] = value.replace(CAPTURE_HBS_TEMPLATE, replacement)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return obj
|
return obj
|
||||||
|
@ -468,8 +599,8 @@ export function removeBindings(obj) {
|
||||||
* When converting from readable to runtime it can sometimes add too many square brackets,
|
* When converting from readable to runtime it can sometimes add too many square brackets,
|
||||||
* this makes sure that doesn't happen.
|
* this makes sure that doesn't happen.
|
||||||
*/
|
*/
|
||||||
function shouldReplaceBinding(currentValue, from, convertTo) {
|
const shouldReplaceBinding = (currentValue, convertFrom, convertTo) => {
|
||||||
if (!currentValue?.includes(from)) {
|
if (!currentValue?.includes(convertFrom)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (convertTo === "readableBinding") {
|
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
|
// remove all the spaces, if the input is surrounded by spaces e.g. [ Auto ID ] then
|
||||||
// this makes sure it is detected
|
// this makes sure it is detected
|
||||||
const noSpaces = currentValue.replace(/\s+/g, "")
|
const noSpaces = currentValue.replace(/\s+/g, "")
|
||||||
const fromNoSpaces = from.replace(/\s+/g, "")
|
const fromNoSpaces = convertFrom.replace(/\s+/g, "")
|
||||||
const invalids = [
|
const invalids = [
|
||||||
`[${fromNoSpaces}]`,
|
`[${fromNoSpaces}]`,
|
||||||
`"${fromNoSpaces}"`,
|
`"${fromNoSpaces}"`,
|
||||||
|
@ -487,14 +618,21 @@ function shouldReplaceBinding(currentValue, from, convertTo) {
|
||||||
return !invalids.find(invalid => noSpaces?.includes(invalid))
|
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)
|
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
|
// Decide from base64 if using JS
|
||||||
const isJS = isJSBinding(textWithBindings)
|
const isJS = isJSBinding(textWithBindings)
|
||||||
if (isJS) {
|
if (isJS) {
|
||||||
|
@ -559,14 +697,17 @@ function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
|
||||||
* Extracts a component ID from a handlebars expression setting of
|
* Extracts a component ID from a handlebars expression setting of
|
||||||
* {{ literal [componentId] }}
|
* {{ literal [componentId] }}
|
||||||
*/
|
*/
|
||||||
function extractLiteralHandlebarsID(value) {
|
const extractLiteralHandlebarsID = value => {
|
||||||
return value?.match(/{{\s*literal\s*\[+([^\]]+)].*}}/)?.[1]
|
return value?.match(/{{\s*literal\s*\[+([^\]]+)].*}}/)?.[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a readable data binding into a runtime data binding
|
* Converts a readable data binding into a runtime data binding
|
||||||
*/
|
*/
|
||||||
export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
|
export const readableToRuntimeBinding = (
|
||||||
|
bindableProperties,
|
||||||
|
textWithBindings
|
||||||
|
) => {
|
||||||
return bindingReplacement(
|
return bindingReplacement(
|
||||||
bindableProperties,
|
bindableProperties,
|
||||||
textWithBindings,
|
textWithBindings,
|
||||||
|
@ -577,56 +718,13 @@ export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
|
||||||
/**
|
/**
|
||||||
* Converts a runtime data binding into a readable data binding
|
* Converts a runtime data binding into a readable data binding
|
||||||
*/
|
*/
|
||||||
export function runtimeToReadableBinding(bindableProperties, textWithBindings) {
|
export const runtimeToReadableBinding = (
|
||||||
|
bindableProperties,
|
||||||
|
textWithBindings
|
||||||
|
) => {
|
||||||
return bindingReplacement(
|
return bindingReplacement(
|
||||||
bindableProperties,
|
bindableProperties,
|
||||||
textWithBindings,
|
textWithBindings,
|
||||||
"readableBinding"
|
"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 { getThemeStore } from "./store/theme"
|
||||||
import { derived, writable } from "svelte/store"
|
import { derived, writable } from "svelte/store"
|
||||||
import { FrontendTypes, LAYOUT_NAMES } from "../constants"
|
import { FrontendTypes, LAYOUT_NAMES } from "../constants"
|
||||||
import { findComponent } from "./storeUtils"
|
import { findComponent } from "./componentUtils"
|
||||||
|
|
||||||
export const store = getFrontendStore()
|
export const store = getFrontendStore()
|
||||||
export const automationStore = getAutomationStore()
|
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,
|
findAllMatchingComponents,
|
||||||
findComponent,
|
findComponent,
|
||||||
getComponentSettings,
|
getComponentSettings,
|
||||||
} from "../storeUtils"
|
} from "../componentUtils"
|
||||||
import { uuid } from "../uuid"
|
import { uuid } from "../uuid"
|
||||||
import { removeBindings } from "../dataBinding"
|
import { removeBindings } from "../dataBinding"
|
||||||
|
|
||||||
|
@ -329,12 +329,12 @@ export const getFrontendStore = () => {
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
select: component => {
|
select: component => {
|
||||||
if (!component) {
|
const asset = get(currentAsset)
|
||||||
|
if (!asset || !component) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this is the root component, select the asset instead
|
// If this is the root component, select the asset instead
|
||||||
const asset = get(currentAsset)
|
|
||||||
const parent = findComponentParent(asset.props, component._id)
|
const parent = findComponentParent(asset.props, component._id)
|
||||||
if (parent == null) {
|
if (parent == null) {
|
||||||
const state = get(store)
|
const state = get(store)
|
||||||
|
@ -537,7 +537,7 @@ export const getFrontendStore = () => {
|
||||||
|
|
||||||
// immediately need to remove bindings, currently these aren't valid when pasted
|
// immediately need to remove bindings, currently these aren't valid when pasted
|
||||||
if (!cut && !preserveBindings) {
|
if (!cut && !preserveBindings) {
|
||||||
state.componentToPaste = removeBindings(state.componentToPaste)
|
state.componentToPaste = removeBindings(state.componentToPaste, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone the component to paste
|
// Clone the component to paste
|
||||||
|
|
|
@ -137,6 +137,7 @@ const fieldTypeToComponentMap = {
|
||||||
datetime: "datetimefield",
|
datetime: "datetimefield",
|
||||||
attachment: "attachmentfield",
|
attachment: "attachmentfield",
|
||||||
link: "relationshipfield",
|
link: "relationshipfield",
|
||||||
|
json: "jsonfield",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeDatasourceFormComponents(datasource) {
|
export function makeDatasourceFormComponents(datasource) {
|
||||||
|
@ -146,7 +147,7 @@ export function makeDatasourceFormComponents(datasource) {
|
||||||
fields.forEach(field => {
|
fields.forEach(field => {
|
||||||
const fieldSchema = schema[field]
|
const fieldSchema = schema[field]
|
||||||
// skip autocolumns
|
// skip autocolumns
|
||||||
if (fieldSchema.autocolumn) {
|
if (fieldSchema.autocolumn || fieldSchema.nestedJSON) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const fieldType =
|
const fieldType =
|
||||||
|
|
|
@ -6,16 +6,20 @@
|
||||||
Toggle,
|
Toggle,
|
||||||
TextArea,
|
TextArea,
|
||||||
Multiselect,
|
Multiselect,
|
||||||
|
Label,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import Dropzone from "components/common/Dropzone.svelte"
|
import Dropzone from "components/common/Dropzone.svelte"
|
||||||
import { capitalise } from "helpers"
|
import { capitalise } from "helpers"
|
||||||
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
|
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
|
||||||
|
import Editor from "../../integration/QueryEditor.svelte"
|
||||||
|
|
||||||
export let defaultValue
|
export let defaultValue
|
||||||
export let meta
|
export let meta
|
||||||
export let value = defaultValue || (meta.type === "boolean" ? false : "")
|
export let value = defaultValue || (meta.type === "boolean" ? false : "")
|
||||||
export let readonly
|
export let readonly
|
||||||
|
|
||||||
|
$: stringVal =
|
||||||
|
typeof value === "object" ? JSON.stringify(value, null, 2) : value
|
||||||
$: type = meta?.type
|
$: type = meta?.type
|
||||||
$: label = meta.name ? capitalise(meta.name) : ""
|
$: label = meta.name ? capitalise(meta.name) : ""
|
||||||
</script>
|
</script>
|
||||||
|
@ -40,6 +44,14 @@
|
||||||
<LinkedRowSelector bind:linkedRows={value} schema={meta} />
|
<LinkedRowSelector bind:linkedRows={value} schema={meta} />
|
||||||
{:else if type === "longform"}
|
{:else if type === "longform"}
|
||||||
<TextArea {label} bind:value />
|
<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}
|
{:else}
|
||||||
<Input
|
<Input
|
||||||
{label}
|
{label}
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
DatePicker,
|
DatePicker,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
Context,
|
Context,
|
||||||
|
Modal,
|
||||||
notifications,
|
notifications,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
|
@ -32,12 +33,14 @@
|
||||||
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
|
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
|
||||||
import { getBindings } from "components/backend/DataTable/formula"
|
import { getBindings } from "components/backend/DataTable/formula"
|
||||||
import { getContext } from "svelte"
|
import { getContext } from "svelte"
|
||||||
|
import JSONSchemaModal from "./JSONSchemaModal.svelte"
|
||||||
|
|
||||||
const AUTO_TYPE = "auto"
|
const AUTO_TYPE = "auto"
|
||||||
const FORMULA_TYPE = FIELDS.FORMULA.type
|
const FORMULA_TYPE = FIELDS.FORMULA.type
|
||||||
const LINK_TYPE = FIELDS.LINK.type
|
const LINK_TYPE = FIELDS.LINK.type
|
||||||
const STRING_TYPE = FIELDS.STRING.type
|
const STRING_TYPE = FIELDS.STRING.type
|
||||||
const NUMBER_TYPE = FIELDS.NUMBER.type
|
const NUMBER_TYPE = FIELDS.NUMBER.type
|
||||||
|
const JSON_TYPE = FIELDS.JSON.type
|
||||||
const DATE_TYPE = FIELDS.DATETIME.type
|
const DATE_TYPE = FIELDS.DATETIME.type
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
@ -64,6 +67,7 @@
|
||||||
let confirmDeleteDialog
|
let confirmDeleteDialog
|
||||||
let deletion
|
let deletion
|
||||||
let deleteColName
|
let deleteColName
|
||||||
|
let jsonSchemaModal
|
||||||
|
|
||||||
$: checkConstraints(field)
|
$: checkConstraints(field)
|
||||||
$: required = !!field?.constraints?.presence || primaryDisplay
|
$: required = !!field?.constraints?.presence || primaryDisplay
|
||||||
|
@ -79,10 +83,14 @@
|
||||||
// used to select what different options can be displayed for column type
|
// used to select what different options can be displayed for column type
|
||||||
$: canBeSearched =
|
$: canBeSearched =
|
||||||
field.type !== LINK_TYPE &&
|
field.type !== LINK_TYPE &&
|
||||||
|
field.type !== JSON_TYPE &&
|
||||||
field.subtype !== AUTO_COLUMN_SUB_TYPES.CREATED_BY &&
|
field.subtype !== AUTO_COLUMN_SUB_TYPES.CREATED_BY &&
|
||||||
field.subtype !== AUTO_COLUMN_SUB_TYPES.UPDATED_BY &&
|
field.subtype !== AUTO_COLUMN_SUB_TYPES.UPDATED_BY &&
|
||||||
field.type !== FORMULA_TYPE
|
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 =
|
$: canBeRequired =
|
||||||
field.type !== LINK_TYPE && !uneditable && field.type !== AUTO_TYPE
|
field.type !== LINK_TYPE && !uneditable && field.type !== AUTO_TYPE
|
||||||
$: relationshipOptions = getRelationshipOptions(field)
|
$: relationshipOptions = getRelationshipOptions(field)
|
||||||
|
@ -176,6 +184,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openJsonSchemaEditor() {
|
||||||
|
jsonSchemaModal.show()
|
||||||
|
}
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
confirmDeleteDialog.show()
|
confirmDeleteDialog.show()
|
||||||
deletion = true
|
deletion = true
|
||||||
|
@ -430,6 +442,10 @@
|
||||||
getOptionLabel={option => option[1].name}
|
getOptionLabel={option => option[1].name}
|
||||||
getOptionValue={option => option[0]}
|
getOptionValue={option => option[0]}
|
||||||
/>
|
/>
|
||||||
|
{:else if field.type === JSON_TYPE}
|
||||||
|
<Button primary text on:click={openJsonSchemaEditor}
|
||||||
|
>Open schema editor</Button
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div slot="footer">
|
<div slot="footer">
|
||||||
|
@ -438,6 +454,16 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</ModalContent>
|
</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
|
<ConfirmDialog
|
||||||
bind:this={confirmDeleteDialog}
|
bind:this={confirmDeleteDialog}
|
||||||
okText="Delete Column"
|
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,
|
notifications,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import ErrorSVG from "assets/error.svg?raw"
|
import ErrorSVG from "assets/error.svg?raw"
|
||||||
import { findComponent, findComponentPath } from "builderStore/storeUtils"
|
import { findComponent, findComponentPath } from "builderStore/componentUtils"
|
||||||
|
|
||||||
let iframe
|
let iframe
|
||||||
let layout
|
let layout
|
||||||
|
@ -69,15 +69,7 @@
|
||||||
previewDevice: $store.previewDevice,
|
previewDevice: $store.previewDevice,
|
||||||
messagePassing: $store.clientFeatures.messagePassing,
|
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)
|
$: json = JSON.stringify(previewData)
|
||||||
$: strippedJson = json.replace(/"_rev":\s*"[^"]+"/g, `"_rev":""`)
|
|
||||||
|
|
||||||
// Update the iframe with the builder info to render the correct preview
|
// Update the iframe with the builder info to render the correct preview
|
||||||
const refreshContent = message => {
|
const refreshContent = message => {
|
||||||
|
@ -87,7 +79,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the preview when required
|
// Refresh the preview when required
|
||||||
$: refreshContent(strippedJson)
|
$: refreshContent(json)
|
||||||
|
|
||||||
function receiveMessage(message) {
|
function receiveMessage(message) {
|
||||||
const handlers = {
|
const handlers = {
|
||||||
|
@ -102,7 +94,7 @@
|
||||||
if (!$store.clientFeatures.intelligentLoading) {
|
if (!$store.clientFeatures.intelligentLoading) {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
refreshContent(strippedJson)
|
refreshContent(json)
|
||||||
},
|
},
|
||||||
[MessageTypes.ERROR]: event => {
|
[MessageTypes.ERROR]: event => {
|
||||||
// Catch any app errors
|
// Catch any app errors
|
||||||
|
|
|
@ -43,7 +43,8 @@
|
||||||
"attachmentfield",
|
"attachmentfield",
|
||||||
"relationshipfield",
|
"relationshipfield",
|
||||||
"daterangepicker",
|
"daterangepicker",
|
||||||
"multifieldselect"
|
"multifieldselect",
|
||||||
|
"jsonfield"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -73,6 +74,7 @@
|
||||||
"heading",
|
"heading",
|
||||||
"text",
|
"text",
|
||||||
"button",
|
"button",
|
||||||
|
"tag",
|
||||||
"divider",
|
"divider",
|
||||||
"image",
|
"image",
|
||||||
"backgroundimage",
|
"backgroundimage",
|
||||||
|
|
|
@ -49,6 +49,7 @@ export default `
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(event.data)
|
parsed = JSON.parse(event.data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Client received invalid JSON")
|
||||||
// Ignore
|
// Ignore
|
||||||
}
|
}
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
import { store, currentAsset } from "builderStore"
|
import { store, currentAsset } from "builderStore"
|
||||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
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"
|
import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui"
|
||||||
|
|
||||||
export let component
|
export let component
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { writable, get } from "svelte/store"
|
import { writable, get } from "svelte/store"
|
||||||
import { store as frontendStore } from "builderStore"
|
import { store as frontendStore } from "builderStore"
|
||||||
import { findComponentPath } from "builderStore/storeUtils"
|
import { findComponentPath } from "builderStore/componentUtils"
|
||||||
|
|
||||||
export const DropEffect = {
|
export const DropEffect = {
|
||||||
MOVE: "move",
|
MOVE: "move",
|
||||||
|
|
|
@ -63,7 +63,14 @@
|
||||||
// If no specific value is depended upon, check if a value exists at all
|
// If no specific value is depended upon, check if a value exists at all
|
||||||
// for the dependent setting
|
// for the dependent setting
|
||||||
if (dependantValue == null) {
|
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
|
// Otherwise check the value matches
|
||||||
|
|
|
@ -9,8 +9,9 @@
|
||||||
ActionMenu,
|
ActionMenu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { getAvailableActions } from "./actions"
|
import { getAvailableActions } from "./index"
|
||||||
import { generate } from "shortid"
|
import { generate } from "shortid"
|
||||||
|
import { getButtonContextBindings } from "builderStore/dataBinding"
|
||||||
|
|
||||||
const flipDurationMs = 150
|
const flipDurationMs = 150
|
||||||
const EVENT_TYPE_KEY = "##eventHandlerType"
|
const EVENT_TYPE_KEY = "##eventHandlerType"
|
||||||
|
@ -19,7 +20,16 @@
|
||||||
export let actions
|
export let actions
|
||||||
export let bindings = []
|
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) {
|
if (actions) {
|
||||||
actions.forEach(action => {
|
actions.forEach(action => {
|
||||||
|
@ -30,8 +40,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedAction = actions?.length ? actions[0] : null
|
|
||||||
|
|
||||||
$: selectedActionComponent =
|
$: selectedActionComponent =
|
||||||
selectedAction &&
|
selectedAction &&
|
||||||
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_KEY])?.component
|
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_KEY])?.component
|
||||||
|
@ -122,7 +130,7 @@
|
||||||
<svelte:component
|
<svelte:component
|
||||||
this={selectedActionComponent}
|
this={selectedActionComponent}
|
||||||
parameters={selectedAction.parameters}
|
parameters={selectedAction.parameters}
|
||||||
{bindings}
|
bindings={allBindings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
|
@ -2,7 +2,7 @@
|
||||||
import { ActionButton, Button, Drawer } from "@budibase/bbui"
|
import { ActionButton, Button, Drawer } from "@budibase/bbui"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
import { notifications } from "@budibase/bbui"
|
import { notifications } from "@budibase/bbui"
|
||||||
import EventEditor from "./EventEditor.svelte"
|
import ButtonActionDrawer from "./ButtonActionDrawer.svelte"
|
||||||
import { automationStore } from "builderStore"
|
import { automationStore } from "builderStore"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
|
|
||||||
|
@ -67,7 +67,7 @@
|
||||||
Define what actions to run.
|
Define what actions to run.
|
||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
<Button cta slot="buttons" on:click={saveEventData}>Save</Button>
|
<Button cta slot="buttons" on:click={saveEventData}>Save</Button>
|
||||||
<EventEditor
|
<ButtonActionDrawer
|
||||||
slot="body"
|
slot="body"
|
||||||
bind:actions={tmpValue}
|
bind:actions={tmpValue}
|
||||||
eventType={name}
|
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 { selectedComponent } from "builderStore"
|
||||||
import { getComponentForSettingType } from "./componentSettings"
|
import { getComponentForSettingType } from "./componentSettings"
|
||||||
import PropertyControl from "./PropertyControl.svelte"
|
import PropertyControl from "./PropertyControl.svelte"
|
||||||
import { getComponentSettings } from "builderStore/storeUtils"
|
import { getComponentSettings } from "builderStore/componentUtils"
|
||||||
|
|
||||||
export let conditions = []
|
export let conditions = []
|
||||||
export let bindings = []
|
export let bindings = []
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import { Select } from "@budibase/bbui"
|
import { Select } from "@budibase/bbui"
|
||||||
import { makePropSafe } from "@budibase/string-templates"
|
import { makePropSafe } from "@budibase/string-templates"
|
||||||
import { currentAsset, store } from "builderStore"
|
import { currentAsset, store } from "builderStore"
|
||||||
import { findComponentPath } from "builderStore/storeUtils"
|
import { findComponentPath } from "builderStore/componentUtils"
|
||||||
import { createEventDispatcher, onMount } from "svelte"
|
import { createEventDispatcher, onMount } from "svelte"
|
||||||
|
|
||||||
export let value
|
export let value
|
||||||
|
|
|
@ -48,9 +48,7 @@
|
||||||
return [...acc, ...viewsArr]
|
return [...acc, ...viewsArr]
|
||||||
}, [])
|
}, [])
|
||||||
$: queries = $queriesStore.list
|
$: queries = $queriesStore.list
|
||||||
.filter(
|
.filter(q => showAllQueries || q.queryVerb === "read" || q.readable)
|
||||||
query => showAllQueries || query.queryVerb === "read" || query.readable
|
|
||||||
)
|
|
||||||
.map(query => ({
|
.map(query => ({
|
||||||
label: query.name,
|
label: query.name,
|
||||||
name: query.name,
|
name: query.name,
|
||||||
|
@ -104,6 +102,22 @@
|
||||||
value: `{{ literal ${runtimeBinding} }}`,
|
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 => {
|
const handleSelected = selected => {
|
||||||
dispatch("change", selected)
|
dispatch("change", selected)
|
||||||
|
@ -230,6 +244,17 @@
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/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}
|
{#if dataProviders?.length}
|
||||||
<Divider size="S" />
|
<Divider size="S" />
|
||||||
<div class="title">
|
<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 panel = ClientBindingPanel
|
||||||
export let allowBindings = true
|
export let allowBindings = true
|
||||||
|
|
||||||
const BannedTypes = ["link", "attachment", "formula"]
|
const BannedTypes = ["link", "attachment", "formula", "json", "jsonarray"]
|
||||||
|
|
||||||
$: fieldOptions = (schemaFields ?? [])
|
$: fieldOptions = (schemaFields ?? [])
|
||||||
.filter(field => !BannedTypes.includes(field.type))
|
.filter(field => !BannedTypes.includes(field.type))
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
getSchemaForDatasource,
|
getSchemaForDatasource,
|
||||||
} from "builderStore/dataBinding"
|
} from "builderStore/dataBinding"
|
||||||
import { currentAsset } from "builderStore"
|
import { currentAsset } from "builderStore"
|
||||||
import { findClosestMatchingComponent } from "builderStore/storeUtils"
|
import { findClosestMatchingComponent } from "builderStore/componentUtils"
|
||||||
|
|
||||||
export let componentInstance
|
export let componentInstance
|
||||||
export let value
|
export let value
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { ActionButton } from "@budibase/bbui"
|
import { ActionButton } from "@budibase/bbui"
|
||||||
import { currentAsset, store } from "builderStore"
|
import { currentAsset, store } from "builderStore"
|
||||||
import { findClosestMatchingComponent } from "builderStore/storeUtils"
|
import { findClosestMatchingComponent } from "builderStore/componentUtils"
|
||||||
import { makeDatasourceFormComponents } from "builderStore/store/screenTemplates/utils/commonComponents"
|
import { makeDatasourceFormComponents } from "builderStore/store/screenTemplates/utils/commonComponents"
|
||||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
DatePicker,
|
DatePicker,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { currentAsset, selectedComponent } from "builderStore"
|
import { currentAsset, selectedComponent } from "builderStore"
|
||||||
import { findClosestMatchingComponent } from "builderStore/storeUtils"
|
import { findClosestMatchingComponent } from "builderStore/componentUtils"
|
||||||
import { getSchemaForDatasource } from "builderStore/dataBinding"
|
import { getSchemaForDatasource } from "builderStore/dataBinding"
|
||||||
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
||||||
import { generate } from "shortid"
|
import { generate } from "shortid"
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Checkbox, Select, Stepper } from "@budibase/bbui"
|
import { Checkbox, Select, Stepper } from "@budibase/bbui"
|
||||||
import DataSourceSelect from "./DataSourceSelect.svelte"
|
import DataSourceSelect from "./DataSourceSelect.svelte"
|
||||||
import DataProviderSelect from "./DataProviderSelect.svelte"
|
import DataProviderSelect from "./DataProviderSelect.svelte"
|
||||||
import EventsEditor from "./EventsEditor"
|
import ButtonActionEditor from "./ButtonActionEditor/ButtonActionEditor.svelte"
|
||||||
import TableSelect from "./TableSelect.svelte"
|
import TableSelect from "./TableSelect.svelte"
|
||||||
import ColorPicker from "./ColorPicker.svelte"
|
import ColorPicker from "./ColorPicker.svelte"
|
||||||
import { IconSelect } from "./IconSelect"
|
import { IconSelect } from "./IconSelect"
|
||||||
|
@ -24,7 +24,7 @@ const componentMap = {
|
||||||
dataProvider: DataProviderSelect,
|
dataProvider: DataProviderSelect,
|
||||||
boolean: Checkbox,
|
boolean: Checkbox,
|
||||||
number: Stepper,
|
number: Stepper,
|
||||||
event: EventsEditor,
|
event: ButtonActionEditor,
|
||||||
table: TableSelect,
|
table: TableSelect,
|
||||||
color: ColorPicker,
|
color: ColorPicker,
|
||||||
icon: IconSelect,
|
icon: IconSelect,
|
||||||
|
@ -45,6 +45,7 @@ const componentMap = {
|
||||||
"field/attachment": FormFieldSelect,
|
"field/attachment": FormFieldSelect,
|
||||||
"field/link": FormFieldSelect,
|
"field/link": FormFieldSelect,
|
||||||
"field/array": FormFieldSelect,
|
"field/array": FormFieldSelect,
|
||||||
|
"field/json": FormFieldSelect,
|
||||||
// Some validation types are the same as others, so not all types are
|
// Some validation types are the same as others, so not all types are
|
||||||
// explicitly listed here. e.g. options uses string validation
|
// explicitly listed here. e.g. options uses string validation
|
||||||
"validation/string": ValidationEditor,
|
"validation/string": ValidationEditor,
|
||||||
|
|
|
@ -89,6 +89,14 @@ export const FIELDS = {
|
||||||
presence: false,
|
presence: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
JSON: {
|
||||||
|
name: "JSON",
|
||||||
|
type: "json",
|
||||||
|
constraints: {
|
||||||
|
type: "object",
|
||||||
|
presence: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AUTO_COLUMN_SUB_TYPES = {
|
export const AUTO_COLUMN_SUB_TYPES = {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { NoEmptyFilterStrings } from "../constants/lucene"
|
import { NoEmptyFilterStrings } from "../constants/lucene"
|
||||||
|
import { deepGet } from "@budibase/bbui"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes any fields that contain empty strings that would cause inconsistent
|
* 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
|
* @param query the JSON lucene query
|
||||||
*/
|
*/
|
||||||
export const luceneQuery = (docs, query) => {
|
export const luceneQuery = (docs, query) => {
|
||||||
|
if (!docs || !Array.isArray(docs)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return docs
|
return docs
|
||||||
}
|
}
|
||||||
|
|
||||||
// make query consistent first
|
// make query consistent first
|
||||||
query = cleanupQuery(query)
|
query = cleanupQuery(query)
|
||||||
|
|
||||||
|
@ -106,7 +111,9 @@ export const luceneQuery = (docs, query) => {
|
||||||
const match = (type, failFn) => doc => {
|
const match = (type, failFn) => doc => {
|
||||||
const filters = Object.entries(query[type] || {})
|
const filters = Object.entries(query[type] || {})
|
||||||
for (let i = 0; i < filters.length; i++) {
|
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
|
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)
|
// Process a string match (fails if the value does not start with the string)
|
||||||
const stringMatch = match("string", (key, value, doc) => {
|
const stringMatch = match("string", (docValue, testValue) => {
|
||||||
return !doc[key] || !doc[key].startsWith(value)
|
return !docValue || !docValue.startsWith(testValue)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process a fuzzy match (treat the same as starts with when running locally)
|
// Process a fuzzy match (treat the same as starts with when running locally)
|
||||||
const fuzzyMatch = match("fuzzy", (key, value, doc) => {
|
const fuzzyMatch = match("fuzzy", (docValue, testValue) => {
|
||||||
return !doc[key] || !doc[key].startsWith(value)
|
return !docValue || !docValue.startsWith(testValue)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process a range match
|
// Process a range match
|
||||||
const rangeMatch = match("range", (key, value, doc) => {
|
const rangeMatch = match("range", (docValue, testValue) => {
|
||||||
return !doc[key] || doc[key] < value.low || doc[key] > value.high
|
return !docValue || docValue < testValue.low || docValue > testValue.high
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process an equal match (fails if the value is different)
|
// Process an equal match (fails if the value is different)
|
||||||
const equalMatch = match("equal", (key, value, doc) => {
|
const equalMatch = match("equal", (docValue, testValue) => {
|
||||||
return value != null && value !== "" && doc[key] !== value
|
return testValue != null && testValue !== "" && docValue !== testValue
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process a not-equal match (fails if the value is the same)
|
// Process a not-equal match (fails if the value is the same)
|
||||||
const notEqualMatch = match("notEqual", (key, value, doc) => {
|
const notEqualMatch = match("notEqual", (docValue, testValue) => {
|
||||||
return value != null && value !== "" && doc[key] === value
|
return testValue != null && testValue !== "" && docValue === testValue
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process an empty match (fails if the value is not empty)
|
// Process an empty match (fails if the value is not empty)
|
||||||
const emptyMatch = match("empty", (key, value, doc) => {
|
const emptyMatch = match("empty", docValue => {
|
||||||
return doc[key] != null && doc[key] !== ""
|
return docValue != null && docValue !== ""
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process a not-empty match (fails is the value is empty)
|
// Process a not-empty match (fails is the value is empty)
|
||||||
const notEmptyMatch = match("notEmpty", (key, value, doc) => {
|
const notEmptyMatch = match("notEmpty", docValue => {
|
||||||
return doc[key] == null || doc[key] === ""
|
return docValue == null || docValue === ""
|
||||||
})
|
})
|
||||||
|
|
||||||
// Match a document against all criteria
|
// Match a document against all criteria
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
import FrontendNavigatePane from "components/design/NavigationPanel/FrontendNavigatePane.svelte"
|
import FrontendNavigatePane from "components/design/NavigationPanel/FrontendNavigatePane.svelte"
|
||||||
import { goto, leftover, params } from "@roxi/routify"
|
import { goto, leftover, params } from "@roxi/routify"
|
||||||
import { FrontendTypes } from "constants"
|
import { FrontendTypes } from "constants"
|
||||||
import { findComponent, findComponentPath } from "builderStore/storeUtils"
|
import { findComponent, findComponentPath } from "builderStore/componentUtils"
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
import AppThemeSelect from "components/design/AppPreview/AppThemeSelect.svelte"
|
import AppThemeSelect from "components/design/AppPreview/AppThemeSelect.svelte"
|
||||||
import ThemeEditor from "components/design/AppPreview/ThemeEditor.svelte"
|
import ThemeEditor from "components/design/AppPreview/ThemeEditor.svelte"
|
||||||
|
|
|
@ -500,9 +500,20 @@
|
||||||
"defaultValue": "M"
|
"defaultValue": "M"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"context": {
|
"context": [
|
||||||
|
{
|
||||||
"type": "schema"
|
"type": "schema"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "static",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"label": "Row Index",
|
||||||
|
"key": "index"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"stackedlist": {
|
"stackedlist": {
|
||||||
"deprecated": true,
|
"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": {
|
"image": {
|
||||||
"name": "Image",
|
"name": "Image",
|
||||||
"description": "A basic component for displaying images",
|
"description": "A basic component for displaying images",
|
||||||
|
@ -1775,6 +1837,10 @@
|
||||||
{
|
{
|
||||||
"type": "static",
|
"type": "static",
|
||||||
"values": [
|
"values": [
|
||||||
|
{
|
||||||
|
"label": "Value",
|
||||||
|
"key": "__value"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "Valid",
|
"label": "Valid",
|
||||||
"key": "__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": {
|
"dataprovider": {
|
||||||
"name": "Data Provider",
|
"name": "Data Provider",
|
||||||
"info": "Pagination is only available for data stored in tables.",
|
"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",
|
"type": "schema",
|
||||||
"suffix": "repeater"
|
"suffix": "repeater"
|
||||||
|
|
|
@ -35,6 +35,7 @@
|
||||||
"@spectrum-css/divider": "^1.0.3",
|
"@spectrum-css/divider": "^1.0.3",
|
||||||
"@spectrum-css/link": "^3.1.3",
|
"@spectrum-css/link": "^3.1.3",
|
||||||
"@spectrum-css/page": "^3.0.1",
|
"@spectrum-css/page": "^3.0.1",
|
||||||
|
"@spectrum-css/tag": "^3.1.4",
|
||||||
"@spectrum-css/typography": "^3.0.2",
|
"@spectrum-css/typography": "^3.0.2",
|
||||||
"@spectrum-css/vars": "^3.0.1",
|
"@spectrum-css/vars": "^3.0.1",
|
||||||
"apexcharts": "^3.22.1",
|
"apexcharts": "^3.22.1",
|
||||||
|
|
|
@ -4,6 +4,10 @@ import { fetchViewData } from "./views"
|
||||||
import { fetchRelationshipData } from "./relationships"
|
import { fetchRelationshipData } from "./relationships"
|
||||||
import { FieldTypes } from "../constants"
|
import { FieldTypes } from "../constants"
|
||||||
import { executeQuery, fetchQueryDefinition } from "./queries"
|
import { executeQuery, fetchQueryDefinition } from "./queries"
|
||||||
|
import {
|
||||||
|
convertJSONSchemaToTableSchema,
|
||||||
|
getJSONArrayDatasourceSchema,
|
||||||
|
} from "builder/src/builderStore/jsonUtils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches all rows for a particular Budibase data source.
|
* Fetches all rows for a particular Budibase data source.
|
||||||
|
@ -49,16 +53,17 @@ export const fetchDatasourceSchema = async dataSource => {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const { type } = dataSource
|
const { type } = dataSource
|
||||||
|
let schema
|
||||||
|
|
||||||
// Nested providers should already have exposed their own schema
|
// Nested providers should already have exposed their own schema
|
||||||
if (type === "provider") {
|
if (type === "provider") {
|
||||||
return dataSource.value?.schema
|
schema = dataSource.value?.schema
|
||||||
}
|
}
|
||||||
|
|
||||||
// Field sources have their schema statically defined
|
// Field sources have their schema statically defined
|
||||||
if (type === "field") {
|
if (type === "field") {
|
||||||
if (dataSource.fieldType === "attachment") {
|
if (dataSource.fieldType === "attachment") {
|
||||||
return {
|
schema = {
|
||||||
url: {
|
url: {
|
||||||
type: "string",
|
type: "string",
|
||||||
},
|
},
|
||||||
|
@ -67,7 +72,7 @@ export const fetchDatasourceSchema = async dataSource => {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if (dataSource.fieldType === "array") {
|
} else if (dataSource.fieldType === "array") {
|
||||||
return {
|
schema = {
|
||||||
value: {
|
value: {
|
||||||
type: "string",
|
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
|
// Tables, views and links can be fetched by table ID
|
||||||
if (
|
if (
|
||||||
(type === "table" || type === "view" || type === "link") &&
|
(type === "table" || type === "view" || type === "link") &&
|
||||||
dataSource.tableId
|
dataSource.tableId
|
||||||
) {
|
) {
|
||||||
const table = await fetchTableDefinition(dataSource.tableId)
|
const table = await fetchTableDefinition(dataSource.tableId)
|
||||||
return table?.schema
|
schema = table?.schema
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queries can be fetched by query ID
|
// Queries can be fetched by query ID
|
||||||
if (type === "query" && dataSource._id) {
|
if (type === "query" && dataSource._id) {
|
||||||
const definition = await fetchQueryDefinition(dataSource._id)
|
const definition = await fetchQueryDefinition(dataSource._id)
|
||||||
return definition?.schema
|
schema = definition?.schema
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanity check
|
||||||
|
if (!schema) {
|
||||||
return null
|
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
|
// For providers referencing another provider, just use the rows it
|
||||||
// provides
|
// provides
|
||||||
allRows = dataSource?.value?.rows || []
|
allRows = dataSource?.value?.rows || []
|
||||||
} else if (dataSource?.type === "field") {
|
} else if (
|
||||||
// Field sources will be available from context.
|
dataSource?.type === "field" ||
|
||||||
// Enrich non object elements into object to ensure a valid schema.
|
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 || []
|
const data = dataSource?.value || []
|
||||||
if (Array.isArray(data) && data[0] && typeof data[0] !== "object") {
|
if (Array.isArray(data) && data[0] && typeof data[0] !== "object") {
|
||||||
allRows = data.map(value => ({ value }))
|
allRows = data.map(value => ({ value }))
|
||||||
|
|
|
@ -21,8 +21,8 @@
|
||||||
{#if $component.empty}
|
{#if $component.empty}
|
||||||
<Placeholder />
|
<Placeholder />
|
||||||
{:else if rows.length > 0}
|
{:else if rows.length > 0}
|
||||||
{#each rows as row}
|
{#each rows as row, index}
|
||||||
<Provider data={row}>
|
<Provider data={{ ...row, index }}>
|
||||||
<slot />
|
<slot />
|
||||||
</Provider>
|
</Provider>
|
||||||
{/each}
|
{/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({
|
enrichedFilter.push({
|
||||||
field: column.name,
|
field: column.name,
|
||||||
operator: column.type === "string" ? "string" : "equal",
|
operator: column.type === "string" ? "string" : "equal",
|
||||||
type: "string",
|
type: column.type === "string" ? "string" : "number",
|
||||||
valueType: "Binding",
|
valueType: "Binding",
|
||||||
value: `{{ [${formId}].[${column.name}] }}`,
|
value: `{{ [${formId}].[${column.name}] }}`,
|
||||||
})
|
})
|
||||||
|
|
|
@ -61,7 +61,7 @@
|
||||||
enrichedFilter.push({
|
enrichedFilter.push({
|
||||||
field: column.name,
|
field: column.name,
|
||||||
operator: column.type === "string" ? "string" : "equal",
|
operator: column.type === "string" ? "string" : "equal",
|
||||||
type: "string",
|
type: column.type === "string" ? "string" : "number",
|
||||||
valueType: "Binding",
|
valueType: "Binding",
|
||||||
value: `{{ ${safe(formId)}.${safe(column.name)} }}`,
|
value: `{{ ${safe(formId)}.${safe(column.name)} }}`,
|
||||||
})
|
})
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
export let schemaFields
|
export let schemaFields
|
||||||
export let filters = []
|
export let filters = []
|
||||||
|
|
||||||
const BannedTypes = ["link", "attachment", "formula"]
|
const BannedTypes = ["link", "attachment", "formula", "json"]
|
||||||
|
|
||||||
$: fieldOptions = (schemaFields ?? [])
|
$: fieldOptions = (schemaFields ?? [])
|
||||||
.filter(field => !BannedTypes.includes(field.type))
|
.filter(field => !BannedTypes.includes(field.type))
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
import { derived, get, writable } from "svelte/store"
|
import { derived, get, writable } from "svelte/store"
|
||||||
import { createValidatorFromConstraints } from "./validation"
|
import { createValidatorFromConstraints } from "./validation"
|
||||||
import { generateID } from "utils/helpers"
|
import { generateID } from "utils/helpers"
|
||||||
|
import { deepGet, deepSet } from "@budibase/bbui"
|
||||||
|
import { cloneDeep } from "lodash/fp"
|
||||||
|
|
||||||
export let dataSource
|
export let dataSource
|
||||||
export let disabled = false
|
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
|
// Generates a derived store from an array of fields, comprised of a map of
|
||||||
// extracted values from the field array
|
// extracted values from the field array
|
||||||
const deriveFieldProperty = (fieldStores, getProp) => {
|
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
|
// Searches the field array for a certain field
|
||||||
const getField = name => {
|
const getField = name => {
|
||||||
return fields.find(field => get(field).name === 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
|
// 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()}`
|
let fieldId = `id-${generateID()}`
|
||||||
const existingField = getField(field)
|
const existingField = getField(field)
|
||||||
if (existingField) {
|
if (existingField) {
|
||||||
const { fieldState } = get(existingField)
|
const { fieldState } = get(existingField)
|
||||||
initialValue = fieldState.value ?? initialValue
|
|
||||||
fieldId = fieldState.fieldId
|
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
|
// Auto columns are always disabled
|
||||||
|
@ -130,6 +182,7 @@
|
||||||
disabled: disabled || fieldDisabled || isAutoColumn,
|
disabled: disabled || fieldDisabled || isAutoColumn,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
validator,
|
validator,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
},
|
},
|
||||||
fieldApi: makeFieldApi(field, defaultValue),
|
fieldApi: makeFieldApi(field, defaultValue),
|
||||||
fieldSchema: schema?.[field] ?? {},
|
fieldSchema: schema?.[field] ?? {},
|
||||||
|
@ -204,6 +257,7 @@
|
||||||
fieldInfo.update(state => {
|
fieldInfo.update(state => {
|
||||||
state.fieldState.value = value
|
state.fieldState.value = value
|
||||||
state.fieldState.error = error
|
state.fieldState.error = error
|
||||||
|
state.fieldState.lastUpdate = Date.now()
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -220,6 +274,7 @@
|
||||||
fieldInfo.update(state => {
|
fieldInfo.update(state => {
|
||||||
state.fieldState.value = newValue
|
state.fieldState.value = newValue
|
||||||
state.fieldState.error = null
|
state.fieldState.error = null
|
||||||
|
state.fieldState.lastUpdate = Date.now()
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -299,18 +354,6 @@
|
||||||
{ type: ActionTypes.ClearForm, callback: formApi.clear },
|
{ type: ActionTypes.ClearForm, callback: formApi.clear },
|
||||||
{ type: ActionTypes.ChangeFormStep, callback: formApi.changeStep },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<Provider {actions} data={dataContext}>
|
<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>
|
<script>
|
||||||
import { CoreTextArea } from "@budibase/bbui"
|
import { CoreTextArea } from "@budibase/bbui"
|
||||||
import Field from "./Field.svelte"
|
import Field from "./Field.svelte"
|
||||||
|
import { getContext } from "svelte"
|
||||||
|
|
||||||
export let field
|
export let field
|
||||||
export let label
|
export let label
|
||||||
|
@ -11,6 +12,9 @@
|
||||||
|
|
||||||
let fieldState
|
let fieldState
|
||||||
let fieldApi
|
let fieldApi
|
||||||
|
|
||||||
|
const component = getContext("component")
|
||||||
|
$: height = $component.styles?.normal?.height || "124px"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
@ -24,6 +28,7 @@
|
||||||
bind:fieldApi
|
bind:fieldApi
|
||||||
>
|
>
|
||||||
{#if fieldState}
|
{#if fieldState}
|
||||||
|
<div style="--height: {height};">
|
||||||
<CoreTextArea
|
<CoreTextArea
|
||||||
value={fieldState.value}
|
value={fieldState.value}
|
||||||
on:change={e => fieldApi.setValue(e.detail)}
|
on:change={e => fieldApi.setValue(e.detail)}
|
||||||
|
@ -32,5 +37,17 @@
|
||||||
id={fieldState.fieldId}
|
id={fieldState.fieldId}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Field>
|
</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 relationshipfield } from "./RelationshipField.svelte"
|
||||||
export { default as passwordfield } from "./PasswordField.svelte"
|
export { default as passwordfield } from "./PasswordField.svelte"
|
||||||
export { default as formstep } from "./FormStep.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
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse array, treating no elements as null
|
||||||
if (type === FieldTypes.ARRAY) {
|
if (type === FieldTypes.ARRAY) {
|
||||||
if (!Array.isArray(value) || !value.length) {
|
if (!Array.isArray(value) || !value.length) {
|
||||||
return null
|
return null
|
||||||
|
@ -213,6 +214,12 @@ const parseType = (value, type) => {
|
||||||
return value
|
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
|
// If some unknown type, treat as null to avoid breaking validators
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -290,6 +297,19 @@ const notContainsHandler = (value, rule) => {
|
||||||
return !containsHandler(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.
|
* Map of constraint types to handlers.
|
||||||
*/
|
*/
|
||||||
|
@ -306,6 +326,7 @@ const handlerMap = {
|
||||||
notRegex: notRegexHandler,
|
notRegex: notRegexHandler,
|
||||||
contains: containsHandler,
|
contains: containsHandler,
|
||||||
notContains: notContainsHandler,
|
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 daterangepicker } from "./DateRangePicker.svelte"
|
||||||
export { default as cardstat } from "./CardStat.svelte"
|
export { default as cardstat } from "./CardStat.svelte"
|
||||||
export { default as spectrumcard } from "./SpectrumCard.svelte"
|
export { default as spectrumcard } from "./SpectrumCard.svelte"
|
||||||
|
export { default as tag } from "./Tag.svelte"
|
||||||
export * from "./charts"
|
export * from "./charts"
|
||||||
export * from "./forms"
|
export * from "./forms"
|
||||||
export * from "./table"
|
export * from "./table"
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
class:flipped
|
class:flipped
|
||||||
class:line
|
class:line
|
||||||
style="top: {top}px; left: {left}px; width: {width}px; height: {height}px; --color: {color}; --zIndex: {zIndex};"
|
style="top: {top}px; left: {left}px; width: {width}px; height: {height}px; --color: {color}; --zIndex: {zIndex};"
|
||||||
|
class:withText={!!text}
|
||||||
>
|
>
|
||||||
{#if text}
|
{#if text}
|
||||||
<div class="text" class:flipped class:line class:right={alignRight}>
|
<div class="text" class:flipped class:line class:right={alignRight}>
|
||||||
|
@ -39,12 +40,12 @@
|
||||||
z-index: var(--zIndex);
|
z-index: var(--zIndex);
|
||||||
border: 2px solid var(--color);
|
border: 2px solid var(--color);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
border-top-right-radius: 4px;
|
border-radius: 4px;
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
border-bottom-right-radius: 4px;
|
|
||||||
}
|
}
|
||||||
.indicator.flipped {
|
.indicator.withText {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
}
|
||||||
|
.indicator.withText.flipped {
|
||||||
border-top-left-radius: 4px;
|
border-top-left-radius: 4px;
|
||||||
}
|
}
|
||||||
.indicator.line {
|
.indicator.line {
|
||||||
|
@ -74,8 +75,7 @@
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.text.flipped {
|
.text.flipped {
|
||||||
border-top-left-radius: 4px;
|
border-radius: 4px;
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
transform: translateY(0%);
|
transform: translateY(0%);
|
||||||
top: -2px;
|
top: -2px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ export const FieldTypes = {
|
||||||
ATTACHMENT: "attachment",
|
ATTACHMENT: "attachment",
|
||||||
LINK: "link",
|
LINK: "link",
|
||||||
FORMULA: "formula",
|
FORMULA: "formula",
|
||||||
|
JSON: "json",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UnsortableTypes = [
|
export const UnsortableTypes = [
|
||||||
|
|
|
@ -61,7 +61,8 @@ export const createDataSourceStore = () => {
|
||||||
|
|
||||||
// Emit this as a window event, so parent screens which are iframing us in
|
// Emit this as a window event, so parent screens which are iframing us in
|
||||||
// can also invalidate the same datasource
|
// can also invalidate the same datasource
|
||||||
if (get(routeStore).queryParams?.peek) {
|
const inModal = get(routeStore).queryParams?.peek
|
||||||
|
if (inModal) {
|
||||||
window.parent.postMessage({
|
window.parent.postMessage({
|
||||||
type: "invalidate-datasource",
|
type: "invalidate-datasource",
|
||||||
detail: { dataSourceId },
|
detail: { dataSourceId },
|
||||||
|
|
|
@ -8,20 +8,49 @@ import {
|
||||||
} from "stores"
|
} from "stores"
|
||||||
import { saveRow, deleteRow, executeQuery, triggerAutomation } from "api"
|
import { saveRow, deleteRow, executeQuery, triggerAutomation } from "api"
|
||||||
import { ActionTypes } from "constants"
|
import { ActionTypes } from "constants"
|
||||||
|
import { enrichDataBindings } from "./enrichDataBinding"
|
||||||
|
import { deepSet } from "@budibase/bbui"
|
||||||
|
|
||||||
const saveRowHandler = async (action, context) => {
|
const saveRowHandler = async (action, context) => {
|
||||||
const { fields, providerId, tableId } = action.parameters
|
const { fields, providerId, tableId } = action.parameters
|
||||||
|
let payload
|
||||||
if (providerId) {
|
if (providerId) {
|
||||||
let draft = context[providerId]
|
payload = { ...context[providerId] }
|
||||||
|
} else {
|
||||||
|
payload = {}
|
||||||
|
}
|
||||||
if (fields) {
|
if (fields) {
|
||||||
for (let [field, value] of Object.entries(fields)) {
|
for (let [field, value] of Object.entries(fields)) {
|
||||||
draft[field] = value
|
deepSet(payload, field, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (tableId) {
|
if (tableId) {
|
||||||
draft.tableId = 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)) {
|
||||||
|
deepSet(payload, field, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (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 queryExecutionHandler = async action => {
|
||||||
const { datasourceId, queryId, queryParams } = action.parameters
|
const { datasourceId, queryId, queryParams } = action.parameters
|
||||||
await executeQuery({
|
const result = await executeQuery({
|
||||||
datasourceId,
|
datasourceId,
|
||||||
queryId,
|
queryId,
|
||||||
parameters: queryParams,
|
parameters: queryParams,
|
||||||
})
|
})
|
||||||
|
return { result }
|
||||||
}
|
}
|
||||||
|
|
||||||
const executeActionHandler = async (
|
const executeActionHandler = async (
|
||||||
|
@ -129,6 +159,7 @@ const updateStateHandler = action => {
|
||||||
|
|
||||||
const handlerMap = {
|
const handlerMap = {
|
||||||
["Save Row"]: saveRowHandler,
|
["Save Row"]: saveRowHandler,
|
||||||
|
["Duplicate Row"]: duplicateRowHandler,
|
||||||
["Delete Row"]: deleteRowHandler,
|
["Delete Row"]: deleteRowHandler,
|
||||||
["Navigate To"]: navigationHandler,
|
["Navigate To"]: navigationHandler,
|
||||||
["Execute Query"]: queryExecutionHandler,
|
["Execute Query"]: queryExecutionHandler,
|
||||||
|
@ -165,12 +196,27 @@ export const enrichButtonActions = (actions, context) => {
|
||||||
return actions
|
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"]])
|
const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
|
||||||
return async () => {
|
return async () => {
|
||||||
for (let i = 0; i < handlers.length; i++) {
|
for (let i = 0; i < handlers.length; i++) {
|
||||||
try {
|
try {
|
||||||
const action = actions[i]
|
// Skip any non-existent action definitions
|
||||||
const callback = async () => handlers[i](action, context)
|
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
|
// If this action is confirmable, show confirmation and await a
|
||||||
// callback to execute further actions
|
// callback to execute further actions
|
||||||
|
@ -185,7 +231,15 @@ export const enrichButtonActions = (actions, context) => {
|
||||||
// then execute the rest of the actions in the chain
|
// then execute the rest of the actions in the chain
|
||||||
const result = await callback()
|
const result = await callback()
|
||||||
if (result !== false) {
|
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()
|
await next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -201,6 +255,8 @@ export const enrichButtonActions = (actions, context) => {
|
||||||
const result = await callback()
|
const result = await callback()
|
||||||
if (result === false) {
|
if (result === false) {
|
||||||
return
|
return
|
||||||
|
} else {
|
||||||
|
buttonContext.push(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -32,35 +32,56 @@ export const enrichProps = (props, context) => {
|
||||||
data: context[context.closestComponentId],
|
data: context[context.closestComponentId],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich all data bindings in top level props
|
// We want to exclude any button actions from enrichment at this stage.
|
||||||
let enrichedProps = enrichDataBindings(props, totalContext)
|
// Extract top level button action settings.
|
||||||
|
let normalProps = { ...props }
|
||||||
// Enrich click actions if they exist
|
let actionProps = {}
|
||||||
Object.keys(enrichedProps).forEach(prop => {
|
Object.keys(normalProps).forEach(prop => {
|
||||||
if (prop?.toLowerCase().includes("onclick")) {
|
if (prop?.toLowerCase().includes("onclick")) {
|
||||||
enrichedProps[prop] = enrichButtonActions(
|
actionProps[prop] = normalProps[prop]
|
||||||
enrichedProps[prop],
|
delete normalProps[prop]
|
||||||
totalContext
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Enrich any click actions in conditions
|
// Handle conditional UI separately after normal settings
|
||||||
if (enrichedProps._conditions) {
|
let conditions = normalProps._conditions
|
||||||
enrichedProps._conditions.forEach(condition => {
|
delete normalProps._conditions
|
||||||
|
|
||||||
|
// 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")) {
|
if (condition.setting?.toLowerCase().includes("onclick")) {
|
||||||
condition.settingValue = enrichButtonActions(
|
// 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,
|
condition.settingValue,
|
||||||
totalContext
|
totalContext
|
||||||
)
|
),
|
||||||
|
rand: Math.random(),
|
||||||
// If there is an onclick function in here then it won't be serialised
|
})
|
||||||
// properly, and therefore will not be updated properly.
|
} else {
|
||||||
// The solution to this is add a rand which will ensure diffs happen
|
// Normal condition
|
||||||
// every time.
|
enrichedConditions.push(enrichDataBindings(condition, totalContext))
|
||||||
condition.rand = Math.random()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
enrichedProps._conditions = enrichedConditions
|
||||||
}
|
}
|
||||||
|
|
||||||
return enrichedProps
|
return enrichedProps
|
||||||
|
|
|
@ -305,6 +305,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@spectrum-css/tabs/-/tabs-3.1.5.tgz#cc82e69c1fc721902345178231fb95d05938b983"
|
resolved "https://registry.yarnpkg.com/@spectrum-css/tabs/-/tabs-3.1.5.tgz#cc82e69c1fc721902345178231fb95d05938b983"
|
||||||
integrity sha512-UtfW8bA1quYnJM6v/lp6AVYGnQFkiUix2FHAf/4VHVrk4mh7ydtLiXS0IR3Kx+t/S8FWdSdSQHDZ8tHbY1ZLZg==
|
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":
|
"@spectrum-css/tags@^3.0.2":
|
||||||
version "3.0.3"
|
version "3.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@spectrum-css/tags/-/tags-3.0.3.tgz#fc76d2735cdc442de91b7eb3bee49a928c0767ac"
|
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 = {}
|
const errors = {}
|
||||||
for (let fieldName of Object.keys(table.schema)) {
|
for (let fieldName of Object.keys(table.schema)) {
|
||||||
const constraints = cloneDeep(table.schema[fieldName].constraints)
|
const constraints = cloneDeep(table.schema[fieldName].constraints)
|
||||||
|
const type = table.schema[fieldName].type
|
||||||
// special case for options, need to always allow unselected (null)
|
// special case for options, need to always allow unselected (null)
|
||||||
if (
|
if (
|
||||||
table.schema[fieldName].type ===
|
(type === FieldTypes.OPTIONS || type === FieldTypes.ARRAY) &&
|
||||||
(FieldTypes.OPTIONS || FieldTypes.ARRAY) &&
|
|
||||||
constraints.inclusion
|
constraints.inclusion
|
||||||
) {
|
) {
|
||||||
constraints.inclusion.push(null)
|
constraints.inclusion.push(null)
|
||||||
|
@ -61,17 +61,20 @@ exports.validate = async ({ appId, tableId, row, table }) => {
|
||||||
let res
|
let res
|
||||||
|
|
||||||
// Validate.js doesn't seem to handle array
|
// Validate.js doesn't seem to handle array
|
||||||
if (
|
if (type === FieldTypes.ARRAY && row[fieldName] && row[fieldName].length) {
|
||||||
table.schema[fieldName].type === FieldTypes.ARRAY &&
|
|
||||||
row[fieldName] &&
|
|
||||||
row[fieldName].length
|
|
||||||
) {
|
|
||||||
row[fieldName].map(val => {
|
row[fieldName].map(val => {
|
||||||
if (!constraints.inclusion.includes(val)) {
|
if (!constraints.inclusion.includes(val)) {
|
||||||
errors[fieldName] = "Field not in list"
|
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(
|
res = validateJs.single(
|
||||||
processStringSync(table.schema[fieldName].formula, row),
|
processStringSync(table.schema[fieldName].formula, row),
|
||||||
constraints
|
constraints
|
||||||
|
|
|
@ -81,6 +81,18 @@ const TYPE_TRANSFORM_MAP = {
|
||||||
[FieldTypes.AUTO]: {
|
[FieldTypes.AUTO]: {
|
||||||
parse: () => undefined,
|
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 { atob } = require("../utilities")
|
||||||
|
const { cloneDeep } = require("lodash/fp")
|
||||||
|
|
||||||
// The method of executing JS scripts depends on the bundle being built.
|
// 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).
|
// 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.
|
// This is required to allow the final `return` statement to be valid.
|
||||||
const js = `function run(){${atob(handlebars)}};run();`
|
const js = `function run(){${atob(handlebars)}};run();`
|
||||||
|
|
||||||
// Our $ context function gets a value from context
|
// Our $ context function gets a value from context.
|
||||||
const sandboxContext = { $: path => getContextValue(path, 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
|
// Create a sandbox with out context and run the JS
|
||||||
return runJS(js, sandboxContext)
|
return runJS(js, sandboxContext)
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
const handlebars = require("handlebars")
|
const handlebars = require("handlebars")
|
||||||
const { registerAll } = require("./helpers/index")
|
const { registerAll } = require("./helpers/index")
|
||||||
const processors = require("./processors")
|
const processors = require("./processors")
|
||||||
const { removeHandlebarsStatements, atob, btoa } = require("./utilities")
|
const { atob, btoa } = require("./utilities")
|
||||||
const manifest = require("../manifest.json")
|
const manifest = require("../manifest.json")
|
||||||
|
|
||||||
const hbsInstance = handlebars.create()
|
const hbsInstance = handlebars.create()
|
||||||
registerAll(hbsInstance)
|
registerAll(hbsInstance)
|
||||||
const hbsInstanceNoHelpers = handlebars.create()
|
const hbsInstanceNoHelpers = handlebars.create()
|
||||||
|
const defaultOpts = { noHelpers: false }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* utility function to check if the object is valid
|
* 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.
|
* @param {object} opts optional - specify some options for processing.
|
||||||
* @returns {Promise<object|array>} The structure input, as fully updated as possible.
|
* @returns {Promise<object|array>} The structure input, as fully updated as possible.
|
||||||
*/
|
*/
|
||||||
module.exports.processObject = async (
|
module.exports.processObject = async (object, context, opts) => {
|
||||||
object,
|
|
||||||
context,
|
|
||||||
opts = { noHelpers: false }
|
|
||||||
) => {
|
|
||||||
testObject(object)
|
testObject(object)
|
||||||
for (let key of Object.keys(object || {})) {
|
for (let key of Object.keys(object || {})) {
|
||||||
if (object[key] != null) {
|
if (object[key] != null) {
|
||||||
|
@ -63,11 +60,7 @@ module.exports.processObject = async (
|
||||||
* @param {object} opts optional - specify some options for processing.
|
* @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.
|
* @returns {Promise<string>} The enriched string, all templates should have been replaced if they can be.
|
||||||
*/
|
*/
|
||||||
module.exports.processString = async (
|
module.exports.processString = async (string, context, opts) => {
|
||||||
string,
|
|
||||||
context,
|
|
||||||
opts = { noHelpers: false }
|
|
||||||
) => {
|
|
||||||
// TODO: carry out any async calls before carrying out async call
|
// TODO: carry out any async calls before carrying out async call
|
||||||
return module.exports.processStringSync(string, context, opts)
|
return module.exports.processStringSync(string, context, opts)
|
||||||
}
|
}
|
||||||
|
@ -81,11 +74,7 @@ module.exports.processString = async (
|
||||||
* @param {object} opts optional - specify some options for processing.
|
* @param {object} opts optional - specify some options for processing.
|
||||||
* @returns {object|array} The structure input, as fully updated as possible.
|
* @returns {object|array} The structure input, as fully updated as possible.
|
||||||
*/
|
*/
|
||||||
module.exports.processObjectSync = (
|
module.exports.processObjectSync = (object, context, opts) => {
|
||||||
object,
|
|
||||||
context,
|
|
||||||
opts = { noHelpers: false }
|
|
||||||
) => {
|
|
||||||
testObject(object)
|
testObject(object)
|
||||||
for (let key of Object.keys(object || {})) {
|
for (let key of Object.keys(object || {})) {
|
||||||
let val = object[key]
|
let val = object[key]
|
||||||
|
@ -106,26 +95,20 @@ module.exports.processObjectSync = (
|
||||||
* @param {object} opts optional - specify some options for processing.
|
* @param {object} opts optional - specify some options for processing.
|
||||||
* @returns {string} The enriched string, all templates should have been replaced if they can be.
|
* @returns {string} The enriched string, all templates should have been replaced if they can be.
|
||||||
*/
|
*/
|
||||||
module.exports.processStringSync = (
|
module.exports.processStringSync = (string, context, opts) => {
|
||||||
string,
|
opts = { ...defaultOpts, ...opts }
|
||||||
context,
|
|
||||||
opts = { noHelpers: false }
|
// take a copy of input in case of error
|
||||||
) => {
|
|
||||||
if (!exports.isValid(string)) {
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
// take a copy of input incase error
|
|
||||||
const input = string
|
const input = string
|
||||||
if (typeof string !== "string") {
|
if (typeof string !== "string") {
|
||||||
throw "Cannot process non-string types."
|
throw "Cannot process non-string types."
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const noHelpers = opts && opts.noHelpers
|
|
||||||
// finalising adds a helper, can't do this with no helpers
|
// finalising adds a helper, can't do this with no helpers
|
||||||
const shouldFinalise = !noHelpers
|
const shouldFinalise = !opts.noHelpers
|
||||||
string = processors.preprocess(string, shouldFinalise)
|
string = processors.preprocess(string, shouldFinalise)
|
||||||
// this does not throw an error when template can't be fulfilled, have to try correct beforehand
|
// 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, {
|
const template = instance.compile(string, {
|
||||||
strict: false,
|
strict: false,
|
||||||
})
|
})
|
||||||
|
@ -136,7 +119,7 @@ module.exports.processStringSync = (
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return removeHandlebarsStatements(input)
|
return input
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -155,7 +138,8 @@ module.exports.makePropSafe = property => {
|
||||||
* @param opts optional - specify some options for processing.
|
* @param opts optional - specify some options for processing.
|
||||||
* @returns {boolean} Whether or not the input string is valid.
|
* @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 = [
|
const validCases = [
|
||||||
"string",
|
"string",
|
||||||
"number",
|
"number",
|
||||||
|
@ -169,7 +153,7 @@ module.exports.isValid = (string, opts = { noHelpers: false }) => {
|
||||||
// don't really need a real context to check if its valid
|
// don't really need a real context to check if its valid
|
||||||
const context = {}
|
const context = {}
|
||||||
try {
|
try {
|
||||||
const instance = opts && opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
|
const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
|
||||||
instance.compile(processors.preprocess(string, false))(context)
|
instance.compile(processors.preprocess(string, false))(context)
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
@ -10,7 +10,10 @@ module.exports.swapStrings = (string, start, length, swap) => {
|
||||||
return string.slice(0, start) + swap + string.slice(start + length)
|
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 regexp = new RegExp(exports.FIND_HBS_REGEX)
|
||||||
let matches = string.match(regexp)
|
let matches = string.match(regexp)
|
||||||
if (matches == null) {
|
if (matches == null) {
|
||||||
|
@ -18,7 +21,7 @@ module.exports.removeHandlebarsStatements = string => {
|
||||||
}
|
}
|
||||||
for (let match of matches) {
|
for (let match of matches) {
|
||||||
const idx = string.indexOf(match)
|
const idx = string.indexOf(match)
|
||||||
string = exports.swapStrings(string, idx, match.length, "Invalid Binding")
|
string = exports.swapStrings(string, idx, match.length, replacement)
|
||||||
}
|
}
|
||||||
return string
|
return string
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,10 +13,14 @@ describe("test the custom helpers we have applied", () => {
|
||||||
|
|
||||||
describe("test that it can run without helpers", () => {
|
describe("test that it can run without helpers", () => {
|
||||||
it("should be able to run without helpers", async () => {
|
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 }}", {})
|
const valid = await processString("{{ avg 1 1 1 }}", {})
|
||||||
expect(valid).toBe("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 () => {
|
it("should test the timezone capabilities", async () => {
|
||||||
const date = new Date(1611577535000)
|
const date = new Date(1611577535000)
|
||||||
const output = await processString("{{ date time 'HH-mm-ss Z' 'America/New_York' }}", {
|
const output = await processString(
|
||||||
|
"{{ date time 'HH-mm-ss Z' 'America/New_York' }}",
|
||||||
|
{
|
||||||
time: date.toUTCString(),
|
time: date.toUTCString(),
|
||||||
})
|
}
|
||||||
const formatted = new dayjs(date).tz("America/New_York").format("HH-mm-ss Z")
|
)
|
||||||
|
const formatted = new dayjs(date)
|
||||||
|
.tz("America/New_York")
|
||||||
|
.format("HH-mm-ss Z")
|
||||||
expect(output).toBe(formatted)
|
expect(output).toBe(formatted)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should guess the users timezone when not specified", async () => {
|
it("should guess the users timezone when not specified", async () => {
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
const output = await processString("{{ date time 'Z' }}", {
|
const output = await processString("{{ date time 'Z' }}", {
|
||||||
time: date.toUTCString()
|
time: date.toUTCString(),
|
||||||
})
|
})
|
||||||
const timezone = dayjs.tz.guess()
|
const timezone = dayjs.tz.guess()
|
||||||
const offset = new dayjs(date).tz(timezone).format("Z")
|
const offset = new dayjs(date).tz(timezone).format("Z")
|
||||||
|
@ -307,12 +316,12 @@ describe("test the comparison helpers", () => {
|
||||||
describe("Test the object/array helper", () => {
|
describe("Test the object/array helper", () => {
|
||||||
it("should allow plucking from an array of objects", async () => {
|
it("should allow plucking from an array of objects", async () => {
|
||||||
const context = {
|
const context = {
|
||||||
items: [
|
items: [{ price: 20 }, { price: 30 }],
|
||||||
{ 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)
|
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 () => {
|
it("should only invalidate a single string in an object", async () => {
|
||||||
const input = {
|
const input = {
|
||||||
dataProvider:"{{ literal [c670254c9e74e40518ee5becff53aa5be] }}",
|
dataProvider: "{{ literal [c670254c9e74e40518ee5becff53aa5be] }}",
|
||||||
theme:"spectrum--lightest",
|
theme: "spectrum--lightest",
|
||||||
showAutoColumns:false,
|
showAutoColumns: false,
|
||||||
quiet:true,
|
quiet: true,
|
||||||
size:"spectrum--medium",
|
size: "spectrum--medium",
|
||||||
rowCount:8,
|
rowCount: 8,
|
||||||
}
|
}
|
||||||
const output = await processObject(input, tableJson)
|
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 () => {
|
it("should be able to handle external ids", async () => {
|
||||||
|
|
|
@ -8,7 +8,7 @@ jest.mock("nodemailer")
|
||||||
const nodemailer = require("nodemailer")
|
const nodemailer = require("nodemailer")
|
||||||
nodemailer.createTransport.mockReturnValue({
|
nodemailer.createTransport.mockReturnValue({
|
||||||
sendMail: sendMailMock,
|
sendMail: sendMailMock,
|
||||||
verify: jest.fn()
|
verify: jest.fn(),
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("/api/global/email", () => {
|
describe("/api/global/email", () => {
|
||||||
|
@ -39,6 +39,6 @@ describe("/api/global/email", () => {
|
||||||
expect(sendMailMock).toHaveBeenCalled()
|
expect(sendMailMock).toHaveBeenCalled()
|
||||||
const emailCall = sendMailMock.mock.calls[0][0]
|
const emailCall = sendMailMock.mock.calls[0][0]
|
||||||
expect(emailCall.subject).toBe("Hello!")
|
expect(emailCall.subject).toBe("Hello!")
|
||||||
expect(emailCall.html).not.toContain("Invalid Binding")
|
expect(emailCall.html).not.toContain("Invalid binding")
|
||||||
})
|
})
|
||||||
})
|
})
|
Loading…
Reference in New Issue