datajunction-ui 0.0.23-rc.0 → 0.0.26

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.
Files changed (25) hide show
  1. package/package.json +8 -2
  2. package/src/app/index.tsx +6 -0
  3. package/src/app/pages/NamespacePage/CompactSelect.jsx +100 -0
  4. package/src/app/pages/NamespacePage/NodeModeSelect.jsx +8 -5
  5. package/src/app/pages/NamespacePage/__tests__/CompactSelect.test.jsx +190 -0
  6. package/src/app/pages/NamespacePage/__tests__/index.test.jsx +297 -8
  7. package/src/app/pages/NamespacePage/index.jsx +489 -62
  8. package/src/app/pages/QueryPlannerPage/Loadable.jsx +6 -0
  9. package/src/app/pages/QueryPlannerPage/MetricFlowGraph.jsx +311 -0
  10. package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +470 -0
  11. package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +384 -0
  12. package/src/app/pages/QueryPlannerPage/__tests__/MetricFlowGraph.test.jsx +239 -0
  13. package/src/app/pages/QueryPlannerPage/__tests__/PreAggDetailsPanel.test.jsx +638 -0
  14. package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +429 -0
  15. package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +317 -0
  16. package/src/app/pages/QueryPlannerPage/index.jsx +209 -0
  17. package/src/app/pages/QueryPlannerPage/styles.css +1251 -0
  18. package/src/app/pages/Root/index.tsx +5 -0
  19. package/src/app/services/DJService.js +61 -2
  20. package/src/styles/index.css +2 -2
  21. package/src/app/icons/FilterIcon.jsx +0 -7
  22. package/src/app/pages/NamespacePage/FieldControl.jsx +0 -21
  23. package/src/app/pages/NamespacePage/NodeTypeSelect.jsx +0 -30
  24. package/src/app/pages/NamespacePage/TagSelect.jsx +0 -44
  25. package/src/app/pages/NamespacePage/UserSelect.jsx +0 -47
@@ -1,19 +1,14 @@
1
1
  import * as React from 'react';
2
- import { useParams } from 'react-router-dom';
3
- import { useContext, useEffect, useState } from 'react';
2
+ import { useParams, useSearchParams } from 'react-router-dom';
3
+ import { useContext, useEffect, useState, useCallback } from 'react';
4
4
  import NodeStatus from '../NodePage/NodeStatus';
5
5
  import DJClientContext from '../../providers/djclient';
6
6
  import { useCurrentUser } from '../../providers/UserProvider';
7
7
  import Explorer from '../NamespacePage/Explorer';
8
8
  import AddNodeDropdown from '../../components/AddNodeDropdown';
9
9
  import NodeListActions from '../../components/NodeListActions';
10
- import AddNamespacePopover from './AddNamespacePopover';
11
- import FilterIcon from '../../icons/FilterIcon';
12
10
  import LoadingIcon from '../../icons/LoadingIcon';
13
- import UserSelect from './UserSelect';
14
- import NodeTypeSelect from './NodeTypeSelect';
15
- import NodeModeSelect from './NodeModeSelect';
16
- import TagSelect from './TagSelect';
11
+ import CompactSelect from './CompactSelect';
17
12
 
18
13
  import 'styles/node-list.css';
19
14
  import 'styles/sorted-table.css';
@@ -27,6 +22,142 @@ export function NamespacePage() {
27
22
  const djClient = useContext(DJClientContext).DataJunctionAPI;
28
23
  const { currentUser } = useCurrentUser();
29
24
  var { namespace } = useParams();
25
+ const [searchParams, setSearchParams] = useSearchParams();
26
+
27
+ // Data for select options
28
+ const [users, setUsers] = useState([]);
29
+ const [tags, setTags] = useState([]);
30
+ const [usersLoading, setUsersLoading] = useState(true);
31
+ const [tagsLoading, setTagsLoading] = useState(true);
32
+
33
+ // Load users and tags for dropdowns
34
+ useEffect(() => {
35
+ const fetchUsers = async () => {
36
+ const data = await djClient.users();
37
+ setUsers(data || []);
38
+ setUsersLoading(false);
39
+ };
40
+ const fetchTags = async () => {
41
+ const data = await djClient.listTags();
42
+ setTags(data || []);
43
+ setTagsLoading(false);
44
+ };
45
+ fetchUsers().catch(console.error);
46
+ fetchTags().catch(console.error);
47
+ }, [djClient]);
48
+
49
+ // Parse all filters from URL
50
+ const getFiltersFromUrl = useCallback(
51
+ () => ({
52
+ node_type: searchParams.get('type') || '',
53
+ tags: searchParams.get('tags') ? searchParams.get('tags').split(',') : [],
54
+ edited_by: searchParams.get('editedBy') || '',
55
+ mode: searchParams.get('mode') || '',
56
+ ownedBy: searchParams.get('ownedBy') || '',
57
+ statuses: searchParams.get('statuses') || '',
58
+ missingDescription: searchParams.get('missingDescription') === 'true',
59
+ hasMaterialization: searchParams.get('hasMaterialization') === 'true',
60
+ orphanedDimension: searchParams.get('orphanedDimension') === 'true',
61
+ }),
62
+ [searchParams],
63
+ );
64
+
65
+ const [filters, setFilters] = useState(getFiltersFromUrl);
66
+ const [moreFiltersOpen, setMoreFiltersOpen] = useState(false);
67
+
68
+ // Sync filters state when URL changes
69
+ useEffect(() => {
70
+ setFilters(getFiltersFromUrl());
71
+ }, [searchParams, getFiltersFromUrl]);
72
+
73
+ // Update URL when filters change
74
+ const updateFilters = useCallback(
75
+ newFilters => {
76
+ const params = new URLSearchParams();
77
+
78
+ if (newFilters.node_type) params.set('type', newFilters.node_type);
79
+ if (newFilters.tags?.length)
80
+ params.set('tags', newFilters.tags.join(','));
81
+ if (newFilters.edited_by) params.set('editedBy', newFilters.edited_by);
82
+ if (newFilters.mode) params.set('mode', newFilters.mode);
83
+ if (newFilters.ownedBy) params.set('ownedBy', newFilters.ownedBy);
84
+ if (newFilters.statuses) params.set('statuses', newFilters.statuses);
85
+ if (newFilters.missingDescription)
86
+ params.set('missingDescription', 'true');
87
+ if (newFilters.hasMaterialization)
88
+ params.set('hasMaterialization', 'true');
89
+ if (newFilters.orphanedDimension) params.set('orphanedDimension', 'true');
90
+
91
+ setSearchParams(params);
92
+ },
93
+ [setSearchParams],
94
+ );
95
+
96
+ const clearAllFilters = () => {
97
+ setSearchParams(new URLSearchParams());
98
+ };
99
+
100
+ // Check if any filters are active
101
+ const hasActiveFilters =
102
+ filters.node_type ||
103
+ filters.tags?.length ||
104
+ filters.edited_by ||
105
+ filters.mode ||
106
+ filters.ownedBy ||
107
+ filters.statuses ||
108
+ filters.missingDescription ||
109
+ filters.hasMaterialization ||
110
+ filters.orphanedDimension;
111
+
112
+ // Quick presets
113
+ const presets = [
114
+ {
115
+ id: 'my-nodes',
116
+ label: 'My Nodes',
117
+ filters: { ownedBy: currentUser?.username },
118
+ },
119
+ {
120
+ id: 'needs-attention',
121
+ label: 'Needs Attention',
122
+ filters: { ownedBy: currentUser?.username, statuses: 'INVALID' },
123
+ },
124
+ {
125
+ id: 'drafts',
126
+ label: 'Drafts',
127
+ filters: { ownedBy: currentUser?.username, mode: 'draft' },
128
+ },
129
+ ];
130
+
131
+ const applyPreset = preset => {
132
+ const newFilters = {
133
+ node_type: '',
134
+ tags: [],
135
+ edited_by: '',
136
+ mode: preset.filters.mode || '',
137
+ ownedBy: preset.filters.ownedBy || '',
138
+ statuses: preset.filters.statuses || '',
139
+ missingDescription: preset.filters.missingDescription || false,
140
+ hasMaterialization: preset.filters.hasMaterialization || false,
141
+ orphanedDimension: preset.filters.orphanedDimension || false,
142
+ };
143
+ updateFilters(newFilters);
144
+ };
145
+
146
+ // Check if a preset is active
147
+ const isPresetActive = preset => {
148
+ const pf = preset.filters;
149
+ return (
150
+ (pf.ownedBy || '') === (filters.ownedBy || '') &&
151
+ (pf.statuses || '') === (filters.statuses || '') &&
152
+ (pf.mode || '') === (filters.mode || '') &&
153
+ !filters.node_type &&
154
+ !filters.tags?.length &&
155
+ !filters.edited_by &&
156
+ !filters.missingDescription &&
157
+ !filters.hasMaterialization &&
158
+ !filters.orphanedDimension
159
+ );
160
+ };
30
161
 
31
162
  const [state, setState] = useState({
32
163
  namespace: namespace ? namespace : '',
@@ -34,13 +165,6 @@ export function NamespacePage() {
34
165
  });
35
166
  const [retrieved, setRetrieved] = useState(false);
36
167
 
37
- const [filters, setFilters] = useState({
38
- tags: [],
39
- node_type: '',
40
- edited_by: '',
41
- mode: '',
42
- });
43
-
44
168
  const [namespaceHierarchy, setNamespaceHierarchy] = useState([]);
45
169
 
46
170
  const [sortConfig, setSortConfig] = useState({
@@ -113,6 +237,16 @@ export function NamespacePage() {
113
237
  useEffect(() => {
114
238
  const fetchData = async () => {
115
239
  setRetrieved(false);
240
+
241
+ // Build extended filters for API
242
+ const extendedFilters = {
243
+ ownedBy: filters.ownedBy || null,
244
+ statuses: filters.statuses ? [filters.statuses] : null,
245
+ missingDescription: filters.missingDescription,
246
+ hasMaterialization: filters.hasMaterialization,
247
+ orphanedDimension: filters.orphanedDimension,
248
+ };
249
+
116
250
  const nodes = await djClient.listNodesForLanding(
117
251
  namespace,
118
252
  filters.node_type ? [filters.node_type.toUpperCase()] : [],
@@ -123,6 +257,7 @@ export function NamespacePage() {
123
257
  50,
124
258
  sortConfig,
125
259
  filters.mode ? filters.mode.toUpperCase() : null,
260
+ extendedFilters,
126
261
  );
127
262
 
128
263
  setState({
@@ -152,7 +287,15 @@ export function NamespacePage() {
152
287
  setRetrieved(true);
153
288
  };
154
289
  fetchData().catch(console.error);
155
- }, [djClient, filters, before, after, sortConfig.key, sortConfig.direction]);
290
+ }, [
291
+ djClient,
292
+ filters,
293
+ before,
294
+ after,
295
+ sortConfig.key,
296
+ sortConfig.direction,
297
+ namespace,
298
+ ]);
156
299
 
157
300
  const loadNext = () => {
158
301
  if (nextCursor) {
@@ -167,6 +310,31 @@ export function NamespacePage() {
167
310
  }
168
311
  };
169
312
 
313
+ // Select options
314
+ const typeOptions = [
315
+ { value: 'source', label: 'Source' },
316
+ { value: 'transform', label: 'Transform' },
317
+ { value: 'dimension', label: 'Dimension' },
318
+ { value: 'metric', label: 'Metric' },
319
+ { value: 'cube', label: 'Cube' },
320
+ ];
321
+
322
+ const modeOptions = [
323
+ { value: 'published', label: 'Published' },
324
+ { value: 'draft', label: 'Draft' },
325
+ ];
326
+
327
+ const statusOptions = [
328
+ { value: 'VALID', label: 'Valid' },
329
+ { value: 'INVALID', label: 'Invalid' },
330
+ ];
331
+
332
+ const userOptions = users.map(u => ({
333
+ value: u.username,
334
+ label: u.username,
335
+ }));
336
+ const tagOptions = tags.map(t => ({ value: t.name, label: t.display_name }));
337
+
170
338
  const nodesList = retrieved ? (
171
339
  state.nodes.length > 0 ? (
172
340
  state.nodes.map(node => (
@@ -234,7 +402,7 @@ export function NamespacePage() {
234
402
  ))
235
403
  ) : (
236
404
  <tr>
237
- <td>
405
+ <td colSpan={7}>
238
406
  <span
239
407
  style={{
240
408
  display: 'block',
@@ -243,9 +411,19 @@ export function NamespacePage() {
243
411
  fontSize: '16px',
244
412
  }}
245
413
  >
246
- There are no nodes in{' '}
247
- <a href={`/namespaces/${namespace}`}>{namespace}</a> with the above
248
- filters!
414
+ No nodes found with the current filters.
415
+ {hasActiveFilters && (
416
+ <a
417
+ href="#"
418
+ onClick={e => {
419
+ e.preventDefault();
420
+ clearAllFilters();
421
+ }}
422
+ style={{ marginLeft: '0.5rem' }}
423
+ >
424
+ Clear filters
425
+ </a>
426
+ )}
249
427
  </span>
250
428
  </td>
251
429
  </tr>
@@ -260,63 +438,312 @@ export function NamespacePage() {
260
438
  </tr>
261
439
  );
262
440
 
441
+ // Count active quality filters (the ones in the "More" dropdown)
442
+ const moreFiltersCount = [
443
+ filters.missingDescription,
444
+ filters.hasMaterialization,
445
+ filters.orphanedDimension,
446
+ ].filter(Boolean).length;
447
+
263
448
  return (
264
449
  <div className="mid">
265
450
  <div className="card">
266
451
  <div className="card-header">
267
- <h2>Explore</h2>
268
- <div className="menu" style={{ margin: '0 0 20px 0' }}>
452
+ <div
453
+ style={{
454
+ display: 'flex',
455
+ justifyContent: 'space-between',
456
+ alignItems: 'center',
457
+ marginBottom: '1rem',
458
+ }}
459
+ >
460
+ <h2 style={{ margin: 0 }}>Explore</h2>
461
+ <AddNodeDropdown namespace={namespace} />
462
+ </div>
463
+
464
+ {/* Unified Filter Bar */}
465
+ <div
466
+ style={{
467
+ marginBottom: '1rem',
468
+ padding: '1rem',
469
+ backgroundColor: '#f8f9fa',
470
+ borderRadius: '8px',
471
+ }}
472
+ >
473
+ {/* Top row: Quick presets + Clear all */}
269
474
  <div
270
- className="menu-link"
271
475
  style={{
272
- marginTop: '0.7em',
273
- color: '#777',
274
- fontFamily: "'Jost'",
275
- fontSize: '18px',
276
- marginRight: '10px',
277
- marginLeft: '15px',
476
+ display: 'flex',
477
+ alignItems: 'center',
478
+ gap: '12px',
479
+ marginBottom: '12px',
278
480
  }}
279
481
  >
280
- <FilterIcon />
482
+ <div
483
+ style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
484
+ >
485
+ <span style={{ fontSize: '12px', color: '#555' }}>Quick:</span>
486
+ {presets.map(preset => (
487
+ <button
488
+ key={preset.id}
489
+ onClick={() => applyPreset(preset)}
490
+ style={{
491
+ padding: '4px 10px',
492
+ fontSize: '11px',
493
+ border: '1px solid',
494
+ borderColor: isPresetActive(preset) ? '#1976d2' : '#ddd',
495
+ borderRadius: '12px',
496
+ backgroundColor: isPresetActive(preset)
497
+ ? '#e3f2fd'
498
+ : 'white',
499
+ color: isPresetActive(preset) ? '#1976d2' : '#666',
500
+ cursor: 'pointer',
501
+ fontWeight: isPresetActive(preset) ? '600' : '400',
502
+ }}
503
+ >
504
+ {preset.label}
505
+ </button>
506
+ ))}
507
+ {hasActiveFilters && (
508
+ <button
509
+ onClick={clearAllFilters}
510
+ style={{
511
+ padding: '4px 10px',
512
+ fontSize: '11px',
513
+ border: 'none',
514
+ backgroundColor: 'transparent',
515
+ color: '#dc3545',
516
+ cursor: 'pointer',
517
+ }}
518
+ >
519
+ Clear all ×
520
+ </button>
521
+ )}
522
+ </div>
281
523
  </div>
524
+
525
+ {/* Bottom row: Dropdowns */}
282
526
  <div
283
- className="menu-link"
284
527
  style={{
285
- marginTop: '0.6em',
286
- color: '#777',
287
- fontFamily: "'Jost'",
288
- fontSize: '18px',
289
- marginRight: '10px',
528
+ display: 'flex',
529
+ alignItems: 'flex-end',
530
+ gap: '12px',
290
531
  }}
291
532
  >
292
- Filter
533
+ <CompactSelect
534
+ label="Type"
535
+ name="type"
536
+ options={typeOptions}
537
+ value={filters.node_type}
538
+ onChange={e =>
539
+ updateFilters({ ...filters, node_type: e?.value || '' })
540
+ }
541
+ flex={1}
542
+ minWidth="80px"
543
+ testId="select-node-type"
544
+ />
545
+ <CompactSelect
546
+ label="Tags"
547
+ name="tags"
548
+ options={tagOptions}
549
+ value={filters.tags}
550
+ onChange={e =>
551
+ updateFilters({
552
+ ...filters,
553
+ tags: e ? e.map(t => t.value) : [],
554
+ })
555
+ }
556
+ isMulti
557
+ isLoading={tagsLoading}
558
+ flex={1.5}
559
+ minWidth="100px"
560
+ testId="select-tag"
561
+ />
562
+ <CompactSelect
563
+ label="Edited By"
564
+ name="editedBy"
565
+ options={userOptions}
566
+ value={filters.edited_by}
567
+ onChange={e =>
568
+ updateFilters({ ...filters, edited_by: e?.value || '' })
569
+ }
570
+ isLoading={usersLoading}
571
+ flex={1}
572
+ minWidth="80px"
573
+ testId="select-user"
574
+ />
575
+ <CompactSelect
576
+ label="Mode"
577
+ name="mode"
578
+ options={modeOptions}
579
+ value={filters.mode}
580
+ onChange={e =>
581
+ updateFilters({ ...filters, mode: e?.value || '' })
582
+ }
583
+ flex={1}
584
+ minWidth="80px"
585
+ />
586
+ <CompactSelect
587
+ label="Owner"
588
+ name="owner"
589
+ options={userOptions}
590
+ value={filters.ownedBy}
591
+ onChange={e =>
592
+ updateFilters({ ...filters, ownedBy: e?.value || '' })
593
+ }
594
+ isLoading={usersLoading}
595
+ flex={1}
596
+ minWidth="80px"
597
+ />
598
+ <CompactSelect
599
+ label="Status"
600
+ name="status"
601
+ options={statusOptions}
602
+ value={filters.statuses}
603
+ onChange={e =>
604
+ updateFilters({ ...filters, statuses: e?.value || '' })
605
+ }
606
+ flex={1}
607
+ minWidth="80px"
608
+ />
609
+
610
+ {/* More Filters (Quality) */}
611
+ <div style={{ position: 'relative', flex: 0, minWidth: 'auto' }}>
612
+ <div
613
+ style={{
614
+ display: 'flex',
615
+ flexDirection: 'column',
616
+ gap: '2px',
617
+ }}
618
+ >
619
+ <label
620
+ style={{
621
+ fontSize: '10px',
622
+ fontWeight: '600',
623
+ color: '#666',
624
+ textTransform: 'uppercase',
625
+ letterSpacing: '0.5px',
626
+ }}
627
+ >
628
+ Quality
629
+ </label>
630
+ <button
631
+ onClick={() => setMoreFiltersOpen(!moreFiltersOpen)}
632
+ style={{
633
+ height: '32px',
634
+ padding: '0 12px',
635
+ fontSize: '12px',
636
+ border: '1px solid #ccc',
637
+ borderRadius: '4px',
638
+ backgroundColor:
639
+ moreFiltersCount > 0 ? '#e3f2fd' : 'white',
640
+ color: '#666',
641
+ cursor: 'pointer',
642
+ display: 'flex',
643
+ alignItems: 'center',
644
+ gap: '4px',
645
+ whiteSpace: 'nowrap',
646
+ }}
647
+ >
648
+ {moreFiltersCount > 0
649
+ ? `${moreFiltersCount} active`
650
+ : 'Issues'}
651
+ <span style={{ fontSize: '8px' }}>
652
+ {moreFiltersOpen ? '▲' : '▼'}
653
+ </span>
654
+ </button>
655
+ </div>
656
+
657
+ {moreFiltersOpen && (
658
+ <div
659
+ style={{
660
+ position: 'absolute',
661
+ top: '100%',
662
+ right: 0,
663
+ marginTop: '4px',
664
+ padding: '12px',
665
+ backgroundColor: 'white',
666
+ border: '1px solid #ddd',
667
+ borderRadius: '8px',
668
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
669
+ zIndex: 1000,
670
+ minWidth: '200px',
671
+ }}
672
+ >
673
+ <label
674
+ style={{
675
+ display: 'flex',
676
+ alignItems: 'center',
677
+ gap: '8px',
678
+ fontSize: '12px',
679
+ color: '#444',
680
+ marginBottom: '8px',
681
+ cursor: 'pointer',
682
+ }}
683
+ >
684
+ <input
685
+ type="checkbox"
686
+ checked={filters.missingDescription}
687
+ onChange={e =>
688
+ updateFilters({
689
+ ...filters,
690
+ missingDescription: e.target.checked,
691
+ })
692
+ }
693
+ />
694
+ Missing Description
695
+ </label>
696
+ <label
697
+ style={{
698
+ display: 'flex',
699
+ alignItems: 'center',
700
+ gap: '8px',
701
+ fontSize: '12px',
702
+ color: '#444',
703
+ marginBottom: '8px',
704
+ cursor: 'pointer',
705
+ }}
706
+ >
707
+ <input
708
+ type="checkbox"
709
+ checked={filters.orphanedDimension}
710
+ onChange={e =>
711
+ updateFilters({
712
+ ...filters,
713
+ orphanedDimension: e.target.checked,
714
+ })
715
+ }
716
+ />
717
+ Orphaned Dimensions
718
+ </label>
719
+ <label
720
+ style={{
721
+ display: 'flex',
722
+ alignItems: 'center',
723
+ gap: '8px',
724
+ fontSize: '12px',
725
+ color: '#444',
726
+ cursor: 'pointer',
727
+ }}
728
+ >
729
+ <input
730
+ type="checkbox"
731
+ checked={filters.hasMaterialization}
732
+ onChange={e =>
733
+ updateFilters({
734
+ ...filters,
735
+ hasMaterialization: e.target.checked,
736
+ })
737
+ }
738
+ />
739
+ Has Materialization
740
+ </label>
741
+ </div>
742
+ )}
743
+ </div>
293
744
  </div>
294
- <NodeTypeSelect
295
- onChange={entry =>
296
- setFilters({ ...filters, node_type: entry ? entry.value : '' })
297
- }
298
- />
299
- <TagSelect
300
- onChange={entry =>
301
- setFilters({
302
- ...filters,
303
- tags: entry ? entry.map(tag => tag.value) : [],
304
- })
305
- }
306
- />
307
- <UserSelect
308
- onChange={entry =>
309
- setFilters({ ...filters, edited_by: entry ? entry.value : '' })
310
- }
311
- currentUser={currentUser?.username}
312
- />
313
- <NodeModeSelect
314
- onChange={entry =>
315
- setFilters({ ...filters, mode: entry ? entry.value : '' })
316
- }
317
- />
318
- <AddNodeDropdown namespace={namespace} />
319
745
  </div>
746
+
320
747
  <div className="table-responsive">
321
748
  <div className={`sidebar`}>
322
749
  <div
@@ -0,0 +1,6 @@
1
+ import { lazyLoad } from 'utils/loadable';
2
+
3
+ export const QueryPlannerPage = lazyLoad(
4
+ () => import('./index'),
5
+ module => module.QueryPlannerPage,
6
+ );