@object-ui/fields 11.2.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.
- package/CHANGELOG.md +153 -0
- package/dist/FieldEditWidget.d.ts +21 -0
- package/dist/FieldEditWidget.test.d.ts +8 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/index.cjs +4 -4
- package/dist/index.d.ts +8 -0
- package/dist/index.js +2155 -1438
- package/dist/widgets/LookupField.dependsOn.test.d.ts +8 -0
- package/dist/widgets/PeoplePicker.d.ts +63 -0
- package/dist/widgets/PeoplePicker.test.d.ts +8 -0
- package/dist/widgets/PeoplePickerComponents.test.d.ts +8 -0
- package/dist/widgets/PersonRow.d.ts +23 -0
- package/dist/widgets/RecordPickerDialog.d.ts +9 -1
- package/dist/widgets/SelectionTray.d.ts +27 -0
- package/dist/widgets/UserField.d.ts +0 -13
- package/dist/widgets/UserFieldPicker.test.d.ts +8 -0
- package/dist/widgets/deriveLookupColumns.d.ts +12 -4
- package/dist/widgets/personDisplay.d.ts +37 -0
- package/dist/widgets/personDisplay.test.d.ts +8 -0
- package/dist/widgets/useRecordQuery.d.ts +87 -0
- package/dist/widgets/useRecordQuery.test.d.ts +8 -0
- package/package.json +11 -11
- package/src/FieldEditWidget.test.ts +61 -0
- package/src/FieldEditWidget.tsx +146 -0
- package/src/__tests__/NumberCellRenderer.test.tsx +60 -0
- package/src/__tests__/lookupCellReferenceKey.test.tsx +57 -0
- package/src/complex-widgets.test.tsx +241 -0
- package/src/index.tsx +33 -4
- package/src/widgets/GridField.test.tsx +14 -0
- package/src/widgets/GridField.tsx +7 -8
- package/src/widgets/LookupField.dependsOn.test.tsx +233 -0
- package/src/widgets/LookupField.tsx +397 -199
- package/src/widgets/NumberField.tsx +4 -2
- package/src/widgets/PeoplePicker.test.tsx +334 -0
- package/src/widgets/PeoplePicker.tsx +552 -0
- package/src/widgets/PeoplePickerComponents.test.tsx +104 -0
- package/src/widgets/PersonRow.tsx +117 -0
- package/src/widgets/RecordPickerDialog.tsx +58 -129
- package/src/widgets/SelectionTray.tsx +110 -0
- package/src/widgets/UserField.tsx +20 -1
- package/src/widgets/UserFieldPicker.test.tsx +78 -0
- package/src/widgets/deriveLookupColumns.test.ts +25 -0
- package/src/widgets/deriveLookupColumns.ts +24 -10
- package/src/widgets/personDisplay.test.ts +113 -0
- package/src/widgets/personDisplay.ts +92 -0
- package/src/widgets/useRecordQuery.test.ts +174 -0
- 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
|
-
/**
|
|
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
|
|
57
|
-
*
|
|
58
|
-
* 2.
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
//
|
|
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
|
+
});
|