From c41ef65a2bc9cf38fbc465110f112baf77e72baa Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Wed, 30 Oct 2024 19:41:34 +0200 Subject: [PATCH] feat: add `defaultPopulate` property to collection config (#8934) ### What? Adds `defaultPopulate` property to collection config that allows to specify which fields to select when the collection is populated from another document. ```ts import type { CollectionConfig } from 'payload' // The TSlug generic can be passed to have type safety for `defaultPopulate`. // If avoided, the `defaultPopulate` type resolves to `SelectType`. export const Pages: CollectionConfig<'pages'> = { slug: 'pages', // I need only slug, NOT the WHOLE CONTENT! defaultPopulate: { slug: true, }, fields: [ { name: 'slug', type: 'text', required: true, }, ], } ``` ### Why? This is essential for example in case of links. You don't need the whole document, which can contain large data but only the `slug`. ### How? Implements `defaultPopulate` when populating relationships, including inside of lexical / slate rich text fields. --- docs/configuration/collections.mdx | 3 +- docs/queries/select.mdx | 31 ++++ .../payload/src/collections/config/types.ts | 5 +- .../payload/src/collections/dataloader.ts | 10 +- .../relationshipPopulationPromise.ts | 1 + .../src/features/relationship/server/index.ts | 1 + .../features/upload/server/feature.server.ts | 1 + .../src/populateGraphQL/populate.ts | 5 +- packages/richtext-slate/src/data/populate.ts | 5 +- .../src/data/recurseNestedFields.ts | 4 + .../src/data/richTextRelationshipPromise.ts | 2 + test/select/collections/Pages/index.ts | 84 +++++++++++ test/select/config.ts | 2 + test/select/int.spec.ts | 136 ++++++++++++++++++ test/select/payload-types.ts | 90 ++++++++++++ 15 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 test/select/collections/Pages/index.ts diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index f20ea6df8..59f074806 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -58,7 +58,7 @@ export const Posts: CollectionConfig = { The following options are available: | Option | Description | -|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`admin`** | The configuration options for the Admin Panel. [More details](../admin/collections). | | **`access`** | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). | | **`auth`** | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). | @@ -77,6 +77,7 @@ The following options are available: | **`typescript`** | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. | | **`upload`** | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. | | **`versions`** | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). | +| **`defaultPopulate`** | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). | _\* An asterisk denotes that a property is required._ diff --git a/docs/queries/select.mdx b/docs/queries/select.mdx index 3bd4fe694..6ff7f1fb1 100644 --- a/docs/queries/select.mdx +++ b/docs/queries/select.mdx @@ -97,3 +97,34 @@ const getPosts = async () => { Reminder: This is the same for [Globals](../configuration/globals) using the `/api/globals` endpoint. +``` + + +## `defaultPopulate` collection config property + +The `defaultPopulate` property allows you specify which fields to select when populating the collection from another document. +This is especially useful for links where only the `slug` is needed instead of the entire document. + +```ts +import type { CollectionConfig } from 'payload' + +import { lexicalEditor, LinkFeature } from '@payloadcms/richtext-lexical' +import { slateEditor } from '@payloadcms/richtext-slate' + +// The TSlug generic can be passed to have type safety for `defaultPopulate`. +// If avoided, the `defaultPopulate` type resolves to `SelectType`. +export const Pages: CollectionConfig<'pages'> = { + slug: 'pages', + // Specify `select`. + defaultPopulate: { + slug: true, + }, + fields: [ + { + name: 'slug', + type: 'text', + required: true, + }, + ], +} +``` diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 4a83e9874..4243b5408 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -1,5 +1,5 @@ import type { GraphQLInputObjectType, GraphQLNonNull, GraphQLObjectType } from 'graphql' -import type { DeepRequired, MarkOptional } from 'ts-essentials' +import type { DeepRequired, IsAny, MarkOptional } from 'ts-essentials' import type { CustomPreviewButton, @@ -382,6 +382,9 @@ export type CollectionConfig = { * @WARNING: If you change this property with existing data, you will need to handle the renaming of the table in your database or by using migrations */ dbName?: DBIdentifierName + defaultPopulate?: IsAny> extends true + ? SelectType + : SelectFromCollectionSlug /** * Default field to sort by in collection list view */ diff --git a/packages/payload/src/collections/dataloader.ts b/packages/payload/src/collections/dataloader.ts index 3fc55acba..b846b1add 100644 --- a/packages/payload/src/collections/dataloader.ts +++ b/packages/payload/src/collections/dataloader.ts @@ -2,7 +2,7 @@ import type { BatchLoadFn } from 'dataloader' import DataLoader from 'dataloader' -import type { PayloadRequest } from '../types/index.js' +import type { PayloadRequest, SelectType } from '../types/index.js' import type { TypeWithID } from './config/types.js' import { isValidID } from '../utilities/isValidID.js' @@ -55,6 +55,7 @@ const batchAndLoadDocs = overrideAccess, showHiddenFields, draft, + select, ] = JSON.parse(key) const batchKeyArray = [ @@ -67,6 +68,7 @@ const batchAndLoadDocs = overrideAccess, showHiddenFields, draft, + select, ] const batchKey = JSON.stringify(batchKeyArray) @@ -103,6 +105,7 @@ const batchAndLoadDocs = overrideAccess, showHiddenFields, draft, + select, ] = JSON.parse(batchKey) req.transactionID = transactionID @@ -118,6 +121,7 @@ const batchAndLoadDocs = overrideAccess: Boolean(overrideAccess), pagination: false, req, + select, showHiddenFields: Boolean(showHiddenFields), where: { id: { @@ -139,6 +143,7 @@ const batchAndLoadDocs = fallbackLocale, locale, overrideAccess, + select, showHiddenFields, transactionID: req.transactionID, }) @@ -167,6 +172,7 @@ type CreateCacheKeyArgs = { fallbackLocale: string locale: string overrideAccess: boolean + select?: SelectType showHiddenFields: boolean transactionID: number | Promise | string } @@ -179,6 +185,7 @@ export const createDataloaderCacheKey = ({ fallbackLocale, locale, overrideAccess, + select, showHiddenFields, transactionID, }: CreateCacheKeyArgs): string => @@ -193,4 +200,5 @@ export const createDataloaderCacheKey = ({ overrideAccess, showHiddenFields, draft, + select, ]) diff --git a/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts b/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts index 8d2599094..1c11a2f3d 100644 --- a/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts +++ b/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts @@ -69,6 +69,7 @@ const populate = async ({ fallbackLocale, locale, overrideAccess, + select: relatedCollection.config.defaultPopulate, showHiddenFields, transactionID: req.transactionID, }), diff --git a/packages/richtext-lexical/src/features/relationship/server/index.ts b/packages/richtext-lexical/src/features/relationship/server/index.ts index bd8edc66e..cde6b0e0e 100644 --- a/packages/richtext-lexical/src/features/relationship/server/index.ts +++ b/packages/richtext-lexical/src/features/relationship/server/index.ts @@ -90,6 +90,7 @@ export const RelationshipFeature = createServerFeature< key: 'value', overrideAccess, req, + select: collection.config.defaultPopulate, showHiddenFields, }), ) diff --git a/packages/richtext-lexical/src/features/upload/server/feature.server.ts b/packages/richtext-lexical/src/features/upload/server/feature.server.ts index 5fee0e94c..d6a00bbad 100644 --- a/packages/richtext-lexical/src/features/upload/server/feature.server.ts +++ b/packages/richtext-lexical/src/features/upload/server/feature.server.ts @@ -261,6 +261,7 @@ export const UploadFeature = createServerFeature< key: 'value', overrideAccess, req, + select: collection.config.defaultPopulate, showHiddenFields, }), ) diff --git a/packages/richtext-lexical/src/populateGraphQL/populate.ts b/packages/richtext-lexical/src/populateGraphQL/populate.ts index bf208287f..555fa5672 100644 --- a/packages/richtext-lexical/src/populateGraphQL/populate.ts +++ b/packages/richtext-lexical/src/populateGraphQL/populate.ts @@ -1,4 +1,4 @@ -import type { PayloadRequest } from 'payload' +import type { PayloadRequest, SelectType } from 'payload' import { createDataloaderCacheKey } from 'payload' @@ -10,6 +10,7 @@ type Arguments = { key: number | string overrideAccess: boolean req: PayloadRequest + select?: SelectType showHiddenFields: boolean } @@ -23,6 +24,7 @@ export const populate = async ({ key, overrideAccess, req, + select, showHiddenFields, }: { collectionSlug: string @@ -46,6 +48,7 @@ export const populate = async ({ fallbackLocale: req.fallbackLocale!, locale: req.locale!, overrideAccess, + select, showHiddenFields, transactionID: req.transactionID!, }), diff --git a/packages/richtext-slate/src/data/populate.ts b/packages/richtext-slate/src/data/populate.ts index 593da378d..ec73fbdba 100644 --- a/packages/richtext-slate/src/data/populate.ts +++ b/packages/richtext-slate/src/data/populate.ts @@ -1,4 +1,4 @@ -import type { Collection, Field, PayloadRequest, RichTextField } from 'payload' +import type { Collection, Field, PayloadRequest, RichTextField, SelectType } from 'payload' import { createDataloaderCacheKey } from 'payload' @@ -13,6 +13,7 @@ type Arguments = { key: number | string overrideAccess?: boolean req: PayloadRequest + select?: SelectType showHiddenFields: boolean } @@ -26,6 +27,7 @@ export const populate = async ({ key, overrideAccess, req, + select, showHiddenFields, }: { collection: Collection @@ -44,6 +46,7 @@ export const populate = async ({ fallbackLocale: req.locale, locale: req.fallbackLocale, overrideAccess: typeof overrideAccess === 'undefined' ? false : overrideAccess, + select, showHiddenFields, transactionID: req.transactionID, }), diff --git a/packages/richtext-slate/src/data/recurseNestedFields.ts b/packages/richtext-slate/src/data/recurseNestedFields.ts index 0de737cf3..349fd507e 100644 --- a/packages/richtext-slate/src/data/recurseNestedFields.ts +++ b/packages/richtext-slate/src/data/recurseNestedFields.ts @@ -48,6 +48,7 @@ export const recurseNestedFields = ({ key: i, overrideAccess, req, + select: collection.config.defaultPopulate, showHiddenFields, }), ) @@ -69,6 +70,7 @@ export const recurseNestedFields = ({ key: i, overrideAccess, req, + select: collection.config.defaultPopulate, showHiddenFields, }), ) @@ -94,6 +96,7 @@ export const recurseNestedFields = ({ key: 'value', overrideAccess, req, + select: collection.config.defaultPopulate, showHiddenFields, }), ) @@ -114,6 +117,7 @@ export const recurseNestedFields = ({ key: field.name, overrideAccess, req, + select: collection.config.defaultPopulate, showHiddenFields, }), ) diff --git a/packages/richtext-slate/src/data/richTextRelationshipPromise.ts b/packages/richtext-slate/src/data/richTextRelationshipPromise.ts index bbcecf786..e1bd066b8 100644 --- a/packages/richtext-slate/src/data/richTextRelationshipPromise.ts +++ b/packages/richtext-slate/src/data/richTextRelationshipPromise.ts @@ -54,6 +54,7 @@ export const recurseRichText = ({ key: 'value', overrideAccess, req, + select: collection.config.defaultPopulate, showHiddenFields, }), ) @@ -93,6 +94,7 @@ export const recurseRichText = ({ key: 'value', overrideAccess, req, + select: collection.config.defaultPopulate, showHiddenFields, }), ) diff --git a/test/select/collections/Pages/index.ts b/test/select/collections/Pages/index.ts new file mode 100644 index 000000000..0f4248f70 --- /dev/null +++ b/test/select/collections/Pages/index.ts @@ -0,0 +1,84 @@ +import type { CollectionConfig } from 'payload' + +import { lexicalEditor, LinkFeature } from '@payloadcms/richtext-lexical' +import { slateEditor } from '@payloadcms/richtext-slate' + +// The TSlug generic can be passed to have type safety for `defaultPopulate`. +// If avoided, the `defaultPopulate` type resolves to `SelectType`. +export const Pages: CollectionConfig<'pages'> = { + slug: 'pages', + // I need only slug, NOT the WHOLE CONTENT! + defaultPopulate: { + slug: true, + }, + fields: [ + { + name: 'content', + type: 'blocks', + blocks: [ + { + slug: 'cta', + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'link', + type: 'group', + fields: [ + { + name: 'docPoly', + type: 'relationship', + relationTo: ['pages'], + }, + { + name: 'doc', + type: 'relationship', + relationTo: 'pages', + }, + { + name: 'docMany', + hasMany: true, + type: 'relationship', + relationTo: 'pages', + }, + { + name: 'docHasManyPoly', + type: 'relationship', + relationTo: ['pages'], + hasMany: true, + }, + { + name: 'label', + type: 'text', + required: true, + }, + ], + }, + { + name: 'richTextLexical', + type: 'richText', + editor: lexicalEditor({ + features({ defaultFeatures }) { + return [...defaultFeatures, LinkFeature({ enabledCollections: ['pages'] })] + }, + }), + }, + { + name: 'richTextSlate', + type: 'richText', + editor: slateEditor({}), + }, + ], + }, + ], + }, + { + name: 'slug', + type: 'text', + required: true, + }, + ], +} diff --git a/test/select/config.ts b/test/select/config.ts index 00e571018..8330e462f 100644 --- a/test/select/config.ts +++ b/test/select/config.ts @@ -6,6 +6,7 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { devUser } from '../credentials.js' import { DeepPostsCollection } from './collections/DeepPosts/index.js' import { LocalizedPostsCollection } from './collections/LocalizedPosts/index.js' +import { Pages } from './collections/Pages/index.js' import { PostsCollection } from './collections/Posts/index.js' import { VersionedPostsCollection } from './collections/VersionedPosts/index.js' @@ -19,6 +20,7 @@ export default buildConfigWithDefaults({ LocalizedPostsCollection, VersionedPostsCollection, DeepPostsCollection, + Pages, ], globals: [ { diff --git a/test/select/int.spec.ts b/test/select/int.spec.ts index 86b1fe02f..d9ef79e50 100644 --- a/test/select/int.spec.ts +++ b/test/select/int.spec.ts @@ -8,6 +8,7 @@ import type { DeepPost, GlobalPost, LocalizedPost, + Page, Post, VersionedPost, } from './payload-types.js' @@ -1561,6 +1562,141 @@ describe('Select', () => { }) }) }) + + describe('defaultPopulate', () => { + let homePage: Page + let aboutPage: Page + let expectedHomePage: { id: number | string; slug: string } + beforeAll(async () => { + homePage = await payload.create({ + depth: 0, + collection: 'pages', + data: { content: [], slug: 'home' }, + }) + expectedHomePage = { id: homePage.id, slug: homePage.slug } + aboutPage = await payload.create({ + depth: 0, + collection: 'pages', + data: { + content: [ + { + blockType: 'cta', + richTextSlate: [ + { + type: 'relationship', + relationTo: 'pages', + value: { id: homePage.id }, + }, + ], + richTextLexical: { + root: { + children: [ + { + format: '', + type: 'relationship', + version: 2, + relationTo: 'pages', + value: homePage.id, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }, + link: { + doc: homePage.id, + docHasManyPoly: [ + { + relationTo: 'pages', + value: homePage.id, + }, + ], + docMany: [homePage.id], + docPoly: { + relationTo: 'pages', + value: homePage.id, + }, + label: 'Visit our Home Page!', + }, + title: 'Contact Us', + }, + ], + slug: 'about', + }, + }) + }) + + it('local API - should populate with the defaultPopulate select shape', async () => { + const result = await payload.findByID({ collection: 'pages', depth: 1, id: aboutPage.id }) + + const { + content: [ + { + link: { doc, docHasManyPoly, docMany, docPoly }, + richTextSlate: [richTextSlateRel], + richTextLexical: { + root: { + children: [richTextLexicalRel], + }, + }, + }, + ], + } = result + + expect(doc).toStrictEqual(expectedHomePage) + expect(docMany).toStrictEqual([expectedHomePage]) + expect(docPoly).toStrictEqual({ + relationTo: 'pages', + value: expectedHomePage, + }) + expect(docHasManyPoly).toStrictEqual([ + { + relationTo: 'pages', + value: expectedHomePage, + }, + ]) + expect(richTextLexicalRel.value).toStrictEqual(expectedHomePage) + expect(richTextSlateRel.value).toStrictEqual(expectedHomePage) + }) + + it('REST API - should populate with the defaultPopulate select shape', async () => { + const restResult = await ( + await restClient.GET(`/pages/${aboutPage.id}`, { query: { depth: 1 } }) + ).json() + + const { + content: [ + { + link: { doc, docHasManyPoly, docMany, docPoly }, + richTextSlate: [richTextSlateRel], + richTextLexical: { + root: { + children: [richTextLexicalRel], + }, + }, + }, + ], + } = restResult + + expect(doc).toMatchObject(expectedHomePage) + expect(docMany).toMatchObject([expectedHomePage]) + expect(docPoly).toMatchObject({ + relationTo: 'pages', + value: expectedHomePage, + }) + expect(docHasManyPoly).toMatchObject([ + { + relationTo: 'pages', + value: expectedHomePage, + }, + ]) + expect(richTextLexicalRel.value).toMatchObject(expectedHomePage) + expect(richTextSlateRel.value).toMatchObject(expectedHomePage) + }) + }) }) function createPost() { diff --git a/test/select/payload-types.ts b/test/select/payload-types.ts index 664e8d4a2..9a3bee9d1 100644 --- a/test/select/payload-types.ts +++ b/test/select/payload-types.ts @@ -15,6 +15,7 @@ export interface Config { 'localized-posts': LocalizedPost; 'versioned-posts': VersionedPost; 'deep-posts': DeepPost; + pages: Page; users: User; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -25,6 +26,7 @@ export interface Config { 'localized-posts': LocalizedPostsSelect | LocalizedPostsSelect; 'versioned-posts': VersionedPostsSelect | VersionedPostsSelect; 'deep-posts': DeepPostsSelect | DeepPostsSelect; + pages: PagesSelect | PagesSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -239,6 +241,59 @@ export interface DeepPost { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "pages". + */ +export interface Page { + id: string; + content?: + | { + title: string; + link: { + docPoly?: { + relationTo: 'pages'; + value: string | Page; + } | null; + doc?: (string | null) | Page; + docMany?: (string | Page)[] | null; + docHasManyPoly?: + | { + relationTo: 'pages'; + value: string | Page; + }[] + | null; + label: string; + }; + richTextLexical?: { + root: { + type: string; + children: { + type: string; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + richTextSlate?: + | { + [k: string]: unknown; + }[] + | null; + id?: string | null; + blockName?: string | null; + blockType: 'cta'; + }[] + | null; + slug: string; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". @@ -279,6 +334,10 @@ export interface PayloadLockedDocument { relationTo: 'deep-posts'; value: string | DeepPost; } | null) + | ({ + relationTo: 'pages'; + value: string | Page; + } | null) | ({ relationTo: 'users'; value: string | User; @@ -520,6 +579,37 @@ export interface DeepPostsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "pages_select". + */ +export interface PagesSelect { + content?: + | T + | { + cta?: + | T + | { + title?: T; + link?: + | T + | { + docPoly?: T; + doc?: T; + docMany?: T; + docHasManyPoly?: T; + label?: T; + }; + richTextLexical?: T; + richTextSlate?: T; + id?: T; + blockName?: T; + }; + }; + slug?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select".