diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index 9dcebc92cc..e00043c9dc 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -193,13 +193,13 @@ export const MyCollection: CollectionConfig = { The following options are available: -| Option | Description | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SaveButton` | Replace the default Save Button within the Edit View. [Drafts](../versions/drafts) must be disabled. [More details](../custom-components/edit-view#save-button). | -| `SaveDraftButton` | Replace the default Save Draft Button within the Edit View. [Drafts](../versions/drafts) must be enabled and autosave must be disabled. [More details](../custom-components/edit-view#save-draft-button). | -| `PublishButton` | Replace the default Publish Button within the Edit View. [Drafts](../versions/drafts) must be enabled. [More details](../custom-components/edit-view#publish-button). | -| `PreviewButton` | Replace the default Preview Button within the Edit View. [Preview](../admin/preview) must be enabled. [More details](../custom-components/edit-view#preview-button). | -| `Upload` | Replace the default Upload component within the Edit View. [Upload](../upload/overview) must be enabled. [More details](../custom-components/edit-view#upload). | +| Option | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SaveButton` | Replace the default Save Button within the Edit View. [Drafts](../versions/drafts) must be disabled. [More details](../custom-components/edit-view#savebutton). | +| `SaveDraftButton` | Replace the default Save Draft Button within the Edit View. [Drafts](../versions/drafts) must be enabled and autosave must be disabled. [More details](../custom-components/edit-view#savedraftbutton). | +| `PublishButton` | Replace the default Publish Button within the Edit View. [Drafts](../versions/drafts) must be enabled. [More details](../custom-components/edit-view#publishbutton). | +| `PreviewButton` | Replace the default Preview Button within the Edit View. [Preview](../admin/preview) must be enabled. [More details](../custom-components/edit-view#previewbutton). | +| `Upload` | Replace the default Upload component within the Edit View. [Upload](../upload/overview) must be enabled. [More details](../custom-components/edit-view#upload). | **Note:** For details on how to build Custom Components, see [Building Custom diff --git a/docs/configuration/globals.mdx b/docs/configuration/globals.mdx index c833bb0d2c..9339262100 100644 --- a/docs/configuration/globals.mdx +++ b/docs/configuration/globals.mdx @@ -179,12 +179,12 @@ export const MyGlobal: SanitizedGlobalConfig = { The following options are available: -| Option | Description | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `SaveButton` | Replace the default Save Button with a Custom Component. [Drafts](../versions/drafts) must be disabled. [More details](../custom-components/edit-view#save-button). | -| `SaveDraftButton` | Replace the default Save Draft Button with a Custom Component. [Drafts](../versions/drafts) must be enabled and autosave must be disabled. [More details](../custom-components/edit-view#save-draft-button). | -| `PublishButton` | Replace the default Publish Button with a Custom Component. [Drafts](../versions/drafts) must be enabled. [More details](../custom-components/edit-view#publish-button). | -| `PreviewButton` | Replace the default Preview Button with a Custom Component. [Preview](../admin/preview) must be enabled. [More details](../custom-components/edit-view#preview-button). | +| Option | Description | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SaveButton` | Replace the default Save Button with a Custom Component. [Drafts](../versions/drafts) must be disabled. [More details](../custom-components/edit-view#savebutton). | +| `SaveDraftButton` | Replace the default Save Draft Button with a Custom Component. [Drafts](../versions/drafts) must be enabled and autosave must be disabled. [More details](../custom-components/edit-view#savedraftbutton). | +| `PublishButton` | Replace the default Publish Button with a Custom Component. [Drafts](../versions/drafts) must be enabled. [More details](../custom-components/edit-view#publishbutton). | +| `PreviewButton` | Replace the default Preview Button with a Custom Component. [Preview](../admin/preview) must be enabled. [More details](../custom-components/edit-view#previewbutton). | **Note:** For details on how to build Custom Components, see [Building Custom diff --git a/docs/database/migrations.mdx b/docs/database/migrations.mdx index 2281412015..a0d96d72e5 100644 --- a/docs/database/migrations.mdx +++ b/docs/database/migrations.mdx @@ -298,3 +298,15 @@ Passing your migrations as shown above will tell Payload, in production only, to may slow down serverless cold starts on platforms such as Vercel. Generally, this option should only be used for long-running servers / containers. + +## Environment-Specific Configurations and Migrations + +Your configuration may include environment-specific settings (e.g., enabling a plugin only in production). If you generate migrations without considering the environment, it can lead to discrepancies and issues. When running migrations locally, Payload uses the development environment, which might miss production-specific configurations. Similarly, running migrations in production could miss development-specific entities. + +This is an easy oversight, so be mindful of any environment-specific logic in your config when handling migrations. + +**Ways to address this:** + +- Manually update your migration file after it is generated to include any environment-specific configurations. +- Temporarily enable any required production environment variables in your local setup when generating the migration to capture the necessary updates. +- Use separate migration files for each environment to ensure the correct migration is executed in the corresponding environment. diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 10d652a2c2..12e95b2896 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) | +| **`placeholder`** | Define a custom text or function to replace the generic default placeholder | | **`appearance`** | Set to `drawer` or `select` to change the behavior of the field. Defaults to `select`. | ### Sort Options @@ -149,7 +150,7 @@ The `filterOptions` property can either be a `Where` query, or a function return | `id` | The `id` of the current document being edited. Will be `undefined` during the `create` operation or when called on a `Filter` component within the list view. | | `relationTo` | The collection `slug` to filter against, limited to this field's `relationTo` property. | | `req` | The Payload Request, which contains references to `payload`, `user`, `locale`, and more. | -| `siblingData` | An object containing document data that is scoped to only fields within the same parent of this field. Will be an emprt object when called on a `Filter` component within the list view. | +| `siblingData` | An object containing document data that is scoped to only fields within the same parent of this field. Will be an empty object when called on a `Filter` component within the list view. | | `user` | An object containing the currently authenticated user. | ## Example diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index c8328a8d8a..cf5ed27b54 100644 --- a/docs/fields/select.mdx +++ b/docs/fields/select.mdx @@ -89,6 +89,7 @@ The Select Field inherits all of the default options from the base [Field Admin | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | **`isClearable`** | Set to `true` if you'd like this field to be clearable within the Admin UI. | | **`isSortable`** | Set to `true` if you'd like this field to be sortable within the Admin UI using drag and drop. (Only works when `hasMany` is set to `true`) | +| **`placeholder`** | Define a custom text or function to replace the generic default placeholder | ## Example diff --git a/docs/query-presets/overview.mdx b/docs/query-presets/overview.mdx index abc5852279..a5ca006833 100644 --- a/docs/query-presets/overview.mdx +++ b/docs/query-presets/overview.mdx @@ -117,7 +117,7 @@ Adding custom access control rules requires: 2. A set of fields to conditionally render when that option is selected 3. A function that returns the access control rules for that option -To do this, use the `queryPresets.constraints` property in your [Payload Config](../configuration/payload-config). +To do this, use the `queryPresets.constraints` property in your [Payload Config](../configuration/overview). ```ts import { buildConfig } from 'payload' @@ -128,26 +128,28 @@ const config = buildConfig({ // ... // highlight-start constraints: { - read: { - label: 'Specific Roles', - value: 'specificRoles', - fields: [ - { - name: 'roles', - type: 'select', - hasMany: true, - options: [ - { label: 'Admin', value: 'admin' }, - { label: 'User', value: 'user' }, - ], - }, - ], - access: ({ req: { user } }) => ({ - 'access.read.roles': { - in: [user?.roles], - }, - }), - }, + read: [ + { + label: 'Specific Roles', + value: 'specificRoles', + fields: [ + { + name: 'roles', + type: 'select', + hasMany: true, + options: [ + { label: 'Admin', value: 'admin' }, + { label: 'User', value: 'user' }, + ], + }, + ], + access: ({ req: { user } }) => ({ + 'access.read.roles': { + in: [user?.roles], + }, + }), + }, + ], // highlight-end }, }, diff --git a/examples/live-preview/README.md b/examples/live-preview/README.md index 800e4de0d2..42aad30b7c 100644 --- a/examples/live-preview/README.md +++ b/examples/live-preview/README.md @@ -58,7 +58,7 @@ See the [Collections](https://payloadcms.com/docs/configuration/collections) doc } ``` - For more details on how to extend this functionality, see the [Live Preview](https://payloadcms.com/docs/live-preview) docs. + For more details on how to extend this functionality, see the [Live Preview](https://payloadcms.com/docs/live-preview/overview) docs. ## Front-end diff --git a/examples/live-preview/src/migrations/seed.ts b/examples/live-preview/src/migrations/seed.ts index 91b0a5323f..98eb2164ce 100644 --- a/examples/live-preview/src/migrations/seed.ts +++ b/examples/live-preview/src/migrations/seed.ts @@ -36,7 +36,7 @@ export const home: Partial = { type: 'link', children: [{ text: 'Live Preview' }], newTab: true, - url: 'https://payloadcms.com/docs/live-preview', + url: 'https://payloadcms.com/docs/live-preview/overview', }, { text: ' you can edit this page in the admin panel and see the changes reflected here in real time.', diff --git a/package.json b/package.json index 026c32f976..a25e17adc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload-monorepo", - "version": "3.35.1", + "version": "3.36.1", "private": true, "type": "module", "scripts": { diff --git a/packages/admin-bar/package.json b/packages/admin-bar/package.json index 6292ace878..c4a3947edf 100644 --- a/packages/admin-bar/package.json +++ b/packages/admin-bar/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/admin-bar", - "version": "3.35.1", + "version": "3.36.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 d0a1244a45..cec49d40be 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.1", + "version": "3.36.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/create-payload-app/src/lib/create-project.spec.ts b/packages/create-payload-app/src/lib/create-project.spec.ts index 93503c1e6f..578ebbb6e7 100644 --- a/packages/create-payload-app/src/lib/create-project.spec.ts +++ b/packages/create-payload-app/src/lib/create-project.spec.ts @@ -10,6 +10,7 @@ import type { CliArgs, DbType, ProjectExample, ProjectTemplate } from '../types. import { createProject } from './create-project.js' import { dbReplacements } from './replacements.js' import { getValidTemplates } from './templates.js' +import { manageEnvFiles } from './manage-env-files.js' describe('createProject', () => { let projectDir: string @@ -154,5 +155,75 @@ describe('createProject', () => { expect(content).toContain(dbReplacement.configReplacement().join('\n')) }) }) + describe('managing env files', () => { + it('updates .env files without overwriting existing data', async () => { + const envFilePath = path.join(projectDir, '.env') + const envExampleFilePath = path.join(projectDir, '.env.example') + + fse.ensureDirSync(projectDir) + fse.ensureFileSync(envFilePath) + fse.ensureFileSync(envExampleFilePath) + + const initialEnvContent = `CUSTOM_VAR=custom-value\nDATABASE_URI=old-connection\n` + const initialEnvExampleContent = `CUSTOM_VAR=custom-value\nDATABASE_URI=old-connection\nPAYLOAD_SECRET=YOUR_SECRET_HERE\n` + + fse.writeFileSync(envFilePath, initialEnvContent) + fse.writeFileSync(envExampleFilePath, initialEnvExampleContent) + + await manageEnvFiles({ + cliArgs: { + '--debug': true, + } as CliArgs, + databaseType: 'mongodb', + databaseUri: 'mongodb://localhost:27017/test', + payloadSecret: 'test-secret', + projectDir, + template: undefined, + }) + + const updatedEnvContent = fse.readFileSync(envFilePath, 'utf-8') + + expect(updatedEnvContent).toContain('CUSTOM_VAR=custom-value') + expect(updatedEnvContent).toContain('DATABASE_URI=mongodb://localhost:27017/test') + expect(updatedEnvContent).toContain('PAYLOAD_SECRET=test-secret') + + const updatedEnvExampleContent = fse.readFileSync(envExampleFilePath, 'utf-8') + + expect(updatedEnvExampleContent).toContain('CUSTOM_VAR=custom-value') + expect(updatedEnvContent).toContain('DATABASE_URI=mongodb://localhost:27017/test') + expect(updatedEnvContent).toContain('PAYLOAD_SECRET=test-secret') + }) + + it('creates .env and .env.example if they do not exist', async () => { + const envFilePath = path.join(projectDir, '.env') + const envExampleFilePath = path.join(projectDir, '.env.example') + + fse.ensureDirSync(projectDir) + + if (fse.existsSync(envFilePath)) fse.removeSync(envFilePath) + if (fse.existsSync(envExampleFilePath)) fse.removeSync(envExampleFilePath) + + await manageEnvFiles({ + cliArgs: { + '--debug': true, + } as CliArgs, + databaseUri: '', + payloadSecret: '', + projectDir, + template: undefined, + }) + + expect(fse.existsSync(envFilePath)).toBe(true) + expect(fse.existsSync(envExampleFilePath)).toBe(true) + + const updatedEnvContent = fse.readFileSync(envFilePath, 'utf-8') + expect(updatedEnvContent).toContain('DATABASE_URI=your-connection-string-here') + expect(updatedEnvContent).toContain('PAYLOAD_SECRET=YOUR_SECRET_HERE') + + const updatedEnvExampleContent = fse.readFileSync(envExampleFilePath, 'utf-8') + expect(updatedEnvExampleContent).toContain('DATABASE_URI=your-connection-string-here') + expect(updatedEnvExampleContent).toContain('PAYLOAD_SECRET=YOUR_SECRET_HERE') + }) + }) }) }) diff --git a/packages/create-payload-app/src/lib/manage-env-files.ts b/packages/create-payload-app/src/lib/manage-env-files.ts index b2454c8833..aa87e4d6e0 100644 --- a/packages/create-payload-app/src/lib/manage-env-files.ts +++ b/packages/create-payload-app/src/lib/manage-env-files.ts @@ -6,66 +6,57 @@ import type { CliArgs, DbType, ProjectTemplate } from '../types.js' import { debug, error } from '../utils/log.js' import { dbChoiceRecord } from './select-db.js' -const updateEnvExampleVariables = (contents: string, databaseType: DbType | undefined): string => { - return contents +const updateEnvExampleVariables = ( + contents: string, + databaseType: DbType | undefined, + payloadSecret?: string, + databaseUri?: string, +): string => { + const seenKeys = new Set() + const updatedEnv = contents .split('\n') .map((line) => { if (line.startsWith('#') || !line.includes('=')) { - return line // Preserve comments and unrelated lines + return line } const [key] = line.split('=') + if (!key) { + return + } + if (key === 'DATABASE_URI' || key === 'POSTGRES_URL' || key === 'MONGODB_URI') { const dbChoice = databaseType ? dbChoiceRecord[databaseType] : null - if (dbChoice) { - const placeholderUri = `${dbChoice.dbConnectionPrefix}your-database-name${ - dbChoice.dbConnectionSuffix || '' - }` - return databaseType === 'vercel-postgres' - ? `POSTGRES_URL=${placeholderUri}` - : `DATABASE_URI=${placeholderUri}` + const placeholderUri = databaseUri + ? databaseUri + : `${dbChoice.dbConnectionPrefix}your-database-name${dbChoice.dbConnectionSuffix || ''}` + line = + databaseType === 'vercel-postgres' + ? `POSTGRES_URL=${placeholderUri}` + : `DATABASE_URI=${placeholderUri}` } - - return `DATABASE_URI=your-database-connection-here` // Fallback } if (key === 'PAYLOAD_SECRET' || key === 'PAYLOAD_SECRET_KEY') { - return `PAYLOAD_SECRET=YOUR_SECRET_HERE` + line = `PAYLOAD_SECRET=${payloadSecret || 'YOUR_SECRET_HERE'}` } + // handles dupes + if (seenKeys.has(key)) { + return null + } + + seenKeys.add(key) + return line }) + .filter(Boolean) + .reverse() .join('\n') -} -const generateEnvContent = ( - existingEnv: string, - databaseType: DbType | undefined, - databaseUri: string, - payloadSecret: string, -): string => { - const dbKey = databaseType === 'vercel-postgres' ? 'POSTGRES_URL' : 'DATABASE_URI' - - const envVars: Record = {} - existingEnv - .split('\n') - .filter((line) => line.includes('=') && !line.startsWith('#')) - .forEach((line) => { - const [key, value] = line.split('=') - // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve - envVars[key] = value - }) - - // Override specific keys - envVars[dbKey] = databaseUri - envVars['PAYLOAD_SECRET'] = payloadSecret - - // Rebuild content - return Object.entries(envVars) - .map(([key, value]) => `${key}=${value}`) - .join('\n') + return updatedEnv } /** Parse and swap .env.example values and write .env */ @@ -88,42 +79,71 @@ export async function manageEnvFiles(args: { const envExamplePath = path.join(projectDir, '.env.example') const envPath = path.join(projectDir, '.env') - + const emptyEnvContent = `# Added by Payload\nDATABASE_URI=your-connection-string-here\nPAYLOAD_SECRET=YOUR_SECRET_HERE\n` try { let updatedExampleContents: string - // Update .env.example - if (template?.type === 'starter') { - if (!fs.existsSync(envExamplePath)) { - error(`.env.example file not found at ${envExamplePath}`) - process.exit(1) + if (template?.type === 'plugin') { + if (debugFlag) { + debug(`plugin template detected - no .env added .env.example added`) } + return + } + if (!fs.existsSync(envExamplePath)) { + updatedExampleContents = updateEnvExampleVariables( + emptyEnvContent, + databaseType, + payloadSecret, + databaseUri, + ) + + await fs.writeFile(envExamplePath, updatedExampleContents) + if (debugFlag) { + debug(`.env.example file successfully created`) + } + } else { const envExampleContents = await fs.readFile(envExamplePath, 'utf8') - updatedExampleContents = updateEnvExampleVariables(envExampleContents, databaseType) - - await fs.writeFile(envExamplePath, updatedExampleContents.trimEnd() + '\n') + const mergedEnvs = envExampleContents + '\n' + emptyEnvContent + updatedExampleContents = updateEnvExampleVariables( + mergedEnvs, + databaseType, + payloadSecret, + databaseUri, + ) + await fs.writeFile(envExamplePath, updatedExampleContents) if (debugFlag) { debug(`.env.example file successfully updated`) } - } else { - updatedExampleContents = `# Added by Payload\nDATABASE_URI=your-connection-string-here\nPAYLOAD_SECRET=YOUR_SECRET_HERE\n` - await fs.writeFile(envExamplePath, updatedExampleContents.trimEnd() + '\n') } - // Merge existing variables and create or update .env - const envExampleContents = await fs.readFile(envExamplePath, 'utf8') - const envContent = generateEnvContent( - envExampleContents, - databaseType, - databaseUri, - payloadSecret, - ) - await fs.writeFile(envPath, `# Added by Payload\n${envContent.trimEnd()}\n`) + if (!fs.existsSync(envPath)) { + const envContent = updateEnvExampleVariables( + emptyEnvContent, + databaseType, + payloadSecret, + databaseUri, + ) + await fs.writeFile(envPath, envContent) - if (debugFlag) { - debug(`.env file successfully created or updated`) + if (debugFlag) { + debug(`.env file successfully created`) + } + } else { + const envContents = await fs.readFile(envPath, 'utf8') + const mergedEnvs = envContents + '\n' + emptyEnvContent + const updatedEnvContents = updateEnvExampleVariables( + mergedEnvs, + databaseType, + payloadSecret, + databaseUri, + ) + + await fs.writeFile(envPath, updatedEnvContents) + if (debugFlag) { + debug(`.env file successfully updated`) + } } } catch (err: unknown) { error('Unable to manage environment files') diff --git a/packages/db-mongodb/package.json b/packages/db-mongodb/package.json index b433605a93..bba9c0dd54 100644 --- a/packages/db-mongodb/package.json +++ b/packages/db-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-mongodb", - "version": "3.35.1", + "version": "3.36.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 b6017ea73c..51a933ad4c 100644 --- a/packages/db-postgres/package.json +++ b/packages/db-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-postgres", - "version": "3.35.1", + "version": "3.36.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 12390b6c5c..87db6a8f0e 100644 --- a/packages/db-sqlite/package.json +++ b/packages/db-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-sqlite", - "version": "3.35.1", + "version": "3.36.1", "description": "The officially supported SQLite database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-sqlite/src/countDistinct.ts b/packages/db-sqlite/src/countDistinct.ts index de0af7a995..ae729138f0 100644 --- a/packages/db-sqlite/src/countDistinct.ts +++ b/packages/db-sqlite/src/countDistinct.ts @@ -16,7 +16,7 @@ export const countDistinct: CountDistinct = async function countDistinct( }) .from(this.tables[tableName]) .where(where) - return Number(countResult[0]?.count) + return Number(countResult?.[0]?.count ?? 0) } let query: SQLiteSelect = db @@ -39,5 +39,5 @@ export const countDistinct: CountDistinct = async function countDistinct( // Instead, COUNT (GROUP BY id) can be used which is still slower than COUNT(*) but acceptable. const countResult = await query - return Number(countResult[0]?.count) + return Number(countResult?.[0]?.count ?? 0) } diff --git a/packages/db-vercel-postgres/package.json b/packages/db-vercel-postgres/package.json index f33b6c80b6..1268e2c95c 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.1", + "version": "3.36.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 866f2477b2..254394b989 100644 --- a/packages/drizzle/package.json +++ b/packages/drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/drizzle", - "version": "3.35.1", + "version": "3.36.1", "description": "A library of shared functions used by different payload database adapters", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/drizzle/src/find/findMany.ts b/packages/drizzle/src/find/findMany.ts index 2c0304617d..6b429f38ca 100644 --- a/packages/drizzle/src/find/findMany.ts +++ b/packages/drizzle/src/find/findMany.ts @@ -46,6 +46,7 @@ export const findMany = async function find({ const offset = skip || (page - 1) * limit if (limit === 0) { + pagination = false limit = undefined } diff --git a/packages/drizzle/src/postgres/countDistinct.ts b/packages/drizzle/src/postgres/countDistinct.ts index 16c2f576c9..04d7559fcf 100644 --- a/packages/drizzle/src/postgres/countDistinct.ts +++ b/packages/drizzle/src/postgres/countDistinct.ts @@ -16,7 +16,8 @@ export const countDistinct: CountDistinct = async function countDistinct( }) .from(this.tables[tableName]) .where(where) - return Number(countResult[0].count) + + return Number(countResult?.[0]?.count ?? 0) } let query = db @@ -39,5 +40,5 @@ export const countDistinct: CountDistinct = async function countDistinct( // Instead, COUNT (GROUP BY id) can be used which is still slower than COUNT(*) but acceptable. const countResult = await query - return Number(countResult[0].count) + return Number(countResult?.[0]?.count ?? 0) } diff --git a/packages/email-nodemailer/package.json b/packages/email-nodemailer/package.json index 1cdc4983a8..0d36ea0b00 100644 --- a/packages/email-nodemailer/package.json +++ b/packages/email-nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-nodemailer", - "version": "3.35.1", + "version": "3.36.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 338b35c5af..c986fa70d4 100644 --- a/packages/email-resend/package.json +++ b/packages/email-resend/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-resend", - "version": "3.35.1", + "version": "3.36.1", "description": "Payload Resend Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/graphql/package.json b/packages/graphql/package.json index db700269b7..10f27f1383 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/graphql", - "version": "3.35.1", + "version": "3.36.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/graphql/src/schema/buildObjectType.ts b/packages/graphql/src/schema/buildObjectType.ts index 0ce88092e7..f368f663b0 100644 --- a/packages/graphql/src/schema/buildObjectType.ts +++ b/packages/graphql/src/schema/buildObjectType.ts @@ -11,6 +11,7 @@ export type ObjectTypeConfig = { type Args = { baseFields?: ObjectTypeConfig + collectionSlug?: string config: SanitizedConfig fields: Field[] forceNullable?: boolean @@ -23,6 +24,7 @@ type Args = { export function buildObjectType({ name, baseFields = {}, + collectionSlug, config, fields, forceNullable, @@ -43,6 +45,7 @@ export function buildObjectType({ return { ...objectTypeConfig, ...fieldSchema({ + collectionSlug, config, field, forceNullable, diff --git a/packages/graphql/src/schema/fieldToSchemaMap.ts b/packages/graphql/src/schema/fieldToSchemaMap.ts index fefd598393..fc5750add9 100644 --- a/packages/graphql/src/schema/fieldToSchemaMap.ts +++ b/packages/graphql/src/schema/fieldToSchemaMap.ts @@ -8,6 +8,7 @@ import type { DateField, EmailField, Field, + FlattenedJoinField, GraphQLInfo, GroupField, JoinField, @@ -68,6 +69,7 @@ function formattedNameResolver({ } type SharedArgs = { + collectionSlug?: string config: SanitizedConfig forceNullable?: boolean graphqlResult: GraphQLInfo @@ -340,7 +342,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = { }, } }, - join: ({ field, graphqlResult, objectTypeConfig, parentName }) => { + join: ({ collectionSlug, field, graphqlResult, objectTypeConfig, parentName }) => { const joinName = combineParentName(parentName, toWords(field.name, true)) const joinType = { @@ -385,27 +387,54 @@ export const fieldToSchemaMap: FieldToSchemaMap = { const draft = Boolean(args.draft ?? context.req.query?.draft) - const fullWhere = combineQueries(where, { - [field.on]: { equals: parent._id ?? parent.id }, - }) + const targetField = (field as FlattenedJoinField).targetField + + const fullWhere = combineQueries( + where, + Array.isArray(targetField.relationTo) + ? { + [field.on]: { + equals: { + relationTo: collectionSlug, + value: parent._id ?? parent.id, + }, + }, + } + : { + [field.on]: { equals: parent._id ?? parent.id }, + }, + ) if (Array.isArray(collection)) { throw new Error('GraphQL with array of join.field.collection is not implemented') } - return await req.payload.find({ + const { docs } = await req.payload.find({ collection, depth: 0, draft, fallbackLocale: req.fallbackLocale, - limit, + // Fetch one extra document to determine if there are more documents beyond the requested limit (used for hasNextPage calculation). + limit: typeof limit === 'number' && limit > 0 ? limit + 1 : 0, locale: req.locale, overrideAccess: false, page, + pagination: false, req, sort, where: fullWhere, }) + + let shouldSlice = false + + if (typeof limit === 'number' && limit !== 0 && limit < docs.length) { + shouldSlice = true + } + + return { + docs: shouldSlice ? docs.slice(0, -1) : docs, + hasNextPage: limit === 0 ? false : limit < docs.length, + } }, } diff --git a/packages/graphql/src/schema/fieldToWhereInputSchemaMap.ts b/packages/graphql/src/schema/fieldToWhereInputSchemaMap.ts index db3f0e1f39..ab77eff030 100644 --- a/packages/graphql/src/schema/fieldToWhereInputSchemaMap.ts +++ b/packages/graphql/src/schema/fieldToWhereInputSchemaMap.ts @@ -29,6 +29,7 @@ import { recursivelyBuildNestedPaths } from './recursivelyBuildNestedPaths.js' import { withOperators } from './withOperators.js' type Args = { + collectionSlug?: string nestedFieldName?: string parentName: string } diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index 626a2a1202..7dda78057d 100644 --- a/packages/graphql/src/schema/initCollections.ts +++ b/packages/graphql/src/schema/initCollections.ts @@ -111,6 +111,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ collection.graphQL.type = buildObjectType({ name: singularName, baseFields, + collectionSlug: collectionConfig.slug, config, fields, forceNullable: forceNullableObjectType, @@ -339,6 +340,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ collection.graphQL.versionType = buildObjectType({ name: `${singularName}Version`, + collectionSlug: collectionConfig.slug, config, fields: versionCollectionFields, forceNullable: forceNullableObjectType, diff --git a/packages/live-preview-react/package.json b/packages/live-preview-react/package.json index 5ae4fd75a6..70fb80fcb7 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.1", + "version": "3.36.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 c6d2c35823..219c04d332 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.1", + "version": "3.36.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 338ffa0c2e..8caac4e6aa 100644 --- a/packages/live-preview/package.json +++ b/packages/live-preview/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview", - "version": "3.35.1", + "version": "3.36.1", "description": "The official live preview JavaScript SDK for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/live-preview/src/handleMessage.ts b/packages/live-preview/src/handleMessage.ts index ed7ebeb47f..45f267684b 100644 --- a/packages/live-preview/src/handleMessage.ts +++ b/packages/live-preview/src/handleMessage.ts @@ -1,6 +1,6 @@ import type { FieldSchemaJSON } from 'payload' -import type { LivePreviewMessageEvent } from './types.js' +import type { CollectionPopulationRequestHandler, LivePreviewMessageEvent } from './types.js' import { isLivePreviewEvent } from './isLivePreviewEvent.js' import { mergeData } from './mergeData.js' @@ -29,9 +29,10 @@ export const handleMessage = async >(args: { depth?: number event: LivePreviewMessageEvent initialData: T + requestHandler?: CollectionPopulationRequestHandler serverURL: string }): Promise => { - const { apiRoute, depth, event, initialData, serverURL } = args + const { apiRoute, depth, event, initialData, requestHandler, serverURL } = args if (isLivePreviewEvent(event, serverURL)) { const { data, externallyUpdatedRelationship, fieldSchemaJSON, locale } = event.data @@ -57,6 +58,7 @@ export const handleMessage = async >(args: { incomingData: data, initialData: _payloadLivePreview?.previousData || initialData, locale, + requestHandler, serverURL, }) diff --git a/packages/live-preview/src/mergeData.ts b/packages/live-preview/src/mergeData.ts index 1448377475..60f497fb20 100644 --- a/packages/live-preview/src/mergeData.ts +++ b/packages/live-preview/src/mergeData.ts @@ -1,6 +1,6 @@ import type { DocumentEvent, FieldSchemaJSON, PaginatedDocs } from 'payload' -import type { PopulationsByCollection } from './types.js' +import type { CollectionPopulationRequestHandler, PopulationsByCollection } from './types.js' import { traverseFields } from './traverseFields.js' @@ -29,21 +29,17 @@ let prevLocale: string | undefined export const mergeData = async >(args: { apiRoute?: string - collectionPopulationRequestHandler?: ({ - apiPath, - endpoint, - serverURL, - }: { - apiPath: string - endpoint: string - serverURL: string - }) => Promise + /** + * @deprecated Use `requestHandler` instead + */ + collectionPopulationRequestHandler?: CollectionPopulationRequestHandler depth?: number externallyUpdatedRelationship?: DocumentEvent fieldSchema: FieldSchemaJSON incomingData: Partial initialData: T locale?: string + requestHandler?: CollectionPopulationRequestHandler returnNumberOfRequests?: boolean serverURL: string }): Promise< @@ -81,7 +77,8 @@ export const mergeData = async >(args: { let res: PaginatedDocs const ids = new Set(populations.map(({ id }) => id)) - const requestHandler = args.collectionPopulationRequestHandler || defaultRequestHandler + const requestHandler = + args.collectionPopulationRequestHandler || args.requestHandler || defaultRequestHandler try { res = await requestHandler({ diff --git a/packages/live-preview/src/subscribe.ts b/packages/live-preview/src/subscribe.ts index 6cbe1bf4e7..f6c27e9843 100644 --- a/packages/live-preview/src/subscribe.ts +++ b/packages/live-preview/src/subscribe.ts @@ -1,3 +1,5 @@ +import type { CollectionPopulationRequestHandler } from './types.js' + import { handleMessage } from './handleMessage.js' export const subscribe = >(args: { @@ -5,9 +7,10 @@ export const subscribe = >(args: { callback: (data: T) => void depth?: number initialData: T + requestHandler?: CollectionPopulationRequestHandler serverURL: string }): ((event: MessageEvent) => Promise | void) => { - const { apiRoute, callback, depth, initialData, serverURL } = args + const { apiRoute, callback, depth, initialData, requestHandler, serverURL } = args const onMessage = async (event: MessageEvent) => { const mergedData = await handleMessage({ @@ -15,6 +18,7 @@ export const subscribe = >(args: { depth, event, initialData, + requestHandler, serverURL, }) diff --git a/packages/live-preview/src/types.ts b/packages/live-preview/src/types.ts index 94ae0ba447..4128d23720 100644 --- a/packages/live-preview/src/types.ts +++ b/packages/live-preview/src/types.ts @@ -1,5 +1,15 @@ import type { DocumentEvent, FieldSchemaJSON } from 'payload' +export type CollectionPopulationRequestHandler = ({ + apiPath, + endpoint, + serverURL, +}: { + apiPath: string + endpoint: string + serverURL: string +}) => Promise + export type LivePreviewArgs = {} export type LivePreview = void diff --git a/packages/next/package.json b/packages/next/package.json index f4ae1af419..9102fc40ca 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/next", - "version": "3.35.1", + "version": "3.36.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/next/src/views/Version/SelectComparison/index.tsx b/packages/next/src/views/Version/SelectComparison/index.tsx index 82f8ef2958..37c50742e1 100644 --- a/packages/next/src/views/Version/SelectComparison/index.tsx +++ b/packages/next/src/views/Version/SelectComparison/index.tsx @@ -2,7 +2,14 @@ import type { PaginatedDocs, Where } from 'payload' -import { fieldBaseClass, Pill, ReactSelect, useConfig, useTranslation } from '@payloadcms/ui' +import { + fieldBaseClass, + Pill, + ReactSelect, + useConfig, + useDocumentInfo, + useTranslation, +} from '@payloadcms/ui' import { formatDate } from '@payloadcms/ui/shared' import { stringify } from 'qs-esm' import React, { useCallback, useEffect, useState } from 'react' @@ -37,6 +44,8 @@ export const SelectComparison: React.FC = (props) => { }, } = useConfig() + const { hasPublishedDoc } = useDocumentInfo() + const [options, setOptions] = useState< { label: React.ReactNode | string @@ -109,7 +118,10 @@ export const SelectComparison: React.FC = (props) => { }, published: { currentLabel: t('version:currentPublishedVersion'), - latestVersion: latestPublishedVersion, + // The latest published version does not necessarily equal the current published version, + // because the latest published version might have been unpublished in the meantime. + // Hence, we should only use the latest published version if there is a published document. + latestVersion: hasPublishedDoc ? latestPublishedVersion : undefined, pillStyle: 'success', previousLabel: t('version:previouslyPublished'), }, diff --git a/packages/next/src/views/Versions/index.tsx b/packages/next/src/views/Versions/index.tsx index 54e05e0088..744ddfd08b 100644 --- a/packages/next/src/views/Versions/index.tsx +++ b/packages/next/src/views/Versions/index.tsx @@ -85,13 +85,34 @@ export async function VersionsView(props: DocumentViewServerProps) { payload, status: 'draft', }) - latestPublishedVersion = await getLatestVersion({ - slug: collectionSlug, - type: 'collection', - parentID: id, - payload, - status: 'published', + const publishedDoc = await payload.count({ + collection: collectionSlug, + depth: 0, + overrideAccess: true, + req, + where: { + id: { + equals: id, + }, + _status: { + equals: 'published', + }, + }, }) + + // If we pass a latestPublishedVersion to buildVersionColumns, + // this will be used to display it as the "current published version". + // However, the latest published version might have been unpublished in the meantime. + // Hence, we should only pass the latest published version if there is a published document. + latestPublishedVersion = + publishedDoc.totalDocs > 0 && + (await getLatestVersion({ + slug: collectionSlug, + type: 'collection', + parentID: id, + payload, + status: 'published', + })) } } catch (err) { logError({ err, payload }) diff --git a/packages/next/src/withPayload.js b/packages/next/src/withPayload.js index a80aa99b4b..d72690c55f 100644 --- a/packages/next/src/withPayload.js +++ b/packages/next/src/withPayload.js @@ -140,6 +140,13 @@ export const withPayload = (nextConfig = {}, options = {}) => { { module: /node_modules\/mongodb\/lib\/bson\.js/ }, { file: /node_modules\/mongodb\/lib\/bson\.js/ }, ], + plugins: [ + ...(incomingWebpackConfig?.plugins || []), + // Fix cloudflare:sockets error: https://github.com/vercel/next.js/discussions/50177 + new webpackOptions.webpack.IgnorePlugin({ + resourceRegExp: /^pg-native$|^cloudflare:sockets$/, + }), + ], resolve: { ...(incomingWebpackConfig?.resolve || {}), alias: { diff --git a/packages/payload-cloud/package.json b/packages/payload-cloud/package.json index bbaf0361da..9e7ce8217e 100644 --- a/packages/payload-cloud/package.json +++ b/packages/payload-cloud/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/payload-cloud", - "version": "3.35.1", + "version": "3.36.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 e294512b71..0b62938b55 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "3.35.1", + "version": "3.36.1", "description": "Node, React, Headless CMS and Application Framework built on Next.js", "keywords": [ "admin panel", diff --git a/packages/payload/src/auth/operations/forgotPassword.ts b/packages/payload/src/auth/operations/forgotPassword.ts index a51f947b32..dad7df932d 100644 --- a/packages/payload/src/auth/operations/forgotPassword.ts +++ b/packages/payload/src/auth/operations/forgotPassword.ts @@ -138,15 +138,17 @@ export const forgotPasswordOperation = async ( return null } - user.resetPasswordToken = token - user.resetPasswordExpiration = new Date( + const resetPasswordExpiration = new Date( Date.now() + (collectionConfig.auth?.forgotPassword?.expiration ?? expiration ?? 3600000), ).toISOString() user = await payload.update({ id: user.id, collection: collectionConfig.slug, - data: user, + data: { + resetPasswordExpiration, + resetPasswordToken: token, + }, req, }) diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 908d138a94..9f3e7f5028 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -247,6 +247,7 @@ export const createOperation = async < let doc const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/collections/operations/delete.ts b/packages/payload/src/collections/operations/delete.ts index 824c5a7af6..68a9fecbc3 100644 --- a/packages/payload/src/collections/operations/delete.ts +++ b/packages/payload/src/collections/operations/delete.ts @@ -110,6 +110,7 @@ export const deleteOperation = async < const fullWhere = combineQueries(where, accessResult) const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/collections/operations/deleteByID.ts b/packages/payload/src/collections/operations/deleteByID.ts index 6225700e42..add2bd8445 100644 --- a/packages/payload/src/collections/operations/deleteByID.ts +++ b/packages/payload/src/collections/operations/deleteByID.ts @@ -168,6 +168,7 @@ export const deleteByIDOperation = async ( // ///////////////////////////////////// const select = sanitizeSelect({ + fields: buildVersionCollectionFields(payload.config, collectionConfig, true), forceSelect: getQueryDraftsSelect({ select: collectionConfig.forceSelect }), select: incomingSelect, + versions: true, }) const versionsQuery = await payload.db.findVersions({ diff --git a/packages/payload/src/collections/operations/findVersions.ts b/packages/payload/src/collections/operations/findVersions.ts index fb1e59cdf3..029af417f1 100644 --- a/packages/payload/src/collections/operations/findVersions.ts +++ b/packages/payload/src/collections/operations/findVersions.ts @@ -72,8 +72,10 @@ export const findVersionsOperation = async const fullWhere = combineQueries(where, accessResults) const select = sanitizeSelect({ + fields: buildVersionCollectionFields(payload.config, collectionConfig, true), forceSelect: getQueryDraftsSelect({ select: collectionConfig.forceSelect }), select: incomingSelect, + versions: true, }) // ///////////////////////////////////// diff --git a/packages/payload/src/collections/operations/restoreVersion.ts b/packages/payload/src/collections/operations/restoreVersion.ts index f43e56e943..b671e30300 100644 --- a/packages/payload/src/collections/operations/restoreVersion.ts +++ b/packages/payload/src/collections/operations/restoreVersion.ts @@ -117,6 +117,7 @@ export const restoreVersionOperation = async ( // ///////////////////////////////////// const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index ab2e2308fa..31e412e436 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -201,6 +201,7 @@ export const updateOperation = async < try { const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index a340ea307f..c80487686c 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -161,6 +161,7 @@ export const updateByIDOperation = async < }) const select = sanitizeSelect({ + fields: collectionConfig.flattenedFields, forceSelect: collectionConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/config/orderable/index.ts b/packages/payload/src/config/orderable/index.ts index c09de82a12..26b22f7cd9 100644 --- a/packages/payload/src/config/orderable/index.ts +++ b/packages/payload/src/config/orderable/index.ts @@ -83,6 +83,13 @@ export const addOrderableFieldsAndHook = ( hidden: true, readOnly: true, }, + hooks: { + beforeDuplicate: [ + ({ siblingData }) => { + delete siblingData[orderableFieldName] + }, + ], + }, index: true, required: true, // override the schema to make order fields optional for payload.create() @@ -275,5 +282,6 @@ export const addOrderableEndpoint = (config: SanitizedConfig) => { if (!config.endpoints) { config.endpoints = [] } + config.endpoints.push(reorderEndpoint) } diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 3050e6250c..a8aec98c94 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1061,6 +1061,7 @@ export type SelectField = { } & Admin['components'] isClearable?: boolean isSortable?: boolean + placeholder?: LabelFunction | string } & Admin /** * Customize the SQL table name @@ -1093,7 +1094,7 @@ export type SelectField = { Omit export type SelectFieldClient = { - admin?: AdminClient & Pick + admin?: AdminClient & Pick } & FieldBaseClient & Pick @@ -1160,10 +1161,11 @@ type RelationshipAdmin = { > } & Admin['components'] isSortable?: boolean + placeholder?: LabelFunction | string } & Admin type RelationshipAdminClient = AdminClient & - Pick + Pick export type PolymorphicRelationshipField = { admin?: { diff --git a/packages/payload/src/fields/validations.ts b/packages/payload/src/fields/validations.ts index a22e5cd5d8..5018948c51 100644 --- a/packages/payload/src/fields/validations.ts +++ b/packages/payload/src/fields/validations.ts @@ -200,7 +200,7 @@ export const email: EmailFieldValidation = ( * Supports multiple subdomains (e.g., user@sub.domain.example.com) */ const emailRegex = - /^(?!.*\.\.)[\w.%+-]+@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/i + /^(?!.*\.\.)[\w!#$%&'*+/=?^`{|}~-](?:[\w!#$%&'*+/=?^`{|}~.-]*[\w!#$%&'*+/=?^`{|}~-])?@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/i if ((value && !emailRegex.test(value)) || (!value && required)) { return t('validation:emailAddress') diff --git a/packages/payload/src/globals/operations/findOne.ts b/packages/payload/src/globals/operations/findOne.ts index b076fc5ae3..f341cc019d 100644 --- a/packages/payload/src/globals/operations/findOne.ts +++ b/packages/payload/src/globals/operations/findOne.ts @@ -53,6 +53,7 @@ export const findOneOperation = async >( } const select = sanitizeSelect({ + fields: globalConfig.flattenedFields, forceSelect: globalConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/globals/operations/findVersionByID.ts b/packages/payload/src/globals/operations/findVersionByID.ts index 06a00c12e6..37b457551a 100644 --- a/packages/payload/src/globals/operations/findVersionByID.ts +++ b/packages/payload/src/globals/operations/findVersionByID.ts @@ -11,6 +11,8 @@ import { afterRead } from '../../fields/hooks/afterRead/index.js' import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' import { killTransaction } from '../../utilities/killTransaction.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' +import { buildVersionCollectionFields } from '../../versions/buildCollectionFields.js' +import { buildVersionGlobalFields } from '../../versions/buildGlobalFields.js' import { getQueryDraftsSelect } from '../../versions/drafts/getQueryDraftsSelect.js' export type Arguments = { @@ -60,8 +62,10 @@ export const findVersionByIDOperation = async = an const hasWhereAccess = typeof accessResults === 'object' const select = sanitizeSelect({ + fields: buildVersionGlobalFields(payload.config, globalConfig, true), forceSelect: getQueryDraftsSelect({ select: globalConfig.forceSelect }), select: incomingSelect, + versions: true, }) const findGlobalVersionsArgs: FindGlobalVersionsArgs = { diff --git a/packages/payload/src/globals/operations/findVersions.ts b/packages/payload/src/globals/operations/findVersions.ts index 2f59b44097..57bcbf7099 100644 --- a/packages/payload/src/globals/operations/findVersions.ts +++ b/packages/payload/src/globals/operations/findVersions.ts @@ -70,8 +70,10 @@ export const findVersionsOperation = async >( const fullWhere = combineQueries(where, accessResults) const select = sanitizeSelect({ + fields: buildVersionGlobalFields(payload.config, globalConfig, true), forceSelect: getQueryDraftsSelect({ select: globalConfig.forceSelect }), select: incomingSelect, + versions: true, }) // ///////////////////////////////////// diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 859a04342f..c1f32a7b1d 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -246,6 +246,7 @@ export const updateOperation = async < // ///////////////////////////////////// const select = sanitizeSelect({ + fields: globalConfig.flattenedFields, forceSelect: globalConfig.forceSelect, select: incomingSelect, }) diff --git a/packages/payload/src/query-presets/constraints.ts b/packages/payload/src/query-presets/constraints.ts index 8e9cec4f5d..33603d2f1d 100644 --- a/packages/payload/src/query-presets/constraints.ts +++ b/packages/payload/src/query-presets/constraints.ts @@ -74,7 +74,7 @@ export const getConstraints = (config: Config): Field => ({ }, ], }, - relationTo: 'users', + relationTo: config.admin?.user ?? 'users', // TODO: remove this fallback when the args are properly typed as `SanitizedConfig` }, ...(config?.queryPresets?.constraints?.[operation]?.reduce( (acc: Field[], option: QueryPresetConstraint) => { diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index 860be72713..bf5c86d7f0 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -10,7 +10,7 @@ import type { SanitizedConfig } from '../config/types.js' import type { PayloadRequest } from '../types/index.js' import type { FileData, FileToSave, ProbedImageSize, UploadEdits } from './types.js' -import { FileRetrievalError, FileUploadError, MissingFile } from '../errors/index.js' +import { FileRetrievalError, FileUploadError, Forbidden, MissingFile } from '../errors/index.js' import { canResizeImage } from './canResizeImage.js' import { cropImage } from './cropImage.js' import { getExternalFile } from './getExternalFile.js' @@ -85,6 +85,10 @@ export const generateFileData = async ({ if (!file && uploadEdits && incomingFileData) { const { filename, url } = incomingFileData as FileData + if (filename && (filename.includes('../') || filename.includes('..\\'))) { + throw new Forbidden(req.t) + } + try { if (url && url.startsWith('/') && !disableLocalStorage) { const filePath = `${staticPath}/${filename}` diff --git a/packages/payload/src/uploads/getFileByPath.ts b/packages/payload/src/uploads/getFileByPath.ts index 53ce791196..2f0a1f1526 100644 --- a/packages/payload/src/uploads/getFileByPath.ts +++ b/packages/payload/src/uploads/getFileByPath.ts @@ -5,28 +5,28 @@ import path from 'path' import type { PayloadRequest } from '../types/index.js' -const mimeTypeEstimate = { +const mimeTypeEstimate: Record = { svg: 'image/svg+xml', } export const getFileByPath = async (filePath: string): Promise => { - if (typeof filePath === 'string') { - const data = await fs.readFile(filePath) - const mimetype = fileTypeFromFile(filePath) - const { size } = await fs.stat(filePath) - - const name = path.basename(filePath) - const ext = path.extname(filePath).slice(1) - - const mime = (await mimetype)?.mime || mimeTypeEstimate[ext] - - return { - name, - data, - mimetype: mime, - size, - } + if (typeof filePath !== 'string') { + return undefined } - return undefined + const name = path.basename(filePath) + const ext = path.extname(filePath).slice(1) + + const [data, stat, type] = await Promise.all([ + fs.readFile(filePath), + fs.stat(filePath), + fileTypeFromFile(filePath), + ]) + + return { + name, + data, + mimetype: type?.mime || mimeTypeEstimate[ext], + size: stat.size, + } } diff --git a/packages/payload/src/utilities/sanitizeSelect.ts b/packages/payload/src/utilities/sanitizeSelect.ts index 9d18bcbb51..3e7bf9d56c 100644 --- a/packages/payload/src/utilities/sanitizeSelect.ts +++ b/packages/payload/src/utilities/sanitizeSelect.ts @@ -1,17 +1,129 @@ import { deepMergeSimple } from '@payloadcms/translations/utilities' -import type { SelectType } from '../types/index.js' +import type { FlattenedField } from '../fields/config/types.js' +import type { SelectIncludeType, SelectType } from '../types/index.js' import { getSelectMode } from './getSelectMode.js' +// Transform post.title -> post, post.category.title -> post +const stripVirtualPathToCurrentCollection = ({ + fields, + path, + versions, +}: { + fields: FlattenedField[] + path: string + versions: boolean +}) => { + const resultSegments: string[] = [] + + if (versions) { + resultSegments.push('version') + const versionField = fields.find((each) => each.name === 'version') + + if (versionField && versionField.type === 'group') { + fields = versionField.flattenedFields + } + } + + for (const segment of path.split('.')) { + const field = fields.find((each) => each.name === segment) + + if (!field) { + continue + } + + resultSegments.push(segment) + + if (field.type === 'relationship' || field.type === 'upload') { + return resultSegments.join('.') + } + } + + return resultSegments.join('.') +} + +const getAllVirtualRelations = ({ fields }: { fields: FlattenedField[] }) => { + const result: string[] = [] + + for (const field of fields) { + if ('virtual' in field && typeof field.virtual === 'string') { + result.push(field.virtual) + } else if (field.type === 'group' || field.type === 'tab') { + const nestedResult = getAllVirtualRelations({ fields: field.flattenedFields }) + + for (const nestedItem of nestedResult) { + result.push(nestedItem) + } + } + } + + return result +} + +const resolveVirtualRelationsToSelect = ({ + fields, + selectValue, + topLevelFields, + versions, +}: { + fields: FlattenedField[] + selectValue: SelectIncludeType | true + topLevelFields: FlattenedField[] + versions: boolean +}) => { + const result: string[] = [] + if (selectValue === true) { + for (const item of getAllVirtualRelations({ fields })) { + result.push( + stripVirtualPathToCurrentCollection({ fields: topLevelFields, path: item, versions }), + ) + } + } else { + for (const fieldName in selectValue) { + const field = fields.find((each) => each.name === fieldName) + if (!field) { + continue + } + + if ('virtual' in field && typeof field.virtual === 'string') { + result.push( + stripVirtualPathToCurrentCollection({ + fields: topLevelFields, + path: field.virtual, + versions, + }), + ) + } else if (field.type === 'group' || field.type === 'tab') { + for (const item of resolveVirtualRelationsToSelect({ + fields: field.flattenedFields, + selectValue: selectValue[fieldName], + topLevelFields, + versions, + })) { + result.push( + stripVirtualPathToCurrentCollection({ fields: topLevelFields, path: item, versions }), + ) + } + } + } + } + + return result +} + export const sanitizeSelect = ({ + fields, forceSelect, select, + versions, }: { + fields: FlattenedField[] forceSelect?: SelectType select?: SelectType + versions?: boolean }): SelectType | undefined => { - if (!forceSelect || !select) { + if (!select) { return select } @@ -21,5 +133,36 @@ export const sanitizeSelect = ({ return select } - return deepMergeSimple(select, forceSelect) + if (forceSelect) { + select = deepMergeSimple(select, forceSelect) + } + + if (select) { + const virtualRelations = resolveVirtualRelationsToSelect({ + fields, + selectValue: select as SelectIncludeType, + topLevelFields: fields, + versions: versions ?? false, + }) + + for (const path of virtualRelations) { + let currentRef = select + const segments = path.split('.') + for (let i = 0; i < segments.length; i++) { + const isLast = segments.length - 1 === i + const segment = segments[i] + + if (isLast) { + currentRef[segment] = true + } else { + if (!(segment in currentRef)) { + currentRef[segment] = {} + currentRef = currentRef[segment] + } + } + } + } + } + + return select } diff --git a/packages/plugin-cloud-storage/package.json b/packages/plugin-cloud-storage/package.json index 47dab5d7bd..8b5d9020f9 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.1", + "version": "3.36.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 2ef45b15b1..bf5de206ac 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.1", + "version": "3.36.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 ce73cf23fb..0317874ffc 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.1", + "version": "3.36.1", "description": "Import-Export plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-import-export/src/export/createExport.ts b/packages/plugin-import-export/src/export/createExport.ts index 76021900a4..cee0c80f1c 100644 --- a/packages/plugin-import-export/src/export/createExport.ts +++ b/packages/plugin-import-export/src/export/createExport.ts @@ -87,7 +87,7 @@ export const createExport = async (args: CreateExportArgs) => { let isFirstBatch = true while (result.docs.length > 0) { - const csvInput = result.docs.map((doc) => flattenObject(doc)) + const csvInput = result.docs.map((doc) => flattenObject({ doc, fields })) const csvString = stringify(csvInput, { header: isFirstBatch }) this.push(encoder.encode(csvString)) isFirstBatch = false @@ -119,7 +119,7 @@ export const createExport = async (args: CreateExportArgs) => { result = await payload.find(findArgs) if (isCSV) { - const csvInput = result.docs.map((doc) => flattenObject(doc)) + const csvInput = result.docs.map((doc) => flattenObject({ doc, fields })) outputData.push(stringify(csvInput, { header: isFirstBatch })) isFirstBatch = false } else { diff --git a/packages/plugin-import-export/src/export/flattenObject.ts b/packages/plugin-import-export/src/export/flattenObject.ts index 8fe2c83f23..ccc2de988c 100644 --- a/packages/plugin-import-export/src/export/flattenObject.ts +++ b/packages/plugin-import-export/src/export/flattenObject.ts @@ -1,23 +1,61 @@ -export const flattenObject = (obj: any, prefix: string = ''): Record => { +import type { Document } from 'payload' + +type Args = { + doc: Document + fields?: string[] + prefix?: string +} + +export const flattenObject = ({ doc, fields, prefix }: Args): Record => { const result: Record = {} - Object.entries(obj).forEach(([key, value]) => { - const newKey = prefix ? `${prefix}_${key}` : key + const flatten = (doc: Document, prefix?: string) => { + Object.entries(doc).forEach(([key, value]) => { + const newKey = prefix ? `${prefix}_${key}` : key - if (Array.isArray(value)) { - value.forEach((item, index) => { - if (typeof item === 'object' && item !== null) { - Object.assign(result, flattenObject(item, `${newKey}_${index}`)) - } else { - result[`${newKey}_${index}`] = item - } - }) - } else if (typeof value === 'object' && value !== null) { - Object.assign(result, flattenObject(value, newKey)) - } else { - result[newKey] = value + if (Array.isArray(value)) { + value.forEach((item, index) => { + if (typeof item === 'object' && item !== null) { + flatten(item, `${newKey}_${index}`) + } else { + result[`${newKey}_${index}`] = item + } + }) + } else if (typeof value === 'object' && value !== null) { + flatten(value, newKey) + } else { + result[newKey] = value + } + }) + } + + flatten(doc, prefix) + + if (fields) { + const orderedResult: Record = {} + + const fieldToRegex = (field: string): RegExp => { + const parts = field.split('.').map((part) => `${part}(?:_\\d+)?`) + const pattern = `^${parts.join('_')}` + return new RegExp(pattern) } - }) + + fields.forEach((field) => { + if (result[field.replace(/\./g, '_')]) { + const sanitizedField = field.replace(/\./g, '_') + orderedResult[sanitizedField] = result[sanitizedField] + } else { + const regex = fieldToRegex(field) + Object.keys(result).forEach((key) => { + if (regex.test(key)) { + orderedResult[key] = result[key] + } + }) + } + }) + + return orderedResult + } return result } diff --git a/packages/plugin-multi-tenant/package.json b/packages/plugin-multi-tenant/package.json index 37f1898379..077b46d5b1 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.1", + "version": "3.36.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 a04087888d..daf8954e84 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.1", + "version": "3.36.1", "description": "The official Nested Docs plugin for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/plugin-nested-docs/src/hooks/resaveChildren.ts b/packages/plugin-nested-docs/src/hooks/resaveChildren.ts index dca94c7285..becaf3ad4a 100644 --- a/packages/plugin-nested-docs/src/hooks/resaveChildren.ts +++ b/packages/plugin-nested-docs/src/hooks/resaveChildren.ts @@ -22,7 +22,6 @@ type ResaveArgs = { const resave = async ({ collection, doc, draft, pluginConfig, req }: ResaveArgs) => { const parentSlug = pluginConfig?.parentFieldSlug || 'parent' - const breadcrumbSlug = pluginConfig.breadcrumbsFieldSlug || 'breadcrumbs' if (draft) { // If the parent is a draft, don't resave children diff --git a/packages/plugin-nested-docs/src/hooks/resaveSelfAfterCreate.ts b/packages/plugin-nested-docs/src/hooks/resaveSelfAfterCreate.ts index 14368b248d..facc5464cf 100644 --- a/packages/plugin-nested-docs/src/hooks/resaveSelfAfterCreate.ts +++ b/packages/plugin-nested-docs/src/hooks/resaveSelfAfterCreate.ts @@ -8,47 +8,39 @@ import type { Breadcrumb, NestedDocsPluginConfig } from '../types.js' export const resaveSelfAfterCreate = (pluginConfig: NestedDocsPluginConfig, collection: CollectionConfig): CollectionAfterChangeHook => async ({ doc, operation, req }) => { + if (operation !== 'create') { + return undefined + } + const { locale, payload } = req const breadcrumbSlug = pluginConfig.breadcrumbsFieldSlug || 'breadcrumbs' const breadcrumbs = doc[breadcrumbSlug] as unknown as Breadcrumb[] - if (operation === 'create') { - const originalDocWithDepth0 = await payload.findByID({ + const updateAsDraft = + typeof collection.versions === 'object' && + collection.versions.drafts && + doc._status !== 'published' + + try { + await payload.update({ id: doc.id, collection: collection.slug, + data: { + [breadcrumbSlug]: + breadcrumbs?.map((crumb, i) => ({ + ...crumb, + doc: breadcrumbs.length === i + 1 ? doc.id : crumb.doc, + })) || [], + }, depth: 0, + draft: updateAsDraft, + locale, req, }) - - const updateAsDraft = - typeof collection.versions === 'object' && - collection.versions.drafts && - doc._status !== 'published' - - try { - await payload.update({ - id: doc.id, - collection: collection.slug, - data: { - ...originalDocWithDepth0, - [breadcrumbSlug]: - breadcrumbs?.map((crumb, i) => ({ - ...crumb, - doc: breadcrumbs.length === i + 1 ? doc.id : crumb.doc, - })) || [], - }, - depth: 0, - draft: updateAsDraft, - locale, - req, - }) - } catch (err: unknown) { - payload.logger.error( - `Nested Docs plugin has had an error while adding breadcrumbs during document creation.`, - ) - payload.logger.error(err) - } + } catch (err: unknown) { + payload.logger.error( + `Nested Docs plugin has had an error while adding breadcrumbs during document creation.`, + ) + payload.logger.error(err) } - - return undefined } diff --git a/packages/plugin-redirects/package.json b/packages/plugin-redirects/package.json index 65c605e62a..2aa329ad26 100644 --- a/packages/plugin-redirects/package.json +++ b/packages/plugin-redirects/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-redirects", - "version": "3.35.1", + "version": "3.36.1", "description": "Redirects plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-search/package.json b/packages/plugin-search/package.json index 295d36315e..430bef0eb2 100644 --- a/packages/plugin-search/package.json +++ b/packages/plugin-search/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-search", - "version": "3.35.1", + "version": "3.36.1", "description": "Search plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-search/src/utilities/generateReindexHandler.ts b/packages/plugin-search/src/utilities/generateReindexHandler.ts index b14184228c..929d04e843 100644 --- a/packages/plugin-search/src/utilities/generateReindexHandler.ts +++ b/packages/plugin-search/src/utilities/generateReindexHandler.ts @@ -124,14 +124,15 @@ export const generateReindexHandler = for (let i = 0; i < totalBatches; i++) { const { docs } = await payload.find({ collection, + depth: 0, limit: batchSize, locale: localeToSync, page: i + 1, ...defaultLocalApiProps, }) - const promises = docs.map((doc) => - syncDocAsSearchIndex({ + for (const doc of docs) { + await syncDocAsSearchIndex({ collection, doc, locale: localeToSync, @@ -139,12 +140,7 @@ export const generateReindexHandler = operation, pluginConfig, req, - }), - ) - - // Sequentially await promises to avoid transaction issues - for (const promise of promises) { - await promise + }) } } } diff --git a/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts b/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts index 2f2159815f..433f044683 100644 --- a/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts +++ b/packages/plugin-search/src/utilities/syncDocAsSearchIndex.ts @@ -64,18 +64,17 @@ export const syncDocAsSearchIndex = async ({ const doSync = syncDrafts || (!syncDrafts && status !== 'draft') try { - if (operation === 'create') { - if (doSync) { - await payload.create({ - collection: searchSlug, - data: { - ...dataToSave, - priority: defaultPriority, - }, - locale: syncLocale, - req, - }) - } + if (operation === 'create' && doSync) { + await payload.create({ + collection: searchSlug, + data: { + ...dataToSave, + priority: defaultPriority, + }, + depth: 0, + locale: syncLocale, + req, + }) } if (operation === 'update') { @@ -110,6 +109,7 @@ export const syncDocAsSearchIndex = async ({ const duplicativeDocIDs = duplicativeDocs.map(({ id }) => id) await payload.delete({ collection: searchSlug, + depth: 0, req, where: { id: { in: duplicativeDocIDs } }, }) @@ -134,6 +134,7 @@ export const syncDocAsSearchIndex = async ({ ...dataToSave, priority: foundDoc.priority || defaultPriority, }, + depth: 0, locale: syncLocale, req, }) @@ -148,6 +149,7 @@ export const syncDocAsSearchIndex = async ({ docs: [docWithPublish], } = await payload.find({ collection, + depth: 0, draft: false, limit: 1, locale: syncLocale, @@ -175,6 +177,7 @@ export const syncDocAsSearchIndex = async ({ await payload.delete({ id: searchDocID, collection: searchSlug, + depth: 0, req, }) } catch (err: unknown) { @@ -190,6 +193,7 @@ export const syncDocAsSearchIndex = async ({ ...dataToSave, priority: defaultPriority, }, + depth: 0, locale: syncLocale, req, }) diff --git a/packages/plugin-sentry/package.json b/packages/plugin-sentry/package.json index 9b1218a7b7..02bc00753d 100644 --- a/packages/plugin-sentry/package.json +++ b/packages/plugin-sentry/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-sentry", - "version": "3.35.1", + "version": "3.36.1", "description": "Sentry plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-seo/package.json b/packages/plugin-seo/package.json index 4f13a39f91..769554c198 100644 --- a/packages/plugin-seo/package.json +++ b/packages/plugin-seo/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-seo", - "version": "3.35.1", + "version": "3.36.1", "description": "SEO plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-stripe/package.json b/packages/plugin-stripe/package.json index e45211aaa1..c75b99fd7f 100644 --- a/packages/plugin-stripe/package.json +++ b/packages/plugin-stripe/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-stripe", - "version": "3.35.1", + "version": "3.36.1", "description": "Stripe plugin for Payload", "keywords": [ "payload", diff --git a/packages/richtext-lexical/package.json b/packages/richtext-lexical/package.json index f6e2057f6b..466ec7f03e 100644 --- a/packages/richtext-lexical/package.json +++ b/packages/richtext-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-lexical", - "version": "3.35.1", + "version": "3.36.1", "description": "The officially supported Lexical richtext adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx b/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx index 9df01e47f9..8cae0bdd1b 100644 --- a/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx @@ -284,10 +284,22 @@ export const InlineBlockComponent: React.FC = (props) => { ) // cleanup effect useEffect(() => { + const isStateOutOfSync = (formData: InlineBlockFields, initialState: FormState) => { + return Object.keys(initialState).some( + (key) => initialState[key] && formData[key] !== initialState[key].value, + ) + } + return () => { + // If the component is unmounted (either via removeInlineBlock or via lexical itself) and the form state got changed before, + // we need to reset the initial state to force a re-fetch of the initial state when it gets mounted again (e.g. via lexical history undo). + // Otherwise it would use an outdated initial state. + if (initialState && isStateOutOfSync(formData, initialState)) { + setInitialState(false) + } abortAndIgnore(onChangeAbortControllerRef.current) } - }, []) + }, [formData, initialState]) /** * HANDLE FORM SUBMIT diff --git a/packages/richtext-lexical/src/features/blocks/client/plugin/index.tsx b/packages/richtext-lexical/src/features/blocks/client/plugin/index.tsx index ed8e0c1adf..fa9539852e 100644 --- a/packages/richtext-lexical/src/features/blocks/client/plugin/index.tsx +++ b/packages/richtext-lexical/src/features/blocks/client/plugin/index.tsx @@ -56,22 +56,15 @@ export const BlocksPlugin: PluginComponent = () => { if ($isRangeSelection(selection)) { const blockNode = $createBlockNode(payload) + + // we need to get the focus node before inserting the block node, as $insertNodeToNearestRoot can change the focus node + const { focus } = selection + const focusNode = focus.getNode() // Insert blocks node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist $insertNodeToNearestRoot(blockNode) - const { focus } = selection - const focusNode = focus.getNode() - - // First, delete currently selected node if it's an empty paragraph and if there are sufficient - // paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user - if ( - $isParagraphNode(focusNode) && - focusNode.getTextContentSize() === 0 && - focusNode - .getParentOrThrow() - .getChildren() - .filter((node) => $isParagraphNode(node)).length > 1 - ) { + // Delete the node it it's an empty paragraph + if ($isParagraphNode(focusNode) && !focusNode.__first) { focusNode.remove() } } diff --git a/packages/richtext-lexical/src/features/converters/lexicalToPlaintext/convertLexicalToPlaintext.spec.ts b/packages/richtext-lexical/src/features/converters/lexicalToPlaintext/convertLexicalToPlaintext.spec.ts index eaf53b96f9..d08c002335 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToPlaintext/convertLexicalToPlaintext.spec.ts +++ b/packages/richtext-lexical/src/features/converters/lexicalToPlaintext/convertLexicalToPlaintext.spec.ts @@ -5,6 +5,12 @@ import type { SerializedParagraphNode, SerializedTextNode, SerializedLineBreakNode, + SerializedHeadingNode, + SerializedListItemNode, + SerializedListNode, + SerializedTableRowNode, + SerializedTableNode, + SerializedTableCellNode, } from '../../../nodeTypes.js' import { convertLexicalToPlaintext } from './sync/index.js' @@ -51,7 +57,83 @@ function paragraphNode(children: DefaultNodeTypes[]): SerializedParagraphNode { } } -function rootNode(nodes: DefaultNodeTypes[]): DefaultTypedEditorState { +function headingNode(children: DefaultNodeTypes[]): SerializedHeadingNode { + return { + type: 'heading', + children, + direction: 'ltr', + format: '', + indent: 0, + textFormat: 0, + tag: 'h1', + version: 1, + } +} + +function listItemNode(children: DefaultNodeTypes[]): SerializedListItemNode { + return { + type: 'listitem', + children, + checked: false, + direction: 'ltr', + format: '', + indent: 0, + value: 0, + version: 1, + } +} + +function listNode(children: DefaultNodeTypes[]): SerializedListNode { + return { + type: 'list', + children, + direction: 'ltr', + format: '', + indent: 0, + listType: 'bullet', + start: 0, + tag: 'ul', + version: 1, + } +} + +function tableNode(children: (DefaultNodeTypes | SerializedTableRowNode)[]): SerializedTableNode { + return { + type: 'table', + children, + direction: 'ltr', + format: '', + indent: 0, + version: 1, + } +} + +function tableRowNode( + children: (DefaultNodeTypes | SerializedTableCellNode)[], +): SerializedTableRowNode { + return { + type: 'tablerow', + children, + direction: 'ltr', + format: '', + indent: 0, + version: 1, + } +} + +function tableCellNode(children: DefaultNodeTypes[]): SerializedTableCellNode { + return { + type: 'tablecell', + children, + direction: 'ltr', + format: '', + indent: 0, + headerState: 0, + version: 1, + } +} + +function rootNode(nodes: (DefaultNodeTypes | SerializedTableNode)[]): DefaultTypedEditorState { return { root: { type: 'root', @@ -72,7 +154,6 @@ describe('convertLexicalToPlaintext', () => { data, }) - console.log('plaintext', plaintext) expect(plaintext).toBe('Basic Text') }) @@ -111,4 +192,67 @@ describe('convertLexicalToPlaintext', () => { expect(plaintext).toBe('Basic Text\tNext Line') }) + + it('ensure new lines are added between paragraphs', () => { + const data: DefaultTypedEditorState = rootNode([ + paragraphNode([textNode('Basic text')]), + paragraphNode([textNode('Next block-node')]), + ]) + + const plaintext = convertLexicalToPlaintext({ + data, + }) + + expect(plaintext).toBe('Basic text\n\nNext block-node') + }) + + it('ensure new lines are added between heading nodes', () => { + const data: DefaultTypedEditorState = rootNode([ + headingNode([textNode('Basic text')]), + headingNode([textNode('Next block-node')]), + ]) + + const plaintext = convertLexicalToPlaintext({ + data, + }) + + expect(plaintext).toBe('Basic text\n\nNext block-node') + }) + + it('ensure new lines are added between list items and lists', () => { + const data: DefaultTypedEditorState = rootNode([ + listNode([listItemNode([textNode('First item')]), listItemNode([textNode('Second item')])]), + listNode([listItemNode([textNode('Next list')])]), + ]) + + const plaintext = convertLexicalToPlaintext({ + data, + }) + + expect(plaintext).toBe('First item\nSecond item\n\nNext list') + }) + + it('ensure new lines are added between tables, table rows, and table cells', () => { + const data: DefaultTypedEditorState = rootNode([ + tableNode([ + tableRowNode([ + tableCellNode([textNode('Cell 1, Row 1')]), + tableCellNode([textNode('Cell 2, Row 1')]), + ]), + tableRowNode([ + tableCellNode([textNode('Cell 1, Row 2')]), + tableCellNode([textNode('Cell 2, Row 2')]), + ]), + ]), + tableNode([tableRowNode([tableCellNode([textNode('Cell in Table 2')])])]), + ]) + + const plaintext = convertLexicalToPlaintext({ + data, + }) + + expect(plaintext).toBe( + 'Cell 1, Row 1 | Cell 2, Row 1\nCell 1, Row 2 | Cell 2, Row 2\n\nCell in Table 2', + ) + }) }) diff --git a/packages/richtext-lexical/src/features/converters/lexicalToPlaintext/sync/index.ts b/packages/richtext-lexical/src/features/converters/lexicalToPlaintext/sync/index.ts index c29049d6b2..e3a5dbed10 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToPlaintext/sync/index.ts +++ b/packages/richtext-lexical/src/features/converters/lexicalToPlaintext/sync/index.ts @@ -86,11 +86,25 @@ export function convertLexicalNodesToPlaintext({ } } else { // Default plaintext converter heuristic - if (node.type === 'paragraph') { + if ( + node.type === 'paragraph' || + node.type === 'heading' || + node.type === 'list' || + node.type === 'table' + ) { if (plainTextArray?.length) { // Only add a new line if there is already text in the array plainTextArray.push('\n\n') } + } else if (node.type === 'listitem' || node.type === 'tablerow') { + if (plainTextArray?.length) { + // Only add a new line if there is already text in the array + plainTextArray.push('\n') + } + } else if (node.type === 'tablecell') { + if (plainTextArray?.length) { + plainTextArray.push(' | ') + } } else if (node.type === 'linebreak') { plainTextArray.push('\n') } else if (node.type === 'tab') { diff --git a/packages/richtext-lexical/src/features/relationship/client/plugins/index.tsx b/packages/richtext-lexical/src/features/relationship/client/plugins/index.tsx index 84affa0ed5..bd581620ae 100644 --- a/packages/richtext-lexical/src/features/relationship/client/plugins/index.tsx +++ b/packages/richtext-lexical/src/features/relationship/client/plugins/index.tsx @@ -53,22 +53,14 @@ export const RelationshipPlugin: PluginComponent = ({ if ($isRangeSelection(selection)) { const relationshipNode = $createRelationshipNode(payload) + // we need to get the focus node before inserting the block node, as $insertNodeToNearestRoot can change the focus node + const { focus } = selection + const focusNode = focus.getNode() // Insert relationship node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist $insertNodeToNearestRoot(relationshipNode) - const { focus } = selection - const focusNode = focus.getNode() - - // First, delete currently selected node if it's an empty paragraph and if there are sufficient - // paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user - if ( - $isParagraphNode(focusNode) && - focusNode.getTextContentSize() === 0 && - focusNode - .getParentOrThrow() - .getChildren() - .filter((node) => $isParagraphNode(node)).length > 1 - ) { + // Delete the node it it's an empty paragraph + if ($isParagraphNode(focusNode) && !focusNode.__first) { focusNode.remove() } } diff --git a/packages/richtext-lexical/src/features/upload/client/plugin/index.tsx b/packages/richtext-lexical/src/features/upload/client/plugin/index.tsx index 7d0884433e..5ee652610e 100644 --- a/packages/richtext-lexical/src/features/upload/client/plugin/index.tsx +++ b/packages/richtext-lexical/src/features/upload/client/plugin/index.tsx @@ -53,18 +53,14 @@ export const UploadPlugin: PluginComponent = ({ client value: payload.value, }, }) + // we need to get the focus node before inserting the block node, as $insertNodeToNearestRoot can change the focus node + const { focus } = selection + const focusNode = focus.getNode() // Insert upload node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist $insertNodeToNearestRoot(uploadNode) - const { focus } = selection - const focusNode = focus.getNode() - - // Delete the node it it's an empty paragraph and it has at least one sibling, so that we don't "trap" the user - if ( - $isParagraphNode(focusNode) && - !focusNode.__first && - (focusNode.__prev || focusNode.__next) - ) { + // Delete the node it it's an empty paragraph + if ($isParagraphNode(focusNode) && !focusNode.__first) { focusNode.remove() } } diff --git a/packages/richtext-lexical/src/lexical/LexicalEditor.tsx b/packages/richtext-lexical/src/lexical/LexicalEditor.tsx index ead987a743..aa3a5f3d28 100644 --- a/packages/richtext-lexical/src/lexical/LexicalEditor.tsx +++ b/packages/richtext-lexical/src/lexical/LexicalEditor.tsx @@ -4,13 +4,7 @@ import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary.js' import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin.js' import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin.js' import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin.js' -import { - $createParagraphNode, - $getRoot, - BLUR_COMMAND, - COMMAND_PRIORITY_LOW, - FOCUS_COMMAND, -} from 'lexical' +import { BLUR_COMMAND, COMMAND_PRIORITY_LOW, FOCUS_COMMAND } from 'lexical' import * as React from 'react' import { useEffect, useState } from 'react' @@ -24,6 +18,7 @@ import { AddBlockHandlePlugin } from './plugins/handles/AddBlockHandlePlugin/ind import { DraggableBlockPlugin } from './plugins/handles/DraggableBlockPlugin/index.js' import { InsertParagraphAtEndPlugin } from './plugins/InsertParagraphAtEnd/index.js' import { MarkdownShortcutPlugin } from './plugins/MarkdownShortcut/index.js' +import { NormalizeSelectionPlugin } from './plugins/NormalizeSelection/index.js' import { SlashMenuPlugin } from './plugins/SlashMenu/index.js' import { TextPlugin } from './plugins/TextPlugin/index.js' import { LexicalContentEditable } from './ui/ContentEditable.js' @@ -112,6 +107,7 @@ export const LexicalEditor: React.FC< } ErrorBoundary={LexicalErrorBoundary} /> + diff --git a/packages/richtext-lexical/src/lexical/plugins/NormalizeSelection/index.tsx b/packages/richtext-lexical/src/lexical/plugins/NormalizeSelection/index.tsx new file mode 100644 index 0000000000..93c954790e --- /dev/null +++ b/packages/richtext-lexical/src/lexical/plugins/NormalizeSelection/index.tsx @@ -0,0 +1,35 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $getSelection, $isRangeSelection, RootNode } from 'lexical' +import { useEffect } from 'react' + +/** + * By default, Lexical throws an error if the selection ends in deleted nodes. + * This is very aggressive considering there are reasons why this can happen + * outside of Payload's control (custom features or conflicting features, for example). + * In the case of selections on nonexistent nodes, this plugin moves the selection to + * the end of the editor and displays a warning instead of an error. + */ +export function NormalizeSelectionPlugin() { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + return editor.registerNodeTransform(RootNode, (root) => { + const selection = $getSelection() + if ($isRangeSelection(selection)) { + const anchorNode = selection.anchor.getNode() + const focusNode = selection.focus.getNode() + if (!anchorNode.isAttached() || !focusNode.isAttached()) { + root.selectEnd() + // eslint-disable-next-line no-console + console.warn( + 'updateEditor: selection has been moved to the end of the editor because the previously selected nodes have been removed and ' + + "selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.", + ) + } + } + return false + }) + }, [editor]) + + return null +} diff --git a/packages/richtext-slate/package.json b/packages/richtext-slate/package.json index af889d5f00..fd720101e9 100644 --- a/packages/richtext-slate/package.json +++ b/packages/richtext-slate/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-slate", - "version": "3.35.1", + "version": "3.36.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 b342dbb4c8..52ca8d300d 100644 --- a/packages/storage-azure/package.json +++ b/packages/storage-azure/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-azure", - "version": "3.35.1", + "version": "3.36.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 625022b32e..22f2efbcc4 100644 --- a/packages/storage-gcs/package.json +++ b/packages/storage-gcs/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-gcs", - "version": "3.35.1", + "version": "3.36.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 0e65946913..3c274d09cf 100644 --- a/packages/storage-s3/package.json +++ b/packages/storage-s3/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-s3", - "version": "3.35.1", + "version": "3.36.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 56f1ba3c99..828a00d6ed 100644 --- a/packages/storage-uploadthing/package.json +++ b/packages/storage-uploadthing/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-uploadthing", - "version": "3.35.1", + "version": "3.36.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 84af5e067f..3d7ae0b645 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.1", + "version": "3.36.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 2608d3cb10..7c4c9d9747 100644 --- a/packages/translations/package.json +++ b/packages/translations/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/translations", - "version": "3.35.1", + "version": "3.36.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/ui/package.json b/packages/ui/package.json index 95ed06f956..878e093086 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/ui", - "version": "3.35.1", + "version": "3.36.1", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/ui/src/elements/ReactSelect/index.tsx b/packages/ui/src/elements/ReactSelect/index.tsx index 8319e77146..01c2f8f21d 100644 --- a/packages/ui/src/elements/ReactSelect/index.tsx +++ b/packages/ui/src/elements/ReactSelect/index.tsx @@ -84,7 +84,6 @@ const SelectAdapter: React.FC = (props) => { captureMenuScroll customProps={customProps} isLoading={isLoading} - placeholder={getTranslation(placeholder, i18n)} {...props} className={classes} classNamePrefix="rs" @@ -113,6 +112,7 @@ const SelectAdapter: React.FC = (props) => { onMenuClose={onMenuClose} onMenuOpen={onMenuOpen} options={options} + placeholder={getTranslation(placeholder, i18n)} styles={styles} unstyled={true} value={value} @@ -160,7 +160,6 @@ const SelectAdapter: React.FC = (props) => { = (props) => { onMenuClose={onMenuClose} onMenuOpen={onMenuOpen} options={options} + placeholder={getTranslation(placeholder, i18n)} styles={styles} unstyled={true} value={value} diff --git a/packages/ui/src/elements/ReactSelect/types.ts b/packages/ui/src/elements/ReactSelect/types.ts index 4520327b08..58e324b1ba 100644 --- a/packages/ui/src/elements/ReactSelect/types.ts +++ b/packages/ui/src/elements/ReactSelect/types.ts @@ -1,11 +1,11 @@ +import type { LabelFunction } from 'payload' import type { CommonProps, GroupBase, Props as ReactSelectStateManagerProps } from 'react-select' -import type { DocumentDrawerProps, UseDocumentDrawer } from '../DocumentDrawer/types.js' +import type { DocumentDrawerProps } from '../DocumentDrawer/types.js' type CustomSelectProps = { disableKeyDown?: boolean disableMouseDown?: boolean - DocumentDrawerToggler?: ReturnType[1] draggableProps?: any droppableRef?: React.RefObject editableProps?: ( @@ -14,10 +14,11 @@ type CustomSelectProps = { selectProps: ReactSelectStateManagerProps, ) => any onDelete?: DocumentDrawerProps['onDelete'] - onDocumentDrawerOpen?: (args: { + onDocumentOpen?: (args: { collectionSlug: string hasReadPermission: boolean id: number | string + openInNewTab?: boolean }) => void onDuplicate?: DocumentDrawerProps['onSave'] onSave?: DocumentDrawerProps['onSave'] @@ -101,7 +102,7 @@ export type ReactSelectAdapterProps = { onMenuOpen?: () => void onMenuScrollToBottom?: () => void options: Option[] | OptionGroup[] - placeholder?: string + placeholder?: LabelFunction | string showError?: boolean value?: Option | Option[] } diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx index cd5e1ea22d..825e0144db 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx @@ -23,7 +23,7 @@ const maxResultsPerRequest = 10 export const RelationshipFilter: React.FC = (props) => { const { disabled, - field: { admin: { isSortable } = {}, hasMany, relationTo }, + field: { admin: { isSortable, placeholder } = {}, hasMany, relationTo }, filterOptions, onChange, value, @@ -412,7 +412,7 @@ export const RelationshipFilter: React.FC = (props) => { onInputChange={handleInputChange} onMenuScrollToBottom={handleScrollToBottom} options={options} - placeholder={t('general:selectValue')} + placeholder={placeholder} value={valueToRender} /> )} diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Select/index.tsx b/packages/ui/src/elements/WhereBuilder/Condition/Select/index.tsx index c9b1ebf0bc..43f7b509a0 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/Select/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/Condition/Select/index.tsx @@ -11,6 +11,9 @@ import { formatOptions } from './formatOptions.js' export const Select: React.FC = ({ disabled, + field: { + admin: { placeholder }, + }, isClearable, onChange, operator, @@ -77,6 +80,7 @@ export const Select: React.FC = ({ isMulti={isMulti} onChange={onSelect} options={options.map((option) => ({ ...option, label: getTranslation(option.label, i18n) }))} + placeholder={placeholder} value={valueToRender} /> ) diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Select/types.ts b/packages/ui/src/elements/WhereBuilder/Condition/Select/types.ts index 27c34b3f59..186f42228e 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/Select/types.ts +++ b/packages/ui/src/elements/WhereBuilder/Condition/Select/types.ts @@ -1,4 +1,4 @@ -import type { Option, SelectFieldClient } from 'payload' +import type { LabelFunction, Option, SelectFieldClient } from 'payload' import type { DefaultFilterProps } from '../types.js' @@ -7,5 +7,6 @@ export type SelectFilterProps = { readonly isClearable?: boolean readonly onChange: (val: string) => void readonly options: Option[] + readonly placeholder?: LabelFunction | string readonly value: string } & DefaultFilterProps diff --git a/packages/ui/src/elements/WhereBuilder/reduceFields.tsx b/packages/ui/src/elements/WhereBuilder/reduceFields.tsx index 4822f5ce77..de2b079569 100644 --- a/packages/ui/src/elements/WhereBuilder/reduceFields.tsx +++ b/packages/ui/src/elements/WhereBuilder/reduceFields.tsx @@ -99,7 +99,7 @@ export const reduceFields = ({ return reduced } - if (field.type === 'group' && 'fields' in field) { + if ((field.type === 'group' || field.type === 'array') && 'fields' in field) { const translatedLabel = getTranslation(field.label || '', i18n) const labelWithPrefix = labelPrefix diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index 449c1e1374..21cbaa4299 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -7,9 +7,9 @@ import type { } from 'payload' import { dequal } from 'dequal/lite' -import { wordBoundariesRegex } from 'payload/shared' +import { formatAdminURL, wordBoundariesRegex } from 'payload/shared' import * as qs from 'qs-esm' -import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import type { DocumentDrawerProps } from '../../elements/DocumentDrawer/types.js' import type { ListDrawerProps } from '../../elements/ListDrawer/types.js' @@ -56,6 +56,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => className, description, isSortable = true, + placeholder, sortOptions, } = {}, hasMany, @@ -82,7 +83,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => const hasMultipleRelations = Array.isArray(relationTo) const [currentlyOpenRelationship, setCurrentlyOpenRelationship] = useState< - Parameters[0] + Parameters[0] >({ id: undefined, collectionSlug: undefined, @@ -136,18 +137,38 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => 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], - }, - }, + const valuesByRelation = (Array.isArray(value) ? value : [value]).reduce((acc, val) => { + if (typeof val === 'object' && val.relationTo) { + if (!acc[val.relationTo]) { + acc[val.relationTo] = [] } - }) + acc[val.relationTo].push(val.value) + } else if (val) { + const relation = Array.isArray(relationTo) ? undefined : relationTo + if (relation) { + if (!acc[relation]) { + acc[relation] = [] + } + acc[relation].push(val) + } + } + return acc + }, {}) + + ;(Array.isArray(relationTo) ? relationTo : [relationTo]).forEach((relation) => { + newFilterOptions = { + ...(newFilterOptions || {}), + [relation]: { + ...(typeof filterOptions?.[relation] === 'object' ? filterOptions[relation] : {}), + ...(valuesByRelation[relation] + ? { + id: { + not_in: valuesByRelation[relation], + }, + } + : {}), + }, + } }) } @@ -174,8 +195,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => if (hasMany) { const withSelection = Array.isArray(value) ? value : [] - withSelection.push(formattedSelection) - setValue(withSelection) + setValue([...withSelection, formattedSelection]) } else { setValue(formattedSelection) } @@ -252,6 +272,9 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => limit: maxResultsPerRequest, locale, page: lastLoadedPageToUse, + select: { + [fieldToSearch]: true, + }, sort: fieldToSort, where: { and: [ @@ -608,16 +631,29 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => return r.test(labelString.slice(-breakApartThreshold)) }, []) - const onDocumentDrawerOpen = useCallback< - ReactSelectAdapterProps['customProps']['onDocumentDrawerOpen'] - >(({ id, collectionSlug, hasReadPermission }) => { - openDrawerWhenRelationChanges.current = true - setCurrentlyOpenRelationship({ - id, - collectionSlug, - hasReadPermission, - }) - }, []) + const onDocumentOpen = useCallback( + ({ id, collectionSlug, hasReadPermission, openInNewTab }) => { + if (openInNewTab) { + if (hasReadPermission && id && collectionSlug) { + const docUrl = formatAdminURL({ + adminRoute: config.routes.admin, + path: `/collections/${collectionSlug}/${id}`, + }) + + window.open(docUrl, '_blank') + } + } else { + openDrawerWhenRelationChanges.current = true + + setCurrentlyOpenRelationship({ + id, + collectionSlug, + hasReadPermission, + }) + } + }, + [setCurrentlyOpenRelationship, config.routes.admin], + ) useEffect(() => { if (openDrawerWhenRelationChanges.current) { @@ -674,7 +710,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => customProps={{ disableKeyDown: isDrawerOpen || isListDrawerOpen, disableMouseDown: isDrawerOpen || isListDrawerOpen, - onDocumentDrawerOpen, + onDocumentOpen, onSave, }} disabled={readOnly || disabled || isDrawerOpen || isListDrawerOpen} @@ -757,6 +793,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => }) }} options={options} + placeholder={placeholder} showError={showError} value={valueToRender ?? null} /> diff --git a/packages/ui/src/fields/Relationship/select-components/MultiValueLabel/index.tsx b/packages/ui/src/fields/Relationship/select-components/MultiValueLabel/index.tsx index 03cb247a53..adc01e11d4 100644 --- a/packages/ui/src/fields/Relationship/select-components/MultiValueLabel/index.tsx +++ b/packages/ui/src/fields/Relationship/select-components/MultiValueLabel/index.tsx @@ -25,7 +25,7 @@ export const MultiValueLabel: React.FC< > = (props) => { const { data: { allowEdit, label, relationTo, value }, - selectProps: { customProps: { draggableProps, onDocumentDrawerOpen } = {} } = {}, + selectProps: { customProps: { draggableProps, onDocumentOpen } = {} } = {}, } = props const { permissions } = useAuth() @@ -49,12 +49,13 @@ export const MultiValueLabel: React.FC<