@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/src/form/RecordForm.d.ts.map +1 -1
  7. package/dist/src/form/RecordForm.js +63 -3
  8. package/dist/src/form/RecordForm.js.map +1 -1
  9. package/dist/src/table/RecordTable.d.ts +10 -0
  10. package/dist/src/table/RecordTable.d.ts.map +1 -1
  11. package/dist/src/table/RecordTable.js +126 -49
  12. package/dist/src/table/RecordTable.js.map +1 -1
  13. package/dist/src/table/ReferenceCellValue.d.ts +14 -0
  14. package/dist/src/table/ReferenceCellValue.d.ts.map +1 -0
  15. package/dist/src/table/ReferenceCellValue.js +168 -0
  16. package/dist/src/table/ReferenceCellValue.js.map +1 -0
  17. package/dist/test/recordFormStructuredFields.test.d.ts +5 -0
  18. package/dist/test/recordFormStructuredFields.test.d.ts.map +1 -0
  19. package/dist/test/recordFormStructuredFields.test.js +301 -0
  20. package/dist/test/recordFormStructuredFields.test.js.map +1 -0
  21. package/dist/test/recordTableDefaultRenderers.test.d.ts +5 -0
  22. package/dist/test/recordTableDefaultRenderers.test.d.ts.map +1 -0
  23. package/dist/test/recordTableDefaultRenderers.test.js +309 -0
  24. package/dist/test/recordTableDefaultRenderers.test.js.map +1 -0
  25. package/index.ts +1 -0
  26. package/package.json +5 -4
  27. package/src/form/RecordForm.tsx +76 -3
  28. package/src/table/RecordTable.tsx +154 -49
  29. package/src/table/ReferenceCellValue.tsx +133 -0
  30. package/test/recordFormStructuredFields.test.tsx +169 -0
  31. 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
+ });