@proteinjs/db-ui 1.12.2 → 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.
- package/CHANGELOG.md +11 -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 +4 -3
- 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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* RecordTable's polished defaults (the admin-surface polish rev). Contracts as OUTCOMES:
|
|
5
|
+
* 1. Column pick is tiered meaningful-data, not schema order: identity strings → status-like →
|
|
6
|
+
* booleans → references → rest, hidden columns never picked, long-text demoted; capped at
|
|
7
|
+
* five + created/updated.
|
|
8
|
+
* 2. Booleans render the check/dash grammar (no ✅/❌ emoji).
|
|
9
|
+
* 3. DateTime values render humanized with the precise absolute on the title.
|
|
10
|
+
* 4. Status-like short strings render as the quiet chip.
|
|
11
|
+
* 5. References render as a LINK to the referenced record's form — short id immediately,
|
|
12
|
+
* enriched to the record's name once resolution lands.
|
|
13
|
+
* 6. Numeric columns right-align.
|
|
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 { QueryClient, QueryClientProvider } from 'react-query';
|
|
21
|
+
import { StaticTableLoader } from '@proteinjs/ui';
|
|
22
|
+
import {
|
|
23
|
+
BooleanColumn,
|
|
24
|
+
DateTimeColumn,
|
|
25
|
+
IntegerColumn,
|
|
26
|
+
Record,
|
|
27
|
+
ReferenceColumn,
|
|
28
|
+
StringColumn,
|
|
29
|
+
Table,
|
|
30
|
+
withRecordColumns,
|
|
31
|
+
} from '@proteinjs/db';
|
|
32
|
+
import { RecordTable, defaultRecordTableColumns } from '../src/table/RecordTable';
|
|
33
|
+
import { clearReferenceNameCache } from '../src/table/ReferenceCellValue';
|
|
34
|
+
|
|
35
|
+
// ReferenceCellValue resolves through Reference.get(); the fake resolves user-9 to a named
|
|
36
|
+
// record so the enrichment path is observable without a db.
|
|
37
|
+
jest.mock('@proteinjs/db', () => {
|
|
38
|
+
const actual = jest.requireActual('@proteinjs/db');
|
|
39
|
+
class FakeReference {
|
|
40
|
+
constructor(
|
|
41
|
+
public _table: string,
|
|
42
|
+
public _id?: string
|
|
43
|
+
) {}
|
|
44
|
+
async get() {
|
|
45
|
+
if (this._id === 'user-9') {
|
|
46
|
+
return { id: 'user-9', name: 'Brent Test' };
|
|
47
|
+
}
|
|
48
|
+
throw new Error('not visible');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { ...actual, Reference: FakeReference };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
declare global {
|
|
55
|
+
// eslint-disable-next-line no-var
|
|
56
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean;
|
|
57
|
+
}
|
|
58
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
59
|
+
|
|
60
|
+
class StubIntersectionObserver {
|
|
61
|
+
observe() {}
|
|
62
|
+
unobserve() {}
|
|
63
|
+
disconnect() {}
|
|
64
|
+
}
|
|
65
|
+
(globalThis as any).IntersectionObserver = StubIntersectionObserver;
|
|
66
|
+
|
|
67
|
+
interface Member extends Record {
|
|
68
|
+
secret: string | null;
|
|
69
|
+
notes: string;
|
|
70
|
+
verified: boolean;
|
|
71
|
+
invitedBy: any;
|
|
72
|
+
email: string;
|
|
73
|
+
status: string;
|
|
74
|
+
loginCount: number;
|
|
75
|
+
lastSeen: moment.Moment;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class MemberTable extends Table<Member> {
|
|
79
|
+
public name = 'admin_test_member';
|
|
80
|
+
public columns = withRecordColumns<Member>({
|
|
81
|
+
// schema order is deliberately adversarial: the tiers must reorder it
|
|
82
|
+
secret: new StringColumn('secret', { ui: { hidden: true } }),
|
|
83
|
+
notes: new StringColumn('notes', {}, 4000),
|
|
84
|
+
verified: new BooleanColumn('verified'),
|
|
85
|
+
invitedBy: new ReferenceColumn('invited_by', 'user', false),
|
|
86
|
+
email: new StringColumn('email'),
|
|
87
|
+
status: new StringColumn('status'),
|
|
88
|
+
loginCount: new IntegerColumn('login_count'),
|
|
89
|
+
lastSeen: new DateTimeColumn('last_seen'),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const lastSeen = moment('2026-05-04 14:30:00', 'YYYY-MM-DD HH:mm:ss');
|
|
94
|
+
|
|
95
|
+
const row: Member = {
|
|
96
|
+
id: 'member-1',
|
|
97
|
+
secret: 'never-shown',
|
|
98
|
+
notes: 'a long note',
|
|
99
|
+
verified: true,
|
|
100
|
+
invitedBy: { _table: 'user', _id: 'user-9' },
|
|
101
|
+
email: 'brent+test@n3xa.io',
|
|
102
|
+
status: 'active',
|
|
103
|
+
loginCount: 12,
|
|
104
|
+
lastSeen,
|
|
105
|
+
created: moment('2026-01-02T03:04:05.000Z'),
|
|
106
|
+
updated: moment('2026-02-03T04:05:06.000Z'),
|
|
107
|
+
} as Member;
|
|
108
|
+
|
|
109
|
+
describe('defaultRecordTableColumns (meaningful-data tiers)', () => {
|
|
110
|
+
it('picks identity → status → booleans → references, skips hidden, demotes long text, caps at five + created/updated', () => {
|
|
111
|
+
const columns = defaultRecordTableColumns(new MemberTable());
|
|
112
|
+
expect(columns).toEqual(['email', 'status', 'verified', 'invitedBy', 'loginCount', 'created', 'updated']);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('compound identity names promote too (userEmail leads a session-shaped table)', () => {
|
|
116
|
+
class SessionishTable extends Table<any> {
|
|
117
|
+
public name = 'admin_test_sessionish';
|
|
118
|
+
public columns = withRecordColumns<any>({
|
|
119
|
+
sessionId: new StringColumn('session_id'),
|
|
120
|
+
session: new StringColumn('serialized_session', {}, 4000),
|
|
121
|
+
userEmail: new StringColumn('user_email'),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const columns = defaultRecordTableColumns(new SessionishTable());
|
|
125
|
+
expect(columns[0]).toBe('userEmail');
|
|
126
|
+
// the long-text blob demotes behind the short columns
|
|
127
|
+
expect(columns.indexOf('session')).toBeGreaterThan(columns.indexOf('sessionId'));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('name leads when the table has one', () => {
|
|
131
|
+
class NamedTable extends Table<any> {
|
|
132
|
+
public name = 'admin_test_named';
|
|
133
|
+
public columns = withRecordColumns<any>({
|
|
134
|
+
email: new StringColumn('email'),
|
|
135
|
+
name: new StringColumn('name'),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const columns = defaultRecordTableColumns(new NamedTable());
|
|
139
|
+
expect(columns[0]).toBe('name');
|
|
140
|
+
expect(columns).toContain('email');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('RecordTable default renderers', () => {
|
|
145
|
+
let container: HTMLDivElement;
|
|
146
|
+
let root: Root;
|
|
147
|
+
let client: QueryClient;
|
|
148
|
+
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
clearReferenceNameCache();
|
|
151
|
+
container = document.createElement('div');
|
|
152
|
+
document.body.appendChild(container);
|
|
153
|
+
root = createRoot(container);
|
|
154
|
+
client = new QueryClient({ defaultOptions: { queries: { retry: false, cacheTime: 0 } } });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
afterEach(() => {
|
|
158
|
+
act(() => root.unmount());
|
|
159
|
+
container.remove();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const mount = async (columns?: (keyof Member)[]) => {
|
|
163
|
+
await act(async () => {
|
|
164
|
+
root.render(
|
|
165
|
+
<QueryClientProvider client={client}>
|
|
166
|
+
<MemoryRouter>
|
|
167
|
+
<RecordTable<Member>
|
|
168
|
+
table={new MemberTable()}
|
|
169
|
+
tableLoader={
|
|
170
|
+
new StaticTableLoader([row], { dataKey: `default-renderers-${Math.random()}`, dataQueryKey: 'all' })
|
|
171
|
+
}
|
|
172
|
+
hideButtons
|
|
173
|
+
{...(columns ? { columns } : {})}
|
|
174
|
+
/>
|
|
175
|
+
</MemoryRouter>
|
|
176
|
+
</QueryClientProvider>
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
await act(async () => {
|
|
180
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const cellFor = (index: number) => container.querySelectorAll('tbody td')[index] as HTMLElement;
|
|
185
|
+
|
|
186
|
+
it('booleans render check/dash, datetimes humanized with absolute title, status as the chip', async () => {
|
|
187
|
+
await mount(['verified', 'lastSeen', 'status']);
|
|
188
|
+
expect(cellFor(0).querySelector('svg')).not.toBeNull();
|
|
189
|
+
expect(cellFor(0).textContent).not.toContain('✅');
|
|
190
|
+
|
|
191
|
+
expect(cellFor(1).textContent).toBe('May 4, 2026');
|
|
192
|
+
const titled = cellFor(1).querySelector('[title]') as HTMLElement;
|
|
193
|
+
expect(titled.getAttribute('title')).toBe(lastSeen.format('ddd, MMM D YYYY, h:mm:ss A'));
|
|
194
|
+
|
|
195
|
+
const chip = cellFor(2);
|
|
196
|
+
expect(chip.textContent).toBe('active');
|
|
197
|
+
// the chip carries its tone dot (an inner span before the text)
|
|
198
|
+
expect(chip.querySelectorAll('span span').length).toBeGreaterThan(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('references render as a link to the record form — short id first, the name once resolved', async () => {
|
|
202
|
+
await mount(['invitedBy']);
|
|
203
|
+
const link = cellFor(0).querySelector('a') as HTMLAnchorElement;
|
|
204
|
+
expect(link).not.toBeNull();
|
|
205
|
+
expect(link.getAttribute('href')).toBe('/record/form?table=user&record=user-9');
|
|
206
|
+
|
|
207
|
+
// resolution lands: the short id enriches to the referenced record's name
|
|
208
|
+
await act(async () => {
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
210
|
+
});
|
|
211
|
+
expect(cellFor(0).textContent).toBe('Brent Test');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('numeric columns right-align', async () => {
|
|
215
|
+
await mount(['loginCount']);
|
|
216
|
+
expect(cellFor(0).className).toContain('MuiTableCell-alignRight');
|
|
217
|
+
});
|
|
218
|
+
});
|