Add support for binding forms to deep json fields
This commit is contained in:
parent
488f5b8d97
commit
5793cc3bbd
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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" }
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 =
|
||||||
|
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue