datajunction-ui 0.0.126 → 0.0.127
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "datajunction-ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.127",
|
|
4
4
|
"description": "DataJunction UI",
|
|
5
5
|
"module": "src/index.tsx",
|
|
6
6
|
"repository": {
|
|
@@ -54,7 +54,6 @@
|
|
|
54
54
|
"file-loader": "6.2.0",
|
|
55
55
|
"fontfaceobserver": "2.3.0",
|
|
56
56
|
"formik": "2.4.3",
|
|
57
|
-
"fuse.js": "6.6.2",
|
|
58
57
|
"husky": "8.0.1",
|
|
59
58
|
"i18next": "21.9.2",
|
|
60
59
|
"i18next-browser-languagedetector": "6.1.5",
|
|
@@ -1,101 +1,168 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import DJClientContext from '../providers/djclient';
|
|
3
|
-
import Fuse from 'fuse.js';
|
|
4
3
|
|
|
5
4
|
import './search.css';
|
|
6
5
|
|
|
6
|
+
const DEBOUNCE_MS = 120;
|
|
7
|
+
const MIN_QUERY_LENGTH = 1;
|
|
8
|
+
const CACHE_MAX_ENTRIES = 50;
|
|
9
|
+
const CACHE_TTL_MS = 60 * 1000;
|
|
10
|
+
|
|
11
|
+
const truncate = str => {
|
|
12
|
+
if (!str) return '';
|
|
13
|
+
return str.length > 100 ? str.substring(0, 90) + '...' : str;
|
|
14
|
+
};
|
|
15
|
+
|
|
7
16
|
export default function Search() {
|
|
8
|
-
const [fuse, setFuse] = useState();
|
|
9
17
|
const [searchValue, setSearchValue] = useState('');
|
|
10
|
-
const [
|
|
18
|
+
const [nodes, setNodes] = useState([]);
|
|
19
|
+
const [tags, setTags] = useState([]);
|
|
11
20
|
const [isLoading, setIsLoading] = useState(false);
|
|
12
|
-
const
|
|
21
|
+
const abortRef = useRef(null);
|
|
22
|
+
const debounceRef = useRef(null);
|
|
13
23
|
const inputRef = useRef(null);
|
|
24
|
+
// LRU cache keyed on normalized query; Map preserves insertion order so the
|
|
25
|
+
// oldest entry is always the first key.
|
|
26
|
+
const cacheRef = useRef(new Map());
|
|
14
27
|
|
|
15
28
|
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
16
29
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
30
|
+
const readCache = useCallback(key => {
|
|
31
|
+
const entry = cacheRef.current.get(key);
|
|
32
|
+
if (!entry) return null;
|
|
33
|
+
if (Date.now() - entry.at > CACHE_TTL_MS) {
|
|
34
|
+
cacheRef.current.delete(key);
|
|
35
|
+
return null;
|
|
20
36
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (hasLoadedRef.current || isLoading) return;
|
|
27
|
-
hasLoadedRef.current = true;
|
|
28
|
-
setIsLoading(true);
|
|
37
|
+
// Touch for LRU ordering.
|
|
38
|
+
cacheRef.current.delete(key);
|
|
39
|
+
cacheRef.current.set(key, entry);
|
|
40
|
+
return entry.value;
|
|
41
|
+
}, []);
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
(tags || []).map(tag => {
|
|
37
|
-
tag.type = 'tag';
|
|
38
|
-
return tag;
|
|
39
|
-
}),
|
|
40
|
-
);
|
|
41
|
-
const fuseInstance = new Fuse(allEntities || [], {
|
|
42
|
-
keys: [
|
|
43
|
-
'name', // will be assigned a `weight` of 1
|
|
44
|
-
{ name: 'description', weight: 2 },
|
|
45
|
-
{ name: 'display_name', weight: 3 },
|
|
46
|
-
{ name: 'type', weight: 4 },
|
|
47
|
-
{ name: 'tag_type', weight: 5 },
|
|
48
|
-
],
|
|
49
|
-
});
|
|
50
|
-
setFuse(fuseInstance);
|
|
51
|
-
} catch (error) {
|
|
52
|
-
console.error('Error fetching nodes or tags:', error);
|
|
53
|
-
hasLoadedRef.current = false; // Allow retry on error
|
|
54
|
-
} finally {
|
|
55
|
-
setIsLoading(false);
|
|
43
|
+
const writeCache = useCallback((key, value) => {
|
|
44
|
+
const cache = cacheRef.current;
|
|
45
|
+
cache.set(key, { value, at: Date.now() });
|
|
46
|
+
while (cache.size > CACHE_MAX_ENTRIES) {
|
|
47
|
+
const oldest = cache.keys().next().value;
|
|
48
|
+
cache.delete(oldest);
|
|
56
49
|
}
|
|
57
|
-
}, [
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const runSearch = useCallback(
|
|
53
|
+
async query => {
|
|
54
|
+
const cached = readCache(query);
|
|
55
|
+
if (cached) {
|
|
56
|
+
if (abortRef.current) abortRef.current.abort();
|
|
57
|
+
setNodes(cached.nodes);
|
|
58
|
+
setTags(cached.tags);
|
|
59
|
+
setIsLoading(false);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (abortRef.current) {
|
|
63
|
+
abortRef.current.abort();
|
|
64
|
+
}
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
abortRef.current = controller;
|
|
67
|
+
setIsLoading(true);
|
|
68
|
+
try {
|
|
69
|
+
const results = await djClient.globalSearch(query, {
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
});
|
|
72
|
+
if (!controller.signal.aborted) {
|
|
73
|
+
setNodes(results.nodes);
|
|
74
|
+
setTags(results.tags);
|
|
75
|
+
writeCache(query, results);
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err.name !== 'AbortError') {
|
|
79
|
+
console.error('Search failed:', err);
|
|
80
|
+
setNodes([]);
|
|
81
|
+
setTags([]);
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
if (!controller.signal.aborted) {
|
|
85
|
+
setIsLoading(false);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
[djClient, readCache, writeCache],
|
|
90
|
+
);
|
|
58
91
|
|
|
59
92
|
const handleChange = e => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
93
|
+
const value = e.target.value;
|
|
94
|
+
setSearchValue(value);
|
|
95
|
+
if (debounceRef.current) {
|
|
96
|
+
clearTimeout(debounceRef.current);
|
|
63
97
|
}
|
|
98
|
+
const trimmed = value.trim();
|
|
99
|
+
if (trimmed.length < MIN_QUERY_LENGTH) {
|
|
100
|
+
if (abortRef.current) abortRef.current.abort();
|
|
101
|
+
setNodes([]);
|
|
102
|
+
setTags([]);
|
|
103
|
+
setIsLoading(false);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Synchronous cache hit: render instantly without waiting for debounce.
|
|
107
|
+
const cached = readCache(trimmed);
|
|
108
|
+
if (cached) {
|
|
109
|
+
if (abortRef.current) abortRef.current.abort();
|
|
110
|
+
setNodes(cached.nodes);
|
|
111
|
+
setTags(cached.tags);
|
|
112
|
+
setIsLoading(false);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
debounceRef.current = setTimeout(() => runSearch(trimmed), DEBOUNCE_MS);
|
|
64
116
|
};
|
|
65
117
|
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
return () => {
|
|
120
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
121
|
+
if (abortRef.current) abortRef.current.abort();
|
|
122
|
+
};
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
const hasResults = nodes.length > 0 || tags.length > 0;
|
|
126
|
+
|
|
66
127
|
return (
|
|
67
128
|
<div>
|
|
68
129
|
<div className="nav-search-box" onClick={() => inputRef.current?.focus()}>
|
|
69
130
|
<input
|
|
70
131
|
ref={inputRef}
|
|
71
132
|
type="text"
|
|
72
|
-
placeholder={isLoading ? '
|
|
133
|
+
placeholder={isLoading ? 'Searching...' : 'Search nodes and tags...'}
|
|
73
134
|
name="search"
|
|
74
135
|
value={searchValue}
|
|
75
136
|
onChange={handleChange}
|
|
76
|
-
onFocus={loadSearchData}
|
|
77
137
|
/>
|
|
78
138
|
</div>
|
|
79
|
-
{
|
|
80
|
-
<div
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
139
|
+
{hasResults && (
|
|
140
|
+
<div
|
|
141
|
+
className="search-results"
|
|
142
|
+
style={isLoading ? { opacity: 0.6 } : undefined}
|
|
143
|
+
>
|
|
144
|
+
{nodes.map(item => (
|
|
145
|
+
<a key={`node-${item.name}`} href={`/nodes/${item.name}`}>
|
|
146
|
+
<div className="search-result-item">
|
|
147
|
+
<span className={`node_type__${item.type} badge node_type`}>
|
|
148
|
+
{item.type}
|
|
149
|
+
</span>
|
|
150
|
+
{item.display_name} (<b>{item.name}</b>){' '}
|
|
151
|
+
{item.description ? '- ' : ' '}
|
|
152
|
+
{truncate(item.description)}
|
|
153
|
+
</div>
|
|
154
|
+
</a>
|
|
155
|
+
))}
|
|
156
|
+
{tags.map(item => (
|
|
157
|
+
<a key={`tag-${item.name}`} href={`/tags/${item.name}`}>
|
|
158
|
+
<div className="search-result-item">
|
|
159
|
+
<span className="node_type__tag badge node_type">tag</span>
|
|
160
|
+
{item.display_name} (<b>{item.name}</b>){' '}
|
|
161
|
+
{item.description ? '- ' : ' '}
|
|
162
|
+
{truncate(item.description)}
|
|
163
|
+
</div>
|
|
164
|
+
</a>
|
|
165
|
+
))}
|
|
99
166
|
</div>
|
|
100
167
|
)}
|
|
101
168
|
</div>
|
|
@@ -1,334 +1,250 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { render, fireEvent, waitFor,
|
|
2
|
+
import { render, fireEvent, waitFor, act } from '@testing-library/react';
|
|
3
3
|
import Search from '../Search';
|
|
4
4
|
import DJClientContext from '../../providers/djclient';
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const mockNodes = [
|
|
7
|
+
{
|
|
8
|
+
name: 'default.test_node',
|
|
9
|
+
display_name: 'Test Node',
|
|
10
|
+
description: 'A test node for testing',
|
|
11
|
+
type: 'transform',
|
|
12
|
+
kind: 'node',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: 'default.another_node',
|
|
16
|
+
display_name: 'Another Node',
|
|
17
|
+
description: '',
|
|
18
|
+
type: 'metric',
|
|
19
|
+
kind: 'node',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'default.long_description_node',
|
|
23
|
+
display_name: 'Long Description',
|
|
24
|
+
description:
|
|
25
|
+
'This is a very long description that exceeds 100 characters and should be truncated to prevent display issues in the search results interface',
|
|
26
|
+
type: 'dimension',
|
|
27
|
+
kind: 'node',
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const mockTags = [
|
|
32
|
+
{
|
|
33
|
+
name: 'test_tag',
|
|
34
|
+
display_name: 'Test Tag',
|
|
35
|
+
description: 'A test tag',
|
|
36
|
+
type: 'tag',
|
|
37
|
+
tag_type: 'business',
|
|
38
|
+
kind: 'tag',
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const makeClient = overrides => ({
|
|
7
43
|
DataJunctionAPI: {
|
|
8
|
-
|
|
9
|
-
|
|
44
|
+
globalSearch: jest
|
|
45
|
+
.fn()
|
|
46
|
+
.mockResolvedValue({ nodes: mockNodes, tags: mockTags }),
|
|
47
|
+
...(overrides || {}),
|
|
10
48
|
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const flushDebounce = async () => {
|
|
52
|
+
await act(async () => {
|
|
53
|
+
jest.advanceTimersByTime(200);
|
|
54
|
+
});
|
|
11
55
|
};
|
|
12
56
|
|
|
13
57
|
describe('<Search />', () => {
|
|
14
58
|
beforeEach(() => {
|
|
59
|
+
jest.useFakeTimers();
|
|
15
60
|
jest.clearAllMocks();
|
|
16
61
|
});
|
|
17
62
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
display_name: 'Test Node',
|
|
22
|
-
description: 'A test node for testing',
|
|
23
|
-
type: 'transform',
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
name: 'default.another_node',
|
|
27
|
-
display_name: 'Another Node',
|
|
28
|
-
description: null, // Test null description
|
|
29
|
-
type: 'metric',
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
name: 'default.long_description_node',
|
|
33
|
-
display_name: 'Long Description',
|
|
34
|
-
description:
|
|
35
|
-
'This is a very long description that exceeds 100 characters and should be truncated to prevent display issues in the search results interface',
|
|
36
|
-
type: 'dimension',
|
|
37
|
-
},
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
const mockTags = [
|
|
41
|
-
{
|
|
42
|
-
name: 'test_tag',
|
|
43
|
-
display_name: 'Test Tag',
|
|
44
|
-
description: 'A test tag',
|
|
45
|
-
tag_type: 'business',
|
|
46
|
-
},
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
it('renders search input', async () => {
|
|
50
|
-
mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
|
|
51
|
-
mockDjClient.DataJunctionAPI.listTags.mockResolvedValue(mockTags);
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
jest.useRealTimers();
|
|
65
|
+
});
|
|
52
66
|
|
|
67
|
+
it('renders the search input with the idle placeholder', () => {
|
|
68
|
+
const client = makeClient();
|
|
53
69
|
const { getByPlaceholderText } = render(
|
|
54
|
-
<DJClientContext.Provider value={
|
|
70
|
+
<DJClientContext.Provider value={client}>
|
|
55
71
|
<Search />
|
|
56
72
|
</DJClientContext.Provider>,
|
|
57
73
|
);
|
|
58
|
-
|
|
59
|
-
|
|
74
|
+
expect(
|
|
75
|
+
getByPlaceholderText('Search nodes and tags...'),
|
|
76
|
+
).toBeInTheDocument();
|
|
60
77
|
});
|
|
61
78
|
|
|
62
|
-
it('
|
|
63
|
-
|
|
64
|
-
mockDjClient.DataJunctionAPI.listTags.mockResolvedValue(mockTags);
|
|
65
|
-
|
|
79
|
+
it('does not query the API for an empty string', async () => {
|
|
80
|
+
const client = makeClient();
|
|
66
81
|
const { getByPlaceholderText } = render(
|
|
67
|
-
<DJClientContext.Provider value={
|
|
82
|
+
<DJClientContext.Provider value={client}>
|
|
68
83
|
<Search />
|
|
69
84
|
</DJClientContext.Provider>,
|
|
70
85
|
);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
expect(mockDjClient.DataJunctionAPI.nodeDetails).not.toHaveBeenCalled();
|
|
74
|
-
|
|
75
|
-
// Focus on search input to trigger lazy loading
|
|
76
|
-
const searchInput = getByPlaceholderText('Search nodes...');
|
|
77
|
-
fireEvent.focus(searchInput);
|
|
78
|
-
|
|
79
|
-
await waitFor(() => {
|
|
80
|
-
expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
|
|
81
|
-
expect(mockDjClient.DataJunctionAPI.listTags).toHaveBeenCalled();
|
|
86
|
+
fireEvent.change(getByPlaceholderText('Search nodes and tags...'), {
|
|
87
|
+
target: { value: ' ' },
|
|
82
88
|
});
|
|
89
|
+
await flushDebounce();
|
|
90
|
+
expect(client.DataJunctionAPI.globalSearch).not.toHaveBeenCalled();
|
|
83
91
|
});
|
|
84
92
|
|
|
85
|
-
it('
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const { getByPlaceholderText, getByText } = render(
|
|
90
|
-
<DJClientContext.Provider value={mockDjClient}>
|
|
93
|
+
it('queries the API for single-character input (server handles prefix)', async () => {
|
|
94
|
+
const client = makeClient();
|
|
95
|
+
const { getByPlaceholderText } = render(
|
|
96
|
+
<DJClientContext.Provider value={client}>
|
|
91
97
|
<Search />
|
|
92
98
|
</DJClientContext.Provider>,
|
|
93
99
|
);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
// Focus to trigger lazy loading
|
|
97
|
-
fireEvent.focus(searchInput);
|
|
98
|
-
|
|
99
|
-
await waitFor(() => {
|
|
100
|
-
expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
fireEvent.change(searchInput, { target: { value: 'test' } });
|
|
104
|
-
|
|
105
|
-
await waitFor(() => {
|
|
106
|
-
expect(getByText(/Test Node/)).toBeInTheDocument();
|
|
100
|
+
fireEvent.change(getByPlaceholderText('Search nodes and tags...'), {
|
|
101
|
+
target: { value: 'h' },
|
|
107
102
|
});
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
mockDjClient.DataJunctionAPI.listTags.mockResolvedValue(mockTags);
|
|
113
|
-
|
|
114
|
-
const { getByPlaceholderText, container } = render(
|
|
115
|
-
<DJClientContext.Provider value={mockDjClient}>
|
|
116
|
-
<Search />
|
|
117
|
-
</DJClientContext.Provider>,
|
|
103
|
+
await flushDebounce();
|
|
104
|
+
expect(client.DataJunctionAPI.globalSearch).toHaveBeenCalledWith(
|
|
105
|
+
'h',
|
|
106
|
+
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
118
107
|
);
|
|
119
|
-
|
|
120
|
-
const searchInput = getByPlaceholderText('Search nodes...');
|
|
121
|
-
// Focus to trigger lazy loading
|
|
122
|
-
fireEvent.focus(searchInput);
|
|
123
|
-
|
|
124
|
-
await waitFor(() => {
|
|
125
|
-
expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
fireEvent.change(searchInput, { target: { value: 'node' } });
|
|
129
|
-
|
|
130
|
-
await waitFor(() => {
|
|
131
|
-
const links = container.querySelectorAll('a[href^="/nodes/"]');
|
|
132
|
-
expect(links.length).toBeGreaterThan(0);
|
|
133
|
-
});
|
|
134
108
|
});
|
|
135
109
|
|
|
136
|
-
it('
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const { getByPlaceholderText, container } = render(
|
|
141
|
-
<DJClientContext.Provider value={mockDjClient}>
|
|
110
|
+
it('calls globalSearch after the debounce interval for a non-trivial query', async () => {
|
|
111
|
+
const client = makeClient();
|
|
112
|
+
const { getByPlaceholderText } = render(
|
|
113
|
+
<DJClientContext.Provider value={client}>
|
|
142
114
|
<Search />
|
|
143
115
|
</DJClientContext.Provider>,
|
|
144
116
|
);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// Focus to trigger lazy loading
|
|
148
|
-
fireEvent.focus(searchInput);
|
|
149
|
-
|
|
150
|
-
await waitFor(() => {
|
|
151
|
-
expect(mockDjClient.DataJunctionAPI.listTags).toHaveBeenCalled();
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
fireEvent.change(searchInput, { target: { value: 'tag' } });
|
|
155
|
-
|
|
156
|
-
await waitFor(() => {
|
|
157
|
-
const links = container.querySelectorAll('a[href^="/tags/"]');
|
|
158
|
-
expect(links.length).toBeGreaterThan(0);
|
|
117
|
+
fireEvent.change(getByPlaceholderText('Search nodes and tags...'), {
|
|
118
|
+
target: { value: 'test' },
|
|
159
119
|
});
|
|
120
|
+
await flushDebounce();
|
|
121
|
+
expect(client.DataJunctionAPI.globalSearch).toHaveBeenCalledWith(
|
|
122
|
+
'test',
|
|
123
|
+
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
124
|
+
);
|
|
160
125
|
});
|
|
161
126
|
|
|
162
|
-
it('
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const { getByPlaceholderText, getByText } = render(
|
|
167
|
-
<DJClientContext.Provider value={mockDjClient}>
|
|
127
|
+
it('renders both node and tag results returned by the server', async () => {
|
|
128
|
+
const client = makeClient();
|
|
129
|
+
const { getByPlaceholderText, container, findByText } = render(
|
|
130
|
+
<DJClientContext.Provider value={client}>
|
|
168
131
|
<Search />
|
|
169
132
|
</DJClientContext.Provider>,
|
|
170
133
|
);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
await waitFor(() => {
|
|
183
|
-
expect(getByText(/\.\.\./)).toBeInTheDocument();
|
|
184
|
-
});
|
|
134
|
+
fireEvent.change(getByPlaceholderText('Search nodes and tags...'), {
|
|
135
|
+
target: { value: 'test' },
|
|
136
|
+
});
|
|
137
|
+
await flushDebounce();
|
|
138
|
+
await findByText(/Test Node/);
|
|
139
|
+
expect(
|
|
140
|
+
container.querySelector('a[href="/nodes/default.test_node"]'),
|
|
141
|
+
).toBeInTheDocument();
|
|
142
|
+
expect(
|
|
143
|
+
container.querySelector('a[href="/tags/test_tag"]'),
|
|
144
|
+
).toBeInTheDocument();
|
|
185
145
|
});
|
|
186
146
|
|
|
187
|
-
it('
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
147
|
+
it('truncates descriptions longer than 100 characters', async () => {
|
|
148
|
+
const client = makeClient({
|
|
149
|
+
globalSearch: jest
|
|
150
|
+
.fn()
|
|
151
|
+
.mockResolvedValue({ nodes: [mockNodes[2]], tags: [] }),
|
|
152
|
+
});
|
|
153
|
+
const { getByPlaceholderText, findByText } = render(
|
|
154
|
+
<DJClientContext.Provider value={client}>
|
|
193
155
|
<Search />
|
|
194
156
|
</DJClientContext.Provider>,
|
|
195
157
|
);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// Focus to trigger lazy loading
|
|
199
|
-
fireEvent.focus(searchInput);
|
|
200
|
-
|
|
201
|
-
await waitFor(() => {
|
|
202
|
-
expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
fireEvent.change(searchInput, { target: { value: 'another' } });
|
|
206
|
-
|
|
207
|
-
await waitFor(() => {
|
|
208
|
-
expect(getByText(/Another Node/)).toBeInTheDocument();
|
|
158
|
+
fireEvent.change(getByPlaceholderText('Search nodes and tags...'), {
|
|
159
|
+
target: { value: 'long' },
|
|
209
160
|
});
|
|
161
|
+
await flushDebounce();
|
|
162
|
+
await findByText(/\.\.\./);
|
|
210
163
|
});
|
|
211
164
|
|
|
212
|
-
it('
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
description: `Description ${i}`,
|
|
217
|
-
type: 'transform',
|
|
218
|
-
}));
|
|
219
|
-
|
|
220
|
-
mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(manyNodes);
|
|
221
|
-
mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
|
|
222
|
-
|
|
223
|
-
const { getByPlaceholderText, container } = render(
|
|
224
|
-
<DJClientContext.Provider value={mockDjClient}>
|
|
165
|
+
it('clears results when the input is cleared', async () => {
|
|
166
|
+
const client = makeClient();
|
|
167
|
+
const { getByPlaceholderText, container, findByText } = render(
|
|
168
|
+
<DJClientContext.Provider value={client}>
|
|
225
169
|
<Search />
|
|
226
170
|
</DJClientContext.Provider>,
|
|
227
171
|
);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
172
|
+
const input = getByPlaceholderText('Search nodes and tags...');
|
|
173
|
+
fireEvent.change(input, { target: { value: 'test' } });
|
|
174
|
+
await flushDebounce();
|
|
175
|
+
await findByText(/Test Node/);
|
|
176
|
+
fireEvent.change(input, { target: { value: '' } });
|
|
233
177
|
await waitFor(() => {
|
|
234
|
-
expect(
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
fireEvent.change(searchInput, { target: { value: 'node' } });
|
|
238
|
-
|
|
239
|
-
await waitFor(() => {
|
|
240
|
-
const results = container.querySelectorAll('.search-result-item');
|
|
241
|
-
expect(results.length).toBeLessThanOrEqual(20);
|
|
178
|
+
expect(container.querySelector('.search-result-item')).toBeNull();
|
|
242
179
|
});
|
|
243
180
|
});
|
|
244
181
|
|
|
245
|
-
it('
|
|
182
|
+
it('logs an error but does not throw when the request fails', async () => {
|
|
246
183
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
247
|
-
|
|
248
|
-
new Error('
|
|
249
|
-
);
|
|
250
|
-
mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
|
|
251
|
-
|
|
184
|
+
const client = makeClient({
|
|
185
|
+
globalSearch: jest.fn().mockRejectedValue(new Error('boom')),
|
|
186
|
+
});
|
|
252
187
|
const { getByPlaceholderText } = render(
|
|
253
|
-
<DJClientContext.Provider value={
|
|
188
|
+
<DJClientContext.Provider value={client}>
|
|
254
189
|
<Search />
|
|
255
190
|
</DJClientContext.Provider>,
|
|
256
191
|
);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
192
|
+
fireEvent.change(getByPlaceholderText('Search nodes and tags...'), {
|
|
193
|
+
target: { value: 'test' },
|
|
194
|
+
});
|
|
195
|
+
await flushDebounce();
|
|
262
196
|
await waitFor(() => {
|
|
263
197
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
264
|
-
'
|
|
198
|
+
'Search failed:',
|
|
265
199
|
expect.any(Error),
|
|
266
200
|
);
|
|
267
201
|
});
|
|
268
|
-
|
|
269
202
|
consoleErrorSpy.mockRestore();
|
|
270
203
|
});
|
|
271
204
|
|
|
272
|
-
it('
|
|
273
|
-
|
|
274
|
-
mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
|
|
275
|
-
|
|
276
|
-
const { container } = render(
|
|
277
|
-
<DJClientContext.Provider value={mockDjClient}>
|
|
278
|
-
<Search />
|
|
279
|
-
</DJClientContext.Provider>,
|
|
280
|
-
);
|
|
281
|
-
|
|
282
|
-
const searchBox = container.querySelector('.nav-search-box');
|
|
283
|
-
expect(searchBox).toBeInTheDocument();
|
|
284
|
-
expect(searchBox.querySelector('input')).toBeInTheDocument();
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it('handles empty tags array', async () => {
|
|
288
|
-
mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
|
|
289
|
-
mockDjClient.DataJunctionAPI.listTags.mockResolvedValue(null);
|
|
290
|
-
|
|
205
|
+
it('serves a repeated query from cache without calling the server again', async () => {
|
|
206
|
+
const client = makeClient();
|
|
291
207
|
const { getByPlaceholderText } = render(
|
|
292
|
-
<DJClientContext.Provider value={
|
|
208
|
+
<DJClientContext.Provider value={client}>
|
|
293
209
|
<Search />
|
|
294
210
|
</DJClientContext.Provider>,
|
|
295
211
|
);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
await
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
// Should not throw an error
|
|
306
|
-
expect(searchInput).toBeInTheDocument();
|
|
212
|
+
const input = getByPlaceholderText('Search nodes and tags...');
|
|
213
|
+
fireEvent.change(input, { target: { value: 'test' } });
|
|
214
|
+
await flushDebounce();
|
|
215
|
+
expect(client.DataJunctionAPI.globalSearch).toHaveBeenCalledTimes(1);
|
|
216
|
+
fireEvent.change(input, { target: { value: 'other' } });
|
|
217
|
+
await flushDebounce();
|
|
218
|
+
fireEvent.change(input, { target: { value: 'test' } });
|
|
219
|
+
await flushDebounce();
|
|
220
|
+
expect(client.DataJunctionAPI.globalSearch).toHaveBeenCalledTimes(2);
|
|
307
221
|
});
|
|
308
222
|
|
|
309
|
-
it('
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
223
|
+
it('aborts the in-flight request when a new query is typed', async () => {
|
|
224
|
+
const aborts = [];
|
|
225
|
+
const client = makeClient({
|
|
226
|
+
globalSearch: jest.fn((q, { signal }) => {
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
signal.addEventListener('abort', () => {
|
|
229
|
+
const err = new Error('aborted');
|
|
230
|
+
err.name = 'AbortError';
|
|
231
|
+
aborts.push(q);
|
|
232
|
+
reject(err);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}),
|
|
236
|
+
});
|
|
237
|
+
const { getByPlaceholderText } = render(
|
|
238
|
+
<DJClientContext.Provider value={client}>
|
|
315
239
|
<Search />
|
|
316
240
|
</DJClientContext.Provider>,
|
|
317
241
|
);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
fireEvent.
|
|
322
|
-
|
|
323
|
-
await waitFor(() =>
|
|
324
|
-
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
fireEvent.change(searchInput, { target: { value: 'test' } });
|
|
328
|
-
|
|
329
|
-
await waitFor(() => {
|
|
330
|
-
const results = container.querySelector('.search-result-item');
|
|
331
|
-
expect(results).toBeInTheDocument();
|
|
332
|
-
});
|
|
242
|
+
const input = getByPlaceholderText('Search nodes and tags...');
|
|
243
|
+
fireEvent.change(input, { target: { value: 'aaa' } });
|
|
244
|
+
await flushDebounce();
|
|
245
|
+
fireEvent.change(input, { target: { value: 'bbb' } });
|
|
246
|
+
await flushDebounce();
|
|
247
|
+
await waitFor(() => expect(aborts).toContain('aaa'));
|
|
248
|
+
expect(client.DataJunctionAPI.globalSearch).toHaveBeenCalledTimes(2);
|
|
333
249
|
});
|
|
334
250
|
});
|
|
@@ -1774,6 +1774,57 @@ export const DataJunctionAPI = {
|
|
|
1774
1774
|
});
|
|
1775
1775
|
return await response.json();
|
|
1776
1776
|
},
|
|
1777
|
+
|
|
1778
|
+
globalSearch: async function (
|
|
1779
|
+
query,
|
|
1780
|
+
{ signal, nodeLimit = 10, tagLimit = 5 } = {},
|
|
1781
|
+
) {
|
|
1782
|
+
const gqlQuery = `
|
|
1783
|
+
query GlobalSearch($q: String!, $nodeLimit: Int!, $tagLimit: Int!) {
|
|
1784
|
+
findNodes(search: $q, limit: $nodeLimit) {
|
|
1785
|
+
name
|
|
1786
|
+
type
|
|
1787
|
+
current {
|
|
1788
|
+
displayName
|
|
1789
|
+
description
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
searchTags(search: $q, limit: $tagLimit) {
|
|
1793
|
+
name
|
|
1794
|
+
displayName
|
|
1795
|
+
description
|
|
1796
|
+
tagType
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
`;
|
|
1800
|
+
const response = await fetch(DJ_GQL, {
|
|
1801
|
+
method: 'POST',
|
|
1802
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1803
|
+
credentials: 'include',
|
|
1804
|
+
signal,
|
|
1805
|
+
body: JSON.stringify({
|
|
1806
|
+
query: gqlQuery,
|
|
1807
|
+
variables: { q: query, nodeLimit, tagLimit },
|
|
1808
|
+
}),
|
|
1809
|
+
});
|
|
1810
|
+
const result = await response.json();
|
|
1811
|
+
const nodes = (result?.data?.findNodes || []).map(node => ({
|
|
1812
|
+
name: node.name,
|
|
1813
|
+
display_name: node.current?.displayName || node.name,
|
|
1814
|
+
description: node.current?.description || '',
|
|
1815
|
+
type: node.type?.toLowerCase() || '',
|
|
1816
|
+
kind: 'node',
|
|
1817
|
+
}));
|
|
1818
|
+
const tags = (result?.data?.searchTags || []).map(tag => ({
|
|
1819
|
+
name: tag.name,
|
|
1820
|
+
display_name: tag.displayName || tag.name,
|
|
1821
|
+
description: tag.description || '',
|
|
1822
|
+
type: 'tag',
|
|
1823
|
+
tag_type: tag.tagType,
|
|
1824
|
+
kind: 'tag',
|
|
1825
|
+
}));
|
|
1826
|
+
return { nodes, tags };
|
|
1827
|
+
},
|
|
1777
1828
|
users: async function () {
|
|
1778
1829
|
return await (
|
|
1779
1830
|
await fetch(`${DJ_URL}/users?with_activity=true`, {
|