@nauticana/sail 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -15
- package/package.json +2 -2
- package/src/component/abstract/base_async.ts +8 -2
- package/src/component/abstract/base_table.ts +94 -35
- package/src/component/abstract/base_view.ts +28 -41
- package/src/component/billing/checkout_button.ts +1 -1
- package/src/component/billing/payment_methods.ts +1 -1
- package/src/component/billing/plan_selector.ts +1 -1
- package/src/component/form/form_field.ts +8 -11
- package/src/component/form/form_record.html +2 -2
- package/src/component/form/form_record.ts +1 -1
- package/src/component/form/form_table.html +1 -1
- package/src/component/form/form_table.ts +1 -1
- package/src/component/login/account_deletion.ts +1 -1
- package/src/component/login/chpass_component.ts +11 -16
- package/src/component/login/confirm_chpass_component.ts +9 -15
- package/src/component/login/confirm_register_component.ts +9 -15
- package/src/component/login/consent_gate.ts +1 -1
- package/src/component/login/login_component.html +1 -1
- package/src/component/login/login_component.ts +1 -1
- package/src/component/login/otp_input.ts +1 -1
- package/src/component/login/register_component.ts +1 -1
- package/src/component/login/social_login.ts +1 -1
- package/src/component/navigation/navigation.ts +10 -11
- package/src/component/security/trusted_devices.ts +1 -1
- package/src/component/security/twofactor_setup.ts +18 -26
- package/src/component/security/twofactor_verify.ts +10 -12
- package/src/component/table/table_detail.html +4 -4
- package/src/component/table/table_detail.ts +22 -34
- package/src/component/table/table_edit.html +13 -3
- package/src/component/table/table_edit.ts +17 -17
- package/src/component/table/table_list.ts +18 -52
- package/src/component/table/table_lookup.html +1 -1
- package/src/component/table/table_lookup.ts +1 -3
- package/src/component/table/table_report.ts +11 -14
- package/src/component/table/table_search.html +1 -1
- package/src/component/table/table_search.ts +14 -38
- package/src/index.ts +2 -0
- package/src/model/appdata.ts +2 -2
- package/src/model/common.ts +39 -1
- package/src/service/auth.service.ts +26 -26
- package/src/service/base_rest.service.ts +20 -0
- package/src/service/billing.service.ts +3 -9
- package/src/service/payout.service.ts +3 -9
- package/src/service/rest_service.ts +5 -5
- package/src/service/user_payment_method.service.ts +4 -8
- package/src/util/text.ts +19 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
|
|
4
4
|
|
|
5
|
-
> **Compatibility:** sail and keel are versioned in lock-step. Use **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400.
|
|
5
|
+
> **Compatibility:** sail and keel are versioned in lock-step. Use **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
|
|
6
6
|
|
|
7
7
|
## What it provides
|
|
8
8
|
|
|
@@ -143,7 +143,7 @@ import { Navigation } from '@nauticana/sail';
|
|
|
143
143
|
selector: 'app-root',
|
|
144
144
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
145
145
|
imports: [Navigation],
|
|
146
|
-
template: `<
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
<
|
|
433
|
+
<sail-consent-gate
|
|
434
434
|
[policyVersion]="'v1'"
|
|
435
435
|
[policyLanguage]="'en'"
|
|
436
436
|
[optionalConsents]="extras"
|
|
437
437
|
(consentStateChange)="onConsent($event)">
|
|
438
|
-
</
|
|
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
|
-
<
|
|
471
|
+
<sail-otp-input
|
|
472
472
|
[contact]="contact()"
|
|
473
473
|
[length]="6"
|
|
474
474
|
(codeComplete)="onVerify($event)"
|
|
475
475
|
(resend)="onResend()">
|
|
476
|
-
</
|
|
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
|
-
<
|
|
513
|
+
<sail-social-login
|
|
514
514
|
[providers]="['google', 'apple']"
|
|
515
515
|
[consent]="consentState"
|
|
516
516
|
(loginSuccess)="onSuccess($event)"
|
|
517
517
|
(loginError)="onError($event)">
|
|
518
|
-
</
|
|
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
|
-
<
|
|
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
|
-
<
|
|
570
|
+
<sail-trusted-devices [singleDeviceSession]="user.singleDeviceSession"></sail-trusted-devices>
|
|
571
571
|
```
|
|
572
572
|
|
|
573
573
|
Pass `singleDeviceSession` from your login response (the field is part of `LoginResponse2FA`). When true, the banner reads: *"This account is in single-device mode — signing in on another device will sign you out here."*
|
|
@@ -977,6 +977,111 @@ Type errors after the upgrade fall into four buckets:
|
|
|
977
977
|
- **`Property 'X' does not exist on type 'EventEmitter<T>'.`** — you still import `EventEmitter`. Replace the field with `output<T>()` per step 3.
|
|
978
978
|
- **`'@Input' is deprecated`** / Angular language-service squiggles on decorators — step 3.
|
|
979
979
|
|
|
980
|
+
## Migrating to v0.8.1 — selector standardisation, OOP cleanup, keel v0.8.3 alignment
|
|
981
|
+
|
|
982
|
+
v0.8.1 is a polish pass on top of v0.8.0's signal migration. The biggest change is **the component selector prefix**: every sail component is now `sail-*`, where v0.8.0 still shipped a mix of `app-*` (older components) and `sail-*` (the v0.6 / v0.7 additions). The rest of the release is internal OOP cleanup that's mostly invisible to downstream code, plus a TypeScript downgrade and a keel pin.
|
|
983
|
+
|
|
984
|
+
| | v0.8.0 | v0.8.1 |
|
|
985
|
+
|---|---|---|
|
|
986
|
+
| keel | `v0.8.0` | `v0.8.3` |
|
|
987
|
+
| TypeScript | `~6.0.3` | `~5.9.3` |
|
|
988
|
+
| Component selector prefix | `app-*` (older) + `sail-*` (newer) | `sail-*` (uniform) |
|
|
989
|
+
|
|
990
|
+
### 1. Selector rename — `app-*` → `sail-*` (and `table-*` / `dynamic-field` / `record-form` → `sail-*`)
|
|
991
|
+
|
|
992
|
+
Every sail component selector now starts with `sail-`. **This is the breaking change**: any downstream template that embeds a sail component needs the prefix updated. Search-and-replace across your templates:
|
|
993
|
+
|
|
994
|
+
| Old | New |
|
|
995
|
+
|---|---|
|
|
996
|
+
| `<app-navigation>` | `<sail-navigation>` |
|
|
997
|
+
| `<app-login>` | `<sail-login>` |
|
|
998
|
+
| `<app-register>` | `<sail-register>` |
|
|
999
|
+
| `<app-chpass>` | `<sail-chpass>` |
|
|
1000
|
+
| `<app-confirm-register>` | `<sail-confirm-register>` |
|
|
1001
|
+
| `<app-confirm-chpass>` | `<sail-confirm-chpass>` |
|
|
1002
|
+
| `<app-twofactor-setup>` | `<sail-twofactor-setup>` |
|
|
1003
|
+
| `<app-twofactor-verify>` | `<sail-twofactor-verify>` |
|
|
1004
|
+
| `<app-trusted-devices>` | `<sail-trusted-devices>` |
|
|
1005
|
+
| `<app-account-deletion>` | `<sail-account-deletion>` |
|
|
1006
|
+
| `<app-consent-gate>` | `<sail-consent-gate>` |
|
|
1007
|
+
| `<app-otp-input>` | `<sail-otp-input>` |
|
|
1008
|
+
| `<app-social-login>` | `<sail-social-login>` |
|
|
1009
|
+
| `<app-plan-selector>` | `<sail-plan-selector>` |
|
|
1010
|
+
| `<app-checkout-button>` | `<sail-checkout-button>` |
|
|
1011
|
+
| `<app-payment-methods>` | `<sail-payment-methods>` |
|
|
1012
|
+
| `<table-list>` | `<sail-table-list>` |
|
|
1013
|
+
| `<table-search>` | `<sail-table-search>` |
|
|
1014
|
+
| `<table-edit>` | `<sail-table-edit>` |
|
|
1015
|
+
| `<table-detail>` | `<sail-table-detail>` |
|
|
1016
|
+
| `<table-lookup>` | `<sail-table-lookup>` |
|
|
1017
|
+
| `<table-report>` | `<sail-table-report>` |
|
|
1018
|
+
| `<table-form>` | `<sail-table-form>` |
|
|
1019
|
+
| `<record-form>` | `<sail-record-form>` |
|
|
1020
|
+
| `<dynamic-field>` | `<sail-dynamic-field>` |
|
|
1021
|
+
|
|
1022
|
+
The payout / user-payment-method selectors (`<sail-payout-bank-info-form>`, `<sail-payout-provider-onboarding>`, `<sail-user-payment-methods>`) were already on `sail-*` in v0.7.0 — no change there.
|
|
1023
|
+
|
|
1024
|
+
**Why:** the Angular style guide assigns `app-*` to the consumer application (Angular CLI defaults to it for new components). A library shipping `app-*` selectors collides with the downstream's own components and reads ambiguously in mixed templates. v0.8.1 makes the prefix uniform across sail and unambiguous against your code.
|
|
1025
|
+
|
|
1026
|
+
### 2. Internal: TableAction template-method pull-up
|
|
1027
|
+
|
|
1028
|
+
`executeAction()` was duplicated three times across `TableList`, `TableSearch`, and `TableDetail` in v0.8.0. v0.8.1 moves the body into `BaseTable` with two `protected` hooks:
|
|
1029
|
+
|
|
1030
|
+
```typescript
|
|
1031
|
+
protected beforeExecuteAction(action: TableAction, record?: Record<string, unknown>): boolean { return true; }
|
|
1032
|
+
protected onActionSuccess(action: TableAction, record?: Record<string, unknown>): void { /* no-op */ }
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
**For your subclasses:** if you have a custom view that extends `BaseTable` / `BaseForm` / `BaseView` and renders the action toolbar, you no longer write your own `executeAction()` — inherit it. Override `onActionSuccess()` to refresh your screen (the way `TableList` re-fetches) and `beforeExecuteAction()` to add per-screen pre-flight guards (the way `TableDetail` blocks unsaved rows).
|
|
1036
|
+
|
|
1037
|
+
**For your templates:** unchanged. Action buttons still call `(click)="executeAction(action, record)"`.
|
|
1038
|
+
|
|
1039
|
+
**Bonus:** `TableEdit` now renders per-record actions too (v0.8.0 was missing them) and re-fetches the record after a successful action.
|
|
1040
|
+
|
|
1041
|
+
### 3. Internal: `BaseAsync` pulled up onto 5 more components
|
|
1042
|
+
|
|
1043
|
+
`ChpassComponent`, `ConfirmChpassComponent`, `ConfirmRegisterComponent`, `TwoFactorSetupComponent`, and `TwoFactorVerifyComponent` were re-declaring their own `errorMessage` / `successMessage` signals and hand-rolled subscribe blocks. They now `extends BaseAsync` and use `this.run(obs, onSuccess, fallback)` — public template API is unchanged (`{{ errorMessage() }}` / `{{ successMessage() }}` still work).
|
|
1044
|
+
|
|
1045
|
+
### 4. Internal: `BaseRestService` + `requireAuth()` + `openRecordDialog()` + `linkedSignal` alias-inputs
|
|
1046
|
+
|
|
1047
|
+
- **`BaseRestService`** (`src/service/base_rest.service.ts`) owns the shared `inject(HttpClient)` and `url(path)` helper. `BackendService`, `BaseAuthService`, `BillingService`, `PayoutService`, and `UserPaymentMethodService` all extend it. The 27 hand-spelled `RestURL.httpHost + RestURL.xURL` getters in `BaseAuthService` are now `readonly` fields built via `this.url(...)`.
|
|
1048
|
+
- **`BaseTable.requireAuth(check, verb)`** replaces 8 copies of `alert('Missing authorization to … records')` across the codebase.
|
|
1049
|
+
- **`BaseView.openRecordDialog(record, isNew)`** collapses the duplicated `addRecord` / `editRecord` dialog-open bodies.
|
|
1050
|
+
- **`linkedSignal`** replaces the v0.8.0 `input() + signal + effect()` pattern across `TableList`, `TableSearch`, `TableEdit`, `TableDetail`, and `DynamicField`. One-line declarations per field; behaviour is unchanged (empty input doesn't override a `.set()` value).
|
|
1051
|
+
|
|
1052
|
+
If your downstream component extends `BaseTable` / `BaseView` and uses the alias-input pattern from v0.8.0 (`input + effect`), you can optionally migrate to `linkedSignal` — but the old pattern still compiles.
|
|
1053
|
+
|
|
1054
|
+
### 5. Internal: `BaseTable.caption` is no longer memoised; `Navigation.allMenuItems` via `toSignal`
|
|
1055
|
+
|
|
1056
|
+
`getCaption()` and `colCaption()` previously cached on first read, so `tableName` updates after first render returned stale captions. They now recompute each call (cost is negligible). `Navigation` reads `allMenuItems` via `toSignal(getMenus(), { initialValue: [] })` instead of a manual `subscribe` in `ngOnInit`.
|
|
1057
|
+
|
|
1058
|
+
### 6. TypeScript pinned to `~5.9.3`
|
|
1059
|
+
|
|
1060
|
+
`@angular/compiler-cli@21.2` declares `typescript >=5.9 <6.1` as a peer, but downstream projects report that TS `6.0.3` requires `--legacy-peer-deps` on `npm install` because of transitive package mismatches. To keep the install path clean for all consumers, v0.8.1 pins to `~5.9.3`. Bump your downstream's `package.json` to match:
|
|
1061
|
+
|
|
1062
|
+
```diff
|
|
1063
|
+
- "typescript": "~6.0.x"
|
|
1064
|
+
+ "typescript": "~5.9.3"
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
### 7. Clean install + recompile
|
|
1068
|
+
|
|
1069
|
+
```bash
|
|
1070
|
+
rm -rf node_modules package-lock.json
|
|
1071
|
+
npm install
|
|
1072
|
+
ng build
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
Expected errors after the upgrade:
|
|
1076
|
+
|
|
1077
|
+
- **`'app-*' is not a known element`** — selector rename, see step 1.
|
|
1078
|
+
- **`'@Output' is deprecated`** — leftover from the v0.8.0 migration; finish it.
|
|
1079
|
+
- **`peerinvalid` / `--legacy-peer-deps` prompts on `npm install`** — your project still has TS 6.x. Downgrade per step 6.
|
|
1080
|
+
|
|
1081
|
+
### 8. Backend alignment
|
|
1082
|
+
|
|
1083
|
+
v0.8.1 targets **keel v0.8.3**. The shared keel API surface didn't grow in this release; the version pin tracks keel's matching v0.8.x line. See [keel/README.md → Migration Guide](https://github.com/nauticana/keel/blob/main/README.md) for the matching backend changes.
|
|
1084
|
+
|
|
980
1085
|
## Modernization items
|
|
981
1086
|
|
|
982
1087
|
When you extend sail in your own app, apply the same patterns sail itself follows. Downstream projects that copied templates or scaffolding from earlier sail versions will benefit from the same sweep. This list is a checklist — none are sail-specific.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "0.8.
|
|
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": "~
|
|
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
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Set errorMessage from an HTTP error object; falls back to `fallback`.
|
|
25
|
+
*
|
|
26
|
+
* Reads `err.error.detail` first (keel emits RFC 7807 ProblemDetail with
|
|
27
|
+
* `detail` as the human-readable field), then `err.error.message` for
|
|
28
|
+
* legacy / non-RFC-7807 endpoints, then the supplied fallback.
|
|
29
|
+
*/
|
|
24
30
|
protected setError(err: any, fallback: string): void {
|
|
25
|
-
this.errorMessage.set(err?.error?.message ?? fallback);
|
|
31
|
+
this.errorMessage.set(err?.error?.detail ?? err?.error?.message ?? fallback);
|
|
26
32
|
this.loading.set(false);
|
|
27
33
|
}
|
|
28
34
|
|
|
@@ -1,59 +1,69 @@
|
|
|
1
1
|
import { inject, signal, WritableSignal } from "@angular/core";
|
|
2
2
|
import { ForeignKey, TableAction, TableColumn, TableDefinition } from "../../model/appdata";
|
|
3
|
-
import { ConstantValue } from "../../model/common";
|
|
3
|
+
import { ConstantValue, LookupStyle, OpCode } from "../../model/common";
|
|
4
4
|
import { BaseAuthService } from "../../service/auth.service";
|
|
5
|
+
import { BackendService } from "../../service/rest_service";
|
|
5
6
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
|
|
7
|
+
import { titleCase } from "../../util/text";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/** CRUD verb used by `requireAuth()` to compose the "Missing authorization" alert. */
|
|
11
|
+
export type AuthVerb = 'create' | 'read' | 'update' | 'delete' | 'view';
|
|
6
12
|
|
|
7
13
|
|
|
8
14
|
export abstract class BaseTable {
|
|
9
15
|
protected readonly cacheService = inject(BaseAuthService);
|
|
16
|
+
protected readonly backendService = inject(BackendService);
|
|
10
17
|
protected readonly config: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
11
18
|
protected readonly dialogWidth: string = '400px';
|
|
12
19
|
readonly tableName: WritableSignal<string> = signal('');
|
|
20
|
+
/** Optional consumer-set override; when empty the title-cased table name is used. */
|
|
13
21
|
caption = '';
|
|
22
|
+
/** Consumer-set per-field option overrides (per fieldName). When unset, options come from the column's LookupDomain / LookupTable. */
|
|
14
23
|
fieldOptions: {[key: string]: ConstantValue[]} = {};
|
|
15
|
-
fieldLabels: {[key: string]: string} = {};
|
|
16
24
|
isReadOnlyBound = this.isReadOnly.bind(this);
|
|
17
25
|
|
|
18
26
|
isReadOnly(_fieldName: string): boolean {
|
|
19
27
|
return false;
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
titleCase(
|
|
23
|
-
|
|
24
|
-
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
25
|
-
}).join(' ');
|
|
26
|
-
}
|
|
30
|
+
/** Re-exported on the class so templates can call `titleCase(x)` without an extra import in the consumer. */
|
|
31
|
+
titleCase(str: string): string { return titleCase(str); }
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
return this.caption;
|
|
33
|
+
/** Display title for the active table; recomputes when `tableName` changes. */
|
|
34
|
+
getCaption(): string {
|
|
35
|
+
return this.caption || titleCase(this.tableName());
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
/** Display label for a column; uses the metadata Caption, otherwise title-cases the field name. */
|
|
35
39
|
colCaption(fieldName: string): string {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
this.fieldLabels[fieldName] = col?.Caption || this.titleCase(fieldName);
|
|
39
|
-
}
|
|
40
|
-
return this.fieldLabels[fieldName];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
canRead() {
|
|
44
|
-
return this.cacheService.canRead(this.tableName());
|
|
40
|
+
const col = this.getColumn(fieldName);
|
|
41
|
+
return col?.Caption || titleCase(fieldName);
|
|
45
42
|
}
|
|
46
43
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
44
|
+
canRead() { return this.cacheService.canRead(this.tableName()); }
|
|
45
|
+
canCreate() { return this.cacheService.canCreate(this.tableName()); }
|
|
46
|
+
canUpdate() { return this.cacheService.canUpdate(this.tableName()); }
|
|
47
|
+
canDelete() { return this.cacheService.canDelete(this.tableName()); }
|
|
50
48
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
/** True when the record is a locally-inserted row not yet persisted (op_code === 'I'). */
|
|
50
|
+
isInserted(record: Record<string, unknown>): boolean { return record[this.config.opField] === OpCode.Insert; }
|
|
51
|
+
/** True when the record is marked for update (op_code === 'U'). */
|
|
52
|
+
isUpdated(record: Record<string, unknown>): boolean { return record[this.config.opField] === OpCode.Update; }
|
|
53
|
+
/** True when the record is marked for delete (op_code === 'D'). */
|
|
54
|
+
isDeleted(record: Record<string, unknown>): boolean { return record[this.config.opField] === OpCode.Delete; }
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Authorization guard with shared "Missing authorization" copy. Pass the
|
|
58
|
+
* permission predicate (typically a `canX()` method on this class) and
|
|
59
|
+
* the verb that fills the alert. Returns true when allowed.
|
|
60
|
+
*
|
|
61
|
+
* if (!this.requireAuth(() => this.canDelete(), 'delete')) return;
|
|
62
|
+
*/
|
|
63
|
+
protected requireAuth(check: () => boolean, verb: AuthVerb): boolean {
|
|
64
|
+
if (check()) return true;
|
|
65
|
+
alert(`Missing authorization to ${verb} records`);
|
|
66
|
+
return false;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
/**
|
|
@@ -81,6 +91,55 @@ export abstract class BaseTable {
|
|
|
81
91
|
return this.cacheService.canExecute(action.authorityObject, action.authorityCheck, this.tableName());
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Fire a TableAction. Template method:
|
|
96
|
+
*
|
|
97
|
+
* 1. Authorization check + alert (shared)
|
|
98
|
+
* 2. Subclass-specific pre-flight guard via `beforeExecuteAction()` —
|
|
99
|
+
* returns false to abort silently (the hook has already shown its
|
|
100
|
+
* own alert).
|
|
101
|
+
* 3. Optional confirm prompt from `action.confirmMessage`
|
|
102
|
+
* 4. POST `{}` for table-level actions or `primaryKeyValues(record)`
|
|
103
|
+
* for record-specific actions
|
|
104
|
+
* 5. On success, dispatch `onActionSuccess()` so subclasses can refresh
|
|
105
|
+
* their views; on error, log and alert.
|
|
106
|
+
*
|
|
107
|
+
* Subclasses typically only override the two hooks.
|
|
108
|
+
*/
|
|
109
|
+
executeAction(action: TableAction, record?: Record<string, unknown>): void {
|
|
110
|
+
if (!this.canExecuteAction(action)) {
|
|
111
|
+
alert(`Missing authorization for action ${action.authorityObject}/${action.authorityCheck}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!this.beforeExecuteAction(action, record)) return;
|
|
115
|
+
if (action.confirmMessage && !confirm(action.confirmMessage)) return;
|
|
116
|
+
const body = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
|
|
117
|
+
this.backendService.executeAction(action.method, body).subscribe({
|
|
118
|
+
next: () => this.onActionSuccess(action, record),
|
|
119
|
+
error: (err) => {
|
|
120
|
+
console.error(`Action ${action.action} failed`, err);
|
|
121
|
+
alert(err?.error?.detail ?? `Failed to ${action.caption}`);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Pre-flight hook for `executeAction()`. Return false to abort the action
|
|
128
|
+
* (the override should `alert()` or otherwise surface why). Default: allow.
|
|
129
|
+
*/
|
|
130
|
+
protected beforeExecuteAction(_action: TableAction, _record?: Record<string, unknown>): boolean {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Post-success hook for `executeAction()`. Override to refresh the screen
|
|
136
|
+
* — e.g. `TableList` re-fetches the row list. Default: no-op (screens
|
|
137
|
+
* driven by parent state, like `TableDetail`, leave refresh to the parent).
|
|
138
|
+
*/
|
|
139
|
+
protected onActionSuccess(_action: TableAction, _record?: Record<string, unknown>): void {
|
|
140
|
+
// intentionally empty
|
|
141
|
+
}
|
|
142
|
+
|
|
84
143
|
/**
|
|
85
144
|
* Pulls the primary-key column values off a record into a plain
|
|
86
145
|
* object keyed by the columns' PascalName. Used as the request
|
|
@@ -99,7 +158,7 @@ export abstract class BaseTable {
|
|
|
99
158
|
}
|
|
100
159
|
|
|
101
160
|
emptyRecord(): any {
|
|
102
|
-
const record : any = {[this.config.opField]:
|
|
161
|
+
const record : any = {[this.config.opField]: OpCode.Insert}
|
|
103
162
|
const tableDef = this.cacheService.getTableDefinition(this.tableName());
|
|
104
163
|
const tableOverride = this.config.tableOverrides?.[this.tableName()];
|
|
105
164
|
const editableTimestamps = new Set(tableOverride?.editableTimestamps ?? []);
|
|
@@ -178,7 +237,7 @@ export abstract class BaseTable {
|
|
|
178
237
|
const match = options?.find(o => o.Value === String(rawValue));
|
|
179
238
|
if (match) return match.Caption ?? String(rawValue);
|
|
180
239
|
}
|
|
181
|
-
if (col.LookupTable && col.LookupStyle ===
|
|
240
|
+
if (col.LookupTable && col.LookupStyle === LookupStyle.Dropdown) {
|
|
182
241
|
const options = this.cacheService.getTableValues(col.LookupTable);
|
|
183
242
|
const match = options?.find(o => o.Value === String(rawValue));
|
|
184
243
|
if (match) return match.Caption ?? String(rawValue);
|
|
@@ -261,7 +320,7 @@ export abstract class BaseTable {
|
|
|
261
320
|
readyToSave(record: any, original?: any) {
|
|
262
321
|
this.restoreRecordTimeStamp(record);
|
|
263
322
|
const op = record[this.config.opField];
|
|
264
|
-
if (op ===
|
|
323
|
+
if (op === OpCode.Insert || op === OpCode.Delete) return; // already marked
|
|
265
324
|
// Original snapshot may carry display-formatted timestamps; restore both
|
|
266
325
|
// sides to the same wire format before comparing, otherwise scalar
|
|
267
326
|
// fields like Passdate look "modified" purely due to formatting.
|
|
@@ -269,11 +328,11 @@ export abstract class BaseTable {
|
|
|
269
328
|
const normalized = { ...original };
|
|
270
329
|
this.restoreRecordTimeStamp(normalized);
|
|
271
330
|
if (!this.isRecordDirty(record, normalized)) {
|
|
272
|
-
record[this.config.opField] =
|
|
331
|
+
record[this.config.opField] = OpCode.Readonly;
|
|
273
332
|
return;
|
|
274
333
|
}
|
|
275
334
|
}
|
|
276
|
-
record[this.config.opField] =
|
|
335
|
+
record[this.config.opField] = OpCode.Update;
|
|
277
336
|
}
|
|
278
337
|
|
|
279
338
|
/** Shallow compare of own scalar fields, ignoring child arrays and op_code. */
|
|
@@ -308,7 +367,7 @@ export abstract class BaseTable {
|
|
|
308
367
|
if (col && col.LookupDomain) {
|
|
309
368
|
return this.cacheService.getDomainValues(col.LookupDomain);
|
|
310
369
|
}
|
|
311
|
-
if (col && col.LookupTable && col.LookupStyle ===
|
|
370
|
+
if (col && col.LookupTable && col.LookupStyle === LookupStyle.Dropdown) {
|
|
312
371
|
return this.cacheService.getTableValues(col.LookupTable);
|
|
313
372
|
}
|
|
314
373
|
return undefined;
|
|
@@ -35,66 +35,53 @@ export abstract class BaseView extends BaseTable {
|
|
|
35
35
|
return tableDef.Keys.map((key) => record[key.PascalName]).join('_');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
});
|
|
49
|
-
}
|
|
38
|
+
/**
|
|
39
|
+
* Shared row-dialog opener. Builds the dialog data and wires the result
|
|
40
|
+
* straight into `handleUpdate()` (subclass-specific). Used by both
|
|
41
|
+
* `addRecord()` (new row + empty `original`) and `editRecord()` (existing
|
|
42
|
+
* row passed through verbatim).
|
|
43
|
+
*/
|
|
44
|
+
private openRecordDialog(record: {[key: string]: any}, isNew: boolean): void {
|
|
45
|
+
const dialogData = this.getDialogData(record, isNew);
|
|
46
|
+
const apiName = this.apiName();
|
|
47
|
+
if (apiName) dialogData['apiName'] = apiName;
|
|
50
48
|
|
|
51
|
-
|
|
49
|
+
this.dialog.open(this.dialogComponent(), {
|
|
52
50
|
width: '90vw',
|
|
53
51
|
maxWidth: '1200px',
|
|
54
52
|
maxHeight: '90vh',
|
|
55
53
|
disableClose: true,
|
|
56
|
-
data:
|
|
54
|
+
data: dialogData,
|
|
55
|
+
}).afterClosed().subscribe((result) => {
|
|
56
|
+
if (result) this.handleUpdate(result, isNew, isNew ? {} : record);
|
|
57
57
|
});
|
|
58
|
+
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
addRecord() {
|
|
61
|
+
if (!this.requireAuth(() => this.canCreate(), 'create')) return;
|
|
62
|
+
const record = this.emptyRecord();
|
|
63
|
+
// Seed columns from an existing row when emptyRecord() returned just the
|
|
64
|
+
// op-code field (table metadata wasn't loaded yet).
|
|
65
|
+
if (Object.keys(record).length === 1 && this.records.length > 0) {
|
|
66
|
+
Object.keys(this.records[0]).forEach((key) => { record[key] = ''; });
|
|
67
|
+
}
|
|
68
|
+
this.openRecordDialog(record, true);
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
/**
|
|
65
72
|
* Open the row dialog. Renamed conceptually from "edit": when the user
|
|
66
|
-
* has only `canRead()
|
|
73
|
+
* has only `canRead()`, we still want to show them the record + child rows.
|
|
67
74
|
* The dialog itself (TableEdit) handles read-only mode — its Edit/New/Delete
|
|
68
75
|
* buttons are gated by the same permissions.
|
|
69
76
|
* Block only when neither read nor update is granted.
|
|
70
77
|
*/
|
|
71
78
|
editRecord(record: {[key: string]: any}) {
|
|
72
|
-
if (!this.canUpdate() && !this.canRead())
|
|
73
|
-
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
const dialogData = this.getDialogData(record, false);
|
|
77
|
-
const apiName = this.apiName();
|
|
78
|
-
if (apiName) {
|
|
79
|
-
dialogData['apiName'] = apiName;
|
|
80
|
-
}
|
|
81
|
-
const dialogRef = this.dialog.open(this.dialogComponent(), {
|
|
82
|
-
width: '90vw',
|
|
83
|
-
maxWidth: '1200px',
|
|
84
|
-
maxHeight: '90vh',
|
|
85
|
-
disableClose: true,
|
|
86
|
-
data: dialogData,
|
|
87
|
-
});
|
|
88
|
-
dialogRef.afterClosed().subscribe((result) => {
|
|
89
|
-
if (result) this.handleUpdate(result, false, record);
|
|
90
|
-
});
|
|
79
|
+
if (!this.canUpdate() && !this.requireAuth(() => this.canRead(), 'view')) return;
|
|
80
|
+
this.openRecordDialog(record, false);
|
|
91
81
|
}
|
|
92
82
|
|
|
93
83
|
deleteRecord(record: {[key: string]: any}) {
|
|
94
|
-
if (!this.canDelete())
|
|
95
|
-
alert('Missing authorization to delete records');
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
84
|
+
if (!this.requireAuth(() => this.canDelete(), 'delete')) return;
|
|
98
85
|
this.handleDelete(record);
|
|
99
86
|
}
|
|
100
87
|
}
|
|
@@ -13,7 +13,7 @@ import { CheckoutMode } from '../../model/appdata';
|
|
|
13
13
|
* rejected with 400.
|
|
14
14
|
*/
|
|
15
15
|
@Component({
|
|
16
|
-
selector: '
|
|
16
|
+
selector: 'sail-checkout-button',
|
|
17
17
|
templateUrl: './checkout_button.html',
|
|
18
18
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
19
19
|
encapsulation: ViewEncapsulation.None,
|
|
@@ -11,7 +11,7 @@ import { PaymentMethod } from '../../model/appdata';
|
|
|
11
11
|
* `[portalUrl]` to show it; otherwise the button is hidden.
|
|
12
12
|
*/
|
|
13
13
|
@Component({
|
|
14
|
-
selector: '
|
|
14
|
+
selector: 'sail-payment-methods',
|
|
15
15
|
templateUrl: './payment_methods.html',
|
|
16
16
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
17
17
|
encapsulation: ViewEncapsulation.None,
|
|
@@ -7,7 +7,7 @@ import { PublicPlan } from '../../model/appdata';
|
|
|
7
7
|
* The caller provides the plans and handles the selection event.
|
|
8
8
|
*/
|
|
9
9
|
@Component({
|
|
10
|
-
selector: '
|
|
10
|
+
selector: 'sail-plan-selector',
|
|
11
11
|
templateUrl: './plan_selector.html',
|
|
12
12
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
13
13
|
encapsulation: ViewEncapsulation.None,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component,
|
|
1
|
+
import { ChangeDetectionStrategy, Component, inject, input, linkedSignal, output, ViewEncapsulation } from "@angular/core";
|
|
2
2
|
import { MatButtonModule } from "@angular/material/button";
|
|
3
3
|
import { MatCheckboxModule } from "@angular/material/checkbox";
|
|
4
4
|
import { MatDialog } from "@angular/material/dialog";
|
|
@@ -7,11 +7,11 @@ import { MatIconModule } from "@angular/material/icon";
|
|
|
7
7
|
import { MatInputModule } from "@angular/material/input";
|
|
8
8
|
import { MatSelectModule } from "@angular/material/select";
|
|
9
9
|
import { BaseTable } from "../abstract/base_table";
|
|
10
|
-
import { ConstantValue } from "../../model/common";
|
|
10
|
+
import { ConstantValue, LookupStyle } from "../../model/common";
|
|
11
11
|
import { TableLookup } from "../table/table_lookup";
|
|
12
12
|
|
|
13
13
|
@Component({
|
|
14
|
-
selector: 'dynamic-field',
|
|
14
|
+
selector: 'sail-dynamic-field',
|
|
15
15
|
templateUrl: './form_field.html',
|
|
16
16
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
17
17
|
encapsulation: ViewEncapsulation.None,
|
|
@@ -31,7 +31,7 @@ import { TableLookup } from "../table/table_lookup";
|
|
|
31
31
|
]
|
|
32
32
|
})
|
|
33
33
|
export class DynamicField extends BaseTable {
|
|
34
|
-
readonly tableNameInput = input('', { alias: 'tableName' });
|
|
34
|
+
protected readonly tableNameInput = input('', { alias: 'tableName' });
|
|
35
35
|
readonly value = input<any>(undefined);
|
|
36
36
|
readonly valueChange = output<any>();
|
|
37
37
|
readonly recordUpdate = output<{[key: string]: any}>();
|
|
@@ -41,19 +41,16 @@ export class DynamicField extends BaseTable {
|
|
|
41
41
|
readonly subscriptSizing = input<'fixed' | 'dynamic'>('dynamic');
|
|
42
42
|
readonly appearance = input<'fill' | 'outline'>('outline');
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
super();
|
|
48
|
-
effect(() => { const v = this.tableNameInput(); if (v) this.tableName.set(v); });
|
|
49
|
-
}
|
|
46
|
+
private readonly dialog = inject(MatDialog);
|
|
50
47
|
|
|
51
48
|
get col() {
|
|
52
49
|
return this.getColumn(this.field());
|
|
53
50
|
}
|
|
54
51
|
|
|
55
52
|
get inputType() {
|
|
56
|
-
if (!this.col || !this.col.InputType || this.col.LookupStyle ===
|
|
53
|
+
if (!this.col || !this.col.InputType || this.col.LookupStyle === LookupStyle.Search) return 'text';
|
|
57
54
|
return this.col.InputType;
|
|
58
55
|
}
|
|
59
56
|
|
|
@@ -90,7 +87,7 @@ export class DynamicField extends BaseTable {
|
|
|
90
87
|
}
|
|
91
88
|
|
|
92
89
|
isLookupTableSearch(): boolean {
|
|
93
|
-
return !!(this.col && this.col.LookupTable && this.col.LookupStyle ===
|
|
90
|
+
return !!(this.col && this.col.LookupTable && this.col.LookupStyle === LookupStyle.Search);
|
|
94
91
|
}
|
|
95
92
|
|
|
96
93
|
get maxLength() {
|