@proteinjs/db-ui 1.12.1 → 1.13.0

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/src/form/RecordForm.d.ts.map +1 -1
  7. package/dist/src/form/RecordForm.js +63 -3
  8. package/dist/src/form/RecordForm.js.map +1 -1
  9. package/dist/src/table/RecordTable.d.ts +10 -0
  10. package/dist/src/table/RecordTable.d.ts.map +1 -1
  11. package/dist/src/table/RecordTable.js +126 -49
  12. package/dist/src/table/RecordTable.js.map +1 -1
  13. package/dist/src/table/ReferenceCellValue.d.ts +14 -0
  14. package/dist/src/table/ReferenceCellValue.d.ts.map +1 -0
  15. package/dist/src/table/ReferenceCellValue.js +168 -0
  16. package/dist/src/table/ReferenceCellValue.js.map +1 -0
  17. package/dist/test/recordFormStructuredFields.test.d.ts +5 -0
  18. package/dist/test/recordFormStructuredFields.test.d.ts.map +1 -0
  19. package/dist/test/recordFormStructuredFields.test.js +301 -0
  20. package/dist/test/recordFormStructuredFields.test.js.map +1 -0
  21. package/dist/test/recordTableDefaultRenderers.test.d.ts +5 -0
  22. package/dist/test/recordTableDefaultRenderers.test.d.ts.map +1 -0
  23. package/dist/test/recordTableDefaultRenderers.test.js +309 -0
  24. package/dist/test/recordTableDefaultRenderers.test.js.map +1 -0
  25. package/index.ts +1 -0
  26. package/package.json +5 -4
  27. package/src/form/RecordForm.tsx +76 -3
  28. package/src/table/RecordTable.tsx +154 -49
  29. package/src/table/ReferenceCellValue.tsx +133 -0
  30. package/test/recordFormStructuredFields.test.tsx +169 -0
  31. package/test/recordTableDefaultRenderers.test.tsx +218 -0
@@ -1,7 +1,22 @@
1
1
  import React from 'react';
2
2
  import { Delete, Add } from '@mui/icons-material';
3
3
  import S from 'string';
4
- import { CustomRenderer, TableButton, Table as TableComponent, TableLoader, TableProps } from '@proteinjs/ui';
4
+ import { Typography } from '@mui/material';
5
+ import {
6
+ BooleanCellValue,
7
+ ClampedTextCellValue,
8
+ CustomRenderer,
9
+ DateCellValue,
10
+ DateTimeCellValue,
11
+ EmptyCellValue,
12
+ JsonSnippetCellValue,
13
+ StatusChipCellValue,
14
+ TableButton,
15
+ Table as TableComponent,
16
+ TableLoader,
17
+ TableProps,
18
+ isStatusLikeColumnName,
19
+ } from '@proteinjs/ui';
5
20
  import {
6
21
  Column,
7
22
  QueryBuilderFactory,
@@ -15,6 +30,7 @@ import { QueryTableLoader } from './QueryTableLoader';
15
30
  import { newRecordFormLink, recordFormLink } from '../pages/RecordFormPage';
16
31
  import { recordTableLink } from '../pages/RecordTablePage';
17
32
  import { tableDisplayName } from '../tableDisplayName';
33
+ import { ReferenceArrayCellValue, ReferenceCellValue } from './ReferenceCellValue';
18
34
  import { isInstanceOf } from '@proteinjs/util';
19
35
  import {
20
36
  IntegerColumn,
@@ -27,7 +43,6 @@ import {
27
43
  ObjectColumn,
28
44
  ArrayColumn,
29
45
  } from '@proteinjs/db';
30
- import moment from 'moment';
31
46
 
32
47
  type TablePropsToOmit = 'tableLoader' | 'columns';
33
48
  type SpecificTableProps<T> = Omit<TableProps<T>, TablePropsToOmit>;
@@ -78,76 +93,147 @@ function createButton<T extends Record>(table: Table<T>): TableButton<T> {
78
93
  };
79
94
  }
80
95
 
81
- export function RecordTable<T extends Record>(props: RecordTableProps<T>) {
82
- const { ...passthrough } = props;
83
- function defaultColumns() {
84
- const columnProperties: (keyof T)[] = [];
85
- if ((props.table.columns as any)['name']) {
86
- columnProperties.push('name' as keyof T);
87
- }
96
+ /**
97
+ * The meaningful-data default column pick (the founder's ask — a record table should surface
98
+ * what a human scans for, not the schema's first columns). Deterministic tiers over the
99
+ * visible (non-`ui.hidden`) columns:
100
+ * name identity strings (email/title/description/…) → status-like short strings →
101
+ * booleans → references (they render as linked names now) → the rest in schema order,
102
+ * with long-text columns (maxLength ≥ 1000) demoted to the back.
103
+ * Capped at five + created/updated, exactly as before — the tiers change WHICH five.
104
+ */
105
+ export function defaultRecordTableColumns<T extends Record>(table: Table<T>): (keyof T)[] {
106
+ function isIdentityName(name: string) {
107
+ // suffix match so compound names promote too (userEmail, jobTitle)
108
+ return name.endsWith('email') || name.endsWith('title') || ['description', 'label', 'subject'].includes(name);
109
+ }
88
110
 
89
- for (const columnProperty of Object.keys(props.table.columns)) {
90
- if (columnProperties.length >= 5) {
91
- break;
111
+ function tier(columnPropertyName: string, column: Column<T, any>): number {
112
+ const name = columnPropertyName.toLowerCase();
113
+ if (isIdentityName(name)) {
114
+ return 1;
115
+ }
116
+ if (isStatusLikeColumnName(name) && isInstanceOf(column, StringColumn)) {
117
+ return 2;
118
+ }
119
+ if (isInstanceOf(column, BooleanColumn)) {
120
+ return 3;
121
+ }
122
+ if (isInstanceOf(column, ReferenceColumn)) {
123
+ return 4;
124
+ }
125
+ if (isInstanceOf(column, StringColumn)) {
126
+ const { maxLength } = column as unknown as StringColumn;
127
+ if (maxLength === 'MAX' || maxLength >= 1000) {
128
+ return 6;
92
129
  }
130
+ }
131
+ return 5;
132
+ }
93
133
 
94
- if (columnProperty == 'name' || columnProperty == 'created' || columnProperty == 'updated') {
95
- continue;
134
+ const candidates = Object.keys(table.columns)
135
+ .filter((columnPropertyName) => {
136
+ if (['name', 'id', 'created', 'updated'].includes(columnPropertyName)) {
137
+ return false;
96
138
  }
97
139
 
98
- const column: Column<T, any> = (props.table.columns as any)[columnProperty];
99
- if (column.options?.ui?.hidden) {
100
- continue;
101
- }
140
+ const column: Column<T, any> = (table.columns as any)[columnPropertyName];
141
+ return !column.options?.ui?.hidden;
142
+ })
143
+ .map((columnPropertyName, index) => ({
144
+ columnPropertyName,
145
+ index,
146
+ tier: tier(columnPropertyName, (table.columns as any)[columnPropertyName]),
147
+ }))
148
+ .sort((a, b) => (a.tier !== b.tier ? a.tier - b.tier : a.index - b.index));
102
149
 
103
- columnProperties.push(columnProperty as keyof T);
150
+ const columnProperties: (keyof T)[] = [];
151
+ if ((table.columns as any)['name']) {
152
+ columnProperties.push('name' as keyof T);
153
+ }
154
+
155
+ for (const candidate of candidates) {
156
+ if (columnProperties.length >= 5) {
157
+ break;
104
158
  }
105
159
 
106
- columnProperties.push('created');
107
- columnProperties.push('updated');
160
+ columnProperties.push(candidate.columnPropertyName as keyof T);
161
+ }
108
162
 
109
- return columnProperties;
163
+ if ((table.columns as any)['created']) {
164
+ columnProperties.push('created' as keyof T);
165
+ }
166
+ if ((table.columns as any)['updated']) {
167
+ columnProperties.push('updated' as keyof T);
110
168
  }
111
169
 
112
- function getDefaultRenderer(column: Column<any, any>): CustomRenderer<T, any> {
170
+ return columnProperties;
171
+ }
172
+
173
+ export function RecordTable<T extends Record>(props: RecordTableProps<T>) {
174
+ const { ...passthrough } = props;
175
+ function defaultColumns() {
176
+ return defaultRecordTableColumns(props.table);
177
+ }
178
+
179
+ /**
180
+ * The per-COLUMN-TYPE default presentations, on the base table's shared cell grammar
181
+ * (@proteinjs/ui cellValues): references as linked names, booleans as check/dash, dates
182
+ * humanized, blobs as mono snippets, status-like strings as quiet chips. A consumer's
183
+ * `columnConfig.renderer` replaces any of these per column.
184
+ */
185
+ function getDefaultRenderer(column: Column<any, any>, columnPropertyName: string): CustomRenderer<T, any> {
113
186
  return (value: any) => {
187
+ if (value == null || value === '') {
188
+ return <EmptyCellValue />;
189
+ }
114
190
  if (isInstanceOf(column, ReferenceColumn)) {
115
- return value?._id || '';
191
+ const { referenceTable } = column as unknown as ReferenceColumn<any>;
192
+ return <ReferenceCellValue tableName={value?._table || referenceTable} id={value?._id} />;
116
193
  }
117
194
  if (isInstanceOf(column, ReferenceArrayColumn)) {
118
- return value?._ids?.join(', ') || '';
195
+ const { referenceTable } = column as unknown as ReferenceArrayColumn<any>;
196
+ return <ReferenceArrayCellValue tableName={value?._table || referenceTable} ids={value?._ids} />;
119
197
  }
120
- if (value && typeof value === 'object') {
121
- if ('_id' in value && typeof value._id === 'string') {
122
- return value._id;
123
- }
124
- if ('_ids' in value && Array.isArray(value._ids)) {
125
- return value._ids.join(', ');
126
- }
198
+ // Reference-shaped values on columns the registry didn't type (defensive: pre-rev rows)
199
+ if (value && typeof value === 'object' && '_id' in value && typeof value._id === 'string') {
200
+ return <ReferenceCellValue tableName={value._table} id={value._id} />;
201
+ }
202
+ if (value && typeof value === 'object' && '_ids' in value && Array.isArray(value._ids)) {
203
+ return <ReferenceArrayCellValue tableName={value._table} ids={value._ids} />;
127
204
  }
128
205
  if (isInstanceOf(column, ObjectColumn) || isInstanceOf(column, ArrayColumn)) {
129
- return JSON.stringify(value);
206
+ return <JsonSnippetCellValue value={value} />;
130
207
  }
131
208
  if (
132
209
  isInstanceOf(column, IntegerColumn) ||
133
210
  isInstanceOf(column, FloatColumn) ||
134
211
  isInstanceOf(column, DecimalColumn)
135
212
  ) {
136
- return value != null ? value.toString() : '';
137
- }
138
- if (isInstanceOf(column, StringColumn)) {
139
- return value != null ? String(value) : '';
213
+ return (
214
+ <Typography variant='body2' component='span' sx={{ fontVariantNumeric: 'tabular-nums' }}>
215
+ {value.toString()}
216
+ </Typography>
217
+ );
140
218
  }
141
219
  if (isInstanceOf(column, BooleanColumn)) {
142
- return value ? '✅' : '❌';
220
+ return <BooleanCellValue value={value} />;
143
221
  }
144
222
  if (isInstanceOf(column, DateColumn)) {
145
- return value ? moment(value).format('YYYY-MM-DD') : '';
223
+ return <DateCellValue value={value} />;
146
224
  }
147
225
  if (isInstanceOf(column, DateTimeColumn)) {
148
- return value ? moment(value).format('YYYY-MM-DD HH:mm:ss') : '';
226
+ return <DateTimeCellValue value={value} />;
227
+ }
228
+ if (isInstanceOf(column, StringColumn)) {
229
+ const text = String(value);
230
+ // Status-like short values scan as chips; anything longer stays clamped text.
231
+ if (isStatusLikeColumnName(columnPropertyName) && text.length <= 24) {
232
+ return <StatusChipCellValue value={text} />;
233
+ }
234
+ return <ClampedTextCellValue>{text}</ClampedTextCellValue>;
149
235
  }
150
- return value != null ? value.toString() : '';
236
+ return <ClampedTextCellValue>{value.toString()}</ClampedTextCellValue>;
151
237
  };
152
238
  }
153
239
 
@@ -157,20 +243,39 @@ export function RecordTable<T extends Record>(props: RecordTableProps<T>) {
157
243
 
158
244
  for (const columnName of columns) {
159
245
  const column = (props.table.columns as any)[columnName];
246
+ const isNumeric =
247
+ isInstanceOf(column, IntegerColumn) || isInstanceOf(column, FloatColumn) || isInstanceOf(column, DecimalColumn);
248
+ // Plain strings ride the base Table's own default path (body2 + three-line clamp, quiet
249
+ // dash for empties, and the phone card's identity emphasis + empty-field omission) —
250
+ // a renderer here would just re-implement that and lose the card behaviors.
251
+ const isPlainString =
252
+ isInstanceOf(column, StringColumn) &&
253
+ !isStatusLikeColumnName(columnName as string) &&
254
+ !isInstanceOf(column, ReferenceColumn) &&
255
+ !isInstanceOf(column, ObjectColumn) &&
256
+ !isInstanceOf(column, DateColumn) &&
257
+ !isInstanceOf(column, DateTimeColumn);
258
+ if (isPlainString) {
259
+ continue;
260
+ }
261
+
160
262
  defaultConfig[columnName] = {
161
- renderer: getDefaultRenderer(column),
263
+ renderer: getDefaultRenderer(column, columnName as string),
264
+ // The type renderers are value-driven: an empty value means an empty card field.
265
+ omitEmptyOnCard: true,
266
+ // Numbers right-align (they compare by magnitude); a consumer's cellProps replaces this.
267
+ ...(isNumeric ? { cellProps: { align: 'right' as const } } : {}),
162
268
  };
163
269
  }
164
270
 
165
- // Merge with provided columnConfig, if any
271
+ // Merge with provided columnConfig, if any — including columns with no default entry
272
+ // (plain strings ride the base default, but a consumer's config must still land).
166
273
  if (props.columnConfig) {
167
274
  for (const columnName in props.columnConfig) {
168
- if (defaultConfig[columnName]) {
169
- defaultConfig[columnName] = {
170
- ...defaultConfig[columnName],
171
- ...props.columnConfig[columnName],
172
- };
173
- }
275
+ defaultConfig[columnName] = {
276
+ ...defaultConfig[columnName],
277
+ ...props.columnConfig[columnName],
278
+ };
174
279
  }
175
280
  }
176
281
 
@@ -0,0 +1,133 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Link, Typography } from '@mui/material';
3
+ import { useNavigate } from 'react-router-dom';
4
+ import { EmptyCellValue } from '@proteinjs/ui';
5
+ import { Record, Reference } from '@proteinjs/db';
6
+ import { recordFormLink } from '../pages/RecordFormPage';
7
+
8
+ /**
9
+ * A reference rendered as the referenced record's NAME, linked to its record form — an admin
10
+ * scanning a table reads who/what, not a uuid. Resolution rides `Reference.get()` (the
11
+ * ReferenceCache-backed house path) and lands in a module cache so a page of rows resolves
12
+ * each distinct target once and re-renders never flicker. Until the name arrives — and for
13
+ * records without a usable `name` (or ones this session can't read) — the cell shows the
14
+ * short id in mono: truthful immediately, enriched when the name lands.
15
+ */
16
+
17
+ /** `${table}:${id}` → display name (null: resolved, but no name to show). */
18
+ const resolvedNames = new Map<string, string | null>();
19
+ const inflight = new Map<string, Promise<string | null>>();
20
+
21
+ function resolveDisplayName(tableName: string, id: string): Promise<string | null> {
22
+ const key = `${tableName}:${id}`;
23
+ const settled = resolvedNames.get(key);
24
+ if (settled !== undefined) {
25
+ return Promise.resolve(settled);
26
+ }
27
+
28
+ let pending = inflight.get(key);
29
+ if (!pending) {
30
+ pending = (async () => {
31
+ try {
32
+ const record = (await new Reference<Record>(tableName, id).get()) as any;
33
+ const name = record && typeof record.name === 'string' && record.name.trim() ? (record.name as string) : null;
34
+ resolvedNames.set(key, name);
35
+ return name;
36
+ } catch {
37
+ // Unreadable target (row gone, or not visible to this session): the short id stands.
38
+ resolvedNames.set(key, null);
39
+ return null;
40
+ } finally {
41
+ inflight.delete(key);
42
+ }
43
+ })();
44
+ inflight.set(key, pending);
45
+ }
46
+
47
+ return pending;
48
+ }
49
+
50
+ /** Test seam: a resolved-name cache survives unmounts by design; suites reset it between cases. */
51
+ export function clearReferenceNameCache() {
52
+ resolvedNames.clear();
53
+ inflight.clear();
54
+ }
55
+
56
+ export const shortReferenceId = (id: string) => (id.length > 8 ? id.slice(0, 8) : id);
57
+
58
+ export function ReferenceCellValue({ tableName, id }: { tableName?: string; id?: string | null }) {
59
+ const navigate = useNavigate();
60
+ const [name, setName] = useState<string | null>(() =>
61
+ tableName && id ? resolvedNames.get(`${tableName}:${id}`) ?? null : null
62
+ );
63
+
64
+ useEffect(() => {
65
+ if (!tableName || !id) {
66
+ return;
67
+ }
68
+
69
+ let cancelled = false;
70
+ resolveDisplayName(tableName, id).then((resolved) => {
71
+ if (!cancelled) {
72
+ setName(resolved);
73
+ }
74
+ });
75
+ return () => {
76
+ cancelled = true;
77
+ };
78
+ }, [tableName, id]);
79
+
80
+ if (!tableName || !id) {
81
+ return <EmptyCellValue />;
82
+ }
83
+
84
+ // recordFormLink already leads with '/' — no extra prefix (a '//'-prefixed href reads as
85
+ // a protocol-relative URL and breaks navigation).
86
+ const url = recordFormLink(tableName, id);
87
+ return (
88
+ <Link
89
+ href={url}
90
+ underline='hover'
91
+ onClick={(event) => {
92
+ // The row's own click navigates to the HOST record; this link goes to the TARGET.
93
+ event.stopPropagation();
94
+ event.preventDefault();
95
+ navigate(url);
96
+ }}
97
+ sx={{ whiteSpace: 'nowrap' }}
98
+ >
99
+ {name !== null ? (
100
+ <Typography variant='body2' component='span'>
101
+ {name}
102
+ </Typography>
103
+ ) : (
104
+ <Typography
105
+ variant='body2'
106
+ component='span'
107
+ title={id}
108
+ sx={{ fontFamily: 'ui-monospace, Menlo, monospace', fontSize: '0.8125rem' }}
109
+ >
110
+ {shortReferenceId(id)}
111
+ </Typography>
112
+ )}
113
+ </Link>
114
+ );
115
+ }
116
+
117
+ /** A reference array: each target renders through the single-reference presentation. */
118
+ export function ReferenceArrayCellValue({ tableName, ids }: { tableName?: string; ids?: string[] | null }) {
119
+ if (!tableName || !ids || ids.length === 0) {
120
+ return <EmptyCellValue />;
121
+ }
122
+
123
+ return (
124
+ <Typography variant='body2' component='span' sx={{ overflowWrap: 'anywhere' }}>
125
+ {ids.map((id, index) => (
126
+ <React.Fragment key={id}>
127
+ {index > 0 && ', '}
128
+ <ReferenceCellValue tableName={tableName} id={id} />
129
+ </React.Fragment>
130
+ ))}
131
+ </Typography>
132
+ );
133
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ *
4
+ * RecordForm's long-text and structured-value fields (the admin-surface polish rev).
5
+ * Contracts as OUTCOMES:
6
+ * 1. Long-text columns (maxLength > 255) render as multiline textareas — a single-line
7
+ * input truncates exactly what the form exists to show.
8
+ * 2. Object columns present pretty-printed JSON in a multiline field and the SAVE payload
9
+ * carries the parsed OBJECT (round trip), never the display string.
10
+ * 3. Invalid JSON refuses the save with a message naming the field — the service update
11
+ * never runs.
12
+ * 4. Readonly timestamps display compact ('MMM D, YYYY, h:mm A') with the relative read as
13
+ * the field's helper line.
14
+ */
15
+ import React from 'react';
16
+ import moment from 'moment';
17
+ import { createRoot, Root } from 'react-dom/client';
18
+ import { act } from 'react-dom/test-utils';
19
+ import { MemoryRouter } from 'react-router-dom';
20
+ import { ObjectColumn, Record, StringColumn, Table, withRecordColumns } from '@proteinjs/db';
21
+ import '../generated';
22
+ import { RecordForm } from '../src/form/RecordForm';
23
+
24
+ const mockDbService: { get: jest.Mock; insert: jest.Mock; update: jest.Mock; delete: jest.Mock } = {
25
+ get: jest.fn(),
26
+ insert: jest.fn(async (table: any, record: any) => record),
27
+ update: jest.fn(async (table: any, record: any) => record),
28
+ delete: jest.fn(async () => 1),
29
+ };
30
+
31
+ jest.mock('@proteinjs/db', () => ({
32
+ ...jest.requireActual('@proteinjs/db'),
33
+ getDbService: () => mockDbService,
34
+ }));
35
+
36
+ declare global {
37
+ // eslint-disable-next-line no-var
38
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
39
+ }
40
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
41
+
42
+ interface Job extends Record {
43
+ title: string;
44
+ description: string;
45
+ payload: { retries: number } | null;
46
+ }
47
+
48
+ class JobTable extends Table<Job> {
49
+ public name = 'admin_test_job';
50
+ public columns = withRecordColumns<Job>({
51
+ title: new StringColumn('title'),
52
+ description: new StringColumn('description', {}, 4000),
53
+ payload: new ObjectColumn('payload', { ui: { hidden: false } }),
54
+ });
55
+ }
56
+
57
+ const created = moment('2026-01-02T03:04:05.000Z');
58
+ const updated = moment('2026-02-03T04:05:06.000Z');
59
+
60
+ function loadedRecord(): Job {
61
+ return {
62
+ id: 'job-1',
63
+ title: 'Nightly export',
64
+ description: 'A very long description of what the job does.',
65
+ payload: { retries: 3 },
66
+ created,
67
+ updated,
68
+ } as Job;
69
+ }
70
+
71
+ describe('RecordForm structured fields', () => {
72
+ let container: HTMLDivElement;
73
+ let root: Root;
74
+
75
+ beforeEach(() => {
76
+ jest.clearAllMocks();
77
+ container = document.createElement('div');
78
+ document.body.appendChild(container);
79
+ root = createRoot(container);
80
+ });
81
+
82
+ afterEach(() => {
83
+ act(() => root.unmount());
84
+ container.remove();
85
+ });
86
+
87
+ const mount = async (record?: Job) => {
88
+ await act(async () => {
89
+ root.render(
90
+ <MemoryRouter>
91
+ <RecordForm table={new JobTable()} record={record} />
92
+ </MemoryRouter>
93
+ );
94
+ });
95
+ await act(async () => {
96
+ await new Promise((resolve) => setTimeout(resolve, 0));
97
+ });
98
+ };
99
+
100
+ const controlByLabel = (label: string): HTMLInputElement | HTMLTextAreaElement => {
101
+ const labels = Array.from(document.body.querySelectorAll('label'));
102
+ const match = labels.find((candidate) => candidate.textContent?.startsWith(label));
103
+ if (!match) {
104
+ throw new Error(`no field labeled ${label}`);
105
+ }
106
+ const control = document.getElementById(match.htmlFor) as HTMLInputElement | HTMLTextAreaElement;
107
+ if (!control) {
108
+ throw new Error(`no control for label ${label}`);
109
+ }
110
+ return control;
111
+ };
112
+
113
+ const setValue = async (control: HTMLInputElement | HTMLTextAreaElement, value: string) => {
114
+ const proto = control instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
115
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
116
+ await act(async () => {
117
+ setter.call(control, value);
118
+ control.dispatchEvent(new Event('input', { bubbles: true }));
119
+ });
120
+ };
121
+
122
+ const clickButton = async (name: string) => {
123
+ const button = Array.from(document.body.querySelectorAll('button')).find(
124
+ (candidate) => candidate.textContent === name
125
+ )!;
126
+ await act(async () => {
127
+ button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
128
+ });
129
+ await act(async () => {
130
+ await new Promise((resolve) => setTimeout(resolve, 0));
131
+ });
132
+ };
133
+
134
+ it('long-text columns render as multiline textareas; short ones stay single-line inputs', async () => {
135
+ await mount(loadedRecord());
136
+ expect(controlByLabel('Title').tagName).toBe('INPUT');
137
+ expect(controlByLabel('Description').tagName).toBe('TEXTAREA');
138
+ });
139
+
140
+ it('object columns present pretty JSON and save the parsed object (round trip)', async () => {
141
+ await mount(loadedRecord());
142
+ const payloadControl = controlByLabel('Payload');
143
+ expect(payloadControl.tagName).toBe('TEXTAREA');
144
+ expect(payloadControl.value).toBe(JSON.stringify({ retries: 3 }, null, 2));
145
+
146
+ await setValue(payloadControl, '{\n "retries": 5\n}');
147
+ await clickButton('Save');
148
+
149
+ expect(mockDbService.update).toHaveBeenCalledTimes(1);
150
+ const payloadSent = mockDbService.update.mock.calls[0][1].payload;
151
+ expect(payloadSent).toEqual({ retries: 5 });
152
+ });
153
+
154
+ it('invalid JSON refuses the save with a message naming the field; the update never runs', async () => {
155
+ await mount(loadedRecord());
156
+ await setValue(controlByLabel('Payload'), '{not json');
157
+ await clickButton('Save');
158
+
159
+ expect(mockDbService.update).not.toHaveBeenCalled();
160
+ expect(document.body.textContent).toContain('Payload must be valid JSON');
161
+ });
162
+
163
+ it('readonly timestamps display compact with the relative read as the helper line', async () => {
164
+ await mount(loadedRecord());
165
+ const createdControl = controlByLabel('Created') as HTMLInputElement;
166
+ expect(createdControl.value).toBe(created.format('MMM D, YYYY, h:mm A'));
167
+ expect(document.body.textContent).toContain(created.fromNow());
168
+ });
169
+ });