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.js CHANGED
@@ -1,5 +1,5 @@
1
- import { z, looseBoolean, toDict, PasswordUtils } from './chunk-6KJTKSG5.js';
2
- export { PasswordUtils, VERSION, baseAppSettingsSchema, baseAppSettingsShape, baseResponseSchema, centsField, corsSettingsShape, databaseSettingsShape, looseBoolean as envBoolean, hexColorField, latitudeField, loadSettings, longitudeField, looseBoolean, nonEmptyStrField, nonNegativeFloatField, nonNegativeIntField, percentField, portField, positiveFloatField, positiveIntField, priceField, ratingField, ratioField, serverSettingsShape, slugField, toDict, z } from './chunk-6KJTKSG5.js';
1
+ import { z, looseBoolean, toDict, PasswordUtils } from './chunk-TIW2KPT2.js';
2
+ export { PasswordUtils, VERSION, baseAppSettingsSchema, baseAppSettingsShape, baseResponseSchema, centsField, corsSettingsShape, databaseSettingsShape, looseBoolean as envBoolean, hexColorField, latitudeField, loadSettings, longitudeField, looseBoolean, nonEmptyStrField, nonNegativeFloatField, nonNegativeIntField, percentField, portField, positiveFloatField, positiveIntField, priceField, ratingField, ratioField, serverSettingsShape, slugField, toDict, z } from './chunk-TIW2KPT2.js';
3
3
  import { AsyncLocalStorage } from 'async_hooks';
4
4
  import { Model, column, sql, BaseRepository, RecordNotFound, detectDialect, columnsOf, NodeSqliteDriver, AsyncEngine, or, and } from 'tempest-db-js';
5
5
  export { AsyncEngine, AsyncResult, AsyncSession, BaseRepository, Column, DeleteBuilder, InsertBuilder, Model, NoResultError, NodeSqliteDriver, PostgresDialect, RecordNotFound, SelectBuilder, SqliteDialect, SyncEngine, SyncSession, UpdateBuilder, and, belongsTo, column, columnsOf, createEngine, createSyncEngine, del, detectDialect, getDialect, hasMany, insert, join, loadRelations, not, or, parseDatabaseUrl, select, sql, update } from 'tempest-db-js';
@@ -9317,6 +9317,21 @@ var MessagingHub = class {
9317
9317
  }
9318
9318
  };
9319
9319
 
9320
+ // src/admin/actions.ts
9321
+ function slugify(label) {
9322
+ return label.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "action";
9323
+ }
9324
+ function adminAction(options, handler) {
9325
+ const name = options.name ?? slugify(options.label);
9326
+ if (name === "") throw new Error("adminAction requires a non-empty name or label");
9327
+ return {
9328
+ name,
9329
+ label: options.label,
9330
+ handler,
9331
+ dangerous: options.dangerous ?? false
9332
+ };
9333
+ }
9334
+
9320
9335
  // src/admin/columns.ts
9321
9336
  function humanizeField(name) {
9322
9337
  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(" ");
@@ -9397,6 +9412,9 @@ function filterForColumn(column6) {
9397
9412
  }
9398
9413
  return { kind: "text", options: [] };
9399
9414
  }
9415
+ function foreignKeyTable(column6) {
9416
+ return column6.reference?.table ?? null;
9417
+ }
9400
9418
  function isSearchableColumn(column6) {
9401
9419
  const { kind } = column6.type;
9402
9420
  return kind === "varchar" || kind === "text" || kind === "char";
@@ -9433,6 +9451,7 @@ var AdminModel = class {
9433
9451
  canEdit;
9434
9452
  /** Whether the delete action is exposed. */
9435
9453
  canDelete;
9454
+ actions = /* @__PURE__ */ new Map();
9436
9455
  slugOverride;
9437
9456
  listDisplayOverride;
9438
9457
  verboseNameOverride;
@@ -9457,6 +9476,14 @@ var AdminModel = class {
9457
9476
  this.canCreate = options.canCreate ?? true;
9458
9477
  this.canEdit = options.canEdit ?? true;
9459
9478
  this.canDelete = options.canDelete ?? true;
9479
+ for (const action of options.actions ?? []) {
9480
+ if (this.actions.has(action.name)) {
9481
+ throw new Error(
9482
+ `Duplicate admin action name "${action.name}" on ${this.model.tablename}`
9483
+ );
9484
+ }
9485
+ this.actions.set(action.name, action);
9486
+ }
9460
9487
  const known = new Set(this.columnNames());
9461
9488
  for (const [option, names] of [
9462
9489
  ["listDisplay", this.listDisplayOverride ?? []],
@@ -9559,6 +9586,25 @@ var AdminModel = class {
9559
9586
  const skip = /* @__PURE__ */ new Set([...this.readonlyFields, ...NEVER_EDITABLE]);
9560
9587
  return this.columnNames().filter((name) => !skip.has(name));
9561
9588
  }
9589
+ /**
9590
+ * Return the registered custom actions, in declaration order.
9591
+ *
9592
+ * @returns The actions passed via `actions` (empty when none). The model
9593
+ * type is erased here, the way {@link AdminSite} erases it when it stores a
9594
+ * configuration — a registry keyed by slug cannot stay generic.
9595
+ */
9596
+ customActions() {
9597
+ return [...this.actions.values()];
9598
+ }
9599
+ /**
9600
+ * Look a custom action up by name.
9601
+ *
9602
+ * @param name - The action identifier (its submitted form value).
9603
+ * @returns The action, or `null` when nothing matches.
9604
+ */
9605
+ getAction(name) {
9606
+ return this.actions.get(name) ?? null;
9607
+ }
9562
9608
  /**
9563
9609
  * Build a repository for this model bound to a session.
9564
9610
  *
@@ -9600,10 +9646,12 @@ function buildFormFields(admin, options = {}) {
9600
9646
  const columns = adminColumns(admin.model);
9601
9647
  const values = options.values ?? {};
9602
9648
  const errors = options.errors ?? {};
9649
+ const foreignKeys = options.foreignKeyOptions ?? {};
9603
9650
  return admin.editableFieldNames().flatMap((name) => {
9604
9651
  const column6 = columns[name];
9605
9652
  if (column6 === void 0) return [];
9606
- const spec = widgetForColumn(column6);
9653
+ const related = foreignKeys[name];
9654
+ const spec = related === void 0 ? widgetForColumn(column6) : { widget: "select", step: null, options: related };
9607
9655
  const raw = name in values ? values[name] : literalDefault(column6);
9608
9656
  return [
9609
9657
  {
@@ -9653,7 +9701,7 @@ function coerceValue(column6, widget, raw) {
9653
9701
  throw new Error("Enter valid JSON.");
9654
9702
  }
9655
9703
  case "select": {
9656
- const allowed = meta.values ?? [];
9704
+ const allowed = kind === "enum" ? meta.values ?? [] : [];
9657
9705
  if (allowed.length > 0 && !allowed.includes(raw)) {
9658
9706
  throw new Error(`Choose one of: ${allowed.join(", ")}.`);
9659
9707
  }
@@ -9701,6 +9749,28 @@ function formatCellValue(value) {
9701
9749
  if (typeof value === "object") return JSON.stringify(value);
9702
9750
  return String(value);
9703
9751
  }
9752
+ function foreignKeyFields(admin) {
9753
+ const columns = adminColumns(admin.model);
9754
+ const out = {};
9755
+ for (const name of admin.editableFieldNames()) {
9756
+ const column6 = columns[name];
9757
+ if (column6 === void 0) continue;
9758
+ const table = foreignKeyTable(column6);
9759
+ if (table !== null) out[name] = table;
9760
+ }
9761
+ return out;
9762
+ }
9763
+ function foreignKeyLabel(admin, row) {
9764
+ for (const field of admin.searchFields) {
9765
+ const value = row[field];
9766
+ if (typeof value === "string" && value !== "") return value;
9767
+ }
9768
+ for (const field of ["name", "title", "email", "label", "reference"]) {
9769
+ const value = row[field];
9770
+ if (typeof value === "string" && value !== "") return value;
9771
+ }
9772
+ return String(row[admin.identityField] ?? "");
9773
+ }
9704
9774
 
9705
9775
  // src/admin/auth.ts
9706
9776
  var UserModelAuthBackend = class {
@@ -11838,15 +11908,40 @@ function renderFilter(filter) {
11838
11908
  return `<label><span>${escapeHtml(filter.label)}</span><input type="text" name="${escapeHtml(name)}" value="${escapeHtml(filter.value)}"></label>`;
11839
11909
  }
11840
11910
  function renderListPage(context, view) {
11911
+ const bulk = view.bulkActions.length > 0 && context.session !== null;
11912
+ const checkColumn = bulk ? 1 : 0;
11841
11913
  const headers = view.columns.map((column6) => {
11842
11914
  const state = view.sort[column6];
11843
11915
  if (state === void 0) return `<th>${escapeHtml(column6)}</th>`;
11844
11916
  const arrow = state.active ? state.ascending ? "\u25B2" : "\u25BC" : "\u2195";
11845
11917
  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>`;
11846
11918
  }).join("");
11847
- const rows = view.rows.length > 0 ? view.rows.map(
11848
- (row) => `<tr>${row.cells.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}<td><a href="${escapeHtml(row.url)}">View</a></td></tr>`
11849
- ).join("") : `<tr><td colspan="${view.columns.length + 1}">No records.</td></tr>`;
11919
+ const rows = view.rows.length > 0 ? view.rows.map((row) => {
11920
+ 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>` : "";
11921
+ const cells = row.cells.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("");
11922
+ return `<tr>${check}${cells}<td><a href="${escapeHtml(row.url)}">View</a></td></tr>`;
11923
+ }).join("") : `<tr><td colspan="${view.columns.length + 1 + checkColumn}">No records.</td></tr>`;
11924
+ 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?');">
11925
+ <input type="hidden" name="csrf_token" value="${escapeHtml(context.session?.csrfToken)}">
11926
+ <div class="tempest-admin-bulk__bar">
11927
+ <select name="action" aria-label="Bulk action">
11928
+ ${view.bulkActions.map(
11929
+ (action) => `<option value="${escapeHtml(action.value)}">${escapeHtml(action.label)}${action.dangerous ? " \u26A0" : ""}</option>`
11930
+ ).join("")}
11931
+ </select>
11932
+ <button type="submit">Apply to selected</button>
11933
+ </div>` : "";
11934
+ const selectAllScript = bulk ? `<script>
11935
+ (function () {
11936
+ var master = document.querySelector('[data-select-all]');
11937
+ if (!master) return;
11938
+ master.addEventListener('change', function () {
11939
+ document.querySelectorAll('[data-row-check]').forEach(function (box) {
11940
+ box.checked = master.checked;
11941
+ });
11942
+ });
11943
+ })();
11944
+ </script>` : "";
11850
11945
  const hasControls = view.searchable || view.filters.length > 0;
11851
11946
  const body = `<section class="tempest-admin-list">
11852
11947
  <header class="tempest-admin-list__header">
@@ -11861,14 +11956,19 @@ function renderListPage(context, view) {
11861
11956
  </form>
11862
11957
  <div class="tempest-admin-list__actions">
11863
11958
  ${view.newUrl !== null ? `<a class="tempest-admin-list__new" href="${escapeHtml(view.newUrl)}">+ New</a>` : ""}
11959
+ <a href="${escapeHtml(view.exportCsvUrl)}">Export CSV</a>
11960
+ <a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
11864
11961
  </div>
11865
11962
  </div>
11963
+ ${bulkBar}
11866
11964
  <div class="tempest-admin-table-wrap">
11867
11965
  <table class="tempest-admin-list__table">
11868
- <thead><tr>${headers}<th>Actions</th></tr></thead>
11966
+ <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>
11869
11967
  <tbody>${rows}</tbody>
11870
11968
  </table>
11871
11969
  </div>
11970
+ ${bulk ? "</form>" : ""}
11971
+ ${selectAllScript}
11872
11972
  ${view.pages > 1 ? `<nav class="tempest-admin-list__pagination" aria-label="Pagination">
11873
11973
  ${view.prevUrl !== null ? `<a href="${escapeHtml(view.prevUrl)}">\u2190 Prev</a>` : ""}
11874
11974
  <span>Page ${escapeHtml(view.page)} of ${escapeHtml(view.pages)}</span>
@@ -11962,11 +12062,16 @@ function renderFormPage(context, view) {
11962
12062
  return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
11963
12063
  }
11964
12064
  var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
12065
+ var FK_OPTION_CAP = 1e3;
12066
+ var FLASH_MAX_LENGTH = 300;
11965
12067
  var FLASH_MESSAGES = {
11966
12068
  created: { text: "Record created.", level: "success" },
11967
12069
  updated: { text: "Record updated.", level: "success" },
11968
12070
  deleted: { text: "Record deleted.", level: "success" }
11969
12071
  };
12072
+ async function runCustomAction(action, context) {
12073
+ return await action.handler(context) ?? null;
12074
+ }
11970
12075
  function queryString(value) {
11971
12076
  if (typeof value === "string") return value.trim();
11972
12077
  if (Array.isArray(value) && typeof value[0] === "string") return value[0].trim();
@@ -11984,6 +12089,7 @@ function makeAdminRouter(site, options) {
11984
12089
  const prefix = (options.prefix ?? "/admin").replace(/\/$/, "");
11985
12090
  const theme = resolveAdminTheme(site.theme);
11986
12091
  const showMetrics = options.showMetrics ?? true;
12092
+ const exportMaxRows = options.exportMaxRows ?? 5e3;
11987
12093
  const sessions = new AdminSessionStore({
11988
12094
  secret: options.secretKey,
11989
12095
  ...options.cookieName === void 0 ? {} : { cookieName: options.cookieName },
@@ -12007,8 +12113,17 @@ function makeAdminRouter(site, options) {
12007
12113
  messages: flashFor(req)
12008
12114
  });
12009
12115
  const flashFor = (req) => {
12010
- const message = FLASH_MESSAGES[queryString(req.query.ok)];
12011
- return message === void 0 ? [] : [message];
12116
+ const fixed = FLASH_MESSAGES[queryString(req.query.ok)];
12117
+ if (fixed !== void 0) return [fixed];
12118
+ const text = queryString(req.query.flash);
12119
+ if (text === "") return [];
12120
+ const level = queryString(req.query.level);
12121
+ return [
12122
+ {
12123
+ text: text.slice(0, FLASH_MAX_LENGTH),
12124
+ level: level === "error" || level === "warning" ? level : "success"
12125
+ }
12126
+ ];
12012
12127
  };
12013
12128
  const html = (res, body, status = 200) => {
12014
12129
  res.status(status).type("html").send(body);
@@ -12044,7 +12159,7 @@ function makeAdminRouter(site, options) {
12044
12159
  res.redirect(`${prefix}/mfa`);
12045
12160
  return null;
12046
12161
  }
12047
- return { session, dbSession };
12162
+ return { session, dbSession, principal };
12048
12163
  };
12049
12164
  const resolveAdmin = (req, res, state) => {
12050
12165
  const admin = site.get(String(req.params.slug));
@@ -12200,7 +12315,9 @@ function makeAdminRouter(site, options) {
12200
12315
  renderFormPage(context(req, state.session), {
12201
12316
  mode: "create",
12202
12317
  title: admin.verboseName(),
12203
- fields: buildFormFields(admin),
12318
+ fields: buildFormFields(admin, {
12319
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
12320
+ }),
12204
12321
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12205
12322
  backUrl: `${prefix}/m/${admin.slug()}`,
12206
12323
  error: null
@@ -12222,13 +12339,18 @@ function makeAdminRouter(site, options) {
12222
12339
  if (!checkCsrf(req, res, state)) return;
12223
12340
  const body = req.body;
12224
12341
  const parsed = parseFormBody(admin, body);
12342
+ const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
12225
12343
  const rerender = (error, status) => {
12226
12344
  html(
12227
12345
  res,
12228
12346
  renderFormPage(context(req, state.session), {
12229
12347
  mode: "create",
12230
12348
  title: admin.verboseName(),
12231
- fields: buildFormFields(admin, { values: body, errors: parsed.errors }),
12349
+ fields: buildFormFields(admin, {
12350
+ values: body,
12351
+ errors: parsed.errors,
12352
+ foreignKeyOptions
12353
+ }),
12232
12354
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12233
12355
  backUrl: `${prefix}/m/${admin.slug()}`,
12234
12356
  error
@@ -12249,6 +12371,106 @@ function makeAdminRouter(site, options) {
12249
12371
  res.redirect(`${prefix}/m/${admin.slug()}?ok=created`);
12250
12372
  })
12251
12373
  );
12374
+ router.get(
12375
+ `${prefix}/m/:slug/export.:format`,
12376
+ guarded(async (req, res) => {
12377
+ const state = await authenticate(req, res);
12378
+ if (state === null) return;
12379
+ const admin = resolveAdmin(req, res, state);
12380
+ if (admin === null) return;
12381
+ const format = String(req.params.format);
12382
+ if (format !== "csv" && format !== "json") {
12383
+ html(res, renderNotFound(context(req, state.session)), 404);
12384
+ return;
12385
+ }
12386
+ const query = await resolveListQuery(req, admin, state.dbSession);
12387
+ const result = await admin.repository(state.dbSession).paginate({
12388
+ page: 1,
12389
+ pageSize: exportMaxRows,
12390
+ ...query.orderBy === void 0 ? {} : { orderBy: query.orderBy },
12391
+ ascending: query.ascending,
12392
+ ...query.where === void 0 ? {} : { filters: query.where }
12393
+ });
12394
+ const columns = admin.listDisplayNames();
12395
+ const rows = result.items;
12396
+ const payload = format === "csv" ? toCsv(columns, rows) : toJson(columns, rows);
12397
+ res.status(200).type(format === "csv" ? "text/csv; charset=utf-8" : "application/json").set("content-disposition", `attachment; filename="${admin.slug()}.${format}"`).send(payload);
12398
+ })
12399
+ );
12400
+ router.post(
12401
+ `${prefix}/m/:slug/bulk`,
12402
+ guarded(async (req, res) => {
12403
+ const state = await authenticate(req, res);
12404
+ if (state === null) return;
12405
+ const admin = resolveAdmin(req, res, state);
12406
+ if (admin === null) return;
12407
+ if (!checkCsrf(req, res, state)) return;
12408
+ const body = req.body;
12409
+ const action = typeof body.action === "string" ? body.action : "";
12410
+ const raw = body.ids;
12411
+ const ids = (Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw]).filter((value) => typeof value === "string").filter((value) => value !== "");
12412
+ const listUrl = `${prefix}/m/${admin.slug()}`;
12413
+ const back = (message, level) => {
12414
+ res.redirect(`${listUrl}?${buildQuery({ flash: message, level })}`);
12415
+ };
12416
+ if (ids.length === 0) {
12417
+ back("No rows were selected.", "warning");
12418
+ return;
12419
+ }
12420
+ if (!bulkActionsFor(admin).some((option) => option.value === action)) {
12421
+ html(res, renderNotFound(context(req, state.session)), 400);
12422
+ return;
12423
+ }
12424
+ const repository = admin.repository(state.dbSession);
12425
+ const scope = { [admin.identityField]: { in: ids } };
12426
+ if (action.startsWith("custom:")) {
12427
+ const custom = admin.getAction(action.slice("custom:".length));
12428
+ if (custom === null) {
12429
+ html(res, renderNotFound(context(req, state.session)), 400);
12430
+ return;
12431
+ }
12432
+ let result;
12433
+ try {
12434
+ result = await runCustomAction(custom, {
12435
+ ids,
12436
+ repository,
12437
+ dbSession: state.dbSession,
12438
+ request: req,
12439
+ session: state.session,
12440
+ principal: state.principal
12441
+ });
12442
+ } catch (error) {
12443
+ logger2.error("Admin action failed", {
12444
+ slug: admin.slug(),
12445
+ action: custom.name,
12446
+ error: error instanceof Error ? error.message : String(error)
12447
+ });
12448
+ back(
12449
+ `${custom.label} failed: ${error instanceof Error ? error.message : String(error)}`,
12450
+ "error"
12451
+ );
12452
+ return;
12453
+ }
12454
+ if (result === null) {
12455
+ res.redirect(listUrl);
12456
+ return;
12457
+ }
12458
+ back(result.message, result.category ?? "success");
12459
+ return;
12460
+ }
12461
+ if (action === "delete") {
12462
+ const removed = await repository.delete(scope);
12463
+ back(`Deleted ${removed} record${removed === 1 ? "" : "s"}.`, "success");
12464
+ return;
12465
+ }
12466
+ const active = action === "activate";
12467
+ const changed = await repository.update(scope, { isActive: active });
12468
+ back(
12469
+ `${active ? "Activated" : "Deactivated"} ${changed} record${changed === 1 ? "" : "s"}.`,
12470
+ "success"
12471
+ );
12472
+ })
12473
+ );
12252
12474
  router.get(
12253
12475
  `${prefix}/m/:slug/:identity`,
12254
12476
  guarded(async (req, res) => {
@@ -12293,7 +12515,10 @@ function makeAdminRouter(site, options) {
12293
12515
  renderFormPage(context(req, state.session), {
12294
12516
  mode: "edit",
12295
12517
  title: admin.verboseName(),
12296
- fields: buildFormFields(admin, { values: row }),
12518
+ fields: buildFormFields(admin, {
12519
+ values: row,
12520
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
12521
+ }),
12297
12522
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12298
12523
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
12299
12524
  error: null
@@ -12316,13 +12541,18 @@ function makeAdminRouter(site, options) {
12316
12541
  if (!checkCsrf(req, res, state)) return;
12317
12542
  const body = req.body;
12318
12543
  const parsed = parseFormBody(admin, body);
12544
+ const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
12319
12545
  const rerender = (error, status) => {
12320
12546
  html(
12321
12547
  res,
12322
12548
  renderFormPage(context(req, state.session), {
12323
12549
  mode: "edit",
12324
12550
  title: admin.verboseName(),
12325
- fields: buildFormFields(admin, { values: body, errors: parsed.errors }),
12551
+ fields: buildFormFields(admin, {
12552
+ values: body,
12553
+ errors: parsed.errors,
12554
+ foreignKeyOptions
12555
+ }),
12326
12556
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12327
12557
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
12328
12558
  error
@@ -12369,8 +12599,69 @@ function makeAdminRouter(site, options) {
12369
12599
  }
12370
12600
  async function renderList(req, admin, state) {
12371
12601
  const columns = adminColumns(admin.model);
12372
- const search = queryString(req.query.q);
12602
+ const query = await resolveListQuery(req, admin, state.dbSession);
12373
12603
  const page2 = Math.max(1, Number.parseInt(queryString(req.query.page), 10) || 1);
12604
+ const result = await admin.repository(state.dbSession).paginate({
12605
+ page: page2,
12606
+ pageSize: admin.pageSize,
12607
+ ...query.orderBy === void 0 ? {} : { orderBy: query.orderBy },
12608
+ ascending: query.ascending,
12609
+ ...query.where === void 0 ? {} : { filters: query.where }
12610
+ });
12611
+ const displayed = admin.listDisplayNames();
12612
+ const sort = {};
12613
+ for (const column6 of displayed) {
12614
+ if (!(column6 in columns)) continue;
12615
+ const active = (query.sortColumn ?? admin.orderKey) === column6;
12616
+ const nextAscending = active ? !query.ascending : true;
12617
+ sort[column6] = {
12618
+ url: `?${buildQuery({
12619
+ ...query.baseQuery,
12620
+ sort: column6,
12621
+ dir: nextAscending ? "asc" : "desc"
12622
+ })}`,
12623
+ active,
12624
+ ascending: query.ascending
12625
+ };
12626
+ }
12627
+ const sortParams = {
12628
+ sort: query.sortColumn ?? void 0,
12629
+ dir: query.sortColumn === null ? void 0 : query.ascending ? "asc" : "desc"
12630
+ };
12631
+ const pageUrl = (target) => `?${buildQuery({ ...query.baseQuery, ...sortParams, page: target })}`;
12632
+ const exportQuery = buildQuery({ ...query.baseQuery, ...sortParams });
12633
+ const exportUrl = (format) => `${prefix}/m/${admin.slug()}/export.${format}${exportQuery === "" ? "" : `?${exportQuery}`}`;
12634
+ const view = {
12635
+ title: admin.verboseNamePlural(),
12636
+ columns: displayed,
12637
+ rows: result.items.map((row) => {
12638
+ const identity = String(row[admin.identityField]);
12639
+ return {
12640
+ identity,
12641
+ cells: displayed.map((column6) => formatCellValue(row[column6])),
12642
+ url: `${prefix}/m/${admin.slug()}/${identity}`
12643
+ };
12644
+ }),
12645
+ total: result.total,
12646
+ page: result.page,
12647
+ pages: result.pages,
12648
+ prevUrl: result.page > 1 ? pageUrl(result.page - 1) : null,
12649
+ nextUrl: result.page < result.pages ? pageUrl(result.page + 1) : null,
12650
+ searchable: query.searchable.length > 0,
12651
+ searchValue: query.search,
12652
+ filters: query.filterViews,
12653
+ sort,
12654
+ newUrl: admin.canCreate ? `${prefix}/m/${admin.slug()}/new` : null,
12655
+ bulkActions: bulkActionsFor(admin),
12656
+ bulkUrl: `${prefix}/m/${admin.slug()}/bulk`,
12657
+ exportCsvUrl: exportUrl("csv"),
12658
+ exportJsonUrl: exportUrl("json")
12659
+ };
12660
+ return renderListPage(context(req, state.session), view);
12661
+ }
12662
+ async function resolveListQuery(req, admin, dbSession) {
12663
+ const columns = adminColumns(admin.model);
12664
+ const search = queryString(req.query.q);
12374
12665
  const sortField = queryString(req.query.sort);
12375
12666
  const sortColumn = sortField in columns ? sortField : null;
12376
12667
  const ascending = sortColumn === null ? admin.orderAscending : queryString(req.query.dir) !== "desc";
@@ -12398,6 +12689,8 @@ function makeAdminRouter(site, options) {
12398
12689
  });
12399
12690
  continue;
12400
12691
  }
12692
+ const related = await relatedOptions(column6, dbSession);
12693
+ const options2 = related ?? spec.options;
12401
12694
  const value = queryString(req.query[`filter_${field}`]);
12402
12695
  if (value !== "") {
12403
12696
  conditions.push({
@@ -12407,11 +12700,11 @@ function makeAdminRouter(site, options) {
12407
12700
  filterViews.push({
12408
12701
  field,
12409
12702
  label: humanizeField(field),
12410
- kind: spec.kind,
12703
+ kind: related === null ? spec.kind : "select",
12411
12704
  value,
12412
12705
  valueFrom: "",
12413
12706
  valueTo: "",
12414
- options: spec.options.map((option) => ({
12707
+ options: options2.map((option) => ({
12415
12708
  value: option.value,
12416
12709
  label: option.label,
12417
12710
  selected: option.value === value
@@ -12427,72 +12720,98 @@ function makeAdminRouter(site, options) {
12427
12720
  or(...searchable.map((field) => ({ [field]: { ilike: `%${search}%` } })))
12428
12721
  );
12429
12722
  }
12430
- const where = conditions.length === 0 ? void 0 : and(...conditions);
12431
- const orderBy = sortColumn ?? admin.orderKey ?? void 0;
12432
- const result = await admin.repository(state.dbSession).paginate({
12433
- page: page2,
12434
- pageSize: admin.pageSize,
12435
- ...orderBy === void 0 ? {} : { orderBy },
12436
- ascending,
12437
- ...where === void 0 ? {} : { filters: where }
12438
- });
12439
- const displayed = admin.listDisplayNames();
12440
12723
  const baseQuery = { q: search };
12441
- for (const view2 of filterViews) {
12442
- if (view2.kind === "daterange") {
12443
- baseQuery[`filter_${view2.field}_from`] = view2.valueFrom;
12444
- baseQuery[`filter_${view2.field}_to`] = view2.valueTo;
12724
+ for (const view of filterViews) {
12725
+ if (view.kind === "daterange") {
12726
+ baseQuery[`filter_${view.field}_from`] = view.valueFrom;
12727
+ baseQuery[`filter_${view.field}_to`] = view.valueTo;
12445
12728
  } else {
12446
- baseQuery[`filter_${view2.field}`] = view2.value;
12729
+ baseQuery[`filter_${view.field}`] = view.value;
12447
12730
  }
12448
12731
  }
12449
- const sort = {};
12450
- for (const column6 of displayed) {
12451
- if (!(column6 in columns)) continue;
12452
- const active = (sortColumn ?? admin.orderKey) === column6;
12453
- const nextAscending = active ? !ascending : true;
12454
- sort[column6] = {
12455
- url: `?${buildQuery({
12456
- ...baseQuery,
12457
- sort: column6,
12458
- dir: nextAscending ? "asc" : "desc"
12459
- })}`,
12460
- active,
12461
- ascending
12462
- };
12463
- }
12464
- const pageUrl = (target) => `?${buildQuery({
12465
- ...baseQuery,
12466
- sort: sortColumn ?? void 0,
12467
- dir: sortColumn === null ? void 0 : ascending ? "asc" : "desc",
12468
- page: target
12469
- })}`;
12470
- const view = {
12471
- title: admin.verboseNamePlural(),
12472
- columns: displayed,
12473
- rows: result.items.map((row) => {
12474
- const identity = String(row[admin.identityField]);
12475
- return {
12476
- identity,
12477
- cells: displayed.map((column6) => formatCellValue(row[column6])),
12478
- url: `${prefix}/m/${admin.slug()}/${identity}`
12479
- };
12480
- }),
12481
- total: result.total,
12482
- page: result.page,
12483
- pages: result.pages,
12484
- prevUrl: result.page > 1 ? pageUrl(result.page - 1) : null,
12485
- nextUrl: result.page < result.pages ? pageUrl(result.page + 1) : null,
12486
- searchable: searchable.length > 0,
12487
- searchValue: search,
12488
- filters: filterViews,
12489
- sort,
12490
- newUrl: admin.canCreate ? `${prefix}/m/${admin.slug()}/new` : null
12732
+ return {
12733
+ search,
12734
+ searchable,
12735
+ where: conditions.length === 0 ? void 0 : and(...conditions),
12736
+ orderBy: sortColumn ?? admin.orderKey ?? void 0,
12737
+ ascending,
12738
+ sortColumn,
12739
+ filterViews,
12740
+ baseQuery
12491
12741
  };
12492
- return renderListPage(context(req, state.session), view);
12742
+ }
12743
+ async function relatedOptions(column6, dbSession) {
12744
+ const table = foreignKeyTable(column6);
12745
+ if (table === null) return null;
12746
+ const referenced = site.get(table);
12747
+ if (referenced === null) return null;
12748
+ const rows = await referenced.repository(dbSession).list();
12749
+ return rows.slice(0, FK_OPTION_CAP).map((row) => ({
12750
+ value: String(row[referenced.identityField]),
12751
+ label: foreignKeyLabel(referenced, row)
12752
+ }));
12753
+ }
12754
+ async function foreignKeyOptionsFor(admin, dbSession) {
12755
+ const columns = adminColumns(admin.model);
12756
+ const options2 = {};
12757
+ for (const field of Object.keys(foreignKeyFields(admin))) {
12758
+ const column6 = columns[field];
12759
+ if (column6 === void 0) continue;
12760
+ const related = await relatedOptions(column6, dbSession);
12761
+ if (related !== null) options2[field] = related;
12762
+ }
12763
+ return options2;
12493
12764
  }
12494
12765
  return router;
12495
12766
  }
12767
+ function bulkActionsFor(admin) {
12768
+ const actions = [];
12769
+ const hasActiveFlag = "isActive" in adminColumns(admin.model);
12770
+ if (admin.canEdit && hasActiveFlag) {
12771
+ actions.push({ value: "activate", label: "Activate", dangerous: false });
12772
+ actions.push({ value: "deactivate", label: "Deactivate", dangerous: false });
12773
+ }
12774
+ if (admin.canDelete) {
12775
+ actions.push({ value: "delete", label: "Delete", dangerous: true });
12776
+ }
12777
+ for (const action of admin.customActions()) {
12778
+ actions.push({
12779
+ value: `custom:${action.name}`,
12780
+ label: action.label,
12781
+ dangerous: action.dangerous
12782
+ });
12783
+ }
12784
+ return actions;
12785
+ }
12786
+ function exportValue(value) {
12787
+ if (value instanceof Date) return value.toISOString();
12788
+ if (typeof value === "bigint") return value.toString();
12789
+ if (value instanceof Uint8Array) return Buffer.from(value).toString("base64");
12790
+ return value;
12791
+ }
12792
+ function csvField(value) {
12793
+ if (value === null || value === void 0) return "";
12794
+ const text = typeof value === "object" ? JSON.stringify(value) : String(value);
12795
+ if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
12796
+ return text;
12797
+ }
12798
+ function toCsv(columns, rows) {
12799
+ const lines = [columns.map(csvField).join(",")];
12800
+ for (const row of rows) {
12801
+ lines.push(columns.map((column6) => csvField(exportValue(row[column6]))).join(","));
12802
+ }
12803
+ return `${lines.join("\r\n")}\r
12804
+ `;
12805
+ }
12806
+ function toJson(columns, rows) {
12807
+ return JSON.stringify(
12808
+ rows.map(
12809
+ (row) => Object.fromEntries(columns.map((column6) => [column6, exportValue(row[column6])]))
12810
+ ),
12811
+ null,
12812
+ 2
12813
+ );
12814
+ }
12496
12815
  function describeWriteFailure(admin, error) {
12497
12816
  const detail = error instanceof Error ? error.message : String(error);
12498
12817
  return `The database refused this ${admin.verboseName().toLowerCase()}: ${detail}`;
@@ -14496,6 +14815,6 @@ async function withTestDatabase(models, fn) {
14496
14815
  }
14497
14816
  }
14498
14817
 
14499
- export { ADMIN_CSS, ActivationService, AdminJsonSite, AdminModel, AdminSessionStore, AdminSite, AppException, AttemptThrottle, AuditAction, BaseAuditLogModel, BaseController, BaseModel, BaseOAuthClient, BaseOutboxModel, BaseService, BaseUserModel, BaseUserRefreshTokenModel, BaseUserTokenModel, CEP_PATTERN, CNPJ_PATTERN, CPF_PATTERN, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, CircuitOpenError, CompositeFeatureFlagBackend, ConflictException, DEFAULT_DOCS_FAVICON, DEFAULT_LOCALE, EmailProvider, EmailUtils, EnvFeatureFlagBackend, EventStream, ExpiredTokenException, FeatureFlags, ForbiddenException, GitHubOAuthClient, GoogleOAuthClient, GracefulShutdown, HTTPClient, HTTP_500_LOG_FILE, HTTP_500_MARKER, HttpMetrics, IDEMPOTENCY_HEADER, InvalidTokenException, JSONLogger, JWTUtils, LEVEL_LOG_FILES, LocalUploadStorage, MemoryBroker, MemoryCacheManager, MemoryFeatureFlagBackend, MemoryIdempotencyStore, MemoryRateLimitStore, MemorySessionStore, MemoryThrottleBackend, MessageCatalog, MessagingHub, MetricsUtils, MfaService, NotFoundException, OAuthError, OIDCProvider, OutboxRelay, OutboxStatus, PHONE_BR_PATTERN, PasswordResetService, REDOC_CDN_URL, REQUEST_ID_HEADER, RabbitBroker, RedisCacheManager, RedisIdempotencyStore, RedisRateLimitStore, RedisSSEBroker, RedisSessionStore, Region, RetryPolicy, S3UploadStorage, SSEBroker, ServerSentEvent, SessionService, TOTPHelper, TaskManager, TelegramProvider, TenantScopedRepository, TooManyRequestsException, TwilioSmsProvider, UF, UnauthorizedException, UserAuthService, UserModelAuthBackend, UserTokenPurpose, ValidationException, WebPushDispatcher, WebPushError, WebPushGoneError, WebSocketHub, WebhookSignatureVerifier, WhatsAppProvider, activationSchema, addLogSink, adminColumns, adminThemeCss, attachWebSocketHub, authResponseSchema, authSettingsShape, backupDatabase, bearerToken, bodySizeLimitMiddleware, broadcastText, buildContentDisposition, buildFormFields, buildPaginationLinkHeader, cached2 as cached, cepField, citiesByUf, cnpjField, coerceFlag, configureFileLogging, configureLogging, cpfField, cpfOrCnpjField, createApp, createOpenApiRegistry, createTestDatabase, createdByColumn, csrfMiddleware, csrfTokenMatches, cursorPaginationFilterSchema, cursorPaginationSchema, decodeCursor, defaultMessageCatalog, defineEnum, deletedAtColumn, diffSnapshots, emailSettingsShape, encodeCursor, envList, escapeHtml, filterForColumn, formatCellValue, formatFieldValue, generateCsrfToken, generateOAuthState, generateOpaqueToken, generateOpenApiDocument, getAuth, getClientIp, getConditions, getPaginationConditions, getRequestId, getState, hashOpaqueToken, humanizeField, idempotencyMiddleware, inboundMessageSchema, isColumnOptional, isSearchableColumn, isValidCep, isValidCity, isValidCnpj, isValidCpf, isValidCpfCnpj, isValidPhoneBr, isValidUf, jwtSettingsShape, keyByHeader, keyByIp, keyByJwtClaim, keyByJwtSubject, listStates, logEntrySchema, logSettingsShape, loginSchema, makeAdminJsonRouter, makeAdminRouter, makeAppExceptionHandler, makeAuthRouter, makeFlagGuard, makeHealthRouter, makeJwtAuthMiddleware, makeLogsRouter, makeMetricsRouter, makeSessionMiddleware, makeToolSpecRouter, makeTwilioWebhookRouter, makeUnhandledExceptionHandler, makeWhatsAppWebhookRouter, mfaChallengeSchema, mfaCodeSchema, mfaEnrollResponseSchema, minioSettingsShape, modifyDict, mountOpenApiJson, mountRedoc, mountSwaggerUi, normalizeCep, normalizeCnpj, normalizeCpf, normalizeCpfCnpj, normalizePhoneBr, normalizeUf, notFoundHandler, onlyDigits, paginationFilterSchema, paginationSchema, parseAcceptLanguage, parseCookies, parseFormBody, passwordResetConfirmSchema, passwordResetRequestSchema, phoneBrField, prometheusMiddleware, rabbitmqSettingsShape, rateLimitMiddleware, redisSettingsShape, refreshSchema, registerExceptionHandlers, renderAuthResultPage, renderDashboardPage, renderDetailPage, renderFormPage, renderLayout, renderListPage, renderLoginPage, renderMfaPage, renderPasswordResetFormPage, requestIdMiddleware, requestTracingMiddleware, requireRoles, resolveAdminTheme, resolveDownloadPath, resolveRedocBundle, runServer, runWithRequestContext, sendBytesDownload, sendFileDownload, sessionCookie, sessionSettingsShape, setRequestId, signupSchema, snapshot, sseResponse, statesByRegion, syncFilterSchema, syncPaginationSchema, tableNameFor, toUtc, tokenFromUrl, tokenPairSchema, tokenSettingsShape, ufField, updatedByColumn, uploadSettingsShape, userPublicSchema, utcnow, validateTwilioSignature, verifyOpaqueToken, webPushKeysSchema, webPushPayloadSchema, webPushSettingsShape, webPushSubscriptionSchema, webSocketSettingsShape, widgetForColumn, withTestDatabase, wrapWithSlowQueryLog, wsEnvelopeSchema };
14818
+ export { ADMIN_CSS, ActivationService, AdminJsonSite, AdminModel, AdminSessionStore, AdminSite, AppException, AttemptThrottle, AuditAction, BaseAuditLogModel, BaseController, BaseModel, BaseOAuthClient, BaseOutboxModel, BaseService, BaseUserModel, BaseUserRefreshTokenModel, BaseUserTokenModel, CEP_PATTERN, CNPJ_PATTERN, CPF_PATTERN, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, CircuitOpenError, CompositeFeatureFlagBackend, ConflictException, DEFAULT_DOCS_FAVICON, DEFAULT_LOCALE, EmailProvider, EmailUtils, EnvFeatureFlagBackend, EventStream, ExpiredTokenException, FeatureFlags, ForbiddenException, GitHubOAuthClient, GoogleOAuthClient, GracefulShutdown, HTTPClient, HTTP_500_LOG_FILE, HTTP_500_MARKER, HttpMetrics, IDEMPOTENCY_HEADER, InvalidTokenException, JSONLogger, JWTUtils, LEVEL_LOG_FILES, LocalUploadStorage, MemoryBroker, MemoryCacheManager, MemoryFeatureFlagBackend, MemoryIdempotencyStore, MemoryRateLimitStore, MemorySessionStore, MemoryThrottleBackend, MessageCatalog, MessagingHub, MetricsUtils, MfaService, NotFoundException, OAuthError, OIDCProvider, OutboxRelay, OutboxStatus, PHONE_BR_PATTERN, PasswordResetService, REDOC_CDN_URL, REQUEST_ID_HEADER, RabbitBroker, RedisCacheManager, RedisIdempotencyStore, RedisRateLimitStore, RedisSSEBroker, RedisSessionStore, Region, RetryPolicy, S3UploadStorage, SSEBroker, ServerSentEvent, SessionService, TOTPHelper, TaskManager, TelegramProvider, TenantScopedRepository, TooManyRequestsException, TwilioSmsProvider, UF, UnauthorizedException, UserAuthService, UserModelAuthBackend, UserTokenPurpose, ValidationException, WebPushDispatcher, WebPushError, WebPushGoneError, WebSocketHub, WebhookSignatureVerifier, WhatsAppProvider, activationSchema, addLogSink, adminAction, adminColumns, adminThemeCss, attachWebSocketHub, authResponseSchema, authSettingsShape, backupDatabase, bearerToken, bodySizeLimitMiddleware, broadcastText, buildContentDisposition, buildFormFields, buildPaginationLinkHeader, cached2 as cached, cepField, citiesByUf, cnpjField, coerceFlag, configureFileLogging, configureLogging, cpfField, cpfOrCnpjField, createApp, createOpenApiRegistry, createTestDatabase, createdByColumn, csrfMiddleware, csrfTokenMatches, cursorPaginationFilterSchema, cursorPaginationSchema, decodeCursor, defaultMessageCatalog, defineEnum, deletedAtColumn, diffSnapshots, emailSettingsShape, encodeCursor, envList, escapeHtml, filterForColumn, foreignKeyFields, foreignKeyLabel, foreignKeyTable, formatCellValue, formatFieldValue, generateCsrfToken, generateOAuthState, generateOpaqueToken, generateOpenApiDocument, getAuth, getClientIp, getConditions, getPaginationConditions, getRequestId, getState, hashOpaqueToken, humanizeField, idempotencyMiddleware, inboundMessageSchema, isColumnOptional, isSearchableColumn, isValidCep, isValidCity, isValidCnpj, isValidCpf, isValidCpfCnpj, isValidPhoneBr, isValidUf, jwtSettingsShape, keyByHeader, keyByIp, keyByJwtClaim, keyByJwtSubject, listStates, logEntrySchema, logSettingsShape, loginSchema, makeAdminJsonRouter, makeAdminRouter, makeAppExceptionHandler, makeAuthRouter, makeFlagGuard, makeHealthRouter, makeJwtAuthMiddleware, makeLogsRouter, makeMetricsRouter, makeSessionMiddleware, makeToolSpecRouter, makeTwilioWebhookRouter, makeUnhandledExceptionHandler, makeWhatsAppWebhookRouter, mfaChallengeSchema, mfaCodeSchema, mfaEnrollResponseSchema, minioSettingsShape, modifyDict, mountOpenApiJson, mountRedoc, mountSwaggerUi, normalizeCep, normalizeCnpj, normalizeCpf, normalizeCpfCnpj, normalizePhoneBr, normalizeUf, notFoundHandler, onlyDigits, paginationFilterSchema, paginationSchema, parseAcceptLanguage, parseCookies, parseFormBody, passwordResetConfirmSchema, passwordResetRequestSchema, phoneBrField, prometheusMiddleware, rabbitmqSettingsShape, rateLimitMiddleware, redisSettingsShape, refreshSchema, registerExceptionHandlers, renderAuthResultPage, renderDashboardPage, renderDetailPage, renderFormPage, renderLayout, renderListPage, renderLoginPage, renderMfaPage, renderPasswordResetFormPage, requestIdMiddleware, requestTracingMiddleware, requireRoles, resolveAdminTheme, resolveDownloadPath, resolveRedocBundle, runServer, runWithRequestContext, sendBytesDownload, sendFileDownload, sessionCookie, sessionSettingsShape, setRequestId, signupSchema, snapshot, sseResponse, statesByRegion, syncFilterSchema, syncPaginationSchema, tableNameFor, toUtc, tokenFromUrl, tokenPairSchema, tokenSettingsShape, ufField, updatedByColumn, uploadSettingsShape, userPublicSchema, utcnow, validateTwilioSignature, verifyOpaqueToken, webPushKeysSchema, webPushPayloadSchema, webPushSettingsShape, webPushSubscriptionSchema, webSocketSettingsShape, widgetForColumn, withTestDatabase, wrapWithSlowQueryLog, wsEnvelopeSchema };
14500
14819
  //# sourceMappingURL=index.js.map
14501
14820
  //# sourceMappingURL=index.js.map