tempest-express-sdk 0.27.0 → 0.28.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
@@ -9461,6 +9461,23 @@ function partitionTotal(partition) {
9461
9461
  return partition.segments.reduce((total, segment) => total + segment.value, 0);
9462
9462
  }
9463
9463
 
9464
+ // src/admin/inlines.ts
9465
+ function adminInline(options) {
9466
+ const slug = options.model.tablename;
9467
+ if (typeof slug !== "string" || slug === "") {
9468
+ throw new Error("adminInline requires a concrete model with a tablename");
9469
+ }
9470
+ return {
9471
+ model: options.model,
9472
+ slug,
9473
+ fkField: options.fkField,
9474
+ listDisplay: options.listDisplay === void 0 ? null : [...options.listDisplay],
9475
+ label: options.label ?? null,
9476
+ editable: options.editable ?? false,
9477
+ canDelete: options.canDelete ?? false
9478
+ };
9479
+ }
9480
+
9464
9481
  // src/admin/lenses.ts
9465
9482
  function slugify(name) {
9466
9483
  return name.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "lens";
@@ -9716,6 +9733,8 @@ var AdminModel = class {
9716
9733
  canImport;
9717
9734
  /** Foreign-key columns rendered as a typed search box. */
9718
9735
  autocompleteFields;
9736
+ /** Related child models listed on the detail view. */
9737
+ inlines;
9719
9738
  actions = /* @__PURE__ */ new Map();
9720
9739
  slugOverride;
9721
9740
  listDisplayOverride;
@@ -9747,6 +9766,7 @@ var AdminModel = class {
9747
9766
  this.uploadStorage = options.uploadStorage ?? null;
9748
9767
  this.canImport = options.canImport ?? false;
9749
9768
  this.autocompleteFields = [...options.autocompleteFields ?? []];
9769
+ this.inlines = [...options.inlines ?? []];
9750
9770
  if (this.uploadFields.length > 0 && this.uploadStorage === null) {
9751
9771
  throw new Error(
9752
9772
  `AdminModel(${this.model.tablename}).uploadFields requires an uploadStorage (e.g. LocalUploadStorage / S3UploadStorage) \u2014 without one there is nowhere to write the file.`
@@ -10025,7 +10045,9 @@ function parseFormBody(admin, body, options = {}) {
10025
10045
  const data = {};
10026
10046
  const errors = {};
10027
10047
  const uploads = options.uploadsAsText === true ? /* @__PURE__ */ new Set() : new Set(admin.uploadFields);
10048
+ const only = options.only === void 0 ? null : new Set(options.only);
10028
10049
  for (const name of admin.editableFieldNames()) {
10050
+ if (only !== null && !only.has(name)) continue;
10029
10051
  const column6 = columns[name];
10030
10052
  if (column6 === void 0) continue;
10031
10053
  if (uploads.has(name)) continue;
@@ -12359,10 +12381,97 @@ function renderDetailPage(context, view) {
12359
12381
  </div>
12360
12382
  </header>
12361
12383
  <dl class="tempest-admin-detail__fields">${fields}</dl>
12384
+ ${view.inlines.map((inline) => renderInline(inline, context.session?.csrfToken ?? "", view.inlineError)).join("")}
12362
12385
  ${auditPanel}
12363
12386
  </section>`;
12364
12387
  return renderLayout(context, `${view.title} \xB7 ${view.identity}`, body);
12365
12388
  }
12389
+ function renderInlineCell(field) {
12390
+ const required = field.required ? " required" : "";
12391
+ const name = escapeHtml(field.name);
12392
+ const value = escapeHtml(field.value);
12393
+ let control;
12394
+ switch (field.widget) {
12395
+ case "checkbox":
12396
+ control = `<input type="checkbox" name="${name}" value="true"${field.checked ? " checked" : ""}>`;
12397
+ break;
12398
+ case "textarea":
12399
+ case "json":
12400
+ control = `<textarea name="${name}" rows="2"${required}>${value}</textarea>`;
12401
+ break;
12402
+ case "select": {
12403
+ const blank = field.required ? "" : '<option value="">\u2014 none \u2014</option>';
12404
+ const options = field.options.map(
12405
+ (option) => `<option value="${escapeHtml(option.value)}"${option.value === field.value ? " selected" : ""}>${escapeHtml(option.label)}</option>`
12406
+ ).join("");
12407
+ control = `<select name="${name}"${required}>${blank}${options}</select>`;
12408
+ break;
12409
+ }
12410
+ case "number":
12411
+ control = `<input type="number" name="${name}" value="${value}"${field.step !== null ? ` step="${escapeHtml(field.step)}"` : ""}${required}>`;
12412
+ break;
12413
+ case "datetime":
12414
+ control = `<input type="datetime-local" name="${name}" value="${value}"${required}>`;
12415
+ break;
12416
+ case "date":
12417
+ control = `<input type="date" name="${name}" value="${value}"${required}>`;
12418
+ break;
12419
+ case "time":
12420
+ control = `<input type="time" name="${name}" value="${value}"${required}>`;
12421
+ break;
12422
+ default:
12423
+ control = `<input type="text" name="${name}" value="${value}"${required}>`;
12424
+ }
12425
+ const error = field.error !== null ? `<small class="tempest-admin-form__field-error">${escapeHtml(field.error)}</small>` : "";
12426
+ return `${control}${error}`;
12427
+ }
12428
+ function renderInline(inline, csrfToken, error) {
12429
+ const heading = `<header class="tempest-admin-inline__header">
12430
+ <h2>${escapeHtml(inline.label)}${inline.total > 0 ? ` <span class="tempest-admin-inline__count">(${escapeHtml(inline.total)})</span>` : ""}</h2>
12431
+ ${inline.addUrl !== null ? `<a class="tempest-admin-btn" href="${escapeHtml(inline.addUrl)}">Add</a>` : ""}
12432
+ </header>`;
12433
+ const more = inline.truncated ? `<p class="tempest-admin-inline__more"><em>Showing the first ${escapeHtml(inline.rows.length)} of ${escapeHtml(inline.total)}.</em></p>` : "";
12434
+ if (!inline.editable) {
12435
+ if (inline.rows.length === 0) {
12436
+ return `<section class="tempest-admin-inline">${heading}<p><em>No related records.</em></p></section>`;
12437
+ }
12438
+ const head2 = `<tr>${inline.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}<th></th></tr>`;
12439
+ const body = inline.rows.map(
12440
+ (row) => `<tr>${row.cells.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}<td>${row.url === null ? "" : `<a href="${escapeHtml(row.url)}">View</a>`}</td></tr>`
12441
+ ).join("");
12442
+ return `<section class="tempest-admin-inline">
12443
+ ${heading}
12444
+ <div class="tempest-admin-inline__scroll">
12445
+ <table class="tempest-admin-inline__table">
12446
+ <thead>${head2}</thead>
12447
+ <tbody>${body}</tbody>
12448
+ </table>
12449
+ </div>
12450
+ ${more}
12451
+ </section>`;
12452
+ }
12453
+ const head = `<tr>${inline.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}${inline.canDelete ? "<th>Delete</th>" : ""}</tr>`;
12454
+ const renderRow = (row, isNew) => `<tr${isNew ? ' class="tempest-admin-inline__new"' : ""}>${row.fields.map((field) => `<td>${renderInlineCell(field)}</td>`).join("")}${inline.canDelete ? `<td class="tempest-admin-inline__del">${isNew ? "" : `<input type="checkbox" name="row.${escapeHtml(row.key)}.__delete" value="true">`}</td>` : ""}</tr>`;
12455
+ const rows = inline.rows.map((row) => renderRow(row, false)).join("");
12456
+ const blank = inline.newRow === null ? "" : renderRow(inline.newRow, true);
12457
+ return `<section class="tempest-admin-inline">
12458
+ ${heading}
12459
+ ${error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(error)}</p>` : ""}
12460
+ <form method="post" action="${escapeHtml(inline.formAction)}" class="tempest-admin-inline__form">
12461
+ <input type="hidden" name="csrf_token" value="${escapeHtml(csrfToken)}">
12462
+ <div class="tempest-admin-inline__scroll">
12463
+ <table class="tempest-admin-inline__table">
12464
+ <thead>${head}</thead>
12465
+ <tbody>${rows}${blank}</tbody>
12466
+ </table>
12467
+ </div>
12468
+ <div class="tempest-admin-form__actions">
12469
+ <button type="submit">Save ${escapeHtml(inline.label)}</button>
12470
+ </div>
12471
+ </form>
12472
+ ${more}
12473
+ </section>`;
12474
+ }
12366
12475
  function renderAuditPanel(audit) {
12367
12476
  const rows = audit.fields.map(
12368
12477
  (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
@@ -12551,6 +12660,7 @@ var AUTOCOMPLETE_SCRIPT = `<script>
12551
12660
  })();
12552
12661
  </script>`;
12553
12662
  var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
12663
+ var INLINE_ROW_LIMIT = 50;
12554
12664
  var AUTOCOMPLETE_LIMIT = 20;
12555
12665
  var AUDIT_HISTORY_LIMIT = 50;
12556
12666
  var FK_OPTION_CAP = 1e3;
@@ -13167,6 +13277,8 @@ function makeAdminRouter(site, options) {
13167
13277
  title: admin.verboseName(),
13168
13278
  identity,
13169
13279
  audit: await buildAuditView(admin, row, state.dbSession),
13280
+ inlines: await buildInlines(admin, row, state, identity),
13281
+ inlineError: null,
13170
13282
  fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(row[name]) })),
13171
13283
  backUrl: `${prefix}/m/${admin.slug()}`,
13172
13284
  editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
@@ -13287,6 +13399,103 @@ function makeAdminRouter(site, options) {
13287
13399
  res.redirect(`${prefix}/m/${admin.slug()}/${identity}?ok=updated`);
13288
13400
  })
13289
13401
  );
13402
+ router.post(
13403
+ `${prefix}/m/:slug/:identity/inlines/:child`,
13404
+ guarded(async (req, res) => {
13405
+ const state = await authenticate(req, res);
13406
+ if (state === null) return;
13407
+ const admin = await resolveAdmin(req, res, state);
13408
+ if (admin === null) return;
13409
+ if (!checkCsrf(req, res, state)) return;
13410
+ const childSlug = String(req.params.child);
13411
+ const inline = admin.inlines.find(
13412
+ (entry) => entry.slug === childSlug && entry.editable
13413
+ );
13414
+ const childAdmin = inline === void 0 ? null : site.get(childSlug);
13415
+ if (inline === void 0 || childAdmin === null || !await allows(state.principal, childAdmin, AdminPermission.EDIT)) {
13416
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13417
+ return;
13418
+ }
13419
+ const identity = String(req.params.identity);
13420
+ const parent = await findRow(admin, state.dbSession, identity);
13421
+ if (parent === null) {
13422
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13423
+ return;
13424
+ }
13425
+ const parentId = parent[admin.identityField];
13426
+ const { rows: grouped, deletions } = groupInlineSubmission(
13427
+ req.body
13428
+ );
13429
+ const names = inlineFieldNames(childAdmin, inline);
13430
+ const childRepo = childAdmin.repository(state.dbSession);
13431
+ const actorId = backend.principalId(state.principal);
13432
+ const canDelete = inline.canDelete && await allows(state.principal, childAdmin, AdminPermission.DELETE);
13433
+ const failed = [];
13434
+ let formError = null;
13435
+ const owned = async (key) => await childRepo.first({
13436
+ [childAdmin.identityField]: key,
13437
+ [inline.fkField]: parentId
13438
+ });
13439
+ for (const [key, values] of Object.entries(grouped)) {
13440
+ const isNew = key.startsWith("new");
13441
+ if (!isNew && canDelete && deletions.has(key)) {
13442
+ if (await owned(key) !== null) {
13443
+ await childRepo.delete({ [childAdmin.identityField]: key });
13444
+ }
13445
+ continue;
13446
+ }
13447
+ if (isNew && Object.values(values).every((value) => value.trim() === "")) {
13448
+ continue;
13449
+ }
13450
+ const parsed = parseFormBody(childAdmin, values, { only: names });
13451
+ if (Object.keys(parsed.errors).length > 0) {
13452
+ failed.push({ key, values, errors: parsed.errors });
13453
+ continue;
13454
+ }
13455
+ try {
13456
+ if (isNew) {
13457
+ stampActor(childAdmin, parsed.data, actorId, true);
13458
+ await childRepo.create({
13459
+ ...parsed.data,
13460
+ [inline.fkField]: parentId
13461
+ });
13462
+ } else {
13463
+ if (await owned(key) === null) continue;
13464
+ stampActor(childAdmin, parsed.data, actorId, false);
13465
+ await childRepo.update(
13466
+ { [childAdmin.identityField]: key },
13467
+ parsed.data
13468
+ );
13469
+ }
13470
+ } catch (error) {
13471
+ formError = describeWriteFailure(childAdmin, error);
13472
+ failed.push({ key, values, errors: {} });
13473
+ }
13474
+ }
13475
+ if (failed.length > 0) {
13476
+ const fresh = await findRow(admin, state.dbSession, identity) ?? parent;
13477
+ html(
13478
+ res,
13479
+ renderDetailPage(context(req, state.session, state.visible), {
13480
+ title: admin.verboseName(),
13481
+ identity,
13482
+ audit: await buildAuditView(admin, fresh, state.dbSession),
13483
+ inlines: await buildInlines(admin, fresh, state, identity, {
13484
+ [childSlug]: failed
13485
+ }),
13486
+ inlineError: formError ?? "Some inline rows could not be saved.",
13487
+ fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(fresh[name]) })),
13488
+ backUrl: `${prefix}/m/${admin.slug()}`,
13489
+ editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
13490
+ deleteUrl: await allows(state.principal, admin, AdminPermission.DELETE) ? `${prefix}/m/${admin.slug()}/${identity}/delete` : null
13491
+ }),
13492
+ 400
13493
+ );
13494
+ return;
13495
+ }
13496
+ res.redirect(`${prefix}/m/${admin.slug()}/${identity}?ok=updated`);
13497
+ })
13498
+ );
13290
13499
  router.post(
13291
13500
  `${prefix}/m/:slug/:identity/delete`,
13292
13501
  guarded(async (req, res) => {
@@ -13308,6 +13517,89 @@ function makeAdminRouter(site, options) {
13308
13517
  const principal = await backend.loadPrincipal(dbSession, String(actor));
13309
13518
  return principal === null ? String(actor) : backend.displayName(principal);
13310
13519
  }
13520
+ async function buildInlines(admin, parent, state, identity, overrides = {}) {
13521
+ const parentId = parent[admin.identityField];
13522
+ const blocks = [];
13523
+ for (const inline of admin.inlines) {
13524
+ const childAdmin = site.get(inline.slug);
13525
+ const repository = childAdmin === null ? new tempestDbJs.BaseRepository(inline.model, state.dbSession) : childAdmin.repository(state.dbSession);
13526
+ const children = await repository.list({
13527
+ [inline.fkField]: parentId
13528
+ });
13529
+ const columns = inline.listDisplay ?? childAdmin?.listDisplayNames() ?? Object.keys(adminColumns(inline.model));
13530
+ const label = inline.label ?? childAdmin?.verboseNamePlural() ?? humanizeField(inline.slug);
13531
+ const visible = children.slice(0, INLINE_ROW_LIMIT);
13532
+ const editable = inline.editable && childAdmin !== null && await allows(state.principal, childAdmin, AdminPermission.EDIT);
13533
+ const addUrl = childAdmin !== null && await allows(state.principal, childAdmin, AdminPermission.CREATE) ? `${prefix}/m/${inline.slug}/new` : null;
13534
+ if (!editable || childAdmin === null) {
13535
+ blocks.push({
13536
+ label,
13537
+ total: children.length,
13538
+ columns,
13539
+ editable: false,
13540
+ canDelete: false,
13541
+ addUrl,
13542
+ formAction: "",
13543
+ rows: visible.map((child) => ({
13544
+ key: String(child[childAdmin?.identityField ?? "id"]),
13545
+ cells: columns.map((column6) => formatCellValue(child[column6])),
13546
+ fields: [],
13547
+ url: childAdmin === null ? null : `${prefix}/m/${inline.slug}/${String(child[childAdmin.identityField])}`
13548
+ })),
13549
+ newRow: null,
13550
+ truncated: children.length > visible.length
13551
+ });
13552
+ continue;
13553
+ }
13554
+ const names = inlineFieldNames(childAdmin, inline);
13555
+ const submitted = overrides[inline.slug];
13556
+ const rows = [];
13557
+ if (submitted !== void 0) {
13558
+ for (const entry of submitted) {
13559
+ rows.push({
13560
+ key: entry.key,
13561
+ cells: [],
13562
+ fields: inlineFields(
13563
+ childAdmin,
13564
+ names,
13565
+ entry.key,
13566
+ entry.values,
13567
+ entry.errors
13568
+ ),
13569
+ url: null
13570
+ });
13571
+ }
13572
+ } else {
13573
+ for (const child of visible) {
13574
+ const key = String(child[childAdmin.identityField]);
13575
+ rows.push({
13576
+ key,
13577
+ cells: [],
13578
+ fields: inlineFields(childAdmin, names, key, child, {}),
13579
+ url: `${prefix}/m/${inline.slug}/${key}`
13580
+ });
13581
+ }
13582
+ }
13583
+ blocks.push({
13584
+ label,
13585
+ total: children.length,
13586
+ columns: names.map(humanizeField),
13587
+ editable: true,
13588
+ canDelete: inline.canDelete && childAdmin.canDelete,
13589
+ addUrl,
13590
+ formAction: `${prefix}/m/${admin.slug()}/${identity}/inlines/${inline.slug}`,
13591
+ rows,
13592
+ newRow: {
13593
+ key: "new1",
13594
+ cells: [],
13595
+ fields: inlineFields(childAdmin, names, "new1", {}, {}),
13596
+ url: null
13597
+ },
13598
+ truncated: children.length > visible.length
13599
+ });
13600
+ }
13601
+ return blocks;
13602
+ }
13311
13603
  async function buildAuditView(admin, row, dbSession) {
13312
13604
  const fields = [];
13313
13605
  for (const name of admin.auditFieldNames()) {
@@ -13606,6 +13898,35 @@ function makeAdminRouter(site, options) {
13606
13898
  }
13607
13899
  return router;
13608
13900
  }
13901
+ function groupInlineSubmission(body) {
13902
+ const rows = {};
13903
+ const deletions = /* @__PURE__ */ new Set();
13904
+ for (const [key, raw] of Object.entries(body)) {
13905
+ if (!key.startsWith("row.")) continue;
13906
+ const parts = key.split(".");
13907
+ if (parts.length !== 3) continue;
13908
+ const [, rowKey, field] = parts;
13909
+ const value = typeof raw === "string" ? raw : "";
13910
+ if (field === "__delete") {
13911
+ if (!["", "false", "off", "0", "no"].includes(value.trim().toLowerCase())) {
13912
+ deletions.add(rowKey);
13913
+ }
13914
+ continue;
13915
+ }
13916
+ const row = rows[rowKey] ?? {};
13917
+ row[field] = value;
13918
+ rows[rowKey] = row;
13919
+ }
13920
+ return { rows, deletions };
13921
+ }
13922
+ function inlineFieldNames(childAdmin, inline) {
13923
+ return childAdmin.editableFieldNames().filter(
13924
+ (name) => name !== inline.fkField && !childAdmin.uploadFields.includes(name) && !childAdmin.autocompleteFields.includes(name)
13925
+ );
13926
+ }
13927
+ function inlineFields(childAdmin, names, key, values, errors) {
13928
+ return buildFormFields(childAdmin, { values, errors }).filter((field) => names.includes(field.name)).map((field) => ({ ...field, name: `row.${key}.${field.name}` }));
13929
+ }
13609
13930
  function flagAllows(admin, action) {
13610
13931
  if (action === AdminPermission.CREATE) return admin.canCreate;
13611
13932
  if (action === AdminPermission.EDIT) return admin.canEdit;
@@ -15777,7 +16098,7 @@ async function withTestDatabase(models, fn) {
15777
16098
  }
15778
16099
 
15779
16100
  // src/version.ts
15780
- var VERSION = "0.27.0";
16101
+ var VERSION = "0.28.0";
15781
16102
 
15782
16103
  Object.defineProperty(exports, "OpenAPIRegistry", {
15783
16104
  enumerable: true,
@@ -16037,6 +16358,7 @@ exports.activationSchema = activationSchema;
16037
16358
  exports.addLogSink = addLogSink;
16038
16359
  exports.adminAction = adminAction;
16039
16360
  exports.adminColumns = adminColumns;
16361
+ exports.adminInline = adminInline;
16040
16362
  exports.adminLens = adminLens;
16041
16363
  exports.adminThemeCss = adminThemeCss;
16042
16364
  exports.attachWebSocketHub = attachWebSocketHub;
@@ -16098,6 +16420,7 @@ exports.getConditions = getConditions;
16098
16420
  exports.getPaginationConditions = getPaginationConditions;
16099
16421
  exports.getRequestId = getRequestId;
16100
16422
  exports.getState = getState;
16423
+ exports.groupInlineSubmission = groupInlineSubmission;
16101
16424
  exports.hashOpaqueToken = hashOpaqueToken;
16102
16425
  exports.hexColorField = hexColorField;
16103
16426
  exports.humanizeField = humanizeField;