@nauticana/sail 1.1.9 → 1.1.11
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/sms_consent.html +11 -0
- package/src/component/login/sms_consent.ts +38 -0
- 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 +5 -1
- package/src/index.ts +2 -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
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy,
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, inject, input, linkedSignal, OnInit, signal, ViewEncapsulation } from "@angular/core";
|
|
2
2
|
import { BaseForm } from "../abstract/base_form";
|
|
3
|
+
import { errorDetail } from "../../util/errors";
|
|
3
4
|
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
|
|
4
5
|
import { MatTabsModule } from "@angular/material/tabs";
|
|
5
6
|
import { TableDetail } from "./table_detail";
|
|
@@ -8,6 +9,13 @@ import { MatButtonModule } from "@angular/material/button";
|
|
|
8
9
|
import { MatIconModule } from "@angular/material/icon";
|
|
9
10
|
import { OpCode } from "../../model/common";
|
|
10
11
|
|
|
12
|
+
interface TableEditDialogData {
|
|
13
|
+
record?: Record<string, unknown>;
|
|
14
|
+
tableName?: string;
|
|
15
|
+
apiName?: string;
|
|
16
|
+
isNew?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
@Component({
|
|
12
20
|
selector: 'sail-table-edit',
|
|
13
21
|
templateUrl: './table_edit.html',
|
|
@@ -22,70 +30,66 @@ import { OpCode } from "../../model/common";
|
|
|
22
30
|
]
|
|
23
31
|
})
|
|
24
32
|
export class TableEdit extends BaseForm implements OnInit {
|
|
25
|
-
readonly recordInput = input<
|
|
33
|
+
readonly recordInput = input<Record<string, unknown>>({}, { alias: 'record' });
|
|
26
34
|
readonly tableNameInput = input('', { alias: 'tableName' });
|
|
27
35
|
readonly apiNameInput = input('', { alias: 'apiName' });
|
|
28
36
|
|
|
29
|
-
readonly record = linkedSignal<
|
|
37
|
+
readonly record = linkedSignal<Record<string, unknown>, Record<string, unknown>>({ source: () => this.recordInput(), computation: (v, p) => v ?? p?.value ?? {} });
|
|
30
38
|
override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
31
39
|
readonly apiName = linkedSignal<string, string>({ source: () => this.apiNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
32
40
|
|
|
33
|
-
isReadOnlyMode = true;
|
|
41
|
+
readonly isReadOnlyMode = signal(true);
|
|
42
|
+
readonly saveError = signal('');
|
|
34
43
|
private readonly dialogRef = inject(MatDialogRef<TableEdit>, {optional: true});
|
|
35
|
-
private readonly dialogData = inject<
|
|
36
|
-
|
|
44
|
+
private readonly dialogData = inject<TableEditDialogData | null>(MAT_DIALOG_DATA, {optional: true});
|
|
45
|
+
|
|
46
|
+
/** Tabs are the table's child relations (rest_api_child metadata). */
|
|
47
|
+
protected readonly tabFields = computed(() =>
|
|
48
|
+
(this.cacheService.getTableDefinition(this.tableName())?.Children ?? []).map((child) => child.PascalName));
|
|
37
49
|
|
|
38
|
-
protected tabCount = 0;
|
|
39
|
-
protected tabFields: string[] = [];
|
|
40
50
|
protected get isDialog(): boolean { return !!this.dialogRef; }
|
|
41
51
|
/** Snapshot taken when the user enters edit mode; used for dirty-tracking on save. */
|
|
42
|
-
private originalSnapshot:
|
|
52
|
+
private originalSnapshot: Record<string, unknown> | null = null;
|
|
43
53
|
|
|
44
54
|
ngOnInit(): void {
|
|
45
55
|
if (this.dialogData) {
|
|
46
56
|
this.record.set(this.dialogData.record ?? {});
|
|
47
57
|
this.tableName.set(this.dialogData.tableName ?? '');
|
|
48
58
|
this.apiName.set(this.dialogData.apiName ?? '');
|
|
49
|
-
this.isNew
|
|
50
|
-
this.isReadOnlyMode
|
|
59
|
+
this.isNew.set(this.dialogData.isNew ?? false);
|
|
60
|
+
this.isReadOnlyMode.set(!this.isNew());
|
|
51
61
|
}
|
|
52
|
-
if (this.isNew) {
|
|
53
|
-
this.
|
|
54
|
-
this.buildTabs();
|
|
62
|
+
if (this.isNew()) {
|
|
63
|
+
this.setEditableRecord(Object.assign(this.emptyRecord(), this.record()));
|
|
55
64
|
} else {
|
|
56
65
|
this.fetchFullRecord();
|
|
57
66
|
}
|
|
58
67
|
}
|
|
59
68
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
69
|
+
/** Single entry point: seeds an empty array for any child relation the record lacks. */
|
|
70
|
+
private setEditableRecord(record: Record<string, unknown>) {
|
|
71
|
+
for (const field of this.tabFields()) {
|
|
72
|
+
if (!this.isArray(record[field])) record[field] = [];
|
|
73
|
+
}
|
|
74
|
+
this.editableRecord.set(record);
|
|
64
75
|
}
|
|
65
76
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
*/
|
|
71
|
-
private buildTabs() {
|
|
72
|
-
const tableDef = this.cacheService.getTableDefinition(this.tableName());
|
|
73
|
-
this.tabFields = (tableDef?.Children ?? []).map((child) => child.PascalName);
|
|
74
|
-
for (const field of this.tabFields) {
|
|
75
|
-
if (!this.isArray(this.editableRecord[field])) this.editableRecord[field] = [];
|
|
76
|
-
}
|
|
77
|
-
this.tabCount = this.tabFields.length;
|
|
77
|
+
/** Child rows for a tab; buildTabs guarantees the array exists. */
|
|
78
|
+
childRecords(key: string): Record<string, unknown>[] {
|
|
79
|
+
const value = this.editableRecord()[key];
|
|
80
|
+
return Array.isArray(value) ? value as Record<string, unknown>[] : [];
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
/** Re-fetch the row from the backend after a server-side mutation (TableAction success, etc.). */
|
|
81
84
|
protected override onActionSuccess(): void {
|
|
82
|
-
if (!this.isNew) this.fetchFullRecord();
|
|
85
|
+
if (!this.isNew()) this.fetchFullRecord();
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
private fetchFullRecord() {
|
|
86
89
|
const rec = this.record();
|
|
87
|
-
|
|
88
|
-
this.formatRecordTimeStamp(
|
|
90
|
+
const provisional = {...rec};
|
|
91
|
+
this.formatRecordTimeStamp(provisional);
|
|
92
|
+
this.setEditableRecord(provisional);
|
|
89
93
|
|
|
90
94
|
const filter = this.getKeyFilters(rec);
|
|
91
95
|
if (!filter) {
|
|
@@ -93,73 +97,91 @@ export class TableEdit extends BaseForm implements OnInit {
|
|
|
93
97
|
return;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
this.backendService.get<
|
|
100
|
+
this.backendService.get<Record<string, unknown> | Record<string, unknown>[]>(this.apiName(), filter).subscribe({
|
|
97
101
|
next: (record) => {
|
|
98
102
|
if (!record) {
|
|
99
103
|
console.error('failed to read single record from the backend');
|
|
100
104
|
return;
|
|
101
105
|
}
|
|
102
|
-
|
|
103
|
-
this.formatRecordTimeStamp(
|
|
104
|
-
this.
|
|
105
|
-
this.cdr.markForCheck();
|
|
106
|
+
const full = Array.isArray(record) ? record[0] : record;
|
|
107
|
+
this.formatRecordTimeStamp(full);
|
|
108
|
+
this.setEditableRecord(full);
|
|
106
109
|
},
|
|
107
110
|
error: (error) => {
|
|
108
111
|
console.error('failed to fetch full record details', error);
|
|
112
|
+
this.saveError.set(errorDetail(error, 'Failed to load record details.'));
|
|
109
113
|
},
|
|
110
114
|
});
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
override isReadOnly(fieldName: string): boolean {
|
|
114
|
-
if (this.isReadOnlyMode) return true;
|
|
118
|
+
if (this.isReadOnlyMode()) return true;
|
|
115
119
|
return super.isReadOnly(fieldName);
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
onEdit() {
|
|
119
|
-
this.isReadOnlyMode
|
|
120
|
-
this.isNew
|
|
121
|
-
this.isReadOnlyBound = this.isReadOnly.bind(this);
|
|
123
|
+
this.isReadOnlyMode.set(false);
|
|
124
|
+
this.isNew.set(false);
|
|
122
125
|
// Snapshot scalar fields only; children arrays are dirty-tracked per row.
|
|
123
|
-
this.originalSnapshot = { ...this.editableRecord };
|
|
126
|
+
this.originalSnapshot = { ...this.editableRecord() };
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
onNew() {
|
|
127
|
-
this.isReadOnlyMode
|
|
128
|
-
this.isNew
|
|
129
|
-
this.
|
|
130
|
-
this.
|
|
130
|
+
this.isReadOnlyMode.set(false);
|
|
131
|
+
this.isNew.set(true);
|
|
132
|
+
this.originalSnapshot = null;
|
|
133
|
+
this.setEditableRecord(this.emptyRecord());
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
onSave() {
|
|
134
|
-
const
|
|
137
|
+
const wasNew = this.isNew();
|
|
138
|
+
const result = {...this.editableRecord()};
|
|
135
139
|
this.readyToSave(result, this.originalSnapshot);
|
|
136
140
|
|
|
137
141
|
if (this.dialogRef) {
|
|
138
142
|
this.dialogRef.close(result);
|
|
139
|
-
|
|
140
|
-
this.backendService.post(this.apiName(), result).subscribe({
|
|
141
|
-
next: () => {
|
|
142
|
-
this.isReadOnlyMode = true;
|
|
143
|
-
this.isNew = false;
|
|
144
|
-
this.isReadOnlyBound = this.isReadOnly.bind(this);
|
|
145
|
-
this.cdr.markForCheck();
|
|
146
|
-
},
|
|
147
|
-
error: (err) => console.error('Save failed', err),
|
|
148
|
-
});
|
|
143
|
+
return;
|
|
149
144
|
}
|
|
145
|
+
this.backendService.post(this.apiName(), result).subscribe({
|
|
146
|
+
next: () => {
|
|
147
|
+
this.saveError.set('');
|
|
148
|
+
this.isReadOnlyMode.set(true);
|
|
149
|
+
this.isNew.set(false);
|
|
150
|
+
if (wasNew) {
|
|
151
|
+
// Sequence-keyed inserts have no client-side PK to refetch by;
|
|
152
|
+
// demote op_code so a second Save can't re-insert a duplicate.
|
|
153
|
+
const keys = this.getKeyFilters(result);
|
|
154
|
+
const keysComplete = !!keys && Object.values(keys).every((v) => v !== '');
|
|
155
|
+
if (keysComplete) {
|
|
156
|
+
this.record.set(result);
|
|
157
|
+
this.fetchFullRecord();
|
|
158
|
+
} else {
|
|
159
|
+
this.editableRecord.update((rec) => ({...rec, [this.config.opField]: OpCode.Readonly}));
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
this.fetchFullRecord();
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
error: (err) => {
|
|
166
|
+
console.error('Save failed', err);
|
|
167
|
+
this.saveError.set(errorDetail(err, 'Save failed.'));
|
|
168
|
+
},
|
|
169
|
+
});
|
|
150
170
|
}
|
|
151
171
|
|
|
152
172
|
onDelete() {
|
|
153
173
|
if (confirm('Are you sure you want to delete this record?')) {
|
|
154
|
-
const result = {...this.editableRecord};
|
|
174
|
+
const result = {...this.editableRecord()};
|
|
155
175
|
result[this.config.opField] = OpCode.Delete;
|
|
156
176
|
this.backendService.post(this.apiName(), result).subscribe({
|
|
157
177
|
next: () => {
|
|
158
|
-
this.
|
|
159
|
-
this.
|
|
160
|
-
|
|
178
|
+
this.saveError.set('');
|
|
179
|
+
this.isReadOnlyMode.set(true);
|
|
180
|
+
},
|
|
181
|
+
error: (err) => {
|
|
182
|
+
console.error('Delete failed', err);
|
|
183
|
+
this.saveError.set(errorDetail(err, 'Delete failed.'));
|
|
161
184
|
},
|
|
162
|
-
error: (err) => console.error('Delete failed', err),
|
|
163
185
|
});
|
|
164
186
|
}
|
|
165
187
|
}
|
|
@@ -169,9 +191,14 @@ export class TableEdit extends BaseForm implements OnInit {
|
|
|
169
191
|
this.dialogRef.close();
|
|
170
192
|
return;
|
|
171
193
|
}
|
|
172
|
-
this.
|
|
173
|
-
this.
|
|
174
|
-
this.isNew
|
|
175
|
-
|
|
194
|
+
this.saveError.set('');
|
|
195
|
+
this.isReadOnlyMode.set(true);
|
|
196
|
+
this.isNew.set(false);
|
|
197
|
+
// Refetch, not shallow-copy: the source row lacks the child-tab arrays.
|
|
198
|
+
if (Object.keys(this.record()).length > 0) {
|
|
199
|
+
this.fetchFullRecord();
|
|
200
|
+
} else {
|
|
201
|
+
this.setEditableRecord(this.emptyRecord());
|
|
202
|
+
}
|
|
176
203
|
}
|
|
177
204
|
}
|
|
@@ -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>
|