@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,59 +1,69 @@
1
- import { inject } from "@angular/core";
1
+ import { inject, signal, WritableSignal } from "@angular/core";
2
2
  import { ForeignKey, TableAction, TableColumn, TableDefinition } from "../../model/appdata";
3
- import { ConstantValue } from "../../model/common";
3
+ import { ConstantValue, LookupStyle, OpCode } from "../../model/common";
4
4
  import { BaseAuthService } from "../../service/auth.service";
5
+ import { BackendService } from "../../service/rest_service";
5
6
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
7
+ import { titleCase } from "../../util/text";
8
+
9
+
10
+ /** CRUD verb used by `requireAuth()` to compose the "Missing authorization" alert. */
11
+ export type AuthVerb = 'create' | 'read' | 'update' | 'delete' | 'view';
6
12
 
7
13
 
8
14
  export abstract class BaseTable {
9
15
  protected readonly cacheService = inject(BaseAuthService);
16
+ protected readonly backendService = inject(BackendService);
10
17
  protected readonly config: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
11
18
  protected readonly dialogWidth: string = '400px';
12
- tableName = '';
19
+ readonly tableName: WritableSignal<string> = signal('');
20
+ /** Optional consumer-set override; when empty the title-cased table name is used. */
13
21
  caption = '';
22
+ /** Consumer-set per-field option overrides (per fieldName). When unset, options come from the column's LookupDomain / LookupTable. */
14
23
  fieldOptions: {[key: string]: ConstantValue[]} = {};
15
- fieldLabels: {[key: string]: string} = {};
16
24
  isReadOnlyBound = this.isReadOnly.bind(this);
17
25
 
18
26
  isReadOnly(_fieldName: string): boolean {
19
27
  return false;
20
28
  }
21
29
 
22
- titleCase(str: string): string {
23
- return str.replaceAll('_', ' ').toLowerCase().split(' ').map((word: string) => {
24
- return word.charAt(0).toUpperCase() + word.slice(1);
25
- }).join(' ');
26
- }
30
+ /** Re-exported on the class so templates can call `titleCase(x)` without an extra import in the consumer. */
31
+ titleCase(str: string): string { return titleCase(str); }
27
32
 
28
- getCaption() {
29
- if (!this.caption) {
30
- this.caption = this.titleCase(this.tableName);
31
- }
32
- return this.caption;
33
+ /** Display title for the active table; recomputes when `tableName` changes. */
34
+ getCaption(): string {
35
+ return this.caption || titleCase(this.tableName());
33
36
  }
34
37
 
38
+ /** Display label for a column; uses the metadata Caption, otherwise title-cases the field name. */
35
39
  colCaption(fieldName: string): string {
36
- if (!this.fieldLabels[fieldName]) {
37
- const col = this.getColumn(fieldName);
38
- this.fieldLabels[fieldName] = col?.Caption || this.titleCase(fieldName);
39
- }
40
- return this.fieldLabels[fieldName];
41
- }
42
-
43
- canRead() {
44
- return this.cacheService.canRead(this.tableName);
40
+ const col = this.getColumn(fieldName);
41
+ return col?.Caption || titleCase(fieldName);
45
42
  }
46
43
 
47
- canCreate() {
48
- return this.cacheService.canCreate(this.tableName);
49
- }
44
+ canRead() { return this.cacheService.canRead(this.tableName()); }
45
+ canCreate() { return this.cacheService.canCreate(this.tableName()); }
46
+ canUpdate() { return this.cacheService.canUpdate(this.tableName()); }
47
+ canDelete() { return this.cacheService.canDelete(this.tableName()); }
50
48
 
51
- canUpdate() {
52
- return this.cacheService.canUpdate(this.tableName);
53
- }
49
+ /** True when the record is a locally-inserted row not yet persisted (op_code === 'I'). */
50
+ isInserted(record: Record<string, unknown>): boolean { return record[this.config.opField] === OpCode.Insert; }
51
+ /** True when the record is marked for update (op_code === 'U'). */
52
+ isUpdated(record: Record<string, unknown>): boolean { return record[this.config.opField] === OpCode.Update; }
53
+ /** True when the record is marked for delete (op_code === 'D'). */
54
+ isDeleted(record: Record<string, unknown>): boolean { return record[this.config.opField] === OpCode.Delete; }
54
55
 
55
- canDelete() {
56
- return this.cacheService.canDelete(this.tableName);
56
+ /**
57
+ * Authorization guard with shared "Missing authorization" copy. Pass the
58
+ * permission predicate (typically a `canX()` method on this class) and
59
+ * the verb that fills the alert. Returns true when allowed.
60
+ *
61
+ * if (!this.requireAuth(() => this.canDelete(), 'delete')) return;
62
+ */
63
+ protected requireAuth(check: () => boolean, verb: AuthVerb): boolean {
64
+ if (check()) return true;
65
+ alert(`Missing authorization to ${verb} records`);
66
+ return false;
57
67
  }
58
68
 
59
69
  /**
@@ -62,7 +72,7 @@ export abstract class BaseTable {
62
72
  * when the table has no custom actions.
63
73
  */
64
74
  getActions(recordSpecific?: boolean): TableAction[] {
65
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
75
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
66
76
  const actions = tableDef?.Actions ?? [];
67
77
  if (recordSpecific === undefined) return [...actions].sort(this.sortActions);
68
78
  return actions.filter((a) => a.recordSpecific === recordSpecific).sort(this.sortActions);
@@ -78,7 +88,56 @@ export abstract class BaseTable {
78
88
  * action button should render.
79
89
  */
80
90
  canExecuteAction(action: TableAction): boolean {
81
- return this.cacheService.canExecute(action.authorityObject, action.authorityCheck, this.tableName);
91
+ return this.cacheService.canExecute(action.authorityObject, action.authorityCheck, this.tableName());
92
+ }
93
+
94
+ /**
95
+ * Fire a TableAction. Template method:
96
+ *
97
+ * 1. Authorization check + alert (shared)
98
+ * 2. Subclass-specific pre-flight guard via `beforeExecuteAction()` —
99
+ * returns false to abort silently (the hook has already shown its
100
+ * own alert).
101
+ * 3. Optional confirm prompt from `action.confirmMessage`
102
+ * 4. POST `{}` for table-level actions or `primaryKeyValues(record)`
103
+ * for record-specific actions
104
+ * 5. On success, dispatch `onActionSuccess()` so subclasses can refresh
105
+ * their views; on error, log and alert.
106
+ *
107
+ * Subclasses typically only override the two hooks.
108
+ */
109
+ executeAction(action: TableAction, record?: Record<string, unknown>): void {
110
+ if (!this.canExecuteAction(action)) {
111
+ alert(`Missing authorization for action ${action.authorityObject}/${action.authorityCheck}`);
112
+ return;
113
+ }
114
+ if (!this.beforeExecuteAction(action, record)) return;
115
+ if (action.confirmMessage && !confirm(action.confirmMessage)) return;
116
+ const body = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
117
+ this.backendService.executeAction(action.method, body).subscribe({
118
+ next: () => this.onActionSuccess(action, record),
119
+ error: (err) => {
120
+ console.error(`Action ${action.action} failed`, err);
121
+ alert(err?.error?.detail ?? `Failed to ${action.caption}`);
122
+ },
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Pre-flight hook for `executeAction()`. Return false to abort the action
128
+ * (the override should `alert()` or otherwise surface why). Default: allow.
129
+ */
130
+ protected beforeExecuteAction(_action: TableAction, _record?: Record<string, unknown>): boolean {
131
+ return true;
132
+ }
133
+
134
+ /**
135
+ * Post-success hook for `executeAction()`. Override to refresh the screen
136
+ * — e.g. `TableList` re-fetches the row list. Default: no-op (screens
137
+ * driven by parent state, like `TableDetail`, leave refresh to the parent).
138
+ */
139
+ protected onActionSuccess(_action: TableAction, _record?: Record<string, unknown>): void {
140
+ // intentionally empty
82
141
  }
83
142
 
84
143
  /**
@@ -89,7 +148,7 @@ export abstract class BaseTable {
89
148
  * does for get / delete.
90
149
  */
91
150
  primaryKeyValues(record: Record<string, unknown>): Record<string, unknown> {
92
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
151
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
93
152
  const pk: Record<string, unknown> = {};
94
153
  if (!tableDef?.Keys) return pk;
95
154
  for (const key of tableDef.Keys) {
@@ -99,9 +158,9 @@ export abstract class BaseTable {
99
158
  }
100
159
 
101
160
  emptyRecord(): any {
102
- const record : any = {[this.config.opField]: 'I'}
103
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
104
- const tableOverride = this.config.tableOverrides?.[this.tableName];
161
+ const record : any = {[this.config.opField]: OpCode.Insert}
162
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
163
+ const tableOverride = this.config.tableOverrides?.[this.tableName()];
105
164
  const editableTimestamps = new Set(tableOverride?.editableTimestamps ?? []);
106
165
 
107
166
  if (tableDef) {
@@ -141,8 +200,8 @@ export abstract class BaseTable {
141
200
  }
142
201
 
143
202
  getDisplayedColumns(records: any[] = []): string[] {
144
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
145
- const tableOverride = this.config.tableOverrides?.[this.tableName];
203
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
204
+ const tableOverride = this.config.tableOverrides?.[this.tableName()];
146
205
  const hidden = new Set([
147
206
  ...this.config.hiddenFields,
148
207
  ...(tableOverride?.hiddenFields ?? []),
@@ -162,7 +221,7 @@ export abstract class BaseTable {
162
221
  }
163
222
 
164
223
  getColumn(fieldName: string): TableColumn | undefined {
165
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
224
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
166
225
  if (tableDef && tableDef.Columns) {
167
226
  return tableDef.Columns.find((field) => field.PascalName === fieldName);
168
227
  }
@@ -178,7 +237,7 @@ export abstract class BaseTable {
178
237
  const match = options?.find(o => o.Value === String(rawValue));
179
238
  if (match) return match.Caption ?? String(rawValue);
180
239
  }
181
- if (col.LookupTable && col.LookupStyle === 'D') {
240
+ if (col.LookupTable && col.LookupStyle === LookupStyle.Dropdown) {
182
241
  const options = this.cacheService.getTableValues(col.LookupTable);
183
242
  const match = options?.find(o => o.Value === String(rawValue));
184
243
  if (match) return match.Caption ?? String(rawValue);
@@ -261,7 +320,7 @@ export abstract class BaseTable {
261
320
  readyToSave(record: any, original?: any) {
262
321
  this.restoreRecordTimeStamp(record);
263
322
  const op = record[this.config.opField];
264
- if (op === 'I' || op === 'D') return; // already marked
323
+ if (op === OpCode.Insert || op === OpCode.Delete) return; // already marked
265
324
  // Original snapshot may carry display-formatted timestamps; restore both
266
325
  // sides to the same wire format before comparing, otherwise scalar
267
326
  // fields like Passdate look "modified" purely due to formatting.
@@ -269,11 +328,11 @@ export abstract class BaseTable {
269
328
  const normalized = { ...original };
270
329
  this.restoreRecordTimeStamp(normalized);
271
330
  if (!this.isRecordDirty(record, normalized)) {
272
- record[this.config.opField] = 'R';
331
+ record[this.config.opField] = OpCode.Readonly;
273
332
  return;
274
333
  }
275
334
  }
276
- record[this.config.opField] = 'U';
335
+ record[this.config.opField] = OpCode.Update;
277
336
  }
278
337
 
279
338
  /** Shallow compare of own scalar fields, ignoring child arrays and op_code. */
@@ -290,11 +349,11 @@ export abstract class BaseTable {
290
349
  }
291
350
 
292
351
  getDialogData(record: any, isNew: boolean, readOnlyColumns: string[] = []): any {
293
- return {record, tableName: this.tableName, isNew, readOnlyColumns};
352
+ return {record, tableName: this.tableName(), isNew, readOnlyColumns};
294
353
  }
295
354
 
296
355
  getTableName(fieldName: string): string {
297
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
356
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
298
357
  if (!tableDef) return '';
299
358
  const fk = tableDef.Children?.find((c) => c.PascalName === fieldName);
300
359
  return fk ? fk.ChildTable : '';
@@ -308,14 +367,14 @@ export abstract class BaseTable {
308
367
  if (col && col.LookupDomain) {
309
368
  return this.cacheService.getDomainValues(col.LookupDomain);
310
369
  }
311
- if (col && col.LookupTable && col.LookupStyle === 'D') {
370
+ if (col && col.LookupTable && col.LookupStyle === LookupStyle.Dropdown) {
312
371
  return this.cacheService.getTableValues(col.LookupTable);
313
372
  }
314
373
  return undefined;
315
374
  }
316
375
 
317
376
  getKeyFilters(record: any): {[key: string]: string} | undefined {
318
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
377
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
319
378
  if (!tableDef || !tableDef.Keys || tableDef.Keys.length === 0) {
320
379
  return undefined;
321
380
  }
@@ -328,7 +387,7 @@ export abstract class BaseTable {
328
387
 
329
388
  getForeignKeyConfig(parentTable: string): {fk: ForeignKey, parent: TableDefinition} | null{
330
389
  if (!parentTable) return null;
331
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
390
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
332
391
  const parent = this.cacheService.getTableDefinition(parentTable);
333
392
  if (!tableDef || !tableDef.Keys || !tableDef.Parents || !parent) return null;
334
393
  const fk = tableDef.Parents.find((parent) => parent.ParentTable === parentTable);
@@ -1,13 +1,12 @@
1
- import { Directive, inject, Input } from "@angular/core";
1
+ import { Directive, inject, signal, WritableSignal } from "@angular/core";
2
2
  import { BaseTable } from "./base_table";
3
3
  import { MatDialog } from "@angular/material/dialog";
4
4
 
5
5
  @Directive()
6
6
  export abstract class BaseView extends BaseTable {
7
7
  protected readonly dialog = inject(MatDialog);
8
- @Input() override tableName = '';
9
- @Input() apiName = '';
10
- @Input() dialogComponent: any;
8
+ readonly apiName: WritableSignal<string> = signal('');
9
+ readonly dialogComponent: WritableSignal<any> = signal(undefined);
11
10
  records: Array<{[key: string]: any}> = [];
12
11
  displayedColumns: string[] = [];
13
12
  searchTerms: {[key: string]: string} = {};
@@ -31,70 +30,58 @@ export abstract class BaseView extends BaseTable {
31
30
  }
32
31
 
33
32
  trackByRecord(index: number, record: {[key: string]: any}): any {
34
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
33
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
35
34
  if (!tableDef || !tableDef.Keys || tableDef.Keys.length === 0) return index;
36
35
  return tableDef.Keys.map((key) => record[key.PascalName]).join('_');
37
36
  }
38
37
 
39
- addRecord() {
40
- if (!this.canCreate()) {
41
- alert('Missing authorization to create records');
42
- return;
43
- }
44
- const record = this.emptyRecord();
45
-
46
- if (Object.keys(record).length === 1 && this.records.length > 0) {
47
- Object.keys(this.records[0]).forEach((key) => {
48
- record[key] = '';
49
- });
50
- }
38
+ /**
39
+ * Shared row-dialog opener. Builds the dialog data and wires the result
40
+ * straight into `handleUpdate()` (subclass-specific). Used by both
41
+ * `addRecord()` (new row + empty `original`) and `editRecord()` (existing
42
+ * row passed through verbatim).
43
+ */
44
+ private openRecordDialog(record: {[key: string]: any}, isNew: boolean): void {
45
+ const dialogData = this.getDialogData(record, isNew);
46
+ const apiName = this.apiName();
47
+ if (apiName) dialogData['apiName'] = apiName;
51
48
 
52
- const dialogRef = this.dialog.open(this.dialogComponent, {
49
+ this.dialog.open(this.dialogComponent(), {
53
50
  width: '90vw',
54
51
  maxWidth: '1200px',
55
52
  maxHeight: '90vh',
56
53
  disableClose: true,
57
- data: this.getDialogData(record, true),
54
+ data: dialogData,
55
+ }).afterClosed().subscribe((result) => {
56
+ if (result) this.handleUpdate(result, isNew, isNew ? {} : record);
58
57
  });
58
+ }
59
59
 
60
- dialogRef.afterClosed().subscribe((result) => {
61
- if (result) this.handleUpdate(result, true, {});
62
- });
60
+ addRecord() {
61
+ if (!this.requireAuth(() => this.canCreate(), 'create')) return;
62
+ const record = this.emptyRecord();
63
+ // Seed columns from an existing row when emptyRecord() returned just the
64
+ // op-code field (table metadata wasn't loaded yet).
65
+ if (Object.keys(record).length === 1 && this.records.length > 0) {
66
+ Object.keys(this.records[0]).forEach((key) => { record[key] = ''; });
67
+ }
68
+ this.openRecordDialog(record, true);
63
69
  }
64
70
 
65
71
  /**
66
72
  * Open the row dialog. Renamed conceptually from "edit": when the user
67
- * has only `canRead()` , we still want to show them the record + child rows.
73
+ * has only `canRead()`, we still want to show them the record + child rows.
68
74
  * The dialog itself (TableEdit) handles read-only mode — its Edit/New/Delete
69
75
  * buttons are gated by the same permissions.
70
76
  * Block only when neither read nor update is granted.
71
77
  */
72
78
  editRecord(record: {[key: string]: any}) {
73
- if (!this.canUpdate() && !this.canRead()) {
74
- alert('Missing authorization to view records');
75
- return;
76
- }
77
- const dialogData = this.getDialogData(record, false);
78
- if (this.apiName) {
79
- dialogData['apiName'] = this.apiName;
80
- }
81
- const dialogRef = this.dialog.open(this.dialogComponent, {
82
- width: '90vw',
83
- maxWidth: '1200px',
84
- maxHeight: '90vh',
85
- disableClose: true,
86
- data: dialogData,
87
- });
88
- dialogRef.afterClosed().subscribe((result) => {
89
- if (result) this.handleUpdate(result, false, record);
90
- });
79
+ if (!this.canUpdate() && !this.requireAuth(() => this.canRead(), 'view')) return;
80
+ this.openRecordDialog(record, false);
91
81
  }
92
82
 
93
83
  deleteRecord(record: {[key: string]: any}) {
94
- if (!this.canDelete()) {
95
- alert('Missing authorization to delete records');
96
- return;
97
- }
84
+ if (!this.requireAuth(() => this.canDelete(), 'delete')) return;
98
85
  this.handleDelete(record);
99
86
  }
100
87
  }
@@ -1,5 +1,5 @@
1
1
  <div class="checkout-button">
2
- <button mat-raised-button class="primary" type="button"
2
+ <button matButton="elevated" class="primary" type="button"
3
3
  [disabled]="loading()"
4
4
  (click)="startCheckout()">
5
5
  @if (loading()) { Starting checkout… } @else { {{ label() }} }
@@ -13,7 +13,7 @@ import { CheckoutMode } from '../../model/appdata';
13
13
  * rejected with 400.
14
14
  */
15
15
  @Component({
16
- selector: 'app-checkout-button',
16
+ selector: 'sail-checkout-button',
17
17
  templateUrl: './checkout_button.html',
18
18
  changeDetection: ChangeDetectionStrategy.OnPush,
19
19
  encapsulation: ViewEncapsulation.None,
@@ -11,7 +11,7 @@ import { PaymentMethod } from '../../model/appdata';
11
11
  * `[portalUrl]` to show it; otherwise the button is hidden.
12
12
  */
13
13
  @Component({
14
- selector: 'app-payment-methods',
14
+ selector: 'sail-payment-methods',
15
15
  templateUrl: './payment_methods.html',
16
16
  changeDetection: ChangeDetectionStrategy.OnPush,
17
17
  encapsulation: ViewEncapsulation.None,
@@ -13,7 +13,7 @@
13
13
  }
14
14
  </ul>
15
15
  }
16
- <button mat-raised-button class="primary" type="button" (click)="onSelect(plan.id)">
16
+ <button matButton="elevated" class="primary" type="button" (click)="onSelect(plan.id)">
17
17
  @if (plan.id === selected()) { Selected } @else { Choose }
18
18
  </button>
19
19
  </div>
@@ -7,7 +7,7 @@ import { PublicPlan } from '../../model/appdata';
7
7
  * The caller provides the plans and handles the selection event.
8
8
  */
9
9
  @Component({
10
- selector: 'app-plan-selector',
10
+ selector: 'sail-plan-selector',
11
11
  templateUrl: './plan_selector.html',
12
12
  changeDetection: ChangeDetectionStrategy.OnPush,
13
13
  encapsulation: ViewEncapsulation.None,
@@ -2,7 +2,7 @@
2
2
  <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
3
3
  <mat-label>{{ colCaption(field()) }}</mat-label>
4
4
  <input matInput [value]="readonlyField() ? displayCaption : value()" [required]="required" [readonly]="readonlyField()">
5
- <button mat-icon-button matSuffix (click)="openLookup()" [disabled]="readonlyField()">
5
+ <button matIconButton matSuffix (click)="openLookup()" [disabled]="readonlyField()">
6
6
  <mat-icon>search</mat-icon>
7
7
  </button>
8
8
  </mat-form-field>
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, inject, Input, input, output, ViewEncapsulation } from "@angular/core";
1
+ import { ChangeDetectionStrategy, Component, inject, input, linkedSignal, output, ViewEncapsulation } from "@angular/core";
2
2
  import { MatButtonModule } from "@angular/material/button";
3
3
  import { MatCheckboxModule } from "@angular/material/checkbox";
4
4
  import { MatDialog } from "@angular/material/dialog";
@@ -7,11 +7,11 @@ import { MatIconModule } from "@angular/material/icon";
7
7
  import { MatInputModule } from "@angular/material/input";
8
8
  import { MatSelectModule } from "@angular/material/select";
9
9
  import { BaseTable } from "../abstract/base_table";
10
- import { ConstantValue } from "../../model/common";
10
+ import { ConstantValue, LookupStyle } from "../../model/common";
11
11
  import { TableLookup } from "../table/table_lookup";
12
12
 
13
13
  @Component({
14
- selector: 'dynamic-field',
14
+ selector: 'sail-dynamic-field',
15
15
  templateUrl: './form_field.html',
16
16
  changeDetection: ChangeDetectionStrategy.OnPush,
17
17
  encapsulation: ViewEncapsulation.None,
@@ -31,8 +31,7 @@ import { TableLookup } from "../table/table_lookup";
31
31
  ]
32
32
  })
33
33
  export class DynamicField extends BaseTable {
34
- // @Input() required — signal input not possible due to BaseTable inheritance chain
35
- @Input() override tableName = '';
34
+ protected readonly tableNameInput = input('', { alias: 'tableName' });
36
35
  readonly value = input<any>(undefined);
37
36
  readonly valueChange = output<any>();
38
37
  readonly recordUpdate = output<{[key: string]: any}>();
@@ -42,6 +41,8 @@ export class DynamicField extends BaseTable {
42
41
  readonly subscriptSizing = input<'fixed' | 'dynamic'>('dynamic');
43
42
  readonly appearance = input<'fill' | 'outline'>('outline');
44
43
 
44
+ override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
45
+
45
46
  private readonly dialog = inject(MatDialog);
46
47
 
47
48
  get col() {
@@ -49,7 +50,7 @@ export class DynamicField extends BaseTable {
49
50
  }
50
51
 
51
52
  get inputType() {
52
- if (!this.col || !this.col.InputType || this.col.LookupStyle === 'S') return 'text';
53
+ if (!this.col || !this.col.InputType || this.col.LookupStyle === LookupStyle.Search) return 'text';
53
54
  return this.col.InputType;
54
55
  }
55
56
 
@@ -86,7 +87,7 @@ export class DynamicField extends BaseTable {
86
87
  }
87
88
 
88
89
  isLookupTableSearch(): boolean {
89
- return !!(this.col && this.col.LookupTable && this.col.LookupStyle === 'S');
90
+ return !!(this.col && this.col.LookupTable && this.col.LookupStyle === LookupStyle.Search);
90
91
  }
91
92
 
92
93
  get maxLength() {
@@ -1,14 +1,14 @@
1
1
  <div class="form-container">
2
2
  @for (key of computedFields(); track key) {
3
3
  @if (!isArray(record()[key])) {
4
- <dynamic-field
4
+ <sail-dynamic-field
5
5
  [tableName]="tableName()"
6
6
  [value]="record()[key]"
7
7
  (valueChange)="record()[key]=$event"
8
8
  (recordUpdate)="onRecordUpdate($event)"
9
9
  [field]="key"
10
10
  [readonly]="isReadOnly()(key)">
11
- </dynamic-field>
11
+ </sail-dynamic-field>
12
12
  }
13
13
  }
14
14
  </div>
@@ -4,7 +4,7 @@ import { BaseAuthService } from "../../service/auth.service";
4
4
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
5
5
 
6
6
  @Component({
7
- selector: 'record-form',
7
+ selector: 'sail-record-form',
8
8
  templateUrl: './form_record.html',
9
9
  changeDetection: ChangeDetectionStrategy.OnPush,
10
10
  encapsulation: ViewEncapsulation.None,
@@ -6,14 +6,14 @@
6
6
  {{ getCaption() }} Details
7
7
  }
8
8
  </span>
9
- <button mat-icon-button type="button" (click)="onCancel()" aria-label="Close dialog" class="table-form-close">
9
+ <button matIconButton type="button" (click)="onCancel()" aria-label="Close dialog" class="table-form-close">
10
10
  <mat-icon>close</mat-icon>
11
11
  </button>
12
12
  </h1>
13
13
  <div mat-dialog-content>
14
- <record-form [tableName]="tableName" [record]="editableRecord" [isReadOnly]="isReadOnlyBound"></record-form>
14
+ <sail-record-form [tableName]="tableName()" [record]="editableRecord" [isReadOnly]="isReadOnlyBound"></sail-record-form>
15
15
  </div>
16
16
  <div mat-dialog-actions align="end">
17
- <button mat-raised-button class="primary" type="submit" (click)="onSave()">Save</button>
18
- <button mat-button type="button" (click)="onCancel()">Cancel</button>
17
+ <button matButton="elevated" class="primary" type="submit" (click)="onSave()">Save</button>
18
+ <button matButton type="button" (click)="onCancel()">Cancel</button>
19
19
  </div>
@@ -14,7 +14,7 @@ interface TableFormDialogData {
14
14
  }
15
15
 
16
16
  @Component({
17
- selector: 'table-form',
17
+ selector: 'sail-table-form',
18
18
  changeDetection: ChangeDetectionStrategy.OnPush,
19
19
  encapsulation: ViewEncapsulation.None,
20
20
  templateUrl: './form_table.html',
@@ -36,7 +36,7 @@ export class TableForm extends BaseForm {
36
36
  constructor() {
37
37
  super();
38
38
  this.editableRecord = {...this.data.record};
39
- this.tableName = this.data.tableName;
39
+ this.tableName.set(this.data.tableName);
40
40
  this.isNew = !!this.data.isNew;
41
41
  this.readOnlyColumns = this.data.readOnlyColumns ?? [];
42
42
  }
@@ -23,7 +23,7 @@
23
23
  <textarea matInput rows="3" formControlName="reason"></textarea>
24
24
  </mat-form-field>
25
25
 
26
- <button mat-raised-button class="warn" type="submit" [disabled]="!canSubmit()">
26
+ <button matButton="elevated" class="warn" type="submit" [disabled]="!canSubmit()">
27
27
  @if (loading()) { Deleting… } @else { Permanently delete account }
28
28
  </button>
29
29
 
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ChangeDetectionStrategy, Component, ViewEncapsulation, computed, inject, input, signal,
3
3
  } from '@angular/core';
4
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
4
5
  import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
5
6
  import { MatButtonModule } from '@angular/material/button';
6
7
  import { MatFormFieldModule } from '@angular/material/form-field';
@@ -23,7 +24,7 @@ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
23
24
  * `/login/local`).
24
25
  */
25
26
  @Component({
26
- selector: 'app-account-deletion',
27
+ selector: 'sail-account-deletion',
27
28
  templateUrl: './account_deletion.html',
28
29
  changeDetection: ChangeDetectionStrategy.OnPush,
29
30
  encapsulation: ViewEncapsulation.None,
@@ -59,7 +60,9 @@ export class AccountDeletionComponent extends BaseAsync {
59
60
 
60
61
  constructor() {
61
62
  super();
62
- this.form.controls.confirm.valueChanges.subscribe((v) => this.typedValue.set(v ?? ''));
63
+ this.form.controls.confirm.valueChanges
64
+ .pipe(takeUntilDestroyed())
65
+ .subscribe((v) => this.typedValue.set(v ?? ''));
63
66
  }
64
67
 
65
68
  onDelete() {
@@ -43,7 +43,7 @@
43
43
  <div class="auth-success" role="status">{{ successMessage() }}</div>
44
44
  }
45
45
 
46
- <button mat-raised-button class="primary" type="submit" [disabled]="!chpassForm.valid">
46
+ <button matButton="elevated" class="primary" type="submit" [disabled]="!chpassForm.valid">
47
47
  @if (forgotPassword()) {
48
48
  Send Confirmation Code
49
49
  } @else {