Compare commits

...

7 Commits

Author SHA1 Message Date
Sasha
f0b96860f8 fix cascade publish when relationship has newer draft / autosave versions 2025-03-07 21:56:57 +02:00
Sasha
817d80752e fix blocks 2025-03-06 21:37:11 +02:00
Sasha
f620b081b6 merge 2025-03-06 20:57:06 +02:00
Sasha
ee76ee1223 feat(richtext-slate): cascade publish for Slate nodes 2025-02-13 13:05:11 +02:00
Sasha
7640a2091d feat(richtext-lexical): cascade publish for Lexical nodes (blocks. links, relationships) 2025-02-13 12:43:57 +02:00
Sasha
12392d4a94 feat: handle globals and the create operation 2025-02-13 11:33:56 +02:00
Sasha
f110a3fe8f feat: cascade publish 2025-02-12 22:24:23 +02:00
25 changed files with 1040 additions and 5 deletions

View File

@@ -5,9 +5,14 @@ import type { JSONSchema4 } from 'json-schema'
import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js'
import type { Config, PayloadComponent, SanitizedConfig } from '../config/types.js'
import type { ValidationFieldError } from '../errors/ValidationError.js'
import type { FieldAffectingData, RichTextField, Validate } from '../fields/config/types.js'
import type {
FieldAffectingData,
FlattenedField,
RichTextField,
Validate,
} from '../fields/config/types.js'
import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import type { RequestContext } from '../index.js'
import type { CollectionSlug, RequestContext } from '../index.js'
import type { JsonObject, PayloadRequest, PopulateType } from '../types/index.js'
import type { RichTextFieldClientProps } from './fields/RichText.js'
import type { FieldSchemaMap } from './types.js'
@@ -234,6 +239,16 @@ type RichTextAdapterBase<
interfaceNameDefinitions: Map<string, JSONSchema4>
isRequired: boolean
}) => JSONSchema4
/**
* Executes onRelationship for every relationship field in the richtext data.
* Executes onFields for every node that has its fields in the richtext data.
*/
traverseData?: (args: {
data: Value
field: RichTextField<Value, AdapterProps, ExtraFieldProperties>
onFields?: (args: { data: any; fields: FlattenedField[] }) => void
onRelationship?: (args: { collectionSlug: CollectionSlug; id: number | string }) => void
}) => void
validate: Validate<
Value,
Value,

View File

@@ -35,6 +35,7 @@ import { commitTransaction } from '../../utilities/commitTransaction.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields.js'
import { handleCascadePublish } from '../../versions/handleCascadePublish.js'
import { saveVersion } from '../../versions/saveVersion.js'
import { buildAfterOperation } from './utils.js'
@@ -239,6 +240,20 @@ export const createOperation = async <
await uploadFiles(payload, filesToUpload, req)
}
if (
collectionConfig.versions.drafts &&
collectionConfig.versions.drafts.cascadePublish &&
!draft &&
data._status !== 'draft'
) {
await handleCascadePublish({
collectionSlug: collectionConfig.slug,
doc: resultWithLocales,
fields: collectionConfig.flattenedFields,
req,
})
}
// /////////////////////////////////////
// Create
// /////////////////////////////////////

View File

@@ -33,6 +33,7 @@ import { deleteAssociatedFiles } from '../../../uploads/deleteAssociatedFiles.js
import { uploadFiles } from '../../../uploads/uploadFiles.js'
import { checkDocumentLockStatus } from '../../../utilities/checkDocumentLockStatus.js'
import { getLatestCollectionVersion } from '../../../versions/getLatestCollectionVersion.js'
import { handleCascadePublish } from '../../../versions/handleCascadePublish.js'
import { saveVersion } from '../../../versions/saveVersion.js'
export type SharedUpdateDocumentArgs<TSlug extends CollectionSlug> = {
@@ -262,6 +263,21 @@ export const updateDocument = async <
docWithLocales: publishedDocWithLocales,
})
if (
collectionConfig.versions.drafts &&
collectionConfig.versions.drafts.cascadePublish &&
!draftArg &&
data._status !== 'draft'
) {
await handleCascadePublish({
collectionSlug: collectionConfig.slug,
doc: result,
fields: collectionConfig.flattenedFields,
publishSpecificLocale,
req,
})
}
// /////////////////////////////////////
// Handle potential password update
// /////////////////////////////////////

View File

@@ -99,6 +99,8 @@ export const sanitizeGlobal = async (
if (global.versions.drafts === true) {
global.versions.drafts = {
autosave: false,
cascadePublish: false,
schedulePublish: false,
validate: false,
}
}

View File

@@ -28,6 +28,7 @@ import { getSelectMode } from '../../utilities/getSelectMode.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { getLatestGlobalVersion } from '../../versions/getLatestGlobalVersion.js'
import { handleCascadePublish } from '../../versions/handleCascadePublish.js'
import { saveVersion } from '../../versions/saveVersion.js'
type Args<TSlug extends GlobalSlug> = {
@@ -240,6 +241,20 @@ export const updateOperation = async <
docWithLocales: publishedDocWithLocales,
})
if (
globalConfig.versions?.drafts &&
globalConfig.versions.drafts.cascadePublish &&
!draftArg &&
data._status !== 'draft'
) {
await handleCascadePublish({
doc: result,
fields: globalConfig.flattenedFields,
publishSpecificLocale,
req,
})
}
// /////////////////////////////////////
// Update
// /////////////////////////////////////

View File

@@ -0,0 +1,350 @@
import type { FlattenedField, RelationshipField } from '../fields/config/types.js'
import type { CollectionSlug } from '../index.js'
import type { JsonObject, Payload, PayloadRequest } from '../types/index.js'
const extractID = (value: unknown): number | string => {
if (value && typeof value === 'object' && 'id' in value) {
return value.id as number | string
}
return value as number | string
}
type Relation = {
collectionSlug: CollectionSlug
id: number | string
}
const collectRelationField = ({
addRelation,
field,
fieldData,
payload,
}: {
addRelation: (relation: Relation) => void
field: RelationshipField
fieldData: any
payload: Payload
}) => {
if (!fieldData) {
return
}
if (field.hasMany) {
if (!Array.isArray(fieldData)) {
return
}
fieldData.forEach((relation) => {
if (!relation) {
return
}
if (Array.isArray(field.relationTo)) {
if (!relation?.relationTo || !relation?.value) {
return
}
if (payload.collections[relation.relationTo]?.config?.versions?.drafts) {
addRelation({ id: extractID(relation.value), collectionSlug: relation.relationTo })
}
return
}
if (payload.collections[field.relationTo].config.versions.drafts) {
addRelation({ id: extractID(relation), collectionSlug: field.relationTo })
}
})
return
}
if (Array.isArray(field.relationTo)) {
if (!fieldData?.relationTo || !fieldData?.value) {
return
}
if (payload.collections[fieldData.relationTo]?.config?.versions?.drafts) {
addRelation({ id: extractID(fieldData.value), collectionSlug: fieldData.relationTo })
}
return
}
if (payload.collections[field.relationTo].config.versions.drafts) {
addRelation({ id: extractID(fieldData), collectionSlug: field.relationTo })
}
}
const collectRelationsToPublish = ({
addRelation,
data,
fields,
payload,
}: {
addRelation: (relation: { collectionSlug: CollectionSlug; id: number | string }) => void
data: Record<string, unknown>
fields: FlattenedField[]
payload: Payload
}) => {
for (const field of fields) {
const fieldData = data[field.name]
if (!fieldData) {
continue
}
switch (field.type) {
case 'array':
case 'blocks': {
if (field.localized && payload.config.localization) {
if (typeof fieldData !== 'object') {
break
}
for (const locale in fieldData) {
const localeData = (fieldData as any)[locale]
if (!Array.isArray(localeData)) {
continue
}
localeData.forEach((item) => {
if (!item) {
return
}
let fields: FlattenedField[]
if (field.type === 'blocks') {
const block = field.blocks.find((each) => each.slug === item.blockType)
if (!block) {
return
}
fields = block.flattenedFields
} else {
fields = field.flattenedFields
}
collectRelationsToPublish({
addRelation,
data: item,
fields,
payload,
})
})
}
break
}
if (!Array.isArray(fieldData)) {
break
}
fieldData.forEach((item) => {
if (!item) {
return
}
let fields: FlattenedField[]
if (field.type === 'blocks') {
const block = field.blocks.find((each) => each.slug === item.blockType)
if (!block) {
return
}
fields = block.flattenedFields
} else {
fields = field.flattenedFields
}
collectRelationsToPublish({
addRelation,
data: item,
fields,
payload,
})
})
break
}
case 'group':
case 'tab': {
if (typeof fieldData !== 'object') {
break
}
if (field.localized && payload.config.localization) {
for (const locale in fieldData) {
const localeData = (fieldData as any)[locale]
if (!localeData || typeof localeData !== 'object') {
continue
}
collectRelationsToPublish({
addRelation,
data: localeData,
fields: field.flattenedFields,
payload,
})
}
break
}
collectRelationsToPublish({
addRelation,
data: fieldData as any,
fields: field.flattenedFields,
payload,
})
break
}
case 'relationship':
collectRelationField({ addRelation, field, fieldData, payload })
break
case 'richText':
if (typeof field.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
if (!field.editor?.traverseData) {
break
}
if (!fieldData || typeof fieldData !== 'object') {
break
}
if (field.localized && payload.config.localization) {
for (const locale in fieldData) {
const localeData = (fieldData as any)[locale]
if (!localeData || typeof localeData !== 'object') {
continue
}
field.editor.traverseData({
data: localeData,
field,
onFields: ({ data, fields }) => {
collectRelationsToPublish({ addRelation, data, fields, payload })
},
onRelationship: ({ id, collectionSlug }) => {
collectRelationField({
addRelation,
field: {
name: 'relation',
type: 'relationship',
relationTo: collectionSlug,
},
fieldData: id,
payload,
})
},
})
}
break
}
field.editor.traverseData({
data: fieldData,
field,
onFields: ({ data, fields }) => {
collectRelationsToPublish({ addRelation, data, fields, payload })
},
onRelationship: ({ id, collectionSlug }) => {
collectRelationField({
addRelation,
field: {
name: 'relation',
type: 'relationship',
relationTo: collectionSlug,
},
fieldData: id,
payload,
})
},
})
break
}
}
}
export const handleCascadePublish = async ({
collectionSlug,
doc,
fields,
publishSpecificLocale,
req,
}: {
collectionSlug?: string
doc: JsonObject
fields: FlattenedField[]
publishSpecificLocale?: string
req: PayloadRequest
}) => {
const relationsToPublish: Record<CollectionSlug, (number | string)[]> = {}
const addRelation = (relation: Relation) => {
// Skip cascade itself
if (collectionSlug && relation.collectionSlug === collectionSlug && relation.id === doc.id) {
return
}
if (!relationsToPublish[relation.collectionSlug]) {
relationsToPublish[relation.collectionSlug] = []
}
if (relationsToPublish[relation.collectionSlug].some((doc) => doc === relation.id)) {
return
}
relationsToPublish[relation.collectionSlug].push(relation.id)
}
collectRelationsToPublish({
addRelation,
data: doc,
fields,
payload: req.payload,
})
if (Object.keys(relationsToPublish).length === 0) {
return
}
for (const collectionSlug in relationsToPublish) {
try {
await req.payload.update({
collection: collectionSlug,
data: {
_status: 'published',
},
depth: 0,
draft: true,
publishSpecificLocale,
req,
where: {
and: [
{
id: {
in: relationsToPublish[collectionSlug],
},
},
{
_status: {
equals: 'draft',
},
},
],
},
})
} catch (err) {
// Eat the error
req.payload.logger.error({
err,
msg: `Error publishing cascade of collection ${collectionSlug}, ids - ${JSON.stringify(relationsToPublish[collectionSlug])}`,
})
}
}
}

View File

@@ -14,6 +14,7 @@ export type IncomingDrafts = {
* To enable, set to true or pass an object with options.
*/
autosave?: Autosave | boolean
cascadePublish?: boolean
/**
* Allow for editors to schedule publish / unpublish events in the future.
*/
@@ -32,6 +33,7 @@ export type SanitizedDrafts = {
* To enable, set to true or pass an object with options.
*/
autosave: Autosave | false
cascadePublish: boolean
/**
* Allow for editors to schedule publish / unpublish events in the future.
*/

View File

@@ -16,6 +16,7 @@ import { i18n } from './i18n.js'
import { getBlockMarkdownTransformers } from './markdownTransformer.js'
import { ServerBlockNode } from './nodes/BlocksNode.js'
import { ServerInlineBlockNode } from './nodes/InlineBlocksNode.js'
import { traverseNodeDataHOC } from './traverseNodeData.js'
import { blockValidationHOC } from './validate.js'
export type BlocksFeatureProps = {
@@ -205,6 +206,7 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
nodes: [
createNode({
traverseNodeData: traverseNodeDataHOC(blockConfigs),
// @ts-expect-error - TODO: fix this
getSubFields: ({ node }) => {
if (!node) {

View File

@@ -0,0 +1,28 @@
import { type Block, flattenAllFields } from 'payload'
import type { TraverseNodeData } from '../../typesServer.js'
import type { SerializedBlockNode } from './nodes/BlocksNode.js'
import type { SerializedInlineBlockNode } from './nodes/InlineBlocksNode.js'
export const traverseNodeDataHOC = (
blocks: Block[],
): TraverseNodeData<SerializedBlockNode | SerializedInlineBlockNode> => {
const traverseNodeData: TraverseNodeData<SerializedBlockNode | SerializedInlineBlockNode> = ({
node,
onFields,
}) => {
const blockFieldData = node.fields
// find block used in this node
const block = blocks.find((block) => block.slug === blockFieldData.blockType)
if (!block || !block?.fields?.length || !blockFieldData) {
return
}
if (onFields) {
onFields({ data: blockFieldData, fields: flattenAllFields({ fields: block.fields }) })
}
}
return traverseNodeData
}

View File

@@ -22,6 +22,7 @@ import { LinkNode } from '../nodes/LinkNode.js'
import { linkPopulationPromiseHOC } from './graphQLPopulationPromise.js'
import { i18n } from './i18n.js'
import { transformExtraFields } from './transformExtraFields.js'
import { traverseNodeDataHOC } from './traverseNodeData.js'
import { linkValidation } from './validate.js'
export type ExclusiveLinkCollectionsProps =
@@ -261,6 +262,7 @@ export const LinkFeature = createServerFeature<
},
graphQLPopulationPromises: [linkPopulationPromiseHOC(props)],
node: LinkNode,
traverseNodeData: traverseNodeDataHOC(props),
validations: [linkValidation(props, sanitizedFieldsWithoutText)],
}),
].filter(Boolean) as Array<NodeWithHooks>,

View File

@@ -0,0 +1,22 @@
import { flattenAllFields } from 'payload'
import type { TraverseNodeData } from '../../typesServer.js'
import type { SerializedLinkNode } from '../nodes/types.js'
import type { LinkFeatureServerProps } from './index.js'
export const traverseNodeDataHOC = (
props: LinkFeatureServerProps,
): TraverseNodeData<SerializedLinkNode> => {
return ({ node, onFields }) => {
if (!props.fields?.length) {
return
}
if (onFields && Array.isArray(props.fields)) {
onFields({
data: node.fields,
fields: flattenAllFields({ fields: props.fields }),
})
}
}
}

View File

@@ -6,6 +6,7 @@ import { createNode } from '../../typeUtilities.js'
import { relationshipPopulationPromiseHOC } from './graphQLPopulationPromise.js'
import { i18n } from './i18n.js'
import { RelationshipServerNode } from './nodes/RelationshipNode.js'
import { traverseNodeData } from './traverseNodeData.js'
export type ExclusiveRelationshipFeatureProps =
| {
@@ -102,6 +103,7 @@ export const RelationshipFeature = createServerFeature<
],
},
node: RelationshipServerNode,
traverseNodeData,
}),
],
}

View File

@@ -0,0 +1,19 @@
import type { TraverseNodeData } from '../../typesServer.js'
import type { SerializedRelationshipNode } from './nodes/RelationshipNode.js'
export const traverseNodeData: TraverseNodeData<SerializedRelationshipNode> = ({
node,
onRelationship,
}) => {
if (node?.value) {
// @ts-expect-error
const id = node?.value?.id || node?.value // for backwards-compatibility
if (node?.relationTo && onRelationship) {
onRelationship({
id,
collectionSlug: node.relationTo,
})
}
}
}

View File

@@ -8,15 +8,19 @@ import type {
SerializedLexicalNode,
} from 'lexical'
import type {
CollectionSlug,
Config,
Field,
FieldSchemaMap,
FlattenedField,
JsonObject,
Payload,
PayloadComponent,
PayloadRequest,
PopulateType,
ReplaceAny,
RequestContext,
RichTextAdapter,
RichTextField,
RichTextHooks,
SanitizedConfig,
@@ -55,6 +59,12 @@ export type PopulationPromise<T extends SerializedLexicalNode = SerializedLexica
siblingDoc: JsonObject
}) => void
export type TraverseNodeData<T extends SerializedLexicalNode = SerializedLexicalNode> = (args: {
node: T
onFields?: (args: { data: any; fields: FlattenedField[] }) => void
onRelationship?: (args: { collectionSlug: CollectionSlug; id: number | string }) => void
}) => void
export type NodeValidation<T extends SerializedLexicalNode = SerializedLexicalNode> = ({
node,
nodeValidations,
@@ -257,6 +267,7 @@ export type NodeWithHooks<T extends LexicalNode = any> = {
graphQLPopulationPromises?: Array<
PopulationPromise<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>
>
/**
* Just like payload fields, you can provide hooks which are run for this specific node. These are called Node Hooks.
*/
@@ -272,6 +283,7 @@ export type NodeWithHooks<T extends LexicalNode = any> = {
* The actual lexical node needs to be provided here. This also supports [lexical node replacements](https://lexical.dev/docs/concepts/node-replacement).
*/
node: Klass<T> | LexicalNodeReplacement
traverseNodeData?: TraverseNodeData<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>
/**
* This allows you to provide node validations, which are run when your document is being validated, alongside other payload fields.
* You can use it to throw a validation error for a specific node in case its data is incorrect.
@@ -411,6 +423,7 @@ export type SanitizedServerFeatures = {
beforeChange?: Map<string, Array<BeforeChangeNodeHook<SerializedLexicalNode>>>
beforeValidate?: Map<string, Array<BeforeValidateNodeHook<SerializedLexicalNode>>>
} /** The node types mapped to their populationPromises */
traverseNodeData?: Map<string, TraverseNodeData>
/** The node types mapped to their validations */
validations: Map<string, Array<NodeValidation>>
} & Required<Pick<ResolvedServerFeature<any, any>, 'i18n' | 'nodes'>>

View File

@@ -19,6 +19,7 @@ import { i18n } from './i18n.js'
import { defaultEditorFeatures } from './lexical/config/server/default.js'
import { populateLexicalPopulationPromises } from './populateGraphQL/populateLexicalPopulationPromises.js'
import { featuresInputToEditorConfig } from './utilities/editorConfigFactory.js'
import { recurseNodes } from './utilities/forEachNodeRecursively.js'
import { getGenerateImportMap } from './utilities/generateImportMap.js'
import { getGenerateSchemaMap } from './utilities/generateSchemaMap.js'
import { recurseNodeTree } from './utilities/recurseNodeTree.js'
@@ -853,6 +854,26 @@ export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapter
return outputSchema
},
traverseData: ({ data, onFields, onRelationship }) => {
recurseNodes({
callback: (node) => {
const traverseNodeData = finalSanitizedEditorConfig.features.traverseNodeData?.get(
node.type,
)
if (!traverseNodeData) {
return
}
traverseNodeData({
node,
onFields,
onRelationship,
})
},
nodes: data.root.children,
})
},
validate: richTextValidateHOC({
editorConfig: finalSanitizedEditorConfig,
}),

View File

@@ -37,6 +37,7 @@ export const sanitizeServerFeatures = (
beforeValidate: new Map(),
},
nodes: [],
traverseNodeData: new Map(),
validations: new Map(),
}
@@ -89,6 +90,9 @@ export const sanitizeServerFeatures = (
if (node?.graphQLPopulationPromises?.length) {
sanitized.graphQLPopulationPromises.set(nodeType, node.graphQLPopulationPromises)
}
if (node?.traverseNodeData) {
sanitized.traverseNodeData?.set(nodeType, node.traverseNodeData)
}
if (node?.validations?.length) {
sanitized.validations.set(nodeType, node.validations)
}

View File

@@ -0,0 +1,31 @@
import type { RichTextAdapter } from 'payload'
export const traverseData: RichTextAdapter['traverseData'] = ({
data,
field,
onFields,
onRelationship,
}) => {
if (
field.admin?.elements?.includes('relationship') ||
field.admin?.elements?.includes('upload') ||
field.admin?.elements?.includes('link') ||
!field?.admin?.elements
) {
if (Array.isArray(data)) {
for (const element of data) {
if (element.type === 'relationship' && element?.value?.id && element.relationTo) {
onRelationship({ id: element.value.id, collectionSlug: element.relationTo })
}
if (element.type === 'link' && element?.doc?.value && element?.doc?.relationTo) {
onRelationship({ id: element.doc.value, collectionSlug: element.doc.relationTo })
}
if (Array.isArray(element.children)) {
traverseData({ data: element.children, field, onFields, onRelationship })
}
}
}
}
}

View File

@@ -5,6 +5,7 @@ import { sanitizeFields, withNullableJSONSchemaType } from 'payload'
import type { AdapterArguments } from './types.js'
import { richTextRelationshipPromise } from './data/richTextRelationshipPromise.js'
import { traverseData } from './data/traverseData.js'
import { richTextValidate } from './data/validation.js'
import { elements as elementTypes } from './field/elements/index.js'
import { transformExtraFields } from './field/elements/link/utilities.js'
@@ -205,6 +206,7 @@ export function slateEditor(
},
}
},
traverseData,
validate: richTextValidate,
}
}

View File

@@ -0,0 +1,58 @@
import type { CollectionConfig } from 'payload'
import { BlocksFeature, lexicalEditor, LinkFeature } from '@payloadcms/richtext-lexical'
import { slateEditor } from '@payloadcms/richtext-slate'
import { cascadePublishSlug } from '../slugs.js'
export const CascadePublish: CollectionConfig = {
slug: cascadePublishSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'relation',
type: 'relationship',
relationTo: 'cascade-publish-relations',
},
{
name: 'lexical',
type: 'richText',
editor: lexicalEditor({
features({ defaultFeatures }) {
return [
...defaultFeatures,
LinkFeature({ enabledCollections: ['cascade-publish-relations'] }),
BlocksFeature({
blocks: [
{
slug: 'someBlock',
fields: [
{
type: 'relationship',
relationTo: 'cascade-publish-relations',
name: 'relation',
},
],
},
],
}),
]
},
}),
},
{
name: 'slate',
type: 'richText',
editor: slateEditor({}),
},
],
versions: {
drafts: { cascadePublish: true },
},
}

View File

@@ -0,0 +1,19 @@
import type { CollectionConfig } from 'payload'
import { cascadePublishRelationsSlug } from '../slugs.js'
export const CascadePublishRelations: CollectionConfig = {
slug: cascadePublishRelationsSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
],
versions: {
drafts: { cascadePublish: true },
},
}

View File

@@ -5,6 +5,8 @@ const dirname = path.dirname(filename)
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import AutosavePosts from './collections/Autosave.js'
import AutosaveWithValidate from './collections/AutosaveWithValidate.js'
import { CascadePublish } from './collections/CascadePublish.js'
import { CascadePublishRelations } from './collections/CascadePublishRelations.js'
import CustomIDs from './collections/CustomIDs.js'
import { Diff } from './collections/Diff.js'
import DisablePublish from './collections/DisablePublish.js'
@@ -16,6 +18,7 @@ import { Media } from './collections/Media.js'
import Posts from './collections/Posts.js'
import VersionPosts from './collections/Versions.js'
import AutosaveGlobal from './globals/Autosave.js'
import { CascadePublishGlobal } from './globals/CascadePublishGlobal.js'
import DisablePublishGlobal from './globals/DisablePublish.js'
import DraftGlobal from './globals/Draft.js'
import DraftWithMaxGlobal from './globals/DraftWithMax.js'
@@ -43,8 +46,17 @@ export default buildConfigWithDefaults({
CustomIDs,
Diff,
Media,
CascadePublish,
CascadePublishRelations,
],
globals: [
AutosaveGlobal,
DraftGlobal,
DraftWithMaxGlobal,
DisablePublishGlobal,
LocalizedGlobal,
CascadePublishGlobal,
],
globals: [AutosaveGlobal, DraftGlobal, DraftWithMaxGlobal, DisablePublishGlobal, LocalizedGlobal],
indexSortableFields: true,
localization: {
defaultLocale: 'en',

View File

@@ -0,0 +1,42 @@
import type { CollectionConfig, GlobalConfig } from 'payload'
import { cascadePublishGlobalSlug, cascadePublishSlug } from '../slugs.js'
export const CascadePublish: CollectionConfig = {
slug: cascadePublishSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'relation',
type: 'relationship',
relationTo: 'cascade-publish-relations',
},
],
versions: {
drafts: { cascadePublish: true },
},
}
export const CascadePublishGlobal: GlobalConfig = {
slug: cascadePublishGlobalSlug,
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'relation',
type: 'relationship',
relationTo: 'cascade-publish-relations',
},
],
versions: {
drafts: { cascadePublish: true },
},
}

View File

@@ -1,8 +1,8 @@
import type { Payload } from 'payload';
import type { Payload } from 'payload'
import { schedulePublishHandler } from '@payloadcms/ui/utilities/schedulePublishHandler'
import path from 'path'
import { createLocalReq , ValidationError } from 'payload'
import { createLocalReq, ValidationError } from 'payload'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -16,6 +16,7 @@ import AutosaveGlobal from './globals/Autosave.js'
import {
autosaveCollectionSlug,
autoSaveGlobalSlug,
cascadePublishRelationsSlug,
draftCollectionSlug,
draftGlobalSlug,
localizedCollectionSlug,
@@ -2767,4 +2768,238 @@ describe('Versions', () => {
})
})
})
describe('Cascade publish', () => {
it('should cascade publish collection', async () => {
const relation = await payload.create({
collection: 'cascade-publish-relations',
draft: true,
data: { title: 'Relation 1' },
})
expect(relation._status).toBe('draft')
const doc = await payload.create({
collection: 'cascade-publish',
draft: true,
data: { relation: relation.id, title: 'Cascade Publish 1' },
})
expect(doc._status).toBe('draft')
const publishedDoc = await payload.update({
collection: 'cascade-publish',
id: doc.id,
depth: 0,
data: { _status: 'published', title: 'Cascade Publish - published' },
})
expect(publishedDoc._status).toBe('published')
const publishedRelation = await payload.findByID({
collection: 'cascade-publish-relations',
id: relation.id,
})
// And Here it fails..
expect(publishedRelation._status).toBe('published')
})
it('should cascade publish collection with relationship data within Lexical', async () => {
const relation_1 = await payload.create({
collection: 'cascade-publish-relations',
draft: true,
data: { title: 'Relation 1', _status: 'draft' },
})
const relation_2 = await payload.create({
collection: 'cascade-publish-relations',
draft: true,
data: { title: 'Relation 2', _status: 'draft' },
})
const relation_3 = await payload.create({
collection: 'cascade-publish-relations',
draft: true,
data: { title: 'Relation 3', _status: 'draft' },
})
const doc = await payload.create({
collection: 'cascade-publish',
draft: true,
depth: 0,
data: {
_status: 'draft',
title: 'Cascade Publish 1',
lexical: {
root: {
children: [
{
format: '',
type: 'relationship',
version: 2,
relationTo: cascadePublishRelationsSlug,
value: relation_1.id,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'link to cascade relation',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
id: '665d10938106ab380c7f3730',
type: 'link',
version: 2,
fields: {
url: 'https://',
doc: {
value: relation_2.id,
relationTo: cascadePublishRelationsSlug,
},
newTab: false,
linkType: 'internal',
},
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: '65298b13db4ef8c744a7faaa',
relation: relation_3.id,
blockName: 'Some Block Node, with Relationship Field',
blockType: 'someBlock',
},
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1,
},
},
},
})
const publishedDoc = await payload.update({
collection: 'cascade-publish',
id: doc.id,
depth: 0,
draft: false,
data: { _status: 'published', title: 'Cascade Publish - published' },
})
expect(publishedDoc._status).toBe('published')
const publishedRelation_1 = await payload.findByID({
collection: 'cascade-publish-relations',
id: relation_1.id,
})
expect(publishedRelation_1._status).toBe('published')
const publishedRelation_2 = await payload.findByID({
collection: 'cascade-publish-relations',
id: relation_2.id,
})
expect(publishedRelation_2._status).toBe('published')
const publishedRelation_3 = await payload.findByID({
collection: 'cascade-publish-relations',
id: relation_3.id,
})
expect(publishedRelation_3._status).toBe('published')
})
it('should cascade publish collection with relationship data within Slate', async () => {
const relation_1 = await payload.create({
collection: 'cascade-publish-relations',
draft: true,
data: { title: 'Relation 1', _status: 'draft' },
})
const relation_2 = await payload.create({
collection: 'cascade-publish-relations',
draft: true,
data: { title: 'Relation 2', _status: 'draft' },
})
const doc = await payload.create({
collection: 'cascade-publish',
draft: true,
depth: 0,
data: {
_status: 'draft',
title: 'Cascade Publish 1',
slate: [
{
type: 'relationship',
relationTo: cascadePublishRelationsSlug,
value: { id: relation_1.id },
},
{
type: 'link',
linkType: 'internal',
doc: {
relationTo: cascadePublishRelationsSlug,
value: relation_2.id,
},
},
],
},
})
const publishedDoc = await payload.update({
collection: 'cascade-publish',
id: doc.id,
depth: 0,
draft: false,
data: { _status: 'published', title: 'Cascade Publish - published' },
})
expect(publishedDoc._status).toBe('published')
const publishedRelation_1 = await payload.findByID({
collection: 'cascade-publish-relations',
id: relation_1.id,
})
expect(publishedRelation_1._status).toBe('published')
const publishedRelation_2 = await payload.findByID({
collection: 'cascade-publish-relations',
id: relation_2.id,
})
expect(publishedRelation_2._status).toBe('published')
})
it('should cascade publish global', async () => {
const relation = await payload.create({
collection: 'cascade-publish-relations',
draft: true,
data: { title: 'Relation to global', _status: 'draft' },
})
expect(relation._status).toBe('draft')
const globalDoc = await payload.updateGlobal({
slug: 'cascade-publish-global',
draft: true,
depth: 0,
data: { _status: 'draft', title: 'Some title 1', relation: relation.id },
})
expect(globalDoc._status).toBe('draft')
const publishedGlobalDoc = await payload.updateGlobal({
slug: 'cascade-publish-global',
draft: false,
depth: 0,
data: { _status: 'published' },
})
expect(publishedGlobalDoc._status).toBe('published')
const relationDoc = await payload.findByID({
collection: 'cascade-publish-relations',
id: relation.id,
})
expect(relationDoc._status).toBe('published')
})
})
})

View File

@@ -78,6 +78,8 @@ export interface Config {
'custom-ids': CustomId;
diff: Diff;
media: Media;
'cascade-publish': CascadePublish;
'cascade-publish-relations': CascadePublishRelation;
users: User;
'payload-jobs': PayloadJob;
'payload-locked-documents': PayloadLockedDocument;
@@ -98,6 +100,8 @@ export interface Config {
'custom-ids': CustomIdsSelect<false> | CustomIdsSelect<true>;
diff: DiffSelect<false> | DiffSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
'cascade-publish': CascadePublishSelect<false> | CascadePublishSelect<true>;
'cascade-publish-relations': CascadePublishRelationsSelect<false> | CascadePublishRelationsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
@@ -113,6 +117,7 @@ export interface Config {
'draft-with-max-global': DraftWithMaxGlobal;
'disable-publish-global': DisablePublishGlobal;
'localized-global': LocalizedGlobal;
'cascade-publish-global': CascadePublishGlobal;
};
globalsSelect: {
'autosave-global': AutosaveGlobalSelect<false> | AutosaveGlobalSelect<true>;
@@ -120,6 +125,7 @@ export interface Config {
'draft-with-max-global': DraftWithMaxGlobalSelect<false> | DraftWithMaxGlobalSelect<true>;
'disable-publish-global': DisablePublishGlobalSelect<false> | DisablePublishGlobalSelect<true>;
'localized-global': LocalizedGlobalSelect<false> | LocalizedGlobalSelect<true>;
'cascade-publish-global': CascadePublishGlobalSelect<false> | CascadePublishGlobalSelect<true>;
};
locale: 'en' | 'es' | 'de';
user: User & {
@@ -395,6 +401,49 @@ export interface Media {
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cascade-publish".
*/
export interface CascadePublish {
id: string;
title?: string | null;
relation?: (string | null) | CascadePublishRelation;
lexical?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
slate?:
| {
[k: string]: unknown;
}[]
| null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cascade-publish-relations".
*/
export interface CascadePublishRelation {
id: string;
title?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@@ -559,6 +608,14 @@ export interface PayloadLockedDocument {
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'cascade-publish';
value: string | CascadePublish;
} | null)
| ({
relationTo: 'cascade-publish-relations';
value: string | CascadePublishRelation;
} | null)
| ({
relationTo: 'users';
value: string | User;
@@ -820,6 +877,29 @@ export interface MediaSelect<T extends boolean = true> {
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cascade-publish_select".
*/
export interface CascadePublishSelect<T extends boolean = true> {
title?: T;
relation?: T;
lexical?: T;
slate?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cascade-publish-relations_select".
*/
export interface CascadePublishRelationsSelect<T extends boolean = true> {
title?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
@@ -954,6 +1034,18 @@ export interface LocalizedGlobal {
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cascade-publish-global".
*/
export interface CascadePublishGlobal {
id: string;
title?: string | null;
relation?: (string | null) | CascadePublishRelation;
_status?: ('draft' | 'published') | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-global_select".
@@ -1010,6 +1102,18 @@ export interface LocalizedGlobalSelect<T extends boolean = true> {
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cascade-publish-global_select".
*/
export interface CascadePublishGlobalSelect<T extends boolean = true> {
title?: T;
relation?: T;
_status?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "TaskSchedulePublish".

View File

@@ -38,3 +38,7 @@ export const globalSlugs = [autoSaveGlobalSlug, draftGlobalSlug]
export const localizedCollectionSlug = 'localized-posts'
export const localizedGlobalSlug = 'localized-global'
export const cascadePublishSlug = 'cascade-publish'
export const cascadePublishGlobalSlug = 'cascade-publish-global'
export const cascadePublishRelationsSlug = 'cascade-publish-relations'