From a3db160d02832d43df0a1efb21227bac2af054ad Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Mon, 1 Dec 2025 16:48:14 -0500 Subject: [PATCH 1/2] feat: expand getDataByPath util --- .../src/utilities/getDataByPath.spec.ts | 79 +++++++++++++ .../payload/src/utilities/getDataByPath.ts | 111 ++++++++++++++++-- packages/ui/src/forms/Form/index.tsx | 2 +- 3 files changed, 180 insertions(+), 12 deletions(-) create mode 100644 packages/payload/src/utilities/getDataByPath.spec.ts diff --git a/packages/payload/src/utilities/getDataByPath.spec.ts b/packages/payload/src/utilities/getDataByPath.spec.ts new file mode 100644 index 00000000000..2e8beb9dba9 --- /dev/null +++ b/packages/payload/src/utilities/getDataByPath.spec.ts @@ -0,0 +1,79 @@ +import { getDataByPath } from './getDataByPath' + +const data = { + text: 'Sample text', + textLocalized: { + en: 'Sample text in English', + fr: 'Exemple de texte en français', + }, + array: [ + { + text: 'Array: row 1', + textLocalized: { + en: 'Array: row 1 in English', + fr: "Texte de l'élément 1 du tableau en français", + }, + group: { + text: 'Group item text', + }, + }, + { + text: 'Array: row 2', + textLocalized: { + en: 'Array: row 2 in English', + fr: "Texte de l'élément 2 du tableau en français", + }, + group: { + text: 'Group item text 2', + }, + }, + ], + tabs: { + tab: { + array: [ + { + text: 'Tab > Array: row 1', + }, + { + text: 'Tab > Array: row 2', + }, + ], + }, + }, +} + +describe('getDataByPath', () => { + it('gets top-level field', () => { + const value = getDataByPath({ data, path: 'text' }) + expect(value).toEqual(data.text) + }) + + it('gets localized top-level field', () => { + const value = getDataByPath({ data, path: 'textLocalized' }) + expect(value).toEqual(data.textLocalized) + + const valueEn = getDataByPath({ data, path: 'textLocalized', locale: 'en' }) + expect(valueEn).toEqual(data.textLocalized.en) + }) + + it('gets field nested in array', () => { + const row1Value = getDataByPath({ data, path: 'array.0.text' }) + expect(row1Value).toEqual(data.array[0].text) + + const row2Value = getDataByPath({ data, path: 'array.1.text' }) + expect(row2Value).toEqual(data.array[1].text) + }) + + it('gets group field deeply nested in group', () => { + const value = getDataByPath({ data, path: 'array.1.group.text' }) + expect(value).toEqual(data.array[1].group.text) + }) + + it('gets text field deeply nested in tabs', () => { + const row1Value = getDataByPath({ data, path: 'tabs.tab.array.0.text' }) + expect(row1Value).toEqual(data.tabs.tab.array[0].text) + + const row2Value = getDataByPath({ data, path: 'tabs.tab.array.1.text' }) + expect(row2Value).toEqual(data.tabs.tab.array[1].text) + }) +}) diff --git a/packages/payload/src/utilities/getDataByPath.ts b/packages/payload/src/utilities/getDataByPath.ts index 96190dea65d..02d7c0971fa 100644 --- a/packages/payload/src/utilities/getDataByPath.ts +++ b/packages/payload/src/utilities/getDataByPath.ts @@ -2,22 +2,111 @@ import type { FormState } from '../admin/types.js' import { unflatten } from './unflatten.js' -export const getDataByPath = (fields: FormState, path: string): T => { +/** + * Gets a field's data by its path from either: + * 1. Form state (flattened data keyed by field path) + * 2. Document data (nested data structure) + * + * @example + * ```ts + * // From document data + * const data = { + * group: { + * field: 'value', + * }, + * } + * const value = getDataByPath({ data, path: 'group.field' }) + * // value is 'value' + * + * // From form state + * const formState = { + * 'group.field': { value: 'value' }, + * } + * const value = getDataByPath({ formState, path: 'group.field' }) + * // value is 'value' + * ``` + */ +export const getDataByPath = ( + args: { + /** + * Optional locale for localized fields, e.g. "en", etc. + */ + locale?: string + /** + * The path to the desired field, e.g. "group.array.0.text", etc. + */ + path: string + } & ( + | { + /** + * If `data` is provided, will deeply traverse the given object to get the value at `path`. + * For example, given `path` of `group.field` and `data` of `{ group: { field: 'value' } }`, will return `'value'`. + */ + data: Record + formState?: never + } + | { + data?: never + /** + * If `formState` is provided, will + */ + formState: FormState + } + ), +): T => { + const { data, formState, path } = args + const pathPrefixToRemove = path.substring(0, path.lastIndexOf('.') + 1) - const name = path.split('.').pop() + const pathSegments = path.split('.') + + const fieldName = pathSegments[pathSegments.length - 1] + + if (formState) { + const siblingData: Record = {} + + Object.keys(formState).forEach((key) => { + if (!formState[key]?.disableFormData && (key.indexOf(`${path}.`) === 0 || key === path)) { + siblingData[key.replace(pathPrefixToRemove, '')] = formState[key]?.value + + if (formState[key]?.rows && formState[key].rows.length === 0) { + siblingData[key.replace(pathPrefixToRemove, '')] = [] + } + } + }) + + const unflattenedData = unflatten(siblingData) + + return unflattenedData?.[fieldName!] + } + + if (data) { + let current: any = data + + for (const pathSegment of pathSegments) { + if (current === undefined || current === null) { + break + } + + const rowIndex = Number(pathSegment) - const data: Record = {} - Object.keys(fields).forEach((key) => { - if (!fields[key]?.disableFormData && (key.indexOf(`${path}.`) === 0 || key === path)) { - data[key.replace(pathPrefixToRemove, '')] = fields[key]?.value + if (!Number.isNaN(rowIndex) && Array.isArray(current)) { + current = current[rowIndex] + } else { + /** + * Effectively make "current" become "siblingData" for the next iteration + */ + const value = current[pathSegment] - if (fields[key]?.rows && fields[key].rows.length === 0) { - data[key.replace(pathPrefixToRemove, '')] = [] + if (args.locale && value && typeof value === 'object' && value[args.locale]) { + current = value[args.locale] + } else { + current = value + } } } - }) - const unflattenedData = unflatten(data) + return current + } - return unflattenedData?.[name!] + return undefined as unknown as T } diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 3bd77480cc4..eb351657a1e 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -515,7 +515,7 @@ export const Form: React.FC = (props) => { ) const getDataByPath = useCallback( - (path: string) => getDataByPathFunc(contextRef.current.fields, path), + (path: string) => getDataByPathFunc({ formState: contextRef.current.fields, path }), [], ) From adc5014b63fc8cf00cd1bd001fd01294f243110b Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Mon, 1 Dec 2025 17:03:29 -0500 Subject: [PATCH 2/2] splits logic --- packages/payload/src/exports/shared.ts | 1 + .../payload/src/utilities/getDataByPath.ts | 116 +++++------------- .../src/utilities/getFormStateDataByPath.ts | 51 ++++++++ packages/ui/src/forms/Form/index.tsx | 4 +- 4 files changed, 85 insertions(+), 87 deletions(-) create mode 100644 packages/payload/src/utilities/getFormStateDataByPath.ts diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts index 50f9106b6c5..4c5291f6f04 100644 --- a/packages/payload/src/exports/shared.ts +++ b/packages/payload/src/exports/shared.ts @@ -86,6 +86,7 @@ export { getBestFitFromSizes } from '../utilities/getBestFitFromSizes.js' export { getDataByPath } from '../utilities/getDataByPath.js' export { getFieldPermissions } from '../utilities/getFieldPermissions.js' +export { getFormStateDataByPath } from '../utilities/getFormStateDataByPath.js' export { getObjectDotNotation } from '../utilities/getObjectDotNotation.js' export { getSafeRedirect } from '../utilities/getSafeRedirect.js' diff --git a/packages/payload/src/utilities/getDataByPath.ts b/packages/payload/src/utilities/getDataByPath.ts index 02d7c0971fa..79e05169108 100644 --- a/packages/payload/src/utilities/getDataByPath.ts +++ b/packages/payload/src/utilities/getDataByPath.ts @@ -1,11 +1,6 @@ -import type { FormState } from '../admin/types.js' - -import { unflatten } from './unflatten.js' - /** - * Gets a field's data by its path from either: - * 1. Form state (flattened data keyed by field path) - * 2. Document data (nested data structure) + * Gets a field's data by its path from a nested data object. + * To get data from flattened form state, use `getFormStateDataByPath` instead. * * @example * ```ts @@ -17,96 +12,47 @@ import { unflatten } from './unflatten.js' * } * const value = getDataByPath({ data, path: 'group.field' }) * // value is 'value' - * - * // From form state - * const formState = { - * 'group.field': { value: 'value' }, - * } - * const value = getDataByPath({ formState, path: 'group.field' }) - * // value is 'value' * ``` */ -export const getDataByPath = ( - args: { - /** - * Optional locale for localized fields, e.g. "en", etc. - */ - locale?: string - /** - * The path to the desired field, e.g. "group.array.0.text", etc. - */ - path: string - } & ( - | { - /** - * If `data` is provided, will deeply traverse the given object to get the value at `path`. - * For example, given `path` of `group.field` and `data` of `{ group: { field: 'value' } }`, will return `'value'`. - */ - data: Record - formState?: never - } - | { - data?: never - /** - * If `formState` is provided, will - */ - formState: FormState - } - ), -): T => { - const { data, formState, path } = args +export const getDataByPath = (args: { + data: Record + /** + * Optional locale for localized fields, e.g. "en", etc. + */ + locale?: string + /** + * The path to the desired field, e.g. "group.array.0.text", etc. + */ + path: string +}): T => { + const { data, path } = args - const pathPrefixToRemove = path.substring(0, path.lastIndexOf('.') + 1) const pathSegments = path.split('.') - const fieldName = pathSegments[pathSegments.length - 1] - - if (formState) { - const siblingData: Record = {} - - Object.keys(formState).forEach((key) => { - if (!formState[key]?.disableFormData && (key.indexOf(`${path}.`) === 0 || key === path)) { - siblingData[key.replace(pathPrefixToRemove, '')] = formState[key]?.value + let current: any = data - if (formState[key]?.rows && formState[key].rows.length === 0) { - siblingData[key.replace(pathPrefixToRemove, '')] = [] - } - } - }) - - const unflattenedData = unflatten(siblingData) - - return unflattenedData?.[fieldName!] - } - - if (data) { - let current: any = data + for (const pathSegment of pathSegments) { + if (current === undefined || current === null) { + break + } - for (const pathSegment of pathSegments) { - if (current === undefined || current === null) { - break - } + const rowIndex = Number(pathSegment) - const rowIndex = Number(pathSegment) + if (!Number.isNaN(rowIndex) && Array.isArray(current)) { + current = current[rowIndex] + } else { + /** + * Effectively make "current" become "siblingData" for the next iteration + */ + const value = current[pathSegment] - if (!Number.isNaN(rowIndex) && Array.isArray(current)) { - current = current[rowIndex] + if (args.locale && value && typeof value === 'object' && value[args.locale]) { + current = value[args.locale] } else { - /** - * Effectively make "current" become "siblingData" for the next iteration - */ - const value = current[pathSegment] - - if (args.locale && value && typeof value === 'object' && value[args.locale]) { - current = value[args.locale] - } else { - current = value - } + current = value } } - - return current } - return undefined as unknown as T + return current } diff --git a/packages/payload/src/utilities/getFormStateDataByPath.ts b/packages/payload/src/utilities/getFormStateDataByPath.ts new file mode 100644 index 00000000000..18ceac835fe --- /dev/null +++ b/packages/payload/src/utilities/getFormStateDataByPath.ts @@ -0,0 +1,51 @@ +import type { FormState } from '../admin/types.js' + +import { unflatten } from './unflatten.js' + +/** + * Gets a field's data by its path from form state, which is a flattened data object keyed by field paths. + * To get data from nested document data, use `getDataByPath` instead. + * + * @example + * ```ts + * const formState = { + * 'group.field': { value: 'value' }, + * } + * + * const value = getFormStateDataByPath({ formState, path: 'group.field' }) + * // value is 'value' + * ``` + */ +export const getFormStateDataByPath = (args: { + /** + * The form state object to get the data from., e.g. `{ 'group.field': { value: 'value' } }` + */ + formState: FormState + /** + * The path to the desired field, e.g. "group.array.0.text", etc. + */ + path: string +}): T => { + const { formState, path } = args + + const pathPrefixToRemove = path.substring(0, path.lastIndexOf('.') + 1) + const pathSegments = path.split('.') + + const fieldName = pathSegments[pathSegments.length - 1] + + const siblingData: Record = {} + + Object.keys(formState).forEach((key) => { + if (!formState[key]?.disableFormData && (key.indexOf(`${path}.`) === 0 || key === path)) { + siblingData[key.replace(pathPrefixToRemove, '')] = formState[key]?.value + + if (formState[key]?.rows && formState[key].rows.length === 0) { + siblingData[key.replace(pathPrefixToRemove, '')] = [] + } + } + }) + + const unflattenedData = unflatten(siblingData) + + return unflattenedData?.[fieldName!] +} diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index eb351657a1e..3fc4f221f5e 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -5,7 +5,7 @@ import { serialize } from 'object-to-formdata' import { type FormState, type PayloadRequest } from 'payload' import { deepCopyObjectSimpleWithoutReactComponents, - getDataByPath as getDataByPathFunc, + getFormStateDataByPath, getSiblingData as getSiblingDataFunc, reduceFieldsToValues, wait, @@ -515,7 +515,7 @@ export const Form: React.FC = (props) => { ) const getDataByPath = useCallback( - (path: string) => getDataByPathFunc({ formState: contextRef.current.fields, path }), + (path: string) => getFormStateDataByPath({ formState: contextRef.current.fields, path }), [], )