From 3c9ee5d3b497eb33179ac5366c092ea74cf36b8d Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 5 May 2025 09:11:10 -0400 Subject: [PATCH 01/46] fix(db-*): migration batch not incrementing past 1 (#12215) When `payload migrate` is run and a record with name "dev" is returned having `batch: -1`, then the `batch` is not incrementing as expected as it is stuck at 1. This change makes it so the batch is incremented from the correct latest batch, ignoring the `name: "dev"` migration. --- packages/drizzle/src/migrate.ts | 47 ++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/packages/drizzle/src/migrate.ts b/packages/drizzle/src/migrate.ts index 0256031ac6..fa422f42c6 100644 --- a/packages/drizzle/src/migrate.ts +++ b/packages/drizzle/src/migrate.ts @@ -42,33 +42,36 @@ export const migrate: DrizzleAdapter['migrate'] = async function migrate( limit: 0, sort: '-name', })) + + if (migrationsInDB.find((m) => m.batch === -1)) { + const { confirm: runMigrations } = await prompts( + { + name: 'confirm', + type: 'confirm', + initial: false, + message: + "It looks like you've run Payload in dev mode, meaning you've dynamically pushed changes to your database.\n\n" + + "If you'd like to run migrations, data loss will occur. Would you like to proceed?", + }, + { + onCancel: () => { + process.exit(0) + }, + }, + ) + + if (!runMigrations) { + process.exit(0) + } + // ignore the dev migration so that the latest batch number increments correctly + migrationsInDB = migrationsInDB.filter((m) => m.batch !== -1) + } + if (Number(migrationsInDB?.[0]?.batch) > 0) { latestBatch = Number(migrationsInDB[0]?.batch) } } - if (migrationsInDB.find((m) => m.batch === -1)) { - const { confirm: runMigrations } = await prompts( - { - name: 'confirm', - type: 'confirm', - initial: false, - message: - "It looks like you've run Payload in dev mode, meaning you've dynamically pushed changes to your database.\n\n" + - "If you'd like to run migrations, data loss will occur. Would you like to proceed?", - }, - { - onCancel: () => { - process.exit(0) - }, - }, - ) - - if (!runMigrations) { - process.exit(0) - } - } - const newBatch = latestBatch + 1 // Execute 'up' function for each migration sequentially From 3fb81ef43bca83ecbba9bb0a6f54825d648485e3 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Mon, 5 May 2025 16:12:44 +0300 Subject: [PATCH 02/46] fix(graphql): `nextPage` and `prevPage` are non nullable even though they can be `null` sometimes (#12201) This PR introduced https://github.com/payloadcms/payload/pull/11952 improvement for graphql schema with making fields of the `Paginated` interface non-nullable. However, there are a few special ones - `nextPage` and `prevPage`. They can be `null` when: The result returned 0 docs. The result returned `x` docs, but in the DB we don't have `x+1` doc. Thus, `nextPage` will be `null`. The result will have `nextPage: null`. Finally, when we query 1st page, `prevPage` is `null` as well. image --- .../src/schema/buildPaginatedListType.ts | 4 +- test/graphql/int.spec.ts | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/graphql/src/schema/buildPaginatedListType.ts b/packages/graphql/src/schema/buildPaginatedListType.ts index 343fd28cba..2a8a64c5d1 100644 --- a/packages/graphql/src/schema/buildPaginatedListType.ts +++ b/packages/graphql/src/schema/buildPaginatedListType.ts @@ -10,11 +10,11 @@ export const buildPaginatedListType = (name, docType) => hasNextPage: { type: new GraphQLNonNull(GraphQLBoolean) }, hasPrevPage: { type: new GraphQLNonNull(GraphQLBoolean) }, limit: { type: new GraphQLNonNull(GraphQLInt) }, - nextPage: { type: new GraphQLNonNull(GraphQLInt) }, + nextPage: { type: GraphQLInt }, offset: { type: GraphQLInt }, page: { type: new GraphQLNonNull(GraphQLInt) }, pagingCounter: { type: new GraphQLNonNull(GraphQLInt) }, - prevPage: { type: new GraphQLNonNull(GraphQLInt) }, + prevPage: { type: GraphQLInt }, totalDocs: { type: new GraphQLNonNull(GraphQLInt) }, totalPages: { type: new GraphQLNonNull(GraphQLInt) }, }, diff --git a/test/graphql/int.spec.ts b/test/graphql/int.spec.ts index ab4cfbdd69..6006887fb7 100644 --- a/test/graphql/int.spec.ts +++ b/test/graphql/int.spec.ts @@ -104,5 +104,50 @@ describe('graphql', () => { expect(res.hyphenated_name).toStrictEqual('example-hyphenated-name') }) + + it('should not error because of non nullable fields', async () => { + await payload.delete({ collection: 'posts', where: {} }) + + // this is an array if any errors + const res_1 = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ + query: ` +query { + Posts { + docs { + title + } + prevPage + } +} + `, + }), + }) + .then((res) => res.json()) + expect(res_1.errors).toBeFalsy() + + await payload.create({ + collection: 'posts', + data: { title: 'any-title' }, + }) + + const res_2 = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ + query: ` +query { + Posts(limit: 1) { + docs { + title + } + } +} + `, + }), + }) + .then((res) => res.json()) + expect(res_2.errors).toBeFalsy() + }) }) }) From 2628b43639f9bf6efd499a8fe2cb62fc049fda95 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Mon, 5 May 2025 16:20:30 +0300 Subject: [PATCH 03/46] fix(db-postgres): start transaction in v2-v3 migration only after drizzle prompts to avoid timeout (#12302) When running the v2-v3 migration you might receive prompts for renaming columns. Since we start a transaction before, you might end up with a fail if you don't answer within your transaction session period timeout. This moves the `getTransaction` call after prompts were answered, since we don't have a reason to start it earlier. --- .../drizzle/src/postgres/predefinedMigrations/v2-v3/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/drizzle/src/postgres/predefinedMigrations/v2-v3/index.ts b/packages/drizzle/src/postgres/predefinedMigrations/v2-v3/index.ts index 06fc16a9ba..63fd4111fd 100644 --- a/packages/drizzle/src/postgres/predefinedMigrations/v2-v3/index.ts +++ b/packages/drizzle/src/postgres/predefinedMigrations/v2-v3/index.ts @@ -36,7 +36,6 @@ type Args = { */ export const migratePostgresV2toV3 = async ({ debug, payload, req }: Args) => { const adapter = payload.db as unknown as BasePostgresAdapter - const db = await getTransaction(adapter, req) const dir = payload.db.migrationDir // get the drizzle migrateUpSQL from drizzle using the last schema @@ -89,6 +88,8 @@ export const migratePostgresV2toV3 = async ({ debug, payload, req }: Args) => { payload.logger.info(addColumnsStatement) } + const db = await getTransaction(adapter, req) + await db.execute(sql.raw(addColumnsStatement)) for (const collection of payload.config.collections) { From 292b462f34549a00b0d8f7a0142b3627c79d890b Mon Sep 17 00:00:00 2001 From: Tobias Odendahl Date: Mon, 5 May 2025 16:09:26 +0200 Subject: [PATCH 04/46] feat(ui): add document link to drawer (#12036) ### What? Adds an option to open the current document in a new tab when opened in a drawer. ### Why? There is currently no direct way to open a document when opened in a drawer. However, sometimes editors want to edit one or multiple documents from relationships independently of the current edit view and need an easy option to open these separately. ### How? Converts the document id to a link if in drawer context. ![image](https://github.com/user-attachments/assets/e448328f-f685-49b8-95c5-bd5d6aa60e35) --------- Co-authored-by: Jacob Fletcher --- packages/ui/src/elements/IDLabel/index.tsx | 35 ++++++++++--- test/admin/e2e/document-view/e2e.spec.ts | 59 ++++++++++++++++++++++ tsconfig.base.json | 2 +- 3 files changed, 88 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/elements/IDLabel/index.tsx b/packages/ui/src/elements/IDLabel/index.tsx index c224c39203..4315c06e0e 100644 --- a/packages/ui/src/elements/IDLabel/index.tsx +++ b/packages/ui/src/elements/IDLabel/index.tsx @@ -2,7 +2,12 @@ import React from 'react' import './index.scss' +import { Link } from '../../elements/Link/index.js' +import { useConfig } from '../../providers/Config/index.js' +import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' +import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { sanitizeID } from '../../utilities/sanitizeID.js' +import { useDrawerDepth } from '../Drawer/index.js' const baseClass = 'id-label' @@ -10,10 +15,26 @@ export const IDLabel: React.FC<{ className?: string; id: string; prefix?: string id, className, prefix = 'ID:', -}) => ( -
- {prefix} -   - {sanitizeID(id)} -
-) +}) => { + const { + config: { + routes: { admin: adminRoute }, + }, + } = useConfig() + + const { collectionSlug, globalSlug } = useDocumentInfo() + const drawerDepth = useDrawerDepth() + + const docPath = formatAdminURL({ + adminRoute, + path: `/${collectionSlug ? `collections/${collectionSlug}` : `globals/${globalSlug}`}/${id}`, + }) + + return ( +
+ {prefix} +   + {drawerDepth > 1 ? {sanitizeID(id)} : sanitizeID(id)} +
+ ) +} diff --git a/test/admin/e2e/document-view/e2e.spec.ts b/test/admin/e2e/document-view/e2e.spec.ts index d3830b255d..7b5d617198 100644 --- a/test/admin/e2e/document-view/e2e.spec.ts +++ b/test/admin/e2e/document-view/e2e.spec.ts @@ -360,6 +360,65 @@ describe('Document View', () => { await expect.poll(() => drawer2Left > drawerLeft).toBe(true) }) + + test('document drawer displays a link to document', async () => { + await navigateToDoc(page, postsUrl) + + // change the relationship to a document which is a different one than the current one + await page.locator('#field-relationship').click() + await page.locator('#field-relationship .rs__option').nth(2).click() + await saveDocAndAssert(page) + + // open relationship drawer + await page + .locator('.field-type.relationship .relationship--single-value__drawer-toggler') + .click() + + const drawer1Content = page.locator('[id^=doc-drawer_posts_1_] .drawer__content') + await expect(drawer1Content).toBeVisible() + + // modify the title to trigger the leave page modal + await page.locator('.drawer__content #field-title').fill('New Title') + + // Open link in a new tab by holding down the Meta or Control key + const documentLink = page.locator('.id-label a') + const documentId = String(await documentLink.textContent()) + await documentLink.click() + + const leavePageModal = page.locator('#leave-without-saving #confirm-action').last() + await expect(leavePageModal).toBeVisible() + + await leavePageModal.click() + await page.waitForURL(postsUrl.edit(documentId)) + }) + + test('document can be opened in a new tab from within the drawer', async () => { + await navigateToDoc(page, postsUrl) + await page + .locator('.field-type.relationship .relationship--single-value__drawer-toggler') + .click() + await wait(500) + const drawer1Content = page.locator('[id^=doc-drawer_posts_1_] .drawer__content') + await expect(drawer1Content).toBeVisible() + + const currentUrl = page.url() + + // Open link in a new tab by holding down the Meta or Control key + const documentLink = page.locator('.id-label a') + const documentId = String(await documentLink.textContent()) + const [newPage] = await Promise.all([ + page.context().waitForEvent('page'), + documentLink.click({ modifiers: ['ControlOrMeta'] }), + ]) + + // Wait for navigation to complete in the new tab and ensure correct URL + await expect(newPage.locator('.doc-header')).toBeVisible() + // using contain here, because after load the lists view will add query params like "?limit=10" + expect(newPage.url()).toContain(postsUrl.edit(documentId)) + + // Ensure the original page did not change + expect(page.url()).toBe(currentUrl) + }) }) describe('descriptions', () => { diff --git a/tsconfig.base.json b/tsconfig.base.json index 93d171a6a7..c9793d25c6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/fields/config.ts"], + "@payload-config": ["./test/_community/config.ts"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], From 446938b9cb8745fd09d7ac16446a00aae9c6290c Mon Sep 17 00:00:00 2001 From: Ruslan Date: Mon, 5 May 2025 16:14:27 +0200 Subject: [PATCH 05/46] feat(ui): update RelationshipFilter if only filterOptions are changed (#11985) ### What? Extends trigger of a reload of the fields for RelationshipFilter to include `filterOptions`. ### Why? If you have two or more relationship fields that have a relation to the same collection, the options of the filter will not update. ### How By extending dependencies of `useEffect` ### Code setup: ``` { name: 'media', type: 'relationship', relationTo: 'media', filterOptions: () => { return { id: { in: ['67efaee24648d01dffceecf9'] }, } } }, { name: 'media2', type: 'relationship', relationTo: 'media', filterOptions: () => { return { id: { in: ['67efafb04648d01dffceed75'] }, } } }, ``` ### Before: https://github.com/user-attachments/assets/bdc5135b-3afa-48df-98fe-6a9153dd7710 ### After: https://github.com/user-attachments/assets/d71a7558-6413-4c97-9b0b-678cf3b011d0 --> --- .../src/elements/WhereBuilder/Condition/Relationship/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx index 825e0144db..c1808359b5 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx @@ -324,7 +324,7 @@ export const RelationshipFilter: React.FC = (props) => { } }) } - }, [i18n, relationTo, debouncedSearch]) + }, [i18n, relationTo, debouncedSearch, filterOptions]) /** * Load any other options that might exist in the value that were not loaded already From dcd4e37ccc9f5968b26918e0b44437fb4fdcba37 Mon Sep 17 00:00:00 2001 From: James Mikrut Date: Mon, 5 May 2025 10:23:01 -0400 Subject: [PATCH 06/46] feat: exports additional login helper utils (#12309) Exports a few utilities that are used internally to the login operation, but could be helpful for others building plugins. Specifically: - `isUserLocked` - a check to ensure that a given user is not locked due to too many invalid attempts - `checkLoginPermissions` - checks to see that the user is not locked as well as that it is properly verified, if applicable - `jwtSign` - Payload's internal JWT signing approach - `getFieldsToSign` - reduce down a document's fields for JWT creation based on collection config settings - `incrementLoginAttempts` / `resetLoginAttempts` - utilities to handle both failed and successful login attempts - `UnverifiedEmail` - an error that could be thrown if attempting to log in to an account without prior successful email verification --- packages/payload/src/auth/isLocked.ts | 7 --- packages/payload/src/auth/isUserLocked.ts | 6 +++ packages/payload/src/auth/operations/login.ts | 46 ++++++++++++++----- packages/payload/src/index.ts | 12 +++-- 4 files changed, 49 insertions(+), 22 deletions(-) delete mode 100644 packages/payload/src/auth/isLocked.ts create mode 100644 packages/payload/src/auth/isUserLocked.ts diff --git a/packages/payload/src/auth/isLocked.ts b/packages/payload/src/auth/isLocked.ts deleted file mode 100644 index 95efbea80f..0000000000 --- a/packages/payload/src/auth/isLocked.ts +++ /dev/null @@ -1,7 +0,0 @@ -const isLocked = (date: number): boolean => { - if (!date) { - return false - } - return date > Date.now() -} -export default isLocked diff --git a/packages/payload/src/auth/isUserLocked.ts b/packages/payload/src/auth/isUserLocked.ts new file mode 100644 index 0000000000..a1ea9c2bf7 --- /dev/null +++ b/packages/payload/src/auth/isUserLocked.ts @@ -0,0 +1,6 @@ +export const isUserLocked = (date: number): boolean => { + if (!date) { + return false + } + return date > Date.now() +} diff --git a/packages/payload/src/auth/operations/login.ts b/packages/payload/src/auth/operations/login.ts index d133ad0e18..5bd02efd2f 100644 --- a/packages/payload/src/auth/operations/login.ts +++ b/packages/payload/src/auth/operations/login.ts @@ -3,6 +3,7 @@ import type { AuthOperationsFromCollectionSlug, Collection, DataFromCollectionSlug, + SanitizedCollectionConfig, } from '../../collections/config/types.js' import type { CollectionSlug } from '../../index.js' import type { PayloadRequest, Where } from '../../types/index.js' @@ -21,7 +22,7 @@ import { killTransaction } from '../../utilities/killTransaction.js' import sanitizeInternalFields from '../../utilities/sanitizeInternalFields.js' import { getFieldsToSign } from '../getFieldsToSign.js' import { getLoginOptions } from '../getLoginOptions.js' -import isLocked from '../isLocked.js' +import { isUserLocked } from '../isUserLocked.js' import { jwtSign } from '../jwt.js' import { authenticateLocalStrategy } from '../strategies/local/authenticate.js' import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts.js' @@ -42,6 +43,32 @@ export type Arguments = { showHiddenFields?: boolean } +type CheckLoginPermissionArgs = { + collection: SanitizedCollectionConfig + loggingInWithUsername?: boolean + req: PayloadRequest + user: any +} + +export const checkLoginPermission = ({ + collection, + loggingInWithUsername, + req, + user, +}: CheckLoginPermissionArgs) => { + if (!user) { + throw new AuthenticationError(req.t, Boolean(loggingInWithUsername)) + } + + if (collection.auth.verify && user._verified === false) { + throw new UnverifiedEmail({ t: req.t }) + } + + if (isUserLocked(new Date(user.lockUntil).getTime())) { + throw new LockedAuth(req.t) + } +} + export const loginOperation = async ( incomingArgs: Arguments, ): Promise<{ user: DataFromCollectionSlug } & Result> => { @@ -184,21 +211,16 @@ export const loginOperation = async ( where: whereConstraint, }) - if (!user) { - throw new AuthenticationError(req.t, Boolean(canLoginWithUsername && sanitizedUsername)) - } - - if (args.collection.config.auth.verify && user._verified === false) { - throw new UnverifiedEmail({ t: req.t }) - } + checkLoginPermission({ + collection: collectionConfig, + loggingInWithUsername: Boolean(canLoginWithUsername && sanitizedUsername), + req, + user, + }) user.collection = collectionConfig.slug user._strategy = 'local-jwt' - if (isLocked(new Date(user.lockUntil).getTime())) { - throw new LockedAuth(req.t) - } - const authResult = await authenticateLocalStrategy({ doc: user, password }) user = sanitizeInternalFields(user) diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 2c720cc681..f4d876f806 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -89,6 +89,10 @@ import { traverseFields } from './utilities/traverseFields.js' export { default as executeAccess } from './auth/executeAccess.js' export { executeAuthStrategies } from './auth/executeAuthStrategies.js' +export { extractAccessFromPermission } from './auth/extractAccessFromPermission.js' +export { getAccessResults } from './auth/getAccessResults.js' +export { getFieldsToSign } from './auth/getFieldsToSign.js' +export { getLoginOptions } from './auth/getLoginOptions.js' export interface GeneratedTypes { authUntyped: { @@ -977,13 +981,12 @@ interface RequestContext { // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface DatabaseAdapter extends BaseDatabaseAdapter {} export type { Payload, RequestContext } -export { extractAccessFromPermission } from './auth/extractAccessFromPermission.js' -export { getAccessResults } from './auth/getAccessResults.js' -export { getFieldsToSign } from './auth/getFieldsToSign.js' export * from './auth/index.js' +export { jwtSign } from './auth/jwt.js' export { accessOperation } from './auth/operations/access.js' export { forgotPasswordOperation } from './auth/operations/forgotPassword.js' export { initOperation } from './auth/operations/init.js' +export { checkLoginPermission } from './auth/operations/login.js' export { loginOperation } from './auth/operations/login.js' export { logoutOperation } from './auth/operations/logout.js' export type { MeOperationResult } from './auth/operations/me.js' @@ -994,6 +997,8 @@ export { resetPasswordOperation } from './auth/operations/resetPassword.js' export { unlockOperation } from './auth/operations/unlock.js' export { verifyEmailOperation } from './auth/operations/verifyEmail.js' export { JWTAuthentication } from './auth/strategies/jwt.js' +export { incrementLoginAttempts } from './auth/strategies/local/incrementLoginAttempts.js' +export { resetLoginAttempts } from './auth/strategies/local/resetLoginAttempts.js' export type { AuthStrategyFunction, AuthStrategyFunctionArgs, @@ -1201,6 +1206,7 @@ export { MissingFile, NotFound, QueryError, + UnverifiedEmail, ValidationError, ValidationErrorName, } from './errors/index.js' From 0d10f436cc4a219dc433260e8f8ca1cadfef29e6 Mon Sep 17 00:00:00 2001 From: Florian Beeres Date: Mon, 5 May 2025 16:24:08 +0200 Subject: [PATCH 07/46] fix(plugin-cloud-storage): missing 'prefix' in cloud storage plugin (#11970) ## Fix We were able to narrow it down to this call https://github.com/payloadcms/payload/blob/816fb28f552dfefc11704c7b379ca7fb31abd687/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts#L26-L41 Adding `draft: true` fixes the issue. It seems that the `prefix` can only be found in a draft, and without `draft: true` those drafts aren't searched. ### Issue reproduction In the community folder, enable versioning for the media collection and install the `s3storage` plugin (see Git patch). I use `minio` to have a local S3 compatible backend and then I run the app with: `AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin START_MEMORY_DB=true pnpm dev _community`. Next, open the media collection and create a new entry. Then open that entry, remove the file it currently has, and upload a new file. Save as draft. Now the media can no longer be accessed and the thumbnails are broken. If you make an edit but save it by publishing the issue goes away. I also couldn't reproduce this by adding a text field, changing that, and saving the document as draft. ```diff diff --git test/_community/collections/Media/index.ts test/_community/collections/Media/index.ts index bb5edd0349..689423053c 100644 --- test/_community/collections/Media/index.ts +++ test/_community/collections/Media/index.ts @@ -9,6 +9,9 @@ export const MediaCollection: CollectionConfig = { read: () => true, }, fields: [], + versions: { + drafts: true, + }, upload: { crop: true, focalPoint: true, diff --git test/_community/config.ts test/_community/config.ts index ee1aee6e46..c81ec5f933 100644 --- test/_community/config.ts +++ test/_community/config.ts @@ -7,6 +7,7 @@ import { devUser } from '../credentials.js' import { MediaCollection } from './collections/Media/index.js' import { PostsCollection, postsSlug } from './collections/Posts/index.js' import { MenuGlobal } from './globals/Menu/index.js' +import { s3Storage } from '@payloadcms/storage-s3' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -24,6 +25,21 @@ export default buildConfigWithDefaults({ // ...add more globals here MenuGlobal, ], + plugins: [ + s3Storage({ + enabled: true, + bucket: 'amboss', + config: { + region: 'eu-west-1', + endpoint: 'http://localhost:9000', + }, + collections: { + media: { + prefix: 'media', + }, + }, + }), + ], onInit: async (payload) => { await payload.create({ collection: 'users', ``` ## Screen recording https://github.com/user-attachments/assets/b13be4a3-e858-427a-8bfa-6592b87748ee --- packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts index 6e2771f51d..e823a6d861 100644 --- a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts +++ b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts @@ -26,6 +26,7 @@ export async function getFilePrefix({ const files = await req.payload.find({ collection: collection.slug, depth: 0, + draft: true, limit: 1, pagination: false, where: { From a6d76d6058aa6d07429f97eb4c0745d1dbc8406b Mon Sep 17 00:00:00 2001 From: Anyu Jiang <189434904+anyuj@users.noreply.github.com> Date: Mon, 5 May 2025 10:01:55 -0700 Subject: [PATCH 08/46] fix(plugin-multi-tenant): make tenant selector respect order if orderable enabled for tenant collection (#12314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What? Tenant Selector doesn’t honor the custom order when ‘orderable’ is enabled for Tenant collection ### Why? Currently, it uses "useAsTitle" to sort. In some use cases, for example, when a user manages multiple tenants that have an inherent priority (such as usage frequency), sorting purely by the useAsTitle isn’t very practical. ### How? Get "orderable" config from the tenant collection's config, if it has "orderable" set as true, it will use _order to sort. If not, it will use "useAsTitle" to sort as default. Fixes #12246 ![image](https://github.com/user-attachments/assets/b5c4ad5e-3503-4789-91f6-a7aafb326e32) --- packages/plugin-multi-tenant/src/queries/findTenantOptions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/plugin-multi-tenant/src/queries/findTenantOptions.ts b/packages/plugin-multi-tenant/src/queries/findTenantOptions.ts index 3d41343cc8..ae98cbe6a3 100644 --- a/packages/plugin-multi-tenant/src/queries/findTenantOptions.ts +++ b/packages/plugin-multi-tenant/src/queries/findTenantOptions.ts @@ -14,6 +14,7 @@ export const findTenantOptions = async ({ useAsTitle, user, }: Args): Promise => { + const isOrderable = payload.collections[tenantsCollectionSlug]?.config?.orderable || false return payload.find({ collection: tenantsCollectionSlug, depth: 0, @@ -21,8 +22,9 @@ export const findTenantOptions = async ({ overrideAccess: false, select: { [useAsTitle]: true, + ...(isOrderable ? { _order: true } : {}), }, - sort: useAsTitle, + sort: isOrderable ? '_order' : useAsTitle, user, }) } From 38186346f7c206e07f4d942ba2b5871ea0916850 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Mon, 5 May 2025 19:09:26 +0200 Subject: [PATCH 09/46] fix(ui): unable to search for nested fields in WhereBuilder field selection (#11986) ### What? Extract text from the React node label in WhereBuilder ### Why? If you have a nested field in filter options, the label would show correctly, but the search will not work ### How By adding an `extractTextFromReactNode` function that gets text out of React.node label ### Code setup: ``` { type: "collapsible", label: "Meta", fields: [ { name: 'media', type: 'relationship', relationTo: 'media', label: 'Ferrari', filterOptions: () => { return { id: { in: ['67efdbc872ca925bc2868933'] }, } } }, { name: 'media2', type: 'relationship', relationTo: 'media', label: 'Williams', filterOptions: () => { return { id: { in: ['67efdbc272ca925bc286891c'] }, } } }, ], }, ``` ### Before: https://github.com/user-attachments/assets/25d4b3a2-6ac0-476b-973e-575238e916c4 ### After: https://github.com/user-attachments/assets/92346a6c-b2d1-4e08-b1e4-9ac1484f9ef3 --------- Co-authored-by: Jacob Fletcher --- .../elements/WhereBuilder/Condition/index.tsx | 5 +++ .../elements/WhereBuilder/reduceFields.tsx | 32 +++++++++++++++++++ .../ui/src/elements/WhereBuilder/types.ts | 1 + test/admin/e2e/list-view/e2e.spec.ts | 18 +++++++++++ tsconfig.base.json | 2 +- 5 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/elements/WhereBuilder/Condition/index.tsx b/packages/ui/src/elements/WhereBuilder/Condition/index.tsx index c57eab59fe..23c849bf21 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/Condition/index.tsx @@ -141,6 +141,11 @@ export const Condition: React.FC = (props) => {
+ ((option?.data?.plainTextLabel as string) || option.label) + .toLowerCase() + .includes(inputValue.toLowerCase()) + } isClearable={false} onChange={handleFieldChange} options={reducedFields.filter((field) => !field.field.admin.disableListFilter)} diff --git a/packages/ui/src/elements/WhereBuilder/reduceFields.tsx b/packages/ui/src/elements/WhereBuilder/reduceFields.tsx index de2b079569..76728b1c72 100644 --- a/packages/ui/src/elements/WhereBuilder/reduceFields.tsx +++ b/packages/ui/src/elements/WhereBuilder/reduceFields.tsx @@ -4,6 +4,7 @@ import type { ClientField } from 'payload' import { getTranslation } from '@payloadcms/translations' import { fieldIsHiddenOrDisabled, fieldIsID, tabHasName } from 'payload/shared' +import { renderToStaticMarkup } from 'react-dom/server' import type { ReducedField } from './types.js' @@ -152,10 +153,15 @@ export const reduceFields = ({ }) : localizedLabel + // React elements in filter options are not searchable in React Select + // Extract plain text to make them filterable in dropdowns + const textFromLabel = extractTextFromReactNode(formattedLabel) + const fieldPath = pathPrefix ? createNestedClientFieldPath(pathPrefix, field) : field.name const formattedField: ReducedField = { label: formattedLabel, + plainTextLabel: textFromLabel, value: fieldPath, ...fieldTypes[field.type], field, @@ -168,3 +174,29 @@ export const reduceFields = ({ return reduced }, []) } + +/** + * Extracts plain text content from a React node by removing HTML tags. + * Used to make React elements searchable in filter dropdowns. + */ +const extractTextFromReactNode = (reactNode: React.ReactNode): string => { + if (!reactNode) { + return '' + } + if (typeof reactNode === 'string') { + return reactNode + } + + const html = renderToStaticMarkup(reactNode) + + // Handle different environments (server vs browser) + if (typeof document !== 'undefined') { + // Browser environment - use actual DOM + const div = document.createElement('div') + div.innerHTML = html + return div.textContent || '' + } else { + // Server environment - use regex to strip HTML tags + return html.replace(/<[^>]*>/g, '') + } +} diff --git a/packages/ui/src/elements/WhereBuilder/types.ts b/packages/ui/src/elements/WhereBuilder/types.ts index 0914c5acee..1e5eee1847 100644 --- a/packages/ui/src/elements/WhereBuilder/types.ts +++ b/packages/ui/src/elements/WhereBuilder/types.ts @@ -23,6 +23,7 @@ export type ReducedField = { label: string value: Operator }[] + plainTextLabel?: string value: Value } diff --git a/test/admin/e2e/list-view/e2e.spec.ts b/test/admin/e2e/list-view/e2e.spec.ts index d9bf0c149e..881aa670b0 100644 --- a/test/admin/e2e/list-view/e2e.spec.ts +++ b/test/admin/e2e/list-view/e2e.spec.ts @@ -393,6 +393,24 @@ describe('List View', () => { await expect(page.locator(tableRowLocator)).toHaveCount(2) }) + test('should search for nested fields in field dropdown', async () => { + await page.goto(postsUrl.list) + + await openListFilters(page, {}) + + const whereBuilder = page.locator('.where-builder') + await whereBuilder.locator('.where-builder__add-first-filter').click() + const conditionField = whereBuilder.locator('.condition__field') + await conditionField.click() + await conditionField.locator('input.rs__input').fill('Tab 1 > Title') + + await expect( + conditionField.locator('.rs__menu-list').locator('div', { + hasText: exactText('Tab 1 > Title'), + }), + ).toBeVisible() + }) + test('should allow to filter in array field', async () => { await createArray() diff --git a/tsconfig.base.json b/tsconfig.base.json index c9793d25c6..02de03b79e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/_community/config.ts"], + "@payload-config": ["./test/admin/config.ts"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], From 9a6bb44e50a5f3914a799b8a95c2f66ffb6e4df0 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 5 May 2025 15:12:34 -0400 Subject: [PATCH 10/46] chore(release): v3.37.0 [skip ci] --- package.json | 2 +- packages/admin-bar/package.json | 2 +- packages/create-payload-app/package.json | 2 +- packages/db-mongodb/package.json | 2 +- packages/db-postgres/package.json | 2 +- packages/db-sqlite/package.json | 2 +- packages/db-vercel-postgres/package.json | 2 +- packages/drizzle/package.json | 2 +- packages/email-nodemailer/package.json | 2 +- packages/email-resend/package.json | 2 +- packages/graphql/package.json | 2 +- packages/live-preview-react/package.json | 2 +- packages/live-preview-vue/package.json | 2 +- packages/live-preview/package.json | 2 +- packages/next/package.json | 2 +- packages/payload-cloud/package.json | 2 +- packages/payload/package.json | 2 +- packages/plugin-cloud-storage/package.json | 2 +- packages/plugin-form-builder/package.json | 2 +- packages/plugin-import-export/package.json | 2 +- packages/plugin-multi-tenant/package.json | 2 +- packages/plugin-nested-docs/package.json | 2 +- packages/plugin-redirects/package.json | 2 +- packages/plugin-search/package.json | 2 +- packages/plugin-sentry/package.json | 2 +- packages/plugin-seo/package.json | 2 +- packages/plugin-stripe/package.json | 2 +- packages/richtext-lexical/package.json | 2 +- packages/richtext-slate/package.json | 2 +- packages/storage-azure/package.json | 2 +- packages/storage-gcs/package.json | 2 +- packages/storage-s3/package.json | 2 +- packages/storage-uploadthing/package.json | 2 +- packages/storage-vercel-blob/package.json | 2 +- packages/translations/package.json | 2 +- packages/ui/package.json | 2 +- 36 files changed, 36 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index a25e17adc3..79930f4428 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload-monorepo", - "version": "3.36.1", + "version": "3.37.0", "private": true, "type": "module", "scripts": { diff --git a/packages/admin-bar/package.json b/packages/admin-bar/package.json index c4a3947edf..e763beb870 100644 --- a/packages/admin-bar/package.json +++ b/packages/admin-bar/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/admin-bar", - "version": "3.36.1", + "version": "3.37.0", "description": "An admin bar for React apps using Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/create-payload-app/package.json b/packages/create-payload-app/package.json index cec49d40be..10daccb29c 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.36.1", + "version": "3.37.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/db-mongodb/package.json b/packages/db-mongodb/package.json index bba9c0dd54..0fb0510d8b 100644 --- a/packages/db-mongodb/package.json +++ b/packages/db-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-mongodb", - "version": "3.36.1", + "version": "3.37.0", "description": "The officially supported MongoDB database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-postgres/package.json b/packages/db-postgres/package.json index 51a933ad4c..3784dabc2a 100644 --- a/packages/db-postgres/package.json +++ b/packages/db-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-postgres", - "version": "3.36.1", + "version": "3.37.0", "description": "The officially supported Postgres database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-sqlite/package.json b/packages/db-sqlite/package.json index 87db6a8f0e..d10d787b88 100644 --- a/packages/db-sqlite/package.json +++ b/packages/db-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/db-sqlite", - "version": "3.36.1", + "version": "3.37.0", "description": "The officially supported SQLite database adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/db-vercel-postgres/package.json b/packages/db-vercel-postgres/package.json index 1268e2c95c..3bd14d72f3 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.36.1", + "version": "3.37.0", "description": "Vercel Postgres adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/drizzle/package.json b/packages/drizzle/package.json index 254394b989..11fb484bbf 100644 --- a/packages/drizzle/package.json +++ b/packages/drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/drizzle", - "version": "3.36.1", + "version": "3.37.0", "description": "A library of shared functions used by different payload database adapters", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/email-nodemailer/package.json b/packages/email-nodemailer/package.json index 0d36ea0b00..f3641b8c2a 100644 --- a/packages/email-nodemailer/package.json +++ b/packages/email-nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-nodemailer", - "version": "3.36.1", + "version": "3.37.0", "description": "Payload Nodemailer Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/email-resend/package.json b/packages/email-resend/package.json index c986fa70d4..bef586ee5b 100644 --- a/packages/email-resend/package.json +++ b/packages/email-resend/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/email-resend", - "version": "3.36.1", + "version": "3.37.0", "description": "Payload Resend Email Adapter", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 10f27f1383..add54873c0 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/graphql", - "version": "3.36.1", + "version": "3.37.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/live-preview-react/package.json b/packages/live-preview-react/package.json index 70fb80fcb7..617df63dce 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.36.1", + "version": "3.37.0", "description": "The official React SDK for Payload Live Preview", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/live-preview-vue/package.json b/packages/live-preview-vue/package.json index 219c04d332..0e801fc27d 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.36.1", + "version": "3.37.0", "description": "The official Vue SDK for Payload Live Preview", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/live-preview/package.json b/packages/live-preview/package.json index 8caac4e6aa..eb7b207519 100644 --- a/packages/live-preview/package.json +++ b/packages/live-preview/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/live-preview", - "version": "3.36.1", + "version": "3.37.0", "description": "The official live preview JavaScript SDK for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/next/package.json b/packages/next/package.json index 9102fc40ca..8358c0b498 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/next", - "version": "3.36.1", + "version": "3.37.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/payload-cloud/package.json b/packages/payload-cloud/package.json index 9e7ce8217e..6b3da32074 100644 --- a/packages/payload-cloud/package.json +++ b/packages/payload-cloud/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/payload-cloud", - "version": "3.36.1", + "version": "3.37.0", "description": "The official Payload Cloud plugin", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/payload/package.json b/packages/payload/package.json index 0b62938b55..5e4eef830c 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "3.36.1", + "version": "3.37.0", "description": "Node, React, Headless CMS and Application Framework built on Next.js", "keywords": [ "admin panel", diff --git a/packages/plugin-cloud-storage/package.json b/packages/plugin-cloud-storage/package.json index 8b5d9020f9..58347227bc 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.36.1", + "version": "3.37.0", "description": "The official cloud storage plugin for Payload CMS", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/plugin-form-builder/package.json b/packages/plugin-form-builder/package.json index bf5de206ac..be94aebd41 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.36.1", + "version": "3.37.0", "description": "Form builder plugin for Payload CMS", "keywords": [ "payload", diff --git a/packages/plugin-import-export/package.json b/packages/plugin-import-export/package.json index 0317874ffc..6720f615ad 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.36.1", + "version": "3.37.0", "description": "Import-Export plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-multi-tenant/package.json b/packages/plugin-multi-tenant/package.json index 077b46d5b1..573c98b489 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.36.1", + "version": "3.37.0", "description": "Multi Tenant plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-nested-docs/package.json b/packages/plugin-nested-docs/package.json index daf8954e84..87bfc03e28 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.36.1", + "version": "3.37.0", "description": "The official Nested Docs plugin for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/plugin-redirects/package.json b/packages/plugin-redirects/package.json index 2aa329ad26..c348c222d6 100644 --- a/packages/plugin-redirects/package.json +++ b/packages/plugin-redirects/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-redirects", - "version": "3.36.1", + "version": "3.37.0", "description": "Redirects plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-search/package.json b/packages/plugin-search/package.json index 430bef0eb2..869c7915bb 100644 --- a/packages/plugin-search/package.json +++ b/packages/plugin-search/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-search", - "version": "3.36.1", + "version": "3.37.0", "description": "Search plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-sentry/package.json b/packages/plugin-sentry/package.json index 02bc00753d..4a4f637972 100644 --- a/packages/plugin-sentry/package.json +++ b/packages/plugin-sentry/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-sentry", - "version": "3.36.1", + "version": "3.37.0", "description": "Sentry plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-seo/package.json b/packages/plugin-seo/package.json index 769554c198..113ae4add7 100644 --- a/packages/plugin-seo/package.json +++ b/packages/plugin-seo/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-seo", - "version": "3.36.1", + "version": "3.37.0", "description": "SEO plugin for Payload", "keywords": [ "payload", diff --git a/packages/plugin-stripe/package.json b/packages/plugin-stripe/package.json index c75b99fd7f..f808e3d67a 100644 --- a/packages/plugin-stripe/package.json +++ b/packages/plugin-stripe/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/plugin-stripe", - "version": "3.36.1", + "version": "3.37.0", "description": "Stripe plugin for Payload", "keywords": [ "payload", diff --git a/packages/richtext-lexical/package.json b/packages/richtext-lexical/package.json index 466ec7f03e..1766ada144 100644 --- a/packages/richtext-lexical/package.json +++ b/packages/richtext-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-lexical", - "version": "3.36.1", + "version": "3.37.0", "description": "The officially supported Lexical richtext adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/richtext-slate/package.json b/packages/richtext-slate/package.json index fd720101e9..ae8481e76f 100644 --- a/packages/richtext-slate/package.json +++ b/packages/richtext-slate/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/richtext-slate", - "version": "3.36.1", + "version": "3.37.0", "description": "The officially supported Slate richtext adapter for Payload", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-azure/package.json b/packages/storage-azure/package.json index 52ca8d300d..5200a09b91 100644 --- a/packages/storage-azure/package.json +++ b/packages/storage-azure/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-azure", - "version": "3.36.1", + "version": "3.37.0", "description": "Payload storage adapter for Azure Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-gcs/package.json b/packages/storage-gcs/package.json index 22f2efbcc4..6fe94f1293 100644 --- a/packages/storage-gcs/package.json +++ b/packages/storage-gcs/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-gcs", - "version": "3.36.1", + "version": "3.37.0", "description": "Payload storage adapter for Google Cloud Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-s3/package.json b/packages/storage-s3/package.json index 3c274d09cf..bacb9301e6 100644 --- a/packages/storage-s3/package.json +++ b/packages/storage-s3/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-s3", - "version": "3.36.1", + "version": "3.37.0", "description": "Payload storage adapter for Amazon S3", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-uploadthing/package.json b/packages/storage-uploadthing/package.json index 828a00d6ed..675b8c9d13 100644 --- a/packages/storage-uploadthing/package.json +++ b/packages/storage-uploadthing/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/storage-uploadthing", - "version": "3.36.1", + "version": "3.37.0", "description": "Payload storage adapter for uploadthing", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/storage-vercel-blob/package.json b/packages/storage-vercel-blob/package.json index 3d7ae0b645..60ab37e7f1 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.36.1", + "version": "3.37.0", "description": "Payload storage adapter for Vercel Blob Storage", "homepage": "https://payloadcms.com", "repository": { diff --git a/packages/translations/package.json b/packages/translations/package.json index 7c4c9d9747..ae1b93bcad 100644 --- a/packages/translations/package.json +++ b/packages/translations/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/translations", - "version": "3.36.1", + "version": "3.37.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", diff --git a/packages/ui/package.json b/packages/ui/package.json index 1e41fd10f9..e0c0f4c340 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@payloadcms/ui", - "version": "3.36.1", + "version": "3.37.0", "homepage": "https://payloadcms.com", "repository": { "type": "git", From 800c424777e5281b9a2ddc6e2701cc445b1d6300 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Mon, 5 May 2025 23:16:14 +0300 Subject: [PATCH 11/46] feat(storage-s3): presigned URLs for file downloads (#12307) Adds pre-signed URLs support file downloads with the S3 adapter. Can be enabled per-collection: ```ts s3Storage({ collections: { media: { signedDownloads: true }, // or { signedDownloads: { expiresIn: 3600 }} for custom expiresIn (default 7200) }, bucket: process.env.S3_BUCKET, config: { credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, }, endpoint: process.env.S3_ENDPOINT, forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true', region: process.env.S3_REGION, }, }), ``` The main use case is when you care about the Payload access control (so you don't want to use `disablePayloadAccessControl: true` but you don't want your files to be served through Payload (which can affect performance with large videos for example). This feature instead generates a signed URL (after verifying the access control) and redirects you directly to the S3 provider. This is an addition to https://github.com/payloadcms/payload/pull/11382 which added pre-signed URLs for file uploads. --- docs/upload/storage-adapters.mdx | 1 + packages/storage-s3/src/index.ts | 45 +++++++++++++++++-- packages/storage-s3/src/staticHandler.ts | 28 +++++++++++- .../collections/MediaWithSignedDownloads.ts | 9 ++++ test/storage-s3/config.ts | 8 +++- test/storage-s3/int.spec.ts | 41 ++++++++++++++--- test/storage-s3/payload-types.ts | 41 +++++++++++++++++ test/storage-s3/shared.ts | 2 + 8 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 test/storage-s3/collections/MediaWithSignedDownloads.ts diff --git a/docs/upload/storage-adapters.mdx b/docs/upload/storage-adapters.mdx index 35c29d9d64..3d4f3639a2 100644 --- a/docs/upload/storage-adapters.mdx +++ b/docs/upload/storage-adapters.mdx @@ -84,6 +84,7 @@ pnpm add @payloadcms/storage-s3 - The `config` object can be any [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3) object (from [`@aws-sdk/client-s3`](https://github.com/aws/aws-sdk-js-v3)). _This is highly dependent on your AWS setup_. Check the AWS documentation for more information. - When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection. - When deploying to Vercel, server uploads are limited with 4.5MB. Set `clientUploads` to `true` to do uploads directly on the client. You must allow CORS PUT method for the bucket to your website. +- Configure `signedDownloads` (either globally of per-collection in `collections`) to use [presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html) for files downloading. This can improve performance for large files (like videos) while still respecting your access control. ```ts import { s3Storage } from '@payloadcms/storage-s3' diff --git a/packages/storage-s3/src/index.ts b/packages/storage-s3/src/index.ts index b45b881f49..b9a0b850a6 100644 --- a/packages/storage-s3/src/index.ts +++ b/packages/storage-s3/src/index.ts @@ -12,6 +12,8 @@ import * as AWS from '@aws-sdk/client-s3' import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage' import { initClientUploads } from '@payloadcms/plugin-cloud-storage/utilities' +import type { SignedDownloadsConfig } from './staticHandler.js' + import { getGenerateSignedURLHandler } from './generateSignedURL.js' import { getGenerateURL } from './generateURL.js' import { getHandleDelete } from './handleDelete.js' @@ -24,6 +26,7 @@ export type S3StorageOptions = { */ acl?: 'private' | 'public-read' + /** * Bucket name to upload files to. * @@ -39,8 +42,15 @@ export type S3StorageOptions = { /** * Collection options to apply the S3 adapter to. */ - collections: Partial | true>> - + collections: Partial< + Record< + UploadCollectionSlug, + | ({ + signedDownloads?: SignedDownloadsConfig + } & Omit) + | true + > + > /** * AWS S3 client configuration. Highly dependent on your AWS setup. * @@ -61,6 +71,10 @@ export type S3StorageOptions = { * Default: true */ enabled?: boolean + /** + * Use pre-signed URLs for files downloading. Can be overriden per-collection. + */ + signedDownloads?: SignedDownloadsConfig } type S3StoragePlugin = (storageS3Args: S3StorageOptions) => Plugin @@ -158,9 +172,27 @@ export const s3Storage: S3StoragePlugin = function s3StorageInternal( getStorageClient: () => AWS.S3, - { acl, bucket, clientUploads, config = {} }: S3StorageOptions, + { + acl, + bucket, + clientUploads, + collections, + config = {}, + signedDownloads: topLevelSignedDownloads, + }: S3StorageOptions, ): Adapter { return ({ collection, prefix }): GeneratedAdapter => { + const collectionStorageConfig = collections[collection.slug] + + let signedDownloads: null | SignedDownloadsConfig = + typeof collectionStorageConfig === 'object' + ? (collectionStorageConfig.signedDownloads ?? false) + : null + + if (signedDownloads === null) { + signedDownloads = topLevelSignedDownloads ?? null + } + return { name: 's3', clientUploads, @@ -173,7 +205,12 @@ function s3StorageInternal( getStorageClient, prefix, }), - staticHandler: getHandler({ bucket, collection, getStorageClient }), + staticHandler: getHandler({ + bucket, + collection, + getStorageClient, + signedDownloads: signedDownloads ?? false, + }), } } } diff --git a/packages/storage-s3/src/staticHandler.ts b/packages/storage-s3/src/staticHandler.ts index 0c01319c14..7f328c1072 100644 --- a/packages/storage-s3/src/staticHandler.ts +++ b/packages/storage-s3/src/staticHandler.ts @@ -3,13 +3,23 @@ import type { StaticHandler } from '@payloadcms/plugin-cloud-storage/types' import type { CollectionConfig } from 'payload' import type { Readable } from 'stream' +import { GetObjectCommand } from '@aws-sdk/client-s3' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import { getFilePrefix } from '@payloadcms/plugin-cloud-storage/utilities' import path from 'path' +export type SignedDownloadsConfig = + | { + /** @default 7200 */ + expiresIn?: number + } + | boolean + interface Args { bucket: string collection: CollectionConfig getStorageClient: () => AWS.S3 + signedDownloads?: SignedDownloadsConfig } // Type guard for NodeJS.Readable streams @@ -40,7 +50,12 @@ const streamToBuffer = async (readableStream: any) => { return Buffer.concat(chunks) } -export const getHandler = ({ bucket, collection, getStorageClient }: Args): StaticHandler => { +export const getHandler = ({ + bucket, + collection, + getStorageClient, + signedDownloads, +}: Args): StaticHandler => { return async (req, { params: { clientUploadContext, filename } }) => { let object: AWS.GetObjectOutput | undefined = undefined try { @@ -48,6 +63,17 @@ export const getHandler = ({ bucket, collection, getStorageClient }: Args): Stat const key = path.posix.join(prefix, filename) + if (signedDownloads && !clientUploadContext) { + const command = new GetObjectCommand({ Bucket: bucket, Key: key }) + const signedUrl = await getSignedUrl( + // @ts-expect-error mismatch versions + getStorageClient(), + command, + typeof signedDownloads === 'object' ? signedDownloads : { expiresIn: 7200 }, + ) + return Response.redirect(signedUrl) + } + object = await getStorageClient().getObject({ Bucket: bucket, Key: key, diff --git a/test/storage-s3/collections/MediaWithSignedDownloads.ts b/test/storage-s3/collections/MediaWithSignedDownloads.ts new file mode 100644 index 0000000000..d4318caea1 --- /dev/null +++ b/test/storage-s3/collections/MediaWithSignedDownloads.ts @@ -0,0 +1,9 @@ +import type { CollectionConfig } from 'payload' + +import { mediaWithSignedDownloadsSlug } from '../shared.js' + +export const MediaWithSignedDownloads: CollectionConfig = { + slug: mediaWithSignedDownloadsSlug, + upload: true, + fields: [], +} diff --git a/test/storage-s3/config.ts b/test/storage-s3/config.ts index 8bef0ea42b..59327fda9f 100644 --- a/test/storage-s3/config.ts +++ b/test/storage-s3/config.ts @@ -7,8 +7,9 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { devUser } from '../credentials.js' import { Media } from './collections/Media.js' import { MediaWithPrefix } from './collections/MediaWithPrefix.js' +import { MediaWithSignedDownloads } from './collections/MediaWithSignedDownloads.js' import { Users } from './collections/Users.js' -import { mediaSlug, mediaWithPrefixSlug, prefix } from './shared.js' +import { mediaSlug, mediaWithPrefixSlug, mediaWithSignedDownloadsSlug, prefix } from './shared.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -25,7 +26,7 @@ export default buildConfigWithDefaults({ baseDir: path.resolve(dirname), }, }, - collections: [Media, MediaWithPrefix, Users], + collections: [Media, MediaWithPrefix, MediaWithSignedDownloads, Users], onInit: async (payload) => { await payload.create({ collection: 'users', @@ -42,6 +43,9 @@ export default buildConfigWithDefaults({ [mediaWithPrefixSlug]: { prefix, }, + [mediaWithSignedDownloadsSlug]: { + signedDownloads: true, + }, }, bucket: process.env.S3_BUCKET, config: { diff --git a/test/storage-s3/int.spec.ts b/test/storage-s3/int.spec.ts index ed898fac60..91b533ef97 100644 --- a/test/storage-s3/int.spec.ts +++ b/test/storage-s3/int.spec.ts @@ -4,12 +4,16 @@ import * as AWS from '@aws-sdk/client-s3' import path from 'path' import { fileURLToPath } from 'url' +import type { NextRESTClient } from '../helpers/NextRESTClient.js' + import { initPayloadInt } from '../helpers/initPayloadInt.js' -import { mediaSlug, mediaWithPrefixSlug, prefix } from './shared.js' +import { mediaSlug, mediaWithPrefixSlug, mediaWithSignedDownloadsSlug, prefix } from './shared.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) +let restClient: NextRESTClient + let payload: Payload describe('@payloadcms/storage-s3', () => { @@ -17,7 +21,7 @@ describe('@payloadcms/storage-s3', () => { let client: AWS.S3Client beforeAll(async () => { - ;({ payload } = await initPayloadInt(dirname)) + ;({ payload, restClient } = await initPayloadInt(dirname)) TEST_BUCKET = process.env.S3_BUCKET client = new AWS.S3({ @@ -77,15 +81,38 @@ describe('@payloadcms/storage-s3', () => { expect(upload.url).toEqual(`/api/${mediaWithPrefixSlug}/file/${String(upload.filename)}`) }) + it('can download with signed downloads', async () => { + await payload.create({ + collection: mediaWithSignedDownloadsSlug, + data: {}, + filePath: path.resolve(dirname, '../uploads/image.png'), + }) + + const response = await restClient.GET(`/${mediaWithSignedDownloadsSlug}/file/image.png`) + expect(response.status).toBe(302) + const url = response.headers.get('Location') + expect(url).toBeDefined() + expect(url!).toContain(`/${TEST_BUCKET}/image.png`) + expect(new URLSearchParams(url!).get('x-id')).toBe('GetObject') + const file = await fetch(url!) + expect(file.headers.get('Content-Type')).toBe('image/png') + }) + describe('R2', () => { it.todo('can upload') }) async function createTestBucket() { - const makeBucketRes = await client.send(new AWS.CreateBucketCommand({ Bucket: TEST_BUCKET })) + try { + const makeBucketRes = await client.send(new AWS.CreateBucketCommand({ Bucket: TEST_BUCKET })) - if (makeBucketRes.$metadata.httpStatusCode !== 200) { - throw new Error(`Failed to create bucket. ${makeBucketRes.$metadata.httpStatusCode}`) + if (makeBucketRes.$metadata.httpStatusCode !== 200) { + throw new Error(`Failed to create bucket. ${makeBucketRes.$metadata.httpStatusCode}`) + } + } catch (e) { + if (e instanceof AWS.BucketAlreadyOwnedByYou) { + console.log('Bucket already exists') + } } } @@ -96,7 +123,9 @@ describe('@payloadcms/storage-s3', () => { }), ) - if (!listedObjects?.Contents?.length) return + if (!listedObjects?.Contents?.length) { + return + } const deleteParams = { Bucket: TEST_BUCKET, diff --git a/test/storage-s3/payload-types.ts b/test/storage-s3/payload-types.ts index 7311e4ebb9..0f7d9fb900 100644 --- a/test/storage-s3/payload-types.ts +++ b/test/storage-s3/payload-types.ts @@ -69,6 +69,7 @@ export interface Config { collections: { media: Media; 'media-with-prefix': MediaWithPrefix; + 'media-with-signed-downloads': MediaWithSignedDownload; users: User; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -78,6 +79,7 @@ export interface Config { collectionsSelect: { media: MediaSelect | MediaSelect; 'media-with-prefix': MediaWithPrefixSelect | MediaWithPrefixSelect; + 'media-with-signed-downloads': MediaWithSignedDownloadsSelect | MediaWithSignedDownloadsSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -171,6 +173,24 @@ export interface MediaWithPrefix { focalX?: number | null; focalY?: number | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media-with-signed-downloads". + */ +export interface MediaWithSignedDownload { + id: string; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". @@ -203,6 +223,10 @@ export interface PayloadLockedDocument { relationTo: 'media-with-prefix'; value: string | MediaWithPrefix; } | null) + | ({ + relationTo: 'media-with-signed-downloads'; + value: string | MediaWithSignedDownload; + } | null) | ({ relationTo: 'users'; value: string | User; @@ -309,6 +333,23 @@ export interface MediaWithPrefixSelect { focalX?: T; focalY?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media-with-signed-downloads_select". + */ +export interface MediaWithSignedDownloadsSelect { + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select". diff --git a/test/storage-s3/shared.ts b/test/storage-s3/shared.ts index 7d74b323ae..27653dab7d 100644 --- a/test/storage-s3/shared.ts +++ b/test/storage-s3/shared.ts @@ -1,3 +1,5 @@ export const mediaSlug = 'media' export const mediaWithPrefixSlug = 'media-with-prefix' export const prefix = 'test-prefix' + +export const mediaWithSignedDownloadsSlug = 'media-with-signed-downloads' From 05ae957cd5d5a3cd2dba709d3e437d9e47c39ff9 Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 5 May 2025 13:17:04 -0700 Subject: [PATCH 12/46] docs: add pagination and limit: 0 information in pagination for API docs (#12243) Fixes https://github.com/payloadcms/payload/issues/12140 --- docs/queries/pagination.mdx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/queries/pagination.mdx b/docs/queries/pagination.mdx index e3549d5f3a..a9f39137db 100644 --- a/docs/queries/pagination.mdx +++ b/docs/queries/pagination.mdx @@ -55,10 +55,11 @@ All collection `find` queries are paginated automatically. Responses are returne All Payload APIs support the pagination controls below. With them, you can create paginated lists of documents within your application: -| Control | Description | -| ------- | --------------------------------------- | -| `limit` | Limits the number of documents returned | -| `page` | Get a specific page number | +| Control | Default | Description | +| ------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `limit` | `10` | Limits the number of documents returned per page - set to `0` to show all documents, we automatically disabled pagination for you when `limit` is `0` for optimisation | +| `pagination` | `true` | Set to `false` to disable pagination and return all documents | +| `page` | `1` | Get a specific page number | ### Disabling pagination within Local API From b3cac753d67993d3657eaa5c38583e48ea01b761 Mon Sep 17 00:00:00 2001 From: Tobias Odendahl Date: Wed, 7 May 2025 02:27:05 +0200 Subject: [PATCH 13/46] feat(ui): display the actual error message on unpublish if available (#11898) ### What? If an error occurs while unpublishing a document in the edit view UI, the toast which shows the error message now displays the actual message which is sent from the server, if available. ### Why? Only a generic error message was shown if an unpublish operation failed. Some errors might be solvable by the user, so that there is value in showing the actual, actionable error message instead of a generic one. ### How? The server response is parsed for error message if an unpublish operation fails and displayed in the toast, instead of the generic error message. ![image](https://github.com/user-attachments/assets/774d68c6-b36b-4447-93a0-b437845694a9) --- packages/ui/src/elements/Status/index.tsx | 15 ++++++++- test/versions/collections/ErrorOnUnpublish.ts | 33 +++++++++++++++++++ test/versions/config.ts | 2 ++ test/versions/e2e.spec.ts | 19 +++++++++++ test/versions/payload-types.ts | 27 +++++++++++++++ test/versions/slugs.ts | 1 + 6 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 test/versions/collections/ErrorOnUnpublish.ts diff --git a/packages/ui/src/elements/Status/index.tsx b/packages/ui/src/elements/Status/index.tsx index 0ae4211962..6e473e8fc5 100644 --- a/packages/ui/src/elements/Status/index.tsx +++ b/packages/ui/src/elements/Status/index.tsx @@ -117,7 +117,19 @@ export const Status: React.FC = () => { setUnpublishedVersionCount(0) } } else { - toast.error(t('error:unPublishingDocument')) + try { + const json = await res.json() + if (json.errors?.[0]?.message) { + toast.error(json.errors[0].message) + } else if (json.error) { + toast.error(json.error) + } else { + toast.error(t('error:unPublishingDocument')) + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (err) { + toast.error(t('error:unPublishingDocument')) + } } }, [ @@ -154,6 +166,7 @@ export const Status: React.FC = () => {
diff --git a/test/query-presets/collections/Pages/index.ts b/test/query-presets/collections/Pages/index.ts index 7bd8a3659a..f470d7c64e 100644 --- a/test/query-presets/collections/Pages/index.ts +++ b/test/query-presets/collections/Pages/index.ts @@ -15,7 +15,4 @@ export const Pages: CollectionConfig = { type: 'text', }, ], - versions: { - drafts: true, - }, } diff --git a/test/query-presets/collections/Posts/index.ts b/test/query-presets/collections/Posts/index.ts index 84aec17300..2cfd03122d 100644 --- a/test/query-presets/collections/Posts/index.ts +++ b/test/query-presets/collections/Posts/index.ts @@ -15,7 +15,4 @@ export const Posts: CollectionConfig = { type: 'text', }, ], - versions: { - drafts: true, - }, } diff --git a/test/query-presets/config.ts b/test/query-presets/config.ts index ebf5dd1119..294549b952 100644 --- a/test/query-presets/config.ts +++ b/test/query-presets/config.ts @@ -23,10 +23,8 @@ export default buildConfigWithDefaults({ // plural: 'Reports', // }, access: { - read: ({ req: { user } }) => - user ? user && !user?.roles?.some((role) => role === 'anonymous') : false, - update: ({ req: { user } }) => - user ? user && !user?.roles?.some((role) => role === 'anonymous') : false, + read: ({ req: { user } }) => Boolean(user?.roles?.length && !user?.roles?.includes('user')), + update: ({ req: { user } }) => Boolean(user?.roles?.length && !user?.roles?.includes('user')), }, constraints: { read: [ @@ -60,7 +58,7 @@ export default buildConfigWithDefaults({ ], }, }, - collections: [Pages, Users, Posts], + collections: [Pages, Posts, Users], onInit: async (payload) => { if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') { await seed(payload) diff --git a/test/query-presets/e2e.spec.ts b/test/query-presets/e2e.spec.ts index 6f2cc43805..866ddee039 100644 --- a/test/query-presets/e2e.spec.ts +++ b/test/query-presets/e2e.spec.ts @@ -8,7 +8,7 @@ import * as path from 'path' import { fileURLToPath } from 'url' import type { PayloadTestSDK } from '../helpers/sdk/index.js' -import type { Config } from './payload-types.js' +import type { Config, PayloadQueryPreset } from './payload-types.js' import { ensureCompilationIsDone, @@ -39,6 +39,13 @@ let serverURL: string let everyoneID: string | undefined let context: BrowserContext let user: any +let ownerUser: any + +let seededData: { + everyone: PayloadQueryPreset + onlyMe: PayloadQueryPreset + specificUsers: PayloadQueryPreset +} describe('Query Presets', () => { beforeAll(async ({ browser }, testInfo) => { @@ -60,6 +67,19 @@ describe('Query Presets', () => { }) ?.then((res) => res.user) // TODO: this type is wrong + ownerUser = await payload + .find({ + collection: 'users', + where: { + name: { + equals: 'Owner', + }, + }, + limit: 1, + depth: 0, + }) + ?.then((res) => res.docs[0]) + initPageConsoleErrorCatch(page) await ensureCompilationIsDone({ page, serverURL }) @@ -83,7 +103,7 @@ describe('Query Presets', () => { }, }) - const [, everyone] = await Promise.all([ + const [, everyone, onlyMe, specificUsers] = await Promise.all([ payload.delete({ collection: 'payload-preferences', where: { @@ -106,18 +126,24 @@ describe('Query Presets', () => { }), payload.create({ collection: 'payload-query-presets', - data: seedData.everyone, + data: seedData.everyone({ ownerUserID: ownerUser?.id || '' }), }), payload.create({ collection: 'payload-query-presets', - data: seedData.onlyMe, + data: seedData.onlyMe({ ownerUserID: ownerUser?.id || '' }), }), payload.create({ collection: 'payload-query-presets', - data: seedData.specificUsers({ userID: user?.id || '' }), + data: seedData.specificUsers({ ownerUserID: ownerUser?.id || '', adminUserID: user.id }), }), ]) + seededData = { + everyone, + onlyMe, + specificUsers, + } + everyoneID = everyone.id } catch (error) { console.error('Error in beforeEach:', error) @@ -126,12 +152,12 @@ describe('Query Presets', () => { test('should select preset and apply filters', async () => { await page.goto(pagesUrl.list) - await selectPreset({ page, presetTitle: seedData.everyone.title }) + await selectPreset({ page, presetTitle: seededData.everyone.title }) await assertURLParams({ page, - columns: seedData.everyone.columns, - where: seedData.everyone.where, + columns: seededData.everyone.columns, + where: seededData.everyone.where, presetID: everyoneID, }) @@ -140,14 +166,14 @@ describe('Query Presets', () => { test('should clear selected preset and reset filters', async () => { await page.goto(pagesUrl.list) - await selectPreset({ page, presetTitle: seedData.everyone.title }) + await selectPreset({ page, presetTitle: seededData.everyone.title }) await clearSelectedPreset({ page }) expect(true).toBe(true) }) test('should delete a preset, clear selection, and reset changes', async () => { await page.goto(pagesUrl.list) - await selectPreset({ page, presetTitle: seedData.everyone.title }) + await selectPreset({ page, presetTitle: seededData.everyone.title }) await openListMenu({ page }) await clickListMenuItem({ page, menuItemLabel: 'Delete' }) @@ -172,21 +198,21 @@ describe('Query Presets', () => { await expect( modal.locator('tbody tr td button', { - hasText: exactText(seedData.everyone.title), + hasText: exactText(seededData.everyone.title), }), ).toBeHidden() }) test('should save last used preset to preferences and load on initial render', async () => { await page.goto(pagesUrl.list) - await selectPreset({ page, presetTitle: seedData.everyone.title }) + await selectPreset({ page, presetTitle: seededData.everyone.title }) await page.reload() await assertURLParams({ page, - columns: seedData.everyone.columns, - where: seedData.everyone.where, + columns: seededData.everyone.columns, + where: seededData.everyone.where, // presetID: everyoneID, }) @@ -209,7 +235,7 @@ describe('Query Presets', () => { }), ).toBeHidden() - await selectPreset({ page, presetTitle: seedData.everyone.title }) + await selectPreset({ page, presetTitle: seededData.everyone.title }) await openListMenu({ page }) @@ -249,7 +275,7 @@ describe('Query Presets', () => { }), ).toBeHidden() - await selectPreset({ page, presetTitle: seedData.onlyMe.title }) + await selectPreset({ page, presetTitle: seededData.onlyMe.title }) await toggleColumn(page, { columnLabel: 'ID' }) @@ -271,7 +297,7 @@ describe('Query Presets', () => { test('should conditionally render "update for everyone" label based on if preset is shared', async () => { await page.goto(pagesUrl.list) - await selectPreset({ page, presetTitle: seedData.onlyMe.title }) + await selectPreset({ page, presetTitle: seededData.onlyMe.title }) await toggleColumn(page, { columnLabel: 'ID' }) @@ -284,7 +310,7 @@ describe('Query Presets', () => { }), ).toBeVisible() - await selectPreset({ page, presetTitle: seedData.everyone.title }) + await selectPreset({ page, presetTitle: seededData.everyone.title }) await toggleColumn(page, { columnLabel: 'ID' }) @@ -300,7 +326,7 @@ describe('Query Presets', () => { test('should reset active changes', async () => { await page.goto(pagesUrl.list) - await selectPreset({ page, presetTitle: seedData.everyone.title }) + await selectPreset({ page, presetTitle: seededData.everyone.title }) const { columnContainer } = await toggleColumn(page, { columnLabel: 'ID' }) @@ -318,7 +344,7 @@ describe('Query Presets', () => { test('should only enter modified state when changes are made to an active preset', async () => { await page.goto(pagesUrl.list) await expect(page.locator('.list-controls__modified')).toBeHidden() - await selectPreset({ page, presetTitle: seedData.everyone.title }) + await selectPreset({ page, presetTitle: seededData.everyone.title }) await expect(page.locator('.list-controls__modified')).toBeHidden() await toggleColumn(page, { columnLabel: 'ID' }) await expect(page.locator('.list-controls__modified')).toBeVisible() @@ -337,14 +363,14 @@ describe('Query Presets', () => { await page.goto(pagesUrl.list) - await selectPreset({ page, presetTitle: seedData.everyone.title }) + await selectPreset({ page, presetTitle: seededData.everyone.title }) await clickListMenuItem({ page, menuItemLabel: 'Edit' }) const drawer = page.locator('[id^=doc-drawer_payload-query-presets_0_]') const titleValue = drawer.locator('input[name="title"]') - await expect(titleValue).toHaveValue(seedData.everyone.title) + await expect(titleValue).toHaveValue(seededData.everyone.title) - const newTitle = `${seedData.everyone.title} (Updated)` + const newTitle = `${seededData.everyone.title} (Updated)` await drawer.locator('input[name="title"]').fill(newTitle) await saveDocAndAssert(page) @@ -391,9 +417,9 @@ describe('Query Presets', () => { }) test('only shows query presets related to the underlying collection', async () => { - // no results on `users` collection - const postsUrl = new AdminUrlUtil(serverURL, 'posts') - await page.goto(postsUrl.list) + // no results on `posts` collection + const postsURL = new AdminUrlUtil(serverURL, 'posts') + await page.goto(postsURL.list) const drawer = await openQueryPresetDrawer({ page }) await expect(drawer.locator('.table table > tbody > tr')).toHaveCount(0) await expect(drawer.locator('.collection-list__no-results')).toBeVisible() diff --git a/test/query-presets/fields/roles.ts b/test/query-presets/fields/roles.ts index ad5ff3a66c..9304cb2afc 100644 --- a/test/query-presets/fields/roles.ts +++ b/test/query-presets/fields/roles.ts @@ -9,13 +9,13 @@ export const roles: Field = { label: 'Admin', value: 'admin', }, + { + label: 'Editor', + value: 'editor', + }, { label: 'User', value: 'user', }, - { - label: 'Anonymous', - value: 'anonymous', - }, ], } diff --git a/test/query-presets/int.spec.ts b/test/query-presets/int.spec.ts index 42bd771ac1..6fdc915e52 100644 --- a/test/query-presets/int.spec.ts +++ b/test/query-presets/int.spec.ts @@ -1,4 +1,3 @@ -import type { NextRESTClient } from 'helpers/NextRESTClient.js' import type { Payload, User } from 'payload' import path from 'path' @@ -10,10 +9,9 @@ import { initPayloadInt } from '../helpers/initPayloadInt.js' const queryPresetsCollectionSlug = 'payload-query-presets' let payload: Payload -let restClient: NextRESTClient -let user: User -let user2: User -let anonymousUser: User +let adminUser: User +let editorUser: User +let publicUser: User const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -21,9 +19,9 @@ const dirname = path.dirname(filename) describe('Query Presets', () => { beforeAll(async () => { // @ts-expect-error: initPayloadInt does not have a proper type definition - ;({ payload, restClient } = await initPayloadInt(dirname)) + ;({ payload } = await initPayloadInt(dirname)) - user = await payload + adminUser = await payload .login({ collection: 'users', data: { @@ -33,7 +31,7 @@ describe('Query Presets', () => { }) ?.then((result) => result.user) - user2 = await payload + editorUser = await payload .login({ collection: 'users', data: { @@ -43,11 +41,11 @@ describe('Query Presets', () => { }) ?.then((result) => result.user) - anonymousUser = await payload + publicUser = await payload .login({ collection: 'users', data: { - email: 'anonymous@email.com', + email: 'public@email.com', password: regularUser.password, }, }) @@ -155,7 +153,8 @@ describe('Query Presets', () => { it('should respect access when set to "specificUsers"', async () => { const presetForSpecificUsers = await payload.create({ collection: queryPresetsCollectionSlug, - user, + user: adminUser, + overrideAccess: false, data: { title: 'Specific Users', where: { @@ -166,11 +165,11 @@ describe('Query Presets', () => { access: { read: { constraint: 'specificUsers', - users: [user.id], + users: [adminUser.id], }, update: { constraint: 'specificUsers', - users: [user.id], + users: [adminUser.id], }, }, relatedCollection: 'pages', @@ -180,7 +179,7 @@ describe('Query Presets', () => { const foundPresetWithUser1 = await payload.findByID({ collection: queryPresetsCollectionSlug, depth: 0, - user, + user: adminUser, overrideAccess: false, id: presetForSpecificUsers.id, }) @@ -188,53 +187,53 @@ describe('Query Presets', () => { expect(foundPresetWithUser1.id).toBe(presetForSpecificUsers.id) try { - const foundPresetWithUser2 = await payload.findByID({ + const foundPresetWithEditorUser = await payload.findByID({ collection: queryPresetsCollectionSlug, depth: 0, - user: user2, + user: editorUser, overrideAccess: false, id: presetForSpecificUsers.id, }) - expect(foundPresetWithUser2).toBeFalsy() + expect(foundPresetWithEditorUser).toBeFalsy() } catch (error: unknown) { expect((error as Error).message).toBe('Not Found') } - const presetUpdatedByUser1 = await payload.update({ + const presetUpdatedByAdminUser = await payload.update({ collection: queryPresetsCollectionSlug, id: presetForSpecificUsers.id, - user, + user: adminUser, overrideAccess: false, data: { title: 'Specific Users (Updated)', }, }) - expect(presetUpdatedByUser1.title).toBe('Specific Users (Updated)') + expect(presetUpdatedByAdminUser.title).toBe('Specific Users (Updated)') try { - const presetUpdatedByUser2 = await payload.update({ + const presetUpdatedByEditorUser = await payload.update({ collection: queryPresetsCollectionSlug, id: presetForSpecificUsers.id, - user: user2, + user: editorUser, overrideAccess: false, data: { title: 'Specific Users (Updated)', }, }) - expect(presetUpdatedByUser2).toBeFalsy() + expect(presetUpdatedByEditorUser).toBeFalsy() } catch (error: unknown) { expect((error as Error).message).toBe('You are not allowed to perform this action.') } }) it('should respect access when set to "onlyMe"', async () => { - // create a new doc so that the creating user is the owner const presetForOnlyMe = await payload.create({ collection: queryPresetsCollectionSlug, - user, + overrideAccess: false, + user: adminUser, data: { title: 'Only Me', where: { @@ -257,7 +256,7 @@ describe('Query Presets', () => { const foundPresetWithUser1 = await payload.findByID({ collection: queryPresetsCollectionSlug, depth: 0, - user, + user: adminUser, overrideAccess: false, id: presetForOnlyMe.id, }) @@ -265,15 +264,15 @@ describe('Query Presets', () => { expect(foundPresetWithUser1.id).toBe(presetForOnlyMe.id) try { - const foundPresetWithUser2 = await payload.findByID({ + const foundPresetWithEditorUser = await payload.findByID({ collection: queryPresetsCollectionSlug, depth: 0, - user: user2, + user: editorUser, overrideAccess: false, id: presetForOnlyMe.id, }) - expect(foundPresetWithUser2).toBeFalsy() + expect(foundPresetWithEditorUser).toBeFalsy() } catch (error: unknown) { expect((error as Error).message).toBe('Not Found') } @@ -281,7 +280,7 @@ describe('Query Presets', () => { const presetUpdatedByUser1 = await payload.update({ collection: queryPresetsCollectionSlug, id: presetForOnlyMe.id, - user, + user: adminUser, overrideAccess: false, data: { title: 'Only Me (Updated)', @@ -291,17 +290,17 @@ describe('Query Presets', () => { expect(presetUpdatedByUser1.title).toBe('Only Me (Updated)') try { - const presetUpdatedByUser2 = await payload.update({ + const presetUpdatedByEditorUser = await payload.update({ collection: queryPresetsCollectionSlug, id: presetForOnlyMe.id, - user: user2, + user: editorUser, overrideAccess: false, data: { title: 'Only Me (Updated)', }, }) - expect(presetUpdatedByUser2).toBeFalsy() + expect(presetUpdatedByEditorUser).toBeFalsy() } catch (error: unknown) { expect((error as Error).message).toBe('You are not allowed to perform this action.') } @@ -310,7 +309,8 @@ describe('Query Presets', () => { it('should respect access when set to "everyone"', async () => { const presetForEveryone = await payload.create({ collection: queryPresetsCollectionSlug, - user, + overrideAccess: false, + user: adminUser, data: { title: 'Everyone', where: { @@ -336,27 +336,27 @@ describe('Query Presets', () => { const foundPresetWithUser1 = await payload.findByID({ collection: queryPresetsCollectionSlug, depth: 0, - user, + user: adminUser, overrideAccess: false, id: presetForEveryone.id, }) expect(foundPresetWithUser1.id).toBe(presetForEveryone.id) - const foundPresetWithUser2 = await payload.findByID({ + const foundPresetWithEditorUser = await payload.findByID({ collection: queryPresetsCollectionSlug, depth: 0, - user: user2, + user: editorUser, overrideAccess: false, id: presetForEveryone.id, }) - expect(foundPresetWithUser2.id).toBe(presetForEveryone.id) + expect(foundPresetWithEditorUser.id).toBe(presetForEveryone.id) const presetUpdatedByUser1 = await payload.update({ collection: queryPresetsCollectionSlug, id: presetForEveryone.id, - user, + user: adminUser, overrideAccess: false, data: { title: 'Everyone (Update 1)', @@ -365,17 +365,105 @@ describe('Query Presets', () => { expect(presetUpdatedByUser1.title).toBe('Everyone (Update 1)') - const presetUpdatedByUser2 = await payload.update({ + const presetUpdatedByEditorUser = await payload.update({ collection: queryPresetsCollectionSlug, id: presetForEveryone.id, - user: user2, + user: editorUser, overrideAccess: false, data: { title: 'Everyone (Update 2)', }, }) - expect(presetUpdatedByUser2.title).toBe('Everyone (Update 2)') + expect(presetUpdatedByEditorUser.title).toBe('Everyone (Update 2)') + }) + + it('should prevent accidental lockout', async () => { + // attempt to create a preset without access to read or update + try { + const presetWithoutAccess = await payload.create({ + collection: queryPresetsCollectionSlug, + user: adminUser, + overrideAccess: false, + data: { + title: 'Prevent Lockout', + relatedCollection: 'pages', + access: { + read: { + constraint: 'specificUsers', + users: [], + }, + update: { + constraint: 'specificUsers', + users: [], + }, + delete: { + constraint: 'specificUsers', + users: [], + }, + }, + }, + }) + + expect(presetWithoutAccess).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('Cannot remove yourself from this preset.') + } + + const presetWithUser1 = await payload.create({ + collection: queryPresetsCollectionSlug, + user: adminUser, + overrideAccess: false, + data: { + title: 'Prevent Lockout', + relatedCollection: 'pages', + access: { + read: { + constraint: 'specificUsers', + users: [adminUser.id], + }, + update: { + constraint: 'specificUsers', + users: [adminUser.id], + }, + delete: { + constraint: 'specificUsers', + users: [adminUser.id], + }, + }, + }, + }) + + // attempt to update the preset to lock the user out of access + try { + const presetUpdatedByUser1 = await payload.update({ + collection: queryPresetsCollectionSlug, + id: presetWithUser1.id, + user: adminUser, + overrideAccess: false, + data: { + title: 'Prevent Lockout (Updated)', + access: { + read: { + constraint: 'specificUsers', + users: [], + }, + update: { + constraint: 'specificUsers', + users: [], + }, + delete: { + constraint: 'specificUsers', + users: [], + }, + }, + }, + }) + + expect(presetUpdatedByUser1).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('Cannot remove yourself from this preset.') + } }) }) @@ -383,7 +471,8 @@ describe('Query Presets', () => { it('should respect top-level access control overrides', async () => { const preset = await payload.create({ collection: queryPresetsCollectionSlug, - user, + user: adminUser, + overrideAccess: false, data: { title: 'Top-Level Access Control Override', relatedCollection: 'pages', @@ -404,7 +493,7 @@ describe('Query Presets', () => { const foundPresetWithUser1 = await payload.findByID({ collection: queryPresetsCollectionSlug, depth: 0, - user, + user: adminUser, overrideAccess: false, id: preset.id, }) @@ -412,15 +501,15 @@ describe('Query Presets', () => { expect(foundPresetWithUser1.id).toBe(preset.id) try { - const foundPresetWithAnonymousUser = await payload.findByID({ + const foundPresetWithPublicUser = await payload.findByID({ collection: queryPresetsCollectionSlug, depth: 0, - user: anonymousUser, + user: publicUser, overrideAccess: false, id: preset.id, }) - expect(foundPresetWithAnonymousUser).toBeFalsy() + expect(foundPresetWithPublicUser).toBeFalsy() } catch (error: unknown) { expect((error as Error).message).toBe('You are not allowed to perform this action.') } @@ -429,7 +518,8 @@ describe('Query Presets', () => { it('should respect access when set to "specificRoles"', async () => { const presetForSpecificRoles = await payload.create({ collection: queryPresetsCollectionSlug, - user, + user: adminUser, + overrideAccess: false, data: { title: 'Specific Roles', where: { @@ -454,7 +544,7 @@ describe('Query Presets', () => { const foundPresetWithUser1 = await payload.findByID({ collection: queryPresetsCollectionSlug, depth: 0, - user, + user: adminUser, overrideAccess: false, id: presetForSpecificRoles.id, }) @@ -462,15 +552,15 @@ describe('Query Presets', () => { expect(foundPresetWithUser1.id).toBe(presetForSpecificRoles.id) try { - const foundPresetWithUser2 = await payload.findByID({ + const foundPresetWithEditorUser = await payload.findByID({ collection: queryPresetsCollectionSlug, depth: 0, - user: user2, + user: editorUser, overrideAccess: false, id: presetForSpecificRoles.id, }) - expect(foundPresetWithUser2).toBeFalsy() + expect(foundPresetWithEditorUser).toBeFalsy() } catch (error: unknown) { expect((error as Error).message).toBe('Not Found') } @@ -478,7 +568,7 @@ describe('Query Presets', () => { const presetUpdatedByUser1 = await payload.update({ collection: queryPresetsCollectionSlug, id: presetForSpecificRoles.id, - user, + user: adminUser, overrideAccess: false, data: { title: 'Specific Roles (Updated)', @@ -488,17 +578,17 @@ describe('Query Presets', () => { expect(presetUpdatedByUser1.title).toBe('Specific Roles (Updated)') try { - const presetUpdatedByUser2 = await payload.update({ + const presetUpdatedByEditorUser = await payload.update({ collection: queryPresetsCollectionSlug, id: presetForSpecificRoles.id, - user: user2, + user: editorUser, overrideAccess: false, data: { title: 'Specific Roles (Updated)', }, }) - expect(presetUpdatedByUser2).toBeFalsy() + expect(presetUpdatedByEditorUser).toBeFalsy() } catch (error: unknown) { expect((error as Error).message).toBe('You are not allowed to perform this action.') } @@ -508,7 +598,7 @@ describe('Query Presets', () => { // create a preset with the read constraint set to "noone" const presetForNoone = await payload.create({ collection: queryPresetsCollectionSlug, - user, + user: adminUser, data: { relatedCollection: 'pages', title: 'Noone', @@ -529,7 +619,7 @@ describe('Query Presets', () => { const foundPresetWithUser1 = await payload.findByID({ collection: queryPresetsCollectionSlug, depth: 0, - user, + user: adminUser, overrideAccess: false, id: presetForNoone.id, }) @@ -545,7 +635,8 @@ describe('Query Presets', () => { try { const result = await payload.create({ collection: 'payload-query-presets', - user, + user: adminUser, + overrideAccess: false, data: { title: 'Disabled Query Presets', relatedCollection: 'pages', @@ -563,7 +654,8 @@ describe('Query Presets', () => { it('transforms "where" query objects into the "and" / "or" format', async () => { const result = await payload.create({ collection: queryPresetsCollectionSlug, - user, + user: adminUser, + overrideAccess: false, data: { title: 'Where Object Formatting', where: { diff --git a/test/query-presets/payload-types.ts b/test/query-presets/payload-types.ts index 3918740441..61c9398cac 100644 --- a/test/query-presets/payload-types.ts +++ b/test/query-presets/payload-types.ts @@ -68,8 +68,8 @@ export interface Config { blocks: {}; collections: { pages: Page; - users: User; posts: Post; + users: User; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -78,8 +78,8 @@ export interface Config { collectionsJoins: {}; collectionsSelect: { pages: PagesSelect | PagesSelect; - users: UsersSelect | UsersSelect; posts: PostsSelect | PostsSelect; + users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -126,7 +126,16 @@ export interface Page { text?: string | null; updatedAt: string; createdAt: string; - _status?: ('draft' | 'published') | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts". + */ +export interface Post { + id: string; + text?: string | null; + updatedAt: string; + createdAt: string; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -135,7 +144,7 @@ export interface Page { export interface User { id: string; name?: string | null; - roles?: ('admin' | 'user' | 'anonymous')[] | null; + roles?: ('admin' | 'editor' | 'user')[] | null; updatedAt: string; createdAt: string; email: string; @@ -147,17 +156,6 @@ export interface User { lockUntil?: string | null; password?: string | null; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "posts". - */ -export interface Post { - id: string; - text?: string | null; - updatedAt: string; - createdAt: string; - _status?: ('draft' | 'published') | null; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents". @@ -169,13 +167,13 @@ export interface PayloadLockedDocument { relationTo: 'pages'; value: string | Page; } | null) - | ({ - relationTo: 'users'; - value: string | User; - } | null) | ({ relationTo: 'posts'; value: string | Post; + } | null) + | ({ + relationTo: 'users'; + value: string | User; } | null); globalSlug?: string | null; user: { @@ -231,12 +229,12 @@ export interface PayloadQueryPreset { read?: { constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles' | 'noone') | null; users?: (string | User)[] | null; - roles?: ('admin' | 'user' | 'anonymous')[] | null; + roles?: ('admin' | 'editor' | 'user')[] | null; }; update?: { constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles') | null; users?: (string | User)[] | null; - roles?: ('admin' | 'user' | 'anonymous')[] | null; + roles?: ('admin' | 'editor' | 'user')[] | null; }; delete?: { constraint?: ('everyone' | 'onlyMe' | 'specificUsers') | null; @@ -262,6 +260,10 @@ export interface PayloadQueryPreset { | boolean | null; relatedCollection: 'pages' | 'posts'; + /** + * This is a tempoary field used to determine if updating the preset would remove the user's access to it. When `true`, this record will be deleted after running the preset's `validate` function. + */ + isTemp?: boolean | null; updatedAt: string; createdAt: string; } @@ -273,7 +275,15 @@ export interface PagesSelect { text?: T; updatedAt?: T; createdAt?: T; - _status?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts_select". + */ +export interface PostsSelect { + text?: T; + updatedAt?: T; + createdAt?: T; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -292,16 +302,6 @@ export interface UsersSelect { loginAttempts?: T; lockUntil?: T; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "posts_select". - */ -export interface PostsSelect { - text?: T; - updatedAt?: T; - createdAt?: T; - _status?: T; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select". @@ -368,6 +368,7 @@ export interface PayloadQueryPresetsSelect { where?: T; columns?: T; relatedCollection?: T; + isTemp?: T; updatedAt?: T; createdAt?: T; } diff --git a/test/query-presets/seed.ts b/test/query-presets/seed.ts index 029c283919..1027fa22e3 100644 --- a/test/query-presets/seed.ts +++ b/test/query-presets/seed.ts @@ -10,11 +10,11 @@ type SeededQueryPreset = { } & Omit export const seedData: { - everyone: SeededQueryPreset - onlyMe: SeededQueryPreset - specificUsers: (args: { userID: string }) => SeededQueryPreset + everyone: () => SeededQueryPreset + onlyMe: () => SeededQueryPreset + specificUsers: (args: { adminUserID: string }) => SeededQueryPreset } = { - onlyMe: { + onlyMe: () => ({ relatedCollection: pagesSlug, isShared: false, title: 'Only Me', @@ -40,8 +40,8 @@ export const seedData: { equals: 'example page', }, }, - }, - everyone: { + }), + everyone: () => ({ relatedCollection: pagesSlug, isShared: true, title: 'Everyone', @@ -67,8 +67,8 @@ export const seedData: { equals: 'example page', }, }, - }, - specificUsers: ({ userID }: { userID: string }) => ({ + }), + specificUsers: ({ adminUserID }: { adminUserID: string }) => ({ title: 'Specific Users', isShared: true, where: { @@ -79,15 +79,15 @@ export const seedData: { access: { read: { constraint: 'specificUsers', - users: [userID], + users: [adminUserID], }, update: { constraint: 'specificUsers', - users: [userID], + users: [adminUserID], }, delete: { constraint: 'specificUsers', - users: [userID], + users: [adminUserID], }, }, columns: [ @@ -101,7 +101,7 @@ export const seedData: { } export const seed = async (_payload: Payload) => { - const [devUser] = await executePromises( + const [adminUser] = await executePromises( [ () => _payload.create({ @@ -119,18 +119,18 @@ export const seed = async (_payload: Payload) => { data: { email: regularCredentials.email, password: regularCredentials.password, - name: 'User', - roles: ['user'], + name: 'Editor', + roles: ['editor'], }, }), () => _payload.create({ collection: usersSlug, data: { - email: 'anonymous@email.com', + email: 'public@email.com', password: regularCredentials.password, - name: 'User', - roles: ['anonymous'], + name: 'Public User', + roles: ['user'], }, }), ], @@ -149,29 +149,30 @@ export const seed = async (_payload: Payload) => { () => _payload.create({ collection: 'payload-query-presets', - user: devUser, + user: adminUser, overrideAccess: false, - data: seedData.specificUsers({ userID: devUser?.id || '' }), + data: seedData.specificUsers({ + adminUserID: adminUser?.id || '', + }), }), () => _payload.create({ collection: 'payload-query-presets', - user: devUser, + user: adminUser, overrideAccess: false, - data: seedData.everyone, + data: seedData.everyone(), }), () => _payload.create({ collection: 'payload-query-presets', - user: devUser, + user: adminUser, overrideAccess: false, - data: seedData.onlyMe, + data: seedData.onlyMe(), }), () => _payload.create({ collection: 'payload-query-presets', - user: devUser, - overrideAccess: false, + user: adminUser, data: { relatedCollection: 'pages', title: 'Noone', diff --git a/tsconfig.base.json b/tsconfig.base.json index 12877b1be6..b7c0ef5d5c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/_community/config.ts"], + "@payload-config": ["./test/query-presets/config.ts"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], From 93d79b9c62d47b43e5cbbcb90cb901ae98e0bb39 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Wed, 14 May 2025 15:25:44 -0400 Subject: [PATCH 34/46] perf: remove duplicative deep loops during field sanitization (#12402) Optimizes the field sanitization process by removing duplicative deep loops over the config. We were previously iterating over all fields of each collection potentially multiple times in order validate field configs, check reserved field names, etc. Now, we perform all necessary sanitization within a single loop. --- .../collections/config/reservedFieldNames.ts | 151 ------------------ .../src/collections/config/sanitize.ts | 16 +- .../src/collections/config/useAsTitle.ts | 2 +- packages/payload/src/config/sanitize.ts | 2 + .../config/reservedFieldNames.spec.ts | 7 +- .../src/fields/config/reservedFieldNames.ts | 48 ++++++ .../src/fields/config/sanitize.spec.ts | 60 ++++++- .../payload/src/fields/config/sanitize.ts | 80 ++++++++-- 8 files changed, 187 insertions(+), 179 deletions(-) delete mode 100644 packages/payload/src/collections/config/reservedFieldNames.ts rename packages/payload/src/{collections => fields}/config/reservedFieldNames.spec.ts (97%) create mode 100644 packages/payload/src/fields/config/reservedFieldNames.ts diff --git a/packages/payload/src/collections/config/reservedFieldNames.ts b/packages/payload/src/collections/config/reservedFieldNames.ts deleted file mode 100644 index 199571dae1..0000000000 --- a/packages/payload/src/collections/config/reservedFieldNames.ts +++ /dev/null @@ -1,151 +0,0 @@ -// @ts-strict-ignore -import type { Field } from '../../fields/config/types.js' -import type { CollectionConfig } from '../../index.js' - -import { ReservedFieldName } from '../../errors/ReservedFieldName.js' -import { fieldAffectsData } from '../../fields/config/types.js' - -// Note for future reference: We've slimmed down the reserved field names but left them in here for reference in case it's needed in the future. - -/** - * Reserved field names for collections with auth config enabled - */ -const reservedBaseAuthFieldNames = [ - /* 'email', - 'resetPasswordToken', - 'resetPasswordExpiration', */ - 'salt', - 'hash', -] -/** - * Reserved field names for auth collections with verify: true - */ -const reservedVerifyFieldNames = [ - /* '_verified', '_verificationToken' */ -] -/** - * Reserved field names for auth collections with useApiKey: true - */ -const reservedAPIKeyFieldNames = [ - /* 'enableAPIKey', 'apiKeyIndex', 'apiKey' */ -] - -/** - * Reserved field names for collections with upload config enabled - */ -const reservedBaseUploadFieldNames = [ - 'file', - /* 'mimeType', - 'thumbnailURL', - 'width', - 'height', - 'filesize', - 'filename', - 'url', - 'focalX', - 'focalY', - 'sizes', */ -] - -/** - * Reserved field names for collections with versions enabled - */ -const reservedVersionsFieldNames = [ - /* '__v', '_status' */ -] - -/** - * Sanitize fields for collections with auth config enabled. - * - * Should run on top level fields only. - */ -export const sanitizeAuthFields = (fields: Field[], config: CollectionConfig) => { - for (let i = 0; i < fields.length; i++) { - const field = fields[i] - - if (fieldAffectsData(field) && field.name) { - if (config.auth && typeof config.auth === 'object' && !config.auth.disableLocalStrategy) { - const auth = config.auth - - if (reservedBaseAuthFieldNames.includes(field.name)) { - throw new ReservedFieldName(field, field.name) - } - - if (auth.verify) { - if (reservedAPIKeyFieldNames.includes(field.name)) { - throw new ReservedFieldName(field, field.name) - } - } - - /* if (auth.maxLoginAttempts) { - if (field.name === 'loginAttempts' || field.name === 'lockUntil') { - throw new ReservedFieldName(field, field.name) - } - } */ - - /* if (auth.loginWithUsername) { - if (field.name === 'username') { - throw new ReservedFieldName(field, field.name) - } - } */ - - if (auth.verify) { - if (reservedVerifyFieldNames.includes(field.name)) { - throw new ReservedFieldName(field, field.name) - } - } - } - } - - // Handle tabs without a name - if (field.type === 'tabs') { - for (let j = 0; j < field.tabs.length; j++) { - const tab = field.tabs[j] - - if (!('name' in tab)) { - sanitizeAuthFields(tab.fields, config) - } - } - } - - // Handle presentational fields like rows and collapsibles - if (!fieldAffectsData(field) && 'fields' in field && field.fields) { - sanitizeAuthFields(field.fields, config) - } - } -} - -/** - * Sanitize fields for collections with upload config enabled. - * - * Should run on top level fields only. - */ -export const sanitizeUploadFields = (fields: Field[], config: CollectionConfig) => { - if (config.upload && typeof config.upload === 'object') { - for (let i = 0; i < fields.length; i++) { - const field = fields[i] - - if (fieldAffectsData(field) && field.name) { - if (reservedBaseUploadFieldNames.includes(field.name)) { - throw new ReservedFieldName(field, field.name) - } - } - - // Handle tabs without a name - if (field.type === 'tabs') { - for (let j = 0; j < field.tabs.length; j++) { - const tab = field.tabs[j] - - if (!('name' in tab)) { - sanitizeUploadFields(tab.fields, config) - } - } - } - - // Handle presentational fields like rows and collapsibles - if (!fieldAffectsData(field) && 'fields' in field && field.fields) { - sanitizeUploadFields(field.fields, config) - } - } - } -} diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 31198bc80d..fd29217f2b 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -26,7 +26,6 @@ import { addDefaultsToCollectionConfig, addDefaultsToLoginWithUsernameConfig, } from './defaults.js' -import { sanitizeAuthFields, sanitizeUploadFields } from './reservedFieldNames.js' import { sanitizeCompoundIndexes } from './sanitizeCompoundIndexes.js' import { validateUseAsTitle } from './useAsTitle.js' @@ -43,7 +42,9 @@ export const sanitizeCollection = async ( if (collection._sanitized) { return collection as SanitizedCollectionConfig } + collection._sanitized = true + // ///////////////////////////////// // Make copy of collection config // ///////////////////////////////// @@ -57,7 +58,9 @@ export const sanitizeCollection = async ( const validRelationships = _validRelationships ?? config.collections.map((c) => c.slug) ?? [] const joins: SanitizedJoins = {} + const polymorphicJoins: SanitizedJoin[] = [] + sanitized.fields = await sanitizeFields({ collectionConfig: sanitized, config, @@ -96,17 +99,21 @@ export const sanitizeCollection = async ( // add default timestamps fields only as needed let hasUpdatedAt: boolean | null = null let hasCreatedAt: boolean | null = null + sanitized.fields.some((field) => { if (fieldAffectsData(field)) { if (field.name === 'updatedAt') { hasUpdatedAt = true } + if (field.name === 'createdAt') { hasCreatedAt = true } } + return hasCreatedAt && hasUpdatedAt }) + if (!hasUpdatedAt) { sanitized.fields.push({ name: 'updatedAt', @@ -119,6 +126,7 @@ export const sanitizeCollection = async ( label: ({ t }) => t('general:updatedAt'), }) } + if (!hasCreatedAt) { sanitized.fields.push({ name: 'createdAt', @@ -175,9 +183,6 @@ export const sanitizeCollection = async ( sanitized.upload = {} } - // sanitize fields for reserved names - sanitizeUploadFields(sanitized.fields, sanitized) - sanitized.upload.cacheTags = sanitized.upload?.cacheTags ?? true sanitized.upload.bulkUpload = sanitized.upload?.bulkUpload ?? true sanitized.upload.staticDir = sanitized.upload.staticDir || sanitized.slug @@ -195,9 +200,6 @@ export const sanitizeCollection = async ( } if (sanitized.auth) { - // sanitize fields for reserved names - sanitizeAuthFields(sanitized.fields, sanitized) - sanitized.auth = addDefaultsToAuthConfig( typeof sanitized.auth === 'boolean' ? {} : sanitized.auth, ) diff --git a/packages/payload/src/collections/config/useAsTitle.ts b/packages/payload/src/collections/config/useAsTitle.ts index c4633f94f7..9dfb804d25 100644 --- a/packages/payload/src/collections/config/useAsTitle.ts +++ b/packages/payload/src/collections/config/useAsTitle.ts @@ -1,7 +1,7 @@ import type { CollectionConfig } from '../../index.js' import { InvalidConfiguration } from '../../errors/InvalidConfiguration.js' -import { fieldAffectsData, fieldIsVirtual } from '../../fields/config/types.js' +import { fieldAffectsData } from '../../fields/config/types.js' import flattenFields from '../../utilities/flattenTopLevelFields.js' /** diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index 2d6346a301..8bcf6b76fc 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -58,6 +58,7 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial // add default user collection if none provided if (!sanitizedConfig?.admin?.user) { const firstCollectionWithAuth = sanitizedConfig.collections.find(({ auth }) => Boolean(auth)) + if (firstCollectionWithAuth) { sanitizedConfig.admin.user = firstCollectionWithAuth.slug } else { @@ -69,6 +70,7 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial const userCollection = sanitizedConfig.collections.find( ({ slug }) => slug === sanitizedConfig.admin.user, ) + if (!userCollection || !userCollection.auth) { throw new InvalidConfiguration( `${sanitizedConfig.admin.user} is not a valid admin user collection`, diff --git a/packages/payload/src/collections/config/reservedFieldNames.spec.ts b/packages/payload/src/fields/config/reservedFieldNames.spec.ts similarity index 97% rename from packages/payload/src/collections/config/reservedFieldNames.spec.ts rename to packages/payload/src/fields/config/reservedFieldNames.spec.ts index 5254b9d256..cfaaeb1f31 100644 --- a/packages/payload/src/collections/config/reservedFieldNames.spec.ts +++ b/packages/payload/src/fields/config/reservedFieldNames.spec.ts @@ -2,7 +2,7 @@ import type { Config } from '../../config/types.js' import type { CollectionConfig, Field } from '../../index.js' import { ReservedFieldName } from '../../errors/index.js' -import { sanitizeCollection } from './sanitize.js' +import { sanitizeCollection } from '../../collections/config/sanitize.js' describe('reservedFieldNames - collections -', () => { const config = { @@ -25,6 +25,7 @@ describe('reservedFieldNames - collections -', () => { label: 'some-collection', }, ] + await expect(async () => { await sanitizeCollection( // @ts-expect-error @@ -53,6 +54,7 @@ describe('reservedFieldNames - collections -', () => { label: 'some-collection', }, ] + await expect(async () => { await sanitizeCollection( // @ts-expect-error @@ -93,6 +95,7 @@ describe('reservedFieldNames - collections -', () => { label: 'some-collection', }, ] + await expect(async () => { await sanitizeCollection( // @ts-expect-error @@ -121,6 +124,7 @@ describe('reservedFieldNames - collections -', () => { label: 'some-collection', }, ] + await expect(async () => { await sanitizeCollection( // @ts-expect-error @@ -149,6 +153,7 @@ describe('reservedFieldNames - collections -', () => { label: 'some-collection', }, ] + await expect(async () => { await sanitizeCollection( // @ts-expect-error diff --git a/packages/payload/src/fields/config/reservedFieldNames.ts b/packages/payload/src/fields/config/reservedFieldNames.ts new file mode 100644 index 0000000000..394ac10bba --- /dev/null +++ b/packages/payload/src/fields/config/reservedFieldNames.ts @@ -0,0 +1,48 @@ +/** + * Reserved field names for collections with auth config enabled + */ +export const reservedBaseAuthFieldNames = [ + /* 'email', + 'resetPasswordToken', + 'resetPasswordExpiration', */ + 'salt', + 'hash', +] + +/** + * Reserved field names for auth collections with verify: true + */ +export const reservedVerifyFieldNames = [ + /* '_verified', '_verificationToken' */ +] + +/** + * Reserved field names for auth collections with useApiKey: true + */ +export const reservedAPIKeyFieldNames = [ + /* 'enableAPIKey', 'apiKeyIndex', 'apiKey' */ +] + +/** + * Reserved field names for collections with upload config enabled + */ +export const reservedBaseUploadFieldNames = [ + 'file', + /* 'mimeType', + 'thumbnailURL', + 'width', + 'height', + 'filesize', + 'filename', + 'url', + 'focalX', + 'focalY', + 'sizes', */ +] + +/** + * Reserved field names for collections with versions enabled + */ +export const reservedVersionsFieldNames = [ + /* '__v', '_status' */ +] diff --git a/packages/payload/src/fields/config/sanitize.spec.ts b/packages/payload/src/fields/config/sanitize.spec.ts index 3da7508577..3ebad93b3b 100644 --- a/packages/payload/src/fields/config/sanitize.spec.ts +++ b/packages/payload/src/fields/config/sanitize.spec.ts @@ -11,9 +11,12 @@ import type { import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType } from '../../errors/index.js' import { sanitizeFields } from './sanitize.js' +import { CollectionConfig } from '../../index.js' describe('sanitizeFields', () => { const config = {} as Config + const collectionConfig = {} as CollectionConfig + it('should throw on missing type field', async () => { const fields: Field[] = [ // @ts-expect-error @@ -22,14 +25,17 @@ describe('sanitizeFields', () => { label: 'some-collection', }, ] + await expect(async () => { await sanitizeFields({ config, + collectionConfig, fields, validRelationships: [], }) }).rejects.toThrow(MissingFieldType) }) + it('should throw on invalid field name', async () => { const fields: Field[] = [ { @@ -38,9 +44,11 @@ describe('sanitizeFields', () => { label: 'some.collection', }, ] + await expect(async () => { await sanitizeFields({ config, + collectionConfig, fields, validRelationships: [], }) @@ -55,17 +63,21 @@ describe('sanitizeFields', () => { type: 'text', }, ] + const sanitizedField = ( await sanitizeFields({ config, + collectionConfig, fields, validRelationships: [], }) )[0] as TextField + expect(sanitizedField.name).toStrictEqual('someField') expect(sanitizedField.label).toStrictEqual('Some Field') expect(sanitizedField.type).toStrictEqual('text') }) + it('should allow auto-label override', async () => { const fields: Field[] = [ { @@ -74,13 +86,16 @@ describe('sanitizeFields', () => { label: 'Do not label', }, ] + const sanitizedField = ( await sanitizeFields({ config, + collectionConfig, fields, validRelationships: [], }) )[0] as TextField + expect(sanitizedField.name).toStrictEqual('someField') expect(sanitizedField.label).toStrictEqual('Do not label') expect(sanitizedField.type).toStrictEqual('text') @@ -95,13 +110,16 @@ describe('sanitizeFields', () => { label: false, }, ] + const sanitizedField = ( await sanitizeFields({ config, + collectionConfig, fields, validRelationships: [], }) )[0] as TextField + expect(sanitizedField.name).toStrictEqual('someField') expect(sanitizedField.label).toStrictEqual(false) expect(sanitizedField.type).toStrictEqual('text') @@ -119,18 +137,22 @@ describe('sanitizeFields', () => { ], label: false, } + const sanitizedField = ( await sanitizeFields({ config, + collectionConfig, fields: [arrayField], validRelationships: [], }) )[0] as ArrayField + expect(sanitizedField.name).toStrictEqual('items') expect(sanitizedField.label).toStrictEqual(false) expect(sanitizedField.type).toStrictEqual('array') expect(sanitizedField.labels).toBeUndefined() }) + it('should allow label opt-out for blocks', async () => { const fields: Field[] = [ { @@ -150,13 +172,16 @@ describe('sanitizeFields', () => { label: false, }, ] + const sanitizedField = ( await sanitizeFields({ config, + collectionConfig, fields, validRelationships: [], }) )[0] as BlocksField + expect(sanitizedField.name).toStrictEqual('noLabelBlock') expect(sanitizedField.label).toStrictEqual(false) expect(sanitizedField.type).toStrictEqual('blocks') @@ -177,13 +202,16 @@ describe('sanitizeFields', () => { ], }, ] + const sanitizedField = ( await sanitizeFields({ config, + collectionConfig, fields, validRelationships: [], }) )[0] as ArrayField + expect(sanitizedField.name).toStrictEqual('items') expect(sanitizedField.label).toStrictEqual('Items') expect(sanitizedField.type).toStrictEqual('array') @@ -203,13 +231,16 @@ describe('sanitizeFields', () => { ], }, ] + const sanitizedField = ( await sanitizeFields({ config, + collectionConfig, fields, validRelationships: [], }) )[0] as BlocksField + expect(sanitizedField.name).toStrictEqual('specialBlock') expect(sanitizedField.label).toStrictEqual('Special Block') expect(sanitizedField.type).toStrictEqual('blocks') @@ -217,6 +248,7 @@ describe('sanitizeFields', () => { plural: 'Special Blocks', singular: 'Special Block', }) + expect((sanitizedField.blocks[0].fields[0] as NumberField).label).toStrictEqual('Test Number') }) }) @@ -232,8 +264,9 @@ describe('sanitizeFields', () => { relationTo: 'some-collection', }, ] + await expect(async () => { - await sanitizeFields({ config, fields, validRelationships }) + await sanitizeFields({ config, collectionConfig, fields, validRelationships }) }).not.toThrow() }) @@ -247,8 +280,9 @@ describe('sanitizeFields', () => { relationTo: ['some-collection', 'another-collection'], }, ] + await expect(async () => { - await sanitizeFields({ config, fields, validRelationships }) + await sanitizeFields({ config, collectionConfig, fields, validRelationships }) }).not.toThrow() }) @@ -265,6 +299,7 @@ describe('sanitizeFields', () => { }, ], } + const fields: Field[] = [ { name: 'layout', @@ -273,8 +308,9 @@ describe('sanitizeFields', () => { label: 'Layout Blocks', }, ] + await expect(async () => { - await sanitizeFields({ config, fields, validRelationships }) + await sanitizeFields({ config, collectionConfig, fields, validRelationships }) }).not.toThrow() }) @@ -288,8 +324,9 @@ describe('sanitizeFields', () => { relationTo: 'not-valid', }, ] + await expect(async () => { - await sanitizeFields({ config, fields, validRelationships }) + await sanitizeFields({ config, collectionConfig, fields, validRelationships }) }).rejects.toThrow(InvalidFieldRelationship) }) @@ -303,8 +340,9 @@ describe('sanitizeFields', () => { relationTo: ['some-collection', 'not-valid'], }, ] + await expect(async () => { - await sanitizeFields({ config, fields, validRelationships }) + await sanitizeFields({ config, collectionConfig, fields, validRelationships }) }).rejects.toThrow(InvalidFieldRelationship) }) @@ -321,6 +359,7 @@ describe('sanitizeFields', () => { }, ], } + const fields: Field[] = [ { name: 'layout', @@ -329,8 +368,9 @@ describe('sanitizeFields', () => { label: 'Layout Blocks', }, ] + await expect(async () => { - await sanitizeFields({ config, fields, validRelationships }) + await sanitizeFields({ config, collectionConfig, fields, validRelationships }) }).rejects.toThrow(InvalidFieldRelationship) }) @@ -346,19 +386,23 @@ describe('sanitizeFields', () => { const sanitizedField = ( await sanitizeFields({ config, + collectionConfig, fields, validRelationships: [], }) )[0] as CheckboxField + expect(sanitizedField.defaultValue).toStrictEqual(false) }) it('should return empty field array if no fields', async () => { const sanitizedFields = await sanitizeFields({ config, + collectionConfig, fields: [], validRelationships: [], }) + expect(sanitizedFields).toStrictEqual([]) }) }) @@ -385,9 +429,11 @@ describe('sanitizeFields', () => { label: false, }, ] + const sanitizedField = ( await sanitizeFields({ config, + collectionConfig, fields, validRelationships: [], }) @@ -416,9 +462,11 @@ describe('sanitizeFields', () => { label: false, }, ] + const sanitizedField = ( await sanitizeFields({ config, + collectionConfig, fields, validRelationships: [], }) diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index 9495f3773c..34deb9718e 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -17,6 +17,7 @@ import { MissingEditorProp, MissingFieldType, } from '../../errors/index.js' +import { ReservedFieldName } from '../../errors/ReservedFieldName.js' import { formatLabels, toWords } from '../../utilities/formatLabels.js' import { baseBlockFields } from '../baseFields/baseBlockFields.js' import { baseIDField } from '../baseFields/baseIDField.js' @@ -24,14 +25,24 @@ import { baseTimezoneField } from '../baseFields/timezone/baseField.js' import { defaultTimezones } from '../baseFields/timezone/defaultTimezones.js' import { setDefaultBeforeDuplicate } from '../setDefaultBeforeDuplicate.js' import { validations } from '../validations.js' +import { + reservedAPIKeyFieldNames, + reservedBaseAuthFieldNames, + reservedBaseUploadFieldNames, + reservedVerifyFieldNames, +} from './reservedFieldNames.js' import { sanitizeJoinField } from './sanitizeJoinField.js' -import { fieldAffectsData, fieldIsLocalized, tabHasName } from './types.js' +import { fieldAffectsData as _fieldAffectsData, fieldIsLocalized, tabHasName } from './types.js' type Args = { collectionConfig?: CollectionConfig config: Config existingFieldNames?: Set fields: Field[] + /** + * Used to prevent unnecessary sanitization of fields that are not top-level. + */ + isTopLevelField?: boolean joinPath?: string /** * When not passed in, assume that join are not supported (globals, arrays, blocks) @@ -39,7 +50,6 @@ type Args = { joins?: SanitizedJoins parentIsLocalized: boolean polymorphicJoins?: SanitizedJoin[] - /** * If true, a richText field will require an editor property to be set, as the sanitizeFields function will not add it from the payload config if not present. * @@ -59,9 +69,11 @@ type Args = { } export const sanitizeFields = async ({ + collectionConfig, config, existingFieldNames = new Set(), fields, + isTopLevelField = true, joinPath = '', joins, parentIsLocalized, @@ -80,6 +92,7 @@ export const sanitizeFields = async ({ if ('_sanitized' in field && field._sanitized === true) { continue } + if ('_sanitized' in field) { field._sanitized = true } @@ -88,8 +101,39 @@ export const sanitizeFields = async ({ throw new MissingFieldType(field) } + const fieldAffectsData = _fieldAffectsData(field) + + if (isTopLevelField && fieldAffectsData && field.name) { + if (collectionConfig && collectionConfig.upload) { + if (reservedBaseUploadFieldNames.includes(field.name)) { + throw new ReservedFieldName(field, field.name) + } + } + + if ( + collectionConfig && + collectionConfig.auth && + typeof collectionConfig.auth === 'object' && + !collectionConfig.auth.disableLocalStrategy + ) { + if (reservedBaseAuthFieldNames.includes(field.name)) { + throw new ReservedFieldName(field, field.name) + } + + if (collectionConfig.auth.verify) { + if (reservedAPIKeyFieldNames.includes(field.name)) { + throw new ReservedFieldName(field, field.name) + } + + if (reservedVerifyFieldNames.includes(field.name)) { + throw new ReservedFieldName(field, field.name) + } + } + } + } + // assert that field names do not contain forbidden characters - if (fieldAffectsData(field) && field.name.includes('.')) { + if (fieldAffectsData && field.name.includes('.')) { throw new InvalidFieldName(field, field.name) } @@ -122,6 +166,7 @@ export const sanitizeFields = async ({ const relationships = Array.isArray(field.relationTo) ? field.relationTo : [field.relationTo] + relationships.forEach((relationship: string) => { if (!validRelationships.includes(relationship)) { throw new InvalidFieldRelationship(field, relationship) @@ -135,6 +180,7 @@ export const sanitizeFields = async ({ ) field.minRows = field.min } + if (field.max && !field.maxRows) { console.warn( `(payload): The "max" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "maxRows" instead.`, @@ -160,7 +206,7 @@ export const sanitizeFields = async ({ field.labels = field.labels || formatLabels(field.name) } - if (fieldAffectsData(field)) { + if (fieldAffectsData) { if (existingFieldNames.has(field.name)) { throw new DuplicateFieldName(field.name) } else if (!['blockName', 'id'].includes(field.name)) { @@ -254,9 +300,11 @@ export const sanitizeFields = async ({ block.fields = block.fields.concat(baseBlockFields) block.labels = !block.labels ? formatLabels(block.slug) : block.labels block.fields = await sanitizeFields({ + collectionConfig, config, existingFieldNames: new Set(), fields: block.fields, + isTopLevelField: false, parentIsLocalized: parentIsLocalized || field.localized, requireFieldLevelRichTextEditor, richTextSanitizationPromises, @@ -267,12 +315,12 @@ export const sanitizeFields = async ({ if ('fields' in field && field.fields) { field.fields = await sanitizeFields({ + collectionConfig, config, - existingFieldNames: fieldAffectsData(field) ? new Set() : existingFieldNames, + existingFieldNames: fieldAffectsData ? new Set() : existingFieldNames, fields: field.fields, - joinPath: fieldAffectsData(field) - ? `${joinPath ? joinPath + '.' : ''}${field.name}` - : joinPath, + isTopLevelField: isTopLevelField && !fieldAffectsData, + joinPath: fieldAffectsData ? `${joinPath ? joinPath + '.' : ''}${field.name}` : joinPath, joins, parentIsLocalized: parentIsLocalized || fieldIsLocalized(field), polymorphicJoins, @@ -285,7 +333,10 @@ export const sanitizeFields = async ({ if (field.type === 'tabs') { for (let j = 0; j < field.tabs.length; j++) { const tab = field.tabs[j] - if (tabHasName(tab) && typeof tab.label === 'undefined') { + + const isNamedTab = tabHasName(tab) + + if (isNamedTab && typeof tab.label === 'undefined') { tab.label = toWords(tab.name) } @@ -296,21 +347,24 @@ export const sanitizeFields = async ({ !tab.id ) { // Always attach a UUID to tabs with a condition so there's no conflicts even if there are duplicate nested names - tab.id = tabHasName(tab) ? `${tab.name}_${uuid()}` : uuid() + tab.id = isNamedTab ? `${tab.name}_${uuid()}` : uuid() } tab.fields = await sanitizeFields({ + collectionConfig, config, - existingFieldNames: tabHasName(tab) ? new Set() : existingFieldNames, + existingFieldNames: isNamedTab ? new Set() : existingFieldNames, fields: tab.fields, - joinPath: tabHasName(tab) ? `${joinPath ? joinPath + '.' : ''}${tab.name}` : joinPath, + isTopLevelField: isTopLevelField && !isNamedTab, + joinPath: isNamedTab ? `${joinPath ? joinPath + '.' : ''}${tab.name}` : joinPath, joins, - parentIsLocalized: parentIsLocalized || (tabHasName(tab) && tab.localized), + parentIsLocalized: parentIsLocalized || (isNamedTab && tab.localized), polymorphicJoins, requireFieldLevelRichTextEditor, richTextSanitizationPromises, validRelationships, }) + field.tabs[j] = tab } } From d63c8baea50189c8b47b2362c41b74dfe3bb7f92 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Wed, 14 May 2025 14:58:25 -0700 Subject: [PATCH 35/46] fix(plugin-cloud): ensure scheduled publishing works if no custom jobs are defined (#12410) Previously, plugin-cloud would only set up job auto-running if a job configuration was present in the custom config at initialization time. However, some jobs - such as the scheduled publish job which is added during sanitization - are added after plugin-cloud has initialized. This means relying solely on the initial state of the job config is insufficient for determining whether to enable auto-running. This PR removes that check and ensures auto-running is always initialized, allowing later-added jobs to run as expected. ## Weakening type This PR also weakens to `config.jobs.tasks` type and makes that property optional. It's totally permissible to only have workflows that define inline tasks, and to not have any static tasks defined in `config.jobs.tasks`. Thus it makes no sense to make that property required. --- packages/payload-cloud/src/plugin.ts | 27 +++++++++---------- .../queues/config/generateJobsJSONSchemas.ts | 4 +-- packages/payload/src/queues/config/index.ts | 1 - .../payload/src/queues/config/types/index.ts | 2 +- .../runJobs/runJob/getRunTaskFunction.ts | 4 ++- .../payload/src/queues/restEndpointRun.ts | 7 +++-- .../src/queues/utilities/getJobTaskStatus.ts | 2 -- 7 files changed, 22 insertions(+), 25 deletions(-) diff --git a/packages/payload-cloud/src/plugin.ts b/packages/payload-cloud/src/plugin.ts index ace1336907..c72236ddaa 100644 --- a/packages/payload-cloud/src/plugin.ts +++ b/packages/payload-cloud/src/plugin.ts @@ -16,6 +16,14 @@ export const generateRandomString = (): string => { return Array.from({ length: 24 }, () => chars[Math.floor(Math.random() * chars.length)]).join('') } +const DEFAULT_CRON = '* * * * *' +const DEFAULT_LIMIT = 10 +const DEFAULT_CRON_JOB = { + cron: DEFAULT_CRON, + limit: DEFAULT_LIMIT, + queue: 'default', +} + export const payloadCloudPlugin = (pluginOptions?: PluginOptions) => async (incomingConfig: Config): Promise => { @@ -100,15 +108,6 @@ export const payloadCloudPlugin = } // We make sure to only run cronjobs on one instance using a instance identifier stored in a global. - - const DEFAULT_CRON = '* * * * *' - const DEFAULT_LIMIT = 10 - const DEFAULT_CRON_JOB = { - cron: DEFAULT_CRON, - limit: DEFAULT_LIMIT, - queue: 'default', - } - config.globals = [ ...(config.globals || []), { @@ -126,13 +125,13 @@ export const payloadCloudPlugin = }, ] - if (pluginOptions?.enableAutoRun === false || !config.jobs) { + if (pluginOptions?.enableAutoRun === false) { return config } - const oldAutoRunCopy = config.jobs.autoRun ?? [] + const oldAutoRunCopy = config.jobs?.autoRun ?? [] - const hasExistingAutorun = Boolean(config.jobs.autoRun) + const hasExistingAutorun = Boolean(config.jobs?.autoRun) const newShouldAutoRun = async (payload: Payload) => { if (process.env.PAYLOAD_CLOUD_JOBS_INSTANCE) { @@ -150,8 +149,8 @@ export const payloadCloudPlugin = return false } - if (!config.jobs.shouldAutoRun) { - config.jobs.shouldAutoRun = newShouldAutoRun + if (!config.jobs?.shouldAutoRun) { + ;(config.jobs ??= {}).shouldAutoRun = newShouldAutoRun } const newAutoRun = async (payload: Payload) => { diff --git a/packages/payload/src/queues/config/generateJobsJSONSchemas.ts b/packages/payload/src/queues/config/generateJobsJSONSchemas.ts index b61ab05401..bcee9c5b6d 100644 --- a/packages/payload/src/queues/config/generateJobsJSONSchemas.ts +++ b/packages/payload/src/queues/config/generateJobsJSONSchemas.ts @@ -88,7 +88,7 @@ export function generateJobsJSONSchemas( additionalProperties: false, properties: { ...Object.fromEntries( - jobsConfig.tasks.map((task) => { + (jobsConfig.tasks ?? []).map((task) => { const normalizedTaskSlug = task.slug[0].toUpperCase() + task.slug.slice(1) const toReturn: JSONSchema4 = { @@ -110,7 +110,7 @@ export function generateJobsJSONSchemas( required: ['input', 'output'], }, }, - required: [...jobsConfig.tasks.map((task) => task.slug), 'inline'], + required: [...(jobsConfig.tasks ?? []).map((task) => task.slug), 'inline'], } } diff --git a/packages/payload/src/queues/config/index.ts b/packages/payload/src/queues/config/index.ts index f4cb07924d..8e04064fac 100644 --- a/packages/payload/src/queues/config/index.ts +++ b/packages/payload/src/queues/config/index.ts @@ -240,7 +240,6 @@ export const getDefaultJobsCollection: (config: Config) => CollectionConfig | nu export function jobAfterRead({ config, doc }: { config: SanitizedConfig; doc: BaseJob }): BaseJob { doc.taskStatus = getJobTaskStatus({ jobLog: doc.log || [], - tasksConfig: config.jobs.tasks, }) return doc } diff --git a/packages/payload/src/queues/config/types/index.ts b/packages/payload/src/queues/config/types/index.ts index 916724fd8f..8082be5266 100644 --- a/packages/payload/src/queues/config/types/index.ts +++ b/packages/payload/src/queues/config/types/index.ts @@ -116,7 +116,7 @@ export type JobsConfig = { /** * Define all possible tasks here */ - tasks: TaskConfig[] + tasks?: TaskConfig[] /** * Define all the workflows here. Workflows orchestrate the flow of multiple tasks. */ diff --git a/packages/payload/src/queues/operations/runJobs/runJob/getRunTaskFunction.ts b/packages/payload/src/queues/operations/runJobs/runJob/getRunTaskFunction.ts index d5ff4d7c70..c0f22990b7 100644 --- a/packages/payload/src/queues/operations/runJobs/runJob/getRunTaskFunction.ts +++ b/packages/payload/src/queues/operations/runJobs/runJob/getRunTaskFunction.ts @@ -193,7 +193,9 @@ export const getRunTaskFunction = ( let taskConfig: TaskConfig if (!isInline) { - taskConfig = req.payload.config.jobs.tasks.find((t) => t.slug === taskSlug) + taskConfig = + req.payload.config.jobs.tasks?.length && + req.payload.config.jobs.tasks.find((t) => t.slug === taskSlug) if (!taskConfig) { throw new Error(`Task ${taskSlug} not found in workflow ${job.workflowSlug}`) diff --git a/packages/payload/src/queues/restEndpointRun.ts b/packages/payload/src/queues/restEndpointRun.ts index edadcfc46e..888e161c34 100644 --- a/packages/payload/src/queues/restEndpointRun.ts +++ b/packages/payload/src/queues/restEndpointRun.ts @@ -1,4 +1,3 @@ -// @ts-strict-ignore import type { Endpoint, SanitizedConfig } from '../config/types.js' import { runJobs, type RunJobsArgs } from './operations/runJobs/index.js' @@ -8,10 +7,10 @@ const configHasJobs = (config: SanitizedConfig): boolean => { return false } - if (config.jobs.tasks.length > 0) { + if (config.jobs.tasks?.length > 0) { return true } - if (Array.isArray(config.jobs.workflows) && config.jobs.workflows.length > 0) { + if (config.jobs.workflows?.length > 0) { return true } @@ -61,7 +60,7 @@ export const runJobsEndpoint: Endpoint = { let remainingJobsFromQueried = 0 try { const result = await runJobs(runJobsArgs) - noJobsRemaining = result.noJobsRemaining + noJobsRemaining = !!result.noJobsRemaining remainingJobsFromQueried = result.remainingJobsFromQueried } catch (err) { req.payload.logger.error({ diff --git a/packages/payload/src/queues/utilities/getJobTaskStatus.ts b/packages/payload/src/queues/utilities/getJobTaskStatus.ts index d911c42e53..44c49e2b81 100644 --- a/packages/payload/src/queues/utilities/getJobTaskStatus.ts +++ b/packages/payload/src/queues/utilities/getJobTaskStatus.ts @@ -1,10 +1,8 @@ // @ts-strict-ignore -import type { TaskConfig, TaskType } from '../config/types/taskTypes.js' import type { BaseJob, JobTaskStatus } from '../config/types/workflowTypes.js' type Args = { jobLog: BaseJob['log'] - tasksConfig: TaskConfig[] } export const getJobTaskStatus = ({ jobLog }: Args): JobTaskStatus => { From e258cd73efc9dc2d91249944950c6186731e7f66 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 14 May 2025 16:45:34 -0700 Subject: [PATCH 36/46] feat: allow group fields to have an optional name (#12318) Adds the ability to completely omit `name` from group fields now so that they're entirely presentational. New config: ```ts import type { CollectionConfig } from 'payload' export const ExampleCollection: CollectionConfig = { slug: 'posts', fields: [ { label: 'Page header', type: 'group', // required fields: [ { name: 'title', type: 'text', required: true, }, ], }, ], } ``` will create image but the data response will still be ``` { "createdAt": "2025-05-05T13:42:20.326Z", "updatedAt": "2025-05-05T13:42:20.326Z", "title": "example post", "id": "6818c03ce92b7f92be1540f0" } ``` Checklist: - [x] Added int tests - [x] Modify mongo, drizzle and graphql packages - [x] Add type tests - [x] Add e2e tests --- docs/fields/group.mdx | 41 +++++- packages/db-mongodb/src/models/buildSchema.ts | 77 +++++++---- .../src/queries/sanitizeQueryValue.ts | 1 + .../utilities/buildProjectionFromSelect.ts | 1 - .../src/schema/buildMutationInputType.ts | 48 ++++--- .../graphql/src/schema/fieldToSchemaMap.ts | 78 ++++++----- packages/payload/src/auth/getFieldsToSign.ts | 46 ++++--- packages/payload/src/fields/config/types.ts | 52 ++++++-- .../src/fields/hooks/afterChange/promise.ts | 60 ++++++--- .../src/fields/hooks/afterRead/promise.ts | 107 +++++++++------ .../src/fields/hooks/beforeChange/promise.ts | 58 ++++++--- .../fields/hooks/beforeDuplicate/promise.ts | 3 +- .../fields/hooks/beforeValidate/promise.ts | 31 +++-- packages/payload/src/index.ts | 4 + .../src/utilities/configToJSONSchema.ts | 60 ++++++--- .../src/utilities/fieldSchemaToJSON.ts | 17 ++- .../payload/src/utilities/flattenAllFields.ts | 10 +- .../payload/src/utilities/traverseFields.ts | 50 ++++--- .../elements/WhereBuilder/reduceFields.tsx | 43 +++++- packages/ui/src/fields/Group/index.tsx | 34 +++-- packages/ui/src/fields/Join/index.tsx | 4 +- .../addFieldStatePromise.ts | 2 +- .../calculateDefaultValues/promise.ts | 50 ++++--- .../traverseFields.ts | 35 ++++- .../buildFieldSchemaMap/traverseFields.ts | 27 +++- .../ui/src/utilities/copyDataFromLocale.ts | 8 +- test/fields/collections/Group/e2e.spec.ts | 123 ++++++++++++++++++ test/fields/collections/Group/index.ts | 48 +++++++ test/fields/collections/Group/shared.ts | 6 +- test/fields/int.spec.ts | 53 +++++++- test/fields/payload-types.ts | 10 ++ test/fields/seed.ts | 4 +- test/types/config.ts | 20 +++ test/types/payload-types.ts | 10 ++ test/types/types.spec.ts | 13 ++ .../src/generateTranslations/utils/index.ts | 2 +- tsconfig.base.json | 2 +- 37 files changed, 955 insertions(+), 283 deletions(-) create mode 100644 test/fields/collections/Group/e2e.spec.ts diff --git a/docs/fields/group.mdx b/docs/fields/group.mdx index 9062dc6f5d..7865e0fc75 100644 --- a/docs/fields/group.mdx +++ b/docs/fields/group.mdx @@ -35,9 +35,9 @@ export const MyGroupField: Field = { | Option | Description | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`name`** | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | | **`fields`** \* | Array of field types to nest within this Group. | -| **`label`** | Used as a heading in the Admin Panel and to name the generated GraphQL type. | +| **`label`** | Used as a heading in the Admin Panel and to name the generated GraphQL type. Required when name is undefined, defaults to name converted to words. | | **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. | | **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | @@ -86,7 +86,7 @@ export const ExampleCollection: CollectionConfig = { slug: 'example-collection', fields: [ { - name: 'pageMeta', // required + name: 'pageMeta', type: 'group', // required interfaceName: 'Meta', // optional fields: [ @@ -110,3 +110,38 @@ export const ExampleCollection: CollectionConfig = { ], } ``` + +## Presentational group fields + +You can also use the Group field to create a presentational group of fields. This is useful when you want to group fields together visually without affecting the data structure. +The label will be required when a `name` is not provided. + +```ts +import type { CollectionConfig } from 'payload' + +export const ExampleCollection: CollectionConfig = { + slug: 'example-collection', + fields: [ + { + label: 'Page meta', + type: 'group', // required + fields: [ + { + name: 'title', + type: 'text', + required: true, + minLength: 20, + maxLength: 100, + }, + { + name: 'description', + type: 'textarea', + required: true, + minLength: 40, + maxLength: 160, + }, + ], + }, + ], +} +``` diff --git a/packages/db-mongodb/src/models/buildSchema.ts b/packages/db-mongodb/src/models/buildSchema.ts index 4cd833ccec..56e2cf1130 100644 --- a/packages/db-mongodb/src/models/buildSchema.ts +++ b/packages/db-mongodb/src/models/buildSchema.ts @@ -372,36 +372,61 @@ const group: FieldSchemaGenerator = ( buildSchemaOptions, parentIsLocalized, ): void => { - const formattedBaseSchema = formatBaseSchema({ buildSchemaOptions, field, parentIsLocalized }) + if (fieldAffectsData(field)) { + const formattedBaseSchema = formatBaseSchema({ buildSchemaOptions, field, parentIsLocalized }) - // carry indexSortableFields through to versions if drafts enabled - const indexSortableFields = - buildSchemaOptions.indexSortableFields && - field.name === 'version' && - buildSchemaOptions.draftsEnabled + // carry indexSortableFields through to versions if drafts enabled + const indexSortableFields = + buildSchemaOptions.indexSortableFields && + field.name === 'version' && + buildSchemaOptions.draftsEnabled - const baseSchema: SchemaTypeOptions = { - ...formattedBaseSchema, - type: buildSchema({ - buildSchemaOptions: { - disableUnique: buildSchemaOptions.disableUnique, - draftsEnabled: buildSchemaOptions.draftsEnabled, - indexSortableFields, - options: { - _id: false, - id: false, - minimize: false, + const baseSchema: SchemaTypeOptions = { + ...formattedBaseSchema, + type: buildSchema({ + buildSchemaOptions: { + disableUnique: buildSchemaOptions.disableUnique, + draftsEnabled: buildSchemaOptions.draftsEnabled, + indexSortableFields, + options: { + _id: false, + id: false, + minimize: false, + }, }, - }, - configFields: field.fields, - parentIsLocalized: parentIsLocalized || field.localized, - payload, - }), - } + configFields: field.fields, + parentIsLocalized: parentIsLocalized || field.localized, + payload, + }), + } - schema.add({ - [field.name]: localizeSchema(field, baseSchema, payload.config.localization, parentIsLocalized), - }) + schema.add({ + [field.name]: localizeSchema( + field, + baseSchema, + payload.config.localization, + parentIsLocalized, + ), + }) + } else { + field.fields.forEach((subField) => { + if (fieldIsVirtual(subField)) { + return + } + + const addFieldSchema = getSchemaGenerator(subField.type) + + if (addFieldSchema) { + addFieldSchema( + subField, + schema, + payload, + buildSchemaOptions, + (parentIsLocalized || field.localized) ?? false, + ) + } + }) + } } const json: FieldSchemaGenerator = ( diff --git a/packages/db-mongodb/src/queries/sanitizeQueryValue.ts b/packages/db-mongodb/src/queries/sanitizeQueryValue.ts index c17c239b73..3de2aa6cba 100644 --- a/packages/db-mongodb/src/queries/sanitizeQueryValue.ts +++ b/packages/db-mongodb/src/queries/sanitizeQueryValue.ts @@ -105,6 +105,7 @@ export const sanitizeQueryValue = ({ | undefined => { let formattedValue = val let formattedOperator = operator + if (['array', 'blocks', 'group', 'tab'].includes(field.type) && path.includes('.')) { const segments = path.split('.') segments.shift() diff --git a/packages/db-mongodb/src/utilities/buildProjectionFromSelect.ts b/packages/db-mongodb/src/utilities/buildProjectionFromSelect.ts index 98a659643c..bd80cc99d7 100644 --- a/packages/db-mongodb/src/utilities/buildProjectionFromSelect.ts +++ b/packages/db-mongodb/src/utilities/buildProjectionFromSelect.ts @@ -128,7 +128,6 @@ const traverseFields = ({ break } - case 'blocks': { const blocksSelect = select[field.name] as SelectType diff --git a/packages/graphql/src/schema/buildMutationInputType.ts b/packages/graphql/src/schema/buildMutationInputType.ts index 44e627b8ab..448ac80570 100644 --- a/packages/graphql/src/schema/buildMutationInputType.ts +++ b/packages/graphql/src/schema/buildMutationInputType.ts @@ -145,27 +145,37 @@ export function buildMutationInputType({ }, }), group: (inputObjectTypeConfig: InputObjectTypeConfig, field: GroupField) => { - const requiresAtLeastOneField = groupOrTabHasRequiredSubfield(field) - const fullName = combineParentName(parentName, toWords(field.name, true)) - let type: GraphQLType = buildMutationInputType({ - name: fullName, - config, - fields: field.fields, - graphqlResult, - parentIsLocalized: parentIsLocalized || field.localized, - parentName: fullName, - }) + if (fieldAffectsData(field)) { + const requiresAtLeastOneField = groupOrTabHasRequiredSubfield(field) + const fullName = combineParentName(parentName, toWords(field.name, true)) + let type: GraphQLType = buildMutationInputType({ + name: fullName, + config, + fields: field.fields, + graphqlResult, + parentIsLocalized: parentIsLocalized || field.localized, + parentName: fullName, + }) - if (!type) { - return inputObjectTypeConfig - } + if (!type) { + return inputObjectTypeConfig + } - if (requiresAtLeastOneField) { - type = new GraphQLNonNull(type) - } - return { - ...inputObjectTypeConfig, - [formatName(field.name)]: { type }, + if (requiresAtLeastOneField) { + type = new GraphQLNonNull(type) + } + return { + ...inputObjectTypeConfig, + [formatName(field.name)]: { type }, + } + } else { + return field.fields.reduce((acc, subField: CollapsibleField) => { + const addSubField = fieldToSchemaMap[subField.type] + if (addSubField) { + return addSubField(acc, subField) + } + return acc + }, inputObjectTypeConfig) } }, json: (inputObjectTypeConfig: InputObjectTypeConfig, field: JSONField) => ({ diff --git a/packages/graphql/src/schema/fieldToSchemaMap.ts b/packages/graphql/src/schema/fieldToSchemaMap.ts index fc5750add9..571bc61585 100644 --- a/packages/graphql/src/schema/fieldToSchemaMap.ts +++ b/packages/graphql/src/schema/fieldToSchemaMap.ts @@ -41,7 +41,7 @@ import { } from 'graphql' import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars' import { combineQueries, createDataloaderCacheKey, MissingEditorProp, toWords } from 'payload' -import { tabHasName } from 'payload/shared' +import { fieldAffectsData, tabHasName } from 'payload/shared' import type { Context } from '../resolvers/types.js' @@ -302,44 +302,64 @@ export const fieldToSchemaMap: FieldToSchemaMap = { field, forceNullable, graphqlResult, + newlyCreatedBlockType, objectTypeConfig, parentIsLocalized, parentName, }) => { - const interfaceName = - field?.interfaceName || combineParentName(parentName, toWords(field.name, true)) + if (fieldAffectsData(field)) { + const interfaceName = + field?.interfaceName || combineParentName(parentName, toWords(field.name, true)) - if (!graphqlResult.types.groupTypes[interfaceName]) { - const objectType = buildObjectType({ - name: interfaceName, - config, - fields: field.fields, - forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }), - graphqlResult, - parentIsLocalized: field.localized || parentIsLocalized, - parentName: interfaceName, - }) + if (!graphqlResult.types.groupTypes[interfaceName]) { + const objectType = buildObjectType({ + name: interfaceName, + config, + fields: field.fields, + forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }), + graphqlResult, + parentIsLocalized: field.localized || parentIsLocalized, + parentName: interfaceName, + }) - if (Object.keys(objectType.getFields()).length) { - graphqlResult.types.groupTypes[interfaceName] = objectType + if (Object.keys(objectType.getFields()).length) { + graphqlResult.types.groupTypes[interfaceName] = objectType + } } - } - if (!graphqlResult.types.groupTypes[interfaceName]) { - return objectTypeConfig - } + if (!graphqlResult.types.groupTypes[interfaceName]) { + return objectTypeConfig + } - return { - ...objectTypeConfig, - [formatName(field.name)]: { - type: graphqlResult.types.groupTypes[interfaceName], - resolve: (parent, args, context: Context) => { - return { - ...parent[field.name], - _id: parent._id ?? parent.id, - } + return { + ...objectTypeConfig, + [formatName(field.name)]: { + type: graphqlResult.types.groupTypes[interfaceName], + resolve: (parent, args, context: Context) => { + return { + ...parent[field.name], + _id: parent._id ?? parent.id, + } + }, }, - }, + } + } else { + return field.fields.reduce((objectTypeConfigWithCollapsibleFields, subField) => { + const addSubField: GenericFieldToSchemaMap = fieldToSchemaMap[subField.type] + if (addSubField) { + return addSubField({ + config, + field: subField, + forceNullable, + graphqlResult, + newlyCreatedBlockType, + objectTypeConfig: objectTypeConfigWithCollapsibleFields, + parentIsLocalized, + parentName, + }) + } + return objectTypeConfigWithCollapsibleFields + }, objectTypeConfig) } }, join: ({ collectionSlug, field, graphqlResult, objectTypeConfig, parentName }) => { diff --git a/packages/payload/src/auth/getFieldsToSign.ts b/packages/payload/src/auth/getFieldsToSign.ts index f6a2b774f9..c66bf40ca3 100644 --- a/packages/payload/src/auth/getFieldsToSign.ts +++ b/packages/payload/src/auth/getFieldsToSign.ts @@ -28,25 +28,35 @@ const traverseFields = ({ break } case 'group': { - let targetResult - if (typeof field.saveToJWT === 'string') { - targetResult = field.saveToJWT - result[field.saveToJWT] = data[field.name] - } else if (field.saveToJWT) { - targetResult = field.name - result[field.name] = data[field.name] + if (fieldAffectsData(field)) { + let targetResult + if (typeof field.saveToJWT === 'string') { + targetResult = field.saveToJWT + result[field.saveToJWT] = data[field.name] + } else if (field.saveToJWT) { + targetResult = field.name + result[field.name] = data[field.name] + } + const groupData: Record = data[field.name] as Record + const groupResult = (targetResult ? result[targetResult] : result) as Record< + string, + unknown + > + traverseFields({ + data: groupData, + fields: field.fields, + result: groupResult, + }) + break + } else { + traverseFields({ + data, + fields: field.fields, + result, + }) + + break } - const groupData: Record = data[field.name] as Record - const groupResult = (targetResult ? result[targetResult] : result) as Record< - string, - unknown - > - traverseFields({ - data: groupData, - fields: field.fields, - result: groupResult, - }) - break } case 'tab': { if (tabHasName(field)) { diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 159d9657b7..65fc86105c 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -719,7 +719,7 @@ export type DateFieldClient = { } & FieldBaseClient & Pick -export type GroupField = { +export type GroupBase = { admin?: { components?: { afterInput?: CustomComponent[] @@ -729,6 +729,11 @@ export type GroupField = { hideGutter?: boolean } & Admin fields: Field[] + type: 'group' + validate?: Validate +} & Omit + +export type NamedGroupField = { /** Customize generated GraphQL and Typescript schema names. * By default, it is bound to the collection. * @@ -736,15 +741,39 @@ export type GroupField = { * **Note**: Top level types can collide, ensure they are unique amongst collections, arrays, groups, blocks, tabs. */ interfaceName?: string - type: 'group' - validate?: Validate -} & Omit +} & GroupBase -export type GroupFieldClient = { - admin?: AdminClient & Pick +export type UnnamedGroupField = { + interfaceName?: never + /** + * Can be either: + * - A string, which will be used as the tab's label. + * - An object, where the key is the language code and the value is the label. + */ + label: + | { + [selectedLanguage: string]: string + } + | LabelFunction + | string + localized?: never +} & Omit + +export type GroupField = NamedGroupField | UnnamedGroupField + +export type NamedGroupFieldClient = { + admin?: AdminClient & Pick fields: ClientField[] } & Omit & - Pick + Pick + +export type UnnamedGroupFieldClient = { + admin?: AdminClient & Pick + fields: ClientField[] +} & Omit & + Pick + +export type GroupFieldClient = NamedGroupFieldClient | UnnamedGroupFieldClient export type RowField = { admin?: Omit @@ -1611,6 +1640,7 @@ export type FlattenedBlocksField = { export type FlattenedGroupField = { flattenedFields: FlattenedField[] + name: string } & GroupField export type FlattenedArrayField = { @@ -1728,9 +1758,9 @@ export type FieldAffectingData = | CodeField | DateField | EmailField - | GroupField | JoinField | JSONField + | NamedGroupField | NumberField | PointField | RadioField @@ -1749,9 +1779,9 @@ export type FieldAffectingDataClient = | CodeFieldClient | DateFieldClient | EmailFieldClient - | GroupFieldClient | JoinFieldClient | JSONFieldClient + | NamedGroupFieldClient | NumberFieldClient | PointFieldClient | RadioFieldClient @@ -1771,8 +1801,8 @@ export type NonPresentationalField = | CollapsibleField | DateField | EmailField - | GroupField | JSONField + | NamedGroupField | NumberField | PointField | RadioField @@ -1793,8 +1823,8 @@ export type NonPresentationalFieldClient = | CollapsibleFieldClient | DateFieldClient | EmailFieldClient - | GroupFieldClient | JSONFieldClient + | NamedGroupFieldClient | NumberFieldClient | PointFieldClient | RadioFieldClient diff --git a/packages/payload/src/fields/hooks/afterChange/promise.ts b/packages/payload/src/fields/hooks/afterChange/promise.ts index dc18f307d0..8d348fe209 100644 --- a/packages/payload/src/fields/hooks/afterChange/promise.ts +++ b/packages/payload/src/fields/hooks/afterChange/promise.ts @@ -212,25 +212,47 @@ export const promise = async ({ } case 'group': { - await traverseFields({ - blockData, - collection, - context, - data, - doc, - fields: field.fields, - global, - operation, - parentIndexPath: '', - parentIsLocalized: parentIsLocalized || field.localized, - parentPath: path, - parentSchemaPath: schemaPath, - previousDoc, - previousSiblingDoc: previousDoc[field.name] as JsonObject, - req, - siblingData: (siblingData?.[field.name] as JsonObject) || {}, - siblingDoc: siblingDoc[field.name] as JsonObject, - }) + if (fieldAffectsData(field)) { + await traverseFields({ + blockData, + collection, + context, + data, + doc, + fields: field.fields, + global, + operation, + parentIndexPath: '', + parentIsLocalized: parentIsLocalized || field.localized, + parentPath: path, + parentSchemaPath: schemaPath, + previousDoc, + previousSiblingDoc: previousDoc[field.name] as JsonObject, + req, + siblingData: (siblingData?.[field.name] as JsonObject) || {}, + siblingDoc: siblingDoc[field.name] as JsonObject, + }) + } else { + await traverseFields({ + blockData, + collection, + context, + data, + doc, + fields: field.fields, + global, + operation, + parentIndexPath: indexPath, + parentIsLocalized, + parentPath, + parentSchemaPath: schemaPath, + previousDoc, + previousSiblingDoc: { ...previousSiblingDoc }, + req, + siblingData: siblingData || {}, + siblingDoc: { ...siblingDoc }, + }) + } break } diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 20612d465c..a06c7ab706 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -186,7 +186,7 @@ export const promise = async ({ case 'group': { // Fill groups with empty objects so fields with hooks within groups can populate // themselves virtually as necessary - if (typeof siblingDoc[field.name] === 'undefined') { + if (fieldAffectsData(field) && typeof siblingDoc[field.name] === 'undefined') { siblingDoc[field.name] = {} } @@ -609,45 +609,78 @@ export const promise = async ({ } case 'group': { - let groupDoc = siblingDoc[field.name] as JsonObject + if (fieldAffectsData(field)) { + let groupDoc = siblingDoc[field.name] as JsonObject - if (typeof siblingDoc[field.name] !== 'object') { - groupDoc = {} + if (typeof siblingDoc[field.name] !== 'object') { + groupDoc = {} + } + + const groupSelect = select?.[field.name] + + traverseFields({ + blockData, + collection, + context, + currentDepth, + depth, + doc, + draft, + fallbackLocale, + fieldPromises, + fields: field.fields, + findMany, + flattenLocales, + global, + locale, + overrideAccess, + parentIndexPath: '', + parentIsLocalized: parentIsLocalized || field.localized, + parentPath: path, + parentSchemaPath: schemaPath, + populate, + populationPromises, + req, + select: typeof groupSelect === 'object' ? groupSelect : undefined, + selectMode, + showHiddenFields, + siblingDoc: groupDoc, + triggerAccessControl, + triggerHooks, + }) + } else { + traverseFields({ + blockData, + collection, + context, + currentDepth, + depth, + doc, + draft, + fallbackLocale, + fieldPromises, + fields: field.fields, + findMany, + flattenLocales, + global, + locale, + overrideAccess, + parentIndexPath: indexPath, + parentIsLocalized, + parentPath, + parentSchemaPath: schemaPath, + populate, + populationPromises, + req, + select, + selectMode, + showHiddenFields, + siblingDoc, + triggerAccessControl, + triggerHooks, + }) } - const groupSelect = select?.[field.name] - - traverseFields({ - blockData, - collection, - context, - currentDepth, - depth, - doc, - draft, - fallbackLocale, - fieldPromises, - fields: field.fields, - findMany, - flattenLocales, - global, - locale, - overrideAccess, - parentIndexPath: '', - parentIsLocalized: parentIsLocalized || field.localized, - parentPath: path, - parentSchemaPath: schemaPath, - populate, - populationPromises, - req, - select: typeof groupSelect === 'object' ? groupSelect : undefined, - selectMode, - showHiddenFields, - siblingDoc: groupDoc, - triggerAccessControl, - triggerHooks, - }) - break } diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index 89dee90827..27b5978c48 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -390,17 +390,42 @@ export const promise = async ({ } case 'group': { - if (typeof siblingData[field.name] !== 'object') { - siblingData[field.name] = {} + let groupSiblingData = siblingData + let groupSiblingDoc = siblingDoc + let groupSiblingDocWithLocales = siblingDocWithLocales + + const isNamedGroup = fieldAffectsData(field) + + if (isNamedGroup) { + if (typeof siblingData[field.name] !== 'object') { + siblingData[field.name] = {} + } + + if (typeof siblingDoc[field.name] !== 'object') { + siblingDoc[field.name] = {} + } + + if (typeof siblingDocWithLocales[field.name] !== 'object') { + siblingDocWithLocales[field.name] = {} + } + if (typeof siblingData[field.name] !== 'object') { + siblingData[field.name] = {} + } + + if (typeof siblingDoc[field.name] !== 'object') { + siblingDoc[field.name] = {} + } + + if (typeof siblingDocWithLocales[field.name] !== 'object') { + siblingDocWithLocales[field.name] = {} + } + + groupSiblingData = siblingData[field.name] as JsonObject + groupSiblingDoc = siblingDoc[field.name] as JsonObject + groupSiblingDocWithLocales = siblingDocWithLocales[field.name] as JsonObject } - if (typeof siblingDoc[field.name] !== 'object') { - siblingDoc[field.name] = {} - } - - if (typeof siblingDocWithLocales[field.name] !== 'object') { - siblingDocWithLocales[field.name] = {} - } + const fallbackLabel = field?.label || (isNamedGroup ? field.name : field?.type) await traverseFields({ id, @@ -414,23 +439,20 @@ export const promise = async ({ fieldLabelPath: field?.label === false ? fieldLabelPath - : buildFieldLabel( - fieldLabelPath, - getTranslatedLabel(field?.label || field?.name, req.i18n), - ), + : buildFieldLabel(fieldLabelPath, getTranslatedLabel(fallbackLabel, req.i18n)), fields: field.fields, global, mergeLocaleActions, operation, overrideAccess, - parentIndexPath: '', + parentIndexPath: isNamedGroup ? '' : indexPath, parentIsLocalized: parentIsLocalized || field.localized, - parentPath: path, + parentPath: isNamedGroup ? path : parentPath, parentSchemaPath: schemaPath, req, - siblingData: siblingData[field.name] as JsonObject, - siblingDoc: siblingDoc[field.name] as JsonObject, - siblingDocWithLocales: siblingDocWithLocales[field.name] as JsonObject, + siblingData: groupSiblingData, + siblingDoc: groupSiblingDoc, + siblingDocWithLocales: groupSiblingDocWithLocales, skipValidation: skipValidationFromHere, }) diff --git a/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts b/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts index 2daa2a91b4..81e3e4481d 100644 --- a/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts @@ -375,9 +375,10 @@ export const promise = async ({ } } } else { - // Finally, we traverse fields which do not affect data here + // Finally, we traverse fields which do not affect data here - collapsibles, rows, unnamed groups switch (field.type) { case 'collapsible': + case 'group': case 'row': { await traverseFields({ id, diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index 2cda4e9b38..f07dbbe759 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -447,16 +447,23 @@ export const promise = async ({ } case 'group': { - if (typeof siblingData[field.name] !== 'object') { - siblingData[field.name] = {} - } + let groupSiblingData = siblingData + let groupSiblingDoc = siblingDoc - if (typeof siblingDoc[field.name] !== 'object') { - siblingDoc[field.name] = {} - } + const isNamedGroup = fieldAffectsData(field) - const groupData = siblingData[field.name] as Record - const groupDoc = siblingDoc[field.name] as Record + if (isNamedGroup) { + if (typeof siblingData[field.name] !== 'object') { + siblingData[field.name] = {} + } + + if (typeof siblingDoc[field.name] !== 'object') { + siblingDoc[field.name] = {} + } + + groupSiblingData = siblingData[field.name] as Record + groupSiblingDoc = siblingDoc[field.name] as Record + } await traverseFields({ id, @@ -469,13 +476,13 @@ export const promise = async ({ global, operation, overrideAccess, - parentIndexPath: '', + parentIndexPath: isNamedGroup ? '' : indexPath, parentIsLocalized: parentIsLocalized || field.localized, - parentPath: path, + parentPath: isNamedGroup ? path : parentPath, parentSchemaPath: schemaPath, req, - siblingData: groupData as JsonObject, - siblingDoc: groupDoc as JsonObject, + siblingData: groupSiblingData, + siblingDoc: groupSiblingDoc, }) break diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index f4d876f806..291d50e632 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1282,6 +1282,8 @@ export type { JSONFieldClient, Labels, LabelsClient, + NamedGroupField, + NamedGroupFieldClient, NamedTab, NonPresentationalField, NonPresentationalFieldClient, @@ -1318,6 +1320,8 @@ export type { TextFieldClient, UIField, UIFieldClient, + UnnamedGroupField, + UnnamedGroupFieldClient, UnnamedTab, UploadField, UploadFieldClient, diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index c00c95cbe6..daee3a0060 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -367,25 +367,26 @@ export function fieldsToJSONSchema( break } - case 'group': - case 'tab': { - fieldSchema = { - ...baseFieldSchema, - type: 'object', - additionalProperties: false, - ...fieldsToJSONSchema( - collectionIDFieldTypes, - field.flattenedFields, - interfaceNameDefinitions, - config, - i18n, - ), - } + case 'group': { + if (fieldAffectsData(field)) { + fieldSchema = { + ...baseFieldSchema, + type: 'object', + additionalProperties: false, + ...fieldsToJSONSchema( + collectionIDFieldTypes, + field.flattenedFields, + interfaceNameDefinitions, + config, + i18n, + ), + } - if (field.interfaceName) { - interfaceNameDefinitions.set(field.interfaceName, fieldSchema) + if (field.interfaceName) { + interfaceNameDefinitions.set(field.interfaceName, fieldSchema) - fieldSchema = { $ref: `#/definitions/${field.interfaceName}` } + fieldSchema = { $ref: `#/definitions/${field.interfaceName}` } + } } break } @@ -486,6 +487,7 @@ export function fieldsToJSONSchema( } break } + case 'radio': { fieldSchema = { ...baseFieldSchema, @@ -503,7 +505,6 @@ export function fieldsToJSONSchema( break } - case 'relationship': case 'upload': { if (Array.isArray(field.relationTo)) { @@ -595,7 +596,6 @@ export function fieldsToJSONSchema( break } - case 'richText': { if (!field?.editor) { throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor @@ -628,6 +628,7 @@ export function fieldsToJSONSchema( break } + case 'select': { const optionEnums = buildOptionEnums(field.options) // We get the previous field to check for a date in the case of a timezone select @@ -675,6 +676,27 @@ export function fieldsToJSONSchema( break } + case 'tab': { + fieldSchema = { + ...baseFieldSchema, + type: 'object', + additionalProperties: false, + ...fieldsToJSONSchema( + collectionIDFieldTypes, + field.flattenedFields, + interfaceNameDefinitions, + config, + i18n, + ), + } + + if (field.interfaceName) { + interfaceNameDefinitions.set(field.interfaceName, fieldSchema) + + fieldSchema = { $ref: `#/definitions/${field.interfaceName}` } + } + break + } case 'text': if (field.hasMany === true) { diff --git a/packages/payload/src/utilities/fieldSchemaToJSON.ts b/packages/payload/src/utilities/fieldSchemaToJSON.ts index 0b94e9f1eb..2f0e285197 100644 --- a/packages/payload/src/utilities/fieldSchemaToJSON.ts +++ b/packages/payload/src/utilities/fieldSchemaToJSON.ts @@ -1,7 +1,8 @@ import type { ClientConfig } from '../config/client.js' // @ts-strict-ignore import type { ClientField } from '../fields/config/client.js' -import type { FieldTypes } from '../fields/config/types.js' + +import { fieldAffectsData, type FieldTypes } from '../fields/config/types.js' export type FieldSchemaJSON = { blocks?: FieldSchemaJSON // TODO: conditionally add based on `type` @@ -67,11 +68,15 @@ export const fieldSchemaToJSON = (fields: ClientField[], config: ClientConfig): break case 'group': - acc.push({ - name: field.name, - type: field.type, - fields: fieldSchemaToJSON(field.fields, config), - }) + if (fieldAffectsData(field)) { + acc.push({ + name: field.name, + type: field.type, + fields: fieldSchemaToJSON(field.fields, config), + }) + } else { + result = result.concat(fieldSchemaToJSON(field.fields, config)) + } break diff --git a/packages/payload/src/utilities/flattenAllFields.ts b/packages/payload/src/utilities/flattenAllFields.ts index 97fa0b5dd3..9173e0885a 100644 --- a/packages/payload/src/utilities/flattenAllFields.ts +++ b/packages/payload/src/utilities/flattenAllFields.ts @@ -7,7 +7,7 @@ import type { FlattenedJoinField, } from '../fields/config/types.js' -import { tabHasName } from '../fields/config/types.js' +import { fieldAffectsData, tabHasName } from '../fields/config/types.js' export const flattenBlock = ({ block }: { block: Block }): FlattenedBlock => { return { @@ -44,7 +44,13 @@ export const flattenAllFields = ({ switch (field.type) { case 'array': case 'group': { - result.push({ ...field, flattenedFields: flattenAllFields({ fields: field.fields }) }) + if (fieldAffectsData(field)) { + result.push({ ...field, flattenedFields: flattenAllFields({ fields: field.fields }) }) + } else { + for (const nestedField of flattenAllFields({ fields: field.fields })) { + result.push(nestedField) + } + } break } diff --git a/packages/payload/src/utilities/traverseFields.ts b/packages/payload/src/utilities/traverseFields.ts index 63c01f7a55..7768b03eab 100644 --- a/packages/payload/src/utilities/traverseFields.ts +++ b/packages/payload/src/utilities/traverseFields.ts @@ -2,7 +2,11 @@ import type { Config, SanitizedConfig } from '../config/types.js' import type { ArrayField, Block, BlocksField, Field, TabAsField } from '../fields/config/types.js' -import { fieldHasSubFields, fieldShouldBeLocalized } from '../fields/config/types.js' +import { + fieldAffectsData, + fieldHasSubFields, + fieldShouldBeLocalized, +} from '../fields/config/types.js' const traverseArrayOrBlocksField = ({ callback, @@ -329,22 +333,38 @@ export const traverseFields = ({ currentRef && typeof currentRef === 'object' ) { - for (const key in currentRef as Record) { - if (currentRef[key]) { - traverseFields({ - callback, - callbackStack, - config, - fields: field.fields, - fillEmpty, - isTopLevel: false, - leavesFirst, - parentIsLocalized: true, - parentRef: currentParentRef, - ref: currentRef[key], - }) + if (fieldAffectsData(field)) { + for (const key in currentRef as Record) { + if (currentRef[key]) { + traverseFields({ + callback, + callbackStack, + config, + fields: field.fields, + fillEmpty, + isTopLevel: false, + leavesFirst, + parentIsLocalized: true, + parentRef: currentParentRef, + ref: currentRef[key], + }) + } } + } else { + traverseFields({ + callback, + callbackStack, + config, + fields: field.fields, + fillEmpty, + isTopLevel: false, + leavesFirst, + parentIsLocalized, + parentRef: currentParentRef, + ref: currentRef, + }) } + return } diff --git a/packages/ui/src/elements/WhereBuilder/reduceFields.tsx b/packages/ui/src/elements/WhereBuilder/reduceFields.tsx index 76728b1c72..dadcba7c85 100644 --- a/packages/ui/src/elements/WhereBuilder/reduceFields.tsx +++ b/packages/ui/src/elements/WhereBuilder/reduceFields.tsx @@ -3,7 +3,7 @@ import type { ClientTranslationKeys, I18nClient } from '@payloadcms/translations import type { ClientField } from 'payload' import { getTranslation } from '@payloadcms/translations' -import { fieldIsHiddenOrDisabled, fieldIsID, tabHasName } from 'payload/shared' +import { fieldAffectsData, fieldIsHiddenOrDisabled, fieldIsID, tabHasName } from 'payload/shared' import { renderToStaticMarkup } from 'react-dom/server' import type { ReducedField } from './types.js' @@ -100,7 +100,46 @@ export const reduceFields = ({ return reduced } - if ((field.type === 'group' || field.type === 'array') && 'fields' in field) { + if (field.type === 'group' && 'fields' in field) { + const translatedLabel = getTranslation(field.label || '', i18n) + + const labelWithPrefix = labelPrefix + ? translatedLabel + ? labelPrefix + ' > ' + translatedLabel + : labelPrefix + : translatedLabel + + if (fieldAffectsData(field)) { + // Make sure we handle deeply nested groups + const pathWithPrefix = field.name + ? pathPrefix + ? pathPrefix + '.' + field.name + : field.name + : pathPrefix + + reduced.push( + ...reduceFields({ + fields: field.fields, + i18n, + labelPrefix: labelWithPrefix, + pathPrefix: pathWithPrefix, + }), + ) + } else { + reduced.push( + ...reduceFields({ + fields: field.fields, + i18n, + labelPrefix: labelWithPrefix, + pathPrefix, + }), + ) + } + + return reduced + } + + if (field.type === 'array' && 'fields' in field) { const translatedLabel = getTranslation(field.label || '', i18n) const labelWithPrefix = labelPrefix diff --git a/packages/ui/src/fields/Group/index.tsx b/packages/ui/src/fields/Group/index.tsx index d46c53d876..e11df20098 100644 --- a/packages/ui/src/fields/Group/index.tsx +++ b/packages/ui/src/fields/Group/index.tsx @@ -28,6 +28,9 @@ export const GroupFieldComponent: GroupFieldClientComponent = (props) => { const { field, field: { name, admin: { className, description, hideGutter } = {}, fields, label }, + indexPath, + parentPath, + parentSchemaPath, path, permissions, readOnly, @@ -102,15 +105,28 @@ export const GroupFieldComponent: GroupFieldClientComponent = (props) => { )} {BeforeInput} - + {/* Render an unnamed group differently */} + {name ? ( + + ) : ( + + )} {AfterInput} diff --git a/packages/ui/src/fields/Join/index.tsx b/packages/ui/src/fields/Join/index.tsx index 43610cb406..334f8f506f 100644 --- a/packages/ui/src/fields/Join/index.tsx +++ b/packages/ui/src/fields/Join/index.tsx @@ -10,7 +10,7 @@ import type { } from 'payload' import ObjectIdImport from 'bson-objectid' -import { flattenTopLevelFields } from 'payload/shared' +import { fieldAffectsData, flattenTopLevelFields } from 'payload/shared' import React, { useMemo } from 'react' import { RelationshipTable } from '../../elements/RelationshipTable/index.js' @@ -68,7 +68,7 @@ const getInitialDrawerData = ({ const nextSegments = segments.slice(1, segments.length) - if (field.type === 'tab' || field.type === 'group') { + if (field.type === 'tab' || (field.type === 'group' && fieldAffectsData(field))) { return { [field.name]: getInitialDrawerData({ collectionSlug, diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index 56035e4d1e..7032e2869c 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -734,7 +734,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom } } } else if (fieldHasSubFields(field) && !fieldAffectsData(field)) { - // Handle field types that do not use names (row, collapsible, etc) + // Handle field types that do not use names (row, collapsible, unnamed group etc) if (!filter || filter(args)) { state[path] = { diff --git a/packages/ui/src/forms/fieldSchemasToFormState/calculateDefaultValues/promise.ts b/packages/ui/src/forms/fieldSchemasToFormState/calculateDefaultValues/promise.ts index 25b37c6b3d..70a8dff242 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/calculateDefaultValues/promise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/calculateDefaultValues/promise.ts @@ -163,26 +163,40 @@ export const defaultValuePromise = async ({ break } case 'group': { - if (typeof siblingData[field.name] !== 'object') { - siblingData[field.name] = {} + if (fieldAffectsData(field)) { + if (typeof siblingData[field.name] !== 'object') { + siblingData[field.name] = {} + } + + const groupData = siblingData[field.name] as Record + + const groupSelect = select?.[field.name] + + await iterateFields({ + id, + data, + fields: field.fields, + locale, + req, + select: typeof groupSelect === 'object' ? groupSelect : undefined, + selectMode, + siblingData: groupData, + user, + }) + } else { + await iterateFields({ + id, + data, + fields: field.fields, + locale, + req, + select, + selectMode, + siblingData, + user, + }) } - const groupData = siblingData[field.name] as Record - - const groupSelect = select?.[field.name] - - await iterateFields({ - id, - data, - fields: field.fields, - locale, - req, - select: typeof groupSelect === 'object' ? groupSelect : undefined, - selectMode, - siblingData: groupData, - user, - }) - break } diff --git a/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts b/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts index f52a686def..a77c98756a 100644 --- a/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts +++ b/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts @@ -1,7 +1,6 @@ import type { I18n } from '@payloadcms/translations' import { - type ClientBlock, type ClientConfig, type ClientField, type ClientFieldSchemaMap, @@ -10,7 +9,7 @@ import { type FieldSchemaMap, type Payload, } from 'payload' -import { getFieldPaths, tabHasName } from 'payload/shared' +import { fieldAffectsData, getFieldPaths, tabHasName } from 'payload/shared' type Args = { clientSchemaMap: ClientFieldSchemaMap @@ -45,8 +44,7 @@ export const traverseFields = ({ clientSchemaMap.set(schemaPath, field) switch (field.type) { - case 'array': - case 'group': + case 'array': { traverseFields({ clientSchemaMap, config, @@ -59,6 +57,7 @@ export const traverseFields = ({ }) break + } case 'blocks': ;(field.blockReferences ?? field.blocks).map((_block) => { @@ -85,6 +84,7 @@ export const traverseFields = ({ }) break + case 'collapsible': case 'row': traverseFields({ @@ -99,6 +99,33 @@ export const traverseFields = ({ }) break + case 'group': { + if (fieldAffectsData(field)) { + traverseFields({ + clientSchemaMap, + config, + fields: field.fields, + i18n, + parentIndexPath: '', + parentSchemaPath: schemaPath, + payload, + schemaMap, + }) + } else { + traverseFields({ + clientSchemaMap, + config, + fields: field.fields, + i18n, + parentIndexPath: indexPath, + parentSchemaPath, + payload, + schemaMap, + }) + } + break + } + case 'richText': { // richText sub-fields are not part of the ClientConfig or the Config. // They only exist in the field schema map. diff --git a/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts b/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts index 805faa011d..a251b46e8f 100644 --- a/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts +++ b/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts @@ -2,7 +2,7 @@ import type { I18n } from '@payloadcms/translations' import type { Field, FieldSchemaMap, SanitizedConfig } from 'payload' import { MissingEditorProp } from 'payload' -import { getFieldPaths, tabHasName } from 'payload/shared' +import { fieldAffectsData, getFieldPaths, tabHasName } from 'payload/shared' type Args = { config: SanitizedConfig @@ -34,7 +34,6 @@ export const traverseFields = ({ switch (field.type) { case 'array': - case 'group': traverseFields({ config, fields: field.fields, @@ -66,6 +65,7 @@ export const traverseFields = ({ }) break + case 'collapsible': case 'row': traverseFields({ @@ -79,6 +79,29 @@ export const traverseFields = ({ break + case 'group': + if (fieldAffectsData(field)) { + traverseFields({ + config, + fields: field.fields, + i18n, + parentIndexPath: '', + parentSchemaPath: schemaPath, + schemaMap, + }) + } else { + traverseFields({ + config, + fields: field.fields, + i18n, + parentIndexPath: indexPath, + parentSchemaPath, + schemaMap, + }) + } + + break + case 'richText': if (!field?.editor) { throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor diff --git a/packages/ui/src/utilities/copyDataFromLocale.ts b/packages/ui/src/utilities/copyDataFromLocale.ts index 5bf9711c38..7d11a2e5cd 100644 --- a/packages/ui/src/utilities/copyDataFromLocale.ts +++ b/packages/ui/src/utilities/copyDataFromLocale.ts @@ -130,7 +130,11 @@ function iterateFields( break case 'group': { - if (field.name in toLocaleData && fromLocaleData?.[field.name] !== undefined) { + if ( + fieldAffectsData(field) && + field.name in toLocaleData && + fromLocaleData?.[field.name] !== undefined + ) { iterateFields( field.fields, fromLocaleData[field.name], @@ -138,6 +142,8 @@ function iterateFields( req, parentIsLocalized || field.localized, ) + } else { + iterateFields(field.fields, fromLocaleData, toLocaleData, req, parentIsLocalized) } break } diff --git a/test/fields/collections/Group/e2e.spec.ts b/test/fields/collections/Group/e2e.spec.ts new file mode 100644 index 0000000000..d49fbabf43 --- /dev/null +++ b/test/fields/collections/Group/e2e.spec.ts @@ -0,0 +1,123 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { addListFilter } from 'helpers/e2e/addListFilter.js' +import path from 'path' +import { wait } from 'payload/shared' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' +import type { Config } from '../../payload-types.js' + +import { + ensureCompilationIsDone, + initPageConsoleErrorCatch, + saveDocAndAssert, +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { assertToastErrors } from '../../../helpers/assertToastErrors.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../../../helpers/reInitializeDB.js' +import { RESTClient } from '../../../helpers/rest.js' +import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js' +import { groupFieldsSlug } from '../../slugs.js' +import { namedGroupDoc } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') + +const { beforeAll, beforeEach, describe } = test + +let payload: PayloadTestSDK +let client: RESTClient +let page: Page +let serverURL: string +// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' }) +let url: AdminUrlUtil + +describe('Group', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ + dirname, + // prebuild, + })) + url = new AdminUrlUtil(serverURL, groupFieldsSlug) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + + await ensureCompilationIsDone({ page, serverURL }) + }) + + beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + if (client) { + await client.logout() + } + client = new RESTClient({ defaultSlug: 'users', serverURL }) + await client.login() + await ensureCompilationIsDone({ page, serverURL }) + }) + + describe('Named', () => { + test('should display field in list view', async () => { + await page.goto(url.list) + + const textCell = page.locator('.row-1 .cell-group') + + await expect(textCell).toContainText(JSON.stringify(namedGroupDoc.group?.text), { + useInnerText: true, + }) + }) + }) + + describe('Unnamed', () => { + test('should display field in list view', async () => { + await page.goto(url.list) + + const textCell = page.locator('.row-1 .cell-insideUnnamedGroup') + + await expect(textCell).toContainText(namedGroupDoc?.insideUnnamedGroup ?? '', { + useInnerText: true, + }) + }) + + test('should display field in list view deeply nested', async () => { + await page.goto(url.list) + + const textCell = page.locator('.row-1 .cell-deeplyNestedGroup') + + await expect(textCell).toContainText(JSON.stringify(namedGroupDoc.deeplyNestedGroup), { + useInnerText: true, + }) + }) + + test('should display field visually within nested groups', async () => { + await page.goto(url.create) + + // Makes sure the fields are rendered + await page.mouse.wheel(0, 2000) + + const unnamedGroupSelector = `.field-type.group-field #field-insideUnnamedGroup` + const unnamedGroupField = page.locator(unnamedGroupSelector) + + await expect(unnamedGroupField).toBeVisible() + + // Makes sure the fields are rendered + await page.mouse.wheel(0, 2000) + + // A bit repetitive but this selector should fail if the group is not nested + const unnamedNestedGroupSelector = `.field-type.group-field .field-type.group-field .field-type.group-field .field-type.group-field .field-type.group-field #field-deeplyNestedGroup__insideNestedUnnamedGroup` + const unnamedNestedGroupField = page.locator(unnamedNestedGroupSelector) + await expect(unnamedNestedGroupField).toBeVisible() + }) + }) +}) diff --git a/test/fields/collections/Group/index.ts b/test/fields/collections/Group/index.ts index b09c356fc8..1323c92fef 100644 --- a/test/fields/collections/Group/index.ts +++ b/test/fields/collections/Group/index.ts @@ -8,6 +8,9 @@ export const groupDefaultChild = 'child takes priority' const GroupFields: CollectionConfig = { slug: groupFieldsSlug, versions: true, + admin: { + defaultColumns: ['id', 'group', 'insideUnnamedGroup', 'deeplyNestedGroup'], + }, fields: [ { label: 'Group Field', @@ -301,6 +304,51 @@ const GroupFields: CollectionConfig = { }, ], }, + { + type: 'group', + label: 'Unnamed group', + fields: [ + { + type: 'text', + name: 'insideUnnamedGroup', + }, + ], + }, + { + type: 'group', + label: 'Deeply nested group', + fields: [ + { + type: 'group', + label: 'Deeply nested group', + fields: [ + { + type: 'group', + name: 'deeplyNestedGroup', + label: 'Deeply nested group', + fields: [ + { + type: 'group', + label: 'Deeply nested group', + fields: [ + { + type: 'group', + label: 'Deeply nested group', + fields: [ + { + type: 'text', + name: 'insideNestedUnnamedGroup', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, ], } diff --git a/test/fields/collections/Group/shared.ts b/test/fields/collections/Group/shared.ts index 8fe425b69a..021f4b63fd 100644 --- a/test/fields/collections/Group/shared.ts +++ b/test/fields/collections/Group/shared.ts @@ -1,6 +1,6 @@ import type { GroupField } from '../../payload-types.js' -export const groupDoc: Partial = { +export const namedGroupDoc: Partial = { group: { text: 'some text within a group', subGroup: { @@ -12,4 +12,8 @@ export const groupDoc: Partial = { ], }, }, + insideUnnamedGroup: 'text in unnamed group', + deeplyNestedGroup: { + insideNestedUnnamedGroup: 'text in nested unnamed group', + }, } diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index 5ab7350323..157ee33ec5 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -15,7 +15,7 @@ import { arrayDefaultValue } from './collections/Array/index.js' import { blocksDoc } from './collections/Blocks/shared.js' import { dateDoc } from './collections/Date/shared.js' import { groupDefaultChild, groupDefaultValue } from './collections/Group/index.js' -import { groupDoc } from './collections/Group/shared.js' +import { namedGroupDoc } from './collections/Group/shared.js' import { defaultNumber } from './collections/Number/index.js' import { numberDoc } from './collections/Number/shared.js' import { pointDoc } from './collections/Point/shared.js' @@ -1614,7 +1614,7 @@ describe('Fields', () => { it('should create with ids and nested ids', async () => { const docWithIDs = (await payload.create({ collection: groupFieldsSlug, - data: groupDoc, + data: namedGroupDoc, })) as Partial expect(docWithIDs.group.subGroup.arrayWithinGroup[0].id).toBeDefined() }) @@ -1913,6 +1913,53 @@ describe('Fields', () => { }) }) + it('should work with unnamed group', async () => { + const groupDoc = await payload.create({ + collection: groupFieldsSlug, + data: { + insideUnnamedGroup: 'Hello world', + deeplyNestedGroup: { insideNestedUnnamedGroup: 'Secondfield' }, + }, + }) + expect(groupDoc).toMatchObject({ + id: expect.anything(), + insideUnnamedGroup: 'Hello world', + deeplyNestedGroup: { + insideNestedUnnamedGroup: 'Secondfield', + }, + }) + }) + + it('should work with unnamed group - graphql', async () => { + const mutation = `mutation { + createGroupField( + data: { + insideUnnamedGroup: "Hello world", + deeplyNestedGroup: { insideNestedUnnamedGroup: "Secondfield" }, + group: {text: "hello"} + } + ) { + insideUnnamedGroup + deeplyNestedGroup { + insideNestedUnnamedGroup + } + } + }` + + const groupDoc = await restClient.GRAPHQL_POST({ + body: JSON.stringify({ query: mutation }), + }) + + const data = (await groupDoc.json()).data.createGroupField + + expect(data).toMatchObject({ + insideUnnamedGroup: 'Hello world', + deeplyNestedGroup: { + insideNestedUnnamedGroup: 'Secondfield', + }, + }) + }) + it('should query a subfield within a localized group', async () => { const text = 'find this' const hit = await payload.create({ @@ -2357,7 +2404,7 @@ describe('Fields', () => { it('should return empty object for groups when no data present', async () => { const doc = await payload.create({ collection: groupFieldsSlug, - data: groupDoc, + data: namedGroupDoc, }) expect(doc.potentiallyEmptyGroup).toBeDefined() diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index c303ea6b4c..dd8b11bc81 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -1080,6 +1080,10 @@ export interface GroupField { }[] | null; }; + insideUnnamedGroup?: string | null; + deeplyNestedGroup?: { + insideNestedUnnamedGroup?: string | null; + }; updatedAt: string; createdAt: string; } @@ -2676,6 +2680,12 @@ export interface GroupFieldsSelect { | { email?: T; }; + insideUnnamedGroup?: T; + deeplyNestedGroup?: + | T + | { + insideNestedUnnamedGroup?: T; + }; updatedAt?: T; createdAt?: T; } diff --git a/test/fields/seed.ts b/test/fields/seed.ts index 572a987f8d..ac71bad5d9 100644 --- a/test/fields/seed.ts +++ b/test/fields/seed.ts @@ -16,7 +16,7 @@ import { conditionalLogicDoc } from './collections/ConditionalLogic/shared.js' import { customRowID, customTabID, nonStandardID } from './collections/CustomID/shared.js' import { dateDoc } from './collections/Date/shared.js' import { anotherEmailDoc, emailDoc } from './collections/Email/shared.js' -import { groupDoc } from './collections/Group/shared.js' +import { namedGroupDoc } from './collections/Group/shared.js' import { jsonDoc } from './collections/JSON/shared.js' import { numberDoc } from './collections/Number/shared.js' import { pointDoc } from './collections/Point/shared.js' @@ -223,7 +223,7 @@ export const seed = async (_payload: Payload) => { await _payload.create({ collection: groupFieldsSlug, - data: groupDoc, + data: namedGroupDoc, depth: 0, overrideAccess: true, }) diff --git a/test/types/config.ts b/test/types/config.ts index a929272ff1..f33cf08169 100644 --- a/test/types/config.ts +++ b/test/types/config.ts @@ -38,6 +38,26 @@ export default buildConfigWithDefaults({ }, ], }, + { + type: 'group', + label: 'Unnamed Group', + fields: [ + { + type: 'text', + name: 'insideUnnamedGroup', + }, + ], + }, + { + type: 'group', + name: 'namedGroup', + fields: [ + { + type: 'text', + name: 'insideNamedGroup', + }, + ], + }, { name: 'radioField', type: 'radio', diff --git a/test/types/payload-types.ts b/test/types/payload-types.ts index 480f4509c6..543350e287 100644 --- a/test/types/payload-types.ts +++ b/test/types/payload-types.ts @@ -144,6 +144,10 @@ export interface Post { text?: string | null; title?: string | null; selectField: MySelectOptions; + insideUnnamedGroup?: string | null; + namedGroup?: { + insideNamedGroup?: string | null; + }; radioField: MyRadioOptions; updatedAt: string; createdAt: string; @@ -264,6 +268,12 @@ export interface PostsSelect { text?: T; title?: T; selectField?: T; + insideUnnamedGroup?: T; + namedGroup?: + | T + | { + insideNamedGroup?: T; + }; radioField?: T; updatedAt?: T; createdAt?: T; diff --git a/test/types/types.spec.ts b/test/types/types.spec.ts index fb315ec113..d7184cbd50 100644 --- a/test/types/types.spec.ts +++ b/test/types/types.spec.ts @@ -145,4 +145,17 @@ describe('Types testing', () => { expect(asType()).type.toBe() }) }) + + describe('fields', () => { + describe('Group', () => { + test('correctly ignores unnamed group', () => { + expect().type.toHaveProperty('insideUnnamedGroup') + }) + + test('generates nested group name', () => { + expect().type.toHaveProperty('namedGroup') + expect>().type.toHaveProperty('insideNamedGroup') + }) + }) + }) }) diff --git a/tools/scripts/src/generateTranslations/utils/index.ts b/tools/scripts/src/generateTranslations/utils/index.ts index b3cb8e8177..6a73e7fb6c 100644 --- a/tools/scripts/src/generateTranslations/utils/index.ts +++ b/tools/scripts/src/generateTranslations/utils/index.ts @@ -129,7 +129,7 @@ export async function translateObject(props: { for (const missingKey of missingKeys) { const keys: string[] = missingKey.split('.') const sourceText = keys.reduce( - (acc, key) => acc[key] as GenericTranslationsObject, + (acc, key) => acc[key], fromTranslationsObject, ) if (!sourceText || typeof sourceText !== 'string') { diff --git a/tsconfig.base.json b/tsconfig.base.json index b7c0ef5d5c..12877b1be6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/query-presets/config.ts"], + "@payload-config": ["./test/_community/config.ts"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], From 8ebadd4190fd9a8e42fdd7b7dad8076cd4808788 Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Thu, 15 May 2025 05:13:15 +0300 Subject: [PATCH 37/46] fix(ui): respect `filterOptions: { id: { in: [] } }` (#12408) Fixes the issue where this returns all the documents: ``` { name: 'post', type: 'relationship', relationTo: 'posts', filterOptions: { id: { in: [] } } } ``` The issue isn't with the Local API but with how we send the query to the REST API through `qs.stringify`. `qs.stringify({ id: { in: [] } }` becomes `""`, so the server ignores the original query. I don't think it's possible to encode empty arrays with this library https://github.com/sindresorhus/query-string/issues/231, so I just made sanitization to `{ exists: false }` for this case. --- packages/ui/src/fields/Relationship/index.tsx | 3 +++ .../utilities/sanitizeFilterOptionsQuery.ts | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 packages/ui/src/utilities/sanitizeFilterOptionsQuery.ts diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index 21cbaa4299..dc50b3d309 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -33,6 +33,7 @@ import { useConfig } from '../../providers/Config/index.js' import { useLocale } from '../../providers/Locale/index.js' import { useTranslation } from '../../providers/Translation/index.js' import './index.scss' +import { sanitizeFilterOptionsQuery } from '../../utilities/sanitizeFilterOptionsQuery.js' import { mergeFieldStyles } from '../mergeFieldStyles.js' import { fieldBaseClass } from '../shared/index.js' import { createRelationMap } from './createRelationMap.js' @@ -299,6 +300,8 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => query.where.and.push(relationFilterOption) } + sanitizeFilterOptionsQuery(query.where) + const response = await fetch(`${serverURL}${api}/${relation}`, { body: qs.stringify(query), credentials: 'include', diff --git a/packages/ui/src/utilities/sanitizeFilterOptionsQuery.ts b/packages/ui/src/utilities/sanitizeFilterOptionsQuery.ts new file mode 100644 index 0000000000..5f41af9250 --- /dev/null +++ b/packages/ui/src/utilities/sanitizeFilterOptionsQuery.ts @@ -0,0 +1,24 @@ +import type { Where } from 'payload' + +export const sanitizeFilterOptionsQuery = (query: Where): Where => { + for (const key in query) { + const value = query[key] + if ((key.toLowerCase() === 'and' || key.toLowerCase() === 'or') && Array.isArray(value)) { + for (const val of value) { + sanitizeFilterOptionsQuery(val) + } + } else if ( + value && + typeof value === 'object' && + 'in' in value && + Array.isArray(value.in) && + value.in.length === 0 + ) { + { + query[key] = { exists: false } + } + } + } + + return query +} From 77bb7e3638167d21ebc885eebdd5372884bbcd6d Mon Sep 17 00:00:00 2001 From: Dmitrijs Trifonovs <49034932+dt-globelaxy@users.noreply.github.com> Date: Thu, 15 May 2025 04:15:50 +0100 Subject: [PATCH 38/46] feat: add latvian language support (#12363) This PR adds Latvian language support, based on the instructions provided in the documentation --- packages/payload/src/exports/i18n/lv.ts | 1 + packages/translations/src/exports/all.ts | 2 + packages/translations/src/languages/lv.ts | 515 ++++++++++++++++++ packages/translations/src/types.ts | 1 + .../translations/src/utilities/languages.ts | 2 +- 5 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 packages/payload/src/exports/i18n/lv.ts create mode 100644 packages/translations/src/languages/lv.ts diff --git a/packages/payload/src/exports/i18n/lv.ts b/packages/payload/src/exports/i18n/lv.ts new file mode 100644 index 0000000000..b94af42339 --- /dev/null +++ b/packages/payload/src/exports/i18n/lv.ts @@ -0,0 +1 @@ +export { lv } from '@payloadcms/translations/languages/lv' diff --git a/packages/translations/src/exports/all.ts b/packages/translations/src/exports/all.ts index e4e4a8d1f9..6832af1d53 100644 --- a/packages/translations/src/exports/all.ts +++ b/packages/translations/src/exports/all.ts @@ -20,6 +20,7 @@ import { it } from '../languages/it.js' import { ja } from '../languages/ja.js' import { ko } from '../languages/ko.js' import { lt } from '../languages/lt.js' +import { lv } from '../languages/lv.js' import { my } from '../languages/my.js' import { nb } from '../languages/nb.js' import { nl } from '../languages/nl.js' @@ -60,6 +61,7 @@ export const translations = { ja, ko, lt, + lv, my, nb, nl, diff --git a/packages/translations/src/languages/lv.ts b/packages/translations/src/languages/lv.ts new file mode 100644 index 0000000000..21e1b56ea1 --- /dev/null +++ b/packages/translations/src/languages/lv.ts @@ -0,0 +1,515 @@ +import type { Language } from '../types.js' + +export const lvTranslations = { + authentication: { + account: 'Konts', + accountOfCurrentUser: 'Pašreizējā lietotāja konts', + accountVerified: 'Konts veiksmīgi verificēts.', + alreadyActivated: 'Jau aktivizēts', + alreadyLoggedIn: 'Jau pieslēdzies', + apiKey: 'API atslēga', + authenticated: 'Autentificēts', + backToLogin: 'Atpakaļ uz pieslēgšanos', + beginCreateFirstUser: 'Lai sāktu, izveidojiet savu pirmo lietotāju.', + changePassword: 'Mainīt paroli', + checkYourEmailForPasswordReset: + 'Ja e-pasta adrese ir saistīta ar kontu, drīz saņemsiet norādījumus paroles atiestatīšanai. Lūdzu, pārbaudiet arī surogātpasta mapi, ja e-pasts nav iesūtnē.', + confirmGeneration: 'Apstiprināt ģenerēšanu', + confirmPassword: 'Apstiprināt paroli', + createFirstUser: 'Izveidot pirmo lietotāju', + emailNotValid: 'Norādītais e-pasts nav derīgs', + emailOrUsername: 'E-pasts vai lietotājvārds', + emailSent: 'E-pasts nosūtīts', + emailVerified: 'E-pasts veiksmīgi verificēts.', + enableAPIKey: 'Ieslēgt API atslēgu', + failedToUnlock: 'Neizdevās atbloķēt', + forceUnlock: 'Piespiedu atbloķēšana', + forgotPassword: 'Aizmirsi paroli?', + forgotPasswordEmailInstructions: + 'Lūdzu, ievadiet savu e-pastu zemāk. Saņemsiet ziņojumu ar norādījumiem paroles atiestatīšanai.', + forgotPasswordUsernameInstructions: + 'Lūdzu, ievadiet savu lietotājvārdu zemāk. Norādījumi paroles atiestatīšanai tiks nosūtīti uz e-pastu, kas saistīts ar jūsu lietotājvārdu.', + usernameNotValid: 'Norādītais lietotājvārds nav derīgs', + + forgotPasswordQuestion: 'Aizmirsi paroli?', + generate: 'Ģenerēt', + generateNewAPIKey: 'Ģenerēt jaunu API atslēgu', + generatingNewAPIKeyWillInvalidate: + 'Ģenerējot jaunu API atslēgu, <1>iepriekšējā atslēga kļūs nederīga. Vai tiešām vēlaties turpināt?', + lockUntil: 'Bloķēts līdz', + logBackIn: 'Pieslēgties atkārtoti', + loggedIn: 'Lai pieslēgtos ar citu lietotāju, vispirms <0>atslēdzieties.', + loggedInChangePassword: + 'Lai mainītu paroli, dodieties uz savu <0>kontu un rediģējiet paroli tur.', + loggedOutInactivity: 'Jūs esat atslēgts neaktivitātes dēļ.', + loggedOutSuccessfully: 'Jūs veiksmīgi atslēdzāties.', + loggingOut: 'Notiek atslēgšanās...', + login: 'Pieslēgties', + loginAttempts: 'Pieslēgšanās mēģinājumi', + loginUser: 'Pieslēgt lietotāju', + loginWithAnotherUser: 'Lai pieslēgtos ar citu lietotāju, vispirms <0>atslēdzieties.', + logOut: 'Atslēgties', + logout: 'Atslēgties', + logoutSuccessful: 'Atslēgšanās veiksmīga.', + logoutUser: 'Atslēgt lietotāju', + newAccountCreated: + 'Jums tikko ir izveidots jauns konts piekļuvei {{serverURL}}. Lūdzu, noklikšķiniet uz šīs saites vai iekopējiet URL pārlūkprogrammā, lai verificētu savu e-pastu: {{verificationURL}}
Pēc e-pasta verificēšanas varēsiet veiksmīgi pieslēgties.', + newAPIKeyGenerated: 'Jauna API atslēga ģenerēta.', + newPassword: 'Jauna parole', + passed: 'Autentifikācija veiksmīga', + passwordResetSuccessfully: 'Parole veiksmīgi atiestatīta.', + resetPassword: 'Atiestatīt paroli', + resetPasswordExpiration: 'Paroles atiestatīšanas termiņš', + resetPasswordToken: 'Paroles atiestatīšanas tokens', + resetYourPassword: 'Atiestatīt savu paroli', + stayLoggedIn: 'Palikt pieslēgtam', + successfullyRegisteredFirstUser: 'Pirmais lietotājs veiksmīgi reģistrēts.', + successfullyUnlocked: 'Veiksmīgi atbloķēts', + tokenRefreshSuccessful: 'Tokens veiksmīgi atjaunots.', + unableToVerify: 'Neizdevās verificēt', + username: 'Lietotājvārds', + verified: 'Verificēts', + verifiedSuccessfully: 'Veiksmīgi verificēts', + verify: 'Verificēt', + verifyUser: 'Verificēt lietotāju', + verifyYourEmail: 'Verificējiet savu e-pastu', + youAreInactive: + 'Jūs kādu laiku neesat bijis aktīvs, un drošības nolūkos drīz automātiski tiksiet atslēgts. Vai vēlaties palikt pieslēgts?', + youAreReceivingResetPassword: + 'Jūs saņemat šo ziņojumu, jo (vai kāds cits) esat pieprasījis paroles atiestatīšanu savam kontam. Lūdzu, noklikšķiniet uz šīs saites vai iekopējiet to pārlūkprogrammā, lai pabeigtu procesu:', + youDidNotRequestPassword: + 'Ja neesat pieprasījis paroles atiestatīšanu, lūdzu, ignorējiet šo e-pastu, un parole paliks nemainīta.', + }, + error: { + accountAlreadyActivated: 'Šis konts jau ir aktivizēts.', + autosaving: 'Radās problēma, automātiski saglabājot šo dokumentu.', + correctInvalidFields: 'Lūdzu, izlabojiet nederīgos laukus.', + deletingFile: 'Radās kļūda, dzēšot failu.', + deletingTitle: + 'Radās kļūda, dzēšot {{title}}. Lūdzu, pārbaudiet savienojumu un mēģiniet vēlreiz.', + emailOrPasswordIncorrect: 'Norādītais e-pasts vai parole nav pareiza.', + followingFieldsInvalid_one: 'Šis lauks nav derīgs:', + followingFieldsInvalid_other: 'Šie lauki nav derīgi:', + incorrectCollection: 'Nepareiza kolekcija', + invalidFileType: 'Nederīgs faila tips', + invalidFileTypeValue: 'Nederīgs faila tips: {{value}}', + invalidRequestArgs: 'Pieprasījumā nodoti nederīgi argumenti: {{args}}', + loadingDocument: 'Radās problēma, ielādējot dokumentu ar ID {{id}}.', + localesNotSaved_one: 'Šo lokalizāciju nevarēja saglabāt:', + localesNotSaved_other: 'Šīs lokalizācijas nevarēja saglabāt:', + logoutFailed: 'Neizdevās atslēgties.', + missingEmail: 'Trūkst e-pasta.', + missingIDOfDocument: 'Trūkst dokumenta ID, ko atjaunināt.', + missingIDOfVersion: 'Trūkst versijas ID.', + missingRequiredData: 'Trūkst nepieciešamo datu.', + noFilesUploaded: 'Nav augšupielādēti faili.', + noMatchedField: 'Nav atrasts atbilstošs lauks "{{label}}"', + notAllowedToAccessPage: 'Jums nav atļauts piekļūt šai lapai.', + notAllowedToPerformAction: 'Jums nav atļauts veikt šo darbību.', + notFound: 'Pieprasītais resurss nav atrasts.', + noUser: 'Nav lietotāja', + previewing: 'Radās problēma, priekšskatot šo dokumentu.', + problemUploadingFile: 'Radās problēma, augšupielādējot failu.', + tokenInvalidOrExpired: 'Tokens ir nederīgs vai beidzies.', + tokenNotProvided: 'Tokens nav norādīts.', + unableToDeleteCount: 'Neizdevās izdzēst {{count}} no {{total}} {{label}}.', + unableToReindexCollection: + 'Radās kļūda, pārindeksējot kolekciju {{collection}}. Operācija pārtraukta.', + unableToUpdateCount: 'Neizdevās atjaunināt {{count}} no {{total}} {{label}}.', + unauthorized: 'Neautorizēts, jums jāpieslēdzas, lai veiktu šo pieprasījumu.', + unauthorizedAdmin: 'Neautorizēts, šim lietotājam nav piekļuves administrācijas panelim.', + unknown: 'Radās nezināma kļūda.', + unPublishingDocument: 'Radās problēma, atceļot dokumenta publicēšanu.', + unspecific: 'Radās kļūda.', + unverifiedEmail: 'Lūdzu, verificējiet savu e-pastu pirms pieslēgšanās.', + userEmailAlreadyRegistered: 'Lietotājs ar šo e-pastu jau ir reģistrēts.', + userLocked: 'Šis lietotājs ir bloķēts pārāk daudzu neveiksmīgu pieslēgšanās mēģinājumu dēļ.', + usernameAlreadyRegistered: 'Lietotājs ar šo lietotājvārdu jau ir reģistrēts.', + usernameOrPasswordIncorrect: 'Norādītais lietotājvārds vai parole nav pareiza.', + valueMustBeUnique: 'Vērtībai jābūt unikālai', + verificationTokenInvalid: 'Verifikācijas tokens nav derīgs.', + }, + fields: { + addLabel: 'Pievienot {{label}}', + addLink: 'Pievienot saiti', + addNew: 'Pievienot jaunu', + addNewLabel: 'Pievienot jaunu {{label}}', + addRelationship: 'Pievienot saistību', + addUpload: 'Pievienot augšupielādi', + block: 'bloks', + blocks: 'bloki', + blockType: 'Bloka tips', + chooseBetweenCustomTextOrDocument: + 'Izvēlieties starp pielāgotu teksta URL vai saiti uz citu dokumentu.', + chooseDocumentToLink: 'Izvēlieties dokumentu, uz kuru saistīt', + chooseFromExisting: 'Izvēlieties no esošajiem', + chooseLabel: 'Izvēlieties {{label}}', + collapseAll: 'Sakļaut visus', + customURL: 'Pielāgots URL', + editLabelData: 'Rediģēt {{label}} datus', + editLink: 'Rediģēt saiti', + editRelationship: 'Rediģēt saistību', + enterURL: 'Ievadiet URL', + internalLink: 'Iekšēja saite', + itemsAndMore: '{{items}} un vēl {{count}}', + labelRelationship: '{{label}} saistība', + latitude: 'Platums', + linkedTo: 'Saistīts ar <0>{{label}}', + linkType: 'Saites tips', + longitude: 'Garums', + newLabel: 'Jauns {{label}}', + openInNewTab: 'Atvērt jaunā cilnē', + passwordsDoNotMatch: 'Paroles nesakrīt.', + relatedDocument: 'Saistītais dokuments', + relationTo: 'Saistība ar', + removeRelationship: 'Noņemt saistību', + removeUpload: 'Noņemt augšupielādi', + saveChanges: 'Saglabāt izmaiņas', + searchForBlock: 'Meklēt bloku', + selectExistingLabel: 'Izvēlēties esošo {{label}}', + selectFieldsToEdit: 'Izvēlēties laukus rediģēšanai', + showAll: 'Rādīt visus', + swapRelationship: 'Mainīt saistību', + swapUpload: 'Mainīt augšupielādi', + textToDisplay: 'Rādāmais teksts', + toggleBlock: 'Pārslēgt bloku', + uploadNewLabel: 'Augšupielādēt jaunu {{label}}', + }, + general: { + aboutToDelete: 'Jūs grasāties dzēst {{label}} <1>{{title}}. Vai esat pārliecināts?', + aboutToDeleteCount_many: 'Jūs grasāties dzēst {{count}} {{label}}', + aboutToDeleteCount_one: 'Jūs grasāties dzēst {{count}} {{label}}', + aboutToDeleteCount_other: 'Jūs grasāties dzēst {{count}} {{label}}', + addBelow: 'Pievienot zemāk', + addFilter: 'Pievienot filtru', + adminTheme: 'Administratora tēma', + all: 'Visi', + allCollections: 'Visas kolekcijas', + and: 'Un', + anotherUser: 'Cits lietotājs', + anotherUserTakenOver: 'Cits lietotājs ir pārņēmis šī dokumenta rediģēšanu.', + applyChanges: 'Pielietot izmaiņas', + ascending: 'Augošā secībā', + automatic: 'Automātiski', + backToDashboard: 'Atpakaļ uz paneli', + cancel: 'Atcelt', + changesNotSaved: 'Jūsu izmaiņas nav saglabātas. Ja tagad pametīsiet, izmaiņas tiks zaudētas.', + clearAll: 'Notīrīt visu', + close: 'Aizvērt', + collapse: 'Sakļaut', + collections: 'Kolekcijas', + columns: 'Kolonnas', + columnToSort: 'Kolonna kārtošanai', + confirm: 'Apstiprināt', + confirmCopy: 'Apstiprināt kopēšanu', + confirmDeletion: 'Apstiprināt dzēšanu', + confirmDuplication: 'Apstiprināt dublēšanu', + confirmReindex: 'Pārindeksēt visus {{collections}}?', + confirmReindexAll: 'Pārindeksēt visas kolekcijas?', + confirmReindexDescription: + 'Tas noņems esošos indeksus un pārindeksēs dokumentus kolekcijās {{collections}}.', + confirmReindexDescriptionAll: + 'Tas noņems esošos indeksus un pārindeksēs dokumentus visās kolekcijās.', + copied: 'Nokopēts', + copy: 'Kopēt', + copying: 'Kopē...', + copyWarning: + 'Jūs grasāties pārrakstīt {{to}} ar {{from}} priekš {{label}} {{title}}. Vai esat pārliecināts?', + create: 'Izveidot', + created: 'Izveidots', + createdAt: 'Izveidots', + createNew: 'Izveidot jaunu', + createNewLabel: 'Izveidot jaunu {{label}}', + creating: 'Izveido...', + creatingNewLabel: 'Izveido jaunu {{label}}', + currentlyEditing: + 'pašlaik rediģē šo dokumentu. Ja pārņemsiet, viņi tiks bloķēti no turpmākas rediģēšanas un var zaudēt nesaglabātās izmaiņas.', + custom: 'Pielāgots', + dark: 'Tumšs', + dashboard: 'Panelis', + delete: 'Dzēst', + deletedCountSuccessfully: 'Veiksmīgi izdzēsti {{count}} {{label}}.', + deletedSuccessfully: 'Veiksmīgi izdzēsts.', + deleting: 'Dzēš...', + depth: 'Dziļums', + descending: 'Dilstošā secībā', + deselectAllRows: 'Atdzēlēt visas rindas', + document: 'Dokuments', + documentLocked: 'Dokuments bloķēts', + documents: 'Dokumenti', + duplicate: 'Dublēt', + duplicateWithoutSaving: 'Dublēt bez izmaiņu saglabāšanas', + edit: 'Rediģēt', + editAll: 'Rediģēt visus', + editedSince: 'Rediģēts kopš', + editing: 'Rediģē', + editingLabel_many: 'Rediģē {{count}} {{label}}', + editingLabel_one: 'Rediģē {{count}} {{label}}', + editingLabel_other: 'Rediģē {{count}} {{label}}', + editingTakenOver: 'Rediģēšana pārņemta', + editLabel: 'Rediģēt {{label}}', + email: 'E-pasts', + emailAddress: 'E-pasta adrese', + enterAValue: 'Ievadiet vērtību', + error: 'Kļūda', + errors: 'Kļūdas', + fallbackToDefaultLocale: 'Izmantot noklusēto lokalizāciju', + false: 'Nepatiesi', + filter: 'Filtrs', + filters: 'Filtri', + filterWhere: 'Filtrēt {{label}} kur', + globals: 'Globālie', + goBack: 'Doties atpakaļ', + isEditing: 'redzē', + language: 'Valoda', + lastModified: 'Pēdējoreiz mainīts', + leaveAnyway: 'Pamest tāpat', + leaveWithoutSaving: 'Pamest nesaglabājot', + light: 'Gaišs', + livePreview: 'Tiešais priekšskatījums', + loading: 'Ielādē...', + locale: 'Lokalizācija', + locales: 'Lokalizācijas', + menu: 'Izvēlne', + moreOptions: 'Vairāk opciju', + moveDown: 'Pārvietot uz leju', + moveUp: 'Pārvietot uz augšu', + newPassword: 'Jauna parole', + next: 'Nākamais', + noDateSelected: 'Datums nav izvēlēts', + noFiltersSet: 'Nav uzstādīti filtri', + noLabel: '