@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
|
@@ -46,17 +46,11 @@ export class TwoFactorVerifyComponent extends BaseAsync {
|
|
|
46
46
|
const { code, trustDevice } = this.verifyForm.value;
|
|
47
47
|
|
|
48
48
|
if (this.useBackupCode()) {
|
|
49
|
+
// verifyBackupCode completes the login inside BaseAuthService.
|
|
49
50
|
this.run(
|
|
50
51
|
this.auth.verifyBackupCode(code!),
|
|
51
52
|
(res) => {
|
|
52
|
-
if (res.valid
|
|
53
|
-
localStorage.removeItem('loginToken');
|
|
54
|
-
this.auth.token = res.token;
|
|
55
|
-
this.auth.isLoggedIn.set(true);
|
|
56
|
-
localStorage.setItem('jwt', res.token);
|
|
57
|
-
this.auth.loadAppData();
|
|
58
|
-
this.auth.initRoutes();
|
|
59
|
-
} else {
|
|
53
|
+
if (!res.valid || !res.token) {
|
|
60
54
|
this.errorMessage.set('Invalid backup code.');
|
|
61
55
|
}
|
|
62
56
|
},
|
|
@@ -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"
|
|
@@ -18,17 +18,17 @@
|
|
|
18
18
|
<table class="mat-table">
|
|
19
19
|
<thead>
|
|
20
20
|
<tr>
|
|
21
|
-
@for (col of displayedColumns; track col) {
|
|
22
|
-
<th>{{ colCaption(col) }}</th>
|
|
21
|
+
@for (col of displayedColumns(); track col) {
|
|
22
|
+
<th scope="col">{{ colCaption(col) }}</th>
|
|
23
23
|
}
|
|
24
|
-
<th>Actions</th>
|
|
24
|
+
<th scope="col">Actions</th>
|
|
25
25
|
</tr>
|
|
26
26
|
</thead>
|
|
27
27
|
<tbody>
|
|
28
|
-
@for (record of
|
|
28
|
+
@for (record of visibleRecords(); track trackByRecord($index, record)) {
|
|
29
29
|
<tr [class.deleted-record]="isDeleted(record)" [class.updated-record]="isUpdated(record)" [class.new-record]="isInserted(record)">
|
|
30
|
-
@if (record
|
|
31
|
-
@for (col of displayedColumns; track col) {
|
|
30
|
+
@if (record === editingRecord()) {
|
|
31
|
+
@for (col of displayedColumns(); track col) {
|
|
32
32
|
<td>
|
|
33
33
|
@if (isReadOnly(col)) {
|
|
34
34
|
{{ record[col] }}
|
|
@@ -38,27 +38,28 @@
|
|
|
38
38
|
</td>
|
|
39
39
|
}
|
|
40
40
|
<td>
|
|
41
|
-
<button matIconButton (click)="saveRow()" [title]="isNew ? 'Add' : 'Update'"><mat-icon>{{ isNew ? 'add' : 'check' }}</mat-icon></button>
|
|
42
|
-
<button matIconButton (click)="cancelRow(record)" title="Cancel"><mat-icon>cancel</mat-icon></button>
|
|
41
|
+
<button matIconButton (click)="saveRow()" [title]="isNew() ? 'Add' : 'Update'" [attr.aria-label]="isNew() ? 'Add' : 'Update'"><mat-icon>{{ isNew() ? 'add' : 'check' }}</mat-icon></button>
|
|
42
|
+
<button matIconButton (click)="cancelRow(record)" title="Cancel" aria-label="Cancel"><mat-icon>cancel</mat-icon></button>
|
|
43
43
|
</td>
|
|
44
44
|
} @else {
|
|
45
|
-
@for (col of displayedColumns; track col) {
|
|
45
|
+
@for (col of displayedColumns(); track col) {
|
|
46
46
|
<td>{{ displayValue(col, record[col]) }}</td>
|
|
47
47
|
}
|
|
48
48
|
<td>
|
|
49
49
|
@if (canUpdate()) {
|
|
50
|
-
<button matIconButton (click)="editRow(record)" title="Edit"><mat-icon>edit</mat-icon></button>
|
|
50
|
+
<button matIconButton (click)="editRow(record)" title="Edit" aria-label="Edit"><mat-icon>edit</mat-icon></button>
|
|
51
51
|
}
|
|
52
52
|
@if (isDeleted(record)) {
|
|
53
|
-
<button matIconButton (click)="unDeleteRow(record)" title="Undelete"><mat-icon>restore_from_trash</mat-icon></button>
|
|
53
|
+
<button matIconButton (click)="unDeleteRow(record)" title="Undelete" aria-label="Undelete"><mat-icon>restore_from_trash</mat-icon></button>
|
|
54
54
|
} @else if (canDelete()) {
|
|
55
|
-
<button matIconButton (click)="deleteRow(record)" title="Delete"><mat-icon>delete</mat-icon></button>
|
|
55
|
+
<button matIconButton (click)="deleteRow(record)" title="Delete" aria-label="Delete"><mat-icon>delete</mat-icon></button>
|
|
56
56
|
}
|
|
57
57
|
@if (!isInserted(record)) {
|
|
58
|
-
@for (action of
|
|
58
|
+
@for (action of recordActions(); track action.action) {
|
|
59
59
|
@if (canExecuteAction(action)) {
|
|
60
60
|
<button matIconButton type="button"
|
|
61
61
|
[title]="action.caption"
|
|
62
|
+
[attr.aria-label]="action.caption"
|
|
62
63
|
(click)="executeAction(action, record)">
|
|
63
64
|
<mat-icon>{{ action.icon || 'play_arrow' }}</mat-icon>
|
|
64
65
|
</button>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, effect, inject, input, linkedSignal,
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, linkedSignal, output, signal, ViewEncapsulation } from "@angular/core";
|
|
2
2
|
import { DynamicField } from "../form/form_field";
|
|
3
3
|
import { MatButtonModule } from "@angular/material/button";
|
|
4
4
|
import { BaseForm } from "../abstract/base_form";
|
|
@@ -19,11 +19,11 @@ import { OpCode } from "../../model/common";
|
|
|
19
19
|
MatIconModule,
|
|
20
20
|
],
|
|
21
21
|
})
|
|
22
|
-
export class TableDetail extends BaseForm
|
|
22
|
+
export class TableDetail extends BaseForm {
|
|
23
23
|
readonly tableNameInput = input('', { alias: 'tableName' });
|
|
24
|
-
readonly recordsInput = input<
|
|
24
|
+
readonly recordsInput = input<Record<string, unknown>[]>([], { alias: 'records' });
|
|
25
25
|
readonly parentTableName = input('');
|
|
26
|
-
readonly parentRecord = input<
|
|
26
|
+
readonly parentRecord = input<Record<string, unknown>>({});
|
|
27
27
|
/** When true, the parent view is in read-only mode — disable all child row editing. */
|
|
28
28
|
readonly parentReadOnly = input(false);
|
|
29
29
|
/** Fires after any grid mutation (add/edit/delete/undelete/cancel) so a parent
|
|
@@ -32,19 +32,34 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
32
32
|
|
|
33
33
|
override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
// The parent owns the rows array (TableEdit saves it verbatim), so mutations
|
|
36
|
+
// happen in place; refresh() snapshots them for the template.
|
|
37
|
+
records: Record<string, unknown>[] = [];
|
|
38
|
+
readonly visibleRecords = signal<Record<string, unknown>[]>([]);
|
|
39
|
+
|
|
40
|
+
readonly editingRecord = signal<Record<string, unknown> | null>(null);
|
|
41
|
+
private originalRecord: Record<string, unknown> | null = null;
|
|
42
|
+
|
|
43
|
+
readonly displayedColumns = computed(() => {
|
|
44
|
+
this.visibleRecords();
|
|
45
|
+
return this.getDisplayedColumns(this.records);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
readonly fkColumns = computed(() =>
|
|
49
|
+
this.getForeignKeyConfig(this.parentTableName())?.fk?.Columns?.map((col) => col.PascalName) ?? []);
|
|
40
50
|
|
|
41
51
|
private readonly dialog = inject(MatDialog);
|
|
42
52
|
|
|
43
53
|
constructor() {
|
|
44
54
|
super();
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
effect(() => {
|
|
56
|
+
this.records = this.recordsInput() ?? [];
|
|
57
|
+
this.refresh();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private refresh() {
|
|
62
|
+
this.visibleRecords.set([...this.records]);
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
/**
|
|
@@ -64,21 +79,11 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
64
79
|
override canUpdate() { return !this.parentReadOnly() && super.canUpdate(); }
|
|
65
80
|
override canDelete() { return !this.parentReadOnly() && super.canDelete(); }
|
|
66
81
|
|
|
67
|
-
ngOnInit(): void {
|
|
68
|
-
this.displayedColumns = this.getDisplayedColumns(this.records);
|
|
69
|
-
const fkCfg = this.getForeignKeyConfig(this.parentTableName());
|
|
70
|
-
if (fkCfg && fkCfg.fk && fkCfg.fk.Columns) {
|
|
71
|
-
for (const col of fkCfg.fk.Columns) {
|
|
72
|
-
this.fkColumns.push(col.PascalName);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
82
|
override isReadOnly(fieldName: string): boolean {
|
|
78
|
-
return (this.fkColumns.includes(fieldName) || super.isReadOnly(fieldName));
|
|
83
|
+
return (this.fkColumns().includes(fieldName) || super.isReadOnly(fieldName));
|
|
79
84
|
}
|
|
80
85
|
|
|
81
|
-
openEditDialog(record:
|
|
86
|
+
openEditDialog(record: Record<string, unknown>, isNew: boolean, readOnlyColumns: string[]) {
|
|
82
87
|
const dialogRef = this.dialog.open(TableForm, {
|
|
83
88
|
width: this.dialogWidth,
|
|
84
89
|
disableClose: true,
|
|
@@ -91,46 +96,59 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
91
96
|
} else {
|
|
92
97
|
Object.assign(record, result);
|
|
93
98
|
}
|
|
99
|
+
this.refresh();
|
|
94
100
|
this.changed.emit();
|
|
95
101
|
}
|
|
96
102
|
});
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
/** Finish an in-progress inline edit before starting another (its values are
|
|
106
|
+
* already in the row; leaving it un-finalized would drop them on save). */
|
|
107
|
+
private finalizeInlineEdit() {
|
|
108
|
+
if (this.editingRecord()) this.saveRow();
|
|
109
|
+
}
|
|
110
|
+
|
|
99
111
|
addRecord() {
|
|
112
|
+
this.finalizeInlineEdit();
|
|
100
113
|
const newRecord = this.emptyRecord();
|
|
101
114
|
newRecord[this.config.opField] = OpCode.Insert;
|
|
102
115
|
this.initializeForeignKeys(newRecord, this.parentTableName(), this.parentRecord());
|
|
103
116
|
|
|
104
|
-
if (this.displayedColumns.length > 6) {
|
|
105
|
-
this.openEditDialog(newRecord, true, this.fkColumns);
|
|
117
|
+
if (this.displayedColumns().length > 6) {
|
|
118
|
+
this.openEditDialog(newRecord, true, this.fkColumns());
|
|
106
119
|
} else {
|
|
107
120
|
this.records.push(newRecord);
|
|
108
|
-
this.isNew
|
|
121
|
+
this.isNew.set(true);
|
|
109
122
|
this.originalRecord = null;
|
|
110
|
-
this.editingRecord
|
|
123
|
+
this.editingRecord.set(newRecord);
|
|
124
|
+
this.refresh();
|
|
111
125
|
}
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
editRow(record:
|
|
115
|
-
if (this.displayedColumns.length > 6) {
|
|
116
|
-
this.openEditDialog(record, false, this.fkColumns);
|
|
128
|
+
editRow(record: Record<string, unknown>) {
|
|
129
|
+
if (this.displayedColumns().length > 6) {
|
|
130
|
+
this.openEditDialog(record, false, this.fkColumns());
|
|
117
131
|
} else {
|
|
118
|
-
this.
|
|
119
|
-
this.isNew
|
|
132
|
+
this.finalizeInlineEdit();
|
|
133
|
+
this.isNew.set(record[this.config.opField] === OpCode.Insert);
|
|
120
134
|
this.originalRecord = {...record};
|
|
121
135
|
this.formatRecordTimeStamp(record);
|
|
136
|
+
this.editingRecord.set(record);
|
|
137
|
+
this.refresh();
|
|
122
138
|
}
|
|
123
139
|
}
|
|
124
140
|
|
|
125
141
|
saveRow() {
|
|
126
|
-
this.
|
|
127
|
-
this.
|
|
142
|
+
const editing = this.editingRecord();
|
|
143
|
+
if (editing) this.readyToSave(editing, this.originalRecord);
|
|
144
|
+
this.editingRecord.set(null);
|
|
128
145
|
this.originalRecord = null;
|
|
129
|
-
this.isNew
|
|
146
|
+
this.isNew.set(false);
|
|
147
|
+
this.refresh();
|
|
130
148
|
this.changed.emit();
|
|
131
149
|
}
|
|
132
150
|
|
|
133
|
-
cancelRow(record:
|
|
151
|
+
cancelRow(record: Record<string, unknown>) {
|
|
134
152
|
if (this.originalRecord) {
|
|
135
153
|
Object.assign(record, this.originalRecord);
|
|
136
154
|
} else {
|
|
@@ -139,13 +157,14 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
139
157
|
this.records.splice(index, 1);
|
|
140
158
|
}
|
|
141
159
|
}
|
|
142
|
-
this.editingRecord
|
|
160
|
+
this.editingRecord.set(null);
|
|
143
161
|
this.originalRecord = null;
|
|
144
|
-
this.isNew
|
|
162
|
+
this.isNew.set(false);
|
|
163
|
+
this.refresh();
|
|
145
164
|
this.changed.emit();
|
|
146
165
|
}
|
|
147
166
|
|
|
148
|
-
deleteRow(record:
|
|
167
|
+
deleteRow(record: Record<string, unknown>) {
|
|
149
168
|
if (record[this.config.opField] === OpCode.Insert) {
|
|
150
169
|
const index = this.records.indexOf(record);
|
|
151
170
|
if (index > -1) {
|
|
@@ -154,13 +173,15 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
154
173
|
} else {
|
|
155
174
|
record[this.config.opField] = OpCode.Delete;
|
|
156
175
|
}
|
|
176
|
+
this.refresh();
|
|
157
177
|
this.changed.emit();
|
|
158
178
|
}
|
|
159
179
|
|
|
160
|
-
unDeleteRow(record:
|
|
180
|
+
unDeleteRow(record: Record<string, unknown>) {
|
|
161
181
|
if (record[this.config.opField] === OpCode.Delete) {
|
|
162
182
|
record[this.config.opField] = OpCode.Update;
|
|
163
183
|
}
|
|
184
|
+
this.refresh();
|
|
164
185
|
this.changed.emit();
|
|
165
186
|
}
|
|
166
187
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<h1 class="mat-h1 table-edit-title">
|
|
2
2
|
<span class="table-edit-title-text">
|
|
3
|
-
@if (title) {
|
|
4
|
-
{{ title }}
|
|
5
|
-
} @else if (isNew) {
|
|
3
|
+
@if (title()) {
|
|
4
|
+
{{ title() }}
|
|
5
|
+
} @else if (isNew()) {
|
|
6
6
|
New {{ getCaption() }} Record
|
|
7
7
|
} @else {
|
|
8
8
|
{{ getCaption() }} Details
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
<div class="edit-container">
|
|
20
20
|
<div class="actions-bar">
|
|
21
|
-
@if (isReadOnlyMode) {
|
|
21
|
+
@if (isReadOnlyMode()) {
|
|
22
22
|
@if (canUpdate()) {
|
|
23
23
|
<button matButton="elevated" class="primary" (click)="onEdit()">Edit</button>
|
|
24
24
|
}
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
@if (canDelete()) {
|
|
29
29
|
<button matButton class="warn" (click)="onDelete()">Delete</button>
|
|
30
30
|
}
|
|
31
|
-
@for (action of
|
|
31
|
+
@for (action of recordActions(); track action.action) {
|
|
32
32
|
@if (canExecuteAction(action)) {
|
|
33
33
|
<button matButton="outlined" type="button"
|
|
34
34
|
[title]="action.caption"
|
|
@@ -44,20 +44,24 @@
|
|
|
44
44
|
}
|
|
45
45
|
</div>
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
@if (saveError()) {
|
|
48
|
+
<p class="table-edit-error" role="alert">{{ saveError() }}</p>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
<sail-record-form [tableName]="tableName()" [record]="editableRecord()" (recordChange)="editableRecord.set($event)" [isReadOnly]="isReadOnlyFn"></sail-record-form>
|
|
48
52
|
|
|
49
|
-
@if (
|
|
53
|
+
@if (tabFields().length > 0) {
|
|
50
54
|
<mat-tab-group>
|
|
51
|
-
@for (key of tabFields; track key) {
|
|
55
|
+
@for (key of tabFields(); track key) {
|
|
52
56
|
@if (cacheService.canRead(getTableName(key))) {
|
|
53
57
|
<mat-tab [label]="key">
|
|
54
58
|
<div class="tab-content">
|
|
55
59
|
<sail-table-detail
|
|
56
|
-
[records]="
|
|
60
|
+
[records]="childRecords(key)"
|
|
57
61
|
[tableName]="getTableName(key)"
|
|
58
62
|
[parentTableName]="tableName()"
|
|
59
|
-
[parentRecord]="editableRecord"
|
|
60
|
-
[parentReadOnly]="isReadOnlyMode">
|
|
63
|
+
[parentRecord]="editableRecord()"
|
|
64
|
+
[parentReadOnly]="isReadOnlyMode()">
|
|
61
65
|
</sail-table-detail>
|
|
62
66
|
</div>
|
|
63
67
|
</mat-tab>
|
|
@@ -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
|
}
|