Merge pull request #3776 from Budibase/feature/json-backend

JSON data type, button action context, duplication action + more
This commit is contained in:
Andrew Kingston 2022-01-04 11:37:02 +00:00 committed by GitHub
commit 2611dd04e8
80 changed files with 1583 additions and 365 deletions

View File

@ -21,16 +21,8 @@
}
visible = false
}
function handleKey(e) {
if (visible && e.key === "Escape") {
hide()
}
}
</script>
<svelte:window on:keydown={handleKey} />
{#if visible}
<Portal>
<section class:fillWidth class="drawer" transition:slide>

View File

@ -4,6 +4,7 @@
import CellRenderer from "./CellRenderer.svelte"
import SelectEditRenderer from "./SelectEditRenderer.svelte"
import { cloneDeep } from "lodash"
import { deepGet } from "../utils/helpers"
/**
* The expected schema is our normal couch schemas for our tables.
@ -318,7 +319,7 @@
{customRenderers}
{row}
schema={schema[field]}
value={row[field]}
value={deepGet(row, field)}
on:clickrelationship
>
<slot />

View File

@ -77,3 +77,6 @@ export { default as clickOutside } from "./Actions/click_outside"
// Stores
export { notifications, createNotificationStore } from "./Stores/notifications"
// Utils
export * from "./utils/helpers"

View File

@ -6,3 +6,61 @@ export const generateID = () => {
}
export const capitalise = s => s.substring(0, 1).toUpperCase() + s.substring(1)
/**
* Gets a key within an object. The key supports dot syntax for retrieving deep
* fields - e.g. "a.b.c".
* Exact matches of keys with dots in them take precedence over nested keys of
* the same path - e.g. getting "a.b" from { "a.b": "foo", a: { b: "bar" } }
* will return "foo" over "bar".
* @param obj the object
* @param key the key
* @return {*|null} the value or null if a value was not found for this key
*/
export const deepGet = (obj, key) => {
if (!obj || !key) {
return null
}
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return obj[key]
}
const split = key.split(".")
for (let i = 0; i < split.length; i++) {
obj = obj?.[split[i]]
}
return obj
}
/**
* Sets a key within an object. The key supports dot syntax for retrieving deep
* fields - e.g. "a.b.c".
* Exact matches of keys with dots in them take precedence over nested keys of
* the same path - e.g. setting "a.b" of { "a.b": "foo", a: { b: "bar" } }
* will override the value "foo" rather than "bar".
* If a deep path is specified and the parent keys don't exist then these will
* be created.
* @param obj the object
* @param key the key
* @param value the value
*/
export const deepSet = (obj, key, value) => {
if (!obj || !key) {
return
}
if (Object.prototype.hasOwnProperty.call(obj, key)) {
obj[key] = value
return
}
const split = key.split(".")
for (let i = 0; i < split.length - 1; i++) {
const nextKey = split[i]
if (obj && obj[nextKey] == null) {
obj[nextKey] = {}
}
obj = obj?.[nextKey]
}
if (!obj) {
return
}
obj[split[split.length - 1]] = value
}

View File

@ -127,18 +127,37 @@ const searchComponentTree = (rootComponent, matchComponent) => {
}
/**
* Searches a component's definition for a setting matching a certin predicate.
* Searches a component's definition for a setting matching a certain predicate.
* These settings are cached because they cannot change at run time.
*/
let componentSettingCache = {}
export const getComponentSettings = componentType => {
const def = store.actions.components.getDefinition(componentType)
if (!def) {
if (!componentType) {
return []
}
let settings = def.settings?.filter(setting => !setting.section) ?? []
def.settings
?.filter(setting => setting.section)
.forEach(section => {
settings = settings.concat(section.settings || [])
})
// Ensure whole component name is used
if (!componentType.startsWith("@budibase")) {
componentType = `@budibase/standard-components/${componentType}`
}
// Check if we have cached this type already
if (componentSettingCache[componentType]) {
return componentSettingCache[componentType]
}
// Otherwise get the settings and cache them
const def = store.actions.components.getDefinition(componentType)
let settings = []
if (def) {
settings = def.settings?.filter(setting => !setting.section) ?? []
def.settings
?.filter(setting => setting.section)
.forEach(section => {
settings = settings.concat(section.settings || [])
})
}
componentSettingCache[componentType] = settings
return settings
}

View File

@ -5,7 +5,7 @@ import {
findComponent,
findComponentPath,
getComponentSettings,
} from "./storeUtils"
} from "./componentUtils"
import { store } from "builderStore"
import { queries as queriesStores, tables as tablesStore } from "stores/backend"
import {
@ -15,6 +15,11 @@ import {
encodeJSBinding,
} from "@budibase/string-templates"
import { TableNames } from "../constants"
import {
convertJSONSchemaToTableSchema,
getJSONArrayDatasourceSchema,
} from "./jsonUtils"
import ActionDefinitions from "components/design/PropertiesPanel/PropertyControls/ButtonActionEditor/manifest.json"
// Regex to match all instances of template strings
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
@ -186,6 +191,7 @@ const getProviderContextBindings = (asset, dataProviders) => {
}
let schema
let table
let readablePrefix
let runtimeSuffix = context.suffix
@ -209,7 +215,16 @@ const getProviderContextBindings = (asset, dataProviders) => {
}
const info = getSchemaForDatasource(asset, datasource)
schema = info.schema
readablePrefix = info.table?.name
table = info.table
// For JSON arrays, use the array name as the readable prefix.
// Otherwise use the table name
if (datasource.type === "jsonarray") {
const split = datasource.label.split(".")
readablePrefix = split[split.length - 1]
} else {
readablePrefix = info.table?.name
}
}
if (!schema) {
return
@ -229,7 +244,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
const fieldSchema = schema[key]
// Make safe runtime binding
const runtimeBinding = `${safeComponentId}.${makePropSafe(key)}`
const safeKey = key.split(".").map(makePropSafe).join(".")
const runtimeBinding = `${safeComponentId}.${safeKey}`
// Optionally use a prefix with readable bindings
let readableBinding = component._instanceName
@ -247,6 +263,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
// datasource options, based on bindable properties
fieldSchema,
providerId,
// Table ID is used by JSON fields to know what table the field is in
tableId: table?._id,
})
})
})
@ -339,6 +357,36 @@ const getUrlBindings = asset => {
}))
}
/**
* Gets all bindable properties exposed in a button actions flow up until
* the specified action ID.
*/
export const getButtonContextBindings = (actions, actionId) => {
// Get the steps leading up to this value
const index = actions?.findIndex(action => action.id === actionId)
if (index == null || index === -1) {
return []
}
const prevActions = actions.slice(0, index)
// Generate bindings for any steps which provide context
let bindings = []
prevActions.forEach((action, idx) => {
const def = ActionDefinitions.actions.find(
x => x.name === action["##eventHandlerType"]
)
if (def.context) {
def.context.forEach(contextValue => {
bindings.push({
readableBinding: `Action ${idx + 1}.${contextValue.label}`,
runtimeBinding: `actions.${idx}.${contextValue.value}`,
})
})
}
})
return bindings
}
/**
* Gets a schema for a datasource object.
*/
@ -347,16 +395,26 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
if (datasource) {
const { type } = datasource
const tables = get(tablesStore).list
// Determine the source table from the datasource type
// Determine the entity which backs this datasource.
// "provider" datasources are those targeting another data provider
if (type === "provider") {
const component = findComponent(asset.props, datasource.providerId)
const source = getDatasourceForProvider(asset, component)
return getSchemaForDatasource(asset, source, isForm)
} else if (type === "query") {
}
// "query" datasources are those targeting non-plus datasources or
// custom queries
else if (type === "query") {
const queries = get(queriesStores).list
table = queries.find(query => query._id === datasource._id)
} else if (type === "field") {
}
// "field" datasources are array-like fields of rows, such as attachments
// or multi-select fields
else if (type === "field") {
table = { name: datasource.fieldName }
const { fieldType } = datasource
if (fieldType === "attachment") {
@ -375,12 +433,22 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
},
}
}
} else {
const tables = get(tablesStore).list
}
// "jsonarray" datasources are arrays inside JSON fields
else if (type === "jsonarray") {
table = tables.find(table => table._id === datasource.tableId)
let tableSchema = table?.schema
schema = getJSONArrayDatasourceSchema(tableSchema, datasource)
}
// Otherwise we assume we're targeting an internal table or a plus
// datasource, and we can treat it as a table with a schema
else {
table = tables.find(table => table._id === datasource.tableId)
}
// Determine the schema from the table if not already determined
// Determine the schema from the backing entity if not already determined
if (table && !schema) {
if (type === "view") {
schema = cloneDeep(table.views?.[datasource.name]?.schema)
@ -397,6 +465,26 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
}
}
// Check for any JSON fields so we can add any top level properties
if (schema) {
let jsonAdditions = {}
Object.keys(schema).forEach(fieldKey => {
const fieldSchema = schema[fieldKey]
if (fieldSchema?.type === "json") {
const jsonSchema = convertJSONSchemaToTableSchema(fieldSchema, {
squashObjects: true,
})
Object.keys(jsonSchema).forEach(jsonKey => {
jsonAdditions[`${fieldKey}.${jsonKey}`] = {
type: jsonSchema[jsonKey].type,
nestedJSON: true,
}
})
}
})
schema = { ...schema, ...jsonAdditions }
}
// Add _id and _rev fields for certain types
if (schema && !isForm && ["table", "link"].includes(datasource.type)) {
schema["_id"] = { type: "string" }
@ -450,15 +538,58 @@ const buildFormSchema = component => {
return schema
}
/**
* Returns an array of the keys of any state variables which are set anywhere
* in the app.
*/
export const getAllStateVariables = () => {
// Get all component containing assets
let allAssets = []
allAssets = allAssets.concat(get(store).layouts || [])
allAssets = allAssets.concat(get(store).screens || [])
// Find all button action settings in all components
let eventSettings = []
allAssets.forEach(asset => {
findAllMatchingComponents(asset.props, component => {
const settings = getComponentSettings(component._component)
settings
.filter(setting => setting.type === "event")
.forEach(setting => {
eventSettings.push(component[setting.key])
})
})
})
// Extract all state keys from any "update state" actions in each setting
let bindingSet = new Set()
eventSettings.forEach(setting => {
if (!Array.isArray(setting)) {
return
}
setting.forEach(action => {
if (
action["##eventHandlerType"] === "Update State" &&
action.parameters?.type === "set" &&
action.parameters?.key &&
action.parameters?.value
) {
bindingSet.add(action.parameters.key)
}
})
})
return Array.from(bindingSet)
}
/**
* Recurses the input object to remove any instances of bindings.
*/
export function removeBindings(obj) {
export const removeBindings = (obj, replacement = "Invalid binding") => {
for (let [key, value] of Object.entries(obj)) {
if (value && typeof value === "object") {
obj[key] = removeBindings(value)
obj[key] = removeBindings(value, replacement)
} else if (typeof value === "string") {
obj[key] = value.replace(CAPTURE_HBS_TEMPLATE, "Invalid binding")
obj[key] = value.replace(CAPTURE_HBS_TEMPLATE, replacement)
}
}
return obj
@ -468,8 +599,8 @@ export function removeBindings(obj) {
* When converting from readable to runtime it can sometimes add too many square brackets,
* this makes sure that doesn't happen.
*/
function shouldReplaceBinding(currentValue, from, convertTo) {
if (!currentValue?.includes(from)) {
const shouldReplaceBinding = (currentValue, convertFrom, convertTo) => {
if (!currentValue?.includes(convertFrom)) {
return false
}
if (convertTo === "readableBinding") {
@ -478,7 +609,7 @@ function shouldReplaceBinding(currentValue, from, convertTo) {
// remove all the spaces, if the input is surrounded by spaces e.g. [ Auto ID ] then
// this makes sure it is detected
const noSpaces = currentValue.replace(/\s+/g, "")
const fromNoSpaces = from.replace(/\s+/g, "")
const fromNoSpaces = convertFrom.replace(/\s+/g, "")
const invalids = [
`[${fromNoSpaces}]`,
`"${fromNoSpaces}"`,
@ -487,14 +618,21 @@ function shouldReplaceBinding(currentValue, from, convertTo) {
return !invalids.find(invalid => noSpaces?.includes(invalid))
}
function replaceBetween(string, start, end, replacement) {
/**
* Utility function which replaces a string between given indices.
*/
const replaceBetween = (string, start, end, replacement) => {
return string.substring(0, start) + replacement + string.substring(end)
}
/**
* utility function for the readableToRuntimeBinding and runtimeToReadableBinding.
* Utility function for the readableToRuntimeBinding and runtimeToReadableBinding.
*/
function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
const bindingReplacement = (
bindableProperties,
textWithBindings,
convertTo
) => {
// Decide from base64 if using JS
const isJS = isJSBinding(textWithBindings)
if (isJS) {
@ -559,14 +697,17 @@ function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
* Extracts a component ID from a handlebars expression setting of
* {{ literal [componentId] }}
*/
function extractLiteralHandlebarsID(value) {
const extractLiteralHandlebarsID = value => {
return value?.match(/{{\s*literal\s*\[+([^\]]+)].*}}/)?.[1]
}
/**
* Converts a readable data binding into a runtime data binding
*/
export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
export const readableToRuntimeBinding = (
bindableProperties,
textWithBindings
) => {
return bindingReplacement(
bindableProperties,
textWithBindings,
@ -577,56 +718,13 @@ export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
/**
* Converts a runtime data binding into a readable data binding
*/
export function runtimeToReadableBinding(bindableProperties, textWithBindings) {
export const runtimeToReadableBinding = (
bindableProperties,
textWithBindings
) => {
return bindingReplacement(
bindableProperties,
textWithBindings,
"readableBinding"
)
}
/**
* Returns an array of the keys of any state variables which are set anywhere
* in the app.
*/
export const getAllStateVariables = () => {
let allComponents = []
// Find all onClick settings in all layouts
get(store).layouts.forEach(layout => {
const components = findAllMatchingComponents(
layout.props,
c => c.onClick != null
)
allComponents = allComponents.concat(components || [])
})
// Find all onClick settings in all screens
get(store).screens.forEach(screen => {
const components = findAllMatchingComponents(
screen.props,
c => c.onClick != null
)
allComponents = allComponents.concat(components || [])
})
// Add state bindings for all state actions
let bindingSet = new Set()
allComponents.forEach(component => {
if (!Array.isArray(component.onClick)) {
return
}
component.onClick.forEach(action => {
if (
action["##eventHandlerType"] === "Update State" &&
action.parameters?.type === "set" &&
action.parameters?.key &&
action.parameters?.value
) {
bindingSet.add(action.parameters.key)
}
})
})
return Array.from(bindingSet)
}

View File

@ -4,7 +4,7 @@ import { getHostingStore } from "./store/hosting"
import { getThemeStore } from "./store/theme"
import { derived, writable } from "svelte/store"
import { FrontendTypes, LAYOUT_NAMES } from "../constants"
import { findComponent } from "./storeUtils"
import { findComponent } from "./componentUtils"
export const store = getFrontendStore()
export const automationStore = getAutomationStore()

View File

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

View File

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

View File

@ -26,7 +26,7 @@ import {
findAllMatchingComponents,
findComponent,
getComponentSettings,
} from "../storeUtils"
} from "../componentUtils"
import { uuid } from "../uuid"
import { removeBindings } from "../dataBinding"
@ -329,12 +329,12 @@ export const getFrontendStore = () => {
},
components: {
select: component => {
if (!component) {
const asset = get(currentAsset)
if (!asset || !component) {
return
}
// If this is the root component, select the asset instead
const asset = get(currentAsset)
const parent = findComponentParent(asset.props, component._id)
if (parent == null) {
const state = get(store)
@ -537,7 +537,7 @@ export const getFrontendStore = () => {
// immediately need to remove bindings, currently these aren't valid when pasted
if (!cut && !preserveBindings) {
state.componentToPaste = removeBindings(state.componentToPaste)
state.componentToPaste = removeBindings(state.componentToPaste, "")
}
// Clone the component to paste

View File

@ -137,6 +137,7 @@ const fieldTypeToComponentMap = {
datetime: "datetimefield",
attachment: "attachmentfield",
link: "relationshipfield",
json: "jsonfield",
}
export function makeDatasourceFormComponents(datasource) {
@ -146,7 +147,7 @@ export function makeDatasourceFormComponents(datasource) {
fields.forEach(field => {
const fieldSchema = schema[field]
// skip autocolumns
if (fieldSchema.autocolumn) {
if (fieldSchema.autocolumn || fieldSchema.nestedJSON) {
return
}
const fieldType =

View File

@ -6,16 +6,20 @@
Toggle,
TextArea,
Multiselect,
Label,
} from "@budibase/bbui"
import Dropzone from "components/common/Dropzone.svelte"
import { capitalise } from "helpers"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
import Editor from "../../integration/QueryEditor.svelte"
export let defaultValue
export let meta
export let value = defaultValue || (meta.type === "boolean" ? false : "")
export let readonly
$: stringVal =
typeof value === "object" ? JSON.stringify(value, null, 2) : value
$: type = meta?.type
$: label = meta.name ? capitalise(meta.name) : ""
</script>
@ -40,6 +44,14 @@
<LinkedRowSelector bind:linkedRows={value} schema={meta} />
{:else if type === "longform"}
<TextArea {label} bind:value />
{:else if type === "json"}
<Label>{label}</Label>
<Editor
editorHeight="250"
mode="json"
on:change={({ detail }) => (value = detail.value)}
value={stringVal}
/>
{:else}
<Input
{label}

View File

@ -9,6 +9,7 @@
DatePicker,
ModalContent,
Context,
Modal,
notifications,
} from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
@ -32,12 +33,14 @@
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import { getBindings } from "components/backend/DataTable/formula"
import { getContext } from "svelte"
import JSONSchemaModal from "./JSONSchemaModal.svelte"
const AUTO_TYPE = "auto"
const FORMULA_TYPE = FIELDS.FORMULA.type
const LINK_TYPE = FIELDS.LINK.type
const STRING_TYPE = FIELDS.STRING.type
const NUMBER_TYPE = FIELDS.NUMBER.type
const JSON_TYPE = FIELDS.JSON.type
const DATE_TYPE = FIELDS.DATETIME.type
const dispatch = createEventDispatcher()
@ -64,6 +67,7 @@
let confirmDeleteDialog
let deletion
let deleteColName
let jsonSchemaModal
$: checkConstraints(field)
$: required = !!field?.constraints?.presence || primaryDisplay
@ -79,10 +83,14 @@
// used to select what different options can be displayed for column type
$: canBeSearched =
field.type !== LINK_TYPE &&
field.type !== JSON_TYPE &&
field.subtype !== AUTO_COLUMN_SUB_TYPES.CREATED_BY &&
field.subtype !== AUTO_COLUMN_SUB_TYPES.UPDATED_BY &&
field.type !== FORMULA_TYPE
$: canBeDisplay = field.type !== LINK_TYPE && field.type !== AUTO_TYPE
$: canBeDisplay =
field.type !== LINK_TYPE &&
field.type !== AUTO_TYPE &&
field.type !== JSON_TYPE
$: canBeRequired =
field.type !== LINK_TYPE && !uneditable && field.type !== AUTO_TYPE
$: relationshipOptions = getRelationshipOptions(field)
@ -176,6 +184,10 @@
}
}
function openJsonSchemaEditor() {
jsonSchemaModal.show()
}
function confirmDelete() {
confirmDeleteDialog.show()
deletion = true
@ -430,6 +442,10 @@
getOptionLabel={option => option[1].name}
getOptionValue={option => option[0]}
/>
{:else if field.type === JSON_TYPE}
<Button primary text on:click={openJsonSchemaEditor}
>Open schema editor</Button
>
{/if}
<div slot="footer">
@ -438,6 +454,16 @@
{/if}
</div>
</ModalContent>
<Modal bind:this={jsonSchemaModal}>
<JSONSchemaModal
schema={field.schema}
json={field.json}
on:save={({ detail }) => {
field.schema = detail.schema
field.json = detail.json
}}
/>
</Modal>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Column"

View File

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

View File

@ -14,7 +14,7 @@
notifications,
} from "@budibase/bbui"
import ErrorSVG from "assets/error.svg?raw"
import { findComponent, findComponentPath } from "builderStore/storeUtils"
import { findComponent, findComponentPath } from "builderStore/componentUtils"
let iframe
let layout
@ -69,15 +69,7 @@
previewDevice: $store.previewDevice,
messagePassing: $store.clientFeatures.messagePassing,
}
// Saving pages and screens to the DB causes them to have _revs.
// These revisions change every time a save happens and causes
// these reactive statements to fire, even though the actual
// definition hasn't changed.
// By deleting all _rev properties we can avoid this and increase
// performance.
$: json = JSON.stringify(previewData)
$: strippedJson = json.replace(/"_rev":\s*"[^"]+"/g, `"_rev":""`)
// Update the iframe with the builder info to render the correct preview
const refreshContent = message => {
@ -87,7 +79,7 @@
}
// Refresh the preview when required
$: refreshContent(strippedJson)
$: refreshContent(json)
function receiveMessage(message) {
const handlers = {
@ -102,7 +94,7 @@
if (!$store.clientFeatures.intelligentLoading) {
loading = false
}
refreshContent(strippedJson)
refreshContent(json)
},
[MessageTypes.ERROR]: event => {
// Catch any app errors

View File

@ -43,7 +43,8 @@
"attachmentfield",
"relationshipfield",
"daterangepicker",
"multifieldselect"
"multifieldselect",
"jsonfield"
]
},
{
@ -73,6 +74,7 @@
"heading",
"text",
"button",
"tag",
"divider",
"image",
"backgroundimage",

View File

@ -49,6 +49,7 @@ export default `
try {
parsed = JSON.parse(event.data)
} catch (error) {
console.error("Client received invalid JSON")
// Ignore
}
if (!parsed) {

View File

@ -2,7 +2,7 @@
import { get } from "svelte/store"
import { store, currentAsset } from "builderStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { findComponentParent } from "builderStore/storeUtils"
import { findComponentParent } from "builderStore/componentUtils"
import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui"
export let component

View File

@ -1,6 +1,6 @@
import { writable, get } from "svelte/store"
import { store as frontendStore } from "builderStore"
import { findComponentPath } from "builderStore/storeUtils"
import { findComponentPath } from "builderStore/componentUtils"
export const DropEffect = {
MOVE: "move",

View File

@ -63,7 +63,14 @@
// If no specific value is depended upon, check if a value exists at all
// for the dependent setting
if (dependantValue == null) {
return !isEmpty(componentInstance[dependantSetting])
const currentValue = componentInstance[dependantSetting]
if (currentValue === false) {
return false
}
if (currentValue === true) {
return true
}
return !isEmpty(currentValue)
}
// Otherwise check the value matches

View File

@ -9,8 +9,9 @@
ActionMenu,
MenuItem,
} from "@budibase/bbui"
import { getAvailableActions } from "./actions"
import { getAvailableActions } from "./index"
import { generate } from "shortid"
import { getButtonContextBindings } from "builderStore/dataBinding"
const flipDurationMs = 150
const EVENT_TYPE_KEY = "##eventHandlerType"
@ -19,7 +20,16 @@
export let actions
export let bindings = []
// dndzone needs an id on the array items, so this adds some temporary ones.
let selectedAction = actions?.length ? actions[0] : null
// These are ephemeral bindings which only exist while executing actions
$: buttonContextBindings = getButtonContextBindings(
actions,
selectedAction?.id
)
$: allBindings = buttonContextBindings.concat(bindings)
// Assign a unique ID to each action
$: {
if (actions) {
actions.forEach(action => {
@ -30,8 +40,6 @@
}
}
let selectedAction = actions?.length ? actions[0] : null
$: selectedActionComponent =
selectedAction &&
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_KEY])?.component
@ -122,7 +130,7 @@
<svelte:component
this={selectedActionComponent}
parameters={selectedAction.parameters}
{bindings}
bindings={allBindings}
/>
</div>
{/if}

View File

@ -2,7 +2,7 @@
import { ActionButton, Button, Drawer } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { notifications } from "@budibase/bbui"
import EventEditor from "./EventEditor.svelte"
import ButtonActionDrawer from "./ButtonActionDrawer.svelte"
import { automationStore } from "builderStore"
import { cloneDeep } from "lodash/fp"
@ -67,7 +67,7 @@
Define what actions to run.
</svelte:fragment>
<Button cta slot="buttons" on:click={saveEventData}>Save</Button>
<EventEditor
<ButtonActionDrawer
slot="body"
bind:actions={tmpValue}
eventType={name}

View File

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

View File

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

View File

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

View File

@ -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"
}
]
}

View File

@ -16,7 +16,7 @@
import { selectedComponent } from "builderStore"
import { getComponentForSettingType } from "./componentSettings"
import PropertyControl from "./PropertyControl.svelte"
import { getComponentSettings } from "builderStore/storeUtils"
import { getComponentSettings } from "builderStore/componentUtils"
export let conditions = []
export let bindings = []

View File

@ -2,7 +2,7 @@
import { Select } from "@budibase/bbui"
import { makePropSafe } from "@budibase/string-templates"
import { currentAsset, store } from "builderStore"
import { findComponentPath } from "builderStore/storeUtils"
import { findComponentPath } from "builderStore/componentUtils"
import { createEventDispatcher, onMount } from "svelte"
export let value

View File

@ -48,9 +48,7 @@
return [...acc, ...viewsArr]
}, [])
$: queries = $queriesStore.list
.filter(
query => showAllQueries || query.queryVerb === "read" || query.readable
)
.filter(q => showAllQueries || q.queryVerb === "read" || q.readable)
.map(query => ({
label: query.name,
name: query.name,
@ -104,6 +102,22 @@
value: `{{ literal ${runtimeBinding} }}`,
}
})
$: jsonArrays = bindings
.filter(x => x.fieldSchema?.type === "jsonarray")
.map(binding => {
const { providerId, readableBinding, runtimeBinding, tableId } = binding
const { name, type, prefixKeys } = binding.fieldSchema
return {
providerId,
label: readableBinding,
fieldName: name,
fieldType: type,
tableId,
prefixKeys,
type: "jsonarray",
value: `{{ literal ${runtimeBinding} }}`,
}
})
const handleSelected = selected => {
dispatch("change", selected)
@ -230,6 +244,17 @@
{/each}
</ul>
{/if}
{#if jsonArrays?.length}
<Divider size="S" />
<div class="title">
<Heading size="XS">JSON Arrays</Heading>
</div>
<ul>
{#each jsonArrays as field}
<li on:click={() => handleSelected(field)}>{field.label}</li>
{/each}
</ul>
{/if}
{#if dataProviders?.length}
<Divider size="S" />
<div class="title">

View File

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

View File

@ -1,2 +0,0 @@
import EventsEditor from "./EventPropertyControl.svelte"
export default EventsEditor

View File

@ -21,7 +21,7 @@
export let panel = ClientBindingPanel
export let allowBindings = true
const BannedTypes = ["link", "attachment", "formula"]
const BannedTypes = ["link", "attachment", "formula", "json", "jsonarray"]
$: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type))

View File

@ -5,7 +5,7 @@
getSchemaForDatasource,
} from "builderStore/dataBinding"
import { currentAsset } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils"
import { findClosestMatchingComponent } from "builderStore/componentUtils"
export let componentInstance
export let value

View File

@ -1,7 +1,7 @@
<script>
import { ActionButton } from "@budibase/bbui"
import { currentAsset, store } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils"
import { findClosestMatchingComponent } from "builderStore/componentUtils"
import { makeDatasourceFormComponents } from "builderStore/store/screenTemplates/utils/commonComponents"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"

View File

@ -11,7 +11,7 @@
DatePicker,
} from "@budibase/bbui"
import { currentAsset, selectedComponent } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils"
import { findClosestMatchingComponent } from "builderStore/componentUtils"
import { getSchemaForDatasource } from "builderStore/dataBinding"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { generate } from "shortid"

View File

@ -1,7 +1,7 @@
import { Checkbox, Select, Stepper } from "@budibase/bbui"
import DataSourceSelect from "./DataSourceSelect.svelte"
import DataProviderSelect from "./DataProviderSelect.svelte"
import EventsEditor from "./EventsEditor"
import ButtonActionEditor from "./ButtonActionEditor/ButtonActionEditor.svelte"
import TableSelect from "./TableSelect.svelte"
import ColorPicker from "./ColorPicker.svelte"
import { IconSelect } from "./IconSelect"
@ -24,7 +24,7 @@ const componentMap = {
dataProvider: DataProviderSelect,
boolean: Checkbox,
number: Stepper,
event: EventsEditor,
event: ButtonActionEditor,
table: TableSelect,
color: ColorPicker,
icon: IconSelect,
@ -45,6 +45,7 @@ const componentMap = {
"field/attachment": FormFieldSelect,
"field/link": FormFieldSelect,
"field/array": FormFieldSelect,
"field/json": FormFieldSelect,
// Some validation types are the same as others, so not all types are
// explicitly listed here. e.g. options uses string validation
"validation/string": ValidationEditor,

View File

@ -89,6 +89,14 @@ export const FIELDS = {
presence: false,
},
},
JSON: {
name: "JSON",
type: "json",
constraints: {
type: "object",
presence: false,
},
},
}
export const AUTO_COLUMN_SUB_TYPES = {

View File

@ -1,4 +1,5 @@
import { NoEmptyFilterStrings } from "../constants/lucene"
import { deepGet } from "@budibase/bbui"
/**
* Removes any fields that contain empty strings that would cause inconsistent
@ -96,9 +97,13 @@ export const buildLuceneQuery = filter => {
* @param query the JSON lucene query
*/
export const luceneQuery = (docs, query) => {
if (!docs || !Array.isArray(docs)) {
return []
}
if (!query) {
return docs
}
// make query consistent first
query = cleanupQuery(query)
@ -106,7 +111,9 @@ export const luceneQuery = (docs, query) => {
const match = (type, failFn) => doc => {
const filters = Object.entries(query[type] || {})
for (let i = 0; i < filters.length; i++) {
if (failFn(filters[i][0], filters[i][1], doc)) {
const [key, testValue] = filters[i]
const docValue = deepGet(doc, key)
if (failFn(docValue, testValue)) {
return false
}
}
@ -114,38 +121,38 @@ export const luceneQuery = (docs, query) => {
}
// Process a string match (fails if the value does not start with the string)
const stringMatch = match("string", (key, value, doc) => {
return !doc[key] || !doc[key].startsWith(value)
const stringMatch = match("string", (docValue, testValue) => {
return !docValue || !docValue.startsWith(testValue)
})
// Process a fuzzy match (treat the same as starts with when running locally)
const fuzzyMatch = match("fuzzy", (key, value, doc) => {
return !doc[key] || !doc[key].startsWith(value)
const fuzzyMatch = match("fuzzy", (docValue, testValue) => {
return !docValue || !docValue.startsWith(testValue)
})
// Process a range match
const rangeMatch = match("range", (key, value, doc) => {
return !doc[key] || doc[key] < value.low || doc[key] > value.high
const rangeMatch = match("range", (docValue, testValue) => {
return !docValue || docValue < testValue.low || docValue > testValue.high
})
// Process an equal match (fails if the value is different)
const equalMatch = match("equal", (key, value, doc) => {
return value != null && value !== "" && doc[key] !== value
const equalMatch = match("equal", (docValue, testValue) => {
return testValue != null && testValue !== "" && docValue !== testValue
})
// Process a not-equal match (fails if the value is the same)
const notEqualMatch = match("notEqual", (key, value, doc) => {
return value != null && value !== "" && doc[key] === value
const notEqualMatch = match("notEqual", (docValue, testValue) => {
return testValue != null && testValue !== "" && docValue === testValue
})
// Process an empty match (fails if the value is not empty)
const emptyMatch = match("empty", (key, value, doc) => {
return doc[key] != null && doc[key] !== ""
const emptyMatch = match("empty", docValue => {
return docValue != null && docValue !== ""
})
// Process a not-empty match (fails is the value is empty)
const notEmptyMatch = match("notEmpty", (key, value, doc) => {
return doc[key] == null || doc[key] === ""
const notEmptyMatch = match("notEmpty", docValue => {
return docValue == null || docValue === ""
})
// Match a document against all criteria

View File

@ -13,7 +13,7 @@
import FrontendNavigatePane from "components/design/NavigationPanel/FrontendNavigatePane.svelte"
import { goto, leftover, params } from "@roxi/routify"
import { FrontendTypes } from "constants"
import { findComponent, findComponentPath } from "builderStore/storeUtils"
import { findComponent, findComponentPath } from "builderStore/componentUtils"
import { get } from "svelte/store"
import AppThemeSelect from "components/design/AppPreview/AppThemeSelect.svelte"
import ThemeEditor from "components/design/AppPreview/ThemeEditor.svelte"

View File

@ -500,9 +500,20 @@
"defaultValue": "M"
}
],
"context": {
"type": "schema"
}
"context": [
{
"type": "schema"
},
{
"type": "static",
"values": [
{
"label": "Row Index",
"key": "index"
}
]
}
]
},
"stackedlist": {
"deprecated": true,
@ -808,6 +819,57 @@
}
]
},
"tag": {
"name": "Tag",
"icon": "TextParagraph",
"showSettingsBar": true,
"settings": [
{
"type": "text",
"label": "Text",
"key": "text"
},
{
"type": "select",
"label": "Size",
"key": "size",
"defaultValue": "M",
"showInBar": true,
"barStyle": "picker",
"options": [{
"label": "Small",
"value": "S"
}, {
"label": "Medium",
"value": "M"
}, {
"label": "Large",
"value": "L"
}]
},
{
"type": "color",
"label": "Color",
"key": "color",
"showInBar": true,
"barSeparator": false
},
{
"type": "boolean",
"label": "Show delete icon",
"key": "closable",
"showInBar": true,
"barIcon": "TagItalic",
"barTitle": "Show delete icon"
},
{
"type": "event",
"label": "On click delete icon",
"key": "onClick",
"dependsOn": "closable"
}
]
},
"image": {
"name": "Image",
"description": "A basic component for displaying images",
@ -1775,6 +1837,10 @@
{
"type": "static",
"values": [
{
"label": "Value",
"key": "__value"
},
{
"label": "Valid",
"key": "__valid"
@ -2407,6 +2473,40 @@
}
]
},
"jsonfield": {
"name": "JSON Field",
"icon": "Brackets",
"styles": ["size"],
"editable": true,
"settings": [
{
"type": "field/json",
"label": "Field",
"key": "field"
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Placeholder",
"key": "placeholder"
},
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
}
]
},
"dataprovider": {
"name": "Data Provider",
"info": "Pagination is only available for data stored in tables.",
@ -3192,6 +3292,16 @@
}
]
},
{
"type": "static",
"suffix": "repeater",
"values": [
{
"label": "Row Index",
"key": "index"
}
]
},
{
"type": "schema",
"suffix": "repeater"

View File

@ -35,6 +35,7 @@
"@spectrum-css/divider": "^1.0.3",
"@spectrum-css/link": "^3.1.3",
"@spectrum-css/page": "^3.0.1",
"@spectrum-css/tag": "^3.1.4",
"@spectrum-css/typography": "^3.0.2",
"@spectrum-css/vars": "^3.0.1",
"apexcharts": "^3.22.1",

View File

@ -4,6 +4,10 @@ import { fetchViewData } from "./views"
import { fetchRelationshipData } from "./relationships"
import { FieldTypes } from "../constants"
import { executeQuery, fetchQueryDefinition } from "./queries"
import {
convertJSONSchemaToTableSchema,
getJSONArrayDatasourceSchema,
} from "builder/src/builderStore/jsonUtils"
/**
* Fetches all rows for a particular Budibase data source.
@ -49,16 +53,17 @@ export const fetchDatasourceSchema = async dataSource => {
return null
}
const { type } = dataSource
let schema
// Nested providers should already have exposed their own schema
if (type === "provider") {
return dataSource.value?.schema
schema = dataSource.value?.schema
}
// Field sources have their schema statically defined
if (type === "field") {
if (dataSource.fieldType === "attachment") {
return {
schema = {
url: {
type: "string",
},
@ -67,7 +72,7 @@ export const fetchDatasourceSchema = async dataSource => {
},
}
} else if (dataSource.fieldType === "array") {
return {
schema = {
value: {
type: "string",
},
@ -75,20 +80,48 @@ export const fetchDatasourceSchema = async dataSource => {
}
}
// JSON arrays need their table definitions fetched.
// We can then extract their schema as a subset of the table schema.
if (type === "jsonarray") {
const table = await fetchTableDefinition(dataSource.tableId)
schema = getJSONArrayDatasourceSchema(table?.schema, dataSource)
}
// Tables, views and links can be fetched by table ID
if (
(type === "table" || type === "view" || type === "link") &&
dataSource.tableId
) {
const table = await fetchTableDefinition(dataSource.tableId)
return table?.schema
schema = table?.schema
}
// Queries can be fetched by query ID
if (type === "query" && dataSource._id) {
const definition = await fetchQueryDefinition(dataSource._id)
return definition?.schema
schema = definition?.schema
}
return null
// Sanity check
if (!schema) {
return null
}
// Check for any JSON fields so we can add any top level properties
let jsonAdditions = {}
Object.keys(schema).forEach(fieldKey => {
const fieldSchema = schema[fieldKey]
if (fieldSchema?.type === "json") {
const jsonSchema = convertJSONSchemaToTableSchema(fieldSchema, {
squashObjects: true,
})
Object.keys(jsonSchema).forEach(jsonKey => {
jsonAdditions[`${fieldKey}.${jsonKey}`] = {
type: jsonSchema[jsonKey].type,
nestedJSON: true,
}
})
}
})
return { ...schema, ...jsonAdditions }
}

View File

@ -194,9 +194,12 @@
// For providers referencing another provider, just use the rows it
// provides
allRows = dataSource?.value?.rows || []
} else if (dataSource?.type === "field") {
// Field sources will be available from context.
// Enrich non object elements into object to ensure a valid schema.
} else if (
dataSource?.type === "field" ||
dataSource?.type === "jsonarray"
) {
// These sources will be available directly from context.
// Enrich non object elements into objects to ensure a valid schema.
const data = dataSource?.value || []
if (Array.isArray(data) && data[0] && typeof data[0] !== "object") {
allRows = data.map(value => ({ value }))

View File

@ -21,8 +21,8 @@
{#if $component.empty}
<Placeholder />
{:else if rows.length > 0}
{#each rows as row}
<Provider data={row}>
{#each rows as row, index}
<Provider data={{ ...row, index }}>
<slot />
</Provider>
{/each}

View File

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

View File

@ -73,7 +73,7 @@
enrichedFilter.push({
field: column.name,
operator: column.type === "string" ? "string" : "equal",
type: "string",
type: column.type === "string" ? "string" : "number",
valueType: "Binding",
value: `{{ [${formId}].[${column.name}] }}`,
})

View File

@ -61,7 +61,7 @@
enrichedFilter.push({
field: column.name,
operator: column.type === "string" ? "string" : "equal",
type: "string",
type: column.type === "string" ? "string" : "number",
valueType: "Binding",
value: `{{ ${safe(formId)}.${safe(column.name)} }}`,
})

View File

@ -19,7 +19,7 @@
export let schemaFields
export let filters = []
const BannedTypes = ["link", "attachment", "formula"]
const BannedTypes = ["link", "attachment", "formula", "json"]
$: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type))

View File

@ -3,6 +3,8 @@
import { derived, get, writable } from "svelte/store"
import { createValidatorFromConstraints } from "./validation"
import { generateID } from "utils/helpers"
import { deepGet, deepSet } from "@budibase/bbui"
import { cloneDeep } from "lodash/fp"
export let dataSource
export let disabled = false
@ -49,6 +51,20 @@
})
}
// Derive value of whole form
$: formValue = deriveFormValue(initialValues, $values, $enrichments)
// Create data context to provide
$: dataContext = {
...formValue,
// These static values are prefixed to avoid clashes with actual columns
__value: formValue,
__valid: valid,
__currentStep: $currentStep,
__currentStepValid: $currentStepValid,
}
// Generates a derived store from an array of fields, comprised of a map of
// extracted values from the field array
const deriveFieldProperty = (fieldStores, getProp) => {
@ -78,6 +94,35 @@
})
}
// Derive the overall form value and deeply set all field paths so that we
// can support things like JSON fields.
const deriveFormValue = (initialValues, values, enrichments) => {
let formValue = cloneDeep(initialValues || {})
// We need to sort the keys to avoid a JSON field overwriting a nested field
const sortedFields = Object.entries(values || {})
.map(([key, value]) => {
const field = getField(key)
return {
key,
value,
lastUpdate: get(field).fieldState?.lastUpdate || 0,
}
})
.sort((a, b) => {
return a.lastUpdate > b.lastUpdate
})
// Merge all values and enrichments into a single value
sortedFields.forEach(({ key, value }) => {
deepSet(formValue, key, value)
})
Object.entries(enrichments || {}).forEach(([key, value]) => {
deepSet(formValue, key, value)
})
return formValue
}
// Searches the field array for a certain field
const getField = name => {
return fields.find(field => get(field).name === name)
@ -97,13 +142,20 @@
}
// If we've already registered this field then keep some existing state
let initialValue = initialValues[field] ?? defaultValue
let initialValue = deepGet(initialValues, field) ?? defaultValue
let fieldId = `id-${generateID()}`
const existingField = getField(field)
if (existingField) {
const { fieldState } = get(existingField)
initialValue = fieldState.value ?? initialValue
fieldId = fieldState.fieldId
// Use new default value if default value changed,
// otherwise use the current value if possible
if (defaultValue !== fieldState.defaultValue) {
initialValue = defaultValue
} else {
initialValue = fieldState.value ?? initialValue
}
}
// Auto columns are always disabled
@ -130,6 +182,7 @@
disabled: disabled || fieldDisabled || isAutoColumn,
defaultValue,
validator,
lastUpdate: Date.now(),
},
fieldApi: makeFieldApi(field, defaultValue),
fieldSchema: schema?.[field] ?? {},
@ -204,6 +257,7 @@
fieldInfo.update(state => {
state.fieldState.value = value
state.fieldState.error = error
state.fieldState.lastUpdate = Date.now()
return state
})
@ -220,6 +274,7 @@
fieldInfo.update(state => {
state.fieldState.value = newValue
state.fieldState.error = null
state.fieldState.lastUpdate = Date.now()
return state
})
}
@ -299,18 +354,6 @@
{ type: ActionTypes.ClearForm, callback: formApi.clear },
{ type: ActionTypes.ChangeFormStep, callback: formApi.changeStep },
]
// Create data context to provide
$: dataContext = {
...initialValues,
...$values,
...$enrichments,
// These static values are prefixed to avoid clashes with actual columns
__valid: valid,
__currentStep: $currentStep,
__currentStepValid: $currentStepValid,
}
</script>
<Provider {actions} data={dataContext}>

View File

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

View File

@ -1,6 +1,7 @@
<script>
import { CoreTextArea } from "@budibase/bbui"
import Field from "./Field.svelte"
import { getContext } from "svelte"
export let field
export let label
@ -11,6 +12,9 @@
let fieldState
let fieldApi
const component = getContext("component")
$: height = $component.styles?.normal?.height || "124px"
</script>
<Field
@ -24,13 +28,26 @@
bind:fieldApi
>
{#if fieldState}
<CoreTextArea
value={fieldState.value}
on:change={e => fieldApi.setValue(e.detail)}
disabled={fieldState.disabled}
error={fieldState.error}
id={fieldState.fieldId}
{placeholder}
/>
<div style="--height: {height};">
<CoreTextArea
value={fieldState.value}
on:change={e => fieldApi.setValue(e.detail)}
disabled={fieldState.disabled}
error={fieldState.error}
id={fieldState.fieldId}
{placeholder}
/>
</div>
{/if}
</Field>
<style>
:global(.spectrum-Form-itemField .spectrum-Textfield--multiline) {
min-height: calc(var(--height) - 24px);
}
:global(.spectrum-Form--labelsAbove
.spectrum-Form-itemField
.spectrum-Textfield--multiline) {
min-height: calc(var(--height) - 24px);
}
</style>

View File

@ -11,3 +11,4 @@ export { default as attachmentfield } from "./AttachmentField.svelte"
export { default as relationshipfield } from "./RelationshipField.svelte"
export { default as passwordfield } from "./PasswordField.svelte"
export { default as formstep } from "./FormStep.svelte"
export { default as jsonfield } from "./JSONField.svelte"

View File

@ -206,6 +206,7 @@ const parseType = (value, type) => {
return value
}
// Parse array, treating no elements as null
if (type === FieldTypes.ARRAY) {
if (!Array.isArray(value) || !value.length) {
return null
@ -213,6 +214,12 @@ const parseType = (value, type) => {
return value
}
// For JSON we don't touch the value at all as we want to verify it in its
// raw form
if (type === FieldTypes.JSON) {
return value
}
// If some unknown type, treat as null to avoid breaking validators
return null
}
@ -290,6 +297,19 @@ const notContainsHandler = (value, rule) => {
return !containsHandler(value, rule)
}
// Evaluates a constraint that the value must be a valid json object
const jsonHandler = value => {
if (typeof value !== "object" || Array.isArray(value)) {
return false
}
try {
JSON.parse(JSON.stringify(value))
return true
} catch (error) {
return false
}
}
/**
* Map of constraint types to handlers.
*/
@ -306,6 +326,7 @@ const handlerMap = {
notRegex: notRegexHandler,
contains: containsHandler,
notContains: notContainsHandler,
json: jsonHandler,
}
/**

View File

@ -29,6 +29,7 @@ export { default as backgroundimage } from "./BackgroundImage.svelte"
export { default as daterangepicker } from "./DateRangePicker.svelte"
export { default as cardstat } from "./CardStat.svelte"
export { default as spectrumcard } from "./SpectrumCard.svelte"
export { default as tag } from "./Tag.svelte"
export * from "./charts"
export * from "./forms"
export * from "./table"

View File

@ -24,6 +24,7 @@
class:flipped
class:line
style="top: {top}px; left: {left}px; width: {width}px; height: {height}px; --color: {color}; --zIndex: {zIndex};"
class:withText={!!text}
>
{#if text}
<div class="text" class:flipped class:line class:right={alignRight}>
@ -39,12 +40,12 @@
z-index: var(--zIndex);
border: 2px solid var(--color);
pointer-events: none;
border-top-right-radius: 4px;
border-top-left-radius: 0;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-radius: 4px;
}
.indicator.flipped {
.indicator.withText {
border-top-left-radius: 0;
}
.indicator.withText.flipped {
border-top-left-radius: 4px;
}
.indicator.line {
@ -74,8 +75,7 @@
border-radius: 4px;
}
.text.flipped {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
border-radius: 4px;
transform: translateY(0%);
top: -2px;
}

View File

@ -13,6 +13,7 @@ export const FieldTypes = {
ATTACHMENT: "attachment",
LINK: "link",
FORMULA: "formula",
JSON: "json",
}
export const UnsortableTypes = [

View File

@ -61,7 +61,8 @@ export const createDataSourceStore = () => {
// Emit this as a window event, so parent screens which are iframing us in
// can also invalidate the same datasource
if (get(routeStore).queryParams?.peek) {
const inModal = get(routeStore).queryParams?.peek
if (inModal) {
window.parent.postMessage({
type: "invalidate-datasource",
detail: { dataSourceId },

View File

@ -8,20 +8,49 @@ import {
} from "stores"
import { saveRow, deleteRow, executeQuery, triggerAutomation } from "api"
import { ActionTypes } from "constants"
import { enrichDataBindings } from "./enrichDataBinding"
import { deepSet } from "@budibase/bbui"
const saveRowHandler = async (action, context) => {
const { fields, providerId, tableId } = action.parameters
let payload
if (providerId) {
let draft = context[providerId]
payload = { ...context[providerId] }
} else {
payload = {}
}
if (fields) {
for (let [field, value] of Object.entries(fields)) {
deepSet(payload, field, value)
}
}
if (tableId) {
payload.tableId = tableId
}
const row = await saveRow(payload)
return {
row,
}
}
const duplicateRowHandler = async (action, context) => {
const { fields, providerId, tableId } = action.parameters
if (providerId) {
let payload = { ...context[providerId] }
if (fields) {
for (let [field, value] of Object.entries(fields)) {
draft[field] = value
deepSet(payload, field, value)
}
}
if (tableId) {
draft.tableId = tableId
payload.tableId = tableId
}
delete payload._id
delete payload._rev
const row = await saveRow(payload)
return {
row,
}
await saveRow(draft)
}
}
@ -46,11 +75,12 @@ const navigationHandler = action => {
const queryExecutionHandler = async action => {
const { datasourceId, queryId, queryParams } = action.parameters
await executeQuery({
const result = await executeQuery({
datasourceId,
queryId,
parameters: queryParams,
})
return { result }
}
const executeActionHandler = async (
@ -129,6 +159,7 @@ const updateStateHandler = action => {
const handlerMap = {
["Save Row"]: saveRowHandler,
["Duplicate Row"]: duplicateRowHandler,
["Delete Row"]: deleteRowHandler,
["Navigate To"]: navigationHandler,
["Execute Query"]: queryExecutionHandler,
@ -165,12 +196,27 @@ export const enrichButtonActions = (actions, context) => {
return actions
}
// Button context is built up as actions are executed.
// Inherit any previous button context which may have come from actions
// before a confirmable action since this breaks the chain.
let buttonContext = context.actions || []
const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
return async () => {
for (let i = 0; i < handlers.length; i++) {
try {
const action = actions[i]
const callback = async () => handlers[i](action, context)
// Skip any non-existent action definitions
if (!handlers[i]) {
continue
}
// Built total context for this action
const totalContext = { ...context, actions: buttonContext }
// Get and enrich this button action with the total context
let action = actions[i]
action = enrichDataBindings(action, totalContext)
const callback = async () => handlers[i](action, totalContext)
// If this action is confirmable, show confirmation and await a
// callback to execute further actions
@ -185,7 +231,15 @@ export const enrichButtonActions = (actions, context) => {
// then execute the rest of the actions in the chain
const result = await callback()
if (result !== false) {
const next = enrichButtonActions(actions.slice(i + 1), context)
// Generate a new total context to pass into the next enrichment
buttonContext.push(result)
const newContext = { ...context, actions: buttonContext }
// Enrich and call the next button action
const next = enrichButtonActions(
actions.slice(i + 1),
newContext
)
await next()
}
}
@ -201,6 +255,8 @@ export const enrichButtonActions = (actions, context) => {
const result = await callback()
if (result === false) {
return
} else {
buttonContext.push(result)
}
}
} catch (error) {

View File

@ -32,35 +32,56 @@ export const enrichProps = (props, context) => {
data: context[context.closestComponentId],
}
// Enrich all data bindings in top level props
let enrichedProps = enrichDataBindings(props, totalContext)
// Enrich click actions if they exist
Object.keys(enrichedProps).forEach(prop => {
// We want to exclude any button actions from enrichment at this stage.
// Extract top level button action settings.
let normalProps = { ...props }
let actionProps = {}
Object.keys(normalProps).forEach(prop => {
if (prop?.toLowerCase().includes("onclick")) {
enrichedProps[prop] = enrichButtonActions(
enrichedProps[prop],
totalContext
)
actionProps[prop] = normalProps[prop]
delete normalProps[prop]
}
})
// Enrich any click actions in conditions
if (enrichedProps._conditions) {
enrichedProps._conditions.forEach(condition => {
if (condition.setting?.toLowerCase().includes("onclick")) {
condition.settingValue = enrichButtonActions(
condition.settingValue,
totalContext
)
// Handle conditional UI separately after normal settings
let conditions = normalProps._conditions
delete normalProps._conditions
// If there is an onclick function in here then it won't be serialised
// properly, and therefore will not be updated properly.
// The solution to this is add a rand which will ensure diffs happen
// every time.
condition.rand = Math.random()
// Enrich all props except button actions
let enrichedProps = enrichDataBindings(normalProps, totalContext)
// Enrich button actions.
// Actions are enriched into a function at this stage, but actual data
// binding enrichment is done dynamically at runtime.
Object.keys(actionProps).forEach(prop => {
enrichedProps[prop] = enrichButtonActions(actionProps[prop], totalContext)
})
// Conditions
if (conditions?.length) {
let enrichedConditions = []
conditions.forEach(condition => {
if (condition.setting?.toLowerCase().includes("onclick")) {
// Copy and remove the setting value from the condition as it needs
// enriched separately
let toEnrich = { ...condition }
delete toEnrich.settingValue
// Join the condition back together
enrichedConditions.push({
...enrichDataBindings(toEnrich, totalContext),
settingValue: enrichButtonActions(
condition.settingValue,
totalContext
),
rand: Math.random(),
})
} else {
// Normal condition
enrichedConditions.push(enrichDataBindings(condition, totalContext))
}
})
enrichedProps._conditions = enrichedConditions
}
return enrichedProps

View File

@ -305,6 +305,11 @@
resolved "https://registry.yarnpkg.com/@spectrum-css/tabs/-/tabs-3.1.5.tgz#cc82e69c1fc721902345178231fb95d05938b983"
integrity sha512-UtfW8bA1quYnJM6v/lp6AVYGnQFkiUix2FHAf/4VHVrk4mh7ydtLiXS0IR3Kx+t/S8FWdSdSQHDZ8tHbY1ZLZg==
"@spectrum-css/tag@^3.1.4":
version "3.1.4"
resolved "https://registry.yarnpkg.com/@spectrum-css/tag/-/tag-3.1.4.tgz#334384dd789ddf0562679cae62ef763883480ac5"
integrity sha512-9dYBMhCEkjy+p75XJIfCA2/zU4JAqsJrL7fkYIDXakS6/BzeVtIvAW/6JaIHtLIA9lrj0Sn4m+ZjceKnZNIv1w==
"@spectrum-css/tags@^3.0.2":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@spectrum-css/tags/-/tags-3.0.3.tgz#fc76d2735cdc442de91b7eb3bee49a928c0767ac"

View File

@ -50,10 +50,10 @@ exports.validate = async ({ appId, tableId, row, table }) => {
const errors = {}
for (let fieldName of Object.keys(table.schema)) {
const constraints = cloneDeep(table.schema[fieldName].constraints)
const type = table.schema[fieldName].type
// special case for options, need to always allow unselected (null)
if (
table.schema[fieldName].type ===
(FieldTypes.OPTIONS || FieldTypes.ARRAY) &&
(type === FieldTypes.OPTIONS || type === FieldTypes.ARRAY) &&
constraints.inclusion
) {
constraints.inclusion.push(null)
@ -61,17 +61,20 @@ exports.validate = async ({ appId, tableId, row, table }) => {
let res
// Validate.js doesn't seem to handle array
if (
table.schema[fieldName].type === FieldTypes.ARRAY &&
row[fieldName] &&
row[fieldName].length
) {
if (type === FieldTypes.ARRAY && row[fieldName] && row[fieldName].length) {
row[fieldName].map(val => {
if (!constraints.inclusion.includes(val)) {
errors[fieldName] = "Field not in list"
}
})
} else if (table.schema[fieldName].type === FieldTypes.FORMULA) {
} else if (type === FieldTypes.JSON && typeof row[fieldName] === "string") {
// this should only happen if there is an error
try {
JSON.parse(row[fieldName])
} catch (err) {
errors[fieldName] = [`Contains invalid JSON`]
}
} else if (type === FieldTypes.FORMULA) {
res = validateJs.single(
processStringSync(table.schema[fieldName].formula, row),
constraints

View File

@ -81,6 +81,18 @@ const TYPE_TRANSFORM_MAP = {
[FieldTypes.AUTO]: {
parse: () => undefined,
},
[FieldTypes.JSON]: {
parse: input => {
try {
if (input === "") {
return undefined
}
return JSON.parse(input)
} catch (err) {
return input
}
},
},
}
/**

View File

@ -1,4 +1,5 @@
const { atob } = require("../utilities")
const { cloneDeep } = require("lodash/fp")
// The method of executing JS scripts depends on the bundle being built.
// This setter is used in the entrypoint (either index.cjs or index.mjs).
@ -38,8 +39,12 @@ module.exports.processJS = (handlebars, context) => {
// This is required to allow the final `return` statement to be valid.
const js = `function run(){${atob(handlebars)}};run();`
// Our $ context function gets a value from context
const sandboxContext = { $: path => getContextValue(path, context) }
// Our $ context function gets a value from context.
// We clone the context to avoid mutation in the binding affecting real
// app context.
const sandboxContext = {
$: path => getContextValue(path, cloneDeep(context)),
}
// Create a sandbox with out context and run the JS
return runJS(js, sandboxContext)

View File

@ -1,12 +1,13 @@
const handlebars = require("handlebars")
const { registerAll } = require("./helpers/index")
const processors = require("./processors")
const { removeHandlebarsStatements, atob, btoa } = require("./utilities")
const { atob, btoa } = require("./utilities")
const manifest = require("../manifest.json")
const hbsInstance = handlebars.create()
registerAll(hbsInstance)
const hbsInstanceNoHelpers = handlebars.create()
const defaultOpts = { noHelpers: false }
/**
* utility function to check if the object is valid
@ -28,11 +29,7 @@ function testObject(object) {
* @param {object} opts optional - specify some options for processing.
* @returns {Promise<object|array>} The structure input, as fully updated as possible.
*/
module.exports.processObject = async (
object,
context,
opts = { noHelpers: false }
) => {
module.exports.processObject = async (object, context, opts) => {
testObject(object)
for (let key of Object.keys(object || {})) {
if (object[key] != null) {
@ -63,11 +60,7 @@ module.exports.processObject = async (
* @param {object} opts optional - specify some options for processing.
* @returns {Promise<string>} The enriched string, all templates should have been replaced if they can be.
*/
module.exports.processString = async (
string,
context,
opts = { noHelpers: false }
) => {
module.exports.processString = async (string, context, opts) => {
// TODO: carry out any async calls before carrying out async call
return module.exports.processStringSync(string, context, opts)
}
@ -81,11 +74,7 @@ module.exports.processString = async (
* @param {object} opts optional - specify some options for processing.
* @returns {object|array} The structure input, as fully updated as possible.
*/
module.exports.processObjectSync = (
object,
context,
opts = { noHelpers: false }
) => {
module.exports.processObjectSync = (object, context, opts) => {
testObject(object)
for (let key of Object.keys(object || {})) {
let val = object[key]
@ -106,26 +95,20 @@ module.exports.processObjectSync = (
* @param {object} opts optional - specify some options for processing.
* @returns {string} The enriched string, all templates should have been replaced if they can be.
*/
module.exports.processStringSync = (
string,
context,
opts = { noHelpers: false }
) => {
if (!exports.isValid(string)) {
return string
}
// take a copy of input incase error
module.exports.processStringSync = (string, context, opts) => {
opts = { ...defaultOpts, ...opts }
// take a copy of input in case of error
const input = string
if (typeof string !== "string") {
throw "Cannot process non-string types."
}
try {
const noHelpers = opts && opts.noHelpers
// finalising adds a helper, can't do this with no helpers
const shouldFinalise = !noHelpers
const shouldFinalise = !opts.noHelpers
string = processors.preprocess(string, shouldFinalise)
// this does not throw an error when template can't be fulfilled, have to try correct beforehand
const instance = noHelpers ? hbsInstanceNoHelpers : hbsInstance
const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
const template = instance.compile(string, {
strict: false,
})
@ -136,7 +119,7 @@ module.exports.processStringSync = (
})
)
} catch (err) {
return removeHandlebarsStatements(input)
return input
}
}
@ -155,7 +138,8 @@ module.exports.makePropSafe = property => {
* @param opts optional - specify some options for processing.
* @returns {boolean} Whether or not the input string is valid.
*/
module.exports.isValid = (string, opts = { noHelpers: false }) => {
module.exports.isValid = (string, opts) => {
opts = { ...defaultOpts, ...opts }
const validCases = [
"string",
"number",
@ -169,7 +153,7 @@ module.exports.isValid = (string, opts = { noHelpers: false }) => {
// don't really need a real context to check if its valid
const context = {}
try {
const instance = opts && opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
instance.compile(processors.preprocess(string, false))(context)
return true
} catch (err) {

View File

@ -10,7 +10,10 @@ module.exports.swapStrings = (string, start, length, swap) => {
return string.slice(0, start) + swap + string.slice(start + length)
}
module.exports.removeHandlebarsStatements = string => {
module.exports.removeHandlebarsStatements = (
string,
replacement = "Invalid binding"
) => {
let regexp = new RegExp(exports.FIND_HBS_REGEX)
let matches = string.match(regexp)
if (matches == null) {
@ -18,7 +21,7 @@ module.exports.removeHandlebarsStatements = string => {
}
for (let match of matches) {
const idx = string.indexOf(match)
string = exports.swapStrings(string, idx, match.length, "Invalid Binding")
string = exports.swapStrings(string, idx, match.length, replacement)
}
return string
}

View File

@ -13,10 +13,14 @@ describe("test the custom helpers we have applied", () => {
describe("test that it can run without helpers", () => {
it("should be able to run without helpers", async () => {
const output = await processString("{{ avg 1 1 1 }}", {}, { noHelpers: true })
const output = await processString(
"{{ avg 1 1 1 }}",
{},
{ noHelpers: true }
)
const valid = await processString("{{ avg 1 1 1 }}", {})
expect(valid).toBe("1")
expect(output).toBe("Invalid Binding")
expect(output).toBe("{{ avg 1 1 1 }}")
})
})
@ -185,17 +189,22 @@ describe("test the date helpers", () => {
it("should test the timezone capabilities", async () => {
const date = new Date(1611577535000)
const output = await processString("{{ date time 'HH-mm-ss Z' 'America/New_York' }}", {
time: date.toUTCString(),
})
const formatted = new dayjs(date).tz("America/New_York").format("HH-mm-ss Z")
const output = await processString(
"{{ date time 'HH-mm-ss Z' 'America/New_York' }}",
{
time: date.toUTCString(),
}
)
const formatted = new dayjs(date)
.tz("America/New_York")
.format("HH-mm-ss Z")
expect(output).toBe(formatted)
})
it("should guess the users timezone when not specified", async () => {
const date = new Date()
const output = await processString("{{ date time 'Z' }}", {
time: date.toUTCString()
time: date.toUTCString(),
})
const timezone = dayjs.tz.guess()
const offset = new dayjs(date).tz(timezone).format("Z")
@ -307,12 +316,12 @@ describe("test the comparison helpers", () => {
describe("Test the object/array helper", () => {
it("should allow plucking from an array of objects", async () => {
const context = {
items: [
{ price: 20 },
{ price: 30 },
]
items: [{ price: 20 }, { price: 30 }],
}
const output = await processString("{{ literal ( sum ( pluck items 'price' ) ) }}", context)
const output = await processString(
"{{ literal ( sum ( pluck items 'price' ) ) }}",
context
)
expect(output).toBe(50)
})
@ -442,15 +451,15 @@ describe("Cover a few complex use cases", () => {
it("should only invalidate a single string in an object", async () => {
const input = {
dataProvider:"{{ literal [c670254c9e74e40518ee5becff53aa5be] }}",
theme:"spectrum--lightest",
showAutoColumns:false,
quiet:true,
size:"spectrum--medium",
rowCount:8,
dataProvider: "{{ literal [c670254c9e74e40518ee5becff53aa5be] }}",
theme: "spectrum--lightest",
showAutoColumns: false,
quiet: true,
size: "spectrum--medium",
rowCount: 8,
}
const output = await processObject(input, tableJson)
expect(output.dataProvider).not.toBe("Invalid Binding")
expect(output.dataProvider).not.toBe("Invalid binding")
})
it("should be able to handle external ids", async () => {

View File

@ -8,7 +8,7 @@ jest.mock("nodemailer")
const nodemailer = require("nodemailer")
nodemailer.createTransport.mockReturnValue({
sendMail: sendMailMock,
verify: jest.fn()
verify: jest.fn(),
})
describe("/api/global/email", () => {
@ -39,6 +39,6 @@ describe("/api/global/email", () => {
expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0]
expect(emailCall.subject).toBe("Hello!")
expect(emailCall.html).not.toContain("Invalid Binding")
expect(emailCall.html).not.toContain("Invalid binding")
})
})
})