strapi-dz-component-duplicator 0.1.2 → 0.1.4

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,424 @@ 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
+ locale,
377
+ },
378
+ };
379
+
380
+ if (relation.status !== undefined) {
381
+ next.status = relation.status;
382
+ } else if (typeof relation.publishedAt === 'string') {
383
+ next.status = 'published';
384
+ }
385
+
386
+ return next;
387
+ };
388
+
389
+ const sanitizeRelationValue = async ({
390
+ attribute,
391
+ value,
392
+ sourceContext,
393
+ fieldName,
394
+ contentTypes,
395
+ createTempKey,
396
+ fetchRelationItems,
397
+ }) => {
398
+ const currentConnect = Array.isArray(value?.connect) ? value.connect : [];
399
+ const currentDisconnect = Array.isArray(value?.disconnect) ? value.disconnect : [];
400
+ const fetchedRelations =
401
+ sourceContext?.id && sourceContext?.model
402
+ ? await fetchRelationItems(sourceContext.model, sourceContext.id, fieldName)
403
+ : [];
404
+
405
+ const connectedByIdentity = new Map();
406
+
407
+ for (const relation of currentConnect) {
408
+ const entry = toRelationConnectEntry(relation, attribute, contentTypes, createTempKey);
409
+ const identity = getRelationIdentity(entry);
410
+
411
+ if (entry && identity) {
412
+ connectedByIdentity.set(identity, entry);
413
+ }
414
+ }
415
+
416
+ const disconnectedIdentities = new Set(
417
+ currentDisconnect.map(getRelationIdentity).filter(Boolean)
418
+ );
419
+
420
+ const effectiveRelations = [];
421
+
422
+ for (const relation of fetchedRelations) {
423
+ const entry = toRelationConnectEntry(relation, attribute, contentTypes, createTempKey);
424
+ const identity = getRelationIdentity(entry);
425
+
426
+ if (!entry || !identity || disconnectedIdentities.has(identity)) {
427
+ continue;
428
+ }
429
+
430
+ if (connectedByIdentity.has(identity)) {
431
+ effectiveRelations.push(connectedByIdentity.get(identity));
432
+ connectedByIdentity.delete(identity);
433
+ continue;
434
+ }
435
+
436
+ effectiveRelations.push(entry);
437
+ }
438
+
439
+ for (const relation of connectedByIdentity.values()) {
440
+ effectiveRelations.push(relation);
441
+ }
442
+
443
+ const connect = ONE_WAY_RELATIONS.has(attribute.relation)
444
+ ? effectiveRelations.slice(-1)
445
+ : effectiveRelations;
446
+
447
+ return {
448
+ connect,
449
+ disconnect: [],
450
+ };
451
+ };
452
+
453
+ const sanitizeComponentValue = async ({
454
+ value,
455
+ componentUid,
456
+ components,
457
+ contentTypes,
458
+ createTempKey,
459
+ fetchRelationItems,
460
+ sourceContext,
461
+ }) => {
462
+ if (!isPlainObject(value)) {
463
+ return value;
464
+ }
465
+
466
+ const attributes = components?.[componentUid]?.attributes ?? {};
467
+ const next = {};
468
+
469
+ for (const [fieldName, attribute] of Object.entries(attributes)) {
470
+ if (fieldName === 'id' || !(fieldName in value)) {
471
+ continue;
472
+ }
473
+
474
+ next[fieldName] = await sanitizeAttributeValue({
475
+ attribute,
476
+ fieldName,
477
+ value: value[fieldName],
478
+ components,
479
+ contentTypes,
480
+ createTempKey,
481
+ fetchRelationItems,
482
+ sourceContext: {
483
+ model: componentUid,
484
+ id: sourceContext?.id ?? value?.id,
485
+ },
486
+ });
487
+ }
488
+
489
+ return next;
490
+ };
491
+
492
+ const sanitizeDynamicZoneValue = async ({
493
+ value,
494
+ components,
495
+ contentTypes,
496
+ createTempKey,
497
+ fetchRelationItems,
498
+ }) => {
499
+ if (!isDynamicZoneItem(value)) {
500
+ return value;
501
+ }
502
+
503
+ const componentUid = value.__component;
504
+ const next = await sanitizeComponentValue({
505
+ value,
506
+ componentUid,
507
+ components,
508
+ contentTypes,
509
+ createTempKey,
510
+ fetchRelationItems,
511
+ sourceContext: {
512
+ model: componentUid,
513
+ id: value?.id,
514
+ },
515
+ });
516
+
517
+ return {
518
+ __component: componentUid,
519
+ ...next,
520
+ };
521
+ };
522
+
523
+ const sanitizeAttributeValue = async ({
524
+ attribute,
525
+ fieldName,
526
+ value,
527
+ components,
528
+ contentTypes,
529
+ createTempKey,
530
+ fetchRelationItems,
531
+ sourceContext,
532
+ }) => {
533
+ if (value === null || value === undefined) {
534
+ return value;
535
+ }
536
+
537
+ if (attribute.type === 'component') {
538
+ if (attribute.repeatable) {
539
+ if (!Array.isArray(value)) {
540
+ return [];
541
+ }
542
+
543
+ const next = await Promise.all(
544
+ value.map((item) =>
545
+ sanitizeComponentValue({
546
+ value: item,
547
+ componentUid: attribute.component,
548
+ components,
549
+ contentTypes,
550
+ createTempKey,
551
+ fetchRelationItems,
552
+ sourceContext: {
553
+ model: attribute.component,
554
+ id: item?.id,
555
+ },
556
+ })
557
+ )
558
+ );
559
+
560
+ return next.map((item) =>
561
+ isPlainObject(item)
562
+ ? {
563
+ ...item,
564
+ __temp_key__: createTempKey(),
565
+ }
566
+ : item
567
+ );
568
+ }
569
+
570
+ return sanitizeComponentValue({
571
+ value,
572
+ componentUid: attribute.component,
573
+ components,
574
+ contentTypes,
575
+ createTempKey,
576
+ fetchRelationItems,
577
+ sourceContext: {
578
+ model: attribute.component,
579
+ id: value?.id,
580
+ },
581
+ });
582
+ }
583
+
584
+ if (attribute.type === 'dynamiczone') {
585
+ if (!Array.isArray(value)) {
586
+ return [];
587
+ }
588
+
589
+ const next = await Promise.all(
590
+ value.map((item) =>
591
+ sanitizeDynamicZoneValue({
592
+ value: item,
593
+ components,
594
+ contentTypes,
595
+ createTempKey,
596
+ fetchRelationItems,
597
+ })
598
+ )
599
+ );
600
+
601
+ return next.map((item) =>
602
+ isPlainObject(item)
603
+ ? {
604
+ ...item,
605
+ __temp_key__: createTempKey(),
606
+ }
607
+ : item
608
+ );
609
+ }
610
+
611
+ if (attribute.type === 'relation') {
612
+ return sanitizeRelationValue({
613
+ attribute,
614
+ value,
615
+ sourceContext,
616
+ fieldName,
617
+ contentTypes,
618
+ createTempKey,
619
+ fetchRelationItems,
620
+ });
621
+ }
622
+
623
+ if (attribute.type === 'media') {
624
+ return cloneValue(value);
625
+ }
626
+
627
+ if (Array.isArray(value)) {
628
+ return value.map((item) => cloneValue(item));
629
+ }
630
+
631
+ if (isPlainObject(value)) {
632
+ if (isMediaObject(value)) {
633
+ return cloneValue(value);
634
+ }
635
+
636
+ return cloneValue(value);
637
+ }
638
+
639
+ return value;
640
+ };
641
+
276
642
  const DynamicZoneActionInjector = () => {
277
643
  const { formatMessage } = useIntl();
278
644
  const { toggleNotification } = useNotification();
279
- const { form, isLoading, components } = useContentManagerContext();
645
+ const { get } = useFetchClient();
646
+ const [{ query }] = useQueryParams();
647
+ const { form, isLoading, components, contentTypes, model, collectionType, id } =
648
+ useContentManagerContext();
280
649
 
281
650
  const isBrowser = typeof document !== 'undefined';
282
-
283
651
  const values = form?.values;
284
652
  const valuesRef = React.useRef(values);
285
653
  const observerRef = React.useRef(null);
286
654
  const frameRef = React.useRef(0);
287
655
  const abortRef = React.useRef(null);
656
+ const relationCacheRef = React.useRef(new Map());
288
657
 
289
658
  valuesRef.current = values;
290
659
 
660
+ const relationQueryParams = React.useMemo(() => {
661
+ const params = buildValidParams(query ?? {});
662
+
663
+ return {
664
+ locale: params?.locale,
665
+ status: params?.status,
666
+ };
667
+ }, [query]);
668
+
291
669
  const duplicateLabel = formatMessage({
292
670
  id: 'strapi-dz-component-duplicator.action.duplicate',
293
671
  defaultMessage: 'Duplicate component',
@@ -298,6 +676,71 @@ const DynamicZoneActionInjector = () => {
298
676
  defaultMessage: 'Could not duplicate this component.',
299
677
  });
300
678
 
679
+ const fetchRelationItems = React.useCallback(
680
+ async (relationModel, relationId, fieldName) => {
681
+ if (!relationModel || !relationId || !fieldName) {
682
+ return [];
683
+ }
684
+
685
+ const cacheKey = JSON.stringify({
686
+ relationModel,
687
+ relationId,
688
+ fieldName,
689
+ relationQueryParams,
690
+ });
691
+
692
+ const cachedPromise = relationCacheRef.current.get(cacheKey);
693
+
694
+ if (cachedPromise) {
695
+ return cachedPromise;
696
+ }
697
+
698
+ const request = (async () => {
699
+ let page = 1;
700
+ let totalPages = 1;
701
+ const relations = [];
702
+
703
+ while (page <= totalPages) {
704
+ const response = await get(
705
+ `/content-manager/relations/${relationModel}/${relationId}/${fieldName}`,
706
+ {
707
+ params: {
708
+ ...relationQueryParams,
709
+ page,
710
+ pageSize: 100,
711
+ },
712
+ }
713
+ );
714
+ const payload = response?.data ?? {};
715
+ const pageResults = normalizeFetchedRelations(payload.results).reverse();
716
+
717
+ relations.push(...pageResults);
718
+
719
+ const pagination = payload?.pagination;
720
+ const pageCount =
721
+ typeof pagination?.pageCount === 'number'
722
+ ? pagination.pageCount
723
+ : Math.max(1, Math.ceil((pagination?.total ?? pageResults.length) / 100));
724
+
725
+ totalPages = pageCount;
726
+ page += 1;
727
+ }
728
+
729
+ return relations;
730
+ })();
731
+
732
+ relationCacheRef.current.set(cacheKey, request);
733
+
734
+ try {
735
+ return await request;
736
+ } catch (error) {
737
+ relationCacheRef.current.delete(cacheKey);
738
+ throw error;
739
+ }
740
+ },
741
+ [get, relationQueryParams]
742
+ );
743
+
301
744
  const cleanupInjectedButtons = React.useCallback(() => {
302
745
  if (abortRef.current) {
303
746
  abortRef.current.abort();
@@ -311,7 +754,7 @@ const DynamicZoneActionInjector = () => {
311
754
  }, []);
312
755
 
313
756
  const handleDuplicate = React.useCallback(
314
- (dynamicZonePath, index) => {
757
+ async (dynamicZonePath, index) => {
315
758
  if (!form || typeof form.addFieldRow !== 'function') {
316
759
  return;
317
760
  }
@@ -323,7 +766,20 @@ const DynamicZoneActionInjector = () => {
323
766
  }
324
767
 
325
768
  try {
326
- const cloned = stripTransientKeys(cloneValue(item));
769
+ relationCacheRef.current.clear();
770
+ const createTempKey = createTempKeyFactory();
771
+ const cloned = await sanitizeDynamicZoneValue({
772
+ value: item,
773
+ components,
774
+ contentTypes,
775
+ createTempKey,
776
+ fetchRelationItems,
777
+ });
778
+
779
+ if (!isDynamicZoneItem(cloned)) {
780
+ throw new Error('Invalid dynamic zone clone');
781
+ }
782
+
327
783
  form.addFieldRow(dynamicZonePath, cloned, index + 1);
328
784
  } catch {
329
785
  toggleNotification({
@@ -332,7 +788,14 @@ const DynamicZoneActionInjector = () => {
332
788
  });
333
789
  }
334
790
  },
335
- [form, toggleNotification, duplicateErrorLabel]
791
+ [
792
+ components,
793
+ contentTypes,
794
+ duplicateErrorLabel,
795
+ fetchRelationItems,
796
+ form,
797
+ toggleNotification,
798
+ ]
336
799
  );
337
800
 
338
801
  const injectButtons = React.useCallback(() => {
@@ -379,13 +842,13 @@ const DynamicZoneActionInjector = () => {
379
842
  continue;
380
843
  }
381
844
 
382
- const duplicateButton = createDuplicateButton(anchor, duplicateLabel, () => {
383
- handleDuplicate(location.dynamicZonePath, location.index);
384
- }, controller.signal);
385
- anchor.parentElement.insertBefore(
386
- duplicateButton,
387
- anchor
845
+ const duplicateButton = createDuplicateButton(
846
+ anchor,
847
+ duplicateLabel,
848
+ () => handleDuplicate(location.dynamicZonePath, location.index),
849
+ controller.signal
388
850
  );
851
+ anchor.parentElement.insertBefore(duplicateButton, anchor);
389
852
  }
390
853
 
391
854
  if (observer) {
@@ -396,6 +859,10 @@ const DynamicZoneActionInjector = () => {
396
859
  }
397
860
  }, [cleanupInjectedButtons, components, duplicateLabel, handleDuplicate, isBrowser, isLoading]);
398
861
 
862
+ React.useEffect(() => {
863
+ relationCacheRef.current.clear();
864
+ }, [id, model, collectionType, relationQueryParams]);
865
+
399
866
  React.useEffect(() => {
400
867
  if (!isBrowser) {
401
868
  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.4",
4
4
  "description": "Duplicate dynamic zone components directly from Strapi 5 edit view.",
5
5
  "author": "Djordje Savanovic",
6
6
  "keywords": [