Files
Patrik 2371624f84 fix(ui): resolve named-tab field permissions in bulk edit field select (3.x backport of #17523) (#17524)
Backport to #17523 to `3.x`.

Resolves fields nested inside a named tab against the tab's own nested
permission map in the bulk edit field select.

A named tab nests its subfields' permissions under `[tab.name].fields`,
like a group. The tabs branch of `reduceFieldOptions` passed the
parent-level permissions straight into a named tab's subfields instead
of descending into that map. This surfaced once a collection's
permissions resolved to a detailed object — which happens as soon as any
field defines `access` (e.g. `update: () => false`); a plain `true`
masked it — with two effects:

- Granted subfields were dropped from the field select, since their
permissions couldn't be resolved at the parent level.
- Restricted subfields could leak in, since `getFieldPermissions` fell
back to the tab's own blanket `update: true` inherited from the parent.

The fix descends into `fieldPermissions[tab.name].fields` (collapsing to
`true` when the tab is fully granted), matching how `Group` and `Array`
unwrap `.fields` before rendering their children.

Covered by an isolated `reduceFieldOptions` unit test (restricted field
inside a named tab, nested named tabs, multiple named tabs) and a
bulk-edit e2e assertion.
2026-07-29 10:20:55 -07:00

146 lines
4.1 KiB
TypeScript

import type { ClientField, FormState, SanitizedFieldPermissions } from 'payload'
import {
fieldAffectsData,
fieldHasSubFields,
fieldIsHiddenOrDisabled,
getFieldPermissions,
} from 'payload/shared'
import { createNestedClientFieldPath } from '../../forms/Form/createNestedClientFieldPath.js'
import { combineFieldLabel } from '../../utilities/combineFieldLabel.js'
export type SelectedField = {
field: ClientField
fieldPermissions: SanitizedFieldPermissions
path: string
}
export type FieldOption = {
label: React.ReactNode
value: SelectedField
}
export const ignoreFromBulkEdit = (field: ClientField): boolean =>
Boolean(
(fieldAffectsData(field) || field.type === 'ui') &&
(field.admin.disableBulkEdit ||
field.unique ||
fieldIsHiddenOrDisabled(field) ||
('readOnly' in field && field.readOnly)),
)
export const reduceFieldOptions = ({
fields,
formState,
labelPrefix = null,
parentPath = '',
path = '',
permissions,
}: {
readonly fields: ClientField[]
readonly formState?: FormState
readonly labelPrefix?: React.ReactNode
readonly parentPath?: string
readonly path?: string
readonly permissions:
| {
[fieldName: string]: SanitizedFieldPermissions
}
| SanitizedFieldPermissions
}): FieldOption[] => {
if (!fields) {
return []
}
const CustomLabel = formState?.[path]?.customComponents?.Label
return fields?.reduce((fieldsToUse, field) => {
const {
operation: hasOperationPermission,
permissions: fieldPermissions,
read: hasReadPermission,
} = getFieldPermissions({
field,
operation: 'update',
parentName: parentPath?.includes('.')
? parentPath.split('.')[parentPath.split('.').length - 1]
: parentPath,
permissions,
})
// escape for a variety of reasons, include ui fields as they have `name`.
if (
(fieldAffectsData(field) || field.type === 'ui') &&
(field.admin?.disableBulkEdit ||
field.unique ||
fieldIsHiddenOrDisabled(field) ||
('readOnly' in field && field.readOnly) ||
!hasOperationPermission ||
!hasReadPermission)
) {
return fieldsToUse
}
if (!(field.type === 'array' || field.type === 'blocks') && fieldHasSubFields(field)) {
const fieldHasLabel = 'label' in field && field.label
return [
...fieldsToUse,
...reduceFieldOptions({
fields: field.fields,
labelPrefix: fieldHasLabel
? combineFieldLabel({ CustomLabel, field, prefix: labelPrefix })
: labelPrefix,
parentPath: path,
path: createNestedClientFieldPath(path, field),
permissions: fieldPermissions,
}),
]
}
if (field.type === 'tabs' && 'tabs' in field) {
return [
...fieldsToUse,
...field.tabs.reduce((tabFields, tab) => {
if ('fields' in tab) {
const isNamedTab = 'name' in tab && tab.name
const namedTabPermissions =
isNamedTab && fieldPermissions && fieldPermissions !== true
? fieldPermissions[tab.name]
: undefined
const tabPermissions = isNamedTab
? namedTabPermissions === true
? true
: (namedTabPermissions?.fields ?? fieldPermissions)
: fieldPermissions
return [
...tabFields,
...reduceFieldOptions({
fields: tab.fields,
labelPrefix,
parentPath: path,
path: isNamedTab ? createNestedClientFieldPath(path, tab as ClientField) : path,
permissions: tabPermissions,
}),
]
}
}, []),
]
}
const formattedField: FieldOption = {
label: combineFieldLabel({ CustomLabel, field, prefix: labelPrefix }),
value: {
field,
fieldPermissions: fieldPermissions as SanitizedFieldPermissions,
path: createNestedClientFieldPath(path, field),
},
}
return [...fieldsToUse, formattedField]
}, [])
}