diff --git a/__tests__/server/duplicatePageToTenant.server.test.ts b/__tests__/server/duplicatePageToTenant.server.test.ts new file mode 100644 index 000000000..259976403 --- /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/__tests__/server/fixtures/mockBlocks.ts b/__tests__/server/fixtures/mockBlocks.ts new file mode 100644 index 000000000..d5d3db658 --- /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 6d1cfa68e..e58365641 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/__tests__/server/removeIdKey.server.test.ts b/__tests__/server/removeIdKey.server.test.ts deleted file mode 100644 index 0328eede5..000000000 --- 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 3345131d6..aefbfa5d6 100644 --- a/consistent-type-assertions.txt +++ b/consistent-type-assertions.txt @@ -1,11 +1,11 @@ __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 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 @@ -17,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/collections/Pages/components/DuplicatePageFor/DuplicatePageForDrawer.tsx b/src/collections/Pages/components/DuplicatePageFor/DuplicatePageForDrawer.tsx index 4e11967af..7d7daf1ed 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 99d68de5f..c6988fa8b 100644 --- a/src/collections/Pages/endpoints/duplicatePageToTenant.ts +++ b/src/collections/Pages/endpoints/duplicatePageToTenant.ts @@ -1,12 +1,11 @@ 'use server' -import { removeIdKey } from '@/utilities/removeIdKey' +import { clearLayoutRelationships } from '@/utilities/clearLayoutRelationships' 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?.() @@ -15,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 layoutWithoutRefs = clearLayoutRelationships(newPage.layout ?? []) return await payload.create({ collection: 'pages', + draft: true, data: { - ...newPageSansIds, + layout: layoutWithoutRefs, tenant, title: `${newPage.title} - Copy`, slug: `${newPage.slug}-copy`, diff --git a/src/collections/Tenants/components/OnboardingChecklist.tsx b/src/collections/Tenants/components/OnboardingChecklist.tsx index 2d703098d..d609ab14a 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
} +
- !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: [] }, diff --git a/src/utilities/clearLayoutRelationships.ts b/src/utilities/clearLayoutRelationships.ts new file mode 100644 index 000000000..32c4d4e53 --- /dev/null +++ b/src/utilities/clearLayoutRelationships.ts @@ -0,0 +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 + +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 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(data, field.name) + } else if (field.type === 'group') { + if ('name' in field) { + const groupData = data[field.name] + if (isDataObject(groupData)) { + clearFieldValuesInPlace(groupData, field.fields) + } + } else { + // Unnamed group — fields are at the same level + clearFieldValuesInPlace(data, field.fields) + } + } else if (field.type === 'array') { + const arrayData = data[field.name] + if (Array.isArray(arrayData)) { + arrayData.forEach((item) => { + if (isDataObject(item)) { + clearFieldValuesInPlace(item, field.fields) + } + }) + } + } else if (field.type === 'richText') { + const richTextData = data[field.name] + if (isDataObject(richTextData)) { + clearLexicalBlockRelationshipsInPlace(richTextData) + } + } else if (field.type === 'row' || field.type === 'collapsible') { + // 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) { + const tabData = data[tab.name] + if (isDataObject(tabData)) { + clearFieldValuesInPlace(tabData, tab.fields) + } + } else { + clearFieldValuesInPlace(data, tab.fields) + } + } + } + } +} + +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) { + clearFieldValuesInPlace(node.fields, blockConfig.fields ?? []) + } + } + } + if (Array.isArray(node.children)) { + node.children.forEach((child) => { + if (isDataObject(child)) { + walkNode(child) + } + }) + } + } + + if (isDataObject(lexicalData.root)) { + walkNode(lexicalData.root) + } +} + +/** + * Clears relationship and upload fields from a page layout so + * tenant-scoped references can be repopulated after duplication. + */ +export function clearLayoutRelationships(layout: Page['layout']): Page['layout'] { + if (!Array.isArray(layout)) return layout + return layout.map((block) => { + 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 + }) +} diff --git a/src/utilities/removeIdKey.ts b/src/utilities/removeIdKey.ts deleted file mode 100644 index 362fb02cb..000000000 --- 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 -}