Skip to content

Instantly share code, notes, and snippets.

@Livog
Last active February 14, 2025 07:02
Show Gist options
  • Save Livog/1c4673b264158d367ab2b423aacd7f4e to your computer and use it in GitHub Desktop.
Save Livog/1c4673b264158d367ab2b423aacd7f4e to your computer and use it in GitHub Desktop.
Payload CMS 3.0 Path Field to simplify frontend querying
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
const collectionPrefixMap = {
posts: '/blog'
}
export default function generateBreadcrumbsUrl(docs: any, lastDoc: any) {
const prefix = collectionPrefixMap[lastDoc._collection] ?? ''
const result = docs.reduce((url: any, doc: any) => `${url}/${doc.slug ?? ''}`, prefix)
if (result === '/home') {
return '/'
}
return result
}
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
}
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)
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]
}
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
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
const config = {
fields: [
...pathField(PATH_UNIQUE_AGINST_COLLECTIONS, FIELD_TO_USE_FOR_PATH),
]
}
@notflip
Copy link

notflip commented Feb 13, 2025

@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)

@Livog
Copy link
Author

Livog commented Feb 14, 2025

@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