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

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