@nauticana/sail 1.1.8 → 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.
Files changed (79) hide show
  1. package/README.md +36 -3
  2. package/package.json +5 -1
  3. package/src/component/abstract/base_async.ts +9 -11
  4. package/src/component/abstract/base_form.ts +6 -5
  5. package/src/component/abstract/base_table.ts +131 -58
  6. package/src/component/abstract/base_view.ts +43 -27
  7. package/src/component/account/profile_editor.ts +2 -2
  8. package/src/component/billing/payment_methods.ts +1 -2
  9. package/src/component/billing/price_selector.html +2 -1
  10. package/src/component/billing/price_selector.ts +28 -13
  11. package/src/component/billing/trial_banner.ts +7 -1
  12. package/src/component/billing/usage_meter.ts +2 -2
  13. package/src/component/dashboard/data_card.html +1 -1
  14. package/src/component/dashboard/entity_selector.ts +21 -23
  15. package/src/component/form/form_field.html +6 -5
  16. package/src/component/form/form_field.ts +39 -21
  17. package/src/component/form/form_record.html +3 -3
  18. package/src/component/form/form_record.ts +23 -8
  19. package/src/component/form/form_table.html +2 -2
  20. package/src/component/form/form_table.ts +6 -5
  21. package/src/component/login/account_deletion.ts +6 -6
  22. package/src/component/login/chpass_component.html +4 -4
  23. package/src/component/login/chpass_component.ts +0 -1
  24. package/src/component/login/confirm_chpass_component.html +4 -4
  25. package/src/component/login/confirm_chpass_component.ts +1 -4
  26. package/src/component/login/consent_gate.html +6 -6
  27. package/src/component/login/consent_gate.ts +17 -24
  28. package/src/component/login/login_component.html +7 -4
  29. package/src/component/login/login_component.ts +9 -5
  30. package/src/component/login/otp_input.html +13 -5
  31. package/src/component/login/otp_input.ts +34 -27
  32. package/src/component/login/register_component.html +31 -14
  33. package/src/component/login/register_component.ts +80 -89
  34. package/src/component/login/social_login.html +2 -2
  35. package/src/component/login/social_login.ts +20 -13
  36. package/src/component/navigation/navigation.html +2 -2
  37. package/src/component/navigation/navigation.ts +2 -0
  38. package/src/component/oauth_connection/oauth_connection_panel.html +68 -0
  39. package/src/component/oauth_connection/oauth_connection_panel.ts +252 -0
  40. package/src/component/payment/user_payment_methods.html +2 -2
  41. package/src/component/payment/user_payment_methods.ts +12 -23
  42. package/src/component/payout/bank_info_form.html +4 -4
  43. package/src/component/payout/bank_info_form.ts +22 -26
  44. package/src/component/payout/provider_onboarding.html +5 -5
  45. package/src/component/payout/provider_onboarding.ts +28 -48
  46. package/src/component/security/trusted_devices.html +1 -1
  47. package/src/component/security/trusted_devices.ts +0 -1
  48. package/src/component/security/twofactor_setup.html +8 -4
  49. package/src/component/security/twofactor_setup.ts +21 -3
  50. package/src/component/security/twofactor_verify.html +2 -1
  51. package/src/component/security/twofactor_verify.ts +2 -8
  52. package/src/component/table/table_detail.html +15 -14
  53. package/src/component/table/table_detail.ts +62 -41
  54. package/src/component/table/table_edit.html +15 -11
  55. package/src/component/table/table_edit.ts +94 -67
  56. package/src/component/table/table_list.html +45 -12
  57. package/src/component/table/table_list.ts +98 -26
  58. package/src/component/table/table_lookup.html +11 -8
  59. package/src/component/table/table_lookup.ts +45 -37
  60. package/src/component/table/table_report.html +2 -2
  61. package/src/component/table/table_report.ts +37 -31
  62. package/src/component/table/table_search.html +4 -4
  63. package/src/component/table/table_search.ts +36 -45
  64. package/src/config.ts +1 -1
  65. package/src/index.ts +4 -0
  66. package/src/model/appdata.ts +5 -9
  67. package/src/model/common.ts +27 -27
  68. package/src/model/decorator.ts +25 -21
  69. package/src/model/oauth_connection.ts +50 -0
  70. package/src/service/api-response.interceptor.ts +4 -1
  71. package/src/service/auth.interceptor.ts +27 -7
  72. package/src/service/auth.service.ts +188 -134
  73. package/src/service/label.service.ts +6 -5
  74. package/src/service/oauth_connection.service.ts +56 -0
  75. package/src/service/rest_service.ts +57 -13
  76. package/src/service/rest_url.ts +23 -7
  77. package/src/service/script_loader.ts +12 -2
  78. package/src/util/errors.ts +20 -0
  79. package/src/util/money.ts +19 -7
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, effect, inject, input, linkedSignal, OnInit, output, ViewEncapsulation } from "@angular/core";
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 implements OnInit {
22
+ export class TableDetail extends BaseForm {
23
23
  readonly tableNameInput = input('', { alias: 'tableName' });
24
- readonly recordsInput = input<any[]>([], { alias: 'records' });
24
+ readonly recordsInput = input<Record<string, unknown>[]>([], { alias: 'records' });
25
25
  readonly parentTableName = input('');
26
- readonly parentRecord = input<any>({});
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
- records: any[] = [];
36
- editingRecord: any = null;
37
- originalRecord: any = null;
38
- displayedColumns: string[] = [];
39
- fkColumns: string[] = [];
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
- // records is a writable array (push/splice in event handlers); keep it in sync with the input
46
- // via effect rather than linkedSignal so mutations work on the consumer's array reference.
47
- effect(() => { this.records = this.recordsInput(); });
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: any, isNew: boolean, readOnlyColumns: string[]) {
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 = true;
121
+ this.isNew.set(true);
109
122
  this.originalRecord = null;
110
- this.editingRecord = newRecord;
123
+ this.editingRecord.set(newRecord);
124
+ this.refresh();
111
125
  }
112
126
  }
113
127
 
114
- editRow(record: any) {
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.editingRecord = record;
119
- this.isNew = record[this.config.opField] === OpCode.Insert;
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.readyToSave(this.editingRecord);
127
- this.editingRecord = null;
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 = false;
146
+ this.isNew.set(false);
147
+ this.refresh();
130
148
  this.changed.emit();
131
149
  }
132
150
 
133
- cancelRow(record: any) {
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 = null;
160
+ this.editingRecord.set(null);
143
161
  this.originalRecord = null;
144
- this.isNew = false;
162
+ this.isNew.set(false);
163
+ this.refresh();
145
164
  this.changed.emit();
146
165
  }
147
166
 
148
- deleteRow(record: any) {
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: any) {
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 getActions(true); track action.action) {
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
- <sail-record-form [tableName]="tableName()" [record]="editableRecord" [isReadOnly]="isReadOnlyBound"></sail-record-form>
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 (tabCount > 0) {
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]="editableRecord[key]"
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, ChangeDetectorRef, Component, inject, input, linkedSignal, OnInit, ViewEncapsulation } from "@angular/core";
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<any>({}, { alias: 'record' });
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<any, any>({ source: () => this.recordInput(), computation: (v, p) => v ?? p?.value ?? {} });
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<{record?: any; tableName?: string; apiName?: string; isNew?: boolean} | null>(MAT_DIALOG_DATA, {optional: true});
36
- private readonly cdr = inject(ChangeDetectorRef);
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: any = null;
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 = this.dialogData.isNew ?? false;
50
- this.isReadOnlyMode = !this.isNew;
59
+ this.isNew.set(this.dialogData.isNew ?? false);
60
+ this.isReadOnlyMode.set(!this.isNew());
51
61
  }
52
- if (this.isNew) {
53
- this.initializeNewRecord();
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
- private initializeNewRecord() {
61
- this.editableRecord = this.emptyRecord();
62
- const rec = this.record();
63
- if (rec) Object.assign(this.editableRecord, rec);
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
- * Tabs are the table's child relations (rest_api_child metadata), not just
68
- * whatever array fields the backend happened to embed. Seed an empty array
69
- * for any child the response omitted so its grid renders (and accepts new rows).
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
- this.editableRecord = {...rec};
88
- this.formatRecordTimeStamp(this.editableRecord);
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<any>(this.apiName(), filter).subscribe({
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
- this.editableRecord = Array.isArray(record) ? record[0] : record;
103
- this.formatRecordTimeStamp(this.editableRecord);
104
- this.buildTabs();
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 = false;
120
- this.isNew = false;
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 = false;
128
- this.isNew = true;
129
- this.editableRecord = this.emptyRecord();
130
- this.isReadOnlyBound = this.isReadOnly.bind(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 result = {...this.editableRecord};
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
- } else {
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.isReadOnlyMode = true;
159
- this.isReadOnlyBound = this.isReadOnly.bind(this);
160
- this.cdr.markForCheck();
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.isReadOnlyMode = true;
173
- this.editableRecord = {...this.record()};
174
- this.isNew = false;
175
- this.isReadOnlyBound = this.isReadOnly.bind(this);
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 getActions(false); track action.action) {
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>{{ colCaption(column) }}</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 (records.length > 0) {
43
+ @if (sortedRecords().length > 0) {
28
44
  <tbody>
29
- @for (record of records; track trackByRecord($index, record)) {
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)" (keydown.enter)="editRecord(record)">{{ displayValue(column, record[column]) }}</a>
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
- <button matIconButton type="button" (click)="editRecord(record)" title="Edit"><mat-icon>edit</mat-icon></button>
41
- <button matIconButton type="button" (click)="deleteRecord(record)" title="Delete"><mat-icon>delete</mat-icon></button>
42
- @for (action of getActions(true); track action.action) {
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 (records.length === 0 && displayedColumns.length > 0) {
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
  }