From 7532c4ab66c627caf0c30accffdd3cc1c5a66ee4 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 21 Mar 2025 14:44:49 -0400 Subject: [PATCH] fix(ui): exclude fields lacking permissions from bulk edit (#11776) Top-level fields that lack read or update permissions still appear as options in the field selector within the bulk edit drawer. --- packages/payload/src/exports/shared.ts | 2 + .../src/utilities/getFieldPermissions.ts | 56 ++++++ .../BulkUpload/EditMany/DrawerContent.tsx | 58 +++--- .../src/elements/EditMany/DrawerContent.tsx | 39 ++-- packages/ui/src/elements/EditMany/index.tsx | 7 +- .../ui/src/elements/FieldSelect/index.tsx | 18 +- .../FieldSelect/reduceFieldOptions.ts | 55 +++++- packages/ui/src/forms/RenderFields/index.tsx | 47 ++--- test/bulk-edit/collections/Posts/index.ts | 28 +++ test/bulk-edit/e2e.spec.ts | 173 ++++++++++++------ test/bulk-edit/payload-types.ts | 28 ++- 11 files changed, 354 insertions(+), 157 deletions(-) create mode 100644 packages/payload/src/utilities/getFieldPermissions.ts diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts index 9652606fb3..c404336dc6 100644 --- a/packages/payload/src/exports/shared.ts +++ b/packages/payload/src/exports/shared.ts @@ -67,7 +67,9 @@ export { default as flattenTopLevelFields } from '../utilities/flattenTopLevelFi export { formatAdminURL } from '../utilities/formatAdminURL.js' export { getDataByPath } from '../utilities/getDataByPath.js' +export { getFieldPermissions } from '../utilities/getFieldPermissions.js' export { getSelectMode } from '../utilities/getSelectMode.js' + export { getSiblingData } from '../utilities/getSiblingData.js' export { getUniqueListBy } from '../utilities/getUniqueListBy.js' diff --git a/packages/payload/src/utilities/getFieldPermissions.ts b/packages/payload/src/utilities/getFieldPermissions.ts new file mode 100644 index 0000000000..933d167df1 --- /dev/null +++ b/packages/payload/src/utilities/getFieldPermissions.ts @@ -0,0 +1,56 @@ +// @ts-strict-ignore +import type { SanitizedFieldPermissions } from '../auth/types.js' +import type { ClientField, Field } from '../fields/config/types.js' +import type { Operation } from '../types/index.js' + +/** + * Gets read and operation-level permissions for a given field based on cascading field permissions. + * @returns An object with the following properties: + * - `operation`: Whether the user has permission to perform the operation on the field (`create` or `update`). + * - `permissions`: The field-level permissions. + * - `read`: Whether the user has permission to read the field. + */ +export const getFieldPermissions = ({ + field, + operation, + parentName, + permissions, +}: { + readonly field: ClientField | Field + readonly operation: Operation + readonly parentName: string + readonly permissions: + | { + [fieldName: string]: SanitizedFieldPermissions + } + | SanitizedFieldPermissions +}): { + operation: boolean + permissions: SanitizedFieldPermissions + read: boolean +} => ({ + operation: + permissions === true || + permissions?.[operation] === true || + permissions?.[parentName] === true || + ('name' in field && + typeof permissions === 'object' && + permissions?.[field.name] && + (permissions[field.name] === true || + (operation in permissions[field.name] && permissions[field.name][operation]))), + permissions: + permissions === undefined || permissions === null || permissions === true + ? true + : 'name' in field + ? permissions?.[field.name] + : permissions, + read: + permissions === true || + permissions?.read === true || + permissions?.[parentName] === true || + ('name' in field && + typeof permissions === 'object' && + permissions?.[field.name] && + (permissions[field.name] === true || + ('read' in permissions[field.name] && permissions[field.name].read))), +}) diff --git a/packages/ui/src/elements/BulkUpload/EditMany/DrawerContent.tsx b/packages/ui/src/elements/BulkUpload/EditMany/DrawerContent.tsx index 9afbb6a9a9..6464b518c5 100644 --- a/packages/ui/src/elements/BulkUpload/EditMany/DrawerContent.tsx +++ b/packages/ui/src/elements/BulkUpload/EditMany/DrawerContent.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ClientCollectionConfig, FieldWithPathClient, SelectType } from 'payload' +import type { ClientCollectionConfig, SelectType } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' @@ -9,11 +9,12 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import type { FormProps } from '../../../forms/Form/index.js' import type { OnFieldSelect } from '../../FieldSelect/index.js' +import type { FieldOption } from '../../FieldSelect/reduceFieldOptions.js' import type { State } from '../FormsManager/reducer.js' import { Button } from '../../../elements/Button/index.js' import { Form } from '../../../forms/Form/index.js' -import { RenderFields } from '../../../forms/RenderFields/index.js' +import { RenderField } from '../../../forms/RenderFields/RenderField.js' import { XIcon } from '../../../icons/X/index.js' import { useAuth } from '../../../providers/Auth/index.js' import { useServerFunctions } from '../../../providers/ServerFunctions/index.js' @@ -23,6 +24,7 @@ import { FieldSelect } from '../../FieldSelect/index.js' import { useFormsManager } from '../FormsManager/index.js' import { baseClass, type EditManyBulkUploadsProps } from './index.js' import './index.scss' +import '../../../forms/RenderFields/index.scss' export const EditManyBulkUploadsDrawerContent: React.FC< { @@ -46,13 +48,13 @@ export const EditManyBulkUploadsDrawerContent: React.FC< const { getFormState } = useServerFunctions() const abortFormStateRef = React.useRef(null) - const [selectedFields, setSelectedFields] = useState([]) + const [selectedFields, setSelectedFields] = useState([]) const collectionPermissions = permissions?.collections?.[collection.slug] const select = useMemo(() => { return unflatten( - selectedFields.reduce((acc, field) => { - acc[field.path] = true + selectedFields.reduce((acc, option) => { + acc[option.value.path] = true return acc }, {} as SelectType), ) @@ -91,10 +93,9 @@ export const EditManyBulkUploadsDrawerContent: React.FC< const handleSubmit: FormProps['onSubmit'] = useCallback( (formState) => { - const pairedData = selectedFields.reduce((acc, field) => { - const { path } = field - if (formState[path]) { - acc[path] = formState[path].value + const pairedData = selectedFields.reduce((acc, option) => { + if (formState[option.value.path]) { + acc[option.value.path] = formState[option.value.path].value } return acc }, {}) @@ -108,11 +109,7 @@ export const EditManyBulkUploadsDrawerContent: React.FC< async ({ dispatchFields, formState, selected }) => { setIsInitializing(true) - if (selected === null) { - setSelectedFields([]) - } else { - setSelectedFields(selected.map(({ value }) => value)) - } + setSelectedFields(selected || []) const { state } = await getFormState({ collectionSlug: collection.slug, @@ -165,16 +162,31 @@ export const EditManyBulkUploadsDrawerContent: React.FC< onChange={[onChange]} onSubmit={handleSubmit} > - + {selectedFields.length === 0 ? null : ( - +
+ {selectedFields.map((option, i) => { + const { + value: { field, fieldPermissions, path }, + } = option + + return ( + + ) + })} +
)}
diff --git a/packages/ui/src/elements/EditMany/DrawerContent.tsx b/packages/ui/src/elements/EditMany/DrawerContent.tsx index a152aaec55..688ddd7ae8 100644 --- a/packages/ui/src/elements/EditMany/DrawerContent.tsx +++ b/packages/ui/src/elements/EditMany/DrawerContent.tsx @@ -1,6 +1,6 @@ 'use client' -import type { FieldWithPathClient, SelectType } from 'payload' +import type { SelectType } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' @@ -11,6 +11,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import type { FormProps } from '../../forms/Form/index.js' import type { OnFieldSelect } from '../FieldSelect/index.js' +import type { FieldOption } from '../FieldSelect/reduceFieldOptions.js' import { useForm } from '../../forms/Form/context.js' import { Form } from '../../forms/Form/index.js' @@ -113,8 +114,8 @@ const SaveDraftButton: React.FC<{ export const EditManyDrawerContent: React.FC< { drawerSlug: string - selectedFields: FieldWithPathClient[] - setSelectedFields: React.Dispatch> + selectedFields: FieldOption[] + setSelectedFields: (fields: FieldOption[]) => void } & EditManyProps > = (props) => { const { @@ -151,8 +152,8 @@ export const EditManyDrawerContent: React.FC< const select = useMemo(() => { return unflatten( - selectedFields.reduce((acc, field) => { - acc[field.path] = true + selectedFields.reduce((acc, option) => { + acc[option.value.path] = true return acc }, {} as SelectType), ) @@ -216,11 +217,7 @@ export const EditManyDrawerContent: React.FC< async ({ dispatchFields, formState, selected }) => { setIsInitializing(true) - if (selected === null) { - setSelectedFields([]) - } else { - setSelectedFields(selected.map(({ value }) => value)) - } + setSelectedFields(selected || []) const { state } = await getFormState({ collectionSlug: collection.slug, @@ -286,11 +283,17 @@ export const EditManyDrawerContent: React.FC< onChange={[onChange]} onSuccess={onSuccess} > - + {selectedFields.length === 0 ? null : (
- {selectedFields.map((field, i) => { - const { path } = field + {selectedFields.map((option, i) => { + const { + value: { field, fieldPermissions, path }, + } = option return ( ) })} diff --git a/packages/ui/src/elements/EditMany/index.tsx b/packages/ui/src/elements/EditMany/index.tsx index 7042d20f9a..3dadbb3f84 100644 --- a/packages/ui/src/elements/EditMany/index.tsx +++ b/packages/ui/src/elements/EditMany/index.tsx @@ -1,9 +1,11 @@ 'use client' -import type { ClientCollectionConfig, FieldWithPathClient } from 'payload' +import type { ClientCollectionConfig } from 'payload' import { useModal } from '@faceless-ui/modal' import React, { useState } from 'react' +import type { FieldOption } from '../FieldSelect/reduceFieldOptions.js' + import { useAuth } from '../../providers/Auth/index.js' import { EditDepthProvider } from '../../providers/EditDepth/index.js' import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js' @@ -28,7 +30,8 @@ export const EditMany: React.FC = (props) => { const { selectAll } = useSelection() const { t } = useTranslation() - const [selectedFields, setSelectedFields] = useState([]) + + const [selectedFields, setSelectedFields] = useState([]) const collectionPermissions = permissions?.collections?.[slug] diff --git a/packages/ui/src/elements/FieldSelect/index.tsx b/packages/ui/src/elements/FieldSelect/index.tsx index 23c4e809b9..6ab47583b8 100644 --- a/packages/ui/src/elements/FieldSelect/index.tsx +++ b/packages/ui/src/elements/FieldSelect/index.tsx @@ -1,9 +1,10 @@ 'use client' -import type { ClientField, FieldWithPathClient, FormState } from 'payload' +import type { ClientField, FormState, SanitizedFieldPermissions } from 'payload' import React, { useState } from 'react' import type { FieldAction } from '../../forms/Form/types.js' +import type { FieldOption } from './reduceFieldOptions.js' import { FieldLabel } from '../../fields/FieldLabel/index.js' import { useForm } from '../../forms/Form/context.js' @@ -28,16 +29,23 @@ export type OnFieldSelect = ({ export type FieldSelectProps = { readonly fields: ClientField[] readonly onChange: OnFieldSelect + readonly permissions: + | { + [fieldName: string]: SanitizedFieldPermissions + } + | SanitizedFieldPermissions } -export type FieldOption = { Label: React.ReactNode; value: FieldWithPathClient } - -export const FieldSelect: React.FC = ({ fields, onChange }) => { +export const FieldSelect: React.FC = ({ fields, onChange, permissions }) => { const { t } = useTranslation() const { dispatchFields, getFields } = useForm() const [options] = useState(() => - reduceFieldOptions({ fields: filterOutUploadFields(fields), formState: getFields() }), + reduceFieldOptions({ + fields: filterOutUploadFields(fields), + formState: getFields(), + permissions, + }), ) return ( diff --git a/packages/ui/src/elements/FieldSelect/reduceFieldOptions.ts b/packages/ui/src/elements/FieldSelect/reduceFieldOptions.ts index d3d337e146..8b95ca0384 100644 --- a/packages/ui/src/elements/FieldSelect/reduceFieldOptions.ts +++ b/packages/ui/src/elements/FieldSelect/reduceFieldOptions.ts @@ -1,11 +1,18 @@ -import type { ClientField, FieldWithPathClient, FormState } from 'payload' +import type { ClientField, FormState, SanitizedFieldPermissions } from 'payload' -import { fieldAffectsData, fieldHasSubFields, fieldIsHiddenOrDisabled } from 'payload/shared' +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 } @@ -27,13 +34,21 @@ export const reduceFieldOptions = ({ fields, formState, labelPrefix = null, + parentPath = '', path = '', + permissions, }: { - fields: ClientField[] - formState?: FormState - labelPrefix?: React.ReactNode - path?: string -}): { Label: React.ReactNode; value: FieldWithPathClient }[] => { + 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 [] } @@ -41,13 +56,28 @@ export const reduceFieldOptions = ({ 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)) + ('readOnly' in field && field.readOnly) || + !hasOperationPermission || + !hasReadPermission) ) { return fieldsToUse } @@ -58,7 +88,9 @@ export const reduceFieldOptions = ({ ...reduceFieldOptions({ fields: field.fields, labelPrefix: combineFieldLabel({ CustomLabel, field, prefix: labelPrefix }), + parentPath: path, path: createNestedClientFieldPath(path, field), + permissions: fieldPermissions, }), ] } @@ -74,7 +106,9 @@ export const reduceFieldOptions = ({ ...reduceFieldOptions({ fields: tab.fields, labelPrefix, + parentPath: path, path: isNamedTab ? createNestedClientFieldPath(path, field) : path, + permissions: fieldPermissions, }), ] } @@ -82,10 +116,11 @@ export const reduceFieldOptions = ({ ] } - const formattedField = { + const formattedField: FieldOption = { label: combineFieldLabel({ CustomLabel, field, prefix: labelPrefix }), value: { - ...field, + field, + fieldPermissions, path: createNestedClientFieldPath(path, field), }, } diff --git a/packages/ui/src/forms/RenderFields/index.tsx b/packages/ui/src/forms/RenderFields/index.tsx index c81e5ccfdc..bf447d9511 100644 --- a/packages/ui/src/forms/RenderFields/index.tsx +++ b/packages/ui/src/forms/RenderFields/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { fieldIsHiddenOrDisabled, getFieldPaths } from 'payload/shared' +import { fieldIsHiddenOrDisabled, getFieldPaths, getFieldPermissions } from 'payload/shared' import React from 'react' import type { RenderFieldsProps } from './types.js' @@ -49,22 +49,21 @@ export const RenderFields: React.FC = (props) => { return null } - const parentName = parentPath?.includes('.') - ? parentPath.split('.')[parentPath.split('.').length - 1] - : parentPath + const { + operation: hasOperationPermission, + permissions: fieldPermissions, + read: hasReadPermission, + } = getFieldPermissions({ + field, + operation, + parentName: parentPath?.includes('.') + ? parentPath.split('.')[parentPath.split('.').length - 1] + : parentPath, + permissions, + }) // If the user cannot read the field, then filter it out // This is different from `admin.readOnly` which is executed based on `operation` - const hasReadPermission = - permissions === true || - permissions?.read === true || - permissions?.[parentName] === true || - ('name' in field && - typeof permissions === 'object' && - permissions?.[field.name] && - (permissions[field.name] === true || - ('read' in permissions[field.name] && permissions[field.name].read))) - if ('name' in field && !hasReadPermission) { return null } @@ -77,17 +76,7 @@ export const RenderFields: React.FC = (props) => { isReadOnly = false } - // If the user does not have access control to begin with, force it to be read-only - const hasOperationPermission = - permissions === true || - permissions?.[operation] === true || - permissions?.[parentName] === true || - ('name' in field && - typeof permissions === 'object' && - permissions?.[field.name] && - (permissions[field.name] === true || - (operation in permissions[field.name] && permissions[field.name][operation]))) - + // If the user does not have access at the operation level, to begin with, force it to be read-only if ('name' in field && !hasOperationPermission) { isReadOnly = true } @@ -109,13 +98,7 @@ export const RenderFields: React.FC = (props) => { parentPath={parentPath} parentSchemaPath={parentSchemaPath} path={path} - permissions={ - permissions === undefined || permissions === null || permissions === true - ? true - : 'name' in field - ? permissions?.[field.name] - : permissions - } + permissions={fieldPermissions} readOnly={isReadOnly} schemaPath={schemaPath} /> diff --git a/test/bulk-edit/collections/Posts/index.ts b/test/bulk-edit/collections/Posts/index.ts index bc5417ee7c..574ef9767c 100644 --- a/test/bulk-edit/collections/Posts/index.ts +++ b/test/bulk-edit/collections/Posts/index.ts @@ -65,6 +65,20 @@ export const PostsCollection: CollectionConfig = { }, ], }, + { + name: 'noRead', + type: 'text', + access: { + read: () => false, + }, + }, + { + name: 'noUpdate', + type: 'text', + access: { + update: () => false, + }, + }, ], }, { @@ -82,5 +96,19 @@ export const PostsCollection: CollectionConfig = { }, ], }, + { + name: 'noRead', + type: 'text', + access: { + read: () => false, + }, + }, + { + name: 'noUpdate', + type: 'text', + access: { + update: () => false, + }, + }, ], } diff --git a/test/bulk-edit/e2e.spec.ts b/test/bulk-edit/e2e.spec.ts index 0056523fe9..82f843261e 100644 --- a/test/bulk-edit/e2e.spec.ts +++ b/test/bulk-edit/e2e.spec.ts @@ -1,4 +1,4 @@ -import type { BrowserContext, Page } from '@playwright/test' +import type { BrowserContext, Locator, Page } from '@playwright/test' import type { PayloadTestSDK } from 'helpers/sdk/index.js' import { expect, test } from '@playwright/test' @@ -176,18 +176,14 @@ test.describe('Bulk Edit', () => { await page.locator('input#select-all').check() await page.locator('.edit-many__toggle').click() - await page.locator('.field-select .rs__control').click() - const titleOption = page.locator('.field-select .rs__option', { - hasText: exactText('Title'), + const { field, modal } = await selectFieldToEdit(page, { + fieldLabel: 'Title', + fieldID: 'title', }) - await expect(titleOption).toBeVisible() - await titleOption.click() - const titleInput = page.locator('#field-title') - await expect(titleInput).toBeVisible() - await titleInput.fill(updatedPostTitle) - await page.locator('.form-submit button[type="submit"].edit-many__publish').click() + await field.fill(updatedPostTitle) + await modal.locator('.form-submit button[type="submit"].edit-many__publish').click() await expect(page.locator('.payload-toast-container .toast-success')).toContainText( 'Updated 3 Posts successfully.', @@ -222,14 +218,17 @@ test.describe('Bulk Edit', () => { await selectTableRow(page, titleOfPostToPublish1) await selectTableRow(page, titleOfPostToPublish2) - // Bulk edit the selected rows to `published` status await page.locator('.edit-many__toggle').click() - await page.locator('.field-select .rs__control').click() - const options = page.locator('.rs__option') - const field = options.locator('text=Description') - await field.click() - await page.locator('#field-description').fill(description) - await page.locator('.form-submit .edit-many__publish').click() + + const { field, modal } = await selectFieldToEdit(page, { + fieldLabel: 'Description', + fieldID: 'description', + }) + + await field.fill(description) + + // Bulk edit the selected rows to `published` status + await modal.locator('.form-submit .edit-many__publish').click() await expect(page.locator('.payload-toast-container .toast-success')).toContainText( 'Updated 2 Posts successfully.', @@ -258,12 +257,16 @@ test.describe('Bulk Edit', () => { await selectTableRow(page, titleOfPostToDraft2) await page.locator('.edit-many__toggle').click() - await page.locator('.field-select .rs__control').click() - const options = page.locator('.rs__option') - const field = options.locator('text=Description') - await field.click() - await page.locator('#field-description').fill(description) - await page.locator('.form-submit .edit-many__draft').click() + + const { field, modal } = await selectFieldToEdit(page, { + fieldLabel: 'Description', + fieldID: 'description', + }) + + await field.fill(description) + + // Bulk edit the selected rows to `draft` status + await modal.locator('.form-submit .edit-many__draft').click() await expect(page.locator('.payload-toast-container .toast-success')).toContainText( 'Updated 2 Posts successfully.', @@ -336,18 +339,14 @@ test.describe('Bulk Edit', () => { await page.locator('button#select-all-across-pages').click() await page.locator('.edit-many__toggle').click() - await page.locator('.field-select .rs__control').click() - const titleOption = page.locator('.field-select .rs__option', { - hasText: exactText('Title'), + const { field } = await selectFieldToEdit(page, { + fieldLabel: 'Title', + fieldID: 'title', }) - await expect(titleOption).toBeVisible() - await titleOption.click() - const titleInput = page.locator('#field-title') - await expect(titleInput).toBeVisible() - const updatedTitle = `Post (Updated)` - await titleInput.fill(updatedTitle) + const updatedTitle = 'Post (Updated)' + await field.fill(updatedTitle) await page.locator('.form-submit button[type="submit"].edit-many__publish').click() await expect(page.locator('.payload-toast-container .toast-success')).toContainText( @@ -389,19 +388,18 @@ test.describe('Bulk Edit', () => { const updatedPostTitle = 'Post 1 (Updated)' const { id: postID } = await createPost(postData) - await page.goto(postsUrl.list) - await page.locator('input#select-all').check() - await page.locator('.edit-many__toggle').click() - await page.locator('.field-select .rs__control').click() - const titleOption = page.locator('.field-select .rs__option', { - hasText: exactText('Title'), + await page.goto(postsUrl.list) + + const { modal } = await selectAllAndEditMany(page) + + const { field } = await selectFieldToEdit(page, { + fieldLabel: 'Title', + fieldID: 'title', }) - await titleOption.click() - const titleInput = page.locator('#field-title') - await titleInput.fill(updatedPostTitle) - await page.locator('.form-submit button[type="submit"].edit-many__publish').click() + await field.fill(updatedPostTitle) + await modal.locator('.form-submit button[type="submit"].edit-many__publish').click() await expect(page.locator('.payload-toast-container .toast-success')).toContainText( 'Updated 1 Post successfully.', @@ -427,23 +425,19 @@ test.describe('Bulk Edit', () => { test('should bulk edit fields with subfields', async () => { await deleteAllPosts() - const { id: docID } = await createPost() + await createPost() await page.goto(postsUrl.list) - await page.locator('input#select-all').check() - await page.locator('.edit-many__toggle').click() - await page.locator('.field-select .rs__control').click() - const bulkEditModal = page.locator('#edit-posts') + await selectAllAndEditMany(page) - const titleOption = bulkEditModal.locator('.field-select .rs__option', { - hasText: exactText('Group > Title'), + const { modal, field } = await selectFieldToEdit(page, { + fieldLabel: 'Group > Title', + fieldID: 'group__title', }) - await titleOption.click() - const titleInput = bulkEditModal.locator('#field-group__title') - await titleInput.fill('New Group Title') - await page.locator('.form-submit button[type="submit"].edit-many__publish').click() + await field.fill('New Group Title') + await modal.locator('.form-submit button[type="submit"].edit-many__publish').click() await expect(page.locator('.payload-toast-container .toast-success')).toContainText( 'Updated 1 Post successfully.', @@ -458,8 +452,81 @@ test.describe('Bulk Edit', () => { expect(updatedPost?.group?.title).toBe('New Group Title') }) + + test('should not display fields options lacking read and update permissions', async () => { + await deleteAllPosts() + + await createPost() + + await page.goto(postsUrl.list) + + const { modal } = await selectAllAndEditMany(page) + + await expect( + modal.locator('.field-select .rs__option', { hasText: exactText('No Read') }), + ).toBeHidden() + + await expect( + modal.locator('.field-select .rs__option', { hasText: exactText('No Update') }), + ).toBeHidden() + }) + + test('should thread field permissions through subfields', async () => { + await deleteAllPosts() + + await createPost() + + await page.goto(postsUrl.list) + + await selectAllAndEditMany(page) + + const { field } = await selectFieldToEdit(page, { fieldLabel: 'Array', fieldID: 'array' }) + + await field.locator('button.array-field__add-row').click() + + await expect(field.locator('#field-array__0__optional')).toBeVisible() + await expect(field.locator('#field-array__0__noRead')).toBeHidden() + await expect(field.locator('#field-array__0__noUpdate')).toBeDisabled() + }) }) +async function selectFieldToEdit( + page: Page, + { + fieldLabel, + fieldID, + }: { + fieldID: string + fieldLabel: string + }, +): Promise<{ field: Locator; modal: Locator }> { + // ensure modal is open, open if needed + const isModalOpen = await page.locator('#edit-posts').isVisible() + + if (!isModalOpen) { + await page.locator('.edit-many__toggle').click() + } + + const modal = page.locator('#edit-posts') + await expect(modal).toBeVisible() + + await modal.locator('.field-select .rs__control').click() + await modal.locator('.field-select .rs__option', { hasText: exactText(fieldLabel) }).click() + + const field = modal.locator(`#field-${fieldID}`) + await expect(field).toBeVisible() + + return { modal, field } +} + +async function selectAllAndEditMany(page: Page): Promise<{ modal: Locator }> { + await page.locator('input#select-all').check() + await page.locator('.edit-many__toggle').click() + const modal = page.locator('#edit-posts') + await expect(modal).toBeVisible() + return { modal } +} + async function deleteAllPosts() { await payload.delete({ collection: postsSlug, where: { id: { exists: true } } }) } diff --git a/test/bulk-edit/payload-types.ts b/test/bulk-edit/payload-types.ts index f1b36a851e..29fde22843 100644 --- a/test/bulk-edit/payload-types.ts +++ b/test/bulk-edit/payload-types.ts @@ -82,7 +82,7 @@ export interface Config { 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: number; + defaultIDType: string; }; globals: {}; globalsSelect: {}; @@ -118,7 +118,7 @@ export interface UserAuthOperations { * via the `definition` "posts". */ export interface Post { - id: number; + id: string; title?: string | null; description?: string | null; defaultValueField?: string | null; @@ -135,6 +135,8 @@ export interface Post { id?: string | null; }[] | null; + noRead?: string | null; + noUpdate?: string | null; id?: string | null; }[] | null; @@ -146,6 +148,8 @@ export interface Post { blockType: 'textBlock'; }[] | null; + noRead?: string | null; + noUpdate?: string | null; updatedAt: string; createdAt: string; _status?: ('draft' | 'published') | null; @@ -155,7 +159,7 @@ export interface Post { * via the `definition` "users". */ export interface User { - id: number; + id: string; updatedAt: string; createdAt: string; email: string; @@ -172,20 +176,20 @@ export interface User { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: number; + id: string; document?: | ({ relationTo: 'posts'; - value: number | Post; + value: string | Post; } | null) | ({ relationTo: 'users'; - value: number | User; + value: string | User; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: number | User; + value: string | User; }; updatedAt: string; createdAt: string; @@ -195,10 +199,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: number; + id: string; user: { relationTo: 'users'; - value: number | User; + value: string | User; }; key?: string | null; value?: @@ -218,7 +222,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: number; + id: string; name?: string | null; batch?: number | null; updatedAt: string; @@ -248,6 +252,8 @@ export interface PostsSelect { innerOptional?: T; id?: T; }; + noRead?: T; + noUpdate?: T; id?: T; }; blocks?: @@ -261,6 +267,8 @@ export interface PostsSelect { blockName?: T; }; }; + noRead?: T; + noUpdate?: T; updatedAt?: T; createdAt?: T; _status?: T;