@nauticana/sail 0.9.1 → 0.9.3
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
CHANGED
|
@@ -623,6 +623,31 @@ INSERT INTO authorization_role_permission (role_id, authorization_object_id, act
|
|
|
623
623
|
- **Snake_case fields** — keel marshals PascalCase, so a snake_case `address` field on the wire serializes as `Address` from keel but your client TS expects `address`. Mixed payloads break in subtle ways (read paths show data, write paths look like no-ops).
|
|
624
624
|
- **Forgetting to restart httpsrv after seeding `rest_api_header`** — the route table is built once at startup. New rows = 404 until restart.
|
|
625
625
|
|
|
626
|
+
## Tuning edit-form fields (column_display_attribute)
|
|
627
|
+
|
|
628
|
+
The generic edit form renders each column from keel's metadata. By default:
|
|
629
|
+
|
|
630
|
+
- **Textarea vs input** follows the column's `InputType` — keel sets
|
|
631
|
+
`textarea` for `TEXT`, `text` for `VARCHAR`. (Earlier builds keyed off
|
|
632
|
+
`Size > 80`, which turned long `VARCHAR`s into textareas; it now trusts
|
|
633
|
+
`InputType`.)
|
|
634
|
+
- **Read-only** applies only to primary keys (and FK columns in child rows).
|
|
635
|
+
|
|
636
|
+
To override per column without changing the schema, seed keel's
|
|
637
|
+
`column_display_attribute` table (`table_name, column_name, read_only,
|
|
638
|
+
display_width, display_rows`). sail honours three optional `TableColumn`
|
|
639
|
+
fields it produces:
|
|
640
|
+
|
|
641
|
+
| Field | Effect |
|
|
642
|
+
|----------------|--------|
|
|
643
|
+
| `read_only` | Field renders display-only (use for `*_at` audit timestamps, system-managed status). |
|
|
644
|
+
| `display_rows` | Textarea height. `1` forces a single-line input — keep a column `TEXT` (so bulk-loaded values don't overflow) yet render it on one line. |
|
|
645
|
+
| `display_width`| Field max-width in px. |
|
|
646
|
+
|
|
647
|
+
No rows seeded = unchanged behaviour. Requires keel with the
|
|
648
|
+
`column_display_attribute` feature; rebuild httpsrv after seeding (metadata
|
|
649
|
+
is read once at startup).
|
|
650
|
+
|
|
626
651
|
## Account deletion / logout everywhere / push tokens
|
|
627
652
|
|
|
628
653
|
These are App Store / Play Store compliance primitives from keel. All are opt-in.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
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": [
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"@angular/core": "^21.2.8",
|
|
51
51
|
"@angular/forms": "^21.2.8",
|
|
52
52
|
"@angular/material": "^21.2.6",
|
|
53
|
+
"@angular/platform-browser": "^21.2.8",
|
|
53
54
|
"@angular/router": "^21.2.8",
|
|
54
55
|
"class-validator": "^0.15.1",
|
|
55
56
|
"rxjs": "~7.8.0",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
@if (isLookupTableSearch()) {
|
|
2
2
|
<mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
|
|
3
3
|
<mat-label>{{ colCaption(field()) }}</mat-label>
|
|
4
|
-
<input matInput [value]="
|
|
5
|
-
<button matIconButton matSuffix (click)="openLookup()" [disabled]="
|
|
4
|
+
<input matInput [value]="isReadonly ? displayCaption : value()" [required]="required" [readonly]="isReadonly">
|
|
5
|
+
<button matIconButton matSuffix (click)="openLookup()" [disabled]="isReadonly">
|
|
6
6
|
<mat-icon>search</mat-icon>
|
|
7
7
|
</button>
|
|
8
8
|
</mat-form-field>
|
|
9
|
-
} @else if (inputType === 'select' &&
|
|
9
|
+
} @else if (inputType === 'select' && isReadonly) {
|
|
10
10
|
<mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
|
|
11
11
|
<mat-label>{{ colCaption(field()) }}</mat-label>
|
|
12
12
|
<input matInput [value]="displayCaption" readonly>
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
</mat-select>
|
|
22
22
|
</mat-form-field>
|
|
23
23
|
} @else if (inputType === 'checkbox') {
|
|
24
|
-
<mat-checkbox [checked]="value() === true || value() === '1'" (change)="onCheckboxChange($event);" [disabled]="
|
|
25
|
-
} @else if (
|
|
24
|
+
<mat-checkbox [checked]="value() === true || value() === '1'" (change)="onCheckboxChange($event);" [disabled]="isReadonly">{{ colCaption(field()) }}</mat-checkbox>
|
|
25
|
+
} @else if (isReadonly && isDateLike && !value()) {
|
|
26
26
|
<mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
|
|
27
27
|
<mat-label>{{ colCaption(field()) }}</mat-label>
|
|
28
28
|
<input matInput value="" readonly>
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
<textarea matInput
|
|
34
34
|
[value]="value()"
|
|
35
35
|
(input)="onInputChange($event);"
|
|
36
|
-
[disabled]="
|
|
36
|
+
[disabled]="isReadonly"
|
|
37
37
|
[required]="required"
|
|
38
38
|
[attr.maxLength]="maxLength"
|
|
39
39
|
[rows]="textAreaRows">
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
[type]="inputType"
|
|
47
47
|
[value]="value()"
|
|
48
48
|
(input)="onInputChange($event);"
|
|
49
|
-
[disabled]="
|
|
49
|
+
[disabled]="isReadonly"
|
|
50
50
|
[required]="required"
|
|
51
51
|
[attr.maxLength]="maxLength"
|
|
52
52
|
[attr.step]="step">
|
|
@@ -17,8 +17,8 @@ import { TableLookup } from "../table/table_lookup";
|
|
|
17
17
|
encapsulation: ViewEncapsulation.None,
|
|
18
18
|
host: {
|
|
19
19
|
'[class.field-wide]': 'isTextArea',
|
|
20
|
-
'[class.field-editable]': '!
|
|
21
|
-
'[class.field-readonly]': '
|
|
20
|
+
'[class.field-editable]': '!isReadonly',
|
|
21
|
+
'[class.field-readonly]': 'isReadonly',
|
|
22
22
|
'[style.max-width.px]': 'fieldMaxWidth',
|
|
23
23
|
},
|
|
24
24
|
imports: [
|
|
@@ -54,9 +54,19 @@ export class DynamicField extends BaseTable {
|
|
|
54
54
|
return this.col.InputType;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Effective read-only: explicit [readonly] input OR the column's own
|
|
58
|
+
* Readonly flag from keel's column_display_attribute. */
|
|
59
|
+
get isReadonly(): boolean {
|
|
60
|
+
return this.readonlyField() || !!this.col?.Readonly;
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
get isTextArea(): boolean {
|
|
58
64
|
if (!this.col) return false;
|
|
59
|
-
|
|
65
|
+
// Explicit display_rows override wins; otherwise trust keel's InputType
|
|
66
|
+
// (TEXT columns → 'textarea', VARCHAR → 'text') rather than guessing
|
|
67
|
+
// from Size — a long VARCHAR is still a single-line field.
|
|
68
|
+
if (this.col.DisplayRows) return this.col.DisplayRows > 1;
|
|
69
|
+
return this.col.InputType === 'textarea';
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
get isDateLike(): boolean {
|
|
@@ -71,6 +81,7 @@ export class DynamicField extends BaseTable {
|
|
|
71
81
|
* `null` = no cap (field fills its container — used for text areas / long fields).
|
|
72
82
|
*/
|
|
73
83
|
get fieldMaxWidth(): number | null {
|
|
84
|
+
if (this.col?.DisplayWidth) return this.col.DisplayWidth;
|
|
74
85
|
if (this.isTextArea) return null;
|
|
75
86
|
const size = this.col?.Size ?? 0;
|
|
76
87
|
if (size === 0) return 240;
|
|
@@ -81,6 +92,7 @@ export class DynamicField extends BaseTable {
|
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
get textAreaRows(): number {
|
|
95
|
+
if (this.col?.DisplayRows) return this.col.DisplayRows;
|
|
84
96
|
if (!this.col) return 3;
|
|
85
97
|
if (this.col.Size > 500) return 4;
|
|
86
98
|
return 2;
|
package/src/model/appdata.ts
CHANGED
|
@@ -54,6 +54,11 @@ export interface TableColumn {
|
|
|
54
54
|
HasDefault: boolean;
|
|
55
55
|
DefaultValue: string;
|
|
56
56
|
Validators?: AbstractControlOptions['validators'];
|
|
57
|
+
// Optional per-column UI overrides from keel's column_display_attribute.
|
|
58
|
+
// Absent/0 means "no override" — fall back to the Size/InputType heuristics.
|
|
59
|
+
Readonly?: boolean;
|
|
60
|
+
DisplayWidth?: number;
|
|
61
|
+
DisplayRows?: number;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
// One custom button registered against a table via the basis
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { HttpErrorResponse, HttpParams } from "@angular/common/http";
|
|
2
2
|
import { inject, Injectable } from "@angular/core";
|
|
3
|
-
import { catchError, map, Observable, throwError } from "rxjs";
|
|
4
|
-
import { PaginatedList } from "../model/appdata";
|
|
3
|
+
import { catchError, map, Observable, switchMap, take, throwError } from "rxjs";
|
|
4
|
+
import { ApplicationData, 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";
|
|
@@ -56,28 +56,45 @@ export class BackendService extends BaseRestService {
|
|
|
56
56
|
return params;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** Gates a CRUD call on appdata being loaded — `appData$` is a ReplaySubject(1) so this resolves synchronously once the cache is populated, and blocks the call (instead of throwing) when a component fires CRUD before loadAppData() finishes. */
|
|
60
|
+
private appDataReady(): Observable<ApplicationData> {
|
|
61
|
+
return this.auth.getAppData().pipe(take(1));
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
/** Unwraps `{items, limit, offset, total}` to the items array. Pass `_limit` / `_offset` in `filter` to control paging (defaults: 100 / 0). */
|
|
60
65
|
list<T>(apiName: string, filter?: {[key: string]: string}) : Observable<T[]> {
|
|
61
66
|
return this.listPaginated<T>(apiName, filter).pipe(map((page) => page.items));
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
listPaginated<T>(apiName: string, filter?: {[key: string]: string}) : Observable<PaginatedList<T>> {
|
|
65
|
-
return this.
|
|
66
|
-
this.
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
return this.appDataReady().pipe(
|
|
71
|
+
switchMap(() => this.http.get<PaginatedList<T>>(
|
|
72
|
+
this.crudUrl(apiName, 'list'),
|
|
73
|
+
{params: this.toParams(filter)},
|
|
74
|
+
)),
|
|
75
|
+
catchError(this.handleError),
|
|
76
|
+
);
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
get<T>(apiName: string, filter?: {[key: string]: string}) : Observable<T> {
|
|
72
|
-
return this.
|
|
80
|
+
return this.appDataReady().pipe(
|
|
81
|
+
switchMap(() => this.http.get<T>(this.crudUrl(apiName, 'get'), {params: this.toParams(filter)})),
|
|
82
|
+
catchError(this.handleError),
|
|
83
|
+
);
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
post<T>(apiName: string, items: T | T[]) : Observable<{message: string}> {
|
|
76
|
-
return this.
|
|
87
|
+
return this.appDataReady().pipe(
|
|
88
|
+
switchMap(() => this.http.post<{message: string}>(this.crudUrl(apiName, 'post'), items)),
|
|
89
|
+
catchError(this.handleError),
|
|
90
|
+
);
|
|
77
91
|
}
|
|
78
92
|
|
|
79
93
|
delete(apiName: string, filter?: {[key: string]: string}): Observable<{message: string}> {
|
|
80
|
-
return this.
|
|
94
|
+
return this.appDataReady().pipe(
|
|
95
|
+
switchMap(() => this.http.delete<{message: string}>(this.crudUrl(apiName, 'delete'), {params: this.toParams(filter)})),
|
|
96
|
+
catchError(this.handleError),
|
|
97
|
+
);
|
|
81
98
|
}
|
|
82
99
|
|
|
83
100
|
/**
|