mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-23 03:26:37 +00:00
refactor(frontend): consolidate detail-navigation into a single module folder
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.
This commit is contained in:
@@ -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 │
|
||||
│ │
|
||||
▼ ▼
|
||||
<DataTable> useFilteredListNavigation
|
||||
<DataTable> useNavigation
|
||||
(list page) │
|
||||
│ ▼
|
||||
▼ <ListNavigationToolbar>
|
||||
▼ <DetailNavigationToolbar>
|
||||
table_4_<path> (detail page)
|
||||
in localStorage
|
||||
(cold-start fallback)
|
||||
@@ -33,90 +33,91 @@ that every list reuses.
|
||||
into `localStorage` under `table_4_<path>`. 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) | `<input>` + 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) | `<input>` + 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_<path>` 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_<path>` 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_<path>` 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_<path>` 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** (`/<entities>/`):
|
||||
```tsx
|
||||
const { filter, setFilter } = useTableQueryFilter();
|
||||
const { pageIndex, setPage } = usePagination();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns /* use <SortableColumnHeader column={column} label="..." /> */}
|
||||
data={entities}
|
||||
filterColumn="title"
|
||||
filterValue={filter}
|
||||
onFilterChange={setFilter}
|
||||
onPageChange={setPage}
|
||||
pageIndex={pageIndex}
|
||||
/>
|
||||
);
|
||||
```
|
||||
```tsx
|
||||
const { filter, setFilter } = useTableQueryFilter();
|
||||
const { pageIndex, setPage } = usePagination();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns /* use <SortableColumnHeader column={column} label="..." /> */}
|
||||
data={entities}
|
||||
filterColumn="title"
|
||||
filterValue={filter}
|
||||
onFilterChange={setFilter}
|
||||
onPageChange={setPage}
|
||||
pageIndex={pageIndex}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
2. **Feature-scoped navigation hook** (`@/features/<entity>/use-<entity>-detail-navigation.ts`):
|
||||
```ts
|
||||
const getLabel = (item: Entity) => item.title;
|
||||
const getHref = (item: Entity) => `/<entities>/${item.id}`;
|
||||
|
||||
export const useEntityDetailNavigation = (currentId: null | string | undefined) => {
|
||||
const { entities } = useEntities();
|
||||
```ts
|
||||
const getLabel = (item: Entity) => item.title;
|
||||
const getHref = (item: Entity) => `/<entities>/${item.id}`;
|
||||
|
||||
return useDetailNavigation<Entity>({ currentId, getHref, getLabel, items: entities });
|
||||
};
|
||||
```
|
||||
export const useEntityDetailNavigation = (currentId: null | string | undefined) => {
|
||||
const { entities } = useEntities();
|
||||
|
||||
return useDetailNavigation<Entity>({ currentId, getHref, getLabel, items: entities });
|
||||
};
|
||||
```
|
||||
|
||||
3. **Detail page** (`/<entities>/:id`):
|
||||
```tsx
|
||||
const { toolbarProps } = useEntityDetailNavigation(entityId);
|
||||
|
||||
return (
|
||||
<header>
|
||||
<ListNavigationToolbar<Entity>
|
||||
{...toolbarProps}
|
||||
sheetIcon={<Icon className="size-4" />}
|
||||
sheetTitle="Entities"
|
||||
renderItem={(item, isCurrent) => <span>{item.title}</span>}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
```
|
||||
```tsx
|
||||
const { toolbarProps } = useEntityDetailNavigation(entityId);
|
||||
|
||||
return (
|
||||
<header>
|
||||
<DetailNavigationToolbar<Entity>
|
||||
{...toolbarProps}
|
||||
sheetIcon={<Icon className="size-4" />}
|
||||
sheetTitle="Entities"
|
||||
renderItem={(item, isCurrent) => <span>{item.title}</span>}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
+5
-5
@@ -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 (
|
||||
+3
-3
@@ -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<T> {
|
||||
interface DetailNavigationSheetProps<T> {
|
||||
currentId: null | string | undefined;
|
||||
/**
|
||||
* Pre-computed index of `currentId` inside `items` (or `-1` when the
|
||||
@@ -37,7 +37,7 @@ interface ListNavigationSheetProps<T> {
|
||||
* 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<T>({
|
||||
export function DetailNavigationSheet<T>({
|
||||
currentId,
|
||||
currentIndex,
|
||||
getId,
|
||||
@@ -50,7 +50,7 @@ export function ListNavigationSheet<T>({
|
||||
sheetIcon,
|
||||
sheetTitle,
|
||||
total,
|
||||
}: ListNavigationSheetProps<T>) {
|
||||
}: DetailNavigationSheetProps<T>) {
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const buttonRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const [focusedId, setFocusedId] = useState<null | string>(null);
|
||||
+6
-6
@@ -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(
|
||||
<ListNavigationToolbar<Item>
|
||||
<DetailNavigationToolbar<Item>
|
||||
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(
|
||||
<ListNavigationToolbar<Item>
|
||||
<DetailNavigationToolbar<Item>
|
||||
currentId="a"
|
||||
filter=""
|
||||
getHref={getHref}
|
||||
@@ -331,7 +331,7 @@ describe('ListNavigationToolbar — predicate stability', () => {
|
||||
expect(screen.getByRole('button', { name: /1\/4/ })).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ListNavigationToolbar<Item>
|
||||
<DetailNavigationToolbar<Item>
|
||||
currentId="a"
|
||||
filter="Alpha"
|
||||
getHref={getHref}
|
||||
+11
-10
@@ -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<T> {
|
||||
import { DetailNavigationButtons } from './detail-navigation-buttons';
|
||||
import { DetailNavigationSheet } from './detail-navigation-sheet';
|
||||
import { useNavigation } from './use-navigation';
|
||||
|
||||
export interface DetailNavigationToolbarProps<T> {
|
||||
currentId: null | string | undefined;
|
||||
/**
|
||||
* Current free-text filter the list page applies. The toolbar narrows the
|
||||
@@ -56,7 +57,7 @@ export interface ListNavigationToolbarProps<T> {
|
||||
* supports a free-text filter.
|
||||
*
|
||||
* - "Prev" / "Next" walk the same filtered subset the list page renders.
|
||||
* - The middle button opens `<ListNavigationSheet>`, a listbox of every
|
||||
* - The middle button opens `<DetailNavigationSheet>`, 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<T> {
|
||||
* 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<T>({
|
||||
export function DetailNavigationToolbar<T>({
|
||||
currentId,
|
||||
filter,
|
||||
getHref,
|
||||
@@ -82,7 +83,7 @@ export function ListNavigationToolbar<T>({
|
||||
sheetIcon,
|
||||
sheetTitle,
|
||||
sortFn,
|
||||
}: ListNavigationToolbarProps<T>) {
|
||||
}: DetailNavigationToolbarProps<T>) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
@@ -92,7 +93,7 @@ export function ListNavigationToolbar<T>({
|
||||
// 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<T>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListNavigationButtons
|
||||
<DetailNavigationButtons
|
||||
hasEntries={total > 0}
|
||||
nextDisabled={!nextId}
|
||||
onNext={goToNext}
|
||||
@@ -163,7 +164,7 @@ export function ListNavigationToolbar<T>({
|
||||
prevDisabled={!prevId}
|
||||
sheetTitle={sheetTitle}
|
||||
/>
|
||||
<ListNavigationSheet<T>
|
||||
<DetailNavigationSheet<T>
|
||||
currentId={currentId}
|
||||
currentIndex={currentIndex}
|
||||
getId={getId}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { DetailNavigationToolbar } from './detail-navigation-toolbar';
|
||||
export type { DetailNavigationToolbarProps } from './detail-navigation-toolbar';
|
||||
export { useDetailNavigation } from './use-detail-navigation';
|
||||
+10
-10
@@ -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<T>`. 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<T>`. 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<T> = Pick<
|
||||
ListNavigationToolbarProps<T>,
|
||||
type DetailNavigationToolbarDataProps<T> = Pick<
|
||||
DetailNavigationToolbarProps<T>,
|
||||
'currentId' | 'filter' | 'getHref' | 'getId' | 'getLabel' | 'getSearchableText' | 'items'
|
||||
>;
|
||||
|
||||
@@ -56,7 +56,7 @@ interface UseDetailNavigationOptions<T extends { id: string }> {
|
||||
|
||||
interface UseDetailNavigationResult<T extends { id: string }> {
|
||||
debouncedFilter: string;
|
||||
toolbarProps: ListNavigationToolbarDataProps<T>;
|
||||
toolbarProps: DetailNavigationToolbarDataProps<T>;
|
||||
}
|
||||
|
||||
// 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 `<ListNavigationToolbar>` —
|
||||
* The returned `toolbarProps` plug straight into `<DetailNavigationToolbar>` —
|
||||
* 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 = <T extends { id: string }>({
|
||||
// `toolbarProps` memo below would invalidate on every render.
|
||||
const resolvedGetId = useMemo<(item: T) => string>(() => getId ?? defaultGetId, [getId]);
|
||||
|
||||
const toolbarProps = useMemo<ListNavigationToolbarDataProps<T>>(
|
||||
const toolbarProps = useMemo<DetailNavigationToolbarDataProps<T>>(
|
||||
() => ({
|
||||
currentId,
|
||||
filter: debouncedFilter,
|
||||
+21
-21
@@ -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,
|
||||
+11
-11
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { createTextMatcher } from '@/lib/table-filter';
|
||||
|
||||
interface ListNavigationInput<T> {
|
||||
interface NavigationInput<T> {
|
||||
currentId: null | string | undefined;
|
||||
getId: (item: T) => string;
|
||||
/**
|
||||
@@ -21,7 +21,7 @@ interface ListNavigationInput<T> {
|
||||
sortFn?: (a: T, b: T) => number;
|
||||
}
|
||||
|
||||
interface ListNavigationResult<T> {
|
||||
interface NavigationResult<T> {
|
||||
currentIndex: number;
|
||||
currentItem: null | T;
|
||||
filteredItems: readonly T[];
|
||||
@@ -31,8 +31,8 @@ interface ListNavigationResult<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T> {
|
||||
* intentionally not exposed — callers should walk `filteredItems` or use the
|
||||
* returned `prevId` / `nextId`.
|
||||
*/
|
||||
export const computeListNavigation = <T>({
|
||||
export const computeNavigation = <T>({
|
||||
currentId,
|
||||
getId,
|
||||
getSearchableText,
|
||||
items,
|
||||
query,
|
||||
sortFn,
|
||||
}: ListNavigationInput<T>): ListNavigationResult<T> => {
|
||||
}: NavigationInput<T>): NavigationResult<T> => {
|
||||
const hasQuery = query !== undefined && query.length > 0;
|
||||
const filtered =
|
||||
hasQuery && getSearchableText
|
||||
@@ -89,7 +89,7 @@ export const computeListNavigation = <T>({
|
||||
};
|
||||
};
|
||||
|
||||
interface UseFilteredListNavigationOptions<T> {
|
||||
interface UseNavigationOptions<T> {
|
||||
currentId: null | string | undefined;
|
||||
getId: (item: T) => string;
|
||||
/**
|
||||
@@ -108,7 +108,7 @@ interface UseFilteredListNavigationOptions<T> {
|
||||
sortFn?: (a: T, b: T) => number;
|
||||
}
|
||||
|
||||
type UseFilteredListNavigationResult<T> = ListNavigationResult<T>;
|
||||
type UseNavigationResult<T> = NavigationResult<T>;
|
||||
|
||||
/**
|
||||
* Resolve Prev/Next siblings for a detail page that's tied to a filtered list.
|
||||
@@ -130,16 +130,16 @@ type UseFilteredListNavigationResult<T> = ListNavigationResult<T>;
|
||||
* keystroke, and `getSearchableText` is naturally module-scoped at the
|
||||
* feature level (see `use-flow-detail-navigation` etc.).
|
||||
*/
|
||||
export const useFilteredListNavigation = <T>({
|
||||
export const useNavigation = <T>({
|
||||
currentId,
|
||||
getId,
|
||||
getSearchableText,
|
||||
items,
|
||||
query,
|
||||
sortFn,
|
||||
}: UseFilteredListNavigationOptions<T>): UseFilteredListNavigationResult<T> => {
|
||||
}: UseNavigationOptions<T>): UseNavigationResult<T> => {
|
||||
return useMemo(
|
||||
() => computeListNavigation({ currentId, getId, getSearchableText, items, query, sortFn }),
|
||||
() => computeNavigation({ currentId, getId, getSearchableText, items, query, sortFn }),
|
||||
[currentId, getId, getSearchableText, items, query, sortFn],
|
||||
);
|
||||
};
|
||||
@@ -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 `<ListNavigationToolbar<Flow>>`
|
||||
* spreads the returned `toolbarProps` onto `<DetailNavigationToolbar<Flow>>`
|
||||
* and adds the presentation-only props (`sheetTitle`, `renderItem`, etc.).
|
||||
*
|
||||
* Pass `null` instead of an id while the page is in a non-viewing state
|
||||
|
||||
@@ -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
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{canShowActions && (
|
||||
<ListNavigationToolbar<Knowledge>
|
||||
<DetailNavigationToolbar<Knowledge>
|
||||
{...knowledgeToolbarProps}
|
||||
renderItem={(item, isCurrent) => (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{flow && (
|
||||
<ListNavigationToolbar<FlowItem>
|
||||
<DetailNavigationToolbar<FlowItem>
|
||||
{...flowToolbarProps}
|
||||
renderItem={(item, isCurrent) => (
|
||||
<>
|
||||
|
||||
@@ -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 = () => {
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{canShowActions && (
|
||||
<ListNavigationToolbar<Template>
|
||||
<DetailNavigationToolbar<Template>
|
||||
{...templateToolbarProps}
|
||||
sheetIcon={<FileText className="size-4" />}
|
||||
sheetTitle="Templates"
|
||||
|
||||
Reference in New Issue
Block a user