Skip to content

Commit 413f9e4

Browse files
authored
Merge pull request #985 from NWACus/duplicate-to-enhance
Enhance duplicate page action
2 parents 23bcac4 + fa4606c commit 413f9e4

17 files changed

Lines changed: 665 additions & 427 deletions

__tests__/client/components/OnboardingChecklist.client.test.tsx

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jest.mock('../../../src/collections/Tenants/components/onboardingActions', () =>
4040

4141
const buildStatus = (overrides: Partial<ProvisioningStatus> = {}): ProvisioningStatus => ({
4242
builtInPages: { count: 0, expected: 7 },
43-
pages: { copied: 0, expected: 5, missing: [], skipped: [] },
43+
pages: { created: 0, expected: 5, missing: [] },
4444
homePage: false,
4545
navigation: false,
4646
settings: { exists: false },
@@ -50,7 +50,7 @@ const buildStatus = (overrides: Partial<ProvisioningStatus> = {}): ProvisioningS
5050

5151
const fullyProvisioned = buildStatus({
5252
builtInPages: { count: 7, expected: 7 },
53-
pages: { copied: 5, expected: 5, missing: [], skipped: [] },
53+
pages: { created: 5, expected: 5, missing: [] },
5454
homePage: true,
5555
navigation: true,
5656
settings: { exists: true, id: 1 },
@@ -94,7 +94,7 @@ describe('OnboardingChecklist', () => {
9494
// so auto-provision doesn't run but the button shows
9595
const incompleteStatus = buildStatus({
9696
builtInPages: { count: 7, expected: 7 },
97-
pages: { copied: 3, expected: 5, missing: ['About Us', 'Donate'], skipped: [] },
97+
pages: { created: 3, expected: 5, missing: ['About Us', 'Donate'] },
9898
homePage: true,
9999
navigation: true,
100100
settings: { exists: true, id: 1 },
@@ -123,7 +123,7 @@ describe('OnboardingChecklist', () => {
123123
mockCheckStatus.mockResolvedValue({
124124
status: buildStatus({
125125
builtInPages: { count: 7, expected: 7 },
126-
pages: { copied: 3, expected: 5, missing: ['About Us', 'Donate'], skipped: [] },
126+
pages: { created: 3, expected: 5, missing: ['About Us', 'Donate'] },
127127
homePage: true,
128128
navigation: true,
129129
settings: { exists: true, id: 1 },
@@ -136,23 +136,6 @@ describe('OnboardingChecklist', () => {
136136
expect(screen.getByText('Missing: About Us, Donate')).toBeInTheDocument()
137137
})
138138

139-
it('shows skipped demo pages', async () => {
140-
mockCheckStatus.mockResolvedValue({
141-
status: buildStatus({
142-
builtInPages: { count: 7, expected: 7 },
143-
pages: { copied: 4, expected: 5, missing: [], skipped: ['Demo Page'] },
144-
homePage: true,
145-
navigation: true,
146-
settings: { exists: true, id: 1 },
147-
}),
148-
})
149-
150-
render(<OnboardingChecklist />)
151-
await flushAsync()
152-
153-
expect(screen.getByText('Skipped (demo pages): Demo Page')).toBeInTheDocument()
154-
})
155-
156139
it('shows link to settings when settings exist', async () => {
157140
render(<OnboardingChecklist />)
158141
await flushAsync()

__tests__/client/components/needsProvisioning.client.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ProvisioningStatus } from '@/collections/Tenants/components/onboar
33

44
const buildStatus = (overrides: Partial<ProvisioningStatus> = {}): ProvisioningStatus => ({
55
builtInPages: { count: 0, expected: 7 },
6-
pages: { copied: 0, expected: 5, missing: [], skipped: [] },
6+
pages: { created: 0, expected: 5, missing: [] },
77
homePage: false,
88
navigation: false,
99
settings: { exists: false },
@@ -21,6 +21,7 @@ describe('needsProvisioning', () => {
2121
needsProvisioning(
2222
buildStatus({
2323
builtInPages: { count: 7, expected: 7 },
24+
pages: { created: 5, expected: 5, missing: [] },
2425
homePage: true,
2526
navigation: true,
2627
settings: { exists: true },
@@ -34,6 +35,21 @@ describe('needsProvisioning', () => {
3435
needsProvisioning(
3536
buildStatus({
3637
builtInPages: { count: 3, expected: 7 },
38+
pages: { created: 5, expected: 5, missing: [] },
39+
homePage: true,
40+
navigation: true,
41+
settings: { exists: true },
42+
}),
43+
),
44+
).toBe(false)
45+
})
46+
47+
it('returns false when partially provisioned (only pages missing)', () => {
48+
expect(
49+
needsProvisioning(
50+
buildStatus({
51+
builtInPages: { count: 7, expected: 7 },
52+
pages: { created: 3, expected: 5, missing: ['About Us', 'Donate'] },
3753
homePage: true,
3854
navigation: true,
3955
settings: { exists: true },
@@ -47,6 +63,7 @@ describe('needsProvisioning', () => {
4763
needsProvisioning(
4864
buildStatus({
4965
builtInPages: { count: 7, expected: 7 },
66+
pages: { created: 5, expected: 5, missing: [] },
5067
homePage: false,
5168
navigation: true,
5269
settings: { exists: true },
@@ -60,6 +77,7 @@ describe('needsProvisioning', () => {
6077
needsProvisioning(
6178
buildStatus({
6279
builtInPages: { count: 7, expected: 7 },
80+
pages: { created: 5, expected: 5, missing: [] },
6381
homePage: true,
6482
navigation: false,
6583
settings: { exists: true },
@@ -73,6 +91,7 @@ describe('needsProvisioning', () => {
7391
needsProvisioning(
7492
buildStatus({
7593
builtInPages: { count: 7, expected: 7 },
94+
pages: { created: 5, expected: 5, missing: [] },
7695
homePage: true,
7796
navigation: true,
7897
settings: { exists: false },

__tests__/server/OnboardingStatusCell.server.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function isReactElement(value: unknown): value is React.ReactElement<{
3131

3232
const buildStatus = (overrides: Partial<ProvisioningStatus> = {}): ProvisioningStatus => ({
3333
builtInPages: { count: 7, expected: 7 },
34-
pages: { copied: 5, expected: 5, missing: [], skipped: [] },
34+
pages: { created: 5, expected: 5, missing: [] },
3535
homePage: true,
3636
navigation: true,
3737
settings: { exists: true, id: 1 },
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
jest.mock('../../src/constants/defaults', () => ({
2+
// eslint-disable-next-line @typescript-eslint/no-require-imports
3+
DEFAULT_BLOCKS: require('./fixtures/mockBlocks').DEFAULT_BLOCKS,
4+
}))
5+
6+
jest.mock('../../src/blocks/NACMedia/config', () => ({
7+
// eslint-disable-next-line @typescript-eslint/no-require-imports
8+
NACMediaBlock: require('./fixtures/mockBlocks').NACMediaBlock,
9+
}))
10+
11+
jest.mock('../../src/blocks/Button/config', () => ({
12+
// eslint-disable-next-line @typescript-eslint/no-require-imports
13+
ButtonBlock: require('./fixtures/mockBlocks').ButtonBlock,
14+
}))
15+
16+
jest.mock('../../src/blocks/Callout/config', () => ({
17+
// eslint-disable-next-line @typescript-eslint/no-require-imports
18+
CalloutBlock: require('./fixtures/mockBlocks').CalloutBlock,
19+
}))
20+
21+
jest.mock('../../src/payload.config', () => ({}))
22+
23+
jest.mock('payload', () => ({
24+
getPayload: jest.fn(),
25+
}))
26+
27+
import { duplicatePageToTenant } from '@/collections/Pages/endpoints/duplicatePageToTenant'
28+
import type { Payload, PayloadRequest } from 'payload'
29+
import { getPayload } from 'payload'
30+
31+
const mockFind = jest.fn()
32+
const mockCreate = jest.fn()
33+
34+
const mockTenant = { id: 42, name: 'Test AC', slug: 'tac' }
35+
36+
beforeEach(() => {
37+
jest
38+
.mocked(getPayload)
39+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
40+
.mockResolvedValue({ find: mockFind, create: mockCreate } as unknown as Payload)
41+
// First find call: tenant lookup. Second find call: slug existence check (no conflict by default).
42+
mockFind
43+
.mockReset()
44+
.mockResolvedValueOnce({ docs: [mockTenant] })
45+
.mockResolvedValueOnce({ docs: [] })
46+
mockCreate.mockReset().mockResolvedValue({ id: 99 })
47+
})
48+
49+
function buildRequest(
50+
tenantSlug: string | undefined,
51+
body: Record<string, unknown>,
52+
): PayloadRequest {
53+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
54+
return {
55+
routeParams: tenantSlug !== undefined ? { tenantSlug } : undefined,
56+
json: async () => body,
57+
} as unknown as PayloadRequest
58+
}
59+
60+
describe('duplicatePageToTenant', () => {
61+
it('creates the page as a draft', async () => {
62+
const req = buildRequest('42', {
63+
newPage: { title: 'About', slug: 'about', layout: [] },
64+
})
65+
await duplicatePageToTenant(req)
66+
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ draft: true }))
67+
})
68+
69+
it('appends " - Copy" when a page with the same slug exists', async () => {
70+
// Slug existence check returns a match
71+
mockFind
72+
.mockReset()
73+
.mockResolvedValueOnce({ docs: [mockTenant] })
74+
.mockResolvedValueOnce({ docs: [{ id: 1, slug: 'about-us' }] })
75+
const req = buildRequest('42', {
76+
newPage: { title: 'About Us', slug: 'about-us', layout: [] },
77+
})
78+
await duplicatePageToTenant(req)
79+
expect(mockCreate).toHaveBeenCalledWith(
80+
expect.objectContaining({
81+
data: expect.objectContaining({ title: 'About Us - Copy', slug: 'about-us-copy' }),
82+
}),
83+
)
84+
})
85+
86+
it('keeps original title and slug when no conflict exists', async () => {
87+
const req = buildRequest('42', {
88+
newPage: { title: 'About Us', slug: 'about-us', layout: [] },
89+
})
90+
await duplicatePageToTenant(req)
91+
expect(mockCreate).toHaveBeenCalledWith(
92+
expect.objectContaining({
93+
data: expect.objectContaining({ title: 'About Us', slug: 'about-us' }),
94+
}),
95+
)
96+
})
97+
98+
it('sets the tenant from the lookup result', async () => {
99+
const req = buildRequest('42', {
100+
newPage: { title: 'About', slug: 'about', layout: [] },
101+
})
102+
await duplicatePageToTenant(req)
103+
expect(mockCreate).toHaveBeenCalledWith(
104+
expect.objectContaining({
105+
data: expect.objectContaining({ tenant: mockTenant }),
106+
}),
107+
)
108+
})
109+
110+
it('passes layout through clearLayoutRelationships', async () => {
111+
const req = buildRequest('42', {
112+
newPage: {
113+
title: 'About',
114+
slug: 'about',
115+
layout: [{ blockType: 'singleBlogPost', post: 123, backgroundColor: 'red' }],
116+
},
117+
})
118+
await duplicatePageToTenant(req)
119+
const layout = mockCreate.mock.calls[0][0].data.layout
120+
expect(layout[0]).not.toHaveProperty('post')
121+
expect(layout[0]).toHaveProperty('backgroundColor', 'red')
122+
})
123+
124+
it('falls back to empty layout when newPage.layout is absent', async () => {
125+
const req = buildRequest('42', { newPage: { title: 'About', slug: 'about' } })
126+
await duplicatePageToTenant(req)
127+
expect(mockCreate).toHaveBeenCalledWith(
128+
expect.objectContaining({ data: expect.objectContaining({ layout: [] }) }),
129+
)
130+
})
131+
132+
it('looks up the tenant using tenantSlug from route param', async () => {
133+
const req = buildRequest('tac', {
134+
newPage: { title: 'About', slug: 'about', layout: [] },
135+
})
136+
await duplicatePageToTenant(req)
137+
expect(mockFind).toHaveBeenCalledWith(
138+
expect.objectContaining({
139+
collection: 'tenants',
140+
where: { slug: { equals: 'tac' } },
141+
}),
142+
)
143+
})
144+
})
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Minimal block configs for testing clearLayoutRelationships.
2+
// These mirror the shape of Payload Block configs without importing from payload.
3+
export const DEFAULT_BLOCKS = [
4+
{
5+
slug: 'singleBlogPost',
6+
fields: [
7+
{ type: 'relationship', name: 'post', relationTo: 'posts' },
8+
{ type: 'text', name: 'backgroundColor' },
9+
],
10+
},
11+
{
12+
slug: 'mediaBlock',
13+
fields: [{ type: 'upload', name: 'media', relationTo: 'media' }],
14+
},
15+
{
16+
slug: 'sponsorsBlock',
17+
fields: [
18+
{ type: 'relationship', name: 'sponsors', relationTo: 'sponsors', hasMany: true },
19+
{ type: 'text', name: 'sponsorsLayout' },
20+
],
21+
},
22+
{
23+
slug: 'singleEvent',
24+
fields: [
25+
{ type: 'relationship', name: 'event', relationTo: 'events' },
26+
{ type: 'text', name: 'backgroundColor' },
27+
],
28+
},
29+
{
30+
slug: 'blogList',
31+
fields: [
32+
{ type: 'text', name: 'postOptions' },
33+
{
34+
type: 'group',
35+
name: 'staticOptions',
36+
fields: [{ type: 'relationship', name: 'staticPosts', relationTo: 'posts', hasMany: true }],
37+
},
38+
],
39+
},
40+
{
41+
slug: 'imageLinkGrid',
42+
fields: [
43+
{
44+
type: 'array',
45+
name: 'columns',
46+
fields: [
47+
{ type: 'upload', name: 'image', relationTo: 'media' },
48+
{ type: 'text', name: 'caption' },
49+
],
50+
},
51+
],
52+
},
53+
]
54+
55+
export const NACMediaBlock = { slug: 'nacMedia', fields: [] }
56+
57+
// Lexical-embedded blocks (used inside richText BlocksFeature, not in DEFAULT_BLOCKS)
58+
export const ButtonBlock = {
59+
slug: 'buttonBlock',
60+
fields: [
61+
{
62+
type: 'group',
63+
name: 'button',
64+
fields: [
65+
{
66+
type: 'row',
67+
fields: [{ type: 'radio', name: 'type' }],
68+
},
69+
{
70+
type: 'row',
71+
fields: [
72+
{
73+
type: 'relationship',
74+
name: 'reference',
75+
relationTo: ['pages', 'builtInPages', 'posts'],
76+
},
77+
{ type: 'text', name: 'url' },
78+
{ type: 'text', name: 'label' },
79+
],
80+
},
81+
],
82+
},
83+
],
84+
}
85+
86+
export const CalloutBlock = {
87+
slug: 'calloutBlock',
88+
fields: [
89+
{ type: 'richText', name: 'richText' },
90+
{ type: 'text', name: 'style' },
91+
],
92+
}

0 commit comments

Comments
 (0)