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
|
@@ -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
|
|
1622
|
-
m('
|
|
1623
|
-
|
|
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
|
-
|
|
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
|
|
1634
|
-
|
|
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
|
-
|
|
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
|
|
1657
|
-
|
|
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
|
|
1686
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
195
|
-
const
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
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.
|
|
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
|