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.126",
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 { useState, useCallback, useContext, useRef } from 'react';
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 [searchResults, setSearchResults] = useState([]);
18
+ const [nodes, setNodes] = useState([]);
19
+ const [tags, setTags] = useState([]);
11
20
  const [isLoading, setIsLoading] = useState(false);
12
- const hasLoadedRef = useRef(false);
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 truncate = str => {
18
- if (str === null) {
19
- return '';
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
- return str.length > 100 ? str.substring(0, 90) + '...' : str;
22
- };
23
-
24
- // Lazy load search data only when user focuses on search input
25
- const loadSearchData = useCallback(async () => {
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
- try {
31
- const [data, tags] = await Promise.all([
32
- djClient.nodeDetails(),
33
- djClient.listTags(),
34
- ]);
35
- const allEntities = data.concat(
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
- }, [djClient, isLoading]);
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
- setSearchValue(e.target.value);
61
- if (fuse) {
62
- setSearchResults(fuse.search(e.target.value).map(result => result.item));
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 ? 'Loading...' : 'Search nodes...'}
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
- {searchResults.length > 0 && (
80
- <div className="search-results">
81
- {searchResults.slice(0, 20).map(item => {
82
- const itemUrl =
83
- item.type !== 'tag'
84
- ? `/nodes/${item.name}`
85
- : `/tags/${item.name}`;
86
- return (
87
- <a key={item.name} href={itemUrl}>
88
- <div className="search-result-item">
89
- <span className={`node_type__${item.type} badge node_type`}>
90
- {item.type}
91
- </span>
92
- {item.display_name} (<b>{item.name}</b>){' '}
93
- {item.description ? '- ' : ' '}
94
- {truncate(item.description || '')}
95
- </div>
96
- </a>
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, screen } from '@testing-library/react';
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 mockDjClient = {
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
- nodeDetails: jest.fn(),
9
- listTags: jest.fn(),
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
- const mockNodes = [
19
- {
20
- name: 'default.test_node',
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={mockDjClient}>
70
+ <DJClientContext.Provider value={client}>
55
71
  <Search />
56
72
  </DJClientContext.Provider>,
57
73
  );
58
-
59
- expect(getByPlaceholderText('Search nodes...')).toBeInTheDocument();
74
+ expect(
75
+ getByPlaceholderText('Search nodes and tags...'),
76
+ ).toBeInTheDocument();
60
77
  });
61
78
 
62
- it('fetches and initializes search data on focus (lazy loading)', async () => {
63
- mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
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={mockDjClient}>
82
+ <DJClientContext.Provider value={client}>
68
83
  <Search />
69
84
  </DJClientContext.Provider>,
70
85
  );
71
-
72
- // Data should NOT be fetched on mount
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('displays search results when typing', async () => {
86
- mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
87
- mockDjClient.DataJunctionAPI.listTags.mockResolvedValue(mockTags);
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
- const searchInput = getByPlaceholderText('Search nodes...');
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
- it('displays nodes with correct URLs', async () => {
111
- mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
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('displays tags with correct URLs', async () => {
137
- mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue([]);
138
- mockDjClient.DataJunctionAPI.listTags.mockResolvedValue(mockTags);
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
- const searchInput = getByPlaceholderText('Search nodes...');
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('truncates long descriptions', async () => {
163
- mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
164
- mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
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
- const searchInput = getByPlaceholderText('Search nodes...');
173
- // Focus to trigger lazy loading
174
- fireEvent.focus(searchInput);
175
-
176
- await waitFor(() => {
177
- expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
178
- });
179
-
180
- fireEvent.change(searchInput, { target: { value: 'long' } });
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('handles null descriptions', async () => {
188
- mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
189
- mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
190
-
191
- const { getByPlaceholderText, getByText } = render(
192
- <DJClientContext.Provider value={mockDjClient}>
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
- const searchInput = getByPlaceholderText('Search nodes...');
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('limits search results to 20 items', async () => {
213
- const manyNodes = Array.from({ length: 30 }, (_, i) => ({
214
- name: `default.node${i}`,
215
- display_name: `Node ${i}`,
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
- const searchInput = getByPlaceholderText('Search nodes...');
230
- // Focus to trigger lazy loading
231
- fireEvent.focus(searchInput);
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(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
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('handles error when fetching nodes', async () => {
182
+ it('logs an error but does not throw when the request fails', async () => {
246
183
  const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
247
- mockDjClient.DataJunctionAPI.nodeDetails.mockRejectedValue(
248
- new Error('Network 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={mockDjClient}>
188
+ <DJClientContext.Provider value={client}>
254
189
  <Search />
255
190
  </DJClientContext.Provider>,
256
191
  );
257
-
258
- // Focus to trigger lazy loading
259
- const searchInput = getByPlaceholderText('Search nodes...');
260
- fireEvent.focus(searchInput);
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
- 'Error fetching nodes or tags:',
198
+ 'Search failed:',
265
199
  expect.any(Error),
266
200
  );
267
201
  });
268
-
269
202
  consoleErrorSpy.mockRestore();
270
203
  });
271
204
 
272
- it('renders search input inside nav-search-box container', async () => {
273
- mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue([]);
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={mockDjClient}>
208
+ <DJClientContext.Provider value={client}>
293
209
  <Search />
294
210
  </DJClientContext.Provider>,
295
211
  );
296
-
297
- const searchInput = getByPlaceholderText('Search nodes...');
298
- // Focus to trigger lazy loading
299
- fireEvent.focus(searchInput);
300
-
301
- await waitFor(() => {
302
- expect(mockDjClient.DataJunctionAPI.listTags).toHaveBeenCalled();
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('shows description separator correctly', async () => {
310
- mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
311
- mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
312
-
313
- const { getByPlaceholderText, container } = render(
314
- <DJClientContext.Provider value={mockDjClient}>
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
- const searchInput = getByPlaceholderText('Search nodes...');
320
- // Focus to trigger lazy loading
321
- fireEvent.focus(searchInput);
322
-
323
- await waitFor(() => {
324
- expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
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`, {