@nauticana/sail 0.9.2 → 0.9.4

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,32 @@ 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, display_mode,
638
+ display_width, display_rows`). sail honours three optional `TableColumn`
639
+ fields it produces:
640
+
641
+ | Field | Effect |
642
+ |----------------|--------|
643
+ | `DisplayMode` | `R` read-only (shown display-only; **hidden when empty**, so a new record's audit fields disappear and on edit only populated ones show). `H` hidden everywhere — the new-record builder omits it so the DB default/sequence/trigger fills it. `D` editable, prefilled with the column default on a new record. `I` insert-only — editable while creating, locked once it exists. `U` update-stamp — display-only like `R` in the UI, but keel auto-sets it on every UPDATE (`now()` for timestamps, `user_id` for integers), so `updated_at`/`updated_by` need no DB trigger. Empty/NULL = editable. |
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
+ `R`/`H`/`I` are also enforced server-side by keel (excluded from generic
648
+ INSERT/UPDATE), so the modes are real, not just cosmetic. No rows seeded =
649
+ unchanged behaviour. Requires keel with the `column_display_attribute`
650
+ feature; rebuild httpsrv after seeding (metadata is read once at startup).
651
+
626
652
  ## Account deletion / logout everywhere / push tokens
627
653
 
628
654
  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.2",
3
+ "version": "0.9.4",
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",
@@ -6,6 +6,11 @@ export abstract class BaseForm extends BaseTable {
6
6
  title = '';
7
7
 
8
8
  override isReadOnly(fieldName: string): boolean {
9
+ // keel column_display_attribute: 'R' is always display-only; 'I'
10
+ // (insert-only) is editable while creating, locked once it exists.
11
+ const mode = this.getColumn(fieldName)?.DisplayMode;
12
+ if (mode === 'R' || mode === 'U') return true; // 'U' = keel-set audit stamp
13
+ if (mode === 'I') return !this.isNew;
9
14
  return !this.isNew && this.isKey(fieldName);
10
15
  }
11
16
  }
@@ -169,7 +169,20 @@ export abstract class BaseTable {
169
169
  if (field.SequenceName) {
170
170
  return; // auto-generated by database sequence
171
171
  }
172
- if (field.HasDefault && field.DataType === 'timestamp'
172
+ // column_display_attribute modes (explicit, win over the
173
+ // timestamp heuristic). H/R/U are server-managed — omit so the
174
+ // DB default/keel stamp fills them. D = seed the column default.
175
+ if (field.DisplayMode === 'H' || field.DisplayMode === 'R'
176
+ || field.DisplayMode === 'U') {
177
+ return;
178
+ }
179
+ if (field.DisplayMode === 'D') {
180
+ record[field.PascalName] = field.DataType === 'boolean'
181
+ ? field.DefaultValue === 'true'
182
+ : (field.DefaultValue ?? '');
183
+ return;
184
+ }
185
+ if (!field.DisplayMode && field.HasDefault && field.DataType === 'timestamp'
173
186
  && !editableTimestamps.has(field.PascalName)) {
174
187
  return; // audit column — server-managed, don't include
175
188
  }
@@ -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]="readonlyField() ? displayCaption : value()" [required]="required" [readonly]="readonlyField()">
5
- <button matIconButton matSuffix (click)="openLookup()" [disabled]="readonlyField()">
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' && readonlyField()) {
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]="readonlyField()">{{ colCaption(field()) }}</mat-checkbox>
25
- } @else if (readonlyField() && isDateLike && !value()) {
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]="readonlyField()"
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]="readonlyField()"
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]': '!readonlyField()',
21
- '[class.field-readonly]': 'readonlyField()',
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
+ /** Read-only state for this field. The parent form computes it (keys,
58
+ * FK columns, and column_display_attribute modes R/I) and feeds it in. */
59
+ get isReadonly(): boolean {
60
+ return this.readonlyField();
61
+ }
62
+
57
63
  get isTextArea(): boolean {
58
64
  if (!this.col) return false;
59
- return this.col.Size > 80;
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;
@@ -36,9 +36,17 @@ export class RecordForm {
36
36
  this.guiConfig.tableOverrides?.[this.tableName()]?.editableTimestamps ?? []
37
37
  );
38
38
  if (tableDef && tableDef.Columns && tableDef.Columns.length > 0) {
39
+ const record = this.record();
39
40
  return tableDef.Columns
40
41
  .filter(col => !(col.HasDefault && col.DataType === 'timestamp'
41
42
  && !editableTimestamps.has(col.PascalName)))
43
+ // column_display_attribute: 'H' is never shown; 'R'/'U' (read-only
44
+ // and keel-set audit stamp) show display-only but hide when empty —
45
+ // so a new record's audit fields disappear and on edit only
46
+ // populated ones show.
47
+ .filter(col => col.DisplayMode !== 'H')
48
+ .filter(col => !((col.DisplayMode === 'R' || col.DisplayMode === 'U')
49
+ && this.isEmpty(record[col.PascalName])))
42
50
  .sort((a, b) => a.Order - b.Order)
43
51
  .map(col => col.PascalName)
44
52
  .filter(key => !hidden.has(key) && recordKeys.has(key));
@@ -50,6 +58,10 @@ export class RecordForm {
50
58
  return Array.isArray(value);
51
59
  }
52
60
 
61
+ private isEmpty(value: any): boolean {
62
+ return value === null || value === undefined || value === '';
63
+ }
64
+
53
65
  onRecordUpdate(updates: {[key: string]: any}) {
54
66
  Object.assign(this.record(), updates);
55
67
  }
@@ -54,6 +54,12 @@ export interface TableColumn {
54
54
  HasDefault: boolean;
55
55
  DefaultValue: string;
56
56
  Validators?: AbstractControlOptions['validators'];
57
+ // Optional per-column UI/CRUD overrides from keel's column_display_attribute.
58
+ // DisplayMode: 'R' read-only, 'H' hidden, 'D' default-seeded, 'I' insert-only;
59
+ // absent/'' = editable default. Absent width/rows = fall back to heuristics.
60
+ DisplayMode?: string;
61
+ DisplayWidth?: number;
62
+ DisplayRows?: number;
57
63
  }
58
64
 
59
65
  // One custom button registered against a table via the basis