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:
@@ -1,4 +1,3 @@
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'path'
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
|
||||
27
test/helpers/e2e/goToListDoc.ts
Normal file
27
test/helpers/e2e/goToListDoc.ts
Normal 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')
|
||||
}
|
||||
137
test/helpers/e2e/selectInput.ts
Normal file
137
test/helpers/e2e/selectInput.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
14
test/plugin-multi-tenant/credentials.ts
Normal file
14
test/plugin-multi-tenant/credentials.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
355
test/plugin-multi-tenant/e2e.spec.ts
Normal file
355
test/plugin-multi-tenant/e2e.spec.ts
Normal 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'),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user