From 95e373e60b28a1c6fceedeef60ffb378e90e0773 Mon Sep 17 00:00:00 2001 From: Patrik <35232443+PatrikKozak@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:12:58 -0400 Subject: [PATCH] fix(plugin-import-export): disabled flag to cascade to nested fields from parent containers (#13199) ### What? Fixes the `custom.plugin-import-export.disabled` flag to correctly disable fields in all nested structures including: - Groups - Arrays - Tabs - Blocks Previously, only top-level fields or direct children were respected. This update ensures nested paths (e.g. `group.array.field1`, `blocks.hero.title`, etc.) are matched and filtered from exports. ### Why? - Updated regex logic in both `createExport` and Preview components to recursively support: - Indexed array fields (e.g. `array_0_field1`) - Block fields with slugs (e.g. `blocks_0_hero_title`) - Nested field accessors with correct part-by-part expansion ### How? To allow users to disable entire field groups or deeply nested fields in structured layouts. --- .../FieldsToExport/reduceFields.tsx | 6 +- .../src/components/Preview/index.tsx | 25 +++--- .../src/export/createExport.ts | 18 ++-- .../src/export/flattenObject.ts | 14 +++- packages/plugin-import-export/src/index.ts | 14 +--- .../src/utilities/buildDisabledFieldRegex.ts | 13 +++ .../utilities/collectDisabledFieldPaths.ts | 82 +++++++++++++++++++ .../src/utilities/getFlattenedFieldKeys.ts | 9 +- .../migrations/20250714_201659.ts | 2 +- .../up-down-migration/migrations/index.ts | 6 +- test/plugin-import-export/int.spec.ts | 8 +- 11 files changed, 156 insertions(+), 41 deletions(-) create mode 100644 packages/plugin-import-export/src/utilities/buildDisabledFieldRegex.ts create mode 100644 packages/plugin-import-export/src/utilities/collectDisabledFieldPaths.ts diff --git a/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx b/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx index 37c2a47f4..a20568b43 100644 --- a/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx +++ b/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx @@ -114,7 +114,11 @@ export const reduceFields = ({ const val = createNestedClientFieldPath(path, field) // If the field is disabled, skip it - if (disabledFields.includes(val)) { + if ( + disabledFields.some( + (disabledField) => val === disabledField || val.startsWith(`${disabledField}.`), + ) + ) { return fieldsToUse } diff --git a/packages/plugin-import-export/src/components/Preview/index.tsx b/packages/plugin-import-export/src/components/Preview/index.tsx index 046b04c4a..4cafe1f4f 100644 --- a/packages/plugin-import-export/src/components/Preview/index.tsx +++ b/packages/plugin-import-export/src/components/Preview/index.tsx @@ -18,8 +18,9 @@ import type { PluginImportExportTranslations, } from '../../translations/index.js' -import { useImportExport } from '../ImportExportProvider/index.js' +import { buildDisabledFieldRegex } from '../../utilities/buildDisabledFieldRegex.js' import './index.scss' +import { useImportExport } from '../ImportExportProvider/index.js' const baseClass = 'preview' @@ -46,12 +47,11 @@ export const Preview = () => { (collection) => collection.slug === collectionSlug, ) - const disabledFieldsUnderscored = React.useMemo(() => { - return ( - collectionConfig?.admin?.custom?.['plugin-import-export']?.disabledFields?.map((f: string) => - f.replace(/\./g, '_'), - ) ?? [] - ) + const disabledFieldRegexes: RegExp[] = React.useMemo(() => { + const disabledFieldPaths = + collectionConfig?.admin?.custom?.['plugin-import-export']?.disabledFields ?? [] + + return disabledFieldPaths.map(buildDisabledFieldRegex) }, [collectionConfig]) const isCSV = format === 'csv' @@ -101,11 +101,16 @@ export const Preview = () => { Array.isArray(fields) && fields.length > 0 ? fields.flatMap((field) => { const regex = fieldToRegex(field) - return allKeys.filter((key) => regex.test(key)) + return allKeys.filter( + (key) => + regex.test(key) && + !disabledFieldRegexes.some((disabledRegex) => disabledRegex.test(key)), + ) }) : allKeys.filter( (key) => - !defaultMetaFields.includes(key) && !disabledFieldsUnderscored.includes(key), + !defaultMetaFields.includes(key) && + !disabledFieldRegexes.some((regex) => regex.test(key)), ) const fieldKeys = @@ -150,7 +155,7 @@ export const Preview = () => { }, [ collectionConfig, collectionSlug, - disabledFieldsUnderscored, + disabledFieldRegexes, draft, fields, i18n, diff --git a/packages/plugin-import-export/src/export/createExport.ts b/packages/plugin-import-export/src/export/createExport.ts index 9868e0a96..2b4b05bff 100644 --- a/packages/plugin-import-export/src/export/createExport.ts +++ b/packages/plugin-import-export/src/export/createExport.ts @@ -5,6 +5,7 @@ import { stringify } from 'csv-stringify/sync' import { APIError } from 'payload' import { Readable } from 'stream' +import { buildDisabledFieldRegex } from '../utilities/buildDisabledFieldRegex.js' import { flattenObject } from './flattenObject.js' import { getCustomFieldFunctions } from './getCustomFieldFunctions.js' import { getFilename } from './getFilename.js' @@ -108,15 +109,22 @@ export const createExport = async (args: CreateExportArgs) => { fields: collectionConfig.flattenedFields, }) - const disabledFieldsDot = + const disabledFields = collectionConfig.admin?.custom?.['plugin-import-export']?.disabledFields ?? [] - const disabledFields = disabledFieldsDot.map((f: string) => f.replace(/\./g, '_')) + + const disabledRegexes: RegExp[] = disabledFields.map(buildDisabledFieldRegex) const filterDisabled = (row: Record): Record => { - for (const key of disabledFields) { - delete row[key] + const filtered: Record = {} + + for (const [key, value] of Object.entries(row)) { + const isDisabled = disabledRegexes.some((regex) => regex.test(key)) + if (!isDisabled) { + filtered[key] = value + } } - return row + + return filtered } if (download) { diff --git a/packages/plugin-import-export/src/export/flattenObject.ts b/packages/plugin-import-export/src/export/flattenObject.ts index 022238aac..0801a2e5e 100644 --- a/packages/plugin-import-export/src/export/flattenObject.ts +++ b/packages/plugin-import-export/src/export/flattenObject.ts @@ -24,6 +24,10 @@ export const flattenObject = ({ if (Array.isArray(value)) { value.forEach((item, index) => { if (typeof item === 'object' && item !== null) { + const blockType = typeof item.blockType === 'string' ? item.blockType : undefined + + const itemPrefix = blockType ? `${newKey}_${index}_${blockType}` : `${newKey}_${index}` + // Case: hasMany polymorphic relationships if ( 'relationTo' in item && @@ -31,12 +35,12 @@ export const flattenObject = ({ typeof item.value === 'object' && item.value !== null ) { - row[`${`${newKey}_${index}`}_relationTo`] = item.relationTo - row[`${`${newKey}_${index}`}_id`] = item.value.id + row[`${itemPrefix}_relationTo`] = item.relationTo + row[`${itemPrefix}_id`] = item.value.id return } - flatten(item, `${newKey}_${index}`) + flatten(item, itemPrefix) } else { if (toCSVFunctions?.[newKey]) { const columnName = `${newKey}_${index}` @@ -54,7 +58,9 @@ export const flattenObject = ({ } } catch (error) { throw new Error( - `Error in toCSVFunction for array item "${columnName}": ${JSON.stringify(item)}\n${(error as Error).message}`, + `Error in toCSVFunction for array item "${columnName}": ${JSON.stringify(item)}\n${ + (error as Error).message + }`, ) } } else { diff --git a/packages/plugin-import-export/src/index.ts b/packages/plugin-import-export/src/index.ts index 27dd52bd8..a64e80bf1 100644 --- a/packages/plugin-import-export/src/index.ts +++ b/packages/plugin-import-export/src/index.ts @@ -1,6 +1,6 @@ import type { Config, FlattenedField } from 'payload' -import { addDataAndFileToRequest, deepMergeSimple, flattenTopLevelFields } from 'payload' +import { addDataAndFileToRequest, deepMergeSimple } from 'payload' import type { PluginDefaultTranslationsObject } from './translations/types.js' import type { ImportExportPluginConfig, ToCSVFunction } from './types.js' @@ -11,6 +11,7 @@ import { getCustomFieldFunctions } from './export/getCustomFieldFunctions.js' import { getSelect } from './export/getSelect.js' import { getExportCollection } from './getExportCollection.js' import { translations } from './translations/index.js' +import { collectDisabledFieldPaths } from './utilities/collectDisabledFieldPaths.js' import { getFlattenedFieldKeys } from './utilities/getFlattenedFieldKeys.js' export const importExportPlugin = @@ -59,15 +60,8 @@ export const importExportPlugin = path: '@payloadcms/plugin-import-export/rsc#ExportListMenuItem', }) - // Flatten top-level fields to expose nested fields for export config - const flattenedFields = flattenTopLevelFields(collection.fields, { - moveSubFieldsToTop: true, - }) - - // Find fields explicitly marked as disabled for import/export - const disabledFieldAccessors = flattenedFields - .filter((field) => field.custom?.['plugin-import-export']?.disabled) - .map((field) => field.accessor || field.name) + // // Find fields explicitly marked as disabled for import/export + const disabledFieldAccessors = collectDisabledFieldPaths(collection.fields) // Store disabled field accessors in the admin config for use in the UI collection.admin.custom = { diff --git a/packages/plugin-import-export/src/utilities/buildDisabledFieldRegex.ts b/packages/plugin-import-export/src/utilities/buildDisabledFieldRegex.ts new file mode 100644 index 000000000..41e44ad72 --- /dev/null +++ b/packages/plugin-import-export/src/utilities/buildDisabledFieldRegex.ts @@ -0,0 +1,13 @@ +/** + * Builds a RegExp that matches flattened field keys from a given dot-notated path. + */ +export const buildDisabledFieldRegex = (path: string): RegExp => { + const parts = path.split('.') + + const patternParts = parts.map((part) => { + return `${part}(?:_\\d+)?(?:_[^_]+)?` + }) + + const pattern = `^${patternParts.join('_')}(?:_.*)?$` + return new RegExp(pattern) +} diff --git a/packages/plugin-import-export/src/utilities/collectDisabledFieldPaths.ts b/packages/plugin-import-export/src/utilities/collectDisabledFieldPaths.ts new file mode 100644 index 000000000..dafeae456 --- /dev/null +++ b/packages/plugin-import-export/src/utilities/collectDisabledFieldPaths.ts @@ -0,0 +1,82 @@ +import type { Field } from 'payload' + +import { traverseFields } from 'payload' +import { fieldAffectsData } from 'payload/shared' + +/** + * Recursively traverses a Payload field schema to collect all field paths + * that are explicitly disabled for the import/export plugin via: + * field.custom['plugin-import-export'].disabled + * + * Handles nested fields including named tabs, groups, arrays, blocks, etc. + * Tracks each field’s path by storing it in `ref.path` and manually propagating + * it through named tab layers via a temporary `__manualRef` marker. + * + * @param fields - The top-level array of Payload field definitions + * @returns An array of dot-notated field paths that are marked as disabled + */ +export const collectDisabledFieldPaths = (fields: Field[]): string[] => { + const disabledPaths: string[] = [] + + traverseFields({ + callback: ({ field, next, parentRef, ref }) => { + // Handle named tabs + if (field.type === 'tabs' && Array.isArray(field.tabs)) { + for (const tab of field.tabs) { + if ('name' in tab && typeof tab.name === 'string') { + // Build the path prefix for this tab + const parentPath = + parentRef && typeof (parentRef as { path?: unknown }).path === 'string' + ? (parentRef as { path: string }).path + : '' + const tabPath = parentPath ? `${parentPath}.${tab.name}` : tab.name + + // Prepare a ref for this named tab's children to inherit the path + const refObj = ref as Record + const tabRef = refObj[tab.name] ?? {} + tabRef.path = tabPath + tabRef.__manualRef = true // flag this as a manually constructed parentRef + refObj[tab.name] = tabRef + } + } + + // Skip further processing of the tab container itself + return + } + + // Skip unnamed fields (e.g. rows/collapsibles) + if (!('name' in field) || typeof field.name !== 'string') { + return + } + + // Determine the path to the current field + let parentPath: string | undefined + + if ( + parentRef && + typeof parentRef === 'object' && + 'path' in parentRef && + typeof (parentRef as { path?: unknown }).path === 'string' + ) { + parentPath = (parentRef as { path: string }).path + } else if ((ref as any)?.__manualRef && typeof (ref as any)?.path === 'string') { + // Fallback: if current ref is a manual tabRef, use its path + parentPath = (ref as any).path + } + + const fullPath = parentPath ? `${parentPath}.${field.name}` : field.name + + // Store current path for any nested children to use + ;(ref as any).path = fullPath + + // If field is a data-affecting field and disabled via plugin config, collect its path + if (fieldAffectsData(field) && field.custom?.['plugin-import-export']?.disabled) { + disabledPaths.push(fullPath) + return next?.() + } + }, + fields, + }) + + return disabledPaths +} diff --git a/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts b/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts index 5ba649c13..f124208dc 100644 --- a/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts +++ b/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts @@ -34,12 +34,15 @@ export const getFlattenedFieldKeys = (fields: FieldWithPresentational[], prefix keys.push(...subKeys) break } - case 'blocks': + case 'blocks': { field.blocks.forEach((block) => { - const blockKeys = getFlattenedFieldKeys(block.fields as FlattenedField[], `${name}_0`) - keys.push(...blockKeys) + const blockPrefix = `${name}_0_${block.slug}` + keys.push(`${blockPrefix}_blockType`) + keys.push(`${blockPrefix}_id`) + keys.push(...getFlattenedFieldKeys(block.fields as FlattenedField[], blockPrefix)) }) break + } case 'collapsible': case 'group': case 'row': diff --git a/test/database/up-down-migration/migrations/20250714_201659.ts b/test/database/up-down-migration/migrations/20250714_201659.ts index 098ecd2a0..b473da250 100644 --- a/test/database/up-down-migration/migrations/20250714_201659.ts +++ b/test/database/up-down-migration/migrations/20250714_201659.ts @@ -1,4 +1,4 @@ -import type { MigrateDownArgs, MigrateUpArgs} from '@payloadcms/db-postgres'; +import type { MigrateDownArgs, MigrateUpArgs } from '@payloadcms/db-postgres' import { sql } from '@payloadcms/db-postgres' diff --git a/test/database/up-down-migration/migrations/index.ts b/test/database/up-down-migration/migrations/index.ts index fea58e46c..8fbc100ef 100644 --- a/test/database/up-down-migration/migrations/index.ts +++ b/test/database/up-down-migration/migrations/index.ts @@ -1,9 +1,9 @@ -import * as migration_20250714_201659 from './20250714_201659.js'; +import * as migration_20250714_201659 from './20250714_201659.js' export const migrations = [ { up: migration_20250714_201659.up, down: migration_20250714_201659.down, - name: '20250714_201659' + name: '20250714_201659', }, -]; +] diff --git a/test/plugin-import-export/int.spec.ts b/test/plugin-import-export/int.spec.ts index afc6ecb85..64d2516de 100644 --- a/test/plugin-import-export/int.spec.ts +++ b/test/plugin-import-export/int.spec.ts @@ -364,8 +364,8 @@ describe('@payloadcms/plugin-import-export', () => { const expectedPath = path.join(dirname, './uploads', doc.filename as string) const data = await readCSV(expectedPath) - expect(data[0].blocks_0_blockType).toStrictEqual('hero') - expect(data[0].blocks_1_blockType).toStrictEqual('content') + expect(data[0].blocks_0_hero_blockType).toStrictEqual('hero') + expect(data[0].blocks_1_content_blockType).toStrictEqual('content') }) it('should create a csv of all fields when fields is empty', async () => { @@ -629,8 +629,8 @@ describe('@payloadcms/plugin-import-export', () => { const expectedPath = path.join(dirname, './uploads', doc.filename as string) const data = await readCSV(expectedPath) - expect(data[0].blocks_0_blockType).toStrictEqual('hero') - expect(data[0].blocks_1_blockType).toStrictEqual('content') + expect(data[0].blocks_0_hero_blockType).toStrictEqual('hero') + expect(data[0].blocks_1_content_blockType).toStrictEqual('content') }) }) })