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,236 @@
1
+ const INDEX_SEGMENT = /^\d+$/;
2
+
3
+ export interface DynamicZoneField {
4
+ fieldName: string;
5
+ displayName: string;
6
+ }
7
+
8
+ export interface DynamicZoneBlockRef {
9
+ zonePath: string;
10
+ index: number;
11
+ }
12
+
13
+ export function getDynamicZoneFields(attributes?: Record<string, any> | null): DynamicZoneField[] {
14
+ if (!attributes) {
15
+ return [];
16
+ }
17
+
18
+ return Object.entries(attributes)
19
+ .filter(([, attribute]) => attribute?.type === 'dynamiczone')
20
+ .map(([fieldName, attribute]) => ({
21
+ fieldName,
22
+ displayName: attribute?.displayName || fieldName,
23
+ }));
24
+ }
25
+
26
+ /** Infer dynamic zone field names from loaded form values. */
27
+ export function getDynamicZonePathsFromValues(values: Record<string, unknown>): string[] {
28
+ return Object.entries(values)
29
+ .filter(([, zone]) => Array.isArray(zone) && zone.some(isDynamicZoneEntry))
30
+ .map(([fieldName]) => fieldName);
31
+ }
32
+
33
+ export function isDynamicZoneEntry(value: unknown): value is Record<string, any> {
34
+ return Boolean(value && typeof value === 'object' && typeof (value as any).__component === 'string');
35
+ }
36
+
37
+ export function getValueAtPath(source: unknown, path: string): unknown {
38
+ if (!path) {
39
+ return source;
40
+ }
41
+
42
+ let current: unknown = source;
43
+
44
+ for (const segment of path.split('.')) {
45
+ if (current === null || current === undefined) {
46
+ return undefined;
47
+ }
48
+
49
+ if (INDEX_SEGMENT.test(segment)) {
50
+ if (!Array.isArray(current)) {
51
+ return undefined;
52
+ }
53
+
54
+ current = current[Number(segment)];
55
+ continue;
56
+ }
57
+
58
+ current = (current as Record<string, unknown>)[segment];
59
+ }
60
+
61
+ return current;
62
+ }
63
+
64
+ /** Strapi renders dynamic zone blocks inside an accessible ordered list. */
65
+ export function findDynamicZoneLists(root: ParentNode = document): HTMLOListElement[] {
66
+ return Array.from(root.querySelectorAll('ol[aria-describedby]'));
67
+ }
68
+
69
+ export function getDynamicZoneListItems(list: Element): HTMLLIElement[] {
70
+ return Array.from(list.children).filter(
71
+ (child): child is HTMLLIElement => child instanceof HTMLLIElement
72
+ );
73
+ }
74
+
75
+ /** Row action bar in the accordion header (Delete, Move/Drag, More). */
76
+ export function findRowActionContainer(rowElement: Element): HTMLElement | null {
77
+ const header = rowElement.querySelector('h3');
78
+
79
+ if (!header) {
80
+ return null;
81
+ }
82
+
83
+ for (const span of header.querySelectorAll('span')) {
84
+ const buttons = span.querySelectorAll(':scope > button');
85
+
86
+ if (buttons.length >= 2) {
87
+ return span as HTMLElement;
88
+ }
89
+ }
90
+
91
+ return null;
92
+ }
93
+
94
+ /** Insert duplicate after Delete, before Move/Drag controls. */
95
+ export function getRowActionInsertPoint(actionContainer: HTMLElement): Element | null {
96
+ const buttons = Array.from(actionContainer.querySelectorAll(':scope > button'));
97
+
98
+ if (buttons.length === 0) {
99
+ return null;
100
+ }
101
+
102
+ const reorderButton = buttons.find((button) => {
103
+ if (!(button instanceof HTMLButtonElement)) {
104
+ return false;
105
+ }
106
+
107
+ const label = button.querySelector('span')?.textContent?.trim().toLowerCase() ?? '';
108
+ return (
109
+ label === 'drag' ||
110
+ label === 'move up' ||
111
+ label === 'move down' ||
112
+ button.hasAttribute('data-handler-id')
113
+ );
114
+ });
115
+
116
+ return reorderButton ?? buttons[1] ?? null;
117
+ }
118
+
119
+ function disambiguateZonePathByRowLabel(
120
+ rowElement: Element,
121
+ candidates: string[],
122
+ values: Record<string, unknown>,
123
+ index: number
124
+ ): string | null {
125
+ const rowText = (rowElement.textContent || '').toLowerCase();
126
+
127
+ const matches = candidates.filter((fieldName) => {
128
+ const entry = getValueAtPath(values, `${fieldName}.${index}`);
129
+
130
+ if (!isDynamicZoneEntry(entry)) {
131
+ return false;
132
+ }
133
+
134
+ const componentUid = entry.__component.split('.').pop() || entry.__component;
135
+ return rowText.includes(componentUid.toLowerCase()) || rowText.includes(fieldName.toLowerCase());
136
+ });
137
+
138
+ return matches.length === 1 ? matches[0] : null;
139
+ }
140
+
141
+ export function resolveBlockFromListItemIndex(
142
+ listElement: Element,
143
+ index: number,
144
+ values: Record<string, unknown>,
145
+ zonePaths: string[]
146
+ ): DynamicZoneBlockRef | null {
147
+ const rowCount = getDynamicZoneListItems(listElement).length;
148
+ const candidates = zonePaths.filter((fieldName) => {
149
+ const zone = values[fieldName];
150
+ return Array.isArray(zone) && zone.length === rowCount;
151
+ });
152
+
153
+ if (candidates.length === 1) {
154
+ return { zonePath: candidates[0], index };
155
+ }
156
+
157
+ if (candidates.length > 1) {
158
+ const rowElement = getDynamicZoneListItems(listElement)[index];
159
+ const zonePath = rowElement
160
+ ? disambiguateZonePathByRowLabel(rowElement, candidates, values, index)
161
+ : null;
162
+
163
+ if (zonePath) {
164
+ return { zonePath, index };
165
+ }
166
+ }
167
+
168
+ return null;
169
+ }
170
+
171
+ /**
172
+ * Resolves which dynamic zone block a rendered list item represents by inspecting
173
+ * named fields inside the row and matching them against current form values.
174
+ */
175
+ export function resolveBlockFromListItem(
176
+ listItem: Element,
177
+ values: Record<string, unknown>
178
+ ): DynamicZoneBlockRef | null {
179
+ const fieldNames = Array.from(listItem.querySelectorAll('[name]'))
180
+ .map((element) => element.getAttribute('name') || '')
181
+ .filter(Boolean)
182
+ .sort((a, b) => a.length - b.length);
183
+
184
+ for (const fieldName of fieldNames) {
185
+ const segments = fieldName.split('.');
186
+
187
+ for (let indexPosition = segments.length - 2; indexPosition >= 0; indexPosition -= 1) {
188
+ const segment = segments[indexPosition];
189
+
190
+ if (!INDEX_SEGMENT.test(segment)) {
191
+ continue;
192
+ }
193
+
194
+ const itemPath = segments.slice(0, indexPosition + 1).join('.');
195
+ const zonePath = segments.slice(0, indexPosition).join('.');
196
+ const index = Number(segment);
197
+ const itemValue = getValueAtPath(values, itemPath);
198
+
199
+ if (isDynamicZoneEntry(itemValue) && Array.isArray(getValueAtPath(values, zonePath))) {
200
+ return { zonePath, index };
201
+ }
202
+ }
203
+ }
204
+
205
+ const list = listItem.closest('ol[aria-describedby]');
206
+ const zonePaths = getDynamicZonePathsFromValues(values);
207
+
208
+ if (list && zonePaths.length > 0) {
209
+ const index = getDynamicZoneListItems(list).indexOf(listItem as HTMLLIElement);
210
+
211
+ if (index >= 0) {
212
+ return resolveBlockFromListItemIndex(list, index, values, zonePaths);
213
+ }
214
+ }
215
+
216
+ return null;
217
+ }
218
+
219
+ function getButtonLabel(button: HTMLButtonElement): string {
220
+ return button.querySelector('span')?.textContent?.trim().toLowerCase() ?? '';
221
+ }
222
+
223
+ /** Desktop edit view uses a drag handle; mobile uses move up/down buttons. */
224
+ export function findDynamicZoneDragHandles(root: ParentNode = document): HTMLButtonElement[] {
225
+ return Array.from(root.querySelectorAll<HTMLButtonElement>('button[data-handler-id], button')).filter(
226
+ (button) => {
227
+ const label = getButtonLabel(button);
228
+ return label === 'drag' || button.hasAttribute('data-handler-id');
229
+ }
230
+ );
231
+ }
232
+
233
+ export function findRowElementForDragHandle(dragHandle: Element): HTMLLIElement | null {
234
+ return dragHandle.closest('li');
235
+ }
236
+
@@ -0,0 +1,5 @@
1
+ import { PLUGIN_ID } from '../pluginId';
2
+
3
+ const getTranslation = (id: string) => `${PLUGIN_ID}.${id}`;
4
+
5
+ export { getTranslation };