From ef0dbcf0e2b85e3875b9b5b42f43f080979eb732 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Fri, 15 May 2026 09:33:12 +0700 Subject: [PATCH] refactor(frontend): consolidate detail-navigation into a single module folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Prev/Position/Next toolbar and its supporting hooks from scattered locations under components/shared/ and hooks/ into a single components/shared/detail-navigation/ folder. Rename ListNavigation* → DetailNavigation* (toolbar/buttons/sheet) so the names reflect what they actually do — sibling navigation between detail pages, not navigation within a list. The internal algorithmic hook drops its now-redundant prefix (useFilteredListNavigation → useNavigation, computeListNavigation → computeNavigation), and useDetailNavigation moves alongside the toolbar since it's the public composition layer that produces toolbarProps. --- frontend/src/components/shared/README.md | 133 +++++++++--------- .../detail-navigation-buttons.tsx} | 10 +- .../detail-navigation-sheet.tsx} | 6 +- .../detail-navigation-toolbar.test.tsx} | 12 +- .../detail-navigation-toolbar.tsx} | 21 +-- .../shared/detail-navigation/index.ts | 3 + .../use-detail-navigation.test.tsx | 0 .../use-detail-navigation.ts | 20 +-- .../detail-navigation/use-navigation.test.ts} | 42 +++--- .../detail-navigation/use-navigation.ts} | 22 +-- .../flows/use-flow-detail-navigation.ts | 4 +- .../features/knowledges/knowledge-header.tsx | 4 +- .../use-knowledge-detail-navigation.ts | 2 +- .../use-template-detail-navigation.ts | 2 +- frontend/src/pages/flows/flow.tsx | 4 +- frontend/src/pages/templates/template.tsx | 4 +- 16 files changed, 147 insertions(+), 142 deletions(-) rename frontend/src/components/shared/{list-navigation-buttons.tsx => detail-navigation/detail-navigation-buttons.tsx} (91%) rename frontend/src/components/shared/{list-navigation-sheet.tsx => detail-navigation/detail-navigation-sheet.tsx} (98%) rename frontend/src/components/shared/{list-navigation-toolbar.test.tsx => detail-navigation/detail-navigation-toolbar.test.tsx} (97%) rename frontend/src/components/shared/{list-navigation-toolbar.tsx => detail-navigation/detail-navigation-toolbar.tsx} (91%) create mode 100644 frontend/src/components/shared/detail-navigation/index.ts rename frontend/src/{hooks => components/shared/detail-navigation}/use-detail-navigation.test.tsx (100%) rename frontend/src/{hooks => components/shared/detail-navigation}/use-detail-navigation.ts (88%) rename frontend/src/{hooks/use-filtered-list-navigation.test.ts => components/shared/detail-navigation/use-navigation.test.ts} (88%) rename frontend/src/{hooks/use-filtered-list-navigation.ts => components/shared/detail-navigation/use-navigation.ts} (87%) diff --git a/frontend/src/components/shared/README.md b/frontend/src/components/shared/README.md index d7ba400a..b856a384 100644 --- a/frontend/src/components/shared/README.md +++ b/frontend/src/components/shared/README.md @@ -1,7 +1,7 @@ # Shared list/detail building blocks This directory hosts the reusable surface for list-and-detail pages: a -filterable table, a Prev/Next/Sheet toolbar that walks the *same* filtered +filterable table, a Prev/Next/Sheet toolbar that walks the _same_ filtered subset on detail pages, and the inline-rename + sortable-header primitives that every list reuses. @@ -18,10 +18,10 @@ that every list reuses. usePagination │ │ │ ▼ ▼ - useFilteredListNavigation + useNavigation (list page) │ │ ▼ - ▼ + ▼ table_4_ (detail page) in localStorage (cold-start fallback) @@ -33,90 +33,91 @@ that every list reuses. into `localStorage` under `table_4_`. The detail page never writes storage and never replays storage into the URL — opening a shared link shows exactly what the link says. -- **Prev/Next walks the same subset.** `ListNavigationToolbar` runs the +- **Prev/Next walks the same subset.** `DetailNavigationToolbar` runs the same matcher (`createTextMatcher`) the list filter uses, so siblings stay in lockstep with what the user sees in the table. ## Components -| File | Role | -| --------------------------------------------------------------- | -------------------------------------------------------------------------- | -| [`list-navigation-toolbar.tsx`](list-navigation-toolbar.tsx) | Prev / Position / Next cluster + sheet trigger on detail pages. | -| [`list-navigation-buttons.tsx`](list-navigation-buttons.tsx) | Presentation-only button trio; stateless. | -| [`list-navigation-sheet.tsx`](list-navigation-sheet.tsx) | WAI-ARIA listbox sheet — roving tabindex, focus reconciliation. | -| [`sortable-column-header.tsx`](sortable-column-header.tsx) | DRY header for TanStack columns — `none → asc → desc → none` cycle. | -| [`inline-rename-input.tsx`](inline-rename-input.tsx) | `` + Save/Cancel addon with Enter/Escape keybindings. | +| File | Role | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| [`detail-navigation/`](detail-navigation/) | Prev / Position / Next toolbar + listbox sheet for detail pages, and the navigation hooks that feed it. | +| [`sortable-column-header.tsx`](sortable-column-header.tsx) | DRY header for TanStack columns — `none → asc → desc → none` cycle. | +| [`inline-rename-input.tsx`](inline-rename-input.tsx) | `` + Save/Cancel addon with Enter/Escape keybindings. | -## Hooks (in `@/hooks/`) +## Hooks -| Hook | Source of truth | Writes? | Notes | -| ------------------------------- | --------------- | ------- | ------------------------------------------------------------------ | -| `useTableQueryFilter` | URL `?q=` | yes | List pages. Restores from `localStorage` on cold start. | -| `useTableQueryFilterReader` | URL `?q=` | no | Detail pages. Storage-blind — shared links never gain stale `?q=`. | -| `usePagination` | URL `?page=` | yes | Canonicalizes `?page=1` away so the URL has one form per view. | -| `useFilteredListNavigation` | props | no | Pure computation of Prev/Next around a `currentId`. | -| `useDetailNavigation` | URL + props | no | Bundles the three above into a single hook for detail pages. | -| `useInlineEditTitle` | local state | no | Edit-mode toggle + deferred focus (Radix dropdown race fix). | -| `usePageStorageKeys` | router | no | Resolves the three per-page storage keys reactively. | +| Hook | Where | Source of truth | Writes? | Notes | +| --------------------------- | ------------------------------- | --------------- | ------- | ------------------------------------------------------------------ | +| `useTableQueryFilter` | `@/hooks/` | URL `?q=` | yes | List pages. Restores from `localStorage` on cold start. | +| `useTableQueryFilterReader` | `@/hooks/` | URL `?q=` | no | Detail pages. Storage-blind — shared links never gain stale `?q=`. | +| `usePagination` | `@/hooks/` | URL `?page=` | yes | Canonicalizes `?page=1` away so the URL has one form per view. | +| `useNavigation` | `detail-navigation/` (internal) | props | no | Pure computation of Prev/Next around a `currentId`. | +| `useDetailNavigation` | `detail-navigation/` | URL + props | no | Bundles the three above into a single hook for detail pages. | +| `useInlineEditTitle` | `@/hooks/` | local state | no | Edit-mode toggle + deferred focus (Radix dropdown race fix). | +| `usePageStorageKeys` | `@/hooks/` | router | no | Resolves the three per-page storage keys reactively. | ## Library helpers (in `@/lib/`) -| Module | Purpose | -| ----------------------- | -------------------------------------------------------------------------------- | -| `table-filter.ts` | `createTextMatcher` — case + diacritic-insensitive substring matcher. | -| `table-state.ts` | Unified `table_4_` JSON slot. Carries filter + sorting + columnVis + pageSize. | -| `table-sort.ts` | `cycleColumnSort` — pure none/asc/desc cycle for TanStack columns. | -| `view-options-storage.ts` | `viewOptions_4_` for FileManager-style screens (folders-first, etc.). | -| `storage-keys.ts` | Single source of truth for storage-key conventions and `getTopLevelPath`. | -| `url-params.ts` | `URL_PARAMS` constants + `mergeHrefWithSearchParams` (preserves hash on merge). | +| Module | Purpose | +| ------------------------- | ------------------------------------------------------------------------------------ | +| `table-filter.ts` | `createTextMatcher` — case + diacritic-insensitive substring matcher. | +| `table-state.ts` | Unified `table_4_` JSON slot. Carries filter + sorting + columnVis + pageSize. | +| `table-sort.ts` | `cycleColumnSort` — pure none/asc/desc cycle for TanStack columns. | +| `view-options-storage.ts` | `viewOptions_4_` for FileManager-style screens (folders-first, etc.). | +| `storage-keys.ts` | Single source of truth for storage-key conventions and `getTopLevelPath`. | +| `url-params.ts` | `URL_PARAMS` constants + `mergeHrefWithSearchParams` (preserves hash on merge). | ## How to add a new list + detail pair 1. **List page** (`//`): - ```tsx - const { filter, setFilter } = useTableQueryFilter(); - const { pageIndex, setPage } = usePagination(); - return ( - */} - data={entities} - filterColumn="title" - filterValue={filter} - onFilterChange={setFilter} - onPageChange={setPage} - pageIndex={pageIndex} - /> - ); - ``` + ```tsx + const { filter, setFilter } = useTableQueryFilter(); + const { pageIndex, setPage } = usePagination(); + + return ( + */} + data={entities} + filterColumn="title" + filterValue={filter} + onFilterChange={setFilter} + onPageChange={setPage} + pageIndex={pageIndex} + /> + ); + ``` 2. **Feature-scoped navigation hook** (`@/features//use--detail-navigation.ts`): - ```ts - const getLabel = (item: Entity) => item.title; - const getHref = (item: Entity) => `//${item.id}`; - export const useEntityDetailNavigation = (currentId: null | string | undefined) => { - const { entities } = useEntities(); + ```ts + const getLabel = (item: Entity) => item.title; + const getHref = (item: Entity) => `//${item.id}`; - return useDetailNavigation({ currentId, getHref, getLabel, items: entities }); - }; - ``` + export const useEntityDetailNavigation = (currentId: null | string | undefined) => { + const { entities } = useEntities(); + + return useDetailNavigation({ currentId, getHref, getLabel, items: entities }); + }; + ``` 3. **Detail page** (`//:id`): - ```tsx - const { toolbarProps } = useEntityDetailNavigation(entityId); - return ( -
- - {...toolbarProps} - sheetIcon={} - sheetTitle="Entities" - renderItem={(item, isCurrent) => {item.title}} - /> -
- ); - ``` + ```tsx + const { toolbarProps } = useEntityDetailNavigation(entityId); + + return ( +
+ + {...toolbarProps} + sheetIcon={} + sheetTitle="Entities" + renderItem={(item, isCurrent) => {item.title}} + /> +
+ ); + ``` ## Why URL > storage @@ -148,7 +149,7 @@ and deletes them. `view-options-storage`, `url-params`, `table-sort`), the hook behaviours (`use-pagination`, `use-table-query-filter`, `use-inline-edit-title`, `use-page-storage-keys`, `use-detail-navigation`), and the components - (`list-navigation-toolbar`, `data-table`). + (`detail-navigation/`, `data-table`). - jsdom doesn't ship `Element.prototype.scrollIntoView` or `ResizeObserver` — both are polyfilled in `vitest.setup.ts`. - React Testing Library auto-cleans the DOM after every test (see the same diff --git a/frontend/src/components/shared/list-navigation-buttons.tsx b/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx similarity index 91% rename from frontend/src/components/shared/list-navigation-buttons.tsx rename to frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx index 7a3a1445..323a601c 100644 --- a/frontend/src/components/shared/list-navigation-buttons.tsx +++ b/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx @@ -3,7 +3,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -interface ListNavigationButtonsProps { +interface DetailNavigationButtonsProps { /** Disable the position-button when the filtered subset is empty. */ hasEntries: boolean; /** No next sibling — disable the right chevron. */ @@ -21,14 +21,14 @@ interface ListNavigationButtonsProps { /** * Prev / Position / Next button cluster for a detail page. Stateless and - * presentation-only — `ListNavigationToolbar` owns the navigation logic + * presentation-only — `DetailNavigationToolbar` owns the navigation logic * and feeds the resolved indices, labels, and click handlers down. * - * Kept separate from `ListNavigationSheet` so the same buttons could in + * Kept separate from `DetailNavigationSheet` so the same buttons could in * principle be reused without the sheet (e.g. a future variant that ships * keyboard-only navigation without an overlay). */ -export const ListNavigationButtons = ({ +export const DetailNavigationButtons = ({ hasEntries, nextDisabled, onNext, @@ -37,7 +37,7 @@ export const ListNavigationButtons = ({ positionLabel, prevDisabled, sheetTitle, -}: ListNavigationButtonsProps) => { +}: DetailNavigationButtonsProps) => { const lowerTitle = sheetTitle.toLowerCase(); return ( diff --git a/frontend/src/components/shared/list-navigation-sheet.tsx b/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx similarity index 98% rename from frontend/src/components/shared/list-navigation-sheet.tsx rename to frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx index 2de0c384..3c6fa88e 100644 --- a/frontend/src/components/shared/list-navigation-sheet.tsx +++ b/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx @@ -4,7 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { cn } from '@/lib/utils'; -interface ListNavigationSheetProps { +interface DetailNavigationSheetProps { currentId: null | string | undefined; /** * Pre-computed index of `currentId` inside `items` (or `-1` when the @@ -37,7 +37,7 @@ interface ListNavigationSheetProps { * Initial focus on open targets the current entry (if it's part of the * filtered subset) so users land oriented inside their own context. */ -export function ListNavigationSheet({ +export function DetailNavigationSheet({ currentId, currentIndex, getId, @@ -50,7 +50,7 @@ export function ListNavigationSheet({ sheetIcon, sheetTitle, total, -}: ListNavigationSheetProps) { +}: DetailNavigationSheetProps) { const listRef = useRef(null); const buttonRefs = useRef(new Map()); const [focusedId, setFocusedId] = useState(null); diff --git a/frontend/src/components/shared/list-navigation-toolbar.test.tsx b/frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.test.tsx similarity index 97% rename from frontend/src/components/shared/list-navigation-toolbar.test.tsx rename to frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.test.tsx index 7298274c..a743d49c 100644 --- a/frontend/src/components/shared/list-navigation-toolbar.test.tsx +++ b/frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.test.tsx @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'; import { TooltipProvider } from '@/components/ui/tooltip'; -import { ListNavigationToolbar } from './list-navigation-toolbar'; +import { DetailNavigationToolbar } from './detail-navigation-toolbar'; interface Item { id: string; @@ -52,7 +52,7 @@ const renderToolbar = (overrides: { currentId?: null | string; filter?: string; ); return render( - + currentId={overrides.currentId ?? 'c'} filter={overrides.filter ?? ''} getHref={getHref} @@ -65,7 +65,7 @@ const renderToolbar = (overrides: { currentId?: null | string; filter?: string; ); }; -describe('ListNavigationToolbar', () => { +describe('DetailNavigationToolbar', () => { it('renders nothing when items list is empty', () => { renderToolbar({ items: [] }); // The toolbar should not emit any of its own controls — the wrapper @@ -298,7 +298,7 @@ describe('ListNavigationToolbar', () => { }); }); -describe('ListNavigationToolbar — predicate stability', () => { +describe('DetailNavigationToolbar — predicate stability', () => { it('updates the filtered subset when the filter prop changes', async () => { const user = userEvent.setup(); @@ -316,7 +316,7 @@ describe('ListNavigationToolbar — predicate stability', () => { ); const { rerender } = render( - + currentId="a" filter="" getHref={getHref} @@ -331,7 +331,7 @@ describe('ListNavigationToolbar — predicate stability', () => { expect(screen.getByRole('button', { name: /1\/4/ })).toBeInTheDocument(); rerender( - + currentId="a" filter="Alpha" getHref={getHref} diff --git a/frontend/src/components/shared/list-navigation-toolbar.tsx b/frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.tsx similarity index 91% rename from frontend/src/components/shared/list-navigation-toolbar.tsx rename to frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.tsx index 8c0eea9d..79cc7307 100644 --- a/frontend/src/components/shared/list-navigation-toolbar.tsx +++ b/frontend/src/components/shared/detail-navigation/detail-navigation-toolbar.tsx @@ -1,12 +1,13 @@ import { type ReactNode, useCallback, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { ListNavigationButtons } from '@/components/shared/list-navigation-buttons'; -import { ListNavigationSheet } from '@/components/shared/list-navigation-sheet'; -import { useFilteredListNavigation } from '@/hooks/use-filtered-list-navigation'; import { mergeHrefWithSearchParams } from '@/lib/url-params'; -export interface ListNavigationToolbarProps { +import { DetailNavigationButtons } from './detail-navigation-buttons'; +import { DetailNavigationSheet } from './detail-navigation-sheet'; +import { useNavigation } from './use-navigation'; + +export interface DetailNavigationToolbarProps { currentId: null | string | undefined; /** * Current free-text filter the list page applies. The toolbar narrows the @@ -56,7 +57,7 @@ export interface ListNavigationToolbarProps { * supports a free-text filter. * * - "Prev" / "Next" walk the same filtered subset the list page renders. - * - The middle button opens ``, a listbox of every + * - The middle button opens ``, a listbox of every * entry in the filtered subset with the current one highlighted — the * user can jump anywhere in one click. * - When the current item is missing from the filtered subset (e.g. the @@ -70,7 +71,7 @@ export interface ListNavigationToolbarProps { * Returns `null` when there are no items at all — there's nothing useful to * render and a "–/0" disabled toolbar would just be visual noise. */ -export function ListNavigationToolbar({ +export function DetailNavigationToolbar({ currentId, filter, getHref, @@ -82,7 +83,7 @@ export function ListNavigationToolbar({ sheetIcon, sheetTitle, sortFn, -}: ListNavigationToolbarProps) { +}: DetailNavigationToolbarProps) { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [isSheetOpen, setIsSheetOpen] = useState(false); @@ -92,7 +93,7 @@ export function ListNavigationToolbar({ // data shape (`getSearchableText` / `getLabel`), never the comparison. const haystack = getSearchableText ?? getLabel; - const { currentIndex, filteredItems, nextId, prevId, total } = useFilteredListNavigation({ + const { currentIndex, filteredItems, nextId, prevId, total } = useNavigation({ currentId, getId, getSearchableText: haystack, @@ -153,7 +154,7 @@ export function ListNavigationToolbar({ return ( <> - 0} nextDisabled={!nextId} onNext={goToNext} @@ -163,7 +164,7 @@ export function ListNavigationToolbar({ prevDisabled={!prevId} sheetTitle={sheetTitle} /> - + currentId={currentId} currentIndex={currentIndex} getId={getId} diff --git a/frontend/src/components/shared/detail-navigation/index.ts b/frontend/src/components/shared/detail-navigation/index.ts new file mode 100644 index 00000000..9889c03c --- /dev/null +++ b/frontend/src/components/shared/detail-navigation/index.ts @@ -0,0 +1,3 @@ +export { DetailNavigationToolbar } from './detail-navigation-toolbar'; +export type { DetailNavigationToolbarProps } from './detail-navigation-toolbar'; +export { useDetailNavigation } from './use-detail-navigation'; diff --git a/frontend/src/hooks/use-detail-navigation.test.tsx b/frontend/src/components/shared/detail-navigation/use-detail-navigation.test.tsx similarity index 100% rename from frontend/src/hooks/use-detail-navigation.test.tsx rename to frontend/src/components/shared/detail-navigation/use-detail-navigation.test.tsx diff --git a/frontend/src/hooks/use-detail-navigation.ts b/frontend/src/components/shared/detail-navigation/use-detail-navigation.ts similarity index 88% rename from frontend/src/hooks/use-detail-navigation.ts rename to frontend/src/components/shared/detail-navigation/use-detail-navigation.ts index 53bb0503..a415e216 100644 --- a/frontend/src/hooks/use-detail-navigation.ts +++ b/frontend/src/components/shared/detail-navigation/use-detail-navigation.ts @@ -1,21 +1,21 @@ import { useMemo } from 'react'; -import type { ListNavigationToolbarProps } from '@/components/shared/list-navigation-toolbar'; - import { usePageStorageKeys } from '@/hooks/use-page-storage-keys'; import { useTableQueryFilterReader } from '@/hooks/use-table-query-filter'; +import type { DetailNavigationToolbarProps } from './detail-navigation-toolbar'; + /** - * Data-only slice of `ListNavigationToolbarProps`. Derived from the - * single source of truth in `list-navigation-toolbar.tsx` so a new required - * prop (or a rename) can't silently desync the two surfaces. + * Data-only slice of `DetailNavigationToolbarProps`. Derived from the + * single source of truth in `detail-navigation-toolbar.tsx` so a new + * required prop (or a rename) can't silently desync the two surfaces. * * Presentation-only fields (`sheetTitle`, `sheetIcon`, `renderItem`, * `sortFn`) are intentionally excluded — those are supplied per-page at the * call site of the toolbar, not by the navigation hook. */ -type ListNavigationToolbarDataProps = Pick< - ListNavigationToolbarProps, +type DetailNavigationToolbarDataProps = Pick< + DetailNavigationToolbarProps, 'currentId' | 'filter' | 'getHref' | 'getId' | 'getLabel' | 'getSearchableText' | 'items' >; @@ -56,7 +56,7 @@ interface UseDetailNavigationOptions { interface UseDetailNavigationResult { debouncedFilter: string; - toolbarProps: ListNavigationToolbarDataProps; + toolbarProps: DetailNavigationToolbarDataProps; } // Module-level so the reference is stable across renders. Typed against @@ -75,7 +75,7 @@ const defaultGetId = (item: { id: string }): string => item.id; * (read-only — the detail page never mutates the filter from here), * 3. supplying a default `getId` (most domain types use a plain `id`). * - * The returned `toolbarProps` plug straight into `` — + * The returned `toolbarProps` plug straight into `` — * callers add presentation-only props (`sheetTitle`, `renderItem`, etc.). * * Pass each callback through `useCallback`: the toolbar's filter memo relies @@ -104,7 +104,7 @@ export const useDetailNavigation = ({ // `toolbarProps` memo below would invalidate on every render. const resolvedGetId = useMemo<(item: T) => string>(() => getId ?? defaultGetId, [getId]); - const toolbarProps = useMemo>( + const toolbarProps = useMemo>( () => ({ currentId, filter: debouncedFilter, diff --git a/frontend/src/hooks/use-filtered-list-navigation.test.ts b/frontend/src/components/shared/detail-navigation/use-navigation.test.ts similarity index 88% rename from frontend/src/hooks/use-filtered-list-navigation.test.ts rename to frontend/src/components/shared/detail-navigation/use-navigation.test.ts index ff9df8f0..f666cd41 100644 --- a/frontend/src/hooks/use-filtered-list-navigation.test.ts +++ b/frontend/src/components/shared/detail-navigation/use-navigation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { computeListNavigation } from './use-filtered-list-navigation'; +import { computeNavigation } from './use-navigation'; interface Row { id: string; @@ -17,9 +17,9 @@ const ROWS: readonly Row[] = [ { id: 'd', title: 'Delta' }, ] as const; -describe('computeListNavigation', () => { +describe('computeNavigation', () => { it('returns prev/next neighbours for a middle item', () => { - const result = computeListNavigation({ + const result = computeNavigation({ currentId: 'c', getId, items: ROWS, @@ -32,7 +32,7 @@ describe('computeListNavigation', () => { }); it('returns null prev for the first item', () => { - const result = computeListNavigation({ + const result = computeNavigation({ currentId: 'a', getId, items: ROWS, @@ -44,7 +44,7 @@ describe('computeListNavigation', () => { }); it('returns null next for the last item', () => { - const result = computeListNavigation({ + const result = computeNavigation({ currentId: 'd', getId, items: ROWS, @@ -56,7 +56,7 @@ describe('computeListNavigation', () => { }); it('reports currentIndex=-1 when the current item is missing from the filtered subset', () => { - const result = computeListNavigation({ + const result = computeNavigation({ currentId: 'zzz', getId, items: ROWS, @@ -70,7 +70,7 @@ describe('computeListNavigation', () => { }); it('reports currentIndex=-1 when currentId is null or undefined', () => { - const nullResult = computeListNavigation({ + const nullResult = computeNavigation({ currentId: null, getId, items: ROWS, @@ -80,7 +80,7 @@ describe('computeListNavigation', () => { expect(nullResult.prevId).toBeNull(); expect(nullResult.nextId).toBeNull(); - const undefinedResult = computeListNavigation({ + const undefinedResult = computeNavigation({ currentId: undefined, getId, items: ROWS, @@ -90,7 +90,7 @@ describe('computeListNavigation', () => { }); it('honours `query` when narrowing the subset', () => { - const result = computeListNavigation({ + const result = computeNavigation({ currentId: 'c', getId, getSearchableText: getTitle, @@ -107,7 +107,7 @@ describe('computeListNavigation', () => { }); it('drops the current item from the result when it does not match the query', () => { - const result = computeListNavigation({ + const result = computeNavigation({ currentId: 'a', getId, getSearchableText: getTitle, @@ -122,7 +122,7 @@ describe('computeListNavigation', () => { }); it('treats an empty/undefined query as "no filter" even when getSearchableText is provided', () => { - const empty = computeListNavigation({ + const empty = computeNavigation({ currentId: 'c', getId, getSearchableText: getTitle, @@ -132,7 +132,7 @@ describe('computeListNavigation', () => { expect(empty.filteredItems.map(getId)).toEqual(['a', 'b', 'c', 'd']); - const missing = computeListNavigation({ + const missing = computeNavigation({ currentId: 'c', getId, getSearchableText: getTitle, @@ -146,7 +146,7 @@ describe('computeListNavigation', () => { // Documents the boundary: without a haystack accessor we cannot evaluate // the query against rows, so we degrade to "no filter" instead of // throwing — keeps the hook usable while a caller forgets to wire one up. - const result = computeListNavigation({ + const result = computeNavigation({ currentId: 'c', getId, items: ROWS, @@ -158,7 +158,7 @@ describe('computeListNavigation', () => { it('preserves input order when no sortFn is provided', () => { const reversed = [...ROWS].reverse(); - const result = computeListNavigation({ + const result = computeNavigation({ currentId: 'c', getId, items: reversed, @@ -171,7 +171,7 @@ describe('computeListNavigation', () => { }); it('applies sortFn to the filtered subset', () => { - const result = computeListNavigation({ + const result = computeNavigation({ currentId: 'b', getId, items: ROWS, @@ -185,7 +185,7 @@ describe('computeListNavigation', () => { }); it('handles an empty items array', () => { - const result = computeListNavigation({ + const result = computeNavigation({ currentId: 'anything', getId, items: [], @@ -199,7 +199,7 @@ describe('computeListNavigation', () => { }); it('returns the current item when present', () => { - const result = computeListNavigation({ + const result = computeNavigation({ currentId: 'c', getId, items: ROWS, @@ -210,7 +210,7 @@ describe('computeListNavigation', () => { it('does not mutate the input array even when sorting', () => { const before = ROWS.map(getId); - computeListNavigation({ + computeNavigation({ currentId: 'a', getId, items: ROWS, @@ -226,7 +226,7 @@ describe('computeListNavigation', () => { // must surface this as currentIndex=-1 / no neighbours so the UI // disables Prev/Next rather than jumping to an unrelated row. const trimmed = ROWS.filter((row) => row.id !== 'b'); - const result = computeListNavigation({ + const result = computeNavigation({ currentId: 'b', getId, items: trimmed, @@ -246,7 +246,7 @@ describe('computeListNavigation', () => { { id: '3', title: 'resume' }, ]; - const result = computeListNavigation({ + const result = computeNavigation({ currentId: '1', getId, getSearchableText: getTitle, @@ -267,7 +267,7 @@ describe('computeListNavigation', () => { ]; const getNumericId = (row: NumRow) => String(row.id); - const result = computeListNavigation({ + const result = computeNavigation({ currentId: '20', getId: getNumericId, items: rows, diff --git a/frontend/src/hooks/use-filtered-list-navigation.ts b/frontend/src/components/shared/detail-navigation/use-navigation.ts similarity index 87% rename from frontend/src/hooks/use-filtered-list-navigation.ts rename to frontend/src/components/shared/detail-navigation/use-navigation.ts index b75a6c4d..2f3f8972 100644 --- a/frontend/src/hooks/use-filtered-list-navigation.ts +++ b/frontend/src/components/shared/detail-navigation/use-navigation.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { createTextMatcher } from '@/lib/table-filter'; -interface ListNavigationInput { +interface NavigationInput { currentId: null | string | undefined; getId: (item: T) => string; /** @@ -21,7 +21,7 @@ interface ListNavigationInput { sortFn?: (a: T, b: T) => number; } -interface ListNavigationResult { +interface NavigationResult { currentIndex: number; currentItem: null | T; filteredItems: readonly T[]; @@ -31,8 +31,8 @@ interface ListNavigationResult { } /** - * Pure core of {@link useFilteredListNavigation}: filter, sort, and resolve - * Prev/Next around `currentId`. Exposed without React so the algorithm can be + * Pure core of {@link useNavigation}: filter, sort, and resolve Prev/Next + * around `currentId`. Exposed without React so the algorithm can be * unit-tested directly — the hook is a thin `useMemo` wrapper around this. * * The Map of `id → index` is built once per invocation so the `currentId` @@ -40,14 +40,14 @@ interface ListNavigationResult { * intentionally not exposed — callers should walk `filteredItems` or use the * returned `prevId` / `nextId`. */ -export const computeListNavigation = ({ +export const computeNavigation = ({ currentId, getId, getSearchableText, items, query, sortFn, -}: ListNavigationInput): ListNavigationResult => { +}: NavigationInput): NavigationResult => { const hasQuery = query !== undefined && query.length > 0; const filtered = hasQuery && getSearchableText @@ -89,7 +89,7 @@ export const computeListNavigation = ({ }; }; -interface UseFilteredListNavigationOptions { +interface UseNavigationOptions { currentId: null | string | undefined; getId: (item: T) => string; /** @@ -108,7 +108,7 @@ interface UseFilteredListNavigationOptions { sortFn?: (a: T, b: T) => number; } -type UseFilteredListNavigationResult = ListNavigationResult; +type UseNavigationResult = NavigationResult; /** * Resolve Prev/Next siblings for a detail page that's tied to a filtered list. @@ -130,16 +130,16 @@ type UseFilteredListNavigationResult = ListNavigationResult; * keystroke, and `getSearchableText` is naturally module-scoped at the * feature level (see `use-flow-detail-navigation` etc.). */ -export const useFilteredListNavigation = ({ +export const useNavigation = ({ currentId, getId, getSearchableText, items, query, sortFn, -}: UseFilteredListNavigationOptions): UseFilteredListNavigationResult => { +}: UseNavigationOptions): UseNavigationResult => { return useMemo( - () => computeListNavigation({ currentId, getId, getSearchableText, items, query, sortFn }), + () => computeNavigation({ currentId, getId, getSearchableText, items, query, sortFn }), [currentId, getId, getSearchableText, items, query, sortFn], ); }; diff --git a/frontend/src/features/flows/use-flow-detail-navigation.ts b/frontend/src/features/flows/use-flow-detail-navigation.ts index 6847a451..01d25e7b 100644 --- a/frontend/src/features/flows/use-flow-detail-navigation.ts +++ b/frontend/src/features/flows/use-flow-detail-navigation.ts @@ -1,4 +1,4 @@ -import { useDetailNavigation } from '@/hooks/use-detail-navigation'; +import { useDetailNavigation } from '@/components/shared/detail-navigation'; import { type Flow, useFlows } from '@/providers/flows-provider'; const getLabel = (item: Flow) => item.title || `Flow #${item.id}`; @@ -9,7 +9,7 @@ const getHref = (item: Flow) => `/flows/${item.id}`; /** * Detail-page navigation wired up for flows. Encapsulates the getter * callbacks and the call to `useDetailNavigation` so each detail page just - * spreads the returned `toolbarProps` onto `>` + * spreads the returned `toolbarProps` onto `>` * and adds the presentation-only props (`sheetTitle`, `renderItem`, etc.). * * Pass `null` instead of an id while the page is in a non-viewing state diff --git a/frontend/src/features/knowledges/knowledge-header.tsx b/frontend/src/features/knowledges/knowledge-header.tsx index c9e6d073..3f5bccb4 100644 --- a/frontend/src/features/knowledges/knowledge-header.tsx +++ b/frontend/src/features/knowledges/knowledge-header.tsx @@ -8,8 +8,8 @@ import { toast } from 'sonner'; import type { KnowledgeDocumentFragmentFragment } from '@/graphql/types'; import ConfirmationDialog from '@/components/shared/confirmation-dialog'; +import { DetailNavigationToolbar } from '@/components/shared/detail-navigation'; import { InlineRenameInput } from '@/components/shared/inline-rename-input'; -import { ListNavigationToolbar } from '@/components/shared/list-navigation-toolbar'; import { Badge } from '@/components/ui/badge'; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; import { Button } from '@/components/ui/button'; @@ -163,7 +163,7 @@ export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveBu
{canShowActions && ( - + {...knowledgeToolbarProps} renderItem={(item, isCurrent) => ( <> diff --git a/frontend/src/features/knowledges/use-knowledge-detail-navigation.ts b/frontend/src/features/knowledges/use-knowledge-detail-navigation.ts index fa441190..c9f90fe6 100644 --- a/frontend/src/features/knowledges/use-knowledge-detail-navigation.ts +++ b/frontend/src/features/knowledges/use-knowledge-detail-navigation.ts @@ -1,4 +1,4 @@ -import { useDetailNavigation } from '@/hooks/use-detail-navigation'; +import { useDetailNavigation } from '@/components/shared/detail-navigation'; import { type Knowledge, useKnowledges } from '@/providers/knowledges-provider'; const getLabel = (item: Knowledge) => item.question; diff --git a/frontend/src/features/templates/use-template-detail-navigation.ts b/frontend/src/features/templates/use-template-detail-navigation.ts index da451af9..56f7bd7d 100644 --- a/frontend/src/features/templates/use-template-detail-navigation.ts +++ b/frontend/src/features/templates/use-template-detail-navigation.ts @@ -1,4 +1,4 @@ -import { useDetailNavigation } from '@/hooks/use-detail-navigation'; +import { useDetailNavigation } from '@/components/shared/detail-navigation'; import { type Template, useTemplates } from '@/providers/templates-provider'; const getLabel = (item: Template) => item.title; diff --git a/frontend/src/pages/flows/flow.tsx b/frontend/src/pages/flows/flow.tsx index a4dbb682..3aee51d9 100644 --- a/frontend/src/pages/flows/flow.tsx +++ b/frontend/src/pages/flows/flow.tsx @@ -20,9 +20,9 @@ import { toast } from 'sonner'; import { FlowStatusIcon } from '@/components/icons/flow-status-icon'; import { ProviderIcon } from '@/components/icons/provider-icon'; import ConfirmationDialog from '@/components/shared/confirmation-dialog'; +import { DetailNavigationToolbar } from '@/components/shared/detail-navigation'; import { HeaderButton } from '@/components/shared/header-button'; import { InlineRenameInput } from '@/components/shared/inline-rename-input'; -import { ListNavigationToolbar } from '@/components/shared/list-navigation-toolbar'; import { Badge } from '@/components/ui/badge'; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; import { Button } from '@/components/ui/button'; @@ -338,7 +338,7 @@ const Flow = () => {
{flow && ( - + {...flowToolbarProps} renderItem={(item, isCurrent) => ( <> diff --git a/frontend/src/pages/templates/template.tsx b/frontend/src/pages/templates/template.tsx index 4205fac4..2569ad5a 100644 --- a/frontend/src/pages/templates/template.tsx +++ b/frontend/src/pages/templates/template.tsx @@ -18,8 +18,8 @@ import { toast } from 'sonner'; import { z } from 'zod'; import ConfirmationDialog from '@/components/shared/confirmation-dialog'; +import { DetailNavigationToolbar } from '@/components/shared/detail-navigation'; import { InlineRenameInput } from '@/components/shared/inline-rename-input'; -import { ListNavigationToolbar } from '@/components/shared/list-navigation-toolbar'; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; @@ -433,7 +433,7 @@ const Template = () => {
{canShowActions && ( - + {...templateToolbarProps} sheetIcon={} sheetTitle="Templates"