mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-27 19:29:57 -04:00
Merge remote-tracking branch 'origin/feature/frontend-next' into feature/next-release
This commit is contained in:
@@ -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": {
|
||||
|
||||
Generated
+2597
-1991
File diff suppressed because it is too large
Load Diff
+21
-15
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
+3
-3
@@ -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 (
|
||||
+3
-3
@@ -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 />}
|
||||
+10
-12
@@ -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';
|
||||
@@ -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 (~2–4 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);
|
||||
});
|
||||
});
|
||||
@@ -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 2–4 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 };
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -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';
|
||||
@@ -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
Reference in New Issue
Block a user