diff --git a/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx b/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx index 624f27e17..b2a6d2940 100644 --- a/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx +++ b/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx @@ -42,14 +42,16 @@ export const DocumentDrawerContent: React.FC = ({ const [DocumentView, setDocumentView] = useState(undefined) const [isLoading, setIsLoading] = useState(true) - const hasRenderedDocument = useRef(false) + const hasInitialized = useRef(false) const getDocumentView = useCallback( - (docID?: number | string) => { + (docID?: number | string, showLoadingIndicator: boolean = false) => { const controller = handleAbortRef(abortGetDocumentViewRef) const fetchDocumentView = async () => { - setIsLoading(true) + if (showLoadingIndicator) { + setIsLoading(true) + } try { const result = await renderDocument({ @@ -141,13 +143,13 @@ export const DocumentDrawerContent: React.FC = ({ ) const clearDoc = useCallback(() => { - getDocumentView() + getDocumentView(undefined, true) }, [getDocumentView]) useEffect(() => { - if (!DocumentView && !hasRenderedDocument.current) { - getDocumentView(existingDocID) - hasRenderedDocument.current = true + if (!DocumentView && !hasInitialized.current) { + getDocumentView(existingDocID, true) + hasInitialized.current = true } }, [DocumentView, getDocumentView, existingDocID]) diff --git a/packages/ui/src/hooks/useControllableState.ts b/packages/ui/src/hooks/useControllableState.ts new file mode 100644 index 000000000..5d6b9b0e0 --- /dev/null +++ b/packages/ui/src/hooks/useControllableState.ts @@ -0,0 +1,28 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +/** + * A hook for managing state that can be controlled by props but also overridden locally. + * Props always take precedence if they change, but local state can override them temporarily. + */ +export function useControllableState( + propValue: T, + defaultValue?: T, +): [T, (value: ((prev: T) => T) | T) => void] { + const [localValue, setLocalValue] = useState(propValue ?? defaultValue) + const initialRenderRef = useRef(true) + + useEffect(() => { + if (initialRenderRef.current) { + initialRenderRef.current = false + return + } + + setLocalValue(propValue) + }, [propValue]) + + const setValue = useCallback((value: ((prev: T) => T) | T) => { + setLocalValue(value) + }, []) + + return [localValue, setValue] +} diff --git a/packages/ui/src/providers/DocumentInfo/index.tsx b/packages/ui/src/providers/DocumentInfo/index.tsx index c7cec9ae3..b98e4f944 100644 --- a/packages/ui/src/providers/DocumentInfo/index.tsx +++ b/packages/ui/src/providers/DocumentInfo/index.tsx @@ -1,9 +1,10 @@ 'use client' -import type { ClientUser, DocumentPreferences, SanitizedDocumentPermissions } from 'payload' +import type { ClientUser, DocumentPreferences } from 'payload' import * as qs from 'qs-esm' import React, { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useControllableState } from '../../hooks/useControllableState.js' import { useAuth } from '../../providers/Auth/index.js' import { requests } from '../../utilities/api.js' import { formatDocTitle } from '../../utilities/formatDocTitle/index.js' @@ -45,12 +46,11 @@ const DocumentInfo: React.FC< versionCount: versionCountFromProps, } = props - const [docPermissions, setDocPermissions] = - useState(docPermissionsFromProps) + const [docPermissions, setDocPermissions] = useControllableState(docPermissionsFromProps) - const [hasSavePermission, setHasSavePermission] = useState(hasSavePermissionFromProps) + const [hasSavePermission, setHasSavePermission] = useControllableState(hasSavePermissionFromProps) - const [hasPublishPermission, setHasPublishPermission] = useState( + const [hasPublishPermission, setHasPublishPermission] = useControllableState( hasPublishPermissionFromProps, ) @@ -101,15 +101,24 @@ const DocumentInfo: React.FC< unpublishedVersionCountFromProps, ) - const [documentIsLocked, setDocumentIsLocked] = useState(isLockedFromProps) - const [currentEditor, setCurrentEditor] = useState(currentEditorFromProps) - const [lastUpdateTime, setLastUpdateTime] = useState(lastUpdateTimeFromProps) - const [savedDocumentData, setSavedDocumentData] = useState(initialData) - const [uploadStatus, setUploadStatus] = useState<'failed' | 'idle' | 'uploading'>('idle') + const [documentIsLocked, setDocumentIsLocked] = useControllableState( + isLockedFromProps, + ) + const [currentEditor, setCurrentEditor] = useControllableState( + currentEditorFromProps, + ) + const [lastUpdateTime, setLastUpdateTime] = useControllableState(lastUpdateTimeFromProps) + const [savedDocumentData, setSavedDocumentData] = useControllableState(initialData) + const [uploadStatus, setUploadStatus] = useControllableState<'failed' | 'idle' | 'uploading'>( + 'idle', + ) - const updateUploadStatus = useCallback((status: 'failed' | 'idle' | 'uploading') => { - setUploadStatus(status) - }, []) + const updateUploadStatus = useCallback( + (status: 'failed' | 'idle' | 'uploading') => { + setUploadStatus(status) + }, + [setUploadStatus], + ) const { getPreference, setPreference } = usePreferences() const { code: locale } = useLocale() @@ -170,7 +179,7 @@ const DocumentInfo: React.FC< console.error('Failed to unlock the document', error) } }, - [serverURL, api, globalSlug], + [serverURL, api, globalSlug, setDocumentIsLocked], ) const updateDocumentEditor = useCallback( @@ -279,7 +288,7 @@ const DocumentInfo: React.FC< (json) => { setSavedDocumentData(json) }, - [], + [setSavedDocumentData], ) /** diff --git a/test/access-control/e2e.spec.ts b/test/access-control/e2e.spec.ts index 933917a96..41a9469b2 100644 --- a/test/access-control/e2e.spec.ts +++ b/test/access-control/e2e.spec.ts @@ -436,10 +436,15 @@ describe('Access Control', () => { const documentDrawer = page.locator(`[id^=doc-drawer_${createNotUpdateCollectionSlug}_1_]`) await expect(documentDrawer).toBeVisible() await expect(documentDrawer.locator('#action-save')).toBeVisible() + await documentDrawer.locator('#field-name').fill('name') await expect(documentDrawer.locator('#field-name')).toHaveValue('name') - await documentDrawer.locator('#action-save').click() - await expect(page.locator('.payload-toast-container')).toContainText('successfully') + + await saveDocAndAssert( + page, + `[id^=doc-drawer_${createNotUpdateCollectionSlug}_1_] #action-save`, + ) + await expect(documentDrawer.locator('#action-save')).toBeHidden() await expect(documentDrawer.locator('#field-name')).toBeDisabled() }) diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index da8c3cb5a..7653ce796 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -95,7 +95,6 @@ export interface Config { 'auth-collection': AuthCollection; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; - 'payload-sessions': PayloadSession; 'payload-migrations': PayloadMigration; }; collectionsJoins: {}; @@ -126,7 +125,6 @@ export interface Config { 'auth-collection': AuthCollectionSelect | AuthCollectionSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; - 'payload-sessions': PayloadSessionsSelect | PayloadSessionsSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { @@ -232,6 +230,13 @@ export interface User { hash?: string | null; loginAttempts?: number | null; lockUntil?: string | null; + sessions?: + | { + id: string; + createdAt?: string | null; + expiresAt: string; + }[] + | null; password?: string | null; } /** @@ -249,6 +254,13 @@ export interface PublicUser { hash?: string | null; loginAttempts?: number | null; lockUntil?: string | null; + sessions?: + | { + id: string; + createdAt?: string | null; + expiresAt: string; + }[] + | null; password?: string | null; } /** @@ -740,6 +752,13 @@ export interface AuthCollection { _verificationToken?: string | null; loginAttempts?: number | null; lockUntil?: string | null; + sessions?: + | { + id: string; + createdAt?: string | null; + expiresAt: string; + }[] + | null; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -893,26 +912,6 @@ export interface PayloadPreference { updatedAt: string; createdAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-sessions". - */ -export interface PayloadSession { - id: string; - session: string; - expiration: string; - user: - | { - relationTo: 'users'; - value: string | User; - } - | { - relationTo: 'public-users'; - value: string | PublicUser; - }; - updatedAt: string; - createdAt: string; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-migrations". @@ -939,6 +938,13 @@ export interface UsersSelect { hash?: T; loginAttempts?: T; lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -954,6 +960,13 @@ export interface PublicUsersSelect { hash?: T; loginAttempts?: T; lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -1294,6 +1307,13 @@ export interface AuthCollectionSelect { _verificationToken?: T; loginAttempts?: T; lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -1317,17 +1337,6 @@ export interface PayloadPreferencesSelect { updatedAt?: T; createdAt?: T; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "payload-sessions_select". - */ -export interface PayloadSessionsSelect { - session?: T; - expiration?: T; - user?: T; - updatedAt?: T; - createdAt?: T; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-migrations_select". diff --git a/test/admin/e2e/document-view/e2e.spec.ts b/test/admin/e2e/document-view/e2e.spec.ts index 6ebc8a147..64383c571 100644 --- a/test/admin/e2e/document-view/e2e.spec.ts +++ b/test/admin/e2e/document-view/e2e.spec.ts @@ -363,6 +363,43 @@ describe('Document View', () => { }) describe('drawers', () => { + test('document drawers do not unmount across save events', async () => { + // Navigate to a post document + await navigateToDoc(page, postsUrl) + + // Open the relationship drawer + await page + .locator('.field-type.relationship .relationship--single-value__drawer-toggler') + .click() + + const drawer = page.locator('[id^=doc-drawer_posts_1_]') + const drawerEditView = drawer.locator('.drawer__content .collection-edit') + await expect(drawerEditView).toBeVisible() + + const drawerTitleField = drawerEditView.locator('#field-title') + const testTitle = 'Test Title for Persistence' + await drawerTitleField.fill(testTitle) + await expect(drawerTitleField).toHaveValue(testTitle) + + await drawerEditView.evaluate((el) => { + el.setAttribute('data-test-instance', 'This is a test') + }) + + await expect(drawerEditView).toHaveAttribute('data-test-instance', 'This is a test') + + await saveDocAndAssert(page, '[id^=doc-drawer_posts_1_] .drawer__content #action-save') + + await expect(drawerEditView).toBeVisible() + await expect(drawerTitleField).toHaveValue(testTitle) + + // Verify the element instance hasn't changed (i.e., it wasn't re-mounted and discarded the custom attribute) + await expect + .poll(async () => { + return await drawerEditView.getAttribute('data-test-instance') + }) + .toBe('This is a test') + }) + test('document drawers are visually stacking', async () => { await navigateToDoc(page, postsUrl) await page.locator('#field-title').fill(title) diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index 88fb0f3d5..10e34bc1b 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -293,6 +293,13 @@ export interface User { hash?: string | null; loginAttempts?: number | null; lockUntil?: string | null; + sessions?: + | { + id: string; + createdAt?: string | null; + expiresAt: string; + }[] + | null; password?: string | null; } /** @@ -820,6 +827,13 @@ export interface UsersSelect { hash?: T; loginAttempts?: T; lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; } /** * This interface was referenced by `Config`'s JSON-Schema diff --git a/test/helpers.ts b/test/helpers.ts index d1e490c7a..f5a2f7f6f 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -248,7 +248,7 @@ export async function saveDocHotkeyAndAssert(page: Page): Promise { export async function saveDocAndAssert( page: Page, - selector = '#action-save', + selector: '#access-save' | '#action-publish' | '#action-save-draft' | string = '#action-save', expectation: 'error' | 'success' = 'success', ): Promise { await wait(500) // TODO: Fix this