@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
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 **v0.5.x** targets keel **v0.5.x**. The two libraries are versioned in lock-step.
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."*
@@ -739,3 +739,407 @@ Type errors after upgrade fall into two buckets:
739
739
  ### 8. Backend alignment
740
740
 
741
741
  This library only talks to keel **v0.5.x**. Older keel servers will reject the `otpToken` field on verify with HTTP 400. Upgrade keel and sail together. See [keel/README.md → Migration Guide](https://github.com/nauticana/keel/blob/main/README.md) for the matching backend changes.
742
+
743
+ ## Migrating to v0.7.0 — payout, user payment methods, table actions
744
+
745
+ The v0.6 / v0.7 line introduced three additive feature groups against keel v0.7.x. All net-new exports; no breaking changes from v0.5.x. Pull what you need.
746
+
747
+ | Version | Surface |
748
+ |---|---|
749
+ | **v0.6.0** | Payout onboarding (KYC) + multi-partner account reuse |
750
+ | **v0.6.1** | End-user saved payment methods (cards / wallets) |
751
+ | **v0.7.0** | `TableAction` — backend-defined custom buttons on table screens |
752
+
753
+ ### 1. New exports
754
+
755
+ | From | Export | Purpose |
756
+ |---|---|---|
757
+ | `@nauticana/sail` | `PayoutService` | keel/payout API client — hosted-KYC launch, reuse flow, status |
758
+ | `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker + hosted-KYC launcher |
759
+ | `@nauticana/sail` | `PayoutBankInfoFormComponent` (`<sail-payout-bank-info-form>`) | Tax + payout details form (country / currency / tax ID / billing address / agreement) |
760
+ | `@nauticana/sail` | `UserPaymentMethodService` | Saved-card list / delete / set-default API client |
761
+ | `@nauticana/sail` | `UserPaymentMethodsComponent` (`<sail-user-payment-methods>`) | List of saved cards/wallets with set-default + delete |
762
+ | `@nauticana/sail` | `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `UserPaymentMethod`, `DEFAULT_COUNTRY_PROFILES` | Model types / defaults |
763
+
764
+ ### 2. New keel endpoints
765
+
766
+ Make sure your keel deployment exposes these — they're all under `/api/v1/`:
767
+
768
+ | Endpoint | Method | Purpose |
769
+ |---|---|---|
770
+ | `/api/v1/payout/onboard/start` | POST | Open hosted-KYC; returns `{ url, externalAccountId, expiresAt }` |
771
+ | `/api/v1/payout/reusable` | POST | List provider accounts the user has on other partners |
772
+ | `/api/v1/payout/reusable/link` | POST | Copy a `providerAccountId` onto the active partner's `user_bank_info` row |
773
+ | `/api/v1/payout/status` | POST | `{ complete: true }` once the active partner's row has a `providerAccountId` |
774
+ | `/api/v1/payment-methods/set-default` | POST | Atomic multi-row UPDATE — sets one row as default, clears the rest |
775
+ | `/api/v1/{table}/{action_name}` | POST | Per-table custom actions resolved from `basis.table_action` |
776
+
777
+ `list` / `delete` for the `user_payment_method` table go through keel's generic REST CRUD (`/api/v1/user_payment_method/list|delete`) — `user_payment_method` is a UserSpecific basis table, so keel auto-scopes reads to the caller and owner-locks DELETE. No custom endpoint needed for those two.
778
+
779
+ ### 3. Payout onboarding wiring
780
+
781
+ Drop the two components into a wizard step. Routing between them stays in the consumer app — sail emits events, doesn't navigate.
782
+
783
+ ```html
784
+ @switch (step()) {
785
+ @case ('bank') {
786
+ <sail-payout-bank-info-form
787
+ (submitted)="onBankInfo($event)"
788
+ (back)="step.set('plan')">
789
+ </sail-payout-bank-info-form>
790
+ }
791
+ @case ('provider') {
792
+ <sail-payout-provider-onboarding
793
+ (linked)="step.set('done')"
794
+ (started)="step.set('waiting')"
795
+ (skipped)="step.set('done')"
796
+ (back)="step.set('bank')">
797
+ </sail-payout-provider-onboarding>
798
+ }
799
+ }
800
+ ```
801
+
802
+ `BankInfoFormValue` maps 1:1 to `basis.user_bank_info` columns. The consumer decides where to POST it — typically a direct generic-CRUD insert against `/api/v1/user_bank_info`. The provider account itself is created by `<sail-payout-provider-onboarding>` via `PayoutService.startOnboarding()` → hosted KYC → webhook back to keel.
803
+
804
+ For apps that operate on a single keel partner, the reuse picker stays empty and only the "Start onboarding" CTA renders. Multi-partner apps get the picker for free.
805
+
806
+ ### 4. Saved payment methods wiring
807
+
808
+ ```html
809
+ <sail-user-payment-methods
810
+ [title]="'Payment Methods'"
811
+ (addClicked)="goToSetupIntent()"
812
+ (defaultChanged)="onDefaultChanged($event)"
813
+ (deleted)="onDeleted($event)">
814
+ </sail-user-payment-methods>
815
+ ```
816
+
817
+ `(addClicked)` is the only event you must wire — sail intentionally does **not** ship a SetupIntent UI (providers differ too much; consumer flows them through Stripe Elements / Apple Pay / Google Pay / etc.). Listen for the event and route to your own SetupIntent screen.
818
+
819
+ ### 5. TableAction — backend-defined per-table actions
820
+
821
+ v0.7.0 surfaces a new `TableAction` channel: keel's REST engine ships per-table action buttons from `basis.table_action`. The shipped `TableList`, `TableSearch`, `TableEdit`, and `TableDetail` templates render these automatically (toolbar for table-level actions, per-row icon buttons for record-specific ones). Authorization gating mirrors `canRead`/`canCreate` etc. — `canExecuteAction(action)` is checked against `(authorityObject, authorityCheck, table_name)`.
822
+
823
+ For your own components that subclass `BaseView` / `BaseForm`, expose the same buttons in your templates:
824
+
825
+ ```html
826
+ @for (action of getActions(false); track action.action) {
827
+ @if (canExecuteAction(action)) {
828
+ <button matButton="outlined" type="button"
829
+ [title]="action.caption"
830
+ (click)="executeAction(action)">
831
+ @if (action.icon) { <mat-icon>{{ action.icon }}</mat-icon> }
832
+ {{ action.caption }}
833
+ </button>
834
+ }
835
+ }
836
+
837
+ @for (action of getActions(true); track action.action) {
838
+ @if (canExecuteAction(action)) {
839
+ <button matIconButton type="button"
840
+ [title]="action.caption"
841
+ (click)="executeAction(action, record)">
842
+ <mat-icon>{{ action.icon || 'play_arrow' }}</mat-icon>
843
+ </button>
844
+ }
845
+ }
846
+ ```
847
+
848
+ `executeAction()` is provided by sail's table components (it POSTs the row's primary-key columns to `action.method`). If you wrote your own subclass and want the same behaviour, inject `BackendService` and call `backendService.executeAction(action.method, body)`. The `body` is `{}` for table-level actions and the row PK (via `primaryKeyValues(record)`) for record-specific ones.
849
+
850
+ ### 6. Backend alignment
851
+
852
+ v0.6 / v0.7 require keel **v0.7.x**. The earlier `keel v0.5.x` server doesn't expose `/api/v1/payout/*`, `/api/v1/payment-methods/set-default`, or the `basis.table_action` seed data. Upgrade keel and sail together.
853
+
854
+ ## Migrating to v0.8.0 — signal-driven modernization
855
+
856
+ v0.8.0 finishes the Angular-signals migration that v0.5–v0.7 started incrementally. Every `@Input()` / `@Output()` decorator, `EventEmitter`, plain-field-on-`BaseTable` access, and legacy Material M2 button selector is gone. Downstream code that extends sail's abstract classes or copies its template patterns will need the steps below — there is no shim.
857
+
858
+ The TS-side core peer requirements bump with this release:
859
+
860
+ | | v0.7.x | v0.8.x |
861
+ |---|---|---|
862
+ | Angular | `^21.0.0` | `^21.2.0` |
863
+ | TypeScript | `~5.9.2` | `~6.0.3` |
864
+
865
+ ### 1. `BaseTable.tableName` is now a `WritableSignal<string>`
866
+
867
+ The single highest-impact change. Before:
868
+
869
+ ```typescript
870
+ // BaseTable
871
+ tableName = '';
872
+
873
+ // Subclass
874
+ @Input() override tableName = '';
875
+
876
+ // In your code
877
+ if (this.tableName === 'orders') { ... }
878
+ this.tableName = 'orders';
879
+
880
+ // In your template
881
+ [tableName]="tableName"
882
+ {{ tableName }}
883
+ ```
884
+
885
+ After:
886
+
887
+ ```typescript
888
+ // BaseTable
889
+ readonly tableName: WritableSignal<string> = signal('');
890
+
891
+ // In your code
892
+ if (this.tableName() === 'orders') { ... }
893
+ this.tableName.set('orders');
894
+
895
+ // In your template
896
+ [tableName]="tableName()"
897
+ {{ tableName() }}
898
+ ```
899
+
900
+ If your subclass exposes `tableName` as a route/parent input, declare an aliased `input()` and sync it into the inherited signal in the constructor:
901
+
902
+ ```typescript
903
+ import { effect, input } from '@angular/core';
904
+
905
+ export class YourTableComponent extends BaseForm {
906
+ readonly tableNameInput = input('', { alias: 'tableName' });
907
+
908
+ constructor() {
909
+ super();
910
+ effect(() => {
911
+ const v = this.tableNameInput();
912
+ if (v) this.tableName.set(v);
913
+ });
914
+ }
915
+
916
+ ngOnInit() {
917
+ // Route-data fallback now uses .set(), not assignment:
918
+ if (!this.tableName() && data['tableName']) this.tableName.set(data['tableName']);
919
+ }
920
+ }
921
+ ```
922
+
923
+ ### 2. `BaseView.apiName` and `BaseView.dialogComponent` are signals too
924
+
925
+ Same migration path as `tableName`. The TableList/TableLookup pattern in v0.5–v0.7 read these as plain strings and assigned in `ngOnInit`; they are now `WritableSignal<string>` / `WritableSignal<any>`. Update subclass code:
926
+
927
+ ```diff
928
+ - if (!this.apiName && data['apiName']) this.apiName = data['apiName'];
929
+ - this.backendService.list<MyRow>(this.apiName, terms).subscribe(...);
930
+ + if (!this.apiName() && data['apiName']) this.apiName.set(data['apiName']);
931
+ + this.backendService.list<MyRow>(this.apiName(), terms).subscribe(...);
932
+ ```
933
+
934
+ If you bind `[apiName]` / `[dialogComponent]` on a sail subclass from a parent template, declare aliased inputs and sync via `effect()`, exactly like `tableNameInput` above.
935
+
936
+ ### 3. `@Input()` / `@Output()` → `input()` / `output()`
937
+
938
+ Every decorator-based input/output across sail is now signal-based. Update your own components to match. The signal-input requires calling the field as a function inside the class and in templates.
939
+
940
+ ```diff
941
+ - @Input() title = 'Payments';
942
+ - @Output() saved = new EventEmitter<Payment>();
943
+ + readonly title = input('Payments');
944
+ + readonly saved = output<Payment>();
945
+
946
+ // class body
947
+ - this.title // string
948
+ - this.saved.emit(p);
949
+ + this.title() // string (signal read)
950
+ + this.saved.emit(p); // unchanged
951
+
952
+ // template
953
+ - {{ title }}
954
+ + {{ title() }}
955
+ ```
956
+
957
+ `EventEmitter` is no longer imported from `@angular/core` in sail; outputs created with `output<T>()` are returned as `OutputEmitterRef<T>` with the same `.emit(v)` API.
958
+
959
+ For inputs whose **parent value can change at runtime** and you also want a local writable copy (the old "default value, then override in `ngOnInit`" pattern), use the alias + effect template above. If you only need the parent value reactively, just call `this.foo()` everywhere.
960
+
961
+ ### 4. Inline component templates moved to sibling `.html`
962
+
963
+ `UserPaymentMethodsComponent`, `PayoutBankInfoFormComponent`, and `PayoutProviderOnboardingComponent` no longer ship inline `template:` strings — they reference `templateUrl: './*.html'` in the same folder. The compiled output is identical; downstream consumers that just import the component classes need no change. If you have a fork that edits these templates inline, port your edits into the new `.html` files.
964
+
965
+ ### 5. Clean install + recompile
966
+
967
+ ```bash
968
+ rm -rf node_modules package-lock.json
969
+ npm install
970
+ ng build
971
+ ```
972
+
973
+ Type errors after the upgrade fall into four buckets:
974
+
975
+ - **`This expression is not callable. Type 'String' has no call signatures.`** — a template still reads `tableName` / `apiName` as a plain field. Add `()`. See step 1.
976
+ - **`Cannot assign to 'tableName' because it is a read-only property.`** — a subclass declared `readonly tableName = input('')`, conflicting with the inherited writable signal. Use the `tableNameInput = input('', { alias: 'tableName' })` + `effect()` pattern from step 1 instead.
977
+ - **`Property 'X' does not exist on type 'EventEmitter<T>'.`** — you still import `EventEmitter`. Replace the field with `output<T>()` per step 3.
978
+ - **`'@Input' is deprecated`** / Angular language-service squiggles on decorators — step 3.
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
+
1085
+ ## Modernization items
1086
+
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.
1088
+
1089
+ ### Templates
1090
+
1091
+ - **Native control flow.** Use `@if` / `@for` / `@switch` / `@let` blocks. `*ngIf`, `*ngFor`, `*ngSwitch` are legacy.
1092
+ - **Bindings, not directives, for class / style.** `[class.foo]="cond"`, `[style.width.px]="n"` — don't use `ngClass` / `ngStyle`.
1093
+ - **Material 21 M3 button selectors.** See step 5 above. `mat-raised-button` / `mat-stroked-button` etc. work but are not the current API.
1094
+ - **No `color="primary|accent|warn"` on Material components.** Use the `.primary` / `.accent` / `.warn` global CSS classes.
1095
+ - **Template spread inside expressions** (Angular 21). When you have a TS helper that only exists to merge objects for a binding, fold it into the template: `[config]="{ ...base, label: 'Override' }"`, `[items]="[...defaults, ...extra]"`, `[out]="fn(...args)"`. (Note: only valid inside array literals, object literals, or call expressions — there is no JSX-style host-attribute spread.)
1096
+ - **No structural directive on the same element as a component selector.** Already enforced by native control flow.
1097
+
1098
+ ### Components and directives
1099
+
1100
+ - **Signal-based inputs/outputs.** `input()` / `output()` instead of `@Input()` / `@Output()`. Read as `this.x()` / `{{ x() }}`.
1101
+ - **`model()` for two-way bindings.** When the parent needs `[(value)]` binding semantics, declare `value = model('')` rather than rolling your own `@Output() valueChange`.
1102
+ - **`inject()` for dependencies.** No constructor parameter injection. Field-level `inject(Token)` keeps the constructor zero-arg and lets the class be subclassed without parameter forwarding.
1103
+ - **`ChangeDetectionStrategy.OnPush` on every component.** Signal-based code is fully compatible with OnPush; without it, change detection still runs needlessly.
1104
+ - **`host: { ... }` metadata, not `@HostBinding` / `@HostListener`.** Same effect, no decorators.
1105
+ - **Omit `standalone: true`.** It's the default in Angular v20+. Setting it explicitly is noise.
1106
+ - **Move HTML and styles to sibling files** when they grow past a few lines (`templateUrl: './*.html'`, `styleUrl: './*.scss'` or `styles: '...'`).
1107
+
1108
+ ### State and reactivity
1109
+
1110
+ - **`signal()` for component state**, `computed()` for derived state, `effect()` for side effects that depend on signals. Don't roll your own `BehaviorSubject` for local UI state.
1111
+ - **`linkedSignal()`** for "use this input value by default, but allow local override" patterns.
1112
+ - **`takeUntilDestroyed()`** on long-lived observables (`valueChanges`, `route.queryParams`, `route.paramMap`, custom intervals). One-shot HTTP requests can skip it, but `valueChanges` and route streams must have it. Call it in a constructor or other injection context.
1113
+ - **`toSignal()`** for converting an `Observable<T>` into a signal at component boundaries — useful for `BillingService.listPlans()` and similar one-shots you read in templates.
1114
+ - **No `mutate()` on signals** — it was removed. Use `update(fn)` or `set(newValue)`.
1115
+
1116
+ ### Router
1117
+
1118
+ - **Standalone `isActive(url, router, options?)` function** from `@angular/router`. Returns `Signal<boolean>`. `Router.prototype.isActive(...)` was `@deprecated` in Angular 21.1.
1119
+ - **`provideRouter([...])` for bootstrap.** No `RouterModule.forRoot`.
1120
+ - **Component input binding** for route params: enable `withComponentInputBinding()` and declare `id = input.required<string>()` on the routed component — drops the `ActivatedRoute` injection.
1121
+
1122
+ ### HTTP
1123
+
1124
+ - **`provideHttpClient(withInterceptors([...]))`.** No `HttpClientModule`.
1125
+ - **Functional interceptors**, not class-based — sail's `authInterceptor` and `apiResponseInterceptor` are already functional; follow the same shape for your own.
1126
+
1127
+ ### Forms
1128
+
1129
+ - **Reactive forms over template-driven.** sail uses `FormGroup` / `FormBuilder` throughout.
1130
+ - **`fb.nonNullable.group({...})`** for strict typing. Avoids `string | null` everywhere.
1131
+
1132
+ ### Bootstrap
1133
+
1134
+ - **`bootstrapApplication()` with standalone components.** No `@NgModule`, no `AppModule`.
1135
+ - **`provideZonelessChangeDetection()`** — Angular 21's default. sail's components are signal-based and zoneless-safe.
1136
+
1137
+ ### Images
1138
+
1139
+ - **`NgOptimizedImage`** for static `<img>` with known dimensions. **Exception:** base64 / data URIs aren't supported by `NgOptimizedImage` — use a plain `<img>` for those (the 2FA QR code in `TwoFactorSetupComponent` is the canonical case).
1140
+
1141
+ ### Tooling
1142
+
1143
+ - **TypeScript `~6.0.3`** — within Angular 21.2's `>=5.9 <6.1` peer range. Bump from `~5.9.x`.
1144
+ - **`@angular/*` `^21.2.0`** for the matching set: core, common, compiler, forms, router, cdk, material.
1145
+ - **`npm update`** periodically to pull patch releases within the caret range.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "0.7.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": "~5.9.2"
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