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.
This commit is contained in:
Jacob Fletcher
2025-03-21 14:44:49 -04:00
committed by GitHub
parent 5f7202bbb8
commit 7532c4ab66
11 changed files with 354 additions and 157 deletions

View File

@@ -67,7 +67,9 @@ export { default as flattenTopLevelFields } from '../utilities/flattenTopLevelFi
export { formatAdminURL } from '../utilities/formatAdminURL.js' export { formatAdminURL } from '../utilities/formatAdminURL.js'
export { getDataByPath } from '../utilities/getDataByPath.js' export { getDataByPath } from '../utilities/getDataByPath.js'
export { getFieldPermissions } from '../utilities/getFieldPermissions.js'
export { getSelectMode } from '../utilities/getSelectMode.js' export { getSelectMode } from '../utilities/getSelectMode.js'
export { getSiblingData } from '../utilities/getSiblingData.js' export { getSiblingData } from '../utilities/getSiblingData.js'
export { getUniqueListBy } from '../utilities/getUniqueListBy.js' export { getUniqueListBy } from '../utilities/getUniqueListBy.js'

View File

@@ -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))),
})

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import type { ClientCollectionConfig, FieldWithPathClient, SelectType } from 'payload' import type { ClientCollectionConfig, SelectType } from 'payload'
import { useModal } from '@faceless-ui/modal' import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations' 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 { FormProps } from '../../../forms/Form/index.js'
import type { OnFieldSelect } from '../../FieldSelect/index.js' import type { OnFieldSelect } from '../../FieldSelect/index.js'
import type { FieldOption } from '../../FieldSelect/reduceFieldOptions.js'
import type { State } from '../FormsManager/reducer.js' import type { State } from '../FormsManager/reducer.js'
import { Button } from '../../../elements/Button/index.js' import { Button } from '../../../elements/Button/index.js'
import { Form } from '../../../forms/Form/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 { XIcon } from '../../../icons/X/index.js'
import { useAuth } from '../../../providers/Auth/index.js' import { useAuth } from '../../../providers/Auth/index.js'
import { useServerFunctions } from '../../../providers/ServerFunctions/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 { useFormsManager } from '../FormsManager/index.js'
import { baseClass, type EditManyBulkUploadsProps } from './index.js' import { baseClass, type EditManyBulkUploadsProps } from './index.js'
import './index.scss' import './index.scss'
import '../../../forms/RenderFields/index.scss'
export const EditManyBulkUploadsDrawerContent: React.FC< export const EditManyBulkUploadsDrawerContent: React.FC<
{ {
@@ -46,13 +48,13 @@ export const EditManyBulkUploadsDrawerContent: React.FC<
const { getFormState } = useServerFunctions() const { getFormState } = useServerFunctions()
const abortFormStateRef = React.useRef<AbortController>(null) const abortFormStateRef = React.useRef<AbortController>(null)
const [selectedFields, setSelectedFields] = useState<FieldWithPathClient[]>([]) const [selectedFields, setSelectedFields] = useState<FieldOption[]>([])
const collectionPermissions = permissions?.collections?.[collection.slug] const collectionPermissions = permissions?.collections?.[collection.slug]
const select = useMemo<SelectType>(() => { const select = useMemo<SelectType>(() => {
return unflatten( return unflatten(
selectedFields.reduce((acc, field) => { selectedFields.reduce((acc, option) => {
acc[field.path] = true acc[option.value.path] = true
return acc return acc
}, {} as SelectType), }, {} as SelectType),
) )
@@ -91,10 +93,9 @@ export const EditManyBulkUploadsDrawerContent: React.FC<
const handleSubmit: FormProps['onSubmit'] = useCallback( const handleSubmit: FormProps['onSubmit'] = useCallback(
(formState) => { (formState) => {
const pairedData = selectedFields.reduce((acc, field) => { const pairedData = selectedFields.reduce((acc, option) => {
const { path } = field if (formState[option.value.path]) {
if (formState[path]) { acc[option.value.path] = formState[option.value.path].value
acc[path] = formState[path].value
} }
return acc return acc
}, {}) }, {})
@@ -108,11 +109,7 @@ export const EditManyBulkUploadsDrawerContent: React.FC<
async ({ dispatchFields, formState, selected }) => { async ({ dispatchFields, formState, selected }) => {
setIsInitializing(true) setIsInitializing(true)
if (selected === null) { setSelectedFields(selected || [])
setSelectedFields([])
} else {
setSelectedFields(selected.map(({ value }) => value))
}
const { state } = await getFormState({ const { state } = await getFormState({
collectionSlug: collection.slug, collectionSlug: collection.slug,
@@ -165,16 +162,31 @@ export const EditManyBulkUploadsDrawerContent: React.FC<
onChange={[onChange]} onChange={[onChange]}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
<FieldSelect fields={fields} onChange={onFieldSelect} /> <FieldSelect
fields={fields}
onChange={onFieldSelect}
permissions={collectionPermissions.fields}
/>
{selectedFields.length === 0 ? null : ( {selectedFields.length === 0 ? null : (
<RenderFields <div className="render-fields">
fields={selectedFields} {selectedFields.map((option, i) => {
parentIndexPath="" const {
parentPath="" value: { field, fieldPermissions, path },
parentSchemaPath={collection.slug} } = option
permissions={collectionPermissions?.fields}
readOnly={false} return (
/> <RenderField
clientFieldConfig={field}
indexPath=""
key={`${path}-${i}`}
parentPath=""
parentSchemaPath=""
path={path}
permissions={fieldPermissions}
/>
)
})}
</div>
)} )}
<div className={`${baseClass}__sidebar-wrap`}> <div className={`${baseClass}__sidebar-wrap`}>
<div className={`${baseClass}__sidebar`}> <div className={`${baseClass}__sidebar`}>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import type { FieldWithPathClient, SelectType } from 'payload' import type { SelectType } from 'payload'
import { useModal } from '@faceless-ui/modal' import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations' 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 { FormProps } from '../../forms/Form/index.js'
import type { OnFieldSelect } from '../FieldSelect/index.js' import type { OnFieldSelect } from '../FieldSelect/index.js'
import type { FieldOption } from '../FieldSelect/reduceFieldOptions.js'
import { useForm } from '../../forms/Form/context.js' import { useForm } from '../../forms/Form/context.js'
import { Form } from '../../forms/Form/index.js' import { Form } from '../../forms/Form/index.js'
@@ -113,8 +114,8 @@ const SaveDraftButton: React.FC<{
export const EditManyDrawerContent: React.FC< export const EditManyDrawerContent: React.FC<
{ {
drawerSlug: string drawerSlug: string
selectedFields: FieldWithPathClient[] selectedFields: FieldOption[]
setSelectedFields: React.Dispatch<React.SetStateAction<FieldWithPathClient[]>> setSelectedFields: (fields: FieldOption[]) => void
} & EditManyProps } & EditManyProps
> = (props) => { > = (props) => {
const { const {
@@ -151,8 +152,8 @@ export const EditManyDrawerContent: React.FC<
const select = useMemo<SelectType>(() => { const select = useMemo<SelectType>(() => {
return unflatten( return unflatten(
selectedFields.reduce((acc, field) => { selectedFields.reduce((acc, option) => {
acc[field.path] = true acc[option.value.path] = true
return acc return acc
}, {} as SelectType), }, {} as SelectType),
) )
@@ -216,11 +217,7 @@ export const EditManyDrawerContent: React.FC<
async ({ dispatchFields, formState, selected }) => { async ({ dispatchFields, formState, selected }) => {
setIsInitializing(true) setIsInitializing(true)
if (selected === null) { setSelectedFields(selected || [])
setSelectedFields([])
} else {
setSelectedFields(selected.map(({ value }) => value))
}
const { state } = await getFormState({ const { state } = await getFormState({
collectionSlug: collection.slug, collectionSlug: collection.slug,
@@ -286,11 +283,17 @@ export const EditManyDrawerContent: React.FC<
onChange={[onChange]} onChange={[onChange]}
onSuccess={onSuccess} onSuccess={onSuccess}
> >
<FieldSelect fields={fields} onChange={onFieldSelect} /> <FieldSelect
fields={fields}
onChange={onFieldSelect}
permissions={collectionPermissions.fields}
/>
{selectedFields.length === 0 ? null : ( {selectedFields.length === 0 ? null : (
<div className="render-fields"> <div className="render-fields">
{selectedFields.map((field, i) => { {selectedFields.map((option, i) => {
const { path } = field const {
value: { field, fieldPermissions, path },
} = option
return ( return (
<RenderField <RenderField
@@ -300,15 +303,7 @@ export const EditManyDrawerContent: React.FC<
parentPath="" parentPath=""
parentSchemaPath="" parentSchemaPath=""
path={path} path={path}
permissions={ permissions={fieldPermissions}
collectionPermissions.fields === undefined ||
collectionPermissions.fields === null ||
collectionPermissions.fields === true
? true
: 'name' in field
? collectionPermissions.fields?.[field.name]
: undefined
}
/> />
) )
})} })}

View File

@@ -1,9 +1,11 @@
'use client' 'use client'
import type { ClientCollectionConfig, FieldWithPathClient } from 'payload' import type { ClientCollectionConfig } from 'payload'
import { useModal } from '@faceless-ui/modal' import { useModal } from '@faceless-ui/modal'
import React, { useState } from 'react' import React, { useState } from 'react'
import type { FieldOption } from '../FieldSelect/reduceFieldOptions.js'
import { useAuth } from '../../providers/Auth/index.js' import { useAuth } from '../../providers/Auth/index.js'
import { EditDepthProvider } from '../../providers/EditDepth/index.js' import { EditDepthProvider } from '../../providers/EditDepth/index.js'
import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js' import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js'
@@ -28,7 +30,8 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
const { selectAll } = useSelection() const { selectAll } = useSelection()
const { t } = useTranslation() const { t } = useTranslation()
const [selectedFields, setSelectedFields] = useState<FieldWithPathClient[]>([])
const [selectedFields, setSelectedFields] = useState<FieldOption[]>([])
const collectionPermissions = permissions?.collections?.[slug] const collectionPermissions = permissions?.collections?.[slug]

View File

@@ -1,9 +1,10 @@
'use client' 'use client'
import type { ClientField, FieldWithPathClient, FormState } from 'payload' import type { ClientField, FormState, SanitizedFieldPermissions } from 'payload'
import React, { useState } from 'react' import React, { useState } from 'react'
import type { FieldAction } from '../../forms/Form/types.js' import type { FieldAction } from '../../forms/Form/types.js'
import type { FieldOption } from './reduceFieldOptions.js'
import { FieldLabel } from '../../fields/FieldLabel/index.js' import { FieldLabel } from '../../fields/FieldLabel/index.js'
import { useForm } from '../../forms/Form/context.js' import { useForm } from '../../forms/Form/context.js'
@@ -28,16 +29,23 @@ export type OnFieldSelect = ({
export type FieldSelectProps = { export type FieldSelectProps = {
readonly fields: ClientField[] readonly fields: ClientField[]
readonly onChange: OnFieldSelect readonly onChange: OnFieldSelect
readonly permissions:
| {
[fieldName: string]: SanitizedFieldPermissions
}
| SanitizedFieldPermissions
} }
export type FieldOption = { Label: React.ReactNode; value: FieldWithPathClient } export const FieldSelect: React.FC<FieldSelectProps> = ({ fields, onChange, permissions }) => {
export const FieldSelect: React.FC<FieldSelectProps> = ({ fields, onChange }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { dispatchFields, getFields } = useForm() const { dispatchFields, getFields } = useForm()
const [options] = useState<FieldOption[]>(() => const [options] = useState<FieldOption[]>(() =>
reduceFieldOptions({ fields: filterOutUploadFields(fields), formState: getFields() }), reduceFieldOptions({
fields: filterOutUploadFields(fields),
formState: getFields(),
permissions,
}),
) )
return ( return (

View File

@@ -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 { createNestedClientFieldPath } from '../../forms/Form/createNestedClientFieldPath.js'
import { combineFieldLabel } from '../../utilities/combineFieldLabel.js' import { combineFieldLabel } from '../../utilities/combineFieldLabel.js'
export type SelectedField = { export type SelectedField = {
field: ClientField
fieldPermissions: SanitizedFieldPermissions
path: string path: string
} }
@@ -27,13 +34,21 @@ export const reduceFieldOptions = ({
fields, fields,
formState, formState,
labelPrefix = null, labelPrefix = null,
parentPath = '',
path = '', path = '',
permissions,
}: { }: {
fields: ClientField[] readonly fields: ClientField[]
formState?: FormState readonly formState?: FormState
labelPrefix?: React.ReactNode readonly labelPrefix?: React.ReactNode
path?: string readonly parentPath?: string
}): { Label: React.ReactNode; value: FieldWithPathClient }[] => { readonly path?: string
readonly permissions:
| {
[fieldName: string]: SanitizedFieldPermissions
}
| SanitizedFieldPermissions
}): FieldOption[] => {
if (!fields) { if (!fields) {
return [] return []
} }
@@ -41,13 +56,28 @@ export const reduceFieldOptions = ({
const CustomLabel = formState?.[path]?.customComponents?.Label const CustomLabel = formState?.[path]?.customComponents?.Label
return fields?.reduce((fieldsToUse, field) => { 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`. // escape for a variety of reasons, include ui fields as they have `name`.
if ( if (
(fieldAffectsData(field) || field.type === 'ui') && (fieldAffectsData(field) || field.type === 'ui') &&
(field.admin?.disableBulkEdit || (field.admin?.disableBulkEdit ||
field.unique || field.unique ||
fieldIsHiddenOrDisabled(field) || fieldIsHiddenOrDisabled(field) ||
('readOnly' in field && field.readOnly)) ('readOnly' in field && field.readOnly) ||
!hasOperationPermission ||
!hasReadPermission)
) { ) {
return fieldsToUse return fieldsToUse
} }
@@ -58,7 +88,9 @@ export const reduceFieldOptions = ({
...reduceFieldOptions({ ...reduceFieldOptions({
fields: field.fields, fields: field.fields,
labelPrefix: combineFieldLabel({ CustomLabel, field, prefix: labelPrefix }), labelPrefix: combineFieldLabel({ CustomLabel, field, prefix: labelPrefix }),
parentPath: path,
path: createNestedClientFieldPath(path, field), path: createNestedClientFieldPath(path, field),
permissions: fieldPermissions,
}), }),
] ]
} }
@@ -74,7 +106,9 @@ export const reduceFieldOptions = ({
...reduceFieldOptions({ ...reduceFieldOptions({
fields: tab.fields, fields: tab.fields,
labelPrefix, labelPrefix,
parentPath: path,
path: isNamedTab ? createNestedClientFieldPath(path, field) : 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 }), label: combineFieldLabel({ CustomLabel, field, prefix: labelPrefix }),
value: { value: {
...field, field,
fieldPermissions,
path: createNestedClientFieldPath(path, field), path: createNestedClientFieldPath(path, field),
}, },
} }

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { fieldIsHiddenOrDisabled, getFieldPaths } from 'payload/shared' import { fieldIsHiddenOrDisabled, getFieldPaths, getFieldPermissions } from 'payload/shared'
import React from 'react' import React from 'react'
import type { RenderFieldsProps } from './types.js' import type { RenderFieldsProps } from './types.js'
@@ -49,22 +49,21 @@ export const RenderFields: React.FC<RenderFieldsProps> = (props) => {
return null return null
} }
const parentName = parentPath?.includes('.') const {
? parentPath.split('.')[parentPath.split('.').length - 1] operation: hasOperationPermission,
: parentPath 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 // If the user cannot read the field, then filter it out
// This is different from `admin.readOnly` which is executed based on `operation` // 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) { if ('name' in field && !hasReadPermission) {
return null return null
} }
@@ -77,17 +76,7 @@ export const RenderFields: React.FC<RenderFieldsProps> = (props) => {
isReadOnly = false isReadOnly = false
} }
// If the user does not have access control to begin with, force it to be read-only // If the user does not have access at the operation level, 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 ('name' in field && !hasOperationPermission) { if ('name' in field && !hasOperationPermission) {
isReadOnly = true isReadOnly = true
} }
@@ -109,13 +98,7 @@ export const RenderFields: React.FC<RenderFieldsProps> = (props) => {
parentPath={parentPath} parentPath={parentPath}
parentSchemaPath={parentSchemaPath} parentSchemaPath={parentSchemaPath}
path={path} path={path}
permissions={ permissions={fieldPermissions}
permissions === undefined || permissions === null || permissions === true
? true
: 'name' in field
? permissions?.[field.name]
: permissions
}
readOnly={isReadOnly} readOnly={isReadOnly}
schemaPath={schemaPath} schemaPath={schemaPath}
/> />

View File

@@ -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,
},
},
], ],
} }

View File

@@ -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 type { PayloadTestSDK } from 'helpers/sdk/index.js'
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
@@ -176,18 +176,14 @@ test.describe('Bulk Edit', () => {
await page.locator('input#select-all').check() await page.locator('input#select-all').check()
await page.locator('.edit-many__toggle').click() await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const titleOption = page.locator('.field-select .rs__option', { const { field, modal } = await selectFieldToEdit(page, {
hasText: exactText('Title'), fieldLabel: 'Title',
fieldID: 'title',
}) })
await expect(titleOption).toBeVisible() await field.fill(updatedPostTitle)
await titleOption.click() await modal.locator('.form-submit button[type="submit"].edit-many__publish').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 expect(page.locator('.payload-toast-container .toast-success')).toContainText( await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 3 Posts successfully.', 'Updated 3 Posts successfully.',
@@ -222,14 +218,17 @@ test.describe('Bulk Edit', () => {
await selectTableRow(page, titleOfPostToPublish1) await selectTableRow(page, titleOfPostToPublish1)
await selectTableRow(page, titleOfPostToPublish2) await selectTableRow(page, titleOfPostToPublish2)
// Bulk edit the selected rows to `published` status
await page.locator('.edit-many__toggle').click() await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const options = page.locator('.rs__option') const { field, modal } = await selectFieldToEdit(page, {
const field = options.locator('text=Description') fieldLabel: 'Description',
await field.click() fieldID: 'description',
await page.locator('#field-description').fill(description) })
await page.locator('.form-submit .edit-many__publish').click()
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( await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 2 Posts successfully.', 'Updated 2 Posts successfully.',
@@ -258,12 +257,16 @@ test.describe('Bulk Edit', () => {
await selectTableRow(page, titleOfPostToDraft2) await selectTableRow(page, titleOfPostToDraft2)
await page.locator('.edit-many__toggle').click() await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const options = page.locator('.rs__option') const { field, modal } = await selectFieldToEdit(page, {
const field = options.locator('text=Description') fieldLabel: 'Description',
await field.click() fieldID: 'description',
await page.locator('#field-description').fill(description) })
await page.locator('.form-submit .edit-many__draft').click()
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( await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 2 Posts successfully.', 'Updated 2 Posts successfully.',
@@ -336,18 +339,14 @@ test.describe('Bulk Edit', () => {
await page.locator('button#select-all-across-pages').click() await page.locator('button#select-all-across-pages').click()
await page.locator('.edit-many__toggle').click() await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const titleOption = page.locator('.field-select .rs__option', { const { field } = await selectFieldToEdit(page, {
hasText: exactText('Title'), fieldLabel: 'Title',
fieldID: 'title',
}) })
await expect(titleOption).toBeVisible() const updatedTitle = 'Post (Updated)'
await titleOption.click() await field.fill(updatedTitle)
const titleInput = page.locator('#field-title')
await expect(titleInput).toBeVisible()
const updatedTitle = `Post (Updated)`
await titleInput.fill(updatedTitle)
await page.locator('.form-submit button[type="submit"].edit-many__publish').click() await page.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText( await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
@@ -389,19 +388,18 @@ test.describe('Bulk Edit', () => {
const updatedPostTitle = 'Post 1 (Updated)' const updatedPostTitle = 'Post 1 (Updated)'
const { id: postID } = await createPost(postData) 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', { await page.goto(postsUrl.list)
hasText: exactText('Title'),
const { modal } = await selectAllAndEditMany(page)
const { field } = await selectFieldToEdit(page, {
fieldLabel: 'Title',
fieldID: 'title',
}) })
await titleOption.click() await field.fill(updatedPostTitle)
const titleInput = page.locator('#field-title') await modal.locator('.form-submit button[type="submit"].edit-many__publish').click()
await titleInput.fill(updatedPostTitle)
await page.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText( await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 1 Post successfully.', 'Updated 1 Post successfully.',
@@ -427,23 +425,19 @@ test.describe('Bulk Edit', () => {
test('should bulk edit fields with subfields', async () => { test('should bulk edit fields with subfields', async () => {
await deleteAllPosts() await deleteAllPosts()
const { id: docID } = await createPost() await createPost()
await page.goto(postsUrl.list) 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', { const { modal, field } = await selectFieldToEdit(page, {
hasText: exactText('Group > Title'), fieldLabel: 'Group > Title',
fieldID: 'group__title',
}) })
await titleOption.click() await field.fill('New Group Title')
const titleInput = bulkEditModal.locator('#field-group__title') await modal.locator('.form-submit button[type="submit"].edit-many__publish').click()
await titleInput.fill('New Group Title')
await page.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText( await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 1 Post successfully.', 'Updated 1 Post successfully.',
@@ -458,8 +452,81 @@ test.describe('Bulk Edit', () => {
expect(updatedPost?.group?.title).toBe('New Group Title') 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() { async function deleteAllPosts() {
await payload.delete({ collection: postsSlug, where: { id: { exists: true } } }) await payload.delete({ collection: postsSlug, where: { id: { exists: true } } })
} }

View File

@@ -82,7 +82,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
}; };
db: { db: {
defaultIDType: number; defaultIDType: string;
}; };
globals: {}; globals: {};
globalsSelect: {}; globalsSelect: {};
@@ -118,7 +118,7 @@ export interface UserAuthOperations {
* via the `definition` "posts". * via the `definition` "posts".
*/ */
export interface Post { export interface Post {
id: number; id: string;
title?: string | null; title?: string | null;
description?: string | null; description?: string | null;
defaultValueField?: string | null; defaultValueField?: string | null;
@@ -135,6 +135,8 @@ export interface Post {
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
noRead?: string | null;
noUpdate?: string | null;
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
@@ -146,6 +148,8 @@ export interface Post {
blockType: 'textBlock'; blockType: 'textBlock';
}[] }[]
| null; | null;
noRead?: string | null;
noUpdate?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
_status?: ('draft' | 'published') | null; _status?: ('draft' | 'published') | null;
@@ -155,7 +159,7 @@ export interface Post {
* via the `definition` "users". * via the `definition` "users".
*/ */
export interface User { export interface User {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
email: string; email: string;
@@ -172,20 +176,20 @@ export interface User {
* via the `definition` "payload-locked-documents". * via the `definition` "payload-locked-documents".
*/ */
export interface PayloadLockedDocument { export interface PayloadLockedDocument {
id: number; id: string;
document?: document?:
| ({ | ({
relationTo: 'posts'; relationTo: 'posts';
value: number | Post; value: string | Post;
} | null) } | null)
| ({ | ({
relationTo: 'users'; relationTo: 'users';
value: number | User; value: string | User;
} | null); } | null);
globalSlug?: string | null; globalSlug?: string | null;
user: { user: {
relationTo: 'users'; relationTo: 'users';
value: number | User; value: string | User;
}; };
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -195,10 +199,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences". * via the `definition` "payload-preferences".
*/ */
export interface PayloadPreference { export interface PayloadPreference {
id: number; id: string;
user: { user: {
relationTo: 'users'; relationTo: 'users';
value: number | User; value: string | User;
}; };
key?: string | null; key?: string | null;
value?: value?:
@@ -218,7 +222,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations". * via the `definition` "payload-migrations".
*/ */
export interface PayloadMigration { export interface PayloadMigration {
id: number; id: string;
name?: string | null; name?: string | null;
batch?: number | null; batch?: number | null;
updatedAt: string; updatedAt: string;
@@ -248,6 +252,8 @@ export interface PostsSelect<T extends boolean = true> {
innerOptional?: T; innerOptional?: T;
id?: T; id?: T;
}; };
noRead?: T;
noUpdate?: T;
id?: T; id?: T;
}; };
blocks?: blocks?:
@@ -261,6 +267,8 @@ export interface PostsSelect<T extends boolean = true> {
blockName?: T; blockName?: T;
}; };
}; };
noRead?: T;
noUpdate?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
_status?: T; _status?: T;