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:
@@ -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'
|
||||
|
||||
56
packages/payload/src/utilities/getFieldPermissions.ts
Normal file
56
packages/payload/src/utilities/getFieldPermissions.ts
Normal 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))),
|
||||
})
|
||||
@@ -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<AbortController>(null)
|
||||
|
||||
const [selectedFields, setSelectedFields] = useState<FieldWithPathClient[]>([])
|
||||
const [selectedFields, setSelectedFields] = useState<FieldOption[]>([])
|
||||
const collectionPermissions = permissions?.collections?.[collection.slug]
|
||||
|
||||
const select = useMemo<SelectType>(() => {
|
||||
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}
|
||||
>
|
||||
<FieldSelect fields={fields} onChange={onFieldSelect} />
|
||||
<FieldSelect
|
||||
fields={fields}
|
||||
onChange={onFieldSelect}
|
||||
permissions={collectionPermissions.fields}
|
||||
/>
|
||||
{selectedFields.length === 0 ? null : (
|
||||
<RenderFields
|
||||
fields={selectedFields}
|
||||
parentIndexPath=""
|
||||
parentPath=""
|
||||
parentSchemaPath={collection.slug}
|
||||
permissions={collectionPermissions?.fields}
|
||||
readOnly={false}
|
||||
/>
|
||||
<div className="render-fields">
|
||||
{selectedFields.map((option, i) => {
|
||||
const {
|
||||
value: { field, fieldPermissions, path },
|
||||
} = option
|
||||
|
||||
return (
|
||||
<RenderField
|
||||
clientFieldConfig={field}
|
||||
indexPath=""
|
||||
key={`${path}-${i}`}
|
||||
parentPath=""
|
||||
parentSchemaPath=""
|
||||
path={path}
|
||||
permissions={fieldPermissions}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className={`${baseClass}__sidebar-wrap`}>
|
||||
<div className={`${baseClass}__sidebar`}>
|
||||
|
||||
@@ -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<React.SetStateAction<FieldWithPathClient[]>>
|
||||
selectedFields: FieldOption[]
|
||||
setSelectedFields: (fields: FieldOption[]) => void
|
||||
} & EditManyProps
|
||||
> = (props) => {
|
||||
const {
|
||||
@@ -151,8 +152,8 @@ export const EditManyDrawerContent: React.FC<
|
||||
|
||||
const select = useMemo<SelectType>(() => {
|
||||
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}
|
||||
>
|
||||
<FieldSelect fields={fields} onChange={onFieldSelect} />
|
||||
<FieldSelect
|
||||
fields={fields}
|
||||
onChange={onFieldSelect}
|
||||
permissions={collectionPermissions.fields}
|
||||
/>
|
||||
{selectedFields.length === 0 ? null : (
|
||||
<div className="render-fields">
|
||||
{selectedFields.map((field, i) => {
|
||||
const { path } = field
|
||||
{selectedFields.map((option, i) => {
|
||||
const {
|
||||
value: { field, fieldPermissions, path },
|
||||
} = option
|
||||
|
||||
return (
|
||||
<RenderField
|
||||
@@ -300,15 +303,7 @@ export const EditManyDrawerContent: React.FC<
|
||||
parentPath=""
|
||||
parentSchemaPath=""
|
||||
path={path}
|
||||
permissions={
|
||||
collectionPermissions.fields === undefined ||
|
||||
collectionPermissions.fields === null ||
|
||||
collectionPermissions.fields === true
|
||||
? true
|
||||
: 'name' in field
|
||||
? collectionPermissions.fields?.[field.name]
|
||||
: undefined
|
||||
}
|
||||
permissions={fieldPermissions}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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<EditManyProps> = (props) => {
|
||||
|
||||
const { selectAll } = useSelection()
|
||||
const { t } = useTranslation()
|
||||
const [selectedFields, setSelectedFields] = useState<FieldWithPathClient[]>([])
|
||||
|
||||
const [selectedFields, setSelectedFields] = useState<FieldOption[]>([])
|
||||
|
||||
const collectionPermissions = permissions?.collections?.[slug]
|
||||
|
||||
|
||||
@@ -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<FieldSelectProps> = ({ fields, onChange }) => {
|
||||
export const FieldSelect: React.FC<FieldSelectProps> = ({ fields, onChange, permissions }) => {
|
||||
const { t } = useTranslation()
|
||||
const { dispatchFields, getFields } = useForm()
|
||||
|
||||
const [options] = useState<FieldOption[]>(() =>
|
||||
reduceFieldOptions({ fields: filterOutUploadFields(fields), formState: getFields() }),
|
||||
reduceFieldOptions({
|
||||
fields: filterOutUploadFields(fields),
|
||||
formState: getFields(),
|
||||
permissions,
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<RenderFieldsProps> = (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<RenderFieldsProps> = (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<RenderFieldsProps> = (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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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 } } })
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export interface Config {
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
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<T extends boolean = true> {
|
||||
innerOptional?: T;
|
||||
id?: T;
|
||||
};
|
||||
noRead?: T;
|
||||
noUpdate?: T;
|
||||
id?: T;
|
||||
};
|
||||
blocks?:
|
||||
@@ -261,6 +267,8 @@ export interface PostsSelect<T extends boolean = true> {
|
||||
blockName?: T;
|
||||
};
|
||||
};
|
||||
noRead?: T;
|
||||
noUpdate?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
|
||||
Reference in New Issue
Block a user