Skip to content

Commit ec6dca0

Browse files
authored
Merge pull request #997 from NWACus/tenant-improvements
Tenant improvements on addition and ability to delete
2 parents 413f9e4 + 0027fa7 commit ec6dca0

10 files changed

Lines changed: 546 additions & 12 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { authTest, expect } from '../../fixtures/auth.fixture'
2+
import {
3+
AdminUrlUtil,
4+
CollectionSlugs,
5+
getSelectInputOptions,
6+
getSelectInputValue,
7+
openDocControls,
8+
selectInput,
9+
waitForFormReady,
10+
} from '../../helpers'
11+
12+
const SERVER_URL = 'http://localhost:3000'
13+
const tenantsUrl = new AdminUrlUtil(SERVER_URL, CollectionSlugs.tenants)
14+
15+
authTest.describe('Tenants', () => {
16+
authTest.describe('Create', () => {
17+
authTest.describe.configure({ timeout: 60000 })
18+
19+
authTest('slug dropdown only shows unused slugs', async ({ adminPage }) => {
20+
await adminPage.goto(tenantsUrl.list)
21+
await adminPage.waitForLoadState('networkidle')
22+
23+
await adminPage.goto(tenantsUrl.create)
24+
await adminPage.waitForLoadState('networkidle')
25+
await waitForFormReady(adminPage)
26+
27+
const slugField = adminPage.locator('#field-slug')
28+
const options = await getSelectInputOptions({ selectLocator: slugField })
29+
30+
expect(options).not.toContain(expect.stringMatching(/nwac/i))
31+
expect(options).not.toContain(expect.stringMatching(/dvac/i))
32+
expect(options).not.toContain(expect.stringMatching(/sac/i))
33+
expect(options).not.toContain(expect.stringMatching(/snfac/i))
34+
})
35+
36+
authTest('selecting a slug auto-fills the name field', async ({ adminPage }) => {
37+
await adminPage.goto(tenantsUrl.create)
38+
await adminPage.waitForLoadState('networkidle')
39+
await waitForFormReady(adminPage)
40+
41+
const slugField = adminPage.locator('#field-slug')
42+
const nameInput = adminPage.locator('#field-name')
43+
44+
await expect(nameInput).toHaveValue('')
45+
46+
const options = await getSelectInputOptions({ selectLocator: slugField })
47+
if (options.length === 0) {
48+
authTest.skip()
49+
return
50+
}
51+
52+
await selectInput({ selectLocator: slugField, option: options[0] })
53+
54+
await expect(nameInput).not.toHaveValue('', { timeout: 5000 })
55+
})
56+
})
57+
58+
authTest.describe('Read', () => {
59+
authTest.describe.configure({ timeout: 60000 })
60+
61+
authTest('slug field shows current value on existing tenant', async ({ adminPage }) => {
62+
await adminPage.goto(tenantsUrl.list)
63+
await adminPage.waitForLoadState('networkidle')
64+
65+
const firstLink = adminPage.locator('table tbody tr td a').first()
66+
await firstLink.waitFor({ timeout: 15000 })
67+
await firstLink.click()
68+
await adminPage.waitForURL(/\/collections\/tenants\/\d+/)
69+
await waitForFormReady(adminPage)
70+
71+
const slugField = adminPage.locator('#field-slug')
72+
await expect(slugField).toBeVisible()
73+
74+
const value = await getSelectInputValue({ selectLocator: slugField })
75+
expect(value).toBeTruthy()
76+
})
77+
})
78+
79+
authTest.describe('Delete', () => {
80+
authTest.describe.configure({ timeout: 90000 })
81+
82+
authTest('shows custom confirmation modal with type-to-confirm', async ({ adminPage }) => {
83+
await adminPage.goto(tenantsUrl.list)
84+
await adminPage.waitForLoadState('networkidle')
85+
86+
// Click the first tenant to open edit view
87+
const firstLink = adminPage.locator('table tbody tr td a').first()
88+
await firstLink.waitFor({ timeout: 15000 })
89+
await firstLink.click()
90+
await adminPage.waitForURL(/\/collections\/tenants\/\d+/)
91+
await waitForFormReady(adminPage)
92+
93+
// Get the tenant name for verification
94+
const nameInput = adminPage.locator('#field-name')
95+
const tenantName = await nameInput.inputValue()
96+
97+
// Open the doc controls dropdown and click Delete
98+
await openDocControls(adminPage)
99+
const deleteButton = adminPage.getByRole('button', { name: 'Delete', exact: true })
100+
await deleteButton.waitFor({ timeout: 5000 })
101+
await deleteButton.click()
102+
103+
// The custom modal should appear (not Payload's default)
104+
const modal = adminPage.locator('.confirmation-modal')
105+
await expect(modal).toBeVisible({ timeout: 10000 })
106+
107+
// Should show the tenant name in the heading
108+
await expect(modal.locator('h1')).toContainText(tenantName)
109+
110+
// Should have a text input for typing the name
111+
const confirmInput = modal.locator('input[type="text"]')
112+
await expect(confirmInput).toBeVisible()
113+
114+
// Should have a prompt to type the name
115+
await expect(modal).toContainText('Type')
116+
await expect(modal.locator('strong')).toContainText(tenantName)
117+
118+
// Cancel to avoid actually deleting
119+
const cancelButton = modal.getByRole('button', { name: 'Cancel' })
120+
await cancelButton.click()
121+
122+
// Modal should close
123+
await expect(modal).not.toBeVisible({ timeout: 5000 })
124+
})
125+
126+
authTest('shows error when typing wrong name', async ({ adminPage }) => {
127+
await adminPage.goto(tenantsUrl.list)
128+
await adminPage.waitForLoadState('networkidle')
129+
130+
const firstLink = adminPage.locator('table tbody tr td a').first()
131+
await firstLink.waitFor({ timeout: 15000 })
132+
await firstLink.click()
133+
await adminPage.waitForURL(/\/collections\/tenants\/\d+/)
134+
await waitForFormReady(adminPage)
135+
136+
// Open delete modal
137+
await openDocControls(adminPage)
138+
const deleteButton = adminPage.getByRole('button', { name: 'Delete', exact: true })
139+
await deleteButton.waitFor({ timeout: 5000 })
140+
await deleteButton.click()
141+
142+
const modal = adminPage.locator('.confirmation-modal')
143+
await expect(modal).toBeVisible({ timeout: 10000 })
144+
145+
// Type wrong name
146+
const confirmInput = modal.locator('input[type="text"]')
147+
await confirmInput.fill('wrong name')
148+
149+
// Click delete
150+
const confirmDelete = modal.getByRole('button', { name: 'Delete', exact: true })
151+
await confirmDelete.click()
152+
153+
// Should show error toast
154+
await expect(
155+
adminPage.locator(
156+
'.toast-error, .Toastify__toast--error, [data-sonner-toast][data-type="error"]',
157+
),
158+
).toBeVisible({ timeout: 10000 })
159+
160+
// Modal should still be open
161+
await expect(modal).toBeVisible()
162+
163+
// Cancel to clean up
164+
const cancelButton = modal.getByRole('button', { name: 'Cancel' })
165+
await cancelButton.click()
166+
})
167+
})
168+
})
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
deprovisionBeforeDelete,
3+
TENANT_SCOPED_COLLECTIONS,
4+
} from '@/collections/Tenants/hooks/deprovisionBeforeDelete'
5+
import { Logger } from 'pino'
6+
7+
function buildMockLogger(): jest.Mocked<Logger> {
8+
// @ts-expect-error - partial mock of pino Logger; only methods used in tests are provided
9+
return {
10+
warn: jest.fn(),
11+
info: jest.fn(),
12+
error: jest.fn(),
13+
debug: jest.fn(),
14+
}
15+
}
16+
17+
function buildMockPayload(findResults: Record<string, number>) {
18+
const mockLogger = buildMockLogger()
19+
return {
20+
logger: mockLogger,
21+
find: jest.fn(({ collection }: { collection: string }) =>
22+
Promise.resolve({ totalDocs: findResults[collection] ?? 0 }),
23+
),
24+
delete: jest.fn(() => Promise.resolve()),
25+
}
26+
}
27+
28+
describe('deprovisionBeforeDelete', () => {
29+
it('deletes documents from all collections that have tenant-scoped data', async () => {
30+
const findResults: Record<string, number> = {
31+
pages: 5,
32+
posts: 3,
33+
media: 10,
34+
}
35+
const mockPayload = buildMockPayload(findResults)
36+
const mockReq = { payload: mockPayload }
37+
38+
await deprovisionBeforeDelete(
39+
// @ts-expect-error - partial mock; only id and req.payload are used by the hook
40+
{ id: 42, req: mockReq },
41+
)
42+
43+
// Should query every tenant-scoped collection
44+
expect(mockPayload.find).toHaveBeenCalledTimes(TENANT_SCOPED_COLLECTIONS.length)
45+
46+
for (const collection of TENANT_SCOPED_COLLECTIONS) {
47+
expect(mockPayload.find).toHaveBeenCalledWith({
48+
collection,
49+
where: { tenant: { equals: 42 } },
50+
limit: 0,
51+
depth: 0,
52+
})
53+
}
54+
55+
// Should only delete from collections with documents
56+
expect(mockPayload.delete).toHaveBeenCalledTimes(3)
57+
expect(mockPayload.delete).toHaveBeenCalledWith({
58+
collection: 'pages',
59+
where: { tenant: { equals: 42 } },
60+
req: mockReq,
61+
})
62+
expect(mockPayload.delete).toHaveBeenCalledWith({
63+
collection: 'posts',
64+
where: { tenant: { equals: 42 } },
65+
req: mockReq,
66+
})
67+
expect(mockPayload.delete).toHaveBeenCalledWith({
68+
collection: 'media',
69+
where: { tenant: { equals: 42 } },
70+
req: mockReq,
71+
})
72+
})
73+
74+
it('skips delete for collections with no documents', async () => {
75+
const mockPayload = buildMockPayload({})
76+
const mockReq = { payload: mockPayload }
77+
78+
await deprovisionBeforeDelete(
79+
// @ts-expect-error - partial mock; only id and req.payload are used by the hook
80+
{ id: 1, req: mockReq },
81+
)
82+
83+
expect(mockPayload.find).toHaveBeenCalledTimes(TENANT_SCOPED_COLLECTIONS.length)
84+
expect(mockPayload.delete).not.toHaveBeenCalled()
85+
})
86+
87+
it('passes req to delete calls for transaction atomicity', async () => {
88+
const mockPayload = buildMockPayload({ settings: 1 })
89+
const mockReq = { payload: mockPayload }
90+
91+
await deprovisionBeforeDelete(
92+
// @ts-expect-error - partial mock; only id and req.payload are used by the hook
93+
{ id: 7, req: mockReq },
94+
)
95+
96+
expect(mockPayload.delete).toHaveBeenCalledWith(expect.objectContaining({ req: mockReq }))
97+
})
98+
99+
it('logs cleanup start and completion', async () => {
100+
const mockPayload = buildMockPayload({})
101+
const mockReq = { payload: mockPayload }
102+
103+
await deprovisionBeforeDelete(
104+
// @ts-expect-error - partial mock; only id and req.payload are used by the hook
105+
{ id: 99, req: mockReq },
106+
)
107+
108+
expect(mockPayload.logger.info).toHaveBeenCalledWith(
109+
expect.stringContaining('Cleaning up data for tenant 99'),
110+
)
111+
expect(mockPayload.logger.info).toHaveBeenCalledWith(
112+
expect.stringContaining('Cleanup complete for tenant 99'),
113+
)
114+
})
115+
})

docs/testing.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,24 @@ pnpm test:e2e -- --workers=1 --headed __tests__/e2e/admin/tenant-selector/non-te
7373

7474
```
7575
__tests__/e2e/
76-
├── auth.setup.ts # Logs in as each test user and caches browser state
77-
├── admin/ # Admin panel tests (project: admin)
76+
├── auth.setup.ts # Logs in as each test user and caches browser state
77+
├── admin/ # Admin panel tests (project: admin)
78+
│ ├── collections/ # CRUD flow tests for each collection
79+
│ │ └── tenants.e2e.spec.ts
80+
│ ├── tenant-selector/ # Tenant selector behavior tests
81+
│ │ └── ...
82+
│ └── ... # Other admin UI tests
83+
├── frontend/ # Frontend tests (project: frontend)
7884
│ └── ...
79-
├── fixtures/ # Reusable setup/teardown logic
85+
├── fixtures/ # Reusable setup/teardown logic
8086
│ └── ...
81-
├── helpers/ # Shared utilities
87+
├── helpers/ # Shared utilities
8288
│ └── ...
83-
└── .auth/ # Cached storageState files (gitignored)
89+
└── .auth/ # Cached storageState files (gitignored)
8490
```
8591

92+
The `admin/collections/` folder contains tests for the CRUD flows of admin collections. Each file is named after the collection slug (e.g., `tenants.e2e.spec.ts`) and groups tests under `Create`, `Read`, `Update`, and `Delete` describe blocks.
93+
8694
### Writing Tests
8795

8896
Tests use custom Playwright fixtures that provide login and tenant selector helpers. Import from the fixture that matches your needs:

src/app/(payload)/admin/importMap.js

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client'
2+
3+
import { useDocumentInfo, useField, useFormFields } from '@payloadcms/ui'
4+
import { useEffect } from 'react'
5+
6+
import { AVALANCHE_CENTERS, isValidTenantSlug } from '@/utilities/tenancy/avalancheCenters'
7+
8+
/**
9+
* Invisible component that syncs the name field with the selected slug
10+
* for new documents. Each time the slug dropdown changes, the name updates.
11+
*/
12+
export const AutoFillNameFromSlug = () => {
13+
const { id } = useDocumentInfo()
14+
const slugField = useFormFields(([fields]) => fields.slug)
15+
const { setValue: setName } = useField<string>({ path: 'name' })
16+
17+
useEffect(() => {
18+
if (id) return
19+
20+
const slugValue = typeof slugField?.value === 'string' ? slugField.value : null
21+
if (slugValue && isValidTenantSlug(slugValue)) {
22+
setName(AVALANCHE_CENTERS[slugValue].name)
23+
}
24+
}, [id, slugField?.value, setName])
25+
26+
return null
27+
}

0 commit comments

Comments
 (0)