@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
@@ -0,0 +1,264 @@
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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
10
+ import type { DataSource, QueryParams } from '@object-ui/types';
11
+
12
+ /**
13
+ * useRecordQuery — the shared record-query kernel behind record pickers.
14
+ *
15
+ * Encapsulates the query/pagination/search/sort loop that was previously
16
+ * duplicated (and independently tuned) inside `RecordPickerDialog.fetchRecords`
17
+ * and `LookupField.fetchLookupData`:
18
+ *
19
+ * - builds the `QueryParams` (`$top`/`$skip`/`$search`/`$searchFields`/
20
+ * `$orderby`/`$filter`/`$expand`),
21
+ * - issues `dataSource.find(objectName, params)` and normalises the result
22
+ * (`{ data, total }`, tolerating a bare array),
23
+ * - owns `records`/`loading`/`error`/`total` plus the `page`/`search`/`sort`
24
+ * controls, with a debounced search path,
25
+ * - clears itself when `enabled` goes false (e.g. a dialog closing).
26
+ *
27
+ * The caller keeps ownership of *selection* state and of record→option mapping;
28
+ * this hook only answers "what records match the current query". It is the
29
+ * reusable core the search-first PeoplePicker (and a future org-tree tier)
30
+ * compose on top of.
31
+ *
32
+ * Effect shape intentionally mirrors the original components to preserve
33
+ * behaviour: the fetch effect keys on page/sort/filter/expand (NOT `search`,
34
+ * which drives its own debounced fetch), and state resets live in a separate
35
+ * `enabled`-keyed effect so they never cascade into a fetch (React #185).
36
+ */
37
+ export interface UseRecordQueryOptions {
38
+ /** Backing data source. When absent/invalid the hook stays idle. */
39
+ dataSource?: DataSource | null;
40
+ /** Object/resource name to query (e.g. `sys_user`). */
41
+ objectName?: string | null;
42
+ /**
43
+ * Gate fetching. When false no request is issued and query state is cleared.
44
+ * Typically wired to a dialog's `open`, plus any "dependencies satisfied"
45
+ * guard. Default `true`.
46
+ */
47
+ enabled?: boolean;
48
+ /** `$top` — page size. Default 50. */
49
+ pageSize?: number;
50
+ /**
51
+ * When true, use page-based pagination (`$skip = (page - 1) * pageSize`).
52
+ * When false (default) a single page of `pageSize` records is fetched.
53
+ */
54
+ paginate?: boolean;
55
+ /**
56
+ * `$filter` — already merged by the caller (base `lookup_filters`, dependent
57
+ * lookup chain, candidate hygiene like `banned != true`, …). Compared by
58
+ * value, so a referentially-new-but-equal object each render will not loop.
59
+ */
60
+ filter?: Record<string, any>;
61
+ /** `$expand` — related entities to include (e.g. `['primary_business_unit_id']`). */
62
+ expand?: string[];
63
+ /** `$searchFields` — narrow the server searchable set (ADR-0061). */
64
+ searchFields?: string[];
65
+ /** Debounce applied to {@link UseRecordQueryResult.setSearch}, in ms. Default 300. */
66
+ debounceMs?: number;
67
+ }
68
+
69
+ export interface RecordQuerySort {
70
+ field: string;
71
+ direction: 'asc' | 'desc';
72
+ }
73
+
74
+ export interface UseRecordQueryResult {
75
+ /** Records returned by the current query. */
76
+ records: any[];
77
+ loading: boolean;
78
+ error: string | null;
79
+ /** Total matching records (server-reported, else the current page length). */
80
+ total: number;
81
+ /** `ceil(total / pageSize)`, at least 1. */
82
+ totalPages: number;
83
+ page: number;
84
+ search: string;
85
+ sort: RecordQuerySort | null;
86
+ /** Jump to a page (1-indexed). No-op unless `paginate` is set. */
87
+ setPage: (page: number) => void;
88
+ /** Set the search term; debounced, and resets to page 1. */
89
+ setSearch: (query: string) => void;
90
+ /** Toggle sort on a field (new field → asc, same field → flip); resets to page 1. */
91
+ toggleSort: (field: string) => void;
92
+ /** Set the sort directly (or clear with `null`); resets to page 1. */
93
+ setSort: (sort: RecordQuerySort | null) => void;
94
+ /** Clear query state (search/page/sort/records/error). */
95
+ reset: () => void;
96
+ /** Imperatively refetch with the current params. */
97
+ refetch: () => void;
98
+ }
99
+
100
+ export function useRecordQuery(options: UseRecordQueryOptions): UseRecordQueryResult {
101
+ const {
102
+ dataSource,
103
+ objectName,
104
+ enabled = true,
105
+ pageSize = 50,
106
+ paginate = false,
107
+ filter,
108
+ expand,
109
+ searchFields,
110
+ debounceMs = 300,
111
+ } = options;
112
+
113
+ const [records, setRecords] = useState<any[]>([]);
114
+ const [loading, setLoading] = useState(false);
115
+ const [error, setError] = useState<string | null>(null);
116
+ const [total, setTotal] = useState(0);
117
+ const [page, setPageState] = useState(1);
118
+ const [search, setSearchState] = useState('');
119
+ const [sort, setSortState] = useState<RecordQuerySort | null>(null);
120
+
121
+ const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
122
+
123
+ // Stable signatures for object/array inputs so the fetch effect keys on their
124
+ // *value*, not a fresh reference every render.
125
+ const filterSignature = useMemo(
126
+ () => (filter && Object.keys(filter).length ? JSON.stringify(filter) : ''),
127
+ [filter],
128
+ );
129
+ const expandSignature = useMemo(() => (expand?.length ? expand.join(',') : ''), [expand]);
130
+ const searchFieldsSignature = useMemo(
131
+ () => (searchFields?.length ? searchFields.join(',') : ''),
132
+ [searchFields],
133
+ );
134
+
135
+ const canQuery =
136
+ !!enabled && !!dataSource && typeof dataSource.find === 'function' && !!objectName;
137
+
138
+ // Core fetch. Takes the controlled inputs as arguments so the debounced
139
+ // search path and the effect path share one implementation.
140
+ const runQuery = useCallback(
141
+ async (searchTerm: string, pageArg: number, sortArg: RecordQuerySort | null) => {
142
+ if (!canQuery || !dataSource || !objectName) return;
143
+
144
+ setLoading(true);
145
+ setError(null);
146
+
147
+ try {
148
+ const params: QueryParams = { $top: pageSize };
149
+ if (paginate) params.$skip = (pageArg - 1) * pageSize;
150
+ if (searchTerm && searchTerm.trim()) params.$search = searchTerm.trim();
151
+ if (searchFields && searchFields.length > 0) params.$searchFields = searchFields;
152
+ if (sortArg) params.$orderby = { [sortArg.field]: sortArg.direction };
153
+ if (filter && Object.keys(filter).length > 0) params.$filter = filter;
154
+ if (expand && expand.length > 0) params.$expand = expand;
155
+
156
+ const result = await dataSource.find(objectName, params);
157
+ const data: any[] = result?.data ?? (result as any) ?? [];
158
+
159
+ setRecords(data);
160
+ setTotal(result?.total ?? data.length);
161
+ } catch (err) {
162
+ setError(err instanceof Error ? err.message : String(err));
163
+ setRecords([]);
164
+ } finally {
165
+ setLoading(false);
166
+ }
167
+ },
168
+ // filter/expand/searchFields are referenced via their signatures below so
169
+ // this callback stays stable across referentially-new-but-equal props.
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ [canQuery, dataSource, objectName, pageSize, paginate, filterSignature, expandSignature, searchFieldsSignature],
172
+ );
173
+
174
+ // Fetch on enable and whenever page/sort/filter/expand change. `search` is
175
+ // intentionally excluded — it has its own debounced path in `setSearch`.
176
+ useEffect(() => {
177
+ if (canQuery) {
178
+ runQuery(search, page, sort);
179
+ }
180
+ // eslint-disable-next-line react-hooks/exhaustive-deps
181
+ }, [canQuery, page, sort, runQuery]);
182
+
183
+ // Reset query state when disabled — separate from the fetch effect so resets
184
+ // don't re-trigger a fetch (React #185).
185
+ useEffect(() => {
186
+ if (!enabled) {
187
+ setRecords([]);
188
+ setError(null);
189
+ setSearchState('');
190
+ setPageState(1);
191
+ setSortState(null);
192
+ }
193
+ }, [enabled]);
194
+
195
+ // Clean up the debounce timer on unmount.
196
+ useEffect(
197
+ () => () => {
198
+ if (debounceTimer.current) clearTimeout(debounceTimer.current);
199
+ },
200
+ [],
201
+ );
202
+
203
+ const setSearch = useCallback(
204
+ (query: string) => {
205
+ setSearchState(query);
206
+ setPageState(1);
207
+ if (debounceTimer.current) clearTimeout(debounceTimer.current);
208
+ debounceTimer.current = setTimeout(() => {
209
+ runQuery(query, 1, sort);
210
+ }, debounceMs);
211
+ },
212
+ [runQuery, sort, debounceMs],
213
+ );
214
+
215
+ const setPage = useCallback((next: number) => {
216
+ setPageState(next);
217
+ }, []);
218
+
219
+ const toggleSort = useCallback((field: string) => {
220
+ setSortState(prev =>
221
+ prev && prev.field === field
222
+ ? { field, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
223
+ : { field, direction: 'asc' },
224
+ );
225
+ setPageState(1);
226
+ }, []);
227
+
228
+ const setSort = useCallback((next: RecordQuerySort | null) => {
229
+ setSortState(next);
230
+ setPageState(1);
231
+ }, []);
232
+
233
+ const reset = useCallback(() => {
234
+ if (debounceTimer.current) clearTimeout(debounceTimer.current);
235
+ setRecords([]);
236
+ setError(null);
237
+ setSearchState('');
238
+ setPageState(1);
239
+ setSortState(null);
240
+ }, []);
241
+
242
+ const refetch = useCallback(() => {
243
+ runQuery(search, page, sort);
244
+ }, [runQuery, search, page, sort]);
245
+
246
+ const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
247
+
248
+ return {
249
+ records,
250
+ loading,
251
+ error,
252
+ total,
253
+ totalPages,
254
+ page,
255
+ search,
256
+ sort,
257
+ setPage,
258
+ setSearch,
259
+ toggleSort,
260
+ setSort,
261
+ reset,
262
+ refetch,
263
+ };
264
+ }