Skip to content

Commit 2371624

Browse files
authored
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.
1 parent 21e24ca commit 2371624

7 files changed

Lines changed: 296 additions & 3 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { ClientField } from 'payload'
2+
3+
import { describe, expect, it, vi } from 'vitest'
4+
5+
import type { FieldOption } from './reduceFieldOptions.js'
6+
7+
// Mock the JSX label builder: irrelevant to permission logic and avoids needing a React runtime here.
8+
vi.mock('../../utilities/combineFieldLabel.js', () => ({
9+
combineFieldLabel: () => null,
10+
}))
11+
12+
import { reduceFieldOptions } from './reduceFieldOptions.js'
13+
14+
const text = (name: string): ClientField => ({ name, type: 'text' }) as ClientField
15+
16+
const namesOf = (options: FieldOption[]): (string | undefined)[] =>
17+
options.map((option) => ('name' in option.value.field ? option.value.field.name : undefined))
18+
19+
// A denied field omits the operation key at runtime, which `SanitizedFieldPermissions` can't express — cast at the boundary.
20+
type Permissions = Parameters<typeof reduceFieldOptions>[0]['permissions']
21+
const optionsFor = (fields: ClientField[], permissions: unknown): FieldOption[] =>
22+
reduceFieldOptions({ fields, permissions: permissions as Permissions })
23+
24+
// Granted sets the operation to `true`; denied omits the key, mirroring `populateFieldPermissions`.
25+
const granted = { create: true, read: true, update: true }
26+
const readOnly = { create: true, read: true }
27+
28+
describe('reduceFieldOptions', () => {
29+
it('excludes a restricted field inside a named tab while keeping its clean siblings', () => {
30+
const fields: ClientField[] = [
31+
{
32+
type: 'tabs',
33+
tabs: [{ name: 'namedTab', fields: [text('okInTab'), text('restrictedInTab')] }],
34+
} as ClientField,
35+
]
36+
37+
const permissions = {
38+
namedTab: {
39+
...granted,
40+
fields: {
41+
okInTab: granted,
42+
restrictedInTab: readOnly,
43+
},
44+
},
45+
}
46+
47+
const options = optionsFor(fields, permissions)
48+
const names = namesOf(options)
49+
50+
expect(names).toContain('okInTab')
51+
expect(names).not.toContain('restrictedInTab')
52+
})
53+
54+
it('excludes a restricted field inside a nested named tab', () => {
55+
const fields: ClientField[] = [
56+
{
57+
type: 'tabs',
58+
tabs: [
59+
{
60+
name: 'outerTab',
61+
fields: [
62+
{
63+
type: 'tabs',
64+
tabs: [{ name: 'innerTab', fields: [text('deepOk'), text('deepRestricted')] }],
65+
},
66+
],
67+
},
68+
],
69+
} as ClientField,
70+
]
71+
72+
const permissions = {
73+
outerTab: {
74+
...granted,
75+
fields: {
76+
innerTab: {
77+
...granted,
78+
fields: {
79+
deepOk: granted,
80+
deepRestricted: readOnly,
81+
},
82+
},
83+
},
84+
},
85+
}
86+
87+
const options = optionsFor(fields, permissions)
88+
const names = namesOf(options)
89+
90+
expect(names).toContain('deepOk')
91+
expect(names).not.toContain('deepRestricted')
92+
})
93+
94+
it('only affects the named tab that contains the restricted field', () => {
95+
const fields: ClientField[] = [
96+
{
97+
type: 'tabs',
98+
tabs: [
99+
{ name: 'cleanTab', fields: [text('cleanField')] },
100+
{ name: 'restrictedHostTab', fields: [text('restrictedField')] },
101+
],
102+
} as ClientField,
103+
]
104+
105+
const permissions = {
106+
cleanTab: { ...granted, fields: { cleanField: granted } },
107+
restrictedHostTab: { ...granted, fields: { restrictedField: readOnly } },
108+
}
109+
110+
const options = optionsFor(fields, permissions)
111+
const names = namesOf(options)
112+
113+
expect(names).toContain('cleanField')
114+
expect(names).not.toContain('restrictedField')
115+
})
116+
117+
it('includes every named-tab field when permissions are fully granted', () => {
118+
const fields: ClientField[] = [
119+
{
120+
type: 'tabs',
121+
tabs: [{ name: 'namedTab', fields: [text('a'), text('b')] }],
122+
} as ClientField,
123+
]
124+
125+
const options = optionsFor(fields, true)
126+
const names = namesOf(options)
127+
128+
expect(names).toEqual(expect.arrayContaining(['a', 'b']))
129+
})
130+
131+
it('applies parent permissions to an unnamed tab and excludes its restricted fields', () => {
132+
const fields: ClientField[] = [
133+
{
134+
type: 'tabs',
135+
tabs: [{ label: 'Unnamed', fields: [text('okAtRoot'), text('restrictedAtRoot')] }],
136+
} as ClientField,
137+
]
138+
139+
// Unnamed tabs share the parent's permission map, so subfields resolve at the parent level.
140+
const permissions = {
141+
okAtRoot: granted,
142+
restrictedAtRoot: readOnly,
143+
}
144+
145+
const options = optionsFor(fields, permissions)
146+
const names = namesOf(options)
147+
148+
expect(names).toContain('okAtRoot')
149+
expect(names).not.toContain('restrictedAtRoot')
150+
})
151+
})

‎packages/ui/src/elements/FieldSelect/reduceFieldOptions.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,26 @@ export const reduceFieldOptions = ({
104104
...field.tabs.reduce((tabFields, tab) => {
105105
if ('fields' in tab) {
106106
const isNamedTab = 'name' in tab && tab.name
107+
108+
const namedTabPermissions =
109+
isNamedTab && fieldPermissions && fieldPermissions !== true
110+
? fieldPermissions[tab.name]
111+
: undefined
112+
113+
const tabPermissions = isNamedTab
114+
? namedTabPermissions === true
115+
? true
116+
: (namedTabPermissions?.fields ?? fieldPermissions)
117+
: fieldPermissions
118+
107119
return [
108120
...tabFields,
109121
...reduceFieldOptions({
110122
fields: tab.fields,
111123
labelPrefix,
112124
parentPath: path,
113125
path: isNamedTab ? createNestedClientFieldPath(path, tab as ClientField) : path,
114-
permissions: fieldPermissions,
126+
permissions: tabPermissions,
115127
}),
116128
]
117129
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import { restrictedTabsSlug } from '../../shared.js'
4+
5+
export const RestrictedTabsCollection: CollectionConfig = {
6+
slug: restrictedTabsSlug,
7+
admin: {
8+
useAsTitle: 'title',
9+
},
10+
fields: [
11+
{
12+
name: 'title',
13+
type: 'text',
14+
},
15+
{
16+
name: 'noUpdate',
17+
type: 'text',
18+
access: {
19+
update: () => false,
20+
},
21+
},
22+
{
23+
type: 'tabs',
24+
tabs: [
25+
{
26+
name: 'namedTab',
27+
fields: [
28+
{
29+
name: 'namedTabText',
30+
type: 'text',
31+
label: 'Named Tab Field',
32+
},
33+
{
34+
name: 'namedTabNoUpdate',
35+
type: 'text',
36+
label: 'Named Tab No Update',
37+
access: {
38+
update: () => false,
39+
},
40+
},
41+
],
42+
},
43+
],
44+
},
45+
],
46+
versions: false,
47+
}

‎test/bulk-edit/config.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import path from 'path'
33

44
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
55
import { PostsCollection } from './collections/Posts/index.js'
6+
import { RestrictedTabsCollection } from './collections/RestrictedTabs/index.js'
67
import { TabsCollection } from './collections/Tabs/index.js'
78
import { seed } from './seed.js'
89

910
const filename = fileURLToPath(import.meta.url)
1011
const dirname = path.dirname(filename)
1112

1213
export default buildConfigWithDefaults({
13-
collections: [PostsCollection, TabsCollection],
14+
collections: [PostsCollection, TabsCollection, RestrictedTabsCollection],
1415
admin: {
1516
importMap: {
1617
baseDir: path.resolve(dirname),

‎test/bulk-edit/e2e.spec.ts‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js'
2525
import { reInitializeDB } from '../__helpers/shared/clearAndSeed/reInitializeDB.js'
2626
import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js'
2727
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
28-
import { postsSlug, tabsSlug } from './shared.js'
28+
import { postsSlug, restrictedTabsSlug, tabsSlug } from './shared.js'
2929

3030
const filename = fileURLToPath(import.meta.url)
3131
const dirname = path.dirname(filename)
@@ -38,12 +38,14 @@ test.describe('Bulk Edit', () => {
3838
let page: Page
3939
let postsUrl: AdminUrlUtil
4040
let tabsUrl: AdminUrlUtil
41+
let restrictedTabsUrl: AdminUrlUtil
4142

4243
test.beforeAll(async ({ browser }, testInfo) => {
4344
testInfo.setTimeout(TEST_TIMEOUT_LONG)
4445
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
4546
postsUrl = new AdminUrlUtil(serverURL, postsSlug)
4647
tabsUrl = new AdminUrlUtil(serverURL, tabsSlug)
48+
restrictedTabsUrl = new AdminUrlUtil(serverURL, restrictedTabsSlug)
4749

4850
context = await browser.newContext()
4951
page = await context.newPage()
@@ -804,6 +806,47 @@ test.describe('Bulk Edit', () => {
804806
await payload.delete({ collection: tabsSlug, id: originalDoc.id })
805807
})
806808

809+
test('should include named-tab fields in bulk edit when a collection has a restricted field', async () => {
810+
const doc = await payload.create({
811+
collection: restrictedTabsSlug,
812+
data: { title: 'Restricted Tabs Doc' },
813+
})
814+
815+
await page.goto(restrictedTabsUrl.list)
816+
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('limit=')
817+
818+
await addListFilter({
819+
page,
820+
fieldLabel: 'ID',
821+
operatorLabel: 'equals',
822+
value: doc.id,
823+
})
824+
825+
await page.locator('table tbody tr.row-1 input[type="checkbox"]').check()
826+
await page
827+
.locator('.list-selection__actions .btn', {
828+
hasText: 'Edit',
829+
})
830+
.click()
831+
832+
const bulkEditForm = page.locator('form.edit-many__form')
833+
await expect(bulkEditForm).toBeVisible()
834+
835+
await bulkEditForm.locator('.field-select .rs__control').click()
836+
837+
const visibleOption = bulkEditForm.locator('.field-select .rs__option', {
838+
hasText: exactText('Named Tab Field'),
839+
})
840+
await expect(visibleOption).toBeVisible()
841+
842+
const hiddenOption = bulkEditForm.locator('.field-select .rs__option', {
843+
hasText: exactText('Named Tab No Update'),
844+
})
845+
await expect(hiddenOption).toBeHidden()
846+
847+
await payload.delete({ collection: restrictedTabsSlug, id: doc.id })
848+
})
849+
807850
test('should show clean labels for fields inside label-false groups and rows', async () => {
808851
const doc = await payload.create({
809852
collection: tabsSlug,

‎test/bulk-edit/payload-types.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export interface Config {
6969
collections: {
7070
posts: Post;
7171
tabs: Tab;
72+
'restricted-tabs': RestrictedTab;
7273
'payload-kv': PayloadKv;
7374
users: User;
7475
'payload-locked-documents': PayloadLockedDocument;
@@ -79,6 +80,7 @@ export interface Config {
7980
collectionsSelect: {
8081
posts: PostsSelect<false> | PostsSelect<true>;
8182
tabs: TabsSelect<false> | TabsSelect<true>;
83+
'restricted-tabs': RestrictedTabsSelect<false> | RestrictedTabsSelect<true>;
8284
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
8385
users: UsersSelect<false> | UsersSelect<true>;
8486
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
@@ -185,6 +187,21 @@ export interface Tab {
185187
updatedAt: string;
186188
createdAt: string;
187189
}
190+
/**
191+
* This interface was referenced by `Config`'s JSON-Schema
192+
* via the `definition` "restricted-tabs".
193+
*/
194+
export interface RestrictedTab {
195+
id: string;
196+
title?: string | null;
197+
noUpdate?: string | null;
198+
namedTab?: {
199+
namedTabText?: string | null;
200+
namedTabNoUpdate?: string | null;
201+
};
202+
updatedAt: string;
203+
createdAt: string;
204+
}
188205
/**
189206
* This interface was referenced by `Config`'s JSON-Schema
190207
* via the `definition` "payload-kv".
@@ -242,6 +259,10 @@ export interface PayloadLockedDocument {
242259
relationTo: 'tabs';
243260
value: string | Tab;
244261
} | null)
262+
| ({
263+
relationTo: 'restricted-tabs';
264+
value: string | RestrictedTab;
265+
} | null)
245266
| ({
246267
relationTo: 'users';
247268
value: string | User;
@@ -361,6 +382,22 @@ export interface TabsSelect<T extends boolean = true> {
361382
updatedAt?: T;
362383
createdAt?: T;
363384
}
385+
/**
386+
* This interface was referenced by `Config`'s JSON-Schema
387+
* via the `definition` "restricted-tabs_select".
388+
*/
389+
export interface RestrictedTabsSelect<T extends boolean = true> {
390+
title?: T;
391+
noUpdate?: T;
392+
namedTab?:
393+
| T
394+
| {
395+
namedTabText?: T;
396+
namedTabNoUpdate?: T;
397+
};
398+
updatedAt?: T;
399+
createdAt?: T;
400+
}
364401
/**
365402
* This interface was referenced by `Config`'s JSON-Schema
366403
* via the `definition` "payload-kv_select".

‎test/bulk-edit/shared.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export const postsSlug = 'posts'
22

33
export const tabsSlug = 'tabs'
4+
5+
export const restrictedTabsSlug = 'restricted-tabs'

0 commit comments

Comments
 (0)