@nauticana/sail 1.1.9 → 1.1.10
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/README.md +4 -2
- package/package.json +5 -1
- package/src/component/abstract/base_async.ts +9 -11
- package/src/component/abstract/base_form.ts +6 -5
- package/src/component/abstract/base_table.ts +131 -58
- package/src/component/abstract/base_view.ts +43 -27
- package/src/component/account/profile_editor.ts +2 -2
- package/src/component/billing/payment_methods.ts +1 -2
- package/src/component/billing/price_selector.html +2 -1
- package/src/component/billing/price_selector.ts +28 -13
- package/src/component/billing/trial_banner.ts +7 -1
- package/src/component/billing/usage_meter.ts +2 -2
- package/src/component/dashboard/data_card.html +1 -1
- package/src/component/dashboard/entity_selector.ts +21 -23
- package/src/component/form/form_field.html +6 -5
- package/src/component/form/form_field.ts +39 -21
- package/src/component/form/form_record.html +3 -3
- package/src/component/form/form_record.ts +23 -8
- package/src/component/form/form_table.html +2 -2
- package/src/component/form/form_table.ts +6 -5
- package/src/component/login/account_deletion.ts +6 -6
- package/src/component/login/chpass_component.html +4 -4
- package/src/component/login/chpass_component.ts +0 -1
- package/src/component/login/confirm_chpass_component.html +4 -4
- package/src/component/login/confirm_chpass_component.ts +1 -4
- package/src/component/login/consent_gate.html +6 -6
- package/src/component/login/consent_gate.ts +17 -24
- package/src/component/login/login_component.html +7 -4
- package/src/component/login/login_component.ts +9 -5
- package/src/component/login/otp_input.html +13 -5
- package/src/component/login/otp_input.ts +34 -27
- package/src/component/login/register_component.html +31 -14
- package/src/component/login/register_component.ts +80 -89
- package/src/component/login/social_login.html +2 -2
- package/src/component/login/social_login.ts +20 -13
- package/src/component/navigation/navigation.html +2 -2
- package/src/component/navigation/navigation.ts +2 -0
- package/src/component/oauth_connection/oauth_connection_panel.ts +24 -3
- package/src/component/payment/user_payment_methods.html +2 -2
- package/src/component/payment/user_payment_methods.ts +12 -23
- package/src/component/payout/bank_info_form.html +4 -4
- package/src/component/payout/bank_info_form.ts +22 -26
- package/src/component/payout/provider_onboarding.html +5 -5
- package/src/component/payout/provider_onboarding.ts +28 -48
- package/src/component/security/trusted_devices.html +1 -1
- package/src/component/security/trusted_devices.ts +0 -1
- package/src/component/security/twofactor_setup.html +8 -4
- package/src/component/security/twofactor_setup.ts +21 -3
- package/src/component/security/twofactor_verify.html +2 -1
- package/src/component/security/twofactor_verify.ts +2 -8
- package/src/component/table/table_detail.html +15 -14
- package/src/component/table/table_detail.ts +62 -41
- package/src/component/table/table_edit.html +15 -11
- package/src/component/table/table_edit.ts +94 -67
- package/src/component/table/table_list.html +45 -12
- package/src/component/table/table_list.ts +98 -26
- package/src/component/table/table_lookup.html +11 -8
- package/src/component/table/table_lookup.ts +45 -37
- package/src/component/table/table_report.html +2 -2
- package/src/component/table/table_report.ts +37 -31
- package/src/component/table/table_search.html +4 -4
- package/src/component/table/table_search.ts +36 -45
- package/src/config.ts +1 -1
- package/src/index.ts +1 -0
- package/src/model/appdata.ts +5 -9
- package/src/model/common.ts +27 -27
- package/src/model/decorator.ts +25 -21
- package/src/service/api-response.interceptor.ts +4 -1
- package/src/service/auth.interceptor.ts +27 -7
- package/src/service/auth.service.ts +188 -134
- package/src/service/label.service.ts +6 -5
- package/src/service/oauth_connection.service.ts +2 -2
- package/src/service/rest_service.ts +57 -13
- package/src/service/rest_url.ts +23 -7
- package/src/service/script_loader.ts +12 -2
- package/src/util/errors.ts +20 -0
- package/src/util/money.ts +19 -7
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
@if (canCreate()) {
|
|
5
5
|
<button matButton="elevated" class="accent" (click)="addRecord()">New Record</button>
|
|
6
6
|
}
|
|
7
|
-
@for (action of
|
|
7
|
+
@for (action of tableActions(); track action.action) {
|
|
8
8
|
@if (canExecuteAction(action)) {
|
|
9
9
|
<button matButton="outlined" type="button"
|
|
10
10
|
[title]="action.caption"
|
|
@@ -16,33 +16,56 @@
|
|
|
16
16
|
}
|
|
17
17
|
</div>
|
|
18
18
|
|
|
19
|
+
@if (viewError()) {
|
|
20
|
+
<p class="table-list__error" role="alert">{{ viewError() }}</p>
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
<table>
|
|
20
24
|
<thead>
|
|
21
25
|
<tr>
|
|
22
|
-
@for (column of displayedColumns; track column) {
|
|
23
|
-
<th
|
|
26
|
+
@for (column of displayedColumns(); track column) {
|
|
27
|
+
<th scope="col">
|
|
28
|
+
@if (column !== 'actions') {
|
|
29
|
+
<button type="button" class="table-list__sort" (click)="onSortColumn(column)"
|
|
30
|
+
[attr.aria-sort]="sortColumn() === column ? (sortAscending() ? 'ascending' : 'descending') : null">
|
|
31
|
+
{{ colCaption(column) }}
|
|
32
|
+
@if (sortColumn() === column) {
|
|
33
|
+
<mat-icon>{{ sortAscending() ? 'arrow_upward' : 'arrow_downward' }}</mat-icon>
|
|
34
|
+
}
|
|
35
|
+
</button>
|
|
36
|
+
} @else {
|
|
37
|
+
{{ colCaption(column) }}
|
|
38
|
+
}
|
|
39
|
+
</th>
|
|
24
40
|
}
|
|
25
41
|
</tr>
|
|
26
42
|
</thead>
|
|
27
|
-
@if (
|
|
43
|
+
@if (sortedRecords().length > 0) {
|
|
28
44
|
<tbody>
|
|
29
|
-
@for (record of
|
|
45
|
+
@for (record of sortedRecords(); track trackByRecord($index, record)) {
|
|
30
46
|
<tr>
|
|
31
|
-
@for (column of displayedColumns; track column) {
|
|
47
|
+
@for (column of displayedColumns(); track column) {
|
|
32
48
|
<td>
|
|
33
49
|
@if (isLinkColumn(column)) {
|
|
34
|
-
<a tabindex="0" role="button" (click)="editRecord(record)"
|
|
50
|
+
<a tabindex="0" role="button" (click)="editRecord(record)"
|
|
51
|
+
(keydown.enter)="editRecord(record)"
|
|
52
|
+
(keydown.space)="$event.preventDefault(); editRecord(record)">{{ displayValue(column, record[column]) }}</a>
|
|
35
53
|
} @else if (isBoolean(column)) {
|
|
36
|
-
<mat-checkbox [checked]="record[column] === true || record[column] === 'T'" disabled></mat-checkbox>
|
|
54
|
+
<mat-checkbox [checked]="record[column] === true || record[column] === 'T'" disabled [aria-label]="colCaption(column)"></mat-checkbox>
|
|
37
55
|
} @else if (column !== 'actions') {
|
|
38
56
|
{{ displayValue(column, record[column]) }}
|
|
39
57
|
} @else {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
58
|
+
@if (canUpdate() || canRead()) {
|
|
59
|
+
<button matIconButton type="button" (click)="editRecord(record)" title="Edit" aria-label="Edit"><mat-icon>edit</mat-icon></button>
|
|
60
|
+
}
|
|
61
|
+
@if (canDelete()) {
|
|
62
|
+
<button matIconButton type="button" (click)="deleteRecord(record)" title="Delete" aria-label="Delete"><mat-icon>delete</mat-icon></button>
|
|
63
|
+
}
|
|
64
|
+
@for (action of recordActions(); track action.action) {
|
|
43
65
|
@if (canExecuteAction(action)) {
|
|
44
66
|
<button matIconButton type="button"
|
|
45
67
|
[title]="action.caption"
|
|
68
|
+
[attr.aria-label]="action.caption"
|
|
46
69
|
(click)="executeAction(action, record)">
|
|
47
70
|
<mat-icon>{{ action.icon || 'play_arrow' }}</mat-icon>
|
|
48
71
|
</button>
|
|
@@ -57,6 +80,16 @@
|
|
|
57
80
|
}
|
|
58
81
|
</table>
|
|
59
82
|
|
|
60
|
-
@if (
|
|
83
|
+
@if (total() > pageSizeOptions[0]) {
|
|
84
|
+
<mat-paginator [length]="total()"
|
|
85
|
+
[pageIndex]="pageIndex()"
|
|
86
|
+
[pageSize]="pageSize()"
|
|
87
|
+
[pageSizeOptions]="pageSizeOptions"
|
|
88
|
+
(page)="onPage($event)"
|
|
89
|
+
aria-label="Select page">
|
|
90
|
+
</mat-paginator>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@if (records().length === 0 && displayedColumns().length > 0 && !viewError()) {
|
|
61
94
|
<p class="empty-state">No records found.</p>
|
|
62
95
|
}
|
|
@@ -1,34 +1,61 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy,
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input, linkedSignal, OnInit, signal, Type, ViewEncapsulation } from "@angular/core";
|
|
2
2
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
|
3
3
|
import { ActivatedRoute, Params } from "@angular/router";
|
|
4
|
-
import {
|
|
4
|
+
import { catchError, EMPTY, Observable, switchMap, take, tap } from "rxjs";
|
|
5
|
+
import { errorDetail } from "../../util/errors";
|
|
6
|
+
import { AbstractEditableView } from "../abstract/base_view";
|
|
5
7
|
import { TableEdit } from "./table_edit";
|
|
6
8
|
import { MatButtonModule } from "@angular/material/button";
|
|
7
9
|
import { MatCheckboxModule } from "@angular/material/checkbox";
|
|
8
10
|
import { MatIconModule } from "@angular/material/icon";
|
|
11
|
+
import { MatPaginatorModule, PageEvent } from "@angular/material/paginator";
|
|
9
12
|
|
|
10
13
|
@Component({
|
|
11
14
|
selector: 'sail-table-list',
|
|
12
15
|
templateUrl: './table_list.html',
|
|
13
16
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
14
17
|
encapsulation: ViewEncapsulation.None,
|
|
15
|
-
imports: [MatButtonModule, MatCheckboxModule, MatIconModule],
|
|
18
|
+
imports: [MatButtonModule, MatCheckboxModule, MatIconModule, MatPaginatorModule],
|
|
16
19
|
})
|
|
17
|
-
export class TableList extends
|
|
20
|
+
export class TableList extends AbstractEditableView implements OnInit {
|
|
18
21
|
override dialogWidth = '400px';
|
|
19
22
|
readonly tableNameInput = input('', { alias: 'tableName' });
|
|
20
23
|
readonly apiNameInput = input('', { alias: 'apiName' });
|
|
21
|
-
readonly dialogComponentInput = input<
|
|
24
|
+
readonly dialogComponentInput = input<Type<unknown> | undefined>(undefined, { alias: 'dialogComponent' });
|
|
22
25
|
|
|
23
26
|
// linkedSignal: defaults to the consumer's input, but ngOnInit / route data
|
|
24
27
|
// can override with `.set()`. The `computation` preserves the last
|
|
25
28
|
// non-empty value when the input transiently becomes empty.
|
|
26
29
|
override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
27
30
|
override readonly apiName = linkedSignal<string, string>({ source: () => this.apiNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
28
|
-
override readonly dialogComponent = linkedSignal<
|
|
31
|
+
override readonly dialogComponent = linkedSignal<Type<unknown> | undefined, Type<unknown> | undefined>({ source: () => this.dialogComponentInput(), computation: (v, p) => v ?? p?.value });
|
|
32
|
+
|
|
33
|
+
// Server-side paging (keel list caps at the requested _limit; total from the envelope).
|
|
34
|
+
readonly pageIndex = signal(0);
|
|
35
|
+
readonly pageSize = signal(25);
|
|
36
|
+
readonly total = signal(0);
|
|
37
|
+
readonly pageSizeOptions = [10, 25, 50, 100];
|
|
38
|
+
|
|
39
|
+
// Client-side sort of the CURRENT page — keel's list endpoint has no order
|
|
40
|
+
// parameter, so cross-page ordering isn't possible from the frontend.
|
|
41
|
+
readonly sortColumn = signal('');
|
|
42
|
+
readonly sortAscending = signal(true);
|
|
43
|
+
readonly sortedRecords = computed(() => {
|
|
44
|
+
const rows = this.records();
|
|
45
|
+
const column = this.sortColumn();
|
|
46
|
+
if (!column) return rows;
|
|
47
|
+
const dir = this.sortAscending() ? 1 : -1;
|
|
48
|
+
return [...rows].sort((a, b) => {
|
|
49
|
+
const av = a[column], bv = b[column];
|
|
50
|
+
if (av == null && bv == null) return 0;
|
|
51
|
+
if (av == null) return dir;
|
|
52
|
+
if (bv == null) return -dir;
|
|
53
|
+
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
|
54
|
+
return String(av).localeCompare(String(bv), undefined, { numeric: true }) * dir;
|
|
55
|
+
});
|
|
56
|
+
});
|
|
29
57
|
|
|
30
58
|
private readonly route = inject(ActivatedRoute);
|
|
31
|
-
private readonly cdr = inject(ChangeDetectorRef);
|
|
32
59
|
private readonly destroyRef = inject(DestroyRef);
|
|
33
60
|
|
|
34
61
|
ngOnInit() {
|
|
@@ -36,40 +63,80 @@ export class TableList extends BaseView implements OnInit {
|
|
|
36
63
|
if (!this.tableName() && data['tableName']) this.tableName.set(data['tableName']);
|
|
37
64
|
if (!this.apiName() && data['apiName']) this.apiName.set(data['apiName']);
|
|
38
65
|
if (!this.dialogComponent()) this.dialogComponent.set(data['dialogComponent'] ?? TableEdit);
|
|
39
|
-
|
|
66
|
+
// switchMap: a stale slow response must not overwrite a newer one.
|
|
67
|
+
this.route.queryParams.pipe(
|
|
68
|
+
takeUntilDestroyed(this.destroyRef),
|
|
69
|
+
switchMap((params) => this.handleQueryParams(params)),
|
|
70
|
+
).subscribe();
|
|
40
71
|
}
|
|
41
72
|
|
|
42
|
-
protected handleDelete(record:
|
|
73
|
+
protected handleDelete(record: Record<string, unknown>): void {
|
|
43
74
|
if (!this.requireAuth(() => this.canDelete(), 'delete')) return;
|
|
44
75
|
if (confirm('Are you sure you want to delete this record?')) {
|
|
45
76
|
const keyFilter = this.getKeyFilters(record);
|
|
46
77
|
this.backendService.delete(this.apiName(), keyFilter).subscribe({
|
|
47
78
|
next: () => this.fetchRecords(),
|
|
48
|
-
error: (err) =>
|
|
79
|
+
error: (err) => {
|
|
80
|
+
console.error('Delete failed', err);
|
|
81
|
+
this.viewError.set(errorDetail(err, 'Delete failed.'));
|
|
82
|
+
},
|
|
49
83
|
});
|
|
50
84
|
}
|
|
51
85
|
}
|
|
52
86
|
|
|
53
|
-
protected handleUpdate(result:
|
|
87
|
+
protected handleUpdate(result: Record<string, unknown>, isNew: boolean, _original: Record<string, unknown>): void {
|
|
54
88
|
if (!this.requireAuth(() => isNew ? this.canCreate() : this.canUpdate(), isNew ? 'create' : 'update')) return;
|
|
55
89
|
if (confirm('Are you sure you want to save this record?')) {
|
|
56
90
|
this.backendService.post(this.apiName(), result).subscribe({
|
|
57
91
|
next: () => this.fetchRecords(),
|
|
58
|
-
error: (err) =>
|
|
92
|
+
error: (err) => {
|
|
93
|
+
console.error('Save failed', err);
|
|
94
|
+
this.viewError.set(errorDetail(err, 'Save failed.'));
|
|
95
|
+
},
|
|
59
96
|
});
|
|
60
97
|
}
|
|
61
98
|
}
|
|
62
99
|
|
|
63
100
|
fetchRecords() {
|
|
64
101
|
if (!this.requireAuth(() => this.canRead(), 'read')) return;
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
102
|
+
this.loadPage().subscribe();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private loadPage(): Observable<unknown> {
|
|
106
|
+
const filter = {
|
|
107
|
+
...this.searchTerms(),
|
|
108
|
+
_limit: String(this.pageSize()),
|
|
109
|
+
_offset: String(this.pageIndex() * this.pageSize()),
|
|
110
|
+
};
|
|
111
|
+
return this.backendService.listPaginated<Record<string, unknown>>(this.apiName(), filter).pipe(
|
|
112
|
+
tap((page) => {
|
|
113
|
+
this.records.set(page.items ?? []);
|
|
114
|
+
this.total.set(page.total ?? page.items?.length ?? 0);
|
|
115
|
+
this.updateDisplayedColumns(page.items ?? []);
|
|
116
|
+
this.viewError.set('');
|
|
117
|
+
}),
|
|
118
|
+
catchError((err) => {
|
|
119
|
+
console.error('Fetch failed', err);
|
|
120
|
+
this.viewError.set(errorDetail(err, 'Failed to load records.'));
|
|
121
|
+
return EMPTY;
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
onPage(event: PageEvent) {
|
|
127
|
+
this.pageIndex.set(event.pageIndex);
|
|
128
|
+
this.pageSize.set(event.pageSize);
|
|
129
|
+
this.fetchRecords();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
onSortColumn(column: string) {
|
|
133
|
+
if (column === 'actions') return;
|
|
134
|
+
if (this.sortColumn() === column) {
|
|
135
|
+
this.sortAscending.update((asc) => !asc);
|
|
136
|
+
} else {
|
|
137
|
+
this.sortColumn.set(column);
|
|
138
|
+
this.sortAscending.set(true);
|
|
139
|
+
}
|
|
73
140
|
}
|
|
74
141
|
|
|
75
142
|
/** Refresh the row list on successful action — most actions mutate visible state (set_default flipping defaults, etc.). */
|
|
@@ -77,9 +144,9 @@ export class TableList extends BaseView implements OnInit {
|
|
|
77
144
|
this.fetchRecords();
|
|
78
145
|
}
|
|
79
146
|
|
|
80
|
-
|
|
147
|
+
/** Every params emission REPLACES searchTerms, so a bare list URL clears stale filters. */
|
|
148
|
+
private handleQueryParams(params: Params): Observable<unknown> {
|
|
81
149
|
const newSearchTerms: {[key: string]: string} = {};
|
|
82
|
-
let hasSearchTerms = false;
|
|
83
150
|
let action = '';
|
|
84
151
|
for (const key in params) {
|
|
85
152
|
if (Object.prototype.hasOwnProperty.call(params, key)) {
|
|
@@ -87,14 +154,19 @@ export class TableList extends BaseView implements OnInit {
|
|
|
87
154
|
action = params[key];
|
|
88
155
|
} else {
|
|
89
156
|
newSearchTerms[key] = params[key];
|
|
90
|
-
hasSearchTerms = true;
|
|
91
157
|
}
|
|
92
158
|
}
|
|
93
159
|
}
|
|
94
|
-
|
|
95
|
-
this.
|
|
160
|
+
this.searchTerms.set(newSearchTerms);
|
|
161
|
+
this.pageIndex.set(0);
|
|
96
162
|
if (action === 'create') {
|
|
97
|
-
|
|
163
|
+
// Gate on appdata so the create dialog gets real column metadata.
|
|
164
|
+
this.cacheService.getAppData().pipe(
|
|
165
|
+
take(1),
|
|
166
|
+
takeUntilDestroyed(this.destroyRef),
|
|
167
|
+
).subscribe(() => this.addRecord());
|
|
98
168
|
}
|
|
169
|
+
if (!this.requireAuth(() => this.canRead(), 'read')) return EMPTY;
|
|
170
|
+
return this.loadPage();
|
|
99
171
|
}
|
|
100
172
|
}
|
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
<h2 mat-dialog-title>{{ getCaption() }} Search</h2>
|
|
2
2
|
<div mat-dialog-content>
|
|
3
|
-
<sail-record-form [tableName]="tableName()" [record]="searchRecord" [fields]="searchColumns" [isReadOnly]="
|
|
3
|
+
<sail-record-form [tableName]="tableName()" [record]="searchRecord()" (recordChange)="searchRecord.set($event)" [fields]="searchColumns()" [isReadOnly]="isReadOnlyFn"></sail-record-form>
|
|
4
4
|
<div class="search-actions">
|
|
5
5
|
<button matButton="elevated" class="primary" (click)="onSearch()">Search</button>
|
|
6
6
|
<button matButton (click)="onClear()">Clear</button>
|
|
7
7
|
</div>
|
|
8
|
-
@if (
|
|
8
|
+
@if (viewError()) {
|
|
9
|
+
<p role="alert">{{ viewError() }}</p>
|
|
10
|
+
}
|
|
11
|
+
@if (records().length > 0) {
|
|
9
12
|
<table>
|
|
10
13
|
<thead>
|
|
11
14
|
<tr>
|
|
12
|
-
<th>Select</th>
|
|
13
|
-
@for (column of displayedColumns; track column) {
|
|
14
|
-
<th>{{ colCaption(column) }}</th>
|
|
15
|
+
<th scope="col">Select</th>
|
|
16
|
+
@for (column of displayedColumns(); track column) {
|
|
17
|
+
<th scope="col">{{ colCaption(column) }}</th>
|
|
15
18
|
}
|
|
16
19
|
</tr>
|
|
17
20
|
</thead>
|
|
18
21
|
<tbody>
|
|
19
|
-
@for (record of records; track trackByRecord($index, record)) {
|
|
22
|
+
@for (record of records(); track trackByRecord($index, record)) {
|
|
20
23
|
<tr>
|
|
21
|
-
<td><button
|
|
22
|
-
@for (column of displayedColumns; track column) {
|
|
24
|
+
<td><button matButton type="button" (click)="onSelect(record)">Select</button></td>
|
|
25
|
+
@for (column of displayedColumns(); track column) {
|
|
23
26
|
<td>{{ displayValue(column, record[column]) }}</td>
|
|
24
27
|
}
|
|
25
28
|
</tr>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy,
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal, ViewEncapsulation } from "@angular/core";
|
|
2
2
|
import { MatButtonModule } from "@angular/material/button";
|
|
3
3
|
import { MAT_DIALOG_DATA, MatDialogActions, MatDialogClose, MatDialogContent, MatDialogRef, MatDialogTitle } from "@angular/material/dialog";
|
|
4
4
|
import { MatIconModule } from "@angular/material/icon";
|
|
5
|
-
import { take } from "rxjs";
|
|
5
|
+
import { map, Observable, of, switchMap, take } from "rxjs";
|
|
6
6
|
import { RecordForm } from "../form/form_record";
|
|
7
|
+
import { errorDetail } from "../../util/errors";
|
|
7
8
|
import { BaseView } from "../abstract/base_view";
|
|
8
9
|
|
|
9
10
|
@Component({
|
|
@@ -22,67 +23,74 @@ import { BaseView } from "../abstract/base_view";
|
|
|
22
23
|
],
|
|
23
24
|
})
|
|
24
25
|
export class TableLookup extends BaseView implements OnInit {
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
override readonly tableName = signal('');
|
|
27
|
+
readonly searchRecord = signal<Record<string, unknown>>({});
|
|
28
|
+
readonly searchColumns = this.metadataColumns;
|
|
27
29
|
|
|
28
30
|
private readonly dialogRef = inject(MatDialogRef<TableLookup>);
|
|
29
31
|
private readonly data = inject<{tableName?: string; apiName?: string}>(MAT_DIALOG_DATA);
|
|
30
|
-
|
|
32
|
+
|
|
33
|
+
/** A lookup only selects — no edit/delete, so no appended "actions" column. */
|
|
34
|
+
protected override includeActionsColumn(): boolean {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
31
37
|
|
|
32
38
|
ngOnInit() {
|
|
33
39
|
if (this.data) {
|
|
34
40
|
this.tableName.set(this.data.tableName ?? '');
|
|
35
41
|
this.apiName.set(this.data.apiName ?? '');
|
|
36
42
|
}
|
|
37
|
-
this.
|
|
38
|
-
this.
|
|
39
|
-
this.resolveApi();
|
|
43
|
+
this.searchRecord.set(this.emptyRecord());
|
|
44
|
+
this.resolveApi().subscribe();
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
/** Resolve (and cache) the api serving this table; '' when none matches. */
|
|
48
|
+
private resolveApi(): Observable<string> {
|
|
49
|
+
const apiName = this.apiName();
|
|
50
|
+
if (apiName) return of(apiName);
|
|
43
51
|
const tableName = this.tableName();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
break;
|
|
52
|
-
}
|
|
52
|
+
return this.cacheService.getAppData().pipe(
|
|
53
|
+
take(1),
|
|
54
|
+
map((data) => {
|
|
55
|
+
for (const api in data.Apis ?? {}) {
|
|
56
|
+
if (data.Apis[api].Table?.TableName === tableName) {
|
|
57
|
+
this.apiName.set(api); // bare RestUri; BackendService.crudUrl resolves the version
|
|
58
|
+
return api;
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
return '';
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
onSearch() {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
// Chained on resolveApi so an early click waits instead of silently no-oping.
|
|
68
|
+
this.resolveApi().pipe(
|
|
69
|
+
switchMap((apiName) => {
|
|
70
|
+
if (!apiName) {
|
|
71
|
+
this.viewError.set(`No API found for table ${this.tableName()}`);
|
|
72
|
+
return of<Record<string, unknown>[]>([]);
|
|
73
|
+
}
|
|
74
|
+
return this.backendService.list<Record<string, unknown>>(apiName, this.buildSearchTerms(this.searchRecord()));
|
|
75
|
+
}),
|
|
76
|
+
).subscribe({
|
|
67
77
|
next: (records) => {
|
|
68
|
-
this.records
|
|
78
|
+
this.records.set(records);
|
|
69
79
|
this.updateDisplayedColumns(records);
|
|
70
|
-
this.cdr.markForCheck();
|
|
71
80
|
},
|
|
72
|
-
error: (err) =>
|
|
81
|
+
error: (err) => {
|
|
82
|
+
console.error('Lookup search failed', err);
|
|
83
|
+
this.viewError.set(errorDetail(err, 'Search failed.'));
|
|
84
|
+
},
|
|
73
85
|
});
|
|
74
86
|
}
|
|
75
87
|
|
|
76
|
-
onSelect(record:
|
|
88
|
+
onSelect(record: Record<string, unknown>) {
|
|
77
89
|
this.dialogRef.close(record);
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
onClear() {
|
|
81
|
-
this.searchRecord
|
|
82
|
-
this.records
|
|
93
|
+
this.searchRecord.set(this.emptyRecord());
|
|
94
|
+
this.records.set([]);
|
|
83
95
|
}
|
|
84
|
-
|
|
85
|
-
protected handleDelete(_record: {[key: string]: any}): void {}
|
|
86
|
-
|
|
87
|
-
protected handleUpdate(_result: {[key: string]: any}, _isNew: boolean, _original: {[key: string]: any}): void {}
|
|
88
96
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<h2>{{ reportTitle() }}</h2>
|
|
2
2
|
|
|
3
3
|
@if (params().length > 0) {
|
|
4
|
-
<form class="report-params" (
|
|
4
|
+
<form class="report-params" (submit)="onSubmit($event)">
|
|
5
5
|
@for (param of params(); track param.Name) {
|
|
6
6
|
@let opts = optionsFor(param);
|
|
7
7
|
@if (opts) {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
<mat-label>{{ titleCase(param.Name) }}</mat-label>
|
|
24
24
|
<input matInput [type]="inputTypeFor(param)"
|
|
25
25
|
[value]="paramValue(param.Name)"
|
|
26
|
-
(input)="
|
|
26
|
+
(input)="onParamInput(param.Name, $event)">
|
|
27
27
|
</mat-form-field>
|
|
28
28
|
}
|
|
29
29
|
}
|
|
@@ -1,23 +1,21 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from "@angular/core";
|
|
2
|
-
import { HttpParams } from "@angular/common/http";
|
|
3
|
-
import { FormsModule } from "@angular/forms";
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, untracked } from "@angular/core";
|
|
4
2
|
import { MatCheckboxModule } from "@angular/material/checkbox";
|
|
5
3
|
import { MatFormFieldModule } from "@angular/material/form-field";
|
|
6
4
|
import { MatInputModule } from "@angular/material/input";
|
|
7
5
|
import { MatSelectModule } from "@angular/material/select";
|
|
8
6
|
import { MatButtonModule } from "@angular/material/button";
|
|
9
7
|
import { BaseAuthService } from "../../service/auth.service";
|
|
10
|
-
import {
|
|
8
|
+
import { BackendService } from "../../service/rest_service";
|
|
11
9
|
import { ConstantValue } from "../../model/common";
|
|
12
10
|
import { ReportParam } from "../../model/appdata";
|
|
13
11
|
import { titleCase } from "../../util/text";
|
|
12
|
+
import { errorDetail } from "../../util/errors";
|
|
14
13
|
|
|
15
14
|
@Component({
|
|
16
15
|
selector: 'sail-table-report',
|
|
17
16
|
templateUrl: './table_report.html',
|
|
18
17
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
19
18
|
imports: [
|
|
20
|
-
FormsModule,
|
|
21
19
|
MatCheckboxModule,
|
|
22
20
|
MatFormFieldModule,
|
|
23
21
|
MatInputModule,
|
|
@@ -25,11 +23,12 @@ import { titleCase } from "../../util/text";
|
|
|
25
23
|
MatButtonModule,
|
|
26
24
|
],
|
|
27
25
|
})
|
|
28
|
-
export class TableReport
|
|
26
|
+
export class TableReport {
|
|
29
27
|
readonly apiName = input('');
|
|
30
28
|
readonly tableName = input('');
|
|
31
29
|
|
|
32
30
|
private readonly auth = inject(BaseAuthService);
|
|
31
|
+
private readonly backend = inject(BackendService);
|
|
33
32
|
|
|
34
33
|
readonly records = signal<Record<string, unknown>[]>([]);
|
|
35
34
|
readonly displayedColumns = signal<string[]>([]);
|
|
@@ -50,11 +49,7 @@ export class TableReport extends BaseRestService {
|
|
|
50
49
|
*/
|
|
51
50
|
readonly reportId = computed(() => this.apiName().split('/').pop() ?? '');
|
|
52
51
|
|
|
53
|
-
/**
|
|
54
|
-
* Report header text — long-form description from rest_report_header.
|
|
55
|
-
* Falls back to the title-cased table name only if the lookup misses
|
|
56
|
-
* (e.g. a brand-new report whose row hasn't been seeded yet).
|
|
57
|
-
*/
|
|
52
|
+
/** Long-form header from rest_report_header; falls back to the title-cased table name. */
|
|
58
53
|
readonly reportTitle = computed(() => {
|
|
59
54
|
return this.auth.getReport(this.reportId())?.Description || titleCase(this.tableName());
|
|
60
55
|
});
|
|
@@ -62,26 +57,36 @@ export class TableReport extends BaseRestService {
|
|
|
62
57
|
/**
|
|
63
58
|
* Param definitions for the current report. Empty when the report has
|
|
64
59
|
* no rest_report_param rows — in that case we fetch immediately. With
|
|
65
|
-
* params we render an input form and wait for the user to click "Run"
|
|
66
|
-
* preserving the standard "filter then fetch" UX for parameterized
|
|
67
|
-
* reports.
|
|
60
|
+
* params we render an input form and wait for the user to click "Run".
|
|
68
61
|
*/
|
|
69
62
|
readonly params = computed<ReportParam[]>(() => {
|
|
70
63
|
return this.auth.getReport(this.reportId())?.Params ?? [];
|
|
71
64
|
});
|
|
72
65
|
|
|
66
|
+
private lastApi: string | null = null;
|
|
67
|
+
|
|
73
68
|
constructor() {
|
|
74
|
-
|
|
69
|
+
// Re-runs on apiName changes AND appdata (re)loads via params()/canReport;
|
|
70
|
+
// report state is only reset when the report actually changed, so an
|
|
71
|
+
// appdata refresh never wipes half-typed parameters.
|
|
75
72
|
effect(() => {
|
|
76
73
|
const api = this.apiName();
|
|
77
74
|
if (!api) return;
|
|
75
|
+
const apiChanged = api !== this.lastApi;
|
|
76
|
+
this.lastApi = api;
|
|
77
|
+
if (apiChanged) {
|
|
78
|
+
this.records.set([]);
|
|
79
|
+
this.displayedColumns.set([]);
|
|
80
|
+
this.paramValues.set({});
|
|
81
|
+
}
|
|
82
|
+
this.errorMessage.set('');
|
|
78
83
|
|
|
79
84
|
if (!this.auth.canReport(this.reportId())) {
|
|
80
85
|
this.errorMessage.set('You do not have permission to view this report.');
|
|
81
86
|
return;
|
|
82
87
|
}
|
|
83
|
-
// Auto-fetch only when the report takes no inputs.
|
|
84
|
-
if (this.params().length === 0) {
|
|
88
|
+
// Auto-fetch only when the report takes no inputs and nothing is shown yet.
|
|
89
|
+
if (this.params().length === 0 && (apiChanged || untracked(this.records).length === 0)) {
|
|
85
90
|
this.fetchReport(api, {});
|
|
86
91
|
}
|
|
87
92
|
});
|
|
@@ -122,36 +127,37 @@ export class TableReport extends BaseRestService {
|
|
|
122
127
|
this.paramValues.update((current) => ({ ...current, [name]: value }));
|
|
123
128
|
}
|
|
124
129
|
|
|
130
|
+
onParamInput(name: string, event: Event) {
|
|
131
|
+
this.setParam(name, (event.target as HTMLInputElement).value);
|
|
132
|
+
}
|
|
133
|
+
|
|
125
134
|
paramValue(name: string): string {
|
|
126
135
|
return this.paramValues()[name] ?? '';
|
|
127
136
|
}
|
|
128
137
|
|
|
138
|
+
onSubmit(event: Event) {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
this.runReport();
|
|
141
|
+
}
|
|
142
|
+
|
|
129
143
|
runReport() {
|
|
130
144
|
this.fetchReport(this.apiName(), this.paramValues());
|
|
131
145
|
}
|
|
132
146
|
|
|
133
147
|
private fetchReport(api: string, params: Record<string, string>) {
|
|
134
|
-
|
|
148
|
+
const filled: Record<string, string> = {};
|
|
135
149
|
for (const [k, v] of Object.entries(params)) {
|
|
136
|
-
// Drop empty values so the URL doesn't carry `?param=` for
|
|
137
|
-
|
|
138
|
-
// the same, sending neither is the cleanest signal.
|
|
139
|
-
if (v !== '' && v != null) {
|
|
140
|
-
httpParams = httpParams.set(k, v);
|
|
141
|
-
}
|
|
150
|
+
// Drop empty values so the URL doesn't carry `?param=` for unset fields.
|
|
151
|
+
if (v !== '' && v != null) filled[k] = v;
|
|
142
152
|
}
|
|
143
|
-
this.
|
|
153
|
+
this.backend.report(api, filled).subscribe({
|
|
144
154
|
next: (rows) => {
|
|
145
155
|
this.records.set(rows ?? []);
|
|
146
|
-
|
|
147
|
-
this.displayedColumns.set(Object.keys(rows[0]));
|
|
148
|
-
} else {
|
|
149
|
-
this.displayedColumns.set([]);
|
|
150
|
-
}
|
|
156
|
+
this.displayedColumns.set(rows?.length ? Object.keys(rows[0]) : []);
|
|
151
157
|
this.errorMessage.set('');
|
|
152
158
|
},
|
|
153
159
|
error: (err) => {
|
|
154
|
-
this.errorMessage.set(err
|
|
160
|
+
this.errorMessage.set(errorDetail(err, 'Failed to load report data.'));
|
|
155
161
|
},
|
|
156
162
|
});
|
|
157
163
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<h1>
|
|
2
|
-
@if (title) {
|
|
3
|
-
{{ title }}
|
|
2
|
+
@if (title()) {
|
|
3
|
+
{{ title() }}
|
|
4
4
|
} @else {
|
|
5
5
|
{{ getCaption() }} Search
|
|
6
6
|
}
|
|
7
7
|
</h1>
|
|
8
8
|
<div class="page-container">
|
|
9
|
-
<sail-record-form [tableName]="tableName()" [record]="editableRecord" [fields]="searchColumns" [isReadOnly]="
|
|
9
|
+
<sail-record-form [tableName]="tableName()" [record]="editableRecord()" (recordChange)="editableRecord.set($event)" [fields]="searchColumns()" [isReadOnly]="isReadOnlyFn"></sail-record-form>
|
|
10
10
|
</div>
|
|
11
11
|
<div class="search-actions">
|
|
12
12
|
<button matButton="elevated" class="primary" (click)="onSearch()">Search</button>
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
@if (canCreate()) {
|
|
15
15
|
<button matButton="elevated" class="accent" (click)="onAddRecord()">New Record</button>
|
|
16
16
|
}
|
|
17
|
-
@for (action of
|
|
17
|
+
@for (action of tableActions(); track action.action) {
|
|
18
18
|
@if (canExecuteAction(action)) {
|
|
19
19
|
<button matButton="outlined" type="button"
|
|
20
20
|
[title]="action.caption"
|