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".