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.
This commit is contained in:
Patrik
2025-07-17 14:12:58 -04:00
committed by GitHub
parent 12539c61d4
commit 95e373e60b
11 changed files with 156 additions and 41 deletions

View File

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

View File

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

View File

@@ -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<string, unknown>): Record<string, unknown> => {
for (const key of disabledFields) {
delete row[key]
const filtered: Record<string, unknown> = {}
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) {

View File

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

View File

@@ -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 = {

View File

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

View File

@@ -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 fields 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<string, any>
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
}

View File

@@ -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':