datajunction-ui 0.0.103 → 0.0.104

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.103",
3
+ "version": "0.0.104",
4
4
  "description": "DataJunction UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -26,6 +26,11 @@ export default function NamespaceHeader({
26
26
  const [existingPR, setExistingPR] = useState(null);
27
27
  const [prLoading, setPrLoading] = useState(false);
28
28
 
29
+ // Branch switcher state
30
+ const [branches, setBranches] = useState([]);
31
+ const [branchDropdownOpen, setBranchDropdownOpen] = useState(false);
32
+ const branchDropdownRef = useRef(null);
33
+
29
34
  // Modal states
30
35
  const [showGitSettings, setShowGitSettings] = useState(false);
31
36
  const [showCreateBranch, setShowCreateBranch] = useState(false);
@@ -65,7 +70,7 @@ export default function NamespaceHeader({
65
70
  onGitConfigLoaded(config);
66
71
  }
67
72
 
68
- // If this is a branch namespace, fetch parent's git config and check for existing PR
73
+ // If this is a branch namespace, fetch parent's git config, branches, and check for existing PR
69
74
  if (config?.parent_namespace) {
70
75
  try {
71
76
  const parentConfig = await djClient.getNamespaceGitConfig(
@@ -76,6 +81,15 @@ export default function NamespaceHeader({
76
81
  console.error('Failed to fetch parent git config:', e);
77
82
  }
78
83
 
84
+ try {
85
+ const branchList = await djClient.getNamespaceBranches(
86
+ config.parent_namespace,
87
+ );
88
+ setBranches(branchList || []);
89
+ } catch (e) {
90
+ console.error('Failed to fetch branches:', e);
91
+ }
92
+
79
93
  // Check for existing PR
80
94
  setPrLoading(true);
81
95
  try {
@@ -102,12 +116,18 @@ export default function NamespaceHeader({
102
116
  fetchData();
103
117
  }, [djClient, namespace]);
104
118
 
105
- // Close dropdown when clicking outside
119
+ // Close dropdowns when clicking outside
106
120
  useEffect(() => {
107
121
  const handleClickOutside = event => {
108
122
  if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
109
123
  setDeploymentsDropdownOpen(false);
110
124
  }
125
+ if (
126
+ branchDropdownRef.current &&
127
+ !branchDropdownRef.current.contains(event.target)
128
+ ) {
129
+ setBranchDropdownOpen(false);
130
+ }
111
131
  };
112
132
  document.addEventListener('mousedown', handleClickOutside);
113
133
  return () => document.removeEventListener('mousedown', handleClickOutside);
@@ -243,88 +263,215 @@ export default function NamespaceHeader({
243
263
  />
244
264
  </svg>
245
265
  {namespace ? (
246
- namespaceParts.map((part, index, arr) => (
247
- <span
248
- key={index}
249
- style={{
250
- display: 'flex',
251
- alignItems: 'center',
252
- gap: '8px',
253
- }}
254
- >
255
- <a
256
- href={`/namespaces/${arr.slice(0, index + 1).join('.')}`}
257
- style={{
258
- fontWeight: '400',
259
- color: '#1e293b',
260
- textDecoration: 'none',
261
- }}
266
+ namespaceParts.map((part, index, arr) => {
267
+ const isLast = index === arr.length - 1;
268
+ const href = `/namespaces/${arr.slice(0, index + 1).join('.')}`;
269
+ return (
270
+ <span
271
+ key={index}
272
+ style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
262
273
  >
263
- {part}
264
- </a>
265
- {index < arr.length - 1 && (
266
- <svg
267
- xmlns="http://www.w3.org/2000/svg"
268
- width="12"
269
- height="12"
270
- fill="#94a3b8"
271
- viewBox="0 0 16 16"
272
- >
273
- <path
274
- fillRule="evenodd"
275
- d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"
276
- />
277
- </svg>
278
- )}
279
- </span>
280
- ))
274
+ {/* Last segment of a branch namespace becomes the branch switcher */}
275
+ {isLast && isBranchNamespace ? (
276
+ <div
277
+ ref={branchDropdownRef}
278
+ style={{ position: 'relative' }}
279
+ >
280
+ <button
281
+ onClick={() => setBranchDropdownOpen(o => !o)}
282
+ style={{
283
+ display: 'flex',
284
+ alignItems: 'center',
285
+ gap: '4px',
286
+ padding: '0',
287
+ background: 'none',
288
+ border: 'none',
289
+ fontWeight: '400',
290
+ fontSize: 'inherit',
291
+ color: '#1e293b',
292
+ cursor: 'pointer',
293
+ }}
294
+ >
295
+ <svg
296
+ xmlns="http://www.w3.org/2000/svg"
297
+ width="12"
298
+ height="12"
299
+ viewBox="0 0 24 24"
300
+ fill="none"
301
+ stroke="#64748b"
302
+ strokeWidth="2"
303
+ strokeLinecap="round"
304
+ strokeLinejoin="round"
305
+ >
306
+ <line x1="6" y1="3" x2="6" y2="15" />
307
+ <circle cx="18" cy="6" r="3" />
308
+ <circle cx="6" cy="18" r="3" />
309
+ <path d="M18 9a9 9 0 0 1-9 9" />
310
+ </svg>
311
+ {part}
312
+ <span style={{ fontSize: '8px', color: '#94a3b8' }}>
313
+ {branchDropdownOpen ? '▲' : '▼'}
314
+ </span>
315
+ </button>
316
+
317
+ {branchDropdownOpen && (
318
+ <div
319
+ style={{
320
+ position: 'absolute',
321
+ top: '100%',
322
+ left: 0,
323
+ marginTop: '4px',
324
+ backgroundColor: 'white',
325
+ border: '1px solid #e2e8f0',
326
+ borderRadius: '8px',
327
+ boxShadow: '0 4px 12px rgba(0,0,0,0.12)',
328
+ zIndex: 1000,
329
+ minWidth: '180px',
330
+ overflow: 'hidden',
331
+ }}
332
+ >
333
+ <div
334
+ style={{
335
+ padding: '8px 12px 6px',
336
+ fontSize: '10px',
337
+ fontWeight: 600,
338
+ textTransform: 'uppercase',
339
+ letterSpacing: '0.05em',
340
+ color: '#94a3b8',
341
+ borderBottom: '1px solid #f1f5f9',
342
+ }}
343
+ >
344
+ <a
345
+ href={`/namespaces/${gitConfig.parent_namespace}`}
346
+ style={{
347
+ color: '#94a3b8',
348
+ textDecoration: 'none',
349
+ }}
350
+ onClick={() => setBranchDropdownOpen(false)}
351
+ >
352
+ {gitConfig.parent_namespace}
353
+ </a>
354
+ </div>
355
+ {branches.length === 0 ? (
356
+ <div
357
+ style={{
358
+ padding: '10px 12px',
359
+ fontSize: '12px',
360
+ color: '#94a3b8',
361
+ }}
362
+ >
363
+ No branches found
364
+ </div>
365
+ ) : (
366
+ branches.map(b => {
367
+ const isCurrent = b.namespace === namespace;
368
+ return (
369
+ <a
370
+ key={b.namespace}
371
+ href={`/namespaces/${b.namespace}`}
372
+ onClick={() => setBranchDropdownOpen(false)}
373
+ style={{
374
+ display: 'flex',
375
+ alignItems: 'center',
376
+ justifyContent: 'space-between',
377
+ padding: '8px 12px',
378
+ fontSize: '13px',
379
+ color: isCurrent ? '#1e40af' : '#1e293b',
380
+ backgroundColor: isCurrent
381
+ ? '#eff6ff'
382
+ : 'white',
383
+ textDecoration: 'none',
384
+ borderBottom: '1px solid #f8fafc',
385
+ }}
386
+ >
387
+ <span
388
+ style={{
389
+ display: 'flex',
390
+ alignItems: 'center',
391
+ gap: '6px',
392
+ minWidth: 0,
393
+ }}
394
+ >
395
+ {isCurrent && (
396
+ <svg
397
+ xmlns="http://www.w3.org/2000/svg"
398
+ width="10"
399
+ height="10"
400
+ viewBox="0 0 24 24"
401
+ fill="none"
402
+ stroke="currentColor"
403
+ strokeWidth="3"
404
+ strokeLinecap="round"
405
+ strokeLinejoin="round"
406
+ style={{ flexShrink: 0 }}
407
+ >
408
+ <polyline points="20 6 9 17 4 12" />
409
+ </svg>
410
+ )}
411
+ <span
412
+ style={{
413
+ overflow: 'hidden',
414
+ textOverflow: 'ellipsis',
415
+ whiteSpace: 'nowrap',
416
+ maxWidth: '180px',
417
+ }}
418
+ title={b.git_branch || b.namespace}
419
+ >
420
+ {b.git_branch || b.namespace}
421
+ </span>
422
+ </span>
423
+ <span
424
+ style={{
425
+ fontSize: '11px',
426
+ color: '#94a3b8',
427
+ flexShrink: 0,
428
+ marginLeft: '8px',
429
+ }}
430
+ >
431
+ {b.num_nodes} nodes
432
+ </span>
433
+ </a>
434
+ );
435
+ })
436
+ )}
437
+ </div>
438
+ )}
439
+ </div>
440
+ ) : (
441
+ <a
442
+ href={href}
443
+ style={{
444
+ fontWeight: '400',
445
+ color: '#1e293b',
446
+ textDecoration: 'none',
447
+ }}
448
+ >
449
+ {part}
450
+ </a>
451
+ )}
452
+ {!isLast && (
453
+ <svg
454
+ xmlns="http://www.w3.org/2000/svg"
455
+ width="12"
456
+ height="12"
457
+ fill="#94a3b8"
458
+ viewBox="0 0 16 16"
459
+ >
460
+ <path
461
+ fillRule="evenodd"
462
+ d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"
463
+ />
464
+ </svg>
465
+ )}
466
+ </span>
467
+ );
468
+ })
281
469
  ) : (
282
470
  <span style={{ fontWeight: '600', color: '#1e293b' }}>
283
471
  All Namespaces
284
472
  </span>
285
473
  )}
286
474
 
287
- {/* Branch indicator */}
288
- {isBranchNamespace && (
289
- <span
290
- style={{
291
- display: 'flex',
292
- alignItems: 'center',
293
- gap: '4px',
294
- padding: '2px 8px',
295
- backgroundColor: '#dbeafe',
296
- borderRadius: '12px',
297
- fontSize: '11px',
298
- color: '#1e40af',
299
- marginLeft: '4px',
300
- }}
301
- >
302
- <svg
303
- xmlns="http://www.w3.org/2000/svg"
304
- width="12"
305
- height="12"
306
- viewBox="0 0 24 24"
307
- fill="none"
308
- stroke="currentColor"
309
- strokeWidth="2"
310
- strokeLinecap="round"
311
- strokeLinejoin="round"
312
- >
313
- <line x1="6" y1="3" x2="6" y2="15" />
314
- <circle cx="18" cy="6" r="3" />
315
- <circle cx="6" cy="18" r="3" />
316
- <path d="M18 9a9 9 0 0 1-9 9" />
317
- </svg>
318
- Branch of{' '}
319
- <a
320
- href={`/namespaces/${gitConfig.parent_namespace}`}
321
- style={{ color: '#1e40af', textDecoration: 'underline' }}
322
- >
323
- {gitConfig.parent_namespace}
324
- </a>
325
- </span>
326
- )}
327
-
328
475
  {/* Git-only (read-only) indicator */}
329
476
  {gitConfig?.git_only && (
330
477
  <span
@@ -33,6 +33,7 @@ export function NodeBadge({
33
33
  ...sizeStyles,
34
34
  flexShrink: 0,
35
35
  ...style,
36
+ marginRight: 0,
36
37
  }}
37
38
  >
38
39
  {displayText}
@@ -62,7 +63,7 @@ export function NodeLink({
62
63
  const sizeMap = {
63
64
  small: { fontSize: '10px', fontWeight: '500' },
64
65
  medium: { fontSize: '12px', fontWeight: '500' },
65
- large: { fontSize: '13px', fontWeight: '500' },
66
+ large: { fontSize: '14px', fontWeight: '500' },
66
67
  };
67
68
 
68
69
  const sizeStyles = sizeMap[size] || sizeMap.medium;
@@ -160,7 +161,7 @@ export function NodeChip({ node }) {
160
161
  alignItems: 'center',
161
162
  gap: '3px',
162
163
  padding: '2px 6px',
163
- fontSize: '10px',
164
+ fontSize: '12px',
164
165
  border: '1px solid var(--border-color, #ddd)',
165
166
  borderRadius: '3px',
166
167
  textDecoration: 'none',
@@ -127,7 +127,7 @@ describe('NodeComponents', () => {
127
127
  it('should apply large size styles', () => {
128
128
  render(<NodeLink node={mockNode} size="large" />);
129
129
  const link = screen.getByText('My Metric');
130
- expect(link).toHaveStyle({ fontSize: '13px', fontWeight: '500' });
130
+ expect(link).toHaveStyle({ fontSize: '14px', fontWeight: '500' });
131
131
  });
132
132
 
133
133
  it('should use medium as default when invalid size provided', () => {
@@ -253,7 +253,7 @@ describe('NodeComponents', () => {
253
253
  const { container } = render(<NodeChip node={mockNode} />);
254
254
  const link = container.querySelector('a');
255
255
  expect(link).toHaveStyle({
256
- fontSize: '10px',
256
+ fontSize: '12px',
257
257
  padding: '2px 6px',
258
258
  whiteSpace: 'nowrap',
259
259
  });
@@ -16,6 +16,10 @@ const mockDjClient = {
16
16
  listTags: jest.fn(),
17
17
  namespaceSources: jest.fn(),
18
18
  namespaceSourcesBulk: jest.fn(),
19
+ getNamespaceGitConfig: jest.fn(),
20
+ getNamespaceBranches: jest.fn(),
21
+ listDeployments: jest.fn(),
22
+ getPullRequest: jest.fn(),
19
23
  };
20
24
 
21
25
  const mockCurrentUser = { username: 'dj', email: 'dj@test.com' };
@@ -73,6 +77,10 @@ describe('NamespacePage', () => {
73
77
  mockDjClient.namespaceSourcesBulk.mockResolvedValue({
74
78
  namespace_sources: {},
75
79
  });
80
+ mockDjClient.getNamespaceGitConfig.mockResolvedValue(null);
81
+ mockDjClient.getNamespaceBranches.mockResolvedValue([]);
82
+ mockDjClient.listDeployments.mockResolvedValue([]);
83
+ mockDjClient.getPullRequest.mockResolvedValue(null);
76
84
  mockDjClient.namespaces.mockResolvedValue([
77
85
  {
78
86
  namespace: 'common.one',
@@ -626,5 +634,209 @@ describe('NamespacePage', () => {
626
634
  expect(mockDjClient.listNodesForLanding).toHaveBeenCalled();
627
635
  });
628
636
  });
637
+
638
+ it('reads hasMaterialization filter from URL', async () => {
639
+ renderWithProviders(<NamespacePage />, {
640
+ route: '/namespaces/default?hasMaterialization=true',
641
+ });
642
+ await waitFor(() => {
643
+ expect(mockDjClient.listNodesForLanding).toHaveBeenCalled();
644
+ });
645
+ });
646
+ });
647
+
648
+ describe('Git-root namespace (branch landing page)', () => {
649
+ const gitRootConfig = {
650
+ github_repo_path: 'org/repo',
651
+ git_branch: 'main',
652
+ default_branch: 'main',
653
+ parent_namespace: null,
654
+ git_only: false,
655
+ };
656
+
657
+ const mockBranches = [
658
+ {
659
+ namespace: 'default.main',
660
+ git_branch: 'main',
661
+ num_nodes: 10,
662
+ invalid_node_count: 1,
663
+ last_deployed_at: '2024-10-18T12:00:00+00:00',
664
+ },
665
+ {
666
+ namespace: 'default.feature-xyz',
667
+ git_branch: 'feature-xyz',
668
+ num_nodes: 5,
669
+ invalid_node_count: 0,
670
+ last_deployed_at: null,
671
+ },
672
+ ];
673
+
674
+ beforeEach(() => {
675
+ mockDjClient.getNamespaceGitConfig.mockResolvedValue(gitRootConfig);
676
+ mockDjClient.getNamespaceBranches.mockResolvedValue(mockBranches);
677
+ });
678
+
679
+ it('shows Branches section for a git-root namespace', async () => {
680
+ renderWithProviders(<NamespacePage />);
681
+
682
+ await waitFor(() => {
683
+ expect(screen.getByText('Branches')).toBeInTheDocument();
684
+ // cards show git_branch value; 'main' appears in both the card title
685
+ // and the default branch section header, so use getAllByText
686
+ expect(screen.getAllByText('main').length).toBeGreaterThan(0);
687
+ expect(screen.getByText('feature-xyz')).toBeInTheDocument();
688
+ });
689
+ });
690
+
691
+ it('shows branch count next to Branches header', async () => {
692
+ renderWithProviders(<NamespacePage />);
693
+
694
+ await waitFor(() => {
695
+ expect(screen.getByText('Branches')).toBeInTheDocument();
696
+ // branch count (2)
697
+ expect(screen.getByText('2')).toBeInTheDocument();
698
+ });
699
+ });
700
+
701
+ it('shows node counts and invalid counts on branch cards', async () => {
702
+ renderWithProviders(<NamespacePage />);
703
+
704
+ await waitFor(() => {
705
+ expect(screen.getByText('10 nodes')).toBeInTheDocument();
706
+ expect(screen.getByText('1 invalid')).toBeInTheDocument();
707
+ expect(screen.getByText('5 nodes')).toBeInTheDocument();
708
+ });
709
+ });
710
+
711
+ it('shows default branch section header and View all link', async () => {
712
+ renderWithProviders(<NamespacePage />);
713
+
714
+ // The default branch header (name + "default" badge) and "View all" link
715
+ // appear as soon as gitConfig.default_branch is set and isGitRoot is true
716
+ await waitFor(
717
+ () => {
718
+ expect(screen.getByText('View all →')).toBeInTheDocument();
719
+ // default branch name shown as the section title
720
+ expect(screen.getAllByText('main').length).toBeGreaterThan(0);
721
+ },
722
+ { timeout: 3000 },
723
+ );
724
+ });
725
+
726
+ it('calls listNodesForLanding for the default branch namespace', async () => {
727
+ renderWithProviders(<NamespacePage />);
728
+
729
+ await waitFor(
730
+ () => {
731
+ // After isGitRoot is set, a second listNodesForLanding call for
732
+ // 'default.main' should be made
733
+ const calls = mockDjClient.listNodesForLanding.mock.calls;
734
+ const defaultBranchCall = calls.find(
735
+ args => args[0] === 'default.main',
736
+ );
737
+ expect(defaultBranchCall).toBeDefined();
738
+ },
739
+ { timeout: 3000 },
740
+ );
741
+ });
742
+
743
+ it('shows loading state while branches are loading', async () => {
744
+ let resolveBranches;
745
+ mockDjClient.getNamespaceBranches.mockReturnValue(
746
+ new Promise(resolve => {
747
+ resolveBranches = resolve;
748
+ }),
749
+ );
750
+
751
+ renderWithProviders(<NamespacePage />);
752
+
753
+ // While loading, Branches header should still show (branchesLoading=true triggers the section)
754
+ await waitFor(() => {
755
+ expect(screen.getByText('Branches')).toBeInTheDocument();
756
+ });
757
+
758
+ // Resolve to avoid act() warnings
759
+ resolveBranches([]);
760
+ });
761
+ });
762
+
763
+ describe('Quality filter checkboxes', () => {
764
+ it('toggles orphanedDimension filter', async () => {
765
+ renderWithProviders(<NamespacePage />);
766
+
767
+ await waitFor(() => {
768
+ expect(screen.getByText('Quality')).toBeInTheDocument();
769
+ });
770
+
771
+ fireEvent.click(screen.getByText('Issues'));
772
+ await waitFor(() => {
773
+ expect(screen.getByText('Orphaned Dimensions')).toBeInTheDocument();
774
+ });
775
+
776
+ const checkbox = screen.getByLabelText('Orphaned Dimensions');
777
+ const callsBefore = mockDjClient.listNodesForLanding.mock.calls.length;
778
+ fireEvent.click(checkbox);
779
+
780
+ await waitFor(() => {
781
+ expect(
782
+ mockDjClient.listNodesForLanding.mock.calls.length,
783
+ ).toBeGreaterThan(callsBefore);
784
+ });
785
+ });
786
+
787
+ it('toggles hasMaterialization filter', async () => {
788
+ renderWithProviders(<NamespacePage />);
789
+
790
+ await waitFor(() => {
791
+ expect(screen.getByText('Quality')).toBeInTheDocument();
792
+ });
793
+
794
+ fireEvent.click(screen.getByText('Issues'));
795
+ await waitFor(() => {
796
+ expect(screen.getByText('Has Materialization')).toBeInTheDocument();
797
+ });
798
+
799
+ const checkbox = screen.getByLabelText('Has Materialization');
800
+ const callsBefore = mockDjClient.listNodesForLanding.mock.calls.length;
801
+ fireEvent.click(checkbox);
802
+
803
+ await waitFor(() => {
804
+ expect(
805
+ mockDjClient.listNodesForLanding.mock.calls.length,
806
+ ).toBeGreaterThan(callsBefore);
807
+ });
808
+ });
809
+ });
810
+
811
+ describe('formatRelativeTime', () => {
812
+ it('shows last_deployed_at timestamp on branch cards', async () => {
813
+ mockDjClient.getNamespaceGitConfig.mockResolvedValue({
814
+ github_repo_path: 'org/repo',
815
+ git_branch: 'main',
816
+ default_branch: 'main',
817
+ parent_namespace: null,
818
+ git_only: false,
819
+ });
820
+ mockDjClient.getNamespaceBranches.mockResolvedValue([
821
+ {
822
+ namespace: 'default.main',
823
+ git_branch: 'main',
824
+ num_nodes: 3,
825
+ invalid_node_count: 0,
826
+ last_deployed_at: new Date(
827
+ Date.now() - 2 * 24 * 60 * 60 * 1000,
828
+ ).toISOString(),
829
+ },
830
+ ]);
831
+
832
+ renderWithProviders(<NamespacePage />);
833
+
834
+ await waitFor(() => {
835
+ // 'main' appears in both the card title and default branch section header
836
+ expect(screen.getAllByText('main').length).toBeGreaterThan(0);
837
+ // Should show relative time like "2d ago" (may appear on multiple elements)
838
+ expect(screen.getAllByText(/ago/).length).toBeGreaterThan(0);
839
+ });
840
+ });
629
841
  });
630
842
  });