Split EntityNav.svelte (516 lines) into section + item subcomponents
#197
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
EntityNav.svelte(516 lines) inroutes/admin/is the largest route-local component. It renders the entire admin sidebar: section headers with count badges, searchable entity lists, active-state highlighting, and mobile drawer behavior.Proposed Split
1.
EntityNavSection.sveltetitle,count,href,isActive,icon(slot or snippet)2.
EntityNavItem.sveltelabel,href,isActive,subtitle?3.
EntityNav.sveltebecomes orchestratorAcceptance Criteria
npm run checkpasses👨💻 Felix Brandt -- Senior Fullstack Developer
Good refactor target. 516 lines is well past the splitting threshold. A few things I'd want nailed down before implementation:
Props Design
iconas "slot or snippet" onEntityNavSection. In Svelte 5, snippets are the idiomatic choice over slots. I'd commit to snippets now and skip the ambiguity.EntityNavItemhassubtitle?-- what type? A string, or a snippet for richer content (e.g., email under a user name)? This affects whether the component stays simple or needs a render delegation pattern.Keyed
{#each}Blocks{#each}loops? If not, this refactor is the right moment to fix that. Every{#each}over users/groups/tags must use(item.id)keys. I'd add this as an explicit acceptance criterion.KISS vs. DRY Judgment Call
EntityNavSectioncomponent could make future changes harder.Testing Strategy
EntityNav? If not, this refactor should add component tests forEntityNavSectionandEntityNavItemin isolation before wiring them together. Writing tests for the current monolith first would also serve as a regression safety net.$derivedvs.$effect$derivedfor computed filtered lists, not$state+$effect. The refactor is a good opportunity to audit this.Concrete Suggestion
Add to acceptance criteria:
{#each}blocks use identity-based keys(item.id)$derived, not$effect🏗️ Markus Keller -- Senior Application Architect
Clean decomposition proposal. The three-component split (Section, Item, orchestrator) maps well to the visual hierarchy. Architectural considerations:
Component Boundary Clarity
EntityNav.svelte"holds search state, mobile drawer toggle." That's two distinct concerns -- data filtering and layout mode. If the mobile drawer logic is non-trivial (media queries, swipe gestures, focus trapping), consider whether the drawer shell itself should be a separate component, or whether the orchestrator simply toggles a CSS class. Keep the orchestrator as a data flow coordinator, not a layout engine.State Ownership
$page.url)? From a parent layout? This determines whetherisActiveis a prop passed down or derived inside each component. If it's URL-based, eachEntityNavItemcould derive its own active state from$page.url.pathname, which would remove the need for the orchestrator to track and distribute it. That's simpler -- fewer props, less coordination.countonEntityNavSection-- is this loaded in the admin layout's+layout.server.tsand passed down, or fetched separately? If it comes from the layout load, the data flow is clean. If each section fetches its own count, that's a pattern to avoid.Module Boundary
EntityNavSectionandEntityNavItemlive inroutes/admin/as route-local components, correct? They should not migrate to$lib/components/unless another route needs them. Route-local is the right default -- it keeps the admin module self-contained.Naming
EntityNavSectionandEntityNavItemare descriptive and domain-appropriate. No objection. They pass the "one-word nameable region" test.Suggestion
isActiveis computed from URL or passed as a prop. This decision affects the component API surface and should be explicit before implementation starts.🧪 Sara Holt -- Senior QA Engineer
Refactoring a 516-line component into three is exactly the kind of change that needs a test safety net. Here's my test strategy thinking:
Regression Risk
EntityNav?Test Plan for the New Components
Unit layer (Vitest + @testing-library/svelte):
EntityNavSection: renders title, count badge, icon; applies active styling whenisActiveis true; omits count badge when count is 0 or undefinedEntityNavItem: renders label and href; applies active highlight whenisActiveis true; renders optional subtitle when provided; omits subtitle element when not providedE2E layer (Playwright):
Edge Cases to Cover
Acceptance Criteria Addition
EntityNavSectionandEntityNavItemnpm run checkpasses (already listed, good)🔒 Nora "NullX" Steiner -- Application Security Engineer
Pure frontend refactor with no new endpoints or data flows, so the attack surface doesn't change. Still, a few things worth checking during implementation:
Authorization Display Consistency
ADMIN_USER,ADMIN_TAG, etc.). When splitting intoEntityNavSectionandEntityNavItem, confirm that permission-based visibility logic doesn't get lost or duplicated. If the current component conditionally hides sections based on the user's permissions, that logic should live in exactly one place -- the orchestrator -- not be scattered across subcomponents.hrefInjectionEntityNavItemtakes anhrefprop. In the current implementation, these are presumably hardcoded admin routes. But as a general discipline: ifhrefvalues ever come from user-controlled data (e.g., a person name used in a URL slug), ensure they're properly encoded. This is low risk here since admin routes are static, but worth a glance during review.Mobile Drawer Focus Trapping
No New Security Concerns
Suggestion
🎨 Leonie Voss -- UI/UX Design Lead
Component splitting is great for maintainability, but the visual contract must survive intact. Here's what I'd verify:
Active State Styling
EntityNavhandles active-state highlighting for both sections and items. When splitting, confirm:Touch Targets on Mobile
EntityNavItemrows need to maintain 44x44px minimum touch targets. When extracting the component, verify the clickable area covers the full row width, not just the text label. If the current implementation relies on padding from a parent container, the extracted component might lose that spacing.Count Badge Consistency
EntityNavSectionrenders a count badge. Confirm:Mobile Drawer
Responsive Breakpoint
Suggestion
⚙️ Tobias Wendt -- DevOps & Platform Engineer
Pure frontend refactor -- no infrastructure impact. Quick notes from the build/CI perspective:
Build & Bundle Size
npm run buildstill produces a clean output with no new warnings. The acceptance criteria already includenpm run check, which is good.CI Pipeline Impact
File Location
routes/admin/. Confirm they don't accidentally end up in$lib/components/-- that would change the import graph and could affect code splitting boundaries in the SvelteKit build.Dev Server Hot Reload
Suggestion
npm run buildand confirm no new warnings in the build output. A quickdu -sh .svelte-kit/outputcomparison before and after would confirm no unexpected bundle growth.Code Review Response
I read the full 516-line
EntityNav.svelteand the surrounding files (+layout.svelte,+layout.server.ts,entity-nav.svelte.spec.ts,admin.spec.ts). Here are concrete findings addressing every concern raised.Felix Brandt - Senior Fullstack Developer
Snippets vs. slots: Agreed. Svelte 5 snippets are the right choice. The current component uses neither slots nor snippets -- each section's SVG icon is hardcoded inline. The extracted
EntityNavSectionshould accept aniconsnippet prop.subtitle?type on EntityNavItem: This concern is based on a misunderstanding of the component's scope.EntityNavis a section-level navigation bar (4 sections: users, groups, tags, system), not an entity list with individual item rows. There are no user/group/tag list items rendered here. The issue description's mention of "searchable entity lists" andEntityNavItemfor "item row markup across users/groups/tags lists" is misleading -- this component has zero{#each}blocks and no search functionality. The proposedEntityNavItemsubcomponent does not map to anything in the current code. I recommend droppingEntityNavItemfrom the plan and focusing onEntityNavSectiononly.Keyed
{#each}blocks: Not applicable. The component contains no{#each}loops. All four sections are statically defined with{#if}permission guards. No keying concern exists.KISS vs. DRY -- repetition count: The structure repeats 3 times per section (tablet trigger button, desktop link, flyout link) across 4 sections = 12 near-identical blocks. Each block contains: the same SVG icon, the same
isActive()class toggling, and the same text styling. That is genuine 3x repetition of identical structure per section -- well past the DRY threshold. AnEntityNavSectionthat renders all three variants (tablet button, desktop link, flyout link) from a single declaration would eliminate ~350 lines of duplication.$derivedvs.$effect: Already correct.currentPathat line 25 uses$derived(page.url.pathname).isActiveat line 26 is a plain function over that derived value. No$effectis used for computed state. No audit needed.Markus Keller - Senior Application Architect
State ownership for
isActive:isActiveis derived from the URL via$derivedonpage.url.pathname(line 25-26). It is a function(section: string) => booleanthat checkscurrentPath.startsWith('/admin/${section}'). This is self-contained withinEntityNav-- not passed from a parent, not a prop. During the split, this function should stay in the orchestrator and be passed as a prop (or each subcomponent can importpagefrom$app/stateand derive its own). I'd keep it in the orchestrator to avoid each subcomponent importing$app/stateindependently -- single source of truth.Count data origin: Counts come from
+layout.server.ts(lines 48-50), which fetches full entity lists and takes.length. They flow through+layout.svelteas props toEntityNav. Clean top-down data flow, no per-section fetching.Drawer as separate component: The flyout (lines 340-515) is a full navigation panel overlay, not a context-specific drawer. It duplicates the entire nav structure. The logic is modest:
flyoutOpenstate toggle, backdrop click, Escape key, focus management (lines 28-51). This is ~30 lines of script + a template block. Extracting it into a separateEntityNavFlyout.sveltewould be reasonable but not required -- the main win comes from extracting the repeated section markup that the flyout also consumes. If each section is anEntityNavSection, the flyout template shrinks automatically.Module boundary: Confirmed route-local in
routes/admin/. Should stay there.Sara Holt - Senior QA Engineer
Existing tests:
entity-nav.svelte.spec.tsexists with 7 tests covering flyout behavior: initial hidden state, open on click,aria-modal,aria-label, link presence, Escape close, backdrop close, link click close. No tests for permission-based visibility, active state styling, or count rendering.admin.spec.tscovers admin navigation including clicking "Benutzer", "Gruppen", "Schlagworte" buttons (line 28-31) and full CRUD lifecycles.permissions.spec.tsalso touches admin nav.Gaps to fill before refactoring:
canManageUsers=false, count badge renders correct number, active state appliesaria-current="page", system section separated by spacerEdge cases: Empty sections (count=0) currently still render the section with "0" displayed. This is correct behavior and should be preserved. No "empty state" or "hidden when zero" logic exists.
Nora Steiner - Application Security Engineer
Permission-based visibility: Permission checks are cleanly implemented via
{#if}guards at the template level:canManageUsersguards users section (line 71, 362)canManagePermissionsguards groups section (line 138, 400)canManageTagsguards tags section (line 205, 438)canRunMaintenanceguards system section (line 276, 477)These boolean props originate from
+layout.server.tslines 52-55, which checkshasPerm()against the authenticated user's group permissions. The permission logic lives in exactly one place (the layout loader). The component just receives booleans. During the split, these{#if}guards should stay in the orchestrator wrapping each<EntityNavSection>-- not move into the subcomponent.hrefinjection: All hrefs are hardcoded static strings:/admin/users,/admin/groups,/admin/tags,/admin/system(e.g., lines 103, 170, 239, 307). No user-controlled data flows into any href. No encoding concern.Focus trapping in flyout: The current flyout (lines 340-514) does not implement focus trapping. It sets initial focus (line 38-39:
firstLink?.focus()) and returns focus on close (line 44:flyoutTriggerElement?.focus()), but tab/shift-tab can escape the dialog into the page behind it. This is a pre-existing gap, not introduced by the refactor. Worth noting as a follow-up issue but out of scope for this split.Leonie Voss - UI/UX Design Lead
Active state styling: Consistent across all three rendering contexts. Active items get:
border-brand-mint(3px)bg-white/10text-brand-mint(vstext-white/40)text-whitefor labels,text-white/65for counts (vstext-white/55andtext-white/50)aria-current="page"on desktop and flyout links (lines 109, 176, 245, etc.)The tablet trigger buttons do not set
aria-current(they're buttons, not links), which is correct.Touch targets: All tablet trigger buttons have
min-h-[44px]explicitly set (lines 79, 146, 213, 284). Full-width viaw-full. Touch target requirement is met.Count badge accessibility: Counts are rendered as plain
<span>elements inside the link/button. They are not visually isolated badges -- they're inline text within the clickable element. A screen reader reading the button gets: icon (hidden viaaria-hidden) + count text + label text. For example, the desktop users link reads as "5 Benutzer" which is clear. No separatearia-labelneeded since the count and label are both text children of the same<a>.Mobile drawer specifics:
z-40, panelz-50(lines 345, 354) -- correct stackingbg-black/40with click-to-close (line 346)flyoutTriggerElement?.focus()on close (line 44)Responsive breakpoint: The sidebar is
hiddenbelowmd, icon-only strip atmd(md:w-12), full labels atlg(lg:w-30). This is controlled by the parent+layout.svelteline 17 (hidden md:flex) and the nav element itself (line 61). Breakpoint logic will stay in the orchestrator.Tobias Wendt - DevOps & Platform Engineer
CI impact: None. No new dependencies, no docker changes, no env vars. Existing
npm run check,npm run test, and Playwright steps will cover the refactored files automatically.File location: Confirmed route-local in
routes/admin/. Will not move to$lib/components/.Bundle size: Splitting a single Svelte component into subcomponents produces identical compiled output (same DOM operations, just organized differently).
npm run buildshould show no meaningful change.HMR edge case: Acknowledged. Splitting a large file mid-session can cause stale HMR state in Vite. A full page reload resolves it. Not a blocker.
Summary of Revised Plan
Based on the code analysis, the issue description needs correction:
EntityNavItem-- there are no item-level rows or{#each}loops in this component. The "entity lists" are rendered in separate route pages, not here.EntityNavSection-- this is the real win. Each section currently renders 3 variants (tablet button, desktop link, flyout link) with identical structure. Props:section(string key),href,count?,label(message function),icon(snippet),isActive(boolean),onFlyoutOpen,onFlyoutClose, plus optional styling overrides for the system section's border-top.{#if}guards,flyoutOpenstate,isActive()derivation, flyout backdrop/dialog wrapper, Escape handler.