strapi-dz-component-duplicator 0.1.2 → 0.1.3

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.
@@ -1,10 +1,22 @@
1
1
  import React from 'react';
2
- import { useNotification } from '@strapi/admin/strapi-admin';
3
- import { unstable_useContentManagerContext as useContentManagerContext } from '@strapi/content-manager/strapi-admin';
2
+ import { useFetchClient, useNotification, useQueryParams } from '@strapi/admin/strapi-admin';
3
+ import {
4
+ buildValidParams,
5
+ unstable_useContentManagerContext as useContentManagerContext,
6
+ } from '@strapi/content-manager/strapi-admin';
4
7
  import { useIntl } from 'react-intl';
5
8
 
6
9
  const DUPLICATE_CONTAINER_ATTR = 'data-dz-component-duplicator-action';
7
10
  const INDEX_SEGMENT_REGEX = /^\d+$/;
11
+ const COLLECTION_TYPES = 'collection-types';
12
+ const SINGLE_TYPES = 'single-types';
13
+ const ONE_WAY_RELATIONS = new Set([
14
+ 'oneWay',
15
+ 'oneToOne',
16
+ 'manyToOne',
17
+ 'oneToManyMorph',
18
+ 'oneToOneMorph',
19
+ ]);
8
20
  const DUPLICATE_ICON_PATH =
9
21
  'M27 4H11a1 1 0 0 0-1 1v5H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-5h5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1m-1 16h-4v-9a1 1 0 0 0-1-1h-9V6h14z';
10
22
 
@@ -44,6 +56,10 @@ const isDynamicZoneItem = (value) => {
44
56
  return isPlainObject(value) && typeof value.__component === 'string';
45
57
  };
46
58
 
59
+ const isMediaObject = (value) => {
60
+ return isPlainObject(value) && typeof value.mime === 'string';
61
+ };
62
+
47
63
  const collectDynamicZonePaths = (value, currentPath = '', acc = []) => {
48
64
  if (Array.isArray(value)) {
49
65
  if (currentPath && value.length > 0 && value.every(isDynamicZoneItem)) {
@@ -78,36 +94,14 @@ const cloneValue = (value) => {
78
94
  return JSON.parse(JSON.stringify(value));
79
95
  };
80
96
 
81
- const isMediaObject = (value) => {
82
- return isPlainObject(value) && typeof value.mime === 'string';
83
- };
84
-
85
- const stripTransientKeys = (value) => {
86
- if (Array.isArray(value)) {
87
- return value.map(stripTransientKeys);
88
- }
89
-
90
- if (!isPlainObject(value)) {
91
- return value;
92
- }
93
-
94
- // Media references must keep their id/documentId so Strapi can
95
- // maintain the relation on save.
96
- if (isMediaObject(value)) {
97
- return value;
98
- }
99
-
100
- const next = {};
101
-
102
- for (const [key, nested] of Object.entries(value)) {
103
- if (key === 'id' || key === 'documentId' || key === '__temp_key__') {
104
- continue;
105
- }
106
-
107
- next[key] = stripTransientKeys(nested);
108
- }
97
+ const createTempKeyFactory = () => {
98
+ let counter = 0;
99
+ const seed =
100
+ typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
101
+ ? crypto.randomUUID()
102
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
109
103
 
110
- return next;
104
+ return () => `${String(counter++).padStart(4, '0')}-${seed}`;
111
105
  };
112
106
 
113
107
  const getActionAnchor = (listItem) => {
@@ -117,10 +111,6 @@ const getActionAnchor = (listItem) => {
117
111
  return null;
118
112
  }
119
113
 
120
- // Dynamic zone headers include a drag handle, a delete button, and a
121
- // more-actions menu. We look for the drag button by its aria-label and
122
- // fall back to verifying that at least three buttons exist (the minimum
123
- // for a dynamic zone header as of Strapi 5).
124
114
  const dragButton =
125
115
  header.querySelector('button[aria-label="Drag"]') ||
126
116
  header.querySelector('button[aria-label="drag"]');
@@ -258,36 +248,426 @@ const createDuplicateButton = (anchor, label, onClick, signal) => {
258
248
 
259
249
  const textTemplate = anchor.querySelector('span');
260
250
  const text = document.createElement('span');
251
+
261
252
  if (textTemplate?.className) {
262
253
  text.className = textTemplate.className;
263
254
  }
264
255
  text.textContent = label;
265
256
 
266
257
  button.append(icon, text);
267
- button.addEventListener('click', (event) => {
268
- event.preventDefault();
269
- event.stopPropagation();
270
- onClick();
271
- }, { signal });
258
+ button.addEventListener(
259
+ 'click',
260
+ (event) => {
261
+ event.preventDefault();
262
+ event.stopPropagation();
263
+ void onClick();
264
+ },
265
+ { signal }
266
+ );
272
267
 
273
268
  return button;
274
269
  };
275
270
 
271
+ const getRelationIdentity = (relation) => {
272
+ if (!isPlainObject(relation)) {
273
+ return null;
274
+ }
275
+
276
+ const documentId = relation.documentId ?? relation.apiData?.documentId;
277
+ const locale = relation.locale ?? relation.apiData?.locale ?? '';
278
+ const id = relation.id ?? relation.apiData?.id;
279
+
280
+ if (documentId) {
281
+ return `${documentId}::${locale}`;
282
+ }
283
+
284
+ if (id !== null && id !== undefined) {
285
+ return `id:${id}`;
286
+ }
287
+
288
+ return null;
289
+ };
290
+
291
+ const getRelationDisplayValue = (relation) => {
292
+ const candidateKeys = [
293
+ 'label',
294
+ 'title',
295
+ 'name',
296
+ 'displayName',
297
+ 'question',
298
+ 'heading',
299
+ 'slug',
300
+ 'documentId',
301
+ 'id',
302
+ ];
303
+
304
+ for (const key of candidateKeys) {
305
+ const value = relation?.[key];
306
+
307
+ if (typeof value === 'string' && value.trim()) {
308
+ return value;
309
+ }
310
+
311
+ if (typeof value === 'number') {
312
+ return String(value);
313
+ }
314
+ }
315
+
316
+ return '';
317
+ };
318
+
319
+ const getRelationCollectionType = (targetModel, contentTypes) => {
320
+ const targetSchema = Array.isArray(contentTypes)
321
+ ? contentTypes.find((schema) => schema?.uid === targetModel)
322
+ : null;
323
+
324
+ return targetSchema?.kind === 'singleType' ? SINGLE_TYPES : COLLECTION_TYPES;
325
+ };
326
+
327
+ const getRelationHref = (targetModel, contentTypes, documentId, locale) => {
328
+ if (!targetModel || !documentId) {
329
+ return undefined;
330
+ }
331
+
332
+ const collectionType = getRelationCollectionType(targetModel, contentTypes);
333
+ const basePath =
334
+ collectionType === SINGLE_TYPES
335
+ ? `../${SINGLE_TYPES}/${targetModel}`
336
+ : `../${COLLECTION_TYPES}/${targetModel}/${documentId}`;
337
+
338
+ return locale ? `${basePath}?plugins[i18n][locale]=${locale}` : basePath;
339
+ };
340
+
341
+ const normalizeFetchedRelations = (value) => {
342
+ if (Array.isArray(value)) {
343
+ return value.filter(isPlainObject);
344
+ }
345
+
346
+ if (isPlainObject(value) && Array.isArray(value.results)) {
347
+ return value.results.filter(isPlainObject);
348
+ }
349
+
350
+ if (isPlainObject(value)) {
351
+ return [value];
352
+ }
353
+
354
+ return [];
355
+ };
356
+
357
+ const toRelationConnectEntry = (relation, attribute, contentTypes, createTempKey) => {
358
+ if (!isPlainObject(relation)) {
359
+ return null;
360
+ }
361
+
362
+ const id = relation.id ?? relation.apiData?.id;
363
+ const documentId = relation.documentId ?? relation.apiData?.documentId;
364
+ const locale = relation.locale ?? relation.apiData?.locale ?? null;
365
+ const label = relation.label ?? getRelationDisplayValue(relation);
366
+ const href = relation.href ?? getRelationHref(attribute.target, contentTypes, documentId, locale);
367
+ const next = {
368
+ id,
369
+ documentId,
370
+ locale,
371
+ href,
372
+ label: label || documentId || (id !== undefined && id !== null ? String(id) : ''),
373
+ __temp_key__: createTempKey(),
374
+ apiData: {
375
+ id,
376
+ documentId: documentId ?? '',
377
+ locale,
378
+ isTemporary: relation.apiData?.isTemporary ?? true,
379
+ },
380
+ };
381
+
382
+ if (relation.status !== undefined) {
383
+ next.status = relation.status;
384
+ } else if (typeof relation.publishedAt === 'string') {
385
+ next.status = 'published';
386
+ }
387
+
388
+ return next;
389
+ };
390
+
391
+ const sanitizeRelationValue = async ({
392
+ attribute,
393
+ value,
394
+ sourceContext,
395
+ fieldName,
396
+ contentTypes,
397
+ createTempKey,
398
+ fetchRelationItems,
399
+ }) => {
400
+ const currentConnect = Array.isArray(value?.connect) ? value.connect : [];
401
+ const currentDisconnect = Array.isArray(value?.disconnect) ? value.disconnect : [];
402
+ const fetchedRelations =
403
+ sourceContext?.id && sourceContext?.model
404
+ ? await fetchRelationItems(sourceContext.model, sourceContext.id, fieldName)
405
+ : [];
406
+
407
+ const connectedByIdentity = new Map();
408
+
409
+ for (const relation of currentConnect) {
410
+ const entry = toRelationConnectEntry(relation, attribute, contentTypes, createTempKey);
411
+ const identity = getRelationIdentity(entry);
412
+
413
+ if (entry && identity) {
414
+ connectedByIdentity.set(identity, entry);
415
+ }
416
+ }
417
+
418
+ const disconnectedIdentities = new Set(
419
+ currentDisconnect.map(getRelationIdentity).filter(Boolean)
420
+ );
421
+
422
+ const effectiveRelations = [];
423
+
424
+ for (const relation of fetchedRelations) {
425
+ const entry = toRelationConnectEntry(relation, attribute, contentTypes, createTempKey);
426
+ const identity = getRelationIdentity(entry);
427
+
428
+ if (!entry || !identity || disconnectedIdentities.has(identity)) {
429
+ continue;
430
+ }
431
+
432
+ if (connectedByIdentity.has(identity)) {
433
+ effectiveRelations.push(connectedByIdentity.get(identity));
434
+ connectedByIdentity.delete(identity);
435
+ continue;
436
+ }
437
+
438
+ effectiveRelations.push(entry);
439
+ }
440
+
441
+ for (const relation of connectedByIdentity.values()) {
442
+ effectiveRelations.push(relation);
443
+ }
444
+
445
+ const connect = ONE_WAY_RELATIONS.has(attribute.relation)
446
+ ? effectiveRelations.slice(-1)
447
+ : effectiveRelations;
448
+
449
+ return {
450
+ connect,
451
+ disconnect: [],
452
+ };
453
+ };
454
+
455
+ const sanitizeComponentValue = async ({
456
+ value,
457
+ componentUid,
458
+ components,
459
+ contentTypes,
460
+ createTempKey,
461
+ fetchRelationItems,
462
+ sourceContext,
463
+ }) => {
464
+ if (!isPlainObject(value)) {
465
+ return value;
466
+ }
467
+
468
+ const attributes = components?.[componentUid]?.attributes ?? {};
469
+ const next = {};
470
+
471
+ for (const [fieldName, attribute] of Object.entries(attributes)) {
472
+ if (!(fieldName in value)) {
473
+ continue;
474
+ }
475
+
476
+ next[fieldName] = await sanitizeAttributeValue({
477
+ attribute,
478
+ fieldName,
479
+ value: value[fieldName],
480
+ components,
481
+ contentTypes,
482
+ createTempKey,
483
+ fetchRelationItems,
484
+ sourceContext: {
485
+ model: componentUid,
486
+ id: sourceContext?.id ?? value?.id,
487
+ },
488
+ });
489
+ }
490
+
491
+ return next;
492
+ };
493
+
494
+ const sanitizeDynamicZoneValue = async ({
495
+ value,
496
+ components,
497
+ contentTypes,
498
+ createTempKey,
499
+ fetchRelationItems,
500
+ }) => {
501
+ if (!isDynamicZoneItem(value)) {
502
+ return value;
503
+ }
504
+
505
+ const componentUid = value.__component;
506
+ const next = await sanitizeComponentValue({
507
+ value,
508
+ componentUid,
509
+ components,
510
+ contentTypes,
511
+ createTempKey,
512
+ fetchRelationItems,
513
+ sourceContext: {
514
+ model: componentUid,
515
+ id: value?.id,
516
+ },
517
+ });
518
+
519
+ return {
520
+ __component: componentUid,
521
+ ...next,
522
+ };
523
+ };
524
+
525
+ const sanitizeAttributeValue = async ({
526
+ attribute,
527
+ fieldName,
528
+ value,
529
+ components,
530
+ contentTypes,
531
+ createTempKey,
532
+ fetchRelationItems,
533
+ sourceContext,
534
+ }) => {
535
+ if (value === null || value === undefined) {
536
+ return value;
537
+ }
538
+
539
+ if (attribute.type === 'component') {
540
+ if (attribute.repeatable) {
541
+ if (!Array.isArray(value)) {
542
+ return [];
543
+ }
544
+
545
+ const next = await Promise.all(
546
+ value.map((item) =>
547
+ sanitizeComponentValue({
548
+ value: item,
549
+ componentUid: attribute.component,
550
+ components,
551
+ contentTypes,
552
+ createTempKey,
553
+ fetchRelationItems,
554
+ sourceContext: {
555
+ model: attribute.component,
556
+ id: item?.id,
557
+ },
558
+ })
559
+ )
560
+ );
561
+
562
+ return next.map((item) =>
563
+ isPlainObject(item)
564
+ ? {
565
+ ...item,
566
+ __temp_key__: createTempKey(),
567
+ }
568
+ : item
569
+ );
570
+ }
571
+
572
+ return sanitizeComponentValue({
573
+ value,
574
+ componentUid: attribute.component,
575
+ components,
576
+ contentTypes,
577
+ createTempKey,
578
+ fetchRelationItems,
579
+ sourceContext: {
580
+ model: attribute.component,
581
+ id: value?.id,
582
+ },
583
+ });
584
+ }
585
+
586
+ if (attribute.type === 'dynamiczone') {
587
+ if (!Array.isArray(value)) {
588
+ return [];
589
+ }
590
+
591
+ const next = await Promise.all(
592
+ value.map((item) =>
593
+ sanitizeDynamicZoneValue({
594
+ value: item,
595
+ components,
596
+ contentTypes,
597
+ createTempKey,
598
+ fetchRelationItems,
599
+ })
600
+ )
601
+ );
602
+
603
+ return next.map((item) =>
604
+ isPlainObject(item)
605
+ ? {
606
+ ...item,
607
+ __temp_key__: createTempKey(),
608
+ }
609
+ : item
610
+ );
611
+ }
612
+
613
+ if (attribute.type === 'relation') {
614
+ return sanitizeRelationValue({
615
+ attribute,
616
+ value,
617
+ sourceContext,
618
+ fieldName,
619
+ contentTypes,
620
+ createTempKey,
621
+ fetchRelationItems,
622
+ });
623
+ }
624
+
625
+ if (attribute.type === 'media') {
626
+ return cloneValue(value);
627
+ }
628
+
629
+ if (Array.isArray(value)) {
630
+ return value.map((item) => cloneValue(item));
631
+ }
632
+
633
+ if (isPlainObject(value)) {
634
+ if (isMediaObject(value)) {
635
+ return cloneValue(value);
636
+ }
637
+
638
+ return cloneValue(value);
639
+ }
640
+
641
+ return value;
642
+ };
643
+
276
644
  const DynamicZoneActionInjector = () => {
277
645
  const { formatMessage } = useIntl();
278
646
  const { toggleNotification } = useNotification();
279
- const { form, isLoading, components } = useContentManagerContext();
647
+ const { get } = useFetchClient();
648
+ const [{ query }] = useQueryParams();
649
+ const { form, isLoading, components, contentTypes, model, collectionType, id } =
650
+ useContentManagerContext();
280
651
 
281
652
  const isBrowser = typeof document !== 'undefined';
282
-
283
653
  const values = form?.values;
284
654
  const valuesRef = React.useRef(values);
285
655
  const observerRef = React.useRef(null);
286
656
  const frameRef = React.useRef(0);
287
657
  const abortRef = React.useRef(null);
658
+ const relationCacheRef = React.useRef(new Map());
288
659
 
289
660
  valuesRef.current = values;
290
661
 
662
+ const relationQueryParams = React.useMemo(() => {
663
+ const params = buildValidParams(query ?? {});
664
+
665
+ return {
666
+ locale: params?.locale,
667
+ status: params?.status,
668
+ };
669
+ }, [query]);
670
+
291
671
  const duplicateLabel = formatMessage({
292
672
  id: 'strapi-dz-component-duplicator.action.duplicate',
293
673
  defaultMessage: 'Duplicate component',
@@ -298,6 +678,71 @@ const DynamicZoneActionInjector = () => {
298
678
  defaultMessage: 'Could not duplicate this component.',
299
679
  });
300
680
 
681
+ const fetchRelationItems = React.useCallback(
682
+ async (relationModel, relationId, fieldName) => {
683
+ if (!relationModel || !relationId || !fieldName) {
684
+ return [];
685
+ }
686
+
687
+ const cacheKey = JSON.stringify({
688
+ relationModel,
689
+ relationId,
690
+ fieldName,
691
+ relationQueryParams,
692
+ });
693
+
694
+ const cachedPromise = relationCacheRef.current.get(cacheKey);
695
+
696
+ if (cachedPromise) {
697
+ return cachedPromise;
698
+ }
699
+
700
+ const request = (async () => {
701
+ let page = 1;
702
+ let totalPages = 1;
703
+ const relations = [];
704
+
705
+ while (page <= totalPages) {
706
+ const response = await get(
707
+ `/content-manager/relations/${relationModel}/${relationId}/${fieldName}`,
708
+ {
709
+ params: {
710
+ ...relationQueryParams,
711
+ page,
712
+ pageSize: 100,
713
+ },
714
+ }
715
+ );
716
+ const payload = response?.data ?? {};
717
+ const pageResults = normalizeFetchedRelations(payload.results).reverse();
718
+
719
+ relations.push(...pageResults);
720
+
721
+ const pagination = payload?.pagination;
722
+ const pageCount =
723
+ typeof pagination?.pageCount === 'number'
724
+ ? pagination.pageCount
725
+ : Math.max(1, Math.ceil((pagination?.total ?? pageResults.length) / 100));
726
+
727
+ totalPages = pageCount;
728
+ page += 1;
729
+ }
730
+
731
+ return relations;
732
+ })();
733
+
734
+ relationCacheRef.current.set(cacheKey, request);
735
+
736
+ try {
737
+ return await request;
738
+ } catch (error) {
739
+ relationCacheRef.current.delete(cacheKey);
740
+ throw error;
741
+ }
742
+ },
743
+ [get, relationQueryParams]
744
+ );
745
+
301
746
  const cleanupInjectedButtons = React.useCallback(() => {
302
747
  if (abortRef.current) {
303
748
  abortRef.current.abort();
@@ -311,7 +756,7 @@ const DynamicZoneActionInjector = () => {
311
756
  }, []);
312
757
 
313
758
  const handleDuplicate = React.useCallback(
314
- (dynamicZonePath, index) => {
759
+ async (dynamicZonePath, index) => {
315
760
  if (!form || typeof form.addFieldRow !== 'function') {
316
761
  return;
317
762
  }
@@ -323,7 +768,20 @@ const DynamicZoneActionInjector = () => {
323
768
  }
324
769
 
325
770
  try {
326
- const cloned = stripTransientKeys(cloneValue(item));
771
+ relationCacheRef.current.clear();
772
+ const createTempKey = createTempKeyFactory();
773
+ const cloned = await sanitizeDynamicZoneValue({
774
+ value: item,
775
+ components,
776
+ contentTypes,
777
+ createTempKey,
778
+ fetchRelationItems,
779
+ });
780
+
781
+ if (!isDynamicZoneItem(cloned)) {
782
+ throw new Error('Invalid dynamic zone clone');
783
+ }
784
+
327
785
  form.addFieldRow(dynamicZonePath, cloned, index + 1);
328
786
  } catch {
329
787
  toggleNotification({
@@ -332,7 +790,14 @@ const DynamicZoneActionInjector = () => {
332
790
  });
333
791
  }
334
792
  },
335
- [form, toggleNotification, duplicateErrorLabel]
793
+ [
794
+ components,
795
+ contentTypes,
796
+ duplicateErrorLabel,
797
+ fetchRelationItems,
798
+ form,
799
+ toggleNotification,
800
+ ]
336
801
  );
337
802
 
338
803
  const injectButtons = React.useCallback(() => {
@@ -379,13 +844,13 @@ const DynamicZoneActionInjector = () => {
379
844
  continue;
380
845
  }
381
846
 
382
- const duplicateButton = createDuplicateButton(anchor, duplicateLabel, () => {
383
- handleDuplicate(location.dynamicZonePath, location.index);
384
- }, controller.signal);
385
- anchor.parentElement.insertBefore(
386
- duplicateButton,
387
- anchor
847
+ const duplicateButton = createDuplicateButton(
848
+ anchor,
849
+ duplicateLabel,
850
+ () => handleDuplicate(location.dynamicZonePath, location.index),
851
+ controller.signal
388
852
  );
853
+ anchor.parentElement.insertBefore(duplicateButton, anchor);
389
854
  }
390
855
 
391
856
  if (observer) {
@@ -396,6 +861,10 @@ const DynamicZoneActionInjector = () => {
396
861
  }
397
862
  }, [cleanupInjectedButtons, components, duplicateLabel, handleDuplicate, isBrowser, isLoading]);
398
863
 
864
+ React.useEffect(() => {
865
+ relationCacheRef.current.clear();
866
+ }, [id, model, collectionType, relationQueryParams]);
867
+
399
868
  React.useEffect(() => {
400
869
  if (!isBrowser) {
401
870
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-dz-component-duplicator",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Duplicate dynamic zone components directly from Strapi 5 edit view.",
5
5
  "author": "Djordje Savanovic",
6
6
  "keywords": [