diff --git a/packages/bbui/src/utils/helpers.js b/packages/bbui/src/utils/helpers.js index cbb720f9a4..f65e60416f 100644 --- a/packages/bbui/src/utils/helpers.js +++ b/packages/bbui/src/utils/helpers.js @@ -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 } diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index 9f166564b8..74457b3f9c 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -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" } diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 36d707e1a6..fdfe450edf 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -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) diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js b/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js index 4a6ef295ae..ae45b4f25d 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js @@ -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 = diff --git a/packages/client/src/api/datasources.js b/packages/client/src/api/datasources.js index 3c280fcd00..c65ae88a55 100644 --- a/packages/client/src/api/datasources.js +++ b/packages/client/src/api/datasources.js @@ -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 } - 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, + } + }) + } + }) + return { ...schema, ...jsonAdditions } } diff --git a/packages/client/src/components/app/forms/InnerForm.svelte b/packages/client/src/components/app/forms/InnerForm.svelte index ec0db6ae4b..5836b6ae9c 100644 --- a/packages/client/src/components/app/forms/InnerForm.svelte +++ b/packages/client/src/components/app/forms/InnerForm.svelte @@ -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, - } diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js index a1c09d1ed0..378721c7ae 100644 --- a/packages/client/src/utils/buttonActions.js +++ b/packages/client/src/utils/buttonActions.js @@ -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, }