@nauticana/sail 0.8.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 (47) hide show
  1. package/README.md +120 -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 +94 -35
  5. package/src/component/abstract/base_view.ts +28 -41
  6. package/src/component/billing/checkout_button.ts +1 -1
  7. package/src/component/billing/payment_methods.ts +1 -1
  8. package/src/component/billing/plan_selector.ts +1 -1
  9. package/src/component/form/form_field.ts +8 -11
  10. package/src/component/form/form_record.html +2 -2
  11. package/src/component/form/form_record.ts +1 -1
  12. package/src/component/form/form_table.html +1 -1
  13. package/src/component/form/form_table.ts +1 -1
  14. package/src/component/login/account_deletion.ts +1 -1
  15. package/src/component/login/chpass_component.ts +11 -16
  16. package/src/component/login/confirm_chpass_component.ts +9 -15
  17. package/src/component/login/confirm_register_component.ts +9 -15
  18. package/src/component/login/consent_gate.ts +1 -1
  19. package/src/component/login/login_component.html +1 -1
  20. package/src/component/login/login_component.ts +1 -1
  21. package/src/component/login/otp_input.ts +1 -1
  22. package/src/component/login/register_component.ts +1 -1
  23. package/src/component/login/social_login.ts +1 -1
  24. package/src/component/navigation/navigation.ts +10 -11
  25. package/src/component/security/trusted_devices.ts +1 -1
  26. package/src/component/security/twofactor_setup.ts +18 -26
  27. package/src/component/security/twofactor_verify.ts +10 -12
  28. package/src/component/table/table_detail.html +4 -4
  29. package/src/component/table/table_detail.ts +22 -34
  30. package/src/component/table/table_edit.html +13 -3
  31. package/src/component/table/table_edit.ts +17 -17
  32. package/src/component/table/table_list.ts +18 -52
  33. package/src/component/table/table_lookup.html +1 -1
  34. package/src/component/table/table_lookup.ts +1 -3
  35. package/src/component/table/table_report.ts +11 -14
  36. package/src/component/table/table_search.html +1 -1
  37. package/src/component/table/table_search.ts +14 -38
  38. package/src/index.ts +2 -0
  39. package/src/model/appdata.ts +2 -2
  40. package/src/model/common.ts +39 -1
  41. package/src/service/auth.service.ts +26 -26
  42. package/src/service/base_rest.service.ts +20 -0
  43. package/src/service/billing.service.ts +3 -9
  44. package/src/service/payout.service.ts +3 -9
  45. package/src/service/rest_service.ts +5 -5
  46. package/src/service/user_payment_method.service.ts +4 -8
  47. package/src/util/text.ts +19 -0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
4
4
 
5
- > **Compatibility:** sail and keel are versioned in lock-step. Use **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400.
5
+ > **Compatibility:** sail and keel are versioned in lock-step. Use **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
6
6
 
7
7
  ## What it provides
8
8
 
@@ -143,7 +143,7 @@ import { Navigation } from '@nauticana/sail';
143
143
  selector: 'app-root',
144
144
  changeDetection: ChangeDetectionStrategy.OnPush,
145
145
  imports: [Navigation],
146
- template: `<app-navigation><span toolbar-title>My App</span></app-navigation>`,
146
+ template: `<sail-navigation><span toolbar-title>My App</span></sail-navigation>`,
147
147
  })
148
148
  export class App {}
149
149
  ```
@@ -328,23 +328,23 @@ export class PricingPage {
328
328
 
329
329
  **Plan picker** — pure presentational, no API calls:
330
330
  ```html
331
- <app-plan-selector
331
+ <sail-plan-selector
332
332
  [plans]="plans()"
333
333
  [selected]="selectedPlan()"
334
334
  [features]="{ PRO: ['Unlimited users', '24/7 support'] }"
335
335
  (selectionChange)="selectedPlan.set($event)">
336
- </app-plan-selector>
336
+ </sail-plan-selector>
337
337
  ```
338
338
 
339
339
  **Checkout button** — calls `createCheckout()` and redirects to the provider-hosted URL. `PublicPlan.priceId` lets you wire the picker directly to checkout without a local mapping table:
340
340
  ```html
341
- <app-checkout-button
341
+ <sail-checkout-button
342
342
  [priceId]="selectedPlan().priceId!"
343
343
  [mode]="'subscription'"
344
344
  [successUrl]="'https://app.example.com/billing/done'"
345
345
  [cancelUrl]="'https://app.example.com/billing'"
346
346
  [email]="userEmail">
347
- </app-checkout-button>
347
+ </sail-checkout-button>
348
348
  ```
349
349
 
350
350
  For one-off charges use `[mode]="'payment'"`. For "save a card without charging" (Stripe SetupIntent), use `[mode]="'setup'"` and **omit** `[priceId]` — keel rejects a non-empty `priceId` in setup mode with 400.
@@ -364,7 +364,7 @@ metadata: {
364
364
 
365
365
  **Payment methods** — lists saved methods with loading and empty states:
366
366
  ```html
367
- <app-payment-methods></app-payment-methods>
367
+ <sail-payment-methods></sail-payment-methods>
368
368
  ```
369
369
 
370
370
  ### Registration → checkout flow
@@ -430,12 +430,12 @@ import { ConsentGateComponent, ConsentOption, ConsentState, ConsentType } from '
430
430
  @Component({
431
431
  imports: [ConsentGateComponent],
432
432
  template: `
433
- <app-consent-gate
433
+ <sail-consent-gate
434
434
  [policyVersion]="'v1'"
435
435
  [policyLanguage]="'en'"
436
436
  [optionalConsents]="extras"
437
437
  (consentStateChange)="onConsent($event)">
438
- </app-consent-gate>
438
+ </sail-consent-gate>
439
439
  `,
440
440
  })
441
441
  export class SignupPage {
@@ -468,12 +468,12 @@ import { BaseAuthService, OtpInputComponent } from '@nauticana/sail';
468
468
  @Component({
469
469
  imports: [OtpInputComponent],
470
470
  template: `
471
- <app-otp-input
471
+ <sail-otp-input
472
472
  [contact]="contact()"
473
473
  [length]="6"
474
474
  (codeComplete)="onVerify($event)"
475
475
  (resend)="onResend()">
476
- </app-otp-input>
476
+ </sail-otp-input>
477
477
  @if (error()) { <div class="auth-error">{{ error() }}</div> }
478
478
  `,
479
479
  })
@@ -510,12 +510,12 @@ Override `verifyOtp()` in your own `AuthService extends BaseAuthService` when yo
510
510
  `SocialLoginComponent` renders Google / Apple buttons using each provider's official SDK. It loads the SDKs dynamically via `loadScript()` — nothing is bundled into your app.
511
511
 
512
512
  ```html
513
- <app-social-login
513
+ <sail-social-login
514
514
  [providers]="['google', 'apple']"
515
515
  [consent]="consentState"
516
516
  (loginSuccess)="onSuccess($event)"
517
517
  (loginError)="onError($event)">
518
- </app-social-login>
518
+ </sail-social-login>
519
519
  ```
520
520
 
521
521
  Config required in `SAIL_GUI_CONFIG`:
@@ -557,7 +557,7 @@ auth.deleteAccount(reauth, 'Closing my account.').subscribe();
557
557
  ### Account deletion
558
558
 
559
559
  ```html
560
- <app-account-deletion [confirmationText]="'DELETE'"></app-account-deletion>
560
+ <sail-account-deletion [confirmationText]="'DELETE'"></sail-account-deletion>
561
561
  ```
562
562
 
563
563
  Typed-confirmation UX: the destructive button is disabled until the user types the exact `confirmationText` (default `DELETE`) **and** confirms their password. Optional reason textarea is forwarded to the backend. On success, local session is cleared and the router navigates to `config.accountDeletedRoute` (default `/login/local`).
@@ -567,7 +567,7 @@ Typed-confirmation UX: the destructive button is disabled until the user types t
567
567
  `TrustedDevicesComponent` ships a "Sign out of all devices" button that reveals a password field, then calls `BaseAuthService.logoutEverywhere({ password })` (`POST /api/user/logout-everywhere`). It also displays a single-device-mode banner when configured:
568
568
 
569
569
  ```html
570
- <app-trusted-devices [singleDeviceSession]="user.singleDeviceSession"></app-trusted-devices>
570
+ <sail-trusted-devices [singleDeviceSession]="user.singleDeviceSession"></sail-trusted-devices>
571
571
  ```
572
572
 
573
573
  Pass `singleDeviceSession` from your login response (the field is part of `LoginResponse2FA`). When true, the banner reads: *"This account is in single-device mode — signing in on another device will sign you out here."*
@@ -977,6 +977,111 @@ Type errors after the upgrade fall into four buckets:
977
977
  - **`Property 'X' does not exist on type 'EventEmitter<T>'.`** — you still import `EventEmitter`. Replace the field with `output<T>()` per step 3.
978
978
  - **`'@Input' is deprecated`** / Angular language-service squiggles on decorators — step 3.
979
979
 
980
+ ## Migrating to v0.8.1 — selector standardisation, OOP cleanup, keel v0.8.3 alignment
981
+
982
+ v0.8.1 is a polish pass on top of v0.8.0's signal migration. The biggest change is **the component selector prefix**: every sail component is now `sail-*`, where v0.8.0 still shipped a mix of `app-*` (older components) and `sail-*` (the v0.6 / v0.7 additions). The rest of the release is internal OOP cleanup that's mostly invisible to downstream code, plus a TypeScript downgrade and a keel pin.
983
+
984
+ | | v0.8.0 | v0.8.1 |
985
+ |---|---|---|
986
+ | keel | `v0.8.0` | `v0.8.3` |
987
+ | TypeScript | `~6.0.3` | `~5.9.3` |
988
+ | Component selector prefix | `app-*` (older) + `sail-*` (newer) | `sail-*` (uniform) |
989
+
990
+ ### 1. Selector rename — `app-*` → `sail-*` (and `table-*` / `dynamic-field` / `record-form` → `sail-*`)
991
+
992
+ Every sail component selector now starts with `sail-`. **This is the breaking change**: any downstream template that embeds a sail component needs the prefix updated. Search-and-replace across your templates:
993
+
994
+ | Old | New |
995
+ |---|---|
996
+ | `<app-navigation>` | `<sail-navigation>` |
997
+ | `<app-login>` | `<sail-login>` |
998
+ | `<app-register>` | `<sail-register>` |
999
+ | `<app-chpass>` | `<sail-chpass>` |
1000
+ | `<app-confirm-register>` | `<sail-confirm-register>` |
1001
+ | `<app-confirm-chpass>` | `<sail-confirm-chpass>` |
1002
+ | `<app-twofactor-setup>` | `<sail-twofactor-setup>` |
1003
+ | `<app-twofactor-verify>` | `<sail-twofactor-verify>` |
1004
+ | `<app-trusted-devices>` | `<sail-trusted-devices>` |
1005
+ | `<app-account-deletion>` | `<sail-account-deletion>` |
1006
+ | `<app-consent-gate>` | `<sail-consent-gate>` |
1007
+ | `<app-otp-input>` | `<sail-otp-input>` |
1008
+ | `<app-social-login>` | `<sail-social-login>` |
1009
+ | `<app-plan-selector>` | `<sail-plan-selector>` |
1010
+ | `<app-checkout-button>` | `<sail-checkout-button>` |
1011
+ | `<app-payment-methods>` | `<sail-payment-methods>` |
1012
+ | `<table-list>` | `<sail-table-list>` |
1013
+ | `<table-search>` | `<sail-table-search>` |
1014
+ | `<table-edit>` | `<sail-table-edit>` |
1015
+ | `<table-detail>` | `<sail-table-detail>` |
1016
+ | `<table-lookup>` | `<sail-table-lookup>` |
1017
+ | `<table-report>` | `<sail-table-report>` |
1018
+ | `<table-form>` | `<sail-table-form>` |
1019
+ | `<record-form>` | `<sail-record-form>` |
1020
+ | `<dynamic-field>` | `<sail-dynamic-field>` |
1021
+
1022
+ The payout / user-payment-method selectors (`<sail-payout-bank-info-form>`, `<sail-payout-provider-onboarding>`, `<sail-user-payment-methods>`) were already on `sail-*` in v0.7.0 — no change there.
1023
+
1024
+ **Why:** the Angular style guide assigns `app-*` to the consumer application (Angular CLI defaults to it for new components). A library shipping `app-*` selectors collides with the downstream's own components and reads ambiguously in mixed templates. v0.8.1 makes the prefix uniform across sail and unambiguous against your code.
1025
+
1026
+ ### 2. Internal: TableAction template-method pull-up
1027
+
1028
+ `executeAction()` was duplicated three times across `TableList`, `TableSearch`, and `TableDetail` in v0.8.0. v0.8.1 moves the body into `BaseTable` with two `protected` hooks:
1029
+
1030
+ ```typescript
1031
+ protected beforeExecuteAction(action: TableAction, record?: Record<string, unknown>): boolean { return true; }
1032
+ protected onActionSuccess(action: TableAction, record?: Record<string, unknown>): void { /* no-op */ }
1033
+ ```
1034
+
1035
+ **For your subclasses:** if you have a custom view that extends `BaseTable` / `BaseForm` / `BaseView` and renders the action toolbar, you no longer write your own `executeAction()` — inherit it. Override `onActionSuccess()` to refresh your screen (the way `TableList` re-fetches) and `beforeExecuteAction()` to add per-screen pre-flight guards (the way `TableDetail` blocks unsaved rows).
1036
+
1037
+ **For your templates:** unchanged. Action buttons still call `(click)="executeAction(action, record)"`.
1038
+
1039
+ **Bonus:** `TableEdit` now renders per-record actions too (v0.8.0 was missing them) and re-fetches the record after a successful action.
1040
+
1041
+ ### 3. Internal: `BaseAsync` pulled up onto 5 more components
1042
+
1043
+ `ChpassComponent`, `ConfirmChpassComponent`, `ConfirmRegisterComponent`, `TwoFactorSetupComponent`, and `TwoFactorVerifyComponent` were re-declaring their own `errorMessage` / `successMessage` signals and hand-rolled subscribe blocks. They now `extends BaseAsync` and use `this.run(obs, onSuccess, fallback)` — public template API is unchanged (`{{ errorMessage() }}` / `{{ successMessage() }}` still work).
1044
+
1045
+ ### 4. Internal: `BaseRestService` + `requireAuth()` + `openRecordDialog()` + `linkedSignal` alias-inputs
1046
+
1047
+ - **`BaseRestService`** (`src/service/base_rest.service.ts`) owns the shared `inject(HttpClient)` and `url(path)` helper. `BackendService`, `BaseAuthService`, `BillingService`, `PayoutService`, and `UserPaymentMethodService` all extend it. The 27 hand-spelled `RestURL.httpHost + RestURL.xURL` getters in `BaseAuthService` are now `readonly` fields built via `this.url(...)`.
1048
+ - **`BaseTable.requireAuth(check, verb)`** replaces 8 copies of `alert('Missing authorization to … records')` across the codebase.
1049
+ - **`BaseView.openRecordDialog(record, isNew)`** collapses the duplicated `addRecord` / `editRecord` dialog-open bodies.
1050
+ - **`linkedSignal`** replaces the v0.8.0 `input() + signal + effect()` pattern across `TableList`, `TableSearch`, `TableEdit`, `TableDetail`, and `DynamicField`. One-line declarations per field; behaviour is unchanged (empty input doesn't override a `.set()` value).
1051
+
1052
+ If your downstream component extends `BaseTable` / `BaseView` and uses the alias-input pattern from v0.8.0 (`input + effect`), you can optionally migrate to `linkedSignal` — but the old pattern still compiles.
1053
+
1054
+ ### 5. Internal: `BaseTable.caption` is no longer memoised; `Navigation.allMenuItems` via `toSignal`
1055
+
1056
+ `getCaption()` and `colCaption()` previously cached on first read, so `tableName` updates after first render returned stale captions. They now recompute each call (cost is negligible). `Navigation` reads `allMenuItems` via `toSignal(getMenus(), { initialValue: [] })` instead of a manual `subscribe` in `ngOnInit`.
1057
+
1058
+ ### 6. TypeScript pinned to `~5.9.3`
1059
+
1060
+ `@angular/compiler-cli@21.2` declares `typescript >=5.9 <6.1` as a peer, but downstream projects report that TS `6.0.3` requires `--legacy-peer-deps` on `npm install` because of transitive package mismatches. To keep the install path clean for all consumers, v0.8.1 pins to `~5.9.3`. Bump your downstream's `package.json` to match:
1061
+
1062
+ ```diff
1063
+ - "typescript": "~6.0.x"
1064
+ + "typescript": "~5.9.3"
1065
+ ```
1066
+
1067
+ ### 7. Clean install + recompile
1068
+
1069
+ ```bash
1070
+ rm -rf node_modules package-lock.json
1071
+ npm install
1072
+ ng build
1073
+ ```
1074
+
1075
+ Expected errors after the upgrade:
1076
+
1077
+ - **`'app-*' is not a known element`** — selector rename, see step 1.
1078
+ - **`'@Output' is deprecated`** — leftover from the v0.8.0 migration; finish it.
1079
+ - **`peerinvalid` / `--legacy-peer-deps` prompts on `npm install`** — your project still has TS 6.x. Downgrade per step 6.
1080
+
1081
+ ### 8. Backend alignment
1082
+
1083
+ v0.8.1 targets **keel v0.8.3**. The shared keel API surface didn't grow in this release; the version pin tracks keel's matching v0.8.x line. See [keel/README.md → Migration Guide](https://github.com/nauticana/keel/blob/main/README.md) for the matching backend changes.
1084
+
980
1085
  ## Modernization items
981
1086
 
982
1087
  When you extend sail in your own app, apply the same patterns sail itself follows. Downstream projects that copied templates or scaffolding from earlier sail versions will benefit from the same sweep. This list is a checklist — none are sail-specific.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
5
5
  "main": "src/index.ts",
6
6
  "files": [
@@ -53,6 +53,6 @@
53
53
  "@angular/router": "^21.2.8",
54
54
  "class-validator": "^0.15.1",
55
55
  "rxjs": "~7.8.0",
56
- "typescript": "~6.0.3"
56
+ "typescript": "~5.9.3"
57
57
  }
58
58
  }
@@ -20,9 +20,15 @@ export abstract class BaseAsync {
20
20
  this.successMessage.set('');
21
21
  }
22
22
 
23
- /** Set errorMessage from an HTTP error object; falls back to `fallback`. */
23
+ /**
24
+ * Set errorMessage from an HTTP error object; falls back to `fallback`.
25
+ *
26
+ * Reads `err.error.detail` first (keel emits RFC 7807 ProblemDetail with
27
+ * `detail` as the human-readable field), then `err.error.message` for
28
+ * legacy / non-RFC-7807 endpoints, then the supplied fallback.
29
+ */
24
30
  protected setError(err: any, fallback: string): void {
25
- this.errorMessage.set(err?.error?.message ?? fallback);
31
+ this.errorMessage.set(err?.error?.detail ?? err?.error?.message ?? fallback);
26
32
  this.loading.set(false);
27
33
  }
28
34
 
@@ -1,59 +1,69 @@
1
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
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
  /**
@@ -81,6 +91,55 @@ export abstract class BaseTable {
81
91
  return this.cacheService.canExecute(action.authorityObject, action.authorityCheck, this.tableName());
82
92
  }
83
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
141
+ }
142
+
84
143
  /**
85
144
  * Pulls the primary-key column values off a record into a plain
86
145
  * object keyed by the columns' PascalName. Used as the request
@@ -99,7 +158,7 @@ export abstract class BaseTable {
99
158
  }
100
159
 
101
160
  emptyRecord(): any {
102
- const record : any = {[this.config.opField]: 'I'}
161
+ const record : any = {[this.config.opField]: OpCode.Insert}
103
162
  const tableDef = this.cacheService.getTableDefinition(this.tableName());
104
163
  const tableOverride = this.config.tableOverrides?.[this.tableName()];
105
164
  const editableTimestamps = new Set(tableOverride?.editableTimestamps ?? []);
@@ -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. */
@@ -308,7 +367,7 @@ 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;
@@ -35,66 +35,53 @@ export abstract class BaseView extends BaseTable {
35
35
  return tableDef.Keys.map((key) => record[key.PascalName]).join('_');
36
36
  }
37
37
 
38
- addRecord() {
39
- if (!this.canCreate()) {
40
- alert('Missing authorization to create records');
41
- return;
42
- }
43
- const record = this.emptyRecord();
44
-
45
- if (Object.keys(record).length === 1 && this.records.length > 0) {
46
- Object.keys(this.records[0]).forEach((key) => {
47
- record[key] = '';
48
- });
49
- }
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;
50
48
 
51
- const dialogRef = this.dialog.open(this.dialogComponent(), {
49
+ this.dialog.open(this.dialogComponent(), {
52
50
  width: '90vw',
53
51
  maxWidth: '1200px',
54
52
  maxHeight: '90vh',
55
53
  disableClose: true,
56
- data: this.getDialogData(record, true),
54
+ data: dialogData,
55
+ }).afterClosed().subscribe((result) => {
56
+ if (result) this.handleUpdate(result, isNew, isNew ? {} : record);
57
57
  });
58
+ }
58
59
 
59
- dialogRef.afterClosed().subscribe((result) => {
60
- if (result) this.handleUpdate(result, true, {});
61
- });
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);
62
69
  }
63
70
 
64
71
  /**
65
72
  * Open the row dialog. Renamed conceptually from "edit": when the user
66
- * 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.
67
74
  * The dialog itself (TableEdit) handles read-only mode — its Edit/New/Delete
68
75
  * buttons are gated by the same permissions.
69
76
  * Block only when neither read nor update is granted.
70
77
  */
71
78
  editRecord(record: {[key: string]: any}) {
72
- if (!this.canUpdate() && !this.canRead()) {
73
- alert('Missing authorization to view records');
74
- return;
75
- }
76
- const dialogData = this.getDialogData(record, false);
77
- const apiName = this.apiName();
78
- if (apiName) {
79
- dialogData['apiName'] = 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
  }
@@ -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,
@@ -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,
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, effect, inject, 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,7 +31,7 @@ import { TableLookup } from "../table/table_lookup";
31
31
  ]
32
32
  })
33
33
  export class DynamicField extends BaseTable {
34
- readonly tableNameInput = input('', { alias: 'tableName' });
34
+ protected readonly tableNameInput = input('', { alias: 'tableName' });
35
35
  readonly value = input<any>(undefined);
36
36
  readonly valueChange = output<any>();
37
37
  readonly recordUpdate = output<{[key: string]: any}>();
@@ -41,19 +41,16 @@ export class DynamicField extends BaseTable {
41
41
  readonly subscriptSizing = input<'fixed' | 'dynamic'>('dynamic');
42
42
  readonly appearance = input<'fill' | 'outline'>('outline');
43
43
 
44
- private readonly dialog = inject(MatDialog);
44
+ override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
45
45
 
46
- constructor() {
47
- super();
48
- effect(() => { const v = this.tableNameInput(); if (v) this.tableName.set(v); });
49
- }
46
+ private readonly dialog = inject(MatDialog);
50
47
 
51
48
  get col() {
52
49
  return this.getColumn(this.field());
53
50
  }
54
51
 
55
52
  get inputType() {
56
- 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';
57
54
  return this.col.InputType;
58
55
  }
59
56
 
@@ -90,7 +87,7 @@ export class DynamicField extends BaseTable {
90
87
  }
91
88
 
92
89
  isLookupTableSearch(): boolean {
93
- return !!(this.col && this.col.LookupTable && this.col.LookupStyle === 'S');
90
+ return !!(this.col && this.col.LookupTable && this.col.LookupStyle === LookupStyle.Search);
94
91
  }
95
92
 
96
93
  get maxLength() {