@nauticana/sail 1.1.16 → 1.1.17
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 +29 -5
- package/package.json +1 -1
- package/src/component/table/table_list.ts +5 -6
- package/src/model/appdata.ts +7 -0
- package/src/service/label.service.ts +35 -10
- package/src/service/rest_service.ts +16 -12
- package/src/util/errors.ts +8 -3
package/README.md
CHANGED
|
@@ -240,17 +240,41 @@ keel REST list responses are paginated:
|
|
|
240
240
|
{ "items": [...], "limit": 100, "offset": 0, "total": 12345 }
|
|
241
241
|
```
|
|
242
242
|
|
|
243
|
-
`BackendService.list<T>()` continues to return `Observable<T[]>` — sail unwraps `items` for you. To access the metadata, use `listPaginated<T>()`:
|
|
243
|
+
`BackendService.list<T>()` continues to return `Observable<T[]>` — sail unwraps `items` for you. Both list methods accept a typed `PageRequest` after the filter map. To access the metadata, use `listPaginated<T>()`:
|
|
244
244
|
|
|
245
245
|
```typescript
|
|
246
|
-
this.backend.listPaginated<MyRow>('orders', {
|
|
246
|
+
this.backend.listPaginated<MyRow>('orders', { Status: 'open' }, {
|
|
247
|
+
limit: 50,
|
|
248
|
+
offset: 0,
|
|
249
|
+
orderBy: 'created_at DESC',
|
|
250
|
+
})
|
|
247
251
|
.subscribe((page) => {
|
|
248
252
|
this.rows.set(page.items);
|
|
249
253
|
this.total.set(page.total); // total rows matching the filter
|
|
250
254
|
});
|
|
251
255
|
```
|
|
252
256
|
|
|
253
|
-
Default page size is 100, capped at 1000 server-side.
|
|
257
|
+
Default page size is 100, capped at 1000 server-side. Sail maps `limit`, `offset`, and `orderBy` to keel's `_limit`, `_offset`, and `_order` query controls. Existing callers that put those string controls in the filter map remain compatible; explicit `PageRequest` values take precedence.
|
|
258
|
+
|
|
259
|
+
## Backend-driven constant captions
|
|
260
|
+
|
|
261
|
+
`LabelService` loads one keel `constant_value` domain from the backend-provided `ApplicationData.ConstantCache` and caches its value-to-caption map. The domain is the backend `constant_id` (for example `ride_type`), not a locale: keel's constant catalogue does not contain a language column. It does not call generic `constant_value` CRUD, which is normally restricted to administrative roles.
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
this.labels.loadLabels('ride_type').subscribe(() => {
|
|
265
|
+
this.rideTypeCaption.set(this.labels.getLabel('ride_type', ride.type));
|
|
266
|
+
});
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Concurrent loads share one application-data subscription, completed domains stay cached, and application-data failures can be retried. Missing values fall back to the raw key.
|
|
270
|
+
|
|
271
|
+
## Status-filtered API errors
|
|
272
|
+
|
|
273
|
+
Pass an optional status allowlist when backend detail is safe to show only for selected responses. Other HTTP statuses return the caller's generic fallback:
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
errorDetail(error, 'Could not save.', [400, 409]);
|
|
277
|
+
```
|
|
254
278
|
|
|
255
279
|
## Configuration reference
|
|
256
280
|
|
|
@@ -1163,10 +1187,10 @@ Wherever you stored the OTP `sessionId`, swap it for `otpToken: string`.
|
|
|
1163
1187
|
|
|
1164
1188
|
`BackendService.list<T>()` returns `Observable<T[]>` (unchanged). It now expects keel to return paginated `{items, limit, offset, total}` and has dropped the legacy "bare array" fallback. If you have an in-house wrapper that calls keel `list` endpoints directly, expect the wrapper shape.
|
|
1165
1189
|
|
|
1166
|
-
For pagination metadata, use `listPaginated<T>()`:
|
|
1190
|
+
For pagination metadata, use `listPaginated<T>()` with a typed `PageRequest`:
|
|
1167
1191
|
|
|
1168
1192
|
```typescript
|
|
1169
|
-
backend.listPaginated<MyRow>('orders', {
|
|
1193
|
+
backend.listPaginated<MyRow>('orders', undefined, { limit: 50, offset: 0 })
|
|
1170
1194
|
.subscribe(page => {
|
|
1171
1195
|
this.rows.set(page.items);
|
|
1172
1196
|
this.total.set(page.total);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.17",
|
|
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": {
|
|
@@ -103,12 +103,11 @@ export class TableList extends AbstractEditableView implements OnInit {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
private loadPage(): Observable<unknown> {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return this.backendService.listPaginated<Record<string, unknown>>(this.apiName(), filter).pipe(
|
|
106
|
+
return this.backendService.listPaginated<Record<string, unknown>>(
|
|
107
|
+
this.apiName(),
|
|
108
|
+
this.searchTerms(),
|
|
109
|
+
{ limit: this.pageSize(), offset: this.pageIndex() * this.pageSize() },
|
|
110
|
+
).pipe(
|
|
112
111
|
tap((page) => {
|
|
113
112
|
this.records.set(page.items ?? []);
|
|
114
113
|
this.total.set(page.total ?? page.items?.length ?? 0);
|
package/src/model/appdata.ts
CHANGED
|
@@ -330,6 +330,13 @@ export interface PaginatedList<T> {
|
|
|
330
330
|
total: number;
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
/** Typed controls for keel REST list pagination and ordering. */
|
|
334
|
+
export interface PageRequest {
|
|
335
|
+
limit?: number;
|
|
336
|
+
offset?: number;
|
|
337
|
+
orderBy?: string;
|
|
338
|
+
}
|
|
339
|
+
|
|
333
340
|
export interface Subscription {
|
|
334
341
|
planId: string;
|
|
335
342
|
status: string; // 'A' active, 'P' pending-payment, 'C' cancelled, 'T' trialing, 'X' past-due — caption via SUBSCRIPTION_STATUS dict
|
|
@@ -1,19 +1,44 @@
|
|
|
1
|
-
import { Injectable } from "@angular/core";
|
|
1
|
+
import { inject, Injectable } from "@angular/core";
|
|
2
|
+
import { Observable, map, of, shareReplay, take, tap } from "rxjs";
|
|
3
|
+
import { BaseAuthService } from "./auth.service";
|
|
2
4
|
|
|
3
|
-
// TODO: Implement translated captions service
|
|
4
|
-
// - loadLabels(langcode) should fetch labels from the backend
|
|
5
|
-
// - getLabel(langcode, key) should return the translated caption for a given key
|
|
6
|
-
// - BaseTable.colCaption() and related methods should use this service for i18n
|
|
7
5
|
@Injectable({ providedIn: 'root' })
|
|
8
6
|
export class LabelService {
|
|
7
|
+
private readonly auth = inject(BaseAuthService);
|
|
9
8
|
protected labels: Map<string, Map<string, string>> = new Map();
|
|
9
|
+
private readonly pendingLoads = new Map<string, Observable<void>>();
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
getLabel(
|
|
13
|
-
return this.labels.get(
|
|
11
|
+
/** Return a caption from a constant domain, falling back visibly to its raw value. */
|
|
12
|
+
getLabel(constantId: string, key: string): string {
|
|
13
|
+
return this.labels.get(constantId)?.get(key) ?? key;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Load one backend-provided `constant_value` domain from ApplicationData.
|
|
18
|
+
* Completed and concurrent requests are cached; a failed application-data
|
|
19
|
+
* load is evicted so a later call can retry.
|
|
20
|
+
*/
|
|
21
|
+
loadLabels(constantId: string): Observable<void> {
|
|
22
|
+
const domain = constantId.trim();
|
|
23
|
+
if (!domain) throw new Error('sail: a constant id is required to load labels.');
|
|
24
|
+
if (this.labels.has(domain)) return of(undefined);
|
|
25
|
+
|
|
26
|
+
const pending = this.pendingLoads.get(domain);
|
|
27
|
+
if (pending) return pending;
|
|
28
|
+
|
|
29
|
+
const request = this.auth.getAppData().pipe(
|
|
30
|
+
take(1),
|
|
31
|
+
tap({
|
|
32
|
+
next: (data) => this.labels.set(
|
|
33
|
+
domain,
|
|
34
|
+
new Map(Object.entries(data.ConstantCache?.[domain] ?? {})),
|
|
35
|
+
),
|
|
36
|
+
error: () => this.pendingLoads.delete(domain),
|
|
37
|
+
}),
|
|
38
|
+
map(() => undefined),
|
|
39
|
+
shareReplay({ bufferSize: 1, refCount: false }),
|
|
40
|
+
);
|
|
41
|
+
this.pendingLoads.set(domain, request);
|
|
42
|
+
return request;
|
|
18
43
|
}
|
|
19
44
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { HttpErrorResponse, HttpParams } from "@angular/common/http";
|
|
2
2
|
import { inject, Injectable } from "@angular/core";
|
|
3
3
|
import { catchError, map, Observable, switchMap, take, throwError } from "rxjs";
|
|
4
|
-
import { ApplicationData, PaginatedList } from "../model/appdata";
|
|
4
|
+
import { ApplicationData, PageRequest, PaginatedList } from "../model/appdata";
|
|
5
5
|
import { RestURL } from "./rest_url";
|
|
6
6
|
import { BaseAuthService } from "./auth.service";
|
|
7
7
|
import { BaseRestService } from "./base_rest.service";
|
|
@@ -83,15 +83,19 @@ export class BackendService extends BaseRestService {
|
|
|
83
83
|
return this.http.request<T>(method, this.apiUrl(path), { body }).pipe(catchError((err) => this.handleError(err)));
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/** Build query params from
|
|
87
|
-
private toParams(filter?: {[key: string]: string}): HttpParams {
|
|
86
|
+
/** Build query params from filters plus keel's underscore-prefixed paging aliases. */
|
|
87
|
+
private toParams(filter?: {[key: string]: string}, page?: PageRequest): HttpParams {
|
|
88
88
|
let params = new HttpParams();
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
if (filter) {
|
|
90
|
+
for (const key in filter) {
|
|
91
|
+
if (Object.prototype.hasOwnProperty.call(filter, key) && filter[key] != null) {
|
|
92
|
+
params = params.set(key, filter[key]);
|
|
93
|
+
}
|
|
93
94
|
}
|
|
94
95
|
}
|
|
96
|
+
if (page?.limit != null) params = params.set('_limit', page.limit);
|
|
97
|
+
if (page?.offset != null) params = params.set('_offset', page.offset);
|
|
98
|
+
if (page?.orderBy) params = params.set('_order', page.orderBy);
|
|
95
99
|
return params;
|
|
96
100
|
}
|
|
97
101
|
|
|
@@ -100,16 +104,16 @@ export class BackendService extends BaseRestService {
|
|
|
100
104
|
return this.auth.getAppData().pipe(take(1));
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
/** Unwraps `{items, limit, offset, total}` to the items array.
|
|
104
|
-
list<T>(apiName: string, filter?: {[key: string]: string}) : Observable<T[]> {
|
|
105
|
-
return this.listPaginated<T>(apiName, filter).pipe(map((
|
|
107
|
+
/** Unwraps `{items, limit, offset, total}` to the items array. */
|
|
108
|
+
list<T>(apiName: string, filter?: {[key: string]: string}, page?: PageRequest) : Observable<T[]> {
|
|
109
|
+
return this.listPaginated<T>(apiName, filter, page).pipe(map((result) => result.items));
|
|
106
110
|
}
|
|
107
111
|
|
|
108
|
-
listPaginated<T>(apiName: string, filter?: {[key: string]: string}) : Observable<PaginatedList<T>> {
|
|
112
|
+
listPaginated<T>(apiName: string, filter?: {[key: string]: string}, page?: PageRequest) : Observable<PaginatedList<T>> {
|
|
109
113
|
return this.appDataReady().pipe(
|
|
110
114
|
switchMap(() => this.http.get<PaginatedList<T>>(
|
|
111
115
|
this.crudUrl(apiName, 'list'),
|
|
112
|
-
{params: this.toParams(filter)},
|
|
116
|
+
{params: this.toParams(filter, page)},
|
|
113
117
|
)),
|
|
114
118
|
catchError((err) => this.handleError(err)),
|
|
115
119
|
);
|
package/src/util/errors.ts
CHANGED
|
@@ -39,9 +39,15 @@ export function isTransportFailure(err: unknown): boolean {
|
|
|
39
39
|
* `detail` did not come from the API: either nothing reached it (status 0) or
|
|
40
40
|
* something in front of it replied with a body we cannot read. Both used to
|
|
41
41
|
* collapse into the caller's fallback, which reads as a backend rejection and
|
|
42
|
-
* hides the real fault; they are now reported distinctly.
|
|
42
|
+
* hides the real fault; they are now reported distinctly. When `statuses` is
|
|
43
|
+
* supplied, HTTP statuses outside that allowlist return `fallback` without
|
|
44
|
+
* exposing their response text.
|
|
43
45
|
*/
|
|
44
|
-
export function errorDetail(err: unknown, fallback: string): string {
|
|
46
|
+
export function errorDetail(err: unknown, fallback: string, statuses?: readonly number[]): string {
|
|
47
|
+
const status = statusOf(err);
|
|
48
|
+
if (statuses && status !== undefined && !statuses.includes(status)) {
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
45
51
|
if (isTransportFailure(err)) {
|
|
46
52
|
return 'Cannot reach the server — the network or a CORS rule blocked the response.';
|
|
47
53
|
}
|
|
@@ -53,7 +59,6 @@ export function errorDetail(err: unknown, fallback: string): string {
|
|
|
53
59
|
if (err instanceof Error && err.message) {
|
|
54
60
|
return err.message;
|
|
55
61
|
}
|
|
56
|
-
const status = statusOf(err);
|
|
57
62
|
return status ? `${fallback} (HTTP ${status})` : fallback;
|
|
58
63
|
}
|
|
59
64
|
|