@nauticana/sail 1.1.9 → 1.1.10
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 +4 -2
- package/package.json +5 -1
- package/src/component/abstract/base_async.ts +9 -11
- package/src/component/abstract/base_form.ts +6 -5
- package/src/component/abstract/base_table.ts +131 -58
- package/src/component/abstract/base_view.ts +43 -27
- package/src/component/account/profile_editor.ts +2 -2
- package/src/component/billing/payment_methods.ts +1 -2
- package/src/component/billing/price_selector.html +2 -1
- package/src/component/billing/price_selector.ts +28 -13
- package/src/component/billing/trial_banner.ts +7 -1
- package/src/component/billing/usage_meter.ts +2 -2
- package/src/component/dashboard/data_card.html +1 -1
- package/src/component/dashboard/entity_selector.ts +21 -23
- package/src/component/form/form_field.html +6 -5
- package/src/component/form/form_field.ts +39 -21
- package/src/component/form/form_record.html +3 -3
- package/src/component/form/form_record.ts +23 -8
- package/src/component/form/form_table.html +2 -2
- package/src/component/form/form_table.ts +6 -5
- package/src/component/login/account_deletion.ts +6 -6
- package/src/component/login/chpass_component.html +4 -4
- package/src/component/login/chpass_component.ts +0 -1
- package/src/component/login/confirm_chpass_component.html +4 -4
- package/src/component/login/confirm_chpass_component.ts +1 -4
- package/src/component/login/consent_gate.html +6 -6
- package/src/component/login/consent_gate.ts +17 -24
- package/src/component/login/login_component.html +7 -4
- package/src/component/login/login_component.ts +9 -5
- package/src/component/login/otp_input.html +13 -5
- package/src/component/login/otp_input.ts +34 -27
- package/src/component/login/register_component.html +31 -14
- package/src/component/login/register_component.ts +80 -89
- package/src/component/login/social_login.html +2 -2
- package/src/component/login/social_login.ts +20 -13
- package/src/component/navigation/navigation.html +2 -2
- package/src/component/navigation/navigation.ts +2 -0
- package/src/component/oauth_connection/oauth_connection_panel.ts +24 -3
- package/src/component/payment/user_payment_methods.html +2 -2
- package/src/component/payment/user_payment_methods.ts +12 -23
- package/src/component/payout/bank_info_form.html +4 -4
- package/src/component/payout/bank_info_form.ts +22 -26
- package/src/component/payout/provider_onboarding.html +5 -5
- package/src/component/payout/provider_onboarding.ts +28 -48
- package/src/component/security/trusted_devices.html +1 -1
- package/src/component/security/trusted_devices.ts +0 -1
- package/src/component/security/twofactor_setup.html +8 -4
- package/src/component/security/twofactor_setup.ts +21 -3
- package/src/component/security/twofactor_verify.html +2 -1
- package/src/component/security/twofactor_verify.ts +2 -8
- package/src/component/table/table_detail.html +15 -14
- package/src/component/table/table_detail.ts +62 -41
- package/src/component/table/table_edit.html +15 -11
- package/src/component/table/table_edit.ts +94 -67
- package/src/component/table/table_list.html +45 -12
- package/src/component/table/table_list.ts +98 -26
- package/src/component/table/table_lookup.html +11 -8
- package/src/component/table/table_lookup.ts +45 -37
- package/src/component/table/table_report.html +2 -2
- package/src/component/table/table_report.ts +37 -31
- package/src/component/table/table_search.html +4 -4
- package/src/component/table/table_search.ts +36 -45
- package/src/config.ts +1 -1
- package/src/index.ts +1 -0
- package/src/model/appdata.ts +5 -9
- package/src/model/common.ts +27 -27
- package/src/model/decorator.ts +25 -21
- package/src/service/api-response.interceptor.ts +4 -1
- package/src/service/auth.interceptor.ts +27 -7
- package/src/service/auth.service.ts +188 -134
- package/src/service/label.service.ts +6 -5
- package/src/service/oauth_connection.service.ts +2 -2
- package/src/service/rest_service.ts +57 -13
- package/src/service/rest_url.ts +23 -7
- package/src/service/script_loader.ts +12 -2
- package/src/util/errors.ts +20 -0
- package/src/util/money.ts +19 -7
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
6
6
|
|
|
7
7
|
**Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows.
|
|
8
8
|
|
|
9
|
+
> **v1.1.10 is a breaking release.** The base classes migrated to signals (`records()`, `editableRecord()`, `isNew()` …), `login()`/`loginWithGoogle()` now return Observables, CRUD errors are typed `SailApiError`, `BaseView` split into read-only `BaseView` + `AbstractEditableView`, the decorators are renamed `IsSailString`/`IsSailNumeric`, and `configureRestUrls()` is finally safe to call from a subclass constructor. The complete old→new mapping is machine-readable in [`migration_guide.json`](migration_guide.json). Consumers must fully migrate before pushing to test or prod.
|
|
10
|
+
|
|
9
11
|
## What it provides
|
|
10
12
|
|
|
11
13
|
| Category | Exports |
|
|
@@ -22,10 +24,10 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
22
24
|
| **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
|
|
23
25
|
| **Payments / SCA** | `UserPaymentMethodsComponent`, `ScaConfirmComponent`, `ScaConfirmer` (port), `SCA_CONFIRMER` (token) |
|
|
24
26
|
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
25
|
-
| **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `BaseAsync`, `BaseRestService` |
|
|
27
|
+
| **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `AbstractEditableView`, `BaseAsync`, `BaseRestService` |
|
|
26
28
|
| **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
|
|
27
29
|
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, 2FA types, etc. |
|
|
28
|
-
| **Decorators** | `@
|
|
30
|
+
| **Decorators** | `@IsSailString()`, `@IsSailNumeric()` (class-validator based) |
|
|
29
31
|
| **Utils** | `titleCase()`, `fromPriceLabel()` |
|
|
30
32
|
|
|
31
33
|
## Quick start
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.10",
|
|
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
|
"scripts": {
|
|
@@ -61,5 +61,9 @@
|
|
|
61
61
|
"class-validator": "^0.15.1",
|
|
62
62
|
"rxjs": "~7.8.0",
|
|
63
63
|
"typescript": "~5.9.3"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@types/qrcode": "^1.5.6",
|
|
67
|
+
"qrcode": "^1.5.4"
|
|
64
68
|
}
|
|
65
69
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { signal } from '@angular/core';
|
|
1
|
+
import { DestroyRef, inject, signal } from '@angular/core';
|
|
2
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
2
3
|
import { Observable } from 'rxjs';
|
|
4
|
+
import { errorDetail } from '../../util/errors';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Base class for components that perform async operations and need to show
|
|
@@ -10,6 +12,7 @@ import { Observable } from 'rxjs';
|
|
|
10
12
|
* into the shared state automatically.
|
|
11
13
|
*/
|
|
12
14
|
export abstract class BaseAsync {
|
|
15
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
13
16
|
readonly loading = signal(false);
|
|
14
17
|
readonly errorMessage = signal('');
|
|
15
18
|
readonly successMessage = signal('');
|
|
@@ -20,15 +23,9 @@ export abstract class BaseAsync {
|
|
|
20
23
|
this.successMessage.set('');
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
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
|
-
*/
|
|
30
|
-
protected setError(err: any, fallback: string): void {
|
|
31
|
-
this.errorMessage.set(err?.error?.detail ?? err?.error?.message ?? fallback);
|
|
26
|
+
/** Set errorMessage from any sail error shape (SailApiError / HttpErrorResponse / Error). */
|
|
27
|
+
protected setError(err: unknown, fallback: string): void {
|
|
28
|
+
this.errorMessage.set(errorDetail(err, fallback));
|
|
32
29
|
this.loading.set(false);
|
|
33
30
|
}
|
|
34
31
|
|
|
@@ -37,6 +34,7 @@ export abstract class BaseAsync {
|
|
|
37
34
|
* Clears messages, sets loading, invokes onSuccess on next, and routes
|
|
38
35
|
* errors through setError with the given fallback message. Pass `onError`
|
|
39
36
|
* when the component must also react to failures (e.g. emit an output).
|
|
37
|
+
* The subscription is torn down with the component.
|
|
40
38
|
*/
|
|
41
39
|
protected run<T>(
|
|
42
40
|
obs: Observable<T>,
|
|
@@ -46,7 +44,7 @@ export abstract class BaseAsync {
|
|
|
46
44
|
): void {
|
|
47
45
|
this.clearMessages();
|
|
48
46
|
this.loading.set(true);
|
|
49
|
-
obs.subscribe({
|
|
47
|
+
obs.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
|
50
48
|
next: (value) => {
|
|
51
49
|
this.loading.set(false);
|
|
52
50
|
onSuccess(value);
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
import { signal } from "@angular/core";
|
|
1
2
|
import { BaseTable } from "./base_table";
|
|
2
3
|
|
|
3
4
|
export abstract class BaseForm extends BaseTable {
|
|
4
|
-
editableRecord
|
|
5
|
-
isNew = false;
|
|
6
|
-
title = '';
|
|
5
|
+
readonly editableRecord = signal<{[key: string]: unknown}>({});
|
|
6
|
+
readonly isNew = signal(false);
|
|
7
|
+
readonly title = signal('');
|
|
7
8
|
|
|
8
9
|
override isReadOnly(fieldName: string): boolean {
|
|
9
10
|
// keel column_display_attribute: 'R' is always display-only; 'I'
|
|
10
11
|
// (insert-only) is editable while creating, locked once it exists.
|
|
11
12
|
const mode = this.getColumn(fieldName)?.DisplayMode;
|
|
12
13
|
if (mode === 'R' || mode === 'U') return true; // 'U' = keel-set audit stamp
|
|
13
|
-
if (mode === 'I') return !this.isNew;
|
|
14
|
-
return !this.isNew && this.isKey(fieldName);
|
|
14
|
+
if (mode === 'I') return !this.isNew();
|
|
15
|
+
return !this.isNew() && this.isKey(fieldName);
|
|
15
16
|
}
|
|
16
17
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { inject,
|
|
1
|
+
import { computed, inject, Injector, WritableSignal } from "@angular/core";
|
|
2
2
|
import { ForeignKey, TableAction, TableColumn, TableDefinition } from "../../model/appdata";
|
|
3
3
|
import { ConstantValue, LookupStyle, OpCode } from "../../model/common";
|
|
4
4
|
import { BaseAuthService } from "../../service/auth.service";
|
|
5
5
|
import { BackendService } from "../../service/rest_service";
|
|
6
6
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
|
|
7
7
|
import { titleCase } from "../../util/text";
|
|
8
|
+
import { errorDetail } from "../../util/errors";
|
|
8
9
|
import { MatDialog } from "@angular/material/dialog";
|
|
9
10
|
import { RevealDialog } from "../table/reveal_dialog";
|
|
10
11
|
|
|
@@ -12,6 +13,9 @@ import { RevealDialog } from "../table/reveal_dialog";
|
|
|
12
13
|
/** CRUD verb used by `requireAuth()` to compose the "Missing authorization" alert. */
|
|
13
14
|
export type AuthVerb = 'create' | 'read' | 'update' | 'delete' | 'view';
|
|
14
15
|
|
|
16
|
+
/** ISO-like timestamp prefix — gate for comparing two strings as instants. */
|
|
17
|
+
const ISO_PREFIX = /^\d{4}-\d{2}-\d{2}T/;
|
|
18
|
+
|
|
15
19
|
|
|
16
20
|
export abstract class BaseTable {
|
|
17
21
|
protected readonly cacheService = inject(BaseAuthService);
|
|
@@ -20,17 +24,21 @@ export abstract class BaseTable {
|
|
|
20
24
|
readonly #injector = inject(Injector);
|
|
21
25
|
protected readonly config: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
22
26
|
protected readonly dialogWidth: string = '400px';
|
|
23
|
-
|
|
27
|
+
/** Subclasses supply the source — a plain signal or a linkedSignal over an input. */
|
|
28
|
+
abstract readonly tableName: WritableSignal<string>;
|
|
24
29
|
/** Optional consumer-set override; when empty the title-cased table name is used. */
|
|
25
30
|
caption = '';
|
|
26
31
|
/** Consumer-set per-field option overrides (per fieldName). When unset, options come from the column's LookupDomain / LookupTable. */
|
|
27
32
|
fieldOptions: {[key: string]: ConstantValue[]} = {};
|
|
28
|
-
isReadOnlyBound = this.isReadOnly.bind(this);
|
|
29
33
|
|
|
30
34
|
isReadOnly(_fieldName: string): boolean {
|
|
31
35
|
return false;
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
/** Stable function-input for `[isReadOnly]`. Signal reads inside isReadOnly()
|
|
39
|
+
* overrides are tracked by the invoking template, so mode flips re-render. */
|
|
40
|
+
readonly isReadOnlyFn = (fieldName: string): boolean => this.isReadOnly(fieldName);
|
|
41
|
+
|
|
34
42
|
/** Re-exported on the class so templates can call `titleCase(x)` without an extra import in the consumer. */
|
|
35
43
|
titleCase(str: string): string { return titleCase(str); }
|
|
36
44
|
|
|
@@ -71,9 +79,8 @@ export abstract class BaseTable {
|
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* when the table has no custom actions.
|
|
82
|
+
* The table's registered TableAction list (basis `table_action` rows).
|
|
83
|
+
* Templates use the memoized `tableActions` / `recordActions` instead.
|
|
77
84
|
*/
|
|
78
85
|
getActions(recordSpecific?: boolean): TableAction[] {
|
|
79
86
|
const tableDef = this.cacheService.getTableDefinition(this.tableName());
|
|
@@ -82,6 +89,24 @@ export abstract class BaseTable {
|
|
|
82
89
|
return actions.filter((a) => a.recordSpecific === recordSpecific).sort(this.sortActions);
|
|
83
90
|
}
|
|
84
91
|
|
|
92
|
+
// Memoized for templates; the reactive appdata cache retriggers these.
|
|
93
|
+
readonly tableActions = computed(() => this.getActions(false));
|
|
94
|
+
readonly recordActions = computed(() => this.getActions(true));
|
|
95
|
+
|
|
96
|
+
/** Metadata-driven visible columns, memoized. */
|
|
97
|
+
readonly metadataColumns = computed(() => this.getDisplayedColumns());
|
|
98
|
+
|
|
99
|
+
private readonly columnMap = computed(() => {
|
|
100
|
+
const map = new Map<string, TableColumn>();
|
|
101
|
+
for (const col of this.cacheService.getTableDefinition(this.tableName())?.Columns ?? []) {
|
|
102
|
+
map.set(col.PascalName, col);
|
|
103
|
+
}
|
|
104
|
+
return map;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
private readonly keyColumns = computed(() =>
|
|
108
|
+
this.cacheService.getTableDefinition(this.tableName())?.Keys ?? []);
|
|
109
|
+
|
|
85
110
|
private sortActions = (a: TableAction, b: TableAction) =>
|
|
86
111
|
(a.displayOrder ?? 0) - (b.displayOrder ?? 0);
|
|
87
112
|
|
|
@@ -134,7 +159,7 @@ export abstract class BaseTable {
|
|
|
134
159
|
},
|
|
135
160
|
error: (err) => {
|
|
136
161
|
console.error(`Action ${action.action} failed`, err);
|
|
137
|
-
alert(err
|
|
162
|
+
alert(errorDetail(err, `Failed to ${action.caption}`));
|
|
138
163
|
},
|
|
139
164
|
});
|
|
140
165
|
}
|
|
@@ -173,8 +198,29 @@ export abstract class BaseTable {
|
|
|
173
198
|
return pk;
|
|
174
199
|
}
|
|
175
200
|
|
|
176
|
-
|
|
177
|
-
|
|
201
|
+
/** @for track key: joined PK values; index when keys are missing/unset. */
|
|
202
|
+
trackByRecord(index: number, record: Record<string, unknown>): string | number {
|
|
203
|
+
const keys = this.keyColumns();
|
|
204
|
+
if (!keys.length) return index;
|
|
205
|
+
const values = keys.map((key) => record[key.PascalName]);
|
|
206
|
+
if (values.some((v) => v === undefined || v === null || v === '')) return index;
|
|
207
|
+
return values.join('_');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** DefaultValue as a typed literal; undefined for expression defaults (`now()`, `nextval(...)`). */
|
|
211
|
+
private literalDefault(field: TableColumn): unknown {
|
|
212
|
+
const dv = field.DefaultValue;
|
|
213
|
+
if (field.DataType === 'boolean') return dv === 'true';
|
|
214
|
+
if (dv == null || dv === '' || dv.includes('(')) return undefined;
|
|
215
|
+
if (field.DataType === 'integer' || field.DataType === 'float') {
|
|
216
|
+
const n = Number(dv);
|
|
217
|
+
return isNaN(n) ? undefined : n;
|
|
218
|
+
}
|
|
219
|
+
return dv;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
emptyRecord(): Record<string, unknown> {
|
|
223
|
+
const record: Record<string, unknown> = {[this.config.opField]: OpCode.Insert};
|
|
178
224
|
const tableDef = this.cacheService.getTableDefinition(this.tableName());
|
|
179
225
|
const tableOverride = this.config.tableOverrides?.[this.tableName()];
|
|
180
226
|
const editableTimestamps = new Set(tableOverride?.editableTimestamps ?? []);
|
|
@@ -186,16 +232,15 @@ export abstract class BaseTable {
|
|
|
186
232
|
return; // auto-generated by database sequence
|
|
187
233
|
}
|
|
188
234
|
// column_display_attribute modes (explicit, win over the
|
|
189
|
-
// timestamp heuristic). H/R/U are server-managed — omit so the
|
|
235
|
+
// timestamp heuristic). H/S/R/U are server-managed — omit so the
|
|
190
236
|
// DB default/keel stamp fills them. D = seed the column default.
|
|
191
|
-
if (field.DisplayMode === 'H' || field.DisplayMode === '
|
|
192
|
-
|| field.DisplayMode === 'U') {
|
|
237
|
+
if (field.DisplayMode === 'H' || field.DisplayMode === 'S'
|
|
238
|
+
|| field.DisplayMode === 'R' || field.DisplayMode === 'U') {
|
|
193
239
|
return;
|
|
194
240
|
}
|
|
195
241
|
if (field.DisplayMode === 'D') {
|
|
196
|
-
record[field.PascalName] = field
|
|
197
|
-
|
|
198
|
-
: (field.DefaultValue ?? '');
|
|
242
|
+
record[field.PascalName] = this.literalDefault(field)
|
|
243
|
+
?? (field.DataType === 'boolean' ? false : '');
|
|
199
244
|
return;
|
|
200
245
|
}
|
|
201
246
|
if (!field.DisplayMode && field.HasDefault && field.DataType === 'timestamp'
|
|
@@ -203,9 +248,9 @@ export abstract class BaseTable {
|
|
|
203
248
|
return; // audit column — server-managed, don't include
|
|
204
249
|
}
|
|
205
250
|
if (field.HasDefault && field.DataType !== 'timestamp') {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
: field.
|
|
251
|
+
const dv = this.literalDefault(field);
|
|
252
|
+
record[field.PascalName] = dv !== undefined ? dv
|
|
253
|
+
: (field.DataType === 'boolean' ? false : '');
|
|
209
254
|
} else {
|
|
210
255
|
record[field.PascalName] = field.DataType === 'boolean' ? false : '';
|
|
211
256
|
}
|
|
@@ -228,7 +273,7 @@ export abstract class BaseTable {
|
|
|
228
273
|
return record;
|
|
229
274
|
}
|
|
230
275
|
|
|
231
|
-
getDisplayedColumns(records:
|
|
276
|
+
getDisplayedColumns(records: Record<string, unknown>[] = []): string[] {
|
|
232
277
|
const tableDef = this.cacheService.getTableDefinition(this.tableName());
|
|
233
278
|
const tableOverride = this.config.tableOverrides?.[this.tableName()];
|
|
234
279
|
const hidden = new Set([
|
|
@@ -238,6 +283,8 @@ export abstract class BaseTable {
|
|
|
238
283
|
const editableTimestamps = new Set(tableOverride?.editableTimestamps ?? []);
|
|
239
284
|
if (tableDef && tableDef.Columns && tableDef.Columns.length > 0) {
|
|
240
285
|
return tableDef.Columns
|
|
286
|
+
// column_display_attribute: 'H' hidden and 'S' secret never render.
|
|
287
|
+
.filter(col => col.DisplayMode !== 'H' && col.DisplayMode !== 'S')
|
|
241
288
|
.filter(col => !(col.HasDefault && col.DataType === 'timestamp'
|
|
242
289
|
&& !editableTimestamps.has(col.PascalName)))
|
|
243
290
|
.sort((a, b) => a.Order - b.Order)
|
|
@@ -249,15 +296,12 @@ export abstract class BaseTable {
|
|
|
249
296
|
return [];
|
|
250
297
|
}
|
|
251
298
|
|
|
299
|
+
/** O(1) via a map memoized per table/appdata — called several times per cell per CD. */
|
|
252
300
|
getColumn(fieldName: string): TableColumn | undefined {
|
|
253
|
-
|
|
254
|
-
if (tableDef && tableDef.Columns) {
|
|
255
|
-
return tableDef.Columns.find((field) => field.PascalName === fieldName);
|
|
256
|
-
}
|
|
257
|
-
return undefined;
|
|
301
|
+
return this.columnMap().get(fieldName);
|
|
258
302
|
}
|
|
259
303
|
|
|
260
|
-
displayValue(fieldName: string, rawValue:
|
|
304
|
+
displayValue(fieldName: string, rawValue: unknown): string {
|
|
261
305
|
if (rawValue === null || rawValue === undefined || rawValue === '') return '';
|
|
262
306
|
const col = this.getColumn(fieldName);
|
|
263
307
|
if (!col) return String(rawValue);
|
|
@@ -275,20 +319,17 @@ export abstract class BaseTable {
|
|
|
275
319
|
}
|
|
276
320
|
|
|
277
321
|
/**
|
|
278
|
-
* Temporal input kind for a field, or null if not temporal
|
|
279
|
-
*
|
|
280
|
-
* datetime-local (seconds + 'Z') treatment a Postgres TIME/DATE rejects.
|
|
322
|
+
* Temporal input kind for a field, or null if not temporal (metadata-driven
|
|
323
|
+
* only — name-based guessing would corrupt text fields ending in "Time").
|
|
281
324
|
*/
|
|
282
325
|
temporalKind(fieldName: string): 'time' | 'date' | 'datetime-local' | null {
|
|
283
326
|
const col = this.getColumn(fieldName)
|
|
284
|
-
if (col
|
|
285
|
-
if (col
|
|
286
|
-
if (col
|
|
327
|
+
if (!col) return null
|
|
328
|
+
if (col.InputType === 'time') return 'time'
|
|
329
|
+
if (col.InputType === 'date') return 'date'
|
|
330
|
+
if (col.InputType === 'datetime-local') return 'datetime-local'
|
|
287
331
|
// Fallback for column metadata predating keel's time/date split.
|
|
288
|
-
if (col
|
|
289
|
-
: (fieldName.endsWith('Time') || fieldName.endsWith('time'))) {
|
|
290
|
-
return 'datetime-local'
|
|
291
|
-
}
|
|
332
|
+
if (col.DataType === 'timestamp') return 'datetime-local'
|
|
292
333
|
return null
|
|
293
334
|
}
|
|
294
335
|
|
|
@@ -320,43 +361,52 @@ export abstract class BaseTable {
|
|
|
320
361
|
return false;
|
|
321
362
|
}
|
|
322
363
|
|
|
323
|
-
isArray(value:
|
|
364
|
+
isArray(value: unknown): boolean {
|
|
324
365
|
return Array.isArray(value);
|
|
325
366
|
}
|
|
326
367
|
|
|
368
|
+
/** Backend value → form-input value. datetime-local inputs hold LOCAL wall-clock
|
|
369
|
+
* time while keel timestamps are UTC ISO, so convert (don't slice). */
|
|
327
370
|
formatTimeStamp(val: string, kind: 'time' | 'date' | 'datetime-local' = 'datetime-local'): string {
|
|
328
371
|
if (val && typeof val === 'string') {
|
|
329
372
|
if (kind === 'time') return val.slice(0, 5); // HH:MM
|
|
330
373
|
if (kind === 'date') return val.slice(0, 10); // YYYY-MM-DD
|
|
331
|
-
|
|
374
|
+
// Bare timestamps (no zone) from pre-ISO backends are treated as UTC.
|
|
375
|
+
const iso = (val.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(val)) ? val : val + 'Z';
|
|
376
|
+
const d = new Date(iso);
|
|
377
|
+
if (isNaN(d.getTime())) return val.slice(0, 16);
|
|
378
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
379
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
332
380
|
}
|
|
333
381
|
return val;
|
|
334
382
|
}
|
|
335
383
|
|
|
336
|
-
formatRecordTimeStamp(record:
|
|
384
|
+
formatRecordTimeStamp(record: Record<string, unknown>) {
|
|
337
385
|
if (record) {
|
|
338
386
|
for (const key of Object.keys(record)) {
|
|
339
387
|
const kind = this.temporalKind(key);
|
|
340
388
|
if (kind) {
|
|
341
|
-
record[key] = this.formatTimeStamp(record[key], kind);
|
|
389
|
+
record[key] = this.formatTimeStamp(record[key] as string, kind);
|
|
342
390
|
}
|
|
343
391
|
}
|
|
344
392
|
}
|
|
345
393
|
}
|
|
346
394
|
|
|
347
|
-
|
|
395
|
+
/** Form-input value → backend wire value (local wall-clock → UTC ISO for datetime-local). */
|
|
396
|
+
restoreTimeStamp(val: unknown, kind: 'time' | 'date' | 'datetime-local' = 'datetime-local'): unknown {
|
|
348
397
|
// PostgreSQL can't cast '' to a temporal type — normalize empty to null.
|
|
349
398
|
if (val === '' || val === undefined || val === null) return null;
|
|
350
|
-
// TIME/DATE values are sent verbatim ('08:45', '2026-06-15')
|
|
351
|
-
// datetime-local needs the seconds + UTC marker the picker omits.
|
|
399
|
+
// TIME/DATE values are sent verbatim ('08:45', '2026-06-15').
|
|
352
400
|
if (kind === 'time' || kind === 'date') return val;
|
|
353
401
|
if (typeof val === 'string' && !val.endsWith('Z')) {
|
|
354
|
-
|
|
402
|
+
const d = new Date(val); // no zone designator → parsed as LOCAL time
|
|
403
|
+
if (isNaN(d.getTime())) return val;
|
|
404
|
+
return d.toISOString();
|
|
355
405
|
}
|
|
356
406
|
return val;
|
|
357
407
|
}
|
|
358
408
|
|
|
359
|
-
restoreRecordTimeStamp(record:
|
|
409
|
+
restoreRecordTimeStamp(record: Record<string, unknown>) {
|
|
360
410
|
if (record) {
|
|
361
411
|
for (const key of Object.keys(record)) {
|
|
362
412
|
const kind = this.temporalKind(key);
|
|
@@ -367,7 +417,7 @@ export abstract class BaseTable {
|
|
|
367
417
|
}
|
|
368
418
|
}
|
|
369
419
|
|
|
370
|
-
readyToSave(record:
|
|
420
|
+
readyToSave(record: Record<string, unknown>, original?: Record<string, unknown> | null) {
|
|
371
421
|
this.restoreRecordTimeStamp(record);
|
|
372
422
|
const op = record[this.config.opField];
|
|
373
423
|
if (op === OpCode.Insert || op === OpCode.Delete) return; // already marked
|
|
@@ -385,20 +435,33 @@ export abstract class BaseTable {
|
|
|
385
435
|
record[this.config.opField] = OpCode.Update;
|
|
386
436
|
}
|
|
387
437
|
|
|
388
|
-
/**
|
|
389
|
-
|
|
438
|
+
/** Equality tolerant of form coercion: number-vs-string by text, ISO timestamps by instant. */
|
|
439
|
+
private valuesEqual(a: unknown, b: unknown): boolean {
|
|
440
|
+
if (a === b) return true;
|
|
441
|
+
if (a == null || b == null) return false; // one side set, the other not
|
|
442
|
+
if (typeof a === 'number' || typeof b === 'number') return String(a) === String(b);
|
|
443
|
+
if (typeof a === 'string' && typeof b === 'string'
|
|
444
|
+
&& ISO_PREFIX.test(a) && ISO_PREFIX.test(b)) {
|
|
445
|
+
const ta = Date.parse(a), tb = Date.parse(b);
|
|
446
|
+
if (!isNaN(ta) && !isNaN(tb)) return ta === tb;
|
|
447
|
+
}
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Shallow compare of scalar fields over the union of keys; child arrays and op_code ignored. */
|
|
452
|
+
isRecordDirty(current: Record<string, unknown>, original: Record<string, unknown>): boolean {
|
|
390
453
|
const opField = this.config.opField;
|
|
391
|
-
|
|
454
|
+
const keys = new Set([...Object.keys(current), ...Object.keys(original)]);
|
|
455
|
+
for (const key of keys) {
|
|
392
456
|
if (key === opField) continue;
|
|
393
|
-
const curVal = current[key];
|
|
394
457
|
// Ignore child-relation arrays; those have their own op_code per row.
|
|
395
|
-
if (Array.isArray(
|
|
396
|
-
if (
|
|
458
|
+
if (Array.isArray(current[key]) || Array.isArray(original[key])) continue;
|
|
459
|
+
if (!this.valuesEqual(current[key], original[key])) return true;
|
|
397
460
|
}
|
|
398
461
|
return false;
|
|
399
462
|
}
|
|
400
463
|
|
|
401
|
-
getDialogData(record:
|
|
464
|
+
getDialogData(record: Record<string, unknown>, isNew: boolean, readOnlyColumns: string[] = []): Record<string, unknown> {
|
|
402
465
|
return {record, tableName: this.tableName(), isNew, readOnlyColumns};
|
|
403
466
|
}
|
|
404
467
|
|
|
@@ -423,14 +486,14 @@ export abstract class BaseTable {
|
|
|
423
486
|
return undefined;
|
|
424
487
|
}
|
|
425
488
|
|
|
426
|
-
getKeyFilters(record:
|
|
489
|
+
getKeyFilters(record: Record<string, unknown>): {[key: string]: string} | undefined {
|
|
427
490
|
const tableDef = this.cacheService.getTableDefinition(this.tableName());
|
|
428
491
|
if (!tableDef || !tableDef.Keys || tableDef.Keys.length === 0) {
|
|
429
492
|
return undefined;
|
|
430
493
|
}
|
|
431
494
|
const filters: {[key: string]: string} = {};
|
|
432
495
|
for (const key of tableDef.Keys) {
|
|
433
|
-
filters[key.PascalName] = record[key.PascalName];
|
|
496
|
+
filters[key.PascalName] = String(record[key.PascalName] ?? '');
|
|
434
497
|
}
|
|
435
498
|
return filters;
|
|
436
499
|
}
|
|
@@ -445,25 +508,35 @@ export abstract class BaseTable {
|
|
|
445
508
|
return {'fk': fk, 'parent': parent};
|
|
446
509
|
}
|
|
447
510
|
|
|
448
|
-
|
|
511
|
+
/** Seed FK columns from the parent's keys; returns the seeded (read-only) column names. */
|
|
512
|
+
initializeForeignKeys(record: Record<string, unknown>, parentTable: string, parentRecord: Record<string, unknown>): string[] {
|
|
449
513
|
const readOnlyColumns: string[] = [];
|
|
450
514
|
const fkData = this.getForeignKeyConfig(parentTable);
|
|
451
515
|
if (fkData && fkData.fk && fkData.parent) {
|
|
516
|
+
if (fkData.fk.Columns.length !== fkData.parent.Keys.length) {
|
|
517
|
+
console.error(`sail: FK ${fkData.fk.ConstraintName} has ${fkData.fk.Columns.length} columns but parent ${parentTable} has ${fkData.parent.Keys.length} keys; skipping FK seed`);
|
|
518
|
+
return readOnlyColumns;
|
|
519
|
+
}
|
|
452
520
|
fkData.fk.Columns.forEach((column, index) => {
|
|
453
521
|
record[column.PascalName] = parentRecord[fkData.parent.Keys[index].PascalName];
|
|
454
522
|
readOnlyColumns.push(column.PascalName);
|
|
455
523
|
});
|
|
456
524
|
}
|
|
525
|
+
return readOnlyColumns;
|
|
457
526
|
}
|
|
458
527
|
|
|
459
|
-
buildSearchTerms(record: {[key: string]:
|
|
460
|
-
const
|
|
528
|
+
buildSearchTerms(record: {[key: string]: unknown}): {[key: string]: string} {
|
|
529
|
+
const tableOverride = this.config.tableOverrides?.[this.tableName()];
|
|
530
|
+
const hiddenSet = new Set([
|
|
531
|
+
...this.config.hiddenFields,
|
|
532
|
+
...(tableOverride?.hiddenFields ?? []),
|
|
533
|
+
]);
|
|
461
534
|
const searchTerms: {[key: string]: string} = {};
|
|
462
535
|
for (const key of Object.keys(record)) {
|
|
463
536
|
if (hiddenSet.has(key)) continue;
|
|
464
537
|
const val = record[key];
|
|
465
538
|
if (val !== '' && val !== null && val !== undefined && !Array.isArray(val)) {
|
|
466
|
-
searchTerms[key] = val;
|
|
539
|
+
searchTerms[key] = String(val);
|
|
467
540
|
}
|
|
468
541
|
}
|
|
469
542
|
return searchTerms;
|
|
@@ -1,39 +1,49 @@
|
|
|
1
|
-
import { Directive, inject, signal, WritableSignal } from "@angular/core";
|
|
1
|
+
import { computed, Directive, inject, signal, Type, WritableSignal } from "@angular/core";
|
|
2
2
|
import { BaseTable } from "./base_table";
|
|
3
3
|
import { MatDialog } from "@angular/material/dialog";
|
|
4
4
|
|
|
5
|
+
/** Read-only listing surface; complete as-is (TableLookup). Editing views extend AbstractEditableView. */
|
|
5
6
|
@Directive()
|
|
6
7
|
export abstract class BaseView extends BaseTable {
|
|
7
|
-
protected readonly dialog = inject(MatDialog);
|
|
8
8
|
readonly apiName: WritableSignal<string> = signal('');
|
|
9
|
-
readonly
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
readonly records = signal<Record<string, unknown>[]>([]);
|
|
10
|
+
readonly displayedColumns = signal<string[]>([]);
|
|
11
|
+
readonly searchTerms = signal<{[key: string]: string}>({});
|
|
12
|
+
/** Last load/save failure shown by the view's template ('' when none). */
|
|
13
|
+
readonly viewError = signal('');
|
|
14
14
|
|
|
15
|
-
protected
|
|
15
|
+
protected readonly hasLinkPermission = computed(() => this.canUpdate() || this.canRead());
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/** Whether an "actions" column is appended for privileged users; lookup views return false. */
|
|
18
|
+
protected includeActionsColumn(): boolean {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
18
21
|
|
|
19
|
-
updateDisplayedColumns(
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
22
|
+
updateDisplayedColumns(records: Record<string, unknown>[]) {
|
|
23
|
+
const columns = this.getDisplayedColumns(records);
|
|
24
|
+
if (columns.length > 0 &&
|
|
25
|
+
this.includeActionsColumn() &&
|
|
26
|
+
!columns.includes('actions') &&
|
|
23
27
|
(this.canUpdate() || this.canDelete())
|
|
24
|
-
)
|
|
25
|
-
this.
|
|
28
|
+
) columns.push('actions');
|
|
29
|
+
this.displayedColumns.set(columns);
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
isLinkColumn(column: string): boolean {
|
|
29
|
-
|
|
33
|
+
const columns = this.displayedColumns();
|
|
34
|
+
return (columns.length > 0 && columns[0] === column && this.hasLinkPermission());
|
|
30
35
|
}
|
|
36
|
+
}
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
/** Listing surface with dialog-based create/edit/delete; subclasses implement persistence. */
|
|
39
|
+
@Directive()
|
|
40
|
+
export abstract class AbstractEditableView extends BaseView {
|
|
41
|
+
protected readonly dialog = inject(MatDialog);
|
|
42
|
+
readonly dialogComponent: WritableSignal<Type<unknown> | undefined> = signal(undefined);
|
|
43
|
+
|
|
44
|
+
protected abstract handleDelete(record: Record<string, unknown>): void;
|
|
45
|
+
|
|
46
|
+
protected abstract handleUpdate(result: Record<string, unknown>, isNew: boolean, original: Record<string, unknown>): void;
|
|
37
47
|
|
|
38
48
|
/**
|
|
39
49
|
* Shared row-dialog opener. Builds the dialog data and wires the result
|
|
@@ -41,12 +51,17 @@ export abstract class BaseView extends BaseTable {
|
|
|
41
51
|
* `addRecord()` (new row + empty `original`) and `editRecord()` (existing
|
|
42
52
|
* row passed through verbatim).
|
|
43
53
|
*/
|
|
44
|
-
private openRecordDialog(record:
|
|
54
|
+
private openRecordDialog(record: Record<string, unknown>, isNew: boolean): void {
|
|
55
|
+
const component = this.dialogComponent();
|
|
56
|
+
if (!component) {
|
|
57
|
+
console.error('sail: no dialogComponent configured for', this.tableName());
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
45
60
|
const dialogData = this.getDialogData(record, isNew);
|
|
46
61
|
const apiName = this.apiName();
|
|
47
62
|
if (apiName) dialogData['apiName'] = apiName;
|
|
48
63
|
|
|
49
|
-
this.dialog.open(
|
|
64
|
+
this.dialog.open(component, {
|
|
50
65
|
width: '90vw',
|
|
51
66
|
maxWidth: '1200px',
|
|
52
67
|
maxHeight: '90vh',
|
|
@@ -62,8 +77,9 @@ export abstract class BaseView extends BaseTable {
|
|
|
62
77
|
const record = this.emptyRecord();
|
|
63
78
|
// Seed columns from an existing row when emptyRecord() returned just the
|
|
64
79
|
// op-code field (table metadata wasn't loaded yet).
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
const rows = this.records();
|
|
81
|
+
if (Object.keys(record).length === 1 && rows.length > 0) {
|
|
82
|
+
Object.keys(rows[0]).forEach((key) => { record[key] = ''; });
|
|
67
83
|
}
|
|
68
84
|
this.openRecordDialog(record, true);
|
|
69
85
|
}
|
|
@@ -75,12 +91,12 @@ export abstract class BaseView extends BaseTable {
|
|
|
75
91
|
* buttons are gated by the same permissions.
|
|
76
92
|
* Block only when neither read nor update is granted.
|
|
77
93
|
*/
|
|
78
|
-
editRecord(record:
|
|
94
|
+
editRecord(record: Record<string, unknown>) {
|
|
79
95
|
if (!this.canUpdate() && !this.requireAuth(() => this.canRead(), 'view')) return;
|
|
80
96
|
this.openRecordDialog(record, false);
|
|
81
97
|
}
|
|
82
98
|
|
|
83
|
-
deleteRecord(record:
|
|
99
|
+
deleteRecord(record: Record<string, unknown>) {
|
|
84
100
|
if (!this.requireAuth(() => this.canDelete(), 'delete')) return;
|
|
85
101
|
this.handleDelete(record);
|
|
86
102
|
}
|
|
@@ -96,7 +96,7 @@ export class ProfileEditorComponent implements OnInit {
|
|
|
96
96
|
this.loading.set(true);
|
|
97
97
|
this.emailErr.set('');
|
|
98
98
|
this.auth.confirmEmailChange(this.newEmail.value!, Number(this.emailCode.value)).subscribe({
|
|
99
|
-
next: () => { this.loading.set(false); this.email.set(this.newEmail.value!); this.cancelEmailChange(); this.
|
|
99
|
+
next: () => { this.loading.set(false); this.email.set(this.newEmail.value!); this.cancelEmailChange(); this.emailMsg.set('Email updated.'); },
|
|
100
100
|
error: (e) => { this.loading.set(false); this.emailErr.set(this.msg(e, 'Invalid or expired code.')); },
|
|
101
101
|
});
|
|
102
102
|
}
|
|
@@ -128,7 +128,7 @@ export class ProfileEditorComponent implements OnInit {
|
|
|
128
128
|
this.loading.set(true);
|
|
129
129
|
this.phoneErr.set('');
|
|
130
130
|
this.auth.confirmPhoneChange(this.newPhone.value!, Number(this.phoneCode.value)).subscribe({
|
|
131
|
-
next: () => { this.loading.set(false); this.phone.set(this.newPhone.value!); this.cancelPhoneChange(); this.
|
|
131
|
+
next: () => { this.loading.set(false); this.phone.set(this.newPhone.value!); this.cancelPhoneChange(); this.phoneMsg.set('Phone updated.'); },
|
|
132
132
|
error: (e) => { this.loading.set(false); this.phoneErr.set(this.msg(e, 'Invalid or expired code.')); },
|
|
133
133
|
});
|
|
134
134
|
}
|