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

This commit is contained in:
Jessica Chowdhury
2025-08-13 15:12:20 +01:00
193 changed files with 1778 additions and 1172 deletions

View File

@@ -84,7 +84,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
defaultIDType: string;
};
globals: {
menu: Menu;
@@ -124,7 +124,7 @@ export interface UserAuthOperations {
* via the `definition` "posts".
*/
export interface Post {
id: number;
id: string;
title?: string | null;
content?: {
root: {
@@ -149,7 +149,7 @@ export interface Post {
* via the `definition` "media".
*/
export interface Media {
id: number;
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -193,7 +193,7 @@ export interface Media {
* via the `definition` "users".
*/
export interface User {
id: number;
id: string;
updatedAt: string;
createdAt: string;
email: string;
@@ -217,24 +217,24 @@ export interface User {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
id: string;
document?:
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'media';
value: number | Media;
value: string | Media;
} | null)
| ({
relationTo: 'users';
value: number | User;
value: string | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
value: string | User;
};
updatedAt: string;
createdAt: string;
@@ -244,10 +244,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
id: string;
user: {
relationTo: 'users';
value: number | User;
value: string | User;
};
key?: string | null;
value?:
@@ -267,7 +267,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -393,7 +393,7 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
* via the `definition` "menu".
*/
export interface Menu {
id: number;
id: string;
globalText?: string | null;
updatedAt?: string | null;
createdAt?: string | null;

View File

@@ -565,4 +565,109 @@ describe('Form State', () => {
expect(newState === currentState).toBe(true)
})
it('should accept all values from the server regardless of local modifications, e.g. on submit', () => {
const currentState = {
title: {
value: 'Test Post (modified on the client)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
computedTitle: {
value: 'Test Post (computed on the client)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const formStateAtTimeOfRequest = {
...currentState,
title: {
value: 'Test Post (modified on the client 2)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const incomingStateFromServer = {
title: {
value: 'Test Post (modified on the server)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
computedTitle: {
value: 'Test Post (computed on the server)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const newState = mergeServerFormState({
acceptValues: true,
currentState,
formStateAtTimeOfRequest,
incomingState: incomingStateFromServer,
})
expect(newState).toStrictEqual(incomingStateFromServer)
})
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 = {
title: {
value: 'Test Post (modified on the client 1)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
computedTitle: {
value: 'Test Post',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const formStateAtTimeOfRequest = {
...currentState,
title: {
value: 'Test Post (modified on the client 2)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const incomingStateFromServer = {
title: {
value: 'Test Post (modified on the server)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
computedTitle: {
value: 'Test Post (modified on the server)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const newState = mergeServerFormState({
acceptValues: { overrideLocalChanges: false },
currentState,
formStateAtTimeOfRequest,
incomingState: incomingStateFromServer,
})
expect(newState).toStrictEqual({
...currentState,
computedTitle: incomingStateFromServer.computedTitle, // This field was not modified locally, so should be updated from the server
})
})
})

View File

@@ -14,7 +14,7 @@ import { reInitializeDB } from 'helpers/reInitializeDB.js'
import * as path from 'path'
import { fileURLToPath } from 'url'
import type { Config } from './payload-types.js'
import type { Config, Post } from './payload-types.js'
import {
ensureCompilationIsDone,
@@ -38,7 +38,6 @@ test.describe('Group By', () => {
let serverURL: string
let payload: PayloadTestSDK<Config>
let user: any
let category1Id: number | string
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
@@ -695,42 +694,80 @@ test.describe('Group By', () => {
).toHaveCount(0)
})
test('should show trashed docs in trash view when group-by is active', async () => {
await page.goto(url.list)
test.describe('Trash', () => {
test('should show trashed docs in trash view when group-by is active', async () => {
await page.goto(url.list)
// Enable group-by on Category
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
await expect(page.locator('.table-wrap')).toHaveCount(2) // We expect 2 groups initially
// Enable group-by on Category
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
await expect(page.locator('.table-wrap')).toHaveCount(2) // We expect 2 groups initially
// Trash the first document in the first group
const firstTable = page.locator('.table-wrap').first()
await firstTable.locator('.row-1 .cell-_select input').check()
await firstTable.locator('.list-selection__button[aria-label="Delete"]').click()
// Trash the first document in the first group
const firstTable = page.locator('.table-wrap').first()
await firstTable.locator('.row-1 .cell-_select input').check()
await firstTable.locator('.list-selection__button[aria-label="Delete"]').click()
const firstGroupID = await firstTable
.locator('.group-by-header__heading')
.getAttribute('data-group-id')
const firstGroupID = await firstTable
.locator('.group-by-header__heading')
.getAttribute('data-group-id')
const modalId = `[id^="${firstGroupID}-confirm-delete-many-docs"]`
await expect(page.locator(modalId)).toBeVisible()
const modalId = `[id^="${firstGroupID}-confirm-delete-many-docs"]`
await expect(page.locator(modalId)).toBeVisible()
// Confirm trash (skip permanent delete)
await page.locator(`${modalId} #confirm-action`).click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'1 Post moved to trash.',
)
// Confirm trash (skip permanent delete)
await page.locator(`${modalId} #confirm-action`).click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'1 Post moved to trash.',
)
// Go to the trash view
await page.locator('#trash-view-pill').click()
await expect(page).toHaveURL(/\/posts\/trash(\?|$)/)
// Go to the trash view
await page.locator('#trash-view-pill').click()
await expect(page).toHaveURL(/\/posts\/trash(\?|$)/)
// Re-enable group-by on Category in trash view
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
await expect(page.locator('.table-wrap')).toHaveCount(1) // Should only have Category 1 (or the trashed doc's category)
// Re-enable group-by on Category in trash view
await addGroupBy(page, { fieldLabel: 'Category', fieldPath: 'category' })
await expect(page.locator('.table-wrap')).toHaveCount(1) // Should only have Category 1 (or the trashed doc's category)
// Ensure the trashed doc is visible
await expect(
page.locator('.table-wrap tbody tr td.cell-title', { hasText: 'Find me' }),
).toBeVisible()
// Ensure the trashed doc is visible
await expect(
page.locator('.table-wrap tbody tr td.cell-title', { hasText: 'Find me' }),
).toBeVisible()
})
test('should properly clear group-by in trash view', async () => {
await createTrashedPostDoc({ title: 'Trashed Post 1' })
await page.goto(url.trash)
// Enable group-by on Title
await addGroupBy(page, { fieldLabel: 'Title', fieldPath: 'title' })
await expect(page.locator('.table-wrap')).toHaveCount(1)
await expect(page.locator('.group-by-header')).toHaveText('Trashed Post 1')
await page.locator('#group-by--reset').click()
await expect(page.locator('.group-by-header')).toBeHidden()
})
test('should properly navigate to trashed doc edit view from group-by in trash view', async () => {
await createTrashedPostDoc({ title: 'Trashed Post 1' })
await page.goto(url.trash)
// Enable group-by on Title
await addGroupBy(page, { fieldLabel: 'Title', fieldPath: 'title' })
await expect(page.locator('.table-wrap')).toHaveCount(1)
await expect(page.locator('.group-by-header')).toHaveText('Trashed Post 1')
await page.locator('.table-wrap tbody tr td.cell-title a').click()
await expect(page).toHaveURL(/\/posts\/trash\/\d+/)
})
})
async function createTrashedPostDoc(data: Partial<Post>): Promise<Post> {
return payload.create({
collection: postsSlug,
data: {
...data,
deletedAt: new Date().toISOString(), // Set the post as trashed
},
}) as unknown as Promise<Post>
}
})

View File

@@ -44,7 +44,15 @@ export default buildConfigWithDefaults({
isGlobal: true,
},
},
tenantSelectorLabel: { en: 'Site', es: 'Site in es' },
i18n: {
translations: {
en: {
'field-assignedTenant-label': 'Currently Assigned Site',
'nav-tenantSelector-label': 'Filter By Site',
'confirm-modal-tenant-switch--heading': 'Confirm Site Change',
},
},
},
}),
],
typescript: {

View File

@@ -19,7 +19,6 @@ import {
// throttleTest,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { clickListMenuItem, openListMenu } from '../helpers/e2e/toggleListMenu.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { assertURLParams } from './helpers/assertURLParams.js'
@@ -190,9 +189,8 @@ describe('Query Presets', () => {
test('should delete a preset, clear selection, and reset changes', async () => {
await page.goto(pagesUrl.list)
await selectPreset({ page, presetTitle: seededData.everyone.title })
await openListMenu({ page })
await clickListMenuItem({ page, menuItemLabel: 'Delete' })
await page.locator('#delete-preset').click()
await page.locator('#confirm-delete-preset #confirm-action').click()
@@ -249,75 +247,29 @@ describe('Query Presets', () => {
test('should only show "edit" and "delete" controls when there is an active preset', async () => {
await page.goto(pagesUrl.list)
await openListMenu({ page })
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Edit'),
}),
).toBeHidden()
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Delete'),
}),
).toBeHidden()
await expect(page.locator('#edit-preset')).toBeHidden()
await expect(page.locator('#delete-preset')).toBeHidden()
await selectPreset({ page, presetTitle: seededData.everyone.title })
await openListMenu({ page })
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Edit'),
}),
).toBeVisible()
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Delete'),
}),
).toBeVisible()
await expect(page.locator('#edit-preset')).toBeVisible()
await expect(page.locator('#delete-preset')).toBeVisible()
})
test('should only show "reset" and "save" controls when there is an active preset and changes have been made', async () => {
await page.goto(pagesUrl.list)
await openListMenu({ page })
await expect(page.locator('#reset-preset')).toBeHidden()
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Reset'),
}),
).toBeHidden()
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Update for everyone'),
}),
).toBeHidden()
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Save'),
}),
).toBeHidden()
await expect(page.locator('#save-preset')).toBeHidden()
await selectPreset({ page, presetTitle: seededData.onlyMe.title })
await toggleColumn(page, { columnLabel: 'ID' })
await openListMenu({ page })
await expect(page.locator('#reset-preset')).toBeVisible()
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Reset'),
}),
).toBeVisible()
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Save'),
page.locator('#save-preset', {
hasText: exactText('Save changes'),
}),
).toBeVisible()
})
@@ -329,12 +281,12 @@ describe('Query Presets', () => {
await toggleColumn(page, { columnLabel: 'ID' })
await openListMenu({ page })
// When not shared, the label is "Save"
await expect(page.locator('#save-preset')).toBeVisible()
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
hasText: exactText('Save'),
page.locator('#save-preset', {
hasText: exactText('Save changes'),
}),
).toBeVisible()
@@ -342,11 +294,9 @@ describe('Query Presets', () => {
await toggleColumn(page, { columnLabel: 'ID' })
await openListMenu({ page })
// When shared, the label is "Update for everyone"
await expect(
page.locator('#list-menu .popup__content .popup-button-list__button', {
page.locator('#save-preset', {
hasText: exactText('Update for everyone'),
}),
).toBeVisible()
@@ -362,27 +312,28 @@ describe('Query Presets', () => {
hasText: exactText('ID'),
})
await openListMenu({ page })
await clickListMenuItem({ page, menuItemLabel: 'Reset' })
await page.locator('#reset-preset').click()
await openListColumns(page, {})
await expect(column).toHaveClass(/pill-selector__pill--selected/)
})
test('should only enter modified state when changes are made to an active preset', async () => {
test.skip('should only enter modified state when changes are made to an active preset', async () => {
await page.goto(pagesUrl.list)
await expect(page.locator('.list-controls__modified')).toBeHidden()
await selectPreset({ page, presetTitle: seededData.everyone.title })
await expect(page.locator('.list-controls__modified')).toBeHidden()
await toggleColumn(page, { columnLabel: 'ID' })
await expect(page.locator('.list-controls__modified')).toBeVisible()
await openListMenu({ page })
await clickListMenuItem({ page, menuItemLabel: 'Update for everyone' })
await page.locator('#save-preset').click()
await expect(page.locator('.list-controls__modified')).toBeHidden()
await toggleColumn(page, { columnLabel: 'ID' })
await expect(page.locator('.list-controls__modified')).toBeVisible()
await openListMenu({ page })
await clickListMenuItem({ page, menuItemLabel: 'Reset' })
await page.locator('#reset-preset').click()
await expect(page.locator('.list-controls__modified')).toBeHidden()
})
@@ -392,8 +343,7 @@ describe('Query Presets', () => {
await page.goto(pagesUrl.list)
await selectPreset({ page, presetTitle: seededData.everyone.title })
await openListMenu({ page })
await clickListMenuItem({ page, menuItemLabel: 'Edit' })
await page.locator('#edit-preset').click()
const drawer = page.locator('[id^=doc-drawer_payload-query-presets_0_]')
const titleValue = drawer.locator('input[name="title"]')
@@ -427,8 +377,7 @@ describe('Query Presets', () => {
const presetTitle = 'New Preset'
await openListMenu({ page })
await clickListMenuItem({ page, menuItemLabel: 'Create New' })
await page.locator('#create-new-preset').click()
const modal = page.locator('[id^=doc-drawer_payload-query-presets_0_]')
await expect(modal).toBeVisible()
await modal.locator('input[name="title"]').fill(presetTitle)

View File

@@ -69,7 +69,7 @@ describe('Queues - scheduling, without automatic scheduling handling', () => {
it('can auto-schedule through local API and autorun jobs', async () => {
// Do not call payload.jobs.queue() - the `EverySecond` task should be scheduled here
await payload.jobs.handleSchedules()
await payload.jobs.handleSchedules({ queue: 'autorunSecond' })
// Do not call payload.jobs.run{silent: true})
@@ -88,9 +88,50 @@ describe('Queues - scheduling, without automatic scheduling handling', () => {
expect(allSimples?.docs?.[0]?.title).toBe('This task runs every second')
})
it('can auto-schedule through local API and autorun jobs when passing allQueues', async () => {
// Do not call payload.jobs.queue() - the `EverySecond` task should be scheduled here
await payload.jobs.handleSchedules({ queue: 'autorunSecond', allQueues: true })
// Do not call payload.jobs.run{silent: true})
await waitUntilAutorunIsDone({
payload,
queue: 'autorunSecond',
onlyScheduled: true,
})
const allSimples = await payload.find({
collection: 'simple',
limit: 100,
})
expect(allSimples.totalDocs).toBe(1)
expect(allSimples?.docs?.[0]?.title).toBe('This task runs every second')
})
it('should not auto-schedule through local API and autorun jobs when not passing queue and schedule is not set on the default queue', async () => {
// Do not call payload.jobs.queue() - the `EverySecond` task should be scheduled here
await payload.jobs.handleSchedules()
// Do not call payload.jobs.run{silent: true})
await waitUntilAutorunIsDone({
payload,
queue: 'autorunSecond',
onlyScheduled: true,
})
const allSimples = await payload.find({
collection: 'simple',
limit: 100,
})
expect(allSimples.totalDocs).toBe(0)
})
it('can auto-schedule through handleSchedules REST API and autorun jobs', async () => {
// Do not call payload.jobs.queue() - the `EverySecond` task should be scheduled here
await restClient.GET('/payload-jobs/handle-schedules', {
await restClient.GET('/payload-jobs/handle-schedules?queue=autorunSecond', {
headers: {
Authorization: `JWT ${token}`,
},
@@ -115,7 +156,7 @@ describe('Queues - scheduling, without automatic scheduling handling', () => {
it('can auto-schedule through run REST API and autorun jobs', async () => {
// Do not call payload.jobs.queue() - the `EverySecond` task should be scheduled here
await restClient.GET('/payload-jobs/run?silent=true', {
await restClient.GET('/payload-jobs/run?silent=true&allQueues=true', {
headers: {
Authorization: `JWT ${token}`,
},
@@ -161,7 +202,7 @@ describe('Queues - scheduling, without automatic scheduling handling', () => {
it('ensure scheduler does not schedule more jobs than needed if executed sequentially', async () => {
await withoutAutoRun(async () => {
for (let i = 0; i < 3; i++) {
await payload.jobs.handleSchedules()
await payload.jobs.handleSchedules({ allQueues: true })
}
})
@@ -192,7 +233,7 @@ describe('Queues - scheduling, without automatic scheduling handling', () => {
})
}
for (let i = 0; i < 3; i++) {
await payload.jobs.handleSchedules()
await payload.jobs.handleSchedules({ allQueues: true })
}
})
@@ -271,8 +312,8 @@ describe('Queues - scheduling, without automatic scheduling handling', () => {
for (let i = 0; i < 3; i++) {
await withoutAutoRun(async () => {
// Call it twice to test that it only schedules one
await payload.jobs.handleSchedules()
await payload.jobs.handleSchedules()
await payload.jobs.handleSchedules({ allQueues: true })
await payload.jobs.handleSchedules({ allQueues: true })
})
// Advance time to satisfy the waitUntil of newly scheduled jobs
timeTravel(20)

View File

@@ -16,7 +16,7 @@ const AutosavePosts: CollectionConfig = {
maxPerDoc: 35,
drafts: {
autosave: {
interval: 2000,
interval: 100,
},
schedulePublish: true,
},
@@ -53,12 +53,30 @@ const AutosavePosts: CollectionConfig = {
unique: true,
localized: true,
},
{
name: 'computedTitle',
label: 'Computed Title',
type: 'text',
hooks: {
beforeChange: [({ data }) => data?.title],
},
},
{
name: 'description',
label: 'Description',
type: 'textarea',
required: true,
},
{
name: 'array',
type: 'array',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
}

View File

@@ -1285,6 +1285,63 @@ describe('Versions', () => {
// Remove listener
page.removeListener('dialog', acceptAlert)
})
test('- with autosave - applies field hooks to form state after autosave runs', async () => {
const url = new AdminUrlUtil(serverURL, autosaveCollectionSlug)
await page.goto(url.create)
const titleField = page.locator('#field-title')
await titleField.fill('Initial')
await waitForAutoSaveToRunAndComplete(page)
const computedTitleField = page.locator('#field-computedTitle')
await expect(computedTitleField).toHaveValue('Initial')
})
test('- with autosave - does not override local changes to form state after autosave runs', async () => {
const url = new AdminUrlUtil(serverURL, autosaveCollectionSlug)
await page.goto(url.create)
const titleField = page.locator('#field-title')
// press slower than the autosave interval, but not faster than the response and processing
await titleField.pressSequentially('Initial', {
delay: 150,
})
await waitForAutoSaveToRunAndComplete(page)
await expect(titleField).toHaveValue('Initial')
const computedTitleField = page.locator('#field-computedTitle')
await expect(computedTitleField).toHaveValue('Initial')
})
test('- with autosave - does not display success toast after autosave complete', async () => {
const url = new AdminUrlUtil(serverURL, autosaveCollectionSlug)
await page.goto(url.create)
const titleField = page.locator('#field-title')
await titleField.fill('Initial')
let hasDisplayedToast = false
const startTime = Date.now()
const timeout = 5000
const interval = 100
while (Date.now() - startTime < timeout) {
const isHidden = await page.locator('.payload-toast-item').isHidden()
console.log(`Toast is hidden: ${isHidden}`)
// eslint-disable-next-line playwright/no-conditional-in-test
if (!isHidden) {
hasDisplayedToast = true
break
}
await wait(interval)
}
expect(hasDisplayedToast).toBe(false)
})
})
describe('Globals - publish individual locale', () => {

View File

@@ -197,7 +197,14 @@ export interface Post {
export interface AutosavePost {
id: string;
title: string;
computedTitle?: string | null;
description: string;
array?:
| {
text?: string | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@@ -366,7 +373,6 @@ export interface Diff {
textInNamedTab1InBlock?: string | null;
};
textInUnnamedTab2InBlock?: string | null;
textInUnnamedTab2InBlockAccessFalse?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'TabsBlock';
@@ -469,7 +475,6 @@ export interface Diff {
};
textInUnnamedTab2?: string | null;
text?: string | null;
textCannotRead?: string | null;
textArea?: string | null;
upload?: (string | null) | Media;
uploadHasMany?: (string | Media)[] | null;
@@ -787,7 +792,14 @@ export interface PostsSelect<T extends boolean = true> {
*/
export interface AutosavePostsSelect<T extends boolean = true> {
title?: T;
computedTitle?: T;
description?: T;
array?:
| T
| {
text?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
_status?: T;
@@ -960,7 +972,6 @@ export interface DiffSelect<T extends boolean = true> {
textInNamedTab1InBlock?: T;
};
textInUnnamedTab2InBlock?: T;
textInUnnamedTab2InBlockAccessFalse?: T;
id?: T;
blockName?: T;
};
@@ -995,7 +1006,6 @@ export interface DiffSelect<T extends boolean = true> {
};
textInUnnamedTab2?: T;
text?: T;
textCannotRead?: T;
textArea?: T;
upload?: T;
uploadHasMany?: T;