Merge branch 'main' into fix/localized-status-UI

This commit is contained in:
Jessica Chowdhury
2025-08-15 15:05:05 +01:00
26 changed files with 333 additions and 84 deletions

View File

@@ -33,7 +33,7 @@ export const Users: CollectionConfig = {
}
```
![Authentication Admin Panel functionality](https://payloadcms.com/images/docs/auth-admin.jpg)
![Authentication Admin Panel functionality](https://payloadcms.com/images/docs/auth-overview.jpg)
_Admin Panel screenshot depicting an Admins Collection with Auth enabled_
## Config Options

View File

@@ -207,7 +207,7 @@ Everything mentioned above applies to local development as well, but there are a
### Enable Turbopack
<Banner type="warning">
**Note:** In the future this will be the default. Use as your own risk.
**Note:** In the future this will be the default. Use at your own risk.
</Banner>
Add `--turbo` to your dev script to significantly speed up your local development server start time.

View File

@@ -13,8 +13,8 @@ keywords: uploads, images, media, overview, documentation, Content Management Sy
</Banner>
<LightDarkImage
srcLight="https://payloadcms.com/images/docs/upload-admin.jpg"
srcDark="https://payloadcms.com/images/docs/upload-admin.jpg"
srcLight="https://payloadcms.com/images/docs/uploads-overview.jpg"
srcDark="https://payloadcms.com/images/docs/uploads-overview.jpg"
alt="Shows an Upload enabled collection in the Payload Admin Panel"
caption="Admin Panel screenshot depicting a Media Collection with Upload enabled"
/>

View File

@@ -12,7 +12,7 @@ Extending on Payload's [Draft](/docs/versions/drafts) functionality, you can con
Autosave relies on Versions and Drafts being enabled in order to function.
</Banner>
![Autosave Enabled](/images/docs/autosave-enabled.png)
![Autosave Enabled](/images/docs/autosave-v3.jpg)
_If Autosave is enabled, drafts will be created automatically as the document is modified and the Admin UI adds an indicator describing when the document was last saved to the top right of the sidebar._
## Options

View File

@@ -14,7 +14,7 @@ Payload's Draft functionality builds on top of the Versions functionality to all
By enabling Versions with Drafts, your collections and globals can maintain _newer_, and _unpublished_ versions of your documents. It's perfect for cases where you might want to work on a document, update it and save your progress, but not necessarily make it publicly published right away. Drafts are extremely helpful when building preview implementations.
![Drafts Enabled](/images/docs/drafts-enabled.png)
![Drafts Enabled](/images/docs/autosave-drafts.jpg)
_If Drafts are enabled, the typical Save button is replaced with new actions which allow you to either save a draft, or publish your changes._
## Options

View File

@@ -13,7 +13,7 @@ keywords: version history, revisions, audit log, draft, publish, restore, autosa
When enabled, Payload will automatically scaffold a new Collection in your database to store versions of your document(s) over time, and the Admin UI will be extended with additional views that allow you to browse document versions, view diffs in order to see exactly what has changed in your documents (and when they changed), and restore documents back to prior versions easily.
![Versions](/images/docs/versions.png)
![Versions](/images/docs/versions-v3.jpg)
_Comparing an old version to a newer version of a document_
**With Versions, you can:**

View File

@@ -56,6 +56,12 @@ export type FieldState = {
fieldSchema?: Field
filterOptions?: FilterOptionsResult
initialValue?: unknown
/**
* @experimental - Note: this property is experimental and may change in the future. Use at your own discretion.
* Every time a field is changed locally, this flag is set to true. Prevents form state from server from overwriting local changes.
* After merging server form state, this flag is reset.
*/
isModified?: boolean
/**
* The path of the field when its custom components were last rendered.
* This is used to denote if a field has been rendered, and if so,
@@ -114,9 +120,11 @@ export type BuildFormStateArgs = {
mockRSCs?: boolean
operation?: 'create' | 'update'
readOnly?: boolean
/*
If true, will render field components within their state object
*/
/**
* If true, will render field components within their state object.
* Performance optimization: Setting to `false` ensures that only fields that have changed paths will re-render, e.g. new array rows, etc.
* For example, you only need to render ALL fields on initial render, not on every onChange.
*/
renderAllFields?: boolean
req: PayloadRequest
returnLockStatus?: boolean

View File

@@ -149,7 +149,9 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate
if (!skipSubmission && modifiedRef.current && url) {
const result = await submit({
const result = await submit<{
incrementVersionCount: boolean
}>({
acceptValues: {
overrideLocalChanges: false,
},

View File

@@ -21,7 +21,7 @@ export type DocumentDrawerContextProps = {
readonly onSave?: (args: {
collectionConfig?: ClientCollectionConfig
/**
* @experimental - Note: this property is experimental and may change in the future. Use as your own discretion.
* @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?: Record<string, unknown>

View File

@@ -189,12 +189,11 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
}
case 'MERGE_SERVER_STATE': {
const { acceptValues, formStateAtTimeOfRequest, prevStateRef, serverState } = action
const { acceptValues, prevStateRef, serverState } = action
const newState = mergeServerFormState({
acceptValues,
currentState: state || {},
formStateAtTimeOfRequest,
incomingState: serverState,
})
@@ -385,6 +384,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
return {
...field,
[key]: value,
...(key === 'value' ? { isModified: true } : {}),
}
}
@@ -398,6 +398,15 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[action.path]: newField,
}
// reset `isModified` in all other fields
if ('value' in action) {
for (const [path, field] of Object.entries(newState)) {
if (path !== action.path && 'isModified' in field) {
delete newState[path].isModified
}
}
}
return newState
}

View File

@@ -264,23 +264,21 @@ export const Form: React.FC<FormProps> = (props) => {
await wait(100)
}
/**
* Take copies of the current form state and data here. This will ensure it is consistent.
* For example, it is possible for the form state ref to change in the background while this submit function is running.
* TODO: can we send the `formStateCopy` through `reduceFieldsToValues` to even greater consistency? Doing this currently breaks uploads.
*/
const formStateCopy = deepCopyObjectSimpleWithoutReactComponents(contextRef.current.fields)
const data = reduceFieldsToValues(contextRef.current.fields, true)
// Execute server side validations
if (Array.isArray(beforeSubmit)) {
const serializableFormState = deepCopyObjectSimpleWithoutReactComponents(
contextRef.current.fields,
)
let revalidatedFormState: FormState
await beforeSubmit.reduce(async (priorOnChange, beforeSubmitFn) => {
await priorOnChange
const result = await beforeSubmitFn({
formState: formStateCopy,
formState: serializableFormState,
})
revalidatedFormState = result
@@ -328,7 +326,7 @@ export const Form: React.FC<FormProps> = (props) => {
data[key] = value
}
onSubmit(formStateCopy, data)
onSubmit(contextRef.current.fields, data)
}
if (!hasFormSubmitAction) {
@@ -379,13 +377,14 @@ export const Form: React.FC<FormProps> = (props) => {
if (res.status < 400) {
if (typeof onSuccess === 'function') {
const newFormState = await onSuccess(json, context)
const newFormState = await onSuccess(json, {
context,
})
if (newFormState) {
dispatchFields({
type: 'MERGE_SERVER_STATE',
acceptValues,
formStateAtTimeOfRequest: formStateCopy,
prevStateRef: prevFormState,
serverState: newFormState,
})

View File

@@ -21,7 +21,6 @@ export type AcceptValues =
type Args = {
acceptValues?: AcceptValues
currentState?: FormState
formStateAtTimeOfRequest?: FormState
incomingState: FormState
}
@@ -37,7 +36,6 @@ type Args = {
export const mergeServerFormState = ({
acceptValues,
currentState = {},
formStateAtTimeOfRequest,
incomingState,
}: Args): FormState => {
const newState = { ...currentState }
@@ -49,8 +47,9 @@ export const mergeServerFormState = ({
/**
* If it's a new field added by the server, always accept the value.
* Otherwise, only accept the values if explicitly requested, e.g. on submit.
* Can also control this granularly by only accepting unmodified values, e.g. for autosave.
* Otherwise:
* a. accept all values when explicitly requested, e.g. on submit
* b. only accept values for unmodified fields, e.g. on autosave
*/
if (
!incomingField.addedByServer &&
@@ -59,7 +58,7 @@ export const mergeServerFormState = ({
(typeof acceptValues === 'object' &&
acceptValues !== null &&
acceptValues?.overrideLocalChanges === false &&
currentState[path]?.value !== formStateAtTimeOfRequest?.[path]?.value))
currentState[path].isModified))
) {
delete incomingField.value
delete incomingField.initialValue

View File

@@ -54,7 +54,15 @@ export type FormProps = {
log?: boolean
onChange?: ((args: { formState: FormState; submitted?: boolean }) => Promise<FormState>)[]
onSubmit?: (fields: FormState, data: Data) => void
onSuccess?: (json: unknown, context?: Record<string, unknown>) => Promise<FormState | void> | void
onSuccess?: (
json: unknown,
options?: {
/**
* Arbitrary context passed to the onSuccess callback.
*/
context?: Record<string, unknown>
},
) => Promise<FormState | void> | void
redirect?: string
submitted?: boolean
uuid?: string
@@ -70,14 +78,14 @@ export type FormProps = {
}
)
export type SubmitOptions = {
export type SubmitOptions<T = Record<string, unknown>> = {
acceptValues?: AcceptValues
action?: string
/**
* @experimental - Note: this property is experimental and may change in the future. Use as your own discretion.
* @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?: Record<string, unknown>
context?: T
/**
* When true, will disable the form while it is processing.
* @default true
@@ -99,11 +107,11 @@ export type SubmitOptions = {
export type DispatchFields = React.Dispatch<any>
export type Submit = (
options?: SubmitOptions,
export type Submit = <T extends Record<string, unknown>>(
options?: SubmitOptions<T>,
e?: React.FormEvent<HTMLFormElement>,
) => Promise</**
* @experimental - Note: the `{ res: ... }` return type is experimental and may change in the future. Use as your own discretion.
* @experimental - Note: the `{ res: ... }` return type is experimental and may change in the future. Use at your own discretion.
* Returns the form state and the response from the server.
*/
{ formState?: FormState; res: Response } | void>
@@ -185,7 +193,6 @@ export type ADD_ROW = {
export type MERGE_SERVER_STATE = {
acceptValues?: AcceptValues
formStateAtTimeOfRequest?: FormState
prevStateRef: React.RefObject<FormState>
serverState: FormState
type: 'MERGE_SERVER_STATE'

View File

@@ -5,7 +5,6 @@ import type {
DocumentPreferences,
Field,
FieldSchemaMap,
FieldState,
FormState,
FormStateWithoutComponents,
PayloadRequest,
@@ -105,6 +104,7 @@ export const fieldSchemasToFormState = async ({
skipValidation,
}: Args): Promise<FormState> => {
if (!clientFieldSchemaMap && renderFieldFn) {
// eslint-disable-next-line no-console
console.warn(
'clientFieldSchemaMap is not passed to fieldSchemasToFormState - this will reduce performance',
)

View File

@@ -5,7 +5,6 @@ import type {
DocumentPreferences,
Field as FieldSchema,
FieldSchemaMap,
FieldState,
FormState,
FormStateWithoutComponents,
PayloadRequest,

View File

@@ -182,11 +182,13 @@ export const buildFormState = async (
}
let documentData = undefined
if (documentFormState) {
documentData = reduceFieldsToValues(documentFormState, true)
}
let blockData = initialBlockData
if (initialBlockFormState) {
blockData = reduceFieldsToValues(initialBlockFormState, true)
}

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-compiler/react-compiler -- TODO: fix */
'use client'
import type { ClientUser, DocumentViewClientProps, FormState } from 'payload'
import type { ClientUser, DocumentViewClientProps } from 'payload'
import { useRouter, useSearchParams } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
@@ -256,10 +256,13 @@ export function DefaultEditView({
user?.id,
])
const onSave = useCallback(
async (json, context?: Record<string, unknown>): Promise<FormState> => {
const onSave = useCallback<FormProps['onSuccess']>(
async (json, options) => {
const { context } = options || {}
const controller = handleAbortRef(abortOnSaveRef)
// @ts-expect-error can ignore
const document = json?.doc || json?.result
const updatedAt = document?.updatedAt || new Date().toISOString()
@@ -290,9 +293,10 @@ export function DefaultEditView({
const operation = id ? 'update' : 'create'
void onSaveFromContext({
...json,
...(json as Record<string, unknown>),
context,
operation,
// @ts-expect-error todo: this is not right, should be under `doc`?
updatedAt:
operation === 'update'
? new Date().toISOString()
@@ -397,13 +401,11 @@ export function DefaultEditView({
formState: prevFormState,
globalSlug,
operation,
skipValidation: !submitted,
// Performance optimization: Setting it to false ensure that only fields that have explicit requireRender set in the form state will be rendered (e.g. new array rows).
// We only want to render ALL fields on initial render, not in onChange.
renderAllFields: false,
returnLockStatus: isLockingEnabled,
schemaPath: schemaPathSegments.join('.'),
signal: controller.signal,
skipValidation: !submitted,
updateLastEdited,
})

View File

@@ -0,0 +1,31 @@
import type { CollectionConfig } from 'payload'
export const autosavePostsSlug = 'autosave-posts'
export const AutosavePostsCollection: CollectionConfig = {
slug: autosavePostsSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'computedTitle',
type: 'text',
hooks: {
beforeChange: [({ data }) => data?.title],
},
label: 'Computed Title',
},
],
versions: {
drafts: {
autosave: {
interval: 100,
},
},
},
}

View File

@@ -12,6 +12,14 @@ export const PostsCollection: CollectionConfig = {
name: 'title',
type: 'text',
},
{
name: 'computedTitle',
type: 'text',
hooks: {
beforeChange: [({ data }) => data?.title],
},
label: 'Computed Title',
},
{
name: 'renderTracker',
type: 'text',

View File

@@ -3,13 +3,14 @@ import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { AutosavePostsCollection } from './collections/Autosave/index.js'
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
collections: [PostsCollection],
collections: [PostsCollection, AutosavePostsCollection],
admin: {
importMap: {
baseDir: path.resolve(dirname),

View File

@@ -7,6 +7,7 @@ import { addBlock } from 'helpers/e2e/addBlock.js'
import { assertElementStaysVisible } from 'helpers/e2e/assertElementStaysVisible.js'
import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js'
import { assertRequestBody } from 'helpers/e2e/assertRequestBody.js'
import { waitForAutoSaveToRunAndComplete } from 'helpers/e2e/waitForAutoSaveToRunAndComplete.js'
import * as path from 'path'
import { fileURLToPath } from 'url'
@@ -21,6 +22,7 @@ import {
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { autosavePostsSlug } from './collections/Autosave/index.js'
import { postsSlug } from './collections/Posts/index.js'
const { describe, beforeEach, afterEach } = test
@@ -36,11 +38,13 @@ let serverURL: string
test.describe('Form State', () => {
let page: Page
let postsUrl: AdminUrlUtil
let autosavePostsUrl: AdminUrlUtil
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
postsUrl = new AdminUrlUtil(serverURL, postsSlug)
autosavePostsUrl = new AdminUrlUtil(serverURL, autosavePostsSlug)
context = await browser.newContext()
page = await context.newPage()
@@ -296,6 +300,59 @@ test.describe('Form State', () => {
await cdpSession.detach()
})
test('should render computed values after save', async () => {
await page.goto(postsUrl.create)
const titleField = page.locator('#field-title')
const computedTitleField = page.locator('#field-computedTitle')
await titleField.fill('Test Title')
await expect(computedTitleField).toHaveValue('')
await saveDocAndAssert(page)
await expect(computedTitleField).toHaveValue('Test Title')
})
test('autosave - should render computed values after autosave', async () => {
await page.goto(autosavePostsUrl.create)
const titleField = page.locator('#field-title')
const computedTitleField = page.locator('#field-computedTitle')
await titleField.fill('Test Title')
await waitForAutoSaveToRunAndComplete(page)
await expect(computedTitleField).toHaveValue('Test Title')
})
test('autosave - should not overwrite computed values that are being actively edited', async () => {
await page.goto(autosavePostsUrl.create)
const titleField = page.locator('#field-title')
const computedTitleField = page.locator('#field-computedTitle')
await titleField.fill('Test Title')
await expect(computedTitleField).toHaveValue('Test Title')
// Put cursor at end of text
await computedTitleField.evaluate((el: HTMLInputElement) => {
el.focus()
el.setSelectionRange(el.value.length, el.value.length)
})
await computedTitleField.pressSequentially(' - Edited', { delay: 100 })
await waitForAutoSaveToRunAndComplete(page)
await expect(computedTitleField).toHaveValue('Test Title - Edited')
// but then when editing another field, the computed field should update
await titleField.fill('Test Title 2')
await waitForAutoSaveToRunAndComplete(page)
await expect(computedTitleField).toHaveValue('Test Title 2')
})
describe('Throttled tests', () => {
let cdpSession: CDPSession

View File

@@ -1,4 +1,4 @@
import type { FormState, Payload, User } from 'payload'
import type { FieldState, FormState, Payload, User } from 'payload'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import path from 'path'
@@ -567,12 +567,17 @@ describe('Form State', () => {
})
it('should accept all values from the server regardless of local modifications, e.g. on submit', () => {
const currentState = {
const title: FieldState = {
value: 'Test Post (modified on the client)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
}
const currentState: Record<string, FieldState> = {
title: {
value: 'Test Post (modified on the client)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
...title,
isModified: true,
},
computedTitle: {
value: 'Test Post (computed on the client)',
@@ -582,17 +587,7 @@ describe('Form State', () => {
},
}
const formStateAtTimeOfRequest = {
...currentState,
title: {
value: 'Test Post (modified on the client 2)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const incomingStateFromServer = {
const incomingStateFromServer: Record<string, FieldState> = {
title: {
value: 'Test Post (modified on the server)',
initialValue: 'Test Post',
@@ -610,20 +605,30 @@ describe('Form State', () => {
const newState = mergeServerFormState({
acceptValues: true,
currentState,
formStateAtTimeOfRequest,
incomingState: incomingStateFromServer,
})
expect(newState).toStrictEqual(incomingStateFromServer)
expect(newState).toStrictEqual({
...incomingStateFromServer,
title: {
...incomingStateFromServer.title,
isModified: true,
},
})
})
it('should not accept values from the server if they have been modified locally since the request was made, e.g. on autosave', () => {
const currentState = {
const title: FieldState = {
value: 'Test Post (modified on the client 1)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
}
const currentState: Record<string, FieldState> = {
title: {
value: 'Test Post (modified on the client 1)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
...title,
isModified: true,
},
computedTitle: {
value: 'Test Post',
@@ -633,17 +638,7 @@ describe('Form State', () => {
},
}
const formStateAtTimeOfRequest = {
...currentState,
title: {
value: 'Test Post (modified on the client 2)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const incomingStateFromServer = {
const incomingStateFromServer: Record<string, FieldState> = {
title: {
value: 'Test Post (modified on the server)',
initialValue: 'Test Post',
@@ -661,12 +656,15 @@ describe('Form State', () => {
const newState = mergeServerFormState({
acceptValues: { overrideLocalChanges: false },
currentState,
formStateAtTimeOfRequest,
incomingState: incomingStateFromServer,
})
expect(newState).toStrictEqual({
...currentState,
title: {
...currentState.title,
isModified: true,
},
computedTitle: incomingStateFromServer.computedTitle, // This field was not modified locally, so should be updated from the server
})
})

View File

@@ -68,6 +68,7 @@ export interface Config {
blocks: {};
collections: {
posts: Post;
'autosave-posts': AutosavePost;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
@@ -76,6 +77,7 @@ export interface Config {
collectionsJoins: {};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>;
'autosave-posts': AutosavePostsSelect<false> | AutosavePostsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -120,6 +122,7 @@ export interface UserAuthOperations {
export interface Post {
id: string;
title?: string | null;
computedTitle?: string | null;
renderTracker?: string | null;
/**
* This field should only validate on submit. Try typing "Not allowed" and submitting the form.
@@ -151,6 +154,18 @@ export interface Post {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-posts".
*/
export interface AutosavePost {
id: string;
title?: string | null;
computedTitle?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@@ -166,6 +181,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;
}
/**
@@ -179,6 +201,10 @@ export interface PayloadLockedDocument {
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'autosave-posts';
value: string | AutosavePost;
} | null)
| ({
relationTo: 'users';
value: string | User;
@@ -231,6 +257,7 @@ export interface PayloadMigration {
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
computedTitle?: T;
renderTracker?: T;
validateUsingEvent?: T;
blocks?:
@@ -261,6 +288,17 @@ export interface PostsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-posts_select".
*/
export interface AutosavePostsSelect<T extends boolean = true> {
title?: T;
computedTitle?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
@@ -275,6 +313,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@@ -1,15 +1,19 @@
import type { AddressInfo } from 'net'
import type { CollectionSlug, Payload } from 'payload'
import { randomUUID } from 'crypto'
import fs from 'fs'
import { createServer } from 'http'
import path from 'path'
import { _internal_safeFetchGlobal, getFileByPath } from 'payload'
import { _internal_safeFetchGlobal, createPayloadRequest, getFileByPath } from 'payload'
import { fileURLToPath } from 'url'
import { promisify } from 'util'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type { Enlarge, Media } from './payload-types.js'
// eslint-disable-next-line payload/no-relative-monorepo-imports
import { getExternalFile } from '../../packages/payload/src/uploads/getExternalFile.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { createStreamableFile } from './createStreamableFile.js'
import {
@@ -632,6 +636,50 @@ describe('Collections - Uploads', () => {
fetchSpy.mockRestore()
})
it('getExternalFile should not filter out payload cookies when externalFileHeaderFilter is not defined and the URL is not external', async () => {
const testCookies = ['payload-token=123', 'other-cookie=456', 'payload-something=789'].join(
'; ',
)
const fetchSpy = jest.spyOn(global, 'fetch')
// spin up a temporary server so fetch to the local doesn't fail
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
await new Promise((res) => server.listen(0, undefined, undefined, res))
const port = (server.address() as AddressInfo).port
const baseUrl = `http://localhost:${port}`
const req = await createPayloadRequest({
config: payload.config,
request: new Request(baseUrl, {
headers: new Headers({
cookie: testCookies,
origin: baseUrl,
}),
}),
})
await getExternalFile({
data: { url: '/api/media/image.png' },
req,
uploadConfig: { skipSafeFetch: true },
})
const [[, options]] = fetchSpy.mock.calls
const cookieHeader = options.headers.cookie
expect(cookieHeader).toContain('payload-token=123')
expect(cookieHeader).toContain('payload-something=789')
expect(cookieHeader).toContain('other-cookie=456')
fetchSpy.mockRestore()
await new Promise((res) => server.close(res))
})
it('should keep all cookies when externalFileHeaderFilter is defined', async () => {
const testCookies = ['payload-token=123', 'other-cookie=456', 'payload-something=789'].join(
'; ',

View File

@@ -61,6 +61,14 @@ const AutosavePosts: CollectionConfig = {
beforeChange: [({ data }) => data?.title],
},
},
{
name: 'richText',
type: 'richText',
},
{
name: 'json',
type: 'json',
},
{
name: 'description',
label: 'Description',

View File

@@ -198,6 +198,30 @@ export interface AutosavePost {
id: string;
title: string;
computedTitle?: string | null;
richText?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
json?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
description: string;
array?:
| {
@@ -793,6 +817,8 @@ export interface PostsSelect<T extends boolean = true> {
export interface AutosavePostsSelect<T extends boolean = true> {
title?: T;
computedTitle?: T;
richText?: T;
json?: T;
description?: T;
array?:
| T