webspresso 0.0.37 → 0.0.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.37",
3
+ "version": "0.0.39",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -68,6 +68,10 @@ const state = {
68
68
  filters: {}, // Active filters { column: { op, value, from, to } }
69
69
  filterPanelOpen: false, // Filter panel visibility (deprecated)
70
70
  filterDrawerOpen: false, // Filter drawer visibility
71
+ bulkFields: [], // Bulk-updatable fields (enum/boolean)
72
+ bulkFieldDropdownOpen: false, // Bulk field dropdown visibility
73
+ selectedBulkField: null, // Currently selected bulk field for update
74
+ selectAllMode: false, // true = all records selected (not just current page)
71
75
  };
72
76
 
73
77
  // Breadcrumb Component
@@ -1303,6 +1307,165 @@ function formatCellValue(value, col) {
1303
1307
  }
1304
1308
  }
1305
1309
 
1310
+ // Load bulk-updatable fields for a model
1311
+ async function loadBulkFields(modelName) {
1312
+ try {
1313
+ const response = await api.get('/extensions/bulk-fields/' + modelName);
1314
+ state.bulkFields = response.fields || [];
1315
+ m.redraw();
1316
+ } catch (err) {
1317
+ console.error('Failed to load bulk fields:', err);
1318
+ state.bulkFields = [];
1319
+ }
1320
+ }
1321
+
1322
+ // Execute bulk field update
1323
+ async function executeBulkFieldUpdate(modelName, field, value, ids) {
1324
+ try {
1325
+ const response = await api.post('/extensions/bulk-update/' + modelName, {
1326
+ ids: ids,
1327
+ field: field,
1328
+ value: value,
1329
+ });
1330
+ return response;
1331
+ } catch (err) {
1332
+ throw err;
1333
+ }
1334
+ }
1335
+
1336
+ // Execute bulk field update
1337
+ async function executeBulkFieldUpdateWithSelectAll(modelName, field, value, selectedIds, selectAllMode, filters) {
1338
+ try {
1339
+ const payload = selectAllMode
1340
+ ? { selectAll: true, filters: filters, field: field, value: value }
1341
+ : { ids: selectedIds, field: field, value: value };
1342
+ const response = await api.post('/extensions/bulk-update/' + modelName, payload);
1343
+ return response;
1344
+ } catch (err) {
1345
+ throw err;
1346
+ }
1347
+ }
1348
+
1349
+ // Bulk Field Update Dropdown Component
1350
+ const BulkFieldUpdateDropdown = {
1351
+ view: (vnode) => {
1352
+ const { modelName, selectedIds, selectAllMode, filters, onComplete } = vnode.attrs;
1353
+
1354
+ if (!state.bulkFields || state.bulkFields.length === 0) {
1355
+ return null;
1356
+ }
1357
+
1358
+ return m('.relative.inline-block', [
1359
+ // Dropdown trigger
1360
+ m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-purple-600.bg-white.border.border-purple-200.rounded.hover:bg-purple-50.transition-colors', {
1361
+ disabled: state.bulkActionInProgress,
1362
+ onclick: (e) => {
1363
+ e.stopPropagation();
1364
+ state.bulkFieldDropdownOpen = !state.bulkFieldDropdownOpen;
1365
+ state.selectedBulkField = null;
1366
+ m.redraw();
1367
+ },
1368
+ }, [
1369
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1370
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z' })
1371
+ ),
1372
+ 'Set Field',
1373
+ m('svg.w-4.h-4.ml-1', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1374
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M19 9l-7 7-7-7' })
1375
+ ),
1376
+ ]),
1377
+
1378
+ // Dropdown menu
1379
+ state.bulkFieldDropdownOpen && m('.absolute.z-50.mt-1.w-64.bg-white.rounded-lg.shadow-lg.border.border-gray-200.overflow-hidden', {
1380
+ style: 'left: 0; top: 100%;',
1381
+ onclick: (e) => e.stopPropagation(),
1382
+ }, [
1383
+ // Close button area click handler
1384
+ m('.fixed.inset-0.z-40', {
1385
+ onclick: () => {
1386
+ state.bulkFieldDropdownOpen = false;
1387
+ state.selectedBulkField = null;
1388
+ m.redraw();
1389
+ },
1390
+ }),
1391
+
1392
+ // Dropdown content
1393
+ m('.relative.z-50.bg-white', [
1394
+ // Header
1395
+ m('.px-3.py-2.bg-gray-50.border-b.border-gray-200', [
1396
+ m('span.text-xs.font-medium.text-gray-500.uppercase.tracking-wider',
1397
+ state.selectedBulkField ? 'Select Value' : 'Select Field'
1398
+ ),
1399
+ ]),
1400
+
1401
+ // Field list or value list
1402
+ m('.max-h-64.overflow-y-auto', [
1403
+ state.selectedBulkField
1404
+ // Show values for selected field
1405
+ ? state.selectedBulkField.options.map(option =>
1406
+ m('button.w-full.px-3.py-2.text-left.text-sm.hover:bg-purple-50.flex.items-center.justify-between.transition-colors', {
1407
+ onclick: async () => {
1408
+ state.bulkActionInProgress = true;
1409
+ state.bulkFieldDropdownOpen = false;
1410
+ m.redraw();
1411
+
1412
+ try {
1413
+ await executeBulkFieldUpdateWithSelectAll(modelName, state.selectedBulkField.name, option.value, selectedIds, selectAllMode, filters);
1414
+ state.selectedBulkField = null;
1415
+ if (onComplete) onComplete();
1416
+ } catch (err) {
1417
+ alert('Error: ' + err.message);
1418
+ } finally {
1419
+ state.bulkActionInProgress = false;
1420
+ m.redraw();
1421
+ }
1422
+ },
1423
+ }, [
1424
+ m('span.text-gray-700', String(option.label)),
1425
+ state.selectedBulkField.type === 'boolean' && m('span.ml-2',
1426
+ option.value === true
1427
+ ? m('span.inline-flex.items-center.px-2.py-0.5.rounded-full.text-xs.font-medium.bg-green-100.text-green-800', '✓')
1428
+ : m('span.inline-flex.items-center.px-2.py-0.5.rounded-full.text-xs.font-medium.bg-gray-100.text-gray-600', '✗')
1429
+ ),
1430
+ ])
1431
+ )
1432
+ // Show field list
1433
+ : state.bulkFields.map(field =>
1434
+ m('button.w-full.px-3.py-2.text-left.text-sm.hover:bg-purple-50.flex.items-center.justify-between.transition-colors', {
1435
+ onclick: () => {
1436
+ state.selectedBulkField = field;
1437
+ m.redraw();
1438
+ },
1439
+ }, [
1440
+ m('.flex.items-center.gap-2', [
1441
+ m('span.text-gray-700', formatColumnLabel(field.label || field.name)),
1442
+ m('span.text-xs.text-gray-400.uppercase', field.type),
1443
+ ]),
1444
+ m('svg.w-4.h-4.text-gray-400', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1445
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 5l7 7-7 7' })
1446
+ ),
1447
+ ])
1448
+ ),
1449
+
1450
+ // Back button when viewing values
1451
+ state.selectedBulkField && m('button.w-full.px-3.py-2.text-left.text-sm.text-gray-500.hover:bg-gray-50.border-t.border-gray-100.flex.items-center.gap-1', {
1452
+ onclick: () => {
1453
+ state.selectedBulkField = null;
1454
+ m.redraw();
1455
+ },
1456
+ }, [
1457
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1458
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M15 19l-7-7 7-7' })
1459
+ ),
1460
+ 'Back to fields',
1461
+ ]),
1462
+ ]),
1463
+ ]),
1464
+ ]),
1465
+ ]);
1466
+ },
1467
+ };
1468
+
1306
1469
  // Get columns to display in table (limit to reasonable number)
1307
1470
  function getDisplayColumns(columns) {
1308
1471
  if (!columns || columns.length === 0) return [];
@@ -1462,7 +1625,11 @@ function initializeModelView(modelName) {
1462
1625
  state.filterPanelOpen = false;
1463
1626
  state.filterDrawerOpen = false;
1464
1627
  state.selectedRecords = new Set(); // Bulk selection
1628
+ state.selectAllMode = false; // Reset select all mode
1465
1629
  state.bulkActionInProgress = false;
1630
+ state.bulkFields = []; // Reset bulk fields
1631
+ state.bulkFieldDropdownOpen = false;
1632
+ state.selectedBulkField = null;
1466
1633
  state._currentModelName = modelName;
1467
1634
 
1468
1635
  // Parse filters from URL query string
@@ -1476,6 +1643,8 @@ function initializeModelView(modelName) {
1476
1643
  .then(modelMeta => {
1477
1644
  state.currentModelMeta = modelMeta;
1478
1645
  state.currentModel = modelMeta;
1646
+ // Load bulk-updatable fields for this model
1647
+ loadBulkFields(modelName);
1479
1648
  return loadRecords(modelName, page, state.filters);
1480
1649
  })
1481
1650
  .catch(err => {
@@ -1618,22 +1787,44 @@ const RecordList = {
1618
1787
  ])
1619
1788
  : m('.bg-white.rounded-lg.shadow-sm.border.border-gray-200.overflow-hidden', [
1620
1789
  // Bulk Actions Toolbar (shown when items selected)
1621
- state.selectedRecords && state.selectedRecords.size > 0 && m('.bg-indigo-50.border-b.border-indigo-100.px-4.py-3.flex.items-center.justify-between', [
1622
- m('span.text-sm.text-indigo-700.font-medium',
1623
- state.selectedRecords.size + ' record' + (state.selectedRecords.size > 1 ? 's' : '') + ' selected'
1624
- ),
1790
+ (state.selectedRecords && state.selectedRecords.size > 0) || state.selectAllMode ? m('.bg-indigo-50.border-b.border-indigo-100.px-4.py-3.flex.items-center.justify-between', [
1791
+ m('.flex.items-center.gap-3', [
1792
+ m('span.text-sm.text-indigo-700.font-medium',
1793
+ state.selectAllMode
1794
+ ? 'All ' + state.pagination.total + ' records selected'
1795
+ : state.selectedRecords.size + ' record' + (state.selectedRecords.size > 1 ? 's' : '') + ' selected'
1796
+ ),
1797
+ // Show "Select all X records" option when current page is fully selected
1798
+ !state.selectAllMode && state.selectedRecords.size === state.records.length && state.pagination.total > state.records.length && m('button.text-sm.text-indigo-600.hover:text-indigo-800.underline.font-medium', {
1799
+ onclick: () => {
1800
+ state.selectAllMode = true;
1801
+ m.redraw();
1802
+ },
1803
+ }, 'Select all ' + state.pagination.total + ' records'),
1804
+ // Show "Select only this page" when in selectAllMode
1805
+ state.selectAllMode && m('button.text-sm.text-indigo-600.hover:text-indigo-800.underline', {
1806
+ onclick: () => {
1807
+ state.selectAllMode = false;
1808
+ m.redraw();
1809
+ },
1810
+ }, 'Select only this page (' + state.selectedRecords.size + ')'),
1811
+ ]),
1625
1812
  m('.flex.items-center.gap-2', [
1626
1813
  m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-red-600.bg-white.border.border-red-200.rounded.hover:bg-red-50.transition-colors', {
1627
1814
  disabled: state.bulkActionInProgress,
1628
1815
  onclick: async () => {
1629
- if (!confirm('Are you sure you want to delete ' + state.selectedRecords.size + ' records? This action cannot be undone.')) return;
1816
+ const count = state.selectAllMode ? state.pagination.total : state.selectedRecords.size;
1817
+ if (!confirm('Are you sure you want to delete ' + count + ' records? This action cannot be undone.')) return;
1630
1818
  state.bulkActionInProgress = true;
1631
1819
  m.redraw();
1632
1820
  try {
1633
- const ids = Array.from(state.selectedRecords);
1634
- await api.post('/extensions/bulk-actions/bulk-delete/execute?model=' + modelName, { ids });
1821
+ const payload = state.selectAllMode
1822
+ ? { selectAll: true, filters: state.filters }
1823
+ : { ids: Array.from(state.selectedRecords) };
1824
+ await api.post('/extensions/bulk-actions/bulk-delete/' + modelName, payload);
1635
1825
  state.selectedRecords = new Set();
1636
- loadRecords(modelName, state.pagination.page);
1826
+ state.selectAllMode = false;
1827
+ loadRecords(modelName, 1);
1637
1828
  } catch (err) {
1638
1829
  alert('Error: ' + err.message);
1639
1830
  } finally {
@@ -1653,8 +1844,10 @@ const RecordList = {
1653
1844
  state.bulkActionInProgress = true;
1654
1845
  m.redraw();
1655
1846
  try {
1656
- const ids = Array.from(state.selectedRecords);
1657
- const response = await api.post('/extensions/export?model=' + modelName + '&format=json', { ids });
1847
+ const payload = state.selectAllMode
1848
+ ? { selectAll: true, filters: state.filters }
1849
+ : { ids: Array.from(state.selectedRecords) };
1850
+ const response = await api.post('/extensions/export?model=' + modelName + '&format=json', payload);
1658
1851
  // Download as file
1659
1852
  const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: 'application/json' });
1660
1853
  const url = URL.createObjectURL(blob);
@@ -1682,8 +1875,10 @@ const RecordList = {
1682
1875
  state.bulkActionInProgress = true;
1683
1876
  m.redraw();
1684
1877
  try {
1685
- const ids = Array.from(state.selectedRecords);
1686
- const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', { ids });
1878
+ const payload = state.selectAllMode
1879
+ ? { selectAll: true, filters: state.filters }
1880
+ : { ids: Array.from(state.selectedRecords) };
1881
+ const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', payload);
1687
1882
  // Download as file
1688
1883
  const blob = new Blob([response.data], { type: 'text/csv' });
1689
1884
  const url = URL.createObjectURL(blob);
@@ -1705,14 +1900,29 @@ const RecordList = {
1705
1900
  ),
1706
1901
  'Export CSV',
1707
1902
  ]),
1903
+ // Bulk Field Update Dropdown
1904
+ m(BulkFieldUpdateDropdown, {
1905
+ modelName: modelName,
1906
+ selectedIds: state.selectAllMode ? null : Array.from(state.selectedRecords),
1907
+ selectAllMode: state.selectAllMode,
1908
+ filters: state.filters,
1909
+ onComplete: () => {
1910
+ state.selectedRecords = new Set();
1911
+ state.selectAllMode = false;
1912
+ loadRecords(modelName, state.pagination.page);
1913
+ },
1914
+ }),
1708
1915
  m('button.px-3.py-1.5.text-sm.text-gray-500.hover:text-gray-700', {
1709
1916
  onclick: () => {
1710
1917
  state.selectedRecords = new Set();
1918
+ state.selectAllMode = false;
1919
+ state.bulkFieldDropdownOpen = false;
1920
+ state.selectedBulkField = null;
1711
1921
  m.redraw();
1712
1922
  },
1713
1923
  }, 'Clear'),
1714
1924
  ]),
1715
- ]),
1925
+ ]) : null,
1716
1926
  // Table container with sticky header and actions
1717
1927
  m('.overflow-x-auto.max-h-[calc(100vh-380px)]', { style: 'position: relative;' }, [
1718
1928
  m('table.w-full.border-collapse', { style: 'min-width: 100%;' }, [
@@ -4,6 +4,80 @@
4
4
  * @module plugins/admin-panel/core/api-extensions
5
5
  */
6
6
 
7
+ /**
8
+ * Build query with filters applied
9
+ * @param {Object} repo - Repository instance
10
+ * @param {Object} filters - Filter object from frontend
11
+ * @returns {Object} Query builder with filters applied
12
+ */
13
+ function buildFilteredQuery(repo, filters) {
14
+ let query = repo.query();
15
+
16
+ if (!filters || Object.keys(filters).length === 0) {
17
+ return query;
18
+ }
19
+
20
+ for (const [column, filter] of Object.entries(filters)) {
21
+ if (!filter || (filter.value === '' && !filter.from && !filter.to)) continue;
22
+
23
+ const op = filter.op || 'contains';
24
+
25
+ switch (op) {
26
+ case 'contains':
27
+ query = query.where(column, 'like', `%${filter.value}%`);
28
+ break;
29
+ case 'equals':
30
+ query = query.where(column, '=', filter.value);
31
+ break;
32
+ case 'starts_with':
33
+ query = query.where(column, 'like', `${filter.value}%`);
34
+ break;
35
+ case 'ends_with':
36
+ query = query.where(column, 'like', `%${filter.value}`);
37
+ break;
38
+ case 'eq':
39
+ query = query.where(column, '=', filter.value);
40
+ break;
41
+ case 'gt':
42
+ query = query.where(column, '>', filter.value);
43
+ break;
44
+ case 'gte':
45
+ query = query.where(column, '>=', filter.value);
46
+ break;
47
+ case 'lt':
48
+ query = query.where(column, '<', filter.value);
49
+ break;
50
+ case 'lte':
51
+ query = query.where(column, '<=', filter.value);
52
+ break;
53
+ case 'between':
54
+ if (filter.from) query = query.where(column, '>=', filter.from);
55
+ if (filter.to) query = query.where(column, '<=', filter.to);
56
+ break;
57
+ case 'in':
58
+ if (Array.isArray(filter.value) && filter.value.length > 0) {
59
+ query = query.whereIn(column, filter.value);
60
+ }
61
+ break;
62
+ }
63
+ }
64
+
65
+ return query;
66
+ }
67
+
68
+ /**
69
+ * Get all record IDs matching filters (for selectAll mode)
70
+ * @param {Object} repo - Repository instance
71
+ * @param {Object} filters - Filter object
72
+ * @param {string} primaryKey - Primary key column name
73
+ * @returns {Promise<Array>} Array of IDs
74
+ */
75
+ async function getAllMatchingIds(repo, filters, primaryKey = 'id') {
76
+ const query = buildFilteredQuery(repo, filters);
77
+ const records = await query.select(primaryKey).list();
78
+ return records.map(r => r[primaryKey]);
79
+ }
80
+
7
81
  /**
8
82
  * Create extension API handlers
9
83
  * @param {Object} options - Options
@@ -99,15 +173,12 @@ function createExtensionApiHandlers(options) {
99
173
 
100
174
  /**
101
175
  * Execute bulk action
176
+ * Supports both specific IDs and selectAll mode with filters
102
177
  */
103
178
  async function bulkActionHandler(req, res) {
104
179
  try {
105
180
  const { actionId, model: modelName } = req.params;
106
- const { ids } = req.body;
107
-
108
- if (!ids || !Array.isArray(ids) || ids.length === 0) {
109
- return res.status(400).json({ error: 'No records selected' });
110
- }
181
+ const { ids, selectAll, filters } = req.body;
111
182
 
112
183
  const action = registry.bulkActions.get(actionId);
113
184
 
@@ -123,10 +194,31 @@ function createExtensionApiHandlers(options) {
123
194
  }
124
195
  }
125
196
 
126
- // Get records
197
+ // Get repository
127
198
  const repo = db.getRepository(modelName);
199
+
200
+ // Determine IDs to process
201
+ let recordIds;
202
+ if (selectAll) {
203
+ // Get all matching record IDs based on filters
204
+ const { getModel } = require('../../../core/orm/model');
205
+ const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
206
+ const primaryKey = model?.primaryKey || 'id';
207
+ recordIds = await getAllMatchingIds(repo, filters, primaryKey);
208
+ } else {
209
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
210
+ return res.status(400).json({ error: 'No records selected' });
211
+ }
212
+ recordIds = ids;
213
+ }
214
+
215
+ if (recordIds.length === 0) {
216
+ return res.status(400).json({ error: 'No records match the criteria' });
217
+ }
218
+
219
+ // Get records
128
220
  const records = [];
129
- for (const id of ids) {
221
+ for (const id of recordIds) {
130
222
  const record = await repo.findById(id);
131
223
  if (record) {
132
224
  records.push(record);
@@ -151,6 +243,146 @@ function createExtensionApiHandlers(options) {
151
243
  }
152
244
  }
153
245
 
246
+ /**
247
+ * Bulk update field values (for enum/boolean fields)
248
+ * Supports both specific IDs and selectAll mode with filters
249
+ */
250
+ async function bulkUpdateFieldHandler(req, res) {
251
+ try {
252
+ const { model: modelName } = req.params;
253
+ const { ids, selectAll, filters, field, value } = req.body;
254
+
255
+ if (!field) {
256
+ return res.status(400).json({ error: 'Field name is required' });
257
+ }
258
+
259
+ const { getModel } = require('../../../core/orm/model');
260
+ const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
261
+
262
+ if (!model || !model.admin?.enabled) {
263
+ return res.status(404).json({ error: 'Model not found or not enabled' });
264
+ }
265
+
266
+ // Get field metadata
267
+ const column = model.columns.get(field);
268
+ if (!column) {
269
+ return res.status(400).json({ error: `Field "${field}" not found in model` });
270
+ }
271
+
272
+ // Validate field type - only allow enum and boolean
273
+ const columnMeta = column._meta || {};
274
+ const isEnum = columnMeta.enum && Array.isArray(columnMeta.enum);
275
+ const isBoolean = columnMeta.type === 'boolean' || column._def?.typeName === 'ZodBoolean';
276
+
277
+ if (!isEnum && !isBoolean) {
278
+ return res.status(400).json({ error: `Field "${field}" is not an enum or boolean type` });
279
+ }
280
+
281
+ // Validate value for enum fields
282
+ if (isEnum && !columnMeta.enum.includes(value)) {
283
+ return res.status(400).json({ error: `Invalid value "${value}" for enum field "${field}"` });
284
+ }
285
+
286
+ // Coerce boolean value
287
+ let updateValue = value;
288
+ if (isBoolean) {
289
+ updateValue = value === true || value === 'true' || value === 1 || value === '1';
290
+ }
291
+
292
+ // Get repository and determine IDs
293
+ const repo = db.getRepository(modelName);
294
+ let recordIds;
295
+
296
+ if (selectAll) {
297
+ // Get all matching record IDs based on filters
298
+ const primaryKey = model.primaryKey || 'id';
299
+ recordIds = await getAllMatchingIds(repo, filters, primaryKey);
300
+ } else {
301
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
302
+ return res.status(400).json({ error: 'No records selected' });
303
+ }
304
+ recordIds = ids;
305
+ }
306
+
307
+ if (recordIds.length === 0) {
308
+ return res.status(400).json({ error: 'No records match the criteria' });
309
+ }
310
+
311
+ // Perform bulk update
312
+ let updated = 0;
313
+
314
+ for (const id of recordIds) {
315
+ try {
316
+ await repo.update(id, { [field]: updateValue });
317
+ updated++;
318
+ } catch (e) {
319
+ console.error(`Failed to update record ${id}:`, e.message);
320
+ }
321
+ }
322
+
323
+ res.json({
324
+ success: true,
325
+ result: {
326
+ message: `${updated} of ${recordIds.length} records updated`,
327
+ updated,
328
+ field,
329
+ value: updateValue,
330
+ },
331
+ affected: updated,
332
+ });
333
+ } catch (error) {
334
+ console.error('Bulk update field error:', error);
335
+ res.status(500).json({ error: error.message });
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Get bulk-updatable fields for a model (enum and boolean fields)
341
+ */
342
+ function bulkFieldsHandler(req, res) {
343
+ try {
344
+ const { model: modelName } = req.params;
345
+
346
+ const { getModel } = require('../../../core/orm/model');
347
+ const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
348
+
349
+ if (!model || !model.admin?.enabled) {
350
+ return res.status(404).json({ error: 'Model not found or not enabled' });
351
+ }
352
+
353
+ const bulkFields = [];
354
+
355
+ for (const [fieldName, column] of model.columns.entries()) {
356
+ const columnMeta = column._meta || {};
357
+ const isEnum = columnMeta.enum && Array.isArray(columnMeta.enum);
358
+ const isBoolean = columnMeta.type === 'boolean' || column._def?.typeName === 'ZodBoolean';
359
+
360
+ if (isEnum) {
361
+ bulkFields.push({
362
+ name: fieldName,
363
+ type: 'enum',
364
+ label: columnMeta.label || fieldName,
365
+ options: columnMeta.enum.map(v => ({ value: v, label: v })),
366
+ });
367
+ } else if (isBoolean) {
368
+ bulkFields.push({
369
+ name: fieldName,
370
+ type: 'boolean',
371
+ label: columnMeta.label || fieldName,
372
+ options: [
373
+ { value: true, label: 'True' },
374
+ { value: false, label: 'False' },
375
+ ],
376
+ });
377
+ }
378
+ }
379
+
380
+ res.json({ fields: bulkFields });
381
+ } catch (error) {
382
+ res.status(500).json({ error: error.message });
383
+ }
384
+ }
385
+
154
386
  /**
155
387
  * Dashboard stats
156
388
  */
@@ -188,11 +420,27 @@ function createExtensionApiHandlers(options) {
188
420
 
189
421
  /**
190
422
  * Export records (CSV/JSON)
423
+ * Supports both GET (with ids in query) and POST (with ids in body)
424
+ * Also supports selectAll mode with filters
191
425
  */
192
426
  async function exportHandler(req, res) {
193
427
  try {
194
- const { model: modelName } = req.params;
195
- const { format = 'json', ids } = req.query;
428
+ // Support both path param and query param for model name
429
+ const modelName = req.params.model || req.query.model;
430
+ const format = req.query.format || 'json';
431
+ const { selectAll, filters } = req.body || {};
432
+
433
+ // Support IDs from query string (GET) or body (POST)
434
+ let idList = null;
435
+ if (req.body?.ids && Array.isArray(req.body.ids)) {
436
+ idList = req.body.ids;
437
+ } else if (req.query.ids) {
438
+ idList = req.query.ids.split(',');
439
+ }
440
+
441
+ if (!modelName) {
442
+ return res.status(400).json({ error: 'Model name is required' });
443
+ }
196
444
 
197
445
  const { getModel } = require('../../../core/orm/model');
198
446
  const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
@@ -204,15 +452,19 @@ function createExtensionApiHandlers(options) {
204
452
  const repo = db.getRepository(model.name);
205
453
  let records;
206
454
 
207
- // If specific IDs provided, fetch those
208
- if (ids) {
209
- const idList = ids.split(',');
455
+ if (selectAll) {
456
+ // Use filtered query to get all matching records
457
+ const query = buildFilteredQuery(repo, filters);
458
+ records = await query.list();
459
+ } else if (idList && idList.length > 0) {
460
+ // Fetch specific IDs
210
461
  records = [];
211
462
  for (const id of idList) {
212
463
  const record = await repo.findById(id);
213
464
  if (record) records.push(record);
214
465
  }
215
466
  } else {
467
+ // Fetch all records
216
468
  records = await repo.findAll();
217
469
  }
218
470
 
@@ -224,23 +476,26 @@ function createExtensionApiHandlers(options) {
224
476
  return columns.map(col => {
225
477
  const val = record[col];
226
478
  if (val === null || val === undefined) return '';
227
- if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
479
+ if (typeof val === 'string' && (val.includes(',') || val.includes('"') || val.includes('\n'))) {
228
480
  return `"${val.replace(/"/g, '""')}"`;
229
481
  }
482
+ if (typeof val === 'object') {
483
+ return `"${JSON.stringify(val).replace(/"/g, '""')}"`;
484
+ }
230
485
  return String(val);
231
486
  }).join(',');
232
487
  });
233
488
 
489
+ const csvContent = [header, ...rows].join('\n');
234
490
  res.setHeader('Content-Type', 'text/csv');
235
491
  res.setHeader('Content-Disposition', `attachment; filename="${modelName}_export.csv"`);
236
- res.send([header, ...rows].join('\n'));
492
+ res.json({ data: csvContent, format: 'csv' });
237
493
  } else {
238
494
  // JSON export
239
- res.setHeader('Content-Type', 'application/json');
240
- res.setHeader('Content-Disposition', `attachment; filename="${modelName}_export.json"`);
241
495
  res.json({ data: records, model: modelName, exportedAt: new Date().toISOString() });
242
496
  }
243
497
  } catch (error) {
498
+ console.error('Export error:', error);
244
499
  res.status(500).json({ error: error.message });
245
500
  }
246
501
  }
@@ -284,6 +539,8 @@ function createExtensionApiHandlers(options) {
284
539
  widgetDataHandler,
285
540
  actionHandler,
286
541
  bulkActionHandler,
542
+ bulkUpdateFieldHandler,
543
+ bulkFieldsHandler,
287
544
  dashboardStatsHandler,
288
545
  exportHandler,
289
546
  activityLogHandler,
@@ -177,7 +177,12 @@ function adminPanelPlugin(options = {}) {
177
177
  ctx.addRoute('get', `${adminPath}/api/extensions/dashboard/stats`, requireAuth, extensionHandlers.dashboardStatsHandler);
178
178
  ctx.addRoute('post', `${adminPath}/api/extensions/actions/:actionId/:model/:id`, requireAuth, extensionHandlers.actionHandler);
179
179
  ctx.addRoute('post', `${adminPath}/api/extensions/bulk-actions/:actionId/:model`, requireAuth, extensionHandlers.bulkActionHandler);
180
+ ctx.addRoute('get', `${adminPath}/api/extensions/bulk-fields/:model`, requireAuth, extensionHandlers.bulkFieldsHandler);
181
+ ctx.addRoute('post', `${adminPath}/api/extensions/bulk-update/:model`, requireAuth, extensionHandlers.bulkUpdateFieldHandler);
180
182
  ctx.addRoute('get', `${adminPath}/api/extensions/export/:model`, requireAuth, extensionHandlers.exportHandler);
183
+ ctx.addRoute('get', `${adminPath}/api/extensions/export`, requireAuth, extensionHandlers.exportHandler);
184
+ ctx.addRoute('post', `${adminPath}/api/extensions/export/:model`, requireAuth, extensionHandlers.exportHandler);
185
+ ctx.addRoute('post', `${adminPath}/api/extensions/export`, requireAuth, extensionHandlers.exportHandler);
181
186
  ctx.addRoute('get', `${adminPath}/api/extensions/activity`, requireAuth, extensionHandlers.activityLogHandler);
182
187
 
183
188
  // Custom pages API routes