feat(sidebar): redesign with sections, new navigation items, profile footer

- Split sidebar into 3 groups: Resources / Operations / System
- Added Migration section (Overview + Mappings) - was hidden in routes
- Added Git section (Repository Status) - was hidden in routes
- Added Tools section (Mapper, Debug, Storage, Backups) - was hidden in routes
- Moved Settings gear icon to footer (quick link to /settings)
- Moved Profile to footer (avatar + username)
- Moved Collapse button to header (always visible)
- Organized Admin with all sub-items (Users, Roles, ADFS, LLM)
- Reduced visual noise: section labels collapse independently
- Kept existing features: RBAC filtering, health badges, mobile overlay, collapse animation
- Updated tests to match new structure
- Updated i18n (en/ru) with new keys
This commit is contained in:
2026-05-19 11:34:58 +03:00
parent 8039d09505
commit da2c77dc15
5 changed files with 528 additions and 410 deletions

View File

@@ -1,5 +1,5 @@
<!-- #region Sidebar [C:4] [TYPE Component] [SEMANTICS sidebar, navigation, categories, toggle, resource] -->
<!-- @BRIEF Persistent left sidebar with resource categories navigation, expand/collapse, health indicators, and RBAC filtering. -->
<!-- #region Sidebar [C:4] [TYPE Component] [SEMANTICS sidebar, navigation, sections, toggle, resource] -->
<!-- @BRIEF Persistent left sidebar with grouped sections (Resources/Operations/System), expand/collapse, health indicators, RBAC filtering, profile footer. -->
<!-- @LAYER UI -->
<!-- @RELATION BINDS_TO -> [sidebarStore] -->
<!-- @RELATION BINDS_TO -> [healthStore] -->
@@ -8,47 +8,11 @@
<!-- @UX_STATE Idle -> Sidebar visible with current state. -->
<!-- @UX_STATE Toggling -> Animation plays for 200ms on expand/collapse. -->
<!-- @UX_STATE MobileOpen -> Overlay and sidebar visible until dismissed. -->
<!-- @UX_FEEDBACK Active item highlighted with different background. -->
<!-- @UX_FEEDBACK Active item highlighted with different background; section labels visible when expanded. -->
<!-- @UX_FEEDBACK Badge count shown on failing dashboards. -->
<!-- @UX_FEEDBACK Profile footer shows user avatar and username. -->
<!-- @UX_RECOVERY Click outside on mobile closes overlay. -->
<script>
/**
* @PURPOSE: Persistent left sidebar with resource categories navigation
* @LAYER: UI
* @RELATION: BINDS_TO -> sidebar
* @RELATION: BINDS_TO -> auth
* @RELATION: BINDS_TO -> health_store
* @RELATION: DEPENDS_ON -> SidebarNavigation
* @RELATION: DEPENDS_ON -> Icon
* @SEMANTICS: Navigation
* @PRE: Sidebar store and translated category definitions are available before navigation is rendered.
* @POST: Active category and item remain aligned with the current route and mobile state is dismissible.
* @SIDE_EFFECT: Persists sidebar state via store actions, refreshes health indicators, and updates window location on navigation.
* @DATA_CONTRACT: SidebarCategory[] -> RenderedNavigationTree
* @INVARIANT: Always shows active category and item
* @UX_REACTIVITY: Uses $derived for store-backed navigation state and $effect guards for route/category coherence.
*
* @UX_STATE: Idle -> Sidebar visible with current state
* @UX_STATE: Toggling -> Animation plays for 200ms
* @UX_STATE: MobileOpen -> Overlay and sidebar stay visible on small screens until dismissed.
* @UX_FEEDBACK: Active item highlighted with different background
* @UX_RECOVERY: Click outside on mobile closes overlay
*
* @TEST_CONTRACT Component_Sidebar ->
* {
* required_props: {},
* optional_props: {},
* invariants: [
* "Highlights active category and sub-item based on current page URL",
* "Toggles sidebar via toggleSidebar store action",
* "Closes mobile overlay on click outside"
* ]
* }
* @TEST_FIXTURE idle_state -> {}
* @TEST_EDGE mobile_open -> shows mobile overlay mask
* @TEST_INVARIANT navigation -> verifies: [idle_state]
*/
import { page } from "$app/state";
import { fromStore } from "svelte/store";
import {
@@ -61,9 +25,10 @@
import { onMount } from "svelte";
import { t } from "$lib/i18n";
import { auth } from "$lib/auth/store.js";
import { buildSidebarCategories } from "$lib/components/layout/sidebarNavigation.js";
import { buildSidebarSections } from "$lib/components/layout/sidebarNavigation.js";
import { fetchApi } from "$lib/api.js";
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import Icon from "$lib/ui/Icon.svelte";
const sidebarState = fromStore(sidebarStore);
@@ -74,14 +39,17 @@
let features = $state({});
let cleanupInterval = null;
let categories = $derived(
buildSidebarCategories(
let sections = $derived(
buildSidebarSections(
translationState.current,
authState.current?.user || null,
features,
),
);
// Flat categories list for fallback resolution
let allCategories = $derived(sections.flatMap((s) => s.categories));
let isExpanded = $derived(sidebarState.current?.isExpanded ?? true);
let activeCategory = $derived(
sidebarState.current?.activeCategory || "dashboards",
@@ -90,6 +58,9 @@
let isMobileOpen = $derived(Boolean(sidebarState.current?.isMobileOpen));
let failingDashboardCount = $derived(failingCountState.current || 0);
let expandedCategories = $state(new Set(["dashboards"]));
let expandedSections = $state(new Set(["resources", "operations", "system"]));
let user = $derived(authState.current?.user || null);
function withExpandedCategory(categoryId) {
const next = new Set(expandedCategories);
@@ -107,13 +78,23 @@
expandedCategories = next;
}
function toggleExpandedSection(sectionId) {
const next = new Set(expandedSections);
if (next.has(sectionId)) {
next.delete(sectionId);
} else {
next.add(sectionId);
}
expandedSections = next;
}
// Keep active category valid after RBAC filtering.
$effect(() => {
if (
categories.length > 0 &&
!categories.some((category) => category.id === activeCategory)
allCategories.length > 0 &&
!allCategories.some((cat) => cat.id === activeCategory)
) {
const fallbackCategory = categories[0];
const fallbackCategory = allCategories[0];
const fallbackPath =
fallbackCategory.subItems?.[0]?.path || fallbackCategory.path;
setActiveItem(fallbackCategory.id, fallbackPath);
@@ -130,7 +111,7 @@
// Update active item when page changes
$effect(() => {
if (page.url.pathname !== activeItem) {
const matched = categories.find((cat) => {
const matched = allCategories.find((cat) => {
if (page.url.pathname.startsWith(cat.path)) return true;
return (cat.subItems || []).some((item) =>
page.url.pathname.startsWith(item.path),
@@ -142,33 +123,19 @@
}
});
function handleItemClick(category) {
console.log(`[Sidebar][Action] Clicked category ${category.id}`);
setActiveItem(category.id, category.path);
closeMobile();
if (browser) {
window.location.href = category.path;
}
}
function handleCategoryToggle(categoryId, event) {
event.stopPropagation();
if (!isExpanded) {
console.log(
`[Sidebar][Action] Expand sidebar and category ${categoryId}`,
);
toggleSidebar();
withExpandedCategory(categoryId);
return;
}
console.log(`[Sidebar][Action] Toggle category ${categoryId}`);
toggleExpandedCategory(categoryId);
}
function handleSubItemClick(categoryId, path) {
console.log(`[Sidebar][Action] Clicked sub-item ${path}`);
setActiveItem(categoryId, path);
closeMobile();
if (browser) {
@@ -178,44 +145,58 @@
function handleToggleClick(event) {
event.stopPropagation();
console.log("[Sidebar][Action] Toggle sidebar");
toggleSidebar();
}
function handleOverlayClick() {
console.log("[Sidebar][Action] Close mobile overlay");
closeMobile();
}
// Close mobile overlay on route change — depends only on page.url.pathname, NOT isMobileOpen.
// Otherwise clicking the burger (which sets isMobileOpen=true) would immediately close it.
function handleProfileClick() {
closeMobile();
if (browser) {
window.location.href = "/profile";
}
}
function handleSettingsClick(e) {
e.stopPropagation();
closeMobile();
if (browser) {
window.location.href = "/settings";
}
}
function getInitials(user) {
if (!user) return "?";
if (user.display_name) return user.display_name.charAt(0).toUpperCase();
if (user.username) return user.username.charAt(0).toUpperCase();
if (user.email) return user.email.charAt(0).toUpperCase();
return "?";
}
// Close mobile overlay on route change
$effect(() => {
const _pathname = page.url.pathname;
closeMobile();
});
onMount(() => {
// Fetch feature flags first, then conditionally refresh health
fetchApi("/settings/features")
.then((res) => {
features = res;
if (res.health_monitor) {
healthStore.refresh();
// Refresh every 5 minutes
const interval = setInterval(() => healthStore.refresh(), 5 * 60 * 1000);
cleanupInterval = interval;
}
})
.catch(() => {
// Graceful degradation: default to {} (all features visible)
features = {};
// Feature flags unavailable — start health polling only if it doesn't return disabled
healthStore.refresh().then(() => {
const interval = setInterval(() => healthStore.refresh(), 5 * 60 * 1000);
cleanupInterval = interval;
}).catch(() => {
// If the first refresh already failed (feature disabled), do not poll
});
}).catch(() => {});
});
return () => {
@@ -226,10 +207,10 @@
<!-- Mobile overlay (only on mobile) -->
{#if isMobileOpen}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/50 z-20 md:hidden"
onclick={handleOverlayClick}
onkeydown={(e) => e.key === "Escape" && handleOverlayClick()}
role="presentation"
></div>
{/if}
@@ -242,135 +223,209 @@
? 'translate-x-0 w-sidebar'
: '-translate-x-full md:translate-x-0'}"
>
<!-- Header -->
<!-- ═══ HEADER: Logo + Collapse ═══ -->
<div
class="flex items-center border-b border-slate-200 p-4 {isExpanded
class="flex items-center border-b border-slate-200 px-3 py-3 {isExpanded
? 'justify-between'
: 'justify-center'}"
>
{#if isExpanded}
<span class="font-semibold text-gray-800 flex items-center gap-2">
<span class="font-semibold text-gray-800 flex items-center gap-2 text-sm">
<span
class="inline-flex h-6 w-6 items-center justify-center rounded-md bg-gradient-to-br from-slate-100 to-slate-200 text-slate-700 ring-1 ring-slate-200"
class="inline-flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-primary-light to-blue-100 text-primary ring-1 ring-primary-ring"
>
<Icon name="layers" size={14} />
<Icon name="layers" size={15} />
</span>
{translationState.current?.nav?.menu}
{translationState.current?.nav?.menu || "Menu"}
</span>
<button
onclick={handleToggleClick}
class="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
aria-label={translationState.current?.nav?.collapse}
title={translationState.current?.nav?.collapse}
>
<Icon name="chevronLeft" size={15} />
</button>
{:else}
<span class="text-xs text-gray-500">M</span>
<span
class="inline-flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-primary-light to-blue-100 text-primary ring-1 ring-primary-ring"
>
<Icon name="layers" size={15} />
</span>
<button
onclick={handleToggleClick}
class="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors ml-1"
aria-label={translationState.current?.nav?.expand}
title={translationState.current?.nav?.expand}
>
<Icon name="chevronRight" size={15} />
</button>
{/if}
</div>
<!-- Navigation items -->
<nav class="flex-1 overflow-y-auto py-2">
{#each categories as category}
<div>
<!-- Category Header -->
<!-- ═══ NAVIGATION: Sections with categories ═══ -->
<nav class="flex-1 overflow-y-auto py-1">
{#each sections as section}
<!-- Section label (only when expanded) -->
{#if isExpanded}
<div
class="flex cursor-pointer items-center justify-between px-4 py-3 transition-colors hover:bg-slate-100
{activeCategory === category.id
? 'bg-primary-light text-primary md:border-r-2 md:border-primary'
: ''}"
onclick={(e) => handleCategoryToggle(category.id, e)}
onkeydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
handleCategoryToggle(category.id, e)}
class="flex items-center justify-between px-4 pt-4 pb-1 cursor-pointer select-none"
onclick={() => toggleExpandedSection(section.id)}
onkeydown={(e) => (e.key === "Enter") && toggleExpandedSection(section.id)}
role="button"
tabindex="0"
aria-label={category.label}
aria-expanded={expandedCategories.has(category.id)}
aria-expanded={expandedSections.has(section.id)}
>
<div class="flex items-center">
<span
class="relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br ring-1 transition-all {category.tone}"
<span class="text-[11px] font-semibold uppercase tracking-widest text-gray-400">
{section.label}
</span>
<Icon
name="chevronDown"
size={12}
className="text-gray-300 transition-transform duration-200 {expandedSections.has(section.id) ? '' : '-rotate-90'}"
/>
</div>
{/if}
<!-- Section categories -->
{#if !isExpanded || expandedSections.has(section.id)}
{#each section.categories as category}
<div>
<!-- Category Header -->
<div
class="mx-2 flex cursor-pointer items-center justify-between rounded-lg px-2 py-2 transition-colors hover:bg-slate-100
{activeCategory === category.id
? 'bg-primary-light text-primary'
: 'text-gray-600'}"
onclick={(e) => handleCategoryToggle(category.id, e)}
onkeydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
handleCategoryToggle(category.id, e)}
role="button"
tabindex="0"
aria-label={category.label}
aria-expanded={expandedCategories.has(category.id)}
>
<Icon name={category.icon} size={16} strokeWidth={2} />
{#if !isExpanded && category.id === "dashboards" && failingDashboardCount > 0}
<div class="flex items-center min-w-0">
<span
class="absolute -top-1 -right-1 flex h-3 w-3 items-center justify-center rounded-full bg-red-500 ring-2 ring-white"
></span>
{/if}
</span>
{#if isExpanded}
<span class="ml-3 text-sm font-medium truncate"
>{category.label}</span
>
{#if category.id === "dashboards" && failingDashboardCount > 0}
<span
class="ml-auto mr-2 inline-flex items-center justify-center px-2 py-0.5 text-[10px] font-bold leading-none text-white bg-red-500 rounded-full"
title="{failingDashboardCount} failing dashboards"
class="relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br ring-1 transition-all {category.tone}"
>
{failingDashboardCount}
<Icon name={category.icon} size={15} strokeWidth={2} />
{#if !isExpanded && category.id === "dashboards" && failingDashboardCount > 0}
<span
class="absolute -top-1 -right-1 flex h-2.5 w-2.5 items-center justify-center rounded-full bg-red-500 ring-2 ring-white"
></span>
{/if}
</span>
{#if isExpanded}
<span class="ml-3 text-sm font-medium truncate"
>{category.label}</span
>
{#if category.id === "dashboards" && failingDashboardCount > 0}
<span
class="ml-auto mr-1 inline-flex items-center justify-center px-1.5 py-0.5 text-[10px] font-bold leading-none text-white bg-red-500 rounded-full"
title="{failingDashboardCount} failing dashboards"
>
{failingDashboardCount}
</span>
{/if}
{/if}
</div>
{#if isExpanded}
<Icon
name="chevronDown"
size={14}
className="shrink-0 text-gray-400 transition-transform duration-200 {expandedCategories.has(
category.id,
)
? 'rotate-180'
: ''}"
/>
{/if}
</div>
<!-- Sub Items (only when expanded) -->
{#if isExpanded && expandedCategories.has(category.id)}
<div class="overflow-hidden transition-all duration-200">
{#each category.subItems as subItem}
<div
class="mx-2 flex items-center rounded-md px-2 py-1.5 pl-10 cursor-pointer transition-colors text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-900
{activeItem === subItem.path
? 'bg-primary-light text-primary font-medium'
: ''}"
onclick={() => handleSubItemClick(category.id, subItem.path)}
onkeydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
handleSubItemClick(category.id, subItem.path)}
role="button"
tabindex="0"
>
{subItem.label}
</div>
{/each}
</div>
{/if}
</div>
{#if isExpanded}
<Icon
name="chevronDown"
size={16}
className="text-gray-400 transition-transform duration-200 {expandedCategories.has(
category.id,
)
? 'rotate-180'
: ''}"
/>
{/if}
</div>
<!-- Sub Items (only when expanded) -->
{#if isExpanded && expandedCategories.has(category.id)}
<div class="bg-gray-50 overflow-hidden transition-all duration-200">
{#each category.subItems as subItem}
<div
class="flex items-center px-4 py-2 pl-12 cursor-pointer transition-colors text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900
{activeItem === subItem.path
? 'bg-primary-light text-primary'
: ''}"
onclick={() => handleSubItemClick(category.id, subItem.path)}
onkeydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
handleSubItemClick(category.id, subItem.path)}
role="button"
tabindex="0"
>
{subItem.label}
</div>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
{/each}
</nav>
<!-- Footer with Collapse button -->
{#if isExpanded}
<div class="border-t border-gray-200 p-4">
<button
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
onclick={handleToggleClick}
>
<span
class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-slate-100 text-slate-600"
<!-- ═══ FOOTER: Profile + Settings + Collapse ═══ -->
<div class="border-t border-gray-200">
{#if isExpanded}
<!-- Expanded footer: profile info + settings gear -->
<div class="flex items-center justify-between px-3 py-3">
<button
onclick={handleProfileClick}
class="flex items-center gap-2 min-w-0 rounded-lg px-2 py-1.5 text-sm text-gray-600 hover:bg-gray-100 transition-colors"
>
<Icon name="chevronLeft" size={14} />
</span>
{translationState.current?.nav?.collapse}
</button>
</div>
{:else}
<div class="border-t border-gray-200 p-4">
<button
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
onclick={handleToggleClick}
aria-label={translationState.current?.nav?.expand_sidebar}
>
<Icon name="chevronRight" size={16} />
<span class="ml-2">{translationState.current?.nav?.expand}</span>
</button>
</div>
{/if}
<span
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-100 to-indigo-200 text-indigo-700 text-xs font-bold ring-1 ring-indigo-200"
>
{getInitials(user)}
</span>
<span class="truncate max-w-[120px]">
{user?.display_name || user?.username || user?.email || "Profile"}
</span>
</button>
<div class="flex items-center gap-1">
<button
onclick={handleSettingsClick}
class="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
aria-label={translationState.current?.nav?.settings}
title={translationState.current?.nav?.settings}
>
<Icon name="settings" size={15} />
</button>
</div>
</div>
{:else}
<!-- Collapsed footer: just avatar + expand -->
<div class="flex flex-col items-center gap-1 px-2 py-3">
<button
onclick={handleProfileClick}
class="flex items-center justify-center rounded-lg px-1 py-1 hover:bg-gray-100 transition-colors"
aria-label={translationState.current?.nav?.profile}
title={translationState.current?.nav?.profile}
>
<span
class="inline-flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-indigo-100 to-indigo-200 text-indigo-700 text-xs font-bold ring-1 ring-indigo-200"
>
{getInitials(user)}
</span>
</button>
<button
onclick={handleToggleClick}
class="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
aria-label={translationState.current?.nav?.expand}
title={translationState.current?.nav?.expand}
>
<Icon name="chevronRight" size={15} />
</button>
</div>
{/if}
</div>
</div>
<!-- #endregion Sidebar -->

View File

@@ -1,27 +1,49 @@
// #region SidebarNavigationTest:Module [TYPE Function]
// @SEMANTICS: tests, sidebar, navigation, rbac, permissions
// @PURPOSE: Verifies RBAC-based sidebar category and subitem visibility.
// @PURPOSE: Verifies RBAC-based sidebar sections and category visibility.
// @LAYER: UI (Tests)
// @RELATION: DEPENDS_ON -> [SidebarNavigation]
import { describe, it, expect } from "vitest";
import { buildSidebarCategories } from "../sidebarNavigation.js";
import { buildSidebarCategories, buildSidebarSections } from "../sidebarNavigation.js";
const i18nState = {
nav: {
section_resources: "Resources",
section_operations: "Operations",
section_system: "System",
dashboards: "Dashboards",
overview: "Overview",
health_center: "Health Center",
datasets: "Datasets",
all_datasets: "All datasets",
all_datasets: "All Datasets",
dataset_review_workspace: "Review Workspace",
migration: "Migration",
migration_overview: "Overview",
migration_mappings: "Mappings",
git: "Git",
git_repo_status: "Repository Status",
translation: "Translation",
translation_jobs: "Jobs",
translation_dictionaries: "Dictionaries",
translation_history: "History",
reports: "Reports",
all_reports: "All Reports",
tools: "Tools",
tools_mapper: "Dataset Mapper",
tools_debug: "System Debug",
tools_storage: "File Storage",
tools_backups: "Backup Manager",
storage: "Storage",
backups: "Backups",
repositories: "Repositories",
reports: "Reports",
profile: "Profile",
admin: "Admin",
admin_users: "User management",
admin_roles: "Role management",
settings: "Settings",
admin: "Admin",
admin_users: "User Management",
admin_roles: "Role Management",
admin_settings: "ADFS Config",
admin_llm: "LLM Providers",
profile: "Profile",
},
};
@@ -30,6 +52,20 @@ function makeUser(roles) {
}
describe("sidebarNavigation", () => {
it("shows sections structure with correct section IDs", () => {
// Admin user sees all sections
const user = makeUser([
{
name: "Admin",
permissions: [],
},
]);
const sections = buildSidebarSections(i18nState, user);
const sectionIds = sections.map((s) => s.id);
expect(sectionIds).toEqual(["resources", "operations", "system"]);
});
it("shows only categories available to a non-admin user", () => {
const user = makeUser([
{
@@ -44,7 +80,19 @@ describe("sidebarNavigation", () => {
const categories = buildSidebarCategories(i18nState, user);
const categoryIds = categories.map((category) => category.id);
expect(categoryIds).toEqual(["dashboards", "datasets", "reports", "profile"]);
// migration requires plugin:migration → visible
// git/tools have no requiredPermission → always visible
// translation requires translate.job → hidden
// storage requires plugin:storage → hidden
// admin requires admin:* → hidden
expect(categoryIds).toEqual([
"dashboards",
"datasets",
"migration",
"git",
"reports",
"tools",
]);
});
it("hides admin category when user has no admin permissions", () => {
@@ -76,11 +124,12 @@ describe("sidebarNavigation", () => {
expect(adminCategory.subItems.map((item) => item.path)).toEqual([
"/admin/users",
"/admin/roles",
"/settings",
"/admin/settings",
"/admin/settings/llm",
]);
});
it("keeps profile visible even without explicit plugin permissions", () => {
it("shows git and tools for users without plugin permissions", () => {
const user = makeUser([
{
name: "Basic",
@@ -91,8 +140,10 @@ describe("sidebarNavigation", () => {
const categories = buildSidebarCategories(i18nState, user);
const categoryIds = categories.map((category) => category.id);
expect(categoryIds).toEqual(["profile"]);
// Profile is now in the sidebar footer, not a nav category
// Git and Tools have no requiredPermission → always visible
expect(categoryIds).toContain("git");
expect(categoryIds).toContain("tools");
});
});
// #endregion SidebarNavigationTest:Module

View File

@@ -1,6 +1,6 @@
// #region SidebarNavigation:Module [TYPE Function]
// @SEMANTICS: navigation, sidebar, rbac, menu, filtering
// @PURPOSE: Build sidebar navigation categories filtered by current user permissions.
// @PURPOSE: Build sidebar navigation sections and categories filtered by user permissions and feature flags.
// @LAYER: UI
// @RELATION: DEPENDS_ON -> [Permissions]
// @RELATION: BINDS_TO -> [Sidebar]
@@ -22,192 +22,184 @@ function isItemAllowed(user, item) {
}
// #endregion isItemAllowed:Function
// #region buildSidebarCategories:Function [TYPE Function]
// @PURPOSE: Build translated sidebar categories and filter them by RBAC permissions and feature flags.
// #region isFeatureEnabled:Function [TYPE Function]
function isFeatureEnabled(item, features) {
if (!item?.requiredFeature) return true;
return features[item.requiredFeature] !== false;
}
// #endregion isFeatureEnabled:Function
// #region buildSidebarSections:Function [TYPE Function]
// @PURPOSE: Build translated sidebar sections with categories, filtered by RBAC permissions and feature flags.
// @PRE: i18nState provides nav labels; user can be null; features default to {} (all enabled).
// @POST: Returns only categories/subitems available for provided user and enabled features.
export function buildSidebarCategories(i18nState, user, features = {}) {
// @POST: Returns only sections/categories/subitems available for provided user and enabled features.
export function buildSidebarSections(i18nState, user, features = {}) {
const nav = i18nState?.nav || {};
const categories = [
{
id: "dashboards",
label: nav.dashboards,
icon: "dashboard",
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
path: "/dashboards",
requiredPermission: "plugin:migration",
requiredAction: "READ",
subItems: [
{
label: nav.overview,
path: "/dashboards",
requiredPermission: "plugin:migration",
requiredAction: "READ",
},
{
label: nav.health_center,
path: "/dashboards/health",
requiredPermission: "plugin:migration",
requiredAction: "READ",
requiredFeature: "health_monitor",
},
],
},
{
id: "datasets",
label: nav.datasets,
icon: "database",
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
path: "/datasets",
requiredPermission: "plugin:migration",
requiredAction: "READ",
subItems: [
{
label: nav.all_datasets,
path: "/datasets",
requiredPermission: "plugin:migration",
requiredAction: "READ",
},
{
label: nav.dataset_review_workspace,
path: "/datasets/review",
requiredPermission: "plugin:migration",
requiredAction: "READ",
requiredFeature: "dataset_review",
},
],
},
{
id: "storage",
label: nav.storage,
icon: "storage",
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
path: "/storage",
requiredPermission: "plugin:storage",
requiredAction: "READ",
subItems: [
{
label: nav.backups,
path: "/storage/backups",
requiredPermission: "plugin:storage",
requiredAction: "READ",
},
{
label: nav.repositories,
path: "/storage/repos",
requiredPermission: "plugin:storage",
requiredAction: "READ",
},
],
},
{
id: "translation",
label: nav.translation,
icon: "translate",
tone: "from-cyan-100 to-teal-100 text-teal-700 ring-teal-200",
path: "/translate",
requiredPermission: "translate.job",
requiredAction: "VIEW",
subItems: [
{
label: nav.translation_jobs,
path: "/translate",
requiredPermission: "translate.job",
requiredAction: "VIEW",
},
{
label: nav.translation_dictionaries,
path: "/translate/dictionaries",
requiredPermission: "translate.dictionary",
requiredAction: "VIEW",
},
{
label: nav.translation_history,
path: "/translate/history",
requiredPermission: "translate.job",
requiredAction: "VIEW",
},
],
},
{
id: "reports",
label: nav.reports,
icon: "reports",
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
path: "/reports",
requiredPermission: "tasks",
requiredAction: "READ",
subItems: [
{
label: nav.reports,
path: "/reports",
requiredPermission: "tasks",
requiredAction: "READ",
},
],
},
{
id: "profile",
label: nav.profile,
icon: "admin",
tone: "from-indigo-100 to-indigo-200 text-indigo-700 ring-indigo-200",
path: "/profile",
subItems: [{ label: nav.profile, path: "/profile" }],
},
{
id: "admin",
label: nav.admin,
icon: "admin",
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
path: "/admin",
subItems: [
{
label: nav.admin_users,
path: "/admin/users",
requiredPermission: "admin:users",
requiredAction: "READ",
},
{
label: nav.admin_roles,
path: "/admin/roles",
requiredPermission: "admin:roles",
requiredAction: "READ",
},
{
label: nav.settings,
path: "/settings",
requiredPermission: "admin:settings",
requiredAction: "READ",
},
],
},
];
// ── Section 1: Resources ─────────────────────────────────
const resources = {
id: "resources",
label: nav.section_resources,
categories: [
{
id: "dashboards",
label: nav.dashboards,
icon: "dashboard",
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
path: "/dashboards",
requiredPermission: "plugin:migration",
requiredAction: "READ",
subItems: [
{ label: nav.overview, path: "/dashboards", requiredPermission: "plugin:migration", requiredAction: "READ" },
{ label: nav.health_center, path: "/dashboards/health", requiredPermission: "plugin:migration", requiredAction: "READ", requiredFeature: "health_monitor" },
],
},
{
id: "datasets",
label: nav.datasets,
icon: "database",
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
path: "/datasets",
requiredPermission: "plugin:migration",
requiredAction: "READ",
subItems: [
{ label: nav.all_datasets, path: "/datasets", requiredPermission: "plugin:migration", requiredAction: "READ" },
{ label: nav.dataset_review_workspace, path: "/datasets/review", requiredPermission: "plugin:migration", requiredAction: "READ", requiredFeature: "dataset_review" },
],
},
{
id: "migration",
label: nav.migration,
icon: "layers",
tone: "from-orange-100 to-orange-200 text-orange-700 ring-orange-200",
path: "/migration",
requiredPermission: "plugin:migration",
requiredAction: "READ",
subItems: [
{ label: nav.migration_overview || "Overview", path: "/migration", requiredPermission: "plugin:migration", requiredAction: "READ" },
{ label: nav.migration_mappings || "Mappings", path: "/migration/mappings", requiredPermission: "plugin:migration", requiredAction: "READ" },
],
},
],
};
function isFeatureEnabled(item) {
if (!item?.requiredFeature) return true;
return features[item.requiredFeature] !== false;
}
// ── Section 2: Operations ────────────────────────────────
const operations = {
id: "operations",
label: nav.section_operations,
categories: [
{
id: "git",
label: nav.git,
icon: "activity",
tone: "from-purple-100 to-purple-200 text-purple-700 ring-purple-200",
path: "/git",
subItems: [
{ label: nav.git_repo_status || "Repository Status", path: "/git" },
],
},
{
id: "translation",
label: nav.translation,
icon: "translate",
tone: "from-cyan-100 to-teal-100 text-teal-700 ring-teal-200",
path: "/translate",
requiredPermission: "translate.job",
requiredAction: "VIEW",
subItems: [
{ label: nav.translation_jobs, path: "/translate", requiredPermission: "translate.job", requiredAction: "VIEW" },
{ label: nav.translation_dictionaries, path: "/translate/dictionaries", requiredPermission: "translate.dictionary", requiredAction: "VIEW" },
{ label: nav.translation_history, path: "/translate/history", requiredPermission: "translate.job", requiredAction: "VIEW" },
],
},
{
id: "reports",
label: nav.reports,
icon: "reports",
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
path: "/reports",
requiredPermission: "tasks",
requiredAction: "READ",
subItems: [
{ label: nav.all_reports || "All Reports", path: "/reports", requiredPermission: "tasks", requiredAction: "READ" },
],
},
],
};
return categories
.map((category) => {
const visibleSubItems = (category.subItems || []).filter(
(subItem) => isItemAllowed(user, subItem) && isFeatureEnabled(subItem),
);
return {
...category,
subItems: visibleSubItems,
};
})
.filter((category) => {
const categoryVisible = isItemAllowed(user, category);
if (!categoryVisible) return false;
// ── Section 3: System ────────────────────────────────────
const system = {
id: "system",
label: nav.section_system,
categories: [
{
id: "tools",
label: nav.tools,
icon: "list",
tone: "from-slate-100 to-slate-200 text-slate-700 ring-slate-200",
path: "/tools/mapper",
subItems: [
{ label: nav.tools_mapper, path: "/tools/mapper" },
{ label: nav.tools_debug, path: "/tools/debug" },
{ label: nav.tools_storage, path: "/tools/storage" },
{ label: nav.tools_backups, path: "/tools/backups" },
],
},
{
id: "storage",
label: nav.storage,
icon: "storage",
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
path: "/storage",
requiredPermission: "plugin:storage",
requiredAction: "READ",
subItems: [
{ label: nav.backups, path: "/storage/backups", requiredPermission: "plugin:storage", requiredAction: "READ" },
{ label: nav.repositories, path: "/storage/repos", requiredPermission: "plugin:storage", requiredAction: "READ" },
],
},
{
id: "admin",
label: nav.admin,
icon: "admin",
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
path: "/admin/users",
subItems: [
{ label: nav.admin_users, path: "/admin/users", requiredPermission: "admin:users", requiredAction: "READ" },
{ label: nav.admin_roles, path: "/admin/roles", requiredPermission: "admin:roles", requiredAction: "READ" },
{ label: nav.admin_settings, path: "/admin/settings", requiredPermission: "admin:settings", requiredAction: "READ" },
{ label: nav.admin_llm, path: "/admin/settings/llm", requiredPermission: "admin:settings", requiredAction: "READ" },
],
},
],
};
const hasVisibleSubItems =
Array.isArray(category.subItems) && category.subItems.length > 0;
return hasVisibleSubItems;
});
const allSections = [resources, operations, system];
// Filter by permissions and features
return allSections
.map((section) => ({
...section,
categories: section.categories
.map((category) => {
const visibleSubItems = (category.subItems || []).filter(
(sub) => isItemAllowed(user, sub) && isFeatureEnabled(sub, features),
);
return { ...category, subItems: visibleSubItems };
})
.filter((category) => {
if (!isItemAllowed(user, category)) return false;
return Array.isArray(category.subItems) && category.subItems.length > 0;
}),
}))
.filter((section) => section.categories.length > 0);
}
// #endregion buildSidebarCategories:Function
// #endregion buildSidebarSections:Function
// #endregion SidebarNavigation:Module
// ── Legacy adapter for backward compatibility ──────────────
// @DEPRECATED Use buildSidebarSections instead. Returns flat category list for old consumers.
export function buildSidebarCategories(i18nState, user, features = {}) {
const sections = buildSidebarSections(i18nState, user, features);
return sections.flatMap((s) => s.categories);
}
// #endregion SidebarNavigation:Module

View File

@@ -5,41 +5,51 @@
"collapse": "Collapse",
"expand": "Expand",
"expand_sidebar": "Expand sidebar",
"dashboard": "Dashboard",
"section_resources": "Resources",
"section_operations": "Operations",
"section_system": "System",
"dashboards": "Dashboards",
"datasets": "Datasets",
"overview": "Overview",
"all_datasets": "All Datasets",
"dataset_review_workspace": "Review Workspace",
"health_center": "Health Center",
"storage": "Storage",
"backups": "Backups",
"repositories": "Repositories",
"migration": "Migration",
"migration_overview": "Overview",
"migration_mappings": "Mappings",
"git": "Git",
"tasks": "Tasks",
"reports": "Reports",
"settings": "Settings",
"tools": "Tools",
"tools_search": "Dataset Search",
"tools_mapper": "Dataset Mapper",
"tools_backups": "Backup Manager",
"tools_debug": "System Debug",
"tools_storage": "File Storage",
"tools_llm": "LLM Tools",
"git_repo_status": "Repository Status",
"translation": "Translation",
"translation_jobs": "Jobs",
"translation_dictionaries": "Dictionaries",
"translation_history": "History",
"settings_general": "General Settings",
"reports": "Reports",
"all_reports": "All Reports",
"tools": "Tools",
"tools_mapper": "Dataset Mapper",
"tools_debug": "System Debug",
"tools_storage": "File Storage",
"tools_backups": "Backup Manager",
"storage": "Storage",
"backups": "Backups",
"repositories": "Repositories",
"settings": "Settings",
"settings_general": "General",
"settings_connections": "Connections",
"settings_git": "Git Integration",
"settings_environments": "Environments",
"settings_storage": "Storage",
"admin": "Admin",
"admin_users": "User Management",
"admin_roles": "Role Management",
"admin_settings": "ADFS Configuration",
"admin_settings": "ADFS Config",
"admin_llm": "LLM Providers",
"profile": "Profile"
"profile": "Profile",
"sign_out": "Sign Out"
}

View File

@@ -5,41 +5,51 @@
"collapse": "Свернуть",
"expand": "Развернуть",
"expand_sidebar": "Развернуть боковую панель",
"dashboard": "Панель управления",
"section_resources": "Ресурсы",
"section_operations": "Операции",
"section_system": "Система",
"dashboards": "Дашборды",
"datasets": "Датасеты",
"overview": "Обзор",
"all_datasets": "Все датасеты",
"dataset_review_workspace": "Review Workspace",
"health_center": "Центр здоровья",
"storage": "Хранилище",
"backups": "Бэкапы",
"repositories": "Репозитории",
"migration": "Миграция",
"migration_overview": "Обзор",
"migration_mappings": "Маппинги",
"git": "Git",
"tasks": "Задачи",
"reports": "Отчеты",
"settings": "Настройки",
"tools": "Инструменты",
"tools_search": "Поиск датасетов",
"tools_mapper": "Маппер колонок",
"tools_backups": "Управление бэкапами",
"tools_debug": "Диагностика системы",
"tools_storage": "Хранилище файлов",
"tools_llm": "Инструменты LLM",
"git_repo_status": "Состояние репозитория",
"translation": "Переводы",
"translation_jobs": "Задания",
"translation_dictionaries": "Словари",
"translation_history": "История",
"settings_general": "Общие настройки",
"reports": "Отчеты",
"all_reports": "Все отчеты",
"tools": "Инструменты",
"tools_mapper": "Маппер колонок",
"tools_debug": "Диагностика",
"tools_storage": "Файловое хранилище",
"tools_backups": "Бэкапы",
"storage": "Хранилище",
"backups": "Бэкапы",
"repositories": "Репозитории",
"settings": "Настройки",
"settings_general": "Общие",
"settings_connections": "Подключения",
"settings_git": "Интеграция Git",
"settings_environments": "Окружения",
"settings_storage": "Хранилище",
"admin": "Админ",
"admin_users": "Управление пользователями",
"admin_roles": "Управление ролями",
"admin_users": "Пользователи",
"admin_roles": "Роли",
"admin_settings": "Настройка ADFS",
"admin_llm": "Провайдеры LLM",
"profile": "Профиль"
"profile": "Профиль",
"sign_out": "Выйти"
}