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 891538eaa4
commit dc7d22e5cb
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".
* @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 (obj[key] != null) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return obj[key]
}
const split = key.split(".")
let value = obj
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
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.
// Otherwise use the table name
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
if (schema && !isForm && ["table", "link"].includes(datasource.type)) {
schema["_id"] = { type: "string" }

View File

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

View File

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

@ -4,7 +4,10 @@ import { fetchViewData } from "./views"
import { fetchRelationshipData } from "./relationships"
import { FieldTypes } from "../constants"
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.
@ -50,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",
},
@ -68,7 +72,7 @@ export const fetchDatasourceSchema = async dataSource => {
},
}
} else if (dataSource.fieldType === "array") {
return {
schema = {
value: {
type: "string",
},
@ -80,7 +84,7 @@ export const fetchDatasourceSchema = async dataSource => {
// We can then extract their schema as a subset of the table schema.
if (type === "jsonarray") {
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
@ -89,14 +93,34 @@ export const fetchDatasourceSchema = async dataSource => {
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
}
// 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,
}
})
}
})
return { ...schema, ...jsonAdditions }
}

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,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
// extracted values from the field array
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
const getField = 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
let initialValue = initialValues[field] ?? defaultValue
let initialValue = deepGet(initialValues, field) ?? defaultValue
let fieldId = `id-${generateID()}`
const existingField = getField(field)
if (existingField) {
@ -137,6 +181,7 @@
disabled: disabled || fieldDisabled || isAutoColumn,
defaultValue,
validator,
lastUpdate: Date.now(),
},
fieldApi: makeFieldApi(field, defaultValue),
fieldSchema: schema?.[field] ?? {},
@ -211,6 +256,7 @@
fieldInfo.update(state => {
state.fieldState.value = value
state.fieldState.error = error
state.fieldState.lastUpdate = Date.now()
return state
})
@ -227,6 +273,7 @@
fieldInfo.update(state => {
state.fieldState.value = newValue
state.fieldState.error = null
state.fieldState.lastUpdate = Date.now()
return state
})
}
@ -306,18 +353,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

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