Skip to content
Draft
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
12 changes: 12 additions & 0 deletions packages/payload/src/collections/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import type {
TypedCollectionSelect,
TypedLocale,
} from '../../index.js'
import type { TemplatesConfig } from '../../templates/types.js'
import type {
PayloadRequest,
SelectIncludeType,
Expand Down Expand Up @@ -724,6 +725,17 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
* Cannot be used together with `folders` or `hierarchy`.
*/
tags?: boolean | TagsConfig
/**
* Opt-in to the Templates API for this collection.
*
* Pass `true` as shorthand for `{ save: true, create: true }`. Provide an object for
* granular control over the "Save as Template" / "Create from Template" affordances
* and their access control.
*
* @experimental
* @see https://github.com/payloadcms/payload/discussions/16515
*/
templates?: TemplatesConfig | true
/**
* Add `createdAt`, `deletedAt` and `updatedAt` fields
*
Expand Down
3 changes: 3 additions & 0 deletions packages/payload/src/collections/endpoints/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const createHandler: PayloadHandler = async (req) => {

const publishSpecificLocale = req.query.publishSpecificLocale as string | undefined

const templateID = req.query.templateID as number | string | undefined

const doc = await createOperation({
autosave,
collection,
Expand All @@ -26,6 +28,7 @@ export const createHandler: PayloadHandler = async (req) => {
publishSpecificLocale,
req,
select,
templateID,
})

return Response.json(
Expand Down
12 changes: 12 additions & 0 deletions packages/payload/src/collections/operations/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export type Arguments<TSlug extends CollectionSlug> = {
req: PayloadRequest
selectedLocales?: string[]
showHiddenFields?: boolean
templateID?: number | string
} & Pick<FindOptions<TSlug, SelectType>, 'select'>

export const createOperation = async <
Expand Down Expand Up @@ -117,10 +118,21 @@ export const createOperation = async <
select: incomingSelect,
selectedLocales,
showHiddenFields,
templateID,
} = args

let { data } = args

if (templateID) {
const { applyTemplate } = await import('../../templates/applyTemplate.js')
data = (await applyTemplate({
collection: collectionConfig,
data: data as JsonObject,
req,
templateID,
})) as RequiredDataFromCollectionSlug<TSlug>
}

const publishAllLocales =
!draft &&
(publishAllLocalesArg ?? (hasLocalizeStatusEnabled(collectionConfig) ? false : true))
Expand Down
12 changes: 12 additions & 0 deletions packages/payload/src/collections/operations/local/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,16 @@ type BaseOptions<TSlug extends CollectionSlug, TSelect extends SelectType> = {
* @default false
*/
showHiddenFields?: boolean
/**
* If you want to seed the new document from a saved template in `payload-templates`,
* pass the template document's ID. Template `data` is overlaid on top of field defaults
* and beneath any user-provided `data`. The merge runs through field hooks and
* validation as normal.
*
* @experimental
* @see https://github.com/payloadcms/payload/discussions/16515
*/
templateID?: number | string
// TODO: Strongly type User as TypedUser (= User in v4.0)
/**
* If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks.
Expand Down Expand Up @@ -206,6 +216,7 @@ export async function createLocal<
publishAllLocales,
select,
showHiddenFields,
templateID,
} = options

const collection = payload.collections[collectionSlug]
Expand Down Expand Up @@ -235,5 +246,6 @@ export async function createLocal<
req,
select,
showHiddenFields,
templateID,
})
}
11 changes: 11 additions & 0 deletions packages/payload/src/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { getPreferencesCollection, preferencesCollectionSlug } from '../preferen
import { getQueryPresetsConfig, queryPresetsCollectionSlug } from '../query-presets/config.js'
import { getDefaultJobsCollection, jobsCollectionSlug } from '../queues/config/collection.js'
import { getJobStatsGlobal } from '../queues/config/global.js'
import { getTemplatesCollection, templatesCollectionSlug } from '../templates/config.js'
import { flattenAllFields, flattenBlock } from '../utilities/flattenAllFields.js'
import { hasScheduledPublishEnabled } from '../utilities/getVersionsConfig.js'
import { validateTimezones } from '../utilities/validateTimezones.js'
Expand Down Expand Up @@ -199,6 +200,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
jobsCollectionSlug,
lockedDocumentsCollectionSlug,
preferencesCollectionSlug,
templatesCollectionSlug,
]

const dashboardWidgets = config.admin?.dashboard?.widgets ?? ([] as Widget[])
Expand Down Expand Up @@ -427,6 +429,15 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
),
)

configWithDefaults.collections!.push(
await sanitizeCollection(
config as unknown as Config,
getTemplatesCollection(config as unknown as Config),
richTextSanitizationPromises,
validRelationships,
),
)

const migrations = await sanitizeCollection(
config as unknown as Config,
migrationsCollection,
Expand Down
33 changes: 33 additions & 0 deletions packages/payload/src/errors/TemplateOutOfDate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { status as httpStatus } from 'http-status'

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

/**
* Thrown when an attempt to apply a template against a collection / block / field
* detects that the template's stored schema fingerprint no longer matches the live
* schema. The template is also stamped with `_isStale: true` as a side effect of
* the throw so that future picker queries filter it out.
*
* @experimental
* @see https://github.com/payloadcms/payload/discussions/16515
*/
export class TemplateOutOfDateError extends APIError {
templateID: number | string
templateTitle?: string

constructor({
templateID,
templateTitle,
}: {
templateID: number | string
templateTitle?: string
}) {
const label = templateTitle ? `"${templateTitle}"` : `(id: ${templateID})`
super(
`Template ${label} is out of date. Edit it to bring it up to date with the current schema, or delete it.`,
httpStatus.CONFLICT,
)
this.templateID = templateID
this.templateTitle = templateTitle
}
}
1 change: 1 addition & 0 deletions packages/payload/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export { MissingFile } from './MissingFile.js'
export { NotFound } from './NotFound.js'
export { QueryError } from './QueryError.js'
export { ReservedFieldName } from './ReservedFieldName.js'
export { TemplateOutOfDateError } from './TemplateOutOfDate.js'
export { UnauthorizedError } from './UnauthorizedError.js'
export { UnverifiedEmail } from './UnverifiedEmail.js'
export { ValidationError, ValidationErrorName } from './ValidationError.js'
Expand Down
20 changes: 18 additions & 2 deletions packages/payload/src/fields/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,14 @@ export type ArrayField = {
labels?: Labels
maxRows?: number
minRows?: number
/**
* Enable saving rows of this array as field templates, and inserting them via the row menu.
* Requires the host collection to have `templates` enabled.
*
* @experimental
* @see https://github.com/payloadcms/payload/discussions/16515
*/
templates?: true
type: 'array'
validate?: ArrayFieldValidation
} & Omit<FieldBase, 'validate'>
Expand All @@ -1409,7 +1417,7 @@ export type ArrayFieldClient = {
fields: ClientField[]
labels?: LabelsClient
} & FieldBaseClient &
Pick<ArrayField, 'interfaceName' | 'maxRows' | 'minRows' | 'type'>
Pick<ArrayField, 'interfaceName' | 'maxRows' | 'minRows' | 'templates' | 'type'>

export type RadioField = {
admin?: {
Expand Down Expand Up @@ -1660,6 +1668,14 @@ export type BlocksField = {
labels?: Labels
maxRows?: number
minRows?: number
/**
* Enable saving individual blocks (tier 2) and the entire field value (tier 3) as templates.
* Requires the host collection to have `templates` enabled.
*
* @experimental
* @see https://github.com/payloadcms/payload/discussions/16515
*/
templates?: true
type: 'blocks'
validate?: BlocksFieldValidation
} & Omit<FieldBase, 'validate'>
Expand All @@ -1676,7 +1692,7 @@ export type BlocksFieldClient = {
blocks: ClientBlock[]
labels?: LabelsClient
} & FieldBaseClient &
Pick<BlocksField, 'maxRows' | 'minRows' | 'type'>
Pick<BlocksField, 'maxRows' | 'minRows' | 'templates' | 'type'>

export type PointField = {
admin?: {
Expand Down
17 changes: 14 additions & 3 deletions packages/payload/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,16 @@ type Payload = BasePayload

interface RequestContext {
[key: string]: unknown
/**
* Set to `true` while saving, applying, or editing a Templates API template.
* Field hooks, validators, and access control may branch on this flag — for
* example, required-field validation is relaxed when `true` to allow partial
* templates.
*
* @experimental
* @see https://github.com/payloadcms/payload/discussions/16515
*/
isTemplate?: boolean
}

// eslint-disable-next-line @typescript-eslint/no-empty-object-type
Expand Down Expand Up @@ -1776,7 +1786,6 @@ export { getAncestors } from './hierarchy/utils/getAncestors.js'
export * from './kv/adapters/DatabaseKVAdapter.js'
export * from './kv/adapters/InMemoryKVAdapter.js'
export * from './kv/index.js'

export type {
CollapsedPreferences,
CollectionPreferences,
Expand All @@ -1792,6 +1801,7 @@ export type {
PreferenceUpdateRequest,
TabsPreferences,
} from './preferences/types.js'

export type { QueryPreset } from './query-presets/types.js'
export { jobAfterRead } from './queues/config/collection.js'
export type { JobsConfig, RunJobAccess, RunJobAccessArgs } from './queues/config/types/index.js'
Expand Down Expand Up @@ -1819,16 +1829,17 @@ export type {
WorkflowHandler,
WorkflowTypes,
} from './queues/config/types/workflowTypes.js'

export { JobCancelledError } from './queues/errors/index.js'

export { countRunnableOrActiveJobsForQueue } from './queues/operations/handleSchedules/countRunnableOrActiveJobsForQueue.js'
export { importHandlerPath } from './queues/operations/runJobs/runJob/importHandlerPath.js'

export {
_internal_jobSystemGlobals,
_internal_resetJobSystemGlobals,
getCurrentDate,
} from './queues/utilities/getCurrentDate.js'

export type { TemplateDocument, TemplateEntityType, TemplatesConfig } from './templates/types.js'
export { getLocalI18n } from './translations/getLocalI18n.js'
export * from './types/index.js'

Expand Down
Loading
Loading