@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.
- package/README.md +300 -1
- package/package.json +2 -2
- package/src/component/abstract/base_table.ts +19 -19
- package/src/component/abstract/base_view.ts +9 -9
- package/src/component/billing/checkout_button.html +1 -1
- package/src/component/billing/plan_selector.html +1 -1
- package/src/component/form/form_field.html +1 -1
- package/src/component/form/form_field.ts +7 -3
- package/src/component/form/form_table.html +4 -4
- package/src/component/form/form_table.ts +1 -1
- package/src/component/login/account_deletion.html +1 -1
- package/src/component/login/account_deletion.ts +4 -1
- package/src/component/login/chpass_component.html +1 -1
- package/src/component/login/confirm_chpass_component.html +1 -1
- package/src/component/login/confirm_register_component.html +1 -1
- package/src/component/login/login_component.html +1 -1
- package/src/component/login/otp_input.html +3 -3
- package/src/component/login/register_component.html +1 -1
- package/src/component/navigation/navigation.html +3 -3
- package/src/component/payment/user_payment_methods.html +46 -0
- package/src/component/payment/user_payment_methods.ts +10 -58
- package/src/component/payout/bank_info_form.html +59 -0
- package/src/component/payout/bank_info_form.ts +21 -77
- package/src/component/payout/provider_onboarding.html +70 -0
- package/src/component/payout/provider_onboarding.ts +9 -80
- package/src/component/security/trusted_devices.html +4 -4
- package/src/component/security/twofactor_setup.html +4 -4
- package/src/component/security/twofactor_verify.html +1 -1
- package/src/component/table/table_detail.html +9 -9
- package/src/component/table/table_detail.ts +10 -3
- package/src/component/table/table_edit.html +8 -8
- package/src/component/table/table_edit.ts +25 -14
- package/src/component/table/table_list.html +5 -5
- package/src/component/table/table_list.ts +17 -7
- package/src/component/table/table_lookup.html +4 -4
- package/src/component/table/table_lookup.ts +10 -9
- package/src/component/table/table_report.html +1 -1
- package/src/component/table/table_search.html +5 -5
- 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
|
|
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.
|
|
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": "~
|
|
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,
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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',
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
}
|
|
14
14
|
</ul>
|
|
15
15
|
}
|
|
16
|
-
<button
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
18
|
-
<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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
39
|
+
<button matButton="elevated" class="primary" type="submit" [disabled]="!confirmForm.valid">
|
|
40
40
|
Confirm Password Change
|
|
41
41
|
</button>
|
|
42
42
|
</form>
|