From 55d00e2b1ddbd2d08959b142ebb046b5c47329fd Mon Sep 17 00:00:00 2001 From: Sam Wheeler <104921112+swheeler7@users.noreply.github.com> Date: Mon, 14 Apr 2025 11:37:09 -0700 Subject: [PATCH 01/21] feat(ui): add option for rendering the relationship field as list drawer (#11553) ### What? This PR adds the ability to use the ListDrawer component for selecting related collections for the relationship field instead of the default drop down interface. This exposes the advanced filtering options that the list view provides and provides a good interface for searching for the correct relationship when the workflows may be more complicated. I've added an additional "selectionType" prop to the relationship field admin config that defaults to "dropdown" for compatability with the existing implementation but "drawer" can be passed in as well which enables using the ListDrawer for selecting related components. ### Why? Adding the ability to search through the list view enables advanced workflows or handles edge cases when just using the useAsTitle may not be informative enough to find the related record that the user wants. For example, if we have a collection of oscars nominations and are trying to relate the nomination to the person who recieved the nomination there may be multiple actors with the same name (Michelle Williams, for example: [https://www.imdb.com/name/nm0931329/](https://www.imdb.com/name/nm0931329/), [https://www.imdb.com/name/nm0931332/](https://www.imdb.com/name/nm0931332/)). It would be hard to search through the current dropdown ui to choose the correct person, but in the list view the user could use other fields to identify the correct person such as an imdb id, description, or anything else they have in the collection for that person. Other advanced workflows could be if there are multiple versions of a record in a collection and the user wants to select the most recent one or just anything where the user needs to see more details about the record that they are setting up the relationship to. ### How? This implementation just re-uses the useListDrawer hook and the ListDrawer component so the code changes are pretty minimal. The main change is a new onListSelect handler that gets passed into the ListDrawer and handles updating the value in the field when a record is selected in the ListDrawer. There were also a two things that I didn't implement as they would require broader code changes 1) Bulk select from the ListDrawer when a relationship is hasMany - when using bulkSelect in the list drawer the relatedCollection doesn't get returned so this doesn't work for polymorphic relationships. Updating this would involve changing the useListDrawer hook 2) Hide values that are already selected from the ListDrawer - potentially possible by modifying the filterOptions and passing in an additional filter but honestly it may not be desired behaviour to hide values from the ListDrawer as this could be confusing for the user if they don't see records that they are expected to see (maybe if anything make them unselectable and indicate that they are disabled). Currently if an already selected value gets selected the selected value gets replaced by the new value https://github.com/user-attachments/assets/fee164da-4270-4612-9304-73ccf34ccf69 --------- Co-authored-by: Jacob Fletcher --- docs/fields/relationship.mdx | 1 + packages/payload/src/fields/config/types.ts | 3 +- packages/ui/src/fields/Relationship/index.tsx | 110 +++++++++--- .../collections/Relationship/e2e.spec.ts | 157 ++++++++++++++++++ test/fields/collections/Relationship/index.ts | 65 ++++++++ test/fields/payload-types.ts | 30 ++++ tsconfig.base.json | 2 +- 7 files changed, 347 insertions(+), 21 deletions(-) diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 201a7993f7..e6c4faf81d 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -94,6 +94,7 @@ The Relationship Field inherits all of the default options from the base [Field | **`allowCreate`** | Set to `false` if you'd like to disable the ability to create new documents from within the relationship field. | | **`allowEdit`** | Set to `false` if you'd like to disable the ability to edit documents from within the relationship field. | | **`sortOptions`** | Define a default sorting order for the options within a Relationship field's dropdown. [More](#sort-options) | +| **`appearance`** | Set to `drawer` or `select` to change the behavior of the field. Defaults to `select`. | ### Sort Options diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 442980b6bf..db91662f59 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1143,6 +1143,7 @@ type SharedRelationshipPropertiesClient = FieldBaseClient & type RelationshipAdmin = { allowCreate?: boolean allowEdit?: boolean + appearance?: 'drawer' | 'select' components?: { afterInput?: CustomComponent[] beforeInput?: CustomComponent[] @@ -1157,7 +1158,7 @@ type RelationshipAdmin = { } & Admin type RelationshipAdminClient = AdminClient & - Pick + Pick export type PolymorphicRelationshipField = { admin?: { diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index f704829812..85a897f2dd 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -1,5 +1,10 @@ 'use client' -import type { PaginatedDocs, RelationshipFieldClientComponent, Where } from 'payload' +import type { + FilterOptionsResult, + PaginatedDocs, + RelationshipFieldClientComponent, + Where, +} from 'payload' import { dequal } from 'dequal/lite' import { wordBoundariesRegex } from 'payload/shared' @@ -7,11 +12,13 @@ import * as qs from 'qs-esm' import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import type { DocumentDrawerProps } from '../../elements/DocumentDrawer/types.js' +import type { ListDrawerProps } from '../../elements/ListDrawer/types.js' import type { ReactSelectAdapterProps } from '../../elements/ReactSelect/types.js' import type { GetResults, Option, Value } from './types.js' import { AddNewRelation } from '../../elements/AddNewRelation/index.js' import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js' +import { useListDrawer } from '../../elements/ListDrawer/index.js' import { ReactSelect } from '../../elements/ReactSelect/index.js' import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js' import { FieldDescription } from '../../fields/FieldDescription/index.js' @@ -45,6 +52,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => admin: { allowCreate = true, allowEdit = true, + appearance = 'select', className, description, isSortable = true, @@ -111,6 +119,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => path, validate: memoizedValidate, }) + const [options, dispatchOptions] = useReducer(optionsReducer, []) const valueRef = useRef(value) @@ -121,6 +130,60 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => collectionSlug: currentlyOpenRelationship.collectionSlug, }) + // Filter selected values from displaying in the list drawer + const listDrawerFilterOptions = useMemo(() => { + let newFilterOptions = filterOptions + + if (value) { + ;(Array.isArray(value) ? value : [value]).forEach((val) => { + ;(Array.isArray(relationTo) ? relationTo : [relationTo]).forEach((relationTo) => { + newFilterOptions = { + ...(filterOptions || {}), + [relationTo]: { + ...(typeof filterOptions?.[relationTo] === 'object' ? filterOptions[relationTo] : {}), + id: { + not_in: [typeof val === 'object' ? val.value : val], + }, + }, + } + }) + }) + } + + return newFilterOptions + }, [filterOptions, value, relationTo]) + + const [ + ListDrawer, + , + { closeDrawer: closeListDrawer, isDrawerOpen: isListDrawerOpen, openDrawer: openListDrawer }, + ] = useListDrawer({ + collectionSlugs: hasMultipleRelations ? relationTo : [relationTo], + filterOptions: listDrawerFilterOptions, + }) + + const onListSelect = useCallback>( + ({ collectionSlug, doc }) => { + const formattedSelection = hasMultipleRelations + ? { + relationTo: collectionSlug, + value: doc.id, + } + : doc.id + + if (hasMany) { + const withSelection = Array.isArray(value) ? value : [] + withSelection.push(formattedSelection) + setValue(withSelection) + } else { + setValue(formattedSelection) + } + + closeListDrawer() + }, + [hasMany, hasMultipleRelations, setValue, closeListDrawer, value], + ) + const openDrawerWhenRelationChanges = useRef(false) const getResults: GetResults = useCallback( @@ -601,18 +664,19 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => {!errorLoading && (
{ if (!option) { @@ -622,9 +686,11 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => ? `${option.relationTo}_${option.value}` : (option.value as string) }} - isLoading={isLoading} + isLoading={appearance === 'select' && isLoading} isMulti={hasMany} + isSearchable={appearance === 'select'} isSortable={isSortable} + menuIsOpen={appearance === 'select' ? menuIsOpen : false} onChange={ !(readOnly || disabled) ? (selected) => { @@ -661,19 +727,22 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => setMenuIsOpen(false) }} onMenuOpen={() => { - setMenuIsOpen(true) - - if (!hasLoadedFirstPageRef.current) { - setIsLoading(true) - void getResults({ - filterOptions, - lastLoadedPage: {}, - onSuccess: () => { - hasLoadedFirstPageRef.current = true - setIsLoading(false) - }, - value: initialValue, - }) + if (appearance === 'drawer') { + openListDrawer() + } else if (appearance === 'select') { + setMenuIsOpen(true) + if (!hasLoadedFirstPageRef.current) { + setIsLoading(true) + void getResults({ + filterOptions, + lastLoadedPage: {}, + onSuccess: () => { + hasLoadedFirstPageRef.current = true + setIsLoading(false) + }, + value: initialValue, + }) + } } }} onMenuScrollToBottom={() => { @@ -711,6 +780,9 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => {currentlyOpenRelationship.collectionSlug && currentlyOpenRelationship.hasReadPermission && ( )} + {appearance === 'drawer' && !readOnly && ( + + )}
) } diff --git a/test/fields/collections/Relationship/e2e.spec.ts b/test/fields/collections/Relationship/e2e.spec.ts index eed7ef78ac..5fd1c5a110 100644 --- a/test/fields/collections/Relationship/e2e.spec.ts +++ b/test/fields/collections/Relationship/e2e.spec.ts @@ -650,6 +650,163 @@ describe('relationship', () => { await expect(page.locator(tableRowLocator)).toHaveCount(1) }) + + test('should be able to select relationship with drawer appearance', async () => { + await page.goto(url.create) + + const relationshipField = page.locator('#field-relationshipDrawer') + await relationshipField.click() + const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content') + await expect(listDrawerContent).toBeVisible() + + const firstRow = listDrawerContent.locator('table tbody tr').first() + const button = firstRow.locator('button') + await button.click() + await expect(listDrawerContent).toBeHidden() + + const selectedValue = relationshipField.locator('.relationship--single-value__text') + await expect(selectedValue).toBeVisible() + + // Fill required field + await page.locator('#field-relationship').click() + await page.locator('.rs__option:has-text("Seeded text document")').click() + + await saveDocAndAssert(page) + }) + + test('should be able to search within relationship list drawer', async () => { + await page.goto(url.create) + + const relationshipField = page.locator('#field-relationshipDrawer') + await relationshipField.click() + const searchField = page.locator('.list-drawer .search-filter') + await expect(searchField).toBeVisible() + + const searchInput = searchField.locator('input') + await searchInput.fill('seeded') + const rows = page.locator('.list-drawer table tbody tr') + + await expect(rows).toHaveCount(1) + const closeButton = page.locator('.list-drawer__header-close') + await closeButton.click() + + await expect(page.locator('.list-drawer')).toBeHidden() + }) + + test('should handle read-only relationship field when `appearance: "drawer"`', async () => { + await page.goto(url.create) + const readOnlyField = page.locator( + '#field-relationshipDrawerReadOnly .rs__control--is-disabled', + ) + await expect(readOnlyField).toBeVisible() + }) + + test('should handle polymorphic relationship when `appearance: "drawer"`', async () => { + await page.goto(url.create) + const relationshipField = page.locator('#field-polymorphicRelationshipDrawer') + await relationshipField.click() + const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content') + await expect(listDrawerContent).toBeVisible() + + const relationToSelector = page.locator('.list-header__select-collection') + await expect(relationToSelector).toBeVisible() + + await relationToSelector.locator('.rs__control').click() + const option = relationToSelector.locator('.rs__option').nth(1) + await option.click() + const firstRow = listDrawerContent.locator('table tbody tr').first() + const button = firstRow.locator('button') + await button.click() + await expect(listDrawerContent).toBeHidden() + + const selectedValue = relationshipField.locator('.relationship--single-value__text') + await expect(selectedValue).toBeVisible() + + // Fill required field + await page.locator('#field-relationship').click() + await page.locator('.rs__option:has-text("Seeded text document")').click() + + await saveDocAndAssert(page) + }) + + test('should handle `hasMany` relationship when `appearance: "drawer"`', async () => { + await page.goto(url.create) + const relationshipField = page.locator('#field-relationshipDrawerHasMany') + await relationshipField.click() + const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content') + await expect(listDrawerContent).toBeVisible() + + const firstRow = listDrawerContent.locator('table tbody tr').first() + const button = firstRow.locator('button') + await button.click() + await expect(listDrawerContent).toBeHidden() + + const selectedValue = relationshipField.locator('.relationship--multi-value-label__text') + await expect(selectedValue).toBeVisible() + }) + + test('should handle `hasMany` polymorphic relationship when `appearance: "drawer"`', async () => { + await page.goto(url.create) + const relationshipField = page.locator('#field-relationshipDrawerHasManyPolymorphic') + await relationshipField.click() + const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content') + await expect(listDrawerContent).toBeVisible() + + const firstRow = listDrawerContent.locator('table tbody tr').first() + const button = firstRow.locator('button') + await button.click() + await expect(listDrawerContent).toBeHidden() + + const selectedValue = relationshipField.locator('.relationship--multi-value-label__text') + await expect(selectedValue).toBeVisible() + }) + + test('should not be allowed to create in relationship list drawer when `allowCreate` is `false`', async () => { + await page.goto(url.create) + const relationshipField = page.locator('#field-relationshipDrawerWithAllowCreateFalse') + await relationshipField.click() + const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content') + await expect(listDrawerContent).toBeVisible() + + const createNewButton = listDrawerContent.locator('list-drawer__create-new-button') + await expect(createNewButton).toBeHidden() + }) + + test('should respect `filterOptions` in the relationship list drawer for filtered relationship', async () => { + // Create test documents + await createTextFieldDoc({ text: 'list drawer test' }) + await createTextFieldDoc({ text: 'not test' }) + await page.goto(url.create) + + const relationshipField = page.locator('#field-relationshipDrawerWithFilterOptions') + await relationshipField.click() + const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content') + await expect(listDrawerContent).toBeVisible() + + const rows = page.locator('.list-drawer table tbody tr') + await expect(rows).toHaveCount(1) + }) + + test('should filter out existing values from relationship list drawer', async () => { + await page.goto(url.create) + + await page.locator('#field-relationshipDrawer').click() + const listDrawerContent = page.locator('.list-drawer').locator('.drawer__content') + await expect(listDrawerContent).toBeVisible() + const rows = listDrawerContent.locator('table tbody tr') + await expect(rows).toHaveCount(2) + await listDrawerContent.getByText('Seeded text document', { exact: true }).click() + + const selectedValue = page.locator( + '#field-relationshipDrawer .relationship--single-value__text', + ) + + await expect(selectedValue).toHaveText('Seeded text document') + await page.locator('#field-relationshipDrawer').click() + const newRows = listDrawerContent.locator('table tbody tr') + await expect(newRows).toHaveCount(1) + await expect(listDrawerContent.getByText('Seeded text document')).toHaveCount(0) + }) }) async function createTextFieldDoc(overrides?: Partial): Promise { diff --git a/test/fields/collections/Relationship/index.ts b/test/fields/collections/Relationship/index.ts index ee2ee915fa..1a8f6ff924 100644 --- a/test/fields/collections/Relationship/index.ts +++ b/test/fields/collections/Relationship/index.ts @@ -126,6 +126,71 @@ const RelationshipFields: CollectionConfig = { type: 'relationship', hasMany: true, }, + { + name: 'relationshipDrawer', + relationTo: 'text-fields', + admin: { appearance: 'drawer' }, + type: 'relationship', + }, + { + name: 'relationshipDrawerReadOnly', + relationTo: 'text-fields', + admin: { + readOnly: true, + appearance: 'drawer', + }, + type: 'relationship', + }, + { + name: 'polymorphicRelationshipDrawer', + admin: { appearance: 'drawer' }, + type: 'relationship', + relationTo: ['text-fields', 'array-fields'], + }, + { + name: 'relationshipDrawerHasMany', + relationTo: 'text-fields', + admin: { + appearance: 'drawer', + }, + hasMany: true, + type: 'relationship', + }, + { + name: 'relationshipDrawerHasManyPolymorphic', + relationTo: ['text-fields'], + admin: { + appearance: 'drawer', + }, + hasMany: true, + type: 'relationship', + }, + { + name: 'relationshipDrawerWithAllowCreateFalse', + admin: { + allowCreate: false, + appearance: 'drawer', + }, + type: 'relationship', + relationTo: 'text-fields', + }, + { + name: 'relationshipDrawerWithFilterOptions', + admin: { appearance: 'drawer' }, + type: 'relationship', + relationTo: ['text-fields'], + filterOptions: ({ relationTo }) => { + if (relationTo === 'text-fields') { + return { + text: { + equals: 'list drawer test', + }, + } + } else { + return true + } + }, + }, ], slug: relationshipFieldsSlug, } diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 89fa819e9b..4c5ae89238 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -1312,6 +1312,29 @@ export interface RelationshipField { | null; relationToRow?: (string | null) | RowField; relationToRowMany?: (string | RowField)[] | null; + relationshipDrawer?: (string | null) | TextField; + relationshipDrawerReadOnly?: (string | null) | TextField; + polymorphicRelationshipDrawer?: + | ({ + relationTo: 'text-fields'; + value: string | TextField; + } | null) + | ({ + relationTo: 'array-fields'; + value: string | ArrayField; + } | null); + relationshipDrawerHasMany?: (string | TextField)[] | null; + relationshipDrawerHasManyPolymorphic?: + | { + relationTo: 'text-fields'; + value: string | TextField; + }[] + | null; + relationshipDrawerWithAllowCreateFalse?: (string | null) | TextField; + relationshipDrawerWithFilterOptions?: { + relationTo: 'text-fields'; + value: string | TextField; + } | null; updatedAt: string; createdAt: string; } @@ -2786,6 +2809,13 @@ export interface RelationshipFieldsSelect { relationshipWithMinRows?: T; relationToRow?: T; relationToRowMany?: T; + relationshipDrawer?: T; + relationshipDrawerReadOnly?: T; + polymorphicRelationshipDrawer?: T; + relationshipDrawerHasMany?: T; + relationshipDrawerHasManyPolymorphic?: T; + relationshipDrawerWithAllowCreateFalse?: T; + relationshipDrawerWithFilterOptions?: T; updatedAt?: T; createdAt?: T; } diff --git a/tsconfig.base.json b/tsconfig.base.json index 940c8a587f..c9793d25c6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/hooks/config.ts"], + "@payload-config": ["./test/_community/config.ts"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], From da7be35a150c6e30d3d47bf2f82782a7fba94ad6 Mon Sep 17 00:00:00 2001 From: Adler Weber Date: Mon, 14 Apr 2025 15:27:53 -0400 Subject: [PATCH 02/21] feat(db-postgres): dependency inject pg to allow Sentry instrumentation (#11478) ### What? I changed the interface of `@payloadcms/db-postgres` to allow a user to (optionally) inject their own `pg` module. ### Why? I noticed that `@payloadcms/sentry-plugin` wasn't instrumenting Payload's database queries through the [local payload API](https://payloadcms.com/docs/local-api/overview): ![image](https://github.com/user-attachments/assets/425691f5-cf7e-4625-89e0-6d07dda9cbc0) This is because Sentry applies a patch to the `pg` driver on import. For whatever reason, it doesn't patch `pg` when imported by dependencies (e.g. `@payloadcms/db-postgres`). After applying this fix, I can see the underlying query traces! ![image](https://github.com/user-attachments/assets/fb6f9aef-13d9-41b1-b4cc-36c565d15930) --- packages/db-postgres/src/connect.ts | 3 +-- packages/db-postgres/src/index.ts | 2 ++ packages/db-postgres/src/types.ts | 6 ++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/db-postgres/src/connect.ts b/packages/db-postgres/src/connect.ts index 4a323558a8..f7aabfc0e1 100644 --- a/packages/db-postgres/src/connect.ts +++ b/packages/db-postgres/src/connect.ts @@ -3,7 +3,6 @@ import type { Connect, Migration, Payload } from 'payload' import { pushDevSchema } from '@payloadcms/drizzle' import { drizzle } from 'drizzle-orm/node-postgres' -import pg from 'pg' import type { PostgresAdapter } from './types.js' @@ -61,7 +60,7 @@ export const connect: Connect = async function connect( try { if (!this.pool) { - this.pool = new pg.Pool(this.poolOptions) + this.pool = new this.pg.Pool(this.poolOptions) await connectWithReconnect({ adapter: this, payload: this.payload }) } diff --git a/packages/db-postgres/src/index.ts b/packages/db-postgres/src/index.ts index 41258c590b..8d9597c510 100644 --- a/packages/db-postgres/src/index.ts +++ b/packages/db-postgres/src/index.ts @@ -54,6 +54,7 @@ import { } from '@payloadcms/drizzle/postgres' import { pgEnum, pgSchema, pgTable } from 'drizzle-orm/pg-core' import { createDatabaseAdapter, defaultBeginTransaction } from 'payload' +import pgDependency from 'pg' import { fileURLToPath } from 'url' import type { Args, PostgresAdapter } from './types.js' @@ -130,6 +131,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj localesSuffix: args.localesSuffix || '_locales', logger: args.logger, operators: operatorMap, + pg: args.pg || pgDependency, pgSchema: adapterSchema, // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve pool: undefined, diff --git a/packages/db-postgres/src/types.ts b/packages/db-postgres/src/types.ts index 2eb8082a63..8d19181cf2 100644 --- a/packages/db-postgres/src/types.ts +++ b/packages/db-postgres/src/types.ts @@ -12,6 +12,8 @@ import type { NodePgDatabase } from 'drizzle-orm/node-postgres' import type { PgSchema, PgTableFn, PgTransactionConfig } from 'drizzle-orm/pg-core' import type { Pool, PoolConfig } from 'pg' +type PgDependency = typeof import('pg') + export type Args = { /** * Transform the schema after it's built. @@ -45,6 +47,7 @@ export type Args = { localesSuffix?: string logger?: DrizzleConfig['logger'] migrationDir?: string + pg?: PgDependency pool: PoolConfig prodMigrations?: { down: (args: MigrateDownArgs) => Promise @@ -74,6 +77,7 @@ type ResolveSchemaType = 'schema' extends keyof T type Drizzle = NodePgDatabase> export type PostgresAdapter = { drizzle: Drizzle + pg: PgDependency pool: Pool poolOptions: PoolConfig } & BasePostgresAdapter @@ -98,6 +102,8 @@ declare module 'payload' { initializing: Promise localesSuffix?: string logger: DrizzleConfig['logger'] + /** Optionally inject your own node-postgres. This is required if you wish to instrument the driver with @payloadcms/plugin-sentry. */ + pg?: PgDependency pgSchema?: { table: PgTableFn } | PgSchema pool: Pool poolOptions: Args['pool'] From 6572bf4ae18dd877523f660e888edc3a24c00cd5 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 14 Apr 2025 17:05:08 -0400 Subject: [PATCH 03/21] fix(db-sqlite): text field converts to floating point number (#12107) ### What? Converts numbers passed to a text field to avoid the database/drizzle from converting it incorrectly. ### Why? If you have a hook that passes a value to another field you can experience this problem where drizzle converts a number value for a text field to a floating point number in sqlite for example. ### How? Adds logic to `transform/write/traverseFields.ts` to cast text field values to string. --- .../drizzle/src/transform/write/traverseFields.ts | 4 ++++ test/database/config.ts | 4 ++++ test/database/int.spec.ts | 13 +++++++++++++ 3 files changed, 21 insertions(+) diff --git a/packages/drizzle/src/transform/write/traverseFields.ts b/packages/drizzle/src/transform/write/traverseFields.ts index 7fa8430cc6..57bfc1f65e 100644 --- a/packages/drizzle/src/transform/write/traverseFields.ts +++ b/packages/drizzle/src/transform/write/traverseFields.ts @@ -496,6 +496,10 @@ export const traverseFields = ({ formattedValue = sql`ST_GeomFromGeoJSON(${JSON.stringify(value)})` } + if (field.type === 'text' && value && typeof value !== 'string') { + formattedValue = JSON.stringify(value) + } + if (field.type === 'date') { if (typeof value === 'number' && !Number.isNaN(value)) { formattedValue = new Date(value).toISOString() diff --git a/test/database/config.ts b/test/database/config.ts index b8331c6958..f9f8be424d 100644 --- a/test/database/config.ts +++ b/test/database/config.ts @@ -44,6 +44,10 @@ export default buildConfigWithDefaults({ type: 'text', required: true, }, + { + name: 'text', + type: 'text', + }, { name: 'number', type: 'number', diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index f4e6263e6f..2cb385f815 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -1979,6 +1979,19 @@ describe('database', () => { }) }) + it('should convert numbers to text', async () => { + const result = await payload.create({ + collection: postsSlug, + data: { + title: 'testing', + // @ts-expect-error hardcoding a number and expecting that it will convert to string + text: 1, + }, + }) + + expect(result.text).toStrictEqual('1') + }) + it('should not allow to query by a field with `virtual: true`', async () => { await expect( payload.find({ From babf4f965dc0ecff88ebeae9bd440d4d6b6c2b8d Mon Sep 17 00:00:00 2001 From: Tobias Odendahl Date: Tue, 15 Apr 2025 14:02:41 +0200 Subject: [PATCH 04/21] fix(richtext-lexical): allow to indent children even if their parents are not indentable (#12042) ### What? Allows to indent children in richtext-lexical if the parent of that child is not indentable. Changes the behavior introduced in https://github.com/payloadcms/payload/pull/11739 ### Why? If there is a document structure with e.g. `tableNode > list > listItem` and indentation of `tableNode` is disabled, it should still be possible to indent the list items. ### How? Disable the indent button only if indentation of one of the selected nodes itself is disabled. --- .../richtext-lexical/src/features/indent/client/index.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/richtext-lexical/src/features/indent/client/index.tsx b/packages/richtext-lexical/src/features/indent/client/index.tsx index e3e625ec4e..cf56a2c0c6 100644 --- a/packages/richtext-lexical/src/features/indent/client/index.tsx +++ b/packages/richtext-lexical/src/features/indent/client/index.tsx @@ -68,12 +68,7 @@ const toolbarGroups = ({ disabledNodes }: IndentFeatureProps): ToolbarGroup[] => if (!nodes?.length) { return false } - if (nodes.some((node) => disabledNodes?.includes(node.getType()))) { - return false - } - return !$pointsAncestorMatch(selection, (node) => - (disabledNodes ?? []).includes(node.getType()), - ) + return !nodes.some((node) => disabledNodes?.includes(node.getType())) }, key: 'indentIncrease', label: ({ i18n }) => { From e90ff72b3742c34d10745b9349a1359a8ecca826 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Tue, 15 Apr 2025 12:09:55 -0400 Subject: [PATCH 05/21] fix: reordering draft documents causes data loss (#12109) Re-ordering documents with drafts uses `payload.update()` with `select: { id: true }` and that causes draft versions of those docs to be updated without any data. I've removed the `select` optimization to prevent data loss. Fixes #12097 --- .../payload/src/config/orderable/index.ts | 1 - test/sort/collections/Drafts/index.ts | 1 + test/sort/int.spec.ts | 118 +++++++++++++++++- 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/packages/payload/src/config/orderable/index.ts b/packages/payload/src/config/orderable/index.ts index be3cdc53c1..c09de82a12 100644 --- a/packages/payload/src/config/orderable/index.ts +++ b/packages/payload/src/config/orderable/index.ts @@ -257,7 +257,6 @@ export const addOrderableEndpoint = (config: SanitizedConfig) => { }, depth: 0, req, - select: { id: true }, }) } diff --git a/test/sort/collections/Drafts/index.ts b/test/sort/collections/Drafts/index.ts index a6d73ae2d9..eab76ebcbc 100644 --- a/test/sort/collections/Drafts/index.ts +++ b/test/sort/collections/Drafts/index.ts @@ -7,6 +7,7 @@ export const DraftsCollection: CollectionConfig = { admin: { useAsTitle: 'text', }, + orderable: true, versions: { drafts: true, }, diff --git a/test/sort/int.spec.ts b/test/sort/int.spec.ts index 12689c3393..0fea47ee92 100644 --- a/test/sort/int.spec.ts +++ b/test/sort/int.spec.ts @@ -4,9 +4,10 @@ import path from 'path' import { fileURLToPath } from 'url' import type { NextRESTClient } from '../helpers/NextRESTClient.js' -import type { Orderable, OrderableJoin } from './payload-types.js' +import type { Draft, Orderable, OrderableJoin } from './payload-types.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' +import { draftsSlug } from './collections/Drafts/index.js' import { orderableSlug } from './collections/Orderable/index.js' import { orderableJoinSlug } from './collections/OrderableJoin/index.js' @@ -330,6 +331,121 @@ describe('Sort', () => { }) }) + describe('Orderable', () => { + let orderable1: Orderable + let orderable2: Orderable + let orderableDraft1: Draft + let orderableDraft2: Draft + beforeAll(async () => { + orderable1 = await payload.create({ + collection: orderableSlug, + data: { + title: 'Orderable 1', + }, + }) + orderable2 = await payload.create({ + collection: orderableSlug, + data: { + title: 'Orderable 2', + }, + }) + orderableDraft1 = await payload.create({ + collection: draftsSlug, + data: { + text: 'Orderable 1', + _status: 'draft', + }, + }) + orderableDraft2 = await payload.create({ + collection: draftsSlug, + data: { + text: 'Orderable 2', + _status: 'draft', + }, + }) + }) + + it('should set order by default', async () => { + const ordered = await payload.find({ + collection: orderableSlug, + where: { + title: { + contains: 'Orderable ', + }, + }, + }) + + expect(orderable1._order).toBeDefined() + expect(orderable2._order).toBeDefined() + expect(parseInt(orderable1._order, 16)).toBeLessThan(parseInt(orderable2._order, 16)) + expect(ordered.docs[0].id).toStrictEqual(orderable1.id) + expect(ordered.docs[1].id).toStrictEqual(orderable2.id) + }) + + it('should allow reordering with REST API', async () => { + const res = await restClient.POST('/reorder', { + body: JSON.stringify({ + collectionSlug: orderableSlug, + docsToMove: [orderable1.id], + newKeyWillBe: 'greater', + orderableFieldName: '_order', + target: { + id: orderable2.id, + key: orderable2._order, + }, + }), + }) + + expect(res.status).toStrictEqual(200) + + const ordered = await payload.find({ + collection: 'orderable', + where: { + title: { + contains: 'Orderable ', + }, + }, + }) + + expect(parseInt(ordered.docs[0]._order, 16)).toBeLessThan( + parseInt(ordered.docs[1]._order, 16), + ) + }) + + it('should allow reordering with REST API with drafts enabled', async () => { + const res = await restClient.POST('/reorder', { + body: JSON.stringify({ + collectionSlug: draftsSlug, + docsToMove: [orderableDraft1.id], + newKeyWillBe: 'greater', + orderableFieldName: '_order', + target: { + id: orderableDraft2.id, + key: orderableDraft2._order, + }, + }), + }) + + expect(res.status).toStrictEqual(200) + + const ordered = await payload.find({ + collection: draftsSlug, + draft: true, + where: { + text: { + contains: 'Orderable ', + }, + }, + }) + + expect(ordered.docs).toHaveLength(2) + + expect(parseInt(ordered.docs[0]._order, 16)).toBeLessThan( + parseInt(ordered.docs[1]._order, 16), + ) + }) + }) + describe('Orderable join', () => { let related: OrderableJoin let orderable1: Orderable From 21599b87f5159b394d6b0b1784ac87e1829f0bce Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Tue, 15 Apr 2025 15:23:51 -0400 Subject: [PATCH 06/21] fix(ui): stale paths on custom components within rows (#11973) When server rendering custom components within form state, those components receive a path that is correct at render time, but potentially stale after manipulating array and blocks rows. This causes the field to briefly render incorrect values while the form state request is in flight. The reason for this is that paths are passed as a prop statically into those components. Then when we manipulate rows, form state is modified, potentially changing field paths. The component's `path` prop, however, hasn't changed. This means it temporarily points to the wrong field in form state, rendering the data of another row until the server responds with a freshly rendered component. This is not an issue with default Payload fields as they are rendered on the client and can be passed dynamic props. This is only an issue within custom server components, including rich text fields which are treated as custom components. Since they are rendered on the server and passed to the client, props are inaccessible after render. The fix for this is to provide paths dynamically through context. This way as we make changes to form state, there is a mechanism in which server components can receive the updated path without waiting on its props to update. --- .../src/components/FieldsToExport/index.tsx | 3 +- .../src/components/SortBy/index.tsx | 4 +-- .../src/components/WhereField/index.tsx | 1 + .../components/TenantField/index.client.tsx | 4 +-- .../MetaDescriptionComponent.tsx | 17 +++++----- .../fields/MetaImage/MetaImageComponent.tsx | 21 ++++++------ .../fields/MetaTitle/MetaTitleComponent.tsx | 17 ++++++---- packages/richtext-lexical/src/field/Field.tsx | 5 ++- .../richtext-slate/src/field/RichText.tsx | 6 ++-- .../fields/ColumnsField/index.tsx | 3 +- .../QueryPresets/fields/WhereField/index.tsx | 3 +- packages/ui/src/fields/Array/index.tsx | 5 +-- packages/ui/src/fields/Blocks/index.tsx | 5 +-- packages/ui/src/fields/Checkbox/index.tsx | 5 +-- packages/ui/src/fields/Code/index.tsx | 5 +-- packages/ui/src/fields/DateTime/index.tsx | 5 +-- packages/ui/src/fields/Email/index.tsx | 7 ++-- packages/ui/src/fields/Hidden/index.tsx | 7 ++-- packages/ui/src/fields/JSON/index.tsx | 5 +-- packages/ui/src/fields/Join/index.tsx | 5 +-- packages/ui/src/fields/Number/index.tsx | 5 +-- packages/ui/src/fields/Point/index.tsx | 5 +-- packages/ui/src/fields/RadioGroup/index.tsx | 5 +-- packages/ui/src/fields/Relationship/index.tsx | 5 +-- packages/ui/src/fields/Select/index.tsx | 5 +-- packages/ui/src/fields/Tabs/index.tsx | 6 ++-- packages/ui/src/fields/Text/index.tsx | 5 +-- packages/ui/src/fields/Textarea/index.tsx | 5 +-- packages/ui/src/fields/Upload/index.tsx | 5 +-- packages/ui/src/forms/RenderFields/context.ts | 14 ++++++++ packages/ui/src/forms/RenderFields/index.tsx | 28 ++++++++-------- packages/ui/src/forms/useField/index.tsx | 17 ++++++++-- packages/ui/src/forms/useField/types.ts | 23 ++++++++++++- test/field-error-states/e2e.spec.ts | 6 ++-- .../index.tsx | 4 +-- .../collections/UpdatedExternally/index.ts | 6 ++-- test/form-state/collections/Posts/index.ts | 4 +++ test/form-state/e2e.spec.ts | 32 ++++++++++++++++++- test/form-state/payload-types.ts | 2 ++ tsconfig.base.json | 2 +- 40 files changed, 211 insertions(+), 106 deletions(-) create mode 100644 packages/ui/src/forms/RenderFields/context.ts rename test/fields-relationship/{PrePopulateFieldUI => PopulateFieldButton}/index.tsx (91%) diff --git a/packages/plugin-import-export/src/components/FieldsToExport/index.tsx b/packages/plugin-import-export/src/components/FieldsToExport/index.tsx index 1a33f1fe1e..67461e1678 100644 --- a/packages/plugin-import-export/src/components/FieldsToExport/index.tsx +++ b/packages/plugin-import-export/src/components/FieldsToExport/index.tsx @@ -20,8 +20,7 @@ const baseClass = 'fields-to-export' export const FieldsToExport: SelectFieldClientComponent = (props) => { const { id } = useDocumentInfo() - const { path } = props - const { setValue, value } = useField({ path }) + const { setValue, value } = useField() const { value: collectionSlug } = useField({ path: 'collectionSlug' }) const { getEntityConfig } = useConfig() const { collection } = useImportExport() diff --git a/packages/plugin-import-export/src/components/SortBy/index.tsx b/packages/plugin-import-export/src/components/SortBy/index.tsx index 75d7cbb297..0d06a21162 100644 --- a/packages/plugin-import-export/src/components/SortBy/index.tsx +++ b/packages/plugin-import-export/src/components/SortBy/index.tsx @@ -20,12 +20,12 @@ const baseClass = 'sort-by-fields' export const SortBy: SelectFieldClientComponent = (props) => { const { id } = useDocumentInfo() - const { path } = props - const { setValue, value } = useField({ path }) + const { setValue, value } = useField() const { value: collectionSlug } = useField({ path: 'collectionSlug' }) const { query } = useListQuery() const { getEntityConfig } = useConfig() const { collection } = useImportExport() + const [displayedValue, setDisplayedValue] = useState<{ id: string label: ReactNode diff --git a/packages/plugin-import-export/src/components/WhereField/index.tsx b/packages/plugin-import-export/src/components/WhereField/index.tsx index 02c1d3db38..68f4daf653 100644 --- a/packages/plugin-import-export/src/components/WhereField/index.tsx +++ b/packages/plugin-import-export/src/components/WhereField/index.tsx @@ -11,6 +11,7 @@ export const WhereField: React.FC = () => { const { setValue: setSelectionToUseValue, value: selectionToUseValue } = useField({ path: 'selectionToUse', }) + const { setValue } = useField({ path: 'where' }) const { selectAll, selected } = useSelection() const { query } = useListQuery() diff --git a/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx b/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx index 4f6605e60b..b0a8bf0b90 100644 --- a/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx +++ b/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx @@ -16,8 +16,8 @@ type Props = { } & RelationshipFieldClientProps export const TenantField = (args: Props) => { - const { debug, path, unique } = args - const { setValue, value } = useField({ path }) + const { debug, unique } = args + const { setValue, value } = useField() const { options, selectedTenantID, setPreventRefreshOnChange, setTenant } = useTenantSelection() const hasSetValueRef = React.useRef(false) diff --git a/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx b/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx index 5a544a9f30..7b93ff3777 100644 --- a/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx +++ b/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx @@ -1,6 +1,6 @@ 'use client' -import type { FieldType, Options } from '@payloadcms/ui' +import type { FieldType } from '@payloadcms/ui' import type { TextareaFieldClientProps } from 'payload' import { @@ -38,7 +38,6 @@ export const MetaDescriptionComponent: React.FC = (props) required, }, hasGenerateDescriptionFn, - path, readOnly, } = props @@ -58,12 +57,14 @@ export const MetaDescriptionComponent: React.FC = (props) const maxLength = maxLengthFromProps || maxLengthDefault const minLength = minLengthFromProps || minLengthDefault - const { customComponents, errorMessage, setValue, showError, value }: FieldType = - useField({ - path, - } as Options) - - const { AfterInput, BeforeInput, Label } = customComponents ?? {} + const { + customComponents: { AfterInput, BeforeInput, Label } = {}, + errorMessage, + path, + setValue, + showError, + value, + }: FieldType = useField() const regenerateDescription = useCallback(async () => { if (!hasGenerateDescriptionFn) { diff --git a/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx b/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx index 987c4a2041..acfc2aaef9 100644 --- a/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx +++ b/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx @@ -1,6 +1,6 @@ 'use client' -import type { FieldType, Options } from '@payloadcms/ui' +import type { FieldType } from '@payloadcms/ui' import type { UploadFieldClientProps } from 'payload' import { @@ -30,9 +30,8 @@ export const MetaImageComponent: React.FC = (props) => { const { field: { label, localized, relationTo, required }, hasGenerateImageFn, - path, readOnly, - } = props || {} + } = props const { config: { @@ -42,10 +41,14 @@ export const MetaImageComponent: React.FC = (props) => { getEntityConfig, } = useConfig() - const field: FieldType = useField({ ...props, path } as Options) - const { customComponents } = field - - const { Error, Label } = customComponents ?? {} + const { + customComponents: { Error, Label } = {}, + filterOptions, + path, + setValue, + showError, + value, + }: FieldType = useField() const { t } = useTranslation() @@ -53,8 +56,6 @@ export const MetaImageComponent: React.FC = (props) => { const { getData } = useForm() const docInfo = useDocumentInfo() - const { setValue, showError, value } = field - const regenerateImage = useCallback(async () => { if (!hasGenerateImageFn) { return @@ -174,7 +175,7 @@ export const MetaImageComponent: React.FC = (props) => { api={api} collection={collection} Error={Error} - filterOptions={field.filterOptions} + filterOptions={filterOptions} onChange={(incomingImage) => { if (incomingImage !== null) { if (typeof incomingImage === 'object') { diff --git a/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx b/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx index e1496ca63d..4d7e57b119 100644 --- a/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx +++ b/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx @@ -1,6 +1,6 @@ 'use client' -import type { FieldType, Options } from '@payloadcms/ui' +import type { FieldType } from '@payloadcms/ui' import type { TextFieldClientProps } from 'payload' import { @@ -33,9 +33,8 @@ export const MetaTitleComponent: React.FC = (props) => { const { field: { label, maxLength: maxLengthFromProps, minLength: minLengthFromProps, required }, hasGenerateTitleFn, - path, readOnly, - } = props || {} + } = props const { t } = useTranslation() @@ -46,8 +45,14 @@ export const MetaTitleComponent: React.FC = (props) => { }, } = useConfig() - const field: FieldType = useField({ path } as Options) - const { customComponents: { AfterInput, BeforeInput, Label } = {} } = field + const { + customComponents: { AfterInput, BeforeInput, Label } = {}, + errorMessage, + path, + setValue, + showError, + value, + }: FieldType = useField() const locale = useLocale() const { getData } = useForm() @@ -56,8 +61,6 @@ export const MetaTitleComponent: React.FC = (props) => { const minLength = minLengthFromProps || minLengthDefault const maxLength = maxLengthFromProps || maxLengthDefault - const { errorMessage, setValue, showError, value } = field - const regenerateTitle = useCallback(async () => { if (!hasGenerateTitleFn) { return diff --git a/packages/richtext-lexical/src/field/Field.tsx b/packages/richtext-lexical/src/field/Field.tsx index 65edb6ede5..1f5bc28257 100644 --- a/packages/richtext-lexical/src/field/Field.tsx +++ b/packages/richtext-lexical/src/field/Field.tsx @@ -36,7 +36,6 @@ const RichTextComponent: React.FC< editorConfig, field, field: { - name, admin: { className, description, readOnly: readOnlyFromAdmin } = {}, label, localized, @@ -48,7 +47,6 @@ const RichTextComponent: React.FC< } = props const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin - const path = pathFromProps ?? name const editDepth = useEditDepth() @@ -70,11 +68,12 @@ const RichTextComponent: React.FC< customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled: disabledFromField, initialValue, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/richtext-slate/src/field/RichText.tsx b/packages/richtext-slate/src/field/RichText.tsx index 1effc7eb09..fbcb1df135 100644 --- a/packages/richtext-slate/src/field/RichText.tsx +++ b/packages/richtext-slate/src/field/RichText.tsx @@ -28,7 +28,6 @@ import type { LoadedSlateFieldProps } from './types.js' import { defaultRichTextValue } from '../data/defaultValue.js' import { richTextValidate } from '../data/validation.js' import { listTypes } from './elements/listTypes.js' -import './index.scss' import { hotkeys } from './hotkeys.js' import { toggleLeaf } from './leaves/toggle.js' import { withEnterBreakOut } from './plugins/withEnterBreakOut.js' @@ -37,6 +36,7 @@ import { ElementButtonProvider } from './providers/ElementButtonProvider.js' import { ElementProvider } from './providers/ElementProvider.js' import { LeafButtonProvider } from './providers/LeafButtonProvider.js' import { LeafProvider } from './providers/LeafProvider.js' +import './index.scss' const baseClass = 'rich-text' @@ -66,7 +66,6 @@ const RichTextField: React.FC = (props) => { validate = richTextValidate, } = props - const path = pathFromProps ?? name const schemaPath = schemaPathFromProps ?? name const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin @@ -97,11 +96,12 @@ const RichTextField: React.FC = (props) => { customComponents: { Description, Error, Label } = {}, disabled: disabledFromField, initialValue, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx b/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx index 78570d6671..6c21780e5f 100644 --- a/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx +++ b/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx @@ -11,9 +11,8 @@ import './index.scss' export const QueryPresetsColumnField: JSONFieldClientComponent = ({ field: { label, required }, - path, }) => { - const { value } = useField({ path }) + const { path, value } = useField() return (
diff --git a/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx b/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx index 564cbe82b1..a44fd868bb 100644 --- a/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx +++ b/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx @@ -88,9 +88,8 @@ const transformWhereToNaturalLanguage = ( export const QueryPresetsWhereField: JSONFieldClientComponent = ({ field: { label, required }, - path, }) => { - const { value } = useField({ path }) + const { path, value } = useField() const { collectionSlug } = useListQuery() const { getEntityConfig } = useConfig() diff --git a/packages/ui/src/fields/Array/index.tsx b/packages/ui/src/fields/Array/index.tsx index ed2501903c..954937599b 100644 --- a/packages/ui/src/fields/Array/index.tsx +++ b/packages/ui/src/fields/Array/index.tsx @@ -46,7 +46,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => { required, }, forceRender = false, - path, + path: pathFromProps, permissions, readOnly, schemaPath: schemaPathFromProps, @@ -113,13 +113,14 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, errorPaths, + path, rows = [], showError, valid, value, } = useField({ hasRows: true, - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Blocks/index.tsx b/packages/ui/src/fields/Blocks/index.tsx index 0dea396448..99a8578083 100644 --- a/packages/ui/src/fields/Blocks/index.tsx +++ b/packages/ui/src/fields/Blocks/index.tsx @@ -48,7 +48,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => { minRows: minRowsProp, required, }, - path, + path: pathFromProps, permissions, readOnly, schemaPath: schemaPathFromProps, @@ -101,13 +101,14 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, errorPaths, + path, rows = [], showError, valid, value, } = useField({ hasRows: true, - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Checkbox/index.tsx b/packages/ui/src/fields/Checkbox/index.tsx index e3fc493cf6..2a685e6ea7 100644 --- a/packages/ui/src/fields/Checkbox/index.tsx +++ b/packages/ui/src/fields/Checkbox/index.tsx @@ -39,7 +39,7 @@ const CheckboxFieldComponent: CheckboxFieldClientComponent = (props) => { } = {} as CheckboxFieldClientProps['field'], onChange: onChangeFromProps, partialChecked, - path, + path: pathFromProps, readOnly, validate, } = props @@ -60,12 +60,13 @@ const CheckboxFieldComponent: CheckboxFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ disableFormData, - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Code/index.tsx b/packages/ui/src/fields/Code/index.tsx index 5a9f38e45b..143df342ef 100644 --- a/packages/ui/src/fields/Code/index.tsx +++ b/packages/ui/src/fields/Code/index.tsx @@ -31,7 +31,7 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => { required, }, onMount, - path, + path: pathFromProps, readOnly, validate, } = props @@ -48,11 +48,12 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/DateTime/index.tsx b/packages/ui/src/fields/DateTime/index.tsx index da801b937a..4afbc3c919 100644 --- a/packages/ui/src/fields/DateTime/index.tsx +++ b/packages/ui/src/fields/DateTime/index.tsx @@ -33,7 +33,7 @@ const DateTimeFieldComponent: DateFieldClientComponent = (props) => { required, timezone, }, - path, + path: pathFromProps, readOnly, validate, } = props @@ -59,11 +59,12 @@ const DateTimeFieldComponent: DateFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Email/index.tsx b/packages/ui/src/fields/Email/index.tsx index 305ad5b287..6cce6d753a 100644 --- a/packages/ui/src/fields/Email/index.tsx +++ b/packages/ui/src/fields/Email/index.tsx @@ -16,8 +16,8 @@ import { withCondition } from '../../forms/withCondition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { FieldLabel } from '../FieldLabel/index.js' import { mergeFieldStyles } from '../mergeFieldStyles.js' -import './index.scss' import { fieldBaseClass } from '../shared/index.js' +import './index.scss' const EmailFieldComponent: EmailFieldClientComponent = (props) => { const { @@ -33,7 +33,7 @@ const EmailFieldComponent: EmailFieldClientComponent = (props) => { localized, required, } = {} as EmailFieldClientProps['field'], - path, + path: pathFromProps, readOnly, validate, } = props @@ -52,11 +52,12 @@ const EmailFieldComponent: EmailFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Hidden/index.tsx b/packages/ui/src/fields/Hidden/index.tsx index 90c4e0f98e..3938e354d3 100644 --- a/packages/ui/src/fields/Hidden/index.tsx +++ b/packages/ui/src/fields/Hidden/index.tsx @@ -8,14 +8,15 @@ import { useField } from '../../forms/useField/index.js' import { withCondition } from '../../forms/withCondition/index.js' /** + * Renders an input with `type="hidden"`. * This is mainly used to save a value on the form that is not visible to the user. * For example, this sets the `ìd` property of a block in the Blocks field. */ const HiddenFieldComponent: React.FC = (props) => { - const { disableModifyingForm = true, path, value: valueFromProps } = props + const { disableModifyingForm = true, path: pathFromProps, value: valueFromProps } = props - const { setValue, value } = useField({ - path, + const { path, setValue, value } = useField({ + potentiallyStalePath: pathFromProps, }) useEffect(() => { diff --git a/packages/ui/src/fields/JSON/index.tsx b/packages/ui/src/fields/JSON/index.tsx index 75754cb181..c0b80a670a 100644 --- a/packages/ui/src/fields/JSON/index.tsx +++ b/packages/ui/src/fields/JSON/index.tsx @@ -28,7 +28,7 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => { localized, required, }, - path, + path: pathFromProps, readOnly, validate, } = props @@ -50,11 +50,12 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, initialValue, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Join/index.tsx b/packages/ui/src/fields/Join/index.tsx index 1a75eff971..43610cb406 100644 --- a/packages/ui/src/fields/Join/index.tsx +++ b/packages/ui/src/fields/Join/index.tsx @@ -131,7 +131,7 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => { on, required, }, - path, + path: pathFromProps, } = props const { id: docID, docConfig } = useDocumentInfo() @@ -140,10 +140,11 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, + path, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, }) const filterOptions: null | Where = useMemo(() => { diff --git a/packages/ui/src/fields/Number/index.tsx b/packages/ui/src/fields/Number/index.tsx index 98be6bee36..c7f135a26a 100644 --- a/packages/ui/src/fields/Number/index.tsx +++ b/packages/ui/src/fields/Number/index.tsx @@ -38,7 +38,7 @@ const NumberFieldComponent: NumberFieldClientComponent = (props) => { required, }, onChange: onChangeFromProps, - path, + path: pathFromProps, readOnly, validate, } = props @@ -57,11 +57,12 @@ const NumberFieldComponent: NumberFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Point/index.tsx b/packages/ui/src/fields/Point/index.tsx index 3d3ec40a94..dbe86849fc 100644 --- a/packages/ui/src/fields/Point/index.tsx +++ b/packages/ui/src/fields/Point/index.tsx @@ -26,7 +26,7 @@ export const PointFieldComponent: PointFieldClientComponent = (props) => { localized, required, }, - path, + path: pathFromProps, readOnly, validate, } = props @@ -45,11 +45,12 @@ export const PointFieldComponent: PointFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value = [null, null], } = useField<[number, number]>({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/RadioGroup/index.tsx b/packages/ui/src/fields/RadioGroup/index.tsx index bb5746bda0..0542d55b94 100644 --- a/packages/ui/src/fields/RadioGroup/index.tsx +++ b/packages/ui/src/fields/RadioGroup/index.tsx @@ -34,7 +34,7 @@ const RadioGroupFieldComponent: RadioFieldClientComponent = (props) => { required, } = {} as RadioFieldClientProps['field'], onChange: onChangeFromProps, - path, + path: pathFromProps, readOnly, validate, value: valueFromProps, @@ -54,11 +54,12 @@ const RadioGroupFieldComponent: RadioFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value: valueFromContext, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index 85a897f2dd..449c1e1374 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -64,7 +64,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => relationTo, required, }, - path, + path: pathFromProps, readOnly, validate, } = props @@ -112,11 +112,12 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => disabled, filterOptions, initialValue, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Select/index.tsx b/packages/ui/src/fields/Select/index.tsx index f49af6fe6c..b8b67af5f9 100644 --- a/packages/ui/src/fields/Select/index.tsx +++ b/packages/ui/src/fields/Select/index.tsx @@ -46,7 +46,7 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => { required, }, onChange: onChangeFromProps, - path, + path: pathFromProps, readOnly, validate, } = props @@ -65,11 +65,12 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Tabs/index.tsx b/packages/ui/src/fields/Tabs/index.tsx index 5b7e9b6bf3..b8be36c96e 100644 --- a/packages/ui/src/fields/Tabs/index.tsx +++ b/packages/ui/src/fields/Tabs/index.tsx @@ -269,15 +269,13 @@ function TabContent({ parentIndexPath, parentPath, parentSchemaPath, - path, permissions, readOnly, }: ActiveTabProps) { const { i18n } = useTranslation() - const { customComponents: { AfterInput, BeforeInput, Description, Field } = {} } = useField({ - path, - }) + const { customComponents: { AfterInput, BeforeInput, Description, Field } = {}, path } = + useField() if (Field) { return Field diff --git a/packages/ui/src/fields/Text/index.tsx b/packages/ui/src/fields/Text/index.tsx index afa9aaa536..637b5efe4d 100644 --- a/packages/ui/src/fields/Text/index.tsx +++ b/packages/ui/src/fields/Text/index.tsx @@ -32,7 +32,7 @@ const TextFieldComponent: TextFieldClientComponent = (props) => { required, }, inputRef, - path, + path: pathFromProps, readOnly, validate, } = props @@ -55,11 +55,12 @@ const TextFieldComponent: TextFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Textarea/index.tsx b/packages/ui/src/fields/Textarea/index.tsx index f27695f369..3a3ffba6be 100644 --- a/packages/ui/src/fields/Textarea/index.tsx +++ b/packages/ui/src/fields/Textarea/index.tsx @@ -29,7 +29,7 @@ const TextareaFieldComponent: TextareaFieldClientComponent = (props) => { minLength, required, }, - path, + path: pathFromProps, readOnly, validate, } = props @@ -61,11 +61,12 @@ const TextareaFieldComponent: TextareaFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Upload/index.tsx b/packages/ui/src/fields/Upload/index.tsx index 681e5c18e5..9fc43b2037 100644 --- a/packages/ui/src/fields/Upload/index.tsx +++ b/packages/ui/src/fields/Upload/index.tsx @@ -28,7 +28,7 @@ export function UploadComponent(props: UploadFieldClientProps) { relationTo, required, }, - path, + path: pathFromProps, readOnly, validate, } = props @@ -50,11 +50,12 @@ export function UploadComponent(props: UploadFieldClientProps) { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, filterOptions, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/forms/RenderFields/context.ts b/packages/ui/src/forms/RenderFields/context.ts new file mode 100644 index 0000000000..d6d9da067d --- /dev/null +++ b/packages/ui/src/forms/RenderFields/context.ts @@ -0,0 +1,14 @@ +import React from 'react' + +export const FieldPathContext = React.createContext(undefined) + +export const useFieldPath = () => { + const context = React.useContext(FieldPathContext) + + if (!context) { + // swallow the error, not all fields are wrapped in a FieldPathContext + return undefined + } + + return context +} diff --git a/packages/ui/src/forms/RenderFields/index.tsx b/packages/ui/src/forms/RenderFields/index.tsx index bf447d9511..ed662e202e 100644 --- a/packages/ui/src/forms/RenderFields/index.tsx +++ b/packages/ui/src/forms/RenderFields/index.tsx @@ -7,8 +7,9 @@ import type { RenderFieldsProps } from './types.js' import { RenderIfInViewport } from '../../elements/RenderIfInViewport/index.js' import { useOperation } from '../../providers/Operation/index.js' -import { RenderField } from './RenderField.js' +import { FieldPathContext } from './context.js' import './index.scss' +import { RenderField } from './RenderField.js' const baseClass = 'render-fields' @@ -90,18 +91,19 @@ export const RenderFields: React.FC = (props) => { }) return ( - + + + ) })} diff --git a/packages/ui/src/forms/useField/index.tsx b/packages/ui/src/forms/useField/index.tsx index f3fc737df1..987b819957 100644 --- a/packages/ui/src/forms/useField/index.tsx +++ b/packages/ui/src/forms/useField/index.tsx @@ -23,14 +23,27 @@ import { useFormProcessing, useFormSubmitted, } from '../Form/context.js' +import { useFieldPath } from '../RenderFields/context.js' /** * Get and set the value of a form field. * * @see https://payloadcms.com/docs/admin/react-hooks#usefield */ -export const useField = (options: Options): FieldType => { - const { disableFormData = false, hasRows, path, validate } = options +export const useField = (options?: Options): FieldType => { + const { + disableFormData = false, + hasRows, + path: pathFromOptions, + potentiallyStalePath, + validate, + } = options || {} + + const pathFromContext = useFieldPath() + + // This is a workaround for stale props given to server rendered components. + // See the notes in the `potentiallyStalePath` type definition for more details. + const path = pathFromOptions || pathFromContext || potentiallyStalePath const submitted = useFormSubmitted() const processing = useFormProcessing() diff --git a/packages/ui/src/forms/useField/types.ts b/packages/ui/src/forms/useField/types.ts index 2f43cbab68..e91e60e8bd 100644 --- a/packages/ui/src/forms/useField/types.ts +++ b/packages/ui/src/forms/useField/types.ts @@ -3,7 +3,27 @@ import type { FieldState, FilterOptionsResult, Row, Validate } from 'payload' export type Options = { disableFormData?: boolean hasRows?: boolean - path: string + /** + * If `path` is provided to this hook, it will be used outright. This is useful when calling this hook directly within a custom component. + * Otherwise, the field will attempt to get the path from the `FieldPathContext` via the `useFieldPath` hook. + * If still not found, the `potentiallyStalePath` arg will be used. See the note below about why this is important. + */ + path?: string + /** + * Custom server components receive a static `path` prop at render-time, leading to temporarily stale paths when re-ordering rows in form state. + * This is because when manipulating rows, field paths change in form state, but the prop remains the same until the component is re-rendered on the server. + * This causes the component to temporarily point to the wrong field in form state until the server responds with a freshly rendered component. + * To prevent this, fields are wrapped with a `FieldPathContext` which is guaranteed to be up-to-date. + * The `path` prop that Payload's default fields receive, then, are sent into this hook as the `potentiallyStalePath` arg. + * This ensures that: + * 1. Custom components that use this hook directly will still respect the `path` prop as top priority. + * 2. Custom server components that blindly spread their props into default Payload fields still prefer the dynamic path from context. + * 3. Components that render default Payload fields directly do not require a `FieldPathProvider`, e.g. the email field in the account view. + */ + potentiallyStalePath?: string + /** + * Client-side validation function fired when the form is submitted. + */ validate?: Validate } @@ -17,6 +37,7 @@ export type FieldType = { formProcessing: boolean formSubmitted: boolean initialValue?: T + path: string readOnly?: boolean rows?: Row[] setValue: (val: unknown, disableModifyingForm?: boolean) => void diff --git a/test/field-error-states/e2e.spec.ts b/test/field-error-states/e2e.spec.ts index 3398d1b8fd..49bb09b559 100644 --- a/test/field-error-states/e2e.spec.ts +++ b/test/field-error-states/e2e.spec.ts @@ -57,7 +57,10 @@ describe('Field Error States', () => { // add third child array await page.locator('#parentArray-row-0 .collapsible__content .array-field__add-row').click() + + // remove the row await page.locator('#parentArray-0-childArray-row-2 .array-actions__button').click() + await page .locator('#parentArray-0-childArray-row-2 .array-actions__action.array-actions__remove') .click() @@ -68,6 +71,7 @@ describe('Field Error States', () => { '#parentArray-row-0 > .collapsible > .collapsible__toggle-wrap .array-field__row-error-pill', { state: 'hidden', timeout: 500 }, ) + expect(errorPill).toBeNull() }) @@ -77,13 +81,11 @@ describe('Field Error States', () => { await saveDocAndAssert(page, '#action-save-draft') }) - // eslint-disable-next-line playwright/expect-expect test('should validate drafts when enabled', async () => { await page.goto(validateDraftsOn.create) await saveDocAndAssert(page, '#action-save-draft', 'error') }) - // eslint-disable-next-line playwright/expect-expect test('should show validation errors when validate and autosave are enabled', async () => { await page.goto(validateDraftsOnAutosave.create) await page.locator('#field-title').fill('valid') diff --git a/test/fields-relationship/PrePopulateFieldUI/index.tsx b/test/fields-relationship/PopulateFieldButton/index.tsx similarity index 91% rename from test/fields-relationship/PrePopulateFieldUI/index.tsx rename to test/fields-relationship/PopulateFieldButton/index.tsx index 1ed66a3c2a..c9d8b51e9d 100644 --- a/test/fields-relationship/PrePopulateFieldUI/index.tsx +++ b/test/fields-relationship/PopulateFieldButton/index.tsx @@ -4,12 +4,12 @@ import * as React from 'react' import { collection1Slug } from '../slugs.js' -export const PrePopulateFieldUI: React.FC<{ +export const PopulateFieldButton: React.FC<{ hasMany?: boolean hasMultipleRelations?: boolean path?: string targetFieldPath: string -}> = ({ hasMany = true, hasMultipleRelations = false, path, targetFieldPath }) => { +}> = ({ hasMany = true, hasMultipleRelations = false, targetFieldPath }) => { const { setValue } = useField({ path: targetFieldPath }) const addDefaults = React.useCallback(() => { diff --git a/test/fields-relationship/collections/UpdatedExternally/index.ts b/test/fields-relationship/collections/UpdatedExternally/index.ts index 8d208a4000..8e2cd9f5e5 100644 --- a/test/fields-relationship/collections/UpdatedExternally/index.ts +++ b/test/fields-relationship/collections/UpdatedExternally/index.ts @@ -19,7 +19,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = { admin: { components: { Field: { - path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI', + path: '/PopulateFieldButton/index.js#PopulateFieldButton', clientProps: { hasMany: false, hasMultipleRelations: false, @@ -50,7 +50,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = { admin: { components: { Field: { - path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI', + path: '/PopulateFieldButton/index.js#PopulateFieldButton', clientProps: { hasMultipleRelations: false, targetFieldPath: 'relationHasMany', @@ -80,7 +80,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = { admin: { components: { Field: { - path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI', + path: '/PopulateFieldButton/index.js#PopulateFieldButton', clientProps: { hasMultipleRelations: true, targetFieldPath: 'relationToManyHasMany', diff --git a/test/form-state/collections/Posts/index.ts b/test/form-state/collections/Posts/index.ts index 14550604fd..54183c26f7 100644 --- a/test/form-state/collections/Posts/index.ts +++ b/test/form-state/collections/Posts/index.ts @@ -83,6 +83,10 @@ export const PostsCollection: CollectionConfig = { }, }, }, + { + name: 'defaultTextField', + type: 'text', + }, ], }, ], diff --git a/test/form-state/e2e.spec.ts b/test/form-state/e2e.spec.ts index 596cb061d0..d44b812ec8 100644 --- a/test/form-state/e2e.spec.ts +++ b/test/form-state/e2e.spec.ts @@ -213,7 +213,37 @@ test.describe('Form State', () => { }) }) - test('new rows should contain default values', async () => { + test('should not render stale values for server components while form state is in flight', async () => { + await page.goto(postsUrl.create) + + await page.locator('#field-array .array-field__add-row').click() + await page.locator('#field-array #array-row-0 #field-array__0__customTextField').fill('1') + + await page.locator('#field-array .array-field__add-row').click() + await page.locator('#field-array #array-row-1 #field-array__1__customTextField').fill('2') + + // block the next form state request from firing to ensure the field remains in stale state + await page.route(postsUrl.create, async (route) => { + if (route.request().method() === 'POST' && route.request().url() === postsUrl.create) { + await route.abort() + } + + await route.continue() + }) + + // remove the first row + await page.locator('#field-array #array-row-0 .array-actions__button').click() + + await page + .locator('#field-array #array-row-0 .array-actions__action.array-actions__remove') + .click() + + await expect( + page.locator('#field-array #array-row-0 #field-array__0__customTextField'), + ).toHaveValue('2') + }) + + test('should queue onChange functions', async () => { await page.goto(postsUrl.create) await page.locator('#field-array .array-field__add-row').click() await expect( diff --git a/test/form-state/payload-types.ts b/test/form-state/payload-types.ts index 72b04f150d..dd00cd42ef 100644 --- a/test/form-state/payload-types.ts +++ b/test/form-state/payload-types.ts @@ -144,6 +144,7 @@ export interface Post { array?: | { customTextField?: string | null; + defaultTextField?: string | null; id?: string | null; }[] | null; @@ -254,6 +255,7 @@ export interface PostsSelect { | T | { customTextField?: T; + defaultTextField?: T; id?: T; }; updatedAt?: T; diff --git a/tsconfig.base.json b/tsconfig.base.json index c9793d25c6..e2b64e3bc8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/_community/config.ts"], + "@payload-config": ["./test/form-state/config.ts"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], From e79b20363e3b43b9f90ec338dacf013371360e05 Mon Sep 17 00:00:00 2001 From: James Mikrut Date: Wed, 16 Apr 2025 09:15:39 -0400 Subject: [PATCH 07/21] fix: ensures cors headers are run against custom endpoints (#12091) Restores goal of #10597 and reverts #10718 This is a more surgical way of adding CORS headers to custom endpoints --- packages/payload/src/utilities/handleEndpoints.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/utilities/handleEndpoints.ts b/packages/payload/src/utilities/handleEndpoints.ts index 25a70d04b1..e5fe5c6bef 100644 --- a/packages/payload/src/utilities/handleEndpoints.ts +++ b/packages/payload/src/utilities/handleEndpoints.ts @@ -222,8 +222,12 @@ export const handleEndpoints = async ({ } const response = await handler(req) + return new Response(response.body, { - headers: mergeHeaders(req.responseHeaders ?? new Headers(), response.headers), + headers: headersWithCors({ + headers: mergeHeaders(req.responseHeaders ?? new Headers(), response.headers), + req, + }), status: response.status, statusText: response.statusText, }) From a675c04c99bf05dfefdf81365ce7b78dff40986a Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Wed, 16 Apr 2025 09:16:43 -0400 Subject: [PATCH 08/21] fix: respects boolean query preset constraints (#12124) Returning a boolean value from a constraint-level access control function does nothing. For example: ```ts { label: 'Noone', value: 'noone', access: () => false, }, ``` This is because we were only handling query objects, disregarding any boolean values. The fix is to check if the query is a boolean, and if so, format a query object to return. --- packages/payload/src/query-presets/access.ts | 12 ++++++- .../payload/src/query-presets/constraints.ts | 2 +- packages/payload/src/query-presets/types.ts | 2 +- test/query-presets/config.ts | 9 +++-- test/query-presets/int.spec.ts | 36 +++++++++++++++++++ test/query-presets/payload-types.ts | 34 +++++++++--------- test/query-presets/seed.ts | 15 ++++++++ tsconfig.base.json | 2 +- 8 files changed, 89 insertions(+), 23 deletions(-) diff --git a/packages/payload/src/query-presets/access.ts b/packages/payload/src/query-presets/access.ts index 4d62a088ca..d7bb050493 100644 --- a/packages/payload/src/query-presets/access.ts +++ b/packages/payload/src/query-presets/access.ts @@ -71,7 +71,17 @@ export const getAccess = (config: Config): Record => return { and: [ - ...(typeof constraintAccess === 'object' ? [constraintAccess] : []), + ...(typeof constraintAccess === 'object' + ? [constraintAccess] + : constraintAccess === false + ? [ + { + id: { + equals: null, + }, + }, + ] + : []), { [`access.${operation}.constraint`]: { equals: constraint.value, diff --git a/packages/payload/src/query-presets/constraints.ts b/packages/payload/src/query-presets/constraints.ts index 2453276b54..8e9cec4f5d 100644 --- a/packages/payload/src/query-presets/constraints.ts +++ b/packages/payload/src/query-presets/constraints.ts @@ -78,7 +78,7 @@ export const getConstraints = (config: Config): Field => ({ }, ...(config?.queryPresets?.constraints?.[operation]?.reduce( (acc: Field[], option: QueryPresetConstraint) => { - option.fields.forEach((field, index) => { + option.fields?.forEach((field, index) => { acc.push({ ...field }) if (fieldAffectsData(field)) { diff --git a/packages/payload/src/query-presets/types.ts b/packages/payload/src/query-presets/types.ts index a2f35de730..722a2fc6e2 100644 --- a/packages/payload/src/query-presets/types.ts +++ b/packages/payload/src/query-presets/types.ts @@ -25,7 +25,7 @@ export type QueryPreset = { export type QueryPresetConstraint = { access: Access - fields: Field[] + fields?: Field[] label: string value: string } diff --git a/test/query-presets/config.ts b/test/query-presets/config.ts index 2a786ef4af..ebf5dd1119 100644 --- a/test/query-presets/config.ts +++ b/test/query-presets/config.ts @@ -24,9 +24,9 @@ export default buildConfigWithDefaults({ // }, access: { read: ({ req: { user } }) => - user ? !user?.roles?.some((role) => role === 'anonymous') : false, + user ? user && !user?.roles?.some((role) => role === 'anonymous') : false, update: ({ req: { user } }) => - user ? !user?.roles?.some((role) => role === 'anonymous') : false, + user ? user && !user?.roles?.some((role) => role === 'anonymous') : false, }, constraints: { read: [ @@ -40,6 +40,11 @@ export default buildConfigWithDefaults({ }, }), }, + { + label: 'Noone', + value: 'noone', + access: () => false, + }, ], update: [ { diff --git a/test/query-presets/int.spec.ts b/test/query-presets/int.spec.ts index 3fba8f4f65..42bd771ac1 100644 --- a/test/query-presets/int.spec.ts +++ b/test/query-presets/int.spec.ts @@ -503,6 +503,42 @@ describe('Query Presets', () => { expect((error as Error).message).toBe('You are not allowed to perform this action.') } }) + + it('should respect boolean access control results', async () => { + // create a preset with the read constraint set to "noone" + const presetForNoone = await payload.create({ + collection: queryPresetsCollectionSlug, + user, + data: { + relatedCollection: 'pages', + title: 'Noone', + where: { + text: { + equals: 'example page', + }, + }, + access: { + read: { + constraint: 'noone', + }, + }, + }, + }) + + try { + const foundPresetWithUser1 = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + user, + overrideAccess: false, + id: presetForNoone.id, + }) + + expect(foundPresetWithUser1).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('Not Found') + } + }) }) it.skip('should disable query presets when "enabledQueryPresets" is not true on the collection', async () => { diff --git a/test/query-presets/payload-types.ts b/test/query-presets/payload-types.ts index 759cc0ae93..3918740441 100644 --- a/test/query-presets/payload-types.ts +++ b/test/query-presets/payload-types.ts @@ -86,7 +86,7 @@ export interface Config { 'payload-query-presets': PayloadQueryPresetsSelect | PayloadQueryPresetsSelect; }; db: { - defaultIDType: number; + defaultIDType: string; }; globals: {}; globalsSelect: {}; @@ -122,7 +122,7 @@ export interface UserAuthOperations { * via the `definition` "pages". */ export interface Page { - id: number; + id: string; text?: string | null; updatedAt: string; createdAt: string; @@ -133,7 +133,7 @@ export interface Page { * via the `definition` "users". */ export interface User { - id: number; + id: string; name?: string | null; roles?: ('admin' | 'user' | 'anonymous')[] | null; updatedAt: string; @@ -152,7 +152,7 @@ export interface User { * via the `definition` "posts". */ export interface Post { - id: number; + id: string; text?: string | null; updatedAt: string; createdAt: string; @@ -163,24 +163,24 @@ export interface Post { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: number; + id: string; document?: | ({ relationTo: 'pages'; - value: number | Page; + value: string | Page; } | null) | ({ relationTo: 'users'; - value: number | User; + value: string | User; } | null) | ({ relationTo: 'posts'; - value: number | Post; + value: string | Post; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: number | User; + value: string | User; }; updatedAt: string; createdAt: string; @@ -190,10 +190,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?: @@ -213,7 +213,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: number; + id: string; name?: string | null; batch?: number | null; updatedAt: string; @@ -224,23 +224,23 @@ export interface PayloadMigration { * via the `definition` "payload-query-presets". */ export interface PayloadQueryPreset { - id: number; + id: string; title: string; isShared?: boolean | null; access?: { read?: { - constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles') | null; - users?: (number | User)[] | null; + constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles' | 'noone') | null; + users?: (string | User)[] | null; roles?: ('admin' | 'user' | 'anonymous')[] | null; }; update?: { constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles') | null; - users?: (number | User)[] | null; + users?: (string | User)[] | null; roles?: ('admin' | 'user' | 'anonymous')[] | null; }; delete?: { constraint?: ('everyone' | 'onlyMe' | 'specificUsers') | null; - users?: (number | User)[] | null; + users?: (string | User)[] | null; }; }; where?: diff --git a/test/query-presets/seed.ts b/test/query-presets/seed.ts index 38f116f67e..029c283919 100644 --- a/test/query-presets/seed.ts +++ b/test/query-presets/seed.ts @@ -167,6 +167,21 @@ export const seed = async (_payload: Payload) => { overrideAccess: false, data: seedData.onlyMe, }), + () => + _payload.create({ + collection: 'payload-query-presets', + user: devUser, + overrideAccess: false, + data: { + relatedCollection: 'pages', + title: 'Noone', + access: { + read: { + constraint: 'noone', + }, + }, + }, + }), ], false, ) diff --git a/tsconfig.base.json b/tsconfig.base.json index e2b64e3bc8..daa36c7211 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/form-state/config.ts"], + "@payload-config": ["./test/query-presets/config.ts"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], From b9832f40e41fff0a5a5b7bf7b08032aa2d6e8b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Djakovi=C4=87?= Date: Wed, 16 Apr 2025 16:27:42 +0200 Subject: [PATCH 09/21] docs: fix syntax issue in blocks field (#11855) ### What? This PR fixes the config example in the block field page. ### Why? The syntax was incorrect ### How? Missing object property --- docs/fields/blocks.mdx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/fields/blocks.mdx b/docs/fields/blocks.mdx index 143abd58f4..edb5e51e86 100644 --- a/docs/fields/blocks.mdx +++ b/docs/fields/blocks.mdx @@ -359,10 +359,12 @@ const config = buildConfig({ name: 'editor', type: 'richText', editor: lexicalEditor({ - BlocksFeature({ - // Same reference can be reused anywhere, even in the lexical editor, without incurred performance hit - blocks: ['TextBlock'], - }) + features: [ + BlocksFeature({ + // Same reference can be reused anywhere, even in the lexical editor, without incurred performance hit + blocks: ['TextBlock'], + }), + ], }) }, ], From 23628996d057cc5570937220c56fffc7c9ea1ecc Mon Sep 17 00:00:00 2001 From: Tylan Davis <89618855+tylandavis@users.noreply.github.com> Date: Wed, 16 Apr 2025 13:24:10 -0400 Subject: [PATCH 10/21] chore: adjusts ChevronIcon styling to match other icons (#12133) ### What? Adjusts the `ChevronIcon` component to match the sizing of other icons in the `ui` package. Also adds various styling adjustments to places where icons are used. ### Why? Using the `ChevronIcon` in other elements currently requires different styling to make it consistent with other icons. This will make it so that any usage of the any icons is consistent across components. ### How? Resizes the `ChevronIcon` components and updates styling throughout the admin panel. --- .../next/src/views/LivePreview/Toolbar/Controls/index.tsx | 2 -- packages/ui/src/elements/BulkUpload/ActionsBar/index.scss | 4 ---- packages/ui/src/elements/Button/index.scss | 2 +- .../ui/src/elements/Localizer/LocalizerLabel/index.scss | 3 +-- .../ui/src/elements/Localizer/LocalizerLabel/index.tsx | 2 +- packages/ui/src/elements/PerPage/index.scss | 4 ---- packages/ui/src/elements/Pill/index.scss | 3 ++- packages/ui/src/elements/Popup/PopupTrigger/index.scss | 6 +++--- packages/ui/src/elements/ReactSelect/index.scss | 4 ---- packages/ui/src/elements/SortColumn/index.scss | 2 +- packages/ui/src/icons/Chevron/index.scss | 8 ++++---- packages/ui/src/icons/Chevron/index.tsx | 4 ++-- 12 files changed, 15 insertions(+), 29 deletions(-) diff --git a/packages/next/src/views/LivePreview/Toolbar/Controls/index.tsx b/packages/next/src/views/LivePreview/Toolbar/Controls/index.tsx index 19fef263af..148f967964 100644 --- a/packages/next/src/views/LivePreview/Toolbar/Controls/index.tsx +++ b/packages/next/src/views/LivePreview/Toolbar/Controls/index.tsx @@ -31,7 +31,6 @@ export const ToolbarControls: React.FC = () => { {breakpoints.find((bp) => bp.name == breakpoint)?.label ?? customOption.label} -   } @@ -82,7 +81,6 @@ export const ToolbarControls: React.FC = () => { button={ {zoom * 100}% -   } diff --git a/packages/ui/src/elements/BulkUpload/ActionsBar/index.scss b/packages/ui/src/elements/BulkUpload/ActionsBar/index.scss index a1e95f4b7f..5f09ceb7c2 100644 --- a/packages/ui/src/elements/BulkUpload/ActionsBar/index.scss +++ b/packages/ui/src/elements/BulkUpload/ActionsBar/index.scss @@ -33,10 +33,6 @@ width: calc(var(--base) * 1.2); height: calc(var(--base) * 1.2); - svg { - max-width: 1rem; - } - &:hover { background-color: var(--theme-elevation-200); } diff --git a/packages/ui/src/elements/Button/index.scss b/packages/ui/src/elements/Button/index.scss index 389b40b627..c471d6ebce 100644 --- a/packages/ui/src/elements/Button/index.scss +++ b/packages/ui/src/elements/Button/index.scss @@ -6,7 +6,7 @@ } .btn--withPopup { - margin-block: 24px; + margin-block: 4px; .btn { margin: 0; } diff --git a/packages/ui/src/elements/Localizer/LocalizerLabel/index.scss b/packages/ui/src/elements/Localizer/LocalizerLabel/index.scss index 545fc80954..d3590b927c 100644 --- a/packages/ui/src/elements/Localizer/LocalizerLabel/index.scss +++ b/packages/ui/src/elements/Localizer/LocalizerLabel/index.scss @@ -7,7 +7,7 @@ white-space: nowrap; display: flex; padding-inline-start: base(0.4); - padding-inline-end: base(0.4); + padding-inline-end: base(0.2); background-color: var(--theme-elevation-100); border-radius: var(--style-radius-s); @@ -24,7 +24,6 @@ &__current { display: flex; align-items: center; - gap: base(0.3); } button { diff --git a/packages/ui/src/elements/Localizer/LocalizerLabel/index.tsx b/packages/ui/src/elements/Localizer/LocalizerLabel/index.tsx index 2ddfe6cc3c..c6665d66be 100644 --- a/packages/ui/src/elements/Localizer/LocalizerLabel/index.tsx +++ b/packages/ui/src/elements/Localizer/LocalizerLabel/index.tsx @@ -28,7 +28,7 @@ export const LocalizerLabel: React.FC<{ {`${getTranslation(locale.label, i18n)}`} - +
) diff --git a/packages/ui/src/elements/PerPage/index.scss b/packages/ui/src/elements/PerPage/index.scss index 96a24a52c0..ce7ac9700c 100644 --- a/packages/ui/src/elements/PerPage/index.scss +++ b/packages/ui/src/elements/PerPage/index.scss @@ -36,10 +36,6 @@ } } - &__chevron { - padding-left: calc(var(--base) / 4); - } - &__button-active { font-weight: bold; color: var(--theme-text); diff --git a/packages/ui/src/elements/Pill/index.scss b/packages/ui/src/elements/Pill/index.scss index f370546f3b..27670fd1ab 100644 --- a/packages/ui/src/elements/Pill/index.scss +++ b/packages/ui/src/elements/Pill/index.scss @@ -55,8 +55,9 @@ } &--has-icon { + gap: 0; padding-inline-start: base(0.4); - padding-inline-end: base(0.3); + padding-inline-end: base(0.1); svg { display: block; diff --git a/packages/ui/src/elements/Popup/PopupTrigger/index.scss b/packages/ui/src/elements/Popup/PopupTrigger/index.scss index c3626bcb83..08cab98043 100644 --- a/packages/ui/src/elements/Popup/PopupTrigger/index.scss +++ b/packages/ui/src/elements/Popup/PopupTrigger/index.scss @@ -17,15 +17,15 @@ } &--size-small { - padding: base(0.4); + padding: base(0.2); } &--size-medium { - padding: base(0.6); + padding: base(0.3); } &--size-large { - padding: base(0.8); + padding: base(0.4); } &--disabled { diff --git a/packages/ui/src/elements/ReactSelect/index.scss b/packages/ui/src/elements/ReactSelect/index.scss index a76e639335..1cfd4e12e6 100644 --- a/packages/ui/src/elements/ReactSelect/index.scss +++ b/packages/ui/src/elements/ReactSelect/index.scss @@ -17,10 +17,6 @@ padding: base(0.5) base(0.6); } - .rs__indicators { - gap: calc(var(--base) / 4); - } - .rs__indicator { padding: 0px 4px; cursor: pointer; diff --git a/packages/ui/src/elements/SortColumn/index.scss b/packages/ui/src/elements/SortColumn/index.scss index bfdadfac9b..31b61f8676 100644 --- a/packages/ui/src/elements/SortColumn/index.scss +++ b/packages/ui/src/elements/SortColumn/index.scss @@ -31,7 +31,7 @@ &__button { margin: 0; opacity: 0.3; - padding: calc(var(--base) / 4); + padding: calc(var(--base) / 4) 0; display: inline-flex; align-items: center; justify-content: center; diff --git a/packages/ui/src/icons/Chevron/index.scss b/packages/ui/src/icons/Chevron/index.scss index 5cad750b50..f984fe8c71 100644 --- a/packages/ui/src/icons/Chevron/index.scss +++ b/packages/ui/src/icons/Chevron/index.scss @@ -2,8 +2,8 @@ @layer payload-default { .icon--chevron { - height: calc(var(--base) / 2); - width: calc(var(--base) / 2); + height: var(--base); + width: var(--base); .stroke { fill: none; @@ -18,8 +18,8 @@ } &.icon--size-small { - height: 8px; - width: 8px; + height: 12px; + width: 12px; } } } diff --git a/packages/ui/src/icons/Chevron/index.tsx b/packages/ui/src/icons/Chevron/index.tsx index 93c7b23c6c..d59ac0b8d3 100644 --- a/packages/ui/src/icons/Chevron/index.tsx +++ b/packages/ui/src/icons/Chevron/index.tsx @@ -24,10 +24,10 @@ export const ChevronIcon: React.FC<{ ? 'rotate(180deg)' : undefined, }} - viewBox="0 0 22 12" + viewBox="0 0 20 20" width="100%" xmlns="http://www.w3.org/2000/svg" > - + ) From 4426625b83910c3dc2daba8706ffdaebf6963401 Mon Sep 17 00:00:00 2001 From: Philipp Schneider <47689073+philipp-tailor@users.noreply.github.com> Date: Wed, 16 Apr 2025 21:03:35 +0200 Subject: [PATCH 11/21] perf(ui): prevent `blockType: "$undefined"` from being sent through the network (#12131) Removes `$undefined` strings from being sent through the network when sending form state requests. When adding new array rows, we assign `blockType: undefined` which is stringified to `"$undefined"`. This is unnecessary, as simply not sending this property is equivalent, and this is only a requirement for blocks. This change will save on request size, albeit minimal. | Before | After | |--|--| |Untitled | image | --- packages/ui/src/forms/Form/fieldReducer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/forms/Form/fieldReducer.ts b/packages/ui/src/forms/Form/fieldReducer.ts index d305a0e7ab..2260a2d940 100644 --- a/packages/ui/src/forms/Form/fieldReducer.ts +++ b/packages/ui/src/forms/Form/fieldReducer.ts @@ -28,11 +28,14 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState { const newRow: Row = { id: (subFieldState?.id?.value as string) || new ObjectId().toHexString(), - blockType: blockType || undefined, collapsed: false, isLoading: true, } + if (blockType) { + newRow.blockType = blockType + } + withNewRow.splice(rowIndex, 0, newRow) if (blockType) { From c877b1ad4346a6b26d77c44944c06adf46430326 Mon Sep 17 00:00:00 2001 From: Patrik Date: Wed, 16 Apr 2025 15:38:53 -0400 Subject: [PATCH 12/21] feat: threads operation through field condition function (#12132) This PR updates the field `condition` function property to include a new `operation` argument. The `operation` arg provides a string relating to which operation the field type is currently executing within. #### Changes: - Added `operation: Operation` in the Condition type. - Updated relevant condition checks to ensure correct parameter usage. --- docs/fields/overview.mdx | 1 + packages/payload/src/fields/config/types.ts | 5 +++++ .../src/fields/hooks/beforeChange/promise.ts | 7 ++++++- .../addFieldStatePromise.ts | 1 + .../fieldSchemasToFormState/iterateFields.ts | 1 + .../collections/ConditionalLogic/e2e.spec.ts | 16 ++++++++++++++++ .../fields/collections/ConditionalLogic/index.ts | 15 ++++++++++++++- test/fields/payload-types.ts | 2 ++ 8 files changed, 46 insertions(+), 2 deletions(-) diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx index 5b8b0bf463..17b46884c5 100644 --- a/docs/fields/overview.mdx +++ b/docs/fields/overview.mdx @@ -541,6 +541,7 @@ The `ctx` object: | Property | Description | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **`blockData`** | The nearest parent block's data. If the field is not inside a block, this will be `undefined`. | +| **`operation`** | A string relating to which operation the field type is currently executing within. | | **`path`** | The full path to the field in the schema, represented as an array of string segments, including array indexes. I.e `['group', 'myArray', '1', 'textField']`. | | **`user`** | The currently authenticated user object. | diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index db91662f59..835c91da5e 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -269,6 +269,7 @@ export type Condition = ( siblingData: Partial, { blockData, + operation, path, user, }: { @@ -276,6 +277,10 @@ export type Condition = ( * The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`. */ blockData: Partial + /** + * A string relating to which operation the field type is currently executing within. + */ + operation: Operation /** * The path of the field, e.g. ["group", "myArray", 1, "textField"]. The path is the schemaPath but with indexes and would be used in the context of field data, not field schemas. */ diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index 662e4706f9..87a323a852 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -109,7 +109,12 @@ export const promise = async ({ const passesCondition = field.admin?.condition ? Boolean( - field.admin.condition(data, siblingData, { blockData, path: pathSegments, user: req.user }), + field.admin.condition(data, siblingData, { + blockData, + operation, + path: pathSegments, + user: req.user, + }), ) : true let skipValidationFromHere = skipValidation || !passesCondition diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index 0951315668..b3896560dc 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -827,6 +827,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom if (passesCondition && typeof tab.admin?.condition === 'function') { tabPassesCondition = tab.admin.condition(fullData, data, { blockData, + operation, path: pathSegments, user: req.user, }) diff --git a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts index 5e7b2e5340..f934198319 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts @@ -151,6 +151,7 @@ export const iterateFields = async ({ ? Boolean( field.admin.condition(fullData || {}, data || {}, { blockData, + operation, path: pathSegments, user: req.user, }), diff --git a/test/fields/collections/ConditionalLogic/e2e.spec.ts b/test/fields/collections/ConditionalLogic/e2e.spec.ts index 3c990f2347..a5dc5ed9d2 100644 --- a/test/fields/collections/ConditionalLogic/e2e.spec.ts +++ b/test/fields/collections/ConditionalLogic/e2e.spec.ts @@ -10,6 +10,7 @@ import type { Config } from '../../payload-types.js' import { ensureCompilationIsDone, initPageConsoleErrorCatch, + saveDocAndAssert, // throttleTest, } from '../../../helpers.js' import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' @@ -225,4 +226,19 @@ describe('Conditional Logic', () => { await expect(numberField).toBeVisible() }) + + test('should render field based on operation argument', async () => { + await page.goto(url.create) + + const textField = page.locator('#field-text') + const fieldWithOperationCondition = page.locator('#field-fieldWithOperationCondition') + + await textField.fill('some text') + + await expect(fieldWithOperationCondition).toBeVisible() + + await saveDocAndAssert(page) + + await expect(fieldWithOperationCondition).toBeHidden() + }) }) diff --git a/test/fields/collections/ConditionalLogic/index.ts b/test/fields/collections/ConditionalLogic/index.ts index 864eff4462..96af0df38c 100644 --- a/test/fields/collections/ConditionalLogic/index.ts +++ b/test/fields/collections/ConditionalLogic/index.ts @@ -24,6 +24,19 @@ const ConditionalLogic: CollectionConfig = { condition: ({ toggleField }) => Boolean(toggleField), }, }, + { + name: 'fieldWithOperationCondition', + type: 'text', + admin: { + condition: (data, siblingData, { operation }) => { + if (operation === 'create') { + return true + } + + return false + }, + }, + }, { name: 'customFieldWithField', type: 'text', @@ -217,7 +230,7 @@ const ConditionalLogic: CollectionConfig = { name: 'numberField', type: 'number', admin: { - condition: (data, siblingData, { path, user }) => { + condition: (data, siblingData, { path }) => { // Ensure path has enough depth if (path.length < 5) { return false diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 4c5ae89238..8b1ce2ad53 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -790,6 +790,7 @@ export interface ConditionalLogic { text: string; toggleField?: boolean | null; fieldWithCondition?: string | null; + fieldWithOperationCondition?: string | null; customFieldWithField?: string | null; customFieldWithHOC?: string | null; customClientFieldWithCondition?: string | null; @@ -2364,6 +2365,7 @@ export interface ConditionalLogicSelect { text?: T; toggleField?: T; fieldWithCondition?: T; + fieldWithOperationCondition?: T; customFieldWithField?: T; customFieldWithHOC?: T; customClientFieldWithCondition?: T; From 1c99f46e4fa705c15c85e0173a5387622a000a4f Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Wed, 16 Apr 2025 22:46:18 +0300 Subject: [PATCH 13/21] feat: queriable / sortable / `useAsTitle` virtual fields linked with a relationship field (#11805) This PR adds an ability to specify a virtual field in this way ```js { slug: 'posts', fields: [ { name: 'title', type: 'text', required: true, }, ], }, { slug: 'virtual-relations', fields: [ { name: 'postTitle', type: 'text', virtual: 'post.title', }, { name: 'post', type: 'relationship', relationTo: 'posts', }, ], }, ``` Then, every time you query `virtual-relations`, `postTitle` will be automatically populated (even if using `depth: 0`) on the db level. This field also, unlike `virtual: true` is available for querying / sorting / `useAsTitle`. Also, the field can be deeply nested to 2 or more relationships, for example: ``` { name: 'postCategoryTitle', type: 'text', virtual: 'post.category.title', }, ``` Where the current collection has `post` - a relationship to `posts`, the collection `posts` has `category` that's a relationship to `categories` and finally `categories` has `title`. --- docs/configuration/overview.mdx | 4 +- docs/fields/blocks.mdx | 4 +- docs/getting-started/installation.mdx | 3 +- .../src/collections/config/useAsTitle.ts | 4 +- .../src/collections/operations/find.ts | 13 +- .../src/collections/operations/update.ts | 8 +- .../operations/utilities/sanitizeSortQuery.ts | 51 +++++++ .../queryValidation/validateQueryPaths.ts | 56 ++++--- .../queryValidation/validateSearchParams.ts | 14 +- packages/payload/src/fields/config/types.ts | 6 +- .../src/fields/hooks/afterRead/promise.ts | 21 ++- .../virtualFieldPopulationPromise.ts | 144 ++++++++++++++++++ packages/plugin-seo/src/translations/es.ts | 2 +- test/database/config.ts | 62 ++++++++ test/database/int.spec.ts | 127 +++++++++++++++ test/database/payload-types.ts | 88 +++++++++++ 16 files changed, 568 insertions(+), 39 deletions(-) create mode 100644 packages/payload/src/collections/operations/utilities/sanitizeSortQuery.ts create mode 100644 packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index 6b1b28c9fa..25b1d9c83f 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -240,8 +240,8 @@ export default buildConfig({ // highlight-start cors: { origins: ['http://localhost:3000'], - headers: ['x-custom-header'] - } + headers: ['x-custom-header'], + }, // highlight-end }) ``` diff --git a/docs/fields/blocks.mdx b/docs/fields/blocks.mdx index edb5e51e86..aa1b860c2c 100644 --- a/docs/fields/blocks.mdx +++ b/docs/fields/blocks.mdx @@ -352,7 +352,7 @@ const config = buildConfig({ }, ], }, - { + { slug: 'collection2', fields: [ { @@ -365,7 +365,7 @@ const config = buildConfig({ blocks: ['TextBlock'], }), ], - }) + }), }, ], }, diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index d42d3e7ca4..2f507862a6 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -63,6 +63,7 @@ To install a Database Adapter, you can run **one** of the following commands: ``` - To install the [Postgres Adapter](../database/postgres), run: + ```bash pnpm i @payloadcms/db-postgres ``` @@ -80,7 +81,7 @@ To install a Database Adapter, you can run **one** of the following commands: #### 2. Copy Payload files into your Next.js app folder -Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template](https://github.com/payloadcms/payload/tree/main/templates/blank/src/app/(payload)) on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this: +Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template]() on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this: ```plaintext app/ diff --git a/packages/payload/src/collections/config/useAsTitle.ts b/packages/payload/src/collections/config/useAsTitle.ts index a57aa32e17..c4633f94f7 100644 --- a/packages/payload/src/collections/config/useAsTitle.ts +++ b/packages/payload/src/collections/config/useAsTitle.ts @@ -33,9 +33,9 @@ export const validateUseAsTitle = (config: CollectionConfig) => { } } } else { - if (useAsTitleField && fieldIsVirtual(useAsTitleField)) { + if (useAsTitleField && 'virtual' in useAsTitleField && useAsTitleField.virtual === true) { throw new InvalidConfiguration( - `The field "${config.admin.useAsTitle}" specified in "admin.useAsTitle" in the collection "${config.slug}" is virtual. A virtual field cannot be used as the title.`, + `The field "${config.admin.useAsTitle}" specified in "admin.useAsTitle" in the collection "${config.slug}" is virtual. A virtual field can be used as the title only when linked to a relationship field.`, ) } if (!useAsTitleField) { diff --git a/packages/payload/src/collections/operations/find.ts b/packages/payload/src/collections/operations/find.ts index 48810c7285..f0c9117f00 100644 --- a/packages/payload/src/collections/operations/find.ts +++ b/packages/payload/src/collections/operations/find.ts @@ -28,6 +28,7 @@ import { buildVersionCollectionFields } from '../../versions/buildCollectionFiel import { appendVersionToQueryKey } from '../../versions/drafts/appendVersionToQueryKey.js' import { getQueryDraftsSelect } from '../../versions/drafts/getQueryDraftsSelect.js' import { getQueryDraftsSort } from '../../versions/drafts/getQueryDraftsSort.js' +import { sanitizeSortQuery } from './utilities/sanitizeSortQuery.js' import { buildAfterOperation } from './utils.js' export type Arguments = { @@ -96,7 +97,7 @@ export const findOperation = async < req, select: incomingSelect, showHiddenFields, - sort, + sort: incomingSort, where, } = args @@ -143,6 +144,11 @@ export const findOperation = async < let fullWhere = combineQueries(where, accessResult) + const sort = sanitizeSortQuery({ + fields: collection.config.flattenedFields, + sort: incomingSort, + }) + const sanitizedJoins = await sanitizeJoinQuery({ collectionConfig, joins, @@ -170,7 +176,10 @@ export const findOperation = async < pagination: usePagination, req, select: getQueryDraftsSelect({ select }), - sort: getQueryDraftsSort({ collectionConfig, sort }), + sort: getQueryDraftsSort({ + collectionConfig, + sort, + }), where: fullWhere, }) } else { diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index f64ca7f47d..ab2e2308fa 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -27,6 +27,7 @@ import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js' import { appendVersionToQueryKey } from '../../versions/drafts/appendVersionToQueryKey.js' import { getQueryDraftsSort } from '../../versions/drafts/getQueryDraftsSort.js' +import { sanitizeSortQuery } from './utilities/sanitizeSortQuery.js' import { updateDocument } from './utilities/update.js' import { buildAfterOperation } from './utils.js' @@ -103,7 +104,7 @@ export const updateOperation = async < req, select: incomingSelect, showHiddenFields, - sort, + sort: incomingSort, where, } = args @@ -136,6 +137,11 @@ export const updateOperation = async < const fullWhere = combineQueries(where, accessResult) + const sort = sanitizeSortQuery({ + fields: collection.config.flattenedFields, + sort: incomingSort, + }) + let docs if (collectionConfig.versions?.drafts && shouldSaveDraft) { diff --git a/packages/payload/src/collections/operations/utilities/sanitizeSortQuery.ts b/packages/payload/src/collections/operations/utilities/sanitizeSortQuery.ts new file mode 100644 index 0000000000..692d13d318 --- /dev/null +++ b/packages/payload/src/collections/operations/utilities/sanitizeSortQuery.ts @@ -0,0 +1,51 @@ +import type { FlattenedField } from '../../../fields/config/types.js' + +const sanitizeSort = ({ fields, sort }: { fields: FlattenedField[]; sort: string }): string => { + let sortProperty = sort + let desc = false + if (sort.indexOf('-') === 0) { + desc = true + sortProperty = sortProperty.substring(1) + } + + const segments = sortProperty.split('.') + + for (const segment of segments) { + const field = fields.find((each) => each.name === segment) + if (!field) { + return sort + } + + if ('fields' in field) { + fields = field.flattenedFields + continue + } + + if ('virtual' in field && typeof field.virtual === 'string') { + return `${desc ? '-' : ''}${field.virtual}` + } + } + + return sort +} + +/** + * Sanitizes the sort parameter, for example virtual fields linked to relationships are replaced with the full path. + */ +export const sanitizeSortQuery = ({ + fields, + sort, +}: { + fields: FlattenedField[] + sort?: string | string[] +}): string | string[] | undefined => { + if (!sort) { + return undefined + } + + if (Array.isArray(sort)) { + return sort.map((sort) => sanitizeSort({ fields, sort })) + } + + return sanitizeSort({ fields, sort }) +} diff --git a/packages/payload/src/database/queryValidation/validateQueryPaths.ts b/packages/payload/src/database/queryValidation/validateQueryPaths.ts index 563b90b051..c6af5ccf23 100644 --- a/packages/payload/src/database/queryValidation/validateQueryPaths.ts +++ b/packages/payload/src/database/queryValidation/validateQueryPaths.ts @@ -28,22 +28,6 @@ type Args = { } ) -const flattenWhere = (query: Where): WhereField[] => { - const flattenedConstraints: WhereField[] = [] - - for (const [key, val] of Object.entries(query)) { - if ((key === 'and' || key === 'or') && Array.isArray(val)) { - for (const subVal of val) { - flattenedConstraints.push(...flattenWhere(subVal)) - } - } else { - flattenedConstraints.push({ [key]: val }) - } - } - - return flattenedConstraints -} - export async function validateQueryPaths({ collectionConfig, errors = [], @@ -61,17 +45,47 @@ export async function validateQueryPaths({ const fields = versionFields || (globalConfig || collectionConfig).flattenedFields if (typeof where === 'object') { - const whereFields = flattenWhere(where) // We need to determine if the whereKey is an AND, OR, or a schema path const promises = [] - for (const constraint of whereFields) { - for (const path in constraint) { - for (const operator in constraint[path]) { - const val = constraint[path][operator] + for (const path in where) { + const constraint = where[path] + + if ((path === 'and' || path === 'or') && Array.isArray(constraint)) { + for (const item of constraint) { + if (collectionConfig) { + promises.push( + validateQueryPaths({ + collectionConfig, + errors, + overrideAccess, + policies, + req, + versionFields, + where: item, + }), + ) + } else { + promises.push( + validateQueryPaths({ + errors, + globalConfig, + overrideAccess, + policies, + req, + versionFields, + where: item, + }), + ) + } + } + } else if (!Array.isArray(constraint)) { + for (const operator in constraint) { + const val = constraint[operator] if (validOperatorSet.has(operator as Operator)) { promises.push( validateSearchParam({ collectionConfig, + constraint: where as WhereField, errors, fields, globalConfig, diff --git a/packages/payload/src/database/queryValidation/validateSearchParams.ts b/packages/payload/src/database/queryValidation/validateSearchParams.ts index 9ec7fde56d..125df0336a 100644 --- a/packages/payload/src/database/queryValidation/validateSearchParams.ts +++ b/packages/payload/src/database/queryValidation/validateSearchParams.ts @@ -2,17 +2,19 @@ import type { SanitizedCollectionConfig } from '../../collections/config/types.js' import type { FlattenedField } from '../../fields/config/types.js' import type { SanitizedGlobalConfig } from '../../globals/config/types.js' -import type { PayloadRequest } from '../../types/index.js' +import type { PayloadRequest, WhereField } from '../../types/index.js' import type { EntityPolicies, PathToQuery } from './types.js' import { fieldAffectsData, fieldIsVirtual } from '../../fields/config/types.js' import { getEntityPolicies } from '../../utilities/getEntityPolicies.js' +import { getFieldByPath } from '../../utilities/getFieldByPath.js' import isolateObjectProperty from '../../utilities/isolateObjectProperty.js' import { getLocalizedPaths } from '../getLocalizedPaths.js' import { validateQueryPaths } from './validateQueryPaths.js' type Args = { collectionConfig?: SanitizedCollectionConfig + constraint: WhereField errors: { path: string }[] fields: FlattenedField[] globalConfig?: SanitizedGlobalConfig @@ -32,6 +34,7 @@ type Args = { */ export async function validateSearchParam({ collectionConfig, + constraint, errors, fields, globalConfig, @@ -100,8 +103,13 @@ export async function validateSearchParam({ return } - if (fieldIsVirtual(field)) { - errors.push({ path }) + if ('virtual' in field && field.virtual) { + if (field.virtual === true) { + errors.push({ path }) + } else { + constraint[`${field.virtual}`] = constraint[path] + delete constraint[path] + } } if (polymorphicJoin && path === 'relationTo') { diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 835c91da5e..3050e6250c 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -514,9 +514,9 @@ export interface FieldBase { /** * Pass `true` to disable field in the DB * for [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges): - * A virtual field cannot be used in `admin.useAsTitle` + * A virtual field can be used in `admin.useAsTitle` only when linked to a relationship. */ - virtual?: boolean + virtual?: boolean | string } export interface FieldBaseClient { @@ -1955,7 +1955,7 @@ export function fieldShouldBeLocalized({ } export function fieldIsVirtual(field: Field | Tab): boolean { - return 'virtual' in field && field.virtual + return 'virtual' in field && Boolean(field.virtual) } export type HookName = diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index fd2b0d1443..20612d465c 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -2,7 +2,6 @@ import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { RequestContext } from '../../../index.js' import type { JsonObject, PayloadRequest, @@ -13,6 +12,7 @@ import type { import type { Block, Field, TabAsField } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' +import { type RequestContext } from '../../../index.js' import { getBlockSelect } from '../../../utilities/getBlockSelect.js' import { stripUnselectedFields } from '../../../utilities/stripUnselectedFields.js' import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../../config/types.js' @@ -20,6 +20,7 @@ import { getDefaultValue } from '../../getDefaultValue.js' import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js' import { relationshipPopulationPromise } from './relationshipPopulationPromise.js' import { traverseFields } from './traverseFields.js' +import { virtualFieldPopulationPromise } from './virtualFieldPopulationPromise.js' type Args = { /** @@ -306,6 +307,24 @@ export const promise = async ({ } } + if ('virtual' in field && typeof field.virtual === 'string') { + populationPromises.push( + virtualFieldPopulationPromise({ + name: field.name, + draft, + fallbackLocale, + fields: (collection || global).flattenedFields, + locale, + overrideAccess, + ref: doc, + req, + segments: field.virtual.split('.'), + showHiddenFields, + siblingDoc, + }), + ) + } + // Execute access control let allowDefaultValue = true if (triggerAccessControl && field.access && field.access.read) { diff --git a/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts b/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts new file mode 100644 index 0000000000..4c8eb758d1 --- /dev/null +++ b/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts @@ -0,0 +1,144 @@ +import type { PayloadRequest } from '../../../types/index.js' +import type { FlattenedField } from '../../config/types.js' + +import { createDataloaderCacheKey } from '../../../collections/dataloader.js' + +export const virtualFieldPopulationPromise = async ({ + name, + draft, + fallbackLocale, + fields, + locale, + overrideAccess, + ref, + req, + segments, + showHiddenFields, + siblingDoc, +}: { + draft: boolean + fallbackLocale: string + fields: FlattenedField[] + locale: string + name: string + overrideAccess: boolean + ref: any + req: PayloadRequest + segments: string[] + showHiddenFields: boolean + siblingDoc: Record +}): Promise => { + const currentSegment = segments.shift() + + if (!currentSegment) { + return + } + + const currentValue = ref[currentSegment] + + if (typeof currentValue === 'undefined') { + return + } + + // Final step + if (segments.length === 0) { + siblingDoc[name] = currentValue + return + } + + const currentField = fields.find((each) => each.name === currentSegment) + + if (!currentField) { + return + } + + if (currentField.type === 'group' || currentField.type === 'tab') { + if (!currentValue || typeof currentValue !== 'object') { + return + } + + return virtualFieldPopulationPromise({ + name, + draft, + fallbackLocale, + fields: currentField.flattenedFields, + locale, + overrideAccess, + ref: currentValue, + req, + segments, + showHiddenFields, + siblingDoc, + }) + } + + if ( + (currentField.type === 'relationship' || currentField.type === 'upload') && + typeof currentField.relationTo === 'string' && + !currentField.hasMany + ) { + let docID: number | string + + if (typeof currentValue === 'object' && currentValue) { + docID = currentValue.id + } else { + docID = currentValue + } + + if (typeof docID !== 'string' && typeof docID !== 'number') { + return + } + + const select = {} + let currentSelectRef: any = select + const currentFields = req.payload.collections[currentField.relationTo].config.flattenedFields + + for (let i = 0; i < segments.length; i++) { + const field = currentFields.find((each) => each.name === segments[i]) + + const shouldBreak = + i === segments.length - 1 || field?.type === 'relationship' || field?.type === 'upload' + + currentSelectRef[segments[i]] = shouldBreak ? true : {} + currentSelectRef = currentSelectRef[segments[i]] + + if (shouldBreak) { + break + } + } + + const populatedDoc = await req.payloadDataLoader.load( + createDataloaderCacheKey({ + collectionSlug: currentField.relationTo, + currentDepth: 0, + depth: 0, + docID, + draft, + fallbackLocale, + locale, + overrideAccess, + select, + showHiddenFields, + transactionID: req.transactionID as number, + }), + ) + + if (!populatedDoc) { + return + } + + return virtualFieldPopulationPromise({ + name, + draft, + fallbackLocale, + fields: req.payload.collections[currentField.relationTo].config.flattenedFields, + locale, + overrideAccess, + ref: populatedDoc, + req, + segments, + showHiddenFields, + siblingDoc, + }) + } +} diff --git a/packages/plugin-seo/src/translations/es.ts b/packages/plugin-seo/src/translations/es.ts index fe0cc4c852..014f03ba1b 100644 --- a/packages/plugin-seo/src/translations/es.ts +++ b/packages/plugin-seo/src/translations/es.ts @@ -25,4 +25,4 @@ export const es: GenericTranslationsObject = { tooLong: 'Demasiado largo', tooShort: 'Demasiado corto', }, -} \ No newline at end of file +} diff --git a/test/database/config.ts b/test/database/config.ts index f9f8be424d..d1656c347a 100644 --- a/test/database/config.ts +++ b/test/database/config.ts @@ -36,6 +36,15 @@ export default buildConfigWithDefaults({ }, }, collections: [ + { + slug: 'categories', + fields: [ + { + type: 'text', + name: 'title', + }, + ], + }, { slug: postsSlug, fields: [ @@ -43,6 +52,17 @@ export default buildConfigWithDefaults({ name: 'title', type: 'text', required: true, + // access: { read: () => false }, + }, + { + type: 'relationship', + relationTo: 'categories', + name: 'category', + }, + { + name: 'localized', + type: 'text', + localized: true, }, { name: 'text', @@ -437,6 +457,33 @@ export default buildConfigWithDefaults({ }, ], }, + { + slug: 'virtual-relations', + admin: { useAsTitle: 'postTitle' }, + fields: [ + { + name: 'postTitle', + type: 'text', + virtual: 'post.title', + }, + { + name: 'postCategoryTitle', + type: 'text', + virtual: 'post.category.title', + }, + { + name: 'postLocalized', + type: 'text', + virtual: 'post.localized', + }, + { + name: 'post', + type: 'relationship', + relationTo: 'posts', + }, + ], + versions: { drafts: true }, + }, { slug: fieldsPersistanceSlug, fields: [ @@ -662,6 +709,21 @@ export default buildConfigWithDefaults({ }, ], }, + { + slug: 'virtual-relation-global', + fields: [ + { + type: 'text', + name: 'postTitle', + virtual: 'post.title', + }, + { + type: 'relationship', + name: 'post', + relationTo: 'posts', + }, + ], + }, ], localization: { defaultLocale: 'en', diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index 2cb385f815..a4c3210a9a 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -7,6 +7,7 @@ import { migrateRelationshipsV2_V3, migrateVersionsV1_V2, } from '@payloadcms/db-mongodb/migration-utils' +import { objectToFrontmatter } from '@payloadcms/richtext-lexical' import { randomUUID } from 'crypto' import { type Table } from 'drizzle-orm' import * as drizzlePg from 'drizzle-orm/pg-core' @@ -1977,6 +1978,132 @@ describe('database', () => { expect(res.textWithinRow).toBeUndefined() expect(res.textWithinTabs).toBeUndefined() }) + + it('should allow virtual field with reference', async () => { + const post = await payload.create({ collection: 'posts', data: { title: 'my-title' } }) + const { id } = await payload.create({ + collection: 'virtual-relations', + depth: 0, + data: { post: post.id }, + }) + + const doc = await payload.findByID({ collection: 'virtual-relations', depth: 0, id }) + expect(doc.postTitle).toBe('my-title') + const draft = await payload.find({ + collection: 'virtual-relations', + depth: 0, + where: { id: { equals: id } }, + draft: true, + }) + expect(draft.docs[0]?.postTitle).toBe('my-title') + }) + + it('should allow virtual field with reference localized', async () => { + const post = await payload.create({ + collection: 'posts', + data: { title: 'my-title', localized: 'localized en' }, + }) + + await payload.update({ + collection: 'posts', + id: post.id, + locale: 'es', + data: { localized: 'localized es' }, + }) + + const { id } = await payload.create({ + collection: 'virtual-relations', + depth: 0, + data: { post: post.id }, + }) + + let doc = await payload.findByID({ collection: 'virtual-relations', depth: 0, id }) + expect(doc.postLocalized).toBe('localized en') + + doc = await payload.findByID({ collection: 'virtual-relations', depth: 0, id, locale: 'es' }) + expect(doc.postLocalized).toBe('localized es') + }) + + it('should allow to query by a virtual field with reference', async () => { + await payload.delete({ collection: 'posts', where: {} }) + await payload.delete({ collection: 'virtual-relations', where: {} }) + const post_1 = await payload.create({ collection: 'posts', data: { title: 'Dan' } }) + const post_2 = await payload.create({ collection: 'posts', data: { title: 'Mr.Dan' } }) + + const doc_1 = await payload.create({ + collection: 'virtual-relations', + depth: 0, + data: { post: post_1.id }, + }) + const doc_2 = await payload.create({ + collection: 'virtual-relations', + depth: 0, + data: { post: post_2.id }, + }) + + const { docs: ascDocs } = await payload.find({ + collection: 'virtual-relations', + sort: 'postTitle', + depth: 0, + }) + + expect(ascDocs[0]?.id).toBe(doc_1.id) + + expect(ascDocs[1]?.id).toBe(doc_2.id) + + const { docs: descDocs } = await payload.find({ + collection: 'virtual-relations', + sort: '-postTitle', + depth: 0, + }) + + expect(descDocs[1]?.id).toBe(doc_1.id) + + expect(descDocs[0]?.id).toBe(doc_2.id) + }) + + it.todo('should allow to sort by a virtual field with reference') + + it('should allow virtual field 2x deep', async () => { + const category = await payload.create({ + collection: 'categories', + data: { title: '1-category' }, + }) + const post = await payload.create({ + collection: 'posts', + data: { title: '1-post', category: category.id }, + }) + const doc = await payload.create({ collection: 'virtual-relations', data: { post: post.id } }) + expect(doc.postCategoryTitle).toBe('1-category') + }) + + it('should allow to query by virtual field 2x deep', async () => { + const category = await payload.create({ + collection: 'categories', + data: { title: '2-category' }, + }) + const post = await payload.create({ + collection: 'posts', + data: { title: '2-post', category: category.id }, + }) + const doc = await payload.create({ collection: 'virtual-relations', data: { post: post.id } }) + const found = await payload.find({ + collection: 'virtual-relations', + where: { postCategoryTitle: { equals: '2-category' } }, + }) + expect(found.docs).toHaveLength(1) + expect(found.docs[0].id).toBe(doc.id) + }) + + it('should allow referenced virtual field in globals', async () => { + const post = await payload.create({ collection: 'posts', data: { title: 'post' } }) + const globalData = await payload.updateGlobal({ + slug: 'virtual-relation-global', + data: { post: post.id }, + depth: 0, + }) + expect(globalData.postTitle).toBe('post') + }) }) it('should convert numbers to text', async () => { diff --git a/test/database/payload-types.ts b/test/database/payload-types.ts index f2692eaaa8..f29cf028fc 100644 --- a/test/database/payload-types.ts +++ b/test/database/payload-types.ts @@ -67,6 +67,7 @@ export interface Config { }; blocks: {}; collections: { + categories: Category; posts: Post; 'error-on-unnamed-fields': ErrorOnUnnamedField; 'default-values': DefaultValue; @@ -75,6 +76,7 @@ export interface Config { 'pg-migrations': PgMigration; 'custom-schema': CustomSchema; places: Place; + 'virtual-relations': VirtualRelation; 'fields-persistance': FieldsPersistance; 'custom-ids': CustomId; 'fake-custom-ids': FakeCustomId; @@ -88,6 +90,7 @@ export interface Config { }; collectionsJoins: {}; collectionsSelect: { + categories: CategoriesSelect | CategoriesSelect; posts: PostsSelect | PostsSelect; 'error-on-unnamed-fields': ErrorOnUnnamedFieldsSelect | ErrorOnUnnamedFieldsSelect; 'default-values': DefaultValuesSelect | DefaultValuesSelect; @@ -96,6 +99,7 @@ export interface Config { 'pg-migrations': PgMigrationsSelect | PgMigrationsSelect; 'custom-schema': CustomSchemaSelect | CustomSchemaSelect; places: PlacesSelect | PlacesSelect; + 'virtual-relations': VirtualRelationsSelect | VirtualRelationsSelect; 'fields-persistance': FieldsPersistanceSelect | FieldsPersistanceSelect; 'custom-ids': CustomIdsSelect | CustomIdsSelect; 'fake-custom-ids': FakeCustomIdsSelect | FakeCustomIdsSelect; @@ -114,11 +118,13 @@ export interface Config { global: Global; 'global-2': Global2; 'global-3': Global3; + 'virtual-relation-global': VirtualRelationGlobal; }; globalsSelect: { global: GlobalSelect | GlobalSelect; 'global-2': Global2Select | Global2Select; 'global-3': Global3Select | Global3Select; + 'virtual-relation-global': VirtualRelationGlobalSelect | VirtualRelationGlobalSelect; }; locale: 'en' | 'es'; user: User & { @@ -147,6 +153,16 @@ export interface UserAuthOperations { password: string; }; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "categories". + */ +export interface Category { + id: string; + title?: string | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "posts". @@ -154,6 +170,9 @@ export interface UserAuthOperations { export interface Post { id: string; title: string; + category?: (string | null) | Category; + localized?: string | null; + text?: string | null; number?: number | null; D1?: { D2?: { @@ -346,6 +365,20 @@ export interface Place { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "virtual-relations". + */ +export interface VirtualRelation { + id: string; + postTitle?: string | null; + postCategoryTitle?: string | null; + postLocalized?: string | null; + post?: (string | null) | Post; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "fields-persistance". @@ -465,6 +498,10 @@ export interface User { export interface PayloadLockedDocument { id: string; document?: + | ({ + relationTo: 'categories'; + value: string | Category; + } | null) | ({ relationTo: 'posts'; value: string | Post; @@ -497,6 +534,10 @@ export interface PayloadLockedDocument { relationTo: 'places'; value: string | Place; } | null) + | ({ + relationTo: 'virtual-relations'; + value: string | VirtualRelation; + } | null) | ({ relationTo: 'fields-persistance'; value: string | FieldsPersistance; @@ -567,12 +608,24 @@ export interface PayloadMigration { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "categories_select". + */ +export interface CategoriesSelect { + title?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "posts_select". */ export interface PostsSelect { title?: T; + category?: T; + localized?: T; + text?: T; number?: T; D1?: | T @@ -747,6 +800,19 @@ export interface PlacesSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "virtual-relations_select". + */ +export interface VirtualRelationsSelect { + postTitle?: T; + postCategoryTitle?: T; + postLocalized?: T; + post?: T; + updatedAt?: T; + createdAt?: T; + _status?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "fields-persistance_select". @@ -917,6 +983,17 @@ export interface Global3 { updatedAt?: string | null; createdAt?: string | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "virtual-relation-global". + */ +export interface VirtualRelationGlobal { + id: string; + postTitle?: string | null; + post?: (string | null) | Post; + updatedAt?: string | null; + createdAt?: string | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "global_select". @@ -947,6 +1024,17 @@ export interface Global3Select { createdAt?: T; globalType?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "virtual-relation-global_select". + */ +export interface VirtualRelationGlobalSelect { + postTitle?: T; + post?: T; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". From bcbb912d50b82befc54ffc15258c5dcac60e639e Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 16 Apr 2025 15:52:57 -0400 Subject: [PATCH 14/21] chore(release): v3.35.0 [skip ci] --- package.json | 2 +- packages/admin-bar/package.json | 2 +- packages/create-payload-app/package.json | 2 +- packages/db-mongodb/package.json | 2 +- packages/db-postgres/package.json | 2 +- packages/db-sqlite/package.json | 2 +- packages/db-vercel-postgres/package.json | 2 +- packages/drizzle/package.json | 2 +- packages/email-nodemailer/package.json | 2 +- packages/email-resend/package.json | 2 +- packages/graphql/package.json | 2 +- packages/live-preview-react/package.json | 2 +- packages/live-preview-vue/package.json | 2 +- packages/live-preview/package.json | 2 +- packages/next/package.json | 2 +- packages/payload-cloud/package.json | 2 +- packages/payload/package.json | 2 +- packages/plugin-cloud-storage/package.json | 2 +- packages/plugin-form-builder/package.json | 2 +- packages/plugin-import-export/package.json | 2 +- packages/plugin-multi-tenant/package.json | 2 +- packages/plugin-nested-docs/package.json | 2 +- packages/plugin-redirects/package.json | 2 +- packages/plugin-search/package.json | 2 +- packages/plugin-sentry/package.json | 2 +- packages/plugin-seo/package.json | 2 +- packages/plugin-stripe/package.json | 2 +- packages/richtext-lexical/package.json | 2 +- packages/richtext-slate/package.json | 2 +- packages/storage-azure/package.json | 2 +- packages/storage-gcs/package.json | 2 +- packages/storage-s3/package.json | 2 +- packages/storage-uploadthing/package.json | 2 +- packages/storage-vercel-blob/package.json | 2 +- packages/translations/package.json | 2 +- packages/ui/package.json | 2 +- 36 files changed, 36 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 3fe296d29a..b21d857c34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload-monorepo", - "version": "3.34.0", + "version": "3.35.0", "private": true, "type": "module", "scripts": { diff --git a/packages/admin-bar/package.json b/packages/admin-bar/package.json index 48dd1f5aff..aacb7a3fd2 100644 --- a/packages/admin-bar/package.json +++ b/packages/admin-bar/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/admin-bar", - "version": "3.34.0", + "version": "3.35.0", "description": "An admin bar for React apps using Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/create-payload-app/package.json b/packages/create-payload-app/package.json index e5778f0ee6..7d79125578 100644 --- a/packages/create-payload-app/package.json +++ b/packages/create-payload-app/package.json @@ -1,6 +1,6 @@ { "name": "create-payload-app", - "version": "3.34.0", + "version": "3.35.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/db-mongodb/package.json b/packages/db-mongodb/package.json index 999c46e82f..a5629480da 100644 --- a/packages/db-mongodb/package.json +++ b/packages/db-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-mongodb", - "version": "3.34.0", + "version": "3.35.0", "description": "The officially supported MongoDB database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-postgres/package.json b/packages/db-postgres/package.json index d6deaef12f..63093a0ac6 100644 --- a/packages/db-postgres/package.json +++ b/packages/db-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-postgres", - "version": "3.34.0", + "version": "3.35.0", "description": "The officially supported Postgres database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-sqlite/package.json b/packages/db-sqlite/package.json index f1323ac0d2..392c3634c7 100644 --- a/packages/db-sqlite/package.json +++ b/packages/db-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-sqlite", - "version": "3.34.0", + "version": "3.35.0", "description": "The officially supported SQLite database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-vercel-postgres/package.json b/packages/db-vercel-postgres/package.json index 54409e27db..6fd0f34a9d 100644 --- a/packages/db-vercel-postgres/package.json +++ b/packages/db-vercel-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-vercel-postgres", - "version": "3.34.0", + "version": "3.35.0", "description": "Vercel Postgres adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/drizzle/package.json b/packages/drizzle/package.json index 98dcd0b753..070350b1e3 100644 --- a/packages/drizzle/package.json +++ b/packages/drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/drizzle", - "version": "3.34.0", + "version": "3.35.0", "description": "A library of shared functions used by different payload database adapters", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/email-nodemailer/package.json b/packages/email-nodemailer/package.json index b4c8e3173a..5034f53425 100644 --- a/packages/email-nodemailer/package.json +++ b/packages/email-nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-nodemailer", - "version": "3.34.0", + "version": "3.35.0", "description": "Payload Nodemailer Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/email-resend/package.json b/packages/email-resend/package.json index b4b12efefb..bc9d6154e8 100644 --- a/packages/email-resend/package.json +++ b/packages/email-resend/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-resend", - "version": "3.34.0", + "version": "3.35.0", "description": "Payload Resend Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/graphql/package.json b/packages/graphql/package.json index fb1d132baa..d4a204e99a 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/graphql", - "version": "3.34.0", + "version": "3.35.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/live-preview-react/package.json b/packages/live-preview-react/package.json index 52539c7b84..24dcaa0d5c 100644 --- a/packages/live-preview-react/package.json +++ b/packages/live-preview-react/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview-react", - "version": "3.34.0", + "version": "3.35.0", "description": "The official React SDK for Payload Live Preview", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/live-preview-vue/package.json b/packages/live-preview-vue/package.json index 4553c78e9d..018d75af95 100644 --- a/packages/live-preview-vue/package.json +++ b/packages/live-preview-vue/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview-vue", - "version": "3.34.0", + "version": "3.35.0", "description": "The official Vue SDK for Payload Live Preview", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/live-preview/package.json b/packages/live-preview/package.json index 5092769da7..997a2319eb 100644 --- a/packages/live-preview/package.json +++ b/packages/live-preview/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview", - "version": "3.34.0", + "version": "3.35.0", "description": "The official live preview JavaScript SDK for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/next/package.json b/packages/next/package.json index b93b208a9a..16f75a9c7d 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/next", - "version": "3.34.0", + "version": "3.35.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/payload-cloud/package.json b/packages/payload-cloud/package.json index 81ca737a5b..546648ab2f 100644 --- a/packages/payload-cloud/package.json +++ b/packages/payload-cloud/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/payload-cloud", - "version": "3.34.0", + "version": "3.35.0", "description": "The official Payload Cloud plugin", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/payload/package.json b/packages/payload/package.json index a6f547c16e..35fb761a2c 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "3.34.0", + "version": "3.35.0", "description": "Node, React, Headless CMS and Application Framework built on Next.js", "keywords": [ "admin panel", diff --git a/packages/plugin-cloud-storage/package.json b/packages/plugin-cloud-storage/package.json index a2ad20c57a..dea9eb6628 100644 --- a/packages/plugin-cloud-storage/package.json +++ b/packages/plugin-cloud-storage/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-cloud-storage", - "version": "3.34.0", + "version": "3.35.0", "description": "The official cloud storage plugin for Payload CMS", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/plugin-form-builder/package.json b/packages/plugin-form-builder/package.json index 45716a4e5e..8c81751566 100644 --- a/packages/plugin-form-builder/package.json +++ b/packages/plugin-form-builder/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-form-builder", - "version": "3.34.0", + "version": "3.35.0", "description": "Form builder plugin for Payload CMS", "keywords": [ "payload", diff --git a/packages/plugin-import-export/package.json b/packages/plugin-import-export/package.json index 25d9dc144d..ad7e180a82 100644 --- a/packages/plugin-import-export/package.json +++ b/packages/plugin-import-export/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-import-export", - "version": "3.34.0", + "version": "3.35.0", "description": "Import-Export plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-multi-tenant/package.json b/packages/plugin-multi-tenant/package.json index edebd5a072..b5f24688a0 100644 --- a/packages/plugin-multi-tenant/package.json +++ b/packages/plugin-multi-tenant/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-multi-tenant", - "version": "3.34.0", + "version": "3.35.0", "description": "Multi Tenant plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-nested-docs/package.json b/packages/plugin-nested-docs/package.json index 4a2e3ee2d3..ecd0cca40e 100644 --- a/packages/plugin-nested-docs/package.json +++ b/packages/plugin-nested-docs/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-nested-docs", - "version": "3.34.0", + "version": "3.35.0", "description": "The official Nested Docs plugin for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/plugin-redirects/package.json b/packages/plugin-redirects/package.json index 1890cf43a3..f3e53c3056 100644 --- a/packages/plugin-redirects/package.json +++ b/packages/plugin-redirects/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-redirects", - "version": "3.34.0", + "version": "3.35.0", "description": "Redirects plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-search/package.json b/packages/plugin-search/package.json index 9b12464f9b..8edc147dee 100644 --- a/packages/plugin-search/package.json +++ b/packages/plugin-search/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-search", - "version": "3.34.0", + "version": "3.35.0", "description": "Search plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-sentry/package.json b/packages/plugin-sentry/package.json index 960ae04401..3f525715d4 100644 --- a/packages/plugin-sentry/package.json +++ b/packages/plugin-sentry/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-sentry", - "version": "3.34.0", + "version": "3.35.0", "description": "Sentry plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-seo/package.json b/packages/plugin-seo/package.json index ef225b3cd0..eddfcd81a2 100644 --- a/packages/plugin-seo/package.json +++ b/packages/plugin-seo/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-seo", - "version": "3.34.0", + "version": "3.35.0", "description": "SEO plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-stripe/package.json b/packages/plugin-stripe/package.json index d9cfa22929..4f11acc100 100644 --- a/packages/plugin-stripe/package.json +++ b/packages/plugin-stripe/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-stripe", - "version": "3.34.0", + "version": "3.35.0", "description": "Stripe plugin for Payload", "keywords": [ "payload", diff --git a/packages/richtext-lexical/package.json b/packages/richtext-lexical/package.json index 3f2ebc3ab8..9f9cfbd2ae 100644 --- a/packages/richtext-lexical/package.json +++ b/packages/richtext-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-lexical", - "version": "3.34.0", + "version": "3.35.0", "description": "The officially supported Lexical richtext adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/richtext-slate/package.json b/packages/richtext-slate/package.json index e676f1feda..e3ef18ca02 100644 --- a/packages/richtext-slate/package.json +++ b/packages/richtext-slate/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-slate", - "version": "3.34.0", + "version": "3.35.0", "description": "The officially supported Slate richtext adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-azure/package.json b/packages/storage-azure/package.json index ab80d73da8..24a9e8a8f3 100644 --- a/packages/storage-azure/package.json +++ b/packages/storage-azure/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-azure", - "version": "3.34.0", + "version": "3.35.0", "description": "Payload storage adapter for Azure Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-gcs/package.json b/packages/storage-gcs/package.json index d07cc8f344..5af049acdf 100644 --- a/packages/storage-gcs/package.json +++ b/packages/storage-gcs/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-gcs", - "version": "3.34.0", + "version": "3.35.0", "description": "Payload storage adapter for Google Cloud Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-s3/package.json b/packages/storage-s3/package.json index 108df5bd0a..9d78c08308 100644 --- a/packages/storage-s3/package.json +++ b/packages/storage-s3/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-s3", - "version": "3.34.0", + "version": "3.35.0", "description": "Payload storage adapter for Amazon S3", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-uploadthing/package.json b/packages/storage-uploadthing/package.json index 8fbe66d115..22610484a4 100644 --- a/packages/storage-uploadthing/package.json +++ b/packages/storage-uploadthing/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-uploadthing", - "version": "3.34.0", + "version": "3.35.0", "description": "Payload storage adapter for uploadthing", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-vercel-blob/package.json b/packages/storage-vercel-blob/package.json index 4b517b9d03..3b90db8baa 100644 --- a/packages/storage-vercel-blob/package.json +++ b/packages/storage-vercel-blob/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-vercel-blob", - "version": "3.34.0", + "version": "3.35.0", "description": "Payload storage adapter for Vercel Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/translations/package.json b/packages/translations/package.json index 042a299ab2..82efe96944 100644 --- a/packages/translations/package.json +++ b/packages/translations/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/translations", - "version": "3.34.0", + "version": "3.35.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/ui/package.json b/packages/ui/package.json index 6722b9ff9e..f7c361ab46 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/ui", - "version": "3.34.0", + "version": "3.35.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", From 0a59707ea026bbcd36433aaf15e8f9abfd993ae1 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Thu, 17 Apr 2025 16:55:12 +0300 Subject: [PATCH 15/21] chore(db-postgres): improve table name length exceeded error message (#12142) Improves the error message when table name length exceeds 63 characters with the tip that you can use the `dbName` property. --- packages/drizzle/src/createTableName.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/drizzle/src/createTableName.ts b/packages/drizzle/src/createTableName.ts index bdd2ff2326..c52f5daa94 100644 --- a/packages/drizzle/src/createTableName.ts +++ b/packages/drizzle/src/createTableName.ts @@ -78,7 +78,9 @@ export const createTableName = ({ if (result.length > 63) { throw new APIError( - `Exceeded max identifier length for table or enum name of 63 characters. Invalid name: ${result}`, + `Exceeded max identifier length for table or enum name of 63 characters. Invalid name: ${result}. +Tip: You can use the dbName property to reduce the table name length. + `, ) } From ed50a796430b884a884eea23cd34b34ff213edbf Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury <67977755+JessChowdhury@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:55:11 +0100 Subject: [PATCH 16/21] fix(next): missing @payloadcms/next/auth export (#12144) Follow up to #11900. The `@payloadcms/next/auth` export was missing from the published package.json because it was excluded from the `publishConfig` property. --- packages/next/package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/next/package.json b/packages/next/package.json index 16f75a9c7d..c3777f56bd 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -156,6 +156,11 @@ "types": "./dist/exports/templates.d.ts", "default": "./dist/exports/templates.js" }, + "./auth": { + "import": "./dist/exports/auth.js", + "types": "./dist/exports/auth.d.ts", + "default": "./dist/exports/auth.js" + }, "./utilities": { "import": "./dist/exports/utilities.js", "types": "./dist/exports/utilities.d.ts", From 17d5168728ef9f99dc7bfa5d22800509bf3e2fb1 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 17 Apr 2025 11:02:39 -0400 Subject: [PATCH 17/21] chore(release): v3.35.1 [skip ci] --- package.json | 2 +- packages/admin-bar/package.json | 2 +- packages/create-payload-app/package.json | 2 +- packages/db-mongodb/package.json | 2 +- packages/db-postgres/package.json | 2 +- packages/db-sqlite/package.json | 2 +- packages/db-vercel-postgres/package.json | 2 +- packages/drizzle/package.json | 2 +- packages/email-nodemailer/package.json | 2 +- packages/email-resend/package.json | 2 +- packages/graphql/package.json | 2 +- packages/live-preview-react/package.json | 2 +- packages/live-preview-vue/package.json | 2 +- packages/live-preview/package.json | 2 +- packages/next/package.json | 2 +- packages/payload-cloud/package.json | 2 +- packages/payload/package.json | 2 +- packages/plugin-cloud-storage/package.json | 2 +- packages/plugin-form-builder/package.json | 2 +- packages/plugin-import-export/package.json | 2 +- packages/plugin-multi-tenant/package.json | 2 +- packages/plugin-nested-docs/package.json | 2 +- packages/plugin-redirects/package.json | 2 +- packages/plugin-search/package.json | 2 +- packages/plugin-sentry/package.json | 2 +- packages/plugin-seo/package.json | 2 +- packages/plugin-stripe/package.json | 2 +- packages/richtext-lexical/package.json | 2 +- packages/richtext-slate/package.json | 2 +- packages/storage-azure/package.json | 2 +- packages/storage-gcs/package.json | 2 +- packages/storage-s3/package.json | 2 +- packages/storage-uploadthing/package.json | 2 +- packages/storage-vercel-blob/package.json | 2 +- packages/translations/package.json | 2 +- packages/ui/package.json | 2 +- 36 files changed, 36 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index b21d857c34..026c32f976 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload-monorepo", - "version": "3.35.0", + "version": "3.35.1", "private": true, "type": "module", "scripts": { diff --git a/packages/admin-bar/package.json b/packages/admin-bar/package.json index aacb7a3fd2..6292ace878 100644 --- a/packages/admin-bar/package.json +++ b/packages/admin-bar/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/admin-bar", - "version": "3.35.0", + "version": "3.35.1", "description": "An admin bar for React apps using Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/create-payload-app/package.json b/packages/create-payload-app/package.json index 7d79125578..d0a1244a45 100644 --- a/packages/create-payload-app/package.json +++ b/packages/create-payload-app/package.json @@ -1,6 +1,6 @@ { "name": "create-payload-app", - "version": "3.35.0", + "version": "3.35.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/db-mongodb/package.json b/packages/db-mongodb/package.json index a5629480da..b433605a93 100644 --- a/packages/db-mongodb/package.json +++ b/packages/db-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-mongodb", - "version": "3.35.0", + "version": "3.35.1", "description": "The officially supported MongoDB database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-postgres/package.json b/packages/db-postgres/package.json index 63093a0ac6..b6017ea73c 100644 --- a/packages/db-postgres/package.json +++ b/packages/db-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-postgres", - "version": "3.35.0", + "version": "3.35.1", "description": "The officially supported Postgres database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-sqlite/package.json b/packages/db-sqlite/package.json index 392c3634c7..12390b6c5c 100644 --- a/packages/db-sqlite/package.json +++ b/packages/db-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-sqlite", - "version": "3.35.0", + "version": "3.35.1", "description": "The officially supported SQLite database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-vercel-postgres/package.json b/packages/db-vercel-postgres/package.json index 6fd0f34a9d..f33b6c80b6 100644 --- a/packages/db-vercel-postgres/package.json +++ b/packages/db-vercel-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-vercel-postgres", - "version": "3.35.0", + "version": "3.35.1", "description": "Vercel Postgres adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/drizzle/package.json b/packages/drizzle/package.json index 070350b1e3..866f2477b2 100644 --- a/packages/drizzle/package.json +++ b/packages/drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/drizzle", - "version": "3.35.0", + "version": "3.35.1", "description": "A library of shared functions used by different payload database adapters", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/email-nodemailer/package.json b/packages/email-nodemailer/package.json index 5034f53425..1cdc4983a8 100644 --- a/packages/email-nodemailer/package.json +++ b/packages/email-nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-nodemailer", - "version": "3.35.0", + "version": "3.35.1", "description": "Payload Nodemailer Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/email-resend/package.json b/packages/email-resend/package.json index bc9d6154e8..338b35c5af 100644 --- a/packages/email-resend/package.json +++ b/packages/email-resend/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-resend", - "version": "3.35.0", + "version": "3.35.1", "description": "Payload Resend Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/graphql/package.json b/packages/graphql/package.json index d4a204e99a..db700269b7 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/graphql", - "version": "3.35.0", + "version": "3.35.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/live-preview-react/package.json b/packages/live-preview-react/package.json index 24dcaa0d5c..5ae4fd75a6 100644 --- a/packages/live-preview-react/package.json +++ b/packages/live-preview-react/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview-react", - "version": "3.35.0", + "version": "3.35.1", "description": "The official React SDK for Payload Live Preview", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/live-preview-vue/package.json b/packages/live-preview-vue/package.json index 018d75af95..c6d2c35823 100644 --- a/packages/live-preview-vue/package.json +++ b/packages/live-preview-vue/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview-vue", - "version": "3.35.0", + "version": "3.35.1", "description": "The official Vue SDK for Payload Live Preview", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/live-preview/package.json b/packages/live-preview/package.json index 997a2319eb..338ffa0c2e 100644 --- a/packages/live-preview/package.json +++ b/packages/live-preview/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview", - "version": "3.35.0", + "version": "3.35.1", "description": "The official live preview JavaScript SDK for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/next/package.json b/packages/next/package.json index c3777f56bd..f4ae1af419 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/next", - "version": "3.35.0", + "version": "3.35.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/payload-cloud/package.json b/packages/payload-cloud/package.json index 546648ab2f..bbaf0361da 100644 --- a/packages/payload-cloud/package.json +++ b/packages/payload-cloud/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/payload-cloud", - "version": "3.35.0", + "version": "3.35.1", "description": "The official Payload Cloud plugin", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/payload/package.json b/packages/payload/package.json index 35fb761a2c..e294512b71 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "3.35.0", + "version": "3.35.1", "description": "Node, React, Headless CMS and Application Framework built on Next.js", "keywords": [ "admin panel", diff --git a/packages/plugin-cloud-storage/package.json b/packages/plugin-cloud-storage/package.json index dea9eb6628..47dab5d7bd 100644 --- a/packages/plugin-cloud-storage/package.json +++ b/packages/plugin-cloud-storage/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-cloud-storage", - "version": "3.35.0", + "version": "3.35.1", "description": "The official cloud storage plugin for Payload CMS", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/plugin-form-builder/package.json b/packages/plugin-form-builder/package.json index 8c81751566..2ef45b15b1 100644 --- a/packages/plugin-form-builder/package.json +++ b/packages/plugin-form-builder/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-form-builder", - "version": "3.35.0", + "version": "3.35.1", "description": "Form builder plugin for Payload CMS", "keywords": [ "payload", diff --git a/packages/plugin-import-export/package.json b/packages/plugin-import-export/package.json index ad7e180a82..ce73cf23fb 100644 --- a/packages/plugin-import-export/package.json +++ b/packages/plugin-import-export/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-import-export", - "version": "3.35.0", + "version": "3.35.1", "description": "Import-Export plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-multi-tenant/package.json b/packages/plugin-multi-tenant/package.json index b5f24688a0..37f1898379 100644 --- a/packages/plugin-multi-tenant/package.json +++ b/packages/plugin-multi-tenant/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-multi-tenant", - "version": "3.35.0", + "version": "3.35.1", "description": "Multi Tenant plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-nested-docs/package.json b/packages/plugin-nested-docs/package.json index ecd0cca40e..a04087888d 100644 --- a/packages/plugin-nested-docs/package.json +++ b/packages/plugin-nested-docs/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-nested-docs", - "version": "3.35.0", + "version": "3.35.1", "description": "The official Nested Docs plugin for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/plugin-redirects/package.json b/packages/plugin-redirects/package.json index f3e53c3056..65c605e62a 100644 --- a/packages/plugin-redirects/package.json +++ b/packages/plugin-redirects/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-redirects", - "version": "3.35.0", + "version": "3.35.1", "description": "Redirects plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-search/package.json b/packages/plugin-search/package.json index 8edc147dee..295d36315e 100644 --- a/packages/plugin-search/package.json +++ b/packages/plugin-search/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-search", - "version": "3.35.0", + "version": "3.35.1", "description": "Search plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-sentry/package.json b/packages/plugin-sentry/package.json index 3f525715d4..9b1218a7b7 100644 --- a/packages/plugin-sentry/package.json +++ b/packages/plugin-sentry/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-sentry", - "version": "3.35.0", + "version": "3.35.1", "description": "Sentry plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-seo/package.json b/packages/plugin-seo/package.json index eddfcd81a2..4f13a39f91 100644 --- a/packages/plugin-seo/package.json +++ b/packages/plugin-seo/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-seo", - "version": "3.35.0", + "version": "3.35.1", "description": "SEO plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-stripe/package.json b/packages/plugin-stripe/package.json index 4f11acc100..e45211aaa1 100644 --- a/packages/plugin-stripe/package.json +++ b/packages/plugin-stripe/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-stripe", - "version": "3.35.0", + "version": "3.35.1", "description": "Stripe plugin for Payload", "keywords": [ "payload", diff --git a/packages/richtext-lexical/package.json b/packages/richtext-lexical/package.json index 9f9cfbd2ae..f6e2057f6b 100644 --- a/packages/richtext-lexical/package.json +++ b/packages/richtext-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-lexical", - "version": "3.35.0", + "version": "3.35.1", "description": "The officially supported Lexical richtext adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/richtext-slate/package.json b/packages/richtext-slate/package.json index e3ef18ca02..af889d5f00 100644 --- a/packages/richtext-slate/package.json +++ b/packages/richtext-slate/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-slate", - "version": "3.35.0", + "version": "3.35.1", "description": "The officially supported Slate richtext adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-azure/package.json b/packages/storage-azure/package.json index 24a9e8a8f3..b342dbb4c8 100644 --- a/packages/storage-azure/package.json +++ b/packages/storage-azure/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-azure", - "version": "3.35.0", + "version": "3.35.1", "description": "Payload storage adapter for Azure Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-gcs/package.json b/packages/storage-gcs/package.json index 5af049acdf..625022b32e 100644 --- a/packages/storage-gcs/package.json +++ b/packages/storage-gcs/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-gcs", - "version": "3.35.0", + "version": "3.35.1", "description": "Payload storage adapter for Google Cloud Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-s3/package.json b/packages/storage-s3/package.json index 9d78c08308..0e65946913 100644 --- a/packages/storage-s3/package.json +++ b/packages/storage-s3/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-s3", - "version": "3.35.0", + "version": "3.35.1", "description": "Payload storage adapter for Amazon S3", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-uploadthing/package.json b/packages/storage-uploadthing/package.json index 22610484a4..56f1ba3c99 100644 --- a/packages/storage-uploadthing/package.json +++ b/packages/storage-uploadthing/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-uploadthing", - "version": "3.35.0", + "version": "3.35.1", "description": "Payload storage adapter for uploadthing", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-vercel-blob/package.json b/packages/storage-vercel-blob/package.json index 3b90db8baa..84af5e067f 100644 --- a/packages/storage-vercel-blob/package.json +++ b/packages/storage-vercel-blob/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-vercel-blob", - "version": "3.35.0", + "version": "3.35.1", "description": "Payload storage adapter for Vercel Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/translations/package.json b/packages/translations/package.json index 82efe96944..2608d3cb10 100644 --- a/packages/translations/package.json +++ b/packages/translations/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/translations", - "version": "3.35.0", + "version": "3.35.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/ui/package.json b/packages/ui/package.json index f7c361ab46..360012acd2 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/ui", - "version": "3.35.0", + "version": "3.35.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", From 34ea6ec14f2ccea1ba6cdec9e910dee037972d37 Mon Sep 17 00:00:00 2001 From: Patrik Date: Thu, 17 Apr 2025 14:45:10 -0400 Subject: [PATCH 18/21] feat: adds `showSaveDraftButton` option to show draft button with autosave enabled (#12150) This adds a new `showSaveDraftButton` option to the `versions.drafts.autosave` config for collections and globals. By default, the "Save as draft" button is hidden when autosave is enabled. This new option allows the button to remain visible for manual saves while autosave is active. Also updates the admin UI logic to conditionally render the button when this flag is set, and updates the documentation with an example usage. --- docs/versions/autosave.mdx | 7 +- packages/payload/src/versions/types.ts | 7 + .../src/elements/DocumentControls/index.tsx | 22 +- .../collections/AutosaveWithDraftButton.ts | 32 +++ test/versions/config.ts | 12 +- test/versions/e2e.spec.ts | 204 ++++++++++-------- .../globals/AutosaveWithDraftButton.ts | 26 +++ test/versions/payload-types.ts | 64 +++++- test/versions/slugs.ts | 6 + 9 files changed, 282 insertions(+), 98 deletions(-) create mode 100644 test/versions/collections/AutosaveWithDraftButton.ts create mode 100644 test/versions/globals/AutosaveWithDraftButton.ts diff --git a/docs/versions/autosave.mdx b/docs/versions/autosave.mdx index 077c473259..5d1b9d6a73 100644 --- a/docs/versions/autosave.mdx +++ b/docs/versions/autosave.mdx @@ -22,6 +22,7 @@ Collections and Globals both support the same options for configuring autosave. | Drafts Autosave Options | Description | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `interval` | Define an `interval` in milliseconds to automatically save progress while documents are edited. Document updates are "debounced" at this interval. Defaults to `800`. | +| `showSaveDraftButton` | Set this to `true` to show the "Save as draft" button even while autosave is enabled. Defaults to `false`. | **Example config with versions, drafts, and autosave enabled:** @@ -50,9 +51,13 @@ export const Pages: CollectionConfig = { drafts: { autosave: true, - // Alternatively, you can specify an `interval`: + // Alternatively, you can specify an object to customize autosave: // autosave: { + // Define how often the document should be autosaved (in milliseconds) // interval: 1500, + // + // Show the "Save as draft" button even while autosave is enabled + // showSaveDraftButton: true, // }, }, }, diff --git a/packages/payload/src/versions/types.ts b/packages/payload/src/versions/types.ts index 43c0d21854..efbc4e3c95 100644 --- a/packages/payload/src/versions/types.ts +++ b/packages/payload/src/versions/types.ts @@ -6,6 +6,13 @@ export type Autosave = { * @default 800 */ interval?: number + /** + * When set to `true`, the "Save as draft" button will be displayed even while autosave is enabled. + * By default, this button is hidden to avoid redundancy with autosave behavior. + * + * @default false + */ + showSaveDraftButton?: boolean } export type SchedulePublish = { diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx index 0e99c0dcd5..2c0a6ed3e5 100644 --- a/packages/ui/src/elements/DocumentControls/index.tsx +++ b/packages/ui/src/elements/DocumentControls/index.tsx @@ -133,9 +133,23 @@ export const DocumentControls: React.FC<{ const unsavedDraftWithValidations = !id && collectionConfig?.versions?.drafts && collectionConfig.versions?.drafts.validate + const collectionConfigDrafts = collectionConfig?.versions?.drafts + const globalConfigDrafts = globalConfig?.versions?.drafts + const autosaveEnabled = - (collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) || - (globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave) + (collectionConfigDrafts && collectionConfigDrafts?.autosave) || + (globalConfigDrafts && globalConfigDrafts?.autosave) + + const collectionAutosaveEnabled = collectionConfigDrafts && collectionConfigDrafts?.autosave + const globalAutosaveEnabled = globalConfigDrafts && globalConfigDrafts?.autosave + + const showSaveDraftButton = + (collectionAutosaveEnabled && + collectionConfigDrafts.autosave !== false && + collectionConfigDrafts.autosave.showSaveDraftButton === true) || + (globalAutosaveEnabled && + globalConfigDrafts.autosave !== false && + globalConfigDrafts.autosave.showSaveDraftButton === true) const showCopyToLocale = localization && !collectionConfig?.admin?.disableCopyToLocale @@ -218,7 +232,9 @@ export const DocumentControls: React.FC<{ {collectionConfig?.versions?.drafts || globalConfig?.versions?.drafts ? ( - {(unsavedDraftWithValidations || !autosaveEnabled) && ( + {(unsavedDraftWithValidations || + !autosaveEnabled || + (autosaveEnabled && showSaveDraftButton)) && ( } diff --git a/test/versions/collections/AutosaveWithDraftButton.ts b/test/versions/collections/AutosaveWithDraftButton.ts new file mode 100644 index 0000000000..3fc719a78d --- /dev/null +++ b/test/versions/collections/AutosaveWithDraftButton.ts @@ -0,0 +1,32 @@ +import type { CollectionConfig } from 'payload' + +import { autosaveWithDraftButtonSlug } from '../slugs.js' + +const AutosaveWithDraftButtonPosts: CollectionConfig = { + slug: autosaveWithDraftButtonSlug, + labels: { + singular: 'Autosave with Draft Button Post', + plural: 'Autosave with Draft Button Posts', + }, + admin: { + useAsTitle: 'title', + defaultColumns: ['title', 'subtitle', 'createdAt', '_status'], + }, + versions: { + drafts: { + autosave: { + showSaveDraftButton: true, + interval: 1000, + }, + }, + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + ], +} + +export default AutosaveWithDraftButtonPosts diff --git a/test/versions/config.ts b/test/versions/config.ts index 1b23833611..36fdb76728 100644 --- a/test/versions/config.ts +++ b/test/versions/config.ts @@ -4,6 +4,7 @@ const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import AutosavePosts from './collections/Autosave.js' +import AutosaveWithDraftButtonPosts from './collections/AutosaveWithDraftButton.js' import AutosaveWithValidate from './collections/AutosaveWithValidate.js' import CustomIDs from './collections/CustomIDs.js' import { Diff } from './collections/Diff/index.js' @@ -17,6 +18,7 @@ import Posts from './collections/Posts.js' import { TextCollection } from './collections/Text.js' import VersionPosts from './collections/Versions.js' import AutosaveGlobal from './globals/Autosave.js' +import AutosaveWithDraftButtonGlobal from './globals/AutosaveWithDraftButton.js' import DisablePublishGlobal from './globals/DisablePublish.js' import DraftGlobal from './globals/Draft.js' import DraftWithMaxGlobal from './globals/DraftWithMax.js' @@ -35,6 +37,7 @@ export default buildConfigWithDefaults({ DisablePublish, Posts, AutosavePosts, + AutosaveWithDraftButtonPosts, AutosaveWithValidate, DraftPosts, DraftWithMax, @@ -46,7 +49,14 @@ export default buildConfigWithDefaults({ TextCollection, Media, ], - globals: [AutosaveGlobal, DraftGlobal, DraftWithMaxGlobal, DisablePublishGlobal, LocalizedGlobal], + globals: [ + AutosaveGlobal, + AutosaveWithDraftButtonGlobal, + DraftGlobal, + DraftWithMaxGlobal, + DisablePublishGlobal, + LocalizedGlobal, + ], indexSortableFields: true, localization: { defaultLocale: 'en', diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index e5d9e7cd1a..2798fb52cd 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -48,6 +48,8 @@ import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js' import { autosaveCollectionSlug, autoSaveGlobalSlug, + autosaveWithDraftButtonGlobal, + autosaveWithDraftButtonSlug, autosaveWithValidateCollectionSlug, customIDSlug, diffCollectionSlug, @@ -78,6 +80,7 @@ describe('Versions', () => { let url: AdminUrlUtil let serverURL: string let autosaveURL: AdminUrlUtil + let autosaveWithDraftButtonURL: AdminUrlUtil let autosaveWithValidateURL: AdminUrlUtil let draftWithValidateURL: AdminUrlUtil let disablePublishURL: AdminUrlUtil @@ -116,6 +119,7 @@ describe('Versions', () => { beforeAll(() => { url = new AdminUrlUtil(serverURL, draftCollectionSlug) autosaveURL = new AdminUrlUtil(serverURL, autosaveCollectionSlug) + autosaveWithDraftButtonURL = new AdminUrlUtil(serverURL, autosaveWithDraftButtonSlug) autosaveWithValidateURL = new AdminUrlUtil(serverURL, autosaveWithValidateCollectionSlug) disablePublishURL = new AdminUrlUtil(serverURL, disablePublishSlug) customIDURL = new AdminUrlUtil(serverURL, customIDSlug) @@ -201,78 +205,6 @@ describe('Versions', () => { await expect(page.locator('#field-title')).toHaveValue('v1') }) - test('should show global versions view level action in globals versions view', async () => { - const global = new AdminUrlUtil(serverURL, draftGlobalSlug) - await page.goto(`${global.global(draftGlobalSlug)}/versions`) - await expect(page.locator('.app-header .global-versions-button')).toHaveCount(1) - }) - - // TODO: Check versions/:version-id view for collections / globals - - test('global — has versions tab', async () => { - const global = new AdminUrlUtil(serverURL, draftGlobalSlug) - await page.goto(global.global(draftGlobalSlug)) - - const docURL = page.url() - const pathname = new URL(docURL).pathname - - const versionsTab = page.locator('.doc-tab', { - hasText: 'Versions', - }) - await versionsTab.waitFor({ state: 'visible' }) - - expect(versionsTab).toBeTruthy() - const href = versionsTab.locator('a').first() - await expect(href).toHaveAttribute('href', `${pathname}/versions`) - }) - - test('global — respects max number of versions', async () => { - await payload.updateGlobal({ - slug: draftWithMaxGlobalSlug, - data: { - title: 'initial title', - }, - }) - - const global = new AdminUrlUtil(serverURL, draftWithMaxGlobalSlug) - await page.goto(global.global(draftWithMaxGlobalSlug)) - - const titleFieldInitial = page.locator('#field-title') - await titleFieldInitial.fill('updated title') - await saveDocAndAssert(page, '#action-save-draft') - await expect(titleFieldInitial).toHaveValue('updated title') - - const versionsTab = page.locator('.doc-tab', { - hasText: '1', - }) - - await versionsTab.waitFor({ state: 'visible' }) - - expect(versionsTab).toBeTruthy() - - const titleFieldUpdated = page.locator('#field-title') - await titleFieldUpdated.fill('latest title') - await saveDocAndAssert(page, '#action-save-draft') - await expect(titleFieldUpdated).toHaveValue('latest title') - - const versionsTabUpdated = page.locator('.doc-tab', { - hasText: '1', - }) - - await versionsTabUpdated.waitFor({ state: 'visible' }) - - expect(versionsTabUpdated).toBeTruthy() - }) - - test('global — has versions route', async () => { - const global = new AdminUrlUtil(serverURL, autoSaveGlobalSlug) - const versionsURL = `${global.global(autoSaveGlobalSlug)}/versions` - await page.goto(versionsURL) - await expect(() => { - expect(page.url()).toMatch(/\/versions/) - }).toPass({ timeout: 10000, intervals: [100] }) - }) - test('collection - should autosave', async () => { await page.goto(autosaveURL.create) await page.locator('#field-title').fill('autosave title') @@ -309,6 +241,16 @@ describe('Versions', () => { await expect(drawer.locator('.id-label')).toBeVisible() }) + test('collection - should show "save as draft" button when showSaveDraftButton is true', async () => { + await page.goto(autosaveWithDraftButtonURL.create) + await expect(page.locator('#action-save-draft')).toBeVisible() + }) + + test('collection - should not show "save as draft" button when showSaveDraftButton is false', async () => { + await page.goto(autosaveURL.create) + await expect(page.locator('#action-save-draft')).toBeHidden() + }) + test('collection - autosave - should not create duplicates when clicking Create new', async () => { // This test checks that when we click "Create new" in the list view, it only creates 1 extra document and not more const { totalDocs: initialDocsCount } = await payload.find({ @@ -402,17 +344,6 @@ describe('Versions', () => { await expect(newUpdatedAt).not.toHaveText(initialUpdatedAt) }) - test('global - should autosave', async () => { - const url = new AdminUrlUtil(serverURL, autoSaveGlobalSlug) - await page.goto(url.global(autoSaveGlobalSlug)) - const titleField = page.locator('#field-title') - await titleField.fill('global title') - await waitForAutoSaveToRunAndComplete(page) - await expect(titleField).toHaveValue('global title') - await page.goto(url.global(autoSaveGlobalSlug)) - await expect(page.locator('#field-title')).toHaveValue('global title') - }) - test('should retain localized data during autosave', async () => { const en = 'en' const es = 'es' @@ -519,12 +450,6 @@ describe('Versions', () => { await expect(page.locator('#field-title')).toHaveValue('title') }) - test('globals — should hide publish button when access control prevents update', async () => { - const url = new AdminUrlUtil(serverURL, disablePublishGlobalSlug) - await page.goto(url.global(disablePublishGlobalSlug)) - await expect(page.locator('#action-save')).not.toBeAttached() - }) - test('collections — should hide publish button when access control prevents create', async () => { await page.goto(disablePublishURL.create) await expect(page.locator('#action-save')).not.toBeAttached() @@ -652,6 +577,107 @@ describe('Versions', () => { }) }) + describe('draft globals', () => { + test('should show global versions view level action in globals versions view', async () => { + const global = new AdminUrlUtil(serverURL, draftGlobalSlug) + await page.goto(`${global.global(draftGlobalSlug)}/versions`) + await expect(page.locator('.app-header .global-versions-button')).toHaveCount(1) + }) + + test('global — has versions tab', async () => { + const global = new AdminUrlUtil(serverURL, draftGlobalSlug) + await page.goto(global.global(draftGlobalSlug)) + + const docURL = page.url() + const pathname = new URL(docURL).pathname + + const versionsTab = page.locator('.doc-tab', { + hasText: 'Versions', + }) + await versionsTab.waitFor({ state: 'visible' }) + + expect(versionsTab).toBeTruthy() + const href = versionsTab.locator('a').first() + await expect(href).toHaveAttribute('href', `${pathname}/versions`) + }) + + test('global — respects max number of versions', async () => { + await payload.updateGlobal({ + slug: draftWithMaxGlobalSlug, + data: { + title: 'initial title', + }, + }) + + const global = new AdminUrlUtil(serverURL, draftWithMaxGlobalSlug) + await page.goto(global.global(draftWithMaxGlobalSlug)) + + const titleFieldInitial = page.locator('#field-title') + await titleFieldInitial.fill('updated title') + await saveDocAndAssert(page, '#action-save-draft') + await expect(titleFieldInitial).toHaveValue('updated title') + + const versionsTab = page.locator('.doc-tab', { + hasText: '1', + }) + + await versionsTab.waitFor({ state: 'visible' }) + + expect(versionsTab).toBeTruthy() + + const titleFieldUpdated = page.locator('#field-title') + await titleFieldUpdated.fill('latest title') + await saveDocAndAssert(page, '#action-save-draft') + await expect(titleFieldUpdated).toHaveValue('latest title') + + const versionsTabUpdated = page.locator('.doc-tab', { + hasText: '1', + }) + + await versionsTabUpdated.waitFor({ state: 'visible' }) + + expect(versionsTabUpdated).toBeTruthy() + }) + + test('global — has versions route', async () => { + const global = new AdminUrlUtil(serverURL, autoSaveGlobalSlug) + const versionsURL = `${global.global(autoSaveGlobalSlug)}/versions` + await page.goto(versionsURL) + await expect(() => { + expect(page.url()).toMatch(/\/versions/) + }).toPass({ timeout: 10000, intervals: [100] }) + }) + + test('global - should show "save as draft" button when showSaveDraftButton is true', async () => { + const url = new AdminUrlUtil(serverURL, autosaveWithDraftButtonGlobal) + await page.goto(url.global(autosaveWithDraftButtonGlobal)) + await expect(page.locator('#action-save-draft')).toBeVisible() + }) + + test('global - should not show "save as draft" button when showSaveDraftButton is false', async () => { + const url = new AdminUrlUtil(serverURL, autoSaveGlobalSlug) + await page.goto(url.global(autoSaveGlobalSlug)) + await expect(page.locator('#action-save-draft')).toBeHidden() + }) + + test('global - should autosave', async () => { + const url = new AdminUrlUtil(serverURL, autoSaveGlobalSlug) + await page.goto(url.global(autoSaveGlobalSlug)) + const titleField = page.locator('#field-title') + await titleField.fill('global title') + await waitForAutoSaveToRunAndComplete(page) + await expect(titleField).toHaveValue('global title') + await page.goto(url.global(autoSaveGlobalSlug)) + await expect(page.locator('#field-title')).toHaveValue('global title') + }) + + test('globals — should hide publish button when access control prevents update', async () => { + const url = new AdminUrlUtil(serverURL, disablePublishGlobalSlug) + await page.goto(url.global(disablePublishGlobalSlug)) + await expect(page.locator('#action-save')).not.toBeAttached() + }) + }) + describe('Scheduled publish', () => { beforeAll(() => { url = new AdminUrlUtil(serverURL, draftCollectionSlug) diff --git a/test/versions/globals/AutosaveWithDraftButton.ts b/test/versions/globals/AutosaveWithDraftButton.ts new file mode 100644 index 0000000000..5d8f1372a8 --- /dev/null +++ b/test/versions/globals/AutosaveWithDraftButton.ts @@ -0,0 +1,26 @@ +import type { GlobalConfig } from 'payload' + +import { autosaveWithDraftButtonGlobal } from '../slugs.js' + +const AutosaveWithDraftButtonGlobal: GlobalConfig = { + slug: autosaveWithDraftButtonGlobal, + fields: [ + { + name: 'title', + type: 'text', + localized: true, + required: true, + }, + ], + label: 'Autosave with Draft Button Global', + versions: { + drafts: { + autosave: { + showSaveDraftButton: true, + interval: 1000, + }, + }, + }, +} + +export default AutosaveWithDraftButtonGlobal diff --git a/test/versions/payload-types.ts b/test/versions/payload-types.ts index 6e5e4fc8c6..c016d16822 100644 --- a/test/versions/payload-types.ts +++ b/test/versions/payload-types.ts @@ -70,6 +70,7 @@ export interface Config { 'disable-publish': DisablePublish; posts: Post; 'autosave-posts': AutosavePost; + 'autosave-with-draft-button-posts': AutosaveWithDraftButtonPost; 'autosave-with-validate-posts': AutosaveWithValidatePost; 'draft-posts': DraftPost; 'draft-with-max-posts': DraftWithMaxPost; @@ -91,6 +92,7 @@ export interface Config { 'disable-publish': DisablePublishSelect | DisablePublishSelect; posts: PostsSelect | PostsSelect; 'autosave-posts': AutosavePostsSelect | AutosavePostsSelect; + 'autosave-with-draft-button-posts': AutosaveWithDraftButtonPostsSelect | AutosaveWithDraftButtonPostsSelect; 'autosave-with-validate-posts': AutosaveWithValidatePostsSelect | AutosaveWithValidatePostsSelect; 'draft-posts': DraftPostsSelect | DraftPostsSelect; 'draft-with-max-posts': DraftWithMaxPostsSelect | DraftWithMaxPostsSelect; @@ -112,6 +114,7 @@ export interface Config { }; globals: { 'autosave-global': AutosaveGlobal; + 'autosave-with-draft-button-global': AutosaveWithDraftButtonGlobal; 'draft-global': DraftGlobal; 'draft-with-max-global': DraftWithMaxGlobal; 'disable-publish-global': DisablePublishGlobal; @@ -119,6 +122,7 @@ export interface Config { }; globalsSelect: { 'autosave-global': AutosaveGlobalSelect | AutosaveGlobalSelect; + 'autosave-with-draft-button-global': AutosaveWithDraftButtonGlobalSelect | AutosaveWithDraftButtonGlobalSelect; 'draft-global': DraftGlobalSelect | DraftGlobalSelect; 'draft-with-max-global': DraftWithMaxGlobalSelect | DraftWithMaxGlobalSelect; 'disable-publish-global': DisablePublishGlobalSelect | DisablePublishGlobalSelect; @@ -228,6 +232,17 @@ export interface DraftPost { createdAt: string; _status?: ('draft' | 'published') | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "autosave-with-draft-button-posts". + */ +export interface AutosaveWithDraftButtonPost { + id: string; + title: string; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "autosave-with-validate-posts". @@ -554,6 +569,10 @@ export interface PayloadLockedDocument { relationTo: 'autosave-posts'; value: string | AutosavePost; } | null) + | ({ + relationTo: 'autosave-with-draft-button-posts'; + value: string | AutosaveWithDraftButtonPost; + } | null) | ({ relationTo: 'autosave-with-validate-posts'; value: string | AutosaveWithValidatePost; @@ -676,6 +695,16 @@ export interface AutosavePostsSelect { createdAt?: T; _status?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "autosave-with-draft-button-posts_select". + */ +export interface AutosaveWithDraftButtonPostsSelect { + title?: T; + updatedAt?: T; + createdAt?: T; + _status?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "autosave-with-validate-posts_select". @@ -973,6 +1002,17 @@ export interface AutosaveGlobal { updatedAt?: string | null; createdAt?: string | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "autosave-with-draft-button-global". + */ +export interface AutosaveWithDraftButtonGlobal { + id: string; + title: string; + _status?: ('draft' | 'published') | null; + updatedAt?: string | null; + createdAt?: string | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "draft-global". @@ -1029,6 +1069,17 @@ export interface AutosaveGlobalSelect { createdAt?: T; globalType?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "autosave-with-draft-button-global_select". + */ +export interface AutosaveWithDraftButtonGlobalSelect { + title?: T; + _status?: T; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "draft-global_select". @@ -1082,10 +1133,15 @@ export interface TaskSchedulePublish { input: { type?: ('publish' | 'unpublish') | null; locale?: string | null; - doc?: { - relationTo: 'draft-posts'; - value: string | DraftPost; - } | null; + doc?: + | ({ + relationTo: 'autosave-posts'; + value: string | AutosavePost; + } | null) + | ({ + relationTo: 'draft-posts'; + value: string | DraftPost; + } | null); global?: 'draft-global' | null; user?: (string | null) | User; }; diff --git a/test/versions/slugs.ts b/test/versions/slugs.ts index 2dccdb8b65..5807c381b5 100644 --- a/test/versions/slugs.ts +++ b/test/versions/slugs.ts @@ -1,5 +1,7 @@ export const autosaveCollectionSlug = 'autosave-posts' +export const autosaveWithDraftButtonSlug = 'autosave-with-draft-button-posts' + export const autosaveWithValidateCollectionSlug = 'autosave-with-validate-posts' export const customIDSlug = 'custom-ids' @@ -33,7 +35,11 @@ export const collectionSlugs = [ ] export const autoSaveGlobalSlug = 'autosave-global' + +export const autosaveWithDraftButtonGlobal = 'autosave-with-draft-button-global' + export const draftGlobalSlug = 'draft-global' + export const draftWithMaxGlobalSlug = 'draft-with-max-global' export const globalSlugs = [autoSaveGlobalSlug, draftGlobalSlug] From d55306980e149a085d4c05692e46d09d4341b9ee Mon Sep 17 00:00:00 2001 From: Patrik Date: Thu, 17 Apr 2025 15:23:17 -0400 Subject: [PATCH 19/21] feat: adds `beforeDocumentControls` slot to allow custom component injection next to document controls (#12104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What This PR introduces a new `beforeDocumentControls` slot to the edit view of both collections and globals. It allows injecting one or more custom components next to the document control buttons (e.g., Save, Publish, Save Draft) in the admin UI — useful for adding context, additional buttons, or custom UI elements. #### Usage ##### For collections: ``` admin: { components: { edit: { beforeDocumentControls: ['/path/to/CustomComponent'], }, }, }, ``` ##### For globals: ``` admin: { components: { elements: { beforeDocumentControls: ['/path/to/CustomComponent'], }, }, }, ``` --- docs/custom-components/edit-view.mdx | 99 ++++++++++++++++--- .../views/Document/renderDocumentSlots.tsx | 13 +++ packages/payload/src/admin/types.ts | 4 + packages/payload/src/admin/views/document.ts | 11 ++- .../generateImportMap/iterateCollections.ts | 1 + .../payload/src/collections/config/types.ts | 4 + packages/payload/src/globals/config/types.ts | 5 + .../src/elements/DocumentControls/index.tsx | 3 + packages/ui/src/views/Edit/index.tsx | 2 + test/admin/collections/Posts.ts | 6 ++ .../CustomDraftButton/index.tsx | 23 +++++ .../CustomSaveButton/index.tsx | 23 +++++ test/admin/e2e/document-view/e2e.spec.ts | 38 ++++++- test/admin/globals/Global.ts | 5 + 14 files changed, 216 insertions(+), 21 deletions(-) create mode 100644 test/admin/components/BeforeDocumentControls/CustomDraftButton/index.tsx create mode 100644 test/admin/components/BeforeDocumentControls/CustomSaveButton/index.tsx diff --git a/docs/custom-components/edit-view.mdx b/docs/custom-components/edit-view.mdx index 4f26004a86..03043e2daf 100644 --- a/docs/custom-components/edit-view.mdx +++ b/docs/custom-components/edit-view.mdx @@ -101,14 +101,15 @@ export const MyCollection: CollectionConfig = { The following options are available: -| Path | Description | -| ----------------- | -------------------------------------------------------------------------------------- | -| `SaveButton` | A button that saves the current document. [More details](#savebutton). | -| `SaveDraftButton` | A button that saves the current document as a draft. [More details](#savedraftbutton). | -| `PublishButton` | A button that publishes the current document. [More details](#publishbutton). | -| `PreviewButton` | A button that previews the current document. [More details](#previewbutton). | -| `Description` | A description of the Collection. [More details](#description). | -| `Upload` | A file upload component. [More details](#upload). | +| Path | Description | +| ------------------------ | ---------------------------------------------------------------------------------------------------- | +| `beforeDocumentControls` | Inject custom components before the Save / Publish buttons. [More details](#beforedocumentcontrols). | +| `SaveButton` | A button that saves the current document. [More details](#savebutton). | +| `SaveDraftButton` | A button that saves the current document as a draft. [More details](#savedraftbutton). | +| `PublishButton` | A button that publishes the current document. [More details](#publishbutton). | +| `PreviewButton` | A button that previews the current document. [More details](#previewbutton). | +| `Description` | A description of the Collection. [More details](#description). | +| `Upload` | A file upload component. [More details](#upload). | #### Globals @@ -133,13 +134,14 @@ export const MyGlobal: GlobalConfig = { The following options are available: -| Path | Description | -| ----------------- | -------------------------------------------------------------------------------------- | -| `SaveButton` | A button that saves the current document. [More details](#savebutton). | -| `SaveDraftButton` | A button that saves the current document as a draft. [More details](#savedraftbutton). | -| `PublishButton` | A button that publishes the current document. [More details](#publishbutton). | -| `PreviewButton` | A button that previews the current document. [More details](#previewbutton). | -| `Description` | A description of the Global. [More details](#description). | +| Path | Description | +| ------------------------ | ---------------------------------------------------------------------------------------------------- | +| `beforeDocumentControls` | Inject custom components before the Save / Publish buttons. [More details](#beforedocumentcontrols). | +| `SaveButton` | A button that saves the current document. [More details](#savebutton). | +| `SaveDraftButton` | A button that saves the current document as a draft. [More details](#savedraftbutton). | +| `PublishButton` | A button that publishes the current document. [More details](#publishbutton). | +| `PreviewButton` | A button that previews the current document. [More details](#previewbutton). | +| `Description` | A description of the Global. [More details](#description). | ### SaveButton @@ -191,6 +193,73 @@ export function MySaveButton(props: SaveButtonClientProps) { } ``` +### beforeDocumentControls + +The `beforeDocumentControls` property allows you to render custom components just before the default document action buttons (like Save, Publish, or Preview). This is useful for injecting custom buttons, status indicators, or any other UI elements before the built-in controls. + +To add `beforeDocumentControls` components, use the `components.edit.beforeDocumentControls` property in you [Collection Config](../configuration/collections) or `components.elements.beforeDocumentControls` in your [Global Config](../configuration/globals): + +#### Collections + +``` +export const MyCollection: CollectionConfig = { + admin: { + components: { + edit: { + // highlight-start + beforeDocumentControls: ['/path/to/CustomComponent'], + // highlight-end + }, + }, + }, +} +``` + +#### Globals + +``` +export const MyGlobal: GlobalConfig = { + admin: { + components: { + elements: { + // highlight-start + beforeDocumentControls: ['/path/to/CustomComponent'], + // highlight-end + }, + }, + }, +} +``` + +Here's an example of a custom `beforeDocumentControls` component: + +#### Server Component + +```tsx +import React from 'react' +import type { BeforeDocumentControlsServerProps } from 'payload' + +export function MyCustomDocumentControlButton( + props: BeforeDocumentControlsServerProps, +) { + return
This is a custom beforeDocumentControl button (Server)
+} +``` + +#### Client Component + +```tsx +'use client' +import React from 'react' +import type { BeforeDocumentControlsClientProps } from 'payload' + +export function MyCustomDocumentControlButton( + props: BeforeDocumentControlsClientProps, +) { + return
This is a custom beforeDocumentControl button (Client)
+} +``` + ### SaveDraftButton The `SaveDraftButton` property allows you to render a custom Save Draft Button in the Edit View. diff --git a/packages/next/src/views/Document/renderDocumentSlots.tsx b/packages/next/src/views/Document/renderDocumentSlots.tsx index dde74e5e26..dee93e9410 100644 --- a/packages/next/src/views/Document/renderDocumentSlots.tsx +++ b/packages/next/src/views/Document/renderDocumentSlots.tsx @@ -1,4 +1,5 @@ import type { + BeforeDocumentControlsServerPropsOnly, DefaultServerFunctionArgs, DocumentSlots, PayloadRequest, @@ -42,6 +43,18 @@ export const renderDocumentSlots: (args: { // TODO: Add remaining serverProps } + const BeforeDocumentControls = + collectionConfig?.admin?.components?.edit?.beforeDocumentControls || + globalConfig?.admin?.components?.elements?.beforeDocumentControls + + if (BeforeDocumentControls) { + components.BeforeDocumentControls = RenderServerComponent({ + Component: BeforeDocumentControls, + importMap: req.payload.importMap, + serverProps: serverProps satisfies BeforeDocumentControlsServerPropsOnly, + }) + } + const CustomPreviewButton = collectionConfig?.admin?.components?.edit?.PreviewButton || globalConfig?.admin?.components?.elements?.PreviewButton diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts index f98d8075b4..40fb5abf40 100644 --- a/packages/payload/src/admin/types.ts +++ b/packages/payload/src/admin/types.ts @@ -553,6 +553,7 @@ export type FieldRow = { } export type DocumentSlots = { + BeforeDocumentControls?: React.ReactNode Description?: React.ReactNode PreviewButton?: React.ReactNode PublishButton?: React.ReactNode @@ -578,6 +579,9 @@ export type { LanguageOptions } from './LanguageOptions.js' export type { RichTextAdapter, RichTextAdapterProvider, RichTextHooks } from './RichText.js' export type { + BeforeDocumentControlsClientProps, + BeforeDocumentControlsServerProps, + BeforeDocumentControlsServerPropsOnly, DocumentSubViewTypes, DocumentTabClientProps, /** diff --git a/packages/payload/src/admin/views/document.ts b/packages/payload/src/admin/views/document.ts index db0b2ee2db..7e8d14f145 100644 --- a/packages/payload/src/admin/views/document.ts +++ b/packages/payload/src/admin/views/document.ts @@ -36,12 +36,12 @@ export type DocumentTabServerPropsOnly = { readonly permissions: SanitizedPermissions } & ServerProps -export type DocumentTabServerProps = DocumentTabClientProps & DocumentTabServerPropsOnly - export type DocumentTabClientProps = { path: string } +export type DocumentTabServerProps = DocumentTabClientProps & DocumentTabServerPropsOnly + export type DocumentTabCondition = (args: { collectionConfig: SanitizedCollectionConfig config: SanitizedConfig @@ -75,3 +75,10 @@ export type DocumentTabConfig = { export type DocumentTabComponent = PayloadComponent<{ path: string }> + +// BeforeDocumentControls + +export type BeforeDocumentControlsClientProps = {} +export type BeforeDocumentControlsServerPropsOnly = {} & ServerProps +export type BeforeDocumentControlsServerProps = BeforeDocumentControlsClientProps & + BeforeDocumentControlsServerPropsOnly diff --git a/packages/payload/src/bin/generateImportMap/iterateCollections.ts b/packages/payload/src/bin/generateImportMap/iterateCollections.ts index 9aa7783576..27134fc86f 100644 --- a/packages/payload/src/bin/generateImportMap/iterateCollections.ts +++ b/packages/payload/src/bin/generateImportMap/iterateCollections.ts @@ -36,6 +36,7 @@ export function iterateCollections({ addToImportMap(collection.admin?.components?.beforeListTable) addToImportMap(collection.admin?.components?.Description) + addToImportMap(collection.admin?.components?.edit?.beforeDocumentControls) addToImportMap(collection.admin?.components?.edit?.PreviewButton) addToImportMap(collection.admin?.components?.edit?.PublishButton) addToImportMap(collection.admin?.components?.edit?.SaveButton) diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 7478a64af6..90be14abdc 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -279,6 +279,10 @@ export type CollectionAdminOptions = { * Components within the edit view */ edit?: { + /** + * Inject custom components before the document controls + */ + beforeDocumentControls?: CustomComponent[] /** * Replaces the "Preview" button */ diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts index 7521695068..a2bbeb94c1 100644 --- a/packages/payload/src/globals/config/types.ts +++ b/packages/payload/src/globals/config/types.ts @@ -9,6 +9,7 @@ import type { } from '../../admin/types.js' import type { Access, + CustomComponent, EditConfig, Endpoint, EntityDescription, @@ -80,6 +81,10 @@ export type GlobalAdminOptions = { */ components?: { elements?: { + /** + * Inject custom components before the document controls + */ + beforeDocumentControls?: CustomComponent[] Description?: EntityDescriptionComponent /** * Replaces the "Preview" button diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx index 2c0a6ed3e5..f13be0fd15 100644 --- a/packages/ui/src/elements/DocumentControls/index.tsx +++ b/packages/ui/src/elements/DocumentControls/index.tsx @@ -37,6 +37,7 @@ const baseClass = 'doc-controls' export const DocumentControls: React.FC<{ readonly apiURL: string + readonly BeforeDocumentControls?: React.ReactNode readonly customComponents?: { readonly PreviewButton?: React.ReactNode readonly PublishButton?: React.ReactNode @@ -67,6 +68,7 @@ export const DocumentControls: React.FC<{ const { id, slug, + BeforeDocumentControls, customComponents: { PreviewButton: CustomPreviewButton, PublishButton: CustomPublishButton, @@ -222,6 +224,7 @@ export const DocumentControls: React.FC<{
+ {BeforeDocumentControls} {(collectionConfig?.admin.preview || globalConfig?.admin.preview) && ( +

+ Custom Draft Button +

+
+ ) +} diff --git a/test/admin/components/BeforeDocumentControls/CustomSaveButton/index.tsx b/test/admin/components/BeforeDocumentControls/CustomSaveButton/index.tsx new file mode 100644 index 0000000000..d4ef70125e --- /dev/null +++ b/test/admin/components/BeforeDocumentControls/CustomSaveButton/index.tsx @@ -0,0 +1,23 @@ +import type { BeforeDocumentControlsServerProps } from 'payload' + +import React from 'react' + +const baseClass = 'custom-save-button' + +export function CustomSaveButton(props: BeforeDocumentControlsServerProps) { + return ( +
+

+ Custom Save Button +

+
+ ) +} diff --git a/test/admin/e2e/document-view/e2e.spec.ts b/test/admin/e2e/document-view/e2e.spec.ts index 0fee868040..d3830b255d 100644 --- a/test/admin/e2e/document-view/e2e.spec.ts +++ b/test/admin/e2e/document-view/e2e.spec.ts @@ -110,7 +110,7 @@ describe('Document View', () => { }) expect(collectionItems.docs.length).toBe(1) await page.goto( - `${postsUrl.collection(noApiViewGlobalSlug)}/${collectionItems.docs[0].id}/api`, + `${postsUrl.collection(noApiViewGlobalSlug)}/${collectionItems?.docs[0]?.id}/api`, ) await expect(page.locator('.not-found')).toHaveCount(1) }) @@ -333,20 +333,32 @@ describe('Document View', () => { await navigateToDoc(page, postsUrl) await page.locator('#field-title').fill(title) await saveDocAndAssert(page) + await page .locator('.field-type.relationship .relationship--single-value__drawer-toggler') .click() + await wait(500) + const drawer1Content = page.locator('[id^=doc-drawer_posts_1_] .drawer__content') await expect(drawer1Content).toBeVisible() - const drawerLeft = await drawer1Content.boundingBox().then((box) => box.x) + + const drawer1Box = await drawer1Content.boundingBox() + await expect.poll(() => drawer1Box).not.toBeNull() + const drawerLeft = drawer1Box!.x + await drawer1Content .locator('.field-type.relationship .relationship--single-value__drawer-toggler') .click() + const drawer2Content = page.locator('[id^=doc-drawer_posts_2_] .drawer__content') await expect(drawer2Content).toBeVisible() - const drawer2Left = await drawer2Content.boundingBox().then((box) => box.x) - expect(drawer2Left > drawerLeft).toBe(true) + + const drawer2Box = await drawer2Content.boundingBox() + await expect.poll(() => drawer2Box).not.toBeNull() + const drawer2Left = drawer2Box!.x + + await expect.poll(() => drawer2Left > drawerLeft).toBe(true) }) }) @@ -523,6 +535,24 @@ describe('Document View', () => { await expect(fileField).toHaveValue('some file text') }) }) + + describe('custom document controls', () => { + test('should show custom elements in document controls in collection', async () => { + await page.goto(postsUrl.create) + const customDraftButton = page.locator('#custom-draft-button') + const customSaveButton = page.locator('#custom-save-button') + + await expect(customDraftButton).toBeVisible() + await expect(customSaveButton).toBeVisible() + }) + + test('should show custom elements in document controls in global', async () => { + await page.goto(globalURL.global(globalSlug)) + const customDraftButton = page.locator('#custom-draft-button') + + await expect(customDraftButton).toBeVisible() + }) + }) }) async function createPost(overrides?: Partial): Promise { diff --git a/test/admin/globals/Global.ts b/test/admin/globals/Global.ts index e654c584b6..7f0d615102 100644 --- a/test/admin/globals/Global.ts +++ b/test/admin/globals/Global.ts @@ -6,6 +6,11 @@ export const Global: GlobalConfig = { slug: globalSlug, admin: { components: { + elements: { + beforeDocumentControls: [ + '/components/BeforeDocumentControls/CustomDraftButton/index.js#CustomDraftButton', + ], + }, views: { edit: { api: { From b750ba450917daf37d63ed5feaa67637f93318e5 Mon Sep 17 00:00:00 2001 From: Corey Larson Date: Fri, 18 Apr 2025 05:10:48 -0500 Subject: [PATCH 20/21] fix(ui): reflect default sort in join tables (#12084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What? This PR ensures defaultSort is reflected in join tables. ### Why? Currently, default sort is not reflected in the join table state. The data _is_ sorted correctly, but the table state sort is undefined. This is mainly an issue for join fields with `orderable: true` because you can't re-order the table until `order` is the selected sort column. ### How? Added `defaultSort` prop to the `` in the `` and ensured the default state gets set in `` when `modifySearchParams` is false. **Before:** Screenshot 2025-04-11 at 2 33 19 AM **After:** Screenshot 2025-04-11 at 3 04 07 AM Fixes #12083 --------- Co-authored-by: Germán Jabloñski <43938777+GermanJablo@users.noreply.github.com> --- packages/ui/src/elements/RelationshipTable/index.tsx | 1 + packages/ui/src/providers/ListQuery/index.tsx | 5 ++++- test/sort/e2e.spec.ts | 4 ---- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/elements/RelationshipTable/index.tsx b/packages/ui/src/elements/RelationshipTable/index.tsx index 3247dd3c94..79f998188f 100644 --- a/packages/ui/src/elements/RelationshipTable/index.tsx +++ b/packages/ui/src/elements/RelationshipTable/index.tsx @@ -335,6 +335,7 @@ export const RelationshipTable: React.FC = (pro defaultLimit={ field.defaultLimit ?? collectionConfig?.admin?.pagination?.defaultLimit } + defaultSort={field.defaultSort ?? collectionConfig?.defaultSort} modifySearchParams={false} onQueryChange={setQuery} orderableFieldName={ diff --git a/packages/ui/src/providers/ListQuery/index.tsx b/packages/ui/src/providers/ListQuery/index.tsx index 62a55dac86..53eee2fc72 100644 --- a/packages/ui/src/providers/ListQuery/index.tsx +++ b/packages/ui/src/providers/ListQuery/index.tsx @@ -48,7 +48,10 @@ export const ListQueryProvider: React.FC = ({ if (modifySearchParams) { return searchParams } else { - return {} + return { + limit: String(defaultLimit), + sort: defaultSort, + } } }) diff --git a/test/sort/e2e.spec.ts b/test/sort/e2e.spec.ts index fdc53d19dc..f8a0421378 100644 --- a/test/sort/e2e.spec.ts +++ b/test/sort/e2e.spec.ts @@ -82,19 +82,15 @@ describe('Sort functionality', () => { await page.getByText('Join A').click() await expect(page.locator('.sort-header button')).toHaveCount(2) - await page.locator('.sort-header button').nth(0).click() await assertRows(0, 'A', 'B', 'C', 'D') await moveRow(2, 3, 'success', 0) // move to middle await assertRows(0, 'A', 'C', 'B', 'D') - await page.locator('.sort-header button').nth(1).click() await assertRows(1, 'A', 'B', 'C', 'D') await moveRow(1, 4, 'success', 1) // move to end await assertRows(1, 'B', 'C', 'D', 'A') await page.reload() - await page.locator('.sort-header button').nth(0).click() - await page.locator('.sort-header button').nth(1).click() await assertRows(0, 'A', 'C', 'B', 'D') await assertRows(1, 'B', 'C', 'D', 'A') }) From df7a3692f761f777255e95c17a939daffd1b7389 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Fri, 18 Apr 2025 09:47:36 -0400 Subject: [PATCH 21/21] fix(plugin-search): delete does not also delete the search doc (#12148) The plugin-search collection uses an `afterDelete` hook to remove search records from the database. Since a deleted document in postgres causes cascade updates for the foreign key, the query for the document by relationship was not returning the record to be deleted. The solution was to change the delete hook to `beforeDelete` for the search enabled collections. This way we purge records before the main document so the search document query can find and delete the record as expected. An alternative solution in #9623 would remove the `req` so the delete query could still find the document, however, this just works outside of transactions which isn't desirable. fixes https://github.com/payloadcms/payload/issues/9443 --- .../src/Search/hooks/deleteFromSearch.ts | 57 +++++++------------ packages/plugin-search/src/index.ts | 14 ++--- packages/plugin-search/src/types.ts | 8 +-- test/plugin-search/int.spec.ts | 7 ++- 4 files changed, 31 insertions(+), 55 deletions(-) diff --git a/packages/plugin-search/src/Search/hooks/deleteFromSearch.ts b/packages/plugin-search/src/Search/hooks/deleteFromSearch.ts index 04a395598c..2c7fbcb64a 100644 --- a/packages/plugin-search/src/Search/hooks/deleteFromSearch.ts +++ b/packages/plugin-search/src/Search/hooks/deleteFromSearch.ts @@ -1,43 +1,28 @@ import type { DeleteFromSearch } from '../../types.js' -export const deleteFromSearch: DeleteFromSearch = async ({ - collection, - doc, - pluginConfig, - req: { payload }, - req, -}) => { - const searchSlug = pluginConfig?.searchOverrides?.slug || 'search' - try { - const searchDocQuery = await payload.find({ - collection: searchSlug, - depth: 0, - limit: 1, - pagination: false, - req, - where: { - doc: { - equals: { - relationTo: collection.slug, - value: doc.id, +export const deleteFromSearch: DeleteFromSearch = + (pluginConfig) => + async ({ id, collection, req: { payload }, req }) => { + const searchSlug = pluginConfig?.searchOverrides?.slug || 'search' + + try { + await payload.delete({ + collection: searchSlug, + depth: 0, + req, + where: { + 'doc.relationTo': { + equals: collection.slug, + }, + 'doc.value': { + equals: id, }, }, - }, - }) - - if (searchDocQuery?.docs?.[0]) { - await payload.delete({ - id: searchDocQuery?.docs?.[0]?.id, - collection: searchSlug, - req, + }) + } catch (err: unknown) { + payload.logger.error({ + err, + msg: `Error deleting ${searchSlug} doc.`, }) } - } catch (err: unknown) { - payload.logger.error({ - err, - msg: `Error deleting ${searchSlug} doc.`, - }) } - - return doc -} diff --git a/packages/plugin-search/src/index.ts b/packages/plugin-search/src/index.ts index 05a4939dc1..fc4be4bd5d 100644 --- a/packages/plugin-search/src/index.ts +++ b/packages/plugin-search/src/index.ts @@ -1,4 +1,4 @@ -import type { CollectionAfterChangeHook, CollectionAfterDeleteHook, Config } from 'payload' +import type { CollectionAfterChangeHook, Config } from 'payload' import type { SanitizedSearchPluginConfig, SearchPluginConfig } from './types.js' @@ -7,7 +7,6 @@ import { syncWithSearch } from './Search/hooks/syncWithSearch.js' import { generateSearchCollection } from './Search/index.js' type CollectionAfterChangeHookArgs = Parameters[0] -type CollectionAfterDeleteHookArgs = Parameters[0] export const searchPlugin = (incomingPluginConfig: SearchPluginConfig) => @@ -67,14 +66,9 @@ export const searchPlugin = }) }, ], - afterDelete: [ - ...(existingHooks?.afterDelete || []), - async (args: CollectionAfterDeleteHookArgs) => { - await deleteFromSearch({ - ...args, - pluginConfig, - }) - }, + beforeDelete: [ + ...(existingHooks?.beforeDelete || []), + deleteFromSearch(pluginConfig), ], }, } diff --git a/packages/plugin-search/src/types.ts b/packages/plugin-search/src/types.ts index 4b0e4c1f36..bd6dd0d15c 100644 --- a/packages/plugin-search/src/types.ts +++ b/packages/plugin-search/src/types.ts @@ -1,6 +1,6 @@ import type { CollectionAfterChangeHook, - CollectionAfterDeleteHook, + CollectionBeforeDeleteHook, CollectionConfig, Field, Locale, @@ -96,8 +96,4 @@ export type SyncDocArgs = { // Convert the `collection` arg from `SanitizedCollectionConfig` to a string export type SyncWithSearch = (Args: SyncWithSearchArgs) => ReturnType -export type DeleteFromSearch = ( - Args: { - pluginConfig: SearchPluginConfig - } & Parameters[0], -) => ReturnType +export type DeleteFromSearch = (args: SearchPluginConfig) => CollectionBeforeDeleteHook diff --git a/test/plugin-search/int.spec.ts b/test/plugin-search/int.spec.ts index faaa3bdb3d..cf34f031be 100644 --- a/test/plugin-search/int.spec.ts +++ b/test/plugin-search/int.spec.ts @@ -1,5 +1,6 @@ +import type { Payload } from 'payload' + import path from 'path' -import { NotFound, type Payload } from 'payload' import { wait } from 'payload/shared' import { fileURLToPath } from 'url' @@ -300,8 +301,8 @@ describe('@payloadcms/plugin-search', () => { collection: 'search', depth: 0, where: { - 'doc.value': { - equals: page.id, + id: { + equals: results[0].id, }, }, })