strapi-plugin-bulk-editor 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.
@@ -0,0 +1,1252 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import {
3
+ Modal,
4
+ Button,
5
+ Box,
6
+ Typography,
7
+ Table,
8
+ Thead,
9
+ Tbody,
10
+ Tr,
11
+ Th,
12
+ Td,
13
+ } from '@strapi/design-system';
14
+
15
+ interface FieldSchema {
16
+ type: string;
17
+ enum?: string[];
18
+ default?: any;
19
+ required?: boolean;
20
+ }
21
+
22
+ interface ContentTypeSchema {
23
+ attributes: Record<string, FieldSchema>;
24
+ options?: {
25
+ draftAndPublish?: boolean;
26
+ };
27
+ pluralName?: string;
28
+ }
29
+
30
+ interface BulkEditModalProps {
31
+ documents: any[];
32
+ contentType: string;
33
+ onClose: () => void;
34
+ notificationFn: any;
35
+ fetchClient: any;
36
+ }
37
+
38
+ export const BulkEditModal: React.FC<BulkEditModalProps> = ({
39
+ documents,
40
+ contentType,
41
+ onClose,
42
+ notificationFn,
43
+ fetchClient,
44
+ }) => {
45
+ const [editedEntries, setEditedEntries] = useState<Record<string, any>>({});
46
+ const [saving, setSaving] = useState(false);
47
+ const [schema, setSchema] = useState<ContentTypeSchema | null>(null);
48
+ const [schemaLoading, setSchemaLoading] = useState(true);
49
+ const [dragStart, setDragStart] = useState<{ docId: string; field: string } | null>(null);
50
+ const [dragCurrent, setDragCurrent] = useState<{ docId: string; field: string } | null>(null);
51
+ const [relationOptions, setRelationOptions] = useState<Record<string, any[]>>({});
52
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
53
+ const [showExitConfirm, setShowExitConfirm] = useState(false);
54
+ const [populatingRelations, setPopulatingRelations] = useState(true);
55
+ const [hoveredCell, setHoveredCell] = useState<{ docId: string; field: string } | null>(null);
56
+ const [selectedCells, setSelectedCells] = useState<Set<string>>(new Set());
57
+
58
+ // Helper to create cell key
59
+ const getCellKey = (docId: string, field: string) => `${docId}:${field}`;
60
+
61
+ // Fetch content type schema
62
+ useEffect(() => {
63
+ const fetchSchema = async () => {
64
+ setSchemaLoading(true);
65
+ try {
66
+ const configResponse = await fetchClient.get(`/content-manager/content-types/${contentType}/configuration`);
67
+ const builderResponse = await fetchClient.get(`/content-type-builder/content-types/${contentType}`);
68
+
69
+ let schemaData = null;
70
+
71
+ if (builderResponse.data?.data?.schema?.attributes) {
72
+ const rawSchema = builderResponse.data.data.schema;
73
+ schemaData = {
74
+ attributes: rawSchema.attributes,
75
+ pluralName: rawSchema.pluralName,
76
+ options: {
77
+ draftAndPublish: rawSchema.draftAndPublish || rawSchema.options?.draftAndPublish || false,
78
+ },
79
+ };
80
+ } else if (builderResponse.data?.schema?.attributes) {
81
+ const rawSchema = builderResponse.data.schema;
82
+ schemaData = {
83
+ attributes: rawSchema.attributes,
84
+ pluralName: rawSchema.pluralName,
85
+ options: {
86
+ draftAndPublish: rawSchema.draftAndPublish || rawSchema.options?.draftAndPublish || false,
87
+ },
88
+ };
89
+ } else if (configResponse.data?.data?.contentType?.attributes) {
90
+ const rawSchema = configResponse.data.data.contentType;
91
+ schemaData = {
92
+ attributes: rawSchema.attributes,
93
+ pluralName: rawSchema.pluralName,
94
+ options: {
95
+ draftAndPublish: rawSchema.draftAndPublish || rawSchema.options?.draftAndPublish || false,
96
+ },
97
+ };
98
+ } else if (configResponse.data?.data?.schema?.attributes) {
99
+ const rawSchema = configResponse.data.data.schema;
100
+ schemaData = {
101
+ attributes: rawSchema.attributes,
102
+ pluralName: rawSchema.pluralName,
103
+ options: {
104
+ draftAndPublish: rawSchema.draftAndPublish || rawSchema.options?.draftAndPublish || false,
105
+ },
106
+ };
107
+ } else if (configResponse.data?.contentType?.attributes) {
108
+ const rawSchema = configResponse.data.contentType;
109
+ schemaData = {
110
+ attributes: rawSchema.attributes,
111
+ pluralName: rawSchema.pluralName,
112
+ options: {
113
+ draftAndPublish: rawSchema.draftAndPublish || rawSchema.options?.draftAndPublish || false,
114
+ },
115
+ };
116
+ }
117
+
118
+ if (schemaData) {
119
+ setSchema(schemaData);
120
+ }
121
+ } catch (error) {
122
+ console.error('Failed to fetch schema:', error);
123
+ } finally {
124
+ setSchemaLoading(false);
125
+ }
126
+ };
127
+
128
+ fetchSchema();
129
+ }, [contentType, fetchClient]);
130
+
131
+ // Fetch relation options when schema is loaded
132
+ useEffect(() => {
133
+ const fetchRelationOptions = async () => {
134
+ if (!schema) return;
135
+
136
+ const relationFields = Object.entries(schema.attributes || {})
137
+ .filter(([_, fieldSchema]) => fieldSchema.type === 'relation')
138
+ .map(([fieldName, fieldSchema]) => ({ fieldName, fieldSchema }));
139
+
140
+ if (relationFields.length === 0) return;
141
+
142
+ const options: Record<string, any[]> = {};
143
+
144
+ for (const { fieldName, fieldSchema } of relationFields) {
145
+ try {
146
+ const targetContentType = (fieldSchema as any).target;
147
+ if (!targetContentType) continue;
148
+
149
+ const response = await fetchClient.get(
150
+ `/content-manager/collection-types/${targetContentType}?page=1&pageSize=100`
151
+ );
152
+
153
+ if (response.data?.results) {
154
+ options[fieldName] = response.data.results;
155
+ }
156
+ } catch (error) {
157
+ // Silently handle relation fetch errors
158
+ }
159
+ }
160
+
161
+ setRelationOptions(options);
162
+ };
163
+
164
+ fetchRelationOptions();
165
+ }, [schema, fetchClient]);
166
+
167
+ // Global mouse up listener for drag-to-fill
168
+ useEffect(() => {
169
+ const handleGlobalMouseUp = () => {
170
+ if (dragStart) {
171
+ handleDragEnd();
172
+ }
173
+ };
174
+
175
+ document.addEventListener('mouseup', handleGlobalMouseUp);
176
+ return () => {
177
+ document.removeEventListener('mouseup', handleGlobalMouseUp);
178
+ };
179
+ // eslint-disable-next-line react-hooks/exhaustive-deps
180
+ }, [dragStart, dragCurrent]);
181
+
182
+ // Populate unpopulated relations
183
+ useEffect(() => {
184
+ const populateRelations = async () => {
185
+ if (!schema || documents.length === 0) return;
186
+
187
+ setPopulatingRelations(true);
188
+ const docIdsNeedingPopulation: string[] = [];
189
+
190
+ documents.forEach((doc) => {
191
+ const docId = doc.documentId || doc.id;
192
+ if (!docId) return;
193
+
194
+ Object.keys(doc).forEach((key) => {
195
+ const isRelationField = schema.attributes?.[key]?.type === 'relation';
196
+ if (isRelationField && typeof doc[key] === 'object' && doc[key] !== null && 'count' in doc[key]) {
197
+ if (!docIdsNeedingPopulation.includes(docId)) {
198
+ docIdsNeedingPopulation.push(docId);
199
+ }
200
+ }
201
+ });
202
+ });
203
+
204
+ if (docIdsNeedingPopulation.length > 0) {
205
+ try {
206
+ const response = await fetchClient.post('/bulk-editor/get-populated', {
207
+ contentType,
208
+ documentIds: docIdsNeedingPopulation,
209
+ });
210
+
211
+ const populatedDocs = response.data?.documents || [];
212
+
213
+ const updatedDocuments = documents.map((doc) => {
214
+ const docId = doc.documentId || doc.id;
215
+ const populated = populatedDocs.find((pd: any) => (pd.documentId || pd.id) === docId);
216
+ return populated || doc;
217
+ });
218
+
219
+ initializeEntries(updatedDocuments);
220
+ } catch (error) {
221
+ initializeEntries(documents);
222
+ }
223
+ } else {
224
+ initializeEntries(documents);
225
+ }
226
+ };
227
+
228
+ const initializeEntries = (docs: any[]) => {
229
+ const initial: Record<string, any> = {};
230
+ docs.forEach((doc) => {
231
+ const docId = doc.documentId || doc.id;
232
+ if (docId) {
233
+ const docCopy = { ...doc };
234
+
235
+ Object.keys(docCopy).forEach((key) => {
236
+ if (Array.isArray(docCopy[key])) {
237
+ docCopy[key] = [...docCopy[key]];
238
+ } else if (typeof docCopy[key] === 'object' && docCopy[key] !== null && 'count' in docCopy[key]) {
239
+ docCopy[key] = [];
240
+ }
241
+ });
242
+
243
+ if (schema?.attributes) {
244
+ Object.entries(schema.attributes).forEach(([key, fieldSchema]) => {
245
+ if (fieldSchema.type === 'relation') {
246
+ if (!(key in docCopy) || docCopy[key] === undefined) {
247
+ const relationField = fieldSchema as any;
248
+ const isManyRelation = relationField.relation?.includes('ToMany') || relationField.relation === 'manyToMany';
249
+ docCopy[key] = isManyRelation ? [] : null;
250
+ }
251
+ }
252
+ });
253
+ }
254
+
255
+ initial[docId] = docCopy;
256
+ }
257
+ });
258
+ setEditedEntries(initial);
259
+ setPopulatingRelations(false);
260
+ };
261
+
262
+ populateRelations();
263
+ }, [documents, schema, contentType, fetchClient]);
264
+
265
+ // Get editable fields
266
+ const getEditableFields = () => {
267
+ if (documents.length === 0) return [];
268
+
269
+ const doc = documents[0];
270
+ const excludeFields = [
271
+ 'id',
272
+ 'documentId',
273
+ 'createdAt',
274
+ 'updatedAt',
275
+ 'publishedAt',
276
+ 'createdBy',
277
+ 'updatedBy',
278
+ 'locale',
279
+ 'localizations',
280
+ 'status',
281
+ ];
282
+
283
+ return Object.keys(doc).filter((key) => {
284
+ const value = doc[key];
285
+
286
+ if (excludeFields.includes(key)) return false;
287
+
288
+ if (schema?.attributes?.[key]) {
289
+ const fieldSchema = schema.attributes[key];
290
+ if (
291
+ fieldSchema.type === 'component' ||
292
+ fieldSchema.type === 'dynamiczone'
293
+ ) {
294
+ return false;
295
+ }
296
+ return true;
297
+ }
298
+
299
+ if (typeof value === 'object' && value !== null) return false;
300
+ if (Array.isArray(value)) return false;
301
+
302
+ return (
303
+ typeof value === 'string' ||
304
+ typeof value === 'number' ||
305
+ typeof value === 'boolean' ||
306
+ value === null ||
307
+ value === undefined
308
+ );
309
+ });
310
+ };
311
+
312
+ const fields = getEditableFields();
313
+
314
+ const getFieldType = (field: string): FieldSchema | null => {
315
+ return schema?.attributes?.[field] || null;
316
+ };
317
+
318
+ const handleFieldChange = (docId: string | number, field: string, value: any) => {
319
+ const cellKey = getCellKey(String(docId), field);
320
+
321
+ // Get all cells to update (current cell + any selected cells in same column)
322
+ const cellsToUpdate = new Set<string>();
323
+ cellsToUpdate.add(cellKey);
324
+
325
+ // Add selected cells that are in the same column
326
+ selectedCells.forEach((selectedKey) => {
327
+ const [, selectedField] = selectedKey.split(':');
328
+ if (selectedField === field) {
329
+ cellsToUpdate.add(selectedKey);
330
+ }
331
+ });
332
+
333
+ setEditedEntries((prev) => {
334
+ const updated = { ...prev };
335
+ cellsToUpdate.forEach((key) => {
336
+ const [targetDocId] = key.split(':');
337
+ updated[targetDocId] = {
338
+ ...updated[targetDocId],
339
+ [field]: value,
340
+ };
341
+ });
342
+ return updated;
343
+ });
344
+ setHasUnsavedChanges(true);
345
+ };
346
+
347
+ // Handler for many-to-many incremental changes (add/remove single item across selected cells)
348
+ const handleManyToManyChange = (docId: string | number, field: string, itemId: number, operation: 'add' | 'remove') => {
349
+ const cellKey = getCellKey(String(docId), field);
350
+
351
+ // Get all cells to update (current cell + any selected cells in same column)
352
+ const cellsToUpdate = new Set<string>();
353
+ cellsToUpdate.add(cellKey);
354
+
355
+ // Add selected cells that are in the same column
356
+ selectedCells.forEach((selectedKey) => {
357
+ const [, selectedField] = selectedKey.split(':');
358
+ if (selectedField === field) {
359
+ cellsToUpdate.add(selectedKey);
360
+ }
361
+ });
362
+
363
+ setEditedEntries((prev) => {
364
+ const updated = { ...prev };
365
+ cellsToUpdate.forEach((key) => {
366
+ const [targetDocId] = key.split(':');
367
+ const currentValue = updated[targetDocId]?.[field];
368
+
369
+ // Get current IDs for this specific cell
370
+ const currentIds = Array.isArray(currentValue)
371
+ ? currentValue.map((item: any) =>
372
+ typeof item === 'object' && item?.id ? item.id : typeof item === 'number' ? item : null
373
+ ).filter((id): id is number => id !== null)
374
+ : [];
375
+
376
+ let newIds: number[];
377
+ if (operation === 'add') {
378
+ // Add the item if not already present
379
+ newIds = currentIds.includes(itemId) ? currentIds : [...currentIds, itemId];
380
+ } else {
381
+ // Remove the item
382
+ newIds = currentIds.filter((id) => id !== itemId);
383
+ }
384
+
385
+ updated[targetDocId] = {
386
+ ...updated[targetDocId],
387
+ [field]: newIds,
388
+ };
389
+ });
390
+ return updated;
391
+ });
392
+ setHasUnsavedChanges(true);
393
+ };
394
+
395
+ // Cell click handler for multi-select
396
+ const handleCellClick = (e: React.MouseEvent, docId: string, field: string) => {
397
+ const cellKey = getCellKey(docId, field);
398
+ const docIds = documents.map((doc) => doc.documentId || doc.id).filter(Boolean);
399
+ const isThisCellSelected = selectedCells.has(cellKey);
400
+
401
+ // Check if clicking on an interactive element (input, select, button, option)
402
+ const target = e.target as HTMLElement;
403
+ const tagName = target.tagName.toLowerCase();
404
+ const isInteractiveElement = tagName === 'input' || tagName === 'select' || tagName === 'button' || tagName === 'option';
405
+
406
+ if (e.shiftKey) {
407
+ // Shift+click (with or without Cmd): select range from last selected to current (same column)
408
+ e.preventDefault(); // Prevent text selection
409
+
410
+ setSelectedCells((prev) => {
411
+ const newSet = new Set<string>();
412
+
413
+ // Find the last selected cell in this column
414
+ let lastSelectedIndex = -1;
415
+ prev.forEach((key) => {
416
+ const [keyDocId, keyField] = key.split(':');
417
+ if (keyField === field) {
418
+ const idx = docIds.indexOf(keyDocId);
419
+ if (idx > lastSelectedIndex) {
420
+ lastSelectedIndex = idx;
421
+ }
422
+ }
423
+ });
424
+
425
+ // Copy existing selections from same column
426
+ prev.forEach((key) => {
427
+ const [, keyField] = key.split(':');
428
+ if (keyField === field) {
429
+ newSet.add(key);
430
+ }
431
+ });
432
+
433
+ // If we have a previous selection, select the range
434
+ const currentIndex = docIds.indexOf(docId);
435
+ if (lastSelectedIndex !== -1 && currentIndex !== -1) {
436
+ const [minIndex, maxIndex] = [Math.min(lastSelectedIndex, currentIndex), Math.max(lastSelectedIndex, currentIndex)];
437
+ for (let i = minIndex; i <= maxIndex; i++) {
438
+ newSet.add(getCellKey(docIds[i], field));
439
+ }
440
+ } else {
441
+ newSet.add(cellKey);
442
+ }
443
+
444
+ return newSet;
445
+ });
446
+ } else if (e.metaKey || e.ctrlKey) {
447
+ // Cmd/Ctrl+click: toggle selection, add to existing (same column only)
448
+ e.preventDefault(); // Prevent text selection
449
+
450
+ setSelectedCells((prev) => {
451
+ const newSet = new Set<string>();
452
+
453
+ // Keep only cells from the same column
454
+ prev.forEach((key) => {
455
+ const [, keyField] = key.split(':');
456
+ if (keyField === field) {
457
+ newSet.add(key);
458
+ }
459
+ });
460
+
461
+ if (newSet.has(cellKey)) {
462
+ newSet.delete(cellKey);
463
+ } else {
464
+ newSet.add(cellKey);
465
+ }
466
+ return newSet;
467
+ });
468
+ } else if (isInteractiveElement && isThisCellSelected) {
469
+ // Clicking on input/select in an already-selected cell: keep selection
470
+ return;
471
+ } else {
472
+ // Regular click (or clicking on input in unselected cell): select only this cell
473
+ setSelectedCells(new Set([cellKey]));
474
+ }
475
+ };
476
+
477
+ // Drag-to-fill handlers
478
+ const handleDragStart = (e: React.MouseEvent, docId: string, field: string) => {
479
+ e.preventDefault();
480
+ setDragStart({ docId, field });
481
+ setDragCurrent({ docId, field });
482
+ };
483
+
484
+ const handleDragOver = (docId: string, field: string) => {
485
+ if (dragStart && dragStart.field === field) {
486
+ setDragCurrent({ docId, field });
487
+ }
488
+ };
489
+
490
+ const handleDragEnd = () => {
491
+ if (dragStart && dragCurrent && dragStart.field === dragCurrent.field) {
492
+ const docIds = documents.map((doc) => doc.documentId || doc.id).filter(Boolean);
493
+ const startIndex = docIds.indexOf(dragStart.docId);
494
+ const endIndex = docIds.indexOf(dragCurrent.docId);
495
+
496
+ if (startIndex !== -1 && endIndex !== -1) {
497
+ const [minIndex, maxIndex] = [Math.min(startIndex, endIndex), Math.max(startIndex, endIndex)];
498
+ const affectedDocIds = docIds.slice(minIndex, maxIndex + 1);
499
+
500
+ setEditedEntries((prev) => {
501
+ let sourceValue = prev[dragStart.docId]?.[dragStart.field];
502
+
503
+ const fieldSchema = getFieldType(dragStart.field);
504
+ if (fieldSchema?.type === 'relation') {
505
+ const relationField = fieldSchema as any;
506
+ const isManyRelation = relationField.relation?.includes('ToMany') || relationField.relation?.includes('manyToMany');
507
+
508
+ if (isManyRelation) {
509
+ if (Array.isArray(sourceValue)) {
510
+ sourceValue = sourceValue.map((item: any) =>
511
+ typeof item === 'object' && item?.id ? item.id : typeof item === 'number' ? item : null
512
+ ).filter((id): id is number => id !== null);
513
+ } else {
514
+ sourceValue = [];
515
+ }
516
+ } else {
517
+ if (typeof sourceValue === 'object' && sourceValue?.id) {
518
+ sourceValue = sourceValue.id;
519
+ }
520
+ }
521
+ }
522
+
523
+ const updated = { ...prev };
524
+ affectedDocIds.forEach((docId) => {
525
+ updated[docId] = {
526
+ ...updated[docId],
527
+ [dragStart.field]: sourceValue,
528
+ };
529
+ });
530
+
531
+ return updated;
532
+ });
533
+
534
+ setHasUnsavedChanges(true);
535
+ }
536
+ }
537
+
538
+ setDragStart(null);
539
+ setDragCurrent(null);
540
+ };
541
+
542
+ const isInDragSelection = (docId: string, field: string): boolean => {
543
+ if (!dragStart || !dragCurrent || dragStart.field !== field) return false;
544
+
545
+ const docIds = documents.map((doc) => doc.documentId || doc.id).filter(Boolean);
546
+ const startIndex = docIds.indexOf(dragStart.docId);
547
+ const currentIndex = docIds.indexOf(dragCurrent.docId);
548
+ const thisIndex = docIds.indexOf(docId);
549
+
550
+ const [minIndex, maxIndex] = [Math.min(startIndex, currentIndex), Math.max(startIndex, currentIndex)];
551
+ return thisIndex >= minIndex && thisIndex <= maxIndex;
552
+ };
553
+
554
+ // Check if cell is selected
555
+ const isCellSelected = (docId: string, field: string): boolean => {
556
+ return selectedCells.has(getCellKey(docId, field));
557
+ };
558
+
559
+ // Render appropriate input based on field type
560
+ const renderFieldInput = (docId: string, field: string, value: any, fieldSchema: FieldSchema | null, isLoading: boolean) => {
561
+ const onChange = (newValue: any) => handleFieldChange(docId, field, newValue);
562
+
563
+ const disabledStyle = isLoading ? {
564
+ opacity: 0.5,
565
+ pointerEvents: 'none' as const,
566
+ backgroundColor: '#f6f6f9',
567
+ } : {};
568
+
569
+ // Handle relation fields
570
+ if (fieldSchema?.type === 'relation') {
571
+ const options = relationOptions[field] || [];
572
+ const relationField = fieldSchema as any;
573
+ const isManyRelation = relationField.relation?.includes('ToMany') || relationField.relation?.includes('manyToMany');
574
+
575
+ if (isManyRelation) {
576
+ const selectedIds = Array.isArray(value)
577
+ ? value.map((item: any) => (typeof item === 'object' && item?.id ? item.id : typeof item === 'number' ? item : null)).filter((id): id is number => id !== null)
578
+ : [];
579
+
580
+ const selectedItems = options.filter((opt) =>
581
+ selectedIds.includes(opt.id)
582
+ );
583
+
584
+ return (
585
+ <div style={{ width: '100%', minWidth: '150px', ...disabledStyle }}>
586
+ {selectedItems.length > 0 && (
587
+ <div
588
+ style={{
589
+ display: 'flex',
590
+ flexWrap: 'wrap',
591
+ gap: '4px',
592
+ marginBottom: '4px',
593
+ }}
594
+ >
595
+ {selectedItems.map((item) => (
596
+ <span
597
+ key={item.documentId || item.id}
598
+ style={{
599
+ display: 'inline-flex',
600
+ alignItems: 'center',
601
+ gap: '4px',
602
+ padding: '2px 6px',
603
+ backgroundColor: '#e0f2ff',
604
+ border: '1px solid #4945ff',
605
+ borderRadius: '12px',
606
+ fontSize: '12px',
607
+ color: '#4945ff',
608
+ }}
609
+ >
610
+ {item.title || item.name || `#${item.id}`}
611
+ <button
612
+ onClick={() => {
613
+ handleManyToManyChange(docId, field, item.id, 'remove');
614
+ }}
615
+ style={{
616
+ border: 'none',
617
+ background: 'none',
618
+ cursor: 'pointer',
619
+ padding: 0,
620
+ fontSize: '14px',
621
+ color: '#4945ff',
622
+ fontWeight: 'bold',
623
+ }}
624
+ >
625
+ ×
626
+ </button>
627
+ </span>
628
+ ))}
629
+ </div>
630
+ )}
631
+
632
+ <select
633
+ value=""
634
+ onChange={(e) => {
635
+ const selectedId = e.target.value ? parseInt(e.target.value, 10) : null;
636
+ if (selectedId) {
637
+ handleManyToManyChange(docId, field, selectedId, 'add');
638
+ }
639
+ }}
640
+ style={{
641
+ width: '100%',
642
+ padding: '8px',
643
+ border: '1px solid #dcdce4',
644
+ borderRadius: '4px',
645
+ fontSize: '14px',
646
+ backgroundColor: isLoading ? '#f6f6f9' : 'white',
647
+ cursor: 'default',
648
+ }}
649
+ disabled={isLoading}
650
+ >
651
+ <option value="">+ Add...</option>
652
+ {options
653
+ .filter((opt) => !selectedIds.includes(opt.id))
654
+ .map((option) => (
655
+ <option key={option.documentId || option.id} value={option.id}>
656
+ {option.title || option.name || `#${option.id}`}
657
+ </option>
658
+ ))}
659
+ </select>
660
+ </div>
661
+ );
662
+ }
663
+
664
+ const currentValue = typeof value === 'object' && value?.id
665
+ ? value.id
666
+ : typeof value === 'number'
667
+ ? value
668
+ : '';
669
+
670
+ return (
671
+ <select
672
+ value={currentValue}
673
+ onChange={(e) => {
674
+ const selectedId = e.target.value ? parseInt(e.target.value, 10) : null;
675
+ onChange(selectedId);
676
+ }}
677
+ style={{
678
+ width: '100%',
679
+ minWidth: '120px',
680
+ padding: '8px',
681
+ border: '1px solid #dcdce4',
682
+ borderRadius: '4px',
683
+ fontSize: '14px',
684
+ backgroundColor: isLoading ? '#f6f6f9' : 'white',
685
+ cursor: 'default',
686
+ ...disabledStyle,
687
+ }}
688
+ disabled={isLoading}
689
+ >
690
+ <option value=""></option>
691
+ {options.map((option) => (
692
+ <option key={option.documentId || option.id} value={option.id}>
693
+ {option.title || option.name || `#${(option.documentId || option.id).slice(0, 8)}`}
694
+ </option>
695
+ ))}
696
+ </select>
697
+ );
698
+ }
699
+
700
+ // Handle enumeration fields
701
+ if (fieldSchema?.type === 'enumeration' && fieldSchema.enum) {
702
+ return (
703
+ <select
704
+ value={value ?? ''}
705
+ onChange={(e) => onChange(e.target.value || null)}
706
+ style={{
707
+ width: '100%',
708
+ minWidth: '120px',
709
+ padding: '8px',
710
+ border: '1px solid #dcdce4',
711
+ borderRadius: '4px',
712
+ fontSize: '14px',
713
+ backgroundColor: isLoading ? '#f6f6f9' : 'white',
714
+ cursor: 'default',
715
+ ...disabledStyle,
716
+ }}
717
+ disabled={isLoading}
718
+ >
719
+ <option value=""></option>
720
+ {fieldSchema.enum.map((option) => (
721
+ <option key={option} value={option}>
722
+ {option}
723
+ </option>
724
+ ))}
725
+ </select>
726
+ );
727
+ }
728
+
729
+ // Handle media fields (read-only preview)
730
+ if (fieldSchema?.type === 'media') {
731
+ const mediaUrl = typeof value === 'object' && value?.url
732
+ ? value.url
733
+ : typeof value === 'string'
734
+ ? value
735
+ : null;
736
+
737
+ return (
738
+ <div
739
+ style={{
740
+ display: 'flex',
741
+ alignItems: 'center',
742
+ justifyContent: 'center',
743
+ width: '40px',
744
+ height: '40px',
745
+ borderRadius: '50%',
746
+ backgroundColor: '#f6f6f9',
747
+ border: '1px solid #dcdce4',
748
+ overflow: 'hidden',
749
+ margin: '0 auto',
750
+ ...disabledStyle,
751
+ }}
752
+ >
753
+ {mediaUrl ? (
754
+ <img
755
+ src={mediaUrl}
756
+ alt="Preview"
757
+ style={{
758
+ width: '100%',
759
+ height: '100%',
760
+ objectFit: 'cover',
761
+ }}
762
+ />
763
+ ) : (
764
+ <Typography variant="pi" textColor="neutral400">
765
+
766
+ </Typography>
767
+ )}
768
+ </div>
769
+ );
770
+ }
771
+
772
+ // Handle boolean fields
773
+ if (fieldSchema?.type === 'boolean' || typeof value === 'boolean') {
774
+ return (
775
+ <div
776
+ style={{
777
+ display: 'flex',
778
+ alignItems: 'center',
779
+ justifyContent: 'center',
780
+ width: '100%',
781
+ height: '100%',
782
+ ...disabledStyle,
783
+ }}
784
+ >
785
+ <input
786
+ type="checkbox"
787
+ checked={value || false}
788
+ onChange={(e) => onChange(e.target.checked)}
789
+ style={{
790
+ width: '20px',
791
+ height: '20px',
792
+ cursor: isLoading ? 'default' : 'pointer',
793
+ }}
794
+ disabled={isLoading}
795
+ />
796
+ </div>
797
+ );
798
+ }
799
+
800
+ // Handle date/datetime fields
801
+ if (fieldSchema?.type === 'date' || fieldSchema?.type === 'datetime') {
802
+ const dateValue = value ? new Date(value).toISOString().slice(0, 10) : '';
803
+ return (
804
+ <input
805
+ type="date"
806
+ value={dateValue}
807
+ onChange={(e) => onChange(e.target.value ? new Date(e.target.value).toISOString() : null)}
808
+ style={{
809
+ width: '100%',
810
+ minWidth: '140px',
811
+ padding: '8px',
812
+ border: '1px solid #dcdce4',
813
+ borderRadius: '4px',
814
+ fontSize: '14px',
815
+ backgroundColor: isLoading ? '#f6f6f9' : 'white',
816
+ ...disabledStyle,
817
+ }}
818
+ disabled={isLoading}
819
+ />
820
+ );
821
+ }
822
+
823
+ // Handle number fields
824
+ if (
825
+ fieldSchema?.type === 'integer' ||
826
+ fieldSchema?.type === 'biginteger' ||
827
+ fieldSchema?.type === 'decimal' ||
828
+ fieldSchema?.type === 'float' ||
829
+ typeof value === 'number'
830
+ ) {
831
+ return (
832
+ <input
833
+ type="number"
834
+ value={value ?? ''}
835
+ onChange={(e) => {
836
+ const parsed = parseFloat(e.target.value);
837
+ onChange(isNaN(parsed) ? null : parsed);
838
+ }}
839
+ step={fieldSchema?.type === 'decimal' || fieldSchema?.type === 'float' ? '0.01' : '1'}
840
+ style={{
841
+ width: '100%',
842
+ minWidth: '120px',
843
+ padding: '8px',
844
+ border: '1px solid #dcdce4',
845
+ borderRadius: '4px',
846
+ fontSize: '14px',
847
+ backgroundColor: isLoading ? '#f6f6f9' : 'white',
848
+ ...disabledStyle,
849
+ }}
850
+ disabled={isLoading}
851
+ />
852
+ );
853
+ }
854
+
855
+ // Handle text/string fields (default)
856
+ return (
857
+ <input
858
+ type="text"
859
+ value={value ?? ''}
860
+ onChange={(e) => onChange(e.target.value)}
861
+ style={{
862
+ width: '100%',
863
+ minWidth: '120px',
864
+ padding: '8px',
865
+ border: '1px solid #dcdce4',
866
+ borderRadius: '4px',
867
+ fontSize: '14px',
868
+ backgroundColor: isLoading ? '#f6f6f9' : 'white',
869
+ ...disabledStyle,
870
+ }}
871
+ disabled={isLoading}
872
+ />
873
+ );
874
+ };
875
+
876
+ const handleClose = () => {
877
+ if (hasUnsavedChanges) {
878
+ setShowExitConfirm(true);
879
+ } else {
880
+ onClose();
881
+ }
882
+ };
883
+
884
+ const handleConfirmExit = () => {
885
+ setShowExitConfirm(false);
886
+ onClose();
887
+ };
888
+
889
+ const handleCancelExit = () => {
890
+ setShowExitConfirm(false);
891
+ };
892
+
893
+ const handleSave = async (shouldPublish: boolean = false) => {
894
+ setSaving(true);
895
+ try {
896
+ const updates = Object.entries(editedEntries).map(([id, data]) => {
897
+ const cleanData: Record<string, any> = {};
898
+
899
+ const excludeFields = ['id', 'documentId', 'createdAt', 'updatedAt', 'publishedAt',
900
+ 'createdBy', 'updatedBy', 'locale', 'localizations', 'status'];
901
+
902
+ fields.forEach((field) => {
903
+ if (field in data && !excludeFields.includes(field)) {
904
+ let value = data[field];
905
+
906
+ const fieldSchema = getFieldType(field);
907
+
908
+ if (fieldSchema?.type === 'media') {
909
+ return;
910
+ }
911
+
912
+ if (fieldSchema?.type === 'relation') {
913
+ const relationField = fieldSchema as any;
914
+ const isManyRelation = relationField.relation?.includes('ToMany') || relationField.relation?.includes('manyToMany');
915
+
916
+ if (isManyRelation) {
917
+ if (Array.isArray(value)) {
918
+ value = value.map((item: any) =>
919
+ typeof item === 'object' && item?.id ? item.id : typeof item === 'number' ? item : null
920
+ ).filter((id): id is number => id !== null);
921
+ } else {
922
+ value = [];
923
+ }
924
+ } else {
925
+ if (typeof value === 'object' && value?.id) {
926
+ value = value.id;
927
+ }
928
+ if (value === '' || value === null || value === undefined) {
929
+ value = null;
930
+ }
931
+ }
932
+ }
933
+
934
+ cleanData[field] = value;
935
+ }
936
+ });
937
+
938
+ return {
939
+ id,
940
+ data: cleanData,
941
+ };
942
+ });
943
+
944
+ const response = await fetchClient.post('/bulk-editor/bulk-update', {
945
+ contentType,
946
+ updates,
947
+ publish: shouldPublish,
948
+ });
949
+
950
+ if (response.data?.success) {
951
+ const successCount = response.data.results.filter((r: any) => r.success).length;
952
+ const failCount = response.data.results.filter((r: any) => !r.success).length;
953
+ const action = shouldPublish ? 'Published' : 'Updated';
954
+
955
+ if (notificationFn && typeof notificationFn.toggleNotification === 'function') {
956
+ notificationFn.toggleNotification({
957
+ type: successCount === updates.length ? 'success' : 'warning',
958
+ message: `${action} ${successCount} entries${failCount > 0 ? `, ${failCount} failed` : ''}`,
959
+ });
960
+ } else if (typeof notificationFn === 'function') {
961
+ notificationFn({
962
+ type: successCount === updates.length ? 'success' : 'warning',
963
+ message: `${action} ${successCount} entries${failCount > 0 ? `, ${failCount} failed` : ''}`,
964
+ });
965
+ }
966
+
967
+ setHasUnsavedChanges(false);
968
+ onClose();
969
+ window.location.reload();
970
+ }
971
+ } catch (error: any) {
972
+ console.error('Bulk update error:', error);
973
+
974
+ if (notificationFn && typeof notificationFn.toggleNotification === 'function') {
975
+ notificationFn.toggleNotification({
976
+ type: 'danger',
977
+ message: error?.response?.data?.error?.message || error?.message || 'Failed to update entries',
978
+ });
979
+ } else if (typeof notificationFn === 'function') {
980
+ notificationFn({
981
+ type: 'danger',
982
+ message: error?.response?.data?.error?.message || error?.message || 'Failed to update entries',
983
+ });
984
+ }
985
+ } finally {
986
+ setSaving(false);
987
+ }
988
+ };
989
+
990
+ return (
991
+ <>
992
+ <Modal.Root open onOpenChange={handleClose}>
993
+ <Modal.Content
994
+ style={{
995
+ width: '90vw',
996
+ maxWidth: '1400px',
997
+ }}
998
+ >
999
+ <Modal.Header>
1000
+ <Box>
1001
+ <Typography fontWeight="bold" textColor="neutral800" as="h2" id="title">
1002
+ Bulk Edit - {documents.length} {documents.length === 1 ? 'entry' : 'entries'}
1003
+ </Typography>
1004
+ <Typography variant="omega" textColor="neutral600" marginTop={1}>
1005
+ Drag the corner handle to fill values down. Cmd+click cells in same column to select multiple.
1006
+ {(schemaLoading || populatingRelations) && ' Loading...'}
1007
+ </Typography>
1008
+ {!schemaLoading && (
1009
+ <Typography variant="pi" textColor="neutral500" marginTop={2}>
1010
+ Note: Media fields are read-only previews. Edit them individually in each entry.
1011
+ </Typography>
1012
+ )}
1013
+ </Box>
1014
+ </Modal.Header>
1015
+
1016
+ <Modal.Body
1017
+ style={{
1018
+ padding: 0,
1019
+ margin: 0,
1020
+ }}
1021
+ >
1022
+ <Box
1023
+ style={{
1024
+ maxHeight: '60vh',
1025
+ overflow: 'auto',
1026
+ border: '1px solid #dcdce4',
1027
+ borderRadius: '4px',
1028
+ padding: 0,
1029
+ margin: 0,
1030
+ }}
1031
+ >
1032
+ {fields.length === 0 ? (
1033
+ <Typography style={{ padding: '20px' }}>No editable fields found for these entries.</Typography>
1034
+ ) : (
1035
+ <Table
1036
+ colCount={fields.length + 1}
1037
+ rowCount={documents.length + 1}
1038
+ style={{
1039
+ width: '100%',
1040
+ tableLayout: 'auto',
1041
+ }}
1042
+ >
1043
+ <Thead
1044
+ style={{
1045
+ position: 'sticky',
1046
+ top: 0,
1047
+ backgroundColor: '#f6f6f9',
1048
+ zIndex: 1,
1049
+ }}
1050
+ >
1051
+ <Tr>
1052
+ <Th
1053
+ style={{
1054
+ borderRight: '1px solid #dcdce4',
1055
+ borderBottom: '2px solid #dcdce4',
1056
+ backgroundColor: '#f6f6f9',
1057
+ width: '100px',
1058
+ }}
1059
+ >
1060
+ <Typography variant="sigma" textColor="neutral600">
1061
+ ID
1062
+ </Typography>
1063
+ </Th>
1064
+ {fields.map((field) => {
1065
+ const fieldSchema = getFieldType(field);
1066
+ const headerTextWidth = field.length * 8 + 20;
1067
+
1068
+ let minWidth = Math.max(headerTextWidth, 100);
1069
+ let maxWidth = 'auto';
1070
+
1071
+ if (fieldSchema?.type === 'boolean') {
1072
+ minWidth = Math.max(headerTextWidth, 100);
1073
+ maxWidth = '120px';
1074
+ } else if (fieldSchema?.type === 'media') {
1075
+ minWidth = Math.max(headerTextWidth, 100);
1076
+ maxWidth = '120px';
1077
+ } else if (fieldSchema?.type === 'date' || fieldSchema?.type === 'datetime') {
1078
+ minWidth = Math.max(headerTextWidth, 160);
1079
+ maxWidth = '180px';
1080
+ } else if (fieldSchema?.type === 'integer' || fieldSchema?.type === 'biginteger') {
1081
+ minWidth = Math.max(headerTextWidth, 100);
1082
+ maxWidth = '150px';
1083
+ } else {
1084
+ minWidth = Math.max(headerTextWidth, 150);
1085
+ maxWidth = '300px';
1086
+ }
1087
+
1088
+ return (
1089
+ <Th
1090
+ key={field}
1091
+ style={{
1092
+ borderRight: '1px solid #dcdce4',
1093
+ borderBottom: '2px solid #dcdce4',
1094
+ backgroundColor: '#f6f6f9',
1095
+ minWidth: `${minWidth}px`,
1096
+ maxWidth: maxWidth,
1097
+ whiteSpace: 'nowrap',
1098
+ overflow: 'hidden',
1099
+ textOverflow: 'ellipsis',
1100
+ }}
1101
+ >
1102
+ <Typography variant="sigma" textColor="neutral600">
1103
+ {field}
1104
+ </Typography>
1105
+ </Th>
1106
+ );
1107
+ })}
1108
+ </Tr>
1109
+ </Thead>
1110
+ <Tbody>
1111
+ {documents.map((doc) => {
1112
+ const docId = doc.documentId || doc.id;
1113
+ if (!docId) return null;
1114
+
1115
+ const editedDoc = editedEntries[docId] || doc;
1116
+
1117
+ return (
1118
+ <Tr key={docId}>
1119
+ <Td
1120
+ style={{
1121
+ borderRight: '1px solid #dcdce4',
1122
+ borderBottom: '1px solid #dcdce4',
1123
+ backgroundColor: '#fafafa',
1124
+ fontWeight: 500,
1125
+ }}
1126
+ >
1127
+ <Typography textColor="neutral800" variant="omega">
1128
+ {String(docId).slice(0, 8)}...
1129
+ </Typography>
1130
+ </Td>
1131
+ {fields.map((field) => {
1132
+ const fieldSchema = getFieldType(field);
1133
+ const isSelected = isInDragSelection(docId, field) || isCellSelected(docId, field);
1134
+ const isMediaField = fieldSchema?.type === 'media';
1135
+ const isHovered = hoveredCell?.docId === docId && hoveredCell?.field === field;
1136
+
1137
+ return (
1138
+ <Td
1139
+ key={field}
1140
+ onClick={(e) => handleCellClick(e, docId, field)}
1141
+ onMouseEnter={() => {
1142
+ setHoveredCell({ docId, field });
1143
+ if (!isMediaField) handleDragOver(docId, field);
1144
+ }}
1145
+ onMouseLeave={() => setHoveredCell(null)}
1146
+ style={{
1147
+ position: 'relative',
1148
+ backgroundColor: isSelected ? '#e0f2ff' : 'white',
1149
+ borderRight: '1px solid #dcdce4',
1150
+ borderBottom: '1px solid #dcdce4',
1151
+ padding: '4px',
1152
+ userSelect: (dragStart || selectedCells.size > 0) ? 'none' : 'auto',
1153
+ minHeight: '48px',
1154
+ verticalAlign: 'middle',
1155
+ cursor: 'default',
1156
+ }}
1157
+ >
1158
+ <div style={{ position: 'relative' }}>
1159
+ {renderFieldInput(docId, field, editedDoc[field], fieldSchema, populatingRelations)}
1160
+
1161
+ {/* Drag handle - only show on hover, not for media fields */}
1162
+ {!isMediaField && isHovered && !populatingRelations && (
1163
+ <div
1164
+ onMouseDown={(e) => handleDragStart(e, docId, field)}
1165
+ onMouseUp={handleDragEnd}
1166
+ style={{
1167
+ position: 'absolute',
1168
+ bottom: '2px',
1169
+ right: '2px',
1170
+ width: '8px',
1171
+ height: '8px',
1172
+ backgroundColor: '#4945ff',
1173
+ cursor: 'crosshair',
1174
+ borderRadius: '1px',
1175
+ }}
1176
+ />
1177
+ )}
1178
+ </div>
1179
+ </Td>
1180
+ );
1181
+ })}
1182
+ </Tr>
1183
+ );
1184
+ })}
1185
+ </Tbody>
1186
+ </Table>
1187
+ )}
1188
+ </Box>
1189
+ </Modal.Body>
1190
+
1191
+ <Modal.Footer
1192
+ style={{
1193
+ display: 'flex',
1194
+ justifyContent: 'flex-end',
1195
+ gap: '8px',
1196
+ }}
1197
+ >
1198
+ {schema?.options?.draftAndPublish ? (
1199
+ <>
1200
+ <Button
1201
+ onClick={() => handleSave(false)}
1202
+ variant="secondary"
1203
+ loading={saving}
1204
+ disabled={fields.length === 0 || populatingRelations}
1205
+ >
1206
+ Save All
1207
+ </Button>
1208
+ <Button
1209
+ onClick={() => handleSave(true)}
1210
+ loading={saving}
1211
+ disabled={fields.length === 0 || populatingRelations}
1212
+ >
1213
+ Publish All
1214
+ </Button>
1215
+ </>
1216
+ ) : (
1217
+ <Button onClick={() => handleSave(false)} loading={saving} disabled={fields.length === 0 || populatingRelations}>
1218
+ Save All Changes
1219
+ </Button>
1220
+ )}
1221
+ </Modal.Footer>
1222
+ </Modal.Content>
1223
+ </Modal.Root>
1224
+
1225
+ {/* Exit confirmation dialog */}
1226
+ {showExitConfirm && (
1227
+ <Modal.Root open onOpenChange={handleCancelExit}>
1228
+ <Modal.Content>
1229
+ <Modal.Header>
1230
+ <Typography fontWeight="bold" textColor="neutral800" as="h2">
1231
+ Unsaved Changes
1232
+ </Typography>
1233
+ </Modal.Header>
1234
+ <Modal.Body>
1235
+ <Typography>
1236
+ You have unsaved changes. Are you sure you want to exit without saving?
1237
+ </Typography>
1238
+ </Modal.Body>
1239
+ <Modal.Footer>
1240
+ <Button onClick={handleCancelExit} variant="tertiary">
1241
+ Continue Editing
1242
+ </Button>
1243
+ <Button onClick={handleConfirmExit} variant="danger">
1244
+ Exit Without Saving
1245
+ </Button>
1246
+ </Modal.Footer>
1247
+ </Modal.Content>
1248
+ </Modal.Root>
1249
+ )}
1250
+ </>
1251
+ );
1252
+ };