-
-
Save Livog/1c4673b264158d367ab2b423aacd7f4e to your computer and use it in GitHub Desktop.
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 |
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 I added these as I forgot that getParents has been updated, the idea is of course to use what comes in the plugin but for now I added the code.
Deepmerge is just a package that you can install or code yourself.
I've also added how I query and use the path field.
Thanks @Livog! I'll try this out this afternoon.
@Livog What version of Payload did you create this for? Still trying to implement this, your getParents method seems to include types that are now not existing anymore, such as Collections
. I'm going to try and see if I can get it to work using the getParents
method that is provided with nested-docs. Or is yours a lot different?
@notflip I updated getParents.ts because the types were not derived from the payload. I used a separate types file to keep my frequently used payload types organized. Hope this solves your issue.
I'm a bit confused is all,
I'm looking at your PR: https://github.com/payloadcms/payload/pull/6329/files
And it looks different from the files you're posting here, I also don't know what to do with getDocument, as for the path field, should I just include it inside my Pages.ts collection? As so: ...pathField(),
Would it be easier to try and use your PR in my project, instead of me fiddling around with these files here? Thanks for your time, I really need this feature
@notflip The PR I created is now very outdated, and I’ve repeatedly brought up that this field is missing in Payload but never received a solid answer from Payload Core. The solution you see here is my own approach to solving the problem, and it's the code I use in my personal Payload projects.
Thanks man! Let me try to get this working again, so I copy over all these files above here
- Where and how do you use the getDocument file here above? It doesn't seem to be used anywhere
- I see the
...pathField(PATH_UNIQUE_AGINST_COLLECTIONS, FIELD_TO_USE_FOR_PATH),
I'll try and use that
EDIT: I think I understand now, we don't use this, as mentioned in the PR
const { docs } = await payload.find({
collection: 'pages',
where: { path: { equals: path } },
depth: 3
});
const page = docs?.at(0) || null
But we use
const page = await getCachedDocumentByPath(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.
@Livog What are the contents of the
getParents
anddeepmerge
andgenerateRandomString
files? I'm going to test this code.EDIT: It seems like that getParents() method has changed, it's now imported as
import { getParents } from "@payloadcms/plugin-nested-docs"
, the arguments are also different