From 5d18a5288e09d08d692ce6bc5ea36dabcdb2deea Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Tue, 26 Nov 2024 18:22:04 -0500 Subject: [PATCH] fix: overrides entity visibility within drawers (#9546) When using the `admin.hidden: true` property on a collection, it rightfully removes all navigation and routing for that particular collection. However, this also affects the expected behavior of hidden entities when they are rendered within a drawer, such as the document drawer or list drawer. For example, when creating a new _admin.hidden_ document through the relationship or join field, the drawer should still render the view, despite the underlying route for that view being disabled. This change was a result of the introduction of on-demand server components in #8364, where we now make a server roundtrip to render the view in its entirety, which include the logic that redirects these hidden entities. Now, we pass a new `overrideEntityVisibility` argument through the server function that, when true, skips this step. This way documents can continue to respect `admin.hidden` while also having the ability to override on a case-by-case basis throughout the UI. --- .../views/Document/handleServerFunction.tsx | 3 ++ packages/next/src/views/Document/index.tsx | 10 ++++- .../src/views/List/handleServerFunction.tsx | 3 ++ packages/next/src/views/List/index.tsx | 4 +- packages/payload/src/admin/views/types.ts | 2 +- .../ui/src/elements/AddNewRelation/index.tsx | 3 ++ .../elements/DocumentDrawer/DrawerContent.tsx | 3 ++ .../ui/src/elements/DocumentDrawer/index.tsx | 9 ++++- .../ui/src/elements/DocumentDrawer/types.ts | 7 +++- .../src/elements/ListDrawer/DrawerContent.tsx | 12 +++++- packages/ui/src/elements/ListDrawer/types.ts | 2 + .../src/elements/RelationshipTable/index.tsx | 6 ++- packages/ui/src/fields/Join/index.tsx | 5 ++- .../src/providers/ServerFunctions/index.tsx | 1 + test/joins/collections/Categories.ts | 8 +++- test/joins/collections/HiddenPosts.ts | 23 +++++++++++ test/joins/config.ts | 2 + test/joins/e2e.spec.ts | 38 ++++++++++++++---- test/joins/payload-types.ts | 39 +++++++++++++++++-- test/joins/seed.ts | 16 +++++++- test/joins/shared.ts | 4 ++ 21 files changed, 176 insertions(+), 24 deletions(-) create mode 100644 test/joins/collections/HiddenPosts.ts diff --git a/packages/next/src/views/Document/handleServerFunction.tsx b/packages/next/src/views/Document/handleServerFunction.tsx index d4764b2f34..9a057dff9b 100644 --- a/packages/next/src/views/Document/handleServerFunction.tsx +++ b/packages/next/src/views/Document/handleServerFunction.tsx @@ -19,6 +19,7 @@ export const renderDocumentHandler = async (args: { drawerSlug?: string initialData?: Data initialState?: FormState + overrideEntityVisibility?: boolean redirectAfterDelete: boolean redirectAfterDuplicate: boolean req: PayloadRequest @@ -29,6 +30,7 @@ export const renderDocumentHandler = async (args: { docID, drawerSlug, initialData, + overrideEntityVisibility, redirectAfterDelete, redirectAfterDuplicate, req, @@ -148,6 +150,7 @@ export const renderDocumentHandler = async (args: { translations: undefined, // TODO visibleEntities, }, + overrideEntityVisibility, params: { segments: ['collections', collectionSlug, docID], }, diff --git a/packages/next/src/views/Document/index.tsx b/packages/next/src/views/Document/index.tsx index ab8349b89e..0f4a776daf 100644 --- a/packages/next/src/views/Document/index.tsx +++ b/packages/next/src/views/Document/index.tsx @@ -33,11 +33,14 @@ export const renderDocument = async ({ importMap, initialData, initPageResult, + overrideEntityVisibility, params, redirectAfterDelete, redirectAfterDuplicate, searchParams, -}: AdminViewProps): Promise<{ +}: { + overrideEntityVisibility?: boolean +} & AdminViewProps): Promise<{ data: Data Document: React.ReactNode }> => { @@ -168,7 +171,10 @@ export const renderDocument = async ({ } if (collectionConfig) { - if (!visibleEntities?.collections?.find((visibleSlug) => visibleSlug === collectionSlug)) { + if ( + !visibleEntities?.collections?.find((visibleSlug) => visibleSlug === collectionSlug) && + !overrideEntityVisibility + ) { throw new Error('not-found') } diff --git a/packages/next/src/views/List/handleServerFunction.tsx b/packages/next/src/views/List/handleServerFunction.tsx index d363539ba0..b34be5ea8b 100644 --- a/packages/next/src/views/List/handleServerFunction.tsx +++ b/packages/next/src/views/List/handleServerFunction.tsx @@ -20,6 +20,7 @@ export const renderListHandler = async (args: { documentDrawerSlug: string drawerSlug?: string enableRowSelections: boolean + overrideEntityVisibility?: boolean query: ListQuery redirectAfterDelete: boolean redirectAfterDuplicate: boolean @@ -32,6 +33,7 @@ export const renderListHandler = async (args: { disableBulkEdit, drawerSlug, enableRowSelections, + overrideEntityVisibility, query, redirectAfterDelete, redirectAfterDuplicate, @@ -149,6 +151,7 @@ export const renderListHandler = async (args: { translations: undefined, // TODO visibleEntities, }, + overrideEntityVisibility, params: { segments: ['collections', collectionSlug], }, diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index 3057214036..8a389b33b4 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -23,6 +23,7 @@ type ListViewArgs = { disableBulkDelete?: boolean disableBulkEdit?: boolean enableRowSelections: boolean + overrideEntityVisibility?: boolean query: ListQuery } & AdminViewProps @@ -39,6 +40,7 @@ export const renderListView = async ( drawerSlug, enableRowSelections, initPageResult, + overrideEntityVisibility, params, query: queryFromArgs, searchParams, @@ -111,7 +113,7 @@ export const renderListView = async ( } = config if (collectionConfig) { - if (!visibleEntities.collections.includes(collectionSlug)) { + if (!visibleEntities.collections.includes(collectionSlug) && !overrideEntityVisibility) { throw new Error('not-found') } diff --git a/packages/payload/src/admin/views/types.ts b/packages/payload/src/admin/views/types.ts index 5453916c78..32bfa7c73a 100644 --- a/packages/payload/src/admin/views/types.ts +++ b/packages/payload/src/admin/views/types.ts @@ -8,7 +8,7 @@ import type { Locale, MetaConfig, PayloadComponent, ServerProps } from '../../co import type { SanitizedGlobalConfig } from '../../globals/config/types.js' import type { PayloadRequest } from '../../types/index.js' import type { LanguageOptions } from '../LanguageOptions.js' -import type { Data, DocumentSlots, PayloadServerAction } from '../types.js' +import type { Data, DocumentSlots } from '../types.js' export type AdminViewConfig = { Component: AdminViewComponent diff --git a/packages/ui/src/elements/AddNewRelation/index.tsx b/packages/ui/src/elements/AddNewRelation/index.tsx index 77bb80cc1a..0d1aa15322 100644 --- a/packages/ui/src/elements/AddNewRelation/index.tsx +++ b/packages/ui/src/elements/AddNewRelation/index.tsx @@ -34,10 +34,13 @@ export const AddNewRelation: React.FC = ({ const { permissions } = useAuth() const [show, setShow] = useState(false) const [selectedCollection, setSelectedCollection] = useState() + const relatedToMany = relatedCollections.length > 1 + const [collectionConfig, setCollectionConfig] = useState(() => !relatedToMany ? relatedCollections[0] : undefined, ) + const [popupOpen, setPopupOpen] = useState(false) const { i18n, t } = useTranslation() const [showTooltip, setShowTooltip] = useState(false) diff --git a/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx b/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx index 63b7e92a7b..d443ad4419 100644 --- a/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx +++ b/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx @@ -25,6 +25,7 @@ export const DocumentDrawerContent: React.FC = ({ onDelete: onDeleteFromProps, onDuplicate: onDuplicateFromProps, onSave: onSaveFromProps, + overrideEntityVisibility = true, redirectAfterDelete, redirectAfterDuplicate, }) => { @@ -64,6 +65,7 @@ export const DocumentDrawerContent: React.FC = ({ docID, drawerSlug, initialData, + overrideEntityVisibility, redirectAfterDelete: redirectAfterDelete !== undefined ? redirectAfterDelete : false, redirectAfterDuplicate: redirectAfterDuplicate !== undefined ? redirectAfterDuplicate : false, @@ -92,6 +94,7 @@ export const DocumentDrawerContent: React.FC = ({ redirectAfterDuplicate, renderDocument, closeModal, + overrideEntityVisibility, t, ], ) diff --git a/packages/ui/src/elements/DocumentDrawer/index.tsx b/packages/ui/src/elements/DocumentDrawer/index.tsx index 366dd52b4f..f297e10fde 100644 --- a/packages/ui/src/elements/DocumentDrawer/index.tsx +++ b/packages/ui/src/elements/DocumentDrawer/index.tsx @@ -64,7 +64,11 @@ export const DocumentDrawer: React.FC = (props) => { ) } -export const useDocumentDrawer: UseDocumentDrawer = ({ id, collectionSlug }) => { +export const useDocumentDrawer: UseDocumentDrawer = ({ + id, + collectionSlug, + overrideEntityVisibility, +}) => { const editDepth = useEditDepth() const uuid = useId() const { closeModal, modalState, openModal, toggleModal } = useModal() @@ -101,9 +105,10 @@ export const useDocumentDrawer: UseDocumentDrawer = ({ id, collectionSlug }) => drawerSlug={drawerSlug} id={id} key={drawerSlug} + overrideEntityVisibility={overrideEntityVisibility} /> ) - }, [id, drawerSlug, collectionSlug]) + }, [id, drawerSlug, collectionSlug, overrideEntityVisibility]) const MemoizedDrawerToggler = useMemo(() => { return (props) => ( diff --git a/packages/ui/src/elements/DocumentDrawer/types.ts b/packages/ui/src/elements/DocumentDrawer/types.ts index 85d20bd490..2e94640cf8 100644 --- a/packages/ui/src/elements/DocumentDrawer/types.ts +++ b/packages/ui/src/elements/DocumentDrawer/types.ts @@ -13,6 +13,7 @@ export type DocumentDrawerProps = { readonly id?: null | number | string readonly initialData?: Data readonly initialState?: FormState + readonly overrideEntityVisibility?: boolean readonly redirectAfterDelete?: boolean readonly redirectAfterDuplicate?: boolean } & Pick & @@ -28,7 +29,11 @@ export type DocumentTogglerProps = { readonly onClick?: () => void } & Readonly> -export type UseDocumentDrawer = (args: { collectionSlug: string; id?: number | string }) => [ +export type UseDocumentDrawer = (args: { + collectionSlug: string + id?: number | string + overrideEntityVisibility?: boolean +}) => [ React.FC>, // drawer React.FC>, // toggler { diff --git a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx index afbd58a692..7c22f13366 100644 --- a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx +++ b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx @@ -22,6 +22,7 @@ export const ListDrawerContent: React.FC = ({ filterOptions, onBulkSelect, onSelect, + overrideEntityVisibility = true, selectedCollection: selectedCollectionFromProps, }) => { const { closeModal, isModalOpen } = useModal() @@ -86,6 +87,7 @@ export const ListDrawerContent: React.FC = ({ disableBulkEdit: true, drawerSlug, enableRowSelections, + overrideEntityVisibility, query: newQuery, }, })) as { List: React.ReactNode } @@ -100,7 +102,15 @@ export const ListDrawerContent: React.FC = ({ } } }, - [serverFunction, closeModal, drawerSlug, isOpen, enableRowSelections, filterOptions], + [ + serverFunction, + closeModal, + drawerSlug, + isOpen, + enableRowSelections, + filterOptions, + overrideEntityVisibility, + ], ) useEffect(() => { diff --git a/packages/ui/src/elements/ListDrawer/types.ts b/packages/ui/src/elements/ListDrawer/types.ts index 68de71b2a3..73f86928de 100644 --- a/packages/ui/src/elements/ListDrawer/types.ts +++ b/packages/ui/src/elements/ListDrawer/types.ts @@ -10,6 +10,7 @@ export type ListDrawerProps = { readonly drawerSlug?: string readonly enableRowSelections?: boolean readonly filterOptions?: FilterOptionsResult + readonly overrideEntityVisibility?: boolean readonly selectedCollection?: string } & ListDrawerContextProps @@ -23,6 +24,7 @@ export type ListTogglerProps = { export type UseListDrawer = (args: { collectionSlugs?: SanitizedCollectionConfig['slug'][] filterOptions?: FilterOptionsResult + overrideEntityVisibility?: boolean selectedCollection?: SanitizedCollectionConfig['slug'] uploads?: boolean // finds all collections with upload: true }) => [ diff --git a/packages/ui/src/elements/RelationshipTable/index.tsx b/packages/ui/src/elements/RelationshipTable/index.tsx index 32412a31fb..43705e69f7 100644 --- a/packages/ui/src/elements/RelationshipTable/index.tsx +++ b/packages/ui/src/elements/RelationshipTable/index.tsx @@ -178,7 +178,11 @@ export const RelationshipTable: React.FC = (pro
{Label}
- {canCreate && {i18n.t('fields:addNew')}} + {canCreate && ( + + {i18n.t('fields:addNew')} + + )} { }, [docID, on, field.where]) return ( -
+
{BeforeInput} { test('should render initial rows within relationship table', async () => { await navigateToDoc(page, categoriesURL) - const joinField = page.locator('.field-type.join').first() + const joinField = page.locator('#field-relatedPosts.field-type.join') await expect(joinField).toBeVisible() + await expect(joinField.locator('.relationship-table table')).toBeVisible() const columns = await joinField.locator('.relationship-table tbody tr').count() expect(columns).toBe(3) }) + test('should render join field for hidden posts', async () => { + await navigateToDoc(page, categoriesURL) + const joinField = page.locator('#field-hiddenPosts.field-type.join') + await expect(joinField).toBeVisible() + await expect(joinField.locator('.relationship-table table')).toBeVisible() + const columns = await joinField.locator('.relationship-table tbody tr').count() + expect(columns).toBe(1) + const button = joinField.locator('button.doc-drawer__toggler.relationship-table__add-new') + await expect(button).toBeVisible() + await button.click() + const drawer = page.locator('[id^=doc-drawer_hidden-posts_1_]') + await expect(drawer).toBeVisible() + const titleField = drawer.locator('#field-title') + await expect(titleField).toBeVisible() + await titleField.fill('Test Hidden Post') + await drawer.locator('button[id="action-save"]').click() + await expect(joinField.locator('.relationship-table tbody tr')).toBeVisible() + const newColumns = await joinField.locator('.relationship-table tbody tr').count() + expect(newColumns).toBe(2) + }) + test('should render the create page and create doc with the join field', async () => { await page.goto(categoriesURL.create) const nameField = page.locator('#field-name') @@ -72,7 +94,7 @@ test.describe('Admin Panel', () => { test('should render collection type in first column of relationship table', async () => { await navigateToDoc(page, categoriesURL) - const joinField = page.locator('.field-type.join').first() + const joinField = page.locator('#field-relatedPosts.field-type.join') await expect(joinField).toBeVisible() const collectionTypeColumn = joinField.locator('thead tr th#heading-collection:first-child') const text = collectionTypeColumn @@ -91,7 +113,7 @@ test.describe('Admin Panel', () => { test('should render drawer toggler without document link in second column of relationship table', async () => { await navigateToDoc(page, categoriesURL) - const joinField = page.locator('.field-type.join').first() + const joinField = page.locator('#field-relatedPosts.field-type.join') await expect(joinField).toBeVisible() const actionColumn = joinField.locator('tbody tr td:nth-child(2)').first() const toggler = actionColumn.locator('button.doc-drawer__toggler') @@ -123,7 +145,7 @@ test.describe('Admin Panel', () => { test('should sort relationship table by clicking on column headers', async () => { await navigateToDoc(page, categoriesURL) - const joinField = page.locator('.field-type.join').first() + const joinField = page.locator('#field-relatedPosts.field-type.join') await expect(joinField).toBeVisible() const titleColumn = joinField.locator('thead tr th#heading-title') const titleAscButton = titleColumn.locator('button.sort-column__asc') @@ -143,7 +165,7 @@ test.describe('Admin Panel', () => { test('should update relationship table when new document is created', async () => { await navigateToDoc(page, categoriesURL) - const joinField = page.locator('.field-type.join').first() + const joinField = page.locator('#field-relatedPosts.field-type.join') await expect(joinField).toBeVisible() const addButton = joinField.locator('.relationship-table__actions button.doc-drawer__toggler', { @@ -173,7 +195,7 @@ test.describe('Admin Panel', () => { test('should update relationship table when document is updated', async () => { await navigateToDoc(page, categoriesURL) - const joinField = page.locator('.field-type.join').first() + const joinField = page.locator('#field-relatedPosts.field-type.join') await expect(joinField).toBeVisible() const editButton = joinField.locator( 'tbody tr:first-child td:nth-child(2) button.doc-drawer__toggler', @@ -194,14 +216,14 @@ test.describe('Admin Panel', () => { test('should render empty relationship table when creating new document', async () => { await page.goto(categoriesURL.create) - const joinField = page.locator('.field-type.join').first() + const joinField = page.locator('#field-relatedPosts.field-type.join') await expect(joinField).toBeVisible() await expect(joinField.locator('.relationship-table tbody tr')).toBeHidden() }) test('should update relationship table when new upload is created', async () => { await navigateToDoc(page, uploadsURL) - const joinField = page.locator('.field-type.join').first() + const joinField = page.locator('#field-relatedPosts.field-type.join') await expect(joinField).toBeVisible() // TODO: change this to edit the first row in the join table diff --git a/test/joins/payload-types.ts b/test/joins/payload-types.ts index 00974f3bd5..37f02f32ee 100644 --- a/test/joins/payload-types.ts +++ b/test/joins/payload-types.ts @@ -13,6 +13,7 @@ export interface Config { collections: { posts: Post; categories: Category; + 'hidden-posts': HiddenPost; uploads: Upload; versions: Version; 'categories-versions': CategoriesVersion; @@ -34,6 +35,7 @@ export interface Config { 'group.relatedPosts': 'posts'; 'group.camelCasePosts': 'posts'; filtered: 'posts'; + hiddenPosts: 'hidden-posts'; singulars: 'singular'; }; uploads: { @@ -53,6 +55,7 @@ export interface Config { collectionsSelect: { posts: PostsSelect | PostsSelect; categories: CategoriesSelect | CategoriesSelect; + 'hidden-posts': HiddenPostsSelect | HiddenPostsSelect; uploads: UploadsSelect | UploadsSelect; versions: VersionsSelect | VersionsSelect; 'categories-versions': CategoriesVersionsSelect | CategoriesVersionsSelect; @@ -75,9 +78,9 @@ export interface Config { user: User & { collection: 'users'; }; - jobs?: { + jobs: { tasks: unknown; - workflows?: unknown; + workflows: unknown; }; } export interface UserAuthOperations { @@ -105,7 +108,6 @@ export interface UserAuthOperations { export interface Post { id: string; title?: string | null; - conditionalField?: string | null; isFiltered?: boolean | null; restrictedField?: string | null; upload?: (string | null) | Upload; @@ -160,6 +162,10 @@ export interface Category { docs?: (string | Post)[] | null; hasNextPage?: boolean | null; } | null; + hiddenPosts?: { + docs?: (string | HiddenPost)[] | null; + hasNextPage?: boolean | null; + } | null; group?: { relatedPosts?: { docs?: (string | Post)[] | null; @@ -181,6 +187,17 @@ export interface Category { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "hidden-posts". + */ +export interface HiddenPost { + id: string; + title?: string | null; + category?: (string | null) | Category; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "singular". @@ -305,6 +322,10 @@ export interface PayloadLockedDocument { relationTo: 'categories'; value: string | Category; } | null) + | ({ + relationTo: 'hidden-posts'; + value: string | HiddenPost; + } | null) | ({ relationTo: 'uploads'; value: string | Upload; @@ -389,7 +410,6 @@ export interface PayloadMigration { */ export interface PostsSelect { title?: T; - conditionalField?: T; isFiltered?: T; restrictedField?: T; upload?: T; @@ -414,6 +434,7 @@ export interface CategoriesSelect { relatedPosts?: T; hasManyPosts?: T; hasManyPostsLocalized?: T; + hiddenPosts?: T; group?: | T | { @@ -425,6 +446,16 @@ export interface CategoriesSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "hidden-posts_select". + */ +export interface HiddenPostsSelect { + title?: T; + category?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "uploads_select". diff --git a/test/joins/seed.ts b/test/joins/seed.ts index d82f0b631d..757cebf31f 100644 --- a/test/joins/seed.ts +++ b/test/joins/seed.ts @@ -6,7 +6,13 @@ import { fileURLToPath } from 'url' import { devUser } from '../credentials.js' import { seedDB } from '../helpers/seed.js' -import { categoriesSlug, collectionSlugs, postsSlug, uploadsSlug } from './shared.js' +import { + categoriesSlug, + collectionSlugs, + hiddenPostsSlug, + postsSlug, + uploadsSlug, +} from './shared.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -28,6 +34,14 @@ export const seed = async (_payload) => { }, }) + await _payload.create({ + collection: hiddenPostsSlug, + data: { + category: category.id, + title: 'Test Post 1', + }, + }) + await _payload.create({ collection: postsSlug, data: { diff --git a/test/joins/shared.ts b/test/joins/shared.ts index d1f59c510d..3be3a76814 100644 --- a/test/joins/shared.ts +++ b/test/joins/shared.ts @@ -1,7 +1,11 @@ export const categoriesSlug = 'categories' +export const categories2Slug = 'categories-2' + export const postsSlug = 'posts' +export const hiddenPostsSlug = 'hidden-posts' + export const uploadsSlug = 'uploads' export const localizedPostsSlug = 'localized-posts'