Skip to content

Commit a3c0e84

Browse files
authored
fix(ui): virtual fields disappearing from filter/groupBy dropdowns with access control (#14514)
### What? Virtual fields with `virtual: string` syntax (e.g., `virtual: 'post.title'`) were disappearing from the filter (WhereBuilder) and groupBy dropdowns when the collection had at least one field with custom `access.read` control. ### Why? The bug was introduced in commit `bcd40b6df7` which added permission checks to hide fields with `read: false`. The implementation had two problems: 1. **Permission check failure**: The code was setting `field.name = ''` for virtual fields BEFORE checking permissions. This caused `fieldPermissions?.[field.name]` to become `fieldPermissions?.['']` which evaluated to `undefined`, failing the permission check and hiding all virtual fields. 2. **Object mutation bug**: The field object is shared across multiple components (WhereBuilder, GroupByBuilder, etc.). Mutating `field.name = ''` affected all components that used the same field object, breaking permission checks and field path construction everywhere. ### How? The fix eliminates the mutation entirely: - Instead of mutating `field.name = ''`, we now use a flag `shouldIgnoreFieldName` to track whether to include the field name in the path - The original `field.name` is preserved throughout, ensuring permission checks work correctly: `fieldPermissions?.[field.name]` - The field path is constructed conditionally based on the `shouldIgnoreFieldName` flag This ensures: - Permission checks always have access to the correct field name - The shared field object is never mutated - Virtual fields appear in dropdowns when they should - Virtual fields with `access.read: false` are properly hidden Added e2e tests covering: - Virtual fields in filter and groupBy dropdowns with access control present - Virtual group fields with nested fields - Virtual fields nested inside groups - Virtual fields with `access.read: false` (properly hidden)
1 parent df40d0e commit a3c0e84

File tree

5 files changed

+354
-3
lines changed

5 files changed

+354
-3
lines changed

packages/ui/src/utilities/reduceFieldsToOptions.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,18 @@ export const reduceFieldsToOptions = ({
4444
return reduced
4545
}
4646

47+
// IMPORTANT: We DON'T mutate field.name here because the field object is shared across
48+
// multiple components (WhereBuilder, GroupByBuilder, etc.). Mutating it would break
49+
// permission checks and cause issues in other components that need the field name.
50+
// Instead, we use a flag to determine whether to include the field name in the path.
51+
let shouldIgnoreFieldName = false
52+
4753
// Handle virtual:string fields (virtual relationships, e.g. "post.title")
4854
if ('virtual' in field && typeof field.virtual === 'string') {
4955
pathPrefix = pathPrefix ? pathPrefix + '.' + field.virtual : field.virtual
5056
if (fieldAffectsData(field)) {
51-
// ignore virtual field names
52-
field.name = ''
57+
// Mark that we should ignore the field name when constructing the field path
58+
shouldIgnoreFieldName = true
5359
}
5460
}
5561

@@ -237,7 +243,16 @@ export const reduceFieldsToOptions = ({
237243
})
238244
: localizedLabel
239245

240-
const fieldPath = pathPrefix ? createNestedClientFieldPath(pathPrefix, field) : field.name
246+
// For virtual fields, we use just the pathPrefix (the virtual path) without appending the field name
247+
// For regular fields, we use createNestedClientFieldPath which appends the field name to the path
248+
let fieldPath: string
249+
if (shouldIgnoreFieldName) {
250+
fieldPath = pathPrefix
251+
} else if (pathPrefix) {
252+
fieldPath = createNestedClientFieldPath(pathPrefix, field)
253+
} else {
254+
fieldPath = field.name
255+
}
241256

242257
const formattedField: ReducedField = {
243258
label: formattedLabel,

test/access-control/collections/ReadRestricted/index.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { CollectionConfig } from 'payload'
22

3+
import { unrestrictedSlug } from '../../shared.js'
4+
35
export const readRestrictedSlug = 'read-restricted'
46

57
export const ReadRestricted: CollectionConfig = {
@@ -54,6 +56,19 @@ export const ReadRestricted: CollectionConfig = {
5456
name: 'publicPhone',
5557
type: 'text',
5658
},
59+
{
60+
name: 'virtualContactName',
61+
type: 'text',
62+
virtual: 'unrestricted.name',
63+
},
64+
{
65+
name: 'restrictedVirtualContactInfo',
66+
type: 'text',
67+
virtual: 'unrestricted.name',
68+
access: {
69+
read: () => false,
70+
},
71+
},
5772
],
5873
},
5974
// Row with restricted field
@@ -227,5 +242,38 @@ export const ReadRestricted: CollectionConfig = {
227242
},
228243
],
229244
},
245+
{
246+
name: 'unrestricted',
247+
type: 'relationship',
248+
relationTo: unrestrictedSlug,
249+
},
250+
{
251+
name: 'unrestrictedVirtualFieldName',
252+
type: 'text',
253+
virtual: 'unrestricted.name',
254+
},
255+
{
256+
name: 'unrestrictedVirtualGroupInfo',
257+
type: 'group',
258+
virtual: 'unrestricted.info',
259+
fields: [
260+
{
261+
name: 'title',
262+
type: 'text',
263+
},
264+
{
265+
name: 'description',
266+
type: 'textarea',
267+
},
268+
],
269+
},
270+
{
271+
name: 'restrictedVirtualField',
272+
type: 'text',
273+
virtual: 'unrestricted.name',
274+
access: {
275+
read: () => false,
276+
},
277+
},
230278
],
231279
}

test/access-control/config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,20 @@ export default buildConfigWithDefaults(
204204
name: 'name',
205205
type: 'text',
206206
},
207+
{
208+
name: 'info',
209+
type: 'group',
210+
fields: [
211+
{
212+
name: 'title',
213+
type: 'text',
214+
},
215+
{
216+
name: 'description',
217+
type: 'textarea',
218+
},
219+
],
220+
},
207221
{
208222
name: 'userRestrictedDocs',
209223
type: 'relationship',

test/access-control/e2e.spec.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1315,6 +1315,223 @@ describe('Access Control', () => {
13151315
})
13161316
})
13171317

1318+
describe('virtual fields', () => {
1319+
test('should show virtual field in filter dropdown when collection has field with access control', async () => {
1320+
await page.goto(readRestrictedUrl.list)
1321+
await openListFilters(page, {})
1322+
await page.locator('.where-builder__add-first-filter').click()
1323+
1324+
const initialField = page.locator('.condition__field')
1325+
await initialField.click()
1326+
1327+
// Wait for dropdown options to load
1328+
const visibleOption = initialField.locator('.rs__option', {
1329+
hasText: 'Visible Top Level',
1330+
})
1331+
await expect(visibleOption).toBeVisible()
1332+
1333+
// Virtual field should be visible in the filter dropdown
1334+
const virtualFieldOption = initialField.locator('.rs__option', {
1335+
hasText: 'Unrestricted Virtual Field Name',
1336+
})
1337+
await expect(virtualFieldOption).toBeVisible()
1338+
})
1339+
1340+
test('should show virtual field in groupBy dropdown when collection has field with access control', async () => {
1341+
await page.goto(readRestrictedUrl.list)
1342+
const { groupByContainer } = await openGroupBy(page)
1343+
1344+
const field = groupByContainer.locator('#group-by--field-select')
1345+
await field.click()
1346+
1347+
// Wait for dropdown options to load
1348+
const visibleOption = field.locator('.rs__option', {
1349+
hasText: 'Visible Top Level',
1350+
})
1351+
await expect(visibleOption).toBeVisible()
1352+
1353+
// Virtual field should be visible in the groupBy dropdown
1354+
const virtualFieldOption = field.locator('.rs__option', {
1355+
hasText: 'Unrestricted Virtual Field Name',
1356+
})
1357+
await expect(virtualFieldOption).toBeVisible()
1358+
})
1359+
1360+
test('should show nested fields within virtual group field in filter dropdown', async () => {
1361+
await page.goto(readRestrictedUrl.list)
1362+
await openListFilters(page, {})
1363+
await page.locator('.where-builder__add-first-filter').click()
1364+
1365+
const initialField = page.locator('.condition__field')
1366+
await initialField.click()
1367+
1368+
// Wait for dropdown options to load
1369+
const visibleOption = initialField.locator('.rs__option', {
1370+
hasText: 'Visible Top Level',
1371+
})
1372+
await expect(visibleOption).toBeVisible()
1373+
1374+
// Nested fields within the virtual group should be visible
1375+
const virtualGroupTitleOption = initialField.locator('.rs__option', {
1376+
hasText: 'Unrestricted Virtual Group Info > Title',
1377+
})
1378+
await expect(virtualGroupTitleOption).toBeVisible()
1379+
1380+
const virtualGroupDescriptionOption = initialField.locator('.rs__option', {
1381+
hasText: 'Unrestricted Virtual Group Info > Description',
1382+
})
1383+
await expect(virtualGroupDescriptionOption).toBeVisible()
1384+
})
1385+
1386+
test('should show nested fields within virtual group field in groupBy dropdown', async () => {
1387+
await page.goto(readRestrictedUrl.list)
1388+
const { groupByContainer } = await openGroupBy(page)
1389+
1390+
const field = groupByContainer.locator('#group-by--field-select')
1391+
await field.click()
1392+
1393+
// Wait for dropdown options to load
1394+
const visibleOption = field.locator('.rs__option', {
1395+
hasText: 'Visible Top Level',
1396+
})
1397+
await expect(visibleOption).toBeVisible()
1398+
1399+
// Nested fields within the virtual group should be visible
1400+
const virtualGroupTitleOption = field.locator('.rs__option', {
1401+
hasText: 'Unrestricted Virtual Group Info > Title',
1402+
})
1403+
await expect(virtualGroupTitleOption).toBeVisible()
1404+
1405+
const virtualGroupDescriptionOption = field.locator('.rs__option', {
1406+
hasText: 'Unrestricted Virtual Group Info > Description',
1407+
})
1408+
await expect(virtualGroupDescriptionOption).toBeVisible()
1409+
})
1410+
1411+
test('should show virtual field nested inside group in filter dropdown', async () => {
1412+
await page.goto(readRestrictedUrl.list)
1413+
await openListFilters(page, {})
1414+
await page.locator('.where-builder__add-first-filter').click()
1415+
1416+
const initialField = page.locator('.condition__field')
1417+
await initialField.click()
1418+
1419+
// Wait for dropdown options to load
1420+
const visibleOption = initialField.locator('.rs__option', {
1421+
hasText: 'Visible Top Level',
1422+
})
1423+
await expect(visibleOption).toBeVisible()
1424+
1425+
// Virtual field nested inside contactInfo group should be visible
1426+
const nestedVirtualFieldOption = initialField.locator('.rs__option', {
1427+
hasText: 'Contact Info > Virtual Contact Name',
1428+
})
1429+
await expect(nestedVirtualFieldOption).toBeVisible()
1430+
})
1431+
1432+
test('should show virtual field nested inside group in groupBy dropdown', async () => {
1433+
await page.goto(readRestrictedUrl.list)
1434+
const { groupByContainer } = await openGroupBy(page)
1435+
1436+
const field = groupByContainer.locator('#group-by--field-select')
1437+
await field.click()
1438+
1439+
// Wait for dropdown options to load
1440+
const visibleOption = field.locator('.rs__option', {
1441+
hasText: 'Visible Top Level',
1442+
})
1443+
await expect(visibleOption).toBeVisible()
1444+
1445+
// Virtual field nested inside contactInfo group should be visible
1446+
const nestedVirtualFieldOption = field.locator('.rs__option', {
1447+
hasText: 'Contact Info > Virtual Contact Name',
1448+
})
1449+
await expect(nestedVirtualFieldOption).toBeVisible()
1450+
})
1451+
1452+
test('should hide top-level virtual field with read: false in filter dropdown', async () => {
1453+
await page.goto(readRestrictedUrl.list)
1454+
await openListFilters(page, {})
1455+
await page.locator('.where-builder__add-first-filter').click()
1456+
1457+
const initialField = page.locator('.condition__field')
1458+
await initialField.click()
1459+
1460+
// Wait for dropdown options to load
1461+
const visibleOption = initialField.locator('.rs__option', {
1462+
hasText: 'Visible Top Level',
1463+
})
1464+
await expect(visibleOption).toBeVisible()
1465+
1466+
// Restricted virtual field should be hidden (use exactText to avoid matching "Unrestricted...")
1467+
await expect(
1468+
initialField.locator('.rs__option', { hasText: exactText('Restricted Virtual Field') }),
1469+
).toBeHidden()
1470+
})
1471+
1472+
test('should hide top-level virtual field with read: false in groupBy dropdown', async () => {
1473+
await page.goto(readRestrictedUrl.list)
1474+
const { groupByContainer } = await openGroupBy(page)
1475+
1476+
const field = groupByContainer.locator('#group-by--field-select')
1477+
await field.click()
1478+
1479+
// Wait for dropdown options to load
1480+
const visibleOption = field.locator('.rs__option', {
1481+
hasText: 'Visible Top Level',
1482+
})
1483+
await expect(visibleOption).toBeVisible()
1484+
1485+
// Restricted virtual field should be hidden (use exactText to avoid matching "Unrestricted...")
1486+
await expect(
1487+
field.locator('.rs__option', { hasText: exactText('Restricted Virtual Field') }),
1488+
).toBeHidden()
1489+
})
1490+
1491+
test('should hide nested virtual field with read: false in filter dropdown', async () => {
1492+
await page.goto(readRestrictedUrl.list)
1493+
await openListFilters(page, {})
1494+
await page.locator('.where-builder__add-first-filter').click()
1495+
1496+
const initialField = page.locator('.condition__field')
1497+
await initialField.click()
1498+
1499+
// Wait for dropdown options to load
1500+
const visibleOption = initialField.locator('.rs__option', {
1501+
hasText: 'Visible Top Level',
1502+
})
1503+
await expect(visibleOption).toBeVisible()
1504+
1505+
// Restricted virtual field nested in contactInfo should be hidden
1506+
await expect(
1507+
initialField.locator('.rs__option', {
1508+
hasText: 'Contact Info > Restricted Virtual Contact Info',
1509+
}),
1510+
).toBeHidden()
1511+
})
1512+
1513+
test('should hide nested virtual field with read: false in groupBy dropdown', async () => {
1514+
await page.goto(readRestrictedUrl.list)
1515+
const { groupByContainer } = await openGroupBy(page)
1516+
1517+
const field = groupByContainer.locator('#group-by--field-select')
1518+
await field.click()
1519+
1520+
// Wait for dropdown options to load
1521+
const visibleOption = field.locator('.rs__option', {
1522+
hasText: 'Visible Top Level',
1523+
})
1524+
await expect(visibleOption).toBeVisible()
1525+
1526+
// Restricted virtual field nested in contactInfo should be hidden
1527+
await expect(
1528+
field.locator('.rs__option', {
1529+
hasText: 'Contact Info > Restricted Virtual Contact Info',
1530+
}),
1531+
).toBeHidden()
1532+
})
1533+
})
1534+
13181535
describe('default list view columns', () => {
13191536
test('should not render column for top-level field with read: false by default', async () => {
13201537
await page.goto(readRestrictedUrl.list)

0 commit comments

Comments
 (0)