@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/src/form/RecordForm.d.ts.map +1 -1
  9. package/dist/src/form/RecordForm.js +98 -19
  10. package/dist/src/form/RecordForm.js.map +1 -1
  11. package/dist/src/form/customizations/MigrationRecordFormCustomization.d.ts +5 -0
  12. package/dist/src/form/customizations/MigrationRecordFormCustomization.d.ts.map +1 -1
  13. package/dist/src/form/customizations/MigrationRecordFormCustomization.js +72 -0
  14. package/dist/src/form/customizations/MigrationRecordFormCustomization.js.map +1 -1
  15. package/dist/src/table/RecordTable.d.ts.map +1 -1
  16. package/dist/src/table/RecordTable.js +7 -1
  17. package/dist/src/table/RecordTable.js.map +1 -1
  18. package/dist/src/tableDisplayName.d.ts +9 -0
  19. package/dist/src/tableDisplayName.d.ts.map +1 -0
  20. package/dist/src/tableDisplayName.js +31 -0
  21. package/dist/src/tableDisplayName.js.map +1 -0
  22. package/dist/test/recordFormInteractions.test.d.ts +5 -0
  23. package/dist/test/recordFormInteractions.test.d.ts.map +1 -0
  24. package/dist/test/recordFormInteractions.test.js +370 -0
  25. package/dist/test/recordFormInteractions.test.js.map +1 -0
  26. package/dist/test/recordTableDelete.test.d.ts +5 -0
  27. package/dist/test/recordTableDelete.test.d.ts.map +1 -0
  28. package/dist/test/recordTableDelete.test.js +254 -0
  29. package/dist/test/recordTableDelete.test.js.map +1 -0
  30. package/dist/test/tableDisplayName.test.d.ts +2 -0
  31. package/dist/test/tableDisplayName.test.d.ts.map +1 -0
  32. package/dist/test/tableDisplayName.test.js +50 -0
  33. package/dist/test/tableDisplayName.test.js.map +1 -0
  34. package/generated/index.ts +1 -1
  35. package/index.ts +2 -0
  36. package/jest.config.js +8 -1
  37. package/package.json +5 -4
  38. package/src/form/RecordForm.tsx +109 -22
  39. package/src/form/customizations/MigrationRecordFormCustomization.tsx +53 -1
  40. package/src/table/RecordTable.tsx +9 -1
  41. package/src/tableDisplayName.ts +28 -0
  42. package/test/recordFormInteractions.test.tsx +259 -0
  43. package/test/recordTableDelete.test.tsx +144 -0
  44. package/test/tableDisplayName.test.ts +40 -0
@@ -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
- for (const columnPropertyName in getColumns()) {
161
- fields[columnPropertyName] = textField({
162
- name: columnPropertyName,
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 (moment.isMoment(fieldValue)) {
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
- fieldValue = fieldValue == true ? 'True' : 'False';
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 : `${S(props.table.name).humanize().toString()} Table`}
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
+ });