diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index 54628cc8c7..c9737eb963 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -6,6 +6,8 @@ import { dequal } from 'dequal/lite' import { reduceFieldsToValues, versionDefaults } from 'payload/shared' import React, { useDeferredValue, useEffect, useRef, useState } from 'react' +import type { OnSaveContext } from '../../views/Edit/index.js' + import { useAllFormFields, useForm, @@ -45,7 +47,6 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) const { docConfig, - incrementVersionCount, lastUpdateTime, mostRecentVersionIsAutosaved, setMostRecentVersionIsAutosaved, @@ -149,15 +150,14 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate if (!skipSubmission && modifiedRef.current && url) { - const result = await submit<{ - incrementVersionCount: boolean - }>({ + const result = await submit({ acceptValues: { overrideLocalChanges: false, }, action: url, context: { - incrementVersionCount: false, + getDocPermissions: false, + incrementVersionCount: !mostRecentVersionIsAutosaved, }, disableFormWhileProcessing: false, disableSuccessStatus: true, @@ -169,7 +169,6 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) }) if (result && result?.res?.ok && !mostRecentVersionIsAutosaved) { - incrementVersionCount() setMostRecentVersionIsAutosaved(true) setUnpublishedVersionCount((prev) => prev + 1) } diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts index b62c8a8822..f8eef3e606 100644 --- a/packages/ui/src/forms/Form/types.ts +++ b/packages/ui/src/forms/Form/types.ts @@ -16,6 +16,20 @@ export type Preferences = { [key: string]: unknown } +export type FormOnSuccess> = ( + json: T, + options?: { + /** + * Arbitrary context passed to the onSuccess callback. + */ + context?: C + /** + * Form state at the time of the request used to retrieve the JSON response. + */ + formState?: FormState + }, +) => Promise | void + export type FormProps = { beforeSubmit?: ((args: { formState: FormState }) => Promise)[] children?: React.ReactNode @@ -54,16 +68,7 @@ export type FormProps = { log?: boolean onChange?: ((args: { formState: FormState; submitted?: boolean }) => Promise)[] onSubmit?: (fields: FormState, data: Data) => void - onSuccess?: ( - json: unknown, - options?: { - /** - * Arbitrary context passed to the onSuccess callback. - */ - context?: Record - formState?: FormState - }, - ) => Promise | void + onSuccess?: FormOnSuccess redirect?: string submitted?: boolean uuid?: string @@ -79,14 +84,14 @@ export type FormProps = { } ) -export type SubmitOptions> = { +export type SubmitOptions> = { acceptValues?: AcceptValues action?: string /** * @experimental - Note: this property is experimental and may change in the future. Use at your own discretion. * If you want to pass additional data to the onSuccess callback, you can use this context object. */ - context?: T + context?: C /** * When true, will disable the form while it is processing. * @default true @@ -108,14 +113,14 @@ export type SubmitOptions> = { export type DispatchFields = React.Dispatch -export type Submit = >( - options?: SubmitOptions, +export type Submit = >( + options?: SubmitOptions, e?: React.FormEvent, ) => Promise +{ formState?: FormState; res: T } | void> export type ValidateForm = () => Promise diff --git a/packages/ui/src/providers/DocumentInfo/types.ts b/packages/ui/src/providers/DocumentInfo/types.ts index 4d8d6404c2..cccfe085bd 100644 --- a/packages/ui/src/providers/DocumentInfo/types.ts +++ b/packages/ui/src/providers/DocumentInfo/types.ts @@ -14,6 +14,8 @@ import type { import React from 'react' +import type { GetDocPermissions } from './useGetDocPermissions.js' + export type DocumentInfoProps = { readonly action?: string readonly AfterDocument?: React.ReactNode @@ -57,7 +59,7 @@ export type DocumentInfoContext = { isLocked: boolean user: ClientUser | number | string } | null> - getDocPermissions: (data?: Data) => Promise + getDocPermissions: GetDocPermissions getDocPreferences: () => Promise incrementVersionCount: () => void isInitializing: boolean diff --git a/packages/ui/src/providers/DocumentInfo/useGetDocPermissions.tsx b/packages/ui/src/providers/DocumentInfo/useGetDocPermissions.tsx index 14fdfbed3e..defcff8433 100644 --- a/packages/ui/src/providers/DocumentInfo/useGetDocPermissions.tsx +++ b/packages/ui/src/providers/DocumentInfo/useGetDocPermissions.tsx @@ -6,6 +6,8 @@ import React from 'react' import { hasSavePermission as getHasSavePermission } from '../../utilities/hasSavePermission.js' import { isEditing as getIsEditing } from '../../utilities/isEditing.js' +export type GetDocPermissions = (data?: Data) => Promise + export const useGetDocPermissions = ({ id, api, @@ -30,7 +32,7 @@ export const useGetDocPermissions = ({ setDocPermissions: React.Dispatch> setHasPublishPermission: React.Dispatch> setHasSavePermission: React.Dispatch> -}) => +}): GetDocPermissions => React.useCallback( async (data: Data) => { const params = { @@ -111,5 +113,18 @@ export const useGetDocPermissions = ({ ) } }, - [serverURL, api, id, permissions, i18n.language, locale, collectionSlug, globalSlug], + [ + locale, + id, + collectionSlug, + globalSlug, + serverURL, + api, + i18n.language, + setDocPermissions, + setHasSavePermission, + setHasPublishPermission, + permissions?.collections, + permissions?.globals, + ], ) diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index 0fc404f0f7..ed0421cced 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -1,13 +1,14 @@ /* eslint-disable react-compiler/react-compiler -- TODO: fix */ 'use client' -import type { ClientUser, DocumentViewClientProps } from 'payload' +import type { ClientUser, DocumentViewClientProps, FormState } from 'payload' import { useRouter, useSearchParams } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { FormProps } from '../../forms/Form/index.js' +import type { FormOnSuccess } from '../../forms/Form/types.js' import type { LockedState } from '../../utilities/buildFormState.js' import { DocumentControls } from '../../elements/DocumentControls/index.js' @@ -42,6 +43,11 @@ import './index.scss' const baseClass = 'collection-edit' +export type OnSaveContext = { + getDocPermissions?: boolean + incrementVersionCount?: boolean +} + // This component receives props only on _pages_ // When rendered within a drawer, props are empty // This is solely to support custom edit views which get server-rendered @@ -256,13 +262,12 @@ export function DefaultEditView({ user?.id, ]) - const onSave = useCallback( + const onSave: FormOnSuccess = useCallback( async (json, options) => { const { context, formState } = options || {} const controller = handleAbortRef(abortOnSaveRef) - // @ts-expect-error can ignore const document = json?.doc || json?.result const updatedAt = document?.updatedAt || new Date().toISOString() @@ -316,7 +321,9 @@ export function DefaultEditView({ resetUploadEdits() } - await getDocPermissions(json) + if (context?.getDocPermissions !== false) { + await getDocPermissions(json) + } if (id || globalSlug) { const docPreferences = await getDocPreferences() diff --git a/test/form-state/collections/Autosave/index.tsx b/test/form-state/collections/Autosave/index.tsx index 939b4c463d..51dd8aad41 100644 --- a/test/form-state/collections/Autosave/index.tsx +++ b/test/form-state/collections/Autosave/index.tsx @@ -18,7 +18,6 @@ export const AutosavePostsCollection: CollectionConfig = { hooks: { beforeChange: [({ data }) => data?.title], }, - label: 'Computed Title', }, ], versions: { diff --git a/test/form-state/e2e.spec.ts b/test/form-state/e2e.spec.ts index ac4fd3e6cc..cb0ec2ca0f 100644 --- a/test/form-state/e2e.spec.ts +++ b/test/form-state/e2e.spec.ts @@ -10,6 +10,7 @@ import { addArrayRowAsync, removeArrayRow } from 'helpers/e2e/fields/array/index import { addBlock } from 'helpers/e2e/fields/blocks/index.js' import { waitForAutoSaveToRunAndComplete } from 'helpers/e2e/waitForAutoSaveToRunAndComplete.js' import * as path from 'path' +import { wait } from 'payload/shared' import { fileURLToPath } from 'url' import type { Config, Post } from './payload-types.js' @@ -330,6 +331,94 @@ test.describe('Form State', () => { ).toHaveValue('This is a computed value.') }) + test('should fetch new doc permissions after save', async () => { + const doc = await createPost({ title: 'Initial Title' }) + await page.goto(postsUrl.edit(doc.id)) + const titleField = page.locator('#field-title') + await expect(titleField).toBeEnabled() + + await assertNetworkRequests( + page, + `${serverURL}/api/posts/access/${doc.id}`, + async () => { + await titleField.fill('Updated Title') + await wait(500) + await page.click('#action-save', { delay: 100 }) + }, + { + allowedNumberOfRequests: 2, + minimumNumberOfRequests: 2, + timeout: 3000, + }, + ) + + await assertNetworkRequests( + page, + `${serverURL}/api/posts/access/${doc.id}`, + async () => { + await titleField.fill('Updated Title 2') + await wait(500) + await page.click('#action-save', { delay: 100 }) + }, + { + minimumNumberOfRequests: 2, + allowedNumberOfRequests: 2, + timeout: 3000, + }, + ) + }) + + test('autosave - should not fetch new doc permissions on every autosave', async () => { + const doc = await payload.create({ + collection: autosavePostsSlug, + data: { + title: 'Initial Title', + }, + }) + + await page.goto(autosavePostsUrl.edit(doc.id)) + const titleField = page.locator('#field-title') + await expect(titleField).toBeEnabled() + + await assertNetworkRequests( + page, + `${serverURL}/api/${autosavePostsSlug}/access/${doc.id}`, + async () => { + await titleField.fill('Updated Title') + }, + { + allowedNumberOfRequests: 0, + timeout: 3000, + }, + ) + + await assertNetworkRequests( + page, + `${serverURL}/api/${autosavePostsSlug}/access/${doc.id}`, + async () => { + await titleField.fill('Updated Title Again') + }, + { + allowedNumberOfRequests: 0, + timeout: 3000, + }, + ) + + // save manually and ensure the permissions are fetched again + await assertNetworkRequests( + page, + `${serverURL}/api/${autosavePostsSlug}/access/${doc.id}`, + async () => { + await page.click('#action-save', { delay: 100 }) + }, + { + allowedNumberOfRequests: 2, + minimumNumberOfRequests: 2, + timeout: 3000, + }, + ) + }) + test('autosave - should render computed values after autosave', async () => { await page.goto(autosavePostsUrl.create) const titleField = page.locator('#field-title')