webspresso 0.0.37 → 0.0.38

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.38",
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,9 @@ 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
71
74
  };
72
75
 
73
76
  // Breadcrumb Component
@@ -1303,6 +1306,152 @@ function formatCellValue(value, col) {
1303
1306
  }
1304
1307
  }
1305
1308
 
1309
+ // Load bulk-updatable fields for a model
1310
+ async function loadBulkFields(modelName) {
1311
+ try {
1312
+ const response = await api.get('/extensions/bulk-fields/' + modelName);
1313
+ state.bulkFields = response.fields || [];
1314
+ m.redraw();
1315
+ } catch (err) {
1316
+ console.error('Failed to load bulk fields:', err);
1317
+ state.bulkFields = [];
1318
+ }
1319
+ }
1320
+
1321
+ // Execute bulk field update
1322
+ async function executeBulkFieldUpdate(modelName, field, value, ids) {
1323
+ try {
1324
+ const response = await api.post('/extensions/bulk-update/' + modelName, {
1325
+ ids: ids,
1326
+ field: field,
1327
+ value: value,
1328
+ });
1329
+ return response;
1330
+ } catch (err) {
1331
+ throw err;
1332
+ }
1333
+ }
1334
+
1335
+ // Bulk Field Update Dropdown Component
1336
+ const BulkFieldUpdateDropdown = {
1337
+ view: (vnode) => {
1338
+ const { modelName, selectedIds, onComplete } = vnode.attrs;
1339
+
1340
+ if (!state.bulkFields || state.bulkFields.length === 0) {
1341
+ return null;
1342
+ }
1343
+
1344
+ return m('.relative.inline-block', [
1345
+ // Dropdown trigger
1346
+ 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', {
1347
+ disabled: state.bulkActionInProgress,
1348
+ onclick: (e) => {
1349
+ e.stopPropagation();
1350
+ state.bulkFieldDropdownOpen = !state.bulkFieldDropdownOpen;
1351
+ state.selectedBulkField = null;
1352
+ m.redraw();
1353
+ },
1354
+ }, [
1355
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1356
+ 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' })
1357
+ ),
1358
+ 'Set Field',
1359
+ m('svg.w-4.h-4.ml-1', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1360
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M19 9l-7 7-7-7' })
1361
+ ),
1362
+ ]),
1363
+
1364
+ // Dropdown menu
1365
+ state.bulkFieldDropdownOpen && m('.absolute.z-50.mt-1.w-64.bg-white.rounded-lg.shadow-lg.border.border-gray-200.overflow-hidden', {
1366
+ style: 'left: 0; top: 100%;',
1367
+ onclick: (e) => e.stopPropagation(),
1368
+ }, [
1369
+ // Close button area click handler
1370
+ m('.fixed.inset-0.z-40', {
1371
+ onclick: () => {
1372
+ state.bulkFieldDropdownOpen = false;
1373
+ state.selectedBulkField = null;
1374
+ m.redraw();
1375
+ },
1376
+ }),
1377
+
1378
+ // Dropdown content
1379
+ m('.relative.z-50.bg-white', [
1380
+ // Header
1381
+ m('.px-3.py-2.bg-gray-50.border-b.border-gray-200', [
1382
+ m('span.text-xs.font-medium.text-gray-500.uppercase.tracking-wider',
1383
+ state.selectedBulkField ? 'Select Value' : 'Select Field'
1384
+ ),
1385
+ ]),
1386
+
1387
+ // Field list or value list
1388
+ m('.max-h-64.overflow-y-auto', [
1389
+ state.selectedBulkField
1390
+ // Show values for selected field
1391
+ ? state.selectedBulkField.options.map(option =>
1392
+ m('button.w-full.px-3.py-2.text-left.text-sm.hover:bg-purple-50.flex.items-center.justify-between.transition-colors', {
1393
+ onclick: async () => {
1394
+ state.bulkActionInProgress = true;
1395
+ state.bulkFieldDropdownOpen = false;
1396
+ m.redraw();
1397
+
1398
+ try {
1399
+ await executeBulkFieldUpdate(modelName, state.selectedBulkField.name, option.value, selectedIds);
1400
+ state.selectedBulkField = null;
1401
+ if (onComplete) onComplete();
1402
+ } catch (err) {
1403
+ alert('Error: ' + err.message);
1404
+ } finally {
1405
+ state.bulkActionInProgress = false;
1406
+ m.redraw();
1407
+ }
1408
+ },
1409
+ }, [
1410
+ m('span.text-gray-700', String(option.label)),
1411
+ state.selectedBulkField.type === 'boolean' && m('span.ml-2',
1412
+ option.value === true
1413
+ ? m('span.inline-flex.items-center.px-2.py-0.5.rounded-full.text-xs.font-medium.bg-green-100.text-green-800', '✓')
1414
+ : m('span.inline-flex.items-center.px-2.py-0.5.rounded-full.text-xs.font-medium.bg-gray-100.text-gray-600', '✗')
1415
+ ),
1416
+ ])
1417
+ )
1418
+ // Show field list
1419
+ : state.bulkFields.map(field =>
1420
+ m('button.w-full.px-3.py-2.text-left.text-sm.hover:bg-purple-50.flex.items-center.justify-between.transition-colors', {
1421
+ onclick: () => {
1422
+ state.selectedBulkField = field;
1423
+ m.redraw();
1424
+ },
1425
+ }, [
1426
+ m('.flex.items-center.gap-2', [
1427
+ m('span.text-gray-700', formatColumnLabel(field.label || field.name)),
1428
+ m('span.text-xs.text-gray-400.uppercase', field.type),
1429
+ ]),
1430
+ m('svg.w-4.h-4.text-gray-400', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1431
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 5l7 7-7 7' })
1432
+ ),
1433
+ ])
1434
+ ),
1435
+
1436
+ // Back button when viewing values
1437
+ 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', {
1438
+ onclick: () => {
1439
+ state.selectedBulkField = null;
1440
+ m.redraw();
1441
+ },
1442
+ }, [
1443
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1444
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M15 19l-7-7 7-7' })
1445
+ ),
1446
+ 'Back to fields',
1447
+ ]),
1448
+ ]),
1449
+ ]),
1450
+ ]),
1451
+ ]);
1452
+ },
1453
+ };
1454
+
1306
1455
  // Get columns to display in table (limit to reasonable number)
1307
1456
  function getDisplayColumns(columns) {
1308
1457
  if (!columns || columns.length === 0) return [];
@@ -1463,6 +1612,9 @@ function initializeModelView(modelName) {
1463
1612
  state.filterDrawerOpen = false;
1464
1613
  state.selectedRecords = new Set(); // Bulk selection
1465
1614
  state.bulkActionInProgress = false;
1615
+ state.bulkFields = []; // Reset bulk fields
1616
+ state.bulkFieldDropdownOpen = false;
1617
+ state.selectedBulkField = null;
1466
1618
  state._currentModelName = modelName;
1467
1619
 
1468
1620
  // Parse filters from URL query string
@@ -1476,6 +1628,8 @@ function initializeModelView(modelName) {
1476
1628
  .then(modelMeta => {
1477
1629
  state.currentModelMeta = modelMeta;
1478
1630
  state.currentModel = modelMeta;
1631
+ // Load bulk-updatable fields for this model
1632
+ loadBulkFields(modelName);
1479
1633
  return loadRecords(modelName, page, state.filters);
1480
1634
  })
1481
1635
  .catch(err => {
@@ -1631,7 +1785,7 @@ const RecordList = {
1631
1785
  m.redraw();
1632
1786
  try {
1633
1787
  const ids = Array.from(state.selectedRecords);
1634
- await api.post('/extensions/bulk-actions/bulk-delete/execute?model=' + modelName, { ids });
1788
+ await api.post('/extensions/bulk-actions/bulk-delete/' + modelName, { ids });
1635
1789
  state.selectedRecords = new Set();
1636
1790
  loadRecords(modelName, state.pagination.page);
1637
1791
  } catch (err) {
@@ -1705,9 +1859,20 @@ const RecordList = {
1705
1859
  ),
1706
1860
  'Export CSV',
1707
1861
  ]),
1862
+ // Bulk Field Update Dropdown
1863
+ m(BulkFieldUpdateDropdown, {
1864
+ modelName: modelName,
1865
+ selectedIds: Array.from(state.selectedRecords),
1866
+ onComplete: () => {
1867
+ state.selectedRecords = new Set();
1868
+ loadRecords(modelName, state.pagination.page);
1869
+ },
1870
+ }),
1708
1871
  m('button.px-3.py-1.5.text-sm.text-gray-500.hover:text-gray-700', {
1709
1872
  onclick: () => {
1710
1873
  state.selectedRecords = new Set();
1874
+ state.bulkFieldDropdownOpen = false;
1875
+ state.selectedBulkField = null;
1711
1876
  m.redraw();
1712
1877
  },
1713
1878
  }, 'Clear'),
@@ -151,6 +151,131 @@ function createExtensionApiHandlers(options) {
151
151
  }
152
152
  }
153
153
 
154
+ /**
155
+ * Bulk update field values (for enum/boolean fields)
156
+ */
157
+ async function bulkUpdateFieldHandler(req, res) {
158
+ try {
159
+ const { model: modelName } = req.params;
160
+ const { ids, field, value } = req.body;
161
+
162
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
163
+ return res.status(400).json({ error: 'No records selected' });
164
+ }
165
+
166
+ if (!field) {
167
+ return res.status(400).json({ error: 'Field name is required' });
168
+ }
169
+
170
+ const { getModel } = require('../../../core/orm/model');
171
+ const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
172
+
173
+ if (!model || !model.admin?.enabled) {
174
+ return res.status(404).json({ error: 'Model not found or not enabled' });
175
+ }
176
+
177
+ // Get field metadata
178
+ const column = model.columns.get(field);
179
+ if (!column) {
180
+ return res.status(400).json({ error: `Field "${field}" not found in model` });
181
+ }
182
+
183
+ // Validate field type - only allow enum and boolean
184
+ const columnMeta = column._meta || {};
185
+ const isEnum = columnMeta.enum && Array.isArray(columnMeta.enum);
186
+ const isBoolean = columnMeta.type === 'boolean' || column._def?.typeName === 'ZodBoolean';
187
+
188
+ if (!isEnum && !isBoolean) {
189
+ return res.status(400).json({ error: `Field "${field}" is not an enum or boolean type` });
190
+ }
191
+
192
+ // Validate value for enum fields
193
+ if (isEnum && !columnMeta.enum.includes(value)) {
194
+ return res.status(400).json({ error: `Invalid value "${value}" for enum field "${field}"` });
195
+ }
196
+
197
+ // Coerce boolean value
198
+ let updateValue = value;
199
+ if (isBoolean) {
200
+ updateValue = value === true || value === 'true' || value === 1 || value === '1';
201
+ }
202
+
203
+ // Perform bulk update
204
+ const repo = db.getRepository(modelName);
205
+ let updated = 0;
206
+
207
+ for (const id of ids) {
208
+ try {
209
+ await repo.update(id, { [field]: updateValue });
210
+ updated++;
211
+ } catch (e) {
212
+ console.error(`Failed to update record ${id}:`, e.message);
213
+ }
214
+ }
215
+
216
+ res.json({
217
+ success: true,
218
+ result: {
219
+ message: `${updated} of ${ids.length} records updated`,
220
+ updated,
221
+ field,
222
+ value: updateValue,
223
+ },
224
+ affected: updated,
225
+ });
226
+ } catch (error) {
227
+ console.error('Bulk update field error:', error);
228
+ res.status(500).json({ error: error.message });
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Get bulk-updatable fields for a model (enum and boolean fields)
234
+ */
235
+ function bulkFieldsHandler(req, res) {
236
+ try {
237
+ const { model: modelName } = req.params;
238
+
239
+ const { getModel } = require('../../../core/orm/model');
240
+ const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
241
+
242
+ if (!model || !model.admin?.enabled) {
243
+ return res.status(404).json({ error: 'Model not found or not enabled' });
244
+ }
245
+
246
+ const bulkFields = [];
247
+
248
+ for (const [fieldName, column] of model.columns.entries()) {
249
+ const columnMeta = column._meta || {};
250
+ const isEnum = columnMeta.enum && Array.isArray(columnMeta.enum);
251
+ const isBoolean = columnMeta.type === 'boolean' || column._def?.typeName === 'ZodBoolean';
252
+
253
+ if (isEnum) {
254
+ bulkFields.push({
255
+ name: fieldName,
256
+ type: 'enum',
257
+ label: columnMeta.label || fieldName,
258
+ options: columnMeta.enum.map(v => ({ value: v, label: v })),
259
+ });
260
+ } else if (isBoolean) {
261
+ bulkFields.push({
262
+ name: fieldName,
263
+ type: 'boolean',
264
+ label: columnMeta.label || fieldName,
265
+ options: [
266
+ { value: true, label: 'True' },
267
+ { value: false, label: 'False' },
268
+ ],
269
+ });
270
+ }
271
+ }
272
+
273
+ res.json({ fields: bulkFields });
274
+ } catch (error) {
275
+ res.status(500).json({ error: error.message });
276
+ }
277
+ }
278
+
154
279
  /**
155
280
  * Dashboard stats
156
281
  */
@@ -188,11 +313,25 @@ function createExtensionApiHandlers(options) {
188
313
 
189
314
  /**
190
315
  * Export records (CSV/JSON)
316
+ * Supports both GET (with ids in query) and POST (with ids in body)
191
317
  */
192
318
  async function exportHandler(req, res) {
193
319
  try {
194
- const { model: modelName } = req.params;
195
- const { format = 'json', ids } = req.query;
320
+ // Support both path param and query param for model name
321
+ const modelName = req.params.model || req.query.model;
322
+ const format = req.query.format || 'json';
323
+
324
+ // Support IDs from query string (GET) or body (POST)
325
+ let idList = null;
326
+ if (req.body?.ids && Array.isArray(req.body.ids)) {
327
+ idList = req.body.ids;
328
+ } else if (req.query.ids) {
329
+ idList = req.query.ids.split(',');
330
+ }
331
+
332
+ if (!modelName) {
333
+ return res.status(400).json({ error: 'Model name is required' });
334
+ }
196
335
 
197
336
  const { getModel } = require('../../../core/orm/model');
198
337
  const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
@@ -205,8 +344,7 @@ function createExtensionApiHandlers(options) {
205
344
  let records;
206
345
 
207
346
  // If specific IDs provided, fetch those
208
- if (ids) {
209
- const idList = ids.split(',');
347
+ if (idList && idList.length > 0) {
210
348
  records = [];
211
349
  for (const id of idList) {
212
350
  const record = await repo.findById(id);
@@ -224,23 +362,26 @@ function createExtensionApiHandlers(options) {
224
362
  return columns.map(col => {
225
363
  const val = record[col];
226
364
  if (val === null || val === undefined) return '';
227
- if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
365
+ if (typeof val === 'string' && (val.includes(',') || val.includes('"') || val.includes('\n'))) {
228
366
  return `"${val.replace(/"/g, '""')}"`;
229
367
  }
368
+ if (typeof val === 'object') {
369
+ return `"${JSON.stringify(val).replace(/"/g, '""')}"`;
370
+ }
230
371
  return String(val);
231
372
  }).join(',');
232
373
  });
233
374
 
375
+ const csvContent = [header, ...rows].join('\n');
234
376
  res.setHeader('Content-Type', 'text/csv');
235
377
  res.setHeader('Content-Disposition', `attachment; filename="${modelName}_export.csv"`);
236
- res.send([header, ...rows].join('\n'));
378
+ res.json({ data: csvContent, format: 'csv' });
237
379
  } else {
238
380
  // JSON export
239
- res.setHeader('Content-Type', 'application/json');
240
- res.setHeader('Content-Disposition', `attachment; filename="${modelName}_export.json"`);
241
381
  res.json({ data: records, model: modelName, exportedAt: new Date().toISOString() });
242
382
  }
243
383
  } catch (error) {
384
+ console.error('Export error:', error);
244
385
  res.status(500).json({ error: error.message });
245
386
  }
246
387
  }
@@ -284,6 +425,8 @@ function createExtensionApiHandlers(options) {
284
425
  widgetDataHandler,
285
426
  actionHandler,
286
427
  bulkActionHandler,
428
+ bulkUpdateFieldHandler,
429
+ bulkFieldsHandler,
287
430
  dashboardStatsHandler,
288
431
  exportHandler,
289
432
  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