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:
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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={
|
||||
<Translation
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
i18nKey="plugin-multi-tenant:confirm-tenant-switch--heading"
|
||||
t={t}
|
||||
variables={{
|
||||
tenantLabel: getTranslation(label, i18n),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
heading={t('plugin-multi-tenant:confirm-tenant-switch--heading', {
|
||||
tenantLabel: getTranslation(label, i18n),
|
||||
})}
|
||||
modalSlug={confirmSwitchTenantSlug}
|
||||
onConfirm={() => {
|
||||
switchTenant(tenantSelection)
|
||||
}}
|
||||
/>
|
||||
|
||||
<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)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 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<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
|
||||
})
|
||||
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<string, number | string>) => ({
|
||||
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<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)
|
||||
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 = ({
|
||||
>
|
||||
<Context
|
||||
value={{
|
||||
entityType,
|
||||
modified,
|
||||
options: tenantOptions,
|
||||
preventRefreshOnChange,
|
||||
selectedTenantID,
|
||||
setPreventRefreshOnChange,
|
||||
setEntityType,
|
||||
setModified,
|
||||
setTenant,
|
||||
syncTenants,
|
||||
updateTenants,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -65,6 +65,8 @@ export const TenantSelectionProvider = async ({
|
||||
initialValue={initialValue}
|
||||
tenantCookie={tenantCookie}
|
||||
tenantOptions={tenantOptions}
|
||||
tenantsCollectionSlug={tenantsCollectionSlug}
|
||||
useAsTitle={useAsTitle}
|
||||
>
|
||||
{children}
|
||||
</TenantSelectionProviderClient>
|
||||
|
||||
@@ -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