Skip to content

Commit 53d8557

Browse files
feat: adds new UI option Duplicate In [Select Locales] (#13803)
### What Adds new UI feature and update to the duplicate endpoint to support to duplicating only select locales. ### Why Currently, duplicating a document always copies all locales. To exclude some, users must duplicate the full document and manually clear fields. This feature was requested by the community and is part of planned localization improvements. ### How The new control opens a drawer where users can pick one or more locales to duplicate. The duplicate endpoint now accepts `selectLocales` and automatically clears any unselected locales before creating the new draft document. #### Feature tracked here: #13444 --------- Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
1 parent f1456a0 commit 53d8557

File tree

61 files changed

+709
-100
lines changed

Some content is hidden

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

61 files changed

+709
-100
lines changed

docs/admin/overview.mdx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -312,19 +312,19 @@ const config = buildConfig({
312312
timezones: {
313313
supportedTimezones: [
314314
{
315-
label: "Europe/Dublin",
316-
value: "Europe/Dublin",
315+
label: 'Europe/Dublin',
316+
value: 'Europe/Dublin',
317317
},
318318
{
319-
label: "Europe/Amsterdam",
320-
value: "Europe/Amsterdam",
319+
label: 'Europe/Amsterdam',
320+
value: 'Europe/Amsterdam',
321321
},
322322
{
323-
label: "Europe/Bucharest",
324-
value: "Europe/Bucharest",
323+
label: 'Europe/Bucharest',
324+
value: 'Europe/Bucharest',
325325
},
326326
],
327-
defaultTimezone: "Europe/Amsterdam",
327+
defaultTimezone: 'Europe/Amsterdam',
328328
},
329329
},
330330
})

docs/database/indexes.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const MyCollection: CollectionConfig = {
2626
index: true,
2727
// highlight-end
2828
},
29-
]
29+
],
3030
}
3131
```
3232

@@ -63,6 +63,7 @@ export const MyCollection: CollectionConfig = {
6363
],
6464
}
6565
```
66+
6667
## Localized fields and MongoDB indexes
6768

6869
When you set `index: true` or `unique: true` on a localized field, MongoDB creates one index **per locale path** (e.g., `slug.en`, `slug.da-dk`, etc.). With many locales and indexed fields, this can quickly approach MongoDB's per-collection index limit.

docs/fields/text.mdx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -212,15 +212,15 @@ export const ExampleCollection: CollectionConfig = {
212212

213213
The slug field exposes a few top-level config options for easy customization:
214214

215-
| Option | Description |
216-
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
217-
| `name` | To be used as the slug field's name. Defaults to `slug`. |
218-
| `overrides` | A function that receives the default fields so you can override on a granular level. See example below. [More details](#slug-overrides). |
219-
| `checkboxName` | To be used as the name for the `generateSlug` checkbox field. Defaults to `generateSlug`. |
220-
| `fieldToUse` | The name of the field to use when generating the slug. This field must exist in the same collection. Defaults to `title`. |
221-
| `localized` | Enable localization on the `slug` and `generateSlug` fields. Defaults to `false`. |
222-
| `position` | The position of the slug field. [More details](./overview#admin-options). |
223-
| `required` | Require the slug field. Defaults to `true`. |
215+
| Option | Description |
216+
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
217+
| `name` | To be used as the slug field's name. Defaults to `slug`. |
218+
| `overrides` | A function that receives the default fields so you can override on a granular level. See example below. [More details](#slug-overrides). |
219+
| `checkboxName` | To be used as the name for the `generateSlug` checkbox field. Defaults to `generateSlug`. |
220+
| `fieldToUse` | The name of the field to use when generating the slug. This field must exist in the same collection. Defaults to `title`. |
221+
| `localized` | Enable localization on the `slug` and `generateSlug` fields. Defaults to `false`. |
222+
| `position` | The position of the slug field. [More details](./overview#admin-options). |
223+
| `required` | Require the slug field. Defaults to `true`. |
224224

225225
### Slug Overrides
226226

packages/payload/src/collections/endpoints/duplicate.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,24 @@ import { duplicateOperation } from '../operations/duplicate.js'
1212

1313
export const duplicateHandler: PayloadHandler = async (req) => {
1414
const { id, collection } = getRequestCollectionWithID(req)
15-
const { searchParams } = req
16-
const depth = searchParams.get('depth')
17-
// draft defaults to true, unless explicitly set requested as false to prevent the newly duplicated document from being published
18-
const draft = searchParams.get('draft') !== 'false'
15+
const { depth, draft, populate, select, selectedLocales } = req.query as {
16+
depth?: string
17+
draft?: string
18+
populate?: Record<string, unknown>
19+
select?: Record<string, unknown>
20+
selectedLocales?: string[]
21+
}
1922

2023
const doc = await duplicateOperation({
2124
id,
2225
collection,
2326
data: req.data,
2427
depth: isNumber(depth) ? Number(depth) : undefined,
25-
draft,
26-
populate: sanitizePopulateParam(req.query.populate),
28+
draft: draft === 'true',
29+
populate: sanitizePopulateParam(populate),
2730
req,
28-
select: sanitizeSelectParam(req.query.select),
31+
select: sanitizeSelectParam(select),
32+
selectedLocales,
2933
})
3034

3135
const message = req.t('general:successfullyDuplicated', {

packages/payload/src/collections/operations/create.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export type Arguments<TSlug extends CollectionSlug> = {
5050
publishSpecificLocale?: string
5151
req: PayloadRequest
5252
select?: SelectType
53+
selectedLocales?: string[]
5354
showHiddenFields?: boolean
5455
}
5556

@@ -113,6 +114,7 @@ export const createOperation = async <
113114
},
114115
req,
115116
select: incomingSelect,
117+
selectedLocales,
116118
showHiddenFields,
117119
} = args
118120

@@ -131,6 +133,7 @@ export const createOperation = async <
131133
isSavingDraft,
132134
overrideAccess,
133135
req,
136+
selectedLocales,
134137
})
135138

136139
duplicatedFromDoc = duplicateResult.duplicatedFromDoc

packages/payload/src/collections/operations/local/duplicate.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ export type Options<TSlug extends CollectionSlug, TSelect extends SelectType> =
7979
* Specify [select](https://payloadcms.com/docs/queries/select) to control which fields to include to the result.
8080
*/
8181
select?: TSelect
82+
/**
83+
* Specifies which locales to include when duplicating localized fields. Non-localized data is always duplicated.
84+
* By default, all locales are duplicated.
85+
*/
86+
selectedLocales?: string[]
8287
/**
8388
* Opt-in to receiving hidden fields. By default, they are hidden from returned documents in accordance to your config.
8489
* @default false
@@ -107,6 +112,7 @@ export async function duplicateLocal<
107112
overrideAccess = true,
108113
populate,
109114
select,
115+
selectedLocales,
110116
showHiddenFields,
111117
} = options
112118

@@ -138,6 +144,7 @@ export async function duplicateLocal<
138144
populate,
139145
req,
140146
select,
147+
selectedLocales,
141148
showHiddenFields,
142149
})
143150
}

packages/payload/src/duplicateDocument/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { NotFound } from '../errors/NotFound.js'
1010
import { afterRead } from '../fields/hooks/afterRead/index.js'
1111
import { beforeDuplicate } from '../fields/hooks/beforeDuplicate/index.js'
1212
import { deepCopyObjectSimple } from '../utilities/deepCopyObject.js'
13+
import { filterDataToSelectedLocales } from '../utilities/filterDataToSelectedLocales.js'
1314
import { getLatestCollectionVersion } from '../versions/getLatestCollectionVersion.js'
1415

1516
type GetDuplicateDocumentArgs = {
@@ -19,6 +20,7 @@ type GetDuplicateDocumentArgs = {
1920
isSavingDraft?: boolean
2021
overrideAccess?: boolean
2122
req: PayloadRequest
23+
selectedLocales?: string[]
2224
}
2325
export const getDuplicateDocumentData = async ({
2426
id,
@@ -27,6 +29,7 @@ export const getDuplicateDocumentData = async ({
2729
isSavingDraft,
2830
overrideAccess,
2931
req,
32+
selectedLocales,
3033
}: GetDuplicateDocumentArgs): Promise<{
3134
duplicatedFromDoc: JsonObject
3235
duplicatedFromDocWithLocales: JsonObject
@@ -59,6 +62,15 @@ export const getDuplicateDocumentData = async ({
5962
req,
6063
})
6164

65+
if (selectedLocales && selectedLocales.length > 0 && duplicatedFromDocWithLocales) {
66+
duplicatedFromDocWithLocales = filterDataToSelectedLocales({
67+
configBlockReferences: payload.config.blocks,
68+
docWithLocales: duplicatedFromDocWithLocales,
69+
fields: collectionConfig.fields,
70+
selectedLocales,
71+
})
72+
}
73+
6274
if (!duplicatedFromDocWithLocales && !hasWherePolicy) {
6375
throw new NotFound(req.t)
6476
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import type { Block, Field, FlattenedBlock } from '../fields/config/types.js'
2+
import type { SanitizedConfig } from '../index.js'
3+
import type { JsonObject } from '../types/index.js'
4+
5+
import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../fields/config/types.js'
6+
7+
type FilterDataToSelectedLocalesArgs = {
8+
configBlockReferences: SanitizedConfig['blocks']
9+
docWithLocales: JsonObject
10+
fields: Field[]
11+
parentIsLocalized?: boolean
12+
selectedLocales: string[]
13+
}
14+
15+
/**
16+
* Filters localized field data to only include specified locales.
17+
* For non-localized fields, returns all data as-is.
18+
* For localized fields, if selectedLocales is provided, returns only those locales.
19+
* If selectedLocales is not provided and field is localized, returns all locales.
20+
*/
21+
export function filterDataToSelectedLocales({
22+
configBlockReferences,
23+
docWithLocales,
24+
fields,
25+
parentIsLocalized = false,
26+
selectedLocales,
27+
}: FilterDataToSelectedLocalesArgs): JsonObject {
28+
if (!docWithLocales || typeof docWithLocales !== 'object') {
29+
return docWithLocales
30+
}
31+
32+
const result: JsonObject = {}
33+
34+
for (const field of fields) {
35+
if (fieldAffectsData(field)) {
36+
const fieldIsLocalized = fieldShouldBeLocalized({ field, parentIsLocalized })
37+
38+
switch (field.type) {
39+
case 'array': {
40+
if (Array.isArray(docWithLocales[field.name])) {
41+
result[field.name] = docWithLocales[field.name].map((item: JsonObject) =>
42+
filterDataToSelectedLocales({
43+
configBlockReferences,
44+
docWithLocales: item,
45+
fields: field.fields,
46+
parentIsLocalized: fieldIsLocalized,
47+
selectedLocales,
48+
}),
49+
)
50+
}
51+
break
52+
}
53+
54+
case 'blocks': {
55+
if (field.name in docWithLocales && Array.isArray(docWithLocales[field.name])) {
56+
result[field.name] = docWithLocales[field.name].map((blockData: JsonObject) => {
57+
let block: Block | FlattenedBlock | undefined
58+
if (configBlockReferences && field.blockReferences) {
59+
for (const blockOrReference of field.blockReferences) {
60+
if (typeof blockOrReference === 'string') {
61+
block = configBlockReferences.find((b) => b.slug === blockData.blockType)
62+
} else {
63+
block = blockOrReference
64+
}
65+
}
66+
} else if (field.blocks) {
67+
block = field.blocks.find((b) => b.slug === blockData.blockType)
68+
}
69+
70+
if (block) {
71+
return filterDataToSelectedLocales({
72+
configBlockReferences,
73+
docWithLocales: blockData,
74+
fields: block?.fields || [],
75+
parentIsLocalized: fieldIsLocalized,
76+
selectedLocales,
77+
})
78+
}
79+
80+
return blockData
81+
})
82+
}
83+
break
84+
}
85+
86+
case 'group': {
87+
// Named groups create a nested data structure
88+
if (
89+
fieldAffectsData(field) &&
90+
field.name in docWithLocales &&
91+
typeof docWithLocales[field.name] === 'object'
92+
) {
93+
result[field.name] = filterDataToSelectedLocales({
94+
configBlockReferences,
95+
docWithLocales: docWithLocales[field.name] as JsonObject,
96+
fields: field.fields,
97+
parentIsLocalized: fieldIsLocalized,
98+
selectedLocales,
99+
})
100+
} else {
101+
// Unnamed groups pass through the same data level
102+
const nestedResult = filterDataToSelectedLocales({
103+
configBlockReferences,
104+
docWithLocales,
105+
fields: field.fields,
106+
parentIsLocalized,
107+
selectedLocales,
108+
})
109+
Object.assign(result, nestedResult)
110+
}
111+
break
112+
}
113+
114+
default: {
115+
// For all other data-affecting fields (text, number, select, etc.)
116+
if (field.name in docWithLocales) {
117+
const value = docWithLocales[field.name]
118+
119+
// If the field is localized and has locale data
120+
if (fieldIsLocalized && value && typeof value === 'object' && !Array.isArray(value)) {
121+
// If selectedLocales is provided, filter to only those locales
122+
if (selectedLocales && selectedLocales.length > 0) {
123+
const filtered: Record<string, unknown> = {}
124+
for (const locale of selectedLocales) {
125+
if (locale in value) {
126+
filtered[locale] = value[locale]
127+
}
128+
}
129+
if (Object.keys(filtered).length > 0) {
130+
result[field.name] = filtered
131+
}
132+
} else {
133+
// If no selectedLocales, include all locales
134+
result[field.name] = value
135+
}
136+
} else {
137+
// Non-localized field or non-object value
138+
result[field.name] = value
139+
}
140+
}
141+
break
142+
}
143+
}
144+
} else {
145+
// Layout-only fields that don't affect data structure
146+
switch (field.type) {
147+
case 'collapsible':
148+
case 'row': {
149+
// These pass through the same data level
150+
const nestedResult = filterDataToSelectedLocales({
151+
configBlockReferences,
152+
docWithLocales,
153+
fields: field.fields,
154+
parentIsLocalized,
155+
selectedLocales,
156+
})
157+
Object.assign(result, nestedResult)
158+
break
159+
}
160+
161+
case 'tabs': {
162+
for (const tab of field.tabs) {
163+
if (tabHasName(tab)) {
164+
// Named tabs create a nested data structure
165+
if (tab.name in docWithLocales && typeof docWithLocales[tab.name] === 'object') {
166+
result[tab.name] = filterDataToSelectedLocales({
167+
configBlockReferences,
168+
docWithLocales: docWithLocales[tab.name],
169+
fields: tab.fields,
170+
parentIsLocalized,
171+
selectedLocales,
172+
})
173+
}
174+
} else {
175+
// Unnamed tabs pass through the same data level
176+
const nestedResult = filterDataToSelectedLocales({
177+
configBlockReferences,
178+
docWithLocales,
179+
fields: tab.fields,
180+
parentIsLocalized,
181+
selectedLocales,
182+
})
183+
Object.assign(result, nestedResult)
184+
}
185+
}
186+
break
187+
}
188+
}
189+
}
190+
}
191+
192+
return result
193+
}

packages/translations/src/clientKeys.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,9 @@ export const clientTranslationKeys = createClientTranslationKeys([
376376
'localization:localeToPublish',
377377
'localization:copyToLocale',
378378
'localization:copyFromTo',
379+
'localization:selectedLocales',
379380
'localization:selectLocaleToCopy',
381+
'localization:selectLocaleToDuplicate',
380382
'localization:cannotCopySameLocale',
381383
'localization:copyFrom',
382384
'localization:copyTo',

0 commit comments

Comments
 (0)