-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathLookupField.hydration.test.tsx
More file actions
136 lines (120 loc) · 5.07 KB
/
Copy pathLookupField.hydration.test.tsx
File metadata and controls
136 lines (120 loc) · 5.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Regression (#3108): a multi-value lookup hydrated its committed ids with a
* SERIAL `findOne` per id — 41 selected users meant 41 sequential round-trips,
* during which the trigger rendered the bare "Select…" placeholder,
* indistinguishable from having no value at all.
*
* Fixed behaviour under test:
* 1. unresolved ids are fetched in ONE batched `$in` query (chunked), not
* one findOne per id;
* 2. while hydration is in flight the trigger shows a loading indicator and
* the committed-value count instead of the empty placeholder;
* 3. the loading state settles (falls back to placeholder) when hydration
* fails, instead of spinning forever.
*/
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor, screen } from '@testing-library/react';
import { LookupField } from './LookupField';
afterEach(cleanup);
const USERS = [
{ id: 'u1', name: 'Ada Lovelace' },
{ id: 'u2', name: 'Grace Hopper' },
{ id: 'u3', name: 'Margaret Hamilton' },
];
describe('LookupField — multi-value hydration batches and shows loading (#3108)', () => {
it('hydrates several ids with one $in query instead of a findOne per id', async () => {
const find = vi.fn(async (_obj: string, params: any) => {
const wanted: any[] = params?.$filter?.id?.$in ?? [];
return { data: USERS.filter((u) => wanted.includes(u.id)) };
});
const findOne = vi.fn(async () => null);
render(
<LookupField
value={['u1', 'u2', 'u3']}
onChange={() => {}}
dataSource={{ find, findOne } as never}
field={{ reference_to: 'sys_user', multiple: true } as never}
/>,
);
await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
expect(screen.getByText('Grace Hopper')).toBeTruthy();
expect(screen.getByText('Margaret Hamilton')).toBeTruthy();
// One batched round-trip, zero per-id GETs.
expect(findOne).not.toHaveBeenCalled();
const hydrationCalls = find.mock.calls.filter((c) => c[1]?.$filter?.id?.$in);
expect(hydrationCalls).toHaveLength(1);
expect(hydrationCalls[0][1].$filter.id.$in).toEqual(['u1', 'u2', 'u3']);
});
it('shows the committed count + spinner while hydration is in flight, never the empty placeholder', async () => {
let resolveFind!: (v: any) => void;
const find = vi.fn(
() => new Promise((resolve) => { resolveFind = resolve; }),
);
render(
<LookupField
value={['u1', 'u2', 'u3']}
onChange={() => {}}
dataSource={{ find } as never}
field={{ reference_to: 'sys_user', multiple: true } as never}
/>,
);
// In-flight: loading indicator + raw committed count, no "Select…".
await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
expect(screen.queryByText('Select...')).toBeNull();
expect(screen.getByText(/3/)).toBeTruthy();
resolveFind({ data: USERS });
await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
expect(screen.queryByTestId('lookup-hydrating')).toBeNull();
});
it('settles (no infinite spinner) when the hydration fetch fails', async () => {
const find = vi.fn(async (_obj: string, params: any) => {
if (params?.$filter?.id?.$in) throw new Error('boom');
return { data: [] };
});
render(
<LookupField
value={['u1', 'u2']}
onChange={() => {}}
dataSource={{ find } as never}
field={{ reference_to: 'sys_user', multiple: true } as never}
/>,
);
await waitFor(() => expect(find).toHaveBeenCalled());
// The failed attempt is marked settled — the spinner clears instead of
// spinning forever (pre-existing fallback: unresolvable ids drop).
await waitFor(() => expect(screen.queryByTestId('lookup-hydrating')).toBeNull());
});
it('readonly mode shows the loading count instead of an empty value while hydrating', async () => {
let resolveFind!: (v: any) => void;
const find = vi.fn(
() => new Promise((resolve) => { resolveFind = resolve; }),
);
render(
<LookupField
value={['u1', 'u2']}
onChange={() => {}}
readonly
dataSource={{ find } as never}
field={{ reference_to: 'sys_user', multiple: true } as never}
/>,
);
await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
resolveFind({ data: USERS.slice(0, 2) });
await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
});
it('keeps the cheap findOne GET for a single unresolved primary id', async () => {
const find = vi.fn(async () => ({ data: [] }));
const findOne = vi.fn(async () => ({ id: 'u1', name: 'Ada' }));
render(
<LookupField
value={['u1']}
onChange={() => {}}
dataSource={{ find, findOne } as never}
field={{ reference_to: 'sys_user', multiple: true } as never}
/>,
);
await waitFor(() => expect(findOne).toHaveBeenCalledWith('sys_user', 'u1'));
});
});