@object-ui/fields 11.3.0 → 11.4.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/dist/FieldEditWidget.d.ts +8 -0
  3. package/dist/FieldEditWidget.test.d.ts +8 -0
  4. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  5. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.d.ts +7 -0
  8. package/dist/index.js +1987 -1326
  9. package/dist/widgets/LookupField.dependsOn.test.d.ts +8 -0
  10. package/dist/widgets/PeoplePicker.d.ts +63 -0
  11. package/dist/widgets/PeoplePicker.test.d.ts +8 -0
  12. package/dist/widgets/PeoplePickerComponents.test.d.ts +8 -0
  13. package/dist/widgets/PersonRow.d.ts +23 -0
  14. package/dist/widgets/RecordPickerDialog.d.ts +9 -1
  15. package/dist/widgets/SelectionTray.d.ts +27 -0
  16. package/dist/widgets/UserField.d.ts +0 -13
  17. package/dist/widgets/UserFieldPicker.test.d.ts +8 -0
  18. package/dist/widgets/deriveLookupColumns.d.ts +12 -4
  19. package/dist/widgets/personDisplay.d.ts +37 -0
  20. package/dist/widgets/personDisplay.test.d.ts +8 -0
  21. package/dist/widgets/useRecordQuery.d.ts +87 -0
  22. package/dist/widgets/useRecordQuery.test.d.ts +8 -0
  23. package/package.json +7 -7
  24. package/src/FieldEditWidget.test.ts +61 -0
  25. package/src/FieldEditWidget.tsx +56 -1
  26. package/src/__tests__/NumberCellRenderer.test.tsx +60 -0
  27. package/src/__tests__/lookupCellReferenceKey.test.tsx +57 -0
  28. package/src/complex-widgets.test.tsx +241 -0
  29. package/src/index.tsx +32 -4
  30. package/src/widgets/GridField.test.tsx +14 -0
  31. package/src/widgets/GridField.tsx +7 -8
  32. package/src/widgets/LookupField.dependsOn.test.tsx +233 -0
  33. package/src/widgets/LookupField.tsx +365 -190
  34. package/src/widgets/NumberField.tsx +4 -2
  35. package/src/widgets/PeoplePicker.test.tsx +334 -0
  36. package/src/widgets/PeoplePicker.tsx +552 -0
  37. package/src/widgets/PeoplePickerComponents.test.tsx +104 -0
  38. package/src/widgets/PersonRow.tsx +117 -0
  39. package/src/widgets/RecordPickerDialog.tsx +58 -129
  40. package/src/widgets/SelectionTray.tsx +110 -0
  41. package/src/widgets/UserField.tsx +20 -1
  42. package/src/widgets/UserFieldPicker.test.tsx +78 -0
  43. package/src/widgets/deriveLookupColumns.test.ts +25 -0
  44. package/src/widgets/deriveLookupColumns.ts +24 -10
  45. package/src/widgets/personDisplay.test.ts +113 -0
  46. package/src/widgets/personDisplay.ts +92 -0
  47. package/src/widgets/useRecordQuery.test.ts +174 -0
  48. package/src/widgets/useRecordQuery.ts +264 -0
@@ -32,7 +32,14 @@ export interface DeriveColumnsOptions {
32
32
 
33
33
  interface ObjectSchemaLike {
34
34
  fields?: Record<string, { type?: string; label?: string; hidden?: boolean }>;
35
- /** Object-level "fields to display in search result cards" (spec metadata). */
35
+ /**
36
+ * ADR-0085 semantic role: the object's most important fields. The single
37
+ * source for "how to list this object" — shared with the detail-page related
38
+ * list — so a lookup picker and a related list of the same object agree on
39
+ * columns with zero per-surface config.
40
+ */
41
+ highlightFields?: unknown;
42
+ /** Object-level "fields to display in search result cards" (legacy spec metadata). */
36
43
  displayFields?: unknown;
37
44
  }
38
45
 
@@ -53,9 +60,10 @@ function dedupe(names: string[]): string[] {
53
60
  * a lookup field declares no explicit `lookup_columns`.
54
61
  *
55
62
  * Priority:
56
- * 1. The referenced object's own `displayFields` ("fields to display in
57
- * search result cards") — the author's declared search-card shape.
58
- * 2. Otherwise the display field plus the next few business fields in
63
+ * 1. The referenced object's ADR-0085 `highlightFields` — the canonical
64
+ * "how to list this object" set, shared with the detail-page related list.
65
+ * 2. Else the object's legacy `displayFields` search-card shape.
66
+ * 3. Otherwise the display field plus the next few business fields in
59
67
  * declaration order, skipping system/audit fields and heavy non-tabular
60
68
  * types.
61
69
  *
@@ -79,17 +87,23 @@ export function deriveLookupColumns(
79
87
 
80
88
  if (Object.keys(fields).length === 0) return [];
81
89
 
82
- // 1. Honour the object's explicit search-card fields when present.
83
- const declared = objectSchema?.displayFields;
84
- if (Array.isArray(declared) && declared.length > 0) {
85
- const names = (declared as unknown[]).filter(
90
+ // 1 & 2. Honour a declared column list — `highlightFields` (ADR-0085
91
+ // canonical, shared with the related list) first, then the legacy
92
+ // `displayFields` search-card list. The display field always leads.
93
+ const fromDeclaredList = (list: unknown): LookupColumnDef[] | null => {
94
+ if (!Array.isArray(list) || list.length === 0) return null;
95
+ const names = (list as unknown[]).filter(
86
96
  (n): n is string => typeof n === 'string' && n.length > 0,
87
97
  );
88
98
  const ordered = dedupe([displayField, ...names.filter((n) => n !== displayField)]);
89
99
  return ordered.slice(0, max).map(toCol);
90
- }
100
+ };
101
+ const fromHighlights = fromDeclaredList(objectSchema?.highlightFields);
102
+ if (fromHighlights) return fromHighlights;
103
+ const fromDisplay = fromDeclaredList(objectSchema?.displayFields);
104
+ if (fromDisplay) return fromDisplay;
91
105
 
92
- // 2. Derive from the field set in declaration order.
106
+ // 3. Derive from the field set in declaration order.
93
107
  const candidates = Object.keys(fields).filter((name) => {
94
108
  if (name === displayField) return false;
95
109
  if (SYSTEM_FIELDS.has(name)) return false;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import {
11
+ resolvePath,
12
+ getPersonName,
13
+ getPersonInitials,
14
+ getPersonSubtitle,
15
+ getPersonAvatarUrl,
16
+ getPersonId,
17
+ matchRanges,
18
+ } from './personDisplay';
19
+
20
+ describe('personDisplay helpers', () => {
21
+ describe('resolvePath', () => {
22
+ it('reads flat and dotted paths', () => {
23
+ const rec = { name: 'Amy', primary_business_unit_id: { name: 'Sales' } };
24
+ expect(resolvePath(rec, 'name')).toBe('Amy');
25
+ expect(resolvePath(rec, 'primary_business_unit_id.name')).toBe('Sales');
26
+ });
27
+ it('returns undefined for missing paths or nullish records', () => {
28
+ expect(resolvePath({ a: {} }, 'a.b.c')).toBeUndefined();
29
+ expect(resolvePath(null, 'x')).toBeUndefined();
30
+ expect(resolvePath({ a: 1 }, '')).toBeUndefined();
31
+ });
32
+ });
33
+
34
+ describe('getPersonName', () => {
35
+ it('prefers the display field, then common fallbacks', () => {
36
+ expect(getPersonName({ name: 'Amy' })).toBe('Amy');
37
+ expect(getPersonName({ full_name: 'Amy Lin' }, 'full_name')).toBe('Amy Lin');
38
+ expect(getPersonName({ username: 'amy' })).toBe('amy');
39
+ expect(getPersonName('raw-id')).toBe('raw-id');
40
+ expect(getPersonName(null)).toBe('');
41
+ });
42
+ });
43
+
44
+ describe('getPersonInitials', () => {
45
+ it('takes first+last initial for multi-word latin names', () => {
46
+ expect(getPersonInitials('John Doe')).toBe('JD');
47
+ expect(getPersonInitials('mary jane watson')).toBe('MW');
48
+ });
49
+ it('takes first two letters for a single latin token', () => {
50
+ expect(getPersonInitials('John')).toBe('JO');
51
+ });
52
+ it('takes the given-name chars for CJK names', () => {
53
+ expect(getPersonInitials('张三')).toBe('张三');
54
+ expect(getPersonInitials('王小明')).toBe('小明');
55
+ });
56
+ it('falls back to "?" for empty', () => {
57
+ expect(getPersonInitials('')).toBe('?');
58
+ expect(getPersonInitials(' ')).toBe('?');
59
+ });
60
+ });
61
+
62
+ describe('getPersonSubtitle', () => {
63
+ it('joins non-empty resolved fields with a middot', () => {
64
+ const rec = { email: 'amy@x.io', primary_business_unit_id: { name: 'Sales' } };
65
+ expect(getPersonSubtitle(rec, ['primary_business_unit_id.name', 'email'])).toBe(
66
+ 'Sales · amy@x.io',
67
+ );
68
+ });
69
+ it('drops empty segments and returns "" when nothing resolves', () => {
70
+ expect(getPersonSubtitle({ email: 'amy@x.io' }, ['primary_business_unit_id.name', 'email'])).toBe(
71
+ 'amy@x.io',
72
+ );
73
+ expect(getPersonSubtitle({}, ['a', 'b'])).toBe('');
74
+ expect(getPersonSubtitle({ a: 1 }, undefined)).toBe('');
75
+ });
76
+ });
77
+
78
+ describe('getPersonAvatarUrl', () => {
79
+ it('reads the avatar field, undefined when absent', () => {
80
+ expect(getPersonAvatarUrl({ image: 'http://x/y.png' })).toBe('http://x/y.png');
81
+ expect(getPersonAvatarUrl({})).toBeUndefined();
82
+ expect(getPersonAvatarUrl({ photo: 'p' }, 'photo')).toBe('p');
83
+ });
84
+ });
85
+
86
+ describe('getPersonId', () => {
87
+ it('tolerates id / _id / custom / primitive', () => {
88
+ expect(getPersonId({ id: 1 })).toBe(1);
89
+ expect(getPersonId({ _id: 'a' })).toBe('a');
90
+ expect(getPersonId({ user_id: 9 }, 'user_id')).toBe(9);
91
+ expect(getPersonId('plain')).toBe('plain');
92
+ });
93
+ });
94
+ });
95
+
96
+ describe('matchRanges', () => {
97
+ it('returns no ranges for an empty query or empty text', () => {
98
+ expect(matchRanges('Amy Lin', '')).toEqual([]);
99
+ expect(matchRanges('', 'amy')).toEqual([]);
100
+ });
101
+
102
+ it('finds case-insensitive substring ranges', () => {
103
+ expect(matchRanges('Amy Lin', 'am')).toEqual([[0, 2]]);
104
+ expect(matchRanges('Sales · amy@x.io', 'amy')).toEqual([[8, 11]]);
105
+ });
106
+
107
+ it('finds every non-overlapping occurrence', () => {
108
+ expect(matchRanges('banana', 'an')).toEqual([
109
+ [1, 3],
110
+ [3, 5],
111
+ ]);
112
+ });
113
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * Pure display helpers for person (sys_user) records, shared by the
11
+ * search-first PeoplePicker's rich rows, the SelectionTray chips, and the
12
+ * read-only user cell renderer. Kept framework-free so they're trivially
13
+ * unit-testable and reusable by a future org-tree tier.
14
+ */
15
+
16
+ /** Resolve a possibly-dotted path (e.g. `primary_business_unit_id.name`) against a record. */
17
+ export function resolvePath(record: any, path: string): any {
18
+ if (!record || !path) return undefined;
19
+ if (!path.includes('.')) return record[path];
20
+ return path.split('.').reduce((acc, key) => (acc == null ? acc : acc[key]), record);
21
+ }
22
+
23
+ /** Best display name for a person record, tolerating expanded / raw shapes. */
24
+ export function getPersonName(record: any, nameField = 'name'): string {
25
+ if (record == null) return '';
26
+ if (typeof record !== 'object') return String(record);
27
+ return String(record[nameField] ?? record.name ?? record.username ?? record.label ?? '');
28
+ }
29
+
30
+ /**
31
+ * Up-to-2-char initials for the avatar fallback.
32
+ * - "John Doe" → "JD"; "John" → "JO"
33
+ * - CJK single token → trailing 1–2 chars (given name): "张三" → "张三", "王小明" → "小明"
34
+ */
35
+ export function getPersonInitials(name: string): string {
36
+ const trimmed = (name || '').trim();
37
+ if (!trimmed) return '?';
38
+ const parts = trimmed.split(/\s+/).filter(Boolean);
39
+ if (parts.length >= 2) {
40
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
41
+ }
42
+ const token = parts[0];
43
+ if (/[㐀-鿿]/.test(token)) {
44
+ return token.length <= 2 ? token : token.slice(-2);
45
+ }
46
+ return token.slice(0, 2).toUpperCase();
47
+ }
48
+
49
+ /** Secondary line: resolve each field path, drop empties, join with " · ". */
50
+ export function getPersonSubtitle(record: any, fields?: string[]): string {
51
+ if (!record || !fields || fields.length === 0) return '';
52
+ return fields
53
+ .map(f => resolvePath(record, f))
54
+ .filter(v => v != null && v !== '')
55
+ .map(v => String(v))
56
+ .join(' · ');
57
+ }
58
+
59
+ /** Avatar image URL (defaults to `sys_user.image`); undefined when absent. */
60
+ export function getPersonAvatarUrl(record: any, avatarField = 'image'): string | undefined {
61
+ const v = record ? resolvePath(record, avatarField) : undefined;
62
+ return v ? String(v) : undefined;
63
+ }
64
+
65
+ /** Record id, tolerating `id` / `_id` / a custom id field. */
66
+ export function getPersonId(record: any, idField = 'id'): any {
67
+ if (!record || typeof record !== 'object') return record;
68
+ return record[idField] ?? record.id ?? record._id;
69
+ }
70
+
71
+ /**
72
+ * Case-insensitive literal-substring match ranges of `query` in `text`, as
73
+ * `[start, end)` index pairs. Used to highlight the typed term in candidate
74
+ * rows — a strong disambiguation aid for same-named people. Pinyin matches are
75
+ * resolved server-side and won't have client ranges; that's fine, the row still
76
+ * shows, just without a highlight.
77
+ */
78
+ export function matchRanges(text: string, query: string): Array<[number, number]> {
79
+ const t = text ?? '';
80
+ const q = (query ?? '').trim().toLowerCase();
81
+ if (!q || !t) return [];
82
+ const lower = t.toLowerCase();
83
+ const ranges: Array<[number, number]> = [];
84
+ let from = 0;
85
+ for (;;) {
86
+ const idx = lower.indexOf(q, from);
87
+ if (idx === -1) break;
88
+ ranges.push([idx, idx + q.length]);
89
+ from = idx + q.length;
90
+ }
91
+ return ranges;
92
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import { renderHook, act, waitFor } from '@testing-library/react';
10
+ import { describe, it, expect, vi } from 'vitest';
11
+ import { useRecordQuery } from './useRecordQuery';
12
+
13
+ function makeDataSource(result: any = { data: [], total: 0 }) {
14
+ return { find: vi.fn().mockResolvedValue(result) } as any;
15
+ }
16
+
17
+ describe('useRecordQuery', () => {
18
+ it('fetches on mount with $top and exposes records/total', async () => {
19
+ const ds = makeDataSource({ data: [{ id: 1 }], total: 5 });
20
+ const { result } = renderHook(() =>
21
+ useRecordQuery({ dataSource: ds, objectName: 'sys_user' }),
22
+ );
23
+
24
+ await waitFor(() => expect(result.current.loading).toBe(false));
25
+ expect(ds.find).toHaveBeenCalledWith('sys_user', { $top: 50 });
26
+ expect(result.current.records).toEqual([{ id: 1 }]);
27
+ expect(result.current.total).toBe(5);
28
+ });
29
+
30
+ it('tolerates a bare-array response (no { data } envelope)', async () => {
31
+ const ds = { find: vi.fn().mockResolvedValue([{ id: 1 }, { id: 2 }]) } as any;
32
+ const { result } = renderHook(() =>
33
+ useRecordQuery({ dataSource: ds, objectName: 'o' }),
34
+ );
35
+
36
+ await waitFor(() => expect(result.current.records).toHaveLength(2));
37
+ expect(result.current.total).toBe(2);
38
+ });
39
+
40
+ it('adds $skip only when paginate is set', async () => {
41
+ const ds = makeDataSource({ data: [], total: 100 });
42
+ const { result } = renderHook(() =>
43
+ useRecordQuery({ dataSource: ds, objectName: 'o', pageSize: 10, paginate: true }),
44
+ );
45
+
46
+ await waitFor(() => expect(ds.find).toHaveBeenLastCalledWith('o', { $top: 10, $skip: 0 }));
47
+ // waitFor: the call firing doesn't guarantee its promise resolved and
48
+ // setTotal flushed — assert on the settled state, not a bare read.
49
+ await waitFor(() => expect(result.current.totalPages).toBe(10));
50
+
51
+ act(() => result.current.setPage(3));
52
+ await waitFor(() =>
53
+ expect(ds.find).toHaveBeenLastCalledWith('o', { $top: 10, $skip: 20 }),
54
+ );
55
+ });
56
+
57
+ it('debounces setSearch, resets to page 1, and coalesces rapid input', async () => {
58
+ const ds = makeDataSource();
59
+ const { result } = renderHook(() =>
60
+ useRecordQuery({ dataSource: ds, objectName: 'o', debounceMs: 20 }),
61
+ );
62
+ await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1)); // mount fetch
63
+
64
+ act(() => {
65
+ result.current.setSearch('a');
66
+ result.current.setSearch('ab');
67
+ });
68
+
69
+ await waitFor(() =>
70
+ expect(ds.find).toHaveBeenLastCalledWith('o', { $top: 50, $search: 'ab' }),
71
+ );
72
+ // The superseded 'a' term never reached the server.
73
+ expect(ds.find).not.toHaveBeenCalledWith('o', { $top: 50, $search: 'a' });
74
+ });
75
+
76
+ it('toggleSort sets $orderby asc then flips to desc', async () => {
77
+ const ds = makeDataSource();
78
+ const { result } = renderHook(() =>
79
+ useRecordQuery({ dataSource: ds, objectName: 'o' }),
80
+ );
81
+ await waitFor(() => expect(ds.find).toHaveBeenCalled());
82
+
83
+ act(() => result.current.toggleSort('name'));
84
+ await waitFor(() =>
85
+ expect(ds.find).toHaveBeenLastCalledWith('o', { $top: 50, $orderby: { name: 'asc' } }),
86
+ );
87
+
88
+ act(() => result.current.toggleSort('name'));
89
+ await waitFor(() =>
90
+ expect(ds.find).toHaveBeenLastCalledWith('o', { $top: 50, $orderby: { name: 'desc' } }),
91
+ );
92
+ });
93
+
94
+ it('passes $filter and refetches when the filter value changes', async () => {
95
+ const ds = makeDataSource();
96
+ const { rerender } = renderHook(
97
+ ({ filter }) => useRecordQuery({ dataSource: ds, objectName: 'o', filter }),
98
+ { initialProps: { filter: { banned: { $ne: true } } as Record<string, any> } },
99
+ );
100
+
101
+ await waitFor(() =>
102
+ expect(ds.find).toHaveBeenLastCalledWith('o', {
103
+ $top: 50,
104
+ $filter: { banned: { $ne: true } },
105
+ }),
106
+ );
107
+
108
+ rerender({ filter: { banned: { $ne: true }, active: true } });
109
+ await waitFor(() =>
110
+ expect(ds.find).toHaveBeenLastCalledWith('o', {
111
+ $top: 50,
112
+ $filter: { banned: { $ne: true }, active: true },
113
+ }),
114
+ );
115
+ });
116
+
117
+ it('passes $expand and $searchFields through', async () => {
118
+ const ds = makeDataSource();
119
+ renderHook(() =>
120
+ useRecordQuery({
121
+ dataSource: ds,
122
+ objectName: 'sys_user',
123
+ expand: ['primary_business_unit_id'],
124
+ searchFields: ['name', 'email'],
125
+ }),
126
+ );
127
+
128
+ await waitFor(() =>
129
+ expect(ds.find).toHaveBeenLastCalledWith('sys_user', {
130
+ $top: 50,
131
+ $expand: ['primary_business_unit_id'],
132
+ $searchFields: ['name', 'email'],
133
+ }),
134
+ );
135
+ });
136
+
137
+ it('does not fetch while disabled and clears records when re-disabled', async () => {
138
+ const ds = makeDataSource({ data: [{ id: 1 }], total: 1 });
139
+ const { result, rerender } = renderHook(
140
+ ({ enabled }) => useRecordQuery({ dataSource: ds, objectName: 'o', enabled }),
141
+ { initialProps: { enabled: false } },
142
+ );
143
+
144
+ await new Promise(r => setTimeout(r, 20));
145
+ expect(ds.find).not.toHaveBeenCalled();
146
+
147
+ rerender({ enabled: true });
148
+ await waitFor(() => expect(result.current.records).toEqual([{ id: 1 }]));
149
+
150
+ rerender({ enabled: false });
151
+ await waitFor(() => expect(result.current.records).toEqual([]));
152
+ });
153
+
154
+ it('captures fetch errors and empties the result set', async () => {
155
+ const ds = { find: vi.fn().mockRejectedValue(new Error('boom')) } as any;
156
+ const { result } = renderHook(() =>
157
+ useRecordQuery({ dataSource: ds, objectName: 'o' }),
158
+ );
159
+
160
+ await waitFor(() => expect(result.current.error).toBe('boom'));
161
+ expect(result.current.records).toEqual([]);
162
+ expect(result.current.loading).toBe(false);
163
+ });
164
+
165
+ it('stays idle when no dataSource is provided', async () => {
166
+ const { result } = renderHook(() =>
167
+ useRecordQuery({ dataSource: null, objectName: 'o' }),
168
+ );
169
+
170
+ await new Promise(r => setTimeout(r, 10));
171
+ expect(result.current.loading).toBe(false);
172
+ expect(result.current.records).toEqual([]);
173
+ });
174
+ });