Last active
February 14, 2025 07:02
-
-
Save Livog/1c4673b264158d367ab2b423aacd7f4e to your computer and use it in GitHub Desktop.
Payload CMS 3.0 Path Field to simplify frontend querying
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { CollectionSlug } from 'payload' | |
export const PATH_UNIQUE_AGINST_COLLECTIONS = ['pages', 'posts'] as const satisfies CollectionSlug[] | |
export const FIELD_TO_USE_FOR_PATH = 'slug' as const |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export default function generateRandomString(length: number, characters = 'abcdefghijklmnopqrstuvwxyz0123456789'): string { | |
let result = '' | |
const charactersLength = characters.length | |
for (let i = 0; i < length; i++) { | |
result += characters.charAt(Math.floor(Math.random() * charactersLength)) | |
} | |
return result | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { getPayload } from '@/lib/payload' | |
import { PATH_UNIQUE_AGINST_COLLECTIONS } from '@/payload/fields/path/config' | |
import normalizePath from '@/utilities/normalizePath' | |
import { Config } from '@payload-types' | |
import { draftMode } from 'next/headers' | |
import type { CollectionSlug } from 'payload' | |
import { cache } from 'react' | |
type PathUniqueCollection = (typeof PATH_UNIQUE_AGINST_COLLECTIONS)[number] | |
type CollectionDocument<K extends keyof Config['collections']> = Config['collections'][K] & { _collection: K } | |
type CollectionDocuments = { | |
[K in keyof Config['collections']]: CollectionDocument<K> | |
}[keyof Config['collections']] | |
type PathUniqueCollectionDocuments = { | |
[K in PathUniqueCollection]: CollectionDocument<K> | |
}[PathUniqueCollection] | |
export async function getDocumentByPath<S extends keyof Config['collections']>( | |
path: string | string[], | |
collection: S | |
): Promise<CollectionDocument<S> | null> | |
export async function getDocumentByPath(path: string | string[]): Promise<PathUniqueCollectionDocuments | null> | |
export async function getDocumentByPath(path: string | string[], collection?: CollectionSlug): Promise<CollectionDocuments | null> { | |
const { isEnabled: draft } = await draftMode() | |
const payload = await getPayload() | |
const normalizedPath = normalizePath(path, false) | |
const collectionsToSearch = collection ? [collection] : PATH_UNIQUE_AGINST_COLLECTIONS | |
const queries = collectionsToSearch.map((collectionSlug) => | |
payload | |
.find({ | |
collection: collectionSlug, | |
draft, | |
limit: 1, | |
overrideAccess: draft, | |
where: { path: { equals: normalizedPath } } | |
}) | |
.then((result) => { | |
const doc = result.docs.at(0) | |
if (!doc) return null | |
return { | |
...doc, | |
_collection: collectionSlug | |
} as CollectionDocuments | |
}) | |
.catch(() => null) | |
) | |
const results = (await Promise.allSettled(queries)).filter( | |
(v): v is PromiseFulfilledResult<CollectionDocuments | null> => v.status === 'fulfilled' | |
) | |
return results.find((result) => result.value !== null)?.value ?? null | |
} | |
export const getCachedDocumentByPath = cache(getDocumentByPath) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { Collections, CollectionSlug } from '@/payload/types' | |
import type { BasePayload } from 'payload' | |
type GetParentsParams<S extends CollectionSlug> = { | |
payload: BasePayload | |
parentFieldSlug?: string | |
collectionSlug: S | |
doc: Collections[S] | |
docs?: Array<Collections[S]> | |
} | |
export const getParents = async <S extends CollectionSlug>({ | |
payload, | |
parentFieldSlug = 'parent', | |
collectionSlug, | |
doc, | |
docs = [] | |
}: GetParentsParams<S>): Promise<Array<Collections[S]>> => { | |
const parent = doc[parentFieldSlug] | |
if (!parent) { | |
return docs | |
} | |
let retrievedParent | |
if (typeof parent === 'string' || typeof parent === 'number') { | |
retrievedParent = await payload.findByID({ | |
id: parent, | |
collection: collectionSlug, | |
depth: 0, | |
disableErrors: true | |
}) | |
} else if (typeof parent === 'object') { | |
retrievedParent = parent | |
} else { | |
return docs | |
} | |
if (!retrievedParent) { | |
return docs | |
} | |
if (retrievedParent[parentFieldSlug]) { | |
return getParents({ | |
payload, | |
parentFieldSlug, | |
collectionSlug, | |
doc: retrievedParent, | |
docs: [retrievedParent, ...docs] | |
}) | |
} | |
return [retrievedParent, ...docs] | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import generateBreadcrumbsUrl from '@/payload/utilities/generateBreadcrumbsUrl' | |
import deepmerge from 'deepmerge' | |
import type { BasePayload, Field, Payload, Where } from 'payload' | |
import { APIError } from 'payload' | |
import generateRandomString from '@/payload/utilities/generateRandomString' | |
import { getParents } from './getParents' | |
import type { CollectionSlug } from 'payload' | |
type WillPathConflictParams = { | |
payload: Payload | |
path: string | |
originalDoc?: { id?: string } | |
collection: CollectionSlug | |
uniquePathFieldCollections?: CollectionSlug[] | ReadonlyArray<CollectionSlug> | |
} | |
export const willPathConflict = async ({ | |
payload, | |
path, | |
originalDoc, | |
collection, | |
uniquePathFieldCollections = [] | |
}: WillPathConflictParams): Promise<boolean> => { | |
if (!payload || !uniquePathFieldCollections.includes(collection)) return false | |
const queries = uniquePathFieldCollections.map((targetCollection) => { | |
const whereCondition: Where = { | |
path: { equals: path } | |
} | |
if (originalDoc?.id && collection === targetCollection) { | |
whereCondition.id = { not_equals: originalDoc.id } | |
} | |
return payload.find({ | |
collection: targetCollection, | |
where: whereCondition, | |
limit: 1, | |
pagination: false | |
}) | |
}) | |
const results = await Promise.allSettled(queries) | |
return results.some((result) => result.status === 'fulfilled' && (result as PromiseFulfilledResult<any>).value.docs.length > 0) | |
} | |
type GenerateDocumentPathParams = { | |
payload: BasePayload | |
collection: CollectionSlug | |
currentDoc: any | |
operation?: string | |
fieldToUse: string | |
} | |
export async function generateDocumentPath({ | |
payload, | |
collection, | |
currentDoc, | |
operation, | |
fieldToUse | |
}: GenerateDocumentPathParams): Promise<string> { | |
if (!currentDoc?.[fieldToUse] || !collection) { | |
return `/${currentDoc?.id || generateRandomString(20)}` | |
} | |
const breadcrumbs = currentDoc?.breadcrumbs | |
const newPath = breadcrumbs?.at(-1)?.url | |
if (newPath) return newPath | |
const docs = await getParents({ | |
payload, | |
parentFieldSlug: 'parent', | |
collectionSlug: collection, | |
doc: currentDoc, | |
docs: [currentDoc] | |
}) | |
return generateBreadcrumbsUrl(docs, currentDoc) | |
} | |
const pathField = ( | |
uniquePathFieldCollections: CollectionSlug[] | ReadonlyArray<CollectionSlug>, | |
fieldToUse: string, | |
overrides?: Partial<Field> | |
): Field[] => { | |
return [ | |
{ | |
name: '_collection', | |
type: 'text', | |
admin: { | |
hidden: true | |
}, | |
virtual: true, | |
hooks: { | |
beforeValidate: [({ collection }) => collection?.slug || null] | |
} | |
}, | |
deepmerge<Field>( | |
{ | |
type: 'text', | |
name: 'path', | |
unique: true, | |
index: true, | |
hooks: { | |
beforeDuplicate: [ | |
() => { | |
return `/${generateRandomString(20)}` | |
} | |
], | |
beforeChange: [ | |
async ({ collection, data, req, siblingData, originalDoc, operation }) => { | |
if (!collection) { | |
throw new APIError( | |
'Collection is null.', | |
400, | |
[ | |
{ | |
field: fieldToUse, | |
message: 'Collection is required.' | |
} | |
], | |
false | |
) | |
} | |
const currentDoc = { ...originalDoc, ...siblingData } | |
const newPath = await generateDocumentPath({ | |
payload: req.payload, | |
collection: collection.slug as CollectionSlug, | |
currentDoc, | |
operation, | |
fieldToUse | |
}) | |
const isNewPathConflicting = await willPathConflict({ | |
payload: req.payload, | |
path: newPath, | |
originalDoc, | |
collection: collection.slug as CollectionSlug, | |
uniquePathFieldCollections | |
}) | |
if (isNewPathConflicting) { | |
throw new APIError( | |
`This ${fieldToUse} will create a conflict with an existing path.`, | |
400, | |
[ | |
{ | |
field: fieldToUse, | |
message: `This ${fieldToUse} will create a conflict with an existing path.` | |
} | |
], | |
false | |
) | |
} | |
if (data) data.path = newPath | |
return newPath | |
} | |
] | |
}, | |
admin: { | |
position: 'sidebar', | |
readOnly: true | |
} | |
}, | |
overrides || {} | |
) | |
] | |
} | |
export default pathField |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const normalizePath = (path?: string | string[] | null | undefined, keepTrailingSlash = false): string => { | |
if (!path) return '/' | |
if (Array.isArray(path)) path = path.join('/') | |
path = `/${path}/`.replace(/\/+/g, '/') | |
path = path !== '/' && !keepTrailingSlash ? path.replace(/\/$/, '') : path | |
return path | |
} | |
export default normalizePath |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const config = { | |
fields: [ | |
...pathField(PATH_UNIQUE_AGINST_COLLECTIONS, FIELD_TO_USE_FOR_PATH), | |
] | |
} |
Here is a part of my /app/(frontend)/[[...path]].ts
file, hope it explains a bit more:
export default async function Page({ params, searchParams }: PageProps) {
const path = await extractPath(params)
const document = await getDocumentByPath(path)
const contentProps = {
searchParams: await searchParams,
path
}
if (!document) {
notFound()
}
if (!['pages', 'posts'].includes(document._collection)) return null
const { isEnabled: draft } = await draftMode()
return (
<>
{draft && <LivePreviewListener />}
{document._collection == 'pages' && <PageContent document={document} {...contentProps} />}
{document._collection == 'posts' && <PostContent document={document} {...contentProps} />}
</>
)
}
How you use the document is fully up to you.
@Livog It's working now! This is pretty amazing, my CmsLink component is now also a lot simpler (without mapping a slug to a collection). Thanks again for this.
@Livog One more question, you're using react cache, but how do you invalidate that? The nextjs cache can be revalidated using revalidateTag.
export const getCachedDocumentByPath = cache(getDocumentByPath)
@notflip Cache is to memorize a function call per request so it only lives once per request, and since I'm currently using Cloudflare I don't want to cache things both internally in .next folder and in the CDN.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thanks man! Let me try to get this working again, so I copy over all these files above here
...pathField(PATH_UNIQUE_AGINST_COLLECTIONS, FIELD_TO_USE_FOR_PATH),
I'll try and use thatEDIT: I think I understand now, we don't use this, as mentioned in the PR
But we use