@proteinjs/db-ui 1.9.4 → 1.10.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 +20 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/src/form/RecordForm.d.ts.map +1 -1
- package/dist/src/form/RecordForm.js +98 -19
- package/dist/src/form/RecordForm.js.map +1 -1
- package/dist/src/form/customizations/MigrationRecordFormCustomization.d.ts +5 -0
- package/dist/src/form/customizations/MigrationRecordFormCustomization.d.ts.map +1 -1
- package/dist/src/form/customizations/MigrationRecordFormCustomization.js +72 -0
- package/dist/src/form/customizations/MigrationRecordFormCustomization.js.map +1 -1
- package/dist/src/table/RecordTable.d.ts.map +1 -1
- package/dist/src/table/RecordTable.js +7 -1
- package/dist/src/table/RecordTable.js.map +1 -1
- package/dist/src/tableDisplayName.d.ts +9 -0
- package/dist/src/tableDisplayName.d.ts.map +1 -0
- package/dist/src/tableDisplayName.js +31 -0
- package/dist/src/tableDisplayName.js.map +1 -0
- package/dist/test/recordFormInteractions.test.d.ts +5 -0
- package/dist/test/recordFormInteractions.test.d.ts.map +1 -0
- package/dist/test/recordFormInteractions.test.js +370 -0
- package/dist/test/recordFormInteractions.test.js.map +1 -0
- package/dist/test/recordTableDelete.test.d.ts +5 -0
- package/dist/test/recordTableDelete.test.d.ts.map +1 -0
- package/dist/test/recordTableDelete.test.js +254 -0
- package/dist/test/recordTableDelete.test.js.map +1 -0
- package/dist/test/tableDisplayName.test.d.ts +2 -0
- package/dist/test/tableDisplayName.test.d.ts.map +1 -0
- package/dist/test/tableDisplayName.test.js +50 -0
- package/dist/test/tableDisplayName.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/index.ts +2 -0
- package/jest.config.js +8 -1
- package/package.json +5 -4
- package/src/form/RecordForm.tsx +109 -22
- package/src/form/customizations/MigrationRecordFormCustomization.tsx +53 -1
- package/src/table/RecordTable.tsx +9 -1
- package/src/tableDisplayName.ts +28 -0
- package/test/recordFormInteractions.test.tsx +259 -0
- package/test/recordTableDelete.test.tsx +144 -0
- package/test/tableDisplayName.test.ts +40 -0
package/src/form/RecordForm.tsx
CHANGED
|
@@ -2,12 +2,13 @@ import React from 'react';
|
|
|
2
2
|
import S from 'string';
|
|
3
3
|
import moment from 'moment';
|
|
4
4
|
import { StringUtil, isInstanceOf } from '@proteinjs/util';
|
|
5
|
-
import { Form, Fields, textField, FormButtons } from '@proteinjs/ui';
|
|
5
|
+
import { Form, Fields, textField, checkboxField, dateField, FormButtons } from '@proteinjs/ui';
|
|
6
6
|
import {
|
|
7
7
|
Table,
|
|
8
8
|
Record,
|
|
9
9
|
Column,
|
|
10
10
|
getDbService,
|
|
11
|
+
DateColumn,
|
|
11
12
|
DateTimeColumn,
|
|
12
13
|
BooleanColumn,
|
|
13
14
|
Reference,
|
|
@@ -77,6 +78,15 @@ function parseReferenceIds(value: unknown): string[] {
|
|
|
77
78
|
.filter(Boolean);
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/** Parse a native date/datetime-local input value ('YYYY-MM-DD' / 'YYYY-MM-DDTHH:mm'). */
|
|
82
|
+
function parseDateInputValue(value: unknown): moment.Moment | null {
|
|
83
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return moment(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
function parseBooleanValue(value: unknown): boolean | null {
|
|
81
91
|
if (typeof value === 'boolean') {
|
|
82
92
|
return value;
|
|
@@ -157,17 +167,81 @@ export function RecordForm<T extends Record>({ table, record }: RecordFormProps<
|
|
|
157
167
|
function createFields(): () => Fields {
|
|
158
168
|
return () => {
|
|
159
169
|
const fields: Fields = {};
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
label: StringUtil.humanizeCamel(columnPropertyName),
|
|
164
|
-
});
|
|
170
|
+
const columns = getColumns();
|
|
171
|
+
for (const columnPropertyName in columns) {
|
|
172
|
+
fields[columnPropertyName] = createField(columnPropertyName, columns[columnPropertyName]);
|
|
165
173
|
}
|
|
166
174
|
|
|
167
175
|
return fields;
|
|
168
176
|
};
|
|
169
177
|
}
|
|
170
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Server-managed columns (`id`/`created`/`updated`) and stored timestamps (`DateTimeColumn`)
|
|
181
|
+
* are readonly on existing records; readonly was previously applied in `onLoad`, which made
|
|
182
|
+
* field-control selection impossible at creation time — it lives here now so each column type
|
|
183
|
+
* can pick its control up front.
|
|
184
|
+
*/
|
|
185
|
+
function isReadonlyField(columnPropertyName: string, column: Column<T, any>) {
|
|
186
|
+
return (
|
|
187
|
+
!isNewRecord &&
|
|
188
|
+
(columnPropertyName == 'id' ||
|
|
189
|
+
columnPropertyName == 'created' ||
|
|
190
|
+
columnPropertyName == 'updated' ||
|
|
191
|
+
isInstanceOf(column, DateTimeColumn))
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Pick the field control that tells the truth about the column's type. */
|
|
196
|
+
function createField(columnPropertyName: string, column: Column<T, any>) {
|
|
197
|
+
const name = columnPropertyName;
|
|
198
|
+
const label = StringUtil.humanizeCamel(columnPropertyName);
|
|
199
|
+
|
|
200
|
+
// Readonly values render as text (a native date input isn't text-selectable): copyable
|
|
201
|
+
// ids/timestamps beat a type-specific control the user can't interact with anyway.
|
|
202
|
+
if (isReadonlyField(columnPropertyName, column)) {
|
|
203
|
+
return textField({ name, label, accessibility: { readonly: true } });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (isInstanceOf(column, BooleanColumn)) {
|
|
207
|
+
return checkboxField({ name, label });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (isInstanceOf(column, DateColumn)) {
|
|
211
|
+
return dateField({ name, label });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Only reachable on new-record forms; on existing records DateTimeColumns are readonly.
|
|
215
|
+
if (isInstanceOf(column, DateTimeColumn)) {
|
|
216
|
+
return dateField({ name, label, includeTime: true });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (isInstanceOf(column, ReferenceColumn)) {
|
|
220
|
+
const { referenceTable } = column as unknown as ReferenceColumn<any>;
|
|
221
|
+
return textField({
|
|
222
|
+
name,
|
|
223
|
+
label,
|
|
224
|
+
description: `${referenceTable} record id`,
|
|
225
|
+
onChange: async (value, fields, setFieldStatus) => {
|
|
226
|
+
if (typeof value === 'string' && value.includes(',')) {
|
|
227
|
+
setFieldStatus(`Enter a single ${referenceTable} record id`, true);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (isInstanceOf(column, ReferenceArrayColumn)) {
|
|
234
|
+
const { referenceTable } = column as unknown as ReferenceArrayColumn<any>;
|
|
235
|
+
return textField({
|
|
236
|
+
name,
|
|
237
|
+
label,
|
|
238
|
+
description: `Comma-separated ${referenceTable} record ids`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return textField({ name, label });
|
|
243
|
+
}
|
|
244
|
+
|
|
171
245
|
function fieldLayout(): any {
|
|
172
246
|
const columns = getColumns();
|
|
173
247
|
const layoutColumns = Object.entries(columns).length > 6 ? 2 : 1;
|
|
@@ -213,6 +287,14 @@ export function RecordForm<T extends Record>({ table, record }: RecordFormProps<
|
|
|
213
287
|
return parseBooleanValue(fieldValue);
|
|
214
288
|
}
|
|
215
289
|
|
|
290
|
+
if (isInstanceOf(column, DateColumn)) {
|
|
291
|
+
return parseDateInputValue(fieldValue)?.toDate() ?? null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (isInstanceOf(column, DateTimeColumn)) {
|
|
295
|
+
return parseDateInputValue(fieldValue);
|
|
296
|
+
}
|
|
297
|
+
|
|
216
298
|
return fieldValue;
|
|
217
299
|
}
|
|
218
300
|
|
|
@@ -228,6 +310,11 @@ export function RecordForm<T extends Record>({ table, record }: RecordFormProps<
|
|
|
228
310
|
color: 'primary',
|
|
229
311
|
variant: 'text',
|
|
230
312
|
},
|
|
313
|
+
confirm: (fields: Fields) => ({
|
|
314
|
+
title: `Delete ${S(table.name).humanize().s}?`,
|
|
315
|
+
message: 'This permanently deletes the record.',
|
|
316
|
+
confirmButtonText: 'Delete',
|
|
317
|
+
}),
|
|
231
318
|
redirect: async (fields: Fields, buttons: FormButtons<Fields>) => {
|
|
232
319
|
return { path: recordTableLink(table) };
|
|
233
320
|
},
|
|
@@ -258,6 +345,13 @@ export function RecordForm<T extends Record>({ table, record }: RecordFormProps<
|
|
|
258
345
|
}
|
|
259
346
|
|
|
260
347
|
for (const columnPropertyName in fields) {
|
|
348
|
+
// Readonly fields are display-only; their values are formatted strings. The loaded
|
|
349
|
+
// record already holds the real values (id keys the update; created/updated stay
|
|
350
|
+
// moments — writing the display string back serialized `created` to null on save).
|
|
351
|
+
if (isReadonlyField(columnPropertyName, getColumn(columnPropertyName))) {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
261
355
|
const field = fields[columnPropertyName];
|
|
262
356
|
(record as any)[columnPropertyName] = getFieldValue(columnPropertyName, field.field.value);
|
|
263
357
|
}
|
|
@@ -308,29 +402,22 @@ export function RecordForm<T extends Record>({ table, record }: RecordFormProps<
|
|
|
308
402
|
const field = fields[columnPropertyName].field;
|
|
309
403
|
let fieldValue = (record as any)[columnPropertyName];
|
|
310
404
|
|
|
311
|
-
if (
|
|
312
|
-
fieldValue = fieldValue.format('ddd, MMM Do YY, h:mm:ss a');
|
|
313
|
-
} else if (isReferenceValue(fieldValue)) {
|
|
405
|
+
if (isReferenceValue(fieldValue)) {
|
|
314
406
|
fieldValue = fieldValue._id || '';
|
|
315
407
|
} else if (isReferenceArrayValue(fieldValue)) {
|
|
316
408
|
fieldValue = fieldValue._ids.join(', ');
|
|
317
409
|
} else if (isInstanceOf(column, BooleanColumn)) {
|
|
318
|
-
|
|
410
|
+
// The checkbox control takes a real boolean, not a 'True'/'False' display string
|
|
411
|
+
fieldValue = fieldValue == true;
|
|
412
|
+
} else if (isInstanceOf(column, DateColumn) && fieldValue) {
|
|
413
|
+
// The native date input takes its own value format
|
|
414
|
+
fieldValue = moment(fieldValue).format('YYYY-MM-DD');
|
|
415
|
+
} else if (moment.isMoment(fieldValue)) {
|
|
416
|
+
// Readonly timestamps (created/updated/DateTimeColumn) display human-formatted, copyable
|
|
417
|
+
fieldValue = fieldValue.format('ddd, MMM Do YY, h:mm:ss a');
|
|
319
418
|
}
|
|
320
419
|
|
|
321
420
|
field.value = fieldValue;
|
|
322
|
-
if (
|
|
323
|
-
columnPropertyName == 'created' ||
|
|
324
|
-
columnPropertyName == 'updated' ||
|
|
325
|
-
columnPropertyName == 'id' ||
|
|
326
|
-
isInstanceOf(column, DateTimeColumn)
|
|
327
|
-
) {
|
|
328
|
-
if (!field.accessibility) {
|
|
329
|
-
field.accessibility = {};
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
field.accessibility.readonly = true;
|
|
333
|
-
}
|
|
334
421
|
}
|
|
335
422
|
}
|
|
336
423
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fields, FormButtons } from '@proteinjs/ui';
|
|
2
|
-
import { Migration, getMigrationRunnerService, tables } from '@proteinjs/db';
|
|
2
|
+
import { Migration, getDbService, getMigrationRunnerService, tables } from '@proteinjs/db';
|
|
3
3
|
import { RecordFormCustomization } from '../RecordFormCustomization';
|
|
4
4
|
|
|
5
5
|
export class MigrationRecordFormCustomization extends RecordFormCustomization {
|
|
@@ -26,6 +26,58 @@ export class MigrationRecordFormCustomization extends RecordFormCustomization {
|
|
|
26
26
|
return `Starting migration`;
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
|
+
// Retire/Un-retire: the deploy-gated series stamps `retired` on rows whose source class no
|
|
30
|
+
// longer ships, and a retired row is never auto-run again until un-retired here. The Form
|
|
31
|
+
// renders button labels statically, so the toggle is a mutually-exclusive button pair whose
|
|
32
|
+
// visibility flips in place after each write.
|
|
33
|
+
formButtons['retire'] = {
|
|
34
|
+
name: 'Retire',
|
|
35
|
+
accessibility: {
|
|
36
|
+
hidden: !migration || migration.retired === true,
|
|
37
|
+
},
|
|
38
|
+
style: {
|
|
39
|
+
color: 'primary',
|
|
40
|
+
variant: 'text',
|
|
41
|
+
},
|
|
42
|
+
onClick: async (fields: Fields, buttons: FormButtons<Fields>) => {
|
|
43
|
+
await this.setRetired(migration, true, fields, formButtons);
|
|
44
|
+
return `Migration retired — the deploy series will not auto-run it`;
|
|
45
|
+
},
|
|
46
|
+
progressMessage: (fields: Fields) => {
|
|
47
|
+
return `Retiring migration`;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
formButtons['unretire'] = {
|
|
51
|
+
name: 'Un-retire',
|
|
52
|
+
accessibility: {
|
|
53
|
+
hidden: !migration || migration.retired !== true,
|
|
54
|
+
},
|
|
55
|
+
style: {
|
|
56
|
+
color: 'primary',
|
|
57
|
+
variant: 'text',
|
|
58
|
+
},
|
|
59
|
+
onClick: async (fields: Fields, buttons: FormButtons<Fields>) => {
|
|
60
|
+
await this.setRetired(migration, false, fields, formButtons);
|
|
61
|
+
return `Migration un-retired — the deploy series can auto-run it again`;
|
|
62
|
+
},
|
|
63
|
+
progressMessage: (fields: Fields) => {
|
|
64
|
+
return `Un-retiring migration`;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
29
67
|
return formButtons;
|
|
30
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Partial write + in-place view reconcile: the flag flips on the row, the loaded record, the
|
|
72
|
+
* rendered field, and the button pair — no whole-record save, no stale intermediate state.
|
|
73
|
+
*/
|
|
74
|
+
private async setRetired(migration: Migration, retired: boolean, fields: Fields, formButtons: FormButtons<any>) {
|
|
75
|
+
await getDbService().update(this.table, { id: migration.id, retired } as Partial<Migration>);
|
|
76
|
+
migration.retired = retired;
|
|
77
|
+
if (fields['retired']) {
|
|
78
|
+
fields['retired'].field.value = retired ? 'True' : 'False';
|
|
79
|
+
}
|
|
80
|
+
formButtons['retire'].accessibility = { hidden: retired };
|
|
81
|
+
formButtons['unretire'].accessibility = { hidden: !retired };
|
|
82
|
+
}
|
|
31
83
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { QueryTableLoader } from './QueryTableLoader';
|
|
15
15
|
import { newRecordFormLink, recordFormLink } from '../pages/RecordFormPage';
|
|
16
16
|
import { recordTableLink } from '../pages/RecordTablePage';
|
|
17
|
+
import { tableDisplayName } from '../tableDisplayName';
|
|
17
18
|
import { isInstanceOf } from '@proteinjs/util';
|
|
18
19
|
import {
|
|
19
20
|
IntegerColumn,
|
|
@@ -46,6 +47,13 @@ function deleteButton<T extends Record>(table: Table<T>): TableButton<T> {
|
|
|
46
47
|
showWhenRowsSelected: true,
|
|
47
48
|
showWhenNoRowsSelected: false,
|
|
48
49
|
},
|
|
50
|
+
confirm: (selectedRows) => ({
|
|
51
|
+
title: `Delete ${selectedRows.length} ${selectedRows.length == 1 ? 'row' : 'rows'}?`,
|
|
52
|
+
message: `This permanently deletes ${
|
|
53
|
+
selectedRows.length == 1 ? 'the selected row' : 'the selected rows'
|
|
54
|
+
} from ${tableDisplayName(table)}.`,
|
|
55
|
+
confirmButtonText: 'Delete',
|
|
56
|
+
}),
|
|
49
57
|
onClick: async (selectedRows, navigate) => {
|
|
50
58
|
const qb = new QueryBuilderFactory()
|
|
51
59
|
.getQueryBuilder(table)
|
|
@@ -191,7 +199,7 @@ export function RecordTable<T extends Record>(props: RecordTableProps<T>) {
|
|
|
191
199
|
|
|
192
200
|
return (
|
|
193
201
|
<TableComponent
|
|
194
|
-
title={props.title ? props.title :
|
|
202
|
+
title={props.title ? props.title : tableDisplayName(props.table)}
|
|
195
203
|
columns={props.columns ? props.columns : defaultColumns()}
|
|
196
204
|
columnConfig={mergeColumnConfigs()}
|
|
197
205
|
tableLoader={props.tableLoader ? props.tableLoader : defaultTableLoader()}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import S from 'string';
|
|
2
|
+
import { Table } from '@proteinjs/db';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Human title for a table's record collection: the humanized table name with its last word
|
|
6
|
+
* pluralized — 'user' → 'Users', 'access_grant' → 'Access grants'. `Table` carries no display
|
|
7
|
+
* metadata, so the name is derived; the pluralization is deliberately boring (s/es/ies) and
|
|
8
|
+
* predictable rather than a full inflection library.
|
|
9
|
+
*/
|
|
10
|
+
export function tableDisplayName(table: Table<any>): string {
|
|
11
|
+
const humanized = S(table.name).humanize().s;
|
|
12
|
+
const words = humanized.split(' ');
|
|
13
|
+
words[words.length - 1] = pluralize(words[words.length - 1]);
|
|
14
|
+
return words.join(' ');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function pluralize(word: string): string {
|
|
18
|
+
const lower = word.toLowerCase();
|
|
19
|
+
if (/(s|x|z|ch|sh)$/.test(lower)) {
|
|
20
|
+
return `${word}es`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (/[^aeiou]y$/.test(lower)) {
|
|
24
|
+
return `${word.slice(0, -1)}ies`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return `${word}s`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* RecordForm functional gaps (task #53 part 2):
|
|
5
|
+
* - item 2: Delete routes through the confirmation dialog — the service delete only runs after
|
|
6
|
+
* the user confirms (the immediate-delete repro), and cancel is a no-op.
|
|
7
|
+
* - item 3: readonly fields (id/created/updated) are readOnly, not disabled — copyable.
|
|
8
|
+
* - item 7: field controls tell the truth about column types — booleans render as checkboxes,
|
|
9
|
+
* dates as native date inputs, reference columns say what they expect ('comma-separated ids').
|
|
10
|
+
* - save-path truth: the update payload carries real values — a boolean stays a boolean, a
|
|
11
|
+
* DateColumn becomes a Date — and readonly display strings are never written back into the
|
|
12
|
+
* record (pre-fix, `created` round-tripped through its display string, which
|
|
13
|
+
* DateTimeColumn.serialize turns into null on every save).
|
|
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 {
|
|
21
|
+
BooleanColumn,
|
|
22
|
+
DateColumn,
|
|
23
|
+
DateTimeColumn,
|
|
24
|
+
Record,
|
|
25
|
+
Reference,
|
|
26
|
+
ReferenceArray,
|
|
27
|
+
ReferenceArrayColumn,
|
|
28
|
+
ReferenceColumn,
|
|
29
|
+
StringColumn,
|
|
30
|
+
Table,
|
|
31
|
+
withRecordColumns,
|
|
32
|
+
} from '@proteinjs/db';
|
|
33
|
+
// Load the package's reflection source graph: RecordForm resolves RecordFormCustomizations
|
|
34
|
+
// through SourceRepository, which only knows the type once the generated index has merged it.
|
|
35
|
+
import '../generated';
|
|
36
|
+
import { RecordForm } from '../src/form/RecordForm';
|
|
37
|
+
|
|
38
|
+
const mockDbService: { get: jest.Mock; insert: jest.Mock; update: jest.Mock; delete: jest.Mock } = {
|
|
39
|
+
get: jest.fn(),
|
|
40
|
+
insert: jest.fn(async (table: any, record: any) => record),
|
|
41
|
+
update: jest.fn(async (table: any, record: any) => record),
|
|
42
|
+
delete: jest.fn(async () => 1),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
jest.mock('@proteinjs/db', () => ({
|
|
46
|
+
...jest.requireActual('@proteinjs/db'),
|
|
47
|
+
getDbService: () => mockDbService,
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
declare global {
|
|
51
|
+
// eslint-disable-next-line no-var
|
|
52
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean;
|
|
53
|
+
}
|
|
54
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
55
|
+
|
|
56
|
+
interface Task extends Record {
|
|
57
|
+
title: string;
|
|
58
|
+
active: boolean | null;
|
|
59
|
+
dueDate: Date;
|
|
60
|
+
archivedAt: moment.Moment | null;
|
|
61
|
+
owner: Reference<any>;
|
|
62
|
+
tags: ReferenceArray<any>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class TaskTable extends Table<Task> {
|
|
66
|
+
public name = 'admin_test_task';
|
|
67
|
+
public columns = withRecordColumns<Task>({
|
|
68
|
+
title: new StringColumn('title'),
|
|
69
|
+
active: new BooleanColumn('active'),
|
|
70
|
+
dueDate: new DateColumn('due_date'),
|
|
71
|
+
archivedAt: new DateTimeColumn('archived_at'),
|
|
72
|
+
owner: new ReferenceColumn('owner', 'user', false),
|
|
73
|
+
// ObjectColumn descendants (incl. ReferenceArrayColumn) are ui.hidden by default; surface it
|
|
74
|
+
tags: new ReferenceArrayColumn('tags', 'tag', false, { ui: { hidden: false } }),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const created = moment('2026-01-02T03:04:05.000Z');
|
|
79
|
+
const updated = moment('2026-02-03T04:05:06.000Z');
|
|
80
|
+
const archivedAt = moment('2026-03-04T05:06:07.000Z');
|
|
81
|
+
|
|
82
|
+
function loadedRecord(): Task {
|
|
83
|
+
return {
|
|
84
|
+
id: 'task-1',
|
|
85
|
+
title: 'Write tests',
|
|
86
|
+
active: true,
|
|
87
|
+
dueDate: moment('2026-08-10', 'YYYY-MM-DD').toDate(),
|
|
88
|
+
archivedAt,
|
|
89
|
+
owner: new Reference('user', 'user-9'),
|
|
90
|
+
tags: new ReferenceArray('tag', ['tag-1', 'tag-2']),
|
|
91
|
+
created,
|
|
92
|
+
updated,
|
|
93
|
+
} as Task;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('RecordForm', () => {
|
|
97
|
+
let container: HTMLDivElement;
|
|
98
|
+
let root: Root;
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
jest.clearAllMocks();
|
|
102
|
+
container = document.createElement('div');
|
|
103
|
+
document.body.appendChild(container);
|
|
104
|
+
root = createRoot(container);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
act(() => {
|
|
109
|
+
root.unmount();
|
|
110
|
+
});
|
|
111
|
+
container.remove();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const mount = async (record?: Task) => {
|
|
115
|
+
await act(async () => {
|
|
116
|
+
root.render(
|
|
117
|
+
<MemoryRouter>
|
|
118
|
+
<RecordForm table={new TaskTable()} record={record} />
|
|
119
|
+
</MemoryRouter>
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
// Let Form.onLoad (async componentDidMount work) settle
|
|
123
|
+
await act(async () => {
|
|
124
|
+
await Promise.resolve();
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const findButton = (name: string) => {
|
|
129
|
+
const button = Array.from(document.querySelectorAll('button')).find((b) => b.textContent === name);
|
|
130
|
+
if (!button) {
|
|
131
|
+
throw new Error(`Button not rendered: ${name}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return button;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const click = async (element: Element) => {
|
|
138
|
+
await act(async () => {
|
|
139
|
+
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const inputByLabel = (labelText: string) => {
|
|
144
|
+
const label = Array.from(document.querySelectorAll('label')).find((l) => l.textContent?.startsWith(labelText));
|
|
145
|
+
if (!label) {
|
|
146
|
+
throw new Error(`No field labeled: ${labelText}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const control = label.getAttribute('for')
|
|
150
|
+
? document.getElementById(label.getAttribute('for')!)
|
|
151
|
+
: label.parentElement?.querySelector('input');
|
|
152
|
+
if (!control) {
|
|
153
|
+
throw new Error(`No input for label: ${labelText}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return control as HTMLInputElement;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const dialog = () => document.querySelector('[role="dialog"]');
|
|
160
|
+
|
|
161
|
+
describe('type-truthful field controls (item 7)', () => {
|
|
162
|
+
it('renders booleans as a checked checkbox, dates as a native date input, and readonly timestamps as copyable text', async () => {
|
|
163
|
+
await mount(loadedRecord());
|
|
164
|
+
|
|
165
|
+
const active = inputByLabel('Active');
|
|
166
|
+
expect(active.type).toBe('checkbox');
|
|
167
|
+
expect(active.checked).toBe(true);
|
|
168
|
+
|
|
169
|
+
const dueDate = inputByLabel('Due date');
|
|
170
|
+
expect(dueDate.type).toBe('date');
|
|
171
|
+
expect(dueDate.value).toBe('2026-08-10');
|
|
172
|
+
|
|
173
|
+
// Readonly fields render as text, readOnly-not-disabled (item 3): copyable
|
|
174
|
+
// (`id` itself is ui.hidden at the column layer and never renders)
|
|
175
|
+
for (const labelText of ['Created', 'Updated', 'Archived at']) {
|
|
176
|
+
const input = inputByLabel(labelText);
|
|
177
|
+
expect(input.type).toBe('text');
|
|
178
|
+
expect(input.readOnly).toBe(true);
|
|
179
|
+
expect(input.disabled).toBe(false);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('stops pretending on reference columns: helper text names the expected ids', async () => {
|
|
184
|
+
await mount(loadedRecord());
|
|
185
|
+
|
|
186
|
+
expect(document.body.textContent).toContain('user record id');
|
|
187
|
+
expect(document.body.textContent).toContain('Comma-separated tag record ids');
|
|
188
|
+
expect(inputByLabel('Owner').value).toBe('user-9');
|
|
189
|
+
expect(inputByLabel('Tags').value).toBe('tag-1, tag-2');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('renders a datetime-local input for editable DateTimeColumns on new records', async () => {
|
|
193
|
+
await mount(undefined);
|
|
194
|
+
|
|
195
|
+
expect(inputByLabel('Archived at').type).toBe('datetime-local');
|
|
196
|
+
expect(inputByLabel('Active').type).toBe('checkbox');
|
|
197
|
+
expect(inputByLabel('Active').checked).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('delete confirmation (item 2)', () => {
|
|
202
|
+
it('does not delete on click; the service call runs only after the dialog confirms', async () => {
|
|
203
|
+
await mount(loadedRecord());
|
|
204
|
+
|
|
205
|
+
await click(findButton('Delete'));
|
|
206
|
+
expect(mockDbService.delete).not.toHaveBeenCalled();
|
|
207
|
+
expect(dialog()).not.toBeNull();
|
|
208
|
+
expect(dialog()!.textContent).toContain('Delete Admin test task?');
|
|
209
|
+
|
|
210
|
+
const confirm = Array.from(dialog()!.querySelectorAll('button')).find((b) => b.textContent === 'Delete')!;
|
|
211
|
+
await click(confirm);
|
|
212
|
+
|
|
213
|
+
expect(mockDbService.delete).toHaveBeenCalledTimes(1);
|
|
214
|
+
expect(mockDbService.delete.mock.calls[0][1]).toEqual({ id: 'task-1' });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('cancel is a no-op', async () => {
|
|
218
|
+
await mount(loadedRecord());
|
|
219
|
+
|
|
220
|
+
await click(findButton('Delete'));
|
|
221
|
+
const cancel = Array.from(dialog()!.querySelectorAll('button')).find((b) => b.textContent === 'Cancel')!;
|
|
222
|
+
await click(cancel);
|
|
223
|
+
|
|
224
|
+
expect(mockDbService.delete).not.toHaveBeenCalled();
|
|
225
|
+
expect(dialog()).toBeNull();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('save-path truth', () => {
|
|
230
|
+
it('sends real values: booleans stay booleans, dates become Dates, and readonly fields never round-trip through display strings', async () => {
|
|
231
|
+
await mount(loadedRecord());
|
|
232
|
+
|
|
233
|
+
await click(findButton('Save'));
|
|
234
|
+
|
|
235
|
+
expect(mockDbService.update).toHaveBeenCalledTimes(1);
|
|
236
|
+
const payload = mockDbService.update.mock.calls[0][1];
|
|
237
|
+
expect(payload.active).toBe(true);
|
|
238
|
+
expect(payload.dueDate).toBeInstanceOf(Date);
|
|
239
|
+
expect(moment(payload.dueDate).format('YYYY-MM-DD')).toBe('2026-08-10');
|
|
240
|
+
// Pre-fix, these were the human display strings ('Fri, Jan 2nd 26, ...'); `created` then
|
|
241
|
+
// serialized to null on every save. They must remain the loaded moments.
|
|
242
|
+
expect(moment.isMoment(payload.created)).toBe(true);
|
|
243
|
+
expect(payload.created.valueOf()).toBe(created.valueOf());
|
|
244
|
+
expect(moment.isMoment(payload.archivedAt)).toBe(true);
|
|
245
|
+
expect(payload.archivedAt.valueOf()).toBe(archivedAt.valueOf());
|
|
246
|
+
expect(payload.id).toBe('task-1');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('an unchecked checkbox saves boolean false, not a string', async () => {
|
|
250
|
+
await mount(loadedRecord());
|
|
251
|
+
|
|
252
|
+
await click(inputByLabel('Active'));
|
|
253
|
+
await click(findButton('Save'));
|
|
254
|
+
|
|
255
|
+
const payload = mockDbService.update.mock.calls[0][1];
|
|
256
|
+
expect(payload.active).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|