Add support for binding forms to deep json fields

This commit is contained in:
Andrew Kingston 2021-12-10 14:18:01 +00:00
parent 488f5b8d97
commit 5793cc3bbd
7 changed files with 140 additions and 52 deletions

View File

@ -15,18 +15,43 @@ export const capitalise = s => s.substring(0, 1).toUpperCase() + s.substring(1)
* will return "foo" over "bar". * will return "foo" over "bar".
* @param obj the object * @param obj the object
* @param key the key * @param key the key
* @return {*|null} the value or null if a value was not found for this key
*/ */
export const deepGet = (obj, key) => { export const deepGet = (obj, key) => {
if (!obj || !key) { if (!obj || !key) {
return null return null
} }
if (obj[key] != null) { if (Object.prototype.hasOwnProperty.call(obj, key)) {
return obj[key] return obj[key]
} }
const split = key.split(".") const split = key.split(".")
let value = obj
for (let i = 0; i < split.length; i++) { for (let i = 0; i < split.length; i++) {
value = value?.[split[i]] obj = obj?.[split[i]]
} }
return value 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".
* @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++) {
obj = obj?.[split[i]]
}
obj[split[split.length - 1]] = value
} }

View File

@ -220,23 +220,6 @@ const getProviderContextBindings = (asset, dataProviders) => {
schema = info.schema schema = info.schema
table = info.table table = info.table
// 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,
}
})
}
})
schema = { ...schema, ...jsonAdditions }
// For JSON arrays, use the array name as the readable prefix. // For JSON arrays, use the array name as the readable prefix.
// Otherwise use the table name // Otherwise use the table name
if (datasource.type === "jsonarray") { if (datasource.type === "jsonarray") {
@ -485,6 +468,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" }

View File

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

View File

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

View File

@ -4,7 +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 { getJSONArrayDatasourceSchema } from "builder/src/builderStore/jsonUtils" 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.
@ -50,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",
}, },
@ -68,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",
}, },
@ -80,7 +84,7 @@ export const fetchDatasourceSchema = async dataSource => {
// We can then extract their schema as a subset of the table schema. // We can then extract their schema as a subset of the table schema.
if (type === "jsonarray") { if (type === "jsonarray") {
const table = await fetchTableDefinition(dataSource.tableId) const table = await fetchTableDefinition(dataSource.tableId)
return getJSONArrayDatasourceSchema(table?.schema, dataSource) 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
@ -89,14 +93,34 @@ export const fetchDatasourceSchema = async dataSource => {
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,
}
})
}
})
return { ...schema, ...jsonAdditions }
}

View File

@ -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,19 @@
}) })
} }
// 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
__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 +93,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,7 +141,7 @@
} }
// 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) {
@ -137,6 +181,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] ?? {},
@ -211,6 +256,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
}) })
@ -227,6 +273,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
}) })
} }
@ -306,18 +353,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}>

View File

@ -9,6 +9,7 @@ import {
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 { 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
@ -20,7 +21,7 @@ const saveRowHandler = async (action, context) => {
} }
if (fields) { if (fields) {
for (let [field, value] of Object.entries(fields)) { for (let [field, value] of Object.entries(fields)) {
payload[field] = value deepSet(payload, field, value)
} }
} }
if (tableId) { if (tableId) {
@ -35,18 +36,18 @@ const saveRowHandler = async (action, context) => {
const duplicateRowHandler = async (action, context) => { const duplicateRowHandler = async (action, context) => {
const { fields, providerId, tableId } = action.parameters const { fields, providerId, tableId } = action.parameters
if (providerId) { if (providerId) {
let draft = { ...context[providerId] } let payload = { ...context[providerId] }
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
} }
delete draft._id delete payload._id
delete draft._rev delete payload._rev
const row = await saveRow(draft) const row = await saveRow(payload)
return { return {
row, row,
} }