@nauticana/sail 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing.",
5
5
  "main": "src/index.ts",
6
6
  "files": [
@@ -0,0 +1,26 @@
1
+ <h2>{{ reportTitle() }}</h2>
2
+
3
+ @if (errorMessage()) {
4
+ <p class="empty-state">{{ errorMessage() }}</p>
5
+ } @else if (records().length === 0) {
6
+ <p class="empty-state">No data available.</p>
7
+ } @else {
8
+ <table>
9
+ <thead>
10
+ <tr>
11
+ @for (column of displayedColumns(); track column) {
12
+ <th>{{ titleCase(column) }}</th>
13
+ }
14
+ </tr>
15
+ </thead>
16
+ <tbody>
17
+ @for (record of records(); track $index) {
18
+ <tr>
19
+ @for (column of displayedColumns(); track column) {
20
+ <td>{{ record[column] }}</td>
21
+ }
22
+ </tr>
23
+ }
24
+ </tbody>
25
+ </table>
26
+ }
@@ -0,0 +1,67 @@
1
+ import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from "@angular/core";
2
+ import { HttpClient } from "@angular/common/http";
3
+ import { MatCheckboxModule } from "@angular/material/checkbox";
4
+ import { BaseAuthService } from "../../service/auth.service";
5
+ import { RestURL } from "../../service/rest_url";
6
+
7
+ @Component({
8
+ selector: 'table-report',
9
+ templateUrl: './table_report.html',
10
+ changeDetection: ChangeDetectionStrategy.OnPush,
11
+ imports: [MatCheckboxModule],
12
+ })
13
+ export class TableReport {
14
+ readonly apiName = input('');
15
+ readonly tableName = input('');
16
+
17
+ private readonly http = inject(HttpClient);
18
+ private readonly auth = inject(BaseAuthService);
19
+
20
+ readonly records = signal<Record<string, unknown>[]>([]);
21
+ readonly displayedColumns = signal<string[]>([]);
22
+ readonly errorMessage = signal('');
23
+
24
+ /**
25
+ * Report header text — long-form description from rest_report_header.
26
+ * Falls back to the title-cased table name only if the lookup misses
27
+ * (e.g. a brand-new report whose row hasn't been seeded yet).
28
+ */
29
+ readonly reportTitle = computed(() => {
30
+ const reportId = this.apiName().split('/').pop() ?? '';
31
+ return this.auth.getReport(reportId)?.Description || this.titleCase(this.tableName());
32
+ });
33
+
34
+ constructor() {
35
+ effect(() => {
36
+ const api = this.apiName();
37
+ if (!api) return;
38
+
39
+ if (!this.auth.canReport(api)) {
40
+ this.errorMessage.set('You do not have permission to view this report.');
41
+ return;
42
+ }
43
+ this.fetchReport(api);
44
+ });
45
+ }
46
+
47
+ private fetchReport(api: string) {
48
+ const url = RestURL.httpHost + '/api/' + api;
49
+ this.http.get<Record<string, unknown>[]>(url).subscribe({
50
+ next: (rows) => {
51
+ this.records.set(rows ?? []);
52
+ if (rows?.length) {
53
+ this.displayedColumns.set(Object.keys(rows[0]));
54
+ }
55
+ },
56
+ error: (err) => {
57
+ this.errorMessage.set(err?.error ?? 'Failed to load report data.');
58
+ },
59
+ });
60
+ }
61
+
62
+ titleCase(str: string): string {
63
+ return str.replaceAll('_', ' ').toLowerCase().split(' ')
64
+ .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
65
+ .join(' ');
66
+ }
67
+ }
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export * from './component/table/table_search';
15
15
  export * from './component/table/table_edit';
16
16
  export * from './component/table/table_detail';
17
17
  export * from './component/table/table_lookup';
18
+ export * from './component/table/table_report';
18
19
  export * from './component/navigation/navigation';
19
20
  export * from './component/login/login_component';
20
21
  export * from './component/login/register_component';
@@ -106,10 +106,13 @@ export interface ReportParam {
106
106
  }
107
107
 
108
108
  export interface RestReport {
109
- Id: string;
110
- Version: string;
111
- QueryName: string;
112
- Params: ReportParam[];
109
+ Id: string;
110
+ Version: string;
111
+ QueryName: string;
112
+ // Long-form text rendered as the report page header. The short nav-rail
113
+ // label is application_menu_item.caption — no duplication here.
114
+ Description: string;
115
+ Params: ReportParam[];
113
116
  }
114
117
 
115
118
  // 2FA / Trusted device types
@@ -443,8 +443,24 @@ export abstract class BaseAuthService {
443
443
  return this.cache.Apis[dictionaryId];
444
444
  }
445
445
 
446
+ /**
447
+ * keel emits Reports as `map[string]*RestReport` which serializes to a
448
+ * JSON object keyed by report id, not an array. The previous cast to
449
+ * `RestReport[]` was a type lie that produced an empty iteration in
450
+ * practice. Object.values is the correct unwrap.
451
+ */
446
452
  getReports(): RestReport[] {
447
- return (this.cache?.['Reports'] as RestReport[]) ?? [];
453
+ const reports = this.cache?.['Reports'] as Record<string, RestReport> | undefined;
454
+ return reports ? Object.values(reports) : [];
455
+ }
456
+
457
+ /**
458
+ * Look up a single report by its id (e.g. 'scorecard' for /api/v1/analytic/scorecard).
459
+ * Returns undefined if Reports cache is missing or the id isn't registered.
460
+ */
461
+ getReport(id: string): RestReport | undefined {
462
+ const reports = this.cache?.['Reports'] as Record<string, RestReport> | undefined;
463
+ return reports?.[id];
448
464
  }
449
465
 
450
466
  canRead(tableName: string) { return this.checkPermission('TABLE', 'SELECT', tableName); }
@@ -494,6 +510,7 @@ export abstract class BaseAuthService {
494
510
  // auth.service → TableSearch/TableList → BaseTable → auth.service
495
511
  const { TableSearch } = await import('../component/table/table_search');
496
512
  const { TableList } = await import('../component/table/table_list');
513
+ const { TableReport } = await import('../component/table/table_report');
497
514
 
498
515
  this.getAppData().pipe(take(1)).subscribe({
499
516
  next: (data: ApplicationData) => {
@@ -534,6 +551,17 @@ export abstract class BaseAuthService {
534
551
  canActivate: [this.canActivate],
535
552
  data: { tableName, apiName },
536
553
  });
554
+ } else if (this.getReport(page.ItemId)) {
555
+ // Menu item id matches a row in rest_report_header →
556
+ // render with TableReport. Report endpoints don't go
557
+ // through the metadata-driven CRUD layer (apis cache),
558
+ // so apiName here is just restUri (e.g. analytic/scorecard).
559
+ children.push({
560
+ path: page.ItemId,
561
+ component: TableReport,
562
+ canActivate: [this.canActivate],
563
+ data: { tableName, apiName },
564
+ });
537
565
  } else if (page.FilterOnList) {
538
566
  children.push({
539
567
  path: page.ItemId,