@nauticana/sail 0.5.2 → 0.5.4
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.
|
|
3
|
+
"version": "0.5.4",
|
|
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": [
|
|
@@ -62,9 +62,16 @@ export abstract class BaseView extends BaseTable {
|
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Open the row dialog. Renamed conceptually from "edit": when the user
|
|
67
|
+
* has only `canRead()` , we still want to show them the record + child rows.
|
|
68
|
+
* The dialog itself (TableEdit) handles read-only mode — its Edit/New/Delete
|
|
69
|
+
* buttons are gated by the same permissions.
|
|
70
|
+
* Block only when neither read nor update is granted.
|
|
71
|
+
*/
|
|
65
72
|
editRecord(record: {[key: string]: any}) {
|
|
66
|
-
if (!this.canUpdate()) {
|
|
67
|
-
alert('Missing authorization to
|
|
73
|
+
if (!this.canUpdate() && !this.canRead()) {
|
|
74
|
+
alert('Missing authorization to view records');
|
|
68
75
|
return;
|
|
69
76
|
}
|
|
70
77
|
const dialogData = this.getDialogData(record, false);
|
|
@@ -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';
|
package/src/model/appdata.ts
CHANGED
|
@@ -106,10 +106,13 @@ export interface ReportParam {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
export interface RestReport {
|
|
109
|
-
Id:
|
|
110
|
-
Version:
|
|
111
|
-
QueryName:
|
|
112
|
-
|
|
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
|
-
|
|
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,
|