mirror of
https://github.com/payloadcms/payload.git
synced 2026-09-14 20:07:19 +08:00
2371624f84
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.
146 lines
4.1 KiB
TypeScript
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]
|
|
}, [])
|
|
}
|