datajunction-ui 0.0.94 → 0.0.96
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/Search.jsx +26 -25
- package/src/app/components/Tab.jsx +6 -1
- package/src/app/components/__tests__/Search.test.jsx +15 -23
- package/src/app/components/search.css +43 -5
- package/src/app/icons/ChartIcon.jsx +28 -0
- package/src/app/pages/NodePage/__tests__/NodeDimensionsTab.test.jsx +50 -0
- package/src/app/pages/NodePage/index.jsx +6 -2
- package/src/app/pages/QueryPlannerPage/ResultsView.jsx +566 -86
- package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +32 -1
- package/src/app/pages/QueryPlannerPage/__tests__/ResultsView.test.jsx +326 -0
- package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +431 -2
- package/src/app/pages/QueryPlannerPage/index.jsx +31 -5
- package/src/app/pages/QueryPlannerPage/styles.css +220 -2
- package/src/app/pages/Root/__tests__/index.test.jsx +2 -2
- package/src/app/pages/Root/index.tsx +2 -2
- package/src/app/services/DJService.js +60 -17
- package/src/app/services/__tests__/DJService.test.jsx +13 -15
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ export default function Search() {
|
|
|
10
10
|
const [searchResults, setSearchResults] = useState([]);
|
|
11
11
|
const [isLoading, setIsLoading] = useState(false);
|
|
12
12
|
const hasLoadedRef = useRef(false);
|
|
13
|
+
const inputRef = useRef(null);
|
|
13
14
|
|
|
14
15
|
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
15
16
|
|
|
@@ -64,39 +65,39 @@ export default function Search() {
|
|
|
64
65
|
|
|
65
66
|
return (
|
|
66
67
|
<div>
|
|
67
|
-
<
|
|
68
|
-
className="search-box"
|
|
69
|
-
onSubmit={e => {
|
|
70
|
-
e.preventDefault();
|
|
71
|
-
}}
|
|
72
|
-
>
|
|
68
|
+
<div className="nav-search-box" onClick={() => inputRef.current?.focus()}>
|
|
73
69
|
<input
|
|
70
|
+
ref={inputRef}
|
|
74
71
|
type="text"
|
|
75
|
-
placeholder={isLoading ? 'Loading...' : 'Search'}
|
|
72
|
+
placeholder={isLoading ? 'Loading...' : 'Search nodes...'}
|
|
76
73
|
name="search"
|
|
77
74
|
value={searchValue}
|
|
78
75
|
onChange={handleChange}
|
|
79
76
|
onFocus={loadSearchData}
|
|
80
77
|
/>
|
|
81
|
-
</form>
|
|
82
|
-
<div className="search-results">
|
|
83
|
-
{searchResults.slice(0, 20).map(item => {
|
|
84
|
-
const itemUrl =
|
|
85
|
-
item.type !== 'tag' ? `/nodes/${item.name}` : `/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
|
-
})}
|
|
99
78
|
</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
|
+
})}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
100
101
|
</div>
|
|
101
102
|
);
|
|
102
103
|
}
|
|
@@ -13,7 +13,12 @@ export default class Tab extends Component {
|
|
|
13
13
|
aria-label={this.props.name}
|
|
14
14
|
aria-hidden="false"
|
|
15
15
|
>
|
|
16
|
-
|
|
16
|
+
<span
|
|
17
|
+
style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}
|
|
18
|
+
>
|
|
19
|
+
{this.props.icon}
|
|
20
|
+
{this.props.name}
|
|
21
|
+
</span>
|
|
17
22
|
</button>
|
|
18
23
|
);
|
|
19
24
|
}
|
|
@@ -56,7 +56,7 @@ describe('<Search />', () => {
|
|
|
56
56
|
</DJClientContext.Provider>,
|
|
57
57
|
);
|
|
58
58
|
|
|
59
|
-
expect(getByPlaceholderText('Search')).toBeInTheDocument();
|
|
59
|
+
expect(getByPlaceholderText('Search nodes...')).toBeInTheDocument();
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it('fetches and initializes search data on focus (lazy loading)', async () => {
|
|
@@ -73,7 +73,7 @@ describe('<Search />', () => {
|
|
|
73
73
|
expect(mockDjClient.DataJunctionAPI.nodeDetails).not.toHaveBeenCalled();
|
|
74
74
|
|
|
75
75
|
// Focus on search input to trigger lazy loading
|
|
76
|
-
const searchInput = getByPlaceholderText('Search');
|
|
76
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
77
77
|
fireEvent.focus(searchInput);
|
|
78
78
|
|
|
79
79
|
await waitFor(() => {
|
|
@@ -92,7 +92,7 @@ describe('<Search />', () => {
|
|
|
92
92
|
</DJClientContext.Provider>,
|
|
93
93
|
);
|
|
94
94
|
|
|
95
|
-
const searchInput = getByPlaceholderText('Search');
|
|
95
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
96
96
|
// Focus to trigger lazy loading
|
|
97
97
|
fireEvent.focus(searchInput);
|
|
98
98
|
|
|
@@ -117,7 +117,7 @@ describe('<Search />', () => {
|
|
|
117
117
|
</DJClientContext.Provider>,
|
|
118
118
|
);
|
|
119
119
|
|
|
120
|
-
const searchInput = getByPlaceholderText('Search');
|
|
120
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
121
121
|
// Focus to trigger lazy loading
|
|
122
122
|
fireEvent.focus(searchInput);
|
|
123
123
|
|
|
@@ -143,7 +143,7 @@ describe('<Search />', () => {
|
|
|
143
143
|
</DJClientContext.Provider>,
|
|
144
144
|
);
|
|
145
145
|
|
|
146
|
-
const searchInput = getByPlaceholderText('Search');
|
|
146
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
147
147
|
// Focus to trigger lazy loading
|
|
148
148
|
fireEvent.focus(searchInput);
|
|
149
149
|
|
|
@@ -169,7 +169,7 @@ describe('<Search />', () => {
|
|
|
169
169
|
</DJClientContext.Provider>,
|
|
170
170
|
);
|
|
171
171
|
|
|
172
|
-
const searchInput = getByPlaceholderText('Search');
|
|
172
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
173
173
|
// Focus to trigger lazy loading
|
|
174
174
|
fireEvent.focus(searchInput);
|
|
175
175
|
|
|
@@ -194,7 +194,7 @@ describe('<Search />', () => {
|
|
|
194
194
|
</DJClientContext.Provider>,
|
|
195
195
|
);
|
|
196
196
|
|
|
197
|
-
const searchInput = getByPlaceholderText('Search');
|
|
197
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
198
198
|
// Focus to trigger lazy loading
|
|
199
199
|
fireEvent.focus(searchInput);
|
|
200
200
|
|
|
@@ -226,7 +226,7 @@ describe('<Search />', () => {
|
|
|
226
226
|
</DJClientContext.Provider>,
|
|
227
227
|
);
|
|
228
228
|
|
|
229
|
-
const searchInput = getByPlaceholderText('Search');
|
|
229
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
230
230
|
// Focus to trigger lazy loading
|
|
231
231
|
fireEvent.focus(searchInput);
|
|
232
232
|
|
|
@@ -256,7 +256,7 @@ describe('<Search />', () => {
|
|
|
256
256
|
);
|
|
257
257
|
|
|
258
258
|
// Focus to trigger lazy loading
|
|
259
|
-
const searchInput = getByPlaceholderText('Search');
|
|
259
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
260
260
|
fireEvent.focus(searchInput);
|
|
261
261
|
|
|
262
262
|
await waitFor(() => {
|
|
@@ -269,7 +269,7 @@ describe('<Search />', () => {
|
|
|
269
269
|
consoleErrorSpy.mockRestore();
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
-
it('
|
|
272
|
+
it('renders search input inside nav-search-box container', async () => {
|
|
273
273
|
mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue([]);
|
|
274
274
|
mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
|
|
275
275
|
|
|
@@ -279,17 +279,9 @@ describe('<Search />', () => {
|
|
|
279
279
|
</DJClientContext.Provider>,
|
|
280
280
|
);
|
|
281
281
|
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
bubbles: true,
|
|
286
|
-
cancelable: true,
|
|
287
|
-
});
|
|
288
|
-
const preventDefaultSpy = jest.spyOn(submitEvent, 'preventDefault');
|
|
289
|
-
|
|
290
|
-
form.dispatchEvent(submitEvent);
|
|
291
|
-
|
|
292
|
-
expect(preventDefaultSpy).toHaveBeenCalled();
|
|
282
|
+
const searchBox = container.querySelector('.nav-search-box');
|
|
283
|
+
expect(searchBox).toBeInTheDocument();
|
|
284
|
+
expect(searchBox.querySelector('input')).toBeInTheDocument();
|
|
293
285
|
});
|
|
294
286
|
|
|
295
287
|
it('handles empty tags array', async () => {
|
|
@@ -302,7 +294,7 @@ describe('<Search />', () => {
|
|
|
302
294
|
</DJClientContext.Provider>,
|
|
303
295
|
);
|
|
304
296
|
|
|
305
|
-
const searchInput = getByPlaceholderText('Search');
|
|
297
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
306
298
|
// Focus to trigger lazy loading
|
|
307
299
|
fireEvent.focus(searchInput);
|
|
308
300
|
|
|
@@ -324,7 +316,7 @@ describe('<Search />', () => {
|
|
|
324
316
|
</DJClientContext.Provider>,
|
|
325
317
|
);
|
|
326
318
|
|
|
327
|
-
const searchInput = getByPlaceholderText('Search');
|
|
319
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
328
320
|
// Focus to trigger lazy loading
|
|
329
321
|
fireEvent.focus(searchInput);
|
|
330
322
|
|
|
@@ -1,17 +1,55 @@
|
|
|
1
|
-
.search-box {
|
|
1
|
+
.nav-search-box {
|
|
2
2
|
display: flex;
|
|
3
|
-
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: 4px;
|
|
5
|
+
padding: 6px 10px;
|
|
6
|
+
background: var(--planner-bg, #f8fafc);
|
|
7
|
+
border: 1px solid var(--planner-border, #e2e8f0);
|
|
8
|
+
border-radius: 6px;
|
|
9
|
+
height: 32px;
|
|
10
|
+
cursor: text;
|
|
11
|
+
transition: border-color 0.15s;
|
|
12
|
+
position: relative;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.nav-search-box:focus-within {
|
|
16
|
+
border-color: var(--accent-primary, #3b82f6);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.nav-search-box input {
|
|
20
|
+
flex: 1;
|
|
21
|
+
min-width: 160px;
|
|
22
|
+
border: none;
|
|
23
|
+
background: transparent;
|
|
24
|
+
outline: none;
|
|
25
|
+
box-shadow: none;
|
|
26
|
+
-webkit-appearance: none;
|
|
27
|
+
appearance: none;
|
|
28
|
+
font-size: 12px;
|
|
29
|
+
color: var(--planner-text, #1e293b);
|
|
30
|
+
padding: 2px 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.nav-search-box input::placeholder {
|
|
34
|
+
color: var(--planner-text-dim, #94a3b8);
|
|
4
35
|
}
|
|
5
36
|
|
|
6
37
|
.search-results {
|
|
7
38
|
position: absolute;
|
|
8
39
|
z-index: 1000;
|
|
9
40
|
width: 75%;
|
|
10
|
-
background-color:
|
|
11
|
-
border
|
|
41
|
+
background-color: var(--planner-surface, #ffffff);
|
|
42
|
+
border: 1px solid var(--planner-border, #e2e8f0);
|
|
43
|
+
border-radius: 6px;
|
|
44
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
12
45
|
}
|
|
13
46
|
|
|
14
47
|
.search-result-item {
|
|
15
|
-
text-decoration: wavy;
|
|
16
48
|
padding: 0.5rem;
|
|
49
|
+
color: var(--planner-text, #1e293b);
|
|
50
|
+
font-size: 12px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.search-result-item:hover {
|
|
54
|
+
background-color: var(--planner-surface-hover, #f1f5f9);
|
|
17
55
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const ChartIcon = ({ size = 16, ...props }) => (
|
|
2
|
+
<svg
|
|
3
|
+
width={size}
|
|
4
|
+
height={size}
|
|
5
|
+
viewBox="0 0 24 24"
|
|
6
|
+
fill="currentColor"
|
|
7
|
+
stroke="none"
|
|
8
|
+
{...props}
|
|
9
|
+
>
|
|
10
|
+
{/* Bars */}
|
|
11
|
+
<rect x="4" y="11" width="5" height="8" rx="1" />
|
|
12
|
+
<rect x="10" y="5" width="5" height="14" rx="1" />
|
|
13
|
+
<rect x="16" y="8" width="5" height="11" rx="1" />
|
|
14
|
+
{/* X axis */}
|
|
15
|
+
<line
|
|
16
|
+
x1="2"
|
|
17
|
+
y1="21"
|
|
18
|
+
x2="23"
|
|
19
|
+
y2="21"
|
|
20
|
+
stroke="currentColor"
|
|
21
|
+
strokeWidth="1.5"
|
|
22
|
+
strokeLinecap="round"
|
|
23
|
+
opacity="0.35"
|
|
24
|
+
/>
|
|
25
|
+
</svg>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export default ChartIcon;
|
|
@@ -322,6 +322,56 @@ describe('<NodeDimensionsTab />', () => {
|
|
|
322
322
|
consoleSpy.mockRestore();
|
|
323
323
|
});
|
|
324
324
|
|
|
325
|
+
it('renders cube dimension graph via dimensionDag (backend handles cube expansion)', async () => {
|
|
326
|
+
const djNode = {
|
|
327
|
+
name: 'default.repairs_cube',
|
|
328
|
+
type: 'cube',
|
|
329
|
+
parents: [],
|
|
330
|
+
dimension_links: [],
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// The backend now seeds the BFS from the cube's metric upstreams, so the
|
|
334
|
+
// frontend just calls dimensionDag normally and gets back a populated result.
|
|
335
|
+
mockDjClient.dimensionDag.mockResolvedValue({
|
|
336
|
+
inbound: [
|
|
337
|
+
{
|
|
338
|
+
name: 'default.repair_orders',
|
|
339
|
+
type: 'source',
|
|
340
|
+
display_name: 'Repair Orders',
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
inbound_edges: [
|
|
344
|
+
{ source: 'default.repair_orders', target: 'default.hard_hat' },
|
|
345
|
+
],
|
|
346
|
+
outbound: [
|
|
347
|
+
{
|
|
348
|
+
name: 'default.hard_hat',
|
|
349
|
+
type: 'dimension',
|
|
350
|
+
display_name: 'Hard Hat',
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
name: 'default.dispatcher',
|
|
354
|
+
type: 'dimension',
|
|
355
|
+
display_name: 'Dispatcher',
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
outbound_edges: [
|
|
359
|
+
{ source: 'default.repair_orders', target: 'default.hard_hat' },
|
|
360
|
+
{ source: 'default.repair_orders', target: 'default.dispatcher' },
|
|
361
|
+
],
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
renderWithContext(djNode);
|
|
365
|
+
|
|
366
|
+
await waitFor(() => {
|
|
367
|
+
expect(screen.getByTestId('reactflow')).toBeInTheDocument();
|
|
368
|
+
});
|
|
369
|
+
expect(mockDjClient.dimensionDag).toHaveBeenCalledWith(
|
|
370
|
+
'default.repairs_cube',
|
|
371
|
+
);
|
|
372
|
+
expect(screen.getAllByText('dimension').length).toBeGreaterThan(0);
|
|
373
|
+
});
|
|
374
|
+
|
|
325
375
|
it('renders multi-level inbound chain (non-flat graph)', async () => {
|
|
326
376
|
const djNode = {
|
|
327
377
|
name: 'default.us_state',
|
|
@@ -17,6 +17,7 @@ import WatchButton from './WatchNodeButton';
|
|
|
17
17
|
import NodesWithDimension from './NodesWithDimension';
|
|
18
18
|
import NodeColumnLineage from './NodeLineageTab';
|
|
19
19
|
import EditIcon from '../../icons/EditIcon';
|
|
20
|
+
import ChartIcon from '../../icons/ChartIcon';
|
|
20
21
|
import AlertIcon from '../../icons/AlertIcon';
|
|
21
22
|
import LoadingIcon from '../../icons/LoadingIcon';
|
|
22
23
|
import NodeDependenciesTab from './NodeDependenciesTab';
|
|
@@ -39,7 +40,8 @@ export function NodePage() {
|
|
|
39
40
|
const onClickTab = id => () => {
|
|
40
41
|
// Preview tab redirects to Query Planner instead of showing content
|
|
41
42
|
if (id === 'preview') {
|
|
42
|
-
|
|
43
|
+
const param = node?.type === 'cube' ? 'cube' : 'metrics';
|
|
44
|
+
navigate(`/planner?${param}=${encodeURIComponent(name)}`);
|
|
43
45
|
return;
|
|
44
46
|
}
|
|
45
47
|
navigate(`/nodes/${name}/${id}`);
|
|
@@ -52,6 +54,7 @@ export function NodePage() {
|
|
|
52
54
|
key={tab.id}
|
|
53
55
|
id={tab.id}
|
|
54
56
|
name={tab.name}
|
|
57
|
+
icon={tab.icon}
|
|
55
58
|
onClick={onClickTab(tab.id)}
|
|
56
59
|
selectedTab={state.selectedTab}
|
|
57
60
|
/>
|
|
@@ -121,7 +124,8 @@ export function NodePage() {
|
|
|
121
124
|
{
|
|
122
125
|
id: 'preview',
|
|
123
126
|
name: 'Preview →',
|
|
124
|
-
|
|
127
|
+
icon: <ChartIcon size={18} />,
|
|
128
|
+
display: node?.type === 'metric' || node?.type === 'cube',
|
|
125
129
|
},
|
|
126
130
|
];
|
|
127
131
|
};
|