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,46 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Button } from '@strapi/design-system';
|
|
3
|
+
import { Pencil } from '@strapi/icons';
|
|
4
|
+
import { BulkEditModal } from './BulkEditModal';
|
|
5
|
+
|
|
6
|
+
interface BulkEditorActionProps {
|
|
7
|
+
documents: any[];
|
|
8
|
+
model: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const BulkEditorAction: React.FC<BulkEditorActionProps> = ({
|
|
12
|
+
documents = [],
|
|
13
|
+
model = '',
|
|
14
|
+
}) => {
|
|
15
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
16
|
+
|
|
17
|
+
const handleClick = () => {
|
|
18
|
+
if (documents.length === 0) return;
|
|
19
|
+
setIsOpen(true);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const handleClose = () => {
|
|
23
|
+
setIsOpen(false);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<Button
|
|
29
|
+
onClick={handleClick}
|
|
30
|
+
variant="secondary"
|
|
31
|
+
size="S"
|
|
32
|
+
startIcon={<Pencil />}
|
|
33
|
+
>
|
|
34
|
+
Bulk Edit
|
|
35
|
+
</Button>
|
|
36
|
+
|
|
37
|
+
{isOpen && (
|
|
38
|
+
<BulkEditModal
|
|
39
|
+
documents={documents}
|
|
40
|
+
contentType={model}
|
|
41
|
+
onClose={handleClose}
|
|
42
|
+
/>
|
|
43
|
+
)}
|
|
44
|
+
</>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Button } from '@strapi/design-system';
|
|
3
|
+
import { Pencil } from '@strapi/icons';
|
|
4
|
+
import { BulkEditModal } from './BulkEditModal';
|
|
5
|
+
|
|
6
|
+
interface BulkEditorButtonProps {
|
|
7
|
+
selectedEntries?: any[];
|
|
8
|
+
model?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const BulkEditorButton: React.FC<BulkEditorButtonProps> = (props) => {
|
|
12
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
13
|
+
|
|
14
|
+
// Try to get selected entries from props or context
|
|
15
|
+
const selectedEntries = props.selectedEntries || [];
|
|
16
|
+
const model = props.model || '';
|
|
17
|
+
|
|
18
|
+
const handleClick = () => {
|
|
19
|
+
if (selectedEntries && selectedEntries.length > 0) {
|
|
20
|
+
setIsOpen(true);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Don't render if no entries are selected
|
|
25
|
+
if (!selectedEntries || selectedEntries.length === 0) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<Button
|
|
32
|
+
onClick={handleClick}
|
|
33
|
+
variant="secondary"
|
|
34
|
+
size="S"
|
|
35
|
+
startIcon={<Pencil />}
|
|
36
|
+
>
|
|
37
|
+
Bulk Edit
|
|
38
|
+
</Button>
|
|
39
|
+
|
|
40
|
+
{isOpen && (
|
|
41
|
+
<BulkEditModal
|
|
42
|
+
entries={selectedEntries}
|
|
43
|
+
contentType={model}
|
|
44
|
+
onClose={() => setIsOpen(false)}
|
|
45
|
+
/>
|
|
46
|
+
)}
|
|
47
|
+
</>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { BulkEditModal } from './BulkEditModal';
|
|
4
|
+
|
|
5
|
+
interface ModalData {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
documents: any[];
|
|
8
|
+
model: string;
|
|
9
|
+
notificationFn?: any;
|
|
10
|
+
fetchClient?: any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Event emitter for modal
|
|
14
|
+
const modalEmitter = {
|
|
15
|
+
listeners: [] as Array<(data: ModalData) => void>,
|
|
16
|
+
emit(data: ModalData) {
|
|
17
|
+
this.listeners.forEach(listener => listener(data));
|
|
18
|
+
},
|
|
19
|
+
subscribe(listener: (data: ModalData) => void) {
|
|
20
|
+
this.listeners.push(listener);
|
|
21
|
+
return () => {
|
|
22
|
+
this.listeners = this.listeners.filter(l => l !== listener);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const openBulkEditModal = (documents: any[], model: string, notificationFn: any, fetchClient: any) => {
|
|
28
|
+
modalEmitter.emit({ isOpen: true, documents, model, notificationFn, fetchClient });
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const ModalManager: React.FC = () => {
|
|
32
|
+
const [modalState, setModalState] = useState<ModalData>({
|
|
33
|
+
isOpen: false,
|
|
34
|
+
documents: [],
|
|
35
|
+
model: '',
|
|
36
|
+
notificationFn: null,
|
|
37
|
+
fetchClient: null,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const unsubscribe = modalEmitter.subscribe((data) => {
|
|
42
|
+
setModalState(data);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return unsubscribe;
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
if (!modalState.isOpen || !modalState.notificationFn || !modalState.fetchClient) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return createPortal(
|
|
53
|
+
<BulkEditModal
|
|
54
|
+
documents={modalState.documents}
|
|
55
|
+
contentType={modalState.model}
|
|
56
|
+
onClose={() => setModalState({ isOpen: false, documents: [], model: '', notificationFn: null, fetchClient: null })}
|
|
57
|
+
notificationFn={modalState.notificationFn}
|
|
58
|
+
fetchClient={modalState.fetchClient}
|
|
59
|
+
/>,
|
|
60
|
+
document.body
|
|
61
|
+
);
|
|
62
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { DesignSystemProvider } from '@strapi/design-system';
|
|
2
|
+
import { Pencil } from '@strapi/icons';
|
|
3
|
+
import { useFetchClient, useNotification } from '@strapi/strapi/admin';
|
|
4
|
+
import ReactDOM from 'react-dom/client';
|
|
5
|
+
import { ModalManager, openBulkEditModal } from './components/ModalManager';
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
register(app: any) {
|
|
9
|
+
app.registerPlugin({
|
|
10
|
+
id: 'bulk-editor',
|
|
11
|
+
name: 'Bulk Editor',
|
|
12
|
+
});
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
bootstrap(app: any) {
|
|
16
|
+
// Create a div for the modal manager and render it once
|
|
17
|
+
const modalRoot = document.createElement('div');
|
|
18
|
+
modalRoot.id = 'bulk-editor-modal-root';
|
|
19
|
+
document.body.appendChild(modalRoot);
|
|
20
|
+
|
|
21
|
+
const root = ReactDOM.createRoot(modalRoot);
|
|
22
|
+
root.render(
|
|
23
|
+
<DesignSystemProvider locale="en">
|
|
24
|
+
<ModalManager />
|
|
25
|
+
</DesignSystemProvider>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const contentManager = app.getPlugin('content-manager');
|
|
29
|
+
|
|
30
|
+
if (contentManager && contentManager.apis) {
|
|
31
|
+
contentManager.apis.addBulkAction([
|
|
32
|
+
function BulkEditAction({ documents, model }: { documents: any[]; model: string }) {
|
|
33
|
+
// Access hooks here where we have proper context
|
|
34
|
+
const toggleNotification = useNotification();
|
|
35
|
+
const fetchClient = useFetchClient();
|
|
36
|
+
|
|
37
|
+
const handleClick = () => {
|
|
38
|
+
openBulkEditModal(documents, model, toggleNotification, fetchClient);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Return props object that works
|
|
42
|
+
return {
|
|
43
|
+
label: 'Bulk Edit',
|
|
44
|
+
icon: <Pencil />,
|
|
45
|
+
onClick: handleClick,
|
|
46
|
+
children: 'Bulk Edit',
|
|
47
|
+
variant: 'secondary',
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async registerTrads(app: any) {
|
|
55
|
+
return [];
|
|
56
|
+
},
|
|
57
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "strapi-plugin-bulk-editor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bulk edit Strapi entries in a spreadsheet-like interface",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "John Chedeville <hi@jchd.me>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/JTCHE/strapi-plugin-bulk-editor"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"strapi",
|
|
13
|
+
"strapi-plugin",
|
|
14
|
+
"bulk-edit",
|
|
15
|
+
"spreadsheet",
|
|
16
|
+
"batch-update"
|
|
17
|
+
],
|
|
18
|
+
"strapi": {
|
|
19
|
+
"name": "bulk-editor",
|
|
20
|
+
"displayName": "Bulk Editor",
|
|
21
|
+
"description": "Edit multiple entries at once in a spreadsheet view",
|
|
22
|
+
"kind": "plugin"
|
|
23
|
+
},
|
|
24
|
+
"main": "./strapi-server.js",
|
|
25
|
+
"exports": {
|
|
26
|
+
"./strapi-admin": {
|
|
27
|
+
"source": "./admin/src/index.tsx",
|
|
28
|
+
"require": "./strapi-admin.js"
|
|
29
|
+
},
|
|
30
|
+
"./strapi-server": {
|
|
31
|
+
"source": "./server/index.js",
|
|
32
|
+
"require": "./strapi-server.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"admin",
|
|
37
|
+
"server",
|
|
38
|
+
"strapi-admin.js",
|
|
39
|
+
"strapi-server.js"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@strapi/strapi": "^5.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
// Fetch documents with all relations populated
|
|
3
|
+
async getPopulated(ctx) {
|
|
4
|
+
const { contentType, documentIds } = ctx.request.body;
|
|
5
|
+
|
|
6
|
+
if (!contentType || !documentIds || !Array.isArray(documentIds)) {
|
|
7
|
+
return ctx.badRequest('Missing or invalid contentType or documentIds');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const results = [];
|
|
12
|
+
|
|
13
|
+
for (const docId of documentIds) {
|
|
14
|
+
try {
|
|
15
|
+
const doc = await strapi.documents(contentType).findOne({
|
|
16
|
+
documentId: docId,
|
|
17
|
+
populate: '*',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (doc) {
|
|
21
|
+
results.push(doc);
|
|
22
|
+
}
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error(`[BULK-EDITOR] Failed to fetch ${docId}:`, error.message);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return ctx.send({
|
|
29
|
+
success: true,
|
|
30
|
+
documents: results,
|
|
31
|
+
});
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error('[BULK-EDITOR] Fatal error:', error);
|
|
34
|
+
return ctx.internalServerError('Failed to fetch documents', { error: error.message });
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async bulkUpdate(ctx) {
|
|
39
|
+
const { contentType, updates, publish } = ctx.request.body;
|
|
40
|
+
|
|
41
|
+
if (!contentType || !updates || !Array.isArray(updates)) {
|
|
42
|
+
return ctx.badRequest('Missing or invalid contentType or updates');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const results = [];
|
|
47
|
+
|
|
48
|
+
// Get the content type schema to identify relation fields
|
|
49
|
+
const contentTypeSchema = strapi.contentType(contentType);
|
|
50
|
+
const relationFields = Object.entries(contentTypeSchema.attributes || {})
|
|
51
|
+
.filter(([_, attr]) => attr.type === 'relation')
|
|
52
|
+
.map(([name, attr]) => ({ name, relation: attr.relation, mappedBy: attr.mappedBy, inversedBy: attr.inversedBy }));
|
|
53
|
+
|
|
54
|
+
// Collect mappedBy fields that need to be updated from the owning side
|
|
55
|
+
const mappedByUpdates = new Map();
|
|
56
|
+
|
|
57
|
+
// Process each update
|
|
58
|
+
for (const update of updates) {
|
|
59
|
+
const { id, data } = update;
|
|
60
|
+
|
|
61
|
+
if (!id || !data) {
|
|
62
|
+
results.push({ id, success: false, error: 'Missing id or data' });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Transform relation fields to use Strapi v5's connect/disconnect/set syntax
|
|
67
|
+
const transformedData = { ...data };
|
|
68
|
+
|
|
69
|
+
relationFields.forEach(({ name, relation, mappedBy }) => {
|
|
70
|
+
if (name in transformedData) {
|
|
71
|
+
const value = transformedData[name];
|
|
72
|
+
const fieldAttr = contentTypeSchema.attributes[name];
|
|
73
|
+
const isManyRelation = relation.includes('ToMany') || relation === 'manyToMany';
|
|
74
|
+
|
|
75
|
+
// mappedBy fields are READ-ONLY - must update from owning side
|
|
76
|
+
if (mappedBy) {
|
|
77
|
+
const targetContentType = fieldAttr.target;
|
|
78
|
+
|
|
79
|
+
// Remove from this update - we'll handle it separately
|
|
80
|
+
delete transformedData[name];
|
|
81
|
+
|
|
82
|
+
// Schedule update on the owning side (target content type)
|
|
83
|
+
if (value !== null && value !== undefined && value !== '') {
|
|
84
|
+
if (!mappedByUpdates.has(targetContentType)) {
|
|
85
|
+
mappedByUpdates.set(targetContentType, new Map());
|
|
86
|
+
}
|
|
87
|
+
const targetUpdates = mappedByUpdates.get(targetContentType);
|
|
88
|
+
const targetDocId = String(value);
|
|
89
|
+
|
|
90
|
+
if (!targetUpdates.has(targetDocId)) {
|
|
91
|
+
targetUpdates.set(targetDocId, []);
|
|
92
|
+
}
|
|
93
|
+
targetUpdates.get(targetDocId).push({ field: mappedBy, targetDocId: id });
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// For owning side relations, use { set: [...] }
|
|
99
|
+
if (isManyRelation) {
|
|
100
|
+
const ids = Array.isArray(value) ? value : [];
|
|
101
|
+
transformedData[name] = { set: ids };
|
|
102
|
+
} else {
|
|
103
|
+
if (value === null || value === undefined || value === '') {
|
|
104
|
+
transformedData[name] = { set: [] };
|
|
105
|
+
} else {
|
|
106
|
+
transformedData[name] = { set: [value] };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const updateOptions = {
|
|
114
|
+
documentId: id,
|
|
115
|
+
data: transformedData,
|
|
116
|
+
populate: '*',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (publish !== undefined) {
|
|
120
|
+
updateOptions.status = publish ? 'published' : 'draft';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const updated = await strapi.documents(contentType).update(updateOptions);
|
|
124
|
+
results.push({ id, success: true, data: updated });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error(`[BULK-EDITOR] Update failed for ${id}:`, error.message);
|
|
127
|
+
results.push({ id, success: false, error: error.message });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Update mappedBy relations from the owning side
|
|
132
|
+
if (mappedByUpdates.size > 0) {
|
|
133
|
+
for (const [targetContentType, targetUpdates] of mappedByUpdates.entries()) {
|
|
134
|
+
for (const [targetDocId, updates] of targetUpdates.entries()) {
|
|
135
|
+
// Group updates by field name to accumulate all IDs
|
|
136
|
+
const fieldToIds = new Map();
|
|
137
|
+
for (const { field, targetDocId: galleryId } of updates) {
|
|
138
|
+
if (!fieldToIds.has(field)) {
|
|
139
|
+
fieldToIds.set(field, []);
|
|
140
|
+
}
|
|
141
|
+
fieldToIds.get(field).push(galleryId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Build update data with all accumulated IDs per field
|
|
145
|
+
const updateData = {};
|
|
146
|
+
for (const [field, galleryIds] of fieldToIds.entries()) {
|
|
147
|
+
updateData[field] = { set: galleryIds };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await strapi.documents(targetContentType).update({
|
|
152
|
+
documentId: targetDocId,
|
|
153
|
+
data: updateData,
|
|
154
|
+
});
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error(`[BULK-EDITOR] Failed to update ${targetContentType}/${targetDocId}:`, error.message);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return ctx.send({
|
|
163
|
+
success: true,
|
|
164
|
+
results,
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error('[BULK-EDITOR] Fatal error:', error);
|
|
168
|
+
return ctx.internalServerError('Bulk update failed', { error: error.message });
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
};
|
package/server/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export default [
|
|
2
|
+
{
|
|
3
|
+
method: 'POST',
|
|
4
|
+
path: '/bulk-update',
|
|
5
|
+
handler: 'bulk-editor.bulkUpdate',
|
|
6
|
+
config: {
|
|
7
|
+
policies: [],
|
|
8
|
+
auth: false,
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
method: 'POST',
|
|
13
|
+
path: '/get-populated',
|
|
14
|
+
handler: 'bulk-editor.getPopulated',
|
|
15
|
+
config: {
|
|
16
|
+
policies: [],
|
|
17
|
+
auth: false,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default {};
|
package/strapi-admin.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './admin/src/index.tsx';
|
package/strapi-server.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './server/index.js';
|