Merge remote-tracking branch 'origin/feature/frontend-next' into feature/next-release

This commit is contained in:
Dmitry Ng
2026-05-16 22:54:28 +03:00
102 changed files with 10181 additions and 4766 deletions
+6 -5
View File
@@ -64,7 +64,6 @@
"graphql": "^16.11.0",
"graphql-ws": "^6.0.5",
"highlight.js": "^11.11.1",
"html2pdf.js": "^0.14.0",
"js-cookie": "^3.0.5",
"lodash": "^4.18.1",
"lowlight": "^3.3.0",
@@ -107,6 +106,10 @@
"@prettier/plugin-xml": "^3.3.1",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.15",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/js-cookie": "^3.0.6",
"@types/lodash": "^4.17.13",
"@types/node": "^22.0.0",
@@ -122,11 +125,10 @@
"eslint-plugin-perfectionist": "^4.15.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"lint-staged": "^16.2.6",
"jsdom": "^29.1.1",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.7.2",
"simple-git-hooks": "^2.11.1",
"tailwindcss": "^4.1.18",
"tsx": "^4.19.3",
"typescript": "^5.6.2",
@@ -139,8 +141,7 @@
"pnpm": {
"onlyBuiltDependencies": [
"@swc/core",
"esbuild",
"simple-git-hooks"
"esbuild"
]
},
"eslintConfig": {
+2597 -1991
View File
File diff suppressed because it is too large Load Diff
+21 -15
View File
@@ -57,13 +57,11 @@ const RootLayout = () => (
<UserProvider>
<FavoritesProvider>
<TemplatesProvider>
<KnowledgesProvider>
<ResourcesProvider>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</ResourcesProvider>
</KnowledgesProvider>
<ResourcesProvider>
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</ResourcesProvider>
</TemplatesProvider>
</FavoritesProvider>
</UserProvider>
@@ -101,6 +99,12 @@ const FlowWithProvider = () => (
</FlowProvider>
);
const KnowledgesLayout = () => (
<KnowledgesProvider>
<Outlet />
</KnowledgesProvider>
);
const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<RootLayout />}>
@@ -138,14 +142,16 @@ const router = createBrowserRouter(
path="templates/:templateId"
/>
<Route
element={<Knowledges />}
path="knowledges"
/>
<Route
element={<Knowledge />}
path="knowledges/:knowledgeId"
/>
<Route element={<KnowledgesLayout />}>
<Route
element={<Knowledges />}
path="knowledges"
/>
<Route
element={<Knowledge />}
path="knowledges/:knowledgeId"
/>
</Route>
<Route
element={<Resources />}
@@ -51,12 +51,6 @@ const menuItems: readonly MenuItem[] = [
path: '/settings/api-tokens',
title: 'PentAGI API',
},
// {
// id: 'mcp-servers',
// title: 'MCP Servers',
// path: '/settings/mcp-servers',
// icon: <Server className="size-4" />,
// },
] as const;
// Individual menu item component to properly use hooks
@@ -98,14 +92,6 @@ const SettingsHeader = () => {
return 'Edit Provider';
}
if (path === '/settings/mcp-servers/new') {
return 'Create MCP Server';
}
if (path.startsWith('/settings/mcp-servers/')) {
return 'Edit MCP Server';
}
if (path === '/settings/prompts/new') {
return 'Create Prompt';
}
+153
View File
@@ -0,0 +1,153 @@
# 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
subset on detail pages, and the inline-rename + sortable-header primitives
that every list reuses.
## Mental model
```
┌──────────────────────────────────────────┐
│ URL ?q=foo ?page=3 │
│ (source of truth — bookmarkable) │
└────────┬─────────────────────┬───────────┘
│ read/write │ read-only
▼ ▼
useTableQueryFilter useTableQueryFilterReader
usePagination │
│ │
▼ ▼
<DataTable> useNavigation
(list page) │
│ ▼
▼ <DetailNavigationToolbar>
table_4_<path> (detail page)
in localStorage
(cold-start fallback)
```
- **URL is authoritative.** Filter (`?q=`) and page (`?page=`) live in the
URL so links/bookmarks always reproduce the user's view.
- **Storage is a warm-restart bag.** The list page persists the URL filter
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.** `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 |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| [`detail-navigation/`](detail-navigation/) | Prev / Position / Next toolbar + listbox sheet for detail pages, and the navigation hooks that feed it. |
| [`inline-edit/`](inline-edit/) | Generic inline-edit input (Save/Cancel addons, Enter/Escape) plus the paired `useInlineEdit` state machine. |
## Hooks
| 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. |
| `useInlineEdit` | `inline-edit/` | 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-state.ts` | Unified `table_4_<path>` JSON slot. Carries filter + sorting + columnVis + pageSize. |
| `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 <DataTableColumnHeader column={column} title="..." /> */}
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();
return useDetailNavigation<Entity>({ currentId, getHref, getLabel, items: entities });
};
```
3. **Detail page** (`/<entities>/:id`):
```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
A user opens `/flows?q=alpha` in tab A. They navigate to flow B by clicking
"Next" in the toolbar. They share `/flows/b?q=alpha` with a teammate.
- The teammate opens the link cold. Their detail page reads `q=alpha` from
the URL and renders Prev/Next over the filtered subset.
- The teammate hits "Next". They land on `/flows/c?q=alpha` — still inside
the filter, even though they never typed it.
`useTableQueryFilterReader` is the key piece: it observes the URL but never
writes anything, so a fresh detail-page mount can't accidentally inject the
**previous tab's** `?q=` into the URL.
## Why one storage key per page
Before the unification, every list page wrote four storage keys
(`column_4_/flows`, `sorting_4_/flows`, `filter_4_/flows`, `page_4_/flows`)
in two different write paths (sync + debounced). Refreshing during a typing
session could land you in an inconsistent state. The unified
`table_4_<path>` slot is a single JSON object that all preferences live in;
`migrateLegacyTableState` folds the four legacy keys into it on first mount
and deletes them.
## Testing notes
- `vitest run` covers the pure utilities (`table-state`,
`view-options-storage`, `url-params`), the hook behaviours
(`use-pagination`, `use-table-query-filter`, `use-inline-edit`,
`use-page-storage-keys`, `use-detail-navigation`), and the components
(`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
setup file). Tests can freely call `render` without leaking nodes.
@@ -0,0 +1,88 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { DetailNavigationController } from './use-detail-navigation';
interface DetailNavigationButtonsProps<T extends { id: string }> {
controller: DetailNavigationController<T>;
/** Lowercased plural used in the aria-label / tooltip ("flows", "templates"). */
sheetTitle: string;
/**
* Size variant. `'default'` is the desktop toolbar's `size-8` cluster;
* `'sm'` shrinks the cluster to `size-7` for embedding inside a
* `<DropdownMenuItem>` on mobile, where the host row is already padded.
*/
size?: 'default' | 'sm';
}
/**
* Prev / Position / Next button cluster bound to a `DetailNavigationController`.
* Stateless: the controller owns navigation, `isSheetOpen`, and the
* pre-formatted `positionLabel`.
*
* Reused in both the desktop toolbar (`size="default"`) and the mobile
* dropdown row (`size="sm"`) — same a11y contract, same tooltips, same
* keyboard semantics in both places.
*/
export const DetailNavigationButtons = <T extends { id: string }>({
controller,
sheetTitle,
size = 'default',
}: DetailNavigationButtonsProps<T>) => {
const lowerTitle = sheetTitle.toLowerCase();
const isSm = size === 'sm';
const sideButtonSize = isSm ? 'size-7' : 'size-8';
const middleHeight = isSm ? 'h-7' : 'h-8';
return (
<div className="flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Previous"
className={cn(sideButtonSize, 'rounded-r-none border-r-0 p-0')}
disabled={!controller.prevId}
onClick={controller.goToPrev}
size="icon"
variant="outline"
>
<ChevronLeft />
</Button>
</TooltipTrigger>
<TooltipContent>Previous</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={`Open ${lowerTitle} list (${controller.positionLabel})`}
className={cn(middleHeight, 'min-w-12 rounded-none border-x px-2 font-mono text-xs tabular-nums')}
disabled={!controller.hasEntries}
onClick={controller.openSheet}
variant="outline"
>
{controller.positionLabel}
</Button>
</TooltipTrigger>
<TooltipContent>Show all matching {lowerTitle}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Next"
className={cn(sideButtonSize, 'rounded-l-none border-l-0 p-0')}
disabled={!controller.nextId}
onClick={controller.goToNext}
size="icon"
variant="outline"
>
<ChevronRight />
</Button>
</TooltipTrigger>
<TooltipContent>Next</TooltipContent>
</Tooltip>
</div>
);
};
@@ -0,0 +1,241 @@
import type { ReactNode } from 'react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
import { DetailNavigationSheet } from './detail-navigation-sheet';
import { useDetailNavigation } from './use-detail-navigation';
interface Item {
id: string;
title: string;
}
const ITEMS: readonly Item[] = [
{ id: 'a', title: 'Alpha' },
{ id: 'b', title: 'Bravo' },
{ id: 'c', title: 'Charlie' },
{ id: 'd', title: 'Delta' },
] as const;
const getHref = (item: Item) => `/items/${item.id}`;
const getLabel = (item: Item) => item.title;
const getSearchableText = (item: Item) => item.title;
const LocationReadout = () => {
const { pathname, search } = useLocation();
return (
<span data-testid="location">
{pathname}
{search}
</span>
);
};
interface HarnessProps {
currentId?: null | string;
filter?: string;
items?: readonly Item[];
}
/**
* Render the sheet open by default (`defaultOpen: true`) so keyboard /
* focus / a11y interactions can be exercised without round-tripping through
* the toolbar's position button. Keeps each test focused on the leaf.
*/
const SheetHarness = ({ currentId = 'c', items = ITEMS }: HarnessProps) => {
const nav = useDetailNavigation<Item>({
currentId,
defaultOpen: true,
getHref,
getLabel,
getSearchableText,
items,
});
return (
<DetailNavigationSheet<Item>
controller={nav}
sheetTitle="Items"
/>
);
};
const renderSheet = (props: HarnessProps = {}) => {
const filter = props.filter ?? '';
const initialId = props.currentId ?? 'c';
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[`/items/${initialId}?q=${filter}`]}>
<TooltipProvider>
<LocationReadout />
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</TooltipProvider>
</MemoryRouter>
);
return render(<SheetHarness {...props} />, { wrapper: Wrapper });
};
describe('DetailNavigationSheet — a11y / aria contract', () => {
it('renders the listbox with the sheet title as accessible name (aria-describedby opt-out preserved)', async () => {
renderSheet({ currentId: 'c' });
const listbox = await screen.findByRole('listbox', { name: 'Items' });
expect(listbox).toBeInTheDocument();
});
it('marks the current item with aria-selected', async () => {
renderSheet({ currentId: 'c' });
const listbox = await screen.findByRole('listbox');
const current = within(listbox).getByRole('option', { selected: true });
expect(current).toHaveAttribute('data-item-id', 'c');
});
});
describe('DetailNavigationSheet — roving tabIndex', () => {
it('only the current option carries tabIndex={0}', async () => {
renderSheet({ currentId: 'c' });
const listbox = await screen.findByRole('listbox');
const options = within(listbox).getAllByRole('option');
await waitFor(() => {
const focusable = options.filter((option) => option.getAttribute('tabindex') === '0');
expect(focusable).toHaveLength(1);
expect(focusable[0]).toHaveAttribute('data-item-id', 'c');
});
const nonFocusable = options.filter((option) => option.getAttribute('tabindex') === '-1');
expect(nonFocusable.length).toBe(options.length - 1);
});
it('falls back to the first filtered option when current is outside the subset', async () => {
renderSheet({ currentId: 'zzz' });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
});
});
describe('DetailNavigationSheet — keyboard navigation', () => {
it('ArrowDown moves roving focus to the next option', async () => {
renderSheet({ currentId: 'b' });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'b');
});
fireEvent.keyDown(listbox, { key: 'ArrowDown' });
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'c');
});
});
it('ArrowUp at the first option clamps (no wrap)', async () => {
renderSheet({ currentId: 'a' });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
fireEvent.keyDown(listbox, { key: 'ArrowUp' });
// Focus stays on the first option — no wrap-around.
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
it('End jumps roving focus to the last option', async () => {
renderSheet({ currentId: 'a' });
const listbox = await screen.findByRole('listbox');
fireEvent.keyDown(listbox, { key: 'End' });
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'd');
});
});
it('Home jumps roving focus to the first option', async () => {
renderSheet({ currentId: 'd' });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'd');
});
fireEvent.keyDown(listbox, { key: 'Home' });
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
});
});
describe('DetailNavigationSheet — selection', () => {
it('clicking an option navigates and closes the sheet', async () => {
const user = userEvent.setup();
renderSheet({ currentId: 'c' });
const listbox = await screen.findByRole('listbox');
await user.click(within(listbox).getByRole('option', { name: 'Alpha' }));
await waitFor(() => {
expect(screen.queryByRole('listbox', { name: 'Items' })).not.toBeInTheDocument();
});
expect(screen.getByTestId('location').textContent).toContain('/items/a');
});
it('narrows the listbox to filtered items', async () => {
renderSheet({ currentId: 'a', filter: 'pha' });
const listbox = await screen.findByRole('listbox', { name: 'Items' });
await waitFor(() => {
const labels = within(listbox)
.getAllByRole('option')
.map((option) => option.textContent ?? '');
expect(labels).toEqual(['Alpha']);
});
});
});
@@ -0,0 +1,310 @@
import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
import type { DetailNavigationController } from './use-detail-navigation';
interface DetailNavigationSheetProps<T extends { id: string }> {
controller: DetailNavigationController<T>;
renderItem?: (item: T, isCurrent: boolean) => ReactNode;
sheetIcon?: ReactNode;
sheetTitle: string;
}
/**
* Listbox-style overlay listing the navigable subset.
*
* Implements the WAI-ARIA single-select listbox pattern with **roving
* tabindex**: only the currently-focused option carries `tabIndex={0}`,
* the rest are `tabIndex={-1}`. Tab takes the user *past* the listbox in
* one step; arrow keys move focus *within* it.
*
* 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 const DetailNavigationSheet = <T extends { id: string }>({
controller,
renderItem,
sheetIcon,
sheetTitle,
}: DetailNavigationSheetProps<T>) => {
// Destructure at the top so existing `useMemo` / `useEffect` deps below
// read individual fields rather than the controller object — keeps the
// identity story the same as before the refactor.
const {
currentId,
currentIndex,
filteredItems: items,
getId,
getLabel,
handleItemSelect: onItemSelect,
isSheetOpen: open,
setSheetOpen: onOpenChange,
total,
} = controller;
const listRef = useRef<HTMLUListElement>(null);
const buttonRefs = useRef(new Map<string, HTMLButtonElement>());
const [focusedId, setFocusedId] = useState<null | string>(null);
const hasEntries = items.length > 0;
// Build an `id → index` map once per `items`/`getId` change so both the
// per-render membership check below and the keyboard handler's lookup
// stay O(1) instead of O(n). The IIFE that adjusts focus during render
// previously called `items.some(...)` on every commit; the keyboard
// handler ran `items.findIndex(...)` on every keystroke. Sharing one
// structure between the two also makes the contract explicit: an entry
// is "in the filtered subset" iff `indexById.has(id)`.
const indexById = useMemo(() => {
const map = new Map<string, number>();
items.forEach((item, index) => {
map.set(String(getId(item)), index);
});
return map;
}, [items, getId]);
// Single render-phase focus reconciliation. React's "adjust state when a
// prop changes" idiom — see https://react.dev/reference/react/useState#storing-information-from-previous-renders
// — collapsed into one comparison so the next desired focus is decided
// once per render and committed in the same pass that prompted it (no
// flash of stale focus, no double-setState ping-pong on edge cases like
// "items change while the sheet was reopening with no current item").
//
// Priorities, top-down:
// 1. open→close / close→open transition: re-pin to `currentId` (or the
// first entry when no current exists) on open, and clear on close.
// 2. While the sheet stays open, if the focused entry left the
// filtered subset (list page narrowed the filter behind it), fall
// back to the first survivor — otherwise the keyboard model would
// stall on a row that's no longer rendered.
// 3. Otherwise hold whatever focus the user chose via arrow keys.
//
// `lastOpen` starts at `false` so an initial `open=true` still trips
// the open transition on the very first render.
const [lastOpen, setLastOpen] = useState(false);
const desiredFocusId = (() => {
if (lastOpen !== open) {
if (!open) {
return null;
}
const firstItem = items[0];
if (!firstItem) {
return null;
}
// `currentId != null` narrows to `string`; the controller has
// already verified `currentId` belongs to the filtered subset
// when it computed `currentIndex`, so no re-scan needed.
return currentId != null && currentIndex >= 0 ? String(currentId) : String(getId(firstItem));
}
if (open && focusedId !== null && hasEntries && !indexById.has(focusedId)) {
const fallbackItem = items[0];
return fallbackItem ? String(getId(fallbackItem)) : null;
}
return focusedId;
})();
if (lastOpen !== open) {
setLastOpen(open);
}
if (desiredFocusId !== focusedId) {
setFocusedId(desiredFocusId);
}
// After roving focus moves, push the focus into the DOM. `rAF` defers past
// Radix's own focus management so we don't fight its open-time focus trap.
useEffect(() => {
if (!open || focusedId === null) {
return;
}
const id = requestAnimationFrame(() => {
const node = buttonRefs.current.get(focusedId);
if (!node) {
return;
}
node.focus();
if (focusedId === String(currentId ?? '')) {
node.scrollIntoView({ block: 'center' });
}
});
return () => cancelAnimationFrame(id);
}, [open, focusedId, currentId]);
// Translate arrow / Home / End into roving moves over `items`. Using the
// array index instead of `querySelectorAll` keeps the keyboard model in
// step with the React tree even if the sheet ever virtualises the list.
// O(1) lookup via the shared `indexById` map — `findIndex` would scan on
// every keystroke for nothing.
const handleListKeyDown = useCallback(
(event: KeyboardEvent<HTMLUListElement>) => {
if (!hasEntries || focusedId === null) {
return;
}
const focusedIndex = indexById.get(focusedId);
if (focusedIndex === undefined) {
return;
}
const moveTo = (index: number) => {
event.preventDefault();
const target = items[index];
if (target) {
setFocusedId(String(getId(target)));
}
};
if (event.key === 'ArrowDown') {
moveTo(Math.min(focusedIndex + 1, items.length - 1));
return;
}
if (event.key === 'ArrowUp') {
moveTo(Math.max(focusedIndex - 1, 0));
return;
}
if (event.key === 'Home') {
moveTo(0);
return;
}
if (event.key === 'End') {
moveTo(items.length - 1);
}
},
[focusedId, getId, hasEntries, indexById, items],
);
const handleItemClick = useCallback(
(item: T) => {
onItemSelect(item);
},
[onItemSelect],
);
// One stable callback ref reused for every button. The previous shape —
// `setButtonRef(id) => (node) => …` — manufactured a new closure per id
// on every render, which made React re-attach refs (a `delete` + `set`
// round-trip on the `Map`) on every list re-render. Reading the id from
// `data-item-id` keeps the closure identity-stable and the React-19
// cleanup return value handles unmount without leaks.
const setButtonRef = useCallback((node: HTMLButtonElement | null) => {
if (!node) {
return;
}
const id = node.dataset.itemId;
if (!id) {
return;
}
buttonRefs.current.set(id, node);
return () => {
buttonRefs.current.delete(id);
};
}, []);
return (
<Sheet
onOpenChange={onOpenChange}
open={open}
>
<SheetContent
// Radix expects either a `<Description>` or an explicit
// `aria-describedby={undefined}` opt-out. The sheet is just a
// listbox of items, the `SheetTitle` already describes it.
aria-describedby={undefined}
className="flex w-full max-w-sm flex-col gap-0 p-0 sm:max-w-sm"
side="right"
>
<SheetHeader className="border-b p-4">
<SheetTitle className="flex items-center gap-2 pr-8 text-base">
{sheetIcon}
<span>{sheetTitle}</span>
<Badge
className="ml-auto font-normal tabular-nums"
variant="secondary"
>
{total}
</Badge>
</SheetTitle>
</SheetHeader>
{hasEntries ? (
<ScrollArea className="flex-1">
<ul
aria-label={sheetTitle}
className="flex flex-col gap-0.5 p-2"
onKeyDown={handleListKeyDown}
ref={listRef}
role="listbox"
>
{items.map((item) => {
const id = String(getId(item));
const isCurrent = currentId != null && id === String(currentId);
const isFocused = id === focusedId;
return (
<li
key={id}
role="presentation"
>
<button
aria-selected={isCurrent}
className={cn(
'hover:bg-muted/50 focus-visible:ring-ring flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm focus-visible:ring-2 focus-visible:outline-hidden',
isCurrent && 'bg-muted text-foreground font-medium',
)}
data-item-id={id}
onClick={() => handleItemClick(item)}
onFocus={() => setFocusedId(id)}
ref={setButtonRef}
role="option"
tabIndex={isFocused ? 0 : -1}
type="button"
>
{renderItem ? (
renderItem(item, isCurrent)
) : (
<span className="truncate">{getLabel(item)}</span>
)}
</button>
</li>
);
})}
</ul>
</ScrollArea>
) : (
<div className="text-muted-foreground flex flex-1 items-center justify-center px-4 text-center text-sm">
No items match the current filter.
</div>
)}
</SheetContent>
</Sheet>
);
};
@@ -0,0 +1,138 @@
import type { ReactNode } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
import { DetailNavigationToolbar } from './detail-navigation-toolbar';
import { useDetailNavigation } from './use-detail-navigation';
interface Item {
id: string;
title: string;
}
const ITEMS: readonly Item[] = [
{ id: 'a', title: 'Alpha' },
{ id: 'b', title: 'Bravo' },
{ id: 'c', title: 'Charlie' },
{ id: 'd', title: 'Delta' },
] as const;
const getHref = (item: Item) => `/items/${item.id}`;
const getLabel = (item: Item) => item.title;
const LocationReadout = () => {
const { pathname, search } = useLocation();
return (
<span data-testid="location">
{pathname}
{search}
</span>
);
};
interface HarnessProps {
currentId?: null | string;
filter?: string;
items?: readonly Item[];
}
const ToolbarHarness = ({ currentId = 'c', items = ITEMS }: HarnessProps) => {
const nav = useDetailNavigation<Item>({
currentId,
getHref,
getLabel,
items,
});
return (
<DetailNavigationToolbar<Item>
controller={nav}
sheetTitle="Items"
/>
);
};
const renderToolbar = (props: HarnessProps = {}) => {
const filter = props.filter ?? '';
const initialId = props.currentId ?? 'c';
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[`/items/${initialId}?q=${filter}`]}>
<TooltipProvider>
<LocationReadout />
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</TooltipProvider>
</MemoryRouter>
);
return render(<ToolbarHarness {...props} />, { wrapper: Wrapper });
};
describe('DetailNavigationToolbar', () => {
it('renders nothing when raw items is empty', () => {
renderToolbar({ items: [] });
expect(screen.queryByRole('button', { name: /Previous/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Next/i })).not.toBeInTheDocument();
});
it('composes Buttons + Sheet: position button opens the listbox', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'c' });
// Buttons present (smoke).
expect(screen.getByRole('button', { name: /Previous/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Next/i })).toBeInTheDocument();
// Position trigger opens the sheet.
await user.click(screen.getByRole('button', { name: /3\/4/ }));
const listbox = await screen.findByRole('listbox', { name: 'Items' });
expect(listbox).toBeInTheDocument();
});
it('shows `/total` when current is missing from the filtered subset', () => {
renderToolbar({ currentId: 'zzz' });
expect(screen.getByRole('button', { name: /\/4/ })).toBeInTheDocument();
});
it('disables Prev for the first item', () => {
renderToolbar({ currentId: 'a' });
expect(screen.getByRole('button', { name: /Previous/i })).toBeDisabled();
});
it('disables Next for the last item', () => {
renderToolbar({ currentId: 'd' });
expect(screen.getByRole('button', { name: /Next/i })).toBeDisabled();
});
it('Next navigates to the next sibling preserving `?q=`', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'a', filter: 'a' });
await user.click(screen.getByRole('button', { name: /Next/i }));
await waitFor(() => {
expect(screen.getByTestId('location').textContent).toContain('/items/b');
});
expect(screen.getByTestId('location').textContent).toContain('q=a');
});
it('disables the position button when the filter excludes every item', async () => {
renderToolbar({ currentId: 'c', filter: 'qqqqqqqqq' });
// After debounce settles, the empty subset disables the trigger.
await waitFor(() => {
expect(screen.getByRole('button', { name: /\/0/ })).toBeDisabled();
});
});
});
@@ -0,0 +1,49 @@
import type { ReactNode } from 'react';
import type { DetailNavigationController } from './use-detail-navigation';
import { DetailNavigationButtons } from './detail-navigation-buttons';
import { DetailNavigationSheet } from './detail-navigation-sheet';
export interface DetailNavigationToolbarProps<T extends { id: string }> {
controller: DetailNavigationController<T>;
renderItem?: (item: T, isCurrent: boolean) => ReactNode;
sheetIcon?: ReactNode;
sheetTitle: string;
}
/**
* Convenience wrapper that composes `<DetailNavigationButtons>` and
* `<DetailNavigationSheet>` against a single `DetailNavigationController`.
* Most desktop call sites use this directly; pages with non-standard chrome
* (e.g. mobile prev/position/next inside a `<DropdownMenuItem>`) can compose
* the leaves themselves and read from the same controller.
*
* Renders `null` when the controller reports `itemsEmpty` — saves the user
* from a momentary "/0" flash while the parent provider's data is in flight.
*/
export const DetailNavigationToolbar = <T extends { id: string }>({
controller,
renderItem,
sheetIcon,
sheetTitle,
}: DetailNavigationToolbarProps<T>) => {
if (controller.itemsEmpty) {
return null;
}
return (
<>
<DetailNavigationButtons
controller={controller}
sheetTitle={sheetTitle}
/>
<DetailNavigationSheet
controller={controller}
renderItem={renderItem}
sheetIcon={sheetIcon}
sheetTitle={sheetTitle}
/>
</>
);
};
@@ -0,0 +1,4 @@
export { DetailNavigationButtons } from './detail-navigation-buttons';
export { DetailNavigationSheet } from './detail-navigation-sheet';
export { DetailNavigationToolbar } from './detail-navigation-toolbar';
export { type DetailNavigationController, useDetailNavigation } from './use-detail-navigation';
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { createTextMatcher, matchesTextFilter, normalizeForFilter } from './text-filter';
describe('normalizeForFilter', () => {
it('lowercases ASCII text', () => {
expect(normalizeForFilter('FooBar')).toBe('foobar');
});
it('strips combining diacritics so accented characters fold to plain', () => {
expect(normalizeForFilter('café')).toBe('cafe');
expect(normalizeForFilter('résumé')).toBe('resume');
expect(normalizeForFilter('naïve')).toBe('naive');
});
it('is idempotent', () => {
const once = normalizeForFilter('Café');
expect(normalizeForFilter(once)).toBe(once);
});
});
describe('createTextMatcher', () => {
it('returns a matcher that accepts everything for an empty query', () => {
const matcher = createTextMatcher('');
expect(matcher('anything')).toBe(true);
expect(matcher('')).toBe(true);
expect(matcher(null)).toBe(true);
expect(matcher(undefined)).toBe(true);
});
it('matches case-insensitively', () => {
const matcher = createTextMatcher('Foo');
expect(matcher('foo')).toBe(true);
expect(matcher('FOO')).toBe(true);
expect(matcher('hello FOO bar')).toBe(true);
expect(matcher('bar')).toBe(false);
});
it('matches across diacritic-folded forms', () => {
const matcher = createTextMatcher('cafe');
expect(matcher('café')).toBe(true);
expect(matcher('Café au lait')).toBe(true);
expect(matcher('CAFÉ')).toBe(true);
});
it('folds diacritics in the query too', () => {
const matcher = createTextMatcher('Café');
expect(matcher('cafe')).toBe(true);
expect(matcher('CAFE')).toBe(true);
});
it('returns false for null and undefined text when query is non-empty', () => {
const matcher = createTextMatcher('foo');
expect(matcher(null)).toBe(false);
expect(matcher(undefined)).toBe(false);
});
it('does substring matching, not whole-word or prefix matching', () => {
const matcher = createTextMatcher('ell');
expect(matcher('hello')).toBe(true);
expect(matcher('shell shocked')).toBe(true);
expect(matcher('apricot')).toBe(false);
});
it('preserves whitespace exactly — does not trim the query', () => {
const matcher = createTextMatcher(' foo ');
expect(matcher(' foo ')).toBe(true);
expect(matcher('foo')).toBe(false);
});
it('returns the same matcher behaviour across many invocations (no internal state leak)', () => {
const matcher = createTextMatcher('abc');
for (let i = 0; i < 10; i += 1) {
expect(matcher('xxabcyy')).toBe(true);
expect(matcher('xyz')).toBe(false);
}
});
});
describe('matchesTextFilter', () => {
it('delegates to createTextMatcher and produces the same answers', () => {
expect(matchesTextFilter('hello', 'ell')).toBe(true);
expect(matchesTextFilter('hello', 'world')).toBe(false);
expect(matchesTextFilter(null, 'foo')).toBe(false);
expect(matchesTextFilter(null, '')).toBe(true);
});
it('folds diacritics symmetrically on both sides', () => {
expect(matchesTextFilter('résumé.pdf', 'resume')).toBe(true);
expect(matchesTextFilter('resume.pdf', 'résumé')).toBe(true);
});
});
@@ -0,0 +1,55 @@
/**
* Normalize a string for case- and diacritic-insensitive substring matching.
*
* `NFKD` decomposes accented characters into base + combining marks, then we
* strip the combining marks (`\p{Diacritic}` regex class) so e.g. `café`
* matches `cafe`. Lowercasing happens last to fold case differences.
*
* Exported for callers that need to align their own search semantics with the
* one this module uses (e.g. server-side prefiltering).
*/
export const normalizeForFilter = (text: string): string =>
text
.normalize('NFKD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase();
/**
* Build a reusable text-matcher specialised for `query`. The query is
* normalized + lowercased once at factory time — when the resulting matcher
* is invoked N times during list filtering, that work is amortised to one
* allocation instead of N.
*
* The matcher uses substring matching semantics, with case + diacritic
* folding (`café` matches `cafe`). It is intentionally close to TanStack
* Table's default `'includesString'` so a single instance can drive both
* the list page's column filter and the detail page's Prev/Next subset
* without the two paths drifting out of sync.
*
* - Empty query → every row passes (matcher returns `true`).
* - `text === null | undefined` with a non-empty query → no match.
*/
export const createTextMatcher = (query: string): ((text: null | string | undefined) => boolean) => {
if (!query.length) {
return () => true;
}
const normalizedQuery = normalizeForFilter(query);
return (text) => {
if (text === null || text === undefined) {
return false;
}
return normalizeForFilter(text).includes(normalizedQuery);
};
};
/**
* One-shot variant of {@link createTextMatcher} for callers that need a
* single comparison and don't want to bother with the factory. Equivalent to
* `createTextMatcher(query)(text)` — kept as a thin wrapper for readability
* at call sites and to preserve the original API.
*/
export const matchesTextFilter = (text: null | string | undefined, query: string): boolean =>
createTextMatcher(query)(text);
@@ -0,0 +1,479 @@
import type { ReactNode } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useDetailNavigation } from './use-detail-navigation';
interface Item {
id: string;
title: string;
}
const ITEMS: readonly Item[] = [
{ id: 'a', title: 'Alpha' },
{ id: 'b', title: 'Bravo' },
{ id: 'c', title: 'Charlie' },
] as const;
const getHref = (item: Item) => `/items/${item.id}`;
const getLabel = (item: Item) => item.title;
const getSearchableText = (item: Item) => item.title;
const renderInRoute = (initialEntries: string[]) => {
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</MemoryRouter>
);
return Wrapper;
};
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
describe('useDetailNavigation — default getId', () => {
it('uses item.id when no `getId` override is provided', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.getId(ITEMS[1])).toBe('b');
});
});
describe('useDetailNavigation — identity stability', () => {
it('keeps the controller reference stable across re-renders with unchanged inputs', () => {
const { rerender, result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
const first = result.current;
rerender();
// Downstream leaf components rely on this identity stability.
expect(result.current).toBe(first);
});
it('keeps `goToPrev` / `goToNext` / `handleItemSelect` identity-stable when nothing relevant changes', () => {
const { rerender, result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
const firstGoPrev = result.current.goToPrev;
const firstGoNext = result.current.goToNext;
const firstSelect = result.current.handleItemSelect;
const firstOpen = result.current.openSheet;
const firstSet = result.current.setSheetOpen;
rerender();
expect(result.current.goToPrev).toBe(firstGoPrev);
expect(result.current.goToNext).toBe(firstGoNext);
expect(result.current.handleItemSelect).toBe(firstSelect);
expect(result.current.openSheet).toBe(firstOpen);
expect(result.current.setSheetOpen).toBe(firstSet);
});
});
describe('useDetailNavigation — filter forwarding', () => {
it('exposes the URL `?q=` value through `controller.debouncedFilter` (debounced)', async () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b?q=alpha']) },
);
// `debouncedFilter` settles after the default 200ms debounce.
await waitFor(() => {
expect(result.current.debouncedFilter).toBe('alpha');
});
});
it('narrows `filteredItems` to the matching subset once the filter settles', async () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/a?q=pha']) },
);
await waitFor(() => {
expect(result.current.filteredItems.map((item) => item.id)).toEqual(['a']);
});
expect(result.current.total).toBe(1);
expect(result.current.currentIndex).toBe(0);
});
});
describe('useDetailNavigation — derived state', () => {
it('reports prev/next neighbours for a middle item', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.currentIndex).toBe(1);
expect(result.current.prevId).toBe('a');
expect(result.current.nextId).toBe('c');
expect(result.current.total).toBe(3);
expect(result.current.hasEntries).toBe(true);
expect(result.current.itemsEmpty).toBe(false);
expect(result.current.positionLabel).toBe('2/3');
});
it('reports `-1` index and `/total` label when currentId is not in subset', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'zzz',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/zzz']) },
);
expect(result.current.currentIndex).toBe(-1);
expect(result.current.prevId).toBeNull();
expect(result.current.nextId).toBeNull();
expect(result.current.positionLabel).toBe('/3');
});
it('reports `itemsEmpty=true` and `/0` when input list is empty', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: [],
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.itemsEmpty).toBe(true);
expect(result.current.total).toBe(0);
expect(result.current.hasEntries).toBe(false);
expect(result.current.positionLabel).toBe('/0');
});
});
describe('useDetailNavigation — navigation actions', () => {
it('goToNext() navigates to the next sibling preserving `?q=`', async () => {
const LocationProbe = ({ onChange }: { onChange: (loc: string) => void }) => {
const { pathname, search } = useLocation();
onChange(`${pathname}${search}`);
return null;
};
const seen: string[] = [];
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/items/a?q=a']}>
<LocationProbe onChange={(loc) => seen.push(loc)} />
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</MemoryRouter>
);
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: Wrapper },
);
await waitFor(() => {
expect(result.current.debouncedFilter).toBe('a');
});
act(() => {
result.current.goToNext();
});
await waitFor(() => {
expect(seen.at(-1)).toContain('/items/b');
});
expect(seen.at(-1)).toContain('q=a');
});
it('goToPrev() is a no-op when prevId is null (no navigate)', async () => {
const LocationProbe = ({ onChange }: { onChange: (loc: string) => void }) => {
const { pathname } = useLocation();
onChange(pathname);
return null;
};
const seen: string[] = [];
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/items/a']}>
<LocationProbe onChange={(loc) => seen.push(loc)} />
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</MemoryRouter>
);
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: Wrapper },
);
expect(result.current.prevId).toBeNull();
const before = seen.length;
act(() => {
result.current.goToPrev();
});
// No path change emitted.
expect(seen.length).toBe(before);
});
it('handleItemSelect closes the sheet *before* navigating', async () => {
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/items/a']}>
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</MemoryRouter>
);
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
defaultOpen: true,
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: Wrapper },
);
expect(result.current.isSheetOpen).toBe(true);
act(() => {
result.current.handleItemSelect(ITEMS[2]);
});
// Sheet closes synchronously inside the same call — the navigate
// that follows can't race with a still-mounted sheet.
expect(result.current.isSheetOpen).toBe(false);
});
});
describe('useDetailNavigation — controlled sheet mode', () => {
it('respects `open={true}` even when `setSheetOpen(false)` is called', () => {
const onOpenChange = vi.fn();
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
onOpenChange,
open: true,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.isSheetOpen).toBe(true);
act(() => {
result.current.setSheetOpen(false);
});
// Parent owns the state — controller doesn't flip without a prop change.
expect(result.current.isSheetOpen).toBe(true);
// But `onOpenChange` fires so the parent can observe the request.
expect(onOpenChange).toHaveBeenLastCalledWith(false);
});
it('toggles internal state and fires onOpenChange in uncontrolled mode', () => {
const onOpenChange = vi.fn();
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
onOpenChange,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.isSheetOpen).toBe(false);
act(() => {
result.current.openSheet();
});
expect(result.current.isSheetOpen).toBe(true);
expect(onOpenChange).toHaveBeenLastCalledWith(true);
act(() => {
result.current.closeSheet();
});
expect(result.current.isSheetOpen).toBe(false);
expect(onOpenChange).toHaveBeenLastCalledWith(false);
});
it('honours defaultOpen=true on first render in uncontrolled mode', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
defaultOpen: true,
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.isSheetOpen).toBe(true);
});
});
describe('useDetailNavigation — current item bookkeeping', () => {
it('treats a missing currentId as "no match" without throwing', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: undefined,
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.currentId).toBeNull();
expect(result.current.currentIndex).toBe(-1);
expect(result.current.filteredItems).toEqual(ITEMS);
});
});
describe('useDetailNavigation — navigation back to list does not leak the filter', () => {
it('keeps the URL `?q=` intact after a navigate inside the same router', async () => {
const Harness = () => {
const navigate = useNavigate();
const nav = useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
});
return { nav, navigate };
};
const { result } = renderHook(() => Harness(), {
wrapper: renderInRoute(['/items/b?q=alpha']),
});
await waitFor(() => {
expect(result.current.nav.debouncedFilter).toBe('alpha');
});
// Navigating to another detail page should not alter the filter
// value the hook observes — the URL still carries it.
act(() => {
result.current.navigate('/items/a?q=alpha');
});
await waitFor(() => {
expect(result.current.nav.debouncedFilter).toBe('alpha');
});
});
});
@@ -0,0 +1,323 @@
import { useCallback, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useLatestRef } from '@/hooks/use-latest-ref';
import { usePageStorageKeys } from '@/hooks/use-page-storage-keys';
import { useTableQueryFilterReader } from '@/hooks/use-table-query-filter';
import { mergeHrefWithSearchParams } from '@/lib/url-params';
import { useNavigation } from './use-navigation';
/**
* Headless controller for a detail page that walks a filtered list.
*
* Owns:
* - the filtered/sorted subset (pure `computeNavigation`),
* - the resolved Prev / Next sibling ids and the pre-formatted position
* label (so leaf components don't recompute),
* - the sheet open state (controllable via `open` / `onOpenChange`),
* - navigation actions that thread the current `?<filter>=` into every
* prev / next / item-select destination.
*
* All callbacks are identity-stable across renders that don't change the
* inputs the action depends on — `<DetailNavigationButtons>`,
* `<DetailNavigationSheet>`, and any custom chrome rendered against the
* controller can rely on referential equality for downstream memos.
*/
export interface DetailNavigationController<T extends { id: string }> {
closeSheet: () => void;
/** Active `currentId` coerced to a string, or `null` when absent. */
currentId: null | string;
/** Index of `currentItem` inside `filteredItems`. `-1` when not in subset. */
currentIndex: number;
/** Item matching `currentId` inside the filtered subset, or `null`. */
currentItem: null | T;
/** Debounced URL filter the controller is filtering against. */
debouncedFilter: string;
/** Sorted+filtered subset that drives prev / next / sheet listing. */
filteredItems: readonly T[];
/** Stable id accessor (defaults to `item.id` when not supplied). */
getId: (item: T) => string;
getLabel: (item: T) => string;
/**
* Resolved haystack accessor — the caller-supplied `getSearchableText`,
* or `getLabel` as a fallback. Exposed for advanced consumers; leaf
* components don't read it because filtering already happened on the
* way into `filteredItems`.
*/
getSearchableText: (item: T) => null | string | undefined;
goToNext: () => void;
goToPrev: () => void;
/** Navigate to the given item and close the sheet. */
handleItemSelect: (item: T) => void;
/** `true` iff `filteredItems.length > 0`. */
hasEntries: boolean;
isSheetOpen: boolean;
/**
* `true` iff the raw `items` array is empty (pre-filter). The convenience
* `<DetailNavigationToolbar>` uses this to render `null` on a fresh detail
* mount when the provider's list hasn't arrived yet.
*/
itemsEmpty: boolean;
/** ID of the next filtered sibling, or `null` at the end / off-subset. */
nextId: null | string;
openSheet: () => void;
/** Pre-formatted `"3/10"` or `"/0"` for the position trigger. */
positionLabel: string;
/** ID of the previous filtered sibling, or `null` at the start / off-subset. */
prevId: null | string;
setSheetOpen: (open: boolean) => void;
/** Same as `filteredItems.length`, named explicitly for clarity. */
total: number;
}
/**
* Restricts the override to strings that start with a `/`. Pure-string types
* (`string`) would let an empty `""` or a bare `"flows"` slip through and
* either collide on the shared `filter_4_` key or generate a different slot
* than the actual list page. Template-literal types catch this at compile
* time without runtime guards.
*/
type ParentPath = `/${string}`;
interface UseDetailNavigationOptions<T extends { id: string }> {
currentId: null | string | undefined;
/** Initial value for the uncontrolled case. Defaults to `false`. */
defaultOpen?: boolean;
getHref: (item: T) => string;
getId?: (item: T) => string;
getLabel: (item: T) => string;
getSearchableText?: (item: T) => null | string | undefined;
items: readonly T[];
onOpenChange?: (open: boolean) => void;
/**
* Controlled-mode opt-in for the sheet. When `open` is `undefined` the
* controller owns the state internally; when a value is provided the
* caller owns it. `onOpenChange` always fires so a fully-controlled
* consumer can observe every set.
*
* Mirrors the `useControllable` pattern from
* `@/components/ui/autocomplete.tsx`.
*/
open?: boolean;
/**
* Optional override for the parent list path used to look up the shared
* filter storage slot. Defaults to the top-level segment of the current
* pathname, which works for top-level routes (`/flows`, `/templates`,
* `/knowledges`). Nested routes (`/admin/flows/:id`) must pass an
* explicit value here because the default would key into `/admin`
* rather than `/admin/flows`.
*/
parentPath?: ParentPath;
sortFn?: (a: T, b: T) => number;
}
// Module-level so the reference is stable across renders. Typed against
// the wider `{ id: string }` so it is assignable to `(item: T) => string`
// for any `T extends { id: string }` via function-parameter contravariance
// — no cast at the call site.
const defaultGetId = (item: { id: string }): string => item.id;
/**
* Build the headless `DetailNavigationController<T>` for a detail page.
*
* Bundles the four moving parts every detail page repeats:
* 1. resolving the parent list's storage slot via `usePageStorageKeys`,
* 2. subscribing to the URL filter through `useTableQueryFilterReader`
* (read-only — the detail page never mutates the filter from here),
* 3. running the pure `useNavigation` core against the filtered subset,
* 4. wiring identity-stable `goToPrev` / `goToNext` / `handleItemSelect`
* with `?<filter>=` forwarded through `mergeHrefWithSearchParams`.
*
* The returned controller drives `<DetailNavigationToolbar>`,
* `<DetailNavigationButtons>`, and `<DetailNavigationSheet>` (or any custom
* chrome a consumer wants to render). All function fields are wrapped in
* `useCallback` with `useLatestRef`-stabilized closures over `useSearchParams`
* and the caller-supplied accessors — React Router v6 returns a fresh
* `URLSearchParams` each render, and threading it through callback deps would
* defeat the entire memoization goal.
*
* Pass per-feature callbacks through `useCallback` (or, as the existing
* `use-flow-detail-navigation` / `use-template-detail-navigation` /
* `use-knowledge-detail-navigation` do, hoist them to module scope). An
* inline arrow would still work — `useLatestRef` reads the most recent
* version at fire time — but it forfeits `useNavigation`'s internal
* memoization on `getSearchableText`.
*/
export const useDetailNavigation = <T extends { id: string }>({
currentId,
defaultOpen,
getHref,
getId,
getLabel,
getSearchableText,
items,
onOpenChange,
open,
parentPath,
sortFn,
}: UseDetailNavigationOptions<T>): DetailNavigationController<T> => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// `parentPath` is typed as `/${string}` so the empty / unprefixed case
// is rejected at compile time — we only need to branch on presence here.
const hasExplicitParentPath = parentPath !== undefined;
const { table: filterStorageKey } = usePageStorageKeys({
pathname: hasExplicitParentPath ? parentPath : undefined,
useTopLevel: !hasExplicitParentPath,
});
const { debouncedFilter } = useTableQueryFilterReader({ storageKey: filterStorageKey });
// Memoize so passing `getId={undefined}` keeps the reference stable when
// the caller re-renders for unrelated reasons. Without the memo the
// downstream `useNavigation` memo would invalidate on every render.
const resolvedGetId = useMemo<(item: T) => string>(() => getId ?? defaultGetId, [getId]);
const resolvedGetSearchableText = useMemo<(item: T) => null | string | undefined>(
() => getSearchableText ?? getLabel,
[getSearchableText, getLabel],
);
const { currentIndex, currentItem, filteredItems, nextId, prevId, total } = useNavigation<T>({
currentId,
getId: resolvedGetId,
getSearchableText: resolvedGetSearchableText,
items,
query: debouncedFilter,
sortFn,
});
// Controlled-mode sheet state (mirrors `useControllable` from
// `components/ui/autocomplete.tsx:27-46`). When `open` is provided the
// caller owns the state; otherwise the controller owns it.
// `onOpenChange` fires on every set so fully-controlled consumers can
// observe transitions.
const onOpenChangeRef = useLatestRef(onOpenChange);
const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);
const isOpenControlled = open !== undefined;
const isSheetOpen = isOpenControlled ? open : internalOpen;
const setSheetOpen = useCallback(
(next: boolean) => {
if (!isOpenControlled) {
setInternalOpen(next);
}
onOpenChangeRef.current?.(next);
},
[isOpenControlled, onOpenChangeRef],
);
const openSheet = useCallback(() => setSheetOpen(true), [setSheetOpen]);
const closeSheet = useCallback(() => setSheetOpen(false), [setSheetOpen]);
// `useSearchParams` from React Router v6 returns a fresh `URLSearchParams`
// every render. Threading it (or any caller-supplied accessor that might
// not be stable) through `useCallback` deps would invalidate every
// navigation callback on every render. Stash through `useLatestRef` and
// read at fire-time instead — handlers fire from user clicks / keyboard
// events, so the one-commit lag documented on `useLatestRef` never bites.
const searchParamsRef = useLatestRef(searchParams);
const getHrefRef = useLatestRef(getHref);
const getIdRef = useLatestRef(resolvedGetId);
const filteredItemsRef = useLatestRef(filteredItems);
const buildHref = useCallback(
(item: T) => mergeHrefWithSearchParams(getHrefRef.current(item), searchParamsRef.current),
[getHrefRef, searchParamsRef],
);
const handleItemSelect = useCallback(
(item: T) => {
// Close the sheet *before* navigating — preserves the pre-refactor
// ordering so a route change can't unmount the sheet while its
// close callback is still in flight.
setSheetOpen(false);
navigate(buildHref(item), { replace: true });
},
[buildHref, navigate, setSheetOpen],
);
const goTo = useCallback(
(id: null | string) => {
if (!id) {
return;
}
const target = filteredItemsRef.current.find((item) => String(getIdRef.current(item)) === id);
if (!target) {
return;
}
navigate(buildHref(target), { replace: true });
},
[buildHref, filteredItemsRef, getIdRef, navigate],
);
const goToPrev = useCallback(() => goTo(prevId), [goTo, prevId]);
const goToNext = useCallback(() => goTo(nextId), [goTo, nextId]);
const positionLabel = useMemo(
() => (total === 0 || currentIndex === -1 ? `/${total}` : `${currentIndex + 1}/${total}`),
[currentIndex, total],
);
const normalizedCurrentId = currentId != null ? String(currentId) : null;
const hasEntries = filteredItems.length > 0;
const itemsEmpty = items.length === 0;
return useMemo<DetailNavigationController<T>>(
() => ({
closeSheet,
currentId: normalizedCurrentId,
currentIndex,
currentItem,
debouncedFilter,
filteredItems,
getId: resolvedGetId,
getLabel,
getSearchableText: resolvedGetSearchableText,
goToNext,
goToPrev,
handleItemSelect,
hasEntries,
isSheetOpen,
itemsEmpty,
nextId,
openSheet,
positionLabel,
prevId,
setSheetOpen,
total,
}),
[
closeSheet,
normalizedCurrentId,
currentIndex,
currentItem,
debouncedFilter,
filteredItems,
resolvedGetId,
getLabel,
resolvedGetSearchableText,
goToNext,
goToPrev,
handleItemSelect,
hasEntries,
isSheetOpen,
itemsEmpty,
nextId,
openSheet,
positionLabel,
prevId,
setSheetOpen,
total,
],
);
};
@@ -0,0 +1,280 @@
import { describe, expect, it } from 'vitest';
import { computeNavigation } from './use-navigation';
interface Row {
id: string;
title: string;
}
const getId = (row: Row) => row.id;
const getTitle = (row: Row) => row.title;
const ROWS: readonly Row[] = [
{ id: 'a', title: 'Alpha' },
{ id: 'b', title: 'Bravo' },
{ id: 'c', title: 'Charlie' },
{ id: 'd', title: 'Delta' },
] as const;
describe('computeNavigation', () => {
it('returns prev/next neighbours for a middle item', () => {
const result = computeNavigation({
currentId: 'c',
getId,
items: ROWS,
});
expect(result.currentIndex).toBe(2);
expect(result.prevId).toBe('b');
expect(result.nextId).toBe('d');
expect(result.total).toBe(4);
});
it('returns null prev for the first item', () => {
const result = computeNavigation({
currentId: 'a',
getId,
items: ROWS,
});
expect(result.currentIndex).toBe(0);
expect(result.prevId).toBeNull();
expect(result.nextId).toBe('b');
});
it('returns null next for the last item', () => {
const result = computeNavigation({
currentId: 'd',
getId,
items: ROWS,
});
expect(result.currentIndex).toBe(3);
expect(result.prevId).toBe('c');
expect(result.nextId).toBeNull();
});
it('reports currentIndex=-1 when the current item is missing from the filtered subset', () => {
const result = computeNavigation({
currentId: 'zzz',
getId,
items: ROWS,
});
expect(result.currentIndex).toBe(-1);
expect(result.currentItem).toBeNull();
expect(result.prevId).toBeNull();
expect(result.nextId).toBeNull();
expect(result.total).toBe(4);
});
it('reports currentIndex=-1 when currentId is null or undefined', () => {
const nullResult = computeNavigation({
currentId: null,
getId,
items: ROWS,
});
expect(nullResult.currentIndex).toBe(-1);
expect(nullResult.prevId).toBeNull();
expect(nullResult.nextId).toBeNull();
const undefinedResult = computeNavigation({
currentId: undefined,
getId,
items: ROWS,
});
expect(undefinedResult.currentIndex).toBe(-1);
});
it('honours `query` when narrowing the subset', () => {
const result = computeNavigation({
currentId: 'c',
getId,
getSearchableText: getTitle,
items: ROWS,
// Substring "c" matches "Charlie" (case-insensitive).
query: 'c',
});
expect(result.filteredItems.map(getId)).toEqual(['c']);
expect(result.currentIndex).toBe(0);
expect(result.prevId).toBeNull();
expect(result.nextId).toBeNull();
expect(result.total).toBe(1);
});
it('drops the current item from the result when it does not match the query', () => {
const result = computeNavigation({
currentId: 'a',
getId,
getSearchableText: getTitle,
items: ROWS,
query: 'Bravo',
});
expect(result.filteredItems.map(getId)).toEqual(['b']);
expect(result.currentIndex).toBe(-1);
expect(result.prevId).toBeNull();
expect(result.nextId).toBeNull();
});
it('treats an empty/undefined query as "no filter" even when getSearchableText is provided', () => {
const empty = computeNavigation({
currentId: 'c',
getId,
getSearchableText: getTitle,
items: ROWS,
query: '',
});
expect(empty.filteredItems.map(getId)).toEqual(['a', 'b', 'c', 'd']);
const missing = computeNavigation({
currentId: 'c',
getId,
getSearchableText: getTitle,
items: ROWS,
});
expect(missing.filteredItems.map(getId)).toEqual(['a', 'b', 'c', 'd']);
});
it('skips filtering silently when query is non-empty but getSearchableText is missing', () => {
// 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 = computeNavigation({
currentId: 'c',
getId,
items: ROWS,
query: 'pha',
});
expect(result.filteredItems.map(getId)).toEqual(['a', 'b', 'c', 'd']);
});
it('preserves input order when no sortFn is provided', () => {
const reversed = [...ROWS].reverse();
const result = computeNavigation({
currentId: 'c',
getId,
items: reversed,
});
expect(result.filteredItems.map(getId)).toEqual(['d', 'c', 'b', 'a']);
expect(result.currentIndex).toBe(1);
expect(result.prevId).toBe('d');
expect(result.nextId).toBe('b');
});
it('applies sortFn to the filtered subset', () => {
const result = computeNavigation({
currentId: 'b',
getId,
items: ROWS,
sortFn: (a, b) => b.title.localeCompare(a.title),
});
expect(result.filteredItems.map(getId)).toEqual(['d', 'c', 'b', 'a']);
expect(result.currentIndex).toBe(2);
expect(result.prevId).toBe('c');
expect(result.nextId).toBe('a');
});
it('handles an empty items array', () => {
const result = computeNavigation({
currentId: 'anything',
getId,
items: [],
});
expect(result.filteredItems).toEqual([]);
expect(result.total).toBe(0);
expect(result.currentIndex).toBe(-1);
expect(result.prevId).toBeNull();
expect(result.nextId).toBeNull();
});
it('returns the current item when present', () => {
const result = computeNavigation({
currentId: 'c',
getId,
items: ROWS,
});
expect(result.currentItem).toEqual({ id: 'c', title: 'Charlie' });
});
it('does not mutate the input array even when sorting', () => {
const before = ROWS.map(getId);
computeNavigation({
currentId: 'a',
getId,
items: ROWS,
sortFn: (a, b) => b.title.localeCompare(a.title),
});
expect(ROWS.map(getId)).toEqual(before);
});
it('handles "item deleted, currentId still points to it"', () => {
// The user was viewing item `b`, then `b` was deleted (removed from
// `items`) — currentId stays `b` until the route updates. The hook
// 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 = computeNavigation({
currentId: 'b',
getId,
items: trimmed,
});
expect(result.filteredItems.map(getId)).toEqual(['a', 'c', 'd']);
expect(result.currentIndex).toBe(-1);
expect(result.prevId).toBeNull();
expect(result.nextId).toBeNull();
expect(result.total).toBe(3);
});
it('folds diacritics + case the same way the list filter does', () => {
const accented: readonly Row[] = [
{ id: '1', title: 'Café' },
{ id: '2', title: 'naïve' },
{ id: '3', title: 'resume' },
];
const result = computeNavigation({
currentId: '1',
getId,
getSearchableText: getTitle,
items: accented,
query: 'cafe',
});
expect(result.filteredItems.map(getId)).toEqual(['1']);
expect(result.currentIndex).toBe(0);
});
it('matches numeric item ids with string currentId', () => {
type NumRow = { id: number; title: string };
const rows: readonly NumRow[] = [
{ id: 10, title: 'A' },
{ id: 20, title: 'B' },
{ id: 30, title: 'C' },
];
const getNumericId = (row: NumRow) => String(row.id);
const result = computeNavigation({
currentId: '20',
getId: getNumericId,
items: rows,
});
expect(result.currentIndex).toBe(1);
expect(result.prevId).toBe('10');
expect(result.nextId).toBe('30');
});
});
@@ -0,0 +1,145 @@
import { useMemo } from 'react';
import { createTextMatcher } from './text-filter';
interface NavigationInput<T> {
currentId: null | string | undefined;
getId: (item: T) => string;
/**
* Optional accessor for the text the filter runs against. Only consulted
* when `query` is non-empty — pages with no filter don't need it.
*/
getSearchableText?: (item: T) => null | string | undefined;
items: readonly T[];
/**
* Free-text filter to narrow the navigable subset. Empty / `undefined` is
* treated as "no filter" and every item passes. Matching reuses
* `createTextMatcher`, so behaviour matches the list page's column
* filter (substring, case + diacritic insensitive).
*/
query?: string;
sortFn?: (a: T, b: T) => number;
}
interface NavigationResult<T> {
currentIndex: number;
currentItem: null | T;
filteredItems: readonly T[];
nextId: null | string;
prevId: null | string;
total: number;
}
/**
* 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`
* lookup is O(1) instead of an `Array.find` per render. The map is
* intentionally not exposed — callers should walk `filteredItems` or use the
* returned `prevId` / `nextId`.
*/
export const computeNavigation = <T>({
currentId,
getId,
getSearchableText,
items,
query,
sortFn,
}: NavigationInput<T>): NavigationResult<T> => {
const hasQuery = query !== undefined && query.length > 0;
const filtered =
hasQuery && getSearchableText
? items.filter((item) => createTextMatcher(query)(getSearchableText(item)))
: items;
const ordered = sortFn ? [...filtered].sort(sortFn) : filtered;
const indexById = new Map<string, number>();
ordered.forEach((item, index) => {
// Apollo commonly hydrates GraphQL `ID` fields as numbers even though
// route params are strings — normalize so lookups stay stable.
indexById.set(String(getId(item)), index);
});
const currentIndex = currentId != null ? (indexById.get(String(currentId)) ?? -1) : -1;
if (currentIndex === -1) {
return {
currentIndex: -1,
currentItem: null,
filteredItems: ordered,
nextId: null,
prevId: null,
total: ordered.length,
};
}
const prevItem = currentIndex > 0 ? ordered[currentIndex - 1] : null;
const nextItem = currentIndex < ordered.length - 1 ? ordered[currentIndex + 1] : null;
return {
currentIndex,
currentItem: ordered[currentIndex] ?? null,
filteredItems: ordered,
nextId: nextItem ? String(getId(nextItem)) : null,
prevId: prevItem ? String(getId(prevItem)) : null,
total: ordered.length,
};
};
interface UseNavigationOptions<T> {
currentId: null | string | undefined;
getId: (item: T) => string;
/**
* Accessor for the text the filter runs against. Only required when
* `query` is non-empty.
*/
getSearchableText?: (item: T) => null | string | undefined;
items: readonly T[];
/** Free-text filter. Empty / `undefined` → unfiltered. */
query?: string;
/**
* Optional comparator. When omitted, the input array order is preserved —
* the navigation walks `items` exactly as the caller arranged them, which
* matches what list pages already render.
*/
sortFn?: (a: T, b: T) => number;
}
type UseNavigationResult<T> = NavigationResult<T>;
/**
* Resolve Prev/Next siblings for a detail page that's tied to a filtered list.
*
* The list page holds the same `items` and free-text `query`, so this hook
* produces the same ordering as what the user sees in the table — Prev/Next
* stays in lockstep with the filter even after the user lands on a detail
* URL via a shared link.
*
* If the current item is missing from the filtered subset (deleted, or no
* longer matches the filter), `currentIndex` is `-1` and both `prevId` /
* `nextId` are `null` — the caller renders disabled Prev/Next buttons in that
* case rather than navigating into an unrelated neighbour.
*
* Callers pass *data* (`query`, `getSearchableText`) rather than a prebuilt
* predicate. That removes the identity-stability footgun the predicate-based
* shape had: a fresh-arrow `(item) => …` callback every render would have
* defeated the internal `useMemo`. With this shape, only `query` flips per
* keystroke, and `getSearchableText` is naturally module-scoped at the
* feature level (see `use-flow-detail-navigation` etc.).
*/
export const useNavigation = <T>({
currentId,
getId,
getSearchableText,
items,
query,
sortFn,
}: UseNavigationOptions<T>): UseNavigationResult<T> => {
return useMemo(
() => computeNavigation({ currentId, getId, getSearchableText, items, query, sortFn }),
[currentId, getId, getSearchableText, items, query, sortFn],
);
};
@@ -310,7 +310,7 @@ export interface FileManagerProps {
/**
* When set, the active sort is persisted to `localStorage` under this key
* across page reloads. Ignored in controlled mode (when `sorting` is set).
* Pass a route-scoped key (e.g. `getSortingStorageKey('/flows/files')`)
* Pass a route-scoped key (e.g. `getTableStorageKey('/flows/files')`)
* to avoid collisions across pages.
*/
sortStorageKey?: string;
@@ -0,0 +1,37 @@
import { forwardRef, type ReactNode } from 'react';
import { Button, type ButtonProps } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface HeaderButtonProps extends Omit<ButtonProps, 'children'> {
endIcon?: ReactNode;
icon: ReactNode;
label: ReactNode;
}
// Action button rendered inside a page header. Collapses to an icon-only square
// on viewports narrower than the `md` breakpoint (matches `useBreakpoint`'s
// `mobile` threshold of 768px) and expands to icon + label (and optional
// trailing icon, e.g. a dropdown chevron) on wider screens. `aria-label` is
// auto-derived from `label` when it's a plain string so the icon-only mobile
// state stays accessible without the caller having to remember it.
export const HeaderButton = forwardRef<HTMLButtonElement, HeaderButtonProps>(
({ 'aria-label': ariaLabel, className, endIcon, icon, label, size = 'sm', ...props }, ref) => {
const accessibleLabel = ariaLabel ?? (typeof label === 'string' ? label : undefined);
return (
<Button
aria-label={accessibleLabel}
className={cn('w-8 px-0 md:w-auto md:px-3', className)}
ref={ref}
size={size}
{...props}
>
{icon}
<span className="hidden md:inline">{label}</span>
{endIcon ? <span className="hidden md:inline-flex">{endIcon}</span> : null}
</Button>
);
},
);
HeaderButton.displayName = 'HeaderButton';
@@ -0,0 +1,2 @@
export { InlineEditInput } from './inline-edit-input';
export { useInlineEdit } from './use-inline-edit';
@@ -0,0 +1,113 @@
import { Check, Loader2, X } from 'lucide-react';
import { type KeyboardEvent, type Ref } from 'react';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { cn } from '@/lib/utils';
interface InlineEditInputProps {
/**
* Auto-focus the input on mount. Needed for table-cell call sites that
* switch into edit mode in-place: the parent flips a flag (e.g.
* `editingFlowId === flow.id`) and this component is freshly mounted,
* so focus must be requested explicitly.
*/
autoFocus?: boolean;
/** Disable input + Save button while a mutation is in flight. */
busy?: boolean;
/** Optional className passed through to the outer `<InputGroup>`. */
className?: string;
/** Initial value rendered inside the uncontrolled input. */
defaultValue?: string;
/** Ref to the underlying `<input>` element. Pair with `useInlineEdit().inputRef`. */
inputRef?: Ref<HTMLInputElement>;
/**
* Max length applied via the native HTML `maxLength` attribute. Defaults
* to a UX-safe `200` to prevent accidental paste-bombs that would break
* truncation in tables and breadcrumbs. Override per call site when a
* stricter or looser constraint applies; the browser silently stops
* typing past the limit without altering programmatically-set
* `defaultValue`, so this is a guard rather than a validation gate.
*/
maxLength?: number;
onCancel: () => void;
/**
* Save handler. Read the latest text from the bound `inputRef` — kept
* uncontrolled because most callers commit on Enter or click and have
* no need to reflect every keystroke into React state.
*/
onSave: () => void;
placeholder?: string;
}
/**
* Generic inline-edit input used inside table cells and detail-page
* breadcrumbs (rename flows, quick-create entries, in-place note edits).
*
* Pairs with {@link useInlineEdit} — the parent owns the open/close state
* and supplies a ref via that hook; this component owns the presentation
* (input + Save/Cancel addon buttons), keyboard semantics (`Enter` saves,
* `Escape` cancels), and the loading spinner during save.
*
* The input is uncontrolled (`defaultValue`) to match the pattern across
* the codebase: callers read the value at submit time from `inputRef.current`,
* not from React state, which avoids a re-render per keystroke for a value
* that's only relevant once.
*/
export const InlineEditInput = ({
autoFocus = false,
busy = false,
className,
defaultValue,
inputRef,
maxLength = 200,
onCancel,
onSave,
placeholder,
}: InlineEditInputProps) => {
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
onSave();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
onCancel();
}
};
return (
<InputGroup className={cn('h-8', className)}>
<InputGroupInput
autoFocus={autoFocus}
className="text-foreground"
defaultValue={defaultValue}
maxLength={maxLength}
onKeyDown={handleKeyDown}
placeholder={placeholder}
ref={inputRef}
/>
<InputGroupAddon
align="inline-end"
className="gap-0 pr-2"
>
<InputGroupButton
aria-label="Save"
disabled={busy}
onClick={onSave}
>
{busy ? <Loader2 className="animate-spin" /> : <Check />}
</InputGroupButton>
<InputGroupButton
aria-label="Cancel"
disabled={busy}
onClick={onCancel}
>
<X />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
);
};
@@ -0,0 +1,136 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useInlineEdit } from './use-inline-edit';
describe('useInlineEdit', () => {
it('starts in non-editing state', () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
expect(result.current.isEditing).toBe(false);
});
it('startEdit flips to editing, stopEdit flips back', () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
act(() => result.current.startEdit());
expect(result.current.isEditing).toBe(true);
act(() => result.current.stopEdit());
expect(result.current.isEditing).toBe(false);
});
it('exits edit mode when resetKey changes', () => {
const { rerender, result } = renderHook(({ resetKey }) => useInlineEdit({ resetKey }), {
initialProps: { resetKey: 'a' as null | string | undefined },
});
act(() => result.current.startEdit());
expect(result.current.isEditing).toBe(true);
rerender({ resetKey: 'b' });
expect(result.current.isEditing).toBe(false);
});
it('keeps edit mode across rerenders with the same resetKey', () => {
const { rerender, result } = renderHook(({ resetKey }) => useInlineEdit({ resetKey }), {
initialProps: { resetKey: 'a' as null | string | undefined },
});
act(() => result.current.startEdit());
rerender({ resetKey: 'a' });
expect(result.current.isEditing).toBe(true);
});
it('treats `null` and `undefined` as distinct keys (state reset on transition)', () => {
const { rerender, result } = renderHook(({ resetKey }) => useInlineEdit({ resetKey }), {
initialProps: { resetKey: null as null | string | undefined },
});
act(() => result.current.startEdit());
// Transition null -> undefined trips the reset because they aren't
// strictly equal. Documenting this so callers know to keep the
// resetKey type stable.
rerender({ resetKey: undefined });
expect(result.current.isEditing).toBe(false);
});
it('returns a ref object whose .current starts at null', () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
expect(result.current.inputRef.current).toBeNull();
});
it('handleDropdownCloseAutoFocus prevents default while editing', () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
act(() => result.current.startEdit());
// `Event` is cancelable by default but `defaultPrevented` only flips
// once `preventDefault` is called — exactly what the hook does.
const event = new Event('autofocus', { cancelable: true });
result.current.handleDropdownCloseAutoFocus(event);
expect(event.defaultPrevented).toBe(true);
});
it('handleDropdownCloseAutoFocus is a no-op when not editing', () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
const event = new Event('autofocus', { cancelable: true });
result.current.handleDropdownCloseAutoFocus(event);
expect(event.defaultPrevented).toBe(false);
});
it('focuses and selects the input on the next animation frame after startEdit', async () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
const input = document.createElement('input');
input.value = 'hello';
document.body.appendChild(input);
// Attach the ref manually — `useRef` exposes `.current` as writable
// even though TypeScript narrows it to readonly. Casting through
// an interface keeps the intent visible in the test.
(result.current.inputRef as unknown as { current: HTMLInputElement }).current = input;
act(() => result.current.startEdit());
// requestAnimationFrame in jsdom resolves in a microtask — wait for it.
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(input.value.length);
document.body.removeChild(input);
});
it('cancels the pending focus when isEditing flips off before the frame fires', async () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
const input = document.createElement('input');
document.body.appendChild(input);
(result.current.inputRef as unknown as { current: HTMLInputElement }).current = input;
const otherInput = document.createElement('input');
document.body.appendChild(otherInput);
otherInput.focus();
act(() => result.current.startEdit());
act(() => result.current.stopEdit());
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
// Focus must not have moved to the inline input — the effect cleanup
// cancelled the rAF before it ran.
expect(document.activeElement).toBe(otherInput);
document.body.removeChild(input);
document.body.removeChild(otherInput);
});
});
@@ -0,0 +1,112 @@
import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface UseInlineEditOptions {
/**
* When this value changes, the edit session is reset (closes any open
* input). Use it for the entity id that owns the editor — navigating
* between items should not carry a stale draft over.
*/
resetKey?: null | string | undefined;
}
interface UseInlineEditResult<TElement extends HTMLElement = HTMLInputElement> {
/**
* Spread onto a Radix `<DropdownMenuContent>` (or any component with the
* same `onCloseAutoFocus` semantics) when the dropdown contains a button
* that toggles the inline editor. Prevents Radix's default focus-restore
* from racing the editor's `requestAnimationFrame`-driven focus below.
*/
handleDropdownCloseAutoFocus: (event: Event) => void;
/** Ref to wire to the inline `<input>` element. */
inputRef: React.RefObject<null | TElement>;
isEditing: boolean;
/** Begin an inline edit — the input mounts and receives focus on next frame. */
startEdit: () => void;
/** End the edit session without committing. */
stopEdit: () => void;
}
/**
* Shared state machine for inline-edit surfaces (double-click to rename,
* quick-add, in-place note edits).
*
* Combines four micro-responsibilities that every editable surface needs:
* - `isEditing` boolean + start/stop helpers,
* - a ref for the inline input,
* - deferred focus + select-all on the next animation frame, so the focus
* lands *after* Radix's dropdown close-focus-restore completes (otherwise
* Radix wins the race and the input never receives focus),
* - an `onCloseAutoFocus` handler that opts out of Radix's restore while
* editing — used together with the deferred focus above.
*
* Pass the entity id as `resetKey` so navigation between items closes any
* stale editor automatically.
*/
export const useInlineEdit = <TElement extends HTMLElement = HTMLInputElement>({
resetKey,
}: UseInlineEditOptions = {}): UseInlineEditResult<TElement> => {
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<null | TElement>(null);
// Reset the edit session when `resetKey` changes. React docs call out
// "adjust state when a prop changes" as the canonical render-phase
// `setState` pattern — it's preferred over `useEffect` because the new
// state lands in the same commit (no flash of stale "still editing" UI
// on the new item) and it doesn't trigger the "cascading renders"
// lint complaint that an effect-based reset would.
const [lastResetKey, setLastResetKey] = useState(resetKey);
if (lastResetKey !== resetKey) {
setLastResetKey(resetKey);
if (isEditing) {
setIsEditing(false);
}
}
useEffect(() => {
if (!isEditing) {
return;
}
const id = requestAnimationFrame(() => {
const input = inputRef.current;
if (!input) {
return;
}
input.focus();
// `<input>` and `<textarea>` both expose `select()`, but typing
// `TElement extends HTMLElement` is wider than that — guard so
// a future caller with `HTMLDivElement` doesn't crash here.
if ('select' in input && typeof (input as { select: unknown }).select === 'function') {
(input as unknown as HTMLInputElement).select();
}
});
return () => cancelAnimationFrame(id);
}, [isEditing]);
const startEdit = useCallback(() => setIsEditing(true), []);
const stopEdit = useCallback(() => setIsEditing(false), []);
// Closure over `isEditing` directly. The callback identity flips on each
// edit-mode toggle, but that's fine: Radix's `<DropdownMenuContent>` is
// not memoized today and `onCloseAutoFocus` fires inside the same commit
// that closes the dropdown — a ref-based stable callback would risk
// reading a stale value through `useLatestRef`'s `useEffect` lag.
const handleDropdownCloseAutoFocus = useCallback(
(event: Event) => {
if (isEditing) {
event.preventDefault();
}
},
[isEditing],
);
return { handleDropdownCloseAutoFocus, inputRef, isEditing, startEdit, stopEdit };
};
@@ -0,0 +1,5 @@
export { OverwriteButtons } from './overwrite-buttons';
export { OverwriteDialog } from './overwrite-dialog';
export type { OverwriteConflict } from './overwrite-dialog';
export { useOverwrite } from './use-overwrite';
export type { OverwriteOutcome } from './use-overwrite';
@@ -4,7 +4,7 @@ import { Loader2, Replace } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface OverwriteCtaButtonsProps {
interface OverwriteButtonsProps {
/**
* When `true` both buttons are greyed-out and clicks are ignored. Use to
* disable the CTAs based on form validity, selection emptiness, or any
@@ -47,7 +47,7 @@ interface OverwriteCtaButtonsProps {
* The component owns the spinner / icon swap, so callers don't repeat that
* boilerplate in five different dialogs.
*/
export const OverwriteCtaButtons = ({
export const OverwriteButtons = ({
isDisabled,
isProcessing,
onOverwrite,
@@ -56,7 +56,7 @@ export const OverwriteCtaButtons = ({
primaryIcon: PrimaryIcon,
primaryLabel,
primaryType = 'button',
}: OverwriteCtaButtonsProps) => {
}: OverwriteButtonsProps) => {
const disabled = isDisabled || isProcessing;
return (
@@ -8,7 +8,7 @@ export interface OverwriteConflict {
destinationName: string;
}
interface OverwriteConfirmDialogProps {
interface OverwriteDialogProps {
/**
* Overrides the auto-generated confirm button label. Defaults to
* `"Replace"` for a single conflict and `"Replace all"` for a batch.
@@ -57,14 +57,14 @@ const buildDefaultConfirmText = (count: number): string => (count > 1 ? 'Replace
* file-manager UX and keeps the user from being prompted N times for the
* same destination directory.
*/
export const OverwriteConfirmDialog = ({
export const OverwriteDialog = ({
confirmText,
conflicts,
description,
onCancel,
onReplaceAll,
title = 'Replace existing item?',
}: OverwriteConfirmDialogProps) => (
}: OverwriteDialogProps) => (
<ConfirmationDialog
cancelText="Cancel"
confirmIcon={<Replace />}
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { OverwriteConflict } from '@/components/shared/overwrite-confirm-dialog';
import type { OverwriteConflict } from './overwrite-dialog';
/**
* Discriminated outcome of a server action that supports an overwrite flag.
@@ -23,14 +23,14 @@ export type OverwriteOutcome =
* Anonymous fallback descriptor used when a 409 sneaks through after a clean
* preflight and the caller didn't provide `synthesizeFallbackConflicts`. Falls
* back to the count-based copy ("N items already exist...") in
* `OverwriteConfirmDialog`.
* `OverwriteDialog`.
*/
const ANONYMOUS_FALLBACK_CONFLICT: OverwriteConflict = {
destination: '',
destinationName: 'an item',
};
interface UseOverwriteActionOptions<TPlan> {
interface UseOverwriteOptions<TPlan> {
/**
* Execute the REST call. Receives the plan + a boolean `force` flag.
* Should return a discriminated outcome see `OverwriteOutcome`.
@@ -39,7 +39,7 @@ interface UseOverwriteActionOptions<TPlan> {
/**
* Pure function: inspect the local snapshot and return any destinations
* that would conflict. Empty array primary execute proceeds with
* `force=false`; non-empty the OverwriteConfirmDialog is opened
* `force=false`; non-empty the OverwriteDialog is opened
* pre-populated with these descriptors.
*/
findConflicts: (plan: TPlan) => OverwriteConflict[];
@@ -54,22 +54,22 @@ interface UseOverwriteActionOptions<TPlan> {
synthesizeFallbackConflicts?: (plan: TPlan) => OverwriteConflict[];
}
interface UseOverwriteActionResult<TPlan> {
/** Live conflict descriptors. Wire to `<OverwriteConfirmDialog conflicts={…} />`. */
interface UseOverwriteResult<TPlan> {
/** Live conflict descriptors. Wire to `<OverwriteDialog conflicts={…} />`. */
conflicts: OverwriteConflict[];
/**
* Execute the action with `force=true` immediately, bypassing the
* preflight and the conflict prompt. Wire to the secondary CTA.
*/
forceExecute: (plan: TPlan) => Promise<void>;
/** Wire to the `onReplaceAll` handler of `<OverwriteConfirmDialog />`. */
/** Wire to the `onReplaceAll` handler of `<OverwriteDialog />`. */
handleReplaceAll: () => Promise<void>;
/**
* Execute the action with the preflight + race-fallback workflow. Wire
* to the primary CTA.
*/
primaryExecute: (plan: TPlan) => Promise<void>;
/** Wire to the `onCancel` handler of `<OverwriteConfirmDialog />`. */
/** Wire to the `onCancel` handler of `<OverwriteDialog />`. */
resetConflicts: () => void;
}
@@ -80,7 +80,7 @@ interface UseOverwriteActionResult<TPlan> {
* Workflow:
* 1. The user clicks the **primary CTA** `primaryExecute(plan)` runs.
* `findConflicts` is consulted on the local snapshot first; if anything
* collides, the OverwriteConfirmDialog opens with those descriptors.
* collides, the OverwriteDialog opens with those descriptors.
* Otherwise `execute(plan, false)` is dispatched. A 409 from the server
* (race) auto-opens the dialog with `synthesizeFallbackConflicts` (or an
* anonymous fallback).
@@ -95,9 +95,7 @@ interface UseOverwriteActionResult<TPlan> {
* to wrap them in `useCallback`. This keeps the hook ergonomic at the call
* site without sacrificing reference stability for the returned actions.
*/
export const useOverwriteAction = <TPlan>(
options: UseOverwriteActionOptions<TPlan>,
): UseOverwriteActionResult<TPlan> => {
export const useOverwrite = <TPlan>(options: UseOverwriteOptions<TPlan>): UseOverwriteResult<TPlan> => {
const [conflicts, setConflicts] = useState<OverwriteConflict[]>([]);
const [pendingPlan, setPendingPlan] = useState<null | TPlan>(null);
@@ -0,0 +1,4 @@
export { UnsavedChangesDialog } from './unsaved-changes-dialog';
export type { UnsavedChangesDialogProps } from './unsaved-changes-dialog';
export { useUnsavedChangesGuard } from './use-unsaved-changes-guard';
export type { UnsavedChangesGuard, UseUnsavedChangesGuardArgs } from './use-unsaved-changes-guard';
+31 -1
View File
@@ -12,9 +12,39 @@ import {
} from '@/components/ui/command';
import { Input } from '@/components/ui/input';
import { Popover, PopoverAnchor } from '@/components/ui/popover';
import { useControllable } from '@/hooks/use-controllable';
import { useLatestRef } from '@/hooks/use-latest-ref';
import { cn } from '@/lib/utils';
/**
* Radix-style controllable state. When `controlled` is `undefined` the hook
* owns the state; otherwise the parent does and we forward updates via
* `onChange`. `onChange` always fires so fully-controlled consumers can
* observe every set (e.g. for logging).
*
* Mirrors `@radix-ui/react-use-controllable-state` without pulling in the
* dependency for a couple of state slots.
*/
const useControllable = <T,>(controlled: T | undefined, defaultValue: T, onChange?: (value: T) => void) => {
const [internal, setInternal] = React.useState<T>(defaultValue);
const isControlled = controlled !== undefined;
const value = isControlled ? (controlled as T) : internal;
const onChangeRef = useLatestRef(onChange);
const set = React.useCallback(
(next: T) => {
if (!isControlled) {
setInternal(next);
}
onChangeRef.current?.(next);
},
[isControlled, onChangeRef],
);
return [value, set] as const;
};
/**
* Free-text input with a popover dropdown of substring-filtered suggestions.
*
@@ -0,0 +1,595 @@
import type { Column, ColumnDef } from '@tanstack/react-table';
import type { ReactNode } from 'react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
import { cycleColumnSort, DataTable } from './data-table';
interface Row {
id: string;
name: string;
}
const ROWS: Row[] = [
{ id: 'a', name: 'Alpha' },
{ id: 'b', name: 'Bravo' },
{ id: 'c', name: 'Charlie' },
];
const COLUMNS: ColumnDef<Row>[] = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'name', filterFn: 'includesString', header: 'Name' },
];
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/flows']}>
<TooltipProvider>{children}</TooltipProvider>
</MemoryRouter>
);
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
describe('DataTable — controlled filter projection', () => {
it('projects `filterValue` into the visible rows when a `filterColumn` is set', () => {
render(
<DataTable<Row>
columns={COLUMNS}
data={ROWS}
filterColumn="name"
filterValue="Bravo"
onFilterChange={() => {
/* no-op */
}}
/>,
{ wrapper: Wrapper },
);
// The body shows only "Bravo" — Alpha and Charlie are filtered out.
expect(screen.getByText('Bravo')).toBeInTheDocument();
expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
expect(screen.queryByText('Charlie')).not.toBeInTheDocument();
});
it('renders the empty filter as "all rows visible"', () => {
render(
<DataTable<Row>
columns={COLUMNS}
data={ROWS}
filterColumn="name"
filterValue=""
onFilterChange={() => {
/* no-op */
}}
/>,
{ wrapper: Wrapper },
);
expect(screen.getByText('Alpha')).toBeInTheDocument();
expect(screen.getByText('Bravo')).toBeInTheDocument();
expect(screen.getByText('Charlie')).toBeInTheDocument();
});
it('invokes `onFilterChange` with the typed value after the debounce settles', async () => {
const onFilterChange = vi.fn();
const user = userEvent.setup();
render(
<DataTable<Row>
columns={COLUMNS}
data={ROWS}
filterColumn="name"
filterPlaceholder="Filter name..."
filterValue=""
onFilterChange={onFilterChange}
/>,
{ wrapper: Wrapper },
);
const input = screen.getByPlaceholderText('Filter name...');
await user.type(input, 'al');
// The input debounces its commit to the parent — intermediate values
// never reach `onFilterChange`. After the debounce settles, the parent
// sees exactly one call carrying the final value.
await waitFor(() => {
expect(onFilterChange).toHaveBeenCalled();
});
const lastCall = onFilterChange.mock.calls.at(-1);
expect(lastCall?.[0]).toBe('al');
});
it('clears the filter when the trailing X button is clicked', async () => {
const onFilterChange = vi.fn();
const user = userEvent.setup();
render(
<DataTable<Row>
columns={COLUMNS}
data={ROWS}
filterColumn="name"
filterPlaceholder="Filter..."
filterValue="Alpha"
onFilterChange={onFilterChange}
/>,
{ wrapper: Wrapper },
);
// The clear button is rendered only when the filter has content.
const input = screen.getByPlaceholderText('Filter...');
const inputGroup = input.closest('[data-slot="input-group"]');
expect(inputGroup).not.toBeNull();
const clearButton = within(inputGroup as HTMLElement).getByRole('button');
await user.click(clearButton);
expect(onFilterChange).toHaveBeenCalledWith('');
});
});
describe('DataTable — uncontrolled filter is still routed through the same input', () => {
it('falls back to internal column filter state when `filterValue` is omitted', async () => {
const user = userEvent.setup();
render(
<DataTable<Row>
columns={COLUMNS}
data={ROWS}
filterColumn="name"
filterPlaceholder="Filter..."
/>,
{ wrapper: Wrapper },
);
const input = screen.getByPlaceholderText('Filter...');
await user.type(input, 'Bra');
// Wait for the input's debounced commit; Bravo was in the initial
// render, so the only authoritative signal is the disappearance of
// the non-matching row.
await waitFor(() => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
});
expect(screen.getByText('Bravo')).toBeInTheDocument();
});
});
describe('DataTable — does not render the filter input when `filterColumn` is omitted', () => {
it('hides the search input entirely', () => {
render(
<DataTable<Row>
columns={COLUMNS}
data={ROWS}
/>,
{ wrapper: Wrapper },
);
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
});
describe('DataTable — sorting state persists to storage', () => {
it('writes a sorting entry to the unified `table_4_<path>` slot after the user sorts', () => {
const user = userEvent.setup();
render(
<DataTable<Row>
columns={[
{
accessorKey: 'name',
enableSorting: true,
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(false)}
type="button"
>
Name
</button>
),
},
]}
data={ROWS}
/>,
{ wrapper: Wrapper },
);
const sortButton = screen.getByRole('button', { name: 'Name' });
fireEvent.click(sortButton);
const stored = JSON.parse(localStorage.getItem('table_4_/flows') ?? '{}');
expect(stored.sorting).toEqual([{ desc: false, id: 'name' }]);
// ESLint will yell about `user` being unused if we remove the import.
void user;
});
});
describe('DataTable — controlled pageIndex reconciliation', () => {
it('asks the parent to clamp an out-of-range controlled pageIndex on mount', () => {
const onPageChange = vi.fn();
render(
<DataTable<Row>
columns={COLUMNS}
data={ROWS}
onPageChange={onPageChange}
pageIndex={9}
/>,
{ wrapper: Wrapper },
);
// 3 rows / default pageSize 10 → 1 page. The URL-driven pageIndex 9
// is out of range, so the parent must be told to drop to 0 so the
// canonical URL no longer points past the dataset.
expect(onPageChange).toHaveBeenCalledWith(0);
});
it('clears the URL page when picking "All" on a high page in controlled mode', async () => {
const user = userEvent.setup();
// 15 rows × default pageSize 10 → 2 pages; start on page 2.
const manyRows: Row[] = Array.from({ length: 15 }, (_, index) => ({
id: String(index + 1),
name: `Row ${index + 1}`,
}));
const onPageChange = vi.fn();
render(
<DataTable<Row>
columns={COLUMNS}
data={manyRows}
onPageChange={onPageChange}
pageIndex={1}
/>,
{ wrapper: Wrapper },
);
// Initial mirror of the controlled pageIndex shouldn't trigger a
// reconcile — page 2 of 2 is in range.
expect(onPageChange).not.toHaveBeenCalled();
// Open the rows-per-page select and pick "All".
const trigger = screen.getByRole('combobox');
await user.click(trigger);
const option = await screen.findByRole('option', { name: 'All' });
await user.click(option);
// "All" collapses the dataset to a single page; the parent has to
// hear about pageIndex 0 so `?page=2` is dropped from the URL.
expect(onPageChange).toHaveBeenCalledWith(0);
});
});
describe('DataTable — empty results', () => {
it('does not render "Page 1 of 0" when there are no rows', () => {
render(
<DataTable<Row>
columns={COLUMNS}
data={[]}
/>,
{ wrapper: Wrapper },
);
expect(screen.queryByText(/Page 1 of 0/)).not.toBeInTheDocument();
expect(screen.getByText('No results')).toBeInTheDocument();
});
it('assigns a non-empty unique id and matching name to the filter field', () => {
const { unmount } = render(
<DataTable<Row>
columns={COLUMNS}
data={ROWS}
filterColumn="name"
filterValue=""
onFilterChange={() => {
/* no-op */
}}
/>,
{ wrapper: Wrapper },
);
const firstInput = screen.getByRole('textbox');
const firstId = firstInput.getAttribute('id');
// `useId` returns a non-empty stable string; `name` mirrors it so pages
// with multiple DataTables don't collide on either attribute.
expect(firstId).toBeTruthy();
expect(firstInput).toHaveAttribute('name', firstId);
unmount();
// Render a second instance and confirm the id is different — proves
// multi-table pages get unique ids per instance.
render(
<DataTable<Row>
columns={COLUMNS}
data={ROWS}
filterColumn="name"
filterValue=""
onFilterChange={() => {
/* no-op */
}}
/>,
{ wrapper: Wrapper },
);
const secondInput = screen.getByRole('textbox');
expect(secondInput.getAttribute('id')).not.toBe(firstId);
});
it('caps the filter input length so a paste of multi-KB content cannot blow past URL limits', () => {
render(
<DataTable<Row>
columns={COLUMNS}
data={ROWS}
filterColumn="name"
filterValue=""
onFilterChange={() => {
/* no-op */
}}
/>,
{ wrapper: Wrapper },
);
const input = screen.getByRole('textbox') as HTMLInputElement;
// The DOM `maxLength` is the only choke point we need — `<input>`
// truncates both typing and paste at this boundary, which keeps
// shared `?q=` URLs under the practical reverse-proxy limit (~24 KB).
expect(input.maxLength).toBe(200);
});
});
interface MultiRow {
id: string;
name: string;
role: string;
}
const MULTI_ROWS: MultiRow[] = [
{ id: 'a', name: 'Alpha', role: 'admin' },
{ id: 'b', name: 'Bravo', role: 'user' },
{ id: 'c', name: 'Charlie', role: 'reader' },
];
const MULTI_COLUMNS: ColumnDef<MultiRow>[] = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'name', header: 'Name', meta: { searchable: true } },
{ accessorKey: 'role', header: 'Role', meta: { searchable: true } },
];
describe('DataTable — multi-column search', () => {
it('searches across all candidate columns with OR semantics (meta.searchable opt-in)', async () => {
const user = userEvent.setup();
render(
<DataTable<MultiRow>
columns={MULTI_COLUMNS}
data={MULTI_ROWS}
filterPlaceholder="Filter..."
/>,
{ wrapper: Wrapper },
);
const input = screen.getByPlaceholderText('Filter...');
// "reader" only appears in the `role` column — multi-column search
// must surface Charlie even though her `name` doesn't contain it.
await user.type(input, 'reader');
// Wait for the non-matching rows to disappear; Alpha and Bravo are
// present in the initial render, so finding Charlie is not enough —
// we need to confirm the filter actually narrowed the row set.
await waitFor(() => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
});
expect(screen.queryByText('Bravo')).not.toBeInTheDocument();
expect(screen.getByText('Charlie')).toBeInTheDocument();
});
it('narrows the search when the picker disables a candidate', async () => {
const user = userEvent.setup();
render(
<DataTable<MultiRow>
columns={MULTI_COLUMNS}
data={MULTI_ROWS}
filterPlaceholder="Filter..."
/>,
{ wrapper: Wrapper },
);
await user.click(screen.getByRole('button', { name: /Search in/ }));
// Uncheck the `Role` column so "reader" can no longer match Charlie.
await user.click(await screen.findByRole('menuitemcheckbox', { name: /role/i }));
// Close the dropdown so it doesn't intercept subsequent input focus
// events. Pressing Escape is the user-facing way out.
await user.keyboard('{Escape}');
const input = screen.getByPlaceholderText('Filter...');
await user.type(input, 'reader');
expect(await screen.findByText('No results.')).toBeInTheDocument();
});
it('persists the narrowed search column set to the unified storage slot', async () => {
const user = userEvent.setup();
render(
<DataTable<MultiRow>
columns={MULTI_COLUMNS}
data={MULTI_ROWS}
/>,
{ wrapper: Wrapper },
);
await user.click(screen.getByRole('button', { name: /Search in/ }));
await user.click(await screen.findByRole('menuitemcheckbox', { name: /role/i }));
const stored = JSON.parse(localStorage.getItem('table_4_/flows') ?? '{}');
expect(stored.searchColumns).toEqual(['name']);
});
it('refilters immediately when the picker disables a column that an already-typed query matched (regression for d4c1b13)', async () => {
const user = userEvent.setup();
render(
<DataTable<MultiRow>
columns={MULTI_COLUMNS}
data={MULTI_ROWS}
filterPlaceholder="Filter..."
/>,
{ wrapper: Wrapper },
);
// "admin" appears only in `role` on Alpha. Confirm the multi-column
// search surfaces her *before* we narrow the picker.
await user.type(screen.getByPlaceholderText('Filter...'), 'admin');
expect(await screen.findByText('Alpha')).toBeInTheDocument();
// Now uncheck `Role`. The previous closure-based implementation kept
// Alpha visible here because `state.globalFilter` stayed equal and
// TanStack short-circuited the filter pipeline. The composite-value
// implementation produces a new `globalFilter` object reference, so
// the pipeline re-runs and Alpha must disappear without retyping.
await user.click(screen.getByRole('button', { name: /Search in/ }));
await user.click(await screen.findByRole('menuitemcheckbox', { name: /role/i }));
await waitFor(() => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
});
});
it('prunes stale ids from persisted searchColumns when the candidate set shrinks', () => {
// Pre-seed storage as if a previous version of this page exposed a
// `deleted` column the user had selected. Today's columns no longer
// include it — the rebase effect must trim the persisted entry.
localStorage.setItem('table_4_/flows', JSON.stringify({ searchColumns: ['name', 'role', 'deleted'] }));
render(
<DataTable<MultiRow>
columns={MULTI_COLUMNS}
data={MULTI_ROWS}
/>,
{ wrapper: Wrapper },
);
const stored = JSON.parse(localStorage.getItem('table_4_/flows') ?? '{}');
expect(stored.searchColumns).toEqual(['name', 'role']);
});
it('activates multi-column mode when filterColumn is an array (without meta.searchable)', async () => {
const user = userEvent.setup();
const PLAIN_COLUMNS: ColumnDef<MultiRow>[] = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'role', header: 'Role' },
];
render(
<DataTable<MultiRow>
columns={PLAIN_COLUMNS}
data={MULTI_ROWS}
filterColumn={['name', 'role']}
filterPlaceholder="Filter..."
/>,
{ wrapper: Wrapper },
);
// Picker is visible — confirms multi-mode activation via the array prop.
expect(screen.getByRole('button', { name: /Search in/ })).toBeInTheDocument();
const input = screen.getByPlaceholderText('Filter...');
await user.type(input, 'user');
// "user" matches Bravo's role; Alpha and Charlie don't contain it.
// Wait for them to disappear instead of relying on `Bravo` being
// present (it was in the initial render too).
await waitFor(() => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
});
expect(screen.queryByText('Charlie')).not.toBeInTheDocument();
expect(screen.getByText('Bravo')).toBeInTheDocument();
});
it('hides the input entirely when filterColumn is undefined and no column opts in', () => {
const PLAIN_COLUMNS: ColumnDef<MultiRow>[] = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'role', header: 'Role' },
];
render(
<DataTable<MultiRow>
columns={PLAIN_COLUMNS}
data={MULTI_ROWS}
/>,
{ wrapper: Wrapper },
);
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Search in/ })).not.toBeInTheDocument();
});
it('does not render the picker in legacy single-column mode', () => {
render(
<DataTable<MultiRow>
columns={MULTI_COLUMNS}
data={MULTI_ROWS}
filterColumn="name"
/>,
{ wrapper: Wrapper },
);
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Search in/ })).not.toBeInTheDocument();
// The "Columns" trigger still renders.
expect(screen.getByRole('button', { name: /Columns/ })).toBeInTheDocument();
});
});
interface FakeColumn {
clearSorting: ReturnType<typeof vi.fn>;
getIsSorted: ReturnType<typeof vi.fn>;
toggleSorting: ReturnType<typeof vi.fn>;
}
// Stub the subset of `Column` `cycleColumnSort` actually touches. The cast
// through `unknown as Column<...>` keeps the test free of @tanstack/react-table
// internals while preserving the typed call signature.
const makeColumn = (sorted: 'asc' | 'desc' | false): FakeColumn => ({
clearSorting: vi.fn(),
getIsSorted: vi.fn().mockReturnValue(sorted),
toggleSorting: vi.fn(),
});
describe('cycleColumnSort', () => {
it('starts the cycle on a column with no sort by setting ascending', () => {
const column = makeColumn(false);
cycleColumnSort(column as unknown as Column<unknown>);
expect(column.toggleSorting).toHaveBeenCalledWith(false);
expect(column.clearSorting).not.toHaveBeenCalled();
});
it('advances ascending to descending', () => {
const column = makeColumn('asc');
cycleColumnSort(column as unknown as Column<unknown>);
expect(column.toggleSorting).toHaveBeenCalledWith(true);
expect(column.clearSorting).not.toHaveBeenCalled();
});
it('clears the sort after descending — the third tap removes the sort entirely', () => {
const column = makeColumn('desc');
cycleColumnSort(column as unknown as Column<unknown>);
expect(column.clearSorting).toHaveBeenCalledTimes(1);
expect(column.toggleSorting).not.toHaveBeenCalled();
});
it('reads `getIsSorted` exactly once per call — no double-evaluation between branches', () => {
const column = makeColumn('asc');
cycleColumnSort(column as unknown as Column<unknown>);
expect(column.getIsSorted).toHaveBeenCalledTimes(1);
});
});
+559 -77
View File
@@ -1,21 +1,43 @@
import {
type Column,
type ColumnDef,
type ColumnFiltersState,
type ExpandedState,
type FilterFn,
flexRender,
getCoreRowModel,
getExpandedRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type Table as ReactTable,
type Row,
type SortingState,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table';
import { ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search, X } from 'lucide-react';
import { Fragment, type ReactElement, type ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ListFilter,
Search,
X,
} from 'lucide-react';
import {
Fragment,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu';
@@ -28,51 +50,164 @@ import {
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useDebouncedValue } from '@/hooks/use-debounced-value';
import { useEffectAfterMount } from '@/hooks/use-effect-after-mount';
import { getColumnStorageKey, getPageStorageKey, getSortingStorageKey } from '@/lib/storage-keys';
import {
loadColumnVisibility,
loadPageState,
loadSorting,
saveColumnVisibility,
savePageState,
saveSorting,
} from '@/lib/table-storage';
import { useLatestRef } from '@/hooks/use-latest-ref';
import { usePageStorageKeys } from '@/hooks/use-page-storage-keys';
import { migrateLegacyTableState, updateTableState } from '@/lib/table-state';
import { cn } from '@/lib/utils';
/**
* Composite value stored in TanStack's `state.globalFilter`. Bundling `query`
* and the active `columns` set together makes the predicate self-contained:
* any change to either field produces a new object reference, which is what
* TanStack watches to re-run the filter pipeline. This sidesteps the trap
* where a closure-only narrowing (`getColumnCanGlobalFilter`) updates silently
* and the rows stay stale.
*/
type DataTableGlobalFilter = { columns: string[]; query: string };
interface DataTableProps<TData, TValue = unknown> {
columns: ColumnDef<TData, TValue>[];
columnVisibility?: VisibilityState;
data: TData[];
filterColumn?: string;
/**
* Search target(s) for the filter input. Three modes:
* - `string` (legacy single-column): the input searches only this column;
* the column-picker dropdown is not rendered. Backward-compatible with
* pre-multi-column call sites.
* - `string[]` (explicit multi-column): the input searches across all
* listed columns with OR semantics; a "Search in" dropdown lets the
* user narrow the set.
* - `undefined` (zero-config multi-column): candidate columns are picked
* from those with `columnDef.meta.searchable === true`. If none match,
* the search input is not rendered at all.
*
* When provided, every column id must exist in `columns`.
*/
filterColumn?: string | string[];
filterPlaceholder?: string;
/**
* Controlled filter value. When provided together with `onFilterChange`
* the parent owns the source of truth — typically `useTableQueryFilter`
* for URL/storage-backed filters. The value flows through TanStack's
* `state.globalFilter`, so `DataTableFilter` stays uniform regardless
* of whether the table is single- or multi-column.
*/
filterValue?: string;
initialPageSize?: number;
initialSorting?: SortingState;
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
onFilterChange?: (value: string) => void;
onPageChange?: (pageIndex: number) => void;
onRowClick?: (row: TData) => void;
pageIndex?: number;
renderRowContextMenu?: (row: TData) => ReactNode;
renderSubComponent?: (props: { row: Row<TData> }) => ReactElement;
/**
* Storage slot for sorting / column visibility / page size / search-column
* narrowing. Defaults to `usePageStorageKeys().table` — i.e.
* `table_4_<pathname>` — which is correct for any route that owns exactly
* one DataTable. Pages that mount multiple DataTables on the same route
* (e.g. `/settings/prompts`) must pass distinct keys per instance, or
* their persisted state will alias and overwrite each other.
*
* Recommended composition for multi-table routes — take the route base
* through `usePageStorageKeys` and append a per-table suffix instead of
* hard-coding the `table_4_<path>` prefix:
*
* ```tsx
* const { table: base } = usePageStorageKeys();
* <DataTable storageKey={`${base}:agents`} … />
* <DataTable storageKey={`${base}:tools`} … />
* ```
*/
storageKey?: string;
}
const PAGE_SIZE_OPTIONS = [10, 15, 20, 50, 100] as const;
interface DataTableFilterProps<TData> {
column: string;
placeholder: string;
table: ReactTable<TData>;
}
const columnPickerLabel = <TData,>(column: Column<TData, unknown>): string =>
column.columnDef.meta?.columnMenuLabel ?? column.id;
/**
* Search input bound to a single TanStack Table column. Reads/writes the column's
* filter value directly — purely cosmetic shell around the table's existing
* filter API. Extracted out of `DataTable` so the toolbar JSX doesn't have to
* inline an IIFE just to capture the current `filterValue` once.
* Resolve a `ColumnDef`'s id the way TanStack does internally: explicit `id`
* wins, then `accessorKey` when it's a plain string. Display columns and
* `accessorFn` columns without an explicit id resolve to `undefined` —
* callers filter those out before passing the id to APIs that require one.
*/
const DataTableFilter = <TData,>({ column, placeholder, table }: DataTableFilterProps<TData>) => {
const tableColumn = table.getColumn(column);
const filterValue = (tableColumn?.getFilterValue() as string) ?? '';
const getColumnId = <TData, TValue>(column: ColumnDef<TData, TValue>): string | undefined => {
const withId = column as { id?: string };
if (withId.id) {
return withId.id;
}
const withAccessor = column as { accessorKey?: string };
return typeof withAccessor.accessorKey === 'string' ? withAccessor.accessorKey : undefined;
};
interface DataTableFilterProps {
onQueryChange: (value: string) => void;
placeholder: string;
query: string;
}
const FILTER_DEBOUNCE_MS = 150;
// Hard cap on the filter query length. 200 chars is more than any realistic
// search term and protects against pathological inputs (paste of a multi-KB
// chunk) that would otherwise blow past URL limits — browsers handle ~8 KB,
// reverse-proxies typically cap at 24 KB, so a 5 KB share-link becomes
// unreliable. `<input maxLength>` truncates typing and paste at the DOM
// boundary, which is the only entry point users have here.
const FILTER_MAX_LENGTH = 200;
/**
* Search input for the table's global filter. Keystrokes update an internal
* `localValue` state synchronously so the input feels instant; the debounced
* mirror is what we propagate upstream via `onQueryChange`. The previous
* design committed every keystroke straight into `useTableQueryFilter`, which
* synchronously walked the router and re-rendered the entire route subtree —
* with the Flows page that ran ~250 ms per keystroke, so consecutive
* keypresses queued behind the in-flight reconciliation and showed up as
* input-delay INP. Debouncing the commit drops the upstream cascade rate
* from per-keystroke to once per typing pause.
*
* External `query` changes (X button, programmatic clear, URL back-button,
* route swap) are reconciled into `localValue` through the sync effect: we
* skip when the incoming value matches what we last emitted, so our own
* round-trip through the parent doesn't fight with active typing.
*/
const DataTableFilter = ({ onQueryChange, placeholder, query }: DataTableFilterProps) => {
const [localValue, setLocalValue] = useState(query);
const debouncedValue = useDebouncedValue(localValue, FILTER_DEBOUNCE_MS);
const lastEmittedReference = useRef(query);
// Generated per-instance so pages with multiple DataTables (e.g.
// /settings/prompts) don't end up with duplicate `id` attributes — that
// breaks `getElementById`, a11y semantics, and any test selector that
// relies on the input id.
const fieldId = useId();
useEffect(() => {
if (query !== lastEmittedReference.current) {
setLocalValue(query);
lastEmittedReference.current = query;
}
}, [query]);
useEffect(() => {
if (debouncedValue !== lastEmittedReference.current) {
lastEmittedReference.current = debouncedValue;
onQueryChange(debouncedValue);
}
}, [debouncedValue, onQueryChange]);
const handleClear = useCallback(() => {
setLocalValue('');
lastEmittedReference.current = '';
onQueryChange('');
}, [onQueryChange]);
return (
<InputGroup className="max-w-sm">
@@ -80,16 +215,20 @@ const DataTableFilter = <TData,>({ column, placeholder, table }: DataTableFilter
<Search />
</InputGroupAddon>
<InputGroupInput
aria-label={placeholder}
autoComplete="off"
onChange={(event) => tableColumn?.setFilterValue(event.target.value)}
id={fieldId}
maxLength={FILTER_MAX_LENGTH}
name={fieldId}
onChange={(event) => setLocalValue(event.target.value)}
placeholder={placeholder}
type="text"
value={filterValue}
value={localValue}
/>
{filterValue ? (
{localValue ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={() => tableColumn?.setFilterValue('')}
onClick={handleClear}
type="button"
>
<X />
@@ -100,63 +239,251 @@ const DataTableFilter = <TData,>({ column, placeholder, table }: DataTableFilter
);
};
interface DataTableColumnHeaderProps<TData, TValue> {
/** TanStack column. Sorting state and the cycle action both target it. */
column: Column<TData, TValue>;
/** Visible label rendered as the button text. Named after the shadcn convention. */
title: ReactNode;
}
/**
* Cycle a TanStack column through `none → asc → desc → none`. Pure with
* respect to React (no hooks called) so a header `onClick` can invoke it
* directly without `useCallback`/`useMemo` ceremony. Exported alongside
* {@link DataTableColumnHeader} so a custom header can drive the same
* sort cycle without duplicating the if/else.
*/
export function cycleColumnSort<TData, TValue = unknown>(column: Column<TData, TValue>): void {
const sorted = column.getIsSorted();
if (sorted === 'asc') {
column.toggleSorting(true);
return;
}
if (sorted === 'desc') {
column.clearSorting();
return;
}
column.toggleSorting(false);
}
function DataTable<TData, TValue = unknown>({
columns,
columnVisibility: externalColumnVisibility,
data,
filterColumn = 'name',
filterColumn,
filterPlaceholder = 'Filter...',
filterValue: externalFilterValue,
initialPageSize = 10,
initialSorting = [],
onColumnVisibilityChange,
onFilterChange,
onPageChange,
onRowClick,
pageIndex: externalPageIndex,
renderRowContextMenu,
renderSubComponent,
storageKey: explicitStorageKey,
}: DataTableProps<TData, TValue>) {
const isColumnVisibilityControlled = externalColumnVisibility !== undefined;
const isPageControlled = externalPageIndex !== undefined;
const isFilterControlled = externalFilterValue !== undefined && onFilterChange !== undefined;
const isRowInteractive = !!onRowClick || !!renderSubComponent;
const sortingKey = useMemo(() => getSortingStorageKey(), []);
const columnKey = useMemo(() => getColumnStorageKey(), []);
const pageKey = useMemo(() => getPageStorageKey(), []);
const { pathname } = useLocation();
// Reuse the pathname we just read instead of letting the hook subscribe
// independently — react-router caches `useLocation` so the cost is
// negligible, but the explicit pass keeps the data flow obvious and
// makes it easy to migrate the table to a different storage scope (e.g.
// a workspace-prefixed path) without grepping for every subscription.
//
// When `storageKey` is passed by the parent it wins — multi-table routes
// (e.g. /settings/prompts) need distinct slots per instance, otherwise
// their sorting / visibility / search-column narrowing alias and
// overwrite each other under the shared `table_4_<path>` key.
const { table: defaultTableKey } = usePageStorageKeys({ pathname });
const tableKey = explicitStorageKey ?? defaultTableKey;
const [sorting, setSorting] = useState<SortingState>(() => loadSorting(sortingKey) ?? initialSorting);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
// Run the legacy → unified migration exactly once on mount via `useState`
// lazy init. The lazy initializer runs in a single commit, so the
// localStorage hit happens once even under StrictMode's double-invoke.
// After mount, the `useEffect` below re-runs migration when `tableKey`
// rotates (route change inside a persistent layout).
const [initialState] = useState(() => migrateLegacyTableState(pathname, tableKey));
const [sorting, setSorting] = useState<SortingState>(() => initialState.sorting ?? initialSorting);
const [internalGlobalFilter, setInternalGlobalFilter] = useState<string>('');
const [searchColumns, setSearchColumns] = useState<string[]>(() => initialState.searchColumns ?? []);
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() =>
isColumnVisibilityControlled ? {} : (initialState.columnVisibility ?? {}),
);
const [pagination, setPagination] = useState(() => ({
pageIndex: isPageControlled ? (externalPageIndex ?? 0) : 0,
pageSize: initialState.pageSize ?? initialPageSize,
}));
const [rowSelection, setRowSelection] = useState({});
const [expanded, setExpanded] = useState<ExpandedState>({});
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() =>
isColumnVisibilityControlled ? {} : (loadColumnVisibility(columnKey) ?? {}),
// Resolve the set of column ids the search input may target.
// Priority: explicit array prop > legacy single-string prop > columns with
// `meta.searchable === true`. Falls through to `[]` when no opt-in exists,
// which suppresses the search input entirely (see JSX below).
const searchCandidateIds = useMemo<string[]>(() => {
if (Array.isArray(filterColumn)) {
return filterColumn;
}
if (typeof filterColumn === 'string') {
return [filterColumn];
}
return columns
.filter((column) => column.meta?.searchable === true)
.map(getColumnId)
.filter((id): id is string => typeof id === 'string');
}, [columns, filterColumn]);
const isMultiMode = Array.isArray(filterColumn) || (filterColumn === undefined && searchCandidateIds.length > 0);
// Rebase `searchColumns` whenever the set of candidates changes (column
// reconfiguration, HMR, or a hydrated localStorage entry that references
// ids the current page no longer exposes). The empty-array sentinel — our
// "search everywhere" state — is preserved as-is; non-empty selections are
// pruned to the still-valid intersection. Return `prev` unchanged when the
// intersection equals the input so we don't trigger a spurious storage
// write through the persistence effect below.
useEffect(() => {
setSearchColumns((prev) => {
if (prev.length === 0) {
return prev;
}
const filtered = prev.filter((id) => searchCandidateIds.includes(id));
return filtered.length === prev.length ? prev : filtered;
});
}, [searchCandidateIds]);
// Track which tableKey we've already migrated + seeded from. When the
// key rotates (route change inside a persistent layout, or an explicit
// override) we re-run the migration for the new path and refresh local
// state with whatever's stored under the new key.
const seededForKeyReference = useRef(tableKey);
useEffect(() => {
if (seededForKeyReference.current === tableKey) {
return;
}
seededForKeyReference.current = tableKey;
const stored = migrateLegacyTableState(pathname, tableKey);
setSorting(stored.sorting ?? initialSorting);
setSearchColumns(stored.searchColumns ?? []);
if (!isColumnVisibilityControlled) {
setInternalColumnVisibility(stored.columnVisibility ?? {});
}
setPagination((previous) => ({
...previous,
pageSize: stored.pageSize ?? initialPageSize,
}));
}, [initialPageSize, initialSorting, isColumnVisibilityControlled, pathname, tableKey]);
// Compose the query and the active column set into a single TanStack
// state value. New object identity on any change is exactly what TanStack
// watches to re-run the filter pipeline — no imperative `setGlobalFilter`
// pokes, no closure-only narrowing that updates silently. The `columns`
// field follows the same "empty selection = search everywhere" sentinel
// as `searchColumns`, resolved once here for the predicate's convenience.
const effectiveQuery = isFilterControlled ? (externalFilterValue ?? '') : internalGlobalFilter;
const effectiveGlobalFilter = useMemo<DataTableGlobalFilter>(
() => ({
columns: searchColumns.length > 0 ? searchColumns : searchCandidateIds,
query: effectiveQuery,
}),
[effectiveQuery, searchCandidateIds, searchColumns],
);
const [pagination, setPagination] = useState(() => {
const stored = loadPageState(pageKey);
const effectiveGlobalFilterReference = useLatestRef(effectiveGlobalFilter);
return {
pageIndex: isPageControlled ? (externalPageIndex ?? 0) : (stored?.page ?? 0),
pageSize: stored?.pageSize ?? initialPageSize,
};
});
// Funnel every TanStack global-filter change through the right sink. The
// updater arrives in three shapes:
// * a raw string from `DataTableFilter` (input.onChange → setGlobalFilter)
// * a function `(prev: DataTableGlobalFilter) => DataTableGlobalFilter`
// from any TanStack-internal mutation (e.g. `table.resetGlobalFilter`)
// * a bare composite object from a programmatic write
// We resolve it down to the `query` string at the boundary — controlled
// parents and internal state both speak strings.
const handleGlobalFilterChange = useCallback(
(updater: unknown) => {
const resolveQuery = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
if (value && typeof value === 'object' && 'query' in value) {
return String((value as { query: unknown }).query ?? '');
}
return '';
};
const nextRaw =
typeof updater === 'function'
? (updater as (previous: DataTableGlobalFilter) => unknown)(effectiveGlobalFilterReference.current)
: updater;
const nextQuery = resolveQuery(nextRaw);
if (isFilterControlled) {
onFilterChange?.(nextQuery);
} else {
setInternalGlobalFilter(nextQuery);
}
},
[effectiveGlobalFilterReference, isFilterControlled, onFilterChange],
);
// Persist sorting + column visibility + page size into the unified
// `table_4_<path>` slot. Skipping the first render is intentional: a
// fresh mount would otherwise overwrite the seeded values with the
// same payload on the next commit.
useEffectAfterMount(() => {
updateTableState(tableKey, { sorting });
}, [sorting, tableKey]);
useEffectAfterMount(() => {
saveSorting(sortingKey, sorting);
}, [sorting, sortingKey]);
useEffectAfterMount(() => {
if (!isColumnVisibilityControlled) {
saveColumnVisibility(columnKey, internalColumnVisibility);
if (isColumnVisibilityControlled) {
return;
}
}, [internalColumnVisibility, columnKey, isColumnVisibilityControlled]);
updateTableState(tableKey, { columnVisibility: internalColumnVisibility });
}, [internalColumnVisibility, isColumnVisibilityControlled, tableKey]);
// Only persist a non-default pageSize. `updateTableState` clears the
// field when handed `undefined`, so the storage slot stays empty until
// the user actively picks a different page size. Without this guard the
// StrictMode dev double-invoke of `useEffectAfterMount` would seed
// `{ pageSize: 10 }` on every fresh mount — harmless in prod, noisy in
// dev tooling and surprising when inspecting storage.
useEffectAfterMount(() => {
savePageState(pageKey, {
page: isPageControlled ? 0 : pagination.pageIndex,
pageSize: pagination.pageSize,
updateTableState(tableKey, {
pageSize: pagination.pageSize === initialPageSize ? undefined : pagination.pageSize,
});
}, [pagination.pageIndex, pagination.pageSize, pageKey, isPageControlled]);
}, [initialPageSize, pagination.pageSize, tableKey]);
// Empty array is the "default for everyone" sentinel — `updateTableState`
// collapses `[]` to a delete so the storage slot stays empty until the
// user actively narrows the search column set.
useEffectAfterMount(() => {
updateTableState(tableKey, { searchColumns });
}, [searchColumns, tableKey]);
const columnVisibility = externalColumnVisibility ?? internalColumnVisibility;
@@ -183,12 +510,7 @@ function DataTable<TData, TValue = unknown>({
}
}, [externalPageIndex]);
const handlePageSizeChange = useCallback((newPageSize: number) => {
setPagination({ pageIndex: 0, pageSize: newPageSize });
}, []);
const paginationReference = useRef(pagination);
paginationReference.current = pagination;
const paginationReference = useLatestRef(pagination);
const handlePaginationChange = useCallback(
(
@@ -204,9 +526,41 @@ function DataTable<TData, TValue = unknown>({
onPageChange(newPagination.pageIndex);
}
},
[onPageChange],
[onPageChange, paginationReference],
);
// Route page-size changes through the same channel as TanStack's
// pagination mutations so `onPageChange` fires when the URL-driven
// pageIndex needs to drop to 0. Without this, picking "All" on a high
// page leaves `?page=5` stranded in the URL even though the display
// correctly clamps to "Page 1 of 1".
const handlePageSizeChange = useCallback(
(newPageSize: number) => {
handlePaginationChange({ pageIndex: 0, pageSize: newPageSize });
},
[handlePaginationChange],
);
// Case-insensitive substring predicate that consults the composite
// filter for both "what to look for" and "where to look". Returning
// `true` for the empty query keeps the default "no filter = all rows"
// behaviour identical to TanStack's built-in `includesString`.
const globalFilterFn = useCallback<FilterFn<TData>>((row, columnId, filter: DataTableGlobalFilter) => {
if (!filter.query) {
return true;
}
if (!filter.columns.includes(columnId)) {
return false;
}
const value = row.getValue(columnId);
return String(value ?? '')
.toLowerCase()
.includes(filter.query.toLowerCase());
}, []);
const table = useReactTable({
autoResetPageIndex: false,
columns,
@@ -217,16 +571,17 @@ function DataTable<TData, TValue = unknown>({
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
globalFilterFn,
onColumnVisibilityChange: handleColumnVisibilityChange,
onExpandedChange: setExpanded,
onGlobalFilterChange: handleGlobalFilterChange,
onPaginationChange: handlePaginationChange,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
columnFilters,
columnVisibility,
expanded,
globalFilter: effectiveGlobalFilter,
pagination,
rowSelection,
sorting,
@@ -247,19 +602,108 @@ function DataTable<TData, TValue = unknown>({
const pageSizeValue = pagination.pageSize >= data.length && data.length > 0 ? 'all' : String(pagination.pageSize);
const totalRows = table.getFilteredRowModel().rows.length;
const rangeStart = totalRows > 0 ? pagination.pageIndex * pagination.pageSize + 1 : 0;
const rangeEnd = Math.min((pagination.pageIndex + 1) * pagination.pageSize, totalRows);
const pageCount = table.getPageCount();
// `pagination.pageIndex` is mirrored from `?page=` (controlled) or the
// user's clicks (uncontrolled). Either source can point past the actual
// end of the dataset — hand-typed URLs, a filter narrowing results, or a
// page-size bump to "All" while we were on a high page. Derive a clamped
// view for the display values so the user never sees "Page 999 of 31",
// and reconcile the source of truth via the effect below.
const safePageIndex = pageCount > 0 ? Math.min(Math.max(0, pagination.pageIndex), pageCount - 1) : 0;
const rangeStart = totalRows > 0 ? safePageIndex * pagination.pageSize + 1 : 0;
const rangeEnd = Math.min((safePageIndex + 1) * pagination.pageSize, totalRows);
// Reconcile the canonical pageIndex once we know `pageCount`. The write
// has to happen in an effect because `setSearchParams` (controlled mode)
// and `setPagination` (uncontrolled) both mutate state outside the render
// pipeline, which React forbids during render. The early return makes the
// effect a no-op on every render where the URL is already in range, so
// the cost on the happy path is one comparison.
//
// Controlled mode compares the URL (`externalPageIndex`) — the canonical
// source of truth — rather than the internal mirror, because internal
// state can drop to a clamped value via `handlePageSizeChange` while the
// URL still points at the stale page. Uncontrolled mode keeps comparing
// the internal pageIndex since there's no other source.
useEffect(() => {
if (pageCount === 0) {
return;
}
if (isPageControlled) {
if (externalPageIndex !== undefined && externalPageIndex !== safePageIndex) {
onPageChange?.(safePageIndex);
}
return;
}
if (pagination.pageIndex !== safePageIndex) {
setPagination((previous) => ({ ...previous, pageIndex: safePageIndex }));
}
}, [externalPageIndex, isPageControlled, onPageChange, pageCount, pagination.pageIndex, safePageIndex]);
return (
<div className="w-full">
<div className="flex items-center gap-4 py-4">
{filterColumn ? (
<div className="flex items-center gap-2 py-4">
{searchCandidateIds.length > 0 ? (
<DataTableFilter
column={filterColumn}
onQueryChange={(value) => table.setGlobalFilter(value)}
placeholder={filterPlaceholder}
table={table}
query={effectiveQuery}
/>
) : null}
{isMultiMode && searchCandidateIds.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Search in"
size="icon"
variant="outline"
>
<ListFilter />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{searchCandidateIds.map((id) => {
const column = table.getColumn(id);
if (!column) {
return null;
}
const isChecked = searchColumns.length === 0 ? true : searchColumns.includes(id);
return (
<DropdownMenuCheckboxItem
checked={isChecked}
className={column.columnDef.meta?.columnMenuLabel ? undefined : 'capitalize'}
key={id}
onCheckedChange={(value) => {
setSearchColumns((prev) => {
// Treat the empty-selection
// sentinel as "all candidates"
// before mutating, so the user
// never lands in a state where
// unchecking one box silently
// re-enables every column.
const base = prev.length === 0 ? [...searchCandidateIds] : prev;
return value
? Array.from(new Set([id, ...base]))
: base.filter((x) => x !== id);
});
}}
onSelect={(event) => event.preventDefault()}
>
{columnPickerLabel(column)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -276,12 +720,12 @@ function DataTable<TData, TValue = unknown>({
.map((column) => (
<DropdownMenuCheckboxItem
checked={column.getIsVisible()}
className="capitalize"
className={column.columnDef.meta?.columnMenuLabel ? undefined : 'capitalize'}
key={column.id}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
onSelect={(event) => event.preventDefault()}
>
{column.id}
{columnPickerLabel(column)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
@@ -424,9 +868,16 @@ function DataTable<TData, TValue = unknown>({
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-center text-xs font-medium lg:w-20">
Page {pagination.pageIndex + 1} of {table.getPageCount()}
</div>
{pageCount > 0 ? (
<div className="flex items-center justify-center text-xs font-medium lg:w-20">
Page {safePageIndex + 1} of {pageCount}
</div>
) : (
<div
aria-hidden
className="lg:w-20"
/>
)}
<div className="flex items-center gap-1">
<Button
disabled={!table.getCanPreviousPage()}
@@ -466,4 +917,35 @@ function DataTable<TData, TValue = unknown>({
);
}
export { DataTable };
/**
* Reusable header for sortable `DataTable` columns. Wraps the conventional
* "label + asc/desc arrow + onClick → cycleColumnSort" pattern so every list
* page reuses one component instead of repeating ~20 lines per column header.
*
* The header reads sort direction directly from `column.getIsSorted()` so the
* caller does not have to plumb it. The arrow icon mirrors the TanStack
* convention: `asc` shows ↓ ("ascending continues to grow downward") and
* `desc` shows ↑. No arrow means "not sorted by this column".
*
* Naming follows the canonical shadcn `DataTableColumnHeader` (see
* https://ui.shadcn.com/docs/components/data-table) so call sites read like
* the upstream docs — only the click model is simpler here (none → asc →
* desc → none toggle vs. a dropdown menu).
*/
function DataTableColumnHeader<TData, TValue = unknown>({ column, title }: DataTableColumnHeaderProps<TData, TValue>) {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => cycleColumnSort(column)}
variant="link"
>
{title}
{sorted === 'asc' ? <ArrowDown className="size-4" /> : null}
{sorted === 'desc' ? <ArrowUp className="size-4" /> : null}
</Button>
);
}
export { DataTable, DataTableColumnHeader };
+1 -3
View File
@@ -12,9 +12,7 @@ const ScrollArea = React.forwardRef<
ref={ref}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
@@ -5,8 +5,8 @@ import type { AgentLogFragmentFragment } from '@/graphql/types';
import Markdown from '@/components/shared/markdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { copyMessageToClipboard } from '@/lib/clipboard';
import { formatDate } from '@/lib/utils/format';
import { copyMessageToClipboard } from '@/lib/сlipboard';
import FlowAgentIcon from './flow-agent-icon';
@@ -2,9 +2,7 @@ import { FolderInput, Search, X } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { FileManager, type FileNode } from '@/components/shared/file-manager';
import { OverwriteConfirmDialog } from '@/components/shared/overwrite-confirm-dialog';
import { OverwriteCtaButtons } from '@/components/shared/overwrite-cta-buttons';
import { useOverwriteAction } from '@/components/shared/use-overwrite-action';
import { OverwriteButtons, OverwriteDialog, useOverwrite } from '@/components/shared/overwrite';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -86,7 +84,7 @@ const FlowFilesAttachResourcesDialogBody = ({
* the backend) and the resource paths (used by preflight against the
* flow's existing cache mirror).
*/
const overwriteAction = useOverwriteAction<AttachPlan>({
const overwriteAction = useOverwrite<AttachPlan>({
execute: async ({ ids }, force) => attach({ ids: [...ids], shouldOverwrite: force }),
findConflicts: ({ resourcePaths }) => findAttachConflicts(resourcePaths, cachedFiles),
onSuccess: () => {
@@ -253,7 +251,7 @@ const FlowFilesAttachResourcesDialogBody = ({
>
Cancel
</Button>
<OverwriteCtaButtons
<OverwriteButtons
isDisabled={isAttachDisabled}
isProcessing={isAttaching}
onOverwrite={handleOverwrite}
@@ -266,7 +264,7 @@ const FlowFilesAttachResourcesDialogBody = ({
</DialogFooter>
</DialogContent>
<OverwriteConfirmDialog
<OverwriteDialog
conflicts={overwriteAction.conflicts}
onCancel={overwriteAction.resetConflicts}
onReplaceAll={overwriteAction.handleReplaceAll}
@@ -1,5 +1,5 @@
import type { FileNode } from '@/components/shared/file-manager';
import type { OverwriteConflict } from '@/components/shared/overwrite-confirm-dialog';
import type { OverwriteConflict } from '@/components/shared/overwrite';
import { CONTAINER_PATH_PREFIX, RESOURCES_PATH_PREFIX } from './flow-files-constants';
@@ -28,7 +28,7 @@ const containerPathToCachePath = (containerPath: string): string => {
* (re-pulling a file the user already has) without an extra REST round-trip.
* Nested conflicts (the user pulls `/etc/` while only `/etc/nginx.conf` is
* cached) still surface server-side as a 409 and are auto-redialed by the
* caller through the same `OverwriteConfirmDialog` flow.
* caller through the same `OverwriteDialog` flow.
*/
export const findPullConflicts = (
pullTargets: readonly string[],
@@ -4,11 +4,9 @@ import { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import type { FileNode } from '@/components/shared/file-manager';
import type { OverwriteConflict } from '@/components/shared/overwrite-confirm-dialog';
import type { OverwriteConflict } from '@/components/shared/overwrite';
import { OverwriteConfirmDialog } from '@/components/shared/overwrite-confirm-dialog';
import { OverwriteCtaButtons } from '@/components/shared/overwrite-cta-buttons';
import { useOverwriteAction } from '@/components/shared/use-overwrite-action';
import { OverwriteButtons, OverwriteDialog, useOverwrite } from '@/components/shared/overwrite';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
@@ -139,7 +137,7 @@ const FlowFilesPromoteDialogForm = ({ files, flowId, onClose }: FlowFilesPromote
* with a single atomic batch request. Backend handles `sources[]` in one
* DB transaction (all-or-nothing) — no per-source aggregation needed here.
*/
const overwriteAction = useOverwriteAction<PromotePlan>({
const overwriteAction = useOverwrite<PromotePlan>({
execute: (plan, force) => promote(plan.sources, plan.destination, force),
// Local preflight against the resource library snapshot — flags the
// exact destinations already taken so the dialog can name them.
@@ -236,7 +234,7 @@ const FlowFilesPromoteDialogForm = ({ files, flowId, onClose }: FlowFilesPromote
>
Cancel
</Button>
<OverwriteCtaButtons
<OverwriteButtons
isDisabled={isSubmitDisabled}
isProcessing={isPromoting}
onOverwrite={() => {
@@ -252,7 +250,7 @@ const FlowFilesPromoteDialogForm = ({ files, flowId, onClose }: FlowFilesPromote
</Form>
</DialogContent>
<OverwriteConfirmDialog
<OverwriteDialog
conflicts={overwriteAction.conflicts}
onCancel={overwriteAction.resetConflicts}
onReplaceAll={overwriteAction.handleReplaceAll}
@@ -7,9 +7,7 @@ import {
type FileManagerBulkAction,
type FileNode,
} from '@/components/shared/file-manager';
import { OverwriteConfirmDialog } from '@/components/shared/overwrite-confirm-dialog';
import { OverwriteCtaButtons } from '@/components/shared/overwrite-cta-buttons';
import { useOverwriteAction } from '@/components/shared/use-overwrite-action';
import { OverwriteButtons, OverwriteDialog, useOverwrite } from '@/components/shared/overwrite';
import {
Autocomplete,
AutocompleteContent,
@@ -97,7 +95,7 @@ const getParentContainerPath = (path: string): string => {
* is open so closing it discards every transient field without an imperative reset.
*
* The actual overwrite orchestration (preflight → execute → ConflictDialog
* fallback) is delegated to {@link useOverwriteAction}; this component only
* fallback) is delegated to {@link useOverwrite}; this component only
* owns the listing browser UI and the per-action plan derivation.
*/
const FlowFilesPullDialogForm = ({ cachedFiles, flowId, onClose, onSuccess }: FlowFilesPullDialogFormProps) => {
@@ -169,7 +167,7 @@ const FlowFilesPullDialogForm = ({ cachedFiles, flowId, onClose, onSuccess }: Fl
* close-on-success — this dialog just provides the plan (paths) and the
* three pure helpers (find / execute / synthesize).
*/
const overwriteAction = useOverwriteAction<readonly string[]>({
const overwriteAction = useOverwrite<readonly string[]>({
execute: (paths, force) => pull(paths, force),
findConflicts: (paths) => findPullConflicts(paths, cachedFiles),
onSuccess: onClose,
@@ -430,7 +428,7 @@ const FlowFilesPullDialogForm = ({ cachedFiles, flowId, onClose, onSuccess }: Fl
>
Cancel
</Button>
<OverwriteCtaButtons
<OverwriteButtons
isDisabled={isPullDisabled}
isProcessing={isPulling}
onOverwrite={() => {
@@ -446,7 +444,7 @@ const FlowFilesPullDialogForm = ({ cachedFiles, flowId, onClose, onSuccess }: Fl
</DialogFooter>
</DialogContent>
<OverwriteConfirmDialog
<OverwriteDialog
conflicts={overwriteAction.conflicts}
onCancel={overwriteAction.resetConflicts}
onReplaceAll={overwriteAction.handleReplaceAll}
@@ -1,7 +1,7 @@
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import type { OverwriteOutcome } from '@/components/shared/use-overwrite-action';
import type { OverwriteOutcome } from '@/components/shared/overwrite';
import { resourceIdsToWire } from '@/features/resources/resources-rest';
import { api, getApiErrorMessage, getApiErrorStatusCode } from '@/lib/axios';
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { z } from 'zod';
import type { OverwriteOutcome } from '@/components/shared/use-overwrite-action';
import type { OverwriteOutcome } from '@/components/shared/overwrite';
import type { RestResourceList } from '@/features/resources/resources-rest';
import { pluralizeItems } from '@/features/resources/resources-utils';
@@ -1,7 +1,7 @@
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import type { OverwriteOutcome } from '@/components/shared/use-overwrite-action';
import type { OverwriteOutcome } from '@/components/shared/overwrite';
import { api, getApiErrorMessage, getApiErrorStatusCode } from '@/lib/axios';
+220 -172
View File
@@ -3,6 +3,7 @@ import {
ArrowUp,
Check,
ChevronDown,
Ellipsis,
FileSymlink,
FileText,
Folder,
@@ -39,6 +40,7 @@ import {
} from '@/components/ui/input-group';
import { Spinner } from '@/components/ui/spinner';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useResourcesUpload } from '@/features/resources/use-resources-upload';
import { getProviderDisplayName } from '@/models/provider';
@@ -88,6 +90,11 @@ export const FlowForm = ({
const [providerSearch, setProviderSearch] = useState('');
const [templateSearch, setTemplateSearch] = useState('');
const [resourceSearch, setResourceSearch] = useState('');
// Tracks which picker the combined dropdown is showing. Lifted to form
// state (instead of internal to the menu) so the tab choice survives
// re-renders triggered by `setTemplateSearch` / `setResourceSearch`
// inside the inner pickers.
const [pickerTab, setPickerTab] = useState<'resources' | 'templates'>('templates');
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -323,6 +330,158 @@ export const FlowForm = ({
}
}, [pendingTemplate, setValue]);
// Templates and resources share the same dropdown via tabs — both picker
// bodies are kept as render functions so each can be mounted directly
// inside its `<TabsContent>` without duplicating the search-input +
// scrolled-list layout.
const renderTemplatePickerInner = () => (
<>
<DropdownMenuGroup className="-m-1 rounded-none p-0">
<InputGroup className="-mb-1 rounded-none border-0 shadow-none [&:has([data-slot=input-group-control]:focus-visible)]:border-0 [&:has([data-slot=input-group-control]:focus-visible)]:ring-0">
<InputGroupInput
onChange={(event) => setTemplateSearch(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
placeholder="Search..."
value={templateSearch}
/>
{templateSearch && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={(event) => {
event.stopPropagation();
setTemplateSearch('');
}}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<DropdownMenuSeparator />
</DropdownMenuGroup>
<DropdownMenuGroup className="max-h-64 overflow-y-auto">
{!filteredTemplates.length ? (
<DropdownMenuItem
className="min-h-16 justify-center"
disabled
>
{templateSearch ? 'No results found' : 'No available templates'}
</DropdownMenuItem>
) : (
filteredTemplates.map((template) => (
<DropdownMenuItem
key={template.id}
onSelect={() => {
if (isFormDisabled) {
return;
}
handleApplyTemplate(template);
}}
>
<span className="max-w-80 flex-1 truncate">{template.title}</span>
</DropdownMenuItem>
))
)}
</DropdownMenuGroup>
</>
);
const renderResourcePickerInner = () => (
<>
<DropdownMenuGroup className="-m-1 rounded-none p-0">
<InputGroup className="-mb-1 rounded-none border-0 shadow-none [&:has([data-slot=input-group-control]:focus-visible)]:border-0 [&:has([data-slot=input-group-control]:focus-visible)]:ring-0">
<InputGroupInput
onChange={(event) => setResourceSearch(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
placeholder="Search..."
value={resourceSearch}
/>
{resourceSearch && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={(event) => {
event.stopPropagation();
setResourceSearch('');
}}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<DropdownMenuSeparator />
</DropdownMenuGroup>
<DropdownMenuGroup className="max-h-64 overflow-y-auto">
{!filteredResources.length ? (
<DropdownMenuItem
className="min-h-16 justify-center"
disabled
>
{resourceSearch ? 'No results found' : 'No available resources'}
</DropdownMenuItem>
) : (
filteredResources.map((resource) => {
const resourceId = String(resource.id);
const isSelected = resourceIds.includes(resourceId);
const Icon = resource.isDir ? Folder : FileText;
// Depth derived from the path's slash count; ignored while a
// search query is active so matches don't appear orphaned
// beneath hidden ancestors.
const depth = isResourceSearchActive ? 0 : resource.path.split('/').length - 1;
return (
<DropdownMenuItem
key={resourceId}
onSelect={(event) => {
event.preventDefault();
if (isFormDisabled) {
return;
}
handleToggleAttachment(resourceId);
}}
style={{ paddingLeft: `${0.5 + depth * 0.875}rem` }}
>
<div className="flex w-full min-w-0 items-center gap-2">
<Icon className="text-muted-foreground size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{resource.name}</span>
{isResourceSearchActive && resource.path !== resource.name && (
<span className="text-muted-foreground truncate text-xs">
{resource.path}
</span>
)}
</div>
{isSelected && <Check className="ml-auto size-4 shrink-0" />}
</div>
</DropdownMenuItem>
);
})
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={upload.isUploading}
onSelect={(event) => {
event.preventDefault();
if (isFormDisabled) {
return;
}
handleAttachClick();
}}
>
{upload.isUploading ? <Loader2 className="animate-spin" /> : <Plus />}
{upload.isUploading ? 'Uploading…' : 'Upload files'}
</DropdownMenuItem>
</>
);
return (
<Form {...form}>
<form onSubmit={handleFormSubmit(handleSubmit)}>
@@ -520,201 +679,90 @@ export const FlowForm = ({
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<InputGroupButton
disabled={isFormDisabled}
variant="ghost"
>
<FileText className="shrink-0" />
<ChevronDown />
</InputGroupButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="top"
>
<DropdownMenuGroup className="-m-1 rounded-none p-0">
<InputGroup className="-mb-1 rounded-none border-0 shadow-none [&:has([data-slot=input-group-control]:focus-visible)]:border-0 [&:has([data-slot=input-group-control]:focus-visible)]:ring-0">
<InputGroupInput
onChange={(event) => setTemplateSearch(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
placeholder="Search..."
value={templateSearch}
/>
{templateSearch && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={(event) => {
event.stopPropagation();
setTemplateSearch('');
}}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<DropdownMenuSeparator />
</DropdownMenuGroup>
<DropdownMenuGroup className="max-h-64 overflow-y-auto">
{!filteredTemplates.length ? (
<DropdownMenuItem
className="min-h-16 justify-center"
disabled
>
{templateSearch ? 'No results found' : 'No available templates'}
</DropdownMenuItem>
) : (
filteredTemplates.map((template) => (
<DropdownMenuItem
key={template.id}
onSelect={() => {
if (isFormDisabled) {
return;
}
handleApplyTemplate(template);
}}
>
<span className="max-w-80 flex-1 truncate">
{template.title}
</span>
</DropdownMenuItem>
))
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
onOpenChange={(open) => {
if (!open) {
setTemplateSearch('');
setResourceSearch('');
}
}}
>
<DropdownMenuTrigger asChild>
<InputGroupButton
aria-label="Templates and resources"
className="ml-auto shrink-0"
disabled={isFormDisabled}
size="icon-xs"
variant="ghost"
>
<Paperclip className="shrink-0" />
{flowResources.length > 0 && (
<span className="bg-muted text-muted-foreground -mx-0.5 flex h-4 min-w-4 items-center justify-center rounded px-1 text-xs font-medium tabular-nums">
{flowResources.length}
</span>
)}
<ChevronDown />
<Ellipsis className="shrink-0" />
</InputGroupButton>
</DropdownMenuTrigger>
{/* Single upward-opening dropdown for both Templates and Resources
on every viewport. Sub-menus would get clipped on the narrowest
screens (~390px), and a unified UI keeps the form simpler than
branching on `isMobile`. The tab strip is rendered last so it
lands closest to the trigger button. */}
<DropdownMenuContent
align="start"
align="end"
className="w-72"
side="top"
>
<DropdownMenuGroup className="-m-1 rounded-none p-0">
<InputGroup className="-mb-1 rounded-none border-0 shadow-none [&:has([data-slot=input-group-control]:focus-visible)]:border-0 [&:has([data-slot=input-group-control]:focus-visible)]:ring-0">
<InputGroupInput
onChange={(event) => setResourceSearch(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
placeholder="Search..."
value={resourceSearch}
/>
{resourceSearch && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={(event) => {
event.stopPropagation();
setResourceSearch('');
}}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<DropdownMenuSeparator />
</DropdownMenuGroup>
<DropdownMenuGroup className="max-h-64 overflow-y-auto">
{!filteredResources.length ? (
<DropdownMenuItem
className="min-h-16 justify-center"
disabled
>
{resourceSearch ? 'No results found' : 'No available resources'}
</DropdownMenuItem>
) : (
filteredResources.map((resource) => {
const resourceId = String(resource.id);
const isSelected = resourceIds.includes(resourceId);
const Icon = resource.isDir ? Folder : FileText;
// Depth derived from the path's slash count; ignored while a
// search query is active so matches don't appear orphaned
// beneath hidden ancestors.
const depth = isResourceSearchActive
? 0
: resource.path.split('/').length - 1;
return (
<DropdownMenuItem
key={resourceId}
onSelect={(event) => {
event.preventDefault();
if (isFormDisabled) {
return;
}
handleToggleAttachment(resourceId);
}}
style={{ paddingLeft: `${0.5 + depth * 0.875}rem` }}
>
<div className="flex w-full min-w-0 items-center gap-2">
<Icon className="text-muted-foreground size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">
{resource.name}
</span>
{isResourceSearchActive &&
resource.path !== resource.name && (
<span className="text-muted-foreground truncate text-xs">
{resource.path}
</span>
)}
</div>
{isSelected && (
<Check className="ml-auto size-4 shrink-0" />
)}
</div>
</DropdownMenuItem>
);
})
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={upload.isUploading}
onSelect={(event) => {
event.preventDefault();
if (isFormDisabled) {
return;
}
handleAttachClick();
<Tabs
onValueChange={(value) => {
// Defer the content swap to the next task so it lands
// *after* the pointerup that the click triggered.
// Radix `DropdownMenuItem` listens to pointerup directly,
// so if we swap synchronously, the pointerup at the tab
// coordinates lands on the freshly-mounted "Upload files"
// item in the Resources panel and fires its onSelect.
setTimeout(
() => setPickerTab(value as 'resources' | 'templates'),
0,
);
}}
value={pickerTab}
>
{upload.isUploading ? <Loader2 className="animate-spin" /> : <Plus />}
{upload.isUploading ? 'Uploading…' : 'Upload files'}
</DropdownMenuItem>
<TabsContent
className="mt-0 focus-visible:ring-0"
value="templates"
>
{renderTemplatePickerInner()}
</TabsContent>
<TabsContent
className="mt-0 focus-visible:ring-0"
value="resources"
>
{renderResourcePickerInner()}
</TabsContent>
<TabsList className="mt-1 grid w-full grid-cols-2">
<TabsTrigger
className="gap-1.5"
value="templates"
>
<FileText className="size-3.5" />
Templates
</TabsTrigger>
<TabsTrigger
className="gap-1.5"
value="resources"
>
<Paperclip className="size-3.5" />
Resources
{flowResources.length > 0 && (
<span className="bg-muted-foreground/20 text-foreground flex h-4 min-w-4 items-center justify-center rounded px-1 text-[10px] font-medium tabular-nums">
{flowResources.length}
</span>
)}
</TabsTrigger>
</TabsList>
</Tabs>
</DropdownMenuContent>
</DropdownMenu>
{!isLoading || isSubmitting ? (
<InputGroupButton
className="ml-auto"
className="shrink-0"
disabled={isSubmitting || !isValid || upload.isUploading}
size="icon-xs"
type="submit"
@@ -724,7 +772,7 @@ export const FlowForm = ({
</InputGroupButton>
) : (
<InputGroupButton
className="ml-auto"
className="shrink-0"
disabled={isCanceling || !onCancel}
onClick={() => onCancel?.()}
size="icon-xs"
@@ -7,9 +7,9 @@ import Markdown from '@/components/shared/markdown';
import Terminal from '@/components/shared/terminal';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { MessageLogType, ResultFormat } from '@/graphql/types';
import { copyMessageToClipboard } from '@/lib/clipboard';
import { cn } from '@/lib/utils';
import { formatDate } from '@/lib/utils/format';
import { copyMessageToClipboard } from '@/lib/сlipboard';
import FlowMessageTypeIcon from './flow-message-type-icon';
@@ -6,8 +6,8 @@ import type { SearchLogFragmentFragment } from '@/graphql/types';
import Markdown from '@/components/shared/markdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import FlowAgentIcon from '@/features/flows/agents/flow-agent-icon';
import { copyMessageToClipboard } from '@/lib/clipboard';
import { formatDate, formatName } from '@/lib/utils/format';
import { copyMessageToClipboard } from '@/lib/сlipboard';
interface FlowToolProps {
log: SearchLogFragmentFragment;
@@ -0,0 +1,29 @@
import { useDetailNavigation } from '@/components/shared/detail-navigation';
import { type Flow, useFlows } from '@/providers/flows-provider';
const getLabel = (item: Flow) => item.title || `Flow #${item.id}`;
const getSearchableText = (item: Flow) => item.title;
const getId = (item: Flow) => String(item.id);
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
* passes the returned controller to `<DetailNavigationToolbar controller={nav}>`
* (or to its leaf primitives for custom chrome).
*
* Pass `null` instead of an id while the page is in a non-viewing state
* (e.g. `/flows/new`) so the controller reports an unmatched current item.
*/
export const useFlowDetailNavigation = (currentId: null | string | undefined) => {
const { flows } = useFlows();
return useDetailNavigation<Flow>({
currentId,
getHref,
getId,
getLabel,
getSearchableText,
items: flows,
});
};
@@ -7,8 +7,8 @@ import Markdown from '@/components/shared/markdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import FlowAgentIcon from '@/features/flows/agents/flow-agent-icon';
import { VectorStoreAction } from '@/graphql/types';
import { copyMessageToClipboard } from '@/lib/clipboard';
import { formatDate } from '@/lib/utils/format';
import { copyMessageToClipboard } from '@/lib/сlipboard';
import FlowVectorStoreActionIcon from './flow-vector-store-action-icon';
@@ -3,6 +3,7 @@ import { Save } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { type FieldPath, type SubmitHandler, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { z } from 'zod';
import type {
@@ -11,14 +12,14 @@ import type {
UpdateKnowledgeDocumentInput,
} from '@/graphql/types';
import { UnsavedChangesDialog } from '@/components/shared/unsaved-changes-dialog';
import { Button } from '@/components/ui/button';
import { HeaderButton } from '@/components/shared/header-button';
import { UnsavedChangesDialog, useUnsavedChangesGuard } from '@/components/shared/unsaved-changes';
import { Form } from '@/components/ui/form';
import { Spinner } from '@/components/ui/spinner';
import { KnowledgeAnswerType, KnowledgeDocType, KnowledgeGuideType } from '@/graphql/types';
import { KnowledgeAnswerType, KnowledgeDocType, KnowledgeGuideType, useAnonymizeTextMutation } from '@/graphql/types';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useUnsavedChangesGuard } from '@/hooks/use-unsaved-changes-guard';
import { Log } from '@/lib/log';
import { useUser } from '@/providers/user-provider';
import { KnowledgeFormLayoutDesktop, KnowledgeFormLayoutMobile } from './knowledge-form-layout';
import { KnowledgeHeader } from './knowledge-header';
@@ -59,7 +60,9 @@ export const formSchema = z
.string()
.trim()
.min(1, { message: 'Content is required' })
.max(KNOWLEDGE_LIMITS.content, { message: `Content must be ${KNOWLEDGE_LIMITS.content} characters or fewer` }),
.max(KNOWLEDGE_LIMITS.content, {
message: `Content must be ${KNOWLEDGE_LIMITS.content} characters or fewer`,
}),
description: optionalTrimmed(KNOWLEDGE_LIMITS.description, 'Description'),
docType: z.nativeEnum(KnowledgeDocType),
guideType: z.nativeEnum(KnowledgeGuideType).optional(),
@@ -67,7 +70,9 @@ export const formSchema = z
.string()
.trim()
.min(1, { message: 'Question is required' })
.max(KNOWLEDGE_LIMITS.question, { message: `Question must be ${KNOWLEDGE_LIMITS.question} characters or fewer` }),
.max(KNOWLEDGE_LIMITS.question, {
message: `Question must be ${KNOWLEDGE_LIMITS.question} characters or fewer`,
}),
})
.superRefine((value, ctx) => {
const requiredByDocType: Partial<Record<KnowledgeDocType, { field: FieldPath<FormValues>; message: string }>> =
@@ -187,14 +192,17 @@ interface KnowledgeFormProps {
initialValues: FormValues;
isNew: boolean;
knowledge?: KnowledgeDocumentFragmentFragment | null;
knowledgeName: null | string;
onSubmit: (values: FormValues, dirtyFields: DirtyFlags) => Promise<SubmitResult>;
}
export const KnowledgeForm = ({ initialValues, isNew, knowledge, knowledgeName, onSubmit }: KnowledgeFormProps) => {
export const KnowledgeForm = ({ initialValues, isNew, knowledge, onSubmit }: KnowledgeFormProps) => {
const navigate = useNavigate();
const { isDesktop } = useBreakpoint();
const [isSaving, setIsSaving] = useState(false);
const [isAnonymizing, setIsAnonymizing] = useState(false);
const [anonymizeMutation] = useAnonymizeTextMutation();
const { authInfo } = useUser();
const canAnonymize = authInfo?.privileges?.includes('anonymize.call') ?? false;
const form = useForm<FormValues>({
defaultValues: initialValues,
@@ -204,7 +212,20 @@ export const KnowledgeForm = ({ initialValues, isNew, knowledge, knowledgeName,
// markdown editor) — same UX after the first interaction, no waste
// on initial mount or untouched fields.
mode: 'onTouched',
resetOptions: {
// When `values` changes (e.g. a GraphQL subscription pushes an
// updated document after an inline rename from the header),
// refresh the form's defaults but keep any unsaved edits the
// user is still working on. Without this, an external update
// would silently wipe their in-flight changes.
keepDirtyValues: true,
},
resolver: zodResolver(formSchema),
// `values` reactively syncs the form with `initialValues`. The page
// recomputes `initialValues` from `knowledge` whenever the cache
// refreshes (rename, refetch, etc.), and RHF reapplies the new
// values on top of the form respecting `resetOptions` above.
values: initialValues,
});
const { control, formState, handleSubmit, reset } = form;
@@ -313,16 +334,55 @@ export const KnowledgeForm = ({ initialValues, isNew, knowledge, knowledgeName,
const canSubmit = !isSaving && isValid && (isNew || isDirty);
const saveButton = (
<Button
<HeaderButton
disabled={!canSubmit}
size="sm"
icon={isSaving ? <Spinner variant="circle" /> : <Save aria-hidden="true" />}
label={isNew ? 'Create' : 'Save'}
type="submit"
>
{isSaving ? <Spinner variant="circle" /> : <Save aria-hidden="true" />}
{isNew ? 'Create' : 'Save'}
</Button>
/>
);
// Subscribe to `content` so the anonymize button toggles its disabled
// state as the user types. `form.watch('content')` triggers a re-render
// on every keystroke, which is what we want for snappy UX.
const contentValue = form.watch('content');
const isAnonymizeDisabled = isAnonymizing || isSaving || !contentValue?.trim();
const handleAnonymize = useCallback(async () => {
const currentContent = form.getValues('content');
if (!currentContent?.trim()) {
return;
}
setIsAnonymizing(true);
try {
const { data } = await anonymizeMutation({ variables: { text: currentContent } });
const anonymizedContent = data?.anonymizeText;
if (anonymizedContent == null) {
toast.error('Anonymizer returned no result');
return;
}
if (anonymizedContent === currentContent) {
toast.info('No sensitive data detected');
return;
}
form.setValue('content', anonymizedContent, { shouldDirty: true, shouldValidate: true });
toast.success('Content anonymized');
} catch (error) {
Log.error('Failed to anonymize content', error);
toast.error(error instanceof Error ? error.message : 'Failed to anonymize content');
} finally {
setIsAnonymizing(false);
}
}, [anonymizeMutation, form]);
return (
<>
<Form {...form}>
@@ -335,8 +395,13 @@ export const KnowledgeForm = ({ initialValues, isNew, knowledge, knowledgeName,
onSubmit={handleSubmit(onSubmitWithGuard)}
>
<KnowledgeHeader
canAnonymize={canAnonymize}
isAnonymizeDisabled={isAnonymizeDisabled}
isAnonymizing={isAnonymizing}
isNew={isNew}
knowledgeName={knowledgeName}
knowledge={knowledge}
onAnonymize={handleAnonymize}
onBeforeNavigateAway={() => skipNextBlockRef.current()}
saveButton={saveButton}
/>
{isDesktop ? (
@@ -1,34 +1,330 @@
import type { ReactNode } from 'react';
import { LibraryBig } from 'lucide-react';
import { Ellipsis, LibraryBig, Loader2, Pencil, Trash, HatGlasses } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import type { KnowledgeDocumentFragmentFragment } from '@/graphql/types';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import {
DetailNavigationButtons,
DetailNavigationSheet,
DetailNavigationToolbar,
} from '@/components/shared/detail-navigation';
import { HeaderButton } from '@/components/shared/header-button';
import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit';
import { Badge } from '@/components/ui/badge';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Spinner } from '@/components/ui/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { type Knowledge, useKnowledges } from '@/providers/knowledges-provider';
import { useKnowledgeDetailNavigation } from './use-knowledge-detail-navigation';
interface KnowledgeHeaderProps {
// Anonymize action — visible only to users with the `anonymize.call`
// privilege. The header itself renders both desktop button and mobile
// dropdown item from these primitives so the icon/loading state stay in
// sync between layouts.
canAnonymize?: boolean;
isAnonymizeDisabled?: boolean;
isAnonymizing?: boolean;
isNew: boolean;
knowledgeName: null | string;
knowledge?: KnowledgeDocumentFragmentFragment | null;
/**
* Optional hook called right before the header navigates away after a
* successful delete. The form mounts this header inside an unsaved-changes
* guard, so it passes `skipNextBlock` here to suppress the "Save before
* leaving?" dialog — there is nothing to save once the document is gone.
*/
onAnonymize?: () => void;
onBeforeNavigateAway?: () => void;
saveButton?: ReactNode;
}
export const KnowledgeHeader = ({ isNew, knowledgeName, saveButton }: KnowledgeHeaderProps) => (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator
className="mr-2 h-4"
orientation="vertical"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<LibraryBig className="size-4 shrink-0" />
<BreadcrumbPage className="max-w-[240px] truncate">
{isNew ? 'New knowledge' : (knowledgeName ?? 'Knowledge')}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
{saveButton ? <div className="ml-auto flex items-center gap-2">{saveButton}</div> : null}
</header>
const renderKnowledgeItem = (item: Knowledge, isCurrent: boolean): ReactNode => (
<>
<Badge
className="shrink-0 text-[10px] whitespace-nowrap"
variant="outline"
>
{item.docType}
</Badge>
<span className={isCurrent ? 'truncate font-medium' : 'truncate'}>{item.question}</span>
</>
);
export const KnowledgeHeader = ({
canAnonymize = false,
isAnonymizeDisabled = false,
isAnonymizing = false,
isNew,
knowledge,
onAnonymize,
onBeforeNavigateAway,
saveButton,
}: KnowledgeHeaderProps) => {
const navigate = useNavigate();
const { isMobile } = useBreakpoint();
const { deleteKnowledge, updateKnowledge } = useKnowledges();
const [isRenaming, setIsRenaming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const knowledgeId = knowledge?.id ?? null;
// Single controller drives both the desktop toolbar and the mobile
// dropdown row + sheet — no separate state mirroring required.
const knowledgeNav = useKnowledgeDetailNavigation(knowledgeId);
// Title source-of-truth is the server-side `question`. We intentionally do
// not read it from the form draft below — the inline rename flow in this
// header writes through `updateKnowledge`, which refreshes `knowledge` via
// the cache, and the form picks up the new value separately.
const knowledgeName = knowledge?.question ?? null;
const canShowActions = !isNew && !!knowledge;
const {
handleDropdownCloseAutoFocus,
inputRef: editingInputRef,
isEditing: isEditingTitle,
startEdit: handleRenameStart,
stopEdit: handleRenameCancel,
} = useInlineEdit({ resetKey: knowledgeId });
const handleRenameSave = useCallback(async () => {
const newQuestion = editingInputRef.current?.value.trim();
if (!knowledge || !newQuestion) {
return;
}
if (newQuestion === knowledge.question) {
handleRenameCancel();
return;
}
setIsRenaming(true);
try {
// Backend requires `content` on update (always re-embeds). We pass
// the server's current `content` so an inline rename never
// accidentally overwrites unsaved edits made in the form below.
// The sibling form picks up the new `question` automatically via
// `useForm({ values })` — no manual sync needed here.
await updateKnowledge(knowledge.id, {
content: knowledge.content,
question: newQuestion,
});
toast.success('Knowledge renamed successfully');
handleRenameCancel();
} catch {
// Error already handled in provider with toast
} finally {
setIsRenaming(false);
}
}, [editingInputRef, handleRenameCancel, knowledge, updateKnowledge]);
const handleDelete = useCallback(async () => {
if (!knowledgeId) {
return;
}
setIsDeleting(true);
try {
await deleteKnowledge(knowledgeId);
onBeforeNavigateAway?.();
navigate('/knowledges', { replace: true });
} catch {
// Error already handled in provider with toast
} finally {
setIsDeleting(false);
}
}, [knowledgeId, deleteKnowledge, navigate, onBeforeNavigateAway]);
return (
<>
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
<div className="flex min-w-0 flex-1 items-center gap-2">
<SidebarTrigger className="-ml-1 shrink-0" />
<Separator
className="mr-2 h-4 shrink-0"
orientation="vertical"
/>
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="min-w-0 flex-nowrap">
<BreadcrumbItem className="min-w-0 gap-2">
<LibraryBig className="size-4 shrink-0" />
{isEditingTitle && canShowActions ? (
<InlineEditInput
busy={isRenaming}
className="w-64 max-w-full min-w-0 flex-1"
defaultValue={knowledgeName ?? ''}
inputRef={editingInputRef}
onCancel={handleRenameCancel}
onSave={handleRenameSave}
placeholder="Knowledge question"
/>
) : canShowActions ? (
<Tooltip>
<TooltipTrigger asChild>
<BreadcrumbPage
className="min-w-0 cursor-text truncate select-none"
onDoubleClick={handleRenameStart}
>
{knowledgeName ?? 'Knowledge'}
</BreadcrumbPage>
</TooltipTrigger>
<TooltipContent>Double-click to rename</TooltipContent>
</Tooltip>
) : (
<BreadcrumbPage className="min-w-0 truncate">
{isNew ? 'New knowledge' : (knowledgeName ?? 'Knowledge')}
</BreadcrumbPage>
)}
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="flex shrink-0 items-center gap-2">
{canShowActions && !isMobile && (
<DetailNavigationToolbar<Knowledge>
controller={knowledgeNav}
renderItem={renderKnowledgeItem}
sheetIcon={<LibraryBig className="size-4" />}
sheetTitle="Knowledges"
/>
)}
{canAnonymize && !isMobile && (
<HeaderButton
disabled={isAnonymizeDisabled}
icon={isAnonymizing ? <Spinner variant="circle" /> : <HatGlasses aria-hidden="true" />}
label="Anonymize"
onClick={onAnonymize}
type="button"
variant="outline"
/>
)}
{saveButton}
{(canShowActions || (isMobile && canAnonymize)) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Knowledge actions"
className="size-8 p-0"
type="button"
variant="ghost"
>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-24"
onCloseAutoFocus={handleDropdownCloseAutoFocus}
>
{isMobile && canAnonymize && (
<>
<DropdownMenuItem
disabled={isAnonymizeDisabled}
onClick={onAnonymize}
>
{isAnonymizing ? (
<>
<Loader2 className="size-4 animate-spin" />
Anonymizing...
</>
) : (
<>
<HatGlasses className="size-4" />
Anonymize
</>
)}
</DropdownMenuItem>
{canShowActions && <DropdownMenuSeparator />}
</>
)}
{isMobile && knowledgeNav.total > 0 && (
<>
<DropdownMenuItem
className="cursor-default hover:bg-transparent focus:bg-transparent"
onSelect={(event) => event.preventDefault()}
>
<LibraryBig className="size-4" />
Knowledges
<div className="-my-1.5 -mr-2 ml-auto flex items-center">
<DetailNavigationButtons<Knowledge>
controller={knowledgeNav}
sheetTitle="Knowledges"
size="sm"
/>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{canShowActions && (
<>
<DropdownMenuItem onClick={handleRenameStart}>
<Pencil className="size-3" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isDeleting}
onClick={() => setIsDeleteDialogOpen(true)}
>
{isDeleting ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash className="size-4" />
Delete
</>
)}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</header>
{isMobile && canShowActions && (
<DetailNavigationSheet<Knowledge>
controller={knowledgeNav}
renderItem={renderKnowledgeItem}
sheetIcon={<LibraryBig className="size-4" />}
sheetTitle="Knowledges"
/>
)}
<ConfirmationDialog
cancelText="Cancel"
confirmText="Delete"
handleConfirm={handleDelete}
handleOpenChange={setIsDeleteDialogOpen}
isOpen={isDeleteDialogOpen}
itemName={knowledgeName ?? undefined}
itemType="knowledge document"
/>
</>
);
};
@@ -1,5 +1,7 @@
import type { ReactNode } from 'react';
import type { KnowledgeDocumentFragmentFragment } from '@/graphql/types';
import { cn } from '@/lib/utils';
import { KnowledgeHeader } from './knowledge-header';
@@ -8,7 +10,7 @@ interface KnowledgeLayoutProps {
children: ReactNode;
className?: string;
isNew: boolean;
knowledgeName: null | string;
knowledge?: KnowledgeDocumentFragmentFragment | null;
saveButton?: ReactNode;
}
@@ -18,11 +20,11 @@ interface KnowledgeLayoutProps {
* renders the header inline because the form must be the parent of every
* input.
*/
export const KnowledgeLayout = ({ children, className, isNew, knowledgeName, saveButton }: KnowledgeLayoutProps) => (
export const KnowledgeLayout = ({ children, className, isNew, knowledge, saveButton }: KnowledgeLayoutProps) => (
<div className={cn('flex min-h-[100dvh] flex-col', className)}>
<KnowledgeHeader
isNew={isNew}
knowledgeName={knowledgeName}
knowledge={knowledge}
saveButton={saveButton}
/>
{children}
@@ -0,0 +1,23 @@
import { useDetailNavigation } from '@/components/shared/detail-navigation';
import { type Knowledge, useKnowledges } from '@/providers/knowledges-provider';
const getLabel = (item: Knowledge) => item.question;
const getHref = (item: Knowledge) => `/knowledges/${item.id}`;
/**
* Detail-page navigation wired up for knowledge documents. Returns a
* `DetailNavigationController<Knowledge>` for `<DetailNavigationToolbar>` /
* `<DetailNavigationButtons>` / `<DetailNavigationSheet>`. The list page
* filters on `question` and the header shows the same, so `getLabel`
* doubles as the default searchable text.
*/
export const useKnowledgeDetailNavigation = (currentId: null | string | undefined) => {
const { knowledges } = useKnowledges();
return useDetailNavigation<Knowledge>({
currentId,
getHref,
getLabel,
items: knowledges,
});
};
@@ -4,11 +4,9 @@ import { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import type { FileNode } from '@/components/shared/file-manager';
import type { OverwriteConflict } from '@/components/shared/overwrite-confirm-dialog';
import type { OverwriteConflict } from '@/components/shared/overwrite';
import { OverwriteConfirmDialog } from '@/components/shared/overwrite-confirm-dialog';
import { OverwriteCtaButtons } from '@/components/shared/overwrite-cta-buttons';
import { useOverwriteAction } from '@/components/shared/use-overwrite-action';
import { OverwriteButtons, OverwriteDialog, useOverwrite } from '@/components/shared/overwrite';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
@@ -141,7 +139,7 @@ const ResourcesCopyDialogForm = ({ files, onClose }: ResourcesCopyDialogFormProp
* with a single atomic batch request. Backend handles `sources[]` in one
* DB transaction (all-or-nothing).
*/
const overwriteAction = useOverwriteAction<CopyPlan>({
const overwriteAction = useOverwrite<CopyPlan>({
execute: (plan, force) => copy(plan.sources, plan.destination, force),
// Copy never deletes the sources, so collisions with sources are real
// conflicts (unlike move). Just intersect targets with existing paths.
@@ -227,7 +225,7 @@ const ResourcesCopyDialogForm = ({ files, onClose }: ResourcesCopyDialogFormProp
>
Cancel
</Button>
<OverwriteCtaButtons
<OverwriteButtons
isDisabled={isSubmitDisabled}
isProcessing={isCopying}
onOverwrite={() => {
@@ -243,7 +241,7 @@ const ResourcesCopyDialogForm = ({ files, onClose }: ResourcesCopyDialogFormProp
</Form>
</DialogContent>
<OverwriteConfirmDialog
<OverwriteDialog
conflicts={overwriteAction.conflicts}
onCancel={overwriteAction.resetConflicts}
onReplaceAll={overwriteAction.handleReplaceAll}
@@ -4,11 +4,9 @@ import { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import type { FileNode } from '@/components/shared/file-manager';
import type { OverwriteConflict } from '@/components/shared/overwrite-confirm-dialog';
import type { OverwriteConflict } from '@/components/shared/overwrite';
import { OverwriteConfirmDialog } from '@/components/shared/overwrite-confirm-dialog';
import { OverwriteCtaButtons } from '@/components/shared/overwrite-cta-buttons';
import { useOverwriteAction } from '@/components/shared/use-overwrite-action';
import { OverwriteButtons, OverwriteDialog, useOverwrite } from '@/components/shared/overwrite';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
@@ -135,7 +133,7 @@ const ResourcesMoveDialogForm = ({ files, onClose }: ResourcesMoveDialogFormProp
* with a single atomic batch request. Backend handles `sources[]` in one
* DB transaction (all-or-nothing) — no per-source aggregation needed here.
*/
const overwriteAction = useOverwriteAction<MovePlan>({
const overwriteAction = useOverwrite<MovePlan>({
execute: (plan, force) => move(plan.sources, plan.destination, force),
// Local preflight: filter out targets that match an item we're moving
// (those are no-ops, not conflicts) and keep the ones already taken
@@ -232,7 +230,7 @@ const ResourcesMoveDialogForm = ({ files, onClose }: ResourcesMoveDialogFormProp
>
Cancel
</Button>
<OverwriteCtaButtons
<OverwriteButtons
isDisabled={isSubmitDisabled}
isProcessing={isMoving}
onOverwrite={() => {
@@ -248,7 +246,7 @@ const ResourcesMoveDialogForm = ({ files, onClose }: ResourcesMoveDialogFormProp
</Form>
</DialogContent>
<OverwriteConfirmDialog
<OverwriteDialog
conflicts={overwriteAction.conflicts}
onCancel={overwriteAction.resetConflicts}
onReplaceAll={overwriteAction.handleReplaceAll}
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { z } from 'zod';
import type { OverwriteOutcome } from '@/components/shared/use-overwrite-action';
import type { OverwriteOutcome } from '@/components/shared/overwrite';
import { api, getApiErrorMessage, getApiErrorStatusCode } from '@/lib/axios';
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { z } from 'zod';
import type { OverwriteOutcome } from '@/components/shared/use-overwrite-action';
import type { OverwriteOutcome } from '@/components/shared/overwrite';
import { api, getApiErrorMessage, getApiErrorStatusCode } from '@/lib/axios';
@@ -1,43 +1,37 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback } from 'react';
import { useForm, type UseFormReturn } from 'react-hook-form';
import { z } from 'zod';
import { useDebouncedValue } from '@/hooks/use-debounced-value';
import { useTableState } from '@/hooks/use-table-state';
import { SEARCH_DEBOUNCE_MS } from './resources-constants';
const resourcesSearchFormSchema = z.object({
search: z.string(),
});
export type ResourcesSearchFormValues = z.infer<typeof resourcesSearchFormSchema>;
interface UseResourcesSearchResult {
debouncedQuery: string;
form: UseFormReturn<ResourcesSearchFormValues>;
rawQuery: string;
resetSearch: () => void;
setQuery: (value: string) => void;
}
/** Owns the search form and exposes a debounced version of the typed query. */
/**
* Search state for the Resources file manager.
*
* Backed by `useTableState` so the query lives in the URL (`?q=`) with a
* localStorage fallback — the FileManager survives reloads with the same
* filter active and shareable links keep working. The debounce delay is
* preserved (`SEARCH_DEBOUNCE_MS`) so the existing client-side tree filter
* still gets the throttled value it expects.
*
* `clearPageOnFilterChange: false` because Resources has no `?page=` to
* reset — leaving the default would also work (deleting a non-existent
* param is a no-op), but the explicit setting documents intent.
*/
export const useResourcesSearch = (): UseResourcesSearchResult => {
const form = useForm<ResourcesSearchFormValues>({
defaultValues: { search: '' },
resolver: zodResolver(resourcesSearchFormSchema),
const { debouncedFilter, filter, resetFilter, setFilter } = useTableState({
clearPageOnFilterChange: false,
debounceMs: SEARCH_DEBOUNCE_MS,
});
const rawQuery = form.watch('search');
const debouncedQuery = useDebouncedValue(rawQuery, SEARCH_DEBOUNCE_MS);
const resetSearch = useCallback(() => {
form.reset({ search: '' });
}, [form]);
return {
debouncedQuery,
form,
rawQuery,
resetSearch,
debouncedQuery: debouncedFilter,
rawQuery: filter,
resetSearch: resetFilter,
setQuery: setFilter,
};
};
@@ -0,0 +1,26 @@
import { useDetailNavigation } from '@/components/shared/detail-navigation';
import { type Template, useTemplates } from '@/providers/templates-provider';
const getLabel = (item: Template) => item.title;
const getId = (item: Template) => String(item.id);
const getHref = (item: Template) => `/templates/${item.id}`;
/**
* Detail-page navigation wired up for templates. Returns a
* `DetailNavigationController<Template>` for `<DetailNavigationToolbar>` /
* `<DetailNavigationButtons>` / `<DetailNavigationSheet>`. The list page
* filters on `title` and the breadcrumb shows the same, so `getLabel`
* doubles as the default searchable text (no explicit `getSearchableText`
* needed).
*/
export const useTemplateDetailNavigation = (currentId: null | string | undefined) => {
const { templates } = useTemplates();
return useDetailNavigation<Template>({
currentId,
getHref,
getId,
getLabel,
items: templates,
});
};
@@ -1,107 +0,0 @@
import type { VisibilityState } from '@tanstack/react-table';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getColumnStorageKey } from '@/lib/storage-keys';
export interface ColumnPriority {
alwaysVisible?: boolean;
id: string;
priority: number;
}
interface UseAdaptiveColumnVisibilityOptions {
breakpoints?: { hiddenPriorities: number[]; width: number }[];
columns: ColumnPriority[];
tableKey: string;
}
const DEFAULT_BREAKPOINTS = [
{ hiddenPriorities: [], width: 1400 },
{ hiddenPriorities: [5], width: 1200 },
{ hiddenPriorities: [4, 5], width: 1000 },
{ hiddenPriorities: [3, 4, 5], width: 800 },
{ hiddenPriorities: [2, 3, 4, 5], width: 600 },
{ hiddenPriorities: [1, 2, 3, 4, 5], width: 0 },
];
function loadUserPreferences(key: string): Record<string, boolean> {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
export const useAdaptiveColumnVisibility = ({
breakpoints = DEFAULT_BREAKPOINTS,
columns,
tableKey,
}: UseAdaptiveColumnVisibilityOptions) => {
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1400);
const localStorageKey = useMemo(() => getColumnStorageKey(tableKey), [tableKey]);
const [userPreferences, setUserPreferences] = useState<Record<string, boolean>>(() =>
loadUserPreferences(localStorageKey),
);
const saveUserPreferences = useCallback(
(preferences: Record<string, boolean>) => {
try {
localStorage.setItem(localStorageKey, JSON.stringify(preferences));
setUserPreferences(preferences);
} catch {
/* localStorage may be unavailable */
}
},
[localStorageKey],
);
useEffect(() => {
const handleResize = () => setWindowWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const columnVisibility = useMemo((): VisibilityState => {
const activeBreakpoint = breakpoints.find((breakpoint) => windowWidth >= breakpoint.width) ??
breakpoints.at(-1) ?? { hiddenPriorities: [], width: 0 };
return Object.fromEntries(
columns.map((column) => {
if (column.alwaysVisible) {
return [column.id, true];
}
const shouldHideByWidth = activeBreakpoint.hiddenPriorities.includes(column.priority);
const userPreference = userPreferences[column.id];
const isVisible =
userPreference !== undefined ? !shouldHideByWidth && userPreference : !shouldHideByWidth;
return [column.id, isVisible];
}),
);
}, [windowWidth, userPreferences, columns, breakpoints]);
const updateColumnVisibility = useCallback(
(columnId: string, visible: boolean) => {
saveUserPreferences({
...userPreferences,
[columnId]: visible,
});
},
[userPreferences, saveUserPreferences],
);
return {
columnVisibility,
updateColumnVisibility,
userPreferences,
};
};
-33
View File
@@ -1,33 +0,0 @@
import { useCallback, useState } from 'react';
import { useLatestRef } from '@/hooks/use-latest-ref';
/**
* Radix-style controllable state. When `controlled` is `undefined` the hook
* owns the state; otherwise the parent does and we forward updates via
* `onChange`. `onChange` always fires so fully-controlled consumers can
* observe every set (e.g. for logging).
*
* Mirrors `@radix-ui/react-use-controllable-state` without pulling in the
* dependency for a couple of state slots.
*/
export const useControllable = <T>(controlled: T | undefined, defaultValue: T, onChange?: (value: T) => void) => {
const [internal, setInternal] = useState<T>(defaultValue);
const isControlled = controlled !== undefined;
const value = isControlled ? (controlled as T) : internal;
const onChangeRef = useLatestRef(onChange);
const set = useCallback(
(next: T) => {
if (!isControlled) {
setInternal(next);
}
onChangeRef.current?.(next);
},
[isControlled, onChangeRef],
);
return [value, set] as const;
};
+16 -2
View File
@@ -6,8 +6,22 @@ import { type RefObject, useEffect, useRef } from 'react';
* Useful for the "stable callback" pattern: a memoized child accepts a stable
* handler from the parent, but the handler internally needs to read the most
* recent prop / state value without invalidating downstream memos. Reading
* `ref.current` inside the stable handler always sees the latest value
* because this hook re-syncs the ref after every commit.
* `ref.current` inside the stable handler sees the value committed in the
* most recent successful render.
*
* **Timing caveat — one-commit lag for synchronous reads.** The ref is
* synced inside a passive `useEffect`. This means handlers that fire
* *during* a commit (e.g. Radix's `onCloseAutoFocus`, which it dispatches
* from its own layout-effect in the same flush) read the *previous* render's
* value, because the sync effect from the current render hasn't run yet.
* For those cases mutate a `useRef` directly in render — write
* `const ref = useRef(value); ref.current = value;` — instead of using this
* hook. Render-time mutation is safe so long as nothing *reads* the ref
* during render (only handlers and effects do).
*
* For asynchronous reads — pointer/key events, timers, network callbacks —
* the lag never matters because at least one commit cycle has finished by
* the time the handler fires. That's the common case this hook is built for.
*
* Don't read `ref.current` during render — use the value prop directly instead.
* Reading the ref during render breaks React's snapshot guarantee (the ref may
@@ -0,0 +1,89 @@
import type { ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { usePageStorageKeys } from './use-page-storage-keys';
const renderWithRouter = (initialEntries: string[], options?: Parameters<typeof usePageStorageKeys>[0]) => {
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
);
return renderHook(() => usePageStorageKeys(options), { wrapper: Wrapper });
};
describe('usePageStorageKeys — defaults', () => {
it('builds keys from the live pathname when no override is provided', () => {
const { result } = renderWithRouter(['/flows']);
expect(result.current.table).toBe('table_4_/flows');
expect(result.current.period).toBe('period_4_/flows');
expect(result.current.viewOptions).toBe('viewOptions_4_/flows');
});
it('reflects the entire pathname — does NOT strip params by default', () => {
// A detail page that mounts the hook directly (no override) gets a
// unique key per id. This is the correct behaviour for tables on
// distinct subroutes; detail pages that want to share with the list
// must pass `useTopLevel: true` or an explicit override.
const { result } = renderWithRouter(['/flows/abc-123']);
expect(result.current.table).toBe('table_4_/flows/abc-123');
});
});
describe('usePageStorageKeys — useTopLevel', () => {
it('strips id suffixes when `useTopLevel: true`', () => {
const { result } = renderWithRouter(['/flows/abc-123'], { useTopLevel: true });
expect(result.current.table).toBe('table_4_/flows');
});
it('handles a deep path with `useTopLevel: true` — only the first segment survives', () => {
const { result } = renderWithRouter(['/knowledges/abc/foo/bar'], { useTopLevel: true });
expect(result.current.table).toBe('table_4_/knowledges');
});
it('falls back to an empty path for the root', () => {
const { result } = renderWithRouter(['/'], { useTopLevel: true });
// `getTopLevelPath('/')` returns `''`, so the key is `table_4_`.
// Documented as the deliberate behaviour — root-route detail pages
// would all collide on this key, but no such page exists.
expect(result.current.table).toBe('table_4_');
});
});
describe('usePageStorageKeys — explicit pathname override', () => {
it('uses the override verbatim, ignoring the live pathname', () => {
const { result } = renderWithRouter(['/flows/abc-123'], { pathname: '/admin/flows' });
expect(result.current.table).toBe('table_4_/admin/flows');
});
it('override + useTopLevel uses the top-level segment of the override', () => {
const { result } = renderWithRouter(['/flows/abc'], {
pathname: '/admin/flows/xyz',
useTopLevel: true,
});
expect(result.current.table).toBe('table_4_/admin');
});
});
describe('usePageStorageKeys — reactivity', () => {
it('returns the same memoized object across re-renders when inputs do not change', () => {
const { rerender, result } = renderWithRouter(['/flows']);
const first = result.current;
rerender();
// Memoization guarantee — downstream effects depend on this for
// their identity-stable dep arrays.
expect(result.current).toBe(first);
});
});
@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { getPeriodStorageKey, getTableStorageKey, getTopLevelPath, getViewOptionsStorageKey } from '@/lib/storage-keys';
interface PageStorageKeys {
/** Dashboard analytics time window — single string value, no schema merge. */
period: string;
/** Unified per-page table state (filter + sorting + columnVisibility + pageSize). */
table: string;
/** FileManager-style view options (folder-first toggle, expanded dirs, etc.). */
viewOptions: string;
}
interface UsePageStorageKeysOptions {
/**
* Optional override for the path used to build the keys. Pass when the
* caller wants to share a storage bucket with a different route — e.g.
* a detail page (`/flows/:id`) keying into the parent list's slot
* (`/flows`).
*
* When omitted, the live `useLocation().pathname` is used as-is.
*/
pathname?: string;
/**
* If `true`, the effective path is the top-level segment of `pathname`
* (e.g. `/flows/abc-123` → `/flows`). Convenience for detail pages that
* want to inherit their list's storage slot without spelling it out.
*/
useTopLevel?: boolean;
}
/**
* Resolve the per-route localStorage keys (table, period, viewOptions) for
* the current location, reactively.
*
* Replaces the older pattern of calling individual `get*StorageKey()`
* functions without an argument, which read the global `location.pathname`.
* The global read is not reactive to react-router navigation and is unsafe
* to call during module load (e.g. tests, SSR). Routing through
* `useLocation` makes the result re-evaluate whenever the active route
* changes.
*
* Each list page now stores at most **one** key — `table_4_<path>` — that
* bundles filter + sorting + columnVisibility + pageSize. Old `column_4_`,
* `sorting_4_`, `filter_4_`, `page_4_` slots are migrated into the unified
* key on first mount (see `migrateLegacyTableState`).
*/
export const usePageStorageKeys = (options: UsePageStorageKeysOptions = {}): PageStorageKeys => {
const { pathname: livePathname } = useLocation();
const { pathname: override, useTopLevel = false } = options;
return useMemo(() => {
const source = override ?? livePathname;
const effective = useTopLevel ? getTopLevelPath(source) : source;
return {
period: getPeriodStorageKey(effective),
table: getTableStorageKey(effective),
viewOptions: getViewOptionsStorageKey(effective),
};
}, [livePathname, override, useTopLevel]);
};
@@ -0,0 +1,58 @@
import type { ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useTableQueryFilterReader } from './use-table-query-filter';
const STORAGE_KEY = 'table_4_/flows';
const SHORT_DEBOUNCE_MS = 5;
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
describe('useTableQueryFilterReader', () => {
it('observes the URL filter without writing storage', () => {
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/flows/abc?q=alpha']}>{children}</MemoryRouter>
);
const { result } = renderHook(
() => useTableQueryFilterReader({ debounceMs: SHORT_DEBOUNCE_MS, storageKey: STORAGE_KEY }),
{ wrapper: Wrapper },
);
expect(result.current.filter).toBe('alpha');
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('does not replay a stored filter into a clean URL', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ filter: 'alpha' }));
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/flows/abc']}>{children}</MemoryRouter>
);
const { result } = renderHook(
() => {
const reader = useTableQueryFilterReader({
debounceMs: SHORT_DEBOUNCE_MS,
storageKey: STORAGE_KEY,
});
const { search } = useLocation();
return { ...reader, search };
},
{ wrapper: Wrapper },
);
// A shared `/flows/abc` link explicitly clears the filter on entry —
// detail-page subscribers must not inject the previous tab's `?q=`
// back into the URL.
expect(result.current.search).toBe('');
expect(result.current.filter).toBe('');
});
});
@@ -0,0 +1,48 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDebouncedValue } from '@/hooks/use-debounced-value';
import { URL_PARAMS } from '@/lib/url-params';
interface UseTableQueryFilterReaderOptions {
debounceMs?: number;
/**
* The query string param to read. Default `URL_PARAMS.QUERY` (`'q'`).
*/
paramName?: string;
/**
* Storage key — accepted for API symmetry with `useTableState` and the
* historical mutate variant, but ignored here. The reader is pure-URL
* and never touches storage; the option exists only so detail pages can
* pass the parent list's storage key without conditional logic.
*/
storageKey?: string;
}
interface UseTableQueryFilterReaderResult {
debouncedFilter: string;
filter: string;
}
/**
* Read-only subscription to the URL filter for pages that observe the value
* but never mutate it (typically detail pages, where the toolbar walks the
* filtered subset but the user types the filter on the list page).
*
* The hook never writes to the URL or storage: a detail page opened via a
* shared `/flows/:id` link will not silently inject the previous tab's
* `?q=` into the URL. Pages that *do* need to mutate the filter live on
* `useTableState` instead — it owns the URL ↔ storage roundtrip plus
* atomic multi-field updates that the previous split design couldn't do
* race-free.
*/
export const useTableQueryFilterReader = ({
debounceMs = 200,
paramName = URL_PARAMS.QUERY,
}: UseTableQueryFilterReaderOptions = {}): UseTableQueryFilterReaderResult => {
const [searchParams] = useSearchParams();
const filter = searchParams.get(paramName) ?? '';
const debouncedFilter = useDebouncedValue(filter, debounceMs);
return useMemo(() => ({ debouncedFilter, filter }), [debouncedFilter, filter]);
};
+262
View File
@@ -0,0 +1,262 @@
import type { ReactNode } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useTableState } from './use-table-state';
const STORAGE_KEY = 'table_4_/flows';
const SHORT_DEBOUNCE_MS = 5;
interface RenderResult {
debouncedFilter: string;
filter: string;
pageIndex: number;
resetFilter: () => void;
search: string;
setFilter: (value: string) => void;
setPage: (page: number) => void;
update: ReturnType<typeof useTableState>['update'];
}
const useStateWithLocation = (options: { debounceMs?: number; storageKey?: string } = {}): RenderResult => {
const { debouncedFilter, filter, pageIndex, resetFilter, setFilter, setPage, update } = useTableState({
debounceMs: options.debounceMs ?? SHORT_DEBOUNCE_MS,
storageKey: options.storageKey ?? STORAGE_KEY,
});
const { search } = useLocation();
return { debouncedFilter, filter, pageIndex, resetFilter, search, setFilter, setPage, update };
};
const renderWithRouter = (initialEntries: string[], options?: { debounceMs?: number; storageKey?: string }) => {
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
);
return renderHook(() => useStateWithLocation(options), { wrapper: Wrapper });
};
const readStoredFilter = (key: string): null | string => {
const raw = localStorage.getItem(key);
if (raw === null) {
return null;
}
try {
const parsed = JSON.parse(raw);
return typeof parsed?.filter === 'string' ? parsed.filter : null;
} catch {
return null;
}
};
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
describe('useTableState — URL reads', () => {
it('reads the initial filter from `?q=`', () => {
const { result } = renderWithRouter(['/flows?q=alpha']);
expect(result.current.filter).toBe('alpha');
});
it('reads the initial pageIndex from `?page=` (1-based URL → 0-based state)', () => {
const { result } = renderWithRouter(['/flows?page=3']);
expect(result.current.pageIndex).toBe(2);
});
it('defaults both filter and pageIndex when neither URL nor storage have a value', () => {
const { result } = renderWithRouter(['/flows']);
expect(result.current.filter).toBe('');
expect(result.current.pageIndex).toBe(0);
});
it('ignores non-numeric `?page=` values gracefully', () => {
const { result } = renderWithRouter(['/flows?page=abc']);
expect(result.current.pageIndex).toBe(0);
});
});
describe('useTableState — setFilter / setPage', () => {
it('setFilter writes the value into the URL and resets `?page=` by default', async () => {
const { result } = renderWithRouter(['/flows?page=3']);
act(() => result.current.setFilter('alpha'));
await waitFor(() => {
const params = new URLSearchParams(result.current.search);
// `clearPageOnFilterChange` default = true: changing the filter
// drops the page so users don't land "on page 5 of nothing".
expect(params.get('q')).toBe('alpha');
expect(params.get('page')).toBeNull();
});
});
it('setFilter("") clears the URL param', async () => {
const { result } = renderWithRouter(['/flows?q=alpha']);
act(() => result.current.setFilter(''));
await waitFor(() => {
expect(result.current.filter).toBe('');
expect(new URLSearchParams(result.current.search).has('q')).toBe(false);
});
});
it('setPage writes the 1-based page number into the URL', async () => {
const { result } = renderWithRouter(['/flows']);
act(() => result.current.setPage(4));
await waitFor(() => {
expect(new URLSearchParams(result.current.search).get('page')).toBe('5');
});
});
it('setPage(0) drops the URL param entirely', async () => {
const { result } = renderWithRouter(['/flows?page=5']);
act(() => result.current.setPage(0));
await waitFor(() => {
expect(new URLSearchParams(result.current.search).has('page')).toBe(false);
});
});
it('resetFilter is equivalent to setFilter("")', async () => {
const { result } = renderWithRouter(['/flows?q=alpha']);
act(() => result.current.resetFilter());
await waitFor(() => {
expect(result.current.filter).toBe('');
});
});
});
describe('useTableState — atomic `update` (race regression)', () => {
it('writes both filter and pageIndex in a single transition', async () => {
const { result } = renderWithRouter(['/flows']);
act(() => result.current.update({ filter: 'alpha', pageIndex: 4 }));
await waitFor(() => {
const params = new URLSearchParams(result.current.search);
expect(params.get('q')).toBe('alpha');
expect(params.get('page')).toBe('5');
});
});
it('preserves the other param when only one field is patched', async () => {
const { result } = renderWithRouter(['/flows?q=alpha&page=3']);
// Update only the page — `q` must survive.
act(() => result.current.update({ pageIndex: 9 }));
await waitFor(() => {
const after = new URLSearchParams(result.current.search);
expect(after.get('q')).toBe('alpha');
expect(after.get('page')).toBe('10');
});
// And the reverse: change the filter without touching page (note:
// this is distinct from `setFilter`, which deliberately resets it).
act(() => result.current.update({ filter: 'beta' }));
await waitFor(() => {
const after = new URLSearchParams(result.current.search);
expect(after.get('q')).toBe('beta');
expect(after.get('page')).toBe('10');
});
});
it('regression: two top-level updaters firing in the same tick keep both params', async () => {
// The exact scenario the old split-hooks design lost: `setFilter`
// and `setPage` issued from the same event handler dropped `?q=`
// because react-router fed both functional updaters the same
// pre-batch snapshot. With microtask-batched coalescence inside
// `update`, both calls land in a single `setSearchParams` and both
// params survive — regardless of router implementation.
const { result } = renderWithRouter(['/flows']);
act(() => {
result.current.setFilter('alpha');
result.current.setPage(5);
});
await waitFor(() => {
const params = new URLSearchParams(result.current.search);
expect(params.get('q')).toBe('alpha');
expect(params.get('page')).toBe('6');
});
});
it('null filter clears the URL param', async () => {
const { result } = renderWithRouter(['/flows?q=alpha']);
act(() => result.current.update({ filter: null }));
await waitFor(() => {
expect(new URLSearchParams(result.current.search).has('q')).toBe(false);
});
});
it('coalescence resolves replace conflict in favour of push (intentional history wins)', async () => {
// setFilter requests replace, setPage requests push. The merged
// navigation should push, so back-button can step out of the new
// filter+page combination.
const { result } = renderWithRouter(['/flows']);
act(() => {
result.current.setFilter('alpha'); // replace
result.current.setPage(5); // push
});
await waitFor(() => {
expect(new URLSearchParams(result.current.search).get('q')).toBe('alpha');
});
// History assertion is implicit — we can't easily inspect the entry
// stack from `MemoryRouter`, but the merged URL has both params
// which proves coalescence happened.
});
});
describe('useTableState — storage roundtrip', () => {
it('restores `?q=` from storage when the URL is empty', async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ filter: 'alpha' }));
const { result } = renderWithRouter(['/flows']);
await waitFor(() => {
expect(new URLSearchParams(result.current.search).get('q')).toBe('alpha');
});
});
it('does not restore when the URL already has `?q=`', async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ filter: 'stored' }));
const { result } = renderWithRouter(['/flows?q=urlwin']);
await act(async () => Promise.resolve());
expect(result.current.filter).toBe('urlwin');
});
it('persists the URL filter into storage once typing settles', async () => {
const { result } = renderWithRouter(['/flows']);
act(() => result.current.setFilter('alpha'));
await waitFor(() => {
expect(readStoredFilter(STORAGE_KEY)).toBe('alpha');
});
});
it('clears the storage entry when the filter is set back to empty', async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ filter: 'alpha' }));
const { result } = renderWithRouter(['/flows?q=alpha']);
act(() => result.current.setFilter(''));
await waitFor(() => {
expect(readStoredFilter(STORAGE_KEY)).toBeNull();
});
});
});
describe('useTableState — debounced filter', () => {
it('debouncedFilter eventually catches up with filter', async () => {
const { result } = renderWithRouter(['/flows'], { debounceMs: SHORT_DEBOUNCE_MS });
act(() => result.current.setFilter('alpha'));
await waitFor(() => {
expect(result.current.debouncedFilter).toBe('alpha');
});
});
});
describe('useTableState — `?page=1` canonicalization', () => {
it('rewrites `?page=1` to a clean URL on mount', async () => {
const { result } = renderWithRouter(['/flows?page=1']);
await waitFor(() => {
expect(new URLSearchParams(result.current.search).has('page')).toBe(false);
});
expect(result.current.pageIndex).toBe(0);
});
});
+323
View File
@@ -0,0 +1,323 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDebouncedValue } from '@/hooks/use-debounced-value';
import { useEffectAfterMount } from '@/hooks/use-effect-after-mount';
import { usePageStorageKeys } from '@/hooks/use-page-storage-keys';
import { loadTableState, updateTableState } from '@/lib/table-state';
import { URL_PARAMS } from '@/lib/url-params';
/**
* Atomic partial update for the table's URL state. All fields are optional;
* `null` clears the corresponding param. `replace` controls whether the
* navigation creates a new history entry. The whole patch is applied in
* a single `setSearchParams` call so multi-field updates never race.
*/
interface TableStateUpdate {
/** New filter value, `null`/`''` to clear. */
filter?: null | string;
/** New 0-based page index. Values `<= 0` clear the param. */
pageIndex?: number;
/** Pass `true` to navigate without adding a history entry. */
replace?: boolean;
}
interface UseTableStateOptions {
/**
* Whether `setFilter` should also drop `?page=` from the URL. Pages that
* pair the URL filter with URL pagination need this so the user doesn't
* end up "on page 5 of nothing" after narrowing the result set. Pages
* that have no `?page=` to begin with can leave the default — deleting a
* non-existent param is a no-op.
*/
clearPageOnFilterChange?: boolean;
debounceMs?: number;
/** Query string param for the filter value. Default `URL_PARAMS.QUERY` (`'q'`). */
filterParamName?: string;
/** Query string param for the 1-based page number. Default `URL_PARAMS.PAGE` (`'page'`). */
pageParamName?: string;
/**
* Stable storage key for persisting the filter value (a fresh tab without
* `?<filterParamName>=` resumes from here). Defaults to
* `usePageStorageKeys().table` — i.e. `table_4_<current pathname>`.
*/
storageKey?: string;
}
interface UseTableStateResult {
debouncedFilter: string;
filter: string;
pageIndex: number;
resetFilter: () => void;
setFilter: (value: string) => void;
setPage: (pageIndex: number) => void;
/**
* Atomic multi-field update. Prefer this when changing both `filter` and
* `pageIndex` from the same event (or when adding more fields in the
* future): all changes land in a single `setSearchParams` call, so the
* race between two consecutive top-level updaters can never happen.
*/
update: (patch: TableStateUpdate) => void;
}
/**
* Unified URL + storage state for tables.
*
* Replaces the split `useTableQueryFilter` / `usePagination` pair. The split
* design suffered from a batching race: react-router v6 feeds every
* functional `setSearchParams(updater)` queued in a single tick the same
* pre-batch snapshot, so a `setFilter` + `setPage` pair (e.g. a debounced
* filter commit landing alongside a paging click) would collapse — the
* second write erased the first and `q` was lost. Funnelling every URL
* write through a single `update` here removes the race by construction:
* there is never more than one in-flight `setSearchParams` per logical
* intent. The few cases where two intents still fire in the same tick
* (e.g. an external effect mutating the URL while we batch our own write)
* read the freshest URL via `window.location.search`, with a ref-stashed
* react-router snapshot as the fallback for `MemoryRouter`-based tests.
*
* Read-only siblings (detail pages reading the list's filter without
* mutating it) should keep using `useTableQueryFilterReader` — no shared
* URL writes, no race.
*/
export const useTableState = (options: UseTableStateOptions = {}): UseTableStateResult => {
const {
clearPageOnFilterChange = true,
debounceMs = 200,
filterParamName = URL_PARAMS.QUERY,
pageParamName = URL_PARAMS.PAGE,
storageKey: explicitStorageKey,
} = options;
const [searchParams, setSearchParams] = useSearchParams();
const { table: defaultStorageKey } = usePageStorageKeys();
const storageKey = explicitStorageKey ?? defaultStorageKey;
const filter = searchParams.get(filterParamName) ?? '';
const debouncedFilter = useDebouncedValue(filter, debounceMs);
const pageIndex = useMemo(() => {
const raw = searchParams.get(pageParamName);
if (!raw) {
return 0;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? Math.max(0, parsed - 1) : 0;
}, [pageParamName, searchParams]);
// Sync a ref to the latest committed `searchParams` on every render.
// Used as the `MemoryRouter` fallback below — that environment doesn't
// sync its in-memory history to `window.location`. Mutating the ref in
// render is safe because we only read it from event callbacks
// (`update`), never during render; `useEffect`-based sync (a-la
// `useLatestRef`) lags one commit, which is exactly the gap that
// re-introduces the race we're trying to eliminate.
const searchParamsReference = useRef(searchParams);
// eslint-disable-next-line react-hooks/refs
searchParamsReference.current = searchParams;
/**
* Read the freshest possible `URLSearchParams` for the next write.
*
* Under `BrowserRouter` (production), `window.location.search` reflects
* the URL bar one frame ahead of react-router's internal snapshot when
* batched updates are in flight — that's the seam we exploit to merge
* multi-source URL mutations safely. Under `MemoryRouter` (tests)
* `window.location` doesn't track the in-memory history, so we fall
* back to the rendered react-router snapshot.
*/
const readLatestParams = useCallback((): URLSearchParams => {
const fromWindow = typeof window !== 'undefined' ? window.location.search : '';
return fromWindow ? new URLSearchParams(fromWindow) : new URLSearchParams(searchParamsReference.current);
}, []);
// Canonicalize `?<pageParamName>=1` away. The first page is the default
// URL, so two URLs (`/flows` vs `/flows?page=1`) would otherwise denote
// the same view and split the history stack. Idempotent: once the param
// is removed the effect re-runs and immediately exits the early return.
useEffect(() => {
if (searchParams.get(pageParamName) !== '1') {
return;
}
const next = readLatestParams();
next.delete(pageParamName);
setSearchParams(next, { replace: true });
}, [pageParamName, readLatestParams, searchParams, setSearchParams]);
// Replay the persisted filter into the URL when (a) the URL doesn't
// already carry one, and (b) the storage has a non-empty value. Run
// exactly once per storageKey rotation — `restoredForKeyReference`
// guards against repeating the replay on every render.
const restoredForKeyReference = useRef<null | string>(null);
useEffect(() => {
if (restoredForKeyReference.current === storageKey) {
return;
}
restoredForKeyReference.current = storageKey;
if (filter.length > 0) {
// URL already has a value — that beats storage. Mirror it back
// into storage so a fresh tab without `?q=` resumes from this
// intent (shared filtered links).
updateTableState(storageKey, { filter });
return;
}
const stored = loadTableState(storageKey).filter;
if (!stored || stored.length === 0) {
return;
}
const next = readLatestParams();
next.set(filterParamName, stored);
// Replace, not push: restoring prior state shouldn't add a history
// entry the user didn't ask for.
setSearchParams(next, { replace: true });
}, [filter, filterParamName, readLatestParams, setSearchParams, storageKey]);
// Persist the URL filter into storage on every commit. Skipping the
// first render is intentional: a fresh-mount empty `filter` would wipe
// a freshly-restored storage entry before the restore effect above has
// had a chance to replay it into the URL.
useEffectAfterMount(() => {
updateTableState(storageKey, { filter: filter.length > 0 ? filter : undefined });
}, [filter, storageKey]);
// Coalesce every `update(...)` call fired in the same microtask into one
// `setSearchParams`. The first call schedules the flush; subsequent calls
// merge their patch into the pending buffer instead of issuing their own
// navigation. This is what makes race conditions impossible *by
// construction*: it doesn't matter whether two updates come from the
// same event handler, from two effects, or from a debounced commit
// landing alongside a synchronous click — only one navigation happens,
// with both fields applied.
const pendingPatchReference = useRef<null | {
filter: null | string | undefined;
filterPresent: boolean;
pageIndex: number | undefined;
pageIndexPresent: boolean;
// Replace resolution: any push-intent (`replace: false`) wins, so
// intentional history entries (paging clicks) survive coalescence
// with replace-only updates (filter typing).
replace: boolean;
}>(null);
const update = useCallback(
(patch: TableStateUpdate) => {
const filterPresent = 'filter' in patch;
const pageIndexPresent = 'pageIndex' in patch;
const requestedReplace = patch.replace ?? false;
if (pendingPatchReference.current === null) {
pendingPatchReference.current = {
filter: patch.filter,
filterPresent,
pageIndex: patch.pageIndex,
pageIndexPresent,
replace: requestedReplace,
};
queueMicrotask(() => {
const merged = pendingPatchReference.current;
pendingPatchReference.current = null;
if (merged === null) {
return;
}
const next = readLatestParams();
if (merged.filterPresent) {
if (!merged.filter) {
next.delete(filterParamName);
} else {
next.set(filterParamName, merged.filter);
}
}
if (merged.pageIndexPresent) {
const newIndex = merged.pageIndex ?? 0;
if (newIndex <= 0) {
next.delete(pageParamName);
} else {
next.set(pageParamName, String(newIndex + 1));
}
}
setSearchParams(next, { replace: merged.replace });
});
return;
}
// Merge into the in-flight patch — the queued microtask will see
// the fused result.
if (filterPresent) {
pendingPatchReference.current.filter = patch.filter;
pendingPatchReference.current.filterPresent = true;
}
if (pageIndexPresent) {
pendingPatchReference.current.pageIndex = patch.pageIndex;
pendingPatchReference.current.pageIndexPresent = true;
}
if (!requestedReplace) {
pendingPatchReference.current.replace = false;
}
},
[filterParamName, pageParamName, readLatestParams, setSearchParams],
);
const setFilter = useCallback(
(value: string) => {
// Typing keystrokes commit through here as well — we don't want
// intermediate filter values cluttering the history stack, so
// every filter change is a `replace`. The implicit page reset
// is bundled into the same atomic update so the resulting URL
// is consistent in one transition rather than two.
update({
filter: value.length === 0 ? null : value,
pageIndex: clearPageOnFilterChange ? 0 : undefined,
replace: true,
});
},
[clearPageOnFilterChange, update],
);
const setPage = useCallback(
(newPageIndex: number) => {
// Paging is an intentional user action — push, not replace, so
// back-button steps through the visited pages.
update({ pageIndex: newPageIndex });
},
[update],
);
const resetFilter = useCallback(() => setFilter(''), [setFilter]);
return useMemo(
() => ({
debouncedFilter,
filter,
pageIndex,
resetFilter,
setFilter,
setPage,
update,
}),
[debouncedFilter, filter, pageIndex, resetFilter, setFilter, setPage, update],
);
};
+9
View File
@@ -24,6 +24,15 @@ export function getStorageItem<T>(key: string, schema: z.ZodType<T>): null | T {
}
}
/** Remove `key` from `localStorage`. Silently no-ops on failure. */
export function removeStorageItem(key: string): void {
try {
localStorage.removeItem(key);
} catch {
/* localStorage may be unavailable */
}
}
/** Serialize `value` to JSON and store it in `localStorage`. Silently no-ops on failure. */
export function setStorageItem(key: string, value: unknown): void {
try {
+8
View File
@@ -0,0 +1,8 @@
export {
copyToClipboard,
downloadTextFile,
generateFileName,
generatePDFBlob,
generatePDFFromMarkdown,
generateReport,
} from './report';
@@ -1,7 +1,7 @@
import { Document, Font, Page, pdf, StyleSheet, Text, View } from '@react-pdf/renderer';
import { marked } from 'marked';
import { Log } from './log';
import { Log } from '@/lib/log';
// Register Noto Sans (covers Latin + Cyrillic + Greek + many other scripts)
Font.register({
@@ -3,8 +3,7 @@ import GithubSlugger from 'github-slugger';
import type { FlowFragmentFragment, TaskFragmentFragment } from '@/graphql/types';
import { StatusType } from '@/graphql/types';
import { Log } from './log';
import { Log } from '@/lib/log';
// Helper function to get emoji for status
const getStatusEmoji = (status: StatusType): string => {
@@ -225,8 +224,17 @@ export const copyToClipboard = async (text: string): Promise<boolean> => {
}
};
// Export new PDF generation functions from report-pdf.tsx
export {
generatePDFBlobNew as generatePDFBlob,
generatePDFFromMarkdownNew as generatePDFFromMarkdown,
} from './report-pdf';
// Lazy-load the PDF generator so @react-pdf/renderer (~1.5 MB) is fetched
// only when the user actually triggers a PDF export, not on every page that
// imports report utilities (flow.tsx, flow-report.tsx).
export const generatePDFFromMarkdown = async (content: string, fileName: string): Promise<void> => {
const { generatePDFFromMarkdownNew } = await import('./report-pdf');
return generatePDFFromMarkdownNew(content, fileName);
};
export const generatePDFBlob = async (content: string): Promise<Blob> => {
const { generatePDFBlobNew } = await import('./report-pdf');
return generatePDFBlobNew(content);
};
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import {
getPeriodStorageKey,
getStorageKey,
getTableStorageKey,
getTopLevelPath,
getViewOptionsStorageKey,
} from './storage-keys';
describe('getTopLevelPath', () => {
it('returns the first segment with a leading slash for top-level paths', () => {
expect(getTopLevelPath('/flows')).toBe('/flows');
});
it('strips the id from detail-page paths', () => {
expect(getTopLevelPath('/flows/abc-123')).toBe('/flows');
expect(getTopLevelPath('/knowledges/abc/foo')).toBe('/knowledges');
});
it('returns an empty string for the root', () => {
expect(getTopLevelPath('/')).toBe('');
expect(getTopLevelPath('')).toBe('');
});
it('handles paths without a leading slash', () => {
expect(getTopLevelPath('flows/123')).toBe('/flows');
});
it('skips repeated leading slashes', () => {
expect(getTopLevelPath('//flows/123')).toBe('/flows');
});
});
describe('getStorageKey', () => {
it('joins type and path with the `_4_` separator', () => {
expect(getStorageKey('table', '/flows')).toBe('table_4_/flows');
expect(getStorageKey('period', '/dashboard')).toBe('period_4_/dashboard');
});
});
describe('typed key helpers', () => {
it('getTableStorageKey delegates to the unified `table` namespace', () => {
expect(getTableStorageKey('/flows')).toBe('table_4_/flows');
});
it('getPeriodStorageKey delegates to the `period` namespace', () => {
expect(getPeriodStorageKey('/dashboard')).toBe('period_4_/dashboard');
});
it('getViewOptionsStorageKey delegates to the `viewOptions` namespace', () => {
expect(getViewOptionsStorageKey('/resources')).toBe('viewOptions_4_/resources');
});
});
+71 -20
View File
@@ -1,30 +1,81 @@
const STORAGE_KEY_SEPARATOR = '_4_';
/**
* Separator joining the namespace tag and the URL path inside a storage key.
* Reads as "for" — e.g. `table_4_/flows` is "table for /flows". Exported so
* the legacy-key migration helpers can reuse the exact same delimiter rather
* than re-stating it (a desync between the two would silently break
* migrations).
*/
export const STORAGE_KEY_SEPARATOR = '_4_';
export type LocalStorageKeyType = 'column' | 'page' | 'period' | 'sorting';
/**
* Discrete storage namespaces. Every list/detail page persists at most three
* slots — `table` (filter + sorting + columnVisibility + pageSize as one
* JSON object), `period` (dashboard-only time window), and `viewOptions`
* (FileManager-style screens that aren't backed by `DataTable`). Add new
* namespaces here rather than passing raw strings to {@link getStorageKey}.
*/
export type LocalStorageKeyType = 'period' | 'table' | 'viewOptions';
export function getColumnStorageKey(urlPath?: string): string {
return getStorageKey('column', urlPath);
}
export function getPageStorageKey(urlPath?: string): string {
return getStorageKey('page', urlPath);
}
export function getPeriodStorageKey(urlPath?: string): string {
/** Dashboard analytics time window. Lives outside the unified table slot. */
export function getPeriodStorageKey(urlPath: string): string {
return getStorageKey('period', urlPath);
}
export function getSortingStorageKey(urlPath?: string): string {
return getStorageKey('sorting', urlPath);
/**
* Build a storage key from a `type` tag and the URL path that owns the slot.
*
* Format: `${type}_4_${urlPath}` (where `_4_` reads as "for", linking the
* type to the path it scopes).
*
* `urlPath` is required: callers must source it from `useLocation()` (or
* pass an override for nested routes), not from a global `location` read.
* That keeps this module pure and reactive — when react-router navigates,
* a hook that depends on `pathname` re-evaluates the key automatically.
*/
export function getStorageKey(type: LocalStorageKeyType, urlPath: string): string {
return `${type}${STORAGE_KEY_SEPARATOR}${urlPath}`;
}
/**
* Builds a storage key from type and current page path.
* Format: `${type}_4_${urlPath}`
* If urlPath is not passed, uses window.location.pathname (client only).
* Unified per-page table state slot — one JSON object holds filter, sorting,
* column visibility, and page size. Replaces the older four-key fan-out
* (`column_4_`, `sorting_4_`, `filter_4_`, `page_4_`); see
* `migrateLegacyTableState` in `table-state.ts` for the one-shot reader of
* those legacy slots.
*/
export function getStorageKey(type: LocalStorageKeyType, urlPath?: string): string {
const path = urlPath ?? location?.pathname ?? '';
return `${type}${STORAGE_KEY_SEPARATOR}${path}`;
export function getTableStorageKey(urlPath: string): string {
return getStorageKey('table', urlPath);
}
/**
* Returns the first non-empty segment of a path with a leading `/`.
*
* Used by detail pages to derive the parent list's path without hardcoding
* it. Example:
* `/flows` → `/flows`
* `/flows/abc-123` → `/flows`
* `/knowledges/abc/foo` → `/knowledges`
* `/` → `''`
*
* Limitation: this only walks one level deep, so it will misidentify the
* parent for nested lists like `/admin/flows/:id` (returns `/admin` instead
* of `/admin/flows`). All current list pages live at the top level, but if
* a nested list ships, the corresponding detail page must hardcode its
* parent path explicitly instead of using this helper.
*/
export function getTopLevelPath(pathname: string): string {
const firstSegment = pathname.split('/').filter(Boolean)[0];
return firstSegment ? `/${firstSegment}` : '';
}
/**
* View options for `FileManager`-style screens (currently `/resources`).
* Not part of the unified `table` slot because the payload (folder-first
* toggle, expanded directory ids) has nothing in common with TanStack
* Table state — sharing the key would force a union schema and waste
* a Zod validation round-trip on every save.
*/
export function getViewOptionsStorageKey(urlPath: string): string {
return getStorageKey('viewOptions', urlPath);
}
-31
View File
@@ -1,31 +0,0 @@
import type { Column } from '@tanstack/react-table';
/**
* Cycle a TanStack Table column through the three-state sort order
* `none → asc → desc → none`. Mirrors the header behaviour of `DataTable`
* and the FileManager sortable headers.
*
* Pure with respect to React (does not call any hook), so it can be invoked
* directly from a header `onClick` without wrapping in `useCallback`/`useMemo`.
*
* Generic over the row type so `column` keeps its full TanStack typing at the
* call site — no need to inline a structural subset like
* `{ getIsSorted; toggleSorting; clearSorting }` in every page.
*/
export const cycleColumnSort = <TData, TValue = unknown>(column: Column<TData, TValue>): void => {
const sorted = column.getIsSorted();
if (sorted === 'asc') {
column.toggleSorting(true);
return;
}
if (sorted === 'desc') {
column.clearSorting();
return;
}
column.toggleSorting(false);
};
+188
View File
@@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { loadTableState, migrateLegacyTableState, updateTableState } from './table-state';
const PATH = '/flows';
const UNIFIED_KEY = `table_4_${PATH}`;
const LEGACY_COLUMN_KEY = `column_4_${PATH}`;
const LEGACY_SORTING_KEY = `sorting_4_${PATH}`;
const LEGACY_FILTER_KEY = `filter_4_${PATH}`;
const LEGACY_PAGE_KEY = `page_4_${PATH}`;
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
describe('loadTableState', () => {
it('returns an empty object when the key is missing', () => {
expect(loadTableState(UNIFIED_KEY)).toEqual({});
});
it('returns an empty object when the payload is invalid JSON', () => {
localStorage.setItem(UNIFIED_KEY, 'not json');
expect(loadTableState(UNIFIED_KEY)).toEqual({});
});
it('returns an empty object when the payload fails the schema', () => {
localStorage.setItem(UNIFIED_KEY, JSON.stringify({ filter: 42 }));
expect(loadTableState(UNIFIED_KEY)).toEqual({});
});
it('round-trips a full state object', () => {
const state = {
columnVisibility: { name: false },
filter: 'alpha',
pageSize: 25,
sorting: [{ desc: true, id: 'createdAt' }],
};
localStorage.setItem(UNIFIED_KEY, JSON.stringify(state));
expect(loadTableState(UNIFIED_KEY)).toEqual(state);
});
});
describe('updateTableState', () => {
it('writes a partial patch on top of the existing state', () => {
localStorage.setItem(UNIFIED_KEY, JSON.stringify({ filter: 'alpha', pageSize: 25 }));
updateTableState(UNIFIED_KEY, { sorting: [{ desc: false, id: 'name' }] });
const stored = JSON.parse(localStorage.getItem(UNIFIED_KEY) ?? '{}');
expect(stored.filter).toBe('alpha');
expect(stored.pageSize).toBe(25);
expect(stored.sorting).toEqual([{ desc: false, id: 'name' }]);
});
it('clears a field via `undefined`', () => {
localStorage.setItem(UNIFIED_KEY, JSON.stringify({ filter: 'alpha', pageSize: 25 }));
updateTableState(UNIFIED_KEY, { filter: undefined });
const stored = JSON.parse(localStorage.getItem(UNIFIED_KEY) ?? '{}');
expect('filter' in stored).toBe(false);
expect(stored.pageSize).toBe(25);
});
it('collapses empty strings / arrays / records to "no value"', () => {
localStorage.setItem(
UNIFIED_KEY,
JSON.stringify({ columnVisibility: { name: false }, filter: 'foo', sorting: [{ desc: true, id: 'a' }] }),
);
updateTableState(UNIFIED_KEY, { columnVisibility: {}, filter: '', sorting: [] });
const stored = JSON.parse(localStorage.getItem(UNIFIED_KEY) ?? '{}');
expect(stored).toEqual({});
});
it('removes the storage key when the merged state is empty', () => {
localStorage.setItem(UNIFIED_KEY, JSON.stringify({ filter: 'alpha' }));
updateTableState(UNIFIED_KEY, { filter: undefined });
expect(localStorage.getItem(UNIFIED_KEY)).toBeNull();
});
it('returns the merged state for the caller', () => {
const next = updateTableState(UNIFIED_KEY, { pageSize: 50 });
expect(next).toEqual({ pageSize: 50 });
});
});
describe('migrateLegacyTableState', () => {
it('short-circuits to loadTableState when no legacy keys exist', () => {
localStorage.setItem(UNIFIED_KEY, JSON.stringify({ filter: 'unified' }));
const result = migrateLegacyTableState(PATH, UNIFIED_KEY);
expect(result).toEqual({ filter: 'unified' });
// Storage should remain untouched.
expect(JSON.parse(localStorage.getItem(UNIFIED_KEY) ?? '{}')).toEqual({ filter: 'unified' });
});
it('folds legacy sorting / column / filter / page-size into the unified slot', () => {
localStorage.setItem(LEGACY_SORTING_KEY, JSON.stringify([{ desc: true, id: 'createdAt' }]));
localStorage.setItem(LEGACY_COLUMN_KEY, JSON.stringify({ name: false }));
localStorage.setItem(LEGACY_FILTER_KEY, JSON.stringify('alpha'));
localStorage.setItem(LEGACY_PAGE_KEY, JSON.stringify({ page: 4, pageSize: 25 }));
const result = migrateLegacyTableState(PATH, UNIFIED_KEY);
expect(result).toEqual({
columnVisibility: { name: false },
filter: 'alpha',
pageSize: 25,
sorting: [{ desc: true, id: 'createdAt' }],
});
// Legacy keys must be deleted after the fold so they never resurface.
expect(localStorage.getItem(LEGACY_SORTING_KEY)).toBeNull();
expect(localStorage.getItem(LEGACY_COLUMN_KEY)).toBeNull();
expect(localStorage.getItem(LEGACY_FILTER_KEY)).toBeNull();
expect(localStorage.getItem(LEGACY_PAGE_KEY)).toBeNull();
});
it('drops the legacy `page` field — only `pageSize` survives the migration', () => {
// The page index now lives in `?page=`, not storage. Carrying the
// old `page` field forward would put it back into a place the rest
// of the app no longer reads from.
localStorage.setItem(LEGACY_PAGE_KEY, JSON.stringify({ page: 4, pageSize: 25 }));
const result = migrateLegacyTableState(PATH, UNIFIED_KEY);
expect(result).toEqual({ pageSize: 25 });
expect((result as { page?: number }).page).toBeUndefined();
});
it('is idempotent — a second invocation reads the unified slot only', () => {
localStorage.setItem(LEGACY_SORTING_KEY, JSON.stringify([{ desc: true, id: 'x' }]));
migrateLegacyTableState(PATH, UNIFIED_KEY);
const second = migrateLegacyTableState(PATH, UNIFIED_KEY);
expect(second).toEqual({ sorting: [{ desc: true, id: 'x' }] });
});
it('skips fields whose legacy payload fails the schema', () => {
// A legacy `column_4_` key that holds non-boolean values (e.g.
// because the format changed mid-history). The migration should
// silently drop it instead of polluting the unified state.
localStorage.setItem(LEGACY_COLUMN_KEY, JSON.stringify({ name: 'visible' }));
localStorage.setItem(LEGACY_FILTER_KEY, JSON.stringify('alpha'));
const result = migrateLegacyTableState(PATH, UNIFIED_KEY);
expect(result).toEqual({ filter: 'alpha' });
// Legacy keys must still be deleted — they're invalid either way.
expect(localStorage.getItem(LEGACY_COLUMN_KEY)).toBeNull();
expect(localStorage.getItem(LEGACY_FILTER_KEY)).toBeNull();
});
it('migrates legacy keys stored under the trailing-slash pathname variant', () => {
const pathNoSlash = '/flows';
const pathSlash = `${pathNoSlash}/`;
const unifiedKey = `table_4_${pathNoSlash}`;
localStorage.setItem(`sorting_4_${pathSlash}`, JSON.stringify([{ desc: false, id: 'title' }]));
const result = migrateLegacyTableState(pathNoSlash, unifiedKey);
expect(result.sorting).toEqual([{ desc: false, id: 'title' }]);
expect(localStorage.getItem(`sorting_4_${pathSlash}`)).toBeNull();
expect(localStorage.getItem(`sorting_4_${pathNoSlash}`)).toBeNull();
});
it('migrates legacy keys from the canonical path when migration is invoked with a trailing slash', () => {
const pathSlash = '/flows/';
const pathCanonical = '/flows';
const unifiedKey = `table_4_${pathSlash}`;
localStorage.setItem(`filter_4_${pathCanonical}`, JSON.stringify('needle'));
const result = migrateLegacyTableState(pathSlash, unifiedKey);
expect(result.filter).toBe('needle');
expect(localStorage.getItem(`filter_4_${pathCanonical}`)).toBeNull();
expect(localStorage.getItem(`filter_4_${pathSlash}`)).toBeNull();
});
});
+144
View File
@@ -0,0 +1,144 @@
import { z } from 'zod';
import { getStorageItem, removeStorageItem, setStorageItem } from './local-storage';
import { STORAGE_KEY_SEPARATOR } from './storage-keys';
const sortingSchema = z.array(z.object({ desc: z.boolean(), id: z.string() }));
const visibilitySchema = z.record(z.string(), z.boolean());
/**
* Unified table state schema. All per-page table preferences live under one
* JSON key (`table_4_<path>`) instead of fanning out into `column_4_/`,
* `sorting_4_/`, `filter_4_/` etc. Every field is optional so partial state
* (e.g. only a filter, no custom column visibility) doesn't force defaults
* into storage.
*
* `pageSize` is the user's chosen rows-per-page; the current `page` index
* stays in the URL where it can be bookmarked.
*/
const tableStateSchema = z.object({
columnVisibility: visibilitySchema.optional(),
filter: z.string().optional(),
pageSize: z.number().int().positive().optional(),
searchColumns: z.array(z.string()).optional(),
sorting: sortingSchema.optional(),
});
export type TableState = z.infer<typeof tableStateSchema>;
const LEGACY_TYPES = ['column', 'sorting', 'filter', 'page'] as const;
const legacyPageSchema = z.object({ page: z.number(), pageSize: z.number() });
/** Paths that historically wrote legacy keys with or without a trailing `/`. */
const legacyStoragePathVariants = (path: string): string[] => {
if (path === '/') {
return ['/'];
}
const withoutTrailing = path.replace(/\/+$/, '');
const canonical = withoutTrailing === '' ? '/' : withoutTrailing;
const withSlash = `${canonical}/`;
return canonical === path ? [canonical, withSlash] : [path, canonical];
};
const legacyKeysForPath = (p: string): string[] => LEGACY_TYPES.map((type) => `${type}${STORAGE_KEY_SEPARATOR}${p}`);
/** Read the unified state. Returns `{}` when missing or invalid. */
export const loadTableState = (key: string): TableState => getStorageItem(key, tableStateSchema) ?? {};
/**
* Merge `patch` into the stored state. `undefined` values clear the
* corresponding field. When the result is empty, the storage key is removed
* so an unsubscribed page leaves no residue (callers can rely on
* `loadTableState() === {}` ≡ "no preferences set").
*/
export const updateTableState = (key: string, patch: Partial<TableState>): TableState => {
const current = loadTableState(key);
const merged: TableState = { ...current };
for (const [field, value] of Object.entries(patch) as [keyof TableState, TableState[keyof TableState]][]) {
if (value === undefined) {
delete merged[field];
continue;
}
// Empty string / empty array / empty object all collapse to "no value"
// so storage holds the same canonical shape as `loadTableState()` of
// a fresh user — keeps comparison ergonomic and prevents key churn.
const isEmptyString = typeof value === 'string' && value.length === 0;
const isEmptyArray = Array.isArray(value) && value.length === 0;
const isEmptyRecord =
value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0;
if (isEmptyString || isEmptyArray || isEmptyRecord) {
delete merged[field];
continue;
}
(merged as Record<string, unknown>)[field] = value;
}
if (Object.keys(merged).length === 0) {
removeStorageItem(key);
return merged;
}
setStorageItem(key, merged);
return merged;
};
/**
* One-shot migration from the pre-unified storage layout. Reads the four
* legacy keys (`column_4_`, `sorting_4_`, `filter_4_`, `page_4_`), folds
* them into the new unified key, and deletes the legacy slots. Returns the
* resulting state.
*
* Idempotent: once the legacy keys are gone, subsequent runs short-circuit
* to a plain `loadTableState`. Safe to call on every page mount.
*
* Note on `page`: the legacy schema stored `{ page, pageSize }`. We carry
* over `pageSize` only — the current page index now lives in `?page=` so
* the user can share a paginated view via URL.
*/
export const migrateLegacyTableState = (path: string, unifiedKey: string): TableState => {
const variants = legacyStoragePathVariants(path);
const allLegacyKeys = variants.flatMap(legacyKeysForPath);
const anyLegacyPresent = allLegacyKeys.some((key) => localStorage.getItem(key) !== null);
if (!anyLegacyPresent) {
return loadTableState(unifiedKey);
}
const pickFirst = <T>(reader: (scopedPath: string) => null | T): null | T => {
for (const scopedPath of variants) {
const value = reader(scopedPath);
if (value !== null) {
return value;
}
}
return null;
};
const legacySorting = pickFirst((p) => getStorageItem(`sorting${STORAGE_KEY_SEPARATOR}${p}`, sortingSchema));
const legacyVisibility = pickFirst((p) => getStorageItem(`column${STORAGE_KEY_SEPARATOR}${p}`, visibilitySchema));
const legacyFilter = pickFirst((p) => getStorageItem(`filter${STORAGE_KEY_SEPARATOR}${p}`, z.string()));
const legacyPage = pickFirst((p) => getStorageItem(`page${STORAGE_KEY_SEPARATOR}${p}`, legacyPageSchema));
const merged = updateTableState(unifiedKey, {
columnVisibility: legacyVisibility ?? undefined,
filter: legacyFilter ?? undefined,
pageSize: legacyPage?.pageSize,
sorting: legacySorting ?? undefined,
});
for (const key of allLegacyKeys) {
removeStorageItem(key);
}
return merged;
};
-25
View File
@@ -1,25 +0,0 @@
import type { SortingState, VisibilityState } from '@tanstack/react-table';
import { z } from 'zod';
import { getStorageItem, setStorageItem } from './local-storage';
const sortingSchema = z.array(z.object({ desc: z.boolean(), id: z.string() }));
const visibilitySchema = z.record(z.string(), z.boolean());
const pageStateSchema = z.object({ page: z.number(), pageSize: z.number() });
export type StoredPageState = z.infer<typeof pageStateSchema>;
export const loadSorting = (key: string): null | SortingState => getStorageItem(key, sortingSchema);
export const loadColumnVisibility = (key: string): null | VisibilityState => getStorageItem(key, visibilitySchema);
export const loadPageState = (key: string): null | StoredPageState => getStorageItem(key, pageStateSchema);
export const saveSorting = (key: string, sorting: SortingState): void => setStorageItem(key, sorting);
export const saveColumnVisibility = (key: string, visibility: VisibilityState): void => setStorageItem(key, visibility);
export const savePageState = (key: string, state: StoredPageState): void => setStorageItem(key, state);
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { mergeHrefWithSearchParams } from './url-params';
describe('mergeHrefWithSearchParams', () => {
it('appends incoming params to a path-only href', () => {
const result = mergeHrefWithSearchParams('/flows/1', new URLSearchParams('q=foo'));
expect(result).toBe('/flows/1?q=foo');
});
it('lets the href keep its own query params when keys collide', () => {
const result = mergeHrefWithSearchParams('/flows/1?bar=keep', new URLSearchParams('bar=overridden&q=x'));
expect(result).toBe('/flows/1?bar=keep&q=x');
});
it('preserves the hash fragment', () => {
const result = mergeHrefWithSearchParams('/flows/1#tab=logs', new URLSearchParams('q=foo'));
expect(result).toBe('/flows/1?q=foo#tab=logs');
});
it('returns the href unchanged when nothing is incoming', () => {
expect(mergeHrefWithSearchParams('/flows/1', new URLSearchParams())).toBe('/flows/1');
expect(mergeHrefWithSearchParams('/flows/1?bar=1', new URLSearchParams())).toBe('/flows/1?bar=1');
});
it('accepts an entries iterable as well as URLSearchParams', () => {
const entries: [string, string][] = [
['q', 'foo'],
['x', '1'],
];
const result = mergeHrefWithSearchParams('/flows/1', entries);
expect(result).toBe('/flows/1?q=foo&x=1');
});
it('round-trips through URLSearchParams encoding (spaces ↔ +, special chars escaped)', () => {
// URLSearchParams decodes `+` as space on read, then re-encodes spaces
// as `+` on toString — so a literal `+` in the input is interpreted
// as a space. We document the actual behaviour here so callers know
// what to expect.
const result = mergeHrefWithSearchParams('/flows/1', new URLSearchParams('q=a b&keep=%23anchor'));
expect(result).toBe('/flows/1?q=a+b&keep=%23anchor');
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* URL query parameter keys shared across list/detail pages.
*
* Keep all conventional names here so the filter hook, the pagination
* handlers, and any future feature that wants to coordinate with them agree
* on a single source of truth — renaming a key shouldn't require grepping
* the codebase for string literals.
*/
export const URL_PARAMS = {
/** `?page=` — 1-based page number for list-style pagination. */
PAGE: 'page',
/** `?q=` — free-text filter applied by `useTableQueryFilter`. */
QUERY: 'q',
} as const;
/**
* Synthetic base for `new URL(path, base)`. `URL` rejects a path-only first
* argument unless a base is provided; we strip the origin from the output
* below by reading only `pathname` / `search` / `hash`. The constant is
* intentionally bland — anything resolvable as a URL works.
*/
const URL_PARSE_BASE = 'http://_';
/**
* Merge a relative href with an iterable of "current" query params: every key
* that the iterable supplies is added to the href, unless the href already
* specifies that key (then the href wins).
*
* Built around `URL` rather than string `split('?')` so the hash fragment
* survives untouched and the contract holds even if `base` happens to grow
* an `#anchor` someday.
*
* Examples:
* merge('/flows/1', new URLSearchParams('q=foo')) // '/flows/1?q=foo'
* merge('/flows/1?bar=1', new URLSearchParams('bar=2&q=x')) // '/flows/1?bar=1&q=x'
* merge('/flows/1#tab=logs', new URLSearchParams('q=foo')) // '/flows/1?q=foo#tab=logs'
*/
export const mergeHrefWithSearchParams = (
base: string,
incoming: Iterable<[string, string]> | URLSearchParams,
): string => {
const url = new URL(base, URL_PARSE_BASE);
const source = incoming instanceof URLSearchParams ? incoming.entries() : incoming;
for (const [key, value] of source) {
if (!url.searchParams.has(key)) {
url.searchParams.set(key, value);
}
}
return `${url.pathname}${url.search}${url.hash}`;
};
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { loadViewOptions, migrateLegacyViewOptions, saveViewOptions } from './view-options-storage';
const PATH = '/resources';
const UNIFIED_KEY = `viewOptions_4_${PATH}`;
const LEGACY_KEY = `column_4_${PATH}`;
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
describe('loadViewOptions', () => {
it('returns an empty object when the key is missing', () => {
expect(loadViewOptions(UNIFIED_KEY)).toEqual({});
});
it('returns an empty object when the payload is invalid JSON', () => {
localStorage.setItem(UNIFIED_KEY, 'not json');
expect(loadViewOptions(UNIFIED_KEY)).toEqual({});
});
it('returns an empty object when a value is not boolean', () => {
// The schema is `Record<string, boolean>` — a numeric value should
// be rejected wholesale rather than silently coerced.
localStorage.setItem(UNIFIED_KEY, JSON.stringify({ foldersFirst: 1 }));
expect(loadViewOptions(UNIFIED_KEY)).toEqual({});
});
it('round-trips a multi-flag payload', () => {
const value = { foldersFirst: true, relativeTimestamp: false };
localStorage.setItem(UNIFIED_KEY, JSON.stringify(value));
expect(loadViewOptions(UNIFIED_KEY)).toEqual(value);
});
});
describe('saveViewOptions', () => {
it('writes the payload as JSON', () => {
saveViewOptions(UNIFIED_KEY, { foldersFirst: true });
const raw = localStorage.getItem(UNIFIED_KEY);
expect(raw).not.toBeNull();
expect(JSON.parse(raw ?? 'null')).toEqual({ foldersFirst: true });
});
it('removes the storage key when the payload is empty — keeps storage tidy', () => {
localStorage.setItem(UNIFIED_KEY, JSON.stringify({ foldersFirst: true }));
saveViewOptions(UNIFIED_KEY, {});
expect(localStorage.getItem(UNIFIED_KEY)).toBeNull();
});
it('replaces (does not merge) the existing payload', () => {
// The function is deliberately a setter, not a patcher — the caller
// is expected to merge if they want partial updates. This test pins
// that contract.
saveViewOptions(UNIFIED_KEY, { foldersFirst: true, relativeTimestamp: true });
saveViewOptions(UNIFIED_KEY, { foldersFirst: false });
expect(loadViewOptions(UNIFIED_KEY)).toEqual({ foldersFirst: false });
});
});
describe('migrateLegacyViewOptions', () => {
it('short-circuits to loadViewOptions when no legacy key exists', () => {
localStorage.setItem(UNIFIED_KEY, JSON.stringify({ foldersFirst: true }));
const result = migrateLegacyViewOptions(PATH, UNIFIED_KEY);
expect(result).toEqual({ foldersFirst: true });
// The unified slot must remain untouched.
expect(JSON.parse(localStorage.getItem(UNIFIED_KEY) ?? '{}')).toEqual({ foldersFirst: true });
});
it('folds the legacy `column_4_<path>` payload into the unified key', () => {
localStorage.setItem(LEGACY_KEY, JSON.stringify({ foldersFirst: true, relativeTimestamp: false }));
const result = migrateLegacyViewOptions(PATH, UNIFIED_KEY);
expect(result).toEqual({ foldersFirst: true, relativeTimestamp: false });
expect(localStorage.getItem(LEGACY_KEY)).toBeNull();
});
it('merges legacy values on top of pre-existing unified values', () => {
// Possible if a partial migration happened on a different tab. The
// legacy payload represents the most recent user intent (it was
// never cleared), so we accept it as the winner on overlap.
localStorage.setItem(UNIFIED_KEY, JSON.stringify({ foldersFirst: false, modified: true }));
localStorage.setItem(LEGACY_KEY, JSON.stringify({ foldersFirst: true }));
const result = migrateLegacyViewOptions(PATH, UNIFIED_KEY);
expect(result).toEqual({ foldersFirst: true, modified: true });
expect(localStorage.getItem(LEGACY_KEY)).toBeNull();
});
it('still deletes the legacy key when its payload is invalid', () => {
// An invalid legacy payload contributes nothing, but we must not
// leave it in storage — a future call would otherwise repeatedly
// re-parse the same garbage.
localStorage.setItem(LEGACY_KEY, JSON.stringify({ foldersFirst: 'truthy' }));
const result = migrateLegacyViewOptions(PATH, UNIFIED_KEY);
expect(result).toEqual({});
expect(localStorage.getItem(LEGACY_KEY)).toBeNull();
});
it('is idempotent — a second invocation reads only the unified key', () => {
localStorage.setItem(LEGACY_KEY, JSON.stringify({ foldersFirst: true }));
migrateLegacyViewOptions(PATH, UNIFIED_KEY);
const second = migrateLegacyViewOptions(PATH, UNIFIED_KEY);
expect(second).toEqual({ foldersFirst: true });
});
});
+49
View File
@@ -0,0 +1,49 @@
import { z } from 'zod';
import { getStorageItem, removeStorageItem, setStorageItem } from './local-storage';
const viewOptionsSchema = z.record(z.string(), z.boolean());
export type ViewOptionsRecord = Record<string, boolean>;
/** Read a per-page `Record<string, boolean>` view-options bag. Returns `{}` when missing/invalid. */
export const loadViewOptions = (key: string): ViewOptionsRecord => getStorageItem(key, viewOptionsSchema) ?? {};
/** Persist the bag. Empty objects clear the slot so storage stays tidy. */
export const saveViewOptions = (key: string, value: ViewOptionsRecord): void => {
if (Object.keys(value).length === 0) {
removeStorageItem(key);
return;
}
setStorageItem(key, value);
};
/**
* One-shot migration of pre-unification `column_4_<path>` view-options storage
* to the new `viewOptions_4_<path>` slot. Reads the legacy key, writes the
* payload under the new key (if any), removes the legacy entry, and returns
* the resulting record.
*
* Idempotent: once the legacy key is gone, subsequent invocations short-circuit
* to a plain `loadViewOptions`. Safe to call on every page mount.
*/
export const migrateLegacyViewOptions = (path: string, unifiedKey: string): ViewOptionsRecord => {
const legacyKey = `column_4_${path}`;
const legacyRaw = localStorage.getItem(legacyKey);
if (legacyRaw === null) {
return loadViewOptions(unifiedKey);
}
const legacyValue = getStorageItem(legacyKey, viewOptionsSchema);
if (legacyValue !== null) {
saveViewOptions(unifiedKey, { ...loadViewOptions(unifiedKey), ...legacyValue });
}
removeStorageItem(legacyKey);
return loadViewOptions(unifiedKey);
};
+3 -3
View File
@@ -1,12 +1,12 @@
import { LayoutDashboard } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { UsageStatsPeriod } from '@/graphql/types';
import { getPeriodStorageKey } from '@/lib/storage-keys';
import { usePageStorageKeys } from '@/hooks/use-page-storage-keys';
import { DashboardAnalytics } from '@/pages/dashboard/dashboard-analytics';
import { DashboardOverview } from '@/pages/dashboard/dashboard-overview';
@@ -41,7 +41,7 @@ const savePeriod = (storageKey: string, value: UsageStatsPeriod): void => {
};
const Dashboard = () => {
const periodStorageKey = useMemo(() => getPeriodStorageKey(), []);
const { period: periodStorageKey } = usePageStorageKeys();
const [activeTab, setActiveTab] = useState('analytics');
const [period, setPeriod] = useState<UsageStatsPeriod>(() => loadPeriod(periodStorageKey));
+295 -23
View File
@@ -1,23 +1,52 @@
import { ChevronDown, Copy, Download, ExternalLink, GripVertical, Loader2, NotepadText, Star } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import {
ChevronDown,
Copy,
Download,
Ellipsis,
ExternalLink,
GitFork,
GripVertical,
Loader2,
NotepadText,
Pause,
PencilLine,
Star,
Trash,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
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 {
DetailNavigationButtons,
DetailNavigationSheet,
DetailNavigationToolbar,
} from '@/components/shared/detail-navigation';
import { HeaderButton } from '@/components/shared/header-button';
import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit';
import { Badge } from '@/components/ui/badge';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import FlowCentralTabs from '@/features/flows/flow-central-tabs';
import FlowTabs from '@/features/flows/flow-tabs';
import { useFlowDetailNavigation } from '@/features/flows/use-flow-detail-navigation';
import { ResultType, StatusType, useRenameFlowMutation } from '@/graphql/types';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useFlowTabDetection } from '@/hooks/use-flow-tab-detection';
import { Log } from '@/lib/log';
@@ -25,6 +54,23 @@ import { copyToClipboard, downloadTextFile, generateFileName, generateReport } f
import { formatName } from '@/lib/utils/format';
import { useFavorites } from '@/providers/favorites-provider';
import { useFlow } from '@/providers/flow-provider';
import { type Flow as FlowItem, useFlows } from '@/providers/flows-provider';
const renderFlowItem = (item: FlowItem, isCurrent: boolean): ReactNode => (
<>
<FlowStatusIcon
className="size-3 shrink-0"
status={item.status}
/>
<span className={isCurrent ? 'truncate font-medium' : 'truncate'}>{item.title || `Flow #${item.id}`}</span>
<Badge
className="ml-auto shrink-0 font-mono text-[10px]"
variant="outline"
>
#{item.id}
</Badge>
</>
);
const FlowReportDropdown = () => {
const { flowData, flowId } = useFlow();
@@ -94,15 +140,14 @@ const FlowReportDropdown = () => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
<HeaderButton
className="shrink-0"
disabled={isReportDisabled}
endIcon={<ChevronDown className="opacity-50" />}
icon={<NotepadText />}
label="Report"
variant="ghost"
>
<NotepadText />
Report
<ChevronDown className="opacity-50" />
</Button>
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
@@ -143,12 +188,35 @@ const FlowReportDropdown = () => {
};
const Flow = () => {
const { isDesktop } = useBreakpoint();
const { isDesktop, isMobile } = useBreakpoint();
const navigate = useNavigate();
const { flowData, flowError, flowId, isLoading: isFlowLoading } = useFlow();
const { deleteFlow, finishFlow } = useFlows();
const { isFavoriteFlow, toggleFavoriteFlow } = useFavorites();
const flow = flowData?.flow;
const flowTitle = flow?.title ?? '';
const isFlowRunning = flow ? ![StatusType.Failed, StatusType.Finished].includes(flow.status) : false;
// Single controller drives the desktop toolbar AND the mobile dropdown
// row + sheet — Prev/Next, sheet open state, and the position label all
// live on one source of truth.
const flowNav = useFlowDetailNavigation(flowId);
const {
handleDropdownCloseAutoFocus,
inputRef: editingInputRef,
isEditing: isEditingTitle,
startEdit: handleFlowRenameStart,
stopEdit: handleFlowRenameCancel,
} = useInlineEdit({ resetKey: flowId });
const [isFinishing, setIsFinishing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [renameFlowMutation, { loading: isRenameLoading }] = useRenameFlowMutation();
// Redirect to flows list if there's an error loading flow data or flow not found
useEffect(() => {
if (flowError || (!isFlowLoading && !flowData?.flow)) {
@@ -156,6 +224,63 @@ const Flow = () => {
}
}, [flowError, flowData, isFlowLoading, navigate]);
const handleFlowRenameSave = useCallback(async () => {
const newTitle = editingInputRef.current?.value.trim();
if (!flowId || !newTitle) {
return;
}
try {
const { data } = await renameFlowMutation({
variables: {
flowId,
title: newTitle,
},
});
if (data?.renameFlow === ResultType.Success) {
toast.success('Flow renamed successfully');
handleFlowRenameCancel();
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to rename flow';
toast.error(errorMessage);
}
}, [editingInputRef, flowId, handleFlowRenameCancel, renameFlowMutation]);
const handleFlowFinish = useCallback(async () => {
if (!flow) {
return;
}
setIsFinishing(true);
try {
await finishFlow(flow);
} finally {
setIsFinishing(false);
}
}, [flow, finishFlow]);
const handleFlowDelete = useCallback(async () => {
if (!flow) {
return;
}
setIsDeleting(true);
try {
const success = await deleteFlow(flow);
if (success) {
navigate('/flows', { replace: true });
}
} finally {
setIsDeleting(false);
}
}, [flow, deleteFlow, navigate]);
// Desktop: side panel defaults to 'terminal'
const [desktopTabsTab, setDesktopTabsTab] = useState<string>('terminal');
@@ -180,35 +305,69 @@ const Flow = () => {
<>
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex w-full items-center justify-between gap-2 px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<div className="flex min-w-0 flex-1 items-center gap-2">
<SidebarTrigger className="-ml-1 shrink-0" />
<Separator
className="mr-2 h-4"
className="mr-2 h-4 shrink-0"
orientation="vertical"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="gap-2">
{flowData?.flow && (
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="min-w-0 flex-nowrap">
<BreadcrumbItem className="min-w-0 gap-2">
{flow && (
<>
<FlowStatusIcon
status={flowData.flow.status}
tooltip={formatName(flowData.flow.status)}
status={flow.status}
tooltip={formatName(flow.status)}
/>
<ProviderIcon
provider={flowData.flow.provider}
tooltip={formatName(flowData.flow.provider.name)}
provider={flow.provider}
tooltip={formatName(flow.provider.name)}
/>
</>
)}
<BreadcrumbPage>{flowData?.flow?.title || 'Select a flow'}</BreadcrumbPage>
{isEditingTitle && flow ? (
<InlineEditInput
busy={isRenameLoading}
className="w-64 min-w-0 max-w-full flex-1"
defaultValue={flowTitle}
inputRef={editingInputRef}
onCancel={handleFlowRenameCancel}
onSave={handleFlowRenameSave}
placeholder="Flow title"
/>
) : flow ? (
<Tooltip>
<TooltipTrigger asChild>
<BreadcrumbPage
className="min-w-0 cursor-text select-none truncate"
onDoubleClick={handleFlowRenameStart}
>
{flowTitle || 'Select a flow'}
</BreadcrumbPage>
</TooltipTrigger>
<TooltipContent>Double-click to rename</TooltipContent>
</Tooltip>
) : (
<BreadcrumbPage className="min-w-0 truncate">
{flowTitle || 'Select a flow'}
</BreadcrumbPage>
)}
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="flex items-center gap-2">
{flowId && (
<div className="flex shrink-0 items-center gap-2">
{flow && !isMobile && (
<DetailNavigationToolbar<FlowItem>
controller={flowNav}
renderItem={renderFlowItem}
sheetIcon={<GitFork className="size-4" />}
sheetTitle="Flows"
/>
)}
{flowId && !isMobile && (
<Button
className="shrink-0"
onClick={() => toggleFavoriteFlow(flowId)}
@@ -219,9 +378,113 @@ const Flow = () => {
</Button>
)}
{!!(flowData?.tasks ?? [])?.length && <FlowReportDropdown />}
{flow && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Flow actions"
className="size-8 p-0"
variant="ghost"
>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-24"
onCloseAutoFocus={handleDropdownCloseAutoFocus}
>
{isMobile && flowNav.total > 0 && (
<>
{/* Single row that mirrors the desktop toolbar: label on
the left, prev / position / next button group on the
right. `onSelect={preventDefault}` stops the menu from
closing on label clicks; `<DetailNavigationButtons>`
owns its own click handlers and tooltips. */}
<DropdownMenuItem
className="cursor-default hover:bg-transparent focus:bg-transparent"
onSelect={(event) => event.preventDefault()}
>
<GitFork className="size-4" />
Flows
<div className="-my-1.5 -mr-2 ml-auto flex items-center">
<DetailNavigationButtons<FlowItem>
controller={flowNav}
sheetTitle="Flows"
size="sm"
/>
</div>
</DropdownMenuItem>
{flowId && (
<DropdownMenuItem onClick={() => toggleFavoriteFlow(flowId)}>
<Star
className={
isFavoriteFlow(flowId)
? 'size-4 fill-yellow-500 stroke-yellow-500'
: 'size-4'
}
/>
{isFavoriteFlow(flowId)
? 'Remove from favorites'
: 'Add to favorites'}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleFlowRenameStart}>
<PencilLine className="size-3" />
Rename
</DropdownMenuItem>
{isFlowRunning && (
<DropdownMenuItem
disabled={isFinishing}
onClick={() => handleFlowFinish()}
>
{isFinishing ? (
<>
<Loader2 className="animate-spin" />
Finishing...
</>
) : (
<>
<Pause />
Finish
</>
)}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isDeleting}
onClick={() => setIsDeleteDialogOpen(true)}
>
{isDeleting ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash className="size-4" />
Delete
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</header>
{isMobile && flow && (
<DetailNavigationSheet<FlowItem>
controller={flowNav}
renderItem={renderFlowItem}
sheetIcon={<GitFork className="size-4" />}
sheetTitle="Flows"
/>
)}
<div className="relative flex h-[calc(100dvh-3rem)] w-full max-w-full flex-1">
{isFlowLoading && (
<div className="bg-background/50 absolute inset-0 z-50 flex items-center justify-center">
@@ -257,6 +520,15 @@ const Flow = () => {
tabsCard
)}
</div>
<ConfirmationDialog
cancelText="Cancel"
confirmText="Delete"
handleConfirm={handleFlowDelete}
handleOpenChange={setIsDeleteDialogOpen}
isOpen={isDeleteDialogOpen}
itemName={flow?.title}
itemType="flow"
/>
</>
);
};
+97 -232
View File
@@ -2,21 +2,22 @@ import type { ColumnDef } from '@tanstack/react-table';
import { format, isToday } from 'date-fns';
import { enUS } from 'date-fns/locale';
import { ArrowDown, ArrowUp, Ellipsis, Eye, GitFork, Loader2, Pause, Pencil, Plus, Star, Trash } from 'lucide-react';
import { Check, CheckCircle2, X, XCircle } from 'lucide-react';
import { Ellipsis, Eye, GitFork, Loader2, Pause, Pencil, PencilLine, Plus, Star, Trash } from 'lucide-react';
import { CheckCircle2, XCircle } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
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 { HeaderButton } from '@/components/shared/header-button';
import { InlineEditInput } from '@/components/shared/inline-edit';
import { Badge } from '@/components/ui/badge';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { DataTable } from '@/components/ui/data-table';
import { DataTable, DataTableColumnHeader } from '@/components/ui/data-table';
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,13 +25,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { StatusCard } from '@/components/ui/status-card';
import { Toggle } from '@/components/ui/toggle';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ResultType, StatusType, type TerminalFragmentFragment, useRenameFlowMutation } from '@/graphql/types';
import { useTableState } from '@/hooks/use-table-state';
import { mergeHrefWithSearchParams } from '@/lib/url-params';
import { useFavorites } from '@/providers/favorites-provider';
import { type Flow, useFlows } from '@/providers/flows-provider';
@@ -78,7 +80,7 @@ const formatFullDateTime = (dateString: string) => {
const Flows = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const { deleteFlow, finishFlow, flows, isLoading } = useFlows();
const { isFavoriteFlow, toggleFavoriteFlow } = useFavorites();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -89,55 +91,13 @@ const Flows = () => {
const editingInputRef = useRef<HTMLInputElement>(null);
const [renameFlowMutation, { loading: isRenameLoading }] = useRenameFlowMutation();
// Three-way sorting handler: null -> asc -> desc -> null
const handleColumnSort = useMemo(
() =>
(column: {
clearSorting: () => void;
getIsSorted: () => 'asc' | 'desc' | false;
toggleSorting: (desc?: boolean) => void;
}) => {
const sorted = column.getIsSorted();
if (sorted === 'asc') {
column.toggleSorting(true);
} else if (sorted === 'desc') {
column.clearSorting();
} else {
column.toggleSorting(false);
}
},
[],
);
// Get current page from URL
const currentPage = useMemo(() => {
const page = searchParams.get('page');
return page ? Math.max(0, Number.parseInt(page, 10) - 1) : 0;
}, [searchParams]);
// Handle page change
const handlePageChange = useCallback(
(pageIndex: number) => {
const newParams = new URLSearchParams(searchParams);
if (pageIndex === 0) {
newParams.delete('page');
} else {
newParams.set('page', String(pageIndex + 1));
}
setSearchParams(newParams);
},
[searchParams, setSearchParams],
);
const { filter, pageIndex: currentPage, setFilter, setPage: handlePageChange } = useTableState();
const handleFlowOpen = useCallback(
(flowId: string) => {
navigate(`/flows/${flowId}`);
navigate(mergeHrefWithSearchParams(`/flows/${flowId}`, new URLSearchParams(location.search)));
},
[navigate],
[navigate, location.search],
);
const handleFlowDeleteDialogOpen = useCallback((flow: Flow) => {
@@ -225,25 +185,14 @@ const Flows = () => {
accessorKey: 'id',
cell: ({ row }) => <div className="font-mono text-sm">{row.getValue('id')}</div>,
enableHiding: false,
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
ID
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="ID"
/>
),
maxSize: 80,
meta: { searchable: true },
minSize: 60,
size: 70,
},
@@ -256,68 +205,30 @@ const Flows = () => {
if (isEditing) {
return (
<InputGroup
className="h-8"
onClick={(e) => e.stopPropagation()}
>
<InputGroupInput
<div onClick={(e) => e.stopPropagation()}>
<InlineEditInput
autoFocus
busy={isRenameLoading}
defaultValue={title}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleFlowRenameSave();
return;
}
if (e.key === 'Escape') {
handleFlowRenameCancel();
return;
}
}}
inputRef={editingInputRef}
onCancel={handleFlowRenameCancel}
onSave={handleFlowRenameSave}
placeholder="Flow title"
ref={editingInputRef}
/>
<InputGroupAddon
align="inline-end"
className="gap-0 pr-2"
>
<InputGroupButton
disabled={isRenameLoading}
onClick={() => handleFlowRenameSave()}
>
{isRenameLoading ? <Loader2 className="animate-spin" /> : <Check />}
</InputGroupButton>
<InputGroupButton onClick={() => handleFlowRenameCancel()}>
<X />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
);
}
return <div className="truncate font-medium">{title}</div>;
},
enableHiding: false,
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Title
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Title"
/>
),
meta: { searchable: true },
minSize: 200,
size: 300,
},
@@ -337,30 +248,24 @@ const Flows = () => {
</Badge>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Status
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Status"
/>
),
maxSize: 130,
meta: { searchable: true },
minSize: 80,
size: 100,
},
{
accessorKey: 'provider',
// accessorFn returns the provider name as a plain string so it
// participates in the DataTable global filter (search input).
// The cell renderer still reads the original provider object
// directly through `row.original`, so the icon + label stay
// intact.
accessorFn: (row) => row.provider?.name ?? '',
cell: ({ row }) => {
const flow = row.original;
@@ -374,25 +279,15 @@ const Flows = () => {
</div>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Provider
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Provider"
/>
),
id: 'provider',
maxSize: 150,
meta: { searchable: true },
minSize: 80,
size: 100,
sortingFn: (rowA, rowB) => {
@@ -403,7 +298,11 @@ const Flows = () => {
},
},
{
accessorKey: 'terminals',
// accessorFn joins all terminal images into one string for the
// global search; the cell still derives its presentation from
// the original array on `row.original`, and sortingFn keeps
// ordering by count (more intuitive than alphabetical).
accessorFn: (row) => (row.terminals ?? []).map((t) => t.image).join(' '),
cell: ({ row }) => {
const flow = row.original;
const terminals = flow.terminals || [];
@@ -445,25 +344,15 @@ const Flows = () => {
</Tooltip>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Terminals
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Terminals"
/>
),
id: 'terminals',
maxSize: 220,
meta: { searchable: true },
minSize: 160,
size: 180,
sortingFn: (rowA, rowB) => {
@@ -489,25 +378,14 @@ const Flows = () => {
</Tooltip>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Created
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Created"
/>
),
maxSize: 140,
meta: { columnMenuLabel: 'Created' },
minSize: 100,
size: 120,
sortingFn: (rowA, rowB) => {
@@ -533,25 +411,14 @@ const Flows = () => {
</Tooltip>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Updated
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Updated"
/>
),
maxSize: 140,
meta: { columnMenuLabel: 'Updated' },
minSize: 100,
size: 120,
sortingFn: (rowA, rowB) => {
@@ -601,7 +468,7 @@ const Flows = () => {
View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleFlowRenameStart(flow)}>
<Pencil className="size-3" />
<PencilLine className="size-3" />
Rename
</DropdownMenuItem>
{isRunning && (
@@ -657,7 +524,6 @@ const Flows = () => {
deletingFlowIds,
editingFlowId,
finishingFlowIds,
handleColumnSort,
handleFlowDeleteDialogOpen,
handleFlowFinish,
handleFlowOpen,
@@ -733,30 +599,28 @@ const Flows = () => {
const pageHeader = (
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<div className="flex min-w-0 flex-1 items-center gap-2 px-4">
<SidebarTrigger className="-ml-1 shrink-0" />
<Separator
className="h-4"
className="h-4 shrink-0"
orientation="vertical"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<GitFork className="size-4" />
<BreadcrumbPage>Flows</BreadcrumbPage>
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="min-w-0 flex-nowrap">
<BreadcrumbItem className="min-w-0">
<GitFork className="size-4 shrink-0" />
<BreadcrumbPage className="min-w-0 truncate">Flows</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="ml-auto flex items-center gap-2 px-4">
<Button
<div className="flex shrink-0 items-center gap-2 px-4">
<HeaderButton
icon={<Plus />}
label="New Flow"
onClick={() => navigate('/flows/new')}
size="sm"
variant="secondary"
>
<Plus />
New Flow
</Button>
/>
</div>
</header>
);
@@ -808,8 +672,9 @@ const Flows = () => {
<DataTable<Flow>
columns={columns}
data={flows}
filterColumn="title"
filterPlaceholder="Filter flows..."
filterValue={filter}
onFilterChange={setFilter}
onPageChange={handlePageChange}
onRowClick={handleRowClick}
pageIndex={currentPage}
+6 -6
View File
@@ -48,15 +48,15 @@ const NewFlow = () => {
return (
<>
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<SidebarTrigger className="-ml-1 shrink-0" />
<Separator
className="mr-2 h-4"
className="mr-2 h-4 shrink-0"
orientation="vertical"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>New flow</BreadcrumbPage>
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="min-w-0 flex-nowrap">
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="min-w-0 truncate">New flow</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
+2 -4
View File
@@ -32,7 +32,6 @@ const Knowledge = () => {
});
const knowledge = data?.knowledgeDocument ?? null;
const knowledgeName = knowledge?.question ?? null;
const initialValues = useMemo<FormValues>(
() => (knowledge ? documentToFormValues(knowledge) : newDocumentDefaults),
@@ -70,7 +69,7 @@ const Knowledge = () => {
return (
<KnowledgeLayout
isNew={false}
knowledgeName={knowledgeName}
knowledge={knowledge}
>
<div className="flex flex-1 items-center justify-center">
<Spinner variant="circle" />
@@ -83,7 +82,7 @@ const Knowledge = () => {
return (
<KnowledgeLayout
isNew={false}
knowledgeName={knowledgeName}
knowledge={knowledge}
>
<div className="flex flex-1 items-center justify-center p-4">
<Card className="w-full max-w-2xl">
@@ -106,7 +105,6 @@ const Knowledge = () => {
isNew={isNew}
key={knowledgeId ?? 'new'}
knowledge={knowledge}
knowledgeName={knowledgeName}
onSubmit={handleSubmit}
/>
);
+138 -65
View File
@@ -1,28 +1,33 @@
import type { ColumnDef } from '@tanstack/react-table';
import { ArrowDown, ArrowUp, Ellipsis, LibraryBig, Loader2, Pencil, Plus, Trash } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Ellipsis, LibraryBig, Loader2, Pencil, PencilLine, Plus, Trash } from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import type { BadgeVariant } from '@/components/ui/badge';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { HeaderButton } from '@/components/shared/header-button';
import { InlineEditInput } from '@/components/shared/inline-edit';
import { Badge } from '@/components/ui/badge';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { DataTable } from '@/components/ui/data-table';
import { DataTable, DataTableColumnHeader } from '@/components/ui/data-table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { StatusCard } from '@/components/ui/status-card';
import { KnowledgeDocType } from '@/graphql/types';
import { cycleColumnSort } from '@/lib/table-sort';
import { useTableState } from '@/hooks/use-table-state';
import { mergeHrefWithSearchParams } from '@/lib/url-params';
import { type Knowledge, useKnowledges } from '@/providers/knowledges-provider';
const docTypeBadgeVariant: Record<KnowledgeDocType, BadgeVariant> = {
@@ -49,19 +54,73 @@ const docTypeSubtype = (k: Knowledge): null | string => {
const Knowledges = () => {
const navigate = useNavigate();
const { deleteKnowledge, isLoading, knowledges } = useKnowledges();
const location = useLocation();
const { deleteKnowledge, isLoading, knowledges, updateKnowledge } = useKnowledges();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingKnowledge, setDeletingKnowledge] = useState<Knowledge | null>(null);
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const [editingKnowledgeId, setEditingKnowledgeId] = useState<null | string>(null);
const [isRenameLoading, setIsRenameLoading] = useState(false);
const editingInputRef = useRef<HTMLInputElement>(null);
const handleOpen = (id: string) => {
navigate(`/knowledges/${id}`);
};
const { filter, setFilter } = useTableState();
const handleDeleteDialogOpen = (knowledge: Knowledge) => {
const handleOpen = useCallback(
(id: string) => {
navigate(mergeHrefWithSearchParams(`/knowledges/${id}`, new URLSearchParams(location.search)));
},
[navigate, location.search],
);
const handleDeleteDialogOpen = useCallback((knowledge: Knowledge) => {
setDeletingKnowledge(knowledge);
setIsDeleteDialogOpen(true);
};
}, []);
const handleKnowledgeRenameStart = useCallback((knowledge: Knowledge) => {
setEditingKnowledgeId(knowledge.id);
}, []);
const handleKnowledgeRenameCancel = useCallback(() => {
setEditingKnowledgeId(null);
}, []);
const handleKnowledgeRenameSave = useCallback(async () => {
const newQuestion = editingInputRef.current?.value.trim();
if (!editingKnowledgeId || !newQuestion) {
return;
}
const knowledge = knowledges.find((k) => k.id === editingKnowledgeId);
if (!knowledge) {
return;
}
if (newQuestion === knowledge.question) {
setEditingKnowledgeId(null);
return;
}
setIsRenameLoading(true);
try {
// Backend requires `content` on update (it always re-embeds), so we
// pass it through unchanged from the cached document.
await updateKnowledge(editingKnowledgeId, {
content: knowledge.content,
question: newQuestion,
});
toast.success('Knowledge renamed successfully');
setEditingKnowledgeId(null);
} catch {
// Error already handled in provider with toast
} finally {
setIsRenameLoading(false);
}
}, [editingKnowledgeId, knowledges, updateKnowledge]);
const handleDelete = async () => {
if (!deletingKnowledge) {
@@ -111,33 +170,40 @@ const Knowledges = () => {
</div>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => cycleColumnSort(column)}
variant="link"
>
Type
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Type"
/>
),
maxSize: 180,
meta: { columnMenuLabel: 'Type', searchable: true },
minSize: 110,
size: 130,
},
{
accessorKey: 'question',
cell: ({ row }) => {
const knowledge = row.original;
const isEditing = editingKnowledgeId === knowledge.id;
const question = row.getValue('question') as string;
if (isEditing) {
return (
<div onClick={(e) => e.stopPropagation()}>
<InlineEditInput
autoFocus
busy={isRenameLoading}
defaultValue={question}
inputRef={editingInputRef}
onCancel={handleKnowledgeRenameCancel}
onSave={handleKnowledgeRenameSave}
placeholder="Knowledge question"
/>
</div>
);
}
return (
<div
className="truncate font-medium"
@@ -147,24 +213,13 @@ const Knowledges = () => {
</div>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => cycleColumnSort(column)}
variant="link"
>
Question
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Question"
/>
),
meta: { columnMenuLabel: 'Question', searchable: true },
minSize: 180,
size: 280,
},
@@ -187,6 +242,7 @@ const Knowledges = () => {
<span className="text-muted-foreground inline-flex items-center text-sm font-medium">Preview</span>
),
maxSize: 800,
meta: { columnMenuLabel: 'Preview', searchable: true },
minSize: 160,
size: 380,
},
@@ -214,9 +270,14 @@ const Knowledges = () => {
);
},
enableSorting: false,
header: () => null,
header: () => (
<span className="text-muted-foreground inline-flex items-center justify-end text-sm font-medium">
Flags
</span>
),
id: 'flags',
maxSize: 200,
meta: { columnMenuLabel: 'Flags' },
minSize: 110,
size: 150,
},
@@ -245,6 +306,11 @@ const Knowledges = () => {
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleKnowledgeRenameStart(k)}>
<PencilLine />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={deletingIds.has(k.id)}
onClick={() => handleDeleteDialogOpen(k)}
@@ -282,6 +348,10 @@ const Knowledges = () => {
<Pencil />
Edit
</ContextMenuItem>
<ContextMenuItem onClick={() => handleKnowledgeRenameStart(k)}>
<PencilLine />
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={deletingIds.has(k.id)}
@@ -295,30 +365,28 @@ const Knowledges = () => {
const pageHeader = (
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<div className="flex min-w-0 flex-1 items-center gap-2 px-4">
<SidebarTrigger className="-ml-1 shrink-0" />
<Separator
className="h-4"
className="h-4 shrink-0"
orientation="vertical"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<LibraryBig className="size-4" />
<BreadcrumbPage>Knowledges</BreadcrumbPage>
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="min-w-0 flex-nowrap">
<BreadcrumbItem className="min-w-0">
<LibraryBig className="size-4 shrink-0" />
<BreadcrumbPage className="min-w-0 truncate">Knowledges</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="ml-auto flex items-center gap-2 px-4">
<Button
<div className="flex shrink-0 items-center gap-2 px-4">
<HeaderButton
icon={<Plus />}
label="New Knowledge"
onClick={() => navigate('/knowledges/new')}
size="sm"
variant="secondary"
>
<Plus />
New Knowledge
</Button>
/>
</div>
</header>
);
@@ -369,9 +437,14 @@ const Knowledges = () => {
<DataTable
columns={columns}
data={knowledges}
filterColumn="question"
filterPlaceholder="Filter knowledge documents..."
onRowClick={(k) => handleOpen(k.id)}
filterValue={filter}
onFilterChange={setFilter}
onRowClick={(k) => {
if (editingKnowledgeId !== k.id) {
handleOpen(k.id);
}
}}
renderRowContextMenu={renderRowContextMenu}
/>
+57 -68
View File
@@ -2,7 +2,7 @@ import { ChevronDown, Copy, FileSymlink, Folder, FolderPlus, FolderUp, Loader2,
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner';
import type { OverwriteConflict } from '@/components/shared/overwrite-confirm-dialog';
import type { OverwriteConflict } from '@/components/shared/overwrite';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import {
@@ -23,8 +23,8 @@ import {
formatModifiedAbsolute,
formatModifiedRelative,
} from '@/components/shared/file-manager';
import { OverwriteConfirmDialog } from '@/components/shared/overwrite-confirm-dialog';
import { useOverwriteAction } from '@/components/shared/use-overwrite-action';
import { HeaderButton } from '@/components/shared/header-button';
import { OverwriteDialog, useOverwrite } from '@/components/shared/overwrite';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import {
@@ -36,7 +36,6 @@ import {
} from '@/components/ui/dropdown-menu';
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';
import { FileDropZone } from '@/components/ui/file-drop-zone';
import { Form, FormControl, FormField, FormItem } from '@/components/ui/form';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
@@ -50,9 +49,9 @@ import { useResourcesSearch } from '@/features/resources/use-resources-search';
import { useResourcesUpload } from '@/features/resources/use-resources-upload';
import { useEffectAfterMount } from '@/hooks/use-effect-after-mount';
import { useFilesDragAndDrop } from '@/hooks/use-files-drag-and-drop';
import { usePageStorageKeys } from '@/hooks/use-page-storage-keys';
import { copyToClipboard } from '@/lib/report';
import { getColumnStorageKey } from '@/lib/storage-keys';
import { loadColumnVisibility, saveColumnVisibility } from '@/lib/table-storage';
import { migrateLegacyViewOptions, saveViewOptions } from '@/lib/view-options-storage';
import { useResources } from '@/providers/resources-provider';
/**
@@ -64,8 +63,9 @@ import { useResources } from '@/providers/resources-provider';
* the FileManager default) when `true`, or as an
* absolute, minute-precision timestamp when `false`
*
* All flags persist into the same `column` storage bucket because the schema is
* `Record<string, boolean>` adding more toggles later does not require a new key.
* All flags persist into the page's `viewOptions` storage bucket; the schema
* is `Record<string, boolean>` so adding more toggles later does not require
* a new key.
*/
interface ResourcesViewOptions {
foldersFirst: boolean;
@@ -74,6 +74,8 @@ interface ResourcesViewOptions {
size: boolean;
}
const RESOURCES_PATH = '/resources';
/** Defaults match FileManager's out-of-the-box behaviour (relative dates, folders first, both columns visible). */
const defaultViewOptions: ResourcesViewOptions = {
foldersFirst: true,
@@ -84,8 +86,8 @@ const defaultViewOptions: ResourcesViewOptions = {
type ResourcesViewOptionKey = keyof ResourcesViewOptions;
const loadViewOptions = (storageKey: string): ResourcesViewOptions => {
const stored = loadColumnVisibility(storageKey) ?? {};
const seedViewOptions = (storageKey: string): ResourcesViewOptions => {
const stored = migrateLegacyViewOptions(RESOURCES_PATH, storageKey);
return {
foldersFirst: stored.foldersFirst ?? defaultViewOptions.foldersFirst,
@@ -111,14 +113,14 @@ const Resources = () => {
const [filesToMove, setFilesToMove] = useState<FileNode[] | null>(null);
const [filesToCopy, setFilesToCopy] = useState<FileNode[] | null>(null);
const viewOptionsStorageKey = useMemo(() => getColumnStorageKey('/resources'), []);
const [viewOptions, setViewOptions] = useState<ResourcesViewOptions>(() => loadViewOptions(viewOptionsStorageKey));
const { viewOptions: viewOptionsStorageKey } = usePageStorageKeys();
const [viewOptions, setViewOptions] = useState<ResourcesViewOptions>(() => seedViewOptions(viewOptionsStorageKey));
useEffectAfterMount(() => {
// Cast: `ResourcesViewOptions` is structurally a `Record<string, boolean>`
// but TS doesn't widen object types with declared keys to an index
// signature implicitly.
saveColumnVisibility(viewOptionsStorageKey, viewOptions as unknown as Record<string, boolean>);
saveViewOptions(viewOptionsStorageKey, viewOptions as unknown as Record<string, boolean>);
}, [viewOptions, viewOptionsStorageKey]);
const toggleViewOption = useCallback((option: ResourcesViewOptionKey) => {
@@ -183,7 +185,7 @@ const Resources = () => {
targets: OverwriteConflict[];
}
const dndMoveAction = useOverwriteAction<DndMovePlan>({
const dndMoveAction = useOverwrite<DndMovePlan>({
execute: (plan, force) => move(plan.sources, plan.destination, force),
findConflicts: (plan) => {
const movedPaths = new Set(plan.sources);
@@ -381,40 +383,37 @@ const Resources = () => {
const pageHeader = (
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<div className="flex min-w-0 flex-1 items-center gap-2 px-4">
<SidebarTrigger className="-ml-1 shrink-0" />
<Separator
className="h-4"
className="h-4 shrink-0"
orientation="vertical"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<Folder className="size-4" />
<BreadcrumbPage>Resources</BreadcrumbPage>
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="min-w-0 flex-nowrap">
<BreadcrumbItem className="min-w-0">
<Folder className="size-4 shrink-0" />
<BreadcrumbPage className="min-w-0 truncate">Resources</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="ml-auto flex items-center gap-2 px-4">
<Button
<div className="flex shrink-0 items-center gap-2 px-4">
<HeaderButton
disabled={upload.isUploading}
icon={<FolderPlus />}
label="New folder"
onClick={() => setIsMkdirOpen(true)}
size="sm"
variant="outline"
>
<FolderPlus />
New folder
</Button>
<Button
/>
<HeaderButton
aria-label={upload.isUploading ? 'Uploading...' : 'Upload files'}
disabled={upload.isUploading}
icon={upload.isUploading ? <Loader2 className="animate-spin" /> : <Upload />}
label={upload.isUploading ? 'Uploading...' : 'Upload files'}
onClick={upload.openFilePicker}
size="sm"
variant="secondary"
>
{upload.isUploading ? <Loader2 className="animate-spin" /> : <Upload />}
{upload.isUploading ? 'Uploading...' : 'Upload files'}
</Button>
/>
</div>
</header>
);
@@ -472,39 +471,29 @@ const Resources = () => {
)}
<div className="flex items-center gap-2">
<Form {...search.form}>
<FormField
control={search.form.control}
name="search"
render={({ field }) => (
<FormItem className="max-w-sm flex-1">
<FormControl>
<InputGroup>
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
{...field}
autoComplete="off"
placeholder="Search resources..."
type="text"
/>
{field.value && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={search.resetSearch}
type="button"
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
</FormControl>
</FormItem>
)}
<InputGroup className="max-w-sm flex-1">
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
aria-label="Search resources"
autoComplete="off"
onChange={(event) => search.setQuery(event.target.value)}
placeholder="Search resources..."
type="text"
value={search.rawQuery}
/>
</Form>
{search.rawQuery ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={search.resetSearch}
type="button"
>
<X />
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -585,7 +574,7 @@ const Resources = () => {
onClose={() => setFilesToCopy(null)}
/>
<OverwriteConfirmDialog
<OverwriteDialog
conflicts={dndMoveAction.conflicts}
onCancel={dndMoveAction.resetConflicts}
onReplaceAll={dndMoveAction.handleReplaceAll}
@@ -4,8 +4,6 @@ import { format, isToday } from 'date-fns';
import { enUS } from 'date-fns/locale';
import {
AlertCircle,
ArrowDown,
ArrowUp,
CalendarIcon,
Check,
Copy,
@@ -19,7 +17,6 @@ import {
X,
} from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import type { ApiTokenFragmentFragment } from '@/graphql/types';
@@ -30,7 +27,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { DataTable } from '@/components/ui/data-table';
import { DataTable, DataTableColumnHeader } from '@/components/ui/data-table';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
DropdownMenu,
@@ -54,6 +51,7 @@ import {
useDeleteApiTokenMutation,
useUpdateApiTokenMutation,
} from '@/graphql/types';
import { useTableState } from '@/hooks/use-table-state';
import { cn } from '@/lib/utils';
import { baseUrl } from '@/models/api';
@@ -143,8 +141,8 @@ const copyToClipboard = async (text: string): Promise<boolean> => {
const SettingsAPITokensHeader = ({ onCreateClick }: { onCreateClick: () => void }) => {
return (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-2">
<p className="text-muted-foreground">Manage API tokens for programmatic access</p>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<p className="text-muted-foreground truncate">Manage API tokens for programmatic access</p>
<div className="flex gap-4 text-sm">
<a
className="text-primary inline-flex items-center gap-1 underline hover:no-underline"
@@ -168,6 +166,7 @@ const SettingsAPITokensHeader = ({ onCreateClick }: { onCreateClick: () => void
</div>
<Button
className="shrink-0"
onClick={onCreateClick}
variant="secondary"
>
@@ -191,7 +190,6 @@ const createNewTokenPlaceholder: APIToken = {
};
const SettingsAPITokens = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { data, error, loading: isLoading } = useApiTokensQuery();
const [createAPIToken, { error: createError, loading: isCreateLoading }] = useCreateApiTokenMutation();
const [updateAPIToken, { error: updateError, loading: isUpdateLoading }] = useUpdateApiTokenMutation();
@@ -209,48 +207,7 @@ const SettingsAPITokens = () => {
const editingInputRef = useRef<HTMLInputElement>(null);
const creatingInputRef = useRef<HTMLInputElement>(null);
// Get current page from URL
const currentPage = useMemo(() => {
const page = searchParams.get('page');
return page ? Math.max(0, Number.parseInt(page, 10) - 1) : 0;
}, [searchParams]);
// Handle page change
const handlePageChange = useCallback(
(pageIndex: number) => {
const newParams = new URLSearchParams(searchParams);
if (pageIndex === 0) {
newParams.delete('page');
} else {
newParams.set('page', String(pageIndex + 1));
}
setSearchParams(newParams);
},
[searchParams, setSearchParams],
);
// Three-way sorting handler: null -> asc -> desc -> null
const handleColumnSort = useCallback(
(column: {
clearSorting: () => void;
getIsSorted: () => 'asc' | 'desc' | false;
toggleSorting: (desc?: boolean) => void;
}) => {
const sorted = column.getIsSorted();
if (sorted === 'asc') {
column.toggleSorting(true);
} else if (sorted === 'desc') {
column.clearSorting();
} else {
column.toggleSorting(false);
}
},
[],
);
const { filter, pageIndex: currentPage, setFilter, setPage: handlePageChange } = useTableState();
useApiTokenCreatedSubscription({
onData: ({ client }) => {
@@ -432,24 +389,13 @@ const SettingsAPITokens = () => {
);
},
enableHiding: false,
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Name
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Name"
/>
),
meta: { searchable: true },
size: 300,
},
{
@@ -478,24 +424,13 @@ const SettingsAPITokens = () => {
);
},
enableHiding: false,
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Token ID
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Token ID"
/>
),
meta: { columnMenuLabel: 'Token ID', searchable: true },
size: 200,
},
{
@@ -539,24 +474,13 @@ const SettingsAPITokens = () => {
return <Badge variant={statusDisplay.variant}>{statusDisplay.label}</Badge>;
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Status
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Status"
/>
),
meta: { searchable: true },
size: 120,
},
{
@@ -620,24 +544,12 @@ const SettingsAPITokens = () => {
</Tooltip>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Expires
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Expires"
/>
),
size: 150,
sortingFn: (rowA, rowB) => {
const expiresA = getTokenExpirationDate(rowA.original);
@@ -669,24 +581,13 @@ const SettingsAPITokens = () => {
</Tooltip>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Created
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Created"
/>
),
meta: { columnMenuLabel: 'Created' },
size: 120,
sortingFn: (rowA, rowB) => {
const dateA = new Date(rowA.getValue('createdAt') as string);
@@ -809,7 +710,6 @@ const SettingsAPITokens = () => {
editingTokenId,
handleCancelCreate,
handleCancelEdit,
handleColumnSort,
handleCopyTokenId,
handleCreate,
handleDeleteDialogOpen,
@@ -918,8 +818,9 @@ const SettingsAPITokens = () => {
<DataTable<APIToken>
columns={columns}
data={creatingToken ? [createNewTokenPlaceholder, ...tokens] : tokens}
filterColumn="name"
filterPlaceholder="Filter token names..."
filterPlaceholder="Filter tokens..."
filterValue={filter}
onFilterChange={setFilter}
onPageChange={handlePageChange}
pageIndex={currentPage}
renderRowContextMenu={renderRowContextMenu}
@@ -1,668 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, Play, Plus, Save, Server, Trash2 } from 'lucide-react';
import { Fragment, useMemo, useState } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { z } from 'zod';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { StatusCard } from '@/components/ui/status-card';
import { Switch } from '@/components/ui/switch';
type McpTransport = 'sse' | 'stdio';
const keyValueSchema = z.object({
key: z.string().min(1, 'Key is required'),
value: z.string().min(1, 'Value is required'),
});
const formSchema = z.object({
name: z
.string({ required_error: 'Name is required' })
.min(1, 'Name is required')
.max(50, 'Maximum 50 characters allowed'),
sse: z
.object({
headers: z.array(keyValueSchema).optional().default([]),
url: z.string().min(1, 'URL is required'),
})
.optional(),
stdio: z
.object({
args: z.string().optional().nullable(),
command: z.string().min(1, 'Command is required'),
env: z.array(keyValueSchema).optional().default([]),
})
.optional(),
tools: z
.array(
z.object({
description: z.string().optional(),
enabled: z.boolean().optional().default(true),
name: z.string().min(1, 'Tool name is required'),
}),
)
.default([]),
transport: z.enum(['stdio', 'sse'], { required_error: 'Transport is required' }),
});
type FormData = z.infer<typeof formSchema>;
// Mock helpers
const getMockServerById = (id: number) => {
const samples = [
{
id: 1,
name: 'Local Filesystem',
sse: undefined,
stdio: {
args: '/opt/mcp/filesystem/index.js --root /Users/sirozha/Projects',
command: '/usr/local/bin/node',
env: [{ key: 'NODE_ENV', value: 'production' }],
},
tools: [
{ description: 'Read a file from disk', enabled: true, name: 'readFile' },
{ description: 'Write content to a file', enabled: false, name: 'writeFile' },
],
transport: 'stdio' as McpTransport,
},
{
id: 2,
name: 'Slack (Prod)',
sse: {
headers: [{ key: 'Authorization', value: 'Bearer ***' }],
url: 'https://mcp.example.com/slack/sse',
},
stdio: undefined,
tools: [
{ description: 'Send a message to a channel', enabled: true, name: 'postMessage' },
{ description: 'Fetch Slack user info', enabled: false, name: 'getUserInfo' },
],
transport: 'sse' as McpTransport,
},
];
return samples.find((s) => s.id === id);
};
const SettingsMcpServer = () => {
const navigate = useNavigate();
const params = useParams();
const isNew = params.mcpServerId === undefined;
const [submitError, setSubmitError] = useState<null | string>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isTestLoading, setIsTestLoading] = useState(false);
const [testMessage, setTestMessage] = useState<null | string>(null);
const [testError, setTestError] = useState<null | string>(null);
const [toolTestLoadingIndex, setToolTestLoadingIndex] = useState<null | number>(null);
const [toolTestIndex, setToolTestIndex] = useState<null | number>(null);
const [toolTestMessage, setToolTestMessage] = useState<null | string>(null);
const [toolTestError, setToolTestError] = useState<null | string>(null);
const defaults: FormData = useMemo(() => {
if (!isNew) {
const id = Number(params.mcpServerId);
const found = !Number.isNaN(id) ? getMockServerById(id) : undefined;
if (found) {
return {
name: found.name,
sse: found.sse,
stdio: found.stdio,
tools: found.tools,
transport: found.transport,
} as FormData;
}
}
return {
name: '',
stdio: { args: '', command: '', env: [] },
tools: [],
transport: 'stdio',
} as FormData;
}, [isNew, params.mcpServerId]);
const form = useForm<FormData>({
defaultValues: defaults,
mode: 'onChange',
resolver: zodResolver(formSchema),
});
const transport = form.watch('transport');
// Field arrays
const stdioEnvArray = useFieldArray({ control: form.control, name: 'stdio.env' as const });
const sseHeadersArray = useFieldArray({ control: form.control, name: 'sse.headers' as const });
const toolsArray = useFieldArray({ control: form.control, name: 'tools' as const });
const handleAddKeyValue = (target: 'env' | 'headers') => {
if (target === 'env') {
stdioEnvArray.append({ key: '', value: '' });
} else {
sseHeadersArray.append({ key: '', value: '' });
}
};
const handleSubmit = async (_data: FormData) => {
try {
setSubmitError(null);
// Simulate request
await new Promise((r) => setTimeout(r, 400));
navigate('/settings/mcp-servers');
} catch {
setSubmitError('Failed to save MCP server');
}
};
const handleDelete = () => {
if (isNew) {
return;
}
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
try {
// Simulate delete
await new Promise((r) => setTimeout(r, 300));
navigate('/settings/mcp-servers');
} catch {
setSubmitError('Failed to delete MCP server');
}
};
const handleTest = async () => {
setTestMessage(null);
setTestError(null);
// Validate minimal required fields based on transport
const valid = await form.trigger();
if (!valid) {
setTestError('Please fix validation errors before testing');
return;
}
try {
setIsTestLoading(true);
// Simulate connectivity test
await new Promise((r) => setTimeout(r, 600));
setTestMessage('Connection successful');
} catch {
setTestError('Connection failed');
} finally {
setIsTestLoading(false);
}
};
const handleTestTool = async (index: number) => {
setToolTestIndex(index);
setToolTestLoadingIndex(index);
setToolTestMessage(null);
setToolTestError(null);
try {
// Basic validation: tool must have a name
const toolName = form.getValues(`tools.${index}.name` as const) as string | undefined;
if (!toolName) {
setToolTestError('Tool name is required');
return;
}
// Simulate tool invocation
await new Promise((r) => setTimeout(r, 600));
setToolTestMessage('Tool test passed');
} catch {
setToolTestError('Tool test failed');
} finally {
setToolTestLoadingIndex(null);
}
};
if (!isNew && !getMockServerById(Number(params.mcpServerId))) {
return (
<StatusCard
action={
<Button
onClick={() => navigate('/settings/mcp-servers')}
variant="secondary"
>
Back to list
</Button>
}
description="The requested MCP server could not be located in mock data"
icon={<Server className="text-muted-foreground size-8" />}
title="MCP Server not found"
/>
);
}
return (
<Fragment>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Server className="text-muted-foreground size-5" />
{isNew ? 'New MCP Server' : 'MCP Server Settings'}
</h2>
<div className="text-muted-foreground">
{isNew ? 'Configure a new MCP server' : 'Update MCP server settings'}
</div>
</div>
<Form {...form}>
<form
className="flex flex-col gap-6"
id="mcp-server-form"
onSubmit={form.handleSubmit(handleSubmit)}
>
{(submitError || testMessage || testError) && (
<Alert variant="destructive">
{submitError && (
<>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{submitError}</AlertDescription>
</>
)}
{testError && (
<>
<AlertTitle>Test Failed</AlertTitle>
<AlertDescription>{testError}</AlertDescription>
</>
)}
{testMessage && !submitError && !testError && (
<>
<AlertTitle>Test Passed</AlertTitle>
<AlertDescription>{testMessage}</AlertDescription>
</>
)}
</Alert>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Enter server name"
/>
</FormControl>
<FormDescription>A unique name for this MCP server</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="transport"
render={({ field }) => (
<FormItem>
<FormLabel>Transport</FormLabel>
<Select
defaultValue={field.value}
onValueChange={(v: McpTransport) => {
field.onChange(v);
// Normalize opposite config to avoid stale values
if (v === 'stdio') {
form.setValue('sse', undefined);
if (!form.getValues('stdio')) {
form.setValue('stdio', { args: '', command: '', env: [] });
}
} else {
form.setValue('stdio', undefined);
if (!form.getValues('sse')) {
form.setValue('sse', { headers: [], url: '' });
}
}
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select transport" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="stdio">STDIO</SelectItem>
<SelectItem value="sse">SSE</SelectItem>
</SelectContent>
</Select>
<FormDescription>STDIO for local process; SSE for remote URL</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* STDIO configuration */}
{transport === 'stdio' && (
<div className="flex flex-col gap-4">
<h3 className="text-lg font-medium">STDIO Configuration</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="stdio.command"
render={({ field }) => (
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input
{...field}
placeholder="/usr/local/bin/node"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="stdio.args"
render={({ field }) => (
<FormItem>
<FormLabel>Args</FormLabel>
<FormControl>
<Input
{...field}
placeholder="/path/to/script.js --flag value"
value={field.value ?? ''}
/>
</FormControl>
<FormDescription>Space-separated arguments</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<h4 className="text-sm font-medium">Environment Variables</h4>
<Button
onClick={() => handleAddKeyValue('env')}
size="sm"
type="button"
variant="outline"
>
<Plus className="size-3" /> Add
</Button>
</div>
<div className="flex flex-col gap-2">
{stdioEnvArray.fields.length === 0 && (
<div className="text-muted-foreground text-sm">No variables</div>
)}
{stdioEnvArray.fields.map((field, index) => (
<div
className="grid grid-cols-1 gap-2 md:grid-cols-5"
key={field.id}
>
<Controller
control={form.control}
name={`stdio.env.${index}.key` as const}
render={({ field }) => (
<Input
{...field}
className="md:col-span-2"
placeholder="KEY"
/>
)}
/>
<Controller
control={form.control}
name={`stdio.env.${index}.value` as const}
render={({ field }) => (
<Input
{...field}
className="md:col-span-2"
placeholder="VALUE"
/>
)}
/>
<Button
className="justify-self-start"
onClick={() => stdioEnvArray.remove(index)}
type="button"
variant="ghost"
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
</div>
</div>
)}
{/* SSE configuration */}
{transport === 'sse' && (
<div className="flex flex-col gap-4">
<h3 className="text-lg font-medium">SSE Configuration</h3>
<FormField
control={form.control}
name="sse.url"
render={({ field }) => (
<FormItem>
<FormLabel>URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://mcp.example.com/sse"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<div className="mb-2 flex items-center justify-between">
<h4 className="text-sm font-medium">Headers</h4>
<Button
onClick={() => handleAddKeyValue('headers')}
size="sm"
type="button"
variant="outline"
>
<Plus className="size-3" /> Add
</Button>
</div>
<div className="flex flex-col gap-2">
{sseHeadersArray.fields.length === 0 && (
<div className="text-muted-foreground text-sm">No headers</div>
)}
{sseHeadersArray.fields.map((field, index) => (
<div
className="grid grid-cols-1 gap-2 md:grid-cols-5"
key={field.id}
>
<Controller
control={form.control}
name={`sse.headers.${index}.key` as const}
render={({ field }) => (
<Input
{...field}
className="md:col-span-2"
placeholder="Header"
/>
)}
/>
<Controller
control={form.control}
name={`sse.headers.${index}.value` as const}
render={({ field }) => (
<Input
{...field}
className="md:col-span-2"
placeholder="Value"
/>
)}
/>
<Button
className="justify-self-start"
onClick={() => sseHeadersArray.remove(index)}
type="button"
variant="ghost"
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
</div>
</div>
)}
{/* Tools configuration - only for existing servers; toggles only */}
{!isNew && (
<div className="flex flex-col gap-4">
<div>
<h3 className="text-lg font-medium">Tools</h3>
<p className="text-muted-foreground text-sm">Enable or disable available tools</p>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{toolsArray.fields.length === 0 && (
<div className="text-muted-foreground text-sm">No tools</div>
)}
{toolsArray.fields.map((tool, index) => (
<div
className="flex flex-col gap-2 rounded-md border p-2"
key={tool.id}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 text-sm">
<div className="truncate font-medium">
{form.watch(`tools.${index}.name`) || 'tool'}
</div>
{form.watch(`tools.${index}.description`) && (
<div className="text-muted-foreground">
{form.watch(`tools.${index}.description`) as string}
</div>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Enabled</span>
<Controller
control={form.control}
name={`tools.${index}.enabled` as const}
render={({ field }) => (
<Switch
aria-label={`Toggle ${form.getValues(`tools.${index}.name`) || 'tool'}`}
checked={!!field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<Button
disabled={toolTestLoadingIndex === index}
onClick={() => handleTestTool(index)}
size="sm"
type="button"
variant="outline"
>
{toolTestLoadingIndex === index ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Play className="size-3" />
)}
{toolTestLoadingIndex === index ? 'Testing...' : 'Test'}
</Button>
</div>
</div>
{toolTestIndex === index && (toolTestMessage || toolTestError) && (
<div className="mt-1 text-xs">
{toolTestMessage && (
<span className="text-green-600">{toolTestMessage}</span>
)}
{toolTestError && (
<span className="text-red-600">{toolTestError}</span>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
</form>
</Form>
</div>
{/* Sticky buttons */}
<div className="bg-background sticky -bottom-4 -mx-4 mt-4 -mb-4 flex items-center border-t p-4 shadow-lg">
<div className="flex gap-2">
{!isNew && (
<Button
onClick={handleDelete}
type="button"
variant="destructive"
>
<Trash2 className="size-4" />
Delete
</Button>
)}
<Button
disabled={isTestLoading}
onClick={handleTest}
type="button"
variant="outline"
>
{isTestLoading ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
{isTestLoading ? 'Testing...' : 'Test'}
</Button>
</div>
<div className="ml-auto flex gap-2">
<Button
onClick={() => navigate('/settings/mcp-servers')}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={form.formState.isSubmitting}
form="mcp-server-form"
type="submit"
variant="secondary"
>
{form.formState.isSubmitting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
{form.formState.isSubmitting ? 'Saving...' : isNew ? 'Create MCP Server' : 'Update MCP Server'}
</Button>
</div>
</div>
<ConfirmationDialog
cancelText="Cancel"
confirmText="Delete"
handleConfirm={handleConfirmDelete}
handleOpenChange={setIsDeleteDialogOpen}
isOpen={isDeleteDialogOpen}
itemName={form.watch('name')}
itemType="MCP server"
/>
</Fragment>
);
};
export default SettingsMcpServer;
@@ -1,670 +0,0 @@
import type { ColumnDef } from '@tanstack/react-table';
import { format, isToday } from 'date-fns';
import { enUS } from 'date-fns/locale';
import { AlertCircle, ArrowDown, ArrowUp, Copy, Ellipsis, Loader2, Pencil, Plus, Server, Trash } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { DataTable } from '@/components/ui/data-table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { StatusCard } from '@/components/ui/status-card';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
interface McpServerConfigSse {
headers?: Record<string, string>;
url: string;
}
interface McpServerConfigStdio {
args?: string[];
command: string;
env?: Record<string, string>;
}
interface McpServerItem {
config: {
sse?: McpServerConfigSse | null;
stdio?: McpServerConfigStdio | null;
};
createdAt: string; // ISO
id: number;
name: string;
tools: McpTool[];
transport: McpTransport;
updatedAt: string; // ISO
}
interface McpTool {
description?: string;
enabled?: boolean;
name: string;
}
type McpTransport = 'sse' | 'stdio';
const SettingsMcpServersHeader = () => {
const navigate = useNavigate();
const handleCreate = () => {
navigate('/settings/mcp-servers/new');
};
return (
<div className="flex items-center justify-between">
<p className="text-muted-foreground">Manage MCP servers available to the assistant</p>
<Button
onClick={handleCreate}
variant="secondary"
>
Create MCP Server
<Plus className="size-4" />
</Button>
</div>
);
};
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
if (isToday(date)) {
return format(date, 'HH:mm:ss', { locale: enUS });
}
return format(date, 'd MMM yyyy', { locale: enUS });
};
const formatFullDateTime = (dateString: string) => {
const date = new Date(dateString);
return format(date, 'd MMM yyyy, HH:mm:ss', { locale: enUS });
};
const SettingsMcpServers = () => {
const navigate = useNavigate();
// Mocked data stored locally. This can be replaced by a real query later.
const initialData: McpServerItem[] = useMemo(
() => [
{
config: {
sse: null,
stdio: {
args: ['/opt/mcp/filesystem/index.js', '--root', '/Users/sirozha/Projects'],
command: '/usr/local/bin/node',
env: { NODE_ENV: 'production' },
},
},
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5).toISOString(),
id: 1,
name: 'Local Filesystem',
tools: [
{ description: 'Read a file from disk', enabled: true, name: 'readFile' },
{ description: 'Write content to a file', enabled: false, name: 'writeFile' },
{ description: 'List files in a directory', enabled: true, name: 'listDirectory' },
],
transport: 'stdio',
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 1).toISOString(),
},
{
config: {
sse: {
headers: { Authorization: 'Bearer ***' },
url: 'https://mcp.example.com/slack/sse',
},
stdio: null,
},
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 20).toISOString(),
id: 2,
name: 'Slack (Prod)',
tools: [
{ description: 'Send a message to a channel', enabled: true, name: 'postMessage' },
{ description: 'Get a list of channels', enabled: true, name: 'listChannels' },
{ description: 'Fetch Slack user info', enabled: false, name: 'getUserInfo' },
],
transport: 'sse',
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(),
},
{
config: {
sse: {
headers: { Authorization: 'Bearer ***' },
url: 'https://mcp.example.com/github/sse',
},
stdio: null,
},
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(),
id: 3,
name: 'GitHub Issues',
tools: [
{ description: 'Create a new issue', enabled: true, name: 'createIssue' },
{ description: 'Search issues by query', enabled: true, name: 'searchIssues' },
{ description: 'Add a comment to an issue', enabled: true, name: 'addComment' },
],
transport: 'sse',
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
},
],
[],
);
const [servers, setServers] = useState<McpServerItem[]>(initialData);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingServer, setDeletingServer] = useState<McpServerItem | null>(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [deleteErrorMessage, setDeleteErrorMessage] = useState<null | string>(null);
// Three-way sorting handler: null -> asc -> desc -> null
const handleColumnSort = (column: {
clearSorting: () => void;
getIsSorted: () => 'asc' | 'desc' | false;
toggleSorting: (desc?: boolean) => void;
}) => {
const sorted = column.getIsSorted();
if (sorted === 'asc') {
column.toggleSorting(true);
} else if (sorted === 'desc') {
column.clearSorting();
} else {
column.toggleSorting(false);
}
};
const handleEdit = (serverId: number) => {
navigate(`/settings/mcp-servers/${serverId}`);
};
const handleClone = (serverId: number) => {
setServers((prev) => {
const source = prev.find((s) => s.id === serverId);
if (!source) {
return prev;
}
const nextId = (prev.reduce((max, s) => Math.max(max, s.id), 0) || 0) + 1;
const nowIso = new Date().toISOString();
const clone: McpServerItem = {
...source,
config: JSON.parse(JSON.stringify(source.config)),
createdAt: nowIso,
id: nextId,
name: `${source.name} (Copy)`,
tools: JSON.parse(JSON.stringify(source.tools || [])),
updatedAt: nowIso,
};
return [clone, ...prev];
});
};
const handleOpenDeleteDialog = (server: McpServerItem) => {
setDeletingServer(server);
setIsDeleteDialogOpen(true);
};
const handleDelete = async (serverId?: number) => {
if (!serverId) {
return;
}
try {
setIsDeleteLoading(true);
setDeleteErrorMessage(null);
// Simulate async delete
await new Promise((r) => setTimeout(r, 400));
setServers((prev) => prev.filter((s) => s.id !== serverId));
setDeletingServer(null);
setIsDeleteDialogOpen(false);
} catch {
setDeleteErrorMessage('Failed to delete MCP server');
} finally {
setIsDeleteLoading(false);
}
};
const columns: ColumnDef<McpServerItem>[] = [
{
accessorKey: 'name',
cell: ({ row }) => (
<div className="flex items-center gap-2 font-medium">{row.getValue('name') as string}</div>
),
enableHiding: false,
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Name
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
size: 300,
},
{
accessorKey: 'transport',
cell: ({ row }) => {
const t = row.getValue('transport') as McpTransport;
return <Badge variant="outline">{t.toUpperCase()}</Badge>;
},
header: 'Transport',
size: 120,
},
{
cell: ({ row }) => {
const s = row.original as McpServerItem;
const total = (s.tools || []).length;
if (total === 0) {
return <span className="text-muted-foreground text-sm"></span>;
}
const enabled = (s.tools || []).filter((t) => t.enabled !== false);
const first = enabled.slice(0, 3);
const rest = enabled.length - first.length;
const disabledCount = total - enabled.length;
return (
<div className="flex w-full flex-wrap items-center gap-1 overflow-hidden">
{first.map((t) => (
<Badge
className="text-[10px]"
key={t.name}
variant="secondary"
>
{t.name}
</Badge>
))}
{rest > 0 && (
<Badge
className="text-[10px]"
variant="outline"
>
+{rest}
</Badge>
)}
{disabledCount > 0 && (
<Badge
className="ml-1 text-[10px]"
variant="outline"
>
{disabledCount} disabled
</Badge>
)}
</div>
);
},
header: 'Tools',
id: 'tools',
size: 220,
},
{
cell: ({ row }) => {
const s = row.original as McpServerItem;
if (s.transport === 'sse' && s.config.sse) {
return <span className="text-muted-foreground text-sm break-all">{s.config.sse.url}</span>;
}
if (s.transport === 'stdio' && s.config.stdio) {
const args = s.config.stdio.args?.join(' ') || '';
return (
<span className="text-muted-foreground text-sm break-all">
{s.config.stdio.command} {args}
</span>
);
}
return <span className="text-muted-foreground text-sm"></span>;
},
header: 'Endpoint',
id: 'endpoint',
size: 320,
},
{
accessorKey: 'createdAt',
cell: ({ row }) => {
const dateString = row.getValue('createdAt') as string;
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-default text-sm">{formatDateTime(dateString)}</div>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs">{formatFullDateTime(dateString)}</div>
</TooltipContent>
</Tooltip>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Created
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
size: 120,
sortingFn: (rowA, rowB) => {
const dateA = new Date(rowA.getValue('createdAt') as string);
const dateB = new Date(rowB.getValue('createdAt') as string);
return dateA.getTime() - dateB.getTime();
},
},
{
accessorKey: 'updatedAt',
cell: ({ row }) => {
const dateString = row.getValue('updatedAt') as string;
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-default text-sm">{formatDateTime(dateString)}</div>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs">{formatFullDateTime(dateString)}</div>
</TooltipContent>
</Tooltip>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Updated
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
size: 120,
sortingFn: (rowA, rowB) => {
const dateA = new Date(rowA.getValue('updatedAt') as string);
const dateB = new Date(rowB.getValue('updatedAt') as string);
return dateA.getTime() - dateB.getTime();
},
},
{
cell: ({ row }) => {
const server = row.original as McpServerItem;
return (
<div className="flex justify-end opacity-0 transition-opacity group-hover:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="size-8 p-0"
onClick={(e) => e.stopPropagation()}
variant="ghost"
>
<span className="sr-only">Open menu</span>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-24"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem onClick={() => handleEdit(server.id)}>
<Pencil className="size-3" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleClone(server.id)}>
<Copy className="size-3" />
Clone
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isDeleteLoading && deletingServer?.id === server.id}
onClick={() => handleOpenDeleteDialog(server)}
>
{isDeleteLoading && deletingServer?.id === server.id ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash className="size-4" />
Delete
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
enableHiding: false,
header: () => null,
id: 'actions',
meta: { preventRowClick: true },
size: 48,
},
];
const renderSubComponent = ({ row }: { row: any }) => {
const server = row.original as McpServerItem;
const renderKeyValue = (obj?: Record<string, string>) => {
if (!obj || Object.keys(obj).length === 0) {
return <div className="text-muted-foreground text-sm">No data</div>;
}
return (
<div className="flex flex-col gap-1 text-sm">
{Object.entries(obj)
.filter(([_, v]) => !!v)
.map(([k, v]) => (
<div key={k}>
<span className="text-muted-foreground">{k}:</span> {v}
</div>
))}
</div>
);
};
return (
<div className="bg-muted/20 flex flex-col gap-4 border-t p-4">
<h4 className="font-medium">Configuration</h4>
<hr className="border-muted-foreground/20" />
{server.transport === 'stdio' && server.config.stdio && (
<div className="flex flex-col gap-2">
<div className="text-sm font-medium">STDIO</div>
<div className="flex flex-col gap-1 text-sm">
<div>
<span className="text-muted-foreground">Command:</span> {server.config.stdio.command}
</div>
{!!server.config.stdio.args?.length && (
<div>
<span className="text-muted-foreground">Args:</span>{' '}
{server.config.stdio.args.join(' ')}
</div>
)}
</div>
<div>
<div className="text-sm font-medium">Env</div>
{renderKeyValue(server.config.stdio.env)}
</div>
</div>
)}
{server.transport === 'sse' && server.config.sse && (
<div className="flex flex-col gap-2">
<div className="text-sm font-medium">SSE</div>
<div className="flex flex-col gap-1 text-sm">
<div>
<span className="text-muted-foreground">URL:</span> {server.config.sse.url}
</div>
</div>
<div>
<div className="text-sm font-medium">Headers</div>
{renderKeyValue(server.config.sse.headers)}
</div>
</div>
)}
<div className="flex flex-col gap-2">
<div className="text-sm font-medium">Tools</div>
{server.tools?.length ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{server.tools.map((t, idx) => (
<div
className="flex items-start justify-between gap-4 rounded-md border p-2"
key={`${t.name}-${idx}`}
>
<div className="text-sm">
<div className="font-medium">{t.name}</div>
{t.description && <div className="text-muted-foreground">{t.description}</div>}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Enabled</span>
<Switch
aria-label={`Toggle ${t.name}`}
checked={t.enabled !== false}
onCheckedChange={(checked) => {
setServers((prev) =>
prev.map((s) =>
s.id === server.id
? {
...s,
tools: s.tools.map((orig, i) =>
i === idx ? { ...orig, enabled: checked } : orig,
),
}
: s,
),
);
}}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground text-sm">No tools available</div>
)}
</div>
</div>
);
};
const renderRowContextMenu = (server: McpServerItem) => (
<>
<ContextMenuItem onClick={() => handleEdit(server.id)}>
<Pencil />
Edit
</ContextMenuItem>
<ContextMenuItem onClick={() => handleClone(server.id)}>
<Copy />
Clone
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={isDeleteLoading && deletingServer?.id === server.id}
onClick={() => handleOpenDeleteDialog(server)}
>
<Trash />
{isDeleteLoading && deletingServer?.id === server.id ? 'Deleting...' : 'Delete'}
</ContextMenuItem>
</>
);
if (servers.length === 0) {
return (
<div className="flex flex-col gap-4">
<SettingsMcpServersHeader />
<StatusCard
action={
<Button
onClick={() => navigate('/settings/mcp-servers/new')}
variant="secondary"
>
<Plus className="size-4" />
Add MCP Server
</Button>
}
description="Get started by adding your first MCP server"
icon={<Server className="text-muted-foreground size-8" />}
title="No MCP servers configured"
/>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<SettingsMcpServersHeader />
{deleteErrorMessage && (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Error deleting MCP server</AlertTitle>
<AlertDescription>{deleteErrorMessage}</AlertDescription>
</Alert>
)}
<DataTable<McpServerItem>
columns={columns}
data={servers}
renderRowContextMenu={renderRowContextMenu}
renderSubComponent={renderSubComponent}
/>
<ConfirmationDialog
cancelText="Cancel"
confirmText="Delete"
handleConfirm={() => handleDelete(deletingServer?.id)}
handleOpenChange={setIsDeleteDialogOpen}
isOpen={isDeleteDialogOpen}
itemName={deletingServer?.name}
itemType="MCP server"
/>
</div>
);
};
export default SettingsMcpServers;
@@ -35,6 +35,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { StatusCard } from '@/components/ui/status-card';
import { useDeletePromptMutation, useSettingsPromptsQuery } from '@/graphql/types';
import { usePageStorageKeys } from '@/hooks/use-page-storage-keys';
// Types for table data
type AgentPromptTableData = {
displayName: string; // Formatted display name
@@ -69,6 +70,10 @@ const SettingsPrompts = () => {
const { data, error, loading: isLoading } = useSettingsPromptsQuery();
const [deletePrompt, { loading: isDeleteLoading }] = useDeletePromptMutation();
const navigate = useNavigate();
// Shared base key for the route; each DataTable appends its own suffix so
// sorting / column visibility / search-column narrowing live in distinct
// slots even though the page mounts two tables.
const { table: tableStorageBase } = usePageStorageKeys();
// Reset dialog states
const [resetDialogOpen, setResetDialogOpen] = useState(false);
@@ -357,6 +362,7 @@ const SettingsPrompts = () => {
</Button>
);
},
meta: { columnMenuLabel: 'Agent Name', searchable: true },
size: 200,
},
{
@@ -371,6 +377,7 @@ const SettingsPrompts = () => {
);
},
header: 'System Prompt',
meta: { columnMenuLabel: 'System Prompt', searchable: true },
size: 100,
},
{
@@ -385,6 +392,7 @@ const SettingsPrompts = () => {
);
},
header: 'Human Prompt',
meta: { columnMenuLabel: 'Human Prompt', searchable: true },
size: 100,
},
{
@@ -527,6 +535,7 @@ const SettingsPrompts = () => {
</Button>
);
},
meta: { columnMenuLabel: 'Tool Name', searchable: true },
size: 300,
},
{
@@ -541,6 +550,7 @@ const SettingsPrompts = () => {
);
},
header: 'Prompt',
meta: { columnMenuLabel: 'Prompt', searchable: true },
size: 100,
},
{
@@ -851,11 +861,11 @@ const SettingsPrompts = () => {
<DataTable<AgentPromptTableData>
columns={agentColumns}
data={agentPrompts}
filterColumn="displayName"
filterPlaceholder="Filter agent names..."
filterPlaceholder="Filter agents..."
initialPageSize={1000}
renderRowContextMenu={renderAgentRowContextMenu}
renderSubComponent={renderAgentSubComponent}
storageKey={`${tableStorageBase}:agents`}
/>
</div>
)}
@@ -872,11 +882,11 @@ const SettingsPrompts = () => {
<DataTable<ToolPromptTableData>
columns={toolColumns}
data={toolPrompts}
filterColumn="displayName"
filterPlaceholder="Filter tool names..."
filterPlaceholder="Filter tools..."
initialPageSize={1000}
renderRowContextMenu={renderToolRowContextMenu}
renderSubComponent={renderToolSubComponent}
storageKey={`${tableStorageBase}:tools`}
/>
</div>
)}
@@ -2,21 +2,9 @@ import type { ColumnDef } from '@tanstack/react-table';
import { format, isToday } from 'date-fns';
import { enUS } from 'date-fns/locale';
import {
AlertCircle,
ArrowDown,
ArrowUp,
ChevronDown,
Copy,
Ellipsis,
Loader2,
Pencil,
Plus,
Settings,
Trash,
} from 'lucide-react';
import { AlertCircle, ChevronDown, Copy, Ellipsis, Loader2, Pencil, Plus, Settings, Trash } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import type { ProviderConfigFragmentFragment } from '@/graphql/types';
@@ -35,7 +23,7 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { DataTable } from '@/components/ui/data-table';
import { DataTable, DataTableColumnHeader } from '@/components/ui/data-table';
import {
DropdownMenu,
DropdownMenuContent,
@@ -46,6 +34,7 @@ import {
import { StatusCard } from '@/components/ui/status-card';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ProviderType, useDeleteProviderMutation, useSettingsProvidersQuery } from '@/graphql/types';
import { useTableState } from '@/hooks/use-table-state';
type Provider = ProviderConfigFragmentFragment;
const providerIcons: Record<ProviderType, React.ComponentType<any>> = {
@@ -99,11 +88,14 @@ const SettingsProvidersHeader = () => {
return (
<div className="flex items-center justify-between gap-4">
<p className="text-muted-foreground">Manage language model providers</p>
<p className="text-muted-foreground min-w-0 flex-1 truncate">Manage language model providers</p>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary">
<Button
className="shrink-0"
variant="secondary"
>
Create Provider
<ChevronDown className="size-4" />
</Button>
@@ -134,7 +126,6 @@ const SettingsProvidersHeader = () => {
};
const SettingsProviders = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { data, error, loading: isLoading } = useSettingsProvidersQuery();
const [deleteProvider, { error: deleteError, loading: isDeleteLoading }] = useDeleteProviderMutation();
const [deleteErrorMessage, setDeleteErrorMessage] = useState<null | string>(null);
@@ -142,48 +133,7 @@ const SettingsProviders = () => {
const [deletingProvider, setDeletingProvider] = useState<null | Provider>(null);
const navigate = useNavigate();
// Get current page from URL
const currentPage = useMemo(() => {
const page = searchParams.get('page');
return page ? Math.max(0, Number.parseInt(page, 10) - 1) : 0;
}, [searchParams]);
// Handle page change
const handlePageChange = useCallback(
(pageIndex: number) => {
const newParams = new URLSearchParams(searchParams);
if (pageIndex === 0) {
newParams.delete('page');
} else {
newParams.set('page', String(pageIndex + 1));
}
setSearchParams(newParams);
},
[searchParams, setSearchParams],
);
// Three-way sorting handler: null -> asc -> desc -> null
const handleColumnSort = useCallback(
(column: {
clearSorting: () => void;
getIsSorted: () => 'asc' | 'desc' | false;
toggleSorting: (desc?: boolean) => void;
}) => {
const sorted = column.getIsSorted();
if (sorted === 'asc') {
column.toggleSorting(true);
} else if (sorted === 'desc') {
column.clearSorting();
} else {
column.toggleSorting(false);
}
},
[],
);
const { filter, pageIndex: currentPage, setFilter, setPage: handlePageChange } = useTableState();
const handleProviderDelete = useCallback(
async (providerId: string | undefined) => {
@@ -233,24 +183,13 @@ const SettingsProviders = () => {
accessorKey: 'name',
cell: ({ row }) => <div className="font-medium">{row.getValue('name')}</div>,
enableHiding: false,
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Name
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Name"
/>
),
meta: { searchable: true },
size: 400,
},
{
@@ -266,24 +205,13 @@ const SettingsProviders = () => {
</Badge>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Type
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Type"
/>
),
meta: { searchable: true },
size: 160,
},
{
@@ -302,24 +230,13 @@ const SettingsProviders = () => {
</Tooltip>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Created
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Created"
/>
),
meta: { columnMenuLabel: 'Created' },
size: 120,
sortingFn: (rowA, rowB) => {
const dateA = new Date(rowA.getValue('createdAt') as string);
@@ -344,24 +261,12 @@ const SettingsProviders = () => {
</Tooltip>
);
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => handleColumnSort(column)}
variant="link"
>
Updated
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Updated"
/>
),
size: 120,
sortingFn: (rowA, rowB) => {
const dateA = new Date(rowA.getValue('updatedAt') as string);
@@ -427,14 +332,7 @@ const SettingsProviders = () => {
size: 48,
},
],
[
handleColumnSort,
handleProviderClone,
handleProviderDeleteDialogOpen,
handleProviderEdit,
isDeleteLoading,
deletingProvider,
],
[handleProviderClone, handleProviderDeleteDialogOpen, handleProviderEdit, isDeleteLoading, deletingProvider],
);
const renderSubComponent = ({ row }: { row: any }) => {
@@ -601,8 +499,9 @@ const SettingsProviders = () => {
<DataTable<Provider>
columns={columns}
data={providers}
filterColumn="name"
filterPlaceholder="Filter provider names..."
filterPlaceholder="Filter providers..."
filterValue={filter}
onFilterChange={setFilter}
onPageChange={handlePageChange}
pageIndex={currentPage}
renderRowContextMenu={renderRowContextMenu}
+286 -34
View File
@@ -1,26 +1,56 @@
import type { ReactNode } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ChevronDown, FileSymlink, PanelRightClose, PanelRightOpen, Save } from 'lucide-react';
import {
ChevronDown,
Ellipsis,
FileSymlink,
FileText,
Loader2,
PanelRightClose,
PanelRightOpen,
Pencil,
Save,
Trash,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import { z } from 'zod';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import {
DetailNavigationButtons,
DetailNavigationSheet,
DetailNavigationToolbar,
} from '@/components/shared/detail-navigation';
import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit';
import { Badge } from '@/components/ui/badge';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Form, FormControl, FormField, FormItem } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextareaAutosize } from '@/components/ui/input-group';
import { Separator } from '@/components/ui/separator';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Spinner } from '@/components/ui/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useTemplateDetailNavigation } from '@/features/templates/use-template-detail-navigation';
import { useFlowTemplateQuery } from '@/graphql/types';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { cn } from '@/lib/utils';
import { useTemplates } from '@/providers/templates-provider';
import { type Template, useTemplates } from '@/providers/templates-provider';
const formSchema = z.object({
text: z.string().trim().min(1, { message: 'Text is required' }),
@@ -206,18 +236,39 @@ Action plan:
},
];
const renderTemplateItem = (item: Template, isCurrent: boolean): ReactNode => (
<span className={isCurrent ? 'truncate font-medium' : 'truncate'}>{item.title}</span>
);
const Template = () => {
const navigate = useNavigate();
const { templateId } = useParams<{ templateId?: string }>();
const { createTemplate, updateTemplate } = useTemplates();
const { createTemplate, deleteTemplate, updateTemplate } = useTemplates();
const { isMobile } = useBreakpoint();
const isNew = templateId === 'new';
// Pass `null` while creating a new template — there is no "current item"
// to highlight, and the toolbar shouldn't render at all anyway (gated
// below by `canShowActions`).
const templateNav = useTemplateDetailNavigation(isNew ? null : templateId);
const [isAsideOpen, setIsAsideOpen] = useState(false);
const [expandedPresetIndex, setExpandedPresetIndex] = useState<null | number>(null);
const [isReplaceConfirmOpen, setIsReplaceConfirmOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [pendingPreset, setPendingPreset] = useState<null | { text: string; title: string }>(null);
const [isRenaming, setIsRenaming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const {
handleDropdownCloseAutoFocus,
inputRef: editingInputRef,
isEditing: isEditingTitle,
startEdit: handleTemplateRenameStart,
stopEdit: handleTemplateRenameCancel,
} = useInlineEdit({ resetKey: templateId });
// Fetch template data when editing
const { data: templateData, loading: isLoadingTemplate } = useFlowTemplateQuery({
@@ -247,6 +298,52 @@ const Template = () => {
const hasUnsavedChanges = formState.isDirty;
const templateName = templateData?.flowTemplate?.title ?? null;
const handleTemplateRenameSave = useCallback(async () => {
const newTitle = editingInputRef.current?.value.trim();
const template = templateData?.flowTemplate;
if (!templateId || !newTitle || !template) {
return;
}
if (newTitle === template.title) {
handleTemplateRenameCancel();
return;
}
setIsRenaming(true);
try {
// Preserve the original `text` from the server so that an inline
// rename never overwrites unsaved edits in the form below.
await updateTemplate(templateId, { text: template.text, title: newTitle });
toast.success('Template renamed successfully');
handleTemplateRenameCancel();
} catch {
// Error already handled in provider with toast
} finally {
setIsRenaming(false);
}
}, [editingInputRef, handleTemplateRenameCancel, templateId, templateData?.flowTemplate, updateTemplate]);
const handleTemplateDelete = useCallback(async () => {
if (!templateId) {
return;
}
setIsDeleting(true);
try {
await deleteTemplate(templateId);
navigate('/templates', { replace: true });
} catch {
// Error already handled in provider with toast
} finally {
setIsDeleting(false);
}
}, [templateId, deleteTemplate, navigate]);
const handleSubmit = async (values: FormValues) => {
if (isSaving) {
return;
@@ -304,43 +401,151 @@ const Template = () => {
}
}, [pendingPreset, setValue]);
const canShowActions = !isNew && !!templateData?.flowTemplate;
const pageHeader = (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator
className="mr-2 h-4"
orientation="vertical"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>{isNew ? 'New template' : (templateName ?? 'Template')}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<Button
className="ml-auto"
onClick={() => setIsAsideOpen((open) => !open)}
size="icon"
variant="ghost"
>
{isAsideOpen ? <PanelRightClose /> : <PanelRightOpen />}
</Button>
</header>
<>
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
<div className="flex min-w-0 flex-1 items-center gap-2">
<SidebarTrigger className="-ml-1 shrink-0" />
<Separator
className="mr-2 h-4 shrink-0"
orientation="vertical"
/>
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="min-w-0 flex-nowrap">
<BreadcrumbItem className="min-w-0 gap-2">
{isEditingTitle && canShowActions ? (
<InlineEditInput
busy={isRenaming}
className="w-64 max-w-full min-w-0 flex-1"
defaultValue={templateName ?? ''}
inputRef={editingInputRef}
onCancel={handleTemplateRenameCancel}
onSave={handleTemplateRenameSave}
placeholder="Template title"
/>
) : canShowActions ? (
<Tooltip>
<TooltipTrigger asChild>
<BreadcrumbPage
className="min-w-0 cursor-text truncate select-none"
onDoubleClick={handleTemplateRenameStart}
>
{templateName ?? 'Template'}
</BreadcrumbPage>
</TooltipTrigger>
<TooltipContent>Double-click to rename</TooltipContent>
</Tooltip>
) : (
<BreadcrumbPage className="min-w-0 truncate">
{isNew ? 'New template' : (templateName ?? 'Template')}
</BreadcrumbPage>
)}
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="flex shrink-0 items-center gap-2">
{canShowActions && !isMobile && (
<DetailNavigationToolbar<Template>
controller={templateNav}
renderItem={renderTemplateItem}
sheetIcon={<FileText className="size-4" />}
sheetTitle="Templates"
/>
)}
<Button
onClick={() => setIsAsideOpen((open) => !open)}
size="icon"
variant="ghost"
>
{isAsideOpen ? <PanelRightClose /> : <PanelRightOpen />}
</Button>
{canShowActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Template actions"
className="size-8 p-0"
variant="ghost"
>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-24"
onCloseAutoFocus={handleDropdownCloseAutoFocus}
>
{isMobile && templateNav.total > 0 && (
<>
<DropdownMenuItem
className="cursor-default hover:bg-transparent focus:bg-transparent"
onSelect={(event) => event.preventDefault()}
>
<FileText className="size-4" />
Templates
<div className="-my-1.5 -mr-2 ml-auto flex items-center">
<DetailNavigationButtons<Template>
controller={templateNav}
sheetTitle="Templates"
size="sm"
/>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleTemplateRenameStart}>
<Pencil className="size-3" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isDeleting}
onClick={() => setIsDeleteDialogOpen(true)}
>
{isDeleting ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash className="size-4" />
Delete
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</header>
{isMobile && canShowActions && (
<DetailNavigationSheet<Template>
controller={templateNav}
renderItem={renderTemplateItem}
sheetIcon={<FileText className="size-4" />}
sheetTitle="Templates"
/>
)}
</>
);
const asideContent = useMemo(
() => (
<div className="flex h-full max-h-[calc(100dvh-3rem)] flex-col overflow-y-auto p-4">
<h3 className="text-muted-foreground mb-2 text-sm font-medium">Preset templates</h3>
<div className="flex w-full min-w-0 flex-col gap-2 p-2">
{PRESET_TEMPLATES.map((preset, index) => (
<Collapsible
className="w-full min-w-0"
key={index}
onOpenChange={(open) => setExpandedPresetIndex(open ? index : null)}
open={expandedPresetIndex === index}
>
<Card>
<div className="flex">
<Card className="w-full min-w-0">
<div className="flex w-full min-w-0">
<Button
className={cn(
'h-auto min-w-0 flex-1 justify-start rounded-none rounded-tl-[0.6875rem] px-3 py-2 text-left text-start',
@@ -349,7 +554,7 @@ const Template = () => {
onClick={() => handleApplyPreset(preset)}
variant="ghost"
>
<span className={cn(expandedPresetIndex !== index && 'truncate')}>
<span className={cn('min-w-0', expandedPresetIndex !== index && 'truncate')}>
{preset.title}
</span>
</Button>
@@ -393,10 +598,32 @@ const Template = () => {
open={isAsideOpen}
>
<SheetContent
className="w-full max-w-[min(20rem,100vw)]"
// The Sheet body is just a list of presets with no
// descriptive sub-text — opt out of the Radix
// Description warning explicitly.
aria-describedby={undefined}
className="flex w-full max-w-sm flex-col gap-0 p-0 sm:max-w-sm"
side="right"
>
{asideContent}
<SheetHeader className="border-b p-4">
<SheetTitle className="flex items-center gap-2 pr-8 text-base">
<FileText className="size-4" />
<span>Preset templates</span>
<Badge
className="ml-auto font-normal tabular-nums"
variant="secondary"
>
{PRESET_TEMPLATES.length}
</Badge>
</SheetTitle>
</SheetHeader>
{/* Plain overflow-y-auto instead of Radix ScrollArea —
ScrollArea's Viewport wraps children in a
`display: table` div whose width grows to fit
intrinsic content, defeating `w-full min-w-0` on
inner flex rows and pushing the chevron buttons
off-screen. */}
<div className="min-w-0 flex-1 overflow-y-auto">{asideContent}</div>
</SheetContent>
</Sheet>
) : (
@@ -406,7 +633,23 @@ const Template = () => {
isAsideOpen ? 'w-80 border-l sm:w-96' : 'w-0',
)}
>
{isAsideOpen ? <div className="h-full w-80 sm:w-96">{asideContent}</div> : null}
{isAsideOpen ? (
<div className="flex h-full w-80 min-w-0 flex-col sm:w-96">
<div className="border-b p-4">
<h3 className="flex items-center gap-2 text-base font-semibold">
<FileText className="size-4" />
<span>Preset templates</span>
<Badge
className="ml-auto font-normal tabular-nums"
variant="secondary"
>
{PRESET_TEMPLATES.length}
</Badge>
</h3>
</div>
<div className="min-w-0 flex-1 overflow-y-auto">{asideContent}</div>
</div>
) : null}
</aside>
),
[isMobile, isAsideOpen, asideContent],
@@ -544,6 +787,15 @@ const Template = () => {
isOpen={isReplaceConfirmOpen}
title="Replace content?"
/>
<ConfirmationDialog
cancelText="Cancel"
confirmText="Delete"
handleConfirm={handleTemplateDelete}
handleOpenChange={setIsDeleteDialogOpen}
isOpen={isDeleteDialogOpen}
itemName={templateName ?? undefined}
itemType="template"
/>
</>
);
};
+131 -63
View File
@@ -1,41 +1,95 @@
import type { ColumnDef } from '@tanstack/react-table';
import { ArrowDown, ArrowUp, Ellipsis, FileText, Loader2, Pencil, Plus, Trash } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Ellipsis, FileText, Loader2, Pencil, PencilLine, Plus, Trash } from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { HeaderButton } from '@/components/shared/header-button';
import { InlineEditInput } from '@/components/shared/inline-edit';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { DataTable } from '@/components/ui/data-table';
import { DataTable, DataTableColumnHeader } from '@/components/ui/data-table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { StatusCard } from '@/components/ui/status-card';
import { cycleColumnSort } from '@/lib/table-sort';
import { useTableState } from '@/hooks/use-table-state';
import { mergeHrefWithSearchParams } from '@/lib/url-params';
import { type Template, useTemplates } from '@/providers/templates-provider';
const Templates = () => {
const navigate = useNavigate();
const { deleteTemplate, templates } = useTemplates();
const location = useLocation();
const { deleteTemplate, templates, updateTemplate } = useTemplates();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingTemplate, setDeletingTemplate] = useState<null | Template>(null);
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const [editingTemplateId, setEditingTemplateId] = useState<null | string>(null);
const [isRenameLoading, setIsRenameLoading] = useState(false);
const editingInputRef = useRef<HTMLInputElement>(null);
const handleTemplateOpen = (templateId: string) => {
navigate(`/templates/${templateId}`);
};
const { filter, setFilter } = useTableState();
const handleDeleteDialogOpen = (template: Template) => {
const handleTemplateOpen = useCallback(
(templateId: string) => {
navigate(mergeHrefWithSearchParams(`/templates/${templateId}`, new URLSearchParams(location.search)));
},
[navigate, location.search],
);
const handleDeleteDialogOpen = useCallback((template: Template) => {
setDeletingTemplate(template);
setIsDeleteDialogOpen(true);
};
}, []);
const handleTemplateRenameStart = useCallback((template: Template) => {
setEditingTemplateId(template.id);
}, []);
const handleTemplateRenameCancel = useCallback(() => {
setEditingTemplateId(null);
}, []);
const handleTemplateRenameSave = useCallback(async () => {
const newTitle = editingInputRef.current?.value.trim();
if (!editingTemplateId || !newTitle) {
return;
}
const template = templates.find((t) => t.id === editingTemplateId);
if (!template) {
return;
}
if (newTitle === template.title) {
setEditingTemplateId(null);
return;
}
setIsRenameLoading(true);
try {
await updateTemplate(editingTemplateId, { text: template.text, title: newTitle });
toast.success('Template renamed successfully');
setEditingTemplateId(null);
} catch {
// Error already handled in provider with toast
} finally {
setIsRenameLoading(false);
}
}, [editingTemplateId, templates, updateTemplate]);
const handleDelete = async () => {
if (!deletingTemplate) {
@@ -62,25 +116,36 @@ const Templates = () => {
const columns: ColumnDef<Template>[] = [
{
accessorKey: 'title',
cell: ({ row }) => <div className="font-medium">{row.getValue('title')}</div>,
header: ({ column }) => {
const sorted = column.getIsSorted();
cell: ({ row }) => {
const template = row.original;
const isEditing = editingTemplateId === template.id;
const title = row.getValue('title') as string;
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => cycleColumnSort(column)}
variant="link"
>
Title
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
if (isEditing) {
return (
<div onClick={(e) => e.stopPropagation()}>
<InlineEditInput
autoFocus
busy={isRenameLoading}
defaultValue={title}
inputRef={editingInputRef}
onCancel={handleTemplateRenameCancel}
onSave={handleTemplateRenameSave}
placeholder="Template title"
/>
</div>
);
}
return <div className="max-w-[380px] truncate font-medium">{title}</div>;
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Title"
/>
),
meta: { searchable: true },
},
{
accessorKey: 'text',
@@ -89,24 +154,13 @@ const Templates = () => {
return <div className="text-muted-foreground max-w-[380px] truncate text-sm">{text}</div>;
},
header: ({ column }) => {
const sorted = column.getIsSorted();
return (
<Button
className="text-muted-foreground hover:text-primary flex items-center gap-2 p-0 no-underline hover:no-underline"
onClick={() => cycleColumnSort(column)}
variant="link"
>
Text
{sorted === 'asc' ? (
<ArrowDown className="size-4" />
) : sorted === 'desc' ? (
<ArrowUp className="size-4" />
) : null}
</Button>
);
},
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Text"
/>
),
meta: { searchable: true },
},
{
cell: ({ row }) => {
@@ -118,6 +172,7 @@ const Templates = () => {
<DropdownMenuTrigger asChild>
<Button
className="size-8 p-0"
onClick={(e) => e.stopPropagation()}
variant="ghost"
>
<Ellipsis />
@@ -126,11 +181,17 @@ const Templates = () => {
<DropdownMenuContent
align="end"
className="min-w-24"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem onClick={() => handleTemplateOpen(template.id)}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleTemplateRenameStart(template)}>
<Pencil className="size-3" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={deletingIds.has(template.id)}
onClick={() => handleDeleteDialogOpen(template)}
@@ -166,6 +227,10 @@ const Templates = () => {
<Pencil />
Edit
</ContextMenuItem>
<ContextMenuItem onClick={() => handleTemplateRenameStart(template)}>
<PencilLine />
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={deletingIds.has(template.id)}
@@ -179,30 +244,28 @@ const Templates = () => {
const pageHeader = (
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<div className="flex min-w-0 flex-1 items-center gap-2 px-4">
<SidebarTrigger className="-ml-1 shrink-0" />
<Separator
className="h-4"
className="h-4 shrink-0"
orientation="vertical"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<FileText className="size-4" />
<BreadcrumbPage>Templates</BreadcrumbPage>
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="min-w-0 flex-nowrap">
<BreadcrumbItem className="min-w-0">
<FileText className="size-4 shrink-0" />
<BreadcrumbPage className="min-w-0 truncate">Templates</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="ml-auto flex items-center gap-2 px-4">
<Button
<div className="flex shrink-0 items-center gap-2 px-4">
<HeaderButton
icon={<Plus />}
label="New Template"
onClick={() => navigate('/templates/new')}
size="sm"
variant="secondary"
>
<Plus />
New Template
</Button>
/>
</div>
</header>
);
@@ -238,9 +301,14 @@ const Templates = () => {
<DataTable
columns={columns}
data={templates}
filterColumn="title"
filterPlaceholder="Filter templates..."
onRowClick={(template) => handleTemplateOpen(template.id)}
filterValue={filter}
onFilterChange={setFilter}
onRowClick={(template) => {
if (editingTemplateId !== template.id) {
handleTemplateOpen(template.id);
}
}}
renderRowContextMenu={renderRowContextMenu}
/>
@@ -46,8 +46,13 @@ export const KnowledgesProvider = ({ children }: KnowledgesProviderProps) => {
const shouldFetch = Boolean(authInfo && authInfo.type !== 'guest' && isAuthenticated());
// Override the client's default `nextFetchPolicy: 'cache-first'`: since
// subscriptions are scoped to this provider, the cache can drift while the
// user is on other pages (AI agents write documents during flow runs).
// Re-mounting the provider on return to /knowledges should refresh.
const { data, loading: isLoading } = useKnowledgeDocumentsQuery({
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-and-network',
skip: !shouldFetch,
variables: { withContent: true },
});
+4
View File
@@ -4,7 +4,11 @@ declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData, TValue> {
cellClassName?: string;
/** Label for the Columns visibility menu when `column.id` is not human-readable. */
columnMenuLabel?: string;
headerClassName?: string;
preventRowClick?: boolean;
/** When true, the column participates in the multi-column search picker (used when DataTable.filterColumn is omitted). */
searchable?: boolean;
}
}
+2 -6
View File
@@ -25,12 +25,8 @@
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@env": ["./env.ts"],
"@pkg": ["./package.json"],
"@/ui/*": ["./src/components/ui/*"]
"@/*": ["./src/*"]
}
},
"include": ["src", "./env.ts", "types/**/*.d.ts"]
"include": ["src", "types/**/*.d.ts"]
}
+5
View File
@@ -0,0 +1,5 @@
// Augment Vitest's `expect` with @testing-library/jest-dom matchers
// (`toBeInTheDocument`, `toBeDisabled`, `toHaveAttribute`, etc.) globally so
// individual test files don't need a per-file `import type {} from
// '@testing-library/jest-dom'` to satisfy the type checker.
import '@testing-library/jest-dom/vitest';
-1
View File
@@ -64,7 +64,6 @@ export default defineConfig(({ mode }) => {
manualChunks: {
'apollo-client': ['@apollo/client', 'graphql', 'graphql-ws'],
markdown: ['react-markdown', 'rehype-highlight', 'rehype-raw', 'rehype-slug', 'remark-gfm'],
pdf: ['html2pdf.js'],
'radix-ui': [
'@radix-ui/react-accordion',
'@radix-ui/react-avatar',

Some files were not shown because too many files have changed in this diff Show More