strapi-dz-component-duplicator 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Djordje Savanovic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # strapi-dz-component-duplicator
2
+
3
+ A Strapi 5 admin plugin that adds a **Duplicate** button to every dynamic zone block in the Content Manager edit view, so editors can clone a block in one click and then tweak only what changes.
4
+
5
+ ## What it does
6
+
7
+ In content types that use [dynamic zones](https://docs.strapi.io/dev-docs/backend-customization/models#dynamic-zones), each block (e.g. a "Quote", "Image", or "CTA" component) gets an action row with delete, drag, and more actions. This plugin injects a **Duplicate** action in that same row.
8
+
9
+ - **One-click clone** — Duplicate creates a full copy of the block (including nested components and media references) and inserts it right after the current block.
10
+ - **Clean copy** — Transient fields like `id`, `documentId`, and `__temp_key__` are stripped so Strapi treats the duplicate as a new entry.
11
+ - **No extra steps** — Works in the existing edit view; no new screens or modals.
12
+
13
+ Useful when you have several similar blocks (e.g. multiple testimonials or CTAs): duplicate once, then change only the differing fields instead of re-adding the component and re-entering everything.
14
+
15
+ ![Dynamic zone duplicate](assets/dynamicZoneDuplicate.png)
16
+
17
+ ![Dynamic zone duplicate close-up](assets/dynamicZoneDuplicateCloseUp.png)
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install strapi-dz-component-duplicator
23
+ # or
24
+ pnpm add strapi-dz-component-duplicator
25
+ # or
26
+ yarn add strapi-dz-component-duplicator
27
+ ```
28
+
29
+ Enable in `config/plugins.ts`:
30
+
31
+ ```ts
32
+ export default () => ({
33
+ 'strapi-dz-component-duplicator': {
34
+ enabled: true,
35
+ },
36
+ });
37
+ ```
38
+
39
+ Then rebuild admin:
40
+
41
+ ```bash
42
+ npm run build
43
+ # or
44
+ npm run develop
45
+ ```
46
+
47
+ The Duplicate button only appears on edit views for content types that have at least one dynamic zone field. If you don’t see it, confirm the plugin is enabled, the admin was rebuilt after installing, and you’re editing an entry whose content type uses a dynamic zone.
48
+
49
+ ## Compatibility
50
+
51
+ - Strapi: `^5.0.0`
52
+ - Node: `>=20.0.0 <=24.x.x`
@@ -0,0 +1,403 @@
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';
4
+ import { useIntl } from 'react-intl';
5
+
6
+ const DUPLICATE_CONTAINER_ATTR = 'data-dz-component-duplicator-action';
7
+ const INDEX_SEGMENT_REGEX = /^\d+$/;
8
+ const DUPLICATE_ICON_PATH =
9
+ '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
+
11
+ const isPlainObject = (value) => {
12
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
13
+ };
14
+
15
+ const getIn = (source, dottedPath) => {
16
+ if (!dottedPath) {
17
+ return source;
18
+ }
19
+
20
+ const segments = dottedPath.split('.');
21
+ let current = source;
22
+
23
+ for (const segment of segments) {
24
+ if (current === null || current === undefined) {
25
+ return undefined;
26
+ }
27
+
28
+ if (INDEX_SEGMENT_REGEX.test(segment)) {
29
+ if (!Array.isArray(current)) {
30
+ return undefined;
31
+ }
32
+
33
+ current = current[Number(segment)];
34
+ continue;
35
+ }
36
+
37
+ current = current[segment];
38
+ }
39
+
40
+ return current;
41
+ };
42
+
43
+ const isDynamicZoneItem = (value) => {
44
+ return isPlainObject(value) && typeof value.__component === 'string';
45
+ };
46
+
47
+ const collectDynamicZonePaths = (value, currentPath = '', acc = []) => {
48
+ if (Array.isArray(value)) {
49
+ if (currentPath && value.length > 0 && value.every(isDynamicZoneItem)) {
50
+ acc.push(currentPath);
51
+ }
52
+
53
+ for (let index = 0; index < value.length; index += 1) {
54
+ const nestedPath = currentPath ? `${currentPath}.${index}` : `${index}`;
55
+ collectDynamicZonePaths(value[index], nestedPath, acc);
56
+ }
57
+
58
+ return acc;
59
+ }
60
+
61
+ if (!isPlainObject(value)) {
62
+ return acc;
63
+ }
64
+
65
+ for (const [key, nested] of Object.entries(value)) {
66
+ const nestedPath = currentPath ? `${currentPath}.${key}` : key;
67
+ collectDynamicZonePaths(nested, nestedPath, acc);
68
+ }
69
+
70
+ return acc;
71
+ };
72
+
73
+ const cloneValue = (value) => {
74
+ if (typeof structuredClone === 'function') {
75
+ return structuredClone(value);
76
+ }
77
+
78
+ return JSON.parse(JSON.stringify(value));
79
+ };
80
+
81
+ const stripTransientKeys = (value) => {
82
+ if (Array.isArray(value)) {
83
+ return value.map(stripTransientKeys);
84
+ }
85
+
86
+ if (!isPlainObject(value)) {
87
+ return value;
88
+ }
89
+
90
+ const next = {};
91
+
92
+ for (const [key, nested] of Object.entries(value)) {
93
+ if (key === 'id' || key === 'documentId' || key === '__temp_key__') {
94
+ continue;
95
+ }
96
+
97
+ next[key] = stripTransientKeys(nested);
98
+ }
99
+
100
+ return next;
101
+ };
102
+
103
+ const getActionAnchor = (listItem) => {
104
+ const header = listItem.querySelector('h3');
105
+
106
+ if (!header) {
107
+ return null;
108
+ }
109
+
110
+ const buttons = header.querySelectorAll('button');
111
+
112
+ // Dynamic zone headers include delete, drag and more-actions controls.
113
+ // Repeatable components usually don't include the extra menu action.
114
+ if (buttons.length < 3) {
115
+ return null;
116
+ }
117
+
118
+ return buttons[1];
119
+ };
120
+
121
+ const findDynamicZoneLocationFromFields = (listItem, values) => {
122
+ const fields = Array.from(listItem.querySelectorAll('[name]'))
123
+ .map((element) => element.getAttribute('name') || '')
124
+ .filter(Boolean)
125
+ .sort((a, b) => a.length - b.length);
126
+
127
+ for (const fieldName of fields) {
128
+ const segments = fieldName.split('.');
129
+
130
+ for (let indexPosition = segments.length - 2; indexPosition >= 0; indexPosition -= 1) {
131
+ const segment = segments[indexPosition];
132
+
133
+ if (!INDEX_SEGMENT_REGEX.test(segment)) {
134
+ continue;
135
+ }
136
+
137
+ const itemPath = segments.slice(0, indexPosition + 1).join('.');
138
+ const itemValue = getIn(values, itemPath);
139
+
140
+ if (isDynamicZoneItem(itemValue)) {
141
+ return {
142
+ dynamicZonePath: segments.slice(0, indexPosition).join('.'),
143
+ index: Number(segment),
144
+ };
145
+ }
146
+ }
147
+ }
148
+
149
+ return null;
150
+ };
151
+
152
+ const findDynamicZoneLocationFromList = (listItem, values, components) => {
153
+ const list = listItem.closest('ol');
154
+
155
+ if (!list) {
156
+ return null;
157
+ }
158
+
159
+ const siblings = Array.from(list.children).filter((node) => node.tagName === 'LI');
160
+ const index = siblings.indexOf(listItem);
161
+
162
+ if (index < 0) {
163
+ return null;
164
+ }
165
+
166
+ const listLength = siblings.length;
167
+ const dynamicZonePaths = collectDynamicZonePaths(values);
168
+ const candidates = dynamicZonePaths.filter((dynamicZonePath) => {
169
+ const zone = getIn(values, dynamicZonePath);
170
+
171
+ return (
172
+ Array.isArray(zone) &&
173
+ zone.length === listLength &&
174
+ isDynamicZoneItem(getIn(values, `${dynamicZonePath}.${index}`))
175
+ );
176
+ });
177
+
178
+ if (candidates.length === 1) {
179
+ return {
180
+ dynamicZonePath: candidates[0],
181
+ index,
182
+ };
183
+ }
184
+
185
+ if (!components || !isPlainObject(components)) {
186
+ return null;
187
+ }
188
+
189
+ const listItemText = (listItem.textContent || '').toLowerCase();
190
+ const byDisplayName = candidates.filter((dynamicZonePath) => {
191
+ const uid = getIn(values, `${dynamicZonePath}.${index}.__component`);
192
+ const displayName = components?.[uid]?.info?.displayName;
193
+
194
+ return typeof displayName === 'string' && listItemText.includes(displayName.toLowerCase());
195
+ });
196
+
197
+ if (byDisplayName.length === 1) {
198
+ return {
199
+ dynamicZonePath: byDisplayName[0],
200
+ index,
201
+ };
202
+ }
203
+
204
+ return null;
205
+ };
206
+
207
+ const findDynamicZoneLocation = (listItem, values, components) => {
208
+ const locationFromFields = findDynamicZoneLocationFromFields(listItem, values);
209
+
210
+ if (locationFromFields) {
211
+ return locationFromFields;
212
+ }
213
+
214
+ return findDynamicZoneLocationFromList(listItem, values, components);
215
+ };
216
+
217
+ const createDuplicateButton = (anchor, label, onClick) => {
218
+ const button = document.createElement('button');
219
+ button.type = 'button';
220
+ button.className = anchor.className;
221
+ button.setAttribute(DUPLICATE_CONTAINER_ATTR, 'true');
222
+ button.setAttribute('aria-label', label);
223
+ button.setAttribute('title', label);
224
+ button.setAttribute('data-state', 'closed');
225
+
226
+ const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
227
+ icon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
228
+ icon.setAttribute('viewBox', '0 0 32 32');
229
+ icon.setAttribute('width', '16');
230
+ icon.setAttribute('height', '16');
231
+ icon.setAttribute('fill', 'currentColor');
232
+ icon.setAttribute('aria-hidden', 'true');
233
+ icon.setAttribute('focusable', 'false');
234
+
235
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
236
+ path.setAttribute('d', DUPLICATE_ICON_PATH);
237
+ icon.appendChild(path);
238
+
239
+ const textTemplate = anchor.querySelector('span');
240
+ const text = document.createElement('span');
241
+ if (textTemplate?.className) {
242
+ text.className = textTemplate.className;
243
+ }
244
+ text.textContent = label;
245
+
246
+ button.append(icon, text);
247
+ button.addEventListener('click', (event) => {
248
+ event.preventDefault();
249
+ event.stopPropagation();
250
+ onClick();
251
+ });
252
+
253
+ return button;
254
+ };
255
+
256
+ const DynamicZoneActionInjector = () => {
257
+ const { formatMessage } = useIntl();
258
+ const { toggleNotification } = useNotification();
259
+ const { form, isLoading, components } = useContentManagerContext();
260
+
261
+ const values = form?.values;
262
+ const valuesRef = React.useRef(values);
263
+ const observerRef = React.useRef(null);
264
+
265
+ valuesRef.current = values;
266
+
267
+ const duplicateLabel = formatMessage({
268
+ id: 'strapi-dz-component-duplicator.action.duplicate',
269
+ defaultMessage: 'Duplicate component',
270
+ });
271
+
272
+ const duplicateErrorLabel = formatMessage({
273
+ id: 'strapi-dz-component-duplicator.error.duplicate',
274
+ defaultMessage: 'Could not duplicate this component.',
275
+ });
276
+
277
+ const cleanupInjectedButtons = React.useCallback(() => {
278
+ if (typeof document === 'undefined') {
279
+ return;
280
+ }
281
+
282
+ const injectedNodes = document.querySelectorAll(`[${DUPLICATE_CONTAINER_ATTR}]`);
283
+ for (const node of injectedNodes) {
284
+ node.remove();
285
+ }
286
+ }, []);
287
+
288
+ const handleDuplicate = React.useCallback(
289
+ (dynamicZonePath, index) => {
290
+ if (!form || typeof form.addFieldRow !== 'function') {
291
+ return;
292
+ }
293
+
294
+ const item = getIn(valuesRef.current, `${dynamicZonePath}.${index}`);
295
+
296
+ if (!isDynamicZoneItem(item)) {
297
+ return;
298
+ }
299
+
300
+ try {
301
+ const cloned = stripTransientKeys(cloneValue(item));
302
+ form.addFieldRow(dynamicZonePath, cloned, index + 1);
303
+ } catch {
304
+ toggleNotification({
305
+ type: 'danger',
306
+ message: duplicateErrorLabel,
307
+ });
308
+ }
309
+ },
310
+ [form, toggleNotification, duplicateErrorLabel]
311
+ );
312
+
313
+ const injectButtons = React.useCallback(() => {
314
+ if (typeof document === 'undefined') {
315
+ return;
316
+ }
317
+
318
+ const observer = observerRef.current;
319
+
320
+ if (observer) {
321
+ observer.disconnect();
322
+ }
323
+
324
+ cleanupInjectedButtons();
325
+
326
+ const currentValues = valuesRef.current;
327
+
328
+ if (!currentValues || isLoading) {
329
+ if (observer) {
330
+ observer.observe(document.body, {
331
+ childList: true,
332
+ subtree: true,
333
+ });
334
+ }
335
+
336
+ return;
337
+ }
338
+
339
+ const listItems = document.querySelectorAll('ol > li');
340
+
341
+ for (const listItem of listItems) {
342
+ const location = findDynamicZoneLocation(listItem, currentValues, components);
343
+
344
+ if (!location) {
345
+ continue;
346
+ }
347
+
348
+ const anchor = getActionAnchor(listItem);
349
+
350
+ if (!anchor || !anchor.parentElement) {
351
+ continue;
352
+ }
353
+
354
+ const duplicateButton = createDuplicateButton(anchor, duplicateLabel, () => {
355
+ handleDuplicate(location.dynamicZonePath, location.index);
356
+ });
357
+ anchor.parentElement.insertBefore(
358
+ duplicateButton,
359
+ anchor
360
+ );
361
+ }
362
+
363
+ if (observer) {
364
+ observer.observe(document.body, {
365
+ childList: true,
366
+ subtree: true,
367
+ });
368
+ }
369
+ }, [cleanupInjectedButtons, duplicateLabel, handleDuplicate, isLoading]);
370
+
371
+ React.useEffect(() => {
372
+ if (typeof document === 'undefined') {
373
+ return undefined;
374
+ }
375
+
376
+ const observer = new MutationObserver(() => {
377
+ injectButtons();
378
+ });
379
+
380
+ observerRef.current = observer;
381
+
382
+ observer.observe(document.body, {
383
+ childList: true,
384
+ subtree: true,
385
+ });
386
+
387
+ injectButtons();
388
+
389
+ return () => {
390
+ observer.disconnect();
391
+ observerRef.current = null;
392
+ cleanupInjectedButtons();
393
+ };
394
+ }, [cleanupInjectedButtons, injectButtons]);
395
+
396
+ React.useEffect(() => {
397
+ injectButtons();
398
+ }, [injectButtons, values]);
399
+
400
+ return null;
401
+ };
402
+
403
+ export { DynamicZoneActionInjector };
@@ -0,0 +1,37 @@
1
+ import { PLUGIN_ID } from './pluginId.mjs';
2
+ import { DynamicZoneActionInjector } from './components/DynamicZoneActionInjector.mjs';
3
+
4
+ const plugin = {
5
+ register(app) {
6
+ app.registerPlugin({
7
+ id: PLUGIN_ID,
8
+ name: PLUGIN_ID,
9
+ });
10
+ },
11
+
12
+ bootstrap(app) {
13
+ const contentManager = app.getPlugin('content-manager');
14
+
15
+ if (!contentManager || typeof contentManager.injectComponent !== 'function') {
16
+ return;
17
+ }
18
+
19
+ const injectionZone = 'right-links';
20
+ const injectorName = `${PLUGIN_ID}-dynamic-zone-action-injector`;
21
+ const existing =
22
+ typeof contentManager.getInjectedComponents === 'function'
23
+ ? contentManager.getInjectedComponents('editView', injectionZone)
24
+ : [];
25
+
26
+ if (Array.isArray(existing) && existing.some((component) => component.name === injectorName)) {
27
+ return;
28
+ }
29
+
30
+ contentManager.injectComponent('editView', injectionZone, {
31
+ name: injectorName,
32
+ Component: DynamicZoneActionInjector,
33
+ });
34
+ },
35
+ };
36
+
37
+ export default plugin;
@@ -0,0 +1 @@
1
+ export const PLUGIN_ID = 'strapi-dz-component-duplicator';
Binary file
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "strapi-dz-component-duplicator",
3
+ "version": "0.1.0",
4
+ "description": "Duplicate dynamic zone components directly from Strapi 5 edit view.",
5
+ "author": "Djordje Savanovic",
6
+ "keywords": [
7
+ "strapi",
8
+ "strapi-plugin",
9
+ "dynamic-zone",
10
+ "component",
11
+ "duplicator"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/djordjesavanovic/strapi-dz-component-duplicator.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/djordjesavanovic/strapi-dz-component-duplicator/issues"
20
+ },
21
+ "homepage": "https://github.com/djordjesavanovic/strapi-dz-component-duplicator",
22
+ "type": "commonjs",
23
+ "scripts": {
24
+ "verify:pack": "npm pack --dry-run"
25
+ },
26
+ "exports": {
27
+ "./strapi-admin": {
28
+ "source": "./admin/src/index.mjs",
29
+ "import": "./admin/src/index.mjs",
30
+ "require": "./admin/src/index.mjs",
31
+ "default": "./admin/src/index.mjs"
32
+ },
33
+ "./strapi-server": {
34
+ "source": "./server/src/index.js",
35
+ "import": "./server/src/index.js",
36
+ "require": "./server/src/index.js",
37
+ "default": "./server/src/index.js"
38
+ },
39
+ "./package.json": "./package.json"
40
+ },
41
+ "files": [
42
+ "admin",
43
+ "server",
44
+ "assets"
45
+ ],
46
+ "peerDependencies": {
47
+ "@strapi/admin": "^5.0.0",
48
+ "@strapi/content-manager": "^5.0.0",
49
+ "@strapi/strapi": "^5.0.0",
50
+ "react": "^18.0.0",
51
+ "react-intl": "^6.0.0"
52
+ },
53
+ "strapi": {
54
+ "name": "strapi-dz-component-duplicator",
55
+ "displayName": "Dynamic Zone Component Duplicator",
56
+ "description": "Adds a duplicate button to Strapi dynamic zone blocks in edit view.",
57
+ "kind": "plugin"
58
+ },
59
+ "engines": {
60
+ "node": ">=20.0.0 <=24.x.x"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ }
65
+ }
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ register() {},
5
+ bootstrap() {},
6
+ destroy() {},
7
+ };