@nauticana/sail 1.1.14 → 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/LICENSE CHANGED
@@ -186,7 +186,7 @@
186
186
  same "printed page" as the copyright notice for easier
187
187
  identification within third-party archives.
188
188
 
189
- Copyright [yyyy] [name of copyright owner]
189
+ Copyright 2026 Mustafa Karli
190
190
 
191
191
  Licensed under the Apache License, Version 2.0 (the "License");
192
192
  you may not use this file except in compliance with the License.
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', { _limit: '50', _offset: '0' })
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. Pass `_limit` / `_offset` in the filter map to control paging.
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', { _limit: '50', _offset: '0' })
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.14",
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
- const filter = {
107
- ...this.searchTerms(),
108
- _limit: String(this.pageSize()),
109
- _offset: String(this.pageIndex() * this.pageSize()),
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);
@@ -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
- /** Falls back to the key itself so a missing translation is visible, not a blank caption. */
12
- getLabel(langcode: string, key: string): string {
13
- return this.labels.get(langcode)?.get(key) ?? key;
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
- loadLabels(_langcode: string) {
17
- // TODO: fetch from backend and populate this.labels
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 a flat string-string map; skips undefined/null values. */
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 (!filter) return params;
90
- for (const key in filter) {
91
- if (Object.prototype.hasOwnProperty.call(filter, key) && filter[key] != null) {
92
- params = params.set(key, filter[key]);
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. Pass `_limit` / `_offset` in `filter` to control paging (defaults: 100 / 0). */
104
- list<T>(apiName: string, filter?: {[key: string]: string}) : Observable<T[]> {
105
- return this.listPaginated<T>(apiName, filter).pipe(map((page) => page.items));
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
  );
@@ -15,11 +15,42 @@ function asString(value: unknown): string | undefined {
15
15
  return typeof value === 'string' && value ? value : undefined;
16
16
  }
17
17
 
18
+ function statusOf(err: unknown): number | undefined {
19
+ if (!err || typeof err !== 'object') return undefined;
20
+ const s = (err as { status?: unknown }).status;
21
+ return typeof s === 'number' ? s : undefined;
22
+ }
23
+
24
+ /**
25
+ * True when the browser never received a readable response — the network was
26
+ * down, a CORS rule blocked it, or the request was aborted. Angular collapses
27
+ * all three to status 0 with a ProgressEvent body, so this is the only signal
28
+ * available. Use it to offer a retry rather than reporting a backend rejection.
29
+ */
30
+ export function isTransportFailure(err: unknown): boolean {
31
+ return statusOf(err) === 0;
32
+ }
33
+
18
34
  /**
19
35
  * Human-readable message from any sail error shape — SailApiError (BackendService),
20
36
  * raw HttpErrorResponse (direct HttpClient calls), or plain Error.
37
+ *
38
+ * keel answers every rejection with an RFC 7807 body, so an error carrying no
39
+ * `detail` did not come from the API: either nothing reached it (status 0) or
40
+ * something in front of it replied with a body we cannot read. Both used to
41
+ * collapse into the caller's fallback, which reads as a backend rejection and
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.
21
45
  */
22
- 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
+ }
51
+ if (isTransportFailure(err)) {
52
+ return 'Cannot reach the server — the network or a CORS rule blocked the response.';
53
+ }
23
54
  const problem = problemOf(err);
24
55
  if (problem) {
25
56
  const message = asString(problem.detail) ?? asString(problem.title) ?? asString(problem.message);
@@ -28,7 +59,7 @@ export function errorDetail(err: unknown, fallback: string): string {
28
59
  if (err instanceof Error && err.message) {
29
60
  return err.message;
30
61
  }
31
- return fallback;
62
+ return status ? `${fallback} (HTTP ${status})` : fallback;
32
63
  }
33
64
 
34
65
  /** Stable machine-readable discriminator from a keel RFC 7807 response. */