@nauticana/sail 0.9.3 → 0.9.5

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
@@ -634,19 +634,20 @@ The generic edit form renders each column from keel's metadata. By default:
634
634
  - **Read-only** applies only to primary keys (and FK columns in child rows).
635
635
 
636
636
  To override per column without changing the schema, seed keel's
637
- `column_display_attribute` table (`table_name, column_name, read_only,
637
+ `column_display_attribute` table (`table_name, column_name, display_mode,
638
638
  display_width, display_rows`). sail honours three optional `TableColumn`
639
639
  fields it produces:
640
640
 
641
641
  | Field | Effect |
642
642
  |----------------|--------|
643
- | `read_only` | Field renders display-only (use for `*_at` audit timestamps, system-managed status). |
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
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
645
  | `display_width`| Field max-width in px. |
646
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).
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).
650
651
 
651
652
  ## Account deletion / logout everywhere / push tokens
652
653
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
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": [
@@ -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
  }
@@ -54,10 +54,10 @@ 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. */
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
59
  get isReadonly(): boolean {
60
- return this.readonlyField() || !!this.col?.Readonly;
60
+ return this.readonlyField();
61
61
  }
62
62
 
63
63
  get isTextArea(): boolean {
@@ -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
  }
@@ -63,15 +63,18 @@ export class TableEdit extends BaseForm implements OnInit {
63
63
  if (rec) Object.assign(this.editableRecord, rec);
64
64
  }
65
65
 
66
+ /**
67
+ * Tabs are the table's child relations (rest_api_child metadata), not just
68
+ * whatever array fields the backend happened to embed. Seed an empty array
69
+ * for any child the response omitted so its grid renders (and accepts new rows).
70
+ */
66
71
  private buildTabs() {
67
- this.tabCount = 0;
68
- this.tabFields = [];
69
- for (const field of Object.keys(this.editableRecord)) {
70
- if (this.isArray(this.editableRecord[field])) {
71
- this.tabCount++;
72
- this.tabFields.push(field);
73
- }
72
+ const tableDef = this.cacheService.getTableDefinition(this.tableName());
73
+ this.tabFields = (tableDef?.Children ?? []).map((child) => child.PascalName);
74
+ for (const field of this.tabFields) {
75
+ if (!this.isArray(this.editableRecord[field])) this.editableRecord[field] = [];
74
76
  }
77
+ this.tabCount = this.tabFields.length;
75
78
  }
76
79
 
77
80
  /** Re-fetch the row from the backend after a server-side mutation (TableAction success, etc.). */
@@ -54,9 +54,10 @@ 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;
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;
60
61
  DisplayWidth?: number;
61
62
  DisplayRows?: number;
62
63
  }