datajunction-ui
Version:
DataJunction UI
1,425 lines (1,356 loc) • 51.2 kB
JSX
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import {
useContext,
useEffect,
useState,
useCallback,
useRef,
useMemo,
} from 'react';
import DJClientContext from '../../providers/djclient';
import { useCurrentUser } from '../../providers/UserProvider';
import AddNodeDropdown from '../../components/AddNodeDropdown';
import NodeListActions from '../../components/NodeListActions';
import NamespaceHeader from '../../components/NamespaceHeader';
import SplitFilter from '../../components/SplitFilter';
import Tooltip from '../../components/Tooltip';
import {
secondaryButtonStyle,
onSecondaryHover,
onSecondaryOut,
} from '../../components/buttonStyles';
import LoadingIcon from '../../icons/LoadingIcon';
import CompactSelect from './CompactSelect';
import NamespaceNav from './NamespaceNav';
import { isHiddenNamespace } from './namespaceOptions';
import { NODE_TYPE_ORDER, NODE_TYPE_COLORS } from './nodeTypes';
import { getDJUrl } from '../../services/DJService';
import 'styles/node-list.css';
import 'styles/sorted-table.css';
const AVATAR_COLORS = [
['#dbeafe', '#1e40af'],
['#dcfce7', '#166534'],
['#fef3c7', '#92400e'],
['#fce7f3', '#9d174d'],
['#ede9fe', '#5b21b6'],
['#ffedd5', '#9a3412'],
['#fee2e2', '#991b1b'],
['#d1fae5', '#065f46'],
];
function avatarColorIndex(username) {
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = (hash * 31 + username.charCodeAt(i)) >>> 0;
}
return hash % AVATAR_COLORS.length;
}
const FIELD_CONFIG = {
name: { label: 'Name' },
displayName: { label: 'Display name' },
type: { label: 'Type' },
status: {
label: 'Validation',
tooltip: 'Whether this node validates successfully.',
},
mode: {
label: 'Publish state',
tooltip: 'Whether this node is published or still a draft.',
},
owners: { label: 'Owners' },
updatedAt: { label: 'Updated' },
};
const CELL_STYLE = {
padding: '8px 16px',
};
const MIDDLE_CELL_STYLE = {
...CELL_STYLE,
verticalAlign: 'middle',
};
const NODE_TYPE_DESCRIPTIONS = {
CUBE: 'A curated set of metrics and dimensions for querying together.',
DIMENSION: 'Descriptive attributes used to group, filter, and join metrics.',
METRIC: 'A reusable business calculation or measure.',
SOURCE: 'A physical table registered in DJ as a source node.',
TRANSFORM: 'A SQL-defined node derived from sources or other DJ nodes.',
};
function splitNodeName(nodeName) {
const lastDot = nodeName.lastIndexOf('.');
if (lastDot === -1) {
return { leafName: nodeName, namespacePath: '' };
}
return {
leafName: nodeName.slice(lastDot + 1),
namespacePath: nodeName.slice(0, lastDot),
};
}
function nodeState(current) {
const isPublished = current.mode === 'PUBLISHED';
const isValid = current.status === 'VALID';
return {
isPublished,
isValid,
publishLabel: isPublished ? 'Published' : 'Draft',
validationLabel: isValid ? 'Valid' : 'Needs fixes',
publishTooltip: isPublished
? 'This version is published and expected to be usable by downstream nodes.'
: 'This version is a draft and may still be incomplete.',
validationTooltip: isValid
? 'This node validates successfully.'
: 'This node has validation errors. Open it to see what needs to be fixed.',
needsAttention: !isPublished || !isValid,
borderColor: isValid && isPublished ? '#28a745' : '#d39e00',
backgroundColor: isValid && isPublished ? '#eaf7ee' : '#fff7df',
color: isValid && isPublished ? '#1f7a3a' : '#8a5b00',
};
}
function exactDateTime(value) {
return new Date(value).toLocaleString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function relativeDate(value) {
const updatedAt = new Date(value);
const diffMs = Date.now() - updatedAt.getTime();
const absMs = Math.abs(diffMs);
const units = [
['year', 365 * 24 * 60 * 60 * 1000],
['month', 30 * 24 * 60 * 60 * 1000],
['week', 7 * 24 * 60 * 60 * 1000],
['day', 24 * 60 * 60 * 1000],
['hour', 60 * 60 * 1000],
['minute', 60 * 1000],
];
for (const [unit, ms] of units) {
if (absMs >= ms) {
const valueForUnit = Math.round(absMs / ms);
return `${valueForUnit} ${unit}${valueForUnit === 1 ? '' : 's'} ${
diffMs >= 0 ? 'ago' : 'from now'
}`;
}
}
return 'just now';
}
export function NamespacePage() {
const ASC = 'ascending';
const DESC = 'descending';
const fields = [
'name',
'displayName',
'type',
'status',
'mode',
'owners',
'updatedAt',
];
const djClient = useContext(DJClientContext).DataJunctionAPI;
const { currentUser } = useCurrentUser();
var { namespace } = useParams();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// Data for select options. These populate filter dropdowns only, so they're
// fetched lazily on first dropdown open instead of on page load — except when a
// users/tags filter is already active from the URL, where we need the options to
// render the selected label.
const [users, setUsers] = useState([]);
const [tags, setTags] = useState([]);
const [usersLoading, setUsersLoading] = useState(false);
const [tagsLoading, setTagsLoading] = useState(false);
const usersRequested = useRef(false);
const tagsRequested = useRef(false);
const ensureUsers = useCallback(async () => {
if (usersRequested.current) return;
usersRequested.current = true;
setUsersLoading(true);
try {
const data = await djClient.users();
setUsers(data || []);
} finally {
setUsersLoading(false);
}
}, [djClient]);
const ensureTags = useCallback(async () => {
if (tagsRequested.current) return;
tagsRequested.current = true;
setTagsLoading(true);
try {
const data = await djClient.listTags();
setTags(data || []);
} finally {
setTagsLoading(false);
}
}, [djClient]);
// Eagerly fetch only what an already-applied filter needs to render its label.
useEffect(() => {
if (searchParams.get('editedBy') || searchParams.get('ownedBy')) {
ensureUsers().catch(console.error);
}
if (searchParams.get('tags')) {
ensureTags().catch(console.error);
}
}, [searchParams, ensureUsers, ensureTags]);
// Parse all filters from URL
const getFiltersFromUrl = useCallback(
() => ({
node_type: searchParams.get('type') || '',
tags: searchParams.get('tags') ? searchParams.get('tags').split(',') : [],
edited_by: searchParams.get('editedBy') || '',
mode: searchParams.get('mode') || '',
ownedBy: searchParams.get('ownedBy') || '',
statuses: searchParams.get('statuses') || '',
missingDescription: searchParams.get('missingDescription') === 'true',
hasMaterialization: searchParams.get('hasMaterialization') === 'true',
orphanedDimension: searchParams.get('orphanedDimension') === 'true',
}),
[searchParams],
);
const [filters, setFilters] = useState(getFiltersFromUrl);
const [moreFiltersOpen, setMoreFiltersOpen] = useState(false);
// Sync filters state when the URL changes. `filters` is already seeded from the
// URL via useState, so we skip the initial run — otherwise it would replace
// filters with an equal-but-new object and trigger a redundant node refetch.
const didSyncFilters = useRef(false);
useEffect(() => {
if (!didSyncFilters.current) {
didSyncFilters.current = true;
return;
}
setFilters(getFiltersFromUrl());
}, [searchParams, getFiltersFromUrl]);
// Update URL when filters change
const updateFilters = useCallback(
newFilters => {
const params = new URLSearchParams();
if (newFilters.node_type) params.set('type', newFilters.node_type);
if (newFilters.tags?.length)
params.set('tags', newFilters.tags.join(','));
if (newFilters.edited_by) params.set('editedBy', newFilters.edited_by);
if (newFilters.mode) params.set('mode', newFilters.mode);
if (newFilters.ownedBy) params.set('ownedBy', newFilters.ownedBy);
if (newFilters.statuses) params.set('statuses', newFilters.statuses);
if (newFilters.missingDescription)
params.set('missingDescription', 'true');
if (newFilters.hasMaterialization)
params.set('hasMaterialization', 'true');
if (newFilters.orphanedDimension) params.set('orphanedDimension', 'true');
setSearchParams(params);
},
[setSearchParams],
);
const clearAllFilters = () => {
setSearchParams(new URLSearchParams());
};
// Check if any filters are active
const hasActiveFilters =
filters.node_type ||
filters.tags?.length ||
filters.edited_by ||
filters.mode ||
filters.ownedBy ||
filters.statuses ||
filters.missingDescription ||
filters.hasMaterialization ||
filters.orphanedDimension;
// Quick presets
const presets = [
{
id: 'my-nodes',
label: 'My Nodes',
filters: { ownedBy: currentUser?.username },
},
{
id: 'needs-attention',
label: 'Needs Attention',
filters: { ownedBy: currentUser?.username, statuses: 'INVALID' },
},
{
id: 'drafts',
label: 'Drafts',
filters: { ownedBy: currentUser?.username, mode: 'draft' },
},
];
const applyPreset = preset => {
const newFilters = {
node_type: '',
tags: [],
edited_by: '',
mode: preset.filters.mode || '',
ownedBy: preset.filters.ownedBy || '',
statuses: preset.filters.statuses || '',
missingDescription: preset.filters.missingDescription || false,
hasMaterialization: preset.filters.hasMaterialization || false,
orphanedDimension: preset.filters.orphanedDimension || false,
};
updateFilters(newFilters);
};
// Check if a preset is active
const isPresetActive = preset => {
const pf = preset.filters;
return (
(pf.ownedBy || '') === (filters.ownedBy || '') &&
(pf.statuses || '') === (filters.statuses || '') &&
(pf.mode || '') === (filters.mode || '') &&
!filters.node_type &&
!filters.tags?.length &&
!filters.edited_by &&
!filters.missingDescription &&
!filters.hasMaterialization &&
!filters.orphanedDimension
);
};
const [state, setState] = useState({
namespace: namespace ? namespace : '',
nodes: [],
});
const [retrieved, setRetrieved] = useState(false);
const [namespaceHierarchy, setNamespaceHierarchy] = useState([]);
const [rawNamespaces, setRawNamespaces] = useState([]);
const [gitRoots, setGitRoots] = useState(new Set());
// Use undefined to indicate "not yet loaded", null means "loaded but no config"
const [gitConfig, setGitConfig] = useState(undefined);
const [sortConfig, setSortConfig] = useState({
key: 'updatedAt',
direction: DESC,
});
const [before, setBefore] = useState(null);
const [after, setAfter] = useState(null);
const [prevCursor, setPrevCursor] = useState(true);
const [nextCursor, setNextCursor] = useState(true);
const [hasNextPage, setHasNextPage] = useState(true);
const [hasPrevPage, setHasPrevPage] = useState(true);
const [nodeSearch, setNodeSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(nodeSearch.trim()), 300);
return () => clearTimeout(t);
}, [nodeSearch]);
// Changing the search term restarts paging from the first page; a cursor from
// a previous page would otherwise be sent with the new query.
useEffect(() => {
setBefore(null);
setAfter(null);
}, [debouncedSearch]);
const [typeCounts, setTypeCounts] = useState(null);
// undefined until NamespaceHeader reports the read-only verdict, so Add Node stays hidden until known
const [headerReadOnly, setHeaderReadOnly] = useState(undefined);
// Only show edit/add controls once git config has loaded and namespace is not git-only
const gitConfigLoaded = gitConfig !== undefined;
// Descendants inherit github_repo_path via cascade, so compare against
// git_root_namespace to know if this namespace IS the git root.
const isGitRoot =
gitConfigLoaded &&
!!gitConfig?.github_repo_path &&
gitConfig?.git_root_namespace === namespace;
// Node-edit controls follow the single read-only verdict from NamespaceHeader
// (`headerReadOnly`), which correctly treats the default branch, 1:1/root git
// namespaces, git_only, and git-deployed non-feature content as read-only
// while leaving feature branches (and plain namespaces) editable. It's
// `undefined` until the header reports, so controls stay hidden until the
// verdict is known (no flash). Deriving a second, shape-based verdict here is
// what let the default branch (shape 'branch') wrongly show edit controls.
const showEditControls = headerReadOnly === false;
// Sub-namespaces can be created from the rail only for plain (non-git-backed)
// namespaces; git-backed ones are managed via git, not the UI. The git config
// endpoint returns an object with null fields (not null) for non-git
// namespaces, so check the actual git markers — a repo (root or cascaded
// descendant) or a branch namespace.
const isGitBacked = !!(
gitConfig?.github_repo_path || gitConfig?.branch_namespace
);
const canCreateNamespace = gitConfigLoaded && !isGitBacked;
const createSubNamespace = async fullNamespace => {
const response = await djClient.addNamespace(fullNamespace);
if (response.status === 200 || response.status === 201) {
// The cached namespace list is now stale — refetch it on next mount.
djClient.invalidateNamespacesCache?.();
navigate(`/namespaces/${fullNamespace}`);
return {};
}
return {
_error: true,
message: response.json?.message || 'Failed to create namespace',
};
};
// Distinguish a *child-spawning* git root (owns the repo, has no branch of its
// own — its nodes live on `<root>.<default_branch>`) from a *flat* git-backed
// namespace (has its own `git_branch`, e.g. tracks `main` directly, with no
// `<ns>.<branch>` children). Only the former is browsed via / redirected to its
// default branch; a flat namespace is its own branch and must stay put.
const isChildSpawningRoot = isGitRoot && !gitConfig?.git_branch;
// A child-spawning root has no nodes of its own — they live on its default
// branch — so the node table browses `<root>.<default_branch>`. A flat
// namespace browses itself.
const tableNamespace =
isChildSpawningRoot && gitConfig?.default_branch
? `${namespace}.${gitConfig.default_branch}`
: namespace;
// A child-spawning root isn't a browsable branch — redirect to its default
// branch so the URL is the branch (giving the breadcrumb its branch switcher)
// and everything is scoped consistently. A flat git-backed namespace is NOT
// redirected: it *is* the branch, and `<ns>.<default_branch>` doesn't exist.
useEffect(() => {
if (isChildSpawningRoot && gitConfig?.default_branch) {
navigate(`/namespaces/${namespace}.${gitConfig.default_branch}`, {
replace: true,
});
}
}, [isChildSpawningRoot, gitConfig, namespace, navigate]);
// Per-type node counts (recursive) for the current namespace, shown inline in
// the TYPE filter options (e.g. "Metric (342)").
useEffect(() => {
// Wait until git config resolves so tableNamespace is final — otherwise a
// git root fetches its provisional namespace, then refetches after redirect.
if (!gitConfigLoaded) return;
if (isGitRoot && gitConfig?.default_branch) return;
let cancelled = false;
if (!tableNamespace) {
setTypeCounts(null);
return;
}
djClient
.nodeTypeCounts(tableNamespace, NODE_TYPE_ORDER)
.then(byType => {
if (!cancelled) setTypeCounts(byType);
})
.catch(() => {
if (!cancelled) setTypeCounts(null);
});
return () => {
cancelled = true;
};
}, [djClient, tableNamespace, gitConfigLoaded, isGitRoot, gitConfig]);
const requestSort = key => {
let direction = ASC;
if (sortConfig.key === key && sortConfig.direction === ASC) {
direction = DESC;
}
if (sortConfig.key !== key || sortConfig.direction !== direction) {
setSortConfig({ key, direction });
}
};
const getClassNamesFor = name => {
if (sortConfig.key === name) {
return sortConfig.direction;
}
return undefined;
};
const createNamespaceHierarchy = namespaceList => {
const hierarchy = [];
// Map lookups (path -> node's children map) keep insertion O(n · depth); we sort
// each level once at the end rather than on every insert (which was O(n² log n)).
const rootMap = new Map();
const childMaps = new Map();
for (const item of namespaceList) {
const segments = item.namespace.split('.');
let currentLevel = hierarchy;
let levelMap = rootMap;
let path = '';
for (const ns of segments) {
path += ns;
let node = levelMap.get(ns);
if (!node) {
node = { namespace: ns, children: [], path };
currentLevel.push(node);
levelMap.set(ns, node);
childMaps.set(path, new Map());
}
currentLevel = node.children;
levelMap = childMaps.get(path);
path += '.';
}
}
const sortLevel = level => {
level.sort((a, b) => a.namespace.localeCompare(b.namespace));
level.forEach(node => sortLevel(node.children));
};
sortLevel(hierarchy);
return hierarchy;
};
useEffect(() => {
const fetchData = async () => {
const all = await djClient.listNamespacesWithGit();
// Hide system/internal scratch namespaces from all browse navigation
// (rail list, header switcher, folders).
const namespaces = all.filter(ns => !isHiddenNamespace(ns.namespace));
setRawNamespaces(namespaces);
const hierarchy = createNamespaceHierarchy(namespaces);
setNamespaceHierarchy(hierarchy);
const roots = new Set(
namespaces
.filter(ns => ns.git?.__typename === 'GitRootConfig')
.map(ns => ns.namespace),
);
setGitRoots(roots);
};
fetchData().catch(console.error);
}, [djClient]);
useEffect(() => {
// Wait until git config resolves so tableNamespace is final — otherwise a
// git root fetches its provisional namespace, then refetches after redirect.
if (!gitConfigLoaded) return;
if (isGitRoot && gitConfig?.default_branch) return;
const fetchData = async () => {
setRetrieved(false);
// Build extended filters for API
const extendedFilters = {
ownedBy: filters.ownedBy || null,
statuses: filters.statuses ? [filters.statuses] : null,
missingDescription: filters.missingDescription,
hasMaterialization: filters.hasMaterialization,
orphanedDimension: filters.orphanedDimension,
// The table shows every node under this namespace (all descendants),
// matching the rail's per-type counts. The rail's Folders drill DOWN to
// re-scope; search narrows within the current scope.
search: debouncedSearch || null,
};
const nodes = await djClient.listNodesForLanding(
tableNamespace,
filters.node_type ? [filters.node_type.toUpperCase()] : [],
filters.tags,
filters.edited_by,
before,
after,
50,
sortConfig,
filters.mode ? filters.mode.toUpperCase() : null,
extendedFilters,
);
setState({
namespace: namespace,
nodes: nodes.data
? nodes.data.findNodesPaginated.edges.map(n => n.node)
: [],
});
if (nodes.data) {
setPrevCursor(
nodes.data ? nodes.data.findNodesPaginated.pageInfo.startCursor : '',
);
setNextCursor(
nodes.data ? nodes.data.findNodesPaginated.pageInfo.endCursor : '',
);
setHasPrevPage(
nodes.data
? nodes.data.findNodesPaginated.pageInfo.hasPrevPage
: false,
);
setHasNextPage(
nodes.data
? nodes.data.findNodesPaginated.pageInfo.hasNextPage
: false,
);
}
setRetrieved(true);
};
fetchData().catch(console.error);
}, [
djClient,
filters,
before,
after,
sortConfig.key,
sortConfig.direction,
tableNamespace,
namespace,
debouncedSearch,
gitConfigLoaded,
isGitRoot,
gitConfig,
]);
const loadNext = () => {
if (nextCursor) {
setAfter(nextCursor);
setBefore(null);
}
};
const loadPrev = () => {
if (prevCursor) {
setAfter(null);
setBefore(prevCursor);
}
};
// Select options. TYPE options carry the recursive per-type node count for the
// current namespace (e.g. "Metric (342)") — this replaces the old rail counts.
const TYPE_LABELS = {
metric: 'Metric',
cube: 'Cube',
dimension: 'Dimension',
transform: 'Transform',
source: 'Source',
};
const typeOptions = NODE_TYPE_ORDER.map(type => ({
value: type,
label: TYPE_LABELS[type],
count: typeCounts?.[type] ?? null,
}));
// Renders a TYPE option as "<name> <colored count pill>" (pill colors match the
// node-type badges). Right-aligns the pill in the menu; inline in the control.
const formatTypeOption = (option, meta) => {
const pill =
option.count != null ? (
<span
style={{
backgroundColor: NODE_TYPE_COLORS[option.value]?.bg ?? '#f1f5f9',
color: NODE_TYPE_COLORS[option.value]?.color ?? '#475569',
borderRadius: '8px',
padding: '1px 8px',
fontSize: '11px',
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
}}
>
{option.count}
</span>
) : null;
return (
<span
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
justifyContent:
meta?.context === 'menu' ? 'space-between' : 'flex-start',
width: meta?.context === 'menu' ? '100%' : 'auto',
}}
>
<span>{option.label}</span>
{pill}
</span>
);
};
const modeOptions = [
{ value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' },
];
const statusOptions = [
{ value: 'VALID', label: 'Valid' },
{ value: 'INVALID', label: 'Needs fixes' },
];
const userOptions = useMemo(
() => users.map(u => ({ value: u.username, label: u.username })),
[users],
);
const tagOptions = useMemo(
() => tags.map(t => ({ value: t.name, label: t.display_name })),
[tags],
);
const nodesList = retrieved ? (
state.nodes.length > 0 ? (
state.nodes.map(node => {
const { leafName, namespacePath } = splitNodeName(node.name);
const stateInfo = nodeState(node.current);
const owners = node.owners || [];
return (
<tr
key={node.name}
style={{
backgroundColor: stateInfo.needsAttention ? '#fffbeb' : undefined,
}}
>
<td
style={{
...CELL_STYLE,
maxWidth: '300px',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
<div>
<a
href={'/nodes/' + node.name}
className="link-table"
title={node.name}
style={{ fontWeight: 600, color: '#334155' }}
>
{leafName}
</a>
<span
className="rounded-pill badge bg-secondary-soft"
style={{ marginLeft: '0.5rem' }}
>
{node.currentVersion}
</span>
</div>
{namespacePath ? (
<div
title={namespacePath}
style={{
color: '#64748b',
fontSize: '12px',
marginTop: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{namespacePath}
</div>
) : null}
</td>
<td
style={{
...MIDDLE_CELL_STYLE,
maxWidth: '250px',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
color: '#64748b',
}}
>
<a
href={'/nodes/' + node.name}
className="link-table"
style={{ color: '#475569', fontWeight: 400 }}
>
{node.type !== 'source' ? node.current.displayName : ''}
</a>
</td>
<td style={MIDDLE_CELL_STYLE}>
<Tooltip content={NODE_TYPE_DESCRIPTIONS[node.type]}>
<span
className={
'node_type__' + node.type.toLowerCase() + ' badge node_type'
}
>
{node.type}
</span>
</Tooltip>
</td>
<td style={MIDDLE_CELL_STYLE}>
<Tooltip content={stateInfo.validationTooltip}>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: '999px',
border: `1px solid ${
stateInfo.isValid ? '#28a745' : '#d39e00'
}`,
backgroundColor: stateInfo.isValid ? '#eaf7ee' : '#fff7df',
color: stateInfo.isValid ? '#1f7a3a' : '#8a5b00',
fontWeight: '600',
fontSize: '12px',
lineHeight: 1.2,
padding: '6px 10px',
whiteSpace: 'nowrap',
}}
>
{stateInfo.validationLabel}
</span>
</Tooltip>
</td>
<td style={MIDDLE_CELL_STYLE}>
<Tooltip content={stateInfo.publishTooltip}>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: '999px',
border: `1px solid ${
stateInfo.isPublished ? '#28a745' : '#d39e00'
}`,
backgroundColor: stateInfo.isPublished
? '#eaf7ee'
: '#fff7df',
color: stateInfo.isPublished ? '#1f7a3a' : '#8a5b00',
fontWeight: '600',
fontSize: '12px',
lineHeight: 1.2,
padding: '6px 10px',
whiteSpace: 'nowrap',
}}
>
{stateInfo.publishLabel}
</span>
</Tooltip>
</td>
<td style={MIDDLE_CELL_STYLE}>
{owners.length > 0 && (
<div style={{ display: 'flex', gap: '2px' }}>
{owners.slice(0, 3).map(owner => {
const initials = owner.username
.split('@')[0]
.slice(0, 2)
.toUpperCase();
const [bg, fg] =
AVATAR_COLORS[avatarColorIndex(owner.username)];
return (
<span
key={owner.username}
title={owner.username}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '24px',
height: '24px',
borderRadius: '50%',
backgroundColor: bg,
color: fg,
fontSize: '9px',
fontWeight: '600',
flexShrink: 0,
}}
>
{initials}
</span>
);
})}
</div>
)}
</td>
<td style={MIDDLE_CELL_STYLE}>
<Tooltip content={exactDateTime(node.current.updatedAt)}>
<span className="status">
{relativeDate(node.current.updatedAt)}
</span>
</Tooltip>
</td>
{showEditControls && (
<td style={MIDDLE_CELL_STYLE}>
<NodeListActions nodeName={node?.name} />
</td>
)}
</tr>
);
})
) : (
<tr>
<td colSpan={fields.length + (showEditControls ? 1 : 0)}>
<span
style={{
display: 'block',
marginTop: '2rem',
marginLeft: '2rem',
fontSize: '16px',
}}
>
{hasActiveFilters ? (
<>
No nodes match the current filters.
<a
href="#"
onClick={e => {
e.preventDefault();
clearAllFilters();
}}
style={{ marginLeft: '0.5rem' }}
>
Clear filters
</a>
</>
) : (
'No nodes in this namespace yet.'
)}
</span>
</td>
</tr>
)
) : (
<tr>
<td>
<span style={{ display: 'block', marginTop: '2rem' }}>
<LoadingIcon />
</span>
</td>
</tr>
);
// Count active filters tucked behind "More filters".
const moreFiltersCount = [
!!filters.edited_by,
filters.missingDescription,
filters.hasMaterialization,
filters.orphanedDimension,
].filter(Boolean).length;
return (
<div className="mid">
<div className="card">
<div className="card-header">
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1rem',
}}
>
<h2 style={{ margin: 0 }}>Browse</h2>
</div>
{/* Unified Filter Bar — always shown; a git root browses its default branch */}
{gitConfigLoaded && (
<div
style={{
marginBottom: '1rem',
padding: '1rem',
backgroundColor: '#f8fafc',
borderRadius: '8px',
}}
>
{/* Top row: Quick presets + Clear all */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '12px',
}}
>
<div
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<span
style={{
fontSize: '11px',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: '#64748b',
}}
>
Quick
</span>
{presets.map(preset => (
<button
key={preset.id}
onClick={() => applyPreset(preset)}
style={{
padding: '4px 10px',
fontSize: '11px',
border: '1px solid',
borderColor: isPresetActive(preset)
? '#1976d2'
: '#ddd',
borderRadius: '12px',
backgroundColor: isPresetActive(preset)
? '#e3f2fd'
: 'white',
color: isPresetActive(preset) ? '#1976d2' : '#666',
cursor: 'pointer',
fontWeight: isPresetActive(preset) ? '600' : '400',
}}
>
{preset.label}
</button>
))}
{hasActiveFilters && (
<button
onClick={clearAllFilters}
style={{
padding: '4px 10px',
fontSize: '11px',
border: 'none',
backgroundColor: 'transparent',
color: '#dc3545',
cursor: 'pointer',
}}
>
Clear all ×
</button>
)}
</div>
</div>
{/* Bottom row: Dropdowns */}
<div
style={{
display: 'flex',
alignItems: 'flex-end',
gap: '12px',
}}
>
<CompactSelect
label="Type"
name="type"
options={typeOptions}
value={filters.node_type}
onChange={e =>
updateFilters({ ...filters, node_type: e?.value || '' })
}
formatOptionLabel={formatTypeOption}
flex={1}
minWidth="80px"
testId="select-node-type"
/>
<CompactSelect
label="Owner"
name="owner"
options={userOptions}
value={filters.ownedBy}
onChange={e =>
updateFilters({ ...filters, ownedBy: e?.value || '' })
}
isLoading={usersLoading}
onMenuOpen={ensureUsers}
flex={1}
minWidth="80px"
/>
<CompactSelect
label="Tags"
name="tags"
options={tagOptions}
value={filters.tags}
onChange={e =>
updateFilters({
...filters,
tags: e ? e.map(t => t.value) : [],
})
}
isMulti
isLoading={tagsLoading}
onMenuOpen={ensureTags}
flex={1.2}
minWidth="100px"
testId="select-tag"
/>
<SplitFilter
label="Publish state"
options={modeOptions}
value={filters.mode}
onChange={value => updateFilters({ ...filters, mode: value })}
/>
<SplitFilter
label="Validation"
options={statusOptions}
value={filters.statuses}
onChange={value =>
updateFilters({ ...filters, statuses: value })
}
/>
{/* More Filters */}
<div
style={{ position: 'relative', flex: 0, minWidth: 'auto' }}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
<label
style={{
fontSize: '10px',
fontWeight: '600',
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
More filters
</label>
<button
onClick={() => setMoreFiltersOpen(!moreFiltersOpen)}
style={{
height: '32px',
minHeight: '32px',
padding: '0 12px',
fontSize: '12px',
border: '1px solid #ccc',
borderRadius: '4px',
backgroundColor:
moreFiltersCount > 0 ? '#e3f2fd' : 'white',
color: '#666',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
whiteSpace: 'nowrap',
}}
>
{moreFiltersCount > 0
? `${moreFiltersCount} active`
: 'Filters'}
<span style={{ fontSize: '8px' }}>
{moreFiltersOpen ? '▲' : '▼'}
</span>
</button>
</div>
{moreFiltersOpen && (
<div
style={{
position: 'absolute',
top: '100%',
right: 0,
marginTop: '4px',
padding: '12px',
backgroundColor: 'white',
border: '1px solid #ddd',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
zIndex: 1000,
minWidth: '320px',
}}
>
<div
style={{
display: 'grid',
gap: '12px',
marginBottom: '12px',
}}
>
<CompactSelect
label="Edited By"
name="editedBy"
options={userOptions}
value={filters.edited_by}
onChange={e =>
updateFilters({
...filters,
edited_by: e?.value || '',
})
}
isLoading={usersLoading}
onMenuOpen={ensureUsers}
flex="none"
minWidth="100%"
testId="select-user"
/>
</div>
<div
style={{
borderTop: '1px solid #e2e8f0',
paddingTop: '12px',
}}
>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '12px',
color: '#444',
marginBottom: '8px',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={filters.missingDescription}
onChange={e =>
updateFilters({
...filters,
missingDescription: e.target.checked,
})
}
/>
Missing Description
</label>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '12px',
color: '#444',
marginBottom: '8px',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={filters.orphanedDimension}
onChange={e =>
updateFilters({
...filters,
orphanedDimension: e.target.checked,
})
}
/>
Orphaned Dimensions
</label>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '12px',
color: '#444',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={filters.hasMaterialization}
onChange={e =>
updateFilters({
...filters,
hasMaterialization: e.target.checked,
})
}
/>
Has Materialization
</label>
</div>
</div>
)}
</div>
</div>
</div>
)}
<NamespaceHeader
namespace={namespace}
onGitConfigLoaded={setGitConfig}
onReadOnlyChange={setHeaderReadOnly}
namespaceOptions={rawNamespaces}
currentNamespace={namespace}
>
{namespace && (
<Tooltip
content={`Download every node in "${namespace}" as a YAML project (.zip) you can version in git and re-deploy with the DJ client.`}
>
<button
type="button"
onClick={async () => {
const response = await fetch(
`${getDJUrl()}/namespaces/${namespace}/export/yaml`,
{ method: 'POST', credentials: 'include' },
);
if (!response.ok) {
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const safeName = namespace.replace(/\./g, '_');
link.href = url;
link.download = `${safeName}_export.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}}
style={secondaryButtonStyle}
onMouseOver={onSecondaryHover}
onMouseOut={onSecondaryOut}
aria-label="Export namespace to YAML"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Export YAML
</button>
</Tooltip>
)}
{showEditControls && <AddNodeDropdown namespace={namespace} />}
</NamespaceHeader>
<div className="table-responsive">
{/* The rail is always present for layout consistency, even on a leaf
namespace with no sub-namespaces (where its Folders list is empty). */}
<div
className="sidebar"
style={{
borderRight: '1px solid #e2e8f0',
paddingRight: '1rem',
}}
>
<NamespaceNav
namespaces={rawNamespaces}
hierarchy={namespaceHierarchy}
currentNamespace={namespace}
gitRoots={gitRoots}
onSelect={value =>
navigate(value ? `/namespaces/${value}` : '/')
}
canCreateNamespace={canCreateNamespace}
onCreateNamespace={createSubNamespace}
/>
</div>
<div
style={{
flex: 1,
minWidth: 0,
marginLeft: '1.5rem',
}}
>
{gitConfigLoaded && (
<input
type="text"
className="dj-node-search"
value={nodeSearch}
onChange={e => setNodeSearch(e.target.value)}
placeholder="Search nodes in this namespace…"
aria-label="Search nodes in this namespace"
/>
)}
{/* NODES: nodes that live directly in this namespace */}
{!gitConfigLoaded ? null : (
<table className="card-table table" style={{ marginBottom: 0 }}>
<thead>
<tr>
{fields.map(field => {
const fieldConfig = FIELD_CONFIG[field] || {};
const sortable = fieldConfig.sortable !== false;
const thStyle = {
fontFamily:
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
fontSize: '11px',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: '#64748b',
padding: '12px 16px',
borderBottom: '1px solid #e2e8f0',
backgroundColor: 'transparent',
};
return (
<th key={field} style={thStyle}>
<Tooltip
content={fieldConfig.tooltip}
placement="top"
>
{sortable ? (
<button
type="button"
onClick={() => requestSort(field)}
className={
'sortable ' + getClassNamesFor(field)
}
style={{
fontSize: 'inherit',
fontWeight: 'inherit',
letterSpacing: 'inherit',
textTransform: 'inherit',
fontFamily: 'inherit',
}}
>
{fieldConfig.label ||
field.replace(/([a-z](?=[A-Z]))/g, '$1 ')}
</button>
) : (
<span>
{fieldConfig.label ||
field.replace(/([a-z](?=[A-Z]))/g, '$1 ')}
</span>
)}
</Tooltip>
</th>
);
})}
{showEditControls && (
<th
style={{
fontFamily:
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
fontSize: '11px',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: '#64748b',
padding: '12px 16px',
borderBottom: '1px solid #e2e8f0',
backgroundColor: 'transparent',
}}
>
Actions