Skip to content

Commit 7c970db

Browse files
authored
Add branch landing page and switcher for repo-backed namespaces (#1917)
* Add better namespace ui for repo root * Add better namespace ui for repo root * Fix * Fix
1 parent b839383 commit 7c970db

File tree

6 files changed

+1407
-402
lines changed

6 files changed

+1407
-402
lines changed

datajunction-ui/src/app/components/NamespaceHeader.jsx

Lines changed: 224 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export default function NamespaceHeader({
2626
const [existingPR, setExistingPR] = useState(null);
2727
const [prLoading, setPrLoading] = useState(false);
2828

29+
// Branch switcher state
30+
const [branches, setBranches] = useState([]);
31+
const [branchDropdownOpen, setBranchDropdownOpen] = useState(false);
32+
const branchDropdownRef = useRef(null);
33+
2934
// Modal states
3035
const [showGitSettings, setShowGitSettings] = useState(false);
3136
const [showCreateBranch, setShowCreateBranch] = useState(false);
@@ -65,7 +70,7 @@ export default function NamespaceHeader({
6570
onGitConfigLoaded(config);
6671
}
6772

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
6974
if (config?.parent_namespace) {
7075
try {
7176
const parentConfig = await djClient.getNamespaceGitConfig(
@@ -76,6 +81,15 @@ export default function NamespaceHeader({
7681
console.error('Failed to fetch parent git config:', e);
7782
}
7883

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+
7993
// Check for existing PR
8094
setPrLoading(true);
8195
try {
@@ -102,12 +116,18 @@ export default function NamespaceHeader({
102116
fetchData();
103117
}, [djClient, namespace]);
104118

105-
// Close dropdown when clicking outside
119+
// Close dropdowns when clicking outside
106120
useEffect(() => {
107121
const handleClickOutside = event => {
108122
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
109123
setDeploymentsDropdownOpen(false);
110124
}
125+
if (
126+
branchDropdownRef.current &&
127+
!branchDropdownRef.current.contains(event.target)
128+
) {
129+
setBranchDropdownOpen(false);
130+
}
111131
};
112132
document.addEventListener('mousedown', handleClickOutside);
113133
return () => document.removeEventListener('mousedown', handleClickOutside);
@@ -243,88 +263,215 @@ export default function NamespaceHeader({
243263
/>
244264
</svg>
245265
{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' }}
262273
>
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+
})
281469
) : (
282470
<span style={{ fontWeight: '600', color: '#1e293b' }}>
283471
All Namespaces
284472
</span>
285473
)}
286474

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-
328475
{/* Git-only (read-only) indicator */}
329476
{gitConfig?.git_only && (
330477
<span

datajunction-ui/src/app/components/NodeComponents.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export function NodeBadge({
3333
...sizeStyles,
3434
flexShrink: 0,
3535
...style,
36+
marginRight: 0,
3637
}}
3738
>
3839
{displayText}
@@ -62,7 +63,7 @@ export function NodeLink({
6263
const sizeMap = {
6364
small: { fontSize: '10px', fontWeight: '500' },
6465
medium: { fontSize: '12px', fontWeight: '500' },
65-
large: { fontSize: '13px', fontWeight: '500' },
66+
large: { fontSize: '14px', fontWeight: '500' },
6667
};
6768

6869
const sizeStyles = sizeMap[size] || sizeMap.medium;
@@ -160,7 +161,7 @@ export function NodeChip({ node }) {
160161
alignItems: 'center',
161162
gap: '3px',
162163
padding: '2px 6px',
163-
fontSize: '10px',
164+
fontSize: '12px',
164165
border: '1px solid var(--border-color, #ddd)',
165166
borderRadius: '3px',
166167
textDecoration: 'none',

datajunction-ui/src/app/components/__tests__/NodeComponents.test.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ describe('NodeComponents', () => {
127127
it('should apply large size styles', () => {
128128
render(<NodeLink node={mockNode} size="large" />);
129129
const link = screen.getByText('My Metric');
130-
expect(link).toHaveStyle({ fontSize: '13px', fontWeight: '500' });
130+
expect(link).toHaveStyle({ fontSize: '14px', fontWeight: '500' });
131131
});
132132

133133
it('should use medium as default when invalid size provided', () => {
@@ -253,7 +253,7 @@ describe('NodeComponents', () => {
253253
const { container } = render(<NodeChip node={mockNode} />);
254254
const link = container.querySelector('a');
255255
expect(link).toHaveStyle({
256-
fontSize: '10px',
256+
fontSize: '12px',
257257
padding: '2px 6px',
258258
whiteSpace: 'nowrap',
259259
});

0 commit comments

Comments
 (0)