Skip to content

Commit e70b450

Browse files
authored
Merge pull request #974 from NWACus/endpoint-new-tenant
Automate onboarding for new tenant
2 parents ba310e1 + 014c3bb commit e70b450

26 files changed

Lines changed: 1872 additions & 162 deletions
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { OnboardingChecklist } from '@/collections/Tenants/components/OnboardingChecklist'
2+
import '@testing-library/jest-dom'
3+
import { act, render, screen } from '@testing-library/react'
4+
5+
import type { ProvisioningStatus } from '@/collections/Tenants/components/onboardingActions'
6+
7+
const mockCheckStatus = jest.fn()
8+
const mockRunProvision = jest.fn()
9+
const mockUseDocumentInfo = jest.fn()
10+
const mockSetProcessing = jest.fn()
11+
const mockToastPromise = jest.fn()
12+
const mockToastError = jest.fn()
13+
14+
jest.mock('@payloadcms/ui', () => ({
15+
useDocumentInfo: (...args: unknown[]) => mockUseDocumentInfo(...args),
16+
useForm: () => ({ setProcessing: mockSetProcessing }),
17+
toast: {
18+
promise: (...args: unknown[]) => mockToastPromise(...args),
19+
error: (...args: unknown[]) => mockToastError(...args),
20+
},
21+
Button: ({
22+
children,
23+
onClick,
24+
disabled,
25+
}: {
26+
children: React.ReactNode
27+
onClick: () => void
28+
disabled: boolean
29+
}) => (
30+
<button onClick={onClick} disabled={disabled}>
31+
{children}
32+
</button>
33+
),
34+
}))
35+
36+
jest.mock('../../../src/collections/Tenants/components/onboardingActions', () => ({
37+
checkProvisioningStatusAction: (...args: unknown[]) => mockCheckStatus(...args),
38+
runProvisionAction: (...args: unknown[]) => mockRunProvision(...args),
39+
}))
40+
41+
const buildStatus = (overrides: Partial<ProvisioningStatus> = {}): ProvisioningStatus => ({
42+
builtInPages: { count: 0, expected: 7 },
43+
pages: { copied: 0, expected: 5, missing: [], skipped: [] },
44+
homePage: false,
45+
navigation: false,
46+
settings: { exists: false },
47+
theme: { brandColors: false, ogColors: false },
48+
...overrides,
49+
})
50+
51+
const fullyProvisioned = buildStatus({
52+
builtInPages: { count: 7, expected: 7 },
53+
pages: { copied: 5, expected: 5, missing: [], skipped: [] },
54+
homePage: true,
55+
navigation: true,
56+
settings: { exists: true, id: 1 },
57+
theme: { brandColors: true, ogColors: true },
58+
})
59+
60+
const flushAsync = () => act(() => new Promise((r) => setTimeout(r, 0)))
61+
62+
describe('OnboardingChecklist', () => {
63+
beforeEach(() => {
64+
jest.clearAllMocks()
65+
mockUseDocumentInfo.mockReturnValue({ data: { id: 1 } })
66+
mockCheckStatus.mockResolvedValue({ status: fullyProvisioned })
67+
})
68+
69+
describe('provisioning', () => {
70+
beforeEach(() => {
71+
mockCheckStatus.mockResolvedValue({ status: buildStatus() })
72+
mockRunProvision.mockReturnValue(new Promise(() => {}))
73+
})
74+
75+
it('shows spinners while provisioning', async () => {
76+
render(<OnboardingChecklist />)
77+
await flushAsync()
78+
79+
expect(screen.getAllByTestId('spinner').length).toBeGreaterThan(0)
80+
})
81+
82+
it('hides details while provisioning', async () => {
83+
render(<OnboardingChecklist />)
84+
await flushAsync()
85+
86+
expect(screen.queryByText('(0/7)')).not.toBeInTheDocument()
87+
expect(screen.queryByText('(0/5)')).not.toBeInTheDocument()
88+
expect(screen.queryByText('colors.css')).not.toBeInTheDocument()
89+
expect(screen.queryByText('centerColorMap')).not.toBeInTheDocument()
90+
})
91+
92+
it('updates button text when rerunning', async () => {
93+
// Automated items complete but pages incomplete — needsProvisioning returns false,
94+
// so auto-provision doesn't run but the button shows
95+
const incompleteStatus = buildStatus({
96+
builtInPages: { count: 7, expected: 7 },
97+
pages: { copied: 3, expected: 5, missing: ['About Us', 'Donate'], skipped: [] },
98+
homePage: true,
99+
navigation: true,
100+
settings: { exists: true, id: 1 },
101+
})
102+
mockCheckStatus.mockResolvedValue({ status: incompleteStatus })
103+
104+
render(<OnboardingChecklist />)
105+
await flushAsync()
106+
107+
const button = screen.getByText('Rerun Provisioning')
108+
expect(button).toBeInTheDocument()
109+
110+
// Click rerun — provision hangs so we stay in provisioning state
111+
mockRunProvision.mockReturnValue(new Promise(() => {}))
112+
await act(async () => {
113+
button.click()
114+
})
115+
116+
expect(screen.getByText('Provisioning...')).toBeInTheDocument()
117+
expect(screen.queryByText('Rerun Provisioning')).not.toBeInTheDocument()
118+
})
119+
})
120+
121+
describe('loaded', () => {
122+
it('shows missing pages', async () => {
123+
mockCheckStatus.mockResolvedValue({
124+
status: buildStatus({
125+
builtInPages: { count: 7, expected: 7 },
126+
pages: { copied: 3, expected: 5, missing: ['About Us', 'Donate'], skipped: [] },
127+
homePage: true,
128+
navigation: true,
129+
settings: { exists: true, id: 1 },
130+
}),
131+
})
132+
133+
render(<OnboardingChecklist />)
134+
await flushAsync()
135+
136+
expect(screen.getByText('Missing: About Us, Donate')).toBeInTheDocument()
137+
})
138+
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+
156+
it('shows link to settings when settings exist', async () => {
157+
render(<OnboardingChecklist />)
158+
await flushAsync()
159+
160+
const link = screen.getByText('Update Brand Assets')
161+
expect(link).toBeInTheDocument()
162+
expect(link.closest('a')).toHaveAttribute('href', '/admin/collections/settings/1')
163+
})
164+
})
165+
})
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { needsProvisioning } from '@/collections/Tenants/components/needsProvisioning'
2+
import type { ProvisioningStatus } from '@/collections/Tenants/components/onboardingActions'
3+
4+
const buildStatus = (overrides: Partial<ProvisioningStatus> = {}): ProvisioningStatus => ({
5+
builtInPages: { count: 0, expected: 7 },
6+
pages: { copied: 0, expected: 5, missing: [], skipped: [] },
7+
homePage: false,
8+
navigation: false,
9+
settings: { exists: false },
10+
theme: { brandColors: false, ogColors: false },
11+
...overrides,
12+
})
13+
14+
describe('needsProvisioning', () => {
15+
it('returns true when nothing is provisioned', () => {
16+
expect(needsProvisioning(buildStatus())).toBe(true)
17+
})
18+
19+
it('returns false when all automated items are complete', () => {
20+
expect(
21+
needsProvisioning(
22+
buildStatus({
23+
builtInPages: { count: 7, expected: 7 },
24+
homePage: true,
25+
navigation: true,
26+
settings: { exists: true },
27+
}),
28+
),
29+
).toBe(false)
30+
})
31+
32+
it('returns false when partially provisioned (only built-in pages missing)', () => {
33+
expect(
34+
needsProvisioning(
35+
buildStatus({
36+
builtInPages: { count: 3, expected: 7 },
37+
homePage: true,
38+
navigation: true,
39+
settings: { exists: true },
40+
}),
41+
),
42+
).toBe(false)
43+
})
44+
45+
it('returns false when partially provisioned (only home page missing)', () => {
46+
expect(
47+
needsProvisioning(
48+
buildStatus({
49+
builtInPages: { count: 7, expected: 7 },
50+
homePage: false,
51+
navigation: true,
52+
settings: { exists: true },
53+
}),
54+
),
55+
).toBe(false)
56+
})
57+
58+
it('returns false when partially provisioned (only navigation missing)', () => {
59+
expect(
60+
needsProvisioning(
61+
buildStatus({
62+
builtInPages: { count: 7, expected: 7 },
63+
homePage: true,
64+
navigation: false,
65+
settings: { exists: true },
66+
}),
67+
),
68+
).toBe(false)
69+
})
70+
71+
it('returns false when partially provisioned (only settings missing)', () => {
72+
expect(
73+
needsProvisioning(
74+
buildStatus({
75+
builtInPages: { count: 7, expected: 7 },
76+
homePage: true,
77+
navigation: true,
78+
settings: { exists: false },
79+
}),
80+
),
81+
).toBe(false)
82+
})
83+
84+
it('ignores theme status (manual step)', () => {
85+
expect(
86+
needsProvisioning(
87+
buildStatus({
88+
builtInPages: { count: 7, expected: 7 },
89+
homePage: true,
90+
navigation: true,
91+
settings: { exists: true },
92+
theme: { brandColors: false, ogColors: false },
93+
}),
94+
),
95+
).toBe(false)
96+
})
97+
})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { expect, authTest as test } from '../fixtures/auth.fixture'
2+
import { TenantIds } from '../helpers/tenant-cookie'
3+
4+
test.describe.configure({ mode: 'serial', timeout: 60000 })
5+
6+
/** Returns a scoped locator for the onboarding checklist. */
7+
async function getChecklist(page: import('@playwright/test').Page) {
8+
await page.locator('form[data-form-ready="true"]').waitFor({ timeout: 15000 })
9+
10+
const heading = page.getByText('Onboarding Checklist', { exact: true })
11+
await expect(heading).toBeVisible({ timeout: 10000 })
12+
13+
// Scope to the checklist's outermost container (the rounded-lg border div)
14+
const checklist = page.locator('.rounded-lg', { has: heading })
15+
await expect(checklist).toBeVisible({ timeout: 10000 })
16+
17+
return checklist
18+
}
19+
20+
test.describe('Onboarding Checklist', () => {
21+
test('displays checklist on tenant edit page', async ({ adminPage: page }) => {
22+
await page.goto(`/admin/collections/tenants/${TenantIds.nwac}`)
23+
await getChecklist(page)
24+
})
25+
26+
test('shows automated section with checklist items', async ({ adminPage: page }) => {
27+
await page.goto(`/admin/collections/tenants/${TenantIds.nwac}`)
28+
const checklist = await getChecklist(page)
29+
30+
// Automated section header
31+
await expect(checklist.getByText('Automated')).toBeVisible()
32+
33+
// Core checklist items
34+
await expect(checklist.getByText('Built-in pages')).toBeVisible()
35+
await expect(checklist.getByText('Pages - copied from DVAC')).toBeVisible()
36+
await expect(checklist.getByText('Home page')).toBeVisible()
37+
await expect(checklist.getByText('Navigation')).toBeVisible()
38+
await expect(checklist.getByText('Website Settings')).toBeVisible()
39+
})
40+
41+
test('shows needs action section with theme items', async ({ adminPage: page }) => {
42+
await page.goto(`/admin/collections/tenants/${TenantIds.nwac}`)
43+
const checklist = await getChecklist(page)
44+
45+
await expect(checklist.getByText('Needs action')).toBeVisible()
46+
await expect(checklist.getByText('Add brand colors')).toBeVisible()
47+
await expect(checklist.getByText('Add OG image colors')).toBeVisible()
48+
})
49+
50+
test('shows success status for fully provisioned tenant', async ({ adminPage: page }) => {
51+
await page.goto(`/admin/collections/tenants/${TenantIds.nwac}`)
52+
const checklist = await getChecklist(page)
53+
54+
// NWAC is fully provisioned — "Rerun Provisioning" button should not appear
55+
await expect(checklist.getByText('Rerun Provisioning')).not.toBeVisible({ timeout: 5000 })
56+
57+
// All automated items should show count details (indicating loaded status)
58+
await expect(checklist.getByText(/\(\d+\/\d+\)/).first()).toBeVisible({ timeout: 5000 })
59+
60+
// Settings link should point to the tenant's settings document
61+
const settingsLink = checklist.locator('a', { hasText: 'Update Brand Assets' })
62+
await expect(settingsLink).toBeVisible()
63+
await expect(settingsLink).toHaveAttribute('href', /\/admin\/collections\/settings\/\d+/)
64+
})
65+
66+
test('shows empty checklist on tenant create page', async ({ adminPage: page }) => {
67+
await page.goto('/admin/collections/tenants/create')
68+
const checklist = await getChecklist(page)
69+
70+
// No tenantId yet, so status never loads — button should not appear
71+
await expect(checklist.getByText('Rerun Provisioning')).not.toBeVisible({ timeout: 3000 })
72+
})
73+
})

0 commit comments

Comments
 (0)