@nauticana/sail 0.7.0 → 0.8.0

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 (39) hide show
  1. package/README.md +300 -1
  2. package/package.json +2 -2
  3. package/src/component/abstract/base_table.ts +19 -19
  4. package/src/component/abstract/base_view.ts +9 -9
  5. package/src/component/billing/checkout_button.html +1 -1
  6. package/src/component/billing/plan_selector.html +1 -1
  7. package/src/component/form/form_field.html +1 -1
  8. package/src/component/form/form_field.ts +7 -3
  9. package/src/component/form/form_table.html +4 -4
  10. package/src/component/form/form_table.ts +1 -1
  11. package/src/component/login/account_deletion.html +1 -1
  12. package/src/component/login/account_deletion.ts +4 -1
  13. package/src/component/login/chpass_component.html +1 -1
  14. package/src/component/login/confirm_chpass_component.html +1 -1
  15. package/src/component/login/confirm_register_component.html +1 -1
  16. package/src/component/login/login_component.html +1 -1
  17. package/src/component/login/otp_input.html +3 -3
  18. package/src/component/login/register_component.html +1 -1
  19. package/src/component/navigation/navigation.html +3 -3
  20. package/src/component/payment/user_payment_methods.html +46 -0
  21. package/src/component/payment/user_payment_methods.ts +10 -58
  22. package/src/component/payout/bank_info_form.html +59 -0
  23. package/src/component/payout/bank_info_form.ts +21 -77
  24. package/src/component/payout/provider_onboarding.html +70 -0
  25. package/src/component/payout/provider_onboarding.ts +9 -80
  26. package/src/component/security/trusted_devices.html +4 -4
  27. package/src/component/security/twofactor_setup.html +4 -4
  28. package/src/component/security/twofactor_verify.html +1 -1
  29. package/src/component/table/table_detail.html +9 -9
  30. package/src/component/table/table_detail.ts +10 -3
  31. package/src/component/table/table_edit.html +8 -8
  32. package/src/component/table/table_edit.ts +25 -14
  33. package/src/component/table/table_list.html +5 -5
  34. package/src/component/table/table_list.ts +17 -7
  35. package/src/component/table/table_lookup.html +4 -4
  36. package/src/component/table/table_lookup.ts +10 -9
  37. package/src/component/table/table_report.html +1 -1
  38. package/src/component/table/table_search.html +5 -5
  39. package/src/component/table/table_search.ts +30 -17
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**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400.
6
6
 
7
7
  ## What it provides
8
8
 
@@ -739,3 +739,302 @@ 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
+ ## Modernization items
981
+
982
+ 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.
983
+
984
+ ### Templates
985
+
986
+ - **Native control flow.** Use `@if` / `@for` / `@switch` / `@let` blocks. `*ngIf`, `*ngFor`, `*ngSwitch` are legacy.
987
+ - **Bindings, not directives, for class / style.** `[class.foo]="cond"`, `[style.width.px]="n"` — don't use `ngClass` / `ngStyle`.
988
+ - **Material 21 M3 button selectors.** See step 5 above. `mat-raised-button` / `mat-stroked-button` etc. work but are not the current API.
989
+ - **No `color="primary|accent|warn"` on Material components.** Use the `.primary` / `.accent` / `.warn` global CSS classes.
990
+ - **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.)
991
+ - **No structural directive on the same element as a component selector.** Already enforced by native control flow.
992
+
993
+ ### Components and directives
994
+
995
+ - **Signal-based inputs/outputs.** `input()` / `output()` instead of `@Input()` / `@Output()`. Read as `this.x()` / `{{ x() }}`.
996
+ - **`model()` for two-way bindings.** When the parent needs `[(value)]` binding semantics, declare `value = model('')` rather than rolling your own `@Output() valueChange`.
997
+ - **`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.
998
+ - **`ChangeDetectionStrategy.OnPush` on every component.** Signal-based code is fully compatible with OnPush; without it, change detection still runs needlessly.
999
+ - **`host: { ... }` metadata, not `@HostBinding` / `@HostListener`.** Same effect, no decorators.
1000
+ - **Omit `standalone: true`.** It's the default in Angular v20+. Setting it explicitly is noise.
1001
+ - **Move HTML and styles to sibling files** when they grow past a few lines (`templateUrl: './*.html'`, `styleUrl: './*.scss'` or `styles: '...'`).
1002
+
1003
+ ### State and reactivity
1004
+
1005
+ - **`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.
1006
+ - **`linkedSignal()`** for "use this input value by default, but allow local override" patterns.
1007
+ - **`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.
1008
+ - **`toSignal()`** for converting an `Observable<T>` into a signal at component boundaries — useful for `BillingService.listPlans()` and similar one-shots you read in templates.
1009
+ - **No `mutate()` on signals** — it was removed. Use `update(fn)` or `set(newValue)`.
1010
+
1011
+ ### Router
1012
+
1013
+ - **Standalone `isActive(url, router, options?)` function** from `@angular/router`. Returns `Signal<boolean>`. `Router.prototype.isActive(...)` was `@deprecated` in Angular 21.1.
1014
+ - **`provideRouter([...])` for bootstrap.** No `RouterModule.forRoot`.
1015
+ - **Component input binding** for route params: enable `withComponentInputBinding()` and declare `id = input.required<string>()` on the routed component — drops the `ActivatedRoute` injection.
1016
+
1017
+ ### HTTP
1018
+
1019
+ - **`provideHttpClient(withInterceptors([...]))`.** No `HttpClientModule`.
1020
+ - **Functional interceptors**, not class-based — sail's `authInterceptor` and `apiResponseInterceptor` are already functional; follow the same shape for your own.
1021
+
1022
+ ### Forms
1023
+
1024
+ - **Reactive forms over template-driven.** sail uses `FormGroup` / `FormBuilder` throughout.
1025
+ - **`fb.nonNullable.group({...})`** for strict typing. Avoids `string | null` everywhere.
1026
+
1027
+ ### Bootstrap
1028
+
1029
+ - **`bootstrapApplication()` with standalone components.** No `@NgModule`, no `AppModule`.
1030
+ - **`provideZonelessChangeDetection()`** — Angular 21's default. sail's components are signal-based and zoneless-safe.
1031
+
1032
+ ### Images
1033
+
1034
+ - **`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).
1035
+
1036
+ ### Tooling
1037
+
1038
+ - **TypeScript `~6.0.3`** — within Angular 21.2's `>=5.9 <6.1` peer range. Bump from `~5.9.x`.
1039
+ - **`@angular/*` `^21.2.0`** for the matching set: core, common, compiler, forms, router, cdk, material.
1040
+ - **`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.0",
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": "~6.0.3"
57
57
  }
58
58
  }
@@ -1,4 +1,4 @@
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
3
  import { ConstantValue } from "../../model/common";
4
4
  import { BaseAuthService } from "../../service/auth.service";
@@ -9,7 +9,7 @@ export abstract class BaseTable {
9
9
  protected readonly cacheService = inject(BaseAuthService);
10
10
  protected readonly config: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
11
11
  protected readonly dialogWidth: string = '400px';
12
- tableName = '';
12
+ readonly tableName: WritableSignal<string> = signal('');
13
13
  caption = '';
14
14
  fieldOptions: {[key: string]: ConstantValue[]} = {};
15
15
  fieldLabels: {[key: string]: string} = {};
@@ -27,7 +27,7 @@ export abstract class BaseTable {
27
27
 
28
28
  getCaption() {
29
29
  if (!this.caption) {
30
- this.caption = this.titleCase(this.tableName);
30
+ this.caption = this.titleCase(this.tableName());
31
31
  }
32
32
  return this.caption;
33
33
  }
@@ -41,19 +41,19 @@ export abstract class BaseTable {
41
41
  }
42
42
 
43
43
  canRead() {
44
- return this.cacheService.canRead(this.tableName);
44
+ return this.cacheService.canRead(this.tableName());
45
45
  }
46
46
 
47
47
  canCreate() {
48
- return this.cacheService.canCreate(this.tableName);
48
+ return this.cacheService.canCreate(this.tableName());
49
49
  }
50
50
 
51
51
  canUpdate() {
52
- return this.cacheService.canUpdate(this.tableName);
52
+ return this.cacheService.canUpdate(this.tableName());
53
53
  }
54
54
 
55
55
  canDelete() {
56
- return this.cacheService.canDelete(this.tableName);
56
+ return this.cacheService.canDelete(this.tableName());
57
57
  }
58
58
 
59
59
  /**
@@ -62,7 +62,7 @@ export abstract class BaseTable {
62
62
  * when the table has no custom actions.
63
63
  */
64
64
  getActions(recordSpecific?: boolean): TableAction[] {
65
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
65
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
66
66
  const actions = tableDef?.Actions ?? [];
67
67
  if (recordSpecific === undefined) return [...actions].sort(this.sortActions);
68
68
  return actions.filter((a) => a.recordSpecific === recordSpecific).sort(this.sortActions);
@@ -78,7 +78,7 @@ export abstract class BaseTable {
78
78
  * action button should render.
79
79
  */
80
80
  canExecuteAction(action: TableAction): boolean {
81
- return this.cacheService.canExecute(action.authorityObject, action.authorityCheck, this.tableName);
81
+ return this.cacheService.canExecute(action.authorityObject, action.authorityCheck, this.tableName());
82
82
  }
83
83
 
84
84
  /**
@@ -89,7 +89,7 @@ export abstract class BaseTable {
89
89
  * does for get / delete.
90
90
  */
91
91
  primaryKeyValues(record: Record<string, unknown>): Record<string, unknown> {
92
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
92
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
93
93
  const pk: Record<string, unknown> = {};
94
94
  if (!tableDef?.Keys) return pk;
95
95
  for (const key of tableDef.Keys) {
@@ -100,8 +100,8 @@ export abstract class BaseTable {
100
100
 
101
101
  emptyRecord(): any {
102
102
  const record : any = {[this.config.opField]: 'I'}
103
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
104
- const tableOverride = this.config.tableOverrides?.[this.tableName];
103
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
104
+ const tableOverride = this.config.tableOverrides?.[this.tableName()];
105
105
  const editableTimestamps = new Set(tableOverride?.editableTimestamps ?? []);
106
106
 
107
107
  if (tableDef) {
@@ -141,8 +141,8 @@ export abstract class BaseTable {
141
141
  }
142
142
 
143
143
  getDisplayedColumns(records: any[] = []): string[] {
144
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
145
- const tableOverride = this.config.tableOverrides?.[this.tableName];
144
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
145
+ const tableOverride = this.config.tableOverrides?.[this.tableName()];
146
146
  const hidden = new Set([
147
147
  ...this.config.hiddenFields,
148
148
  ...(tableOverride?.hiddenFields ?? []),
@@ -162,7 +162,7 @@ export abstract class BaseTable {
162
162
  }
163
163
 
164
164
  getColumn(fieldName: string): TableColumn | undefined {
165
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
165
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
166
166
  if (tableDef && tableDef.Columns) {
167
167
  return tableDef.Columns.find((field) => field.PascalName === fieldName);
168
168
  }
@@ -290,11 +290,11 @@ export abstract class BaseTable {
290
290
  }
291
291
 
292
292
  getDialogData(record: any, isNew: boolean, readOnlyColumns: string[] = []): any {
293
- return {record, tableName: this.tableName, isNew, readOnlyColumns};
293
+ return {record, tableName: this.tableName(), isNew, readOnlyColumns};
294
294
  }
295
295
 
296
296
  getTableName(fieldName: string): string {
297
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
297
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
298
298
  if (!tableDef) return '';
299
299
  const fk = tableDef.Children?.find((c) => c.PascalName === fieldName);
300
300
  return fk ? fk.ChildTable : '';
@@ -315,7 +315,7 @@ export abstract class BaseTable {
315
315
  }
316
316
 
317
317
  getKeyFilters(record: any): {[key: string]: string} | undefined {
318
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
318
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
319
319
  if (!tableDef || !tableDef.Keys || tableDef.Keys.length === 0) {
320
320
  return undefined;
321
321
  }
@@ -328,7 +328,7 @@ export abstract class BaseTable {
328
328
 
329
329
  getForeignKeyConfig(parentTable: string): {fk: ForeignKey, parent: TableDefinition} | null{
330
330
  if (!parentTable) return null;
331
- const tableDef = this.cacheService.getTableDefinition(this.tableName);
331
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
332
332
  const parent = this.cacheService.getTableDefinition(parentTable);
333
333
  if (!tableDef || !tableDef.Keys || !tableDef.Parents || !parent) return null;
334
334
  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,7 +30,7 @@ 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
  }
@@ -49,7 +48,7 @@ export abstract class BaseView extends BaseTable {
49
48
  });
50
49
  }
51
50
 
52
- const dialogRef = this.dialog.open(this.dialogComponent, {
51
+ const dialogRef = this.dialog.open(this.dialogComponent(), {
53
52
  width: '90vw',
54
53
  maxWidth: '1200px',
55
54
  maxHeight: '90vh',
@@ -75,10 +74,11 @@ export abstract class BaseView extends BaseTable {
75
74
  return;
76
75
  }
77
76
  const dialogData = this.getDialogData(record, false);
78
- if (this.apiName) {
79
- dialogData['apiName'] = this.apiName;
77
+ const apiName = this.apiName();
78
+ if (apiName) {
79
+ dialogData['apiName'] = apiName;
80
80
  }
81
- const dialogRef = this.dialog.open(this.dialogComponent, {
81
+ const dialogRef = this.dialog.open(this.dialogComponent(), {
82
82
  width: '90vw',
83
83
  maxWidth: '1200px',
84
84
  maxHeight: '90vh',
@@ -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 @@
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>
@@ -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, effect, inject, input, 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";
@@ -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
+ 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}>();
@@ -44,6 +43,11 @@ export class DynamicField extends BaseTable {
44
43
 
45
44
  private readonly dialog = inject(MatDialog);
46
45
 
46
+ constructor() {
47
+ super();
48
+ effect(() => { const v = this.tableNameInput(); if (v) this.tableName.set(v); });
49
+ }
50
+
47
51
  get col() {
48
52
  return this.getColumn(this.field());
49
53
  }
@@ -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
+ <record-form [tableName]="tableName()" [record]="editableRecord" [isReadOnly]="isReadOnlyBound"></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>
@@ -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';
@@ -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 {
@@ -36,7 +36,7 @@
36
36
  <div class="auth-success" role="status">{{ successMessage() }}</div>
37
37
  }
38
38
 
39
- <button mat-raised-button class="primary" type="submit" [disabled]="!confirmForm.valid">
39
+ <button matButton="elevated" class="primary" type="submit" [disabled]="!confirmForm.valid">
40
40
  Confirm Password Change
41
41
  </button>
42
42
  </form>