Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/plugins/nested-docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ level and stores the following fields.
| `label` | The label of the breadcrumb. This field is automatically set to either the `collection.admin.useAsTitle` (if defined) or is set to the `ID` of the document. You can also dynamically define the `label` by passing a function to the options property of [`generateLabel`](#generateLabel). |
| `url` | The URL of the breadcrumb. By default, this field is undefined. You can manually define this field by passing a property called function to the plugin options property of [`generateURL`](#generateURL). |

#### Path

The `path` field is a string which dynamically populated from the last breadcrumb's `url`. It's also cross-collection
unique, so that you can query for documents solely by their path.

### Options

#### `collections`
Expand Down Expand Up @@ -171,6 +176,24 @@ own `breadcrumbs` field to each collection manually. Set this property to the `n
nested data structures like a `group`, `array`, or `blocks`.
</Banner>


#### `pathFieldSlug`

When defined, the `path` field will not be provided for you, and instead, expects you to add your
own `path` field to each collection manually. Set this property to the `name` of your custom field.

#### `uniquePathCollections`

If you want the `path` field to be unique across other collections then in your `collections` array or just non, you can add the slug
of the collection slugs you want the `path` field to be unique across. If not supplied, the path field will be unique across
the collections that the plugin is added to. Add an empty array to disable this behavior.

<Banner type="info">
<strong>Note:</strong>
<br />
If `pathFieldSlug` is set to `false` the `uniquePathCollections` option will be ignored.
</Banner>

## Overrides

You can also extend the built-in `parent` and `breadcrumbs` fields per collection by using the `createParentField`
Expand Down
20 changes: 20 additions & 0 deletions packages/plugin-nested-docs/src/fields/path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { TextField } from 'payload/types'

export const createPathField = (
overrides?: Partial<
TextField & {
hasMany: false
}
>,
): TextField => ({
name: 'path',
type: 'text',
index: true,
unique: true,
...(overrides || {}),
admin: {
position: 'sidebar',
readOnly: true,
...(overrides?.admin || {}),
},
})
23 changes: 22 additions & 1 deletion packages/plugin-nested-docs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import type { NestedDocsPluginConfig } from './types.js'
import { createBreadcrumbsField } from './fields/breadcrumbs.js'
import { createParentField } from './fields/parent.js'
import { parentFilterOptions } from './fields/parentFilterOptions.js'
import { createPathField } from './fields/path.js'
import { resaveChildren } from './hooks/resaveChildren.js'
import { resaveSelfAfterCreate } from './hooks/resaveSelfAfterCreate.js'
import { getParents } from './utilities/getParents.js'
import { populateBreadcrumbs } from './utilities/populateBreadcrumbs.js'
import { setPathFieldOrThrow } from './utilities/setPathFieldOrThrow.js'

export { createBreadcrumbsField, createParentField, getParents }
export { createBreadcrumbsField, createParentField, createPathField, getParents }

export const nestedDocsPlugin =
(pluginConfig: NestedDocsPluginConfig): Plugin =>
Expand All @@ -20,6 +22,8 @@ export const nestedDocsPlugin =
collections: (config.collections || []).map((collection) => {
if (pluginConfig.collections.indexOf(collection.slug) > -1) {
const fields = [...(collection?.fields || [])]
pluginConfig.pathFieldSlug ??= false
pluginConfig.uniquePathCollections ??= []

const existingBreadcrumbField = collection.fields.find(
(field) =>
Expand All @@ -30,6 +34,12 @@ export const nestedDocsPlugin =
(field) => 'name' in field && field.name === (pluginConfig?.parentFieldSlug || 'parent'),
) as SingleRelationshipField

const existingPathField = pluginConfig?.pathFieldSlug
? collection.fields.find(
(field) => 'name' in field && field.name === pluginConfig.pathFieldSlug,
)
: undefined

const defaultFilterOptions = parentFilterOptions(pluginConfig?.breadcrumbsFieldSlug)

if (existingParentField) {
Expand All @@ -48,6 +58,14 @@ export const nestedDocsPlugin =
fields.push(createBreadcrumbsField(collection.slug))
}

if (
!existingPathField &&
pluginConfig.pathFieldSlug !== false &&
pluginConfig.pathFieldSlug != null
) {
fields.push(createPathField({ name: pluginConfig.pathFieldSlug }))
}

return {
...collection,
fields,
Expand All @@ -61,6 +79,9 @@ export const nestedDocsPlugin =
beforeChange: [
async ({ data, originalDoc, req }) =>
populateBreadcrumbs(req, pluginConfig, collection, data, originalDoc),
...(pluginConfig.pathFieldSlug !== false || existingPathField
? [async (args) => setPathFieldOrThrow({ ...args, pluginConfig })]
: []),
...(collection?.hooks?.beforeChange || []),
],
},
Expand Down
10 changes: 10 additions & 0 deletions packages/plugin-nested-docs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,14 @@ export type NestedDocsPluginConfig = {
* Should be supplied if using an alternative field name for the 'parent' field in collections
*/
parentFieldSlug?: string
/**
* Needs to be set if you want to add a path field to your collection.
* If not supplied, the path field will not be added. False by default which means disabled by default.
*/
pathFieldSlug?: false | string
/**
* Collections that the path field should be unique across. If not supplied, the path field will be
* unique across the collections that the plugin is added to.
*/
uniquePathCollections?: string[]
}
96 changes: 96 additions & 0 deletions packages/plugin-nested-docs/src/utilities/setPathFieldOrThrow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { CollectionBeforeChangeHook, Where } from 'payload/types'

import { APIError } from 'payload/errors'

import type { NestedDocsPluginConfig } from '../types.js'

import { getParents } from './getParents.js'

type ExtendedBeforeChangeHook = (
args: Parameters<CollectionBeforeChangeHook>[0] & {
pluginConfig: NestedDocsPluginConfig
},
) => Promise<any>

function generateRandomString(length = 20) {
return [...Array(length)].map(() => Math.random().toString(36)[2]).join('')
}

type CalculateNewPathParams = Parameters<CollectionBeforeChangeHook>[0] & {
currentDoc: any
pluginConfig: NestedDocsPluginConfig
}

/**
* We can't soloy relay on the breadcrumbs field to generate the path because it's not guaranteed to be populated nor
* exisit for the collection. User might have opted to only have `parent` field & path field.
*/
export async function calculateNewPath({
collection,
currentDoc,
operation,
pluginConfig,
req,
}: CalculateNewPathParams): Promise<string> {
const isAutoSave = operation === 'create' && currentDoc?._status === 'draft'
if (isAutoSave) return `/${currentDoc?.id || generateRandomString(20)}`
const breadcrumbsFieldSlug = pluginConfig?.breadcrumbsFieldSlug || 'breadcrumbs'
const newPath = currentDoc?.[breadcrumbsFieldSlug]?.at(-1)?.url
if (newPath) return newPath
const docs = await getParents(req, pluginConfig, collection, currentDoc, [currentDoc])

return (
pluginConfig.generateURL?.(docs, currentDoc) || `/${currentDoc?.id || generateRandomString(20)}`
)
}

export const setPathFieldOrThrow: ExtendedBeforeChangeHook = async (args) => {
const { collection, data, originalDoc, pluginConfig, req } = args
const collections = pluginConfig?.collections || []
const pathFieldSlug =
pluginConfig?.pathFieldSlug !== false ? pluginConfig?.pathFieldSlug || 'path' : false
const uniquePathCollections =
pluginConfig?.uniquePathCollections?.length > 0
? pluginConfig?.uniquePathCollections
: collections.length > 0
? collections
: []

if (!uniquePathCollections.includes(collection.slug) || !pathFieldSlug) return data

const currentDoc = { ...originalDoc, ...data }
const newPath = await calculateNewPath({ ...args, currentDoc })

const collectionDocsThatConflict = await Promise.all(
uniquePathCollections.map((collectionSlug) => {
const where: Where = {
[pathFieldSlug]: {
equals: newPath,
},
}
if (originalDoc && originalDoc?.id && collectionSlug === collection.slug) {
where.id = { not_equals: originalDoc.id }
}
return req.payload.find({
collection: collectionSlug,
where,
})
}),
)

const foundDocument = collectionDocsThatConflict
.find((result) => result.docs.length > 0)
?.docs.at(0)
const willConflict = !!foundDocument
if (willConflict) {
const error = new APIError(
`The new path "${newPath}" will create a conflict with document: ${foundDocument.id}.`,
400,
[{ field: pathFieldSlug, message: `New path would conflict with another document.` }],
false,
)
throw error
}
data[pathFieldSlug] = newPath
return data
}