Skip to content

Commit 7fde63f

Browse files
JarrodMFleschkendelljoseph
authored andcommitted
feat(plugin-multi-tenant): prompt the user to confirm the change of tenant before actually updating (#12382)
1 parent 5951f82 commit 7fde63f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+1027
-92
lines changed

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export const rootEslintConfig = [
7474
'no-console': 'off',
7575
'perfectionist/sort-object-types': 'off',
7676
'perfectionist/sort-objects': 'off',
77+
'payload/no-relative-monorepo-imports': 'off',
7778
},
7879
},
7980
]

packages/plugin-multi-tenant/package.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@
5656
"import": "./src/exports/utilities.ts",
5757
"types": "./src/exports/utilities.ts",
5858
"default": "./src/exports/utilities.ts"
59+
},
60+
"./translations/languages/all": {
61+
"import": "./src/translations/index.ts",
62+
"types": "./src/translations/index.ts",
63+
"default": "./src/translations/index.ts"
64+
},
65+
"./translations/languages/*": {
66+
"import": "./src/translations/languages/*.ts",
67+
"types": "./src/translations/languages/*.ts",
68+
"default": "./src/translations/languages/*.ts"
5969
}
6070
},
6171
"main": "./src/index.ts",
@@ -118,6 +128,16 @@
118128
"import": "./dist/exports/utilities.js",
119129
"types": "./dist/exports/utilities.d.ts",
120130
"default": "./dist/exports/utilities.js"
131+
},
132+
"./translations/languages/all": {
133+
"import": "./dist/translations/index.js",
134+
"types": "./dist/translations/index.d.ts",
135+
"default": "./dist/translations/index.js"
136+
},
137+
"./translations/languages/*": {
138+
"import": "./dist/translations/languages/*.js",
139+
"types": "./dist/translations/languages/*.d.ts",
140+
"default": "./dist/translations/languages/*.js"
121141
}
122142
},
123143
"main": "./dist/index.js",

packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,52 @@ import type { ReactSelectOption } from '@payloadcms/ui'
33
import type { ViewTypes } from 'payload'
44

55
import { getTranslation } from '@payloadcms/translations'
6-
import { SelectInput, useTranslation } from '@payloadcms/ui'
6+
import {
7+
ConfirmationModal,
8+
SelectInput,
9+
Translation,
10+
useModal,
11+
useTranslation,
12+
} from '@payloadcms/ui'
713
import React from 'react'
814

15+
import type {
16+
PluginMultiTenantTranslationKeys,
17+
PluginMultiTenantTranslations,
18+
} from '../../translations/index.js'
19+
920
import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js'
1021
import './index.scss'
1122

23+
const confirmSwitchTenantSlug = 'confirmSwitchTenant'
24+
1225
export const TenantSelector = ({ label, viewType }: { label: string; viewType?: ViewTypes }) => {
13-
const { options, selectedTenantID, setTenant } = useTenantSelection()
14-
const { i18n } = useTranslation()
26+
const { options, preventRefreshOnChange, selectedTenantID, setTenant } = useTenantSelection()
27+
const { openModal } = useModal()
28+
const { i18n, t } = useTranslation<
29+
PluginMultiTenantTranslations,
30+
PluginMultiTenantTranslationKeys
31+
>()
32+
const [tenantSelection, setTenantSelection] = React.useState<
33+
ReactSelectOption | ReactSelectOption[]
34+
>()
1535

16-
const handleChange = React.useCallback(
17-
(option: ReactSelectOption | ReactSelectOption[]) => {
36+
const selectedValue = React.useMemo(() => {
37+
if (selectedTenantID) {
38+
return options.find((option) => option.value === selectedTenantID)
39+
}
40+
return undefined
41+
}, [options, selectedTenantID])
42+
43+
const newSelectedValue = React.useMemo(() => {
44+
if (tenantSelection && 'value' in tenantSelection) {
45+
return options.find((option) => option.value === tenantSelection.value)
46+
}
47+
return undefined
48+
}, [options, tenantSelection])
49+
50+
const switchTenant = React.useCallback(
51+
(option: ReactSelectOption | ReactSelectOption[] | undefined) => {
1852
if (option && 'value' in option) {
1953
setTenant({ id: option.value as string, refresh: true })
2054
} else {
@@ -24,6 +58,19 @@ export const TenantSelector = ({ label, viewType }: { label: string; viewType?:
2458
[setTenant],
2559
)
2660

61+
const onChange = React.useCallback(
62+
(option: ReactSelectOption | ReactSelectOption[]) => {
63+
if (!preventRefreshOnChange) {
64+
switchTenant(option)
65+
return
66+
} else {
67+
setTenantSelection(option)
68+
openModal(confirmSwitchTenantSlug)
69+
}
70+
},
71+
[openModal, preventRefreshOnChange, switchTenant],
72+
)
73+
2774
if (options.length <= 1) {
2875
return null
2976
}
@@ -34,11 +81,46 @@ export const TenantSelector = ({ label, viewType }: { label: string; viewType?:
3481
isClearable={viewType === 'list'}
3582
label={getTranslation(label, i18n)}
3683
name="setTenant"
37-
onChange={handleChange}
84+
onChange={onChange}
3885
options={options}
3986
path="setTenant"
4087
value={selectedTenantID as string | undefined}
4188
/>
89+
90+
<ConfirmationModal
91+
body={
92+
<Translation
93+
elements={{
94+
0: ({ children }) => {
95+
return <b>{children}</b>
96+
},
97+
}}
98+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
99+
// @ts-expect-error
100+
i18nKey="plugin-multi-tenant:confirm-tenant-switch--body"
101+
t={t}
102+
variables={{
103+
fromTenant: selectedValue?.label,
104+
toTenant: newSelectedValue?.label,
105+
}}
106+
/>
107+
}
108+
heading={
109+
<Translation
110+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
111+
// @ts-expect-error
112+
i18nKey="plugin-multi-tenant:confirm-tenant-switch--heading"
113+
t={t}
114+
variables={{
115+
tenantLabel: label.toLowerCase(),
116+
}}
117+
/>
118+
}
119+
modalSlug={confirmSwitchTenantSlug}
120+
onConfirm={() => {
121+
switchTenant(tenantSelection)
122+
}}
123+
/>
42124
</div>
43125
)
44126
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use client'
2+
3+
import type { ClientCollectionConfig } from 'payload'
4+
5+
import { useConfig, useDocumentInfo, useEffectEvent, useFormFields } from '@payloadcms/ui'
6+
import React from 'react'
7+
8+
import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js'
9+
10+
export const WatchTenantCollection = () => {
11+
const { id, collectionSlug, title } = useDocumentInfo()
12+
const { getEntityConfig } = useConfig()
13+
const [useAsTitleName] = React.useState(
14+
() => (getEntityConfig({ collectionSlug }) as ClientCollectionConfig).admin.useAsTitle,
15+
)
16+
const titleField = useFormFields(([fields]) => fields[useAsTitleName])
17+
18+
const { updateTenants } = useTenantSelection()
19+
20+
const syncTenantTitle = useEffectEvent(() => {
21+
if (id) {
22+
updateTenants({ id, label: title })
23+
}
24+
})
25+
26+
React.useEffect(() => {
27+
// only update the tenant selector when the document saves
28+
// → aka when initial value changes
29+
if (id && titleField?.initialValue) {
30+
syncTenantTitle()
31+
}
32+
}, [id, titleField?.initialValue])
33+
34+
return null
35+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { TenantField } from '../components/TenantField/index.client.js'
22
export { TenantSelector } from '../components/TenantSelector/index.js'
3+
export { WatchTenantCollection } from '../components/WatchTenantCollection/index.js'
34
export { useTenantSelection } from '../providers/TenantSelectionProvider/index.client.js'

packages/plugin-multi-tenant/src/index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { AcceptedLanguages } from '@payloadcms/translations'
22
import type { CollectionConfig, Config } from 'payload'
33

4+
import { deepMergeSimple } from 'payload'
5+
6+
import type { PluginDefaultTranslationsObject } from './translations/types.js'
47
import type { MultiTenantPluginConfig } from './types.js'
58

69
import { defaults } from './defaults.js'
@@ -10,6 +13,7 @@ import { addTenantCleanup } from './hooks/afterTenantDelete.js'
1013
import { filterDocumentsBySelectedTenant } from './list-filters/filterDocumentsBySelectedTenant.js'
1114
import { filterTenantsBySelectedTenant } from './list-filters/filterTenantsBySelectedTenant.js'
1215
import { filterUsersBySelectedTenant } from './list-filters/filterUsersBySelectedTenant.js'
16+
import { translations } from './translations/index.js'
1317
import { addCollectionAccess } from './utilities/addCollectionAccess.js'
1418
import { addFilterOptionsToFields } from './utilities/addFilterOptionsToFields.js'
1519
import { combineListFilters } from './utilities/combineListFilters.js'
@@ -229,6 +233,21 @@ export const multiTenantPlugin =
229233
usersTenantsArrayTenantFieldName: tenantsArrayTenantFieldName,
230234
})
231235
}
236+
237+
/**
238+
* Add custom tenant field that watches and dispatches updates to the selector
239+
*/
240+
collection.fields.push({
241+
name: '_watchTenant',
242+
type: 'ui',
243+
admin: {
244+
components: {
245+
Field: {
246+
path: '@payloadcms/plugin-multi-tenant/client#WatchTenantCollection',
247+
},
248+
},
249+
},
250+
})
232251
} else if (pluginConfig.collections?.[collection.slug]) {
233252
const isGlobal = Boolean(pluginConfig.collections[collection.slug]?.isGlobal)
234253

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

362+
/**
363+
* Merge plugin translations
364+
*/
365+
366+
const simplifiedTranslations = Object.entries(translations).reduce(
367+
(acc, [key, value]) => {
368+
acc[key] = value.translations
369+
return acc
370+
},
371+
{} as Record<string, PluginDefaultTranslationsObject>,
372+
)
373+
374+
incomingConfig.i18n = {
375+
...incomingConfig.i18n,
376+
translations: deepMergeSimple(
377+
simplifiedTranslations,
378+
incomingConfig.i18n?.translations ?? {},
379+
),
380+
}
381+
343382
return incomingConfig
344383
}

packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type ContextType = {
1111
* Array of options to select from
1212
*/
1313
options: OptionObject[]
14+
preventRefreshOnChange: boolean
1415
/**
1516
* The currently selected tenant ID
1617
*/
@@ -28,20 +29,26 @@ type ContextType = {
2829
* @param args.refresh - Whether to refresh the page after changing the tenant
2930
*/
3031
setTenant: (args: { id: number | string | undefined; refresh?: boolean }) => void
32+
/**
33+
*
34+
*/
35+
updateTenants: (args: { id: number | string; label: string }) => void
3136
}
3237

3338
const Context = createContext<ContextType>({
3439
options: [],
40+
preventRefreshOnChange: false,
3541
selectedTenantID: undefined,
3642
setPreventRefreshOnChange: () => null,
3743
setTenant: () => null,
44+
updateTenants: () => null,
3845
})
3946

4047
export const TenantSelectionProviderClient = ({
4148
children,
4249
initialValue,
4350
tenantCookie,
44-
tenantOptions,
51+
tenantOptions: tenantOptionsFromProps,
4552
}: {
4653
children: React.ReactNode
4754
initialValue?: number | string
@@ -54,6 +61,9 @@ export const TenantSelectionProviderClient = ({
5461
const [preventRefreshOnChange, setPreventRefreshOnChange] = React.useState(false)
5562
const { user } = useAuth()
5663
const userID = React.useMemo(() => user?.id, [user?.id])
64+
const [tenantOptions, setTenantOptions] = React.useState<OptionObject[]>(
65+
() => tenantOptionsFromProps,
66+
)
5767
const selectedTenantLabel = React.useMemo(
5868
() => tenantOptions.find((option) => option.value === selectedTenantID)?.label,
5969
[selectedTenantID, tenantOptions],
@@ -91,6 +101,20 @@ export const TenantSelectionProviderClient = ({
91101
[deleteCookie, preventRefreshOnChange, router, setCookie, setSelectedTenantID, tenantOptions],
92102
)
93103

104+
const updateTenants = React.useCallback<ContextType['updateTenants']>(({ id, label }) => {
105+
setTenantOptions((prev) => {
106+
return prev.map((currentTenant) => {
107+
if (id === currentTenant.value) {
108+
return {
109+
label,
110+
value: id,
111+
}
112+
}
113+
return currentTenant
114+
})
115+
})
116+
}, [])
117+
94118
React.useEffect(() => {
95119
if (selectedTenantID && !tenantOptions.find((option) => option.value === selectedTenantID)) {
96120
if (tenantOptions?.[0]?.value) {
@@ -105,13 +129,14 @@ export const TenantSelectionProviderClient = ({
105129
if (userID && !tenantCookie) {
106130
// User is logged in, but does not have a tenant cookie, set it
107131
setSelectedTenantID(initialValue)
132+
setTenantOptions(tenantOptionsFromProps)
108133
if (initialValue) {
109134
setCookie(String(initialValue))
110135
} else {
111136
deleteCookie()
112137
}
113138
}
114-
}, [userID, tenantCookie, initialValue, setCookie, deleteCookie, router])
139+
}, [userID, tenantCookie, initialValue, setCookie, deleteCookie, router, tenantOptionsFromProps])
115140

116141
React.useEffect(() => {
117142
if (!userID && tenantCookie) {
@@ -132,9 +157,11 @@ export const TenantSelectionProviderClient = ({
132157
<Context
133158
value={{
134159
options: tenantOptions,
160+
preventRefreshOnChange,
135161
selectedTenantID,
136162
setPreventRefreshOnChange,
137163
setTenant,
164+
updateTenants,
138165
}}
139166
>
140167
{children}

0 commit comments

Comments
 (0)