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
This commit is contained in:
Jarrod Flesch
2025-06-27 16:30:13 -04:00
committed by GitHub
parent 9f6030641a
commit 16f5538e12
14 changed files with 745 additions and 107 deletions

View File

@@ -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

View File

@@ -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
}
```

View File

@@ -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

View File

@@ -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<number | string>()
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 (

View File

@@ -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,18 +118,24 @@ export const TenantSelector = ({ label, viewType }: { label: string; viewType?:
}}
/>
}
heading={
<Translation
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
i18nKey="plugin-multi-tenant:confirm-tenant-switch--heading"
t={t}
variables={{
heading={t('plugin-multi-tenant:confirm-tenant-switch--heading', {
tenantLabel: getTranslation(label, i18n),
})}
modalSlug={confirmSwitchTenantSlug}
onConfirm={() => {
switchTenant(tenantSelection)
}}
/>
}
modalSlug={confirmSwitchTenantSlug}
<ConfirmationModal
body={t('general:changesNotSaved')}
cancelLabel={t('general:stayOnThisPage')}
confirmLabel={t('general:leaveAnyway')}
heading={t('general:leaveWithoutSaving')}
modalSlug={confirmLeaveWithoutSavingSlug}
onCancel={() => {
closeModal(confirmLeaveWithoutSavingSlug)
}}
onConfirm={() => {
switchTenant(tenantSelection)
}}

View File

@@ -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
}

View File

@@ -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<React.SetStateAction<boolean>>
setEntityType: React.Dispatch<React.SetStateAction<'document' | 'global' | undefined>>
/**
* Sets the modified state
*/
setModified: React.Dispatch<React.SetStateAction<boolean>>
/**
* 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<void>
/**
*
*/
@@ -36,11 +53,13 @@ type ContextType = {
}
const Context = createContext<ContextType>({
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<number | string | undefined>(
initialValue,
)
const [preventRefreshOnChange, setPreventRefreshOnChange] = React.useState(false)
const [modified, setModified] = React.useState<boolean>(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<OptionObject[]>(
() => 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 {
setSelectedTenantID(id)
setCookie(String(id))
}
if (!preventRefreshOnChange && refresh) {
router.refresh()
}
},
[deleteCookie, preventRefreshOnChange, router, setCookie, setSelectedTenantID, tenantOptions],
)
const updateTenants = React.useCallback<ContextType['updateTenants']>(({ 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
})
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)) {
} 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))
}
}, [tenantCookie, setTenant, selectedTenantID, tenantOptions, initialValue, setCookie])
if (entityType !== 'document' && refresh) {
router.refresh()
}
},
[deleteCookie, entityType, router, setCookie, tenantOptions],
)
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',
},
)
const result = await req.json()
if (result.docs) {
setTenantOptions(
result.docs.map((doc: Record<string, number | string>) => ({
label: doc[useAsTitle],
value: doc.id,
})),
)
}
} catch (e) {
toast.error(`Error fetching tenants`)
}
}, [config.serverURL, config.routes.api, tenantsCollectionSlug, useAsTitle])
const updateTenants = React.useCallback<ContextType['updateTenants']>(
({ 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)
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)
if (initialValue) {
setCookie(String(initialValue))
} else {
deleteCookie()
}
}
}, [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 = ({
>
<Context
value={{
entityType,
modified,
options: tenantOptions,
preventRefreshOnChange,
selectedTenantID,
setPreventRefreshOnChange,
setEntityType,
setModified,
setTenant,
syncTenants,
updateTenants,
}}
>

View File

@@ -65,6 +65,8 @@ export const TenantSelectionProvider = async ({
initialValue={initialValue}
tenantCookie={tenantCookie}
tenantOptions={tenantOptions}
tenantsCollectionSlug={tenantsCollectionSlug}
useAsTitle={useAsTitle}
>
{children}
</TenantSelectionProviderClient>

View File

@@ -1,4 +1,3 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)

View File

@@ -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')
}

View File

@@ -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<void> {
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 = <TMultiSelect = true>(args: {
multiSelect: TMultiSelect
selectLocator: Locator
}) => Promise<TMultiSelect extends true ? string[] : string | undefined>
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<string[]> => {
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()
}
}
}

View File

@@ -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',
},
}

View File

@@ -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<Config>({ 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<false>({
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<string[]> {
await openNav(page)
return await getSelectInputOptions({
selectLocator: page.locator('.tenant-selector'),
})
}
async function selectTenant({ page, tenant }: { page: Page; tenant: string }): Promise<void> {
await openNav(page)
return selectInput({
selectLocator: page.locator('.tenant-selector'),
option: tenant,
multiSelect: false,
})
}
async function clearTenant({ page }: { page: Page }): Promise<void> {
await openNav(page)
return clearSelectInput({
selectLocator: page.locator('.tenant-selector'),
})
}

View File

@@ -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,