Merge branch 'main' into fix/localized-status-UI
This commit is contained in:
@@ -33,7 +33,7 @@ export const Users: CollectionConfig = {
|
||||
}
|
||||
```
|
||||
|
||||

|
||||

|
||||
_Admin Panel screenshot depicting an Admins Collection with Auth enabled_
|
||||
|
||||
## Config Options
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||

|
||||

|
||||
_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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
_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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
_Comparing an old version to a newer version of a document_
|
||||
|
||||
**With Versions, you can:**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
DocumentPreferences,
|
||||
Field as FieldSchema,
|
||||
FieldSchemaMap,
|
||||
FieldState,
|
||||
FormState,
|
||||
FormStateWithoutComponents,
|
||||
PayloadRequest,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
31
test/form-state/collections/Autosave/index.tsx
Normal file
31
test/form-state/collections/Autosave/index.tsx
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
'; ',
|
||||
|
||||
@@ -61,6 +61,14 @@ const AutosavePosts: CollectionConfig = {
|
||||
beforeChange: [({ data }) => data?.title],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
type: 'json',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Description',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user