@proteinjs/db-ui 1.12.2 → 1.13.1
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/CHANGELOG.md +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/form/RecordForm.d.ts.map +1 -1
- package/dist/src/form/RecordForm.js +63 -3
- package/dist/src/form/RecordForm.js.map +1 -1
- package/dist/src/table/RecordTable.d.ts +10 -0
- package/dist/src/table/RecordTable.d.ts.map +1 -1
- package/dist/src/table/RecordTable.js +126 -49
- package/dist/src/table/RecordTable.js.map +1 -1
- package/dist/src/table/ReferenceCellValue.d.ts +14 -0
- package/dist/src/table/ReferenceCellValue.d.ts.map +1 -0
- package/dist/src/table/ReferenceCellValue.js +168 -0
- package/dist/src/table/ReferenceCellValue.js.map +1 -0
- package/dist/test/recordFormStructuredFields.test.d.ts +5 -0
- package/dist/test/recordFormStructuredFields.test.d.ts.map +1 -0
- package/dist/test/recordFormStructuredFields.test.js +301 -0
- package/dist/test/recordFormStructuredFields.test.js.map +1 -0
- package/dist/test/recordTableDefaultRenderers.test.d.ts +5 -0
- package/dist/test/recordTableDefaultRenderers.test.d.ts.map +1 -0
- package/dist/test/recordTableDefaultRenderers.test.js +309 -0
- package/dist/test/recordTableDefaultRenderers.test.js.map +1 -0
- package/index.ts +1 -0
- package/package.json +5 -4
- package/src/form/RecordForm.tsx +76 -3
- package/src/table/RecordTable.tsx +154 -49
- package/src/table/ReferenceCellValue.tsx +133 -0
- package/test/recordFormStructuredFields.test.tsx +169 -0
- 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 {
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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> = (
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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(
|
|
107
|
-
|
|
160
|
+
columnProperties.push(candidate.columnPropertyName as keyof T);
|
|
161
|
+
}
|
|
108
162
|
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
+
const { referenceTable } = column as unknown as ReferenceArrayColumn<any>;
|
|
196
|
+
return <ReferenceArrayCellValue tableName={value?._table || referenceTable} ids={value?._ids} />;
|
|
119
197
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
223
|
+
return <DateCellValue value={value} />;
|
|
146
224
|
}
|
|
147
225
|
if (isInstanceOf(column, DateTimeColumn)) {
|
|
148
|
-
return
|
|
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
|
|
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
|
-
|
|
169
|
-
defaultConfig[columnName]
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
});
|