@proteinjs/db-ui 1.13.0 → 1.14.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.
- package/CHANGELOG.md +24 -0
- package/dist/src/form/RecordForm.d.ts.map +1 -1
- package/dist/src/form/RecordForm.js +124 -11
- package/dist/src/form/RecordForm.js.map +1 -1
- package/dist/src/table/ReferenceCellValue.d.ts.map +1 -1
- package/dist/src/table/ReferenceCellValue.js +4 -1
- package/dist/src/table/ReferenceCellValue.js.map +1 -1
- package/dist/test/recordFormInteractions.test.js +12 -7
- package/dist/test/recordFormInteractions.test.js.map +1 -1
- package/dist/test/recordFormSections.test.d.ts +5 -0
- package/dist/test/recordFormSections.test.d.ts.map +1 -0
- package/dist/test/recordFormSections.test.js +289 -0
- package/dist/test/recordFormSections.test.js.map +1 -0
- package/dist/test/recordFormStructuredFields.test.js +11 -5
- package/dist/test/recordFormStructuredFields.test.js.map +1 -1
- package/package.json +4 -4
- package/src/form/RecordForm.tsx +143 -14
- package/src/table/ReferenceCellValue.tsx +3 -1
- package/test/recordFormInteractions.test.tsx +8 -6
- package/test/recordFormSections.test.tsx +189 -0
- package/test/recordFormStructuredFields.test.tsx +12 -4
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* RecordForm's field GROUPING (round 2). Contracts as OUTCOMES:
|
|
5
|
+
* 1. Sections derive from column type + name in a fixed order: identity (name/email/title)
|
|
6
|
+
* → Content (long text, structured values) → Details (everything else) → System
|
|
7
|
+
* (id/created/updated), and System is always last.
|
|
8
|
+
* 2. A column's `ui.formGroup` hint overrides the derivation; an unknown hint value becomes
|
|
9
|
+
* its own titled section, ordered after Details.
|
|
10
|
+
* 3. The record's id renders in System as a readonly value row — a record form's address is
|
|
11
|
+
* what an admin copies, and the table layer's `ui.hidden` (a DATA-column flag) doesn't
|
|
12
|
+
* bury it here.
|
|
13
|
+
* 4. A single-section form stays unlabeled — one group needs no header.
|
|
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 { BooleanColumn, ObjectColumn, Record, StringColumn, Table, withRecordColumns } from '@proteinjs/db';
|
|
21
|
+
import '../generated';
|
|
22
|
+
import { RecordForm } from '../src/form/RecordForm';
|
|
23
|
+
|
|
24
|
+
const mockDbService = {
|
|
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
|
+
name: string;
|
|
44
|
+
failureMessage: string;
|
|
45
|
+
payload: { retries: number } | null;
|
|
46
|
+
status: string;
|
|
47
|
+
manual: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class JobTable extends Table<Job> {
|
|
51
|
+
public name = 'admin_section_job';
|
|
52
|
+
public columns = withRecordColumns<Job>({
|
|
53
|
+
// Declared deliberately out of section order: the form must GROUP, not echo the schema.
|
|
54
|
+
status: new StringColumn('status'),
|
|
55
|
+
failureMessage: new StringColumn('failure_message', {}, 4000),
|
|
56
|
+
name: new StringColumn('name'),
|
|
57
|
+
manual: new BooleanColumn('manual'),
|
|
58
|
+
payload: new ObjectColumn('payload', { ui: { hidden: false } }),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Same shape, but the status column claims the Content section by hint. */
|
|
63
|
+
class HintedJobTable extends Table<Job> {
|
|
64
|
+
public name = 'admin_section_hinted_job';
|
|
65
|
+
public columns = withRecordColumns<Job>({
|
|
66
|
+
status: new StringColumn('status', { ui: { formGroup: 'content' } }),
|
|
67
|
+
failureMessage: new StringColumn('failure_message', {}, 4000),
|
|
68
|
+
name: new StringColumn('name'),
|
|
69
|
+
manual: new BooleanColumn('manual', { ui: { formGroup: 'operations' } }),
|
|
70
|
+
payload: new ObjectColumn('payload', { ui: { hidden: false } }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const created = moment('2026-01-02T03:04:05.000Z');
|
|
75
|
+
const updated = moment('2026-02-03T04:05:06.000Z');
|
|
76
|
+
|
|
77
|
+
function loadedRecord(): Job {
|
|
78
|
+
return {
|
|
79
|
+
id: 'job-77',
|
|
80
|
+
name: 'Nightly export',
|
|
81
|
+
failureMessage: 'It broke',
|
|
82
|
+
payload: { retries: 3 },
|
|
83
|
+
status: 'failure',
|
|
84
|
+
manual: false,
|
|
85
|
+
created,
|
|
86
|
+
updated,
|
|
87
|
+
} as Job;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('RecordForm sections', () => {
|
|
91
|
+
let container: HTMLDivElement;
|
|
92
|
+
let root: Root;
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
jest.clearAllMocks();
|
|
96
|
+
container = document.createElement('div');
|
|
97
|
+
document.body.appendChild(container);
|
|
98
|
+
root = createRoot(container);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
act(() => root.unmount());
|
|
103
|
+
container.remove();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const mount = async (table: Table<Job>, record?: Job) => {
|
|
107
|
+
await act(async () => {
|
|
108
|
+
root.render(
|
|
109
|
+
<MemoryRouter>
|
|
110
|
+
<RecordForm table={table} record={record} />
|
|
111
|
+
</MemoryRouter>
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
await act(async () => {
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const sectionLabels = () =>
|
|
120
|
+
Array.from(document.querySelectorAll('[data-form-section-label]')).map((el) => el.textContent);
|
|
121
|
+
|
|
122
|
+
/** The section element a field's label sits inside. */
|
|
123
|
+
const sectionOf = (labelText: string) => {
|
|
124
|
+
const label = Array.from(document.querySelectorAll('label')).find((l) => l.textContent?.startsWith(labelText));
|
|
125
|
+
if (!label) {
|
|
126
|
+
throw new Error(`No field labeled: ${labelText}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return label.closest('[data-form-section]')!;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const sectionIndex = (labelText: string) =>
|
|
133
|
+
Array.from(document.querySelectorAll('[data-form-section]')).indexOf(sectionOf(labelText) as HTMLElement);
|
|
134
|
+
|
|
135
|
+
it('groups fields into identity → Content → Details → System, in that order', async () => {
|
|
136
|
+
await mount(new JobTable(), loadedRecord());
|
|
137
|
+
|
|
138
|
+
expect(sectionLabels()).toEqual(['Content', 'Details', 'System']);
|
|
139
|
+
|
|
140
|
+
// Identity leads (unlabeled), long text/structured land in Content, the rest in Details,
|
|
141
|
+
// server-managed meta last — regardless of the schema's declaration order.
|
|
142
|
+
expect(sectionIndex('Name')).toBe(0);
|
|
143
|
+
expect(sectionOf('Failure message').textContent).toContain('Content');
|
|
144
|
+
expect(sectionOf('Payload').textContent).toContain('Content');
|
|
145
|
+
expect(sectionOf('Status').textContent).toContain('Details');
|
|
146
|
+
expect(sectionOf('Manual').textContent).toContain('Details');
|
|
147
|
+
expect(sectionOf('Created').textContent).toContain('System');
|
|
148
|
+
expect(sectionOf('Updated').textContent).toContain('System');
|
|
149
|
+
|
|
150
|
+
// System is last.
|
|
151
|
+
const sections = document.querySelectorAll('[data-form-section]');
|
|
152
|
+
expect(sectionIndex('Created')).toBe(sections.length - 1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('renders the record id in System as a readonly value row', async () => {
|
|
156
|
+
await mount(new JobTable(), loadedRecord());
|
|
157
|
+
|
|
158
|
+
const idSection = sectionOf('Id');
|
|
159
|
+
expect(idSection.textContent).toContain('System');
|
|
160
|
+
const row = idSection.querySelector('[data-readonly-value-row]')!;
|
|
161
|
+
expect(row).not.toBeNull();
|
|
162
|
+
expect(idSection.textContent).toContain('job-77');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('ui.formGroup overrides the derivation; an unknown hint becomes its own section after Details', async () => {
|
|
166
|
+
await mount(new HintedJobTable(), loadedRecord());
|
|
167
|
+
|
|
168
|
+
// 'status' (a short string that would derive to Details) claims Content by hint.
|
|
169
|
+
expect(sectionOf('Status').textContent).toContain('Content');
|
|
170
|
+
// 'manual' claims a section of its own, humanized, ordered after Details — and with both
|
|
171
|
+
// would-be Details fields re-hinted, Details doesn't render at all (no empty headers).
|
|
172
|
+
expect(sectionLabels()).toEqual(['Content', 'Operations', 'System']);
|
|
173
|
+
expect(sectionOf('Manual').textContent).toContain('Operations');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('a new-record form (no System meta) stays a single unlabeled section when one group covers it', async () => {
|
|
177
|
+
class SimpleTable extends Table<{ name: string } & Record> {
|
|
178
|
+
public name = 'admin_section_simple';
|
|
179
|
+
public columns = withRecordColumns<{ name: string } & Record>({
|
|
180
|
+
name: new StringColumn('name'),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await mount(new SimpleTable() as any, undefined);
|
|
185
|
+
|
|
186
|
+
expect(document.querySelectorAll('[data-form-section]').length).toBe(1);
|
|
187
|
+
expect(sectionLabels()).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -160,10 +160,18 @@ describe('RecordForm structured fields', () => {
|
|
|
160
160
|
expect(document.body.textContent).toContain('Payload must be valid JSON');
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
-
it('readonly timestamps
|
|
163
|
+
it('readonly timestamps render as a value ROW (no input chrome) — compact stamp with the relative read inline', async () => {
|
|
164
164
|
await mount(loadedRecord());
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
|
|
166
|
+
const labels = Array.from(document.body.querySelectorAll('label'));
|
|
167
|
+
const createdLabel = labels.find((candidate) => candidate.textContent?.startsWith('Created'))!;
|
|
168
|
+
expect(createdLabel).toBeDefined();
|
|
169
|
+
// No control is wired to it: a readonly timestamp is text, not an input.
|
|
170
|
+
expect(createdLabel.htmlFor).toBeFalsy();
|
|
171
|
+
|
|
172
|
+
const row = createdLabel.closest('[data-form-field-row]')!.querySelector('[data-readonly-value-row]')!;
|
|
173
|
+
expect(row).not.toBeNull();
|
|
174
|
+
expect(row.textContent).toContain(created.format('MMM D, YYYY, h:mm A'));
|
|
175
|
+
expect(row.textContent).toContain(created.fromNow());
|
|
168
176
|
});
|
|
169
177
|
});
|