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.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/admin/src/components/BulkEditModal.tsx +1252 -0
- package/admin/src/components/BulkEditorAction.tsx +46 -0
- package/admin/src/components/BulkEditorButton.tsx +49 -0
- package/admin/src/components/ModalManager.tsx +62 -0
- package/admin/src/index.tsx +57 -0
- package/admin/src/translations/en.json +4 -0
- package/package.json +45 -0
- package/server/controllers/bulk-editor.js +171 -0
- package/server/controllers/index.js +5 -0
- package/server/index.js +9 -0
- package/server/routes/index.js +20 -0
- package/server/services/index.js +1 -0
- package/strapi-admin.js +1 -0
- package/strapi-server.js +1 -0
|
@@ -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
|
+
};
|