From 16f5538e12bbf610bd64a11ad934d7a61b77ebea Mon Sep 17 00:00:00 2001 From: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Date: Fri, 27 Jun 2025 16:30:13 -0400 Subject: [PATCH] fix(plugin-multi-tenant): unnecessary modal appearing (#12854) Fixes #12826 Leave without saving was being triggered when no changes were made to the tenant. This should only happen if the value in form state differs from that of the selected tenant, i.e. after changing tenants. Adds tenant selector syncing so the selector updates when a tenant is added or the name is edited. Also adds E2E for most multi-tenant admin functionality. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1210562742356842 --- .github/workflows/main.yml | 2 + docs/fields/overview.mdx | 3 +- .../src/components/Card/index.tsx | 4 +- .../components/TenantField/index.client.tsx | 30 +- .../src/components/TenantSelector/index.tsx | 53 ++- .../WatchTenantCollection/index.tsx | 31 +- .../TenantSelectionProvider/index.client.tsx | 162 +++++--- .../TenantSelectionProvider/index.tsx | 2 + test/access-control/config.ts | 1 - test/helpers/e2e/goToListDoc.ts | 27 ++ test/helpers/e2e/selectInput.ts | 137 +++++++ test/plugin-multi-tenant/credentials.ts | 14 + test/plugin-multi-tenant/e2e.spec.ts | 355 ++++++++++++++++++ test/plugin-multi-tenant/seed/index.ts | 31 +- 14 files changed, 745 insertions(+), 107 deletions(-) create mode 100644 test/helpers/e2e/goToListDoc.ts create mode 100644 test/helpers/e2e/selectInput.ts create mode 100644 test/plugin-multi-tenant/credentials.ts create mode 100644 test/plugin-multi-tenant/e2e.spec.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 010f16e08..8e82aa95f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -315,6 +315,7 @@ jobs: - plugin-cloud-storage - plugin-form-builder - plugin-import-export + - plugin-multi-tenant - plugin-nested-docs - plugin-seo - sort @@ -451,6 +452,7 @@ jobs: - plugin-cloud-storage - plugin-form-builder - plugin-import-export + - plugin-multi-tenant - plugin-nested-docs - plugin-seo - sort diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx index 9ca25f6a5..49fbb64aa 100644 --- a/docs/fields/overview.mdx +++ b/docs/fields/overview.mdx @@ -315,7 +315,8 @@ import type { Field } from 'payload' export const MyField: Field = { type: 'text', name: 'myField', - validate: (value, {req: { t }}) => Boolean(value) || t('validation:required'), // highlight-line + validate: (value, { req: { t } }) => + Boolean(value) || t('validation:required'), // highlight-line } ``` diff --git a/examples/localization/src/components/Card/index.tsx b/examples/localization/src/components/Card/index.tsx index c6873fcb1..e7f088423 100644 --- a/examples/localization/src/components/Card/index.tsx +++ b/examples/localization/src/components/Card/index.tsx @@ -2,7 +2,7 @@ import { cn } from '@/utilities/ui' import useClickableCard from '@/utilities/useClickableCard' import Link from 'next/link' -import { useLocale } from 'next-intl'; +import { useLocale } from 'next-intl' import React, { Fragment } from 'react' import type { Post } from '@/payload-types' @@ -17,7 +17,7 @@ export const Card: React.FC<{ showCategories?: boolean title?: string }> = (props) => { - const locale = useLocale(); + const locale = useLocale() const { card, link } = useClickableCard({}) const { className, doc, relationTo, showCategories, title: titleFromProps } = props diff --git a/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx b/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx index b0a8bf0b9..999b1d5f7 100644 --- a/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx +++ b/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx @@ -2,7 +2,7 @@ import type { RelationshipFieldClientProps } from 'payload' -import { RelationshipField, useField } from '@payloadcms/ui' +import { RelationshipField, useField, useFormModified } from '@payloadcms/ui' import React from 'react' import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js' @@ -18,7 +18,14 @@ type Props = { export const TenantField = (args: Props) => { const { debug, unique } = args const { setValue, value } = useField() - const { options, selectedTenantID, setPreventRefreshOnChange, setTenant } = useTenantSelection() + const modified = useFormModified() + const { + options, + selectedTenantID, + setEntityType: setEntityType, + setModified, + setTenant, + } = useTenantSelection() const hasSetValueRef = React.useRef(false) @@ -35,18 +42,25 @@ export const TenantField = (args: Props) => { hasSetValueRef.current = true } else if (!value || value !== selectedTenantID) { // Update the field on the document value when the tenant is changed - setValue(selectedTenantID) + setValue(selectedTenantID, !value || value === selectedTenantID) } }, [value, selectedTenantID, setTenant, setValue, options, unique]) React.useEffect(() => { - if (!unique) { - setPreventRefreshOnChange(true) - } + setEntityType(unique ? 'global' : 'document') return () => { - setPreventRefreshOnChange(false) + setEntityType(undefined) } - }, [unique, setPreventRefreshOnChange]) + }, [unique, setEntityType]) + + React.useEffect(() => { + // sync form modified state with the tenant selection provider context + setModified(modified) + + return () => { + setModified(false) + } + }, [modified, setModified]) if (debug) { return ( diff --git a/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx b/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx index 087ba4c14..ae9b26929 100644 --- a/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx +++ b/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx @@ -20,11 +20,12 @@ import type { import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js' import './index.scss' -const confirmSwitchTenantSlug = 'confirmSwitchTenant' +const confirmSwitchTenantSlug = 'confirm-switch-tenant' +const confirmLeaveWithoutSavingSlug = 'confirm-leave-without-saving' export const TenantSelector = ({ label, viewType }: { label: string; viewType?: ViewTypes }) => { - const { options, preventRefreshOnChange, selectedTenantID, setTenant } = useTenantSelection() - const { openModal } = useModal() + const { entityType, modified, options, selectedTenantID, setTenant } = useTenantSelection() + const { closeModal, openModal } = useModal() const { i18n, t } = useTranslation< PluginMultiTenantTranslations, PluginMultiTenantTranslationKeys @@ -60,15 +61,27 @@ export const TenantSelector = ({ label, viewType }: { label: string; viewType?: const onChange = React.useCallback( (option: ReactSelectOption | ReactSelectOption[]) => { - if (!preventRefreshOnChange) { - switchTenant(option) + if (option && 'value' in option && option.value === selectedTenantID) { + // If the selected option is the same as the current tenant, do nothing return + } + + if (entityType !== 'document') { + if (entityType === 'global' && modified) { + // If the entityType is 'global' and there are unsaved changes, prompt for confirmation + setTenantSelection(option) + openModal(confirmLeaveWithoutSavingSlug) + } else { + // If the entityType is not 'document', switch tenant without confirmation + switchTenant(option) + } } else { + // non-unique documents should always prompt for confirmation setTenantSelection(option) openModal(confirmSwitchTenantSlug) } }, - [openModal, preventRefreshOnChange, switchTenant], + [selectedTenantID, entityType, modified, switchTenant, openModal], ) if (options.length <= 1) { @@ -105,22 +118,28 @@ export const TenantSelector = ({ label, viewType }: { label: string; viewType?: }} /> } - heading={ - - } + heading={t('plugin-multi-tenant:confirm-tenant-switch--heading', { + tenantLabel: getTranslation(label, i18n), + })} modalSlug={confirmSwitchTenantSlug} onConfirm={() => { switchTenant(tenantSelection) }} /> + + { + closeModal(confirmLeaveWithoutSavingSlug) + }} + onConfirm={() => { + switchTenant(tenantSelection) + }} + /> ) } diff --git a/packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx b/packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx index f0d1b32bb..348189b27 100644 --- a/packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx +++ b/packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx @@ -8,6 +8,8 @@ import { useDocumentTitle, useEffectEvent, useFormFields, + useFormSubmitted, + useOperation, } from '@payloadcms/ui' import React from 'react' @@ -15,8 +17,9 @@ import { useTenantSelection } from '../../providers/TenantSelectionProvider/inde export const WatchTenantCollection = () => { const { id, collectionSlug } = useDocumentInfo() + const operation = useOperation() + const submitted = useFormSubmitted() const { title } = useDocumentTitle() - const addedNewTenant = React.useRef(false) const { getEntityConfig } = useConfig() const [useAsTitleName] = React.useState( @@ -24,7 +27,7 @@ export const WatchTenantCollection = () => { ) const titleField = useFormFields(([fields]) => (useAsTitleName ? fields[useAsTitleName] : {})) - const { options, updateTenants } = useTenantSelection() + const { syncTenants, updateTenants } = useTenantSelection() const syncTenantTitle = useEffectEvent(() => { if (id) { @@ -32,27 +35,19 @@ export const WatchTenantCollection = () => { } }) - React.useEffect(() => { - if (!id || !title || addedNewTenant.current) { - return - } - // Track tenant creation and add it to the tenant selector - const exists = options.some((opt) => opt.value === id) - if (!exists) { - addedNewTenant.current = true - updateTenants({ id, label: title }) - } - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]) - React.useEffect(() => { // only update the tenant selector when the document saves // → aka when initial value changes if (id && titleField?.initialValue) { - syncTenantTitle() + void syncTenantTitle() } - }, [id, titleField?.initialValue]) + }, [id, titleField?.initialValue, syncTenants]) + + React.useEffect(() => { + if (operation === 'create' && submitted) { + void syncTenants() + } + }, [operation, submitted, syncTenants, id]) return null } diff --git a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx index 7aabd31a2..c5fea594b 100644 --- a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx +++ b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx @@ -2,26 +2,39 @@ import type { OptionObject } from 'payload' -import { useAuth } from '@payloadcms/ui' +import { toast, useAuth, useConfig } from '@payloadcms/ui' import { useRouter } from 'next/navigation.js' import React, { createContext } from 'react' type ContextType = { + /** + * What is the context of the selector? It is either 'document' | 'global' | undefined. + * + * - 'document' means you are viewing a document in the context of a tenant + * - 'global' means you are viewing a "global" (globals are collection documents but prevent you from viewing the list view) document in the context of a tenant + * - undefined means you are not viewing a document at all + */ + entityType?: 'document' | 'global' + /** + * Hoists the forms modified state + */ + modified?: boolean /** * Array of options to select from */ options: OptionObject[] - preventRefreshOnChange: boolean /** * The currently selected tenant ID */ selectedTenantID: number | string | undefined /** - * Prevents a refresh when the tenant is changed - * - * If not switching tenants while viewing a "global", set to true + * Sets the entityType when a document is loaded and sets it to undefined when the document unmounts. */ - setPreventRefreshOnChange: React.Dispatch> + setEntityType: React.Dispatch> + /** + * Sets the modified state + */ + setModified: React.Dispatch> /** * Sets the selected tenant ID * @@ -29,6 +42,10 @@ type ContextType = { * @param args.refresh - Whether to refresh the page after changing the tenant */ setTenant: (args: { id: number | string | undefined; refresh?: boolean }) => void + /** + * Used to sync tenants displayed in the tenant selector when updates are made to the tenants collection. + */ + syncTenants: () => Promise /** * */ @@ -36,11 +53,13 @@ type ContextType = { } const Context = createContext({ + entityType: undefined, options: [], - preventRefreshOnChange: false, selectedTenantID: undefined, - setPreventRefreshOnChange: () => null, + setEntityType: () => undefined, + setModified: () => undefined, setTenant: () => null, + syncTenants: () => Promise.resolve(), updateTenants: () => null, }) @@ -49,17 +68,23 @@ export const TenantSelectionProviderClient = ({ initialValue, tenantCookie, tenantOptions: tenantOptionsFromProps, + tenantsCollectionSlug, + useAsTitle, }: { children: React.ReactNode initialValue?: number | string tenantCookie?: string tenantOptions: OptionObject[] + tenantsCollectionSlug: string + useAsTitle: string }) => { const [selectedTenantID, setSelectedTenantID] = React.useState( initialValue, ) - const [preventRefreshOnChange, setPreventRefreshOnChange] = React.useState(false) + const [modified, setModified] = React.useState(false) + const [entityType, setEntityType] = React.useState<'document' | 'global' | undefined>(undefined) const { user } = useAuth() + const { config } = useConfig() const userID = React.useMemo(() => user?.id, [user?.id]) const [tenantOptions, setTenantOptions] = React.useState( () => tenantOptionsFromProps, @@ -85,73 +110,97 @@ export const TenantSelectionProviderClient = ({ ({ id, refresh }) => { if (id === undefined) { if (tenantOptions.length > 1) { + // users with multiple tenants can clear the tenant selection setSelectedTenantID(undefined) deleteCookie() } else { + // if there is only one tenant, force the selection of that tenant setSelectedTenantID(tenantOptions[0]?.value) setCookie(String(tenantOptions[0]?.value)) } + } else if (!tenantOptions.find((option) => option.value === id)) { + // if the tenant is not valid, set the first tenant as selected + if (tenantOptions?.[0]?.value) { + setTenant({ id: tenantOptions[0].value, refresh: true }) + } else { + setTenant({ id: undefined, refresh: true }) + } } else { + // if the tenant is in the options, set it as selected setSelectedTenantID(id) setCookie(String(id)) } - if (!preventRefreshOnChange && refresh) { + if (entityType !== 'document' && refresh) { router.refresh() } }, - [deleteCookie, preventRefreshOnChange, router, setCookie, setSelectedTenantID, tenantOptions], + [deleteCookie, entityType, router, setCookie, tenantOptions], ) - const updateTenants = React.useCallback(({ id, label }) => { - setTenantOptions((prev) => { - const stringID = String(id) - let exists = false - const updated = prev.map((currentTenant) => { - if (stringID === String(currentTenant.value)) { - exists = true - return { - label, - value: stringID, - } - } - return currentTenant - }) + const syncTenants = React.useCallback(async () => { + try { + const req = await fetch( + `${config.serverURL}${config.routes.api}/${tenantsCollectionSlug}?select[${useAsTitle}]=true&limit=0&depth=0`, + { + credentials: 'include', + method: 'GET', + }, + ) - if (!exists) { - updated.push({ label, value: stringID }) - } - - // Sort alphabetically by label (or value as fallback) - return updated.sort((a, b) => { - const aKey = typeof a.label === 'string' ? a.label : String(a.value) - const bKey = typeof b.label === 'string' ? b.label : String(b.value) - return aKey.localeCompare(bKey) - }) - }) - }, []) - - React.useEffect(() => { - if (selectedTenantID && !tenantOptions.find((option) => option.value === selectedTenantID)) { - if (tenantOptions?.[0]?.value) { - setTenant({ id: tenantOptions[0].value, refresh: true }) - } else { - setTenant({ id: undefined, refresh: true }) + const result = await req.json() + + if (result.docs) { + setTenantOptions( + result.docs.map((doc: Record) => ({ + label: doc[useAsTitle], + value: doc.id, + })), + ) } + } catch (e) { + toast.error(`Error fetching tenants`) } - }, [tenantCookie, setTenant, selectedTenantID, tenantOptions, initialValue, setCookie]) + }, [config.serverURL, config.routes.api, tenantsCollectionSlug, useAsTitle]) + + const updateTenants = React.useCallback( + ({ id, label }) => { + setTenantOptions((prev) => { + return prev.map((currentTenant) => { + if (id === currentTenant.value) { + return { + label, + value: id, + } + } + return currentTenant + }) + }) + + void syncTenants() + }, + [syncTenants], + ) React.useEffect(() => { if (userID && !tenantCookie) { - // User is logged in, but does not have a tenant cookie, set it - setSelectedTenantID(initialValue) - setTenantOptions(tenantOptionsFromProps) - if (initialValue) { - setCookie(String(initialValue)) - } else { - deleteCookie() + if (tenantOptionsFromProps.length === 1) { + // Users with no cookie set and only 1 tenant should set that tenant automatically + setTenant({ id: tenantOptionsFromProps[0]?.value, refresh: true }) + setTenantOptions(tenantOptionsFromProps) + } else if ((!tenantOptions || tenantOptions.length === 0) && tenantOptionsFromProps) { + // If there are no tenant options, set them from the props + setTenantOptions(tenantOptionsFromProps) } } - }, [userID, tenantCookie, initialValue, setCookie, deleteCookie, router, tenantOptionsFromProps]) + }, [ + initialValue, + selectedTenantID, + tenantCookie, + userID, + setTenant, + tenantOptionsFromProps, + tenantOptions, + ]) React.useEffect(() => { if (!userID && tenantCookie) { @@ -171,11 +220,14 @@ export const TenantSelectionProviderClient = ({ > diff --git a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx index 04e02f775..e1c248990 100644 --- a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx +++ b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx @@ -65,6 +65,8 @@ export const TenantSelectionProvider = async ({ initialValue={initialValue} tenantCookie={tenantCookie} tenantOptions={tenantOptions} + tenantsCollectionSlug={tenantsCollectionSlug} + useAsTitle={useAsTitle} > {children} diff --git a/test/access-control/config.ts b/test/access-control/config.ts index f82fce76b..df351e009 100644 --- a/test/access-control/config.ts +++ b/test/access-control/config.ts @@ -1,4 +1,3 @@ - import { fileURLToPath } from 'node:url' import path from 'path' const filename = fileURLToPath(import.meta.url) diff --git a/test/helpers/e2e/goToListDoc.ts b/test/helpers/e2e/goToListDoc.ts new file mode 100644 index 000000000..4b2898370 --- /dev/null +++ b/test/helpers/e2e/goToListDoc.ts @@ -0,0 +1,27 @@ +import type { Page } from '@playwright/test' + +import type { AdminUrlUtil } from '../../helpers/adminUrlUtil.js' + +export async function goToListDoc({ + page, + cellClass, + textToMatch, + urlUtil, +}: { + cellClass: `.cell-${string}` + page: Page + textToMatch: string + urlUtil: AdminUrlUtil +}) { + await page.goto(urlUtil.list) + const row = page + .locator(`.collection-list .table tr`) + .filter({ + has: page.locator(`${cellClass}`, { hasText: textToMatch }), + }) + .first() + const cellLink = row.locator(`td a`).first() + const linkURL = await cellLink.getAttribute('href') + await page.goto(`${urlUtil.serverURL}${linkURL}`) + await page.waitForLoadState('networkidle') +} diff --git a/test/helpers/e2e/selectInput.ts b/test/helpers/e2e/selectInput.ts new file mode 100644 index 000000000..67ee60f14 --- /dev/null +++ b/test/helpers/e2e/selectInput.ts @@ -0,0 +1,137 @@ +import type { Locator, Page } from '@playwright/test' + +type SelectReactOptionsParams = { + selectLocator: Locator // Locator for the react-select component +} & ( + | { + clear?: boolean // Whether to clear the selection before selecting new options + multiSelect: true // Multi-selection mode + option?: never + options: string[] // Array of visible labels to select + } + | { + clear?: never + multiSelect: false // Single selection mode + option: string // Single visible label to select + options?: never + } +) + +export async function selectInput({ + selectLocator, + options, + option, + multiSelect = true, + clear = true, +}: SelectReactOptionsParams) { + if (multiSelect && options) { + if (clear) { + await clearSelectInput({ + selectLocator, + }) + } + + for (const optionText of options) { + // Check if the option is already selected + const alreadySelected = await selectLocator + .locator('.multi-value-label__text', { + hasText: optionText, + }) + .count() + + if (alreadySelected === 0) { + await selectOption({ + selectLocator, + optionText, + }) + } + } + } else if (option) { + // For single selection, ensure only one option is selected + const alreadySelected = await selectLocator + .locator('.react-select--single-value', { + hasText: option, + }) + .count() + + if (alreadySelected === 0) { + await selectOption({ + selectLocator, + optionText: option, + }) + } + } +} + +export async function openSelectMenu({ selectLocator }: { selectLocator: Locator }): Promise { + if (await selectLocator.locator('.rs__menu').isHidden()) { + // Open the react-select dropdown + await selectLocator.locator('button.dropdown-indicator').click() + } + + // Wait for the dropdown menu to appear + const menu = selectLocator.locator('.rs__menu') + await menu.waitFor({ state: 'visible', timeout: 2000 }) +} + +async function selectOption({ + selectLocator, + optionText, +}: { + optionText: string + selectLocator: Locator +}) { + await openSelectMenu({ selectLocator }) + + // Find and click the desired option by visible text + const optionLocator = selectLocator.locator('.rs__option', { + hasText: optionText, + }) + + if (optionLocator) { + await optionLocator.click() + } +} + +type GetSelectInputValueFunction = (args: { + multiSelect: TMultiSelect + selectLocator: Locator +}) => Promise + +export const getSelectInputValue: GetSelectInputValueFunction = async ({ + selectLocator, + multiSelect = false, +}) => { + if (multiSelect) { + // For multi-select, get all selected options + const selectedOptions = await selectLocator + .locator('.multi-value-label__text') + .allTextContents() + return selectedOptions || [] + } + + // For single-select, get the selected value + const singleValue = await selectLocator.locator('.react-select--single-value').textContent() + return (singleValue ?? undefined) as any +} + +export const getSelectInputOptions = async ({ + selectLocator, +}: { + selectLocator: Locator +}): Promise => { + await openSelectMenu({ selectLocator }) + const options = await selectLocator.locator('.rs__option').allTextContents() + return options.map((option) => option.trim()).filter(Boolean) +} + +export async function clearSelectInput({ selectLocator }: { selectLocator: Locator }) { + // Clear the selection if clear is true + const clearButton = selectLocator.locator('.clear-indicator') + if (await clearButton.isVisible()) { + const clearButtonCount = await clearButton.count() + if (clearButtonCount > 0) { + await clearButton.click() + } + } +} diff --git a/test/plugin-multi-tenant/credentials.ts b/test/plugin-multi-tenant/credentials.ts new file mode 100644 index 000000000..a6c330a90 --- /dev/null +++ b/test/plugin-multi-tenant/credentials.ts @@ -0,0 +1,14 @@ +export const credentials = { + admin: { + email: 'dev@payloadcms.com', + password: 'test', + }, + blueDog: { + email: 'jane@blue-dog.com', + password: 'test', + }, + owner: { + email: 'owner@anchorAndBlueDog.com', + password: 'test', + }, +} diff --git a/test/plugin-multi-tenant/e2e.spec.ts b/test/plugin-multi-tenant/e2e.spec.ts new file mode 100644 index 000000000..b7d541164 --- /dev/null +++ b/test/plugin-multi-tenant/e2e.spec.ts @@ -0,0 +1,355 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import * as path from 'path' +import { wait } from 'payload/shared' +import { fileURLToPath } from 'url' + +import type { Config } from './payload-types.js' + +import { + ensureCompilationIsDone, + initPageConsoleErrorCatch, + login, + saveDocAndAssert, +} from '../helpers.js' +import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' +import { goToListDoc } from '../helpers/e2e/goToListDoc.js' +import { + clearSelectInput, + getSelectInputOptions, + getSelectInputValue, + selectInput, +} from '../helpers/e2e/selectInput.js' +import { openNav } from '../helpers/e2e/toggleNav.js' +import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../helpers/reInitializeDB.js' +import { TEST_TIMEOUT_LONG } from '../playwright.config.js' +import { credentials } from './credentials.js' +import { menuItemsSlug, menuSlug, tenantsSlug } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +test.describe('Multi Tenant', () => { + let page: Page + let serverURL: string + let globalMenuURL: AdminUrlUtil + let menuItemsURL: AdminUrlUtil + let tenantsURL: AdminUrlUtil + + test.beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + + const { serverURL: serverFromInit } = await initPayloadE2ENoConfig({ dirname }) + serverURL = serverFromInit + globalMenuURL = new AdminUrlUtil(serverURL, menuSlug) + menuItemsURL = new AdminUrlUtil(serverURL, menuItemsSlug) + tenantsURL = new AdminUrlUtil(serverURL, tenantsSlug) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + await ensureCompilationIsDone({ page, serverURL, noAutoLogin: true }) + }) + + test.beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'multiTenant', + }) + }) + + test.describe('tenant selector', () => { + test('should populate tenant selector on login', async () => { + await login({ + page, + serverURL, + data: credentials.admin, + }) + + await expect + .poll(async () => { + return (await getTenantOptions({ page })).sort() + }) + .toEqual(['Blue Dog', 'Steel Cat', 'Anchor Bar'].sort()) + }) + + test('should show all tenants for userHasAccessToAllTenants users', async () => { + await login({ + page, + serverURL, + data: credentials.admin, + }) + + await expect + .poll(async () => { + return (await getTenantOptions({ page })).sort() + }) + .toEqual(['Blue Dog', 'Steel Cat', 'Anchor Bar'].sort()) + }) + + test('should only show users assigned tenants', async () => { + await login({ + page, + serverURL, + data: credentials.owner, + }) + + await expect + .poll(async () => { + return (await getTenantOptions({ page })).sort() + }) + .toEqual(['Blue Dog', 'Anchor Bar'].sort()) + }) + }) + + test.describe('Base List Filter', () => { + test('should show all tenant items when tenant selector is empty', async () => { + await login({ + page, + serverURL, + data: credentials.admin, + }) + + await clearTenant({ page }) + + await page.goto(menuItemsURL.list) + await expect( + page.locator('.collection-list .table .cell-name', { + hasText: 'Spicy Mac', + }), + ).toBeVisible() + await expect( + page.locator('.collection-list .table .cell-name', { + hasText: 'Pretzel Bites', + }), + ).toBeVisible() + }) + test('should show filtered tenant items when tenant selector is set', async () => { + await login({ + page, + serverURL, + data: credentials.admin, + }) + + await selectTenant({ + page, + tenant: 'Blue Dog', + }) + + await page.goto(menuItemsURL.list) + await expect( + page.locator('.collection-list .table .cell-name', { + hasText: 'Spicy Mac', + }), + ).toBeVisible() + await expect( + page.locator('.collection-list .table .cell-name', { + hasText: 'Pretzel Bites', + }), + ).toBeHidden() + }) + }) + + test.describe('globals', () => { + test('should redirect list view to edit view', async () => { + await login({ + page, + serverURL, + data: credentials.admin, + }) + await page.goto(globalMenuURL.list) + await page.waitForURL(globalMenuURL.create) + await expect(page.locator('.collection-edit')).toBeVisible() + }) + + test('should redirect from create to edit view when tenant already has content', async () => { + await login({ + page, + serverURL, + data: credentials.admin, + }) + await selectTenant({ + page, + tenant: 'Blue Dog', + }) + await page.goto(globalMenuURL.list) + await expect(page.locator('.collection-edit')).toBeVisible() + await expect(page.locator('#field-title')).toHaveValue('Blue Dog Menu') + }) + + test('should prompt leave without saving changes modal when switching tenants', async () => { + await login({ + page, + serverURL, + data: credentials.admin, + }) + + await selectTenant({ + page, + tenant: 'Blue Dog', + }) + + await page.goto(globalMenuURL.create) + + // Attempt to switch tenants with unsaved changes + await page.fill('#field-title', 'New Global Menu Name') + await selectTenant({ + page, + tenant: 'Steel Cat', + }) + + const confirmationModal = page.locator('#confirm-leave-without-saving') + await expect(confirmationModal).toBeVisible() + await expect( + confirmationModal.getByText( + 'Your changes have not been saved. If you leave now, you will lose your changes.', + ), + ).toBeVisible() + }) + }) + + test.describe('documents', () => { + test('should set tenant upon entering', async () => { + await login({ + page, + serverURL, + data: credentials.admin, + }) + + await clearTenant({ page }) + + await goToListDoc({ + page, + cellClass: '.cell-name', + textToMatch: 'Spicy Mac', + urlUtil: menuItemsURL, + }) + + await openNav(page) + await expect + .poll(async () => { + return await getSelectInputValue({ + selectLocator: page.locator('.tenant-selector'), + multiSelect: false, + }) + }) + .toBe('Blue Dog') + }) + + test('should prompt for confirmation upon tenant switching', async () => { + await login({ + page, + serverURL, + data: credentials.admin, + }) + + await clearTenant({ page }) + + await goToListDoc({ + page, + cellClass: '.cell-name', + textToMatch: 'Spicy Mac', + urlUtil: menuItemsURL, + }) + + await selectTenant({ + page, + tenant: 'Steel Cat', + }) + + const confirmationModal = page.locator('#confirm-switch-tenant') + await expect(confirmationModal).toBeVisible() + await expect( + confirmationModal.getByText('You are about to change ownership from Blue Dog to Steel Cat'), + ).toBeVisible() + }) + }) + + test.describe('tenants', () => { + test('should update the tenant name in the selector when editing a tenant', async () => { + await login({ + page, + serverURL, + data: credentials.admin, + }) + + await goToListDoc({ + cellClass: '.cell-name', + page, + textToMatch: 'Blue Dog', + urlUtil: tenantsURL, + }) + + await page.locator('#field-name').fill('Red Dog') + await saveDocAndAssert(page) + + // Check the tenant selector + await expect + .poll(async () => { + return (await getTenantOptions({ page })).sort() + }) + .toEqual(['Red Dog', 'Steel Cat', 'Anchor Bar'].sort()) + + // Change the tenant back to the original name + await page.locator('#field-name').fill('Blue Dog') + await saveDocAndAssert(page) + await expect + .poll(async () => { + return (await getTenantOptions({ page })).sort() + }) + .toEqual(['Blue Dog', 'Steel Cat', 'Anchor Bar'].sort()) + }) + + test('should add tenant to the selector when creating a new tenant', async () => { + await login({ + page, + serverURL, + data: credentials.admin, + }) + + await page.goto(tenantsURL.create) + await wait(300) + await expect(page.locator('#field-name')).toBeVisible() + await expect(page.locator('#field-domain')).toBeVisible() + + await page.locator('#field-name').fill('House Rules') + await page.locator('#field-domain').fill('house-rules.com') + await saveDocAndAssert(page) + + // Check the tenant selector + await expect + .poll(async () => { + return (await getTenantOptions({ page })).sort() + }) + .toEqual(['Blue Dog', 'Steel Cat', 'Anchor Bar', 'House Rules'].sort()) + }) + }) +}) + +/** + * Helper Functions + */ +async function getTenantOptions({ page }: { page: Page }): Promise { + await openNav(page) + return await getSelectInputOptions({ + selectLocator: page.locator('.tenant-selector'), + }) +} + +async function selectTenant({ page, tenant }: { page: Page; tenant: string }): Promise { + await openNav(page) + return selectInput({ + selectLocator: page.locator('.tenant-selector'), + option: tenant, + multiSelect: false, + }) +} + +async function clearTenant({ page }: { page: Page }): Promise { + await openNav(page) + return clearSelectInput({ + selectLocator: page.locator('.tenant-selector'), + }) +} diff --git a/test/plugin-multi-tenant/seed/index.ts b/test/plugin-multi-tenant/seed/index.ts index 4e37f2a1e..e4dacb0c2 100644 --- a/test/plugin-multi-tenant/seed/index.ts +++ b/test/plugin-multi-tenant/seed/index.ts @@ -1,6 +1,6 @@ import type { Config } from 'payload' -import { devUser } from '../../credentials.js' +import { credentials } from '../credentials.js' import { menuItemsSlug, menuSlug, tenantsSlug, usersSlug } from '../shared.js' export const seed: Config['onInit'] = async (payload) => { @@ -19,6 +19,13 @@ export const seed: Config['onInit'] = async (payload) => { domain: 'steelcat.com', }, }) + const anchorBarTenant = await payload.create({ + collection: tenantsSlug, + data: { + name: 'Anchor Bar', + domain: 'anchorbar.com', + }, + }) // Create blue dog menu items await payload.create({ @@ -70,8 +77,7 @@ export const seed: Config['onInit'] = async (payload) => { await payload.create({ collection: usersSlug, data: { - email: devUser.email, - password: devUser.password, + ...credentials.admin, roles: ['admin'], }, }) @@ -79,8 +85,7 @@ export const seed: Config['onInit'] = async (payload) => { await payload.create({ collection: usersSlug, data: { - email: 'jane@blue-dog.com', - password: 'test', + ...credentials.blueDog, roles: ['user'], tenants: [ { @@ -90,6 +95,22 @@ export const seed: Config['onInit'] = async (payload) => { }, }) + await payload.create({ + collection: usersSlug, + data: { + ...credentials.owner, + roles: ['user'], + tenants: [ + { + tenant: anchorBarTenant.id, + }, + { + tenant: blueDogTenant.id, + }, + ], + }, + }) + // create menus await payload.create({ collection: menuSlug,