tempest-express-sdk 0.24.0 → 0.25.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/dist/index.cjs CHANGED
@@ -9444,6 +9444,21 @@ var MessagingHub = class {
9444
9444
  }
9445
9445
  };
9446
9446
 
9447
+ // src/admin/actions.ts
9448
+ function slugify(label) {
9449
+ return label.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "action";
9450
+ }
9451
+ function adminAction(options, handler) {
9452
+ const name = options.name ?? slugify(options.label);
9453
+ if (name === "") throw new Error("adminAction requires a non-empty name or label");
9454
+ return {
9455
+ name,
9456
+ label: options.label,
9457
+ handler,
9458
+ dangerous: options.dangerous ?? false
9459
+ };
9460
+ }
9461
+
9447
9462
  // src/admin/columns.ts
9448
9463
  function humanizeField(name) {
9449
9464
  return name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim().split(/\s+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
@@ -9524,6 +9539,9 @@ function filterForColumn(column6) {
9524
9539
  }
9525
9540
  return { kind: "text", options: [] };
9526
9541
  }
9542
+ function foreignKeyTable(column6) {
9543
+ return column6.reference?.table ?? null;
9544
+ }
9527
9545
  function isSearchableColumn(column6) {
9528
9546
  const { kind } = column6.type;
9529
9547
  return kind === "varchar" || kind === "text" || kind === "char";
@@ -9560,6 +9578,7 @@ var AdminModel = class {
9560
9578
  canEdit;
9561
9579
  /** Whether the delete action is exposed. */
9562
9580
  canDelete;
9581
+ actions = /* @__PURE__ */ new Map();
9563
9582
  slugOverride;
9564
9583
  listDisplayOverride;
9565
9584
  verboseNameOverride;
@@ -9584,6 +9603,14 @@ var AdminModel = class {
9584
9603
  this.canCreate = options.canCreate ?? true;
9585
9604
  this.canEdit = options.canEdit ?? true;
9586
9605
  this.canDelete = options.canDelete ?? true;
9606
+ for (const action of options.actions ?? []) {
9607
+ if (this.actions.has(action.name)) {
9608
+ throw new Error(
9609
+ `Duplicate admin action name "${action.name}" on ${this.model.tablename}`
9610
+ );
9611
+ }
9612
+ this.actions.set(action.name, action);
9613
+ }
9587
9614
  const known = new Set(this.columnNames());
9588
9615
  for (const [option, names] of [
9589
9616
  ["listDisplay", this.listDisplayOverride ?? []],
@@ -9686,6 +9713,25 @@ var AdminModel = class {
9686
9713
  const skip = /* @__PURE__ */ new Set([...this.readonlyFields, ...NEVER_EDITABLE]);
9687
9714
  return this.columnNames().filter((name) => !skip.has(name));
9688
9715
  }
9716
+ /**
9717
+ * Return the registered custom actions, in declaration order.
9718
+ *
9719
+ * @returns The actions passed via `actions` (empty when none). The model
9720
+ * type is erased here, the way {@link AdminSite} erases it when it stores a
9721
+ * configuration — a registry keyed by slug cannot stay generic.
9722
+ */
9723
+ customActions() {
9724
+ return [...this.actions.values()];
9725
+ }
9726
+ /**
9727
+ * Look a custom action up by name.
9728
+ *
9729
+ * @param name - The action identifier (its submitted form value).
9730
+ * @returns The action, or `null` when nothing matches.
9731
+ */
9732
+ getAction(name) {
9733
+ return this.actions.get(name) ?? null;
9734
+ }
9689
9735
  /**
9690
9736
  * Build a repository for this model bound to a session.
9691
9737
  *
@@ -9727,10 +9773,12 @@ function buildFormFields(admin, options = {}) {
9727
9773
  const columns = adminColumns(admin.model);
9728
9774
  const values = options.values ?? {};
9729
9775
  const errors = options.errors ?? {};
9776
+ const foreignKeys = options.foreignKeyOptions ?? {};
9730
9777
  return admin.editableFieldNames().flatMap((name) => {
9731
9778
  const column6 = columns[name];
9732
9779
  if (column6 === void 0) return [];
9733
- const spec = widgetForColumn(column6);
9780
+ const related = foreignKeys[name];
9781
+ const spec = related === void 0 ? widgetForColumn(column6) : { widget: "select", step: null, options: related };
9734
9782
  const raw = name in values ? values[name] : literalDefault(column6);
9735
9783
  return [
9736
9784
  {
@@ -9780,7 +9828,7 @@ function coerceValue(column6, widget, raw) {
9780
9828
  throw new Error("Enter valid JSON.");
9781
9829
  }
9782
9830
  case "select": {
9783
- const allowed = meta.values ?? [];
9831
+ const allowed = kind === "enum" ? meta.values ?? [] : [];
9784
9832
  if (allowed.length > 0 && !allowed.includes(raw)) {
9785
9833
  throw new Error(`Choose one of: ${allowed.join(", ")}.`);
9786
9834
  }
@@ -9828,6 +9876,28 @@ function formatCellValue(value) {
9828
9876
  if (typeof value === "object") return JSON.stringify(value);
9829
9877
  return String(value);
9830
9878
  }
9879
+ function foreignKeyFields(admin) {
9880
+ const columns = adminColumns(admin.model);
9881
+ const out = {};
9882
+ for (const name of admin.editableFieldNames()) {
9883
+ const column6 = columns[name];
9884
+ if (column6 === void 0) continue;
9885
+ const table = foreignKeyTable(column6);
9886
+ if (table !== null) out[name] = table;
9887
+ }
9888
+ return out;
9889
+ }
9890
+ function foreignKeyLabel(admin, row) {
9891
+ for (const field of admin.searchFields) {
9892
+ const value = row[field];
9893
+ if (typeof value === "string" && value !== "") return value;
9894
+ }
9895
+ for (const field of ["name", "title", "email", "label", "reference"]) {
9896
+ const value = row[field];
9897
+ if (typeof value === "string" && value !== "") return value;
9898
+ }
9899
+ return String(row[admin.identityField] ?? "");
9900
+ }
9831
9901
 
9832
9902
  // src/admin/auth.ts
9833
9903
  var UserModelAuthBackend = class {
@@ -11965,15 +12035,40 @@ function renderFilter(filter) {
11965
12035
  return `<label><span>${escapeHtml(filter.label)}</span><input type="text" name="${escapeHtml(name)}" value="${escapeHtml(filter.value)}"></label>`;
11966
12036
  }
11967
12037
  function renderListPage(context, view) {
12038
+ const bulk = view.bulkActions.length > 0 && context.session !== null;
12039
+ const checkColumn = bulk ? 1 : 0;
11968
12040
  const headers = view.columns.map((column6) => {
11969
12041
  const state = view.sort[column6];
11970
12042
  if (state === void 0) return `<th>${escapeHtml(column6)}</th>`;
11971
12043
  const arrow = state.active ? state.ascending ? "\u25B2" : "\u25BC" : "\u2195";
11972
12044
  return `<th><a class="tempest-sort${state.active ? " tempest-sort--active" : ""}" href="${escapeHtml(state.url)}"><span>${escapeHtml(column6)}</span><span class="tempest-sort__arrow" aria-hidden="true">${arrow}</span></a></th>`;
11973
12045
  }).join("");
11974
- const rows = view.rows.length > 0 ? view.rows.map(
11975
- (row) => `<tr>${row.cells.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}<td><a href="${escapeHtml(row.url)}">View</a></td></tr>`
11976
- ).join("") : `<tr><td colspan="${view.columns.length + 1}">No records.</td></tr>`;
12046
+ const rows = view.rows.length > 0 ? view.rows.map((row) => {
12047
+ const check = bulk ? `<td class="tempest-admin-list__check"><input type="checkbox" name="ids" value="${escapeHtml(row.identity)}" data-row-check aria-label="Select row"></td>` : "";
12048
+ const cells = row.cells.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("");
12049
+ return `<tr>${check}${cells}<td><a href="${escapeHtml(row.url)}">View</a></td></tr>`;
12050
+ }).join("") : `<tr><td colspan="${view.columns.length + 1 + checkColumn}">No records.</td></tr>`;
12051
+ const bulkBar = bulk ? `<form method="post" action="${escapeHtml(view.bulkUrl)}" class="tempest-admin-bulk" onsubmit="return confirm('Apply the selected action to the checked rows?');">
12052
+ <input type="hidden" name="csrf_token" value="${escapeHtml(context.session?.csrfToken)}">
12053
+ <div class="tempest-admin-bulk__bar">
12054
+ <select name="action" aria-label="Bulk action">
12055
+ ${view.bulkActions.map(
12056
+ (action) => `<option value="${escapeHtml(action.value)}">${escapeHtml(action.label)}${action.dangerous ? " \u26A0" : ""}</option>`
12057
+ ).join("")}
12058
+ </select>
12059
+ <button type="submit">Apply to selected</button>
12060
+ </div>` : "";
12061
+ const selectAllScript = bulk ? `<script>
12062
+ (function () {
12063
+ var master = document.querySelector('[data-select-all]');
12064
+ if (!master) return;
12065
+ master.addEventListener('change', function () {
12066
+ document.querySelectorAll('[data-row-check]').forEach(function (box) {
12067
+ box.checked = master.checked;
12068
+ });
12069
+ });
12070
+ })();
12071
+ </script>` : "";
11977
12072
  const hasControls = view.searchable || view.filters.length > 0;
11978
12073
  const body = `<section class="tempest-admin-list">
11979
12074
  <header class="tempest-admin-list__header">
@@ -11988,14 +12083,19 @@ function renderListPage(context, view) {
11988
12083
  </form>
11989
12084
  <div class="tempest-admin-list__actions">
11990
12085
  ${view.newUrl !== null ? `<a class="tempest-admin-list__new" href="${escapeHtml(view.newUrl)}">+ New</a>` : ""}
12086
+ <a href="${escapeHtml(view.exportCsvUrl)}">Export CSV</a>
12087
+ <a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
11991
12088
  </div>
11992
12089
  </div>
12090
+ ${bulkBar}
11993
12091
  <div class="tempest-admin-table-wrap">
11994
12092
  <table class="tempest-admin-list__table">
11995
- <thead><tr>${headers}<th>Actions</th></tr></thead>
12093
+ <thead><tr>${bulk ? '<th class="tempest-admin-list__check"><input type="checkbox" data-select-all aria-label="Select all"></th>' : ""}${headers}<th>Actions</th></tr></thead>
11996
12094
  <tbody>${rows}</tbody>
11997
12095
  </table>
11998
12096
  </div>
12097
+ ${bulk ? "</form>" : ""}
12098
+ ${selectAllScript}
11999
12099
  ${view.pages > 1 ? `<nav class="tempest-admin-list__pagination" aria-label="Pagination">
12000
12100
  ${view.prevUrl !== null ? `<a href="${escapeHtml(view.prevUrl)}">\u2190 Prev</a>` : ""}
12001
12101
  <span>Page ${escapeHtml(view.page)} of ${escapeHtml(view.pages)}</span>
@@ -12089,11 +12189,16 @@ function renderFormPage(context, view) {
12089
12189
  return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
12090
12190
  }
12091
12191
  var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
12192
+ var FK_OPTION_CAP = 1e3;
12193
+ var FLASH_MAX_LENGTH = 300;
12092
12194
  var FLASH_MESSAGES = {
12093
12195
  created: { text: "Record created.", level: "success" },
12094
12196
  updated: { text: "Record updated.", level: "success" },
12095
12197
  deleted: { text: "Record deleted.", level: "success" }
12096
12198
  };
12199
+ async function runCustomAction(action, context) {
12200
+ return await action.handler(context) ?? null;
12201
+ }
12097
12202
  function queryString(value) {
12098
12203
  if (typeof value === "string") return value.trim();
12099
12204
  if (Array.isArray(value) && typeof value[0] === "string") return value[0].trim();
@@ -12111,6 +12216,7 @@ function makeAdminRouter(site, options) {
12111
12216
  const prefix = (options.prefix ?? "/admin").replace(/\/$/, "");
12112
12217
  const theme = resolveAdminTheme(site.theme);
12113
12218
  const showMetrics = options.showMetrics ?? true;
12219
+ const exportMaxRows = options.exportMaxRows ?? 5e3;
12114
12220
  const sessions = new AdminSessionStore({
12115
12221
  secret: options.secretKey,
12116
12222
  ...options.cookieName === void 0 ? {} : { cookieName: options.cookieName },
@@ -12134,8 +12240,17 @@ function makeAdminRouter(site, options) {
12134
12240
  messages: flashFor(req)
12135
12241
  });
12136
12242
  const flashFor = (req) => {
12137
- const message = FLASH_MESSAGES[queryString(req.query.ok)];
12138
- return message === void 0 ? [] : [message];
12243
+ const fixed = FLASH_MESSAGES[queryString(req.query.ok)];
12244
+ if (fixed !== void 0) return [fixed];
12245
+ const text = queryString(req.query.flash);
12246
+ if (text === "") return [];
12247
+ const level = queryString(req.query.level);
12248
+ return [
12249
+ {
12250
+ text: text.slice(0, FLASH_MAX_LENGTH),
12251
+ level: level === "error" || level === "warning" ? level : "success"
12252
+ }
12253
+ ];
12139
12254
  };
12140
12255
  const html = (res, body, status = 200) => {
12141
12256
  res.status(status).type("html").send(body);
@@ -12171,7 +12286,7 @@ function makeAdminRouter(site, options) {
12171
12286
  res.redirect(`${prefix}/mfa`);
12172
12287
  return null;
12173
12288
  }
12174
- return { session, dbSession };
12289
+ return { session, dbSession, principal };
12175
12290
  };
12176
12291
  const resolveAdmin = (req, res, state) => {
12177
12292
  const admin = site.get(String(req.params.slug));
@@ -12327,7 +12442,9 @@ function makeAdminRouter(site, options) {
12327
12442
  renderFormPage(context(req, state.session), {
12328
12443
  mode: "create",
12329
12444
  title: admin.verboseName(),
12330
- fields: buildFormFields(admin),
12445
+ fields: buildFormFields(admin, {
12446
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
12447
+ }),
12331
12448
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12332
12449
  backUrl: `${prefix}/m/${admin.slug()}`,
12333
12450
  error: null
@@ -12349,13 +12466,18 @@ function makeAdminRouter(site, options) {
12349
12466
  if (!checkCsrf(req, res, state)) return;
12350
12467
  const body = req.body;
12351
12468
  const parsed = parseFormBody(admin, body);
12469
+ const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
12352
12470
  const rerender = (error, status) => {
12353
12471
  html(
12354
12472
  res,
12355
12473
  renderFormPage(context(req, state.session), {
12356
12474
  mode: "create",
12357
12475
  title: admin.verboseName(),
12358
- fields: buildFormFields(admin, { values: body, errors: parsed.errors }),
12476
+ fields: buildFormFields(admin, {
12477
+ values: body,
12478
+ errors: parsed.errors,
12479
+ foreignKeyOptions
12480
+ }),
12359
12481
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12360
12482
  backUrl: `${prefix}/m/${admin.slug()}`,
12361
12483
  error
@@ -12376,6 +12498,106 @@ function makeAdminRouter(site, options) {
12376
12498
  res.redirect(`${prefix}/m/${admin.slug()}?ok=created`);
12377
12499
  })
12378
12500
  );
12501
+ router.get(
12502
+ `${prefix}/m/:slug/export.:format`,
12503
+ guarded(async (req, res) => {
12504
+ const state = await authenticate(req, res);
12505
+ if (state === null) return;
12506
+ const admin = resolveAdmin(req, res, state);
12507
+ if (admin === null) return;
12508
+ const format = String(req.params.format);
12509
+ if (format !== "csv" && format !== "json") {
12510
+ html(res, renderNotFound(context(req, state.session)), 404);
12511
+ return;
12512
+ }
12513
+ const query = await resolveListQuery(req, admin, state.dbSession);
12514
+ const result = await admin.repository(state.dbSession).paginate({
12515
+ page: 1,
12516
+ pageSize: exportMaxRows,
12517
+ ...query.orderBy === void 0 ? {} : { orderBy: query.orderBy },
12518
+ ascending: query.ascending,
12519
+ ...query.where === void 0 ? {} : { filters: query.where }
12520
+ });
12521
+ const columns = admin.listDisplayNames();
12522
+ const rows = result.items;
12523
+ const payload = format === "csv" ? toCsv(columns, rows) : toJson(columns, rows);
12524
+ res.status(200).type(format === "csv" ? "text/csv; charset=utf-8" : "application/json").set("content-disposition", `attachment; filename="${admin.slug()}.${format}"`).send(payload);
12525
+ })
12526
+ );
12527
+ router.post(
12528
+ `${prefix}/m/:slug/bulk`,
12529
+ guarded(async (req, res) => {
12530
+ const state = await authenticate(req, res);
12531
+ if (state === null) return;
12532
+ const admin = resolveAdmin(req, res, state);
12533
+ if (admin === null) return;
12534
+ if (!checkCsrf(req, res, state)) return;
12535
+ const body = req.body;
12536
+ const action = typeof body.action === "string" ? body.action : "";
12537
+ const raw = body.ids;
12538
+ const ids = (Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw]).filter((value) => typeof value === "string").filter((value) => value !== "");
12539
+ const listUrl = `${prefix}/m/${admin.slug()}`;
12540
+ const back = (message, level) => {
12541
+ res.redirect(`${listUrl}?${buildQuery({ flash: message, level })}`);
12542
+ };
12543
+ if (ids.length === 0) {
12544
+ back("No rows were selected.", "warning");
12545
+ return;
12546
+ }
12547
+ if (!bulkActionsFor(admin).some((option) => option.value === action)) {
12548
+ html(res, renderNotFound(context(req, state.session)), 400);
12549
+ return;
12550
+ }
12551
+ const repository = admin.repository(state.dbSession);
12552
+ const scope = { [admin.identityField]: { in: ids } };
12553
+ if (action.startsWith("custom:")) {
12554
+ const custom = admin.getAction(action.slice("custom:".length));
12555
+ if (custom === null) {
12556
+ html(res, renderNotFound(context(req, state.session)), 400);
12557
+ return;
12558
+ }
12559
+ let result;
12560
+ try {
12561
+ result = await runCustomAction(custom, {
12562
+ ids,
12563
+ repository,
12564
+ dbSession: state.dbSession,
12565
+ request: req,
12566
+ session: state.session,
12567
+ principal: state.principal
12568
+ });
12569
+ } catch (error) {
12570
+ logger2.error("Admin action failed", {
12571
+ slug: admin.slug(),
12572
+ action: custom.name,
12573
+ error: error instanceof Error ? error.message : String(error)
12574
+ });
12575
+ back(
12576
+ `${custom.label} failed: ${error instanceof Error ? error.message : String(error)}`,
12577
+ "error"
12578
+ );
12579
+ return;
12580
+ }
12581
+ if (result === null) {
12582
+ res.redirect(listUrl);
12583
+ return;
12584
+ }
12585
+ back(result.message, result.category ?? "success");
12586
+ return;
12587
+ }
12588
+ if (action === "delete") {
12589
+ const removed = await repository.delete(scope);
12590
+ back(`Deleted ${removed} record${removed === 1 ? "" : "s"}.`, "success");
12591
+ return;
12592
+ }
12593
+ const active = action === "activate";
12594
+ const changed = await repository.update(scope, { isActive: active });
12595
+ back(
12596
+ `${active ? "Activated" : "Deactivated"} ${changed} record${changed === 1 ? "" : "s"}.`,
12597
+ "success"
12598
+ );
12599
+ })
12600
+ );
12379
12601
  router.get(
12380
12602
  `${prefix}/m/:slug/:identity`,
12381
12603
  guarded(async (req, res) => {
@@ -12420,7 +12642,10 @@ function makeAdminRouter(site, options) {
12420
12642
  renderFormPage(context(req, state.session), {
12421
12643
  mode: "edit",
12422
12644
  title: admin.verboseName(),
12423
- fields: buildFormFields(admin, { values: row }),
12645
+ fields: buildFormFields(admin, {
12646
+ values: row,
12647
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
12648
+ }),
12424
12649
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12425
12650
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
12426
12651
  error: null
@@ -12443,13 +12668,18 @@ function makeAdminRouter(site, options) {
12443
12668
  if (!checkCsrf(req, res, state)) return;
12444
12669
  const body = req.body;
12445
12670
  const parsed = parseFormBody(admin, body);
12671
+ const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
12446
12672
  const rerender = (error, status) => {
12447
12673
  html(
12448
12674
  res,
12449
12675
  renderFormPage(context(req, state.session), {
12450
12676
  mode: "edit",
12451
12677
  title: admin.verboseName(),
12452
- fields: buildFormFields(admin, { values: body, errors: parsed.errors }),
12678
+ fields: buildFormFields(admin, {
12679
+ values: body,
12680
+ errors: parsed.errors,
12681
+ foreignKeyOptions
12682
+ }),
12453
12683
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12454
12684
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
12455
12685
  error
@@ -12496,8 +12726,69 @@ function makeAdminRouter(site, options) {
12496
12726
  }
12497
12727
  async function renderList(req, admin, state) {
12498
12728
  const columns = adminColumns(admin.model);
12499
- const search = queryString(req.query.q);
12729
+ const query = await resolveListQuery(req, admin, state.dbSession);
12500
12730
  const page2 = Math.max(1, Number.parseInt(queryString(req.query.page), 10) || 1);
12731
+ const result = await admin.repository(state.dbSession).paginate({
12732
+ page: page2,
12733
+ pageSize: admin.pageSize,
12734
+ ...query.orderBy === void 0 ? {} : { orderBy: query.orderBy },
12735
+ ascending: query.ascending,
12736
+ ...query.where === void 0 ? {} : { filters: query.where }
12737
+ });
12738
+ const displayed = admin.listDisplayNames();
12739
+ const sort = {};
12740
+ for (const column6 of displayed) {
12741
+ if (!(column6 in columns)) continue;
12742
+ const active = (query.sortColumn ?? admin.orderKey) === column6;
12743
+ const nextAscending = active ? !query.ascending : true;
12744
+ sort[column6] = {
12745
+ url: `?${buildQuery({
12746
+ ...query.baseQuery,
12747
+ sort: column6,
12748
+ dir: nextAscending ? "asc" : "desc"
12749
+ })}`,
12750
+ active,
12751
+ ascending: query.ascending
12752
+ };
12753
+ }
12754
+ const sortParams = {
12755
+ sort: query.sortColumn ?? void 0,
12756
+ dir: query.sortColumn === null ? void 0 : query.ascending ? "asc" : "desc"
12757
+ };
12758
+ const pageUrl = (target) => `?${buildQuery({ ...query.baseQuery, ...sortParams, page: target })}`;
12759
+ const exportQuery = buildQuery({ ...query.baseQuery, ...sortParams });
12760
+ const exportUrl = (format) => `${prefix}/m/${admin.slug()}/export.${format}${exportQuery === "" ? "" : `?${exportQuery}`}`;
12761
+ const view = {
12762
+ title: admin.verboseNamePlural(),
12763
+ columns: displayed,
12764
+ rows: result.items.map((row) => {
12765
+ const identity = String(row[admin.identityField]);
12766
+ return {
12767
+ identity,
12768
+ cells: displayed.map((column6) => formatCellValue(row[column6])),
12769
+ url: `${prefix}/m/${admin.slug()}/${identity}`
12770
+ };
12771
+ }),
12772
+ total: result.total,
12773
+ page: result.page,
12774
+ pages: result.pages,
12775
+ prevUrl: result.page > 1 ? pageUrl(result.page - 1) : null,
12776
+ nextUrl: result.page < result.pages ? pageUrl(result.page + 1) : null,
12777
+ searchable: query.searchable.length > 0,
12778
+ searchValue: query.search,
12779
+ filters: query.filterViews,
12780
+ sort,
12781
+ newUrl: admin.canCreate ? `${prefix}/m/${admin.slug()}/new` : null,
12782
+ bulkActions: bulkActionsFor(admin),
12783
+ bulkUrl: `${prefix}/m/${admin.slug()}/bulk`,
12784
+ exportCsvUrl: exportUrl("csv"),
12785
+ exportJsonUrl: exportUrl("json")
12786
+ };
12787
+ return renderListPage(context(req, state.session), view);
12788
+ }
12789
+ async function resolveListQuery(req, admin, dbSession) {
12790
+ const columns = adminColumns(admin.model);
12791
+ const search = queryString(req.query.q);
12501
12792
  const sortField = queryString(req.query.sort);
12502
12793
  const sortColumn = sortField in columns ? sortField : null;
12503
12794
  const ascending = sortColumn === null ? admin.orderAscending : queryString(req.query.dir) !== "desc";
@@ -12525,6 +12816,8 @@ function makeAdminRouter(site, options) {
12525
12816
  });
12526
12817
  continue;
12527
12818
  }
12819
+ const related = await relatedOptions(column6, dbSession);
12820
+ const options2 = related ?? spec.options;
12528
12821
  const value = queryString(req.query[`filter_${field}`]);
12529
12822
  if (value !== "") {
12530
12823
  conditions.push({
@@ -12534,11 +12827,11 @@ function makeAdminRouter(site, options) {
12534
12827
  filterViews.push({
12535
12828
  field,
12536
12829
  label: humanizeField(field),
12537
- kind: spec.kind,
12830
+ kind: related === null ? spec.kind : "select",
12538
12831
  value,
12539
12832
  valueFrom: "",
12540
12833
  valueTo: "",
12541
- options: spec.options.map((option) => ({
12834
+ options: options2.map((option) => ({
12542
12835
  value: option.value,
12543
12836
  label: option.label,
12544
12837
  selected: option.value === value
@@ -12554,72 +12847,98 @@ function makeAdminRouter(site, options) {
12554
12847
  tempestDbJs.or(...searchable.map((field) => ({ [field]: { ilike: `%${search}%` } })))
12555
12848
  );
12556
12849
  }
12557
- const where = conditions.length === 0 ? void 0 : tempestDbJs.and(...conditions);
12558
- const orderBy = sortColumn ?? admin.orderKey ?? void 0;
12559
- const result = await admin.repository(state.dbSession).paginate({
12560
- page: page2,
12561
- pageSize: admin.pageSize,
12562
- ...orderBy === void 0 ? {} : { orderBy },
12563
- ascending,
12564
- ...where === void 0 ? {} : { filters: where }
12565
- });
12566
- const displayed = admin.listDisplayNames();
12567
12850
  const baseQuery = { q: search };
12568
- for (const view2 of filterViews) {
12569
- if (view2.kind === "daterange") {
12570
- baseQuery[`filter_${view2.field}_from`] = view2.valueFrom;
12571
- baseQuery[`filter_${view2.field}_to`] = view2.valueTo;
12851
+ for (const view of filterViews) {
12852
+ if (view.kind === "daterange") {
12853
+ baseQuery[`filter_${view.field}_from`] = view.valueFrom;
12854
+ baseQuery[`filter_${view.field}_to`] = view.valueTo;
12572
12855
  } else {
12573
- baseQuery[`filter_${view2.field}`] = view2.value;
12856
+ baseQuery[`filter_${view.field}`] = view.value;
12574
12857
  }
12575
12858
  }
12576
- const sort = {};
12577
- for (const column6 of displayed) {
12578
- if (!(column6 in columns)) continue;
12579
- const active = (sortColumn ?? admin.orderKey) === column6;
12580
- const nextAscending = active ? !ascending : true;
12581
- sort[column6] = {
12582
- url: `?${buildQuery({
12583
- ...baseQuery,
12584
- sort: column6,
12585
- dir: nextAscending ? "asc" : "desc"
12586
- })}`,
12587
- active,
12588
- ascending
12589
- };
12590
- }
12591
- const pageUrl = (target) => `?${buildQuery({
12592
- ...baseQuery,
12593
- sort: sortColumn ?? void 0,
12594
- dir: sortColumn === null ? void 0 : ascending ? "asc" : "desc",
12595
- page: target
12596
- })}`;
12597
- const view = {
12598
- title: admin.verboseNamePlural(),
12599
- columns: displayed,
12600
- rows: result.items.map((row) => {
12601
- const identity = String(row[admin.identityField]);
12602
- return {
12603
- identity,
12604
- cells: displayed.map((column6) => formatCellValue(row[column6])),
12605
- url: `${prefix}/m/${admin.slug()}/${identity}`
12606
- };
12607
- }),
12608
- total: result.total,
12609
- page: result.page,
12610
- pages: result.pages,
12611
- prevUrl: result.page > 1 ? pageUrl(result.page - 1) : null,
12612
- nextUrl: result.page < result.pages ? pageUrl(result.page + 1) : null,
12613
- searchable: searchable.length > 0,
12614
- searchValue: search,
12615
- filters: filterViews,
12616
- sort,
12617
- newUrl: admin.canCreate ? `${prefix}/m/${admin.slug()}/new` : null
12859
+ return {
12860
+ search,
12861
+ searchable,
12862
+ where: conditions.length === 0 ? void 0 : tempestDbJs.and(...conditions),
12863
+ orderBy: sortColumn ?? admin.orderKey ?? void 0,
12864
+ ascending,
12865
+ sortColumn,
12866
+ filterViews,
12867
+ baseQuery
12618
12868
  };
12619
- return renderListPage(context(req, state.session), view);
12869
+ }
12870
+ async function relatedOptions(column6, dbSession) {
12871
+ const table = foreignKeyTable(column6);
12872
+ if (table === null) return null;
12873
+ const referenced = site.get(table);
12874
+ if (referenced === null) return null;
12875
+ const rows = await referenced.repository(dbSession).list();
12876
+ return rows.slice(0, FK_OPTION_CAP).map((row) => ({
12877
+ value: String(row[referenced.identityField]),
12878
+ label: foreignKeyLabel(referenced, row)
12879
+ }));
12880
+ }
12881
+ async function foreignKeyOptionsFor(admin, dbSession) {
12882
+ const columns = adminColumns(admin.model);
12883
+ const options2 = {};
12884
+ for (const field of Object.keys(foreignKeyFields(admin))) {
12885
+ const column6 = columns[field];
12886
+ if (column6 === void 0) continue;
12887
+ const related = await relatedOptions(column6, dbSession);
12888
+ if (related !== null) options2[field] = related;
12889
+ }
12890
+ return options2;
12620
12891
  }
12621
12892
  return router;
12622
12893
  }
12894
+ function bulkActionsFor(admin) {
12895
+ const actions = [];
12896
+ const hasActiveFlag = "isActive" in adminColumns(admin.model);
12897
+ if (admin.canEdit && hasActiveFlag) {
12898
+ actions.push({ value: "activate", label: "Activate", dangerous: false });
12899
+ actions.push({ value: "deactivate", label: "Deactivate", dangerous: false });
12900
+ }
12901
+ if (admin.canDelete) {
12902
+ actions.push({ value: "delete", label: "Delete", dangerous: true });
12903
+ }
12904
+ for (const action of admin.customActions()) {
12905
+ actions.push({
12906
+ value: `custom:${action.name}`,
12907
+ label: action.label,
12908
+ dangerous: action.dangerous
12909
+ });
12910
+ }
12911
+ return actions;
12912
+ }
12913
+ function exportValue(value) {
12914
+ if (value instanceof Date) return value.toISOString();
12915
+ if (typeof value === "bigint") return value.toString();
12916
+ if (value instanceof Uint8Array) return Buffer.from(value).toString("base64");
12917
+ return value;
12918
+ }
12919
+ function csvField(value) {
12920
+ if (value === null || value === void 0) return "";
12921
+ const text = typeof value === "object" ? JSON.stringify(value) : String(value);
12922
+ if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
12923
+ return text;
12924
+ }
12925
+ function toCsv(columns, rows) {
12926
+ const lines = [columns.map(csvField).join(",")];
12927
+ for (const row of rows) {
12928
+ lines.push(columns.map((column6) => csvField(exportValue(row[column6]))).join(","));
12929
+ }
12930
+ return `${lines.join("\r\n")}\r
12931
+ `;
12932
+ }
12933
+ function toJson(columns, rows) {
12934
+ return JSON.stringify(
12935
+ rows.map(
12936
+ (row) => Object.fromEntries(columns.map((column6) => [column6, exportValue(row[column6])]))
12937
+ ),
12938
+ null,
12939
+ 2
12940
+ );
12941
+ }
12623
12942
  function describeWriteFailure(admin, error) {
12624
12943
  const detail = error instanceof Error ? error.message : String(error);
12625
12944
  return `The database refused this ${admin.verboseName().toLowerCase()}: ${detail}`;
@@ -14624,7 +14943,7 @@ async function withTestDatabase(models, fn) {
14624
14943
  }
14625
14944
 
14626
14945
  // src/version.ts
14627
- var VERSION = "0.24.0";
14946
+ var VERSION = "0.25.0";
14628
14947
 
14629
14948
  Object.defineProperty(exports, "OpenAPIRegistry", {
14630
14949
  enumerable: true,
@@ -14880,6 +15199,7 @@ exports.WebhookSignatureVerifier = WebhookSignatureVerifier;
14880
15199
  exports.WhatsAppProvider = WhatsAppProvider;
14881
15200
  exports.activationSchema = activationSchema;
14882
15201
  exports.addLogSink = addLogSink;
15202
+ exports.adminAction = adminAction;
14883
15203
  exports.adminColumns = adminColumns;
14884
15204
  exports.adminThemeCss = adminThemeCss;
14885
15205
  exports.attachWebSocketHub = attachWebSocketHub;
@@ -14926,6 +15246,9 @@ exports.envBoolean = looseBoolean;
14926
15246
  exports.envList = envList;
14927
15247
  exports.escapeHtml = escapeHtml;
14928
15248
  exports.filterForColumn = filterForColumn;
15249
+ exports.foreignKeyFields = foreignKeyFields;
15250
+ exports.foreignKeyLabel = foreignKeyLabel;
15251
+ exports.foreignKeyTable = foreignKeyTable;
14929
15252
  exports.formatCellValue = formatCellValue;
14930
15253
  exports.formatFieldValue = formatFieldValue;
14931
15254
  exports.generateCsrfToken = generateCsrfToken;