datajunction-ui 0.0.14 → 0.0.16
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 +1 -1
- package/src/app/components/__tests__/NodeMaterializationDelete.test.jsx +263 -0
- package/src/app/components/__tests__/QueryInfo.test.jsx +174 -46
- package/src/app/components/__tests__/Search.test.jsx +300 -56
- package/src/app/pages/AddEditNodePage/FormikSelect.jsx +15 -2
- package/src/app/pages/NamespacePage/Explorer.jsx +192 -21
- package/src/app/pages/NamespacePage/__tests__/AddNamespacePopover.test.jsx +283 -0
- package/src/app/pages/NamespacePage/__tests__/index.test.jsx +74 -41
- package/src/app/pages/NamespacePage/index.jsx +13 -7
- package/src/app/pages/NodePage/AddComplexDimensionLinkPopover.jsx +367 -0
- package/src/app/pages/NodePage/LinkDimensionPopover.jsx +1 -1
- package/src/app/pages/NodePage/ManageDimensionLinksDialog.jsx +526 -0
- package/src/app/pages/NodePage/NodeColumnTab.jsx +223 -58
- package/src/app/pages/NodePage/__tests__/AddComplexDimensionLinkPopover.test.jsx +459 -0
- package/src/app/pages/NodePage/__tests__/EditColumnPopover.test.jsx +2 -6
- package/src/app/pages/NodePage/__tests__/LinkDimensionPopover.test.jsx +19 -48
- package/src/app/pages/NodePage/__tests__/ManageDimensionLinksDialog.test.jsx +390 -0
- package/src/app/pages/NodePage/__tests__/NodePage.test.jsx +22 -12
- package/src/app/pages/RegisterTablePage/__tests__/RegisterTablePage.test.jsx +4 -2
- package/src/app/services/DJService.js +46 -6
- package/src/app/services/__tests__/DJService.test.jsx +551 -5
- package/webpack.config.js +1 -0
|
@@ -1,63 +1,307 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { render,
|
|
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
|
-
|
|
6
|
-
|
|
5
|
+
|
|
6
|
+
const mockDjClient = {
|
|
7
|
+
DataJunctionAPI: {
|
|
8
|
+
nodeDetails: jest.fn(),
|
|
9
|
+
listTags: jest.fn(),
|
|
10
|
+
},
|
|
11
|
+
};
|
|
7
12
|
|
|
8
13
|
describe('<Search />', () => {
|
|
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
|
-
|
|
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
|
-
<
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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={
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
207
|
+
style={{
|
|
208
|
+
paddingLeft: '1.4rem',
|
|
209
|
+
marginLeft: '1rem',
|
|
210
|
+
borderLeft: '1px solid rgb(218 233 255)',
|
|
211
|
+
}}
|
|
212
|
+
key={index}
|
|
51
213
|
>
|
|
52
|
-
<
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
57
228
|
</>
|
|
58
229
|
);
|
|
59
230
|
};
|