datajunction-ui 0.0.15 → 0.0.17

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.
@@ -1,63 +1,307 @@
1
- import * as React from 'react';
2
- import { render, screen, fireEvent } from '@testing-library/react';
1
+ import React from 'react';
2
+ import { render, fireEvent, waitFor, screen } from '@testing-library/react';
3
3
  import Search from '../Search';
4
4
  import DJClientContext from '../../providers/djclient';
5
- import { Root } from '../../pages/Root';
6
- import { HelmetProvider } from 'react-helmet-async';
5
+
6
+ const mockDjClient = {
7
+ DataJunctionAPI: {
8
+ nodeDetails: jest.fn(),
9
+ listTags: jest.fn(),
10
+ },
11
+ };
7
12
 
8
13
  describe('<Search />', () => {
9
- const mockDjClient = {
10
- logout: jest.fn(),
11
- nodeDetails: async () => [
12
- {
13
- name: 'default.repair_orders',
14
- display_name: 'Default: Repair Orders',
15
- description: 'Repair orders',
16
- version: 'v1.0',
17
- type: 'source',
18
- status: 'valid',
19
- mode: 'published',
20
- updated_at: '2023-08-21T16:48:52.880498+00:00',
21
- },
22
- {
23
- name: 'default.repair_order_details',
24
- display_name: 'Default: Repair Order Details',
25
- description: 'Details on repair orders',
26
- version: 'v1.0',
27
- type: 'source',
28
- status: 'valid',
29
- mode: 'published',
30
- updated_at: '2023-08-21T16:48:52.981201+00:00',
31
- },
32
- ],
33
- listTags: async () => [
34
- {
35
- description: 'something',
36
- display_name: 'Report A',
37
- tag_metadata: {},
38
- name: 'report.a',
39
- tag_type: 'report',
40
- },
41
- {
42
- description: 'report B',
43
- display_name: 'Report B',
44
- tag_metadata: {},
45
- name: 'report.b',
46
- tag_type: 'report',
47
- },
48
- ],
49
- };
50
-
51
- it('displays search results correctly', () => {
14
+ beforeEach(() => {
15
+ jest.clearAllMocks();
16
+ });
17
+
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);
52
+
53
+ const { getByPlaceholderText } = render(
54
+ <DJClientContext.Provider value={mockDjClient}>
55
+ <Search />
56
+ </DJClientContext.Provider>,
57
+ );
58
+
59
+ expect(getByPlaceholderText('Search')).toBeInTheDocument();
60
+ });
61
+
62
+ it('fetches and initializes search data on mount', async () => {
63
+ mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
64
+ mockDjClient.DataJunctionAPI.listTags.mockResolvedValue(mockTags);
65
+
52
66
  render(
53
- <HelmetProvider>
54
- <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
55
- <Root />
56
- </DJClientContext.Provider>
57
- </HelmetProvider>,
58
- );
59
- const searchInput = screen.queryByPlaceholderText('Search');
60
- fireEvent.change(searchInput, { target: { value: 'Repair' } });
61
- expect(searchInput.value).toBe('Repair');
67
+ <DJClientContext.Provider value={mockDjClient}>
68
+ <Search />
69
+ </DJClientContext.Provider>,
70
+ );
71
+
72
+ await waitFor(() => {
73
+ expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
74
+ expect(mockDjClient.DataJunctionAPI.listTags).toHaveBeenCalled();
75
+ });
76
+ });
77
+
78
+ it('displays search results when typing', async () => {
79
+ mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
80
+ mockDjClient.DataJunctionAPI.listTags.mockResolvedValue(mockTags);
81
+
82
+ const { getByPlaceholderText, getByText } = render(
83
+ <DJClientContext.Provider value={mockDjClient}>
84
+ <Search />
85
+ </DJClientContext.Provider>,
86
+ );
87
+
88
+ await waitFor(() => {
89
+ expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
90
+ });
91
+
92
+ const searchInput = getByPlaceholderText('Search');
93
+ fireEvent.change(searchInput, { target: { value: 'test' } });
94
+
95
+ await waitFor(() => {
96
+ expect(getByText(/Test Node/)).toBeInTheDocument();
97
+ });
98
+ });
99
+
100
+ it('displays nodes with correct URLs', async () => {
101
+ mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
102
+ mockDjClient.DataJunctionAPI.listTags.mockResolvedValue(mockTags);
103
+
104
+ const { getByPlaceholderText, container } = render(
105
+ <DJClientContext.Provider value={mockDjClient}>
106
+ <Search />
107
+ </DJClientContext.Provider>,
108
+ );
109
+
110
+ await waitFor(() => {
111
+ expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
112
+ });
113
+
114
+ const searchInput = getByPlaceholderText('Search');
115
+ fireEvent.change(searchInput, { target: { value: 'node' } });
116
+
117
+ await waitFor(() => {
118
+ const links = container.querySelectorAll('a[href^="/nodes/"]');
119
+ expect(links.length).toBeGreaterThan(0);
120
+ });
121
+ });
122
+
123
+ it('displays tags with correct URLs', async () => {
124
+ mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue([]);
125
+ mockDjClient.DataJunctionAPI.listTags.mockResolvedValue(mockTags);
126
+
127
+ const { getByPlaceholderText, container } = render(
128
+ <DJClientContext.Provider value={mockDjClient}>
129
+ <Search />
130
+ </DJClientContext.Provider>,
131
+ );
132
+
133
+ await waitFor(() => {
134
+ expect(mockDjClient.DataJunctionAPI.listTags).toHaveBeenCalled();
135
+ });
136
+
137
+ const searchInput = getByPlaceholderText('Search');
138
+ fireEvent.change(searchInput, { target: { value: 'tag' } });
139
+
140
+ await waitFor(() => {
141
+ const links = container.querySelectorAll('a[href^="/tags/"]');
142
+ expect(links.length).toBeGreaterThan(0);
143
+ });
144
+ });
145
+
146
+ it('truncates long descriptions', async () => {
147
+ mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
148
+ mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
149
+
150
+ const { getByPlaceholderText, getByText } = render(
151
+ <DJClientContext.Provider value={mockDjClient}>
152
+ <Search />
153
+ </DJClientContext.Provider>,
154
+ );
155
+
156
+ await waitFor(() => {
157
+ expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
158
+ });
159
+
160
+ const searchInput = getByPlaceholderText('Search');
161
+ fireEvent.change(searchInput, { target: { value: 'long' } });
162
+
163
+ await waitFor(() => {
164
+ expect(getByText(/\.\.\./)).toBeInTheDocument();
165
+ });
166
+ });
167
+
168
+ it('handles null descriptions', async () => {
169
+ mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
170
+ mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
171
+
172
+ const { getByPlaceholderText, getByText } = render(
173
+ <DJClientContext.Provider value={mockDjClient}>
174
+ <Search />
175
+ </DJClientContext.Provider>,
176
+ );
177
+
178
+ await waitFor(() => {
179
+ expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
180
+ });
181
+
182
+ const searchInput = getByPlaceholderText('Search');
183
+ fireEvent.change(searchInput, { target: { value: 'another' } });
184
+
185
+ await waitFor(() => {
186
+ expect(getByText(/Another Node/)).toBeInTheDocument();
187
+ });
188
+ });
189
+
190
+ it('limits search results to 20 items', async () => {
191
+ const manyNodes = Array.from({ length: 30 }, (_, i) => ({
192
+ name: `default.node${i}`,
193
+ display_name: `Node ${i}`,
194
+ description: `Description ${i}`,
195
+ type: 'transform',
196
+ }));
197
+
198
+ mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(manyNodes);
199
+ mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
200
+
201
+ const { getByPlaceholderText, container } = render(
202
+ <DJClientContext.Provider value={mockDjClient}>
203
+ <Search />
204
+ </DJClientContext.Provider>,
205
+ );
206
+
207
+ await waitFor(() => {
208
+ expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
209
+ });
210
+
211
+ const searchInput = getByPlaceholderText('Search');
212
+ fireEvent.change(searchInput, { target: { value: 'node' } });
213
+
214
+ await waitFor(() => {
215
+ const results = container.querySelectorAll('.search-result-item');
216
+ expect(results.length).toBeLessThanOrEqual(20);
217
+ });
218
+ });
219
+
220
+ it('handles error when fetching nodes', async () => {
221
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
222
+ mockDjClient.DataJunctionAPI.nodeDetails.mockRejectedValue(
223
+ new Error('Network error'),
224
+ );
225
+ mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
226
+
227
+ render(
228
+ <DJClientContext.Provider value={mockDjClient}>
229
+ <Search />
230
+ </DJClientContext.Provider>,
231
+ );
232
+
233
+ await waitFor(() => {
234
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
235
+ 'Error fetching nodes or tags:',
236
+ expect.any(Error),
237
+ );
238
+ });
239
+
240
+ consoleErrorSpy.mockRestore();
241
+ });
242
+
243
+ it('prevents form submission', async () => {
244
+ mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue([]);
245
+ mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
246
+
247
+ const { container } = render(
248
+ <DJClientContext.Provider value={mockDjClient}>
249
+ <Search />
250
+ </DJClientContext.Provider>,
251
+ );
252
+
253
+ const form = container.querySelector('form');
254
+
255
+ const submitEvent = new Event('submit', {
256
+ bubbles: true,
257
+ cancelable: true,
258
+ });
259
+ const preventDefaultSpy = jest.spyOn(submitEvent, 'preventDefault');
260
+
261
+ form.dispatchEvent(submitEvent);
262
+
263
+ expect(preventDefaultSpy).toHaveBeenCalled();
264
+ });
265
+
266
+ it('handles empty tags array', async () => {
267
+ mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
268
+ mockDjClient.DataJunctionAPI.listTags.mockResolvedValue(null);
269
+
270
+ const { getByPlaceholderText } = render(
271
+ <DJClientContext.Provider value={mockDjClient}>
272
+ <Search />
273
+ </DJClientContext.Provider>,
274
+ );
275
+
276
+ await waitFor(() => {
277
+ expect(mockDjClient.DataJunctionAPI.listTags).toHaveBeenCalled();
278
+ });
279
+
280
+ // Should not throw an error
281
+ const searchInput = getByPlaceholderText('Search');
282
+ expect(searchInput).toBeInTheDocument();
283
+ });
284
+
285
+ it('shows description separator correctly', async () => {
286
+ mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
287
+ mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
288
+
289
+ const { getByPlaceholderText, container } = render(
290
+ <DJClientContext.Provider value={mockDjClient}>
291
+ <Search />
292
+ </DJClientContext.Provider>,
293
+ );
294
+
295
+ await waitFor(() => {
296
+ expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
297
+ });
298
+
299
+ const searchInput = getByPlaceholderText('Search');
300
+ fireEvent.change(searchInput, { target: { value: 'test' } });
301
+
302
+ await waitFor(() => {
303
+ const results = container.querySelector('.search-result-item');
304
+ expect(results).toBeInTheDocument();
305
+ });
62
306
  });
63
307
  });
@@ -14,6 +14,10 @@ export const FormikSelect = ({
14
14
  isMulti = false,
15
15
  isClearable = false,
16
16
  onFocus = event => {},
17
+ onChange: customOnChange,
18
+ menuPortalTarget,
19
+ styles,
20
+ ...rest
17
21
  }) => {
18
22
  // eslint-disable-next-line no-unused-vars
19
23
  const [field, _, helpers] = useField(formikFieldName);
@@ -28,6 +32,13 @@ export const FormikSelect = ({
28
32
  }
29
33
  };
30
34
 
35
+ const handleChange = selected => {
36
+ setValue(getValue(selected));
37
+ if (customOnChange) {
38
+ customOnChange(selected);
39
+ }
40
+ };
41
+
31
42
  return (
32
43
  <Select
33
44
  className={className}
@@ -36,12 +47,14 @@ export const FormikSelect = ({
36
47
  name={field.name}
37
48
  placeholder={placeholder}
38
49
  onBlur={field.onBlur}
39
- onChange={selected => setValue(getValue(selected))}
40
- styles={style}
50
+ onChange={handleChange}
51
+ styles={styles || style}
41
52
  isMulti={isMulti}
42
53
  isClearable={isClearable}
43
54
  onFocus={event => onFocus(event)}
44
55
  id={field.name}
56
+ menuPortalTarget={menuPortalTarget}
57
+ {...rest}
45
58
  />
46
59
  );
47
60
  };
@@ -1,11 +1,20 @@
1
- import React, { useEffect, useState } from 'react';
1
+ import React, { useContext, useEffect, useRef, useState } from 'react';
2
2
  import CollapsedIcon from '../../icons/CollapsedIcon';
3
3
  import ExpandedIcon from '../../icons/ExpandedIcon';
4
+ import AddItemIcon from '../../icons/AddItemIcon';
5
+ import DJClientContext from '../../providers/djclient';
4
6
 
5
- const Explorer = ({ item = [], current }) => {
7
+ const Explorer = ({ item = [], current, isTopLevel = false }) => {
8
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
6
9
  const [items, setItems] = useState([]);
7
10
  const [expand, setExpand] = useState(false);
8
11
  const [highlight, setHighlight] = useState(false);
12
+ const [showAddButton, setShowAddButton] = useState(false);
13
+ const [isCreatingChild, setIsCreatingChild] = useState(false);
14
+ const [newNamespace, setNewNamespace] = useState('');
15
+ const [error, setError] = useState('');
16
+ const inputRef = useRef(null);
17
+ const formRef = useRef(null);
9
18
 
10
19
  useEffect(() => {
11
20
  setItems(item);
@@ -15,6 +24,27 @@ const Explorer = ({ item = [], current }) => {
15
24
  } else setExpand(false);
16
25
  }, [current, item]);
17
26
 
27
+ useEffect(() => {
28
+ if (isCreatingChild && inputRef.current) {
29
+ inputRef.current.focus();
30
+ }
31
+ }, [isCreatingChild]);
32
+
33
+ useEffect(() => {
34
+ const handleClickOutside = event => {
35
+ if (formRef.current && !formRef.current.contains(event.target)) {
36
+ handleCancelAdd();
37
+ }
38
+ };
39
+
40
+ if (isCreatingChild) {
41
+ document.addEventListener('mousedown', handleClickOutside);
42
+ return () => {
43
+ document.removeEventListener('mousedown', handleClickOutside);
44
+ };
45
+ }
46
+ }, [isCreatingChild]);
47
+
18
48
  const handleClickOnParent = e => {
19
49
  e.stopPropagation();
20
50
  setExpand(prev => {
@@ -22,38 +52,179 @@ const Explorer = ({ item = [], current }) => {
22
52
  });
23
53
  };
24
54
 
55
+ const handleAddNamespace = async e => {
56
+ e.preventDefault();
57
+ if (!newNamespace.trim()) {
58
+ setError('Namespace cannot be empty');
59
+ return;
60
+ }
61
+
62
+ const fullNamespace = items.path
63
+ ? `${items.path}.${newNamespace}`
64
+ : newNamespace;
65
+
66
+ const response = await djClient.addNamespace(fullNamespace);
67
+ if (response.status === 200 || response.status === 201) {
68
+ setIsCreatingChild(false);
69
+ setNewNamespace('');
70
+ setError('');
71
+ window.location.href = `/namespaces/${fullNamespace}`;
72
+ } else {
73
+ setError(response.json?.message || 'Failed to create namespace');
74
+ }
75
+ };
76
+
77
+ const handleCancelAdd = () => {
78
+ setIsCreatingChild(false);
79
+ setNewNamespace('');
80
+ setError('');
81
+ };
82
+
83
+ const handleKeyDown = e => {
84
+ if (e.key === 'Enter') {
85
+ handleAddNamespace(e);
86
+ } else if (e.key === 'Escape') {
87
+ handleCancelAdd();
88
+ }
89
+ };
90
+
25
91
  return (
26
92
  <>
27
- <div
28
- className={`select-name ${
29
- highlight === items.path ? 'select-name-highlight' : ''
30
- }`}
31
- onClick={handleClickOnParent}
32
- >
33
- {items.children && items.children.length > 0 ? (
34
- <span>{!expand ? <CollapsedIcon /> : <ExpandedIcon />} </span>
35
- ) : null}
36
- <a href={`/namespaces/${items.path}`}>{items.namespace}</a>{' '}
93
+ <div className="namespace-item" style={{ position: 'relative' }}>
94
+ <div
95
+ className={`select-name ${
96
+ highlight === items.path ? 'select-name-highlight' : ''
97
+ }`}
98
+ onClick={handleClickOnParent}
99
+ onMouseEnter={() => setShowAddButton(true)}
100
+ onMouseLeave={() => setShowAddButton(false)}
101
+ style={{
102
+ display: 'inline-flex',
103
+ alignItems: 'center',
104
+ width: '100%',
105
+ position: 'relative',
106
+ }}
107
+ >
108
+ {items.children && items.children.length > 0 ? (
109
+ <span style={{ marginRight: '4px' }}>
110
+ {!expand ? <CollapsedIcon /> : <ExpandedIcon />}
111
+ </span>
112
+ ) : (
113
+ <span style={{ left: '-18px' }} />
114
+ )}
115
+ <a href={`/namespaces/${items.path}`}>{items.namespace}</a>
116
+ <button
117
+ className="namespace-add-button"
118
+ onClick={e => {
119
+ e.stopPropagation();
120
+ setIsCreatingChild(true);
121
+ setExpand(true);
122
+ }}
123
+ title="Add child namespace"
124
+ style={{
125
+ position: 'absolute',
126
+ right: '0',
127
+ padding: '2px 6px',
128
+ border: 'none',
129
+ background: 'transparent',
130
+ cursor: 'pointer',
131
+ opacity: showAddButton ? 0.6 : 0,
132
+ visibility: showAddButton ? 'visible' : 'hidden',
133
+ display: 'inline-flex',
134
+ alignItems: 'center',
135
+ transition: 'opacity 0.15s ease',
136
+ }}
137
+ >
138
+ <AddItemIcon />
139
+ </button>
140
+ </div>
37
141
  </div>
38
- {items.children
39
- ? items.children.map((item, index) => (
142
+ {(items.children || isCreatingChild) && (
143
+ <div>
144
+ {isCreatingChild && (
40
145
  <div
41
146
  style={{
42
147
  paddingLeft: '1.4rem',
43
148
  marginLeft: '1rem',
44
149
  borderLeft: '1px solid rgb(218 233 255)',
150
+ marginTop: '5px',
45
151
  }}
46
- key={index}
47
152
  >
153
+ <form
154
+ ref={formRef}
155
+ onSubmit={handleAddNamespace}
156
+ style={{
157
+ display: 'flex',
158
+ flexDirection: 'column',
159
+ gap: '4px',
160
+ }}
161
+ >
162
+ <div
163
+ style={{ display: 'flex', gap: '4px', alignItems: 'center' }}
164
+ >
165
+ <input
166
+ ref={inputRef}
167
+ type="text"
168
+ value={newNamespace}
169
+ onChange={e => setNewNamespace(e.target.value)}
170
+ onKeyDown={handleKeyDown}
171
+ placeholder="New namespace name"
172
+ style={{
173
+ padding: '4px 8px',
174
+ fontSize: '0.875rem',
175
+ border: '1px solid #ccc',
176
+ borderRadius: '4px',
177
+ flex: 1,
178
+ }}
179
+ />
180
+ <button
181
+ type="submit"
182
+ style={{
183
+ padding: '4px 8px',
184
+ fontSize: '0.75rem',
185
+ background: '#007bff',
186
+ color: 'white',
187
+ border: 'none',
188
+ borderRadius: '4px',
189
+ cursor: 'pointer',
190
+ margin: '0 1em',
191
+ }}
192
+ >
193
+
194
+ </button>
195
+ </div>
196
+ {error && (
197
+ <span style={{ color: 'red', fontSize: '0.75rem' }}>
198
+ {error}
199
+ </span>
200
+ )}
201
+ </form>
202
+ </div>
203
+ )}
204
+ {items.children &&
205
+ items.children.map((item, index) => (
48
206
  <div
49
- className={`${expand ? '' : 'inactive'}`}
50
- key={`nested-${index}`}
207
+ style={{
208
+ paddingLeft: '1.4rem',
209
+ marginLeft: '1rem',
210
+ borderLeft: '1px solid rgb(218 233 255)',
211
+ }}
212
+ key={index}
51
213
  >
52
- <Explorer item={item} current={highlight} />
214
+ <div
215
+ className={`${expand ? '' : 'inactive'}`}
216
+ key={`nested-${index}`}
217
+ >
218
+ <Explorer
219
+ item={item}
220
+ current={highlight}
221
+ isTopLevel={false}
222
+ />
223
+ </div>
53
224
  </div>
54
- </div>
55
- ))
56
- : null}
225
+ ))}
226
+ </div>
227
+ )}
57
228
  </>
58
229
  );
59
230
  };