datajunction-ui
Version:
DataJunction UI
196 lines (185 loc) • 5.97 kB
JSX
import React, { useState } from 'react';
import ChevronIcon from '../../icons/ChevronIcon';
import {
buildNamespaceOptions,
searchNamespaces,
immediateChildren,
} from './namespaceOptions';
import { getPinned, togglePinned } from './namespaceShortcuts';
import FolderTree from './FolderTree';
import NewSubNamespace from './NewSubNamespace';
// Git context for a selected namespace, derived from the raw namespace list.
function gitContext(gitByNs, ns) {
const g = gitByNs[ns];
if (!g) return null;
if (g.__typename === 'GitRootConfig') {
return {
root: ns,
defaultBranch: g.defaultBranch,
activeBranch: g.defaultBranch,
isRoot: true,
};
}
if (g.__typename === 'GitBranchConfig') {
return {
root: g.parentNamespace,
defaultBranch: g.root?.defaultBranch,
activeBranch: g.branch,
isRoot: false,
};
}
return null;
}
export default function NamespaceNav({
namespaces,
hierarchy,
currentNamespace,
gitRoots,
onSelect,
canCreateNamespace = false,
onCreateNamespace,
}) {
const [collapsed, setCollapsed] = useState({ 'Top-level': true });
const [pinned, setPinned] = useState(() => getPinned());
// Filter only exists in the no-namespace ("All namespaces") view, where there's
// no current scope to confuse it with. Once a namespace is selected, jumping
// elsewhere is done via the header's namespace switcher.
const [filter, setFilter] = useState('');
const isFiltering = filter.trim() !== '';
const matches = isFiltering ? searchNamespaces(namespaces || [], filter) : [];
const showList = !currentNamespace;
const groups = showList ? buildNamespaceOptions(namespaces || []) : [];
const gitByNs = {};
for (const ns of namespaces || []) {
gitByNs[ns.namespace] = ns.git;
}
const pinnedSet = new Set(pinned);
const isOpen = label => !collapsed[label];
const toggleGroup = label =>
setCollapsed(c => ({ ...c, [label]: !c[label] }));
const onPin = (e, ns) => {
e.stopPropagation();
setPinned(togglePinned(ns));
};
const renderRow = (ns, keyPrefix) => (
<div
key={`${keyPrefix}-${ns}`}
className="dj-ns-nav-item"
role="button"
tabIndex={0}
title={ns}
onClick={() => onSelect(ns)}
onKeyDown={e => {
if (e.key === 'Enter') onSelect(ns);
}}
>
<span className="dj-ns-nav-name">{ns}</span>
<button
type="button"
className={`dj-ns-star${pinnedSet.has(ns) ? ' pinned' : ''}`}
aria-label={`${pinnedSet.has(ns) ? 'Unpin' : 'Pin'} ${ns}`}
aria-pressed={pinnedSet.has(ns)}
onClick={e => onPin(e, ns)}
>
{pinnedSet.has(ns) ? '★' : '☆'}
</button>
</div>
);
// One uniform collapsible section used for Pinned and the git groups.
const renderGroup = (label, names, keyPrefix) => {
const open = isOpen(label);
return (
<div key={keyPrefix}>
<button
type="button"
className="dj-ns-group-heading"
aria-expanded={open}
onClick={() => toggleGroup(label)}
>
<span className="dj-ns-chevron">
<ChevronIcon open={open} />
</span>
<span className="dj-ns-group-label">{label}</span>
<span className="dj-ns-group-count">{names.length}</span>
</button>
{open ? names.map(ns => renderRow(ns, keyPrefix)) : null}
</div>
);
};
// Selected (subtree) state — ctx is still needed for subtreePath (git-root folders).
const ctx = currentNamespace ? gitContext(gitByNs, currentNamespace) : null;
const subtreePath =
ctx?.isRoot && ctx.defaultBranch
? `${ctx.root}.${ctx.defaultBranch}`
: currentNamespace;
return (
<div>
{showList ? (
<>
<div className="dj-ns-filter">
<input
type="text"
className="dj-ns-filter-input"
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Filter namespaces…"
aria-label="Filter namespaces"
/>
{isFiltering ? (
<button
type="button"
className="dj-ns-filter-clear"
aria-label="Clear filter"
title="Clear filter"
onClick={() => setFilter('')}
>
✕
</button>
) : null}
</div>
{isFiltering ? (
matches.length === 0 ? (
<div
style={{ padding: '6px', color: '#64748b', fontSize: '12px' }}
>
No namespaces match "{filter.trim()}".
</div>
) : (
renderGroup('Matches', matches, 'matches')
)
) : (
<>
{pinned.length > 0 ? renderGroup('Pinned', pinned, 'pin') : null}
{groups.map(group =>
renderGroup(
group.label,
group.options.map(o => o.value),
`grp-${group.label}`,
),
)}
</>
)}
</>
) : (
// Folder navigation: the current namespace's sub-namespace tree. Chevron
// expands a level in place; clicking a name drills in. Going up is handled
// by the header breadcrumb; switching namespace by the filter box
// (no-namespace view) or the header switcher. Keyed by the current path so
// expansion resets when the tree re-roots.
<>
<FolderTree
key={subtreePath}
folders={immediateChildren(hierarchy || [], subtreePath)}
onSelect={onSelect}
/>
{canCreateNamespace ? (
<NewSubNamespace
parent={subtreePath}
onCreate={onCreateNamespace}
/>
) : null}
</>
)}
</div>
);
}