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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<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
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user