@nauticana/sail 0.7.0 → 0.8.1

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 (68) hide show
  1. package/README.md +419 -15
  2. package/package.json +2 -2
  3. package/src/component/abstract/base_async.ts +8 -2
  4. package/src/component/abstract/base_table.ts +108 -49
  5. package/src/component/abstract/base_view.ts +32 -45
  6. package/src/component/billing/checkout_button.html +1 -1
  7. package/src/component/billing/checkout_button.ts +1 -1
  8. package/src/component/billing/payment_methods.ts +1 -1
  9. package/src/component/billing/plan_selector.html +1 -1
  10. package/src/component/billing/plan_selector.ts +1 -1
  11. package/src/component/form/form_field.html +1 -1
  12. package/src/component/form/form_field.ts +8 -7
  13. package/src/component/form/form_record.html +2 -2
  14. package/src/component/form/form_record.ts +1 -1
  15. package/src/component/form/form_table.html +4 -4
  16. package/src/component/form/form_table.ts +2 -2
  17. package/src/component/login/account_deletion.html +1 -1
  18. package/src/component/login/account_deletion.ts +5 -2
  19. package/src/component/login/chpass_component.html +1 -1
  20. package/src/component/login/chpass_component.ts +11 -16
  21. package/src/component/login/confirm_chpass_component.html +1 -1
  22. package/src/component/login/confirm_chpass_component.ts +9 -15
  23. package/src/component/login/confirm_register_component.html +1 -1
  24. package/src/component/login/confirm_register_component.ts +9 -15
  25. package/src/component/login/consent_gate.ts +1 -1
  26. package/src/component/login/login_component.html +2 -2
  27. package/src/component/login/login_component.ts +1 -1
  28. package/src/component/login/otp_input.html +3 -3
  29. package/src/component/login/otp_input.ts +1 -1
  30. package/src/component/login/register_component.html +1 -1
  31. package/src/component/login/register_component.ts +1 -1
  32. package/src/component/login/social_login.ts +1 -1
  33. package/src/component/navigation/navigation.html +3 -3
  34. package/src/component/navigation/navigation.ts +10 -11
  35. package/src/component/payment/user_payment_methods.html +46 -0
  36. package/src/component/payment/user_payment_methods.ts +10 -58
  37. package/src/component/payout/bank_info_form.html +59 -0
  38. package/src/component/payout/bank_info_form.ts +21 -77
  39. package/src/component/payout/provider_onboarding.html +70 -0
  40. package/src/component/payout/provider_onboarding.ts +9 -80
  41. package/src/component/security/trusted_devices.html +4 -4
  42. package/src/component/security/trusted_devices.ts +1 -1
  43. package/src/component/security/twofactor_setup.html +4 -4
  44. package/src/component/security/twofactor_setup.ts +18 -26
  45. package/src/component/security/twofactor_verify.html +1 -1
  46. package/src/component/security/twofactor_verify.ts +10 -12
  47. package/src/component/table/table_detail.html +12 -12
  48. package/src/component/table/table_detail.ts +28 -33
  49. package/src/component/table/table_edit.html +20 -10
  50. package/src/component/table/table_edit.ts +29 -18
  51. package/src/component/table/table_list.html +5 -5
  52. package/src/component/table/table_list.ts +24 -48
  53. package/src/component/table/table_lookup.html +4 -4
  54. package/src/component/table/table_lookup.ts +11 -12
  55. package/src/component/table/table_report.html +1 -1
  56. package/src/component/table/table_report.ts +11 -14
  57. package/src/component/table/table_search.html +5 -5
  58. package/src/component/table/table_search.ts +31 -42
  59. package/src/index.ts +2 -0
  60. package/src/model/appdata.ts +2 -2
  61. package/src/model/common.ts +39 -1
  62. package/src/service/auth.service.ts +26 -26
  63. package/src/service/base_rest.service.ts +20 -0
  64. package/src/service/billing.service.ts +3 -9
  65. package/src/service/payout.service.ts +3 -9
  66. package/src/service/rest_service.ts +5 -5
  67. package/src/service/user_payment_method.service.ts +4 -8
  68. package/src/util/text.ts +19 -0
@@ -1,15 +1,15 @@
1
- import { ChangeDetectionStrategy, Component, inject, Input, input, OnInit, ViewEncapsulation } from "@angular/core";
1
+ import { ChangeDetectionStrategy, Component, effect, inject, input, linkedSignal, OnInit, 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";
5
5
  import { MatDialog } from "@angular/material/dialog";
6
6
  import { TableForm } from "../form/form_table";
7
7
  import { MatIconModule } from "@angular/material/icon";
8
- import { BackendService } from "../../service/rest_service";
9
8
  import { TableAction } from "../../model/appdata";
9
+ import { OpCode } from "../../model/common";
10
10
 
11
11
  @Component({
12
- selector: 'table-detail',
12
+ selector: 'sail-table-detail',
13
13
  changeDetection: ChangeDetectionStrategy.OnPush,
14
14
  encapsulation: ViewEncapsulation.None,
15
15
  templateUrl: './table_detail.html',
@@ -20,46 +20,41 @@ import { TableAction } from "../../model/appdata";
20
20
  ],
21
21
  })
22
22
  export class TableDetail extends BaseForm implements OnInit {
23
- @Input() override tableName = '';
24
- @Input() records: any[] = [];
23
+ protected readonly tableNameInput = input('', { alias: 'tableName' });
24
+ protected readonly recordsInput = input<any[]>([], { alias: 'records' });
25
25
  readonly parentTableName = input('');
26
26
  readonly parentRecord = input<any>({});
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
 
30
+ override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
31
+
32
+ records: any[] = [];
30
33
  editingRecord: any = null;
31
34
  originalRecord: any = null;
32
35
  displayedColumns: string[] = [];
33
36
  fkColumns: string[] = [];
34
37
 
35
38
  private readonly dialog = inject(MatDialog);
36
- private readonly backendService = inject(BackendService);
39
+
40
+ constructor() {
41
+ super();
42
+ // records is a writable array (push/splice in event handlers); keep it in sync with the input
43
+ // via effect rather than linkedSignal so mutations work on the consumer's array reference.
44
+ effect(() => { this.records = this.recordsInput(); });
45
+ }
37
46
 
38
47
  /**
39
- * Fire a TableAction. Per-row actions require the record to be
40
- * server-side (not 'I' inserted-locally-not-saved); table-level
41
- * actions only need the parent to be writable. Action POSTs hit
42
- * the resolved URL directly — no need to round-trip through the
43
- * parent form.
48
+ * Per-row actions require the record to be server-side (not 'I'
49
+ * inserted-locally-not-saved); the keel action POST would hit a record
50
+ * that doesn't exist yet. Table-level actions are always allowed.
44
51
  */
45
- executeAction(action: TableAction, record?: {[key: string]: unknown}): void {
46
- if (!this.canExecuteAction(action)) {
47
- alert(`Missing authorization for action ${action.authorityObject}/${action.authorityCheck}`);
48
- return;
49
- }
50
- if (action.recordSpecific && record && record[this.config.opField] === 'I') {
52
+ protected override beforeExecuteAction(action: TableAction, record?: Record<string, unknown>): boolean {
53
+ if (action.recordSpecific && record && record[this.config.opField] === OpCode.Insert) {
51
54
  alert('Save the record first before running this action.');
52
- return;
55
+ return false;
53
56
  }
54
- if (action.confirmMessage && !confirm(action.confirmMessage)) return;
55
- const body = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
56
- this.backendService.executeAction(action.method, body).subscribe({
57
- next: () => {/* parent form re-fetches on its own save */},
58
- error: (err) => {
59
- console.error(`Action ${action.action} failed`, err);
60
- alert(err?.error?.detail ?? `Failed to ${action.caption}`);
61
- },
62
- });
57
+ return true;
63
58
  }
64
59
 
65
60
  override canCreate() { return !this.parentReadOnly() && super.canCreate(); }
@@ -99,7 +94,7 @@ export class TableDetail extends BaseForm implements OnInit {
99
94
 
100
95
  addRecord() {
101
96
  const newRecord = this.emptyRecord();
102
- newRecord[this.config.opField] = 'I';
97
+ newRecord[this.config.opField] = OpCode.Insert;
103
98
  this.initializeForeignKeys(newRecord, this.parentTableName(), this.parentRecord());
104
99
 
105
100
  if (this.displayedColumns.length > 6) {
@@ -117,7 +112,7 @@ export class TableDetail extends BaseForm implements OnInit {
117
112
  this.openEditDialog(record, false, this.fkColumns);
118
113
  } else {
119
114
  this.editingRecord = record;
120
- this.isNew = record[this.config.opField] === 'I';
115
+ this.isNew = record[this.config.opField] === OpCode.Insert;
121
116
  this.originalRecord = {...record};
122
117
  this.formatRecordTimeStamp(record);
123
118
  }
@@ -145,19 +140,19 @@ export class TableDetail extends BaseForm implements OnInit {
145
140
  }
146
141
 
147
142
  deleteRow(record: any) {
148
- if (record[this.config.opField] === 'I') {
143
+ if (record[this.config.opField] === OpCode.Insert) {
149
144
  const index = this.records.indexOf(record);
150
145
  if (index > -1) {
151
146
  this.records.splice(index, 1);
152
147
  }
153
148
  } else {
154
- record[this.config.opField] = 'D';
149
+ record[this.config.opField] = OpCode.Delete;
155
150
  }
156
151
  }
157
152
 
158
153
  unDeleteRow(record: any) {
159
- if (record[this.config.opField] === 'D') {
160
- record[this.config.opField] = 'U';
154
+ if (record[this.config.opField] === OpCode.Delete) {
155
+ record[this.config.opField] = OpCode.Update;
161
156
  }
162
157
  }
163
158
  }
@@ -9,7 +9,7 @@
9
9
  }
10
10
  </span>
11
11
  @if (isDialog) {
12
- <button mat-icon-button type="button" class="table-edit-close"
12
+ <button matIconButton type="button" class="table-edit-close"
13
13
  (click)="onCancel()" aria-label="Close dialog">
14
14
  <mat-icon>close</mat-icon>
15
15
  </button>
@@ -20,21 +20,31 @@
20
20
  <div class="actions-bar">
21
21
  @if (isReadOnlyMode) {
22
22
  @if (canUpdate()) {
23
- <button mat-raised-button class="primary" (click)="onEdit()">Edit</button>
23
+ <button matButton="elevated" class="primary" (click)="onEdit()">Edit</button>
24
24
  }
25
25
  @if (canCreate()) {
26
- <button mat-raised-button class="accent" (click)="onNew()">New</button>
26
+ <button matButton="elevated" class="accent" (click)="onNew()">New</button>
27
27
  }
28
28
  @if (canDelete()) {
29
- <button mat-button class="warn" (click)="onDelete()">Delete</button>
29
+ <button matButton class="warn" (click)="onDelete()">Delete</button>
30
+ }
31
+ @for (action of getActions(true); track action.action) {
32
+ @if (canExecuteAction(action)) {
33
+ <button matButton="outlined" type="button"
34
+ [title]="action.caption"
35
+ (click)="executeAction(action, record())">
36
+ @if (action.icon) { <mat-icon>{{ action.icon }}</mat-icon> }
37
+ {{ action.caption }}
38
+ </button>
39
+ }
30
40
  }
31
41
  } @else {
32
- <button mat-raised-button class="primary" (click)="onSave()">Save</button>
33
- <button mat-button (click)="onCancel()">Cancel</button>
42
+ <button matButton="elevated" class="primary" (click)="onSave()">Save</button>
43
+ <button matButton (click)="onCancel()">Cancel</button>
34
44
  }
35
45
  </div>
36
46
 
37
- <record-form [tableName]="tableName" [record]="editableRecord" [isReadOnly]="isReadOnlyBound"></record-form>
47
+ <sail-record-form [tableName]="tableName()" [record]="editableRecord" [isReadOnly]="isReadOnlyBound"></sail-record-form>
38
48
 
39
49
  @if (tabCount > 0) {
40
50
  <mat-tab-group>
@@ -42,13 +52,13 @@
42
52
  @if (cacheService.canRead(getTableName(key))) {
43
53
  <mat-tab [label]="key">
44
54
  <div class="tab-content">
45
- <table-detail
55
+ <sail-table-detail
46
56
  [records]="editableRecord[key]"
47
57
  [tableName]="getTableName(key)"
48
- [parentTableName]="tableName"
58
+ [parentTableName]="tableName()"
49
59
  [parentRecord]="editableRecord"
50
60
  [parentReadOnly]="isReadOnlyMode">
51
- </table-detail>
61
+ </sail-table-detail>
52
62
  </div>
53
63
  </mat-tab>
54
64
  }
@@ -1,15 +1,15 @@
1
- import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit, ViewEncapsulation } from "@angular/core";
1
+ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, input, linkedSignal, OnInit, ViewEncapsulation } from "@angular/core";
2
2
  import { BaseForm } from "../abstract/base_form";
3
3
  import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
4
4
  import { MatTabsModule } from "@angular/material/tabs";
5
5
  import { TableDetail } from "./table_detail";
6
- import { BackendService } from "../../service/rest_service";
7
6
  import { RecordForm } from "../form/form_record";
8
7
  import { MatButtonModule } from "@angular/material/button";
9
8
  import { MatIconModule } from "@angular/material/icon";
9
+ import { OpCode } from "../../model/common";
10
10
 
11
11
  @Component({
12
- selector: 'table-edit',
12
+ selector: 'sail-table-edit',
13
13
  templateUrl: './table_edit.html',
14
14
  changeDetection: ChangeDetectionStrategy.OnPush,
15
15
  encapsulation: ViewEncapsulation.None,
@@ -22,11 +22,15 @@ import { MatIconModule } from "@angular/material/icon";
22
22
  ]
23
23
  })
24
24
  export class TableEdit extends BaseForm implements OnInit {
25
- @Input() record: any = {};
26
- @Input() override tableName = '';
27
- @Input() apiName = '';
25
+ protected readonly recordInput = input<any>({}, { alias: 'record' });
26
+ protected readonly tableNameInput = input('', { alias: 'tableName' });
27
+ protected readonly apiNameInput = input('', { alias: 'apiName' });
28
+
29
+ readonly record = linkedSignal<any, any>({ source: () => this.recordInput(), computation: (v, p) => v ?? p?.value ?? {} });
30
+ override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
31
+ readonly apiName = linkedSignal<string, string>({ source: () => this.apiNameInput(), computation: (v, p) => v || p?.value || '' });
32
+
28
33
  isReadOnlyMode = true;
29
- private readonly backendService = inject(BackendService);
30
34
  private readonly dialogRef = inject(MatDialogRef<TableEdit>, {optional: true});
31
35
  private readonly dialogData = inject<{record?: any; tableName?: string; apiName?: string; isNew?: boolean} | null>(MAT_DIALOG_DATA, {optional: true});
32
36
  private readonly cdr = inject(ChangeDetectorRef);
@@ -39,9 +43,9 @@ export class TableEdit extends BaseForm implements OnInit {
39
43
 
40
44
  ngOnInit(): void {
41
45
  if (this.dialogData) {
42
- this.record = this.dialogData.record ?? {};
43
- this.tableName = this.dialogData.tableName ?? '';
44
- this.apiName = this.dialogData.apiName ?? '';
46
+ this.record.set(this.dialogData.record ?? {});
47
+ this.tableName.set(this.dialogData.tableName ?? '');
48
+ this.apiName.set(this.dialogData.apiName ?? '');
45
49
  this.isNew = this.dialogData.isNew ?? false;
46
50
  this.isReadOnlyMode = !this.isNew;
47
51
  }
@@ -55,7 +59,8 @@ export class TableEdit extends BaseForm implements OnInit {
55
59
 
56
60
  private initializeNewRecord() {
57
61
  this.editableRecord = this.emptyRecord();
58
- if (this.record) Object.assign(this.editableRecord, this.record);
62
+ const rec = this.record();
63
+ if (rec) Object.assign(this.editableRecord, rec);
59
64
  }
60
65
 
61
66
  private buildTabs() {
@@ -69,17 +74,23 @@ export class TableEdit extends BaseForm implements OnInit {
69
74
  }
70
75
  }
71
76
 
77
+ /** Re-fetch the row from the backend after a server-side mutation (TableAction success, etc.). */
78
+ protected override onActionSuccess(): void {
79
+ if (!this.isNew) this.fetchFullRecord();
80
+ }
81
+
72
82
  private fetchFullRecord() {
73
- this.editableRecord = {...this.record};
83
+ const rec = this.record();
84
+ this.editableRecord = {...rec};
74
85
  this.formatRecordTimeStamp(this.editableRecord);
75
86
 
76
- const filter = this.getKeyFilters(this.record);
87
+ const filter = this.getKeyFilters(rec);
77
88
  if (!filter) {
78
89
  console.warn('Key definition could not be found');
79
90
  return;
80
91
  }
81
92
 
82
- this.backendService.get<any>(this.apiName, filter).subscribe({
93
+ this.backendService.get<any>(this.apiName(), filter).subscribe({
83
94
  next: (record) => {
84
95
  if (!record) {
85
96
  console.error('failed to read single record from the backend');
@@ -123,7 +134,7 @@ export class TableEdit extends BaseForm implements OnInit {
123
134
  if (this.dialogRef) {
124
135
  this.dialogRef.close(result);
125
136
  } else {
126
- this.backendService.post(this.apiName, result).subscribe({
137
+ this.backendService.post(this.apiName(), result).subscribe({
127
138
  next: () => {
128
139
  this.isReadOnlyMode = true;
129
140
  this.isNew = false;
@@ -138,8 +149,8 @@ export class TableEdit extends BaseForm implements OnInit {
138
149
  onDelete() {
139
150
  if (confirm('Are you sure you want to delete this record?')) {
140
151
  const result = {...this.editableRecord};
141
- result[this.config.opField] = 'D';
142
- this.backendService.post(this.apiName, result).subscribe({
152
+ result[this.config.opField] = OpCode.Delete;
153
+ this.backendService.post(this.apiName(), result).subscribe({
143
154
  next: () => {
144
155
  this.isReadOnlyMode = true;
145
156
  this.isReadOnlyBound = this.isReadOnly.bind(this);
@@ -156,7 +167,7 @@ export class TableEdit extends BaseForm implements OnInit {
156
167
  return;
157
168
  }
158
169
  this.isReadOnlyMode = true;
159
- this.editableRecord = {...this.record};
170
+ this.editableRecord = {...this.record()};
160
171
  this.isNew = false;
161
172
  this.isReadOnlyBound = this.isReadOnly.bind(this);
162
173
  }
@@ -2,11 +2,11 @@
2
2
 
3
3
  <div class="table-list__toolbar">
4
4
  @if (canCreate()) {
5
- <button mat-raised-button class="accent" (click)="addRecord()">New Record</button>
5
+ <button matButton="elevated" class="accent" (click)="addRecord()">New Record</button>
6
6
  }
7
7
  @for (action of getActions(false); track action.action) {
8
8
  @if (canExecuteAction(action)) {
9
- <button mat-stroked-button type="button"
9
+ <button matButton="outlined" type="button"
10
10
  [title]="action.caption"
11
11
  (click)="executeAction(action)">
12
12
  @if (action.icon) { <mat-icon>{{ action.icon }}</mat-icon> }
@@ -37,11 +37,11 @@
37
37
  } @else if (column !== 'actions') {
38
38
  {{ displayValue(column, record[column]) }}
39
39
  } @else {
40
- <button mat-icon-button type="button" (click)="editRecord(record)" title="Edit"><mat-icon>edit</mat-icon></button>
41
- <button mat-icon-button type="button" (click)="deleteRecord(record)" title="Delete"><mat-icon>delete</mat-icon></button>
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
42
  @for (action of getActions(true); track action.action) {
43
43
  @if (canExecuteAction(action)) {
44
- <button mat-icon-button type="button"
44
+ <button matIconButton type="button"
45
45
  [title]="action.caption"
46
46
  (click)="executeAction(action, record)">
47
47
  <mat-icon>{{ action.icon || 'play_arrow' }}</mat-icon>
@@ -1,16 +1,14 @@
1
- import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit, ViewEncapsulation } from "@angular/core";
1
+ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, input, linkedSignal, OnInit, ViewEncapsulation } from "@angular/core";
2
2
  import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
3
3
  import { ActivatedRoute, Params } from "@angular/router";
4
4
  import { BaseView } from "../abstract/base_view";
5
5
  import { TableEdit } from "./table_edit";
6
- import { BackendService } from "../../service/rest_service";
7
- import { TableAction } from "../../model/appdata";
8
6
  import { MatButtonModule } from "@angular/material/button";
9
7
  import { MatCheckboxModule } from "@angular/material/checkbox";
10
8
  import { MatIconModule } from "@angular/material/icon";
11
9
 
12
10
  @Component({
13
- selector: 'table-list',
11
+ selector: 'sail-table-list',
14
12
  templateUrl: './table_list.html',
15
13
  changeDetection: ChangeDetectionStrategy.OnPush,
16
14
  encapsulation: ViewEncapsulation.None,
@@ -18,28 +16,34 @@ import { MatIconModule } from "@angular/material/icon";
18
16
  })
19
17
  export class TableList extends BaseView implements OnInit {
20
18
  override dialogWidth = '400px';
19
+ protected readonly tableNameInput = input('', { alias: 'tableName' });
20
+ protected readonly apiNameInput = input('', { alias: 'apiName' });
21
+ protected readonly dialogComponentInput = input<any>(undefined, { alias: 'dialogComponent' });
22
+
23
+ // linkedSignal: defaults to the consumer's input, but ngOnInit / route data
24
+ // can override with `.set()`. The `computation` preserves the last
25
+ // non-empty value when the input transiently becomes empty.
26
+ override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
27
+ override readonly apiName = linkedSignal<string, string>({ source: () => this.apiNameInput(), computation: (v, p) => v || p?.value || '' });
28
+ override readonly dialogComponent = linkedSignal<any, any>({ source: () => this.dialogComponentInput(), computation: (v, p) => v ?? p?.value });
21
29
 
22
30
  private readonly route = inject(ActivatedRoute);
23
- private readonly backendService = inject(BackendService);
24
31
  private readonly cdr = inject(ChangeDetectorRef);
25
32
  private readonly destroyRef = inject(DestroyRef);
26
33
 
27
34
  ngOnInit() {
28
35
  const data = this.route.snapshot.data;
29
- if (!this.tableName && data['tableName']) this.tableName = data['tableName'];
30
- if (!this.apiName && data['apiName']) this.apiName = data['apiName'];
31
- if (!this.dialogComponent) this.dialogComponent = data['dialogComponent'] ?? TableEdit;
36
+ if (!this.tableName() && data['tableName']) this.tableName.set(data['tableName']);
37
+ if (!this.apiName() && data['apiName']) this.apiName.set(data['apiName']);
38
+ if (!this.dialogComponent()) this.dialogComponent.set(data['dialogComponent'] ?? TableEdit);
32
39
  this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => this.handleQueryParams(params));
33
40
  }
34
41
 
35
42
  protected handleDelete(record: {[key: string]: any}): void {
36
- if (!this.canDelete()) {
37
- alert('Missing authorization to delete records');
38
- return;
39
- }
43
+ if (!this.requireAuth(() => this.canDelete(), 'delete')) return;
40
44
  if (confirm('Are you sure you want to delete this record?')) {
41
45
  const keyFilter = this.getKeyFilters(record);
42
- this.backendService.delete(this.apiName, keyFilter).subscribe({
46
+ this.backendService.delete(this.apiName(), keyFilter).subscribe({
43
47
  next: () => this.fetchRecords(),
44
48
  error: (err) => console.error('Delete failed', err),
45
49
  });
@@ -47,16 +51,9 @@ export class TableList extends BaseView implements OnInit {
47
51
  }
48
52
 
49
53
  protected handleUpdate(result: {[key: string]: any}, isNew: boolean, _original: {[key: string]: any}): void {
50
- if (isNew && !this.canCreate()) {
51
- alert('Missing authorization to create records');
52
- return;
53
- }
54
- if (!isNew && !this.canUpdate()) {
55
- alert('Missing authorization to update records');
56
- return;
57
- }
54
+ if (!this.requireAuth(() => isNew ? this.canCreate() : this.canUpdate(), isNew ? 'create' : 'update')) return;
58
55
  if (confirm('Are you sure you want to save this record?')) {
59
- this.backendService.post(this.apiName, result).subscribe({
56
+ this.backendService.post(this.apiName(), result).subscribe({
60
57
  next: () => this.fetchRecords(),
61
58
  error: (err) => console.error('Save failed', err),
62
59
  });
@@ -64,11 +61,8 @@ export class TableList extends BaseView implements OnInit {
64
61
  }
65
62
 
66
63
  fetchRecords() {
67
- if (!this.canRead()) {
68
- alert('Missing authorization to read records');
69
- return;
70
- }
71
- this.backendService.list<{[key: string]: any}>(this.apiName, this.searchTerms).subscribe({
64
+ if (!this.requireAuth(() => this.canRead(), 'read')) return;
65
+ this.backendService.list<{[key: string]: any}>(this.apiName(), this.searchTerms).subscribe({
72
66
  next: (records) => {
73
67
  this.records = records;
74
68
  this.updateDisplayedColumns(records);
@@ -78,27 +72,9 @@ export class TableList extends BaseView implements OnInit {
78
72
  });
79
73
  }
80
74
 
81
- /**
82
- * Fire a TableAction. For record-specific actions, `record` carries
83
- * the row's primary key columns (lifted via BaseTable.primaryKeyValues).
84
- * For table-level actions, `record` is undefined and the body is empty.
85
- * Refreshes the row list on success — most actions mutate visible
86
- * state (set_default flipping defaults, etc.).
87
- */
88
- executeAction(action: TableAction, record?: {[key: string]: unknown}): void {
89
- if (!this.canExecuteAction(action)) {
90
- alert(`Missing authorization for action ${action.authorityObject}/${action.authorityCheck}`);
91
- return;
92
- }
93
- if (action.confirmMessage && !confirm(action.confirmMessage)) return;
94
- const body = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
95
- this.backendService.executeAction(action.method, body).subscribe({
96
- next: () => this.fetchRecords(),
97
- error: (err) => {
98
- console.error(`Action ${action.action} failed`, err);
99
- alert(err?.error?.detail ?? `Failed to ${action.caption}`);
100
- },
101
- });
75
+ /** Refresh the row list on successful action — most actions mutate visible state (set_default flipping defaults, etc.). */
76
+ protected override onActionSuccess(): void {
77
+ this.fetchRecords();
102
78
  }
103
79
 
104
80
  handleQueryParams(params: Params) {
@@ -1,9 +1,9 @@
1
1
  <h2 mat-dialog-title>{{ getCaption() }} Search</h2>
2
2
  <div mat-dialog-content>
3
- <record-form [tableName]="tableName" [record]="searchRecord" [fields]="searchColumns" [isReadOnly]="isReadOnlyBound"></record-form>
3
+ <sail-record-form [tableName]="tableName()" [record]="searchRecord" [fields]="searchColumns" [isReadOnly]="isReadOnlyBound"></sail-record-form>
4
4
  <div class="search-actions">
5
- <button mat-raised-button class="primary" (click)="onSearch()">Search</button>
6
- <button mat-button (click)="onClear()">Clear</button>
5
+ <button matButton="elevated" class="primary" (click)="onSearch()">Search</button>
6
+ <button matButton (click)="onClear()">Clear</button>
7
7
  </div>
8
8
  @if (records.length > 0) {
9
9
  <table>
@@ -31,5 +31,5 @@
31
31
  }
32
32
  </div>
33
33
  <div mat-dialog-actions>
34
- <button mat-button [mat-dialog-close]>Cancel</button>
34
+ <button matButton [mat-dialog-close]>Cancel</button>
35
35
  </div>
@@ -4,10 +4,9 @@ import { MAT_DIALOG_DATA, MatDialogActions, MatDialogClose, MatDialogContent, Ma
4
4
  import { MatIconModule } from "@angular/material/icon";
5
5
  import { RecordForm } from "../form/form_record";
6
6
  import { BaseView } from "../abstract/base_view";
7
- import { BackendService } from "../../service/rest_service";
8
7
 
9
8
  @Component({
10
- selector: 'table-lookup',
9
+ selector: 'sail-table-lookup',
11
10
  templateUrl: './table_lookup.html',
12
11
  changeDetection: ChangeDetectionStrategy.OnPush,
13
12
  encapsulation: ViewEncapsulation.None,
@@ -22,19 +21,17 @@ import { BackendService } from "../../service/rest_service";
22
21
  ],
23
22
  })
24
23
  export class TableLookup extends BaseView implements OnInit {
25
- override apiName = '';
26
24
  searchRecord: any = {};
27
25
  searchColumns: string[] = [];
28
26
 
29
- private readonly backendService = inject(BackendService);
30
27
  private readonly dialogRef = inject(MatDialogRef<TableLookup>);
31
28
  private readonly data = inject<{tableName?: string; apiName?: string}>(MAT_DIALOG_DATA);
32
29
  protected readonly cdr = inject(ChangeDetectorRef);
33
30
 
34
31
  ngOnInit() {
35
32
  if (this.data) {
36
- this.tableName = this.data.tableName ?? '';
37
- this.apiName = this.data.apiName ?? '';
33
+ this.tableName.set(this.data.tableName ?? '');
34
+ this.apiName.set(this.data.apiName ?? '');
38
35
  }
39
36
  this.searchColumns = this.getDisplayedColumns();
40
37
  this.searchRecord = this.emptyRecord();
@@ -42,13 +39,14 @@ export class TableLookup extends BaseView implements OnInit {
42
39
  }
43
40
 
44
41
  private resolveApi() {
45
- if (!this.apiName && this.tableName) {
42
+ const tableName = this.tableName();
43
+ if (!this.apiName() && tableName) {
46
44
  this.cacheService.getAppData().subscribe((data) => {
47
45
  if (data.Apis) {
48
46
  for (const api in data.Apis) {
49
47
  const entry = data.Apis[api];
50
- if (entry.Table.TableName === this.tableName) {
51
- this.apiName = entry.Version ? entry.Version + '/' + api : api;
48
+ if (entry.Table.TableName === tableName) {
49
+ this.apiName.set(entry.Version ? entry.Version + '/' + api : api);
52
50
  break;
53
51
  }
54
52
  }
@@ -59,11 +57,12 @@ export class TableLookup extends BaseView implements OnInit {
59
57
 
60
58
  onSearch() {
61
59
  this.resolveApi();
62
- if (!this.apiName) {
63
- console.error('API name not found for table ' + this.tableName);
60
+ const apiName = this.apiName();
61
+ if (!apiName) {
62
+ console.error('API name not found for table ' + this.tableName());
64
63
  return;
65
64
  }
66
- this.backendService.list<{[key: string]: any}>(this.apiName, this.buildSearchTerms(this.searchRecord)).subscribe({
65
+ this.backendService.list<{[key: string]: any}>(apiName, this.buildSearchTerms(this.searchRecord)).subscribe({
67
66
  next: (records) => {
68
67
  this.records = records;
69
68
  this.updateDisplayedColumns(records);
@@ -27,7 +27,7 @@
27
27
  </mat-form-field>
28
28
  }
29
29
  }
30
- <button mat-raised-button color="primary" type="submit">Run</button>
30
+ <button matButton="elevated" class="primary" type="submit">Run</button>
31
31
  </form>
32
32
  }
33
33
 
@@ -1,5 +1,5 @@
1
1
  import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from "@angular/core";
2
- import { HttpClient, HttpParams } from "@angular/common/http";
2
+ import { HttpParams } from "@angular/common/http";
3
3
  import { FormsModule } from "@angular/forms";
4
4
  import { MatCheckboxModule } from "@angular/material/checkbox";
5
5
  import { MatFormFieldModule } from "@angular/material/form-field";
@@ -7,12 +7,13 @@ import { MatInputModule } from "@angular/material/input";
7
7
  import { MatSelectModule } from "@angular/material/select";
8
8
  import { MatButtonModule } from "@angular/material/button";
9
9
  import { BaseAuthService } from "../../service/auth.service";
10
- import { RestURL } from "../../service/rest_url";
10
+ import { BaseRestService } from "../../service/base_rest.service";
11
11
  import { ConstantValue } from "../../model/common";
12
12
  import { ReportParam } from "../../model/appdata";
13
+ import { titleCase } from "../../util/text";
13
14
 
14
15
  @Component({
15
- selector: 'table-report',
16
+ selector: 'sail-table-report',
16
17
  templateUrl: './table_report.html',
17
18
  changeDetection: ChangeDetectionStrategy.OnPush,
18
19
  imports: [
@@ -24,11 +25,10 @@ import { ReportParam } from "../../model/appdata";
24
25
  MatButtonModule,
25
26
  ],
26
27
  })
27
- export class TableReport {
28
+ export class TableReport extends BaseRestService {
28
29
  readonly apiName = input('');
29
30
  readonly tableName = input('');
30
31
 
31
- private readonly http = inject(HttpClient);
32
32
  private readonly auth = inject(BaseAuthService);
33
33
 
34
34
  readonly records = signal<Record<string, unknown>[]>([]);
@@ -49,7 +49,7 @@ export class TableReport {
49
49
  */
50
50
  readonly reportTitle = computed(() => {
51
51
  const reportId = this.apiName().split('/').pop() ?? '';
52
- return this.auth.getReport(reportId)?.Description || this.titleCase(this.tableName());
52
+ return this.auth.getReport(reportId)?.Description || titleCase(this.tableName());
53
53
  });
54
54
 
55
55
  /**
@@ -65,6 +65,7 @@ export class TableReport {
65
65
  });
66
66
 
67
67
  constructor() {
68
+ super();
68
69
  effect(() => {
69
70
  const api = this.apiName();
70
71
  if (!api) return;
@@ -133,8 +134,7 @@ export class TableReport {
133
134
  httpParams = httpParams.set(k, v);
134
135
  }
135
136
  }
136
- const url = RestURL.httpHost + '/api/' + api;
137
- this.http.get<Record<string, unknown>[]>(url, { params: httpParams }).subscribe({
137
+ this.http.get<Record<string, unknown>[]>(this.url('/api/' + api), { params: httpParams }).subscribe({
138
138
  next: (rows) => {
139
139
  this.records.set(rows ?? []);
140
140
  if (rows?.length) {
@@ -145,14 +145,11 @@ export class TableReport {
145
145
  this.errorMessage.set('');
146
146
  },
147
147
  error: (err) => {
148
- this.errorMessage.set(err?.error ?? 'Failed to load report data.');
148
+ this.errorMessage.set(err?.error?.detail ?? err?.error?.message ?? 'Failed to load report data.');
149
149
  },
150
150
  });
151
151
  }
152
152
 
153
- titleCase(str: string): string {
154
- return str.replaceAll('_', ' ').toLowerCase().split(' ')
155
- .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
156
- .join(' ');
157
- }
153
+ /** Re-exported for templates; same as the shared util. */
154
+ titleCase(str: string): string { return titleCase(str); }
158
155
  }