strapi-plugin-dynamic-zone-tools 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/CONTRIBUTING.md +38 -0
  3. package/LICENSE +21 -0
  4. package/README.md +186 -0
  5. package/admin/custom.d.ts +2 -0
  6. package/admin/src/components/DynamicZoneComponentDuplicateInjector.tsx +604 -0
  7. package/admin/src/components/DynamicZoneEditViewExtensions.tsx +7 -0
  8. package/admin/src/components/DynamicZoneToolsPanel.tsx +1027 -0
  9. package/admin/src/components/FillFromRecord.tsx +36 -0
  10. package/admin/src/components/Initializer.tsx +19 -0
  11. package/admin/src/components/PluginIcon.tsx +5 -0
  12. package/admin/src/index.ts +61 -0
  13. package/admin/src/pages/App.tsx +15 -0
  14. package/admin/src/pages/HomePage.tsx +16 -0
  15. package/admin/src/pluginId.ts +1 -0
  16. package/admin/src/translations/en.json +51 -0
  17. package/admin/src/utils/createRowActionButton.ts +57 -0
  18. package/admin/src/utils/createRowActionMenu.ts +276 -0
  19. package/admin/src/utils/dynamicZoneClipboard.ts +134 -0
  20. package/admin/src/utils/dynamicZonePaths.ts +236 -0
  21. package/admin/src/utils/getTranslation.ts +5 -0
  22. package/admin/src/utils/prepareDynamicZoneData.ts +625 -0
  23. package/admin/src/utils/relationQueryParams.ts +19 -0
  24. package/admin/tsconfig.build.json +10 -0
  25. package/admin/tsconfig.json +12 -0
  26. package/dist/admin/en-Ce0ZP0MJ.js +54 -0
  27. package/dist/admin/en-DrSdJbJW.mjs +54 -0
  28. package/dist/admin/index.js +2161 -0
  29. package/dist/admin/index.mjs +2159 -0
  30. package/dist/admin/src/index.d.ts +12 -0
  31. package/dist/server/index.js +137 -0
  32. package/dist/server/index.mjs +137 -0
  33. package/dist/server/src/index.d.ts +55 -0
  34. package/package.json +112 -0
  35. package/server/src/bootstrap.ts +18 -0
  36. package/server/src/config/index.ts +4 -0
  37. package/server/src/content-types/index.ts +1 -0
  38. package/server/src/controllers/controller.ts +85 -0
  39. package/server/src/controllers/index.ts +5 -0
  40. package/server/src/destroy.ts +7 -0
  41. package/server/src/index.ts +30 -0
  42. package/server/src/middlewares/index.ts +1 -0
  43. package/server/src/policies/index.ts +1 -0
  44. package/server/src/register.ts +7 -0
  45. package/server/src/routes/admin-api.ts +18 -0
  46. package/server/src/routes/content-api.ts +1 -0
  47. package/server/src/routes/index.ts +15 -0
  48. package/server/src/services/index.ts +5 -0
  49. package/server/src/services/service.ts +9 -0
  50. package/server/tsconfig.build.json +10 -0
  51. package/server/tsconfig.json +11 -0
  52. package/strapi-admin.js +3 -0
  53. package/strapi-server.js +3 -0
@@ -0,0 +1,1027 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import {
3
+ Button,
4
+ Dialog,
5
+ SingleSelect,
6
+ SingleSelectOption,
7
+ Typography,
8
+ Flex,
9
+ Alert,
10
+ Box,
11
+ Checkbox,
12
+ } from '@strapi/design-system';
13
+ import { useFetchClient, useForm, useNotification } from '@strapi/strapi/admin';
14
+ import { useIntl } from 'react-intl';
15
+ import { Duplicate, WarningCircle } from '@strapi/icons';
16
+ import { PLUGIN_ID } from '../pluginId';
17
+ import {
18
+ filterAllowedDynamicZoneEntries,
19
+ getComponentDisplayName,
20
+ getDynamicZoneEntryLabel,
21
+ loadComponentSchemas,
22
+ prepareZoneData,
23
+ prepareZoneDataForInsert,
24
+ } from '../utils/prepareDynamicZoneData';
25
+ import { getDynamicZoneFields } from '../utils/dynamicZonePaths';
26
+
27
+ const TOOLS_DIALOG_STYLE_ID = `${PLUGIN_ID}-dialog-layout-styles`;
28
+
29
+ function ensureToolsDialogStyles() {
30
+ const dom = globalThis.document;
31
+
32
+ if (!dom || dom.getElementById(TOOLS_DIALOG_STYLE_ID)) {
33
+ return;
34
+ }
35
+
36
+ const style = dom.createElement('style');
37
+ style.id = TOOLS_DIALOG_STYLE_ID;
38
+ style.textContent = `
39
+ [role='alertdialog'][data-dz-tools-dialog='true'] {
40
+ max-height: calc(100dvh - 2rem) !important;
41
+ height: auto !important;
42
+ overflow: hidden !important;
43
+ }
44
+
45
+ [data-dz-tools-dialog='true'] [data-dz-tools-shell='true'] {
46
+ display: flex;
47
+ flex: 1 1 auto;
48
+ flex-direction: column;
49
+ min-height: 0;
50
+ overflow: hidden;
51
+ width: 100%;
52
+ }
53
+
54
+ [data-dz-tools-dialog='true'] [data-dz-tools-body='true'] {
55
+ flex: 1 1 auto !important;
56
+ min-height: 0 !important;
57
+ overflow-y: auto !important;
58
+ }
59
+
60
+ [data-dz-tools-dialog='true'] [data-dz-tools-footer='true'] {
61
+ flex: 0 0 auto !important;
62
+ }
63
+ `;
64
+
65
+ dom.head.appendChild(style);
66
+ }
67
+
68
+ type FillMode = 'replace' | 'append';
69
+
70
+ interface ContentType {
71
+ uid: string;
72
+ displayName: string;
73
+ draftAndPublish: boolean;
74
+ localized: boolean;
75
+ dynamicZones: DynamicZoneInfo[];
76
+ }
77
+
78
+ interface SourceRecord {
79
+ id: number;
80
+ documentId?: string;
81
+ status?: 'draft' | 'published' | 'modified';
82
+ [key: string]: any;
83
+ }
84
+
85
+ interface DynamicZoneInfo {
86
+ fieldName: string;
87
+ displayName: string;
88
+ }
89
+
90
+ interface ConfiguredLocale {
91
+ code: string;
92
+ name: string;
93
+ isDefault?: boolean;
94
+ }
95
+
96
+ interface DynamicZoneToolsDialogProps {
97
+ document: any;
98
+ documentId?: string;
99
+ model: string;
100
+ collectionType: string;
101
+ activeTab?: 'draft' | 'published' | null;
102
+ schemaAttributes?: { [key: string]: any } | null;
103
+ onClose: () => void;
104
+ }
105
+
106
+ const DynamicZoneToolsHeaderAction = ({
107
+ document,
108
+ documentId,
109
+ meta,
110
+ model,
111
+ collectionType,
112
+ activeTab,
113
+ }: any) => {
114
+ const schemaAttributes = meta?.schema?.attributes || meta?.attributes || null;
115
+ if (schemaAttributes) {
116
+ const hasDynamicZone = Object.values(schemaAttributes).some(
117
+ (attr: any) => attr?.type === 'dynamiczone'
118
+ );
119
+
120
+ if (!hasDynamicZone) return null;
121
+ }
122
+
123
+ return {
124
+ type: 'icon',
125
+ icon: <Duplicate />,
126
+ label: 'Copy dynamic zone data',
127
+ dialog: {
128
+ type: 'dialog',
129
+ title: 'Dynamic Zone Tools',
130
+ content: ({ onClose }: { onClose: () => void }) => (
131
+ <>
132
+ <DynamicZoneToolsDialog
133
+ document={document}
134
+ documentId={documentId}
135
+ model={model}
136
+ collectionType={collectionType}
137
+ activeTab={activeTab}
138
+ schemaAttributes={schemaAttributes}
139
+ onClose={onClose}
140
+ />
141
+ </>
142
+ ),
143
+ },
144
+ };
145
+ };
146
+
147
+ const DynamicZoneToolsDialog: React.FC<DynamicZoneToolsDialogProps> = ({
148
+ document,
149
+ documentId,
150
+ model,
151
+ collectionType,
152
+ activeTab,
153
+ schemaAttributes,
154
+ onClose,
155
+ }) => {
156
+ const { formatMessage } = useIntl();
157
+ const { get } = useFetchClient();
158
+ const { setValues, values } = useForm('DynamicZoneTools', (state) => ({
159
+ setValues: state.setValues,
160
+ values: state.values,
161
+ }));
162
+ const { toggleNotification } = useNotification();
163
+
164
+ const parsePayloadData = (payload: any) => {
165
+ if (payload?.data && typeof payload.data === 'object') return payload.data;
166
+ if (payload && typeof payload === 'object') return payload;
167
+ return null;
168
+ };
169
+ const getDynamicZonesFromAttributes = (attributes?: { [key: string]: any } | null) =>
170
+ getDynamicZoneFields(attributes);
171
+
172
+ // State management
173
+ const [targetDynamicZones, setTargetDynamicZones] = useState<DynamicZoneInfo[]>([]);
174
+ const [selectedTargetZone, setSelectedTargetZone] = useState<string>('');
175
+ const [contentTypes, setContentTypes] = useState<ContentType[]>([]);
176
+ const [selectedContentType, setSelectedContentType] = useState<string>('');
177
+ const [sourceDynamicZones, setSourceDynamicZones] = useState<DynamicZoneInfo[]>([]);
178
+ const [selectedSourceZone, setSelectedSourceZone] = useState<string>('');
179
+ const [records, setRecords] = useState<SourceRecord[]>([]);
180
+ const [selectedRecord, setSelectedRecord] = useState<string>('');
181
+ const [configuredLocales, setConfiguredLocales] = useState<ConfiguredLocale[]>([]);
182
+ const [selectedLocale, setSelectedLocale] = useState<string>('');
183
+ const [selectedVersion, setSelectedVersion] = useState<'draft' | 'published'>('draft');
184
+ const [recordsLoading, setRecordsLoading] = useState(false);
185
+ const [loading, setLoading] = useState(false);
186
+ const [error, setError] = useState<string>('');
187
+ const [fillMode, setFillMode] = useState<FillMode>('replace');
188
+ const [sourcePreviewLoading, setSourcePreviewLoading] = useState(false);
189
+ const [sourceZoneEntries, setSourceZoneEntries] = useState<any[]>([]);
190
+ const [selectedBlockIndexes, setSelectedBlockIndexes] = useState<Set<number>>(new Set());
191
+
192
+ const sourceContentType = contentTypes.find((ct) => ct.uid === selectedContentType);
193
+ const sourceHasDraftAndPublish = sourceContentType?.draftAndPublish ?? false;
194
+ const sourceIsLocalized = (sourceContentType?.localized ?? false) && configuredLocales.length > 0;
195
+ const selectedRecordEntry = records.find(
196
+ (record) => (record.documentId || record.id.toString()) === selectedRecord
197
+ );
198
+ // The record list is locale-scoped and entries are draft representations;
199
+ // their status field tells us whether a published version exists.
200
+ const selectedRecordHasPublished =
201
+ selectedRecordEntry?.status === 'published' || selectedRecordEntry?.status === 'modified';
202
+
203
+ // Resolve target dynamic zones for the current model
204
+ useEffect(() => {
205
+ if (schemaAttributes) {
206
+ setTargetDynamicZones(getDynamicZonesFromAttributes(schemaAttributes));
207
+ } else {
208
+ const currentType = contentTypes.find((ct) => ct.uid === model);
209
+ setTargetDynamicZones(currentType?.dynamicZones || []);
210
+ }
211
+ }, [schemaAttributes, contentTypes, model]);
212
+
213
+ // Fetch content types with their dynamic zones
214
+ useEffect(() => {
215
+ const fetchContentTypes = async () => {
216
+ try {
217
+ const response = await get('/content-manager/content-types');
218
+ let contentTypesData = response.data || response;
219
+
220
+ if (!Array.isArray(contentTypesData)) {
221
+ if (contentTypesData.data && Array.isArray(contentTypesData.data)) {
222
+ contentTypesData = contentTypesData.data;
223
+ } else if (contentTypesData.results && Array.isArray(contentTypesData.results)) {
224
+ contentTypesData = contentTypesData.results;
225
+ } else {
226
+ contentTypesData = [];
227
+ }
228
+ }
229
+
230
+ const typesWithDynamicZones = contentTypesData
231
+ .map((ct: any) => {
232
+ const attributes = ct.attributes || ct.schema?.attributes;
233
+ const dynamicZones = getDynamicZonesFromAttributes(attributes);
234
+
235
+ return {
236
+ uid: ct.uid,
237
+ displayName: ct.info?.displayName || ct.schema?.displayName || ct.uid,
238
+ draftAndPublish: Boolean(
239
+ ct.options?.draftAndPublish ?? ct.schema?.options?.draftAndPublish
240
+ ),
241
+ localized: Boolean(
242
+ ct.pluginOptions?.i18n?.localized ?? ct.schema?.pluginOptions?.i18n?.localized
243
+ ),
244
+ dynamicZones,
245
+ };
246
+ })
247
+ .filter((ct: ContentType) => ct.dynamicZones.length > 0);
248
+
249
+ setContentTypes(typesWithDynamicZones);
250
+ } catch (err) {
251
+ setError('Failed to load content types');
252
+ console.error('Error loading content types:', err);
253
+ }
254
+ };
255
+
256
+ fetchContentTypes();
257
+ }, [get]);
258
+
259
+ React.useLayoutEffect(() => {
260
+ ensureToolsDialogStyles();
261
+ }, []);
262
+
263
+ const dialogLayoutRef = React.useCallback((node: HTMLDivElement | null) => {
264
+ const markDialog = () => {
265
+ const dialogContent = node?.closest('[role="alertdialog"]');
266
+
267
+ if (dialogContent instanceof HTMLElement) {
268
+ dialogContent.setAttribute('data-dz-tools-dialog', 'true');
269
+ }
270
+ };
271
+
272
+ if (!node) {
273
+ return;
274
+ }
275
+
276
+ markDialog();
277
+ requestAnimationFrame(markDialog);
278
+ }, []);
279
+
280
+ React.useLayoutEffect(() => {
281
+ return () => {
282
+ const dom = globalThis.document;
283
+
284
+ if (!dom) {
285
+ return;
286
+ }
287
+
288
+ dom
289
+ .querySelectorAll('[data-dz-tools-dialog="true"]')
290
+ .forEach((element: Element) => {
291
+ element.removeAttribute('data-dz-tools-dialog');
292
+ });
293
+ };
294
+ }, []);
295
+
296
+ // Update source dynamic zones when content type is selected
297
+ useEffect(() => {
298
+ if (!selectedContentType) {
299
+ setSourceDynamicZones([]);
300
+ return;
301
+ }
302
+
303
+ const selectedType = contentTypes.find((ct) => ct.uid === selectedContentType);
304
+ if (!selectedType) return;
305
+
306
+ setSourceDynamicZones(selectedType.dynamicZones);
307
+ }, [selectedContentType, contentTypes]);
308
+
309
+ // Fetch records for the selected content type (scoped to the source locale
310
+ // for localized types, so only records existing in that locale are listed)
311
+ useEffect(() => {
312
+ const fetchRecords = async () => {
313
+ if (!selectedContentType || (sourceIsLocalized && !selectedLocale)) {
314
+ setRecords([]);
315
+ return;
316
+ }
317
+
318
+ try {
319
+ setRecordsLoading(true);
320
+ // No status filter: list all records; the version select decides
321
+ // whether the draft or published state gets copied.
322
+ const requestParams = new URLSearchParams({
323
+ page: '1',
324
+ pageSize: '500',
325
+ });
326
+ if (sourceIsLocalized) requestParams.set('locale', selectedLocale);
327
+ const response = await get(
328
+ `/content-manager/collection-types/${encodeURIComponent(
329
+ selectedContentType
330
+ )}?${requestParams.toString()}`
331
+ );
332
+
333
+ let recordsData = [];
334
+ if (response.data) {
335
+ if (Array.isArray(response.data)) {
336
+ recordsData = response.data;
337
+ } else if (response.data.results && Array.isArray(response.data.results)) {
338
+ recordsData = response.data.results;
339
+ } else if (response.data.data && Array.isArray(response.data.data)) {
340
+ recordsData = response.data.data;
341
+ }
342
+ }
343
+
344
+ setRecords(recordsData);
345
+ } catch (err) {
346
+ setError('Failed to load records');
347
+ console.error('Error loading records:', err);
348
+ } finally {
349
+ setRecordsLoading(false);
350
+ }
351
+ };
352
+
353
+ fetchRecords();
354
+ }, [selectedContentType, sourceIsLocalized, selectedLocale, get]);
355
+
356
+ // Configured locales, best-effort (i18n plugin may not be installed)
357
+ useEffect(() => {
358
+ const fetchLocales = async () => {
359
+ try {
360
+ const response = await get('/i18n/locales');
361
+ const locales = Array.isArray(response.data) ? response.data : (response.data?.data ?? []);
362
+ setConfiguredLocales(
363
+ locales
364
+ .filter((locale: any) => locale?.code)
365
+ .map((locale: any) => ({
366
+ code: locale.code,
367
+ name: locale.name || locale.code,
368
+ isDefault: Boolean(locale.isDefault),
369
+ }))
370
+ );
371
+ } catch {
372
+ // Codes/locale picker simply won't be shown
373
+ }
374
+ };
375
+
376
+ fetchLocales();
377
+ }, [get]);
378
+
379
+ // Default the source locale when a localized content type is selected:
380
+ // prefer the locale currently being edited, then Strapi's default locale.
381
+ useEffect(() => {
382
+ if (!selectedContentType || !sourceIsLocalized) {
383
+ setSelectedLocale('');
384
+ return;
385
+ }
386
+
387
+ const currentLocale = document?.locale ?? null;
388
+ const defaultLocale =
389
+ configuredLocales.find((locale) => locale.code === currentLocale)?.code ??
390
+ configuredLocales.find((locale) => locale.isDefault)?.code ??
391
+ configuredLocales[0]?.code ??
392
+ '';
393
+ setSelectedLocale(defaultLocale);
394
+ }, [selectedContentType, sourceIsLocalized, configuredLocales, document?.locale]);
395
+
396
+ // Component schemas (uid -> attributes), needed to transform copied data the
397
+ // same way the content manager does when it loads a document into the form.
398
+ const [componentSchemas, setComponentSchemas] = useState<Record<string, any> | null>(null);
399
+
400
+ const fetchComponentSchemas = async (): Promise<Record<string, any>> => {
401
+ if (componentSchemas) return componentSchemas;
402
+
403
+ const map = await loadComponentSchemas(get);
404
+ setComponentSchemas(map);
405
+ return map;
406
+ };
407
+
408
+ const getRecordLabel = (record: SourceRecord) => {
409
+ const rawLabel = record.title || record.name || `Document ${record.documentId || record.id}`;
410
+ const maxLength = 60;
411
+
412
+ return rawLabel.length > maxLength ? `${rawLabel.substring(0, maxLength)}...` : rawLabel;
413
+ };
414
+
415
+ const getLocaleLabel = (code: string) => {
416
+ const name = configuredLocales.find((locale) => locale.code === code)?.name;
417
+ if (!name) return code;
418
+ // Strapi locale names usually already include the code, e.g. "German (de)"
419
+ return name.includes(`(${code})`) ? name : `${name} (${code})`;
420
+ };
421
+
422
+ const getSelectString = (value: string | number) => String(value);
423
+
424
+ const canFetchSourcePreview =
425
+ Boolean(selectedContentType && selectedSourceZone && selectedRecord) &&
426
+ (!sourceIsLocalized || Boolean(selectedLocale));
427
+
428
+ const buildSourceDocumentQuery = () => {
429
+ const urlParams = new URLSearchParams(window.location.search);
430
+ const urlLocale = urlParams.get('plugins[i18n][locale]');
431
+ const locale = selectedLocale || urlLocale;
432
+ const requestParams = new URLSearchParams();
433
+
434
+ if (locale) requestParams.set('locale', locale);
435
+ if (sourceHasDraftAndPublish) requestParams.set('status', selectedVersion);
436
+
437
+ return requestParams.toString() ? `?${requestParams.toString()}` : '';
438
+ };
439
+
440
+ // Prefetch source zone blocks for preview and partial selection
441
+ useEffect(() => {
442
+ if (!canFetchSourcePreview) {
443
+ setSourceZoneEntries([]);
444
+ setSelectedBlockIndexes(new Set());
445
+ return;
446
+ }
447
+
448
+ let active = true;
449
+
450
+ const fetchSourcePreview = async () => {
451
+ try {
452
+ setSourcePreviewLoading(true);
453
+ setError('');
454
+
455
+ const localeQuery = buildSourceDocumentQuery();
456
+ const response = await get(
457
+ `/${PLUGIN_ID}/source-document/${encodeURIComponent(
458
+ selectedContentType
459
+ )}/${selectedRecord}${localeQuery}`
460
+ );
461
+ const sourceDocument = parsePayloadData(response.data ?? response);
462
+ const zoneData = sourceDocument?.[selectedSourceZone];
463
+
464
+ if (!active) return;
465
+
466
+ if (!Array.isArray(zoneData) || zoneData.length === 0) {
467
+ setSourceZoneEntries([]);
468
+ setSelectedBlockIndexes(new Set());
469
+ return;
470
+ }
471
+
472
+ setSourceZoneEntries(zoneData);
473
+ setSelectedBlockIndexes(new Set(zoneData.map((_, index) => index)));
474
+ } catch {
475
+ if (active) {
476
+ setSourceZoneEntries([]);
477
+ setSelectedBlockIndexes(new Set());
478
+ }
479
+ } finally {
480
+ if (active) {
481
+ setSourcePreviewLoading(false);
482
+ }
483
+ }
484
+ };
485
+
486
+ void fetchSourcePreview();
487
+
488
+ return () => {
489
+ active = false;
490
+ };
491
+ }, [
492
+ canFetchSourcePreview,
493
+ get,
494
+ selectedContentType,
495
+ selectedRecord,
496
+ selectedSourceZone,
497
+ selectedLocale,
498
+ selectedVersion,
499
+ sourceHasDraftAndPublish,
500
+ ]);
501
+
502
+ const selectAllBlocks = () => {
503
+ setSelectedBlockIndexes(new Set(sourceZoneEntries.map((_, index) => index)));
504
+ };
505
+
506
+ const clearAllBlocks = () => {
507
+ setSelectedBlockIndexes(new Set());
508
+ };
509
+
510
+ const handleConfirmFill = async () => {
511
+ if (!selectedTargetZone || !selectedContentType || !selectedSourceZone || !selectedRecord)
512
+ return;
513
+
514
+ if (selectedBlockIndexes.size === 0) {
515
+ setError('Select at least one block to copy');
516
+ return;
517
+ }
518
+
519
+ try {
520
+ setLoading(true);
521
+ setError('');
522
+
523
+ const schemas = await fetchComponentSchemas();
524
+ const sourceData =
525
+ sourceZoneEntries.length > 0
526
+ ? sourceZoneEntries
527
+ : (() => {
528
+ throw new Error('Source blocks are not loaded yet');
529
+ })();
530
+
531
+ const selectedEntries = sourceData.filter((_, index) => selectedBlockIndexes.has(index));
532
+
533
+ const allowedComponents: string[] | undefined =
534
+ schemaAttributes?.[selectedTargetZone]?.components;
535
+ const { allowed: allowedData, skippedCount } = filterAllowedDynamicZoneEntries(
536
+ selectedEntries,
537
+ allowedComponents
538
+ );
539
+
540
+ if (allowedData.length === 0) {
541
+ setError('None of the selected components are allowed in the target dynamic zone');
542
+ return;
543
+ }
544
+
545
+ const currentZone = Array.isArray(values?.[selectedTargetZone])
546
+ ? (values[selectedTargetZone] as any[])
547
+ : [];
548
+
549
+ const cleanedData =
550
+ fillMode === 'append'
551
+ ? prepareZoneDataForInsert(allowedData, schemas, {
552
+ previousKey: currentZone.at(-1)?.__temp_key__ ?? null,
553
+ nextKey: null,
554
+ })
555
+ : prepareZoneData(allowedData, schemas);
556
+
557
+ setValues({
558
+ ...values,
559
+ [selectedTargetZone]:
560
+ fillMode === 'append' ? [...currentZone, ...cleanedData] : cleanedData,
561
+ });
562
+
563
+ toggleNotification({
564
+ type: skippedCount > 0 ? 'warning' : 'success',
565
+ message:
566
+ skippedCount > 0
567
+ ? formatMessage(
568
+ {
569
+ id: `${PLUGIN_ID}.notification.partial`,
570
+ defaultMessage:
571
+ 'Content copied, but {count} component(s) were skipped because the target zone does not allow them.',
572
+ },
573
+ { count: skippedCount }
574
+ )
575
+ : formatMessage({
576
+ id:
577
+ fillMode === 'append'
578
+ ? `${PLUGIN_ID}.notification.append-success`
579
+ : `${PLUGIN_ID}.notification.success`,
580
+ defaultMessage:
581
+ fillMode === 'append'
582
+ ? 'Selected blocks appended successfully!'
583
+ : 'Dynamic zone content copied successfully!',
584
+ }),
585
+ });
586
+
587
+ setSelectedTargetZone('');
588
+ setSelectedContentType('');
589
+ setSelectedSourceZone('');
590
+ setSelectedRecord('');
591
+ setSelectedVersion('draft');
592
+ setFillMode('replace');
593
+ setSourceZoneEntries([]);
594
+ setSelectedBlockIndexes(new Set());
595
+
596
+ onClose();
597
+ } catch (err) {
598
+ const errorMessage = err instanceof Error ? err.message : 'Failed to fill in data';
599
+ setError(errorMessage);
600
+ toggleNotification({
601
+ type: 'warning',
602
+ message: errorMessage,
603
+ });
604
+ } finally {
605
+ setLoading(false);
606
+ }
607
+ };
608
+
609
+ const isFormValid =
610
+ selectedTargetZone &&
611
+ selectedContentType &&
612
+ selectedSourceZone &&
613
+ selectedRecord &&
614
+ (!sourceIsLocalized || selectedLocale) &&
615
+ selectedBlockIndexes.size > 0 &&
616
+ !sourcePreviewLoading;
617
+
618
+ return (
619
+ <Flex
620
+ ref={dialogLayoutRef}
621
+ data-dz-tools-shell="true"
622
+ direction="column"
623
+ alignItems="stretch"
624
+ width="100%"
625
+ >
626
+ <Dialog.Body data-dz-tools-body="true">
627
+ <Flex direction="column" gap={4} width="100%" alignItems="stretch">
628
+ <Flex
629
+ gap={3}
630
+ padding={4}
631
+ background={fillMode === 'replace' ? 'danger100' : 'primary100'}
632
+ hasRadius
633
+ alignItems="flex-start"
634
+ width="100%"
635
+ >
636
+ <Box style={{ flexShrink: 0, display: 'flex' }} paddingTop={1}>
637
+ <WarningCircle fill={fillMode === 'replace' ? 'danger600' : 'primary600'} />
638
+ </Box>
639
+ <Flex direction="column" gap={1} alignItems="flex-start">
640
+ <Typography
641
+ fontWeight="semiBold"
642
+ textColor={fillMode === 'replace' ? 'danger700' : 'primary700'}
643
+ >
644
+ {fillMode === 'replace'
645
+ ? formatMessage({
646
+ id: `${PLUGIN_ID}.modal.warning`,
647
+ defaultMessage:
648
+ 'This will replace the current content of the target dynamic zone.',
649
+ })
650
+ : formatMessage({
651
+ id: `${PLUGIN_ID}.modal.append-info`,
652
+ defaultMessage:
653
+ 'Selected blocks will be appended to the end of the target dynamic zone.',
654
+ })}
655
+ </Typography>
656
+ <Typography
657
+ variant="pi"
658
+ textColor={fillMode === 'replace' ? 'danger600' : 'primary600'}
659
+ >
660
+ {formatMessage({
661
+ id: `${PLUGIN_ID}.modal.warning.helper`,
662
+ defaultMessage:
663
+ 'Nothing is saved yet — review the result and save the document to keep it.',
664
+ })}
665
+ </Typography>
666
+ </Flex>
667
+ </Flex>
668
+
669
+ {error && (
670
+ <Alert closeLabel="Close" title="Error" variant="danger" onClose={() => setError('')}>
671
+ {error}
672
+ </Alert>
673
+ )}
674
+
675
+ {/* Fill mode */}
676
+ <Flex direction="column" gap={1} width="100%">
677
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
678
+ {formatMessage({
679
+ id: `${PLUGIN_ID}.fill-mode.label`,
680
+ defaultMessage: 'Copy mode',
681
+ })}
682
+ </Typography>
683
+ <SingleSelect
684
+ value={fillMode}
685
+ onChange={(value: string | number) => setFillMode(value === 'append' ? 'append' : 'replace')}
686
+ disabled={loading}
687
+ size="S"
688
+ >
689
+ <SingleSelectOption value="replace">
690
+ {formatMessage({
691
+ id: `${PLUGIN_ID}.fill-mode.replace`,
692
+ defaultMessage: 'Replace current zone',
693
+ })}
694
+ </SingleSelectOption>
695
+ <SingleSelectOption value="append">
696
+ {formatMessage({
697
+ id: `${PLUGIN_ID}.fill-mode.append`,
698
+ defaultMessage: 'Append to current zone',
699
+ })}
700
+ </SingleSelectOption>
701
+ </SingleSelect>
702
+ </Flex>
703
+
704
+ {/* Target Dynamic Zone Selection */}
705
+ <Flex direction="column" gap={1} width="100%">
706
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
707
+ {formatMessage({
708
+ id: `${PLUGIN_ID}.target-zone.label`,
709
+ defaultMessage: 'Target Dynamic Zone',
710
+ })}
711
+ </Typography>
712
+ <SingleSelect
713
+ placeholder={formatMessage({
714
+ id: `${PLUGIN_ID}.target-zone.placeholder`,
715
+ defaultMessage: 'Select zone to fill',
716
+ })}
717
+ value={selectedTargetZone}
718
+ onChange={(value: string | number) => {
719
+ setSelectedTargetZone(getSelectString(value));
720
+ setSelectedContentType('');
721
+ setSelectedSourceZone('');
722
+ setSelectedRecord('');
723
+ setSelectedVersion('draft');
724
+ }}
725
+ disabled={loading}
726
+ size="S"
727
+ >
728
+ {targetDynamicZones.map((zone) => (
729
+ <SingleSelectOption key={zone.fieldName} value={zone.fieldName}>
730
+ {zone.displayName}
731
+ </SingleSelectOption>
732
+ ))}
733
+ </SingleSelect>
734
+ </Flex>
735
+
736
+ {/* Source Collection Selection */}
737
+ <Flex direction="column" gap={1} width="100%">
738
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
739
+ {formatMessage({
740
+ id: `${PLUGIN_ID}.source-collection.label`,
741
+ defaultMessage: 'Source Collection',
742
+ })}
743
+ </Typography>
744
+ <SingleSelect
745
+ placeholder={formatMessage({
746
+ id: `${PLUGIN_ID}.source-collection.placeholder`,
747
+ defaultMessage: 'Select source collection',
748
+ })}
749
+ value={selectedContentType}
750
+ onChange={(value: string | number) => {
751
+ setSelectedContentType(getSelectString(value));
752
+ setSelectedSourceZone('');
753
+ setSelectedRecord('');
754
+ setSelectedVersion('draft');
755
+ }}
756
+ disabled={!selectedTargetZone || loading}
757
+ size="S"
758
+ >
759
+ {contentTypes.map((ct) => (
760
+ <SingleSelectOption key={ct.uid} value={ct.uid}>
761
+ {ct.displayName}
762
+ </SingleSelectOption>
763
+ ))}
764
+ </SingleSelect>
765
+ </Flex>
766
+
767
+ {/* Source Dynamic Zone Selection */}
768
+ <Flex direction="column" gap={1} width="100%">
769
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
770
+ {formatMessage({
771
+ id: `${PLUGIN_ID}.source-zone.label`,
772
+ defaultMessage: 'Source Dynamic Zone',
773
+ })}
774
+ </Typography>
775
+ <SingleSelect
776
+ placeholder={formatMessage({
777
+ id: `${PLUGIN_ID}.source-zone.placeholder`,
778
+ defaultMessage: 'Select zone to copy from',
779
+ })}
780
+ value={selectedSourceZone}
781
+ onChange={(value: string | number) => {
782
+ setSelectedSourceZone(getSelectString(value));
783
+ setSelectedRecord('');
784
+ setSelectedVersion('draft');
785
+ }}
786
+ disabled={!selectedContentType || loading}
787
+ size="S"
788
+ >
789
+ {sourceDynamicZones.map((zone) => (
790
+ <SingleSelectOption key={zone.fieldName} value={zone.fieldName}>
791
+ {zone.displayName}
792
+ </SingleSelectOption>
793
+ ))}
794
+ </SingleSelect>
795
+ </Flex>
796
+
797
+ {/* Source Locale Selection (localized content types only) */}
798
+ {sourceIsLocalized && (
799
+ <Flex direction="column" gap={1} width="100%">
800
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
801
+ {formatMessage({
802
+ id: `${PLUGIN_ID}.source-locale.label`,
803
+ defaultMessage: 'Source Locale',
804
+ })}
805
+ </Typography>
806
+ <SingleSelect
807
+ value={selectedLocale}
808
+ onChange={(value: string | number) => {
809
+ setSelectedLocale(getSelectString(value));
810
+ setSelectedRecord('');
811
+ setSelectedVersion('draft');
812
+ }}
813
+ disabled={!selectedSourceZone || loading}
814
+ size="S"
815
+ >
816
+ {configuredLocales.map((locale) => (
817
+ <SingleSelectOption key={locale.code} value={locale.code}>
818
+ {getLocaleLabel(locale.code)}
819
+ </SingleSelectOption>
820
+ ))}
821
+ </SingleSelect>
822
+ </Flex>
823
+ )}
824
+
825
+ {/* Source Record Selection */}
826
+ <Flex direction="column" gap={1} width="100%">
827
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
828
+ {formatMessage({
829
+ id: `${PLUGIN_ID}.source-record.label`,
830
+ defaultMessage: 'Source Record',
831
+ })}
832
+ </Typography>
833
+ <SingleSelect
834
+ placeholder={
835
+ recordsLoading
836
+ ? formatMessage({
837
+ id: `${PLUGIN_ID}.source-record.loading`,
838
+ defaultMessage: 'Loading records...',
839
+ })
840
+ : formatMessage({
841
+ id: `${PLUGIN_ID}.source-record.placeholder`,
842
+ defaultMessage: 'Select record to copy from',
843
+ })
844
+ }
845
+ value={selectedRecord}
846
+ onChange={(value: string | number) => {
847
+ setSelectedRecord(getSelectString(value));
848
+ setSelectedVersion('draft');
849
+ }}
850
+ disabled={
851
+ !selectedSourceZone ||
852
+ recordsLoading ||
853
+ loading ||
854
+ (sourceIsLocalized && !selectedLocale)
855
+ }
856
+ size="S"
857
+ >
858
+ {records.map((record) => (
859
+ <SingleSelectOption
860
+ key={record.documentId || record.id}
861
+ value={record.documentId || record.id.toString()}
862
+ >
863
+ {getRecordLabel(record)}
864
+ </SingleSelectOption>
865
+ ))}
866
+ </SingleSelect>
867
+ </Flex>
868
+
869
+ {/* Source Version Selection (draft & publish content types only) */}
870
+ {sourceHasDraftAndPublish && (
871
+ <Flex direction="column" gap={1} width="100%">
872
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
873
+ {formatMessage({
874
+ id: `${PLUGIN_ID}.source-version.label`,
875
+ defaultMessage: 'Source Version',
876
+ })}
877
+ </Typography>
878
+ <SingleSelect
879
+ value={selectedVersion}
880
+ onChange={(value: string | number) =>
881
+ setSelectedVersion(value === 'published' ? 'published' : 'draft')
882
+ }
883
+ disabled={!selectedRecord || loading}
884
+ size="S"
885
+ >
886
+ <SingleSelectOption value="draft">
887
+ {formatMessage({
888
+ id: `${PLUGIN_ID}.source-version.draft`,
889
+ defaultMessage: 'Draft',
890
+ })}
891
+ </SingleSelectOption>
892
+ <SingleSelectOption value="published" disabled={!selectedRecordHasPublished}>
893
+ {selectedRecord && !selectedRecordHasPublished
894
+ ? formatMessage({
895
+ id: `${PLUGIN_ID}.source-version.published-unavailable`,
896
+ defaultMessage: 'Published (not available)',
897
+ })
898
+ : formatMessage({
899
+ id: `${PLUGIN_ID}.source-version.published`,
900
+ defaultMessage: 'Published',
901
+ })}
902
+ </SingleSelectOption>
903
+ </SingleSelect>
904
+ </Flex>
905
+ )}
906
+
907
+ {/* Source blocks preview */}
908
+ {canFetchSourcePreview && (
909
+ <Flex direction="column" gap={2} width="100%">
910
+ <Flex justifyContent="space-between" alignItems="center" width="100%">
911
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
912
+ {formatMessage({
913
+ id: `${PLUGIN_ID}.source-blocks.label`,
914
+ defaultMessage: 'Blocks to copy',
915
+ })}
916
+ </Typography>
917
+ <Flex gap={2}>
918
+ <Button variant="tertiary" size="S" onClick={selectAllBlocks} disabled={loading}>
919
+ {formatMessage({
920
+ id: `${PLUGIN_ID}.source-blocks.select-all`,
921
+ defaultMessage: 'Select all',
922
+ })}
923
+ </Button>
924
+ <Button variant="tertiary" size="S" onClick={clearAllBlocks} disabled={loading}>
925
+ {formatMessage({
926
+ id: `${PLUGIN_ID}.source-blocks.clear-all`,
927
+ defaultMessage: 'Clear all',
928
+ })}
929
+ </Button>
930
+ </Flex>
931
+ </Flex>
932
+
933
+ {sourcePreviewLoading ? (
934
+ <Typography variant="pi" textColor="neutral600">
935
+ {formatMessage({
936
+ id: `${PLUGIN_ID}.source-blocks.loading`,
937
+ defaultMessage: 'Loading source blocks...',
938
+ })}
939
+ </Typography>
940
+ ) : sourceZoneEntries.length === 0 ? (
941
+ <Typography variant="pi" textColor="neutral600">
942
+ {formatMessage({
943
+ id: `${PLUGIN_ID}.source-blocks.empty`,
944
+ defaultMessage: 'No blocks found in the selected source zone.',
945
+ })}
946
+ </Typography>
947
+ ) : (
948
+ <Flex
949
+ direction="column"
950
+ gap={2}
951
+ width="100%"
952
+ padding={3}
953
+ background="neutral100"
954
+ hasRadius
955
+ >
956
+ {sourceZoneEntries.map((entry, index) => {
957
+ const componentUid = entry?.__component ?? '';
958
+ const label = getDynamicZoneEntryLabel(entry);
959
+ const componentName = getComponentDisplayName(componentUid);
960
+ const allowedComponents: string[] | undefined =
961
+ schemaAttributes?.[selectedTargetZone]?.components;
962
+ const isAllowed =
963
+ !Array.isArray(allowedComponents) ||
964
+ allowedComponents.includes(componentUid);
965
+
966
+ return (
967
+ <Checkbox
968
+ key={`${componentUid}-${index}`}
969
+ checked={selectedBlockIndexes.has(index)}
970
+ onCheckedChange={(checked: boolean | 'indeterminate') => {
971
+ setSelectedBlockIndexes((current) => {
972
+ const next = new Set(current);
973
+
974
+ if (checked === true) {
975
+ next.add(index);
976
+ } else {
977
+ next.delete(index);
978
+ }
979
+
980
+ return next;
981
+ });
982
+ }}
983
+ disabled={loading || !isAllowed}
984
+ >
985
+ {componentName}
986
+ {label ? ` · ${label}` : ''}
987
+ {!isAllowed
988
+ ? ` (${formatMessage({
989
+ id: `${PLUGIN_ID}.source-blocks.not-allowed`,
990
+ defaultMessage: 'not allowed in target zone',
991
+ })})`
992
+ : ''}
993
+ </Checkbox>
994
+ );
995
+ })}
996
+ </Flex>
997
+ )}
998
+ </Flex>
999
+ )}
1000
+ </Flex>
1001
+ </Dialog.Body>
1002
+
1003
+ <Dialog.Footer data-dz-tools-footer="true">
1004
+ <Button variant="tertiary" fullWidth onClick={onClose} disabled={loading}>
1005
+ {formatMessage({
1006
+ id: `${PLUGIN_ID}.modal.cancel`,
1007
+ defaultMessage: 'No, cancel',
1008
+ })}
1009
+ </Button>
1010
+ <Button
1011
+ variant="default"
1012
+ fullWidth
1013
+ onClick={handleConfirmFill}
1014
+ loading={loading}
1015
+ disabled={!isFormValid}
1016
+ >
1017
+ {formatMessage({
1018
+ id: `${PLUGIN_ID}.modal.confirm`,
1019
+ defaultMessage: 'Yes, fill in',
1020
+ })}
1021
+ </Button>
1022
+ </Dialog.Footer>
1023
+ </Flex>
1024
+ );
1025
+ };
1026
+
1027
+ export { DynamicZoneToolsHeaderAction };