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), | |
] | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@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.