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,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
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "plugin.name": "Bulk Editor",
3
+ "plugin.description": "Edit multiple entries at once"
4
+ }
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
+ };
@@ -0,0 +1,5 @@
1
+ import bulkEditor from './bulk-editor.js';
2
+
3
+ export default {
4
+ 'bulk-editor': bulkEditor,
5
+ };
@@ -0,0 +1,9 @@
1
+ import controllers from './controllers/index.js';
2
+ import routes from './routes/index.js';
3
+ import services from './services/index.js';
4
+
5
+ export default {
6
+ controllers,
7
+ routes,
8
+ services,
9
+ };
@@ -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 {};
@@ -0,0 +1 @@
1
+ export { default } from './admin/src/index.tsx';
@@ -0,0 +1 @@
1
+ export { default } from './server/index.js';