diff --git a/__tests__/e2e/admin/collections/tenants.e2e.spec.ts b/__tests__/e2e/admin/collections/tenants.e2e.spec.ts new file mode 100644 index 00000000..0a404d8e --- /dev/null +++ b/__tests__/e2e/admin/collections/tenants.e2e.spec.ts @@ -0,0 +1,168 @@ +import { authTest, expect } from '../../fixtures/auth.fixture' +import { + AdminUrlUtil, + CollectionSlugs, + getSelectInputOptions, + getSelectInputValue, + openDocControls, + selectInput, + waitForFormReady, +} from '../../helpers' + +const SERVER_URL = 'http://localhost:3000' +const tenantsUrl = new AdminUrlUtil(SERVER_URL, CollectionSlugs.tenants) + +authTest.describe('Tenants', () => { + authTest.describe('Create', () => { + authTest.describe.configure({ timeout: 60000 }) + + authTest('slug dropdown only shows unused slugs', async ({ adminPage }) => { + await adminPage.goto(tenantsUrl.list) + await adminPage.waitForLoadState('networkidle') + + await adminPage.goto(tenantsUrl.create) + await adminPage.waitForLoadState('networkidle') + await waitForFormReady(adminPage) + + const slugField = adminPage.locator('#field-slug') + const options = await getSelectInputOptions({ selectLocator: slugField }) + + expect(options).not.toContain(expect.stringMatching(/nwac/i)) + expect(options).not.toContain(expect.stringMatching(/dvac/i)) + expect(options).not.toContain(expect.stringMatching(/sac/i)) + expect(options).not.toContain(expect.stringMatching(/snfac/i)) + }) + + authTest('selecting a slug auto-fills the name field', async ({ adminPage }) => { + await adminPage.goto(tenantsUrl.create) + await adminPage.waitForLoadState('networkidle') + await waitForFormReady(adminPage) + + const slugField = adminPage.locator('#field-slug') + const nameInput = adminPage.locator('#field-name') + + await expect(nameInput).toHaveValue('') + + const options = await getSelectInputOptions({ selectLocator: slugField }) + if (options.length === 0) { + authTest.skip() + return + } + + await selectInput({ selectLocator: slugField, option: options[0] }) + + await expect(nameInput).not.toHaveValue('', { timeout: 5000 }) + }) + }) + + authTest.describe('Read', () => { + authTest.describe.configure({ timeout: 60000 }) + + authTest('slug field shows current value on existing tenant', async ({ adminPage }) => { + await adminPage.goto(tenantsUrl.list) + await adminPage.waitForLoadState('networkidle') + + const firstLink = adminPage.locator('table tbody tr td a').first() + await firstLink.waitFor({ timeout: 15000 }) + await firstLink.click() + await adminPage.waitForURL(/\/collections\/tenants\/\d+/) + await waitForFormReady(adminPage) + + const slugField = adminPage.locator('#field-slug') + await expect(slugField).toBeVisible() + + const value = await getSelectInputValue({ selectLocator: slugField }) + expect(value).toBeTruthy() + }) + }) + + authTest.describe('Delete', () => { + authTest.describe.configure({ timeout: 90000 }) + + authTest('shows custom confirmation modal with type-to-confirm', async ({ adminPage }) => { + await adminPage.goto(tenantsUrl.list) + await adminPage.waitForLoadState('networkidle') + + // Click the first tenant to open edit view + const firstLink = adminPage.locator('table tbody tr td a').first() + await firstLink.waitFor({ timeout: 15000 }) + await firstLink.click() + await adminPage.waitForURL(/\/collections\/tenants\/\d+/) + await waitForFormReady(adminPage) + + // Get the tenant name for verification + const nameInput = adminPage.locator('#field-name') + const tenantName = await nameInput.inputValue() + + // Open the doc controls dropdown and click Delete + await openDocControls(adminPage) + const deleteButton = adminPage.getByRole('button', { name: 'Delete', exact: true }) + await deleteButton.waitFor({ timeout: 5000 }) + await deleteButton.click() + + // The custom modal should appear (not Payload's default) + const modal = adminPage.locator('.confirmation-modal') + await expect(modal).toBeVisible({ timeout: 10000 }) + + // Should show the tenant name in the heading + await expect(modal.locator('h1')).toContainText(tenantName) + + // Should have a text input for typing the name + const confirmInput = modal.locator('input[type="text"]') + await expect(confirmInput).toBeVisible() + + // Should have a prompt to type the name + await expect(modal).toContainText('Type') + await expect(modal.getByText(tenantName, { exact: true })).toBeVisible() + + // Cancel to avoid actually deleting + const cancelButton = modal.getByRole('button', { name: 'Cancel' }) + await cancelButton.click() + + // Modal should close + await expect(modal).not.toBeVisible({ timeout: 5000 }) + }) + + authTest('shows error when typing wrong name', async ({ adminPage }) => { + await adminPage.goto(tenantsUrl.list) + await adminPage.waitForLoadState('networkidle') + + const firstLink = adminPage.locator('table tbody tr td a').first() + await firstLink.waitFor({ timeout: 15000 }) + await firstLink.click() + await adminPage.waitForURL(/\/collections\/tenants\/\d+/) + await waitForFormReady(adminPage) + + // Open delete modal + await openDocControls(adminPage) + const deleteButton = adminPage.getByRole('button', { name: 'Delete', exact: true }) + await deleteButton.waitFor({ timeout: 5000 }) + await deleteButton.click() + + const modal = adminPage.locator('.confirmation-modal') + await expect(modal).toBeVisible({ timeout: 10000 }) + + // Type wrong name + const confirmInput = modal.locator('input[type="text"]') + await confirmInput.fill('wrong name') + + // Click delete + const confirmDelete = modal.getByRole('button', { name: 'Delete', exact: true }) + await confirmDelete.click() + + // Should show error toast + await expect( + adminPage.locator( + '.toast-error, .Toastify__toast--error, [data-sonner-toast][data-type="error"]', + ), + ).toBeVisible({ timeout: 10000 }) + + // Modal should still be open + await expect(modal).toBeVisible() + + // Cancel to clean up + const cancelButton = modal.getByRole('button', { name: 'Cancel' }) + await cancelButton.click() + }) + }) +}) diff --git a/__tests__/e2e/admin/tenant-selector/sync-on-save.e2e.spec.ts b/__tests__/e2e/admin/tenant-selector/sync-on-save.e2e.spec.ts index f0bcd44b..73b6b730 100644 --- a/__tests__/e2e/admin/tenant-selector/sync-on-save.e2e.spec.ts +++ b/__tests__/e2e/admin/tenant-selector/sync-on-save.e2e.spec.ts @@ -69,10 +69,11 @@ async function createTenant( await page.waitForLoadState('networkidle') await waitForFormReady(page) - await page.locator('#field-name').fill(name) const slugField = page.locator('#field-slug') await slugField.locator('button.dropdown-indicator').click() await slugField.locator('.rs__option', { hasText: new RegExp(`\\(${slug}\\)`) }).click() + // Fill name after slug selection — AutoFillNameFromSlug overwrites name on slug change + await page.locator('#field-name').fill(name) await saveDocAndAssert(page) return slug diff --git a/__tests__/e2e/helpers/save-doc.ts b/__tests__/e2e/helpers/save-doc.ts index 4fe8a6e5..478dd1eb 100644 --- a/__tests__/e2e/helpers/save-doc.ts +++ b/__tests__/e2e/helpers/save-doc.ts @@ -122,5 +122,6 @@ export async function waitForLoading(page: Page, timeout = 10000): Promise export async function openDocControls(page: Page): Promise { const docControls = page.locator('.doc-controls__popup .popup-button') await docControls.click() - await expect(page.locator('.doc-controls__popup .popup__content')).toBeVisible() + // Popup content is rendered via React portal at document body level, not inside .doc-controls__popup + await expect(page.locator('.popup__content')).toBeVisible({ timeout: 10000 }) } diff --git a/__tests__/e2e/helpers/select-input.ts b/__tests__/e2e/helpers/select-input.ts index 10090990..73cb679d 100644 --- a/__tests__/e2e/helpers/select-input.ts +++ b/__tests__/e2e/helpers/select-input.ts @@ -48,7 +48,9 @@ const selectors = { * Use with hasText to match exact option text. */ export function exactText(text: string): RegExp { - return new RegExp(`^${text}$`) + // Escape regex special characters so literal text like "(bac)" matches correctly + const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`^${escaped}$`) } /** diff --git a/__tests__/server/deprovisionBeforeDelete.server.test.ts b/__tests__/server/deprovisionBeforeDelete.server.test.ts new file mode 100644 index 00000000..385ab2ee --- /dev/null +++ b/__tests__/server/deprovisionBeforeDelete.server.test.ts @@ -0,0 +1,115 @@ +import { + deprovisionBeforeDelete, + TENANT_SCOPED_COLLECTIONS, +} from '@/collections/Tenants/hooks/deprovisionBeforeDelete' +import { Logger } from 'pino' + +function buildMockLogger(): jest.Mocked { + // @ts-expect-error - partial mock of pino Logger; only methods used in tests are provided + return { + warn: jest.fn(), + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + } +} + +function buildMockPayload(findResults: Record) { + const mockLogger = buildMockLogger() + return { + logger: mockLogger, + find: jest.fn(({ collection }: { collection: string }) => + Promise.resolve({ totalDocs: findResults[collection] ?? 0 }), + ), + delete: jest.fn(() => Promise.resolve()), + } +} + +describe('deprovisionBeforeDelete', () => { + it('deletes documents from all collections that have tenant-scoped data', async () => { + const findResults: Record = { + pages: 5, + posts: 3, + media: 10, + } + const mockPayload = buildMockPayload(findResults) + const mockReq = { payload: mockPayload } + + await deprovisionBeforeDelete( + // @ts-expect-error - partial mock; only id and req.payload are used by the hook + { id: 42, req: mockReq }, + ) + + // Should query every tenant-scoped collection + expect(mockPayload.find).toHaveBeenCalledTimes(TENANT_SCOPED_COLLECTIONS.length) + + for (const collection of TENANT_SCOPED_COLLECTIONS) { + expect(mockPayload.find).toHaveBeenCalledWith({ + collection, + where: { tenant: { equals: 42 } }, + limit: 0, + depth: 0, + }) + } + + // Should only delete from collections with documents + expect(mockPayload.delete).toHaveBeenCalledTimes(3) + expect(mockPayload.delete).toHaveBeenCalledWith({ + collection: 'pages', + where: { tenant: { equals: 42 } }, + req: mockReq, + }) + expect(mockPayload.delete).toHaveBeenCalledWith({ + collection: 'posts', + where: { tenant: { equals: 42 } }, + req: mockReq, + }) + expect(mockPayload.delete).toHaveBeenCalledWith({ + collection: 'media', + where: { tenant: { equals: 42 } }, + req: mockReq, + }) + }) + + it('skips delete for collections with no documents', async () => { + const mockPayload = buildMockPayload({}) + const mockReq = { payload: mockPayload } + + await deprovisionBeforeDelete( + // @ts-expect-error - partial mock; only id and req.payload are used by the hook + { id: 1, req: mockReq }, + ) + + expect(mockPayload.find).toHaveBeenCalledTimes(TENANT_SCOPED_COLLECTIONS.length) + expect(mockPayload.delete).not.toHaveBeenCalled() + }) + + it('passes req to delete calls for transaction atomicity', async () => { + const mockPayload = buildMockPayload({ settings: 1 }) + const mockReq = { payload: mockPayload } + + await deprovisionBeforeDelete( + // @ts-expect-error - partial mock; only id and req.payload are used by the hook + { id: 7, req: mockReq }, + ) + + expect(mockPayload.delete).toHaveBeenCalledWith(expect.objectContaining({ req: mockReq })) + }) + + it('logs cleanup start and completion', async () => { + const mockPayload = buildMockPayload({}) + const mockReq = { payload: mockPayload } + + await deprovisionBeforeDelete( + // @ts-expect-error - partial mock; only id and req.payload are used by the hook + { id: 99, req: mockReq }, + ) + + expect(mockPayload.logger.info).toHaveBeenCalledWith( + expect.stringContaining('Cleaning up data for tenant 99'), + ) + expect(mockPayload.logger.info).toHaveBeenCalledWith( + expect.stringContaining('Cleanup complete for tenant 99'), + ) + }) +}) diff --git a/docs/testing.md b/docs/testing.md index 0700c3c9..bff94e89 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -73,16 +73,24 @@ pnpm test:e2e -- --workers=1 --headed __tests__/e2e/admin/tenant-selector/non-te ``` __tests__/e2e/ -├── auth.setup.ts # Logs in as each test user and caches browser state -├── admin/ # Admin panel tests (project: admin) +├── auth.setup.ts # Logs in as each test user and caches browser state +├── admin/ # Admin panel tests (project: admin) +│ ├── collections/ # CRUD flow tests for each collection +│ │ └── tenants.e2e.spec.ts +│ ├── tenant-selector/ # Tenant selector behavior tests +│ │ └── ... +│ └── ... # Other admin UI tests +├── frontend/ # Frontend tests (project: frontend) │ └── ... -├── fixtures/ # Reusable setup/teardown logic +├── fixtures/ # Reusable setup/teardown logic │ └── ... -├── helpers/ # Shared utilities +├── helpers/ # Shared utilities │ └── ... -└── .auth/ # Cached storageState files (gitignored) +└── .auth/ # Cached storageState files (gitignored) ``` +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. + ### Writing Tests Tests use custom Playwright fixtures that provide login and tenant selector helpers. Import from the fixture that matches your needs: diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index d2402076..e9f8d33a 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -35,9 +35,12 @@ import { InviteUser as InviteUser_6042b6804e11048cd4fbe6206cbc2b0f } from '@/col import { ResendInviteButton as ResendInviteButton_e262b7912e5bdc08a1a83eb2731de735 } from '@/collections/Users/components/ResendInviteButton' import { CollectionsField as CollectionsField_49c0311020325b59204cc21d2f536b8d } from '@/collections/Roles/components/CollectionsField' import { RulesCell as RulesCell_649699f5b285e7a5429592dc58fd6f0c } from '@/collections/Roles/components/RulesCell' +import { TenantSlugField as TenantSlugField_1aeaed4308ae318944aa6215b1567366 } from '@/collections/Tenants/components/TenantSlugField' import { OnboardingStatusCell as OnboardingStatusCell_132cf100d66efa575804a025c9c1c699 } from '@/collections/Tenants/components/OnboardingStatusCell' import { OnboardingChecklist as OnboardingChecklist_e43d4b78209dd849b6f9ccc557d93063 } from '@/collections/Tenants/components/OnboardingChecklist' import { SyncTenantsOnSave as SyncTenantsOnSave_7025498606b767f7843bf544e6535ee1 } from '@/collections/Tenants/components/SyncTenantsOnSave' +import { DeleteTenantModal as DeleteTenantModal_4179c7e18c353aeb4f324f76e2ac1d6e } from '@/collections/Tenants/components/DeleteTenantModal' +import { AutoFillNameFromSlug as AutoFillNameFromSlug_d35762ea9217d01948b813fe4241fe10 } from '@/collections/Tenants/components/AutoFillNameFromSlug' import { LinkLabelDescription as LinkLabelDescription_cc2cf53f1598892c0c926f3cb616a721 } from '@/fields/navLink/components/LinkLabelDescription' import { AvalancheCenterName as AvalancheCenterName_acb7f1a03857e27efe1942bb65ab80ad } from '@/collections/Settings/components/AvalancheCenterName' import { USFSLogoDescription as USFSLogoDescription_d2eea91290575f9a545768dce25713f4 } from '@/collections/Settings/components/USFSLogoDescription' @@ -96,9 +99,12 @@ export const importMap = { "@/collections/Users/components/ResendInviteButton#ResendInviteButton": ResendInviteButton_e262b7912e5bdc08a1a83eb2731de735, "@/collections/Roles/components/CollectionsField#CollectionsField": CollectionsField_49c0311020325b59204cc21d2f536b8d, "@/collections/Roles/components/RulesCell#RulesCell": RulesCell_649699f5b285e7a5429592dc58fd6f0c, + "@/collections/Tenants/components/TenantSlugField#TenantSlugField": TenantSlugField_1aeaed4308ae318944aa6215b1567366, "@/collections/Tenants/components/OnboardingStatusCell#OnboardingStatusCell": OnboardingStatusCell_132cf100d66efa575804a025c9c1c699, "@/collections/Tenants/components/OnboardingChecklist#OnboardingChecklist": OnboardingChecklist_e43d4b78209dd849b6f9ccc557d93063, "@/collections/Tenants/components/SyncTenantsOnSave#SyncTenantsOnSave": SyncTenantsOnSave_7025498606b767f7843bf544e6535ee1, + "@/collections/Tenants/components/DeleteTenantModal#DeleteTenantModal": DeleteTenantModal_4179c7e18c353aeb4f324f76e2ac1d6e, + "@/collections/Tenants/components/AutoFillNameFromSlug#AutoFillNameFromSlug": AutoFillNameFromSlug_d35762ea9217d01948b813fe4241fe10, "@/fields/navLink/components/LinkLabelDescription#LinkLabelDescription": LinkLabelDescription_cc2cf53f1598892c0c926f3cb616a721, "@/collections/Settings/components/AvalancheCenterName#AvalancheCenterName": AvalancheCenterName_acb7f1a03857e27efe1942bb65ab80ad, "@/collections/Settings/components/USFSLogoDescription#USFSLogoDescription": USFSLogoDescription_d2eea91290575f9a545768dce25713f4, diff --git a/src/collections/Tenants/components/AutoFillNameFromSlug.tsx b/src/collections/Tenants/components/AutoFillNameFromSlug.tsx new file mode 100644 index 00000000..7924ad7b --- /dev/null +++ b/src/collections/Tenants/components/AutoFillNameFromSlug.tsx @@ -0,0 +1,27 @@ +'use client' + +import { useDocumentInfo, useField, useFormFields } from '@payloadcms/ui' +import { useEffect } from 'react' + +import { AVALANCHE_CENTERS, isValidTenantSlug } from '@/utilities/tenancy/avalancheCenters' + +/** + * Invisible component that syncs the name field with the selected slug + * for new documents. Each time the slug dropdown changes, the name updates. + */ +export const AutoFillNameFromSlug = () => { + const { id } = useDocumentInfo() + const slugField = useFormFields(([fields]) => fields.slug) + const { setValue: setName } = useField({ path: 'name' }) + + useEffect(() => { + if (id) return + + const slugValue = typeof slugField?.value === 'string' ? slugField.value : null + if (slugValue && isValidTenantSlug(slugValue)) { + setName(AVALANCHE_CENTERS[slugValue].name) + } + }, [id, slugField?.value, setName]) + + return null +} diff --git a/src/collections/Tenants/components/DeleteTenantModal.tsx b/src/collections/Tenants/components/DeleteTenantModal.tsx new file mode 100644 index 00000000..00d42a6c --- /dev/null +++ b/src/collections/Tenants/components/DeleteTenantModal.tsx @@ -0,0 +1,126 @@ +'use client' + +import { Button, Modal, toast, useConfig, useDocumentInfo, useForm, useModal } from '@payloadcms/ui' +import { formatAdminURL } from 'payload/shared' +import { useCallback, useEffect, useRef, useState } from 'react' + +/** + * Intercepts Payload's built-in delete confirmation for tenants and replaces it + * with a type-to-confirm modal. + * + * Registered as a beforeDocumentControls component on the Tenants collection. + * When the admin clicks "Delete" from the actions menu, Payload opens a modal + * with slug `delete-{id}`. This component detects that, closes it, and opens + * a custom modal requiring the user to type the tenant name to confirm. + * + * Uses Modal + Button directly instead of ConfirmationModal so we can + * control the button's loading/disabled state on name mismatch. + */ +export function DeleteTenantModal() { + const { data } = useDocumentInfo() + const { setModified } = useForm() + const { openModal, closeModal, isModalOpen } = useModal() + const { + config: { + routes: { admin: adminRoute }, + }, + } = useConfig() + const [confirmInput, setConfirmInput] = useState('') + const [isDeleting, setIsDeleting] = useState(false) + + const tenantId = data?.id + const tenantName = typeof data?.name === 'string' ? data.name : '' + + const payloadDeleteSlug = `delete-${tenantId}` + const customModalSlug = `delete-tenant-confirm-${tenantId}` + + const interceptingRef = useRef(false) + + // Intercept Payload's delete modal — close it and open ours instead + const payloadDeleteOpen = isModalOpen(payloadDeleteSlug) + useEffect(() => { + if (!tenantId || !payloadDeleteOpen || interceptingRef.current) return + + interceptingRef.current = true + closeModal(payloadDeleteSlug) + setConfirmInput('') + setIsDeleting(false) + openModal(customModalSlug) + interceptingRef.current = false + }, [payloadDeleteOpen, tenantId, payloadDeleteSlug, customModalSlug, closeModal, openModal]) + + const handleConfirmDelete = useCallback(async () => { + if (!tenantId || isDeleting) return + + if (confirmInput !== tenantName) { + toast.error('Name does not match. Please type the exact name to confirm.') + return + } + + setIsDeleting(true) + setModified(false) + + try { + const res = await fetch(`/api/tenants/${tenantId}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }) + + if (res.ok) { + toast.success('Avalanche center deleted') + window.location.href = formatAdminURL({ + adminRoute, + path: '/collections/tenants', + }) + } else { + const json = await res.json() + toast.error(json.message || 'Failed to delete avalanche center') + setIsDeleting(false) + } + } catch { + toast.error('Failed to delete avalanche center') + setIsDeleting(false) + } + }, [tenantId, isDeleting, confirmInput, tenantName, setModified, adminRoute]) + + if (!tenantId) return null + + return ( + +
+
+

Delete {tenantName}?

+

+ This will permanently delete this avalanche center and all its data. + This action cannot be undone. +

+
+ + setConfirmInput(e.target.value)} + className="w-full rounded border border-solid border-[var(--theme-border-color)] bg-[var(--theme-input-bg)] p-2 text-[var(--theme-text)]" + autoComplete="off" + /> +
+
+
+ + +
+
+
+ ) +} diff --git a/src/collections/Tenants/components/TenantSlugField.tsx b/src/collections/Tenants/components/TenantSlugField.tsx new file mode 100644 index 00000000..c9cf4cda --- /dev/null +++ b/src/collections/Tenants/components/TenantSlugField.tsx @@ -0,0 +1,25 @@ +import type { SelectFieldServerComponent } from 'payload' + +import { SelectField } from '@payloadcms/ui' + +export const TenantSlugField: SelectFieldServerComponent = async ({ + clientField, + field, + payload, +}) => { + const { docs } = await payload.find({ + collection: 'tenants', + limit: 0, + depth: 0, + select: { slug: true }, + }) + + const usedSlugs: Set = new Set(docs.map((doc) => doc.slug)) + + const options = clientField.options?.filter((option) => { + const value = typeof option === 'string' ? option : option.value + return !usedSlugs.has(value) + }) + + return +} diff --git a/src/collections/Tenants/hooks/deprovisionBeforeDelete.ts b/src/collections/Tenants/hooks/deprovisionBeforeDelete.ts new file mode 100644 index 00000000..a0a0c11f --- /dev/null +++ b/src/collections/Tenants/hooks/deprovisionBeforeDelete.ts @@ -0,0 +1,52 @@ +import type { CollectionBeforeDeleteHook } from 'payload' + +export const TENANT_SCOPED_COLLECTIONS = [ + 'navigations', + 'homePages', + 'builtInPages', + 'settings', + 'pages', + 'posts', + 'events', + 'eventGroups', + 'eventTags', + 'sponsors', + 'tags', + 'teams', + 'biographies', + 'documents', + 'media', + 'redirects', + 'roleAssignments', + 'forms', +] as const + +/** + * Deletes all data scoped to a tenant before the tenant itself is deleted preventing foreign key constraint errors from SQLite. + */ +export const deprovisionBeforeDelete: CollectionBeforeDeleteHook = async ({ id, req }) => { + const { payload } = req + const log = payload.logger + + log.info(`Cleaning up data for tenant ${id} before deletion...`) + + for (const collection of TENANT_SCOPED_COLLECTIONS) { + const docs = await payload.find({ + collection, + where: { tenant: { equals: id } }, + limit: 0, + depth: 0, + }) + + if (docs.totalDocs > 0) { + log.info(`Deleting ${docs.totalDocs} ${collection} documents for tenant ${id}`) + await payload.delete({ + collection, + where: { tenant: { equals: id } }, + req, + }) + } + } + + log.info(`Cleanup complete for tenant ${id}`) +} diff --git a/src/collections/Tenants/index.ts b/src/collections/Tenants/index.ts index 2818ea0a..cf58c30a 100644 --- a/src/collections/Tenants/index.ts +++ b/src/collections/Tenants/index.ts @@ -1,5 +1,6 @@ import { accessByGlobalRoleOrTenantIds } from '@/collections/Tenants/access/byGlobalRoleOrTenantIds' import { provisionTenant } from '@/collections/Tenants/endpoints/provisionTenant' +import { deprovisionBeforeDelete } from '@/collections/Tenants/hooks/deprovisionBeforeDelete' import { revalidateTenantsAfterChange, revalidateTenantsAfterDelete, @@ -20,6 +21,8 @@ export const Tenants: CollectionConfig = { edit: { beforeDocumentControls: [ '@/collections/Tenants/components/SyncTenantsOnSave#SyncTenantsOnSave', + '@/collections/Tenants/components/DeleteTenantModal#DeleteTenantModal', + '@/collections/Tenants/components/AutoFillNameFromSlug#AutoFillNameFromSlug', ], }, }, @@ -41,18 +44,17 @@ export const Tenants: CollectionConfig = { ], hooks: { afterChange: [revalidateTenantsAfterChange], + beforeDelete: [deprovisionBeforeDelete], afterDelete: [revalidateTenantsAfterDelete], }, fields: [ - { - name: 'name', - type: 'text', - required: true, - }, { name: 'slug', type: 'select', admin: { + components: { + Field: '@/collections/Tenants/components/TenantSlugField#TenantSlugField', + }, description: 'Avalanche center identifier. Used for subdomains and URL paths.', }, options: VALID_TENANT_SLUGS.map((slug) => ({ @@ -66,6 +68,11 @@ export const Tenants: CollectionConfig = { update: () => false, // we should never change this after initial creation }, }, + { + name: 'name', + type: 'text', + required: true, + }, contentHashField(), { type: 'ui', diff --git a/src/payload-types.ts b/src/payload-types.ts index 909f9a5a..e7b42118 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -709,7 +709,6 @@ export interface HomePage { */ export interface Tenant { id: number; - name: string; /** * Avalanche center identifier. Used for subdomains and URL paths. */ @@ -738,6 +737,7 @@ export interface Tenant { | 'vac' | 'wac' | 'wcmac'; + name: string; contentHash?: string | null; updatedAt: string; createdAt: string; @@ -3819,8 +3819,8 @@ export interface GlobalRoleAssignmentsSelect { * via the `definition` "tenants_select". */ export interface TenantsSelect { - name?: T; slug?: T; + name?: T; contentHash?: T; updatedAt?: T; createdAt?: T;