From a58cbedc04c1a78501190ca40354c0c76dd642ef Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Wed, 11 Mar 2026 13:00:08 -0700 Subject: [PATCH 1/7] Remove unnecessary assertion --- consistent-type-assertions.txt | 1 - src/collections/Pages/endpoints/duplicatePageToTenant.ts | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/consistent-type-assertions.txt b/consistent-type-assertions.txt index 3345131d..fe7a9a7a 100644 --- a/consistent-type-assertions.txt +++ b/consistent-type-assertions.txt @@ -5,7 +5,6 @@ src/app/(frontend)/[center]/blog/[slug]/page.tsx src/app/(frontend)/[center]/events/[slug]/page.tsx src/app/(frontend)/[center]/next/preview/route.ts src/collections/Pages/components/DuplicatePageFor/index.tsx -src/collections/Pages/endpoints/duplicatePageToTenant.ts src/collections/Users/components/InviteUser.tsx src/collections/Users/components/InviteUserDrawer.tsx src/collections/Users/components/inviteUserAction.ts diff --git a/src/collections/Pages/endpoints/duplicatePageToTenant.ts b/src/collections/Pages/endpoints/duplicatePageToTenant.ts index 99d68de5..bbfcddd8 100644 --- a/src/collections/Pages/endpoints/duplicatePageToTenant.ts +++ b/src/collections/Pages/endpoints/duplicatePageToTenant.ts @@ -5,8 +5,7 @@ import configPromise from '@payload-config' import { getPayload, PayloadRequest } from 'payload' export async function duplicatePageToTenant(req: PayloadRequest) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const selectedTenantId = req.routeParams?.selectedTenantId as number + const selectedTenantId = req.routeParams?.selectedTenantId const { newPage } = await req.json?.() From 99590ae0d9dd87a89a8e1f6d2d8a9bd46bc6f98c Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Wed, 11 Mar 2026 14:54:30 -0700 Subject: [PATCH 2/7] Add clearLayoutRelationships utility to remove relationships on page duplication --- .../DuplicatePageForDrawer.tsx | 5 +- .../Pages/endpoints/duplicatePageToTenant.ts | 7 +- src/utilities/clearLayoutRelationships.ts | 123 ++++++++++++++++++ 3 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 src/utilities/clearLayoutRelationships.ts diff --git a/src/collections/Pages/components/DuplicatePageFor/DuplicatePageForDrawer.tsx b/src/collections/Pages/components/DuplicatePageFor/DuplicatePageForDrawer.tsx index 4e11967a..7d7daf1e 100644 --- a/src/collections/Pages/components/DuplicatePageFor/DuplicatePageForDrawer.tsx +++ b/src/collections/Pages/components/DuplicatePageFor/DuplicatePageForDrawer.tsx @@ -17,11 +17,8 @@ import { useRouter } from 'next/navigation' import { formatAdminURL } from 'payload/shared' import { useCallback, useState } from 'react' -// TODOs -// - Remove photos from blocks or use a global photo? - export const DuplicatePageForDrawer = () => { - const { savedDocumentData: pageData } = useDocumentInfo() + const { data: pageData } = useDocumentInfo() const modified = useFormModified() const router = useRouter() const { options } = useTenantSelection() diff --git a/src/collections/Pages/endpoints/duplicatePageToTenant.ts b/src/collections/Pages/endpoints/duplicatePageToTenant.ts index bbfcddd8..8b4996cf 100644 --- a/src/collections/Pages/endpoints/duplicatePageToTenant.ts +++ b/src/collections/Pages/endpoints/duplicatePageToTenant.ts @@ -1,6 +1,6 @@ 'use server' -import { removeIdKey } from '@/utilities/removeIdKey' +import { clearLayoutRelationships } from '@/utilities/clearLayoutRelationships' import configPromise from '@payload-config' import { getPayload, PayloadRequest } from 'payload' @@ -14,12 +14,13 @@ export async function duplicatePageToTenant(req: PayloadRequest) { .find({ collection: 'tenants', where: { id: { equals: selectedTenantId } } }) .then((res) => res.docs[0]) - const newPageSansIds = removeIdKey(newPage) + const newPageSansRefs = clearLayoutRelationships(newPage.layout ?? []) return await payload.create({ collection: 'pages', + draft: true, data: { - ...newPageSansIds, + ...newPageSansRefs, tenant, title: `${newPage.title} - Copy`, slug: `${newPage.slug}-copy`, diff --git a/src/utilities/clearLayoutRelationships.ts b/src/utilities/clearLayoutRelationships.ts new file mode 100644 index 00000000..db516cd0 --- /dev/null +++ b/src/utilities/clearLayoutRelationships.ts @@ -0,0 +1,123 @@ +import { NACMediaBlock } from '@/blocks/NACMedia/config' +import { DEFAULT_BLOCKS } from '@/constants/defaults' +import type { Field } from 'payload' + +type DataObject = Record + +interface LexicalNode { + type?: string + children?: LexicalNode[] + fields?: DataObject +} +const allBlocksMap = new Map([...DEFAULT_BLOCKS, NACMediaBlock].map((b) => [b.slug, b])) + +function isDataObject(value: unknown): value is DataObject { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isLexicalNode(value: unknown): value is LexicalNode { + return typeof value === 'object' && value !== null +} + +function clearFieldValues(data: DataObject, fields: Field[]): DataObject { + const result = { ...data } + + // Strip the exact 'id' key (not fields like 'videoId') so new IDs are assigned on create + Reflect.deleteProperty(result, 'id') + + for (const field of fields) { + if (field.type === 'relationship' || field.type === 'upload') { + Reflect.deleteProperty(result, field.name) + } else if (field.type === 'group') { + if ('name' in field) { + const groupData = result[field.name] + if (isDataObject(groupData)) { + result[field.name] = clearFieldValues(groupData, field.fields) + } + } else { + // Unnamed group: fields live at the same data level + const updated = clearFieldValues(result, field.fields) + Object.assign(result, updated) + } + } else if (field.type === 'array') { + const arrayData = result[field.name] + if (Array.isArray(arrayData)) { + result[field.name] = arrayData.map((item) => + isDataObject(item) ? clearFieldValues(item, field.fields) : item, + ) + } + } else if (field.type === 'richText') { + const richTextData = result[field.name] + if (isDataObject(richTextData)) { + result[field.name] = clearLexicalBlockRelationships(richTextData) + } + } else if (field.type === 'row' || field.type === 'collapsible') { + // row/collapsible are layout-only containers — their sub-fields live at the same data level + const updated = clearFieldValues(result, field.fields) + Object.assign(result, updated) + } else if (field.type === 'tabs') { + for (const tab of field.tabs) { + if ('name' in tab) { + // Named tab: data is nested under tab.name + const tabData = result[tab.name] + if (isDataObject(tabData)) { + result[tab.name] = clearFieldValues(tabData, tab.fields) + } + } else { + // Unnamed tab: data is at the same level as the parent + const updated = clearFieldValues(result, tab.fields) + Object.assign(result, updated) + } + } + } + } + + return result +} + +function clearLexicalBlockRelationships(lexicalData: DataObject): DataObject { + function walkNode(node: LexicalNode): LexicalNode { + const result: LexicalNode = { ...node } + + if (node.type === 'block' && isDataObject(node.fields)) { + const blockType = node.fields.blockType + if (typeof blockType === 'string') { + const blockConfig = allBlocksMap.get(blockType) + if (blockConfig) { + result.fields = clearFieldValues(node.fields, blockConfig.fields ?? []) + } + } + } + + if (node.children) { + result.children = node.children.map(walkNode) + } + + return result + } + + const root = lexicalData.root + if (isLexicalNode(root)) { + return { ...lexicalData, root: walkNode(root) } + } + + return lexicalData +} + +/** + * Clears all relationship and upload field values from a page layout. + * Used when duplicating a page to another tenant so that tenant-scoped + * relationships must be repopulated by the user in the new page. + */ +export function clearLayoutRelationships(layout: unknown[]): DataObject[] { + return layout.map((block) => { + if (!isDataObject(block)) return {} + const blockType = block.blockType + if (typeof blockType !== 'string') return block + + const blockConfig = allBlocksMap.get(blockType) + if (!blockConfig) return block + + return clearFieldValues(block, blockConfig.fields ?? []) + }) +} From d3d951ebc600e3f8dfc6371a008085c85b9b91bc Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Wed, 11 Mar 2026 16:11:12 -0700 Subject: [PATCH 3/7] Update provisionTenant endpoint to use clearLayoutRelationships --- __tests__/server/fixtures/mockBlocks.ts | 55 +++++ .../server/provisionTenant.server.test.ts | 226 +++++++++--------- .../Tenants/endpoints/provisionTenant.ts | 42 +--- 3 files changed, 167 insertions(+), 156 deletions(-) create mode 100644 __tests__/server/fixtures/mockBlocks.ts diff --git a/__tests__/server/fixtures/mockBlocks.ts b/__tests__/server/fixtures/mockBlocks.ts new file mode 100644 index 00000000..d5d3db65 --- /dev/null +++ b/__tests__/server/fixtures/mockBlocks.ts @@ -0,0 +1,55 @@ +// Minimal block configs for testing clearLayoutRelationships. +// These mirror the shape of Payload Block configs without importing from payload. +export const DEFAULT_BLOCKS = [ + { + slug: 'singleBlogPost', + fields: [ + { type: 'relationship', name: 'post', relationTo: 'posts' }, + { type: 'text', name: 'backgroundColor' }, + ], + }, + { + slug: 'mediaBlock', + fields: [{ type: 'upload', name: 'media', relationTo: 'media' }], + }, + { + slug: 'sponsorsBlock', + fields: [ + { type: 'relationship', name: 'sponsors', relationTo: 'sponsors', hasMany: true }, + { type: 'text', name: 'sponsorsLayout' }, + ], + }, + { + slug: 'singleEvent', + fields: [ + { type: 'relationship', name: 'event', relationTo: 'events' }, + { type: 'text', name: 'backgroundColor' }, + ], + }, + { + slug: 'blogList', + fields: [ + { type: 'text', name: 'postOptions' }, + { + type: 'group', + name: 'staticOptions', + fields: [{ type: 'relationship', name: 'staticPosts', relationTo: 'posts', hasMany: true }], + }, + ], + }, + { + slug: 'imageLinkGrid', + fields: [ + { + type: 'array', + name: 'columns', + fields: [ + { type: 'upload', name: 'image', relationTo: 'media' }, + { type: 'text', name: 'caption' }, + ], + }, + ], + }, +] + +export const NACMediaBlock = { slug: 'nacMedia', fields: [] } diff --git a/__tests__/server/provisionTenant.server.test.ts b/__tests__/server/provisionTenant.server.test.ts index 6d1cfa68..e5836564 100644 --- a/__tests__/server/provisionTenant.server.test.ts +++ b/__tests__/server/provisionTenant.server.test.ts @@ -1,145 +1,135 @@ -import { - SKIP_PAGE_SLUGS, - TENANT_SCOPED_BLOCK_TYPES, -} from '@/collections/Tenants/endpoints/provisionTenant' - -describe('provisionTenant constants', () => { - describe('TENANT_SCOPED_BLOCK_TYPES', () => { - it('contains the expected block types', () => { - expect(TENANT_SCOPED_BLOCK_TYPES).toEqual( - new Set(['team', 'sponsors', 'singleEvent', 'singleBlogPost', 'formBlock']), - ) - }) - - it.each(['team', 'sponsors', 'singleEvent', 'singleBlogPost', 'formBlock'])( - 'includes %s', - (blockType) => { - expect(TENANT_SCOPED_BLOCK_TYPES.has(blockType)).toBe(true) - }, - ) - - it.each(['text', 'blogList', 'eventList', 'hero', 'callToAction'])( - 'does not include %s', - (blockType) => { - expect(TENANT_SCOPED_BLOCK_TYPES.has(blockType)).toBe(false) - }, - ) +// Mock heavy payload-dependent modules before any imports. +// jest.mock uses relative paths because it doesn't resolve @/ aliases. +jest.mock('../../src/constants/defaults', () => ({ + // eslint-disable-next-line @typescript-eslint/no-require-imports + DEFAULT_BLOCKS: require('./fixtures/mockBlocks').DEFAULT_BLOCKS, +})) + +jest.mock('../../src/blocks/NACMedia/config', () => ({ + // eslint-disable-next-line @typescript-eslint/no-require-imports + NACMediaBlock: require('./fixtures/mockBlocks').NACMediaBlock, +})) + +jest.mock('../../src/access/hasSuperAdminPermissions', () => ({ + hasSuperAdminPermissions: jest.fn(), +})) + +jest.mock('../../src/endpoints/seed/utilities', () => ({ + getSeedImageByFilename: jest.fn(), +})) + +import { SKIP_PAGE_SLUGS } from '@/collections/Tenants/endpoints/provisionTenant' +import type { Page } from '@/payload-types' +import { clearLayoutRelationships } from '@/utilities/clearLayoutRelationships' + +describe('SKIP_PAGE_SLUGS', () => { + it.each(['blocks', 'lexical-blocks'])('includes %s', (slug) => { + expect(SKIP_PAGE_SLUGS.has(slug)).toBe(true) }) - describe('SKIP_PAGE_SLUGS', () => { - it('contains the expected page slugs', () => { - expect(SKIP_PAGE_SLUGS).toEqual(new Set(['blocks', 'lexical-blocks'])) - }) - - it.each(['blocks', 'lexical-blocks'])('includes %s', (slug) => { - expect(SKIP_PAGE_SLUGS.has(slug)).toBe(true) - }) - - it.each(['about-us', 'contact', 'donate-membership'])('does not include %s', (slug) => { - expect(SKIP_PAGE_SLUGS.has(slug)).toBe(false) - }) + it.each(['about-us', 'contact', 'donate-membership'])('does not include %s', (slug) => { + expect(SKIP_PAGE_SLUGS.has(slug)).toBe(false) }) }) -describe('block filtering logic', () => { - // Replicate the inline filtering logic from provisionTenant.ts for unit testing - function filterAndTransformLayout(layout: Array>) { - return layout - .filter( - (block: { blockType?: string }) => - !block.blockType || !TENANT_SCOPED_BLOCK_TYPES.has(block.blockType), - ) - .map((block) => { - if (block.blockType === 'blogList' && block.postOptions === 'static') { - return { ...block, postOptions: 'dynamic', staticOptions: undefined } - } - if (block.blockType === 'eventList' && block.eventOptions === 'static') { - return { ...block, eventOptions: 'dynamic', staticOpts: undefined } - } - return block - }) - } - - it('filters out tenant-scoped blocks', () => { - const layout = [ - { blockType: 'text', content: 'hello' }, - { blockType: 'team', members: [1, 2] }, - { blockType: 'hero', heading: 'Welcome' }, - { blockType: 'sponsors', items: [3] }, - ] - - const result = filterAndTransformLayout(layout) - expect(result).toEqual([ - { blockType: 'text', content: 'hello' }, - { blockType: 'hero', heading: 'Welcome' }, - ]) +describe('clearLayoutRelationships', () => { + it('returns empty array for empty layout', () => { + expect(clearLayoutRelationships([])).toEqual([]) }) - it('converts static blogList to dynamic', () => { - const layout = [ - { blockType: 'blogList', postOptions: 'static', staticOptions: { posts: [1, 2] } }, - ] + it('passes through block types not in allBlocksMap unchanged', () => { + // genericEmbed is not in the mocked DEFAULT_BLOCKS, so it passes through as-is + const block: Page['layout'][number] = { + blockType: 'genericEmbed', + html: '

test

', + backgroundColor: 'transparent', + } + const result = clearLayoutRelationships([block]) + expect(result[0]).toEqual(block) + }) - const result = filterAndTransformLayout(layout) - expect(result).toEqual([ - { blockType: 'blogList', postOptions: 'dynamic', staticOptions: undefined }, + it('clears relationship fields', () => { + const result = clearLayoutRelationships([ + { blockType: 'singleBlogPost', post: 123, backgroundColor: 'red' }, ]) + expect(result[0]).not.toHaveProperty('post') + expect(result[0]).toHaveProperty('backgroundColor', 'red') }) - it('converts static eventList to dynamic', () => { - const layout = [{ blockType: 'eventList', eventOptions: 'static', staticOpts: { events: [1] } }] - - const result = filterAndTransformLayout(layout) - expect(result).toEqual([ - { blockType: 'eventList', eventOptions: 'dynamic', staticOpts: undefined }, + it('clears upload fields', () => { + const result = clearLayoutRelationships([ + { blockType: 'mediaBlock', media: 456, backgroundColor: 'transparent' }, ]) + expect(result[0]).not.toHaveProperty('media') }) - it('leaves dynamic blogList unchanged', () => { - const layout = [{ blockType: 'blogList', postOptions: 'dynamic', dynamicOpts: { max: 5 } }] - - const result = filterAndTransformLayout(layout) - expect(result).toEqual([ - { blockType: 'blogList', postOptions: 'dynamic', dynamicOpts: { max: 5 } }, + it('clears hasMany relationship fields', () => { + const result = clearLayoutRelationships([ + { + blockType: 'sponsorsBlock', + sponsors: [1, 2, 3], + sponsorsLayout: 'static', + backgroundColor: 'transparent', + }, ]) + expect(result[0]).not.toHaveProperty('sponsors') + expect(result[0]).toHaveProperty('sponsorsLayout', 'static') }) - it('leaves dynamic eventList unchanged', () => { - const layout = [ - { blockType: 'eventList', eventOptions: 'dynamic', dynamicOpts: { maxEvents: 4 } }, - ] + it('strips id keys', () => { + const result = clearLayoutRelationships([ + { blockType: 'singleBlogPost', id: 'abc123', post: 1, backgroundColor: 'red' }, + ]) + expect(result[0]).not.toHaveProperty('id') + }) - const result = filterAndTransformLayout(layout) - expect(result).toEqual([ - { blockType: 'eventList', eventOptions: 'dynamic', dynamicOpts: { maxEvents: 4 } }, + it('preserves non-relationship fields', () => { + const result = clearLayoutRelationships([ + { blockType: 'singleEvent', event: 5, backgroundColor: 'blue' }, ]) + expect(result[0]).toHaveProperty('backgroundColor', 'blue') + expect(result[0]).not.toHaveProperty('event') }) - it('returns empty array when all blocks are tenant-scoped', () => { - const layout = [ - { blockType: 'team', members: [] }, - { blockType: 'sponsors', items: [] }, - { blockType: 'formBlock', form: 1 }, - ] + it('clears relationship fields nested in groups', () => { + const result = clearLayoutRelationships([ + { + blockType: 'blogList', + postOptions: 'static', + backgroundColor: 'transparent', + staticOptions: { staticPosts: [1, 2, 3] }, + }, + ]) + expect(result[0]).toHaveProperty('postOptions', 'static') + expect(result[0]).not.toHaveProperty('staticOptions.staticPosts') + }) - const result = filterAndTransformLayout(layout) - expect(result).toEqual([]) + it('clears upload fields nested in arrays', () => { + const result = clearLayoutRelationships([ + { + blockType: 'imageLinkGrid', + columns: [ + { image: 10, caption: 'First' }, + { image: 20, caption: 'Second' }, + ], + }, + ]) + expect(result[0]).not.toHaveProperty(['columns', 0, 'image']) + expect(result[0]).toHaveProperty(['columns', 0, 'caption'], 'First') + expect(result[0]).not.toHaveProperty(['columns', 1, 'image']) + expect(result[0]).toHaveProperty(['columns', 1, 'caption'], 'Second') }) - it('handles mixed layout with filtering and conversion', () => { - const layout = [ - { blockType: 'hero', heading: 'Welcome' }, - { blockType: 'team', members: [1] }, - { blockType: 'blogList', postOptions: 'static', staticOptions: { posts: [1] } }, - { blockType: 'singleEvent', event: 5 }, - { blockType: 'eventList', eventOptions: 'dynamic', dynamicOpts: { maxEvents: 3 } }, - ] - - const result = filterAndTransformLayout(layout) - expect(result).toEqual([ - { blockType: 'hero', heading: 'Welcome' }, - { blockType: 'blogList', postOptions: 'dynamic', staticOptions: undefined }, - { blockType: 'eventList', eventOptions: 'dynamic', dynamicOpts: { maxEvents: 3 } }, + it('processes multiple blocks independently', () => { + const result = clearLayoutRelationships([ + { blockType: 'singleBlogPost', post: 1, backgroundColor: 'red' }, + { blockType: 'singleEvent', event: 2, backgroundColor: 'blue' }, + { blockType: 'mediaBlock', media: 3, backgroundColor: 'transparent' }, ]) + expect(result[0]).not.toHaveProperty('post') + expect(result[1]).not.toHaveProperty('event') + expect(result[2]).not.toHaveProperty('media') + expect(result[0]).toHaveProperty('backgroundColor', 'red') + expect(result[1]).toHaveProperty('backgroundColor', 'blue') }) }) diff --git a/src/collections/Tenants/endpoints/provisionTenant.ts b/src/collections/Tenants/endpoints/provisionTenant.ts index 2d4c0fc6..26d71297 100644 --- a/src/collections/Tenants/endpoints/provisionTenant.ts +++ b/src/collections/Tenants/endpoints/provisionTenant.ts @@ -1,21 +1,11 @@ import { hasSuperAdminPermissions } from '@/access/hasSuperAdminPermissions' import { getSeedImageByFilename } from '@/endpoints/seed/utilities' import type { BuiltInPage, Page, Tenant } from '@/payload-types' -import { removeIdKey } from '@/utilities/removeIdKey' +import { clearLayoutRelationships } from '@/utilities/clearLayoutRelationships' import type { Payload, PayloadHandler } from 'payload' const TEMPLATE_TENANT_SLUG = 'dvac' -// Block types that reference tenant-scoped data (teams, sponsors, events) and can't -// be copied to a new tenant without the corresponding records existing. -export const TENANT_SCOPED_BLOCK_TYPES = new Set([ - 'team', - 'sponsors', - 'singleEvent', - 'singleBlogPost', - 'formBlock', -]) - // Page slugs that should not be copied during provisioning (e.g. demo/showcase pages) export const SKIP_PAGE_SLUGS = new Set(['blocks', 'lexical-blocks']) @@ -238,41 +228,17 @@ export async function provision(payload: Payload, tenant: Tenant) { } } - const cleanedPage = removeIdKey(templatePage) - // Filter out blocks that reference tenant-scoped data (teams, sponsors, events, forms) - // since those records won't exist for the new tenant. - // For blocks with optional static references (blogList, eventList), convert to dynamic mode. - const layout = Array.isArray(cleanedPage.layout) - ? cleanedPage.layout - .filter( - (block: { blockType?: string }) => - !block.blockType || !TENANT_SCOPED_BLOCK_TYPES.has(block.blockType), - ) - .map((block) => { - if (block.blockType === 'blogList' && block.postOptions === 'static') { - return { ...block, postOptions: 'dynamic' as const, staticOptions: undefined } - } - if (block.blockType === 'eventList' && block.eventOptions === 'static') { - return { ...block, eventOptions: 'dynamic' as const, staticOpts: undefined } - } - return block - }) - : cleanedPage.layout - // If all blocks were stripped, create as draft since layout is required - const hasContent = Array.isArray(layout) && layout.length > 0 + const layout = clearLayoutRelationships(templatePage.layout) try { const newPage = await payload.create({ collection: 'pages', + draft: true, data: { - ...cleanedPage, layout, tenant: tenant.id, title: templatePage.title, slug: templatePage.slug, - _status: hasContent ? 'published' : 'draft', - publishedAt: hasContent ? new Date().toISOString() : undefined, }, - draft: !hasContent, }) copiedPages.push(newPage) pagesBySlug[newPage.slug] = newPage @@ -473,8 +439,8 @@ export async function provision(payload: Payload, tenant: Tenant) { try { await payload.create({ collection: 'navigations', + draft: true, data: { - _status: 'published', tenant: tenant.id, forecasts: { items: [] }, observations: { items: [] }, From 83da3e7072a14d0c05ab5d1520a78d49b5c4ae64 Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Wed, 11 Mar 2026 16:11:45 -0700 Subject: [PATCH 4/7] Add typing for clearLayoutRelationships --- src/utilities/clearLayoutRelationships.ts | 109 +++++++++------------- 1 file changed, 45 insertions(+), 64 deletions(-) diff --git a/src/utilities/clearLayoutRelationships.ts b/src/utilities/clearLayoutRelationships.ts index db516cd0..32c4d4e5 100644 --- a/src/utilities/clearLayoutRelationships.ts +++ b/src/utilities/clearLayoutRelationships.ts @@ -1,123 +1,104 @@ import { NACMediaBlock } from '@/blocks/NACMedia/config' import { DEFAULT_BLOCKS } from '@/constants/defaults' +import type { Page } from '@/payload-types' import type { Field } from 'payload' type DataObject = Record -interface LexicalNode { - type?: string - children?: LexicalNode[] - fields?: DataObject -} const allBlocksMap = new Map([...DEFAULT_BLOCKS, NACMediaBlock].map((b) => [b.slug, b])) function isDataObject(value: unknown): value is DataObject { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function isLexicalNode(value: unknown): value is LexicalNode { - return typeof value === 'object' && value !== null -} - -function clearFieldValues(data: DataObject, fields: Field[]): DataObject { - const result = { ...data } - - // Strip the exact 'id' key (not fields like 'videoId') so new IDs are assigned on create - Reflect.deleteProperty(result, 'id') +function clearFieldValuesInPlace(data: DataObject, fields: Field[]): void { + // Strip 'id' (not fields like 'videoId') so new IDs are assigned on create + Reflect.deleteProperty(data, 'id') for (const field of fields) { if (field.type === 'relationship' || field.type === 'upload') { - Reflect.deleteProperty(result, field.name) + Reflect.deleteProperty(data, field.name) } else if (field.type === 'group') { if ('name' in field) { - const groupData = result[field.name] + const groupData = data[field.name] if (isDataObject(groupData)) { - result[field.name] = clearFieldValues(groupData, field.fields) + clearFieldValuesInPlace(groupData, field.fields) } } else { - // Unnamed group: fields live at the same data level - const updated = clearFieldValues(result, field.fields) - Object.assign(result, updated) + // Unnamed group — fields are at the same level + clearFieldValuesInPlace(data, field.fields) } } else if (field.type === 'array') { - const arrayData = result[field.name] + const arrayData = data[field.name] if (Array.isArray(arrayData)) { - result[field.name] = arrayData.map((item) => - isDataObject(item) ? clearFieldValues(item, field.fields) : item, - ) + arrayData.forEach((item) => { + if (isDataObject(item)) { + clearFieldValuesInPlace(item, field.fields) + } + }) } } else if (field.type === 'richText') { - const richTextData = result[field.name] + const richTextData = data[field.name] if (isDataObject(richTextData)) { - result[field.name] = clearLexicalBlockRelationships(richTextData) + clearLexicalBlockRelationshipsInPlace(richTextData) } } else if (field.type === 'row' || field.type === 'collapsible') { - // row/collapsible are layout-only containers — their sub-fields live at the same data level - const updated = clearFieldValues(result, field.fields) - Object.assign(result, updated) + // Layout-only containers — fields are at the same level + clearFieldValuesInPlace(data, field.fields) } else if (field.type === 'tabs') { for (const tab of field.tabs) { if ('name' in tab) { - // Named tab: data is nested under tab.name - const tabData = result[tab.name] + const tabData = data[tab.name] if (isDataObject(tabData)) { - result[tab.name] = clearFieldValues(tabData, tab.fields) + clearFieldValuesInPlace(tabData, tab.fields) } } else { - // Unnamed tab: data is at the same level as the parent - const updated = clearFieldValues(result, tab.fields) - Object.assign(result, updated) + clearFieldValuesInPlace(data, tab.fields) } } } } - - return result } -function clearLexicalBlockRelationships(lexicalData: DataObject): DataObject { - function walkNode(node: LexicalNode): LexicalNode { - const result: LexicalNode = { ...node } - +function clearLexicalBlockRelationshipsInPlace(lexicalData: DataObject): void { + function walkNode(node: DataObject): void { if (node.type === 'block' && isDataObject(node.fields)) { const blockType = node.fields.blockType if (typeof blockType === 'string') { const blockConfig = allBlocksMap.get(blockType) if (blockConfig) { - result.fields = clearFieldValues(node.fields, blockConfig.fields ?? []) + clearFieldValuesInPlace(node.fields, blockConfig.fields ?? []) } } } - - if (node.children) { - result.children = node.children.map(walkNode) + if (Array.isArray(node.children)) { + node.children.forEach((child) => { + if (isDataObject(child)) { + walkNode(child) + } + }) } - - return result } - const root = lexicalData.root - if (isLexicalNode(root)) { - return { ...lexicalData, root: walkNode(root) } + if (isDataObject(lexicalData.root)) { + walkNode(lexicalData.root) } - - return lexicalData } /** - * Clears all relationship and upload field values from a page layout. - * Used when duplicating a page to another tenant so that tenant-scoped - * relationships must be repopulated by the user in the new page. + * Clears relationship and upload fields from a page layout so + * tenant-scoped references can be repopulated after duplication. */ -export function clearLayoutRelationships(layout: unknown[]): DataObject[] { +export function clearLayoutRelationships(layout: Page['layout']): Page['layout'] { + if (!Array.isArray(layout)) return layout return layout.map((block) => { - if (!isDataObject(block)) return {} - const blockType = block.blockType - if (typeof blockType !== 'string') return block - - const blockConfig = allBlocksMap.get(blockType) - if (!blockConfig) return block - - return clearFieldValues(block, blockConfig.fields ?? []) + const result = { ...block } + // Downcast for dynamic field access — specific → general is always valid without assertion + const mutable: DataObject = result + const blockConfig = allBlocksMap.get(block.blockType) + if (blockConfig) { + clearFieldValuesInPlace(mutable, blockConfig.fields ?? []) + } + return result }) } From 8af71991252ee302e94a9b7904c1949b47bdeeeb Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Wed, 11 Mar 2026 17:11:03 -0700 Subject: [PATCH 5/7] add test for duplicatePageToTenant --- .../duplicatePageToTenant.server.test.ts | 113 ++++++++++++++++++ .../Pages/endpoints/duplicatePageToTenant.ts | 4 +- 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 __tests__/server/duplicatePageToTenant.server.test.ts diff --git a/__tests__/server/duplicatePageToTenant.server.test.ts b/__tests__/server/duplicatePageToTenant.server.test.ts new file mode 100644 index 00000000..25997640 --- /dev/null +++ b/__tests__/server/duplicatePageToTenant.server.test.ts @@ -0,0 +1,113 @@ +jest.mock('../../src/constants/defaults', () => ({ + // eslint-disable-next-line @typescript-eslint/no-require-imports + DEFAULT_BLOCKS: require('./fixtures/mockBlocks').DEFAULT_BLOCKS, +})) + +jest.mock('../../src/blocks/NACMedia/config', () => ({ + // eslint-disable-next-line @typescript-eslint/no-require-imports + NACMediaBlock: require('./fixtures/mockBlocks').NACMediaBlock, +})) + +jest.mock('../../src/payload.config', () => ({})) + +jest.mock('payload', () => ({ + getPayload: jest.fn(), +})) + +import { duplicatePageToTenant } from '@/collections/Pages/endpoints/duplicatePageToTenant' +import type { Payload, PayloadRequest } from 'payload' +import { getPayload } from 'payload' + +const mockFind = jest.fn() +const mockCreate = jest.fn() + +const mockTenant = { id: 42, name: 'Test AC', slug: 'tac' } + +beforeEach(() => { + jest + .mocked(getPayload) + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + .mockResolvedValue({ find: mockFind, create: mockCreate } as unknown as Payload) + mockFind.mockReset().mockResolvedValue({ docs: [mockTenant] }) + mockCreate.mockReset().mockResolvedValue({ id: 99 }) +}) + +function buildRequest( + selectedTenantId: string | undefined, + body: Record, +): PayloadRequest { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return { + routeParams: selectedTenantId !== undefined ? { selectedTenantId } : undefined, + json: async () => body, + } as unknown as PayloadRequest +} + +describe('duplicatePageToTenant', () => { + it('creates the page as a draft', async () => { + const req = buildRequest('42', { + newPage: { title: 'About', slug: 'about', layout: [] }, + }) + await duplicatePageToTenant(req) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ draft: true })) + }) + + it('appends " - Copy" to the title and "-copy" to the slug', async () => { + const req = buildRequest('42', { + newPage: { title: 'About Us', slug: 'about-us', layout: [] }, + }) + await duplicatePageToTenant(req) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ title: 'About Us - Copy', slug: 'about-us-copy' }), + }), + ) + }) + + it('sets the tenant from the lookup result', async () => { + const req = buildRequest('42', { + newPage: { title: 'About', slug: 'about', layout: [] }, + }) + await duplicatePageToTenant(req) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ tenant: mockTenant }), + }), + ) + }) + + it('passes layout through clearLayoutRelationships', async () => { + const req = buildRequest('42', { + newPage: { + title: 'About', + slug: 'about', + layout: [{ blockType: 'singleBlogPost', post: 123, backgroundColor: 'red' }], + }, + }) + await duplicatePageToTenant(req) + const layout = mockCreate.mock.calls[0][0].data.layout + expect(layout[0]).not.toHaveProperty('post') + expect(layout[0]).toHaveProperty('backgroundColor', 'red') + }) + + it('falls back to empty layout when newPage.layout is absent', async () => { + const req = buildRequest('42', { newPage: { title: 'About', slug: 'about' } }) + await duplicatePageToTenant(req) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ layout: [] }) }), + ) + }) + + it('looks up the tenant by selectedTenantId', async () => { + const req = buildRequest('99', { + newPage: { title: 'About', slug: 'about', layout: [] }, + }) + await duplicatePageToTenant(req) + expect(mockFind).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'tenants', + where: { id: { equals: '99' } }, + }), + ) + }) +}) diff --git a/src/collections/Pages/endpoints/duplicatePageToTenant.ts b/src/collections/Pages/endpoints/duplicatePageToTenant.ts index 8b4996cf..c6988fa8 100644 --- a/src/collections/Pages/endpoints/duplicatePageToTenant.ts +++ b/src/collections/Pages/endpoints/duplicatePageToTenant.ts @@ -14,13 +14,13 @@ export async function duplicatePageToTenant(req: PayloadRequest) { .find({ collection: 'tenants', where: { id: { equals: selectedTenantId } } }) .then((res) => res.docs[0]) - const newPageSansRefs = clearLayoutRelationships(newPage.layout ?? []) + const layoutWithoutRefs = clearLayoutRelationships(newPage.layout ?? []) return await payload.create({ collection: 'pages', draft: true, data: { - ...newPageSansRefs, + layout: layoutWithoutRefs, tenant, title: `${newPage.title} - Copy`, slug: `${newPage.slug}-copy`, From 942f8bf723e2326f7424ade4e8e568a2c751fcd4 Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Wed, 11 Mar 2026 17:16:11 -0700 Subject: [PATCH 6/7] Delete removeIdKey --- __tests__/server/removeIdKey.server.test.ts | 83 --------------------- consistent-type-assertions.txt | 2 +- src/utilities/removeIdKey.ts | 20 ----- 3 files changed, 1 insertion(+), 104 deletions(-) delete mode 100644 __tests__/server/removeIdKey.server.test.ts delete mode 100644 src/utilities/removeIdKey.ts diff --git a/__tests__/server/removeIdKey.server.test.ts b/__tests__/server/removeIdKey.server.test.ts deleted file mode 100644 index 0328eede..00000000 --- a/__tests__/server/removeIdKey.server.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { removeIdKey } from '@/utilities/removeIdKey' - -describe('removeIdKey', () => { - it('removes top-level id key', () => { - expect(removeIdKey({ id: 1, name: 'test' })).toEqual({ name: 'test' }) - }) - - it('removes nested id keys', () => { - const input = { - id: 1, - block: { id: 'abc', type: 'text', content: 'hello' }, - } - expect(removeIdKey(input)).toEqual({ - block: { type: 'text', content: 'hello' }, - }) - }) - - it('removes id keys from objects inside arrays', () => { - const input = { - id: 1, - items: [ - { id: 'a', label: 'one' }, - { id: 'b', label: 'two' }, - ], - } - expect(removeIdKey(input)).toEqual({ - items: [{ label: 'one' }, { label: 'two' }], - }) - }) - - it('preserves keys that contain "id" but are not exactly "id"', () => { - const input = { id: 1, videoId: 'xyz', blockId: 42 } - expect(removeIdKey(input)).toEqual({ videoId: 'xyz', blockId: 42 }) - }) - - it('handles deeply nested structures', () => { - const input = { - id: 1, - layout: [ - { - id: 'row1', - blockType: 'text', - columns: [ - { - id: 'col1', - content: { id: 'rich1', text: 'hello' }, - }, - ], - }, - ], - } - expect(removeIdKey(input)).toEqual({ - layout: [ - { - blockType: 'text', - columns: [ - { - content: { text: 'hello' }, - }, - ], - }, - ], - }) - }) - - it('returns primitives unchanged', () => { - expect(removeIdKey('hello')).toBe('hello') - expect(removeIdKey(42)).toBe(42) - expect(removeIdKey(null)).toBe(null) - expect(removeIdKey(undefined)).toBe(undefined) - expect(removeIdKey(true)).toBe(true) - }) - - it('handles empty objects and arrays', () => { - expect(removeIdKey({})).toEqual({}) - expect(removeIdKey([])).toEqual([]) - }) - - it('handles arrays of primitives', () => { - expect(removeIdKey([1, 2, 3])).toEqual([1, 2, 3]) - expect(removeIdKey(['a', 'b'])).toEqual(['a', 'b']) - }) -}) diff --git a/consistent-type-assertions.txt b/consistent-type-assertions.txt index fe7a9a7a..aefbfa5d 100644 --- a/consistent-type-assertions.txt +++ b/consistent-type-assertions.txt @@ -1,4 +1,5 @@ __tests__/e2e/auth.setup.ts +__tests__/server/duplicatePageToTenant.server.test.ts src/app/(frontend)/[center]/[...segments]/page.tsx src/app/(frontend)/[center]/[slug]/page.tsx src/app/(frontend)/[center]/blog/[slug]/page.tsx @@ -16,5 +17,4 @@ src/endpoints/seed/upsert.ts src/globals/Diagnostics/actions/revalidateCache.ts src/middleware.ts src/services/vercel.ts -src/utilities/removeIdKey.ts src/utilities/removeNonDeterministicKeys.ts diff --git a/src/utilities/removeIdKey.ts b/src/utilities/removeIdKey.ts deleted file mode 100644 index 362fb02c..00000000 --- a/src/utilities/removeIdKey.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Recursively removes `id` keys from an object to prepare it for duplication. - * Payload auto-generates row IDs — stripping them ensures new IDs are assigned on create. - * Only strips the exact 'id' key, not fields like 'videoId'. - */ -export const removeIdKey = (obj: T): T => { - if (Array.isArray(obj)) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return obj.map(removeIdKey) as T - } - if (obj && typeof obj === 'object') { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return Object.fromEntries( - Object.entries(obj) - .filter(([k]) => k !== 'id') - .map(([k, v]) => [k, removeIdKey(v)]), - ) as T - } - return obj -} From 59d85d37ba90ace47a74fa2f75416e758733638f Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Wed, 11 Mar 2026 17:31:37 -0700 Subject: [PATCH 7/7] Add note about pages and nav copied in draft state --- .../Tenants/components/OnboardingChecklist.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/collections/Tenants/components/OnboardingChecklist.tsx b/src/collections/Tenants/components/OnboardingChecklist.tsx index 2d703098..d609ab14 100644 --- a/src/collections/Tenants/components/OnboardingChecklist.tsx +++ b/src/collections/Tenants/components/OnboardingChecklist.tsx @@ -120,6 +120,12 @@ export function OnboardingChecklist() { )} + {automatedComplete && ( +

+ Pages and navigation are saved as drafts — review and publish. +

+ )} + = builtInPages.expected} label="Built-in pages" @@ -132,10 +138,13 @@ export function OnboardingChecklist() { > {pages.missing.length > 0 &&
Missing: {pages.missing.join(', ')}
} {pages.skipped.length > 0 &&
Skipped (demo pages): {pages.skipped.join(', ')}
} +
Copied as drafts — review and publish
- + + {navigation &&
Created as a draft — review and publish
} +