Skip to content

feat(plugin-multi-tenant): prompt the user to accept changing tenants before actually changing #12382

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
May 14, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -74,6 +74,7 @@ export const rootEslintConfig = [
'no-console': 'off',
'perfectionist/sort-object-types': 'off',
'perfectionist/sort-objects': 'off',
'payload/no-relative-monorepo-imports': 'off',
},
},
]
20 changes: 20 additions & 0 deletions packages/plugin-multi-tenant/package.json
Original file line number Diff line number Diff line change
@@ -56,6 +56,16 @@
"import": "./src/exports/utilities.ts",
"types": "./src/exports/utilities.ts",
"default": "./src/exports/utilities.ts"
},
"./translations/languages/all": {
"import": "./src/translations/index.ts",
"types": "./src/translations/index.ts",
"default": "./src/translations/index.ts"
},
"./translations/languages/*": {
"import": "./src/translations/languages/*.ts",
"types": "./src/translations/languages/*.ts",
"default": "./src/translations/languages/*.ts"
}
},
"main": "./src/index.ts",
@@ -118,6 +128,16 @@
"import": "./dist/exports/utilities.js",
"types": "./dist/exports/utilities.d.ts",
"default": "./dist/exports/utilities.js"
},
"./translations/languages/all": {
"import": "./dist/translations/index.js",
"types": "./dist/translations/index.d.ts",
"default": "./dist/translations/index.js"
},
"./translations/languages/*": {
"import": "./dist/translations/languages/*.js",
"types": "./dist/translations/languages/*.d.ts",
"default": "./dist/translations/languages/*.js"
}
},
"main": "./dist/index.js",
Original file line number Diff line number Diff line change
@@ -3,18 +3,52 @@ import type { ReactSelectOption } from '@payloadcms/ui'
import type { ViewTypes } from 'payload'

import { getTranslation } from '@payloadcms/translations'
import { SelectInput, useTranslation } from '@payloadcms/ui'
import {
ConfirmationModal,
SelectInput,
Translation,
useModal,
useTranslation,
} from '@payloadcms/ui'
import React from 'react'

import type {
PluginMultiTenantTranslationKeys,
PluginMultiTenantTranslations,
} from '../../translations/index.js'

import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js'
import './index.scss'

const confirmSwitchTenantSlug = 'confirmSwitchTenant'

export const TenantSelector = ({ label, viewType }: { label: string; viewType?: ViewTypes }) => {
const { options, selectedTenantID, setTenant } = useTenantSelection()
const { i18n } = useTranslation()
const { options, preventRefreshOnChange, selectedTenantID, setTenant } = useTenantSelection()
const { openModal } = useModal()
const { i18n, t } = useTranslation<
PluginMultiTenantTranslations,
PluginMultiTenantTranslationKeys
>()
const [tenantSelection, setTenantSelection] = React.useState<
ReactSelectOption | ReactSelectOption[]
>()

const handleChange = React.useCallback(
(option: ReactSelectOption | ReactSelectOption[]) => {
const selectedValue = React.useMemo(() => {
if (selectedTenantID) {
return options.find((option) => option.value === selectedTenantID)
}
return undefined
}, [options, selectedTenantID])

const newSelectedValue = React.useMemo(() => {
if (tenantSelection && 'value' in tenantSelection) {
return options.find((option) => option.value === tenantSelection.value)
}
return undefined
}, [options, tenantSelection])

const switchTenant = React.useCallback(
(option: ReactSelectOption | ReactSelectOption[] | undefined) => {
if (option && 'value' in option) {
setTenant({ id: option.value as string, refresh: true })
} else {
@@ -24,6 +58,19 @@ export const TenantSelector = ({ label, viewType }: { label: string; viewType?:
[setTenant],
)

const onChange = React.useCallback(
(option: ReactSelectOption | ReactSelectOption[]) => {
if (!preventRefreshOnChange) {
switchTenant(option)
return
} else {
setTenantSelection(option)
openModal(confirmSwitchTenantSlug)
}
},
[openModal, preventRefreshOnChange, switchTenant],
)

if (options.length <= 1) {
return null
}
@@ -34,11 +81,46 @@ export const TenantSelector = ({ label, viewType }: { label: string; viewType?:
isClearable={viewType === 'list'}
label={getTranslation(label, i18n)}
name="setTenant"
onChange={handleChange}
onChange={onChange}
options={options}
path="setTenant"
value={selectedTenantID as string | undefined}
/>

<ConfirmationModal
body={
<Translation
elements={{
0: ({ children }) => {
return <b>{children}</b>
},
}}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
i18nKey="plugin-multi-tenant:confirm-tenant-switch--body"
t={t}
variables={{
fromTenant: selectedValue?.label,
toTenant: newSelectedValue?.label,
}}
/>
}
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: label.toLowerCase(),
}}
/>
}
modalSlug={confirmSwitchTenantSlug}
onConfirm={() => {
switchTenant(tenantSelection)
}}
/>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client'

import type { ClientCollectionConfig } from 'payload'

import { useConfig, useDocumentInfo, useEffectEvent, useFormFields } from '@payloadcms/ui'
import React from 'react'

import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js'

export const WatchTenantCollection = () => {
const { id, collectionSlug, title } = useDocumentInfo()
const { getEntityConfig } = useConfig()
const [useAsTitleName] = React.useState(
() => (getEntityConfig({ collectionSlug }) as ClientCollectionConfig).admin.useAsTitle,
)
const titleField = useFormFields(([fields]) => fields[useAsTitleName])

const { updateTenants } = useTenantSelection()

const syncTenantTitle = useEffectEvent(() => {
if (id) {
updateTenants({ id, label: title })
}
})

React.useEffect(() => {
// only update the tenant selector when the document saves
// → aka when initial value changes
if (id && titleField?.initialValue) {
syncTenantTitle()
}
}, [id, titleField?.initialValue])

return null
}
1 change: 1 addition & 0 deletions packages/plugin-multi-tenant/src/exports/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { TenantField } from '../components/TenantField/index.client.js'
export { TenantSelector } from '../components/TenantSelector/index.js'
export { WatchTenantCollection } from '../components/WatchTenantCollection/index.js'
export { useTenantSelection } from '../providers/TenantSelectionProvider/index.client.js'
39 changes: 39 additions & 0 deletions packages/plugin-multi-tenant/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { AcceptedLanguages } from '@payloadcms/translations'
import type { CollectionConfig, Config } from 'payload'

import { deepMergeSimple } from 'payload'

import type { PluginDefaultTranslationsObject } from './translations/types.js'
import type { MultiTenantPluginConfig } from './types.js'

import { defaults } from './defaults.js'
@@ -10,6 +13,7 @@ import { addTenantCleanup } from './hooks/afterTenantDelete.js'
import { filterDocumentsBySelectedTenant } from './list-filters/filterDocumentsBySelectedTenant.js'
import { filterTenantsBySelectedTenant } from './list-filters/filterTenantsBySelectedTenant.js'
import { filterUsersBySelectedTenant } from './list-filters/filterUsersBySelectedTenant.js'
import { translations } from './translations/index.js'
import { addCollectionAccess } from './utilities/addCollectionAccess.js'
import { addFilterOptionsToFields } from './utilities/addFilterOptionsToFields.js'
import { combineListFilters } from './utilities/combineListFilters.js'
@@ -229,6 +233,21 @@ export const multiTenantPlugin =
usersTenantsArrayTenantFieldName: tenantsArrayTenantFieldName,
})
}

/**
* Add custom tenant field that watches and dispatches updates to the selector
*/
collection.fields.push({
name: '_watchTenant',
type: 'ui',
admin: {
components: {
Field: {
path: '@payloadcms/plugin-multi-tenant/client#WatchTenantCollection',
},
},
},
})
} else if (pluginConfig.collections?.[collection.slug]) {
const isGlobal = Boolean(pluginConfig.collections[collection.slug]?.isGlobal)

@@ -340,5 +359,25 @@ export const multiTenantPlugin =
path: '@payloadcms/plugin-multi-tenant/client#TenantSelector',
})

/**
* Merge plugin translations
*/

const simplifiedTranslations = Object.entries(translations).reduce(
(acc, [key, value]) => {
acc[key] = value.translations
return acc
},
{} as Record<string, PluginDefaultTranslationsObject>,
)

incomingConfig.i18n = {
...incomingConfig.i18n,
translations: deepMergeSimple(
simplifiedTranslations,
incomingConfig.i18n?.translations ?? {},
),
}

return incomingConfig
}
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@ type ContextType = {
* Array of options to select from
*/
options: OptionObject[]
preventRefreshOnChange: boolean
/**
* The currently selected tenant ID
*/
@@ -28,20 +29,26 @@ type ContextType = {
* @param args.refresh - Whether to refresh the page after changing the tenant
*/
setTenant: (args: { id: number | string | undefined; refresh?: boolean }) => void
/**
*
*/
updateTenants: (args: { id: number | string; label: string }) => void
}

const Context = createContext<ContextType>({
options: [],
preventRefreshOnChange: false,
selectedTenantID: undefined,
setPreventRefreshOnChange: () => null,
setTenant: () => null,
updateTenants: () => null,
})

export const TenantSelectionProviderClient = ({
children,
initialValue,
tenantCookie,
tenantOptions,
tenantOptions: tenantOptionsFromProps,
}: {
children: React.ReactNode
initialValue?: number | string
@@ -54,6 +61,9 @@ export const TenantSelectionProviderClient = ({
const [preventRefreshOnChange, setPreventRefreshOnChange] = React.useState(false)
const { user } = useAuth()
const userID = React.useMemo(() => user?.id, [user?.id])
const [tenantOptions, setTenantOptions] = React.useState<OptionObject[]>(
() => tenantOptionsFromProps,
)
const selectedTenantLabel = React.useMemo(
() => tenantOptions.find((option) => option.value === selectedTenantID)?.label,
[selectedTenantID, tenantOptions],
@@ -91,6 +101,20 @@ export const TenantSelectionProviderClient = ({
[deleteCookie, preventRefreshOnChange, router, setCookie, setSelectedTenantID, tenantOptions],
)

const updateTenants = React.useCallback<ContextType['updateTenants']>(({ id, label }) => {
setTenantOptions((prev) => {
return prev.map((currentTenant) => {
if (id === currentTenant.value) {
return {
label,
value: id,
}
}
return currentTenant
})
})
}, [])

React.useEffect(() => {
if (selectedTenantID && !tenantOptions.find((option) => option.value === selectedTenantID)) {
if (tenantOptions?.[0]?.value) {
@@ -105,13 +129,14 @@ export const TenantSelectionProviderClient = ({
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()
}
}
}, [userID, tenantCookie, initialValue, setCookie, deleteCookie, router])
}, [userID, tenantCookie, initialValue, setCookie, deleteCookie, router, tenantOptionsFromProps])

React.useEffect(() => {
if (!userID && tenantCookie) {
@@ -132,9 +157,11 @@ export const TenantSelectionProviderClient = ({
<Context
value={{
options: tenantOptions,
preventRefreshOnChange,
selectedTenantID,
setPreventRefreshOnChange,
setTenant,
updateTenants,
}}
>
{children}
Loading
Oops, something went wrong.
Loading
Oops, something went wrong.