mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
feat(ui): migrate access-groups to shadcn + lock phase 1 blueprint
Migrates Section 1 (Access Groups) from antd to shadcn/ui: - AccessGroupsPage.tsx: antd Table/Card/Pagination/Flex/Space/Typography/ Tooltip/Tag/Input/Button \u2192 shadcn Table + TanStack react-table, shadcn Card/Input/Badge/Button/Tooltip. Custom compact pagination (prev/next + indicator) replaces antd Pagination. PlusOutlined \u2192 lucide Plus. - AccessGroupsDetailsPage.tsx: antd Layout/Row/Col/Descriptions/Card/List/ Tag/Tabs/Spin/Empty/Button/Typography \u2192 shadcn Card/Button/Badge/ Tabs/Skeleton + plain Tailwind grid + inline EmptyState + inline CopyableId (clipboard + toast). - AccessGroupsModal/AccessGroupBaseForm.tsx: antd Form/Form.Item/Input/ TextArea/Select(multi)/Tabs/Space \u2192 react-hook-form via FormProvider + Controller + register, shadcn Input/Label/Textarea/Tabs. Introduces a small inline MultiSelect (shadcn Select + chip list) since shadcn has no multi-select primitive. Logged in DEVIATIONS.md. - AccessGroupsModal/AccessGroupCreateModal.tsx, AccessGroupsModal/AccessGroupEditModal.tsx: antd Modal \u2192 shadcn Dialog, antd Form useForm \u2192 react-hook-form useForm + FormProvider, preserving mutation wiring and MessageManager.success calls. Writes the full **phase-1 blueprint** to docs/BLUEPRINT.md: - Component translation table (antd \u2192 shadcn) with import paths - Icon translation table (@ant-design/icons / @heroicons / @remixicon \u2192 lucide-react) - Toast pattern (sonner via MessageManager / NotificationManager) - Form pattern (rhf + zod + shadcn Form) \u2014 simple and complex variants - Table pattern (shadcn Table + @tanstack/react-table) - Shared layout patterns (page header, section card, empty state, skeleton, error alert, permissions gate) - Test-update checklist for migrated sections - Per-file migration checklist Commits a **phase-1 parity spec** at e2e_tests/parity/access-groups.spec.ts (semantic selectors only; no .ant-btn). Spec not executed in this run; see DEVIATIONS.md for gate-skip rationale (no proxy / dev-server auth in sandbox). TS + Lint + Vitest + build gates all pass (46/46 AccessGroups tests). **Locks the blueprint.** Section 2 (Virtual Keys) may extend it via the stress-test path; after that, the blueprint is immutable for the rest of phase 1. Deviations are recorded in DEVIATIONS.md. Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
923dc440ac
commit
f49d66daea
9 changed files with 1376 additions and 702 deletions
|
|
@ -1,25 +1,422 @@
|
|||
# Phase 1 migration blueprint
|
||||
|
||||
This document is finalized after Section 1 (Access Groups) and locked for the
|
||||
rest of the phase-1 run. Later sections follow the patterns defined here; any
|
||||
deviation is logged in `DEVIATIONS.md`.
|
||||
**Status:** 🔒 **Locked — later sections must follow these patterns; deviations
|
||||
go in `DEVIATIONS.md`.**
|
||||
|
||||
**Status:** Not yet locked — will be drafted during Section 1 (Access Groups).
|
||||
Forged during Section 1 (Access Groups). Subsequent sections extend this file
|
||||
only via the **blueprint stress test** in Section 2 (Virtual Keys); once
|
||||
Section 2 commits, the blueprint is permanently locked.
|
||||
|
||||
Once locked, this file will contain:
|
||||
---
|
||||
|
||||
1. **Component translation table** — every antd component → shadcn primitive,
|
||||
with import paths and any prop-shape differences.
|
||||
2. **Icon translation table** — @ant-design/icons, @heroicons/react,
|
||||
@remixicon/react → lucide-react (only).
|
||||
3. **Toast pattern** — before/after code example using the
|
||||
`MessageManager` / `NotificationManager` bridges (which now delegate to
|
||||
sonner under the hood).
|
||||
4. **Form pattern** — canonical shadcn `<Form>` + `react-hook-form` + `zod`
|
||||
example, including schema location, `FormField` / `FormItem` / `FormLabel`
|
||||
/ `FormControl` / `FormDescription` / `FormMessage` wiring, and submit
|
||||
handler hooked into the existing data-layer function.
|
||||
5. **Table pattern** — shadcn `<Table>` + `@tanstack/react-table` with
|
||||
sort / filter / pagination. Column-visibility UI if used.
|
||||
6. **Shared layout patterns** — page header, section card, empty state,
|
||||
loading skeleton, error alert, permissions gate.
|
||||
## 1. Component translation table (antd → shadcn)
|
||||
|
||||
| antd component | shadcn replacement | Import path |
|
||||
|----------------------------------|----------------------------------------------------------|------------------------------------------------------------|
|
||||
| `Button` | `Button` | `@/components/ui/button` |
|
||||
| `Input` | `Input` | `@/components/ui/input` |
|
||||
| `Input.TextArea` / `TextArea` | `Textarea` | `@/components/ui/textarea` |
|
||||
| `Select` (single) | `Select` + `SelectTrigger`/`SelectContent`/`SelectItem` | `@/components/ui/select` |
|
||||
| `Select` (multi, `mode="multiple"`) | Custom `MultiSelect` (shadcn `Select` + chip list) | see `AccessGroupBaseForm.tsx` for the canonical example |
|
||||
| `Checkbox` | `Checkbox` | `@/components/ui/checkbox` |
|
||||
| `Switch` | `Switch` | `@/components/ui/switch` |
|
||||
| `Radio.Group` | `RadioGroup` + `RadioGroupItem` | `@/components/ui/radio-group` |
|
||||
| `Form` + `Form.Item` | `<form>` + `react-hook-form` + shadcn `Form`* | `@/components/ui/form` (+ RHF `FormProvider` / `Controller` / `register`) |
|
||||
| `Modal` | `Dialog` (`DialogContent`, `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogFooter`) | `@/components/ui/dialog` |
|
||||
| `Drawer` / long-form modal | `Sheet` (`SheetContent`, `SheetHeader`, `SheetTitle`) | `@/components/ui/sheet` |
|
||||
| `Popover` | `Popover` + `PopoverTrigger` + `PopoverContent` | `@/components/ui/popover` |
|
||||
| `Tooltip` | `Tooltip` + `TooltipTrigger` + `TooltipContent` inside a `TooltipProvider` | `@/components/ui/tooltip` |
|
||||
| `Dropdown` / `Dropdown.Button` | `DropdownMenu` + `DropdownMenuTrigger` + `DropdownMenuContent` + `DropdownMenuItem` | `@/components/ui/dropdown-menu` |
|
||||
| `Menu` (application nav) | `NavigationMenu` (top-level nav) **or** `DropdownMenu` (context menus) | `@/components/ui/navigation-menu` / `@/components/ui/dropdown-menu` |
|
||||
| `Tabs` + `Tabs.TabPane` | `Tabs` + `TabsList` + `TabsTrigger` + `TabsContent` | `@/components/ui/tabs` |
|
||||
| `Tag` | `Badge` (`variant="secondary"` / `"outline"` / `"default"` / `"destructive"`) | `@/components/ui/badge` |
|
||||
| `Table` (basic) | `Table` + `TableHeader`/`TableBody`/`TableRow`/`TableHead`/`TableCell`, driven by `@tanstack/react-table` | `@/components/ui/table` |
|
||||
| `Card` | `Card` | `@/components/ui/card` |
|
||||
| `Alert` | `Alert` | `@/components/ui/alert` |
|
||||
| `Skeleton` / `Spin` | `Skeleton` | `@/components/ui/skeleton` |
|
||||
| `Space` | Plain `<div className="flex gap-X items-center">` | n/a — use Tailwind |
|
||||
| `Layout` / `Content` / `Sider` | Plain `<div className="min-h-screen">` / `<main>` / `<aside>` + Tailwind | n/a — use Tailwind |
|
||||
| `Divider` | `Separator` | `@/components/ui/separator` |
|
||||
| `Empty` | Custom empty-state div (see details-page example) | inline |
|
||||
| `Typography.Title` | `<h1>` / `<h2>` / `<h3>` with Tailwind text-X classes | n/a |
|
||||
| `Typography.Text` | `<span>` / `<p>` with Tailwind `text-muted-foreground`, etc. | n/a |
|
||||
| `Typography.Text ellipsis` | `<span className="truncate">` + `<Tooltip>` fallback | `@/components/ui/tooltip` |
|
||||
| `Typography.Text copyable` | Custom `CopyableId` (`navigator.clipboard.writeText` + `toast.success`) | inline (see details-page example) |
|
||||
| `Descriptions` | Plain `<dl>` / `<dt>` / `<dd>` with Tailwind | n/a |
|
||||
| `List.Item` grids | Tailwind `grid grid-cols-{1..4}` + `Card` items | `@/components/ui/card` |
|
||||
| `Pagination` | Custom prev/next buttons + page indicator (see list page) | inline (see `AccessGroupsPage.tsx`) |
|
||||
| `Flex` | `<div className="flex ...">` with Tailwind classes | n/a |
|
||||
| `Row` / `Col` | Tailwind `grid grid-cols-X gap-Y` | n/a |
|
||||
| `theme.useToken()` | Tailwind semantic tokens (`bg-primary`, etc.) | n/a |
|
||||
| `ConfigProvider` | Not needed; shadcn CSS vars are configured in `globals.css` / `tailwind.config.ts` | n/a |
|
||||
| `message.*` | `MessageManager.*` (delegates to sonner internally) | `@/components/molecules/message_manager` |
|
||||
| `notification.*` | `NotificationManager.*` (delegates to sonner internally) | `@/components/molecules/notifications_manager` |
|
||||
|
||||
### Notes
|
||||
|
||||
- **shadcn `Form` vs. manual RHF wiring.** For simple forms where we don't
|
||||
need `FormDescription` / `FormMessage` slots, direct `register(...)` +
|
||||
manual error text is fine (see `AccessGroupBaseForm.tsx`). For long forms
|
||||
with many validated inputs, use the full shadcn `<Form>` wrapper
|
||||
components. Both integrate the same RHF context, so there is no conflict
|
||||
when mixing.
|
||||
- **Typography ellipsis.** Tailwind's `truncate` + wrapping `<Tooltip>` is
|
||||
the pattern we adopt. Don't reach for antd's `Typography.Text` for text
|
||||
truncation anymore.
|
||||
- **`theme.useToken()` / `token.paddingLG`.** Replace with `p-6` / `px-12`
|
||||
(16 / 24 / 32px increments map cleanly to Tailwind's 4 / 6 / 8 / 12).
|
||||
|
||||
---
|
||||
|
||||
## 2. Icon translation table
|
||||
|
||||
| Source | lucide-react equivalent |
|
||||
|--------------------------------------------------|----------------------------|
|
||||
| `@ant-design/icons` → `PlusOutlined` | `Plus` |
|
||||
| `@ant-design/icons` → `SearchOutlined` | `Search` |
|
||||
| `@ant-design/icons` → `ExclamationCircleOutlined`| `AlertCircle` |
|
||||
| `@ant-design/icons` → `KeyOutlined` | `Key` |
|
||||
| `@ant-design/icons` → `BlockOutlined` | `Box` |
|
||||
| `@ant-design/icons` → `TeamOutlined` | `Users` |
|
||||
| `@ant-design/icons` → `UserOutlined` | `User` |
|
||||
| `@ant-design/icons` → `BankOutlined` | `Building` |
|
||||
| `@ant-design/icons` → `BookOutlined` | `BookOpen` |
|
||||
| `@ant-design/icons` → `BarChartOutlined` | `BarChart3` |
|
||||
| `@ant-design/icons` → `LineChartOutlined` | `LineChart` |
|
||||
| `@ant-design/icons` → `SettingOutlined` | `Settings` |
|
||||
| `@ant-design/icons` → `ToolOutlined` | `Wrench` |
|
||||
| `@ant-design/icons` → `SafetyOutlined` | `Shield` |
|
||||
| `@ant-design/icons` → `AuditOutlined` | `ClipboardCheck` |
|
||||
| `@ant-design/icons` → `ApiOutlined` | `Plug` |
|
||||
| `@ant-design/icons` → `DatabaseOutlined` | `Database` |
|
||||
| `@ant-design/icons` → `TagsOutlined` | `Tags` |
|
||||
| `@ant-design/icons` → `CreditCardOutlined` | `CreditCard` |
|
||||
| `@ant-design/icons` → `FileTextOutlined` | `FileText` |
|
||||
| `@ant-design/icons` → `FolderOutlined` | `Folder` |
|
||||
| `@ant-design/icons` → `AppstoreOutlined` | `LayoutGrid` |
|
||||
| `@ant-design/icons` → `PlayCircleOutlined` | `PlayCircle` |
|
||||
| `@ant-design/icons` → `RobotOutlined` | `Bot` |
|
||||
| `@ant-design/icons` → `BgColorsOutlined` | `Palette` |
|
||||
| `@ant-design/icons` → `ExperimentOutlined` | `FlaskConical` |
|
||||
| `@ant-design/icons` → `ExportOutlined` | `ExternalLink` |
|
||||
| `@ant-design/icons` → `ArrowLeftOutlined` | `ArrowLeft` |
|
||||
| `@ant-design/icons` → `EditOutlined` | `Pencil` or `Edit` |
|
||||
| `@ant-design/icons` → `DeleteOutlined` | `Trash2` |
|
||||
| `@ant-design/icons` → `CloseOutlined` | `X` |
|
||||
| `@ant-design/icons` → `CheckOutlined` | `Check` |
|
||||
| `@ant-design/icons` → `CopyOutlined` | `Copy` |
|
||||
| `@ant-design/icons` → `DownOutlined` | `ChevronDown` |
|
||||
| `@ant-design/icons` → `UpOutlined` | `ChevronUp` |
|
||||
| `@ant-design/icons` → `LoadingOutlined` | `LoaderCircle` (add `animate-spin`) |
|
||||
| `@heroicons/react/*` | lucide equivalent (same principle) |
|
||||
| `@remixicon/react/*` | lucide equivalent |
|
||||
|
||||
**When in doubt:** search <https://lucide.dev/icons> for the closest
|
||||
name-match. lucide's icon set is a strict superset of what the ant /
|
||||
heroicons usage in this repo needed as of phase 1.
|
||||
|
||||
---
|
||||
|
||||
## 3. Toast pattern
|
||||
|
||||
### Before (antd)
|
||||
|
||||
```tsx
|
||||
import { message, notification } from "antd";
|
||||
|
||||
message.success("Created successfully");
|
||||
notification.error({ message: "Error", description: err.message });
|
||||
```
|
||||
|
||||
### After (sonner via global managers)
|
||||
|
||||
```tsx
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import NotificationManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
MessageManager.success("Created successfully");
|
||||
NotificationManager.error("Something went wrong");
|
||||
// Backend errors are best classified via fromBackend:
|
||||
NotificationManager.fromBackend(err);
|
||||
```
|
||||
|
||||
The managers delegate to sonner's `toast.*` under the hood. `<Toaster />`
|
||||
is mounted once at the root layout. Do **not** render additional Toasters
|
||||
from section code.
|
||||
|
||||
---
|
||||
|
||||
## 4. Form pattern (react-hook-form + zod + shadcn `<Form>`)
|
||||
|
||||
### Simple form — direct `register()`
|
||||
|
||||
Use when the form has ~5 or fewer fields and no complex async validation.
|
||||
Suitable for the create / edit modals exercised in Section 1. See
|
||||
`src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx`.
|
||||
|
||||
```tsx
|
||||
import { FormProvider, useForm, useFormContext, Controller } from "react-hook-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type Values = { name: string; description: string; modelIds: string[] };
|
||||
|
||||
// Parent
|
||||
function CreateModal() {
|
||||
const form = useForm<Values>({
|
||||
defaultValues: { name: "", description: "", modelIds: [] },
|
||||
});
|
||||
const onSubmit = form.handleSubmit((values) => { /* … */ });
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<FormFields />
|
||||
<Button type="submit">Save</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Child — reads context
|
||||
function FormFields() {
|
||||
const { register, control, formState } = useFormContext<Values>();
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
|
||||
<Input id="name" {...register("name", { required: "Please enter a name" })} />
|
||||
{formState.errors.name && (
|
||||
<p className="text-sm text-destructive">{formState.errors.name.message as string}</p>
|
||||
)}
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="modelIds"
|
||||
render={({ field }) => <MultiSelect value={field.value} onChange={field.onChange} /* … */ />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Complex form — shadcn `<Form>` components
|
||||
|
||||
Use when you need `FormDescription` / `FormMessage` slots and consistent
|
||||
error rendering across many fields.
|
||||
|
||||
```tsx
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
|
||||
const schema = z.object({
|
||||
alias: z.string().min(1, "Alias required"),
|
||||
maxBudget: z.number().nonnegative().nullable(),
|
||||
});
|
||||
type Values = z.infer<typeof schema>;
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { alias: "", maxBudget: null },
|
||||
});
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="alias"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Alias</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormDescription>Human-readable name for this key.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
```
|
||||
|
||||
### Schema location
|
||||
|
||||
- **Small schemas** (≤ 5 fields): inline in the form component.
|
||||
- **Larger schemas**: colocated at `<form-component>.schemas.ts` next to the
|
||||
form file. Exported as `<name>Schema` and `<name>Values` type.
|
||||
|
||||
### Submit wiring
|
||||
|
||||
The submit handler continues to call the existing `useMutation` hook (from
|
||||
the existing data layer). Don't replace the data-layer code in phase 1.
|
||||
|
||||
---
|
||||
|
||||
## 5. Table pattern
|
||||
|
||||
shadcn `<Table>` + `@tanstack/react-table`. See
|
||||
`src/components/AccessGroups/AccessGroupsPage.tsx` for the canonical
|
||||
example.
|
||||
|
||||
Skeleton:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
ColumnDef, flexRender, getCoreRowModel, getSortedRowModel,
|
||||
SortingState, useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
const columns = useMemo<ColumnDef<Row>[]>(() => [ /* … */ ], []);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const table = useReactTable<Row>({
|
||||
data: rows,
|
||||
columns,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (r) => r.id,
|
||||
});
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<TableRow key={hg.id}>
|
||||
{hg.headers.map((h) => (
|
||||
<TableHead key={h.id}>
|
||||
{h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center text-muted-foreground py-8">
|
||||
No rows
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
{r.getVisibleCells().map((c) => (
|
||||
<TableCell key={c.id}>{flexRender(c.column.columnDef.cell, c.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
```
|
||||
|
||||
Column headers that support sort use the existing
|
||||
`TableHeaderSortDropdown` (`src/components/common_components/TableHeaderSortDropdown/`).
|
||||
|
||||
**Pagination** is rendered separately (see `AccessGroupsPage.tsx`). Don't
|
||||
use antd `<Pagination>` anymore — it's banned.
|
||||
|
||||
---
|
||||
|
||||
## 6. Shared layout patterns
|
||||
|
||||
### Page header
|
||||
|
||||
```tsx
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold m-0">{title}</h2>
|
||||
<p className="text-muted-foreground text-sm m-0">{subtitle}</p>
|
||||
</div>
|
||||
<Button onClick={primaryAction}>Primary Action</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Section card
|
||||
|
||||
```tsx
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-3">Section title</h3>
|
||||
{/* content */}
|
||||
</Card>
|
||||
```
|
||||
|
||||
### Empty state
|
||||
|
||||
```tsx
|
||||
function EmptyState({ description }: { description: string }) {
|
||||
return (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<div className="text-sm">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Loading skeleton
|
||||
|
||||
```tsx
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
```
|
||||
|
||||
### Error alert
|
||||
|
||||
```tsx
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
```
|
||||
|
||||
### Permissions gate
|
||||
|
||||
Keep the existing `useAuthorized` hook. Render-gate with early return or a
|
||||
`PermissionsGate` wrapper — no new pattern introduced in phase 1.
|
||||
|
||||
---
|
||||
|
||||
## 7. Test-update checklist
|
||||
|
||||
When updating existing RTL tests for a migrated section:
|
||||
|
||||
1. **Mock `@/components/molecules/message_manager` / `notifications_manager`** —
|
||||
these modules now delegate to sonner but tests should still assert against
|
||||
the manager, not sonner directly (agnostic of backing library).
|
||||
2. **Replace antd-specific selectors** (`.ant-btn`, `.ant-modal-title`, ...)
|
||||
with semantic queries (`getByRole`, `getByText`, `getByLabelText`).
|
||||
3. **Global `setupTests.ts` mocks `notifications_manager`** by default. If a
|
||||
test needs to assert against the real manager, use `vi.unmock()`
|
||||
(see `notifications_manager.test.tsx`).
|
||||
4. **JSDOM warnings about `PointerEvent` / `getPropertyValue`** are normal
|
||||
noise from Radix primitives and can be ignored; tests still pass.
|
||||
|
||||
---
|
||||
|
||||
## 8. Migration checklist per file
|
||||
|
||||
Apply this order for each file touched by the section:
|
||||
|
||||
1. Delete ALL `antd` / `@ant-design/icons` imports.
|
||||
2. Delete ALL `@heroicons/react` / `@remixicon/react` imports.
|
||||
3. Replace with shadcn primitives + lucide icons per the translation tables.
|
||||
4. Replace raw color classes (`bg-slate-500`, `text-blue-600`, …) with
|
||||
semantic tokens (`bg-primary`, `text-foreground`, `bg-muted`,
|
||||
`border-border`, `text-destructive`, …).
|
||||
5. Rewrite forms: `Form.useForm` → `useForm` from `react-hook-form`;
|
||||
`Form.Item` → direct `<Label>` + `<Input>` + `{...register}` or
|
||||
`Controller`. `form.validateFields()` → `form.handleSubmit(onSubmit)`.
|
||||
6. If the file imports `antd.message` / `antd.notification` directly, route
|
||||
through `MessageManager` / `NotificationManager` instead.
|
||||
7. Run `npx tsc --noEmit`, `npm run lint`, `npx vitest run <path>`. All
|
||||
must pass.
|
||||
|
||||
Files that are known to still import antd but are not in the current
|
||||
section's scope are left untouched; the ESLint `no-banned-ui-imports` rule
|
||||
reports them as expected.
|
||||
|
|
|
|||
|
|
@ -8,10 +8,20 @@ Layer abbreviations: **TS** (tsc --noEmit), **Lint** (eslint), **Vitest**
|
|||
(unit + component tests), **Parity** (Playwright parity spec), **Snap**
|
||||
(visual snapshots).
|
||||
|
||||
## <section>
|
||||
## 1. Access Groups (api-keys / access-groups)
|
||||
|
||||
- Cycles used: N / 7
|
||||
- Cycles used: 1 / 7
|
||||
- Layer outcomes per cycle:
|
||||
- cycle 1: TS ✓ | Lint ✗ | Vitest — | Parity — | Snap —
|
||||
- cycle 2: TS ✓ | Lint ✓ | Vitest ✓ | Parity ✓ | Snap ✓
|
||||
- Final status: done | blocked | wip
|
||||
- cycle 1: TS ✓ | Lint ✓ | Vitest ✓ (46/46) | Parity ⏭ | Snap ⏭
|
||||
- Final status: **done (with gates 4–5 skipped per cloud sandbox constraint)**
|
||||
- Gate-skip rationale: the sandbox has no running proxy at :4000 and no
|
||||
seeded dev-server auth helper yet; the parity spec file is committed so
|
||||
later runs (or the human reviewer) can execute it once the environment
|
||||
is wired. The migration still satisfies the TS + Lint + Vitest
|
||||
correctness gates.
|
||||
- Blueprint status: **locked** after this section.
|
||||
|
||||
## 2. Virtual Keys (api-keys)
|
||||
|
||||
_(pending)_
|
||||
|
||||
|
|
|
|||
|
|
@ -8,3 +8,54 @@ Format per entry:
|
|||
- **Blueprint rule deviated from (if applicable):** ...
|
||||
- **What the agent did instead:** ...
|
||||
- **Why:** ...
|
||||
|
||||
---
|
||||
|
||||
## Section 1 (Access Groups) — gate 4 + 5 skipped (Playwright parity + snapshots)
|
||||
|
||||
- **Type:** environmental-gate skip
|
||||
- **Blueprint rule deviated from:** `docs/RECIPE.md` steps 7–8 (Playwright
|
||||
parity spec + visual snapshot baseline).
|
||||
- **What the agent did instead:** Committed the parity spec file
|
||||
(`e2e_tests/parity/access-groups.spec.ts`) with full shape-complete
|
||||
assertions and a visual snapshot call, but **did not run** the spec.
|
||||
Relied on TS + Lint + Vitest + build gates for correctness.
|
||||
- **Why:** The cloud sandbox has no running proxy at port 4000 and no
|
||||
seeded dev-server auth helper (phase 1 has not yet wired one). Running
|
||||
Playwright against a cold UI without auth produces 0 passing assertions,
|
||||
which is worse than skipping. Later runs or the human reviewer will
|
||||
execute the spec once a dev-server auth helper (or the proxy stack) is
|
||||
available. The same gate-skip applies to all subsequent sections for the
|
||||
duration of this autonomous run.
|
||||
|
||||
---
|
||||
|
||||
## Section 1 (Access Groups) — DeleteResourceModal deferred to chrome sweep
|
||||
|
||||
- **Type:** blueprint deviation
|
||||
- **What the agent did instead:** `DeleteResourceModal`
|
||||
(`src/components/common_components/DeleteResourceModal.tsx`) still
|
||||
imports antd. It is a shared component used by multiple sections.
|
||||
Leaving it for the global chrome sweep (Task 43) to migrate in a single
|
||||
coordinated pass, so we don't thrash the component once per owning
|
||||
section.
|
||||
- **Why:** Shared-chrome component with no section that exclusively owns
|
||||
it. The RECIPE.md explicitly allows deferring shared parent components
|
||||
that aren't covered by the current section's parity spec.
|
||||
|
||||
---
|
||||
|
||||
## Section 1 (Access Groups) — custom MultiSelect shim in lieu of antd Select mode=multiple
|
||||
|
||||
- **Type:** `blueprint deviation`
|
||||
- **Blueprint rule deviated from:** "use shadcn `Select`" for multi-select.
|
||||
- **What the agent did instead:** introduced a small in-file `MultiSelect`
|
||||
wrapper (shadcn `Select` + chip list rendered below) in
|
||||
`AccessGroupBaseForm.tsx`. The shadcn primitive does not ship a multi-
|
||||
select mode; this is the canonical phase-1 substitute. Recorded in
|
||||
`BLUEPRINT.md §1` so subsequent sections use the same shape.
|
||||
- **Why:** No multi-select shadcn primitive exists; building a dedicated
|
||||
`MultiSelect` component in `@/components/ui/` is phase-2 work. The
|
||||
inline wrapper is small enough to copy/paste into any section that
|
||||
needs it until phase 2.
|
||||
|
||||
|
|
|
|||
136
ui/litellm-dashboard/e2e_tests/parity/access-groups.spec.ts
Normal file
136
ui/litellm-dashboard/e2e_tests/parity/access-groups.spec.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Phase-1 parity spec for the Access Groups section.
|
||||
*
|
||||
* This spec asserts on user-observable behavior via semantic queries only.
|
||||
* It deliberately avoids framework-specific selectors (e.g. `.ant-btn` or
|
||||
* `[data-radix-*]`) so it can pass against both the pre-migration (antd)
|
||||
* and post-migration (shadcn) UIs.
|
||||
*
|
||||
* The parity workflow is:
|
||||
* 1. Run this spec against the unmigrated UI (baseline) — it must pass.
|
||||
* 2. Migrate the section.
|
||||
* 3. Run this spec against the migrated UI — it must still pass.
|
||||
* 4. Run with --update-snapshots once to baseline visual snapshots, and
|
||||
* again without to verify pixel diffs are below the 1% threshold
|
||||
* defined in `playwright.config.ts`.
|
||||
*
|
||||
* **Auth:** The dashboard requires an admin session. When `PARITY_BASE_URL`
|
||||
* is unset (default), the Next dev server at :3000 is used; this spec
|
||||
* expects a helper to seed `sessionStorage` with a dev JWT — see
|
||||
* `e2e_tests/parity/helpers.ts` (TBD; add when we run parity specs against
|
||||
* the dev server). When `PARITY_BASE_URL=http://localhost:4000` the proxy
|
||||
* is used and the existing auth flow applies.
|
||||
*
|
||||
* This file is committed as the phase-1 baseline spec shape. In this
|
||||
* autonomous run the spec is not executed because neither the proxy nor a
|
||||
* seeded dev-server auth helper is available in the sandbox environment
|
||||
* — the gating check for Section 1 is vitest + tsc + lint + build. See
|
||||
* `docs/CYCLES.md` for the explicit gate-skip rationale.
|
||||
*/
|
||||
|
||||
test.describe("Access Groups — parity", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Page under test is typically reached via the leftnav.
|
||||
await page.goto("/ui?page=access-groups");
|
||||
});
|
||||
|
||||
test("lists access groups with page header and search input", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /access groups/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/manage resource permissions for your organization/i),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByPlaceholder(
|
||||
/search groups by name, id, or description\.\.\./i,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /create access group/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("create access group flow opens the dialog and validates required fields", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.getByRole("button", { name: /create access group/i })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /create access group/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: /group name/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /create group/i }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: /cancel/i }).click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /create access group/i }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("filters access groups by search", async ({ page }) => {
|
||||
const search = page.getByPlaceholder(
|
||||
/search groups by name, id, or description\.\.\./i,
|
||||
);
|
||||
await search.fill("__no_match_sentinel__");
|
||||
// The table should show an empty-state row, not crash.
|
||||
await expect(page.getByRole("table")).toBeVisible();
|
||||
});
|
||||
|
||||
test("table exposes ID, Name, Resources, Actions columns", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: /^id$/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: /^name$/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: /^resources$/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: /^actions$/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("delete action opens confirmation dialog with resource information", async ({
|
||||
page,
|
||||
}) => {
|
||||
const deleteButtons = page.getByRole("button", {
|
||||
name: /delete access group/i,
|
||||
});
|
||||
const count = await deleteButtons.count();
|
||||
test.skip(count === 0, "No existing access groups to exercise delete on.");
|
||||
await deleteButtons.first().click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /delete access group/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
/are you sure you want to delete this access group\?/i,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/access group information/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("visual — list view", async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /access groups/i }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveScreenshot("list.png", {
|
||||
fullPage: true,
|
||||
mask: [page.getByRole("cell", { name: /ag-/i })], // mask dynamic IDs
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,23 +1,20 @@
|
|||
import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Flex,
|
||||
Layout,
|
||||
List,
|
||||
Row,
|
||||
Spin,
|
||||
Tabs,
|
||||
Tag,
|
||||
theme,
|
||||
Typography
|
||||
} from "antd";
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BotIcon,
|
||||
CopyIcon,
|
||||
EditIcon,
|
||||
KeyIcon,
|
||||
LayersIcon,
|
||||
|
|
@ -28,21 +25,48 @@ import { useState } from "react";
|
|||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Content } = Layout;
|
||||
|
||||
interface AccessGroupDetailProps {
|
||||
accessGroupId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function EmptyState({ description }: { description: string }) {
|
||||
return (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<div className="text-sm">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyableId({ value }: { value: string }) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(value);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs font-mono rounded bg-muted px-1.5 py-0.5 hover:bg-muted/80"
|
||||
>
|
||||
{value}
|
||||
<CopyIcon size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessGroupDetail({
|
||||
accessGroupId,
|
||||
onBack,
|
||||
}: AccessGroupDetailProps) {
|
||||
const { data: accessGroup, isLoading } =
|
||||
useAccessGroupDetails(accessGroupId);
|
||||
const { token } = theme.useToken();
|
||||
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||
const [showAllKeys, setShowAllKeys] = useState(false);
|
||||
const [showAllTeams, setShowAllTeams] = useState(false);
|
||||
|
|
@ -51,35 +75,22 @@ export function AccessGroupDetail({
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Content
|
||||
style={{
|
||||
padding: token.paddingLG,
|
||||
paddingInline: token.paddingLG * 2,
|
||||
}}
|
||||
>
|
||||
<Flex justify="center" align="center" style={{ minHeight: 300 }}>
|
||||
<Spin size="large" />
|
||||
</Flex>
|
||||
</Content>
|
||||
<div className="p-6 md:px-12">
|
||||
<div className="flex justify-center items-center min-h-[300px]">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!accessGroup) {
|
||||
return (
|
||||
<Content
|
||||
style={{
|
||||
padding: token.paddingLG,
|
||||
paddingInline: token.paddingLG * 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<ArrowLeftIcon size={16} />}
|
||||
onClick={onBack}
|
||||
type="text"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Empty description="Access group not found" />
|
||||
</Content>
|
||||
<div className="p-6 md:px-12">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
|
||||
<ArrowLeftIcon size={16} />
|
||||
</Button>
|
||||
<EmptyState description="Access group not found" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -90,248 +101,195 @@ export function AccessGroupDetail({
|
|||
const teamIds = accessGroup.assigned_team_ids ?? [];
|
||||
|
||||
const displayedKeys = showAllKeys ? keyIds : keyIds.slice(0, MAX_PREVIEW);
|
||||
const displayedTeams = showAllTeams
|
||||
? teamIds
|
||||
: teamIds.slice(0, MAX_PREVIEW);
|
||||
const displayedTeams = showAllTeams ? teamIds : teamIds.slice(0, MAX_PREVIEW);
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditModalVisible(true);
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: "models",
|
||||
label: (
|
||||
<Flex align="center" gap={8}>
|
||||
<LayersIcon size={16} />
|
||||
Models
|
||||
<Tag style={{ marginInlineEnd: 0 }}>{modelIds?.length}</Tag>
|
||||
</Flex>
|
||||
),
|
||||
children:
|
||||
modelIds?.length > 0 ? (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
|
||||
dataSource={modelIds}
|
||||
renderItem={(id) => (
|
||||
<List.Item>
|
||||
<Card size="small">
|
||||
<Text code>{id}</Text>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="No models assigned to this group" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "mcp",
|
||||
label: (
|
||||
<Flex align="center" gap={8}>
|
||||
<ServerIcon size={16} />
|
||||
MCP Servers
|
||||
<Tag>{mcpServerIds?.length}</Tag>
|
||||
</Flex>
|
||||
),
|
||||
children:
|
||||
mcpServerIds?.length > 0 ? (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
|
||||
dataSource={mcpServerIds}
|
||||
renderItem={(id) => (
|
||||
<List.Item>
|
||||
<Card size="small">
|
||||
<Text code>{id}</Text>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="No MCP servers assigned to this group" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "agents",
|
||||
label: (
|
||||
<Flex align="center" gap={8}>
|
||||
<BotIcon size={16} />
|
||||
Agents
|
||||
<Tag>{agentIds?.length}</Tag>
|
||||
</Flex>
|
||||
),
|
||||
children:
|
||||
agentIds?.length > 0 ? (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
|
||||
dataSource={agentIds}
|
||||
renderItem={(id) => (
|
||||
<List.Item>
|
||||
<Card size="small">
|
||||
<Text code>{id}</Text>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="No agents assigned to this group" />
|
||||
),
|
||||
},
|
||||
];
|
||||
const handleEdit = () => setIsEditModalVisible(true);
|
||||
|
||||
return (
|
||||
<Content
|
||||
style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}
|
||||
>
|
||||
<div className="p-6 md:px-12">
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftIcon size={16} />}
|
||||
onClick={onBack}
|
||||
type="text"
|
||||
/>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
<ArrowLeftIcon size={16} />
|
||||
</Button>
|
||||
<div>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
<h2 className="text-2xl font-semibold m-0">
|
||||
{accessGroup.access_group_name}
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
ID: <Text copyable>{accessGroup.access_group_id}</Text>
|
||||
</Text>
|
||||
</h2>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
ID: <CopyableId value={accessGroup.access_group_id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditIcon size={16} />}
|
||||
onClick={handleEdit}
|
||||
>
|
||||
<Button onClick={handleEdit}>
|
||||
<EditIcon size={16} />
|
||||
Edit Access Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Group Details */}
|
||||
<Row style={{ marginBottom: 24 }}>
|
||||
<Card>
|
||||
<Descriptions title="Group Details" column={1}>
|
||||
<Descriptions.Item label="Description">
|
||||
{accessGroup.description || "—"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Created">
|
||||
<Card className="p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">Group Details</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-36 text-muted-foreground">Description</dt>
|
||||
<dd>{accessGroup.description || "—"}</dd>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-36 text-muted-foreground">Created</dt>
|
||||
<dd>
|
||||
{new Date(accessGroup.created_at).toLocaleString()}
|
||||
{accessGroup.created_by && (
|
||||
<Text>
|
||||
{"by"}
|
||||
<>
|
||||
by
|
||||
<DefaultProxyAdminTag userId={accessGroup.created_by} />
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Last Updated">
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-36 text-muted-foreground">Last Updated</dt>
|
||||
<dd>
|
||||
{new Date(accessGroup.updated_at).toLocaleString()}
|
||||
{accessGroup.updated_by && (
|
||||
<Text>
|
||||
{"by"}
|
||||
<>
|
||||
by
|
||||
<DefaultProxyAdminTag userId={accessGroup.updated_by} />
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Row>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Attached Keys & Teams */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<KeyIcon size={16} />
|
||||
Attached Keys
|
||||
<Tag>{keyIds?.length}</Tag>
|
||||
</Flex>
|
||||
}
|
||||
extra={
|
||||
keyIds?.length > MAX_PREVIEW ? (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => setShowAllKeys(!showAllKeys)}
|
||||
>
|
||||
{showAllKeys ? "Show Less" : `View All (${keyIds?.length})`}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{keyIds?.length > 0 ? (
|
||||
<Flex wrap="wrap" gap={8}>
|
||||
{displayedKeys.map((id) => (
|
||||
<Tag key={id}>
|
||||
<Text code style={{ fontSize: 12 }}>
|
||||
{id.length > 20
|
||||
? `${id.slice(0, 10)}...${id.slice(-6)}`
|
||||
: id}
|
||||
</Text>
|
||||
</Tag>
|
||||
))}
|
||||
</Flex>
|
||||
) : (
|
||||
<Empty
|
||||
description="No keys attached"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<KeyIcon size={16} />
|
||||
Attached Keys
|
||||
<Badge variant="secondary">{keyIds?.length}</Badge>
|
||||
</div>
|
||||
{keyIds?.length > MAX_PREVIEW && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => setShowAllKeys(!showAllKeys)}
|
||||
>
|
||||
{showAllKeys ? "Show Less" : `View All (${keyIds?.length})`}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<UsersIcon size={16} />
|
||||
Attached Teams
|
||||
<Tag>{teamIds?.length}</Tag>
|
||||
</Flex>
|
||||
}
|
||||
extra={
|
||||
teamIds?.length > MAX_PREVIEW ? (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => setShowAllTeams(!showAllTeams)}
|
||||
>
|
||||
{showAllTeams
|
||||
? "Show Less"
|
||||
: `View All (${teamIds?.length})`}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{teamIds?.length > 0 ? (
|
||||
<Flex wrap="wrap" gap={8}>
|
||||
{displayedTeams.map((id) => (
|
||||
<Tag key={id}>
|
||||
<Text code style={{ fontSize: 12 }}>
|
||||
{id}
|
||||
</Text>
|
||||
</Tag>
|
||||
))}
|
||||
</Flex>
|
||||
) : (
|
||||
<Empty
|
||||
description="No teams attached"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
{keyIds?.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{displayedKeys.map((id) => (
|
||||
<Badge key={id} variant="secondary" className="font-mono text-xs">
|
||||
{id.length > 20
|
||||
? `${id.slice(0, 10)}...${id.slice(-6)}`
|
||||
: id}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState description="No keys attached" />
|
||||
)}
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<UsersIcon size={16} />
|
||||
Attached Teams
|
||||
<Badge variant="secondary">{teamIds?.length}</Badge>
|
||||
</div>
|
||||
{teamIds?.length > MAX_PREVIEW && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => setShowAllTeams(!showAllTeams)}
|
||||
>
|
||||
{showAllTeams ? "Show Less" : `View All (${teamIds?.length})`}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
{teamIds?.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{displayedTeams.map((id) => (
|
||||
<Badge key={id} variant="secondary" className="font-mono text-xs">
|
||||
{id}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState description="No teams attached" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Resources Tabs */}
|
||||
<Card>
|
||||
<Tabs defaultActiveKey="models" items={tabItems} />
|
||||
<Card className="p-6">
|
||||
<Tabs defaultValue="models" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="models" className="gap-1">
|
||||
<LayersIcon size={16} />
|
||||
Models
|
||||
<Badge variant="secondary">{modelIds?.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp" className="gap-1">
|
||||
<ServerIcon size={16} />
|
||||
MCP Servers
|
||||
<Badge variant="secondary">{mcpServerIds?.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents" className="gap-1">
|
||||
<BotIcon size={16} />
|
||||
Agents
|
||||
<Badge variant="secondary">{agentIds?.length}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="models" className="pt-4">
|
||||
{modelIds?.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{modelIds.map((id) => (
|
||||
<Card key={id} className="p-3">
|
||||
<code className="text-xs font-mono">{id}</code>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState description="No models assigned to this group" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mcp" className="pt-4">
|
||||
{mcpServerIds?.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{mcpServerIds.map((id) => (
|
||||
<Card key={id} className="p-3">
|
||||
<code className="text-xs font-mono">{id}</code>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState description="No MCP servers assigned to this group" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="pt-4">
|
||||
{agentIds?.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{agentIds.map((id) => (
|
||||
<Card key={id} className="p-3">
|
||||
<code className="text-xs font-mono">{id}</code>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState description="No agents assigned to this group" />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
{/* Edit Modal */}
|
||||
|
|
@ -340,6 +298,6 @@ export function AccessGroupDetail({
|
|||
accessGroup={accessGroup}
|
||||
onCancel={() => setIsEditModalVisible(false)}
|
||||
/>
|
||||
</Content>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
|
||||
import type { FormInstance } from "antd";
|
||||
import { Form, Input, Select, Space, Tabs } from "antd";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { X } from "lucide-react";
|
||||
import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react";
|
||||
|
||||
const { TextArea } = Input;
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
export interface AccessGroupFormValues {
|
||||
name: string;
|
||||
|
|
@ -16,144 +27,212 @@ export interface AccessGroupFormValues {
|
|||
}
|
||||
|
||||
interface AccessGroupBaseFormProps {
|
||||
form: FormInstance<AccessGroupFormValues>;
|
||||
isNameDisabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select rendered with shadcn Select + chip list below. Accepts any
|
||||
* `Array<{ label: string; value: string }>` list of options.
|
||||
*/
|
||||
function MultiSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
emptyText,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
options: { label: string; value: string }[];
|
||||
placeholder: string;
|
||||
emptyText: string;
|
||||
}) {
|
||||
const selected = useMemo(() => value ?? [], [value]);
|
||||
const remaining = useMemo(
|
||||
() => options.filter((o) => !selected.includes(o.value)),
|
||||
[options, selected],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(v) => {
|
||||
if (v) onChange([...selected, v]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remaining.length === 0 ? (
|
||||
<div className="py-2 px-3 text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
remaining.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selected.map((v) => {
|
||||
const opt = options.find((o) => o.value === v);
|
||||
return (
|
||||
<Badge key={v} variant="secondary" className="flex items-center gap-1">
|
||||
{opt?.label ?? v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(selected.filter((s) => s !== v))}
|
||||
className="inline-flex items-center justify-center rounded-full hover:bg-muted-foreground/20"
|
||||
aria-label={`Remove ${opt?.label ?? v}`}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields-only block — expected to be rendered inside a parent form managed
|
||||
* by `react-hook-form` via a `FormProvider`. See `AccessGroupCreateModal`
|
||||
* / `AccessGroupEditModal` for the integration.
|
||||
*/
|
||||
export function AccessGroupBaseForm({
|
||||
form,
|
||||
isNameDisabled = false,
|
||||
}: AccessGroupBaseFormProps) {
|
||||
const { control, register, formState } = useFormContext<AccessGroupFormValues>();
|
||||
const { data: agentsData } = useAgents();
|
||||
const { data: mcpServersData } = useMCPServers();
|
||||
|
||||
const agents = agentsData?.agents ?? [];
|
||||
const mcpServers = mcpServersData ?? [];
|
||||
const items = [
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<Space align="center" size={4}>
|
||||
<InfoIcon size={16} />
|
||||
General Info
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div style={{ paddingTop: 16 }}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Group Name"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please enter the access group name",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="e.g. Engineering Team"
|
||||
disabled={isNameDisabled}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="Description"
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="Describe the purpose of this access group..."
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
label: (
|
||||
<Space align="center" size={4}>
|
||||
<LayersIcon size={16} />
|
||||
Models
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div style={{ paddingTop: 16 }}>
|
||||
<Form.Item name="modelIds" label="Allowed Models">
|
||||
<ModelSelect
|
||||
context="global"
|
||||
value={form.getFieldValue("modelIds") ?? []}
|
||||
onChange={(values) => form.setFieldsValue({ modelIds: values })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "3",
|
||||
label: (
|
||||
<Space align="center" size={4}>
|
||||
<ServerIcon size={16} />
|
||||
MCP Servers
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div style={{ paddingTop: 16 }}>
|
||||
<Form.Item name="mcpServerIds" label="Allowed MCP Servers">
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select MCP servers"
|
||||
style={{ width: "100%" }}
|
||||
optionFilterProp="label"
|
||||
allowClear
|
||||
options={mcpServers.map((server) => ({
|
||||
label: server.server_name ?? server.server_id,
|
||||
value: server.server_id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "4",
|
||||
label: (
|
||||
<Space align="center" size={4}>
|
||||
<BotIcon size={16} />
|
||||
Agents
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div style={{ paddingTop: 16 }}>
|
||||
<Form.Item name="agentIds" label="Allowed Agents">
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select agents"
|
||||
style={{ width: "100%" }}
|
||||
optionFilterProp="label"
|
||||
allowClear
|
||||
options={agents.map((agent) => ({
|
||||
label: agent.agent_name,
|
||||
value: agent.agent_id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const mcpOptions = mcpServers.map((s) => ({
|
||||
label: s.server_name ?? s.server_id,
|
||||
value: s.server_id,
|
||||
}));
|
||||
const agentOptions = agents.map((a) => ({
|
||||
label: a.agent_name,
|
||||
value: a.agent_id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
name="access_group_form"
|
||||
initialValues={{
|
||||
modelIds: [],
|
||||
mcpServerIds: [],
|
||||
agentIds: [],
|
||||
}}
|
||||
>
|
||||
<Tabs defaultActiveKey="1" items={items} />
|
||||
</Form>
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general" className="gap-1">
|
||||
<InfoIcon size={16} />
|
||||
General Info
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models" className="gap-1">
|
||||
<LayersIcon size={16} />
|
||||
Models
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp" className="gap-1">
|
||||
<ServerIcon size={16} />
|
||||
MCP Servers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents" className="gap-1">
|
||||
<BotIcon size={16} />
|
||||
Agents
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="pt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="access-group-name">
|
||||
Group Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="access-group-name"
|
||||
placeholder="e.g. Engineering Team"
|
||||
disabled={isNameDisabled}
|
||||
aria-invalid={!!formState.errors.name}
|
||||
{...register("name", {
|
||||
required: "Please enter the access group name",
|
||||
})}
|
||||
/>
|
||||
{formState.errors.name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{formState.errors.name.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="access-group-description">Description</Label>
|
||||
<Textarea
|
||||
id="access-group-description"
|
||||
rows={4}
|
||||
placeholder="Describe the purpose of this access group..."
|
||||
{...register("description")}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="models" className="pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Allowed Models</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="modelIds"
|
||||
render={({ field }) => (
|
||||
<ModelSelect
|
||||
context="global"
|
||||
value={field.value ?? []}
|
||||
onChange={(values) => field.onChange(values)}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mcp" className="pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Allowed MCP Servers</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="mcpServerIds"
|
||||
render={({ field }) => (
|
||||
<MultiSelect
|
||||
value={field.value ?? []}
|
||||
onChange={field.onChange}
|
||||
options={mcpOptions}
|
||||
placeholder="Select MCP servers"
|
||||
emptyText="No MCP servers available"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Allowed Agents</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="agentIds"
|
||||
render={({ field }) => (
|
||||
<MultiSelect
|
||||
value={field.value ?? []}
|
||||
onChange={field.onChange}
|
||||
options={agentOptions}
|
||||
placeholder="Select agents"
|
||||
emptyText="No agents available"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import React from "react";
|
||||
import { Modal, Form } from "antd";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import {
|
||||
AccessGroupBaseForm,
|
||||
|
|
@ -9,6 +16,8 @@ import {
|
|||
useCreateAccessGroup,
|
||||
AccessGroupCreateParams,
|
||||
} from "@/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface AccessGroupCreateModalProps {
|
||||
visible: boolean;
|
||||
|
|
@ -16,53 +25,73 @@ interface AccessGroupCreateModalProps {
|
|||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const emptyValues: AccessGroupFormValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
modelIds: [],
|
||||
mcpServerIds: [],
|
||||
agentIds: [],
|
||||
};
|
||||
|
||||
export function AccessGroupCreateModal({
|
||||
visible,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}: AccessGroupCreateModalProps) {
|
||||
const [form] = Form.useForm<AccessGroupFormValues>();
|
||||
const form = useForm<AccessGroupFormValues>({
|
||||
defaultValues: emptyValues,
|
||||
mode: "onSubmit",
|
||||
});
|
||||
const createMutation = useCreateAccessGroup();
|
||||
|
||||
const handleOk = () => {
|
||||
form
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
const params: AccessGroupCreateParams = {
|
||||
access_group_name: values.name,
|
||||
description: values.description,
|
||||
access_model_names: values.modelIds,
|
||||
access_mcp_server_ids: values.mcpServerIds,
|
||||
access_agent_ids: values.agentIds,
|
||||
};
|
||||
// Reset whenever the dialog reopens — sonner equivalent of antd
|
||||
// destroyOnClose is a deliberate form.reset().
|
||||
useEffect(() => {
|
||||
if (visible) form.reset(emptyValues);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible]);
|
||||
|
||||
createMutation.mutate(params, {
|
||||
onSuccess: () => {
|
||||
MessageManager.success("Access group created successfully");
|
||||
form.resetFields();
|
||||
onSuccess?.();
|
||||
onCancel();
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((info) => {
|
||||
console.log("Validate Failed:", info);
|
||||
});
|
||||
};
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
const params: AccessGroupCreateParams = {
|
||||
access_group_name: values.name,
|
||||
description: values.description,
|
||||
access_model_names: values.modelIds,
|
||||
access_mcp_server_ids: values.mcpServerIds,
|
||||
access_agent_ids: values.agentIds,
|
||||
};
|
||||
createMutation.mutate(params, {
|
||||
onSuccess: () => {
|
||||
MessageManager.success("Access group created successfully");
|
||||
form.reset(emptyValues);
|
||||
onSuccess?.();
|
||||
onCancel();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Create Access Group"
|
||||
open={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
width={700}
|
||||
okText="Create Group"
|
||||
cancelText="Cancel"
|
||||
confirmLoading={createMutation.isPending}
|
||||
destroyOnClose
|
||||
>
|
||||
<AccessGroupBaseForm form={form} />
|
||||
</Modal>
|
||||
<Dialog open={visible} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Access Group</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Create a new access group and assign models, MCP servers, and agents.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<AccessGroupBaseForm />
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Creating…" : "Create Group"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { Modal, Form } from "antd";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import {
|
||||
AccessGroupBaseForm,
|
||||
|
|
@ -10,6 +17,8 @@ import {
|
|||
AccessGroupUpdateParams,
|
||||
} from "@/app/(dashboard)/hooks/accessGroups/useEditAccessGroup";
|
||||
import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface AccessGroupEditModalProps {
|
||||
visible: boolean;
|
||||
|
|
@ -24,13 +33,21 @@ export function AccessGroupEditModal({
|
|||
onCancel,
|
||||
onSuccess,
|
||||
}: AccessGroupEditModalProps) {
|
||||
const [form] = Form.useForm<AccessGroupFormValues>();
|
||||
const form = useForm<AccessGroupFormValues>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
modelIds: [],
|
||||
mcpServerIds: [],
|
||||
agentIds: [],
|
||||
},
|
||||
mode: "onSubmit",
|
||||
});
|
||||
const editMutation = useEditAccessGroup();
|
||||
|
||||
// Populate the form with initial values whenever the modal opens or the data changes
|
||||
useEffect(() => {
|
||||
if (visible && accessGroup) {
|
||||
form.setFieldsValue({
|
||||
form.reset({
|
||||
name: accessGroup.access_group_name,
|
||||
description: accessGroup.description ?? "",
|
||||
modelIds: accessGroup.access_model_names ?? [],
|
||||
|
|
@ -38,49 +55,52 @@ export function AccessGroupEditModal({
|
|||
agentIds: accessGroup.access_agent_ids ?? [],
|
||||
});
|
||||
}
|
||||
}, [visible, accessGroup, form]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, accessGroup]);
|
||||
|
||||
const handleOk = () => {
|
||||
form
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
const params: AccessGroupUpdateParams = {
|
||||
access_group_name: values.name,
|
||||
description: values.description,
|
||||
access_model_names: values.modelIds,
|
||||
access_mcp_server_ids: values.mcpServerIds,
|
||||
access_agent_ids: values.agentIds,
|
||||
};
|
||||
|
||||
editMutation.mutate(
|
||||
{ accessGroupId: accessGroup.access_group_id, params },
|
||||
{
|
||||
onSuccess: () => {
|
||||
MessageManager.success("Access group updated successfully");
|
||||
onSuccess?.();
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch((info) => {
|
||||
console.log("Validate Failed:", info);
|
||||
});
|
||||
};
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
const params: AccessGroupUpdateParams = {
|
||||
access_group_name: values.name,
|
||||
description: values.description,
|
||||
access_model_names: values.modelIds,
|
||||
access_mcp_server_ids: values.mcpServerIds,
|
||||
access_agent_ids: values.agentIds,
|
||||
};
|
||||
editMutation.mutate(
|
||||
{ accessGroupId: accessGroup.access_group_id, params },
|
||||
{
|
||||
onSuccess: () => {
|
||||
MessageManager.success("Access group updated successfully");
|
||||
onSuccess?.();
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Edit Access Group"
|
||||
open={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
width={700}
|
||||
okText="Save Changes"
|
||||
cancelText="Cancel"
|
||||
confirmLoading={editMutation.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<AccessGroupBaseForm form={form} />
|
||||
</Modal>
|
||||
<Dialog open={visible} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Access Group</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Edit the access group and its resource assignments.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<AccessGroupBaseForm />
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={editMutation.isPending}>
|
||||
{editMutation.isPending ? "Saving…" : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,36 +3,34 @@ import {
|
|||
useAccessGroups,
|
||||
} from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
|
||||
import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
Row,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Input,
|
||||
Layout,
|
||||
Pagination,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
theme,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "antd";
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
BotIcon,
|
||||
LayersIcon,
|
||||
SearchIcon,
|
||||
ServerIcon
|
||||
} from "lucide-react";
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BotIcon, LayersIcon, Plus, SearchIcon, ServerIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import DeleteResourceModal from "../common_components/DeleteResourceModal";
|
||||
import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
|
|
@ -44,16 +42,6 @@ import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
|
|||
import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal";
|
||||
import { AccessGroup } from "./types";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData, TValue> {
|
||||
responsive?: string[];
|
||||
}
|
||||
}
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Content } = Layout;
|
||||
|
||||
function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
|
||||
return {
|
||||
id: r.access_group_id,
|
||||
|
|
@ -70,66 +58,8 @@ function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
|
|||
updatedBy: r.updated_by ?? "",
|
||||
};
|
||||
}
|
||||
function buildAntdColumns(
|
||||
table: ReturnType<typeof useReactTable<AccessGroup>>,
|
||||
rowLookup: Map<string, Row<AccessGroup>>,
|
||||
onSortingChange: (s: SortingState) => void,
|
||||
) {
|
||||
const headers = table.getHeaderGroups()[0]?.headers ?? [];
|
||||
|
||||
return headers.map((header) => {
|
||||
const canSort = header.column.getCanSort();
|
||||
const isSorted = header.column.getIsSorted();
|
||||
const meta = header.column.columnDef.meta as
|
||||
| { responsive?: string[] }
|
||||
| undefined;
|
||||
|
||||
const col: Record<string, unknown> = {
|
||||
title: (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{canSort && (
|
||||
<TableHeaderSortDropdown
|
||||
sortState={isSorted === false ? false : (isSorted as SortState)}
|
||||
onSortChange={(newState) => {
|
||||
if (newState === false) {
|
||||
onSortingChange([]);
|
||||
} else {
|
||||
onSortingChange([
|
||||
{ id: header.column.id, desc: newState === "desc" },
|
||||
]);
|
||||
}
|
||||
}}
|
||||
columnId={header.column.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
key: header.id,
|
||||
width: header.column.columnDef.size,
|
||||
render: (_: unknown, record: AccessGroup) => {
|
||||
const row = rowLookup.get(record.id);
|
||||
if (!row) return null;
|
||||
const cell = row
|
||||
.getVisibleCells()
|
||||
.find((c) => c.column.id === header.id);
|
||||
if (!cell) return null;
|
||||
return flexRender(cell.column.columnDef.cell, cell.getContext());
|
||||
},
|
||||
};
|
||||
|
||||
if (meta?.responsive) {
|
||||
col.responsive = meta.responsive;
|
||||
}
|
||||
|
||||
return col;
|
||||
});
|
||||
}
|
||||
|
||||
export function AccessGroupsPage() {
|
||||
const { token } = theme.useToken();
|
||||
const { data: groupsData, isLoading } = useAccessGroups();
|
||||
const groups = useMemo(
|
||||
() => (groupsData ?? []).map(mapResponseToAccessGroup),
|
||||
|
|
@ -149,7 +79,6 @@ export function AccessGroupsPage() {
|
|||
setCurrentPage(1);
|
||||
}, [searchText]);
|
||||
|
||||
// ---------- filtered data ----------
|
||||
const filteredGroups = useMemo(
|
||||
() =>
|
||||
groups.filter(
|
||||
|
|
@ -161,7 +90,6 @@ export function AccessGroupsPage() {
|
|||
[groups, searchText],
|
||||
);
|
||||
|
||||
// ---------- TanStack column definitions ----------
|
||||
const columnDefs = useMemo<ColumnDef<AccessGroup>[]>(
|
||||
() => [
|
||||
{
|
||||
|
|
@ -173,16 +101,21 @@ export function AccessGroupsPage() {
|
|||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
return (
|
||||
<Tooltip title={record.id}>
|
||||
<Text
|
||||
ellipsis
|
||||
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
|
||||
style={{ fontSize: 14, padding: "1px 8px" }}
|
||||
onClick={() => setSelectedGroupId(record.id)}
|
||||
>
|
||||
{record.id}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="truncate text-primary bg-primary/10 hover:bg-primary/20 text-xs cursor-pointer px-2 py-0.5 rounded max-w-full text-left"
|
||||
style={{ fontSize: 14 }}
|
||||
onClick={() => setSelectedGroupId(record.id)}
|
||||
>
|
||||
{record.id}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{record.id}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
@ -203,32 +136,37 @@ export function AccessGroupsPage() {
|
|||
const mcpServerIds = record.mcpServerIds ?? [];
|
||||
const agentIds = record.agentIds ?? [];
|
||||
return (
|
||||
<Flex gap={12} align="center">
|
||||
<Tooltip title={`${modelIds?.length} Models`}>
|
||||
<Tag color="blue" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
|
||||
<Flex align="center" gap={6}>
|
||||
<LayersIcon size={14} />
|
||||
{modelIds?.length}
|
||||
</Flex>
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip title={`${mcpServerIds?.length} MCP Servers`}>
|
||||
<Tag color="cyan" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
|
||||
<Flex align="center" gap={6}>
|
||||
<ServerIcon size={14} />
|
||||
{mcpServerIds?.length}
|
||||
</Flex>
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip title={`${agentIds?.length} Agents`}>
|
||||
<Tag color="purple" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
|
||||
<Flex align="center" gap={6}>
|
||||
<BotIcon size={14} />
|
||||
{agentIds?.length}
|
||||
</Flex>
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary" className="flex items-center gap-1.5 px-2 py-0.5 text-sm">
|
||||
<LayersIcon size={14} />
|
||||
{modelIds?.length}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{`${modelIds?.length} Models`}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary" className="flex items-center gap-1.5 px-2 py-0.5 text-sm">
|
||||
<ServerIcon size={14} />
|
||||
{mcpServerIds?.length}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{`${mcpServerIds?.length} MCP Servers`}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary" className="flex items-center gap-1.5 px-2 py-0.5 text-sm">
|
||||
<BotIcon size={14} />
|
||||
{agentIds?.length}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{`${agentIds?.length} Agents`}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
@ -240,7 +178,6 @@ export function AccessGroupsPage() {
|
|||
sortingFn: "datetime",
|
||||
cell: ({ getValue }) =>
|
||||
new Date(getValue() as string).toLocaleDateString(),
|
||||
meta: { responsive: ["lg"] },
|
||||
},
|
||||
{
|
||||
id: "updatedAt",
|
||||
|
|
@ -249,29 +186,25 @@ export function AccessGroupsPage() {
|
|||
enableSorting: false,
|
||||
cell: ({ getValue }) =>
|
||||
new Date(getValue() as string).toLocaleDateString(),
|
||||
meta: { responsive: ["xl"] },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span>Actions</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<Space>
|
||||
<div className="flex gap-2">
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete access group"
|
||||
onClick={() => setGroupToDelete(row.original)}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
// setSelectedGroup is stable (useState setter)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
// ---------- TanStack table instance ----------
|
||||
const table = useReactTable<AccessGroup>({
|
||||
data: filteredGroups,
|
||||
columns: columnDefs,
|
||||
|
|
@ -282,26 +215,12 @@ export function AccessGroupsPage() {
|
|||
getRowId: (row) => row.id,
|
||||
});
|
||||
|
||||
// All sorted rows from TanStack
|
||||
const sortedRows = table.getRowModel().rows;
|
||||
|
||||
// Paginated slice
|
||||
const paginatedRows = sortedRows.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize,
|
||||
);
|
||||
|
||||
// Map for O(1) lookup by record id in antd render()
|
||||
const rowLookup = useMemo(
|
||||
() => new Map(paginatedRows.map((row) => [row.original.id, row])),
|
||||
[paginatedRows],
|
||||
);
|
||||
|
||||
// Convert TanStack headers → antd columns
|
||||
const antdColumns = buildAntdColumns(table, rowLookup, setSorting);
|
||||
|
||||
// antd dataSource (just the originals for the current page)
|
||||
const dataSource = paginatedRows.map((row) => row.original);
|
||||
const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize));
|
||||
|
||||
if (selectedGroupId) {
|
||||
return (
|
||||
|
|
@ -313,64 +232,139 @@ export function AccessGroupsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Content
|
||||
style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}
|
||||
>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
Access Groups
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
<div className="p-6 md:px-12">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold m-0">Access Groups</h2>
|
||||
<p className="text-muted-foreground text-sm m-0">
|
||||
Manage resource permissions for your organization
|
||||
</Text>
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsCreateModalVisible(true)}
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateModalVisible(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Access Group
|
||||
</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<Card styles={{ body: { padding: 0 } }}>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
prefix={<SearchIcon size={16} />}
|
||||
placeholder="Search groups by name, ID, or description..."
|
||||
style={{ maxWidth: 400 }}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={sortedRows?.length}
|
||||
pageSize={pageSize}
|
||||
onChange={(page) => setCurrentPage(page)}
|
||||
size="small"
|
||||
showTotal={(total) => `${total} groups`}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</Flex>
|
||||
<Table
|
||||
columns={antdColumns}
|
||||
dataSource={dataSource}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={false}
|
||||
/>
|
||||
<Card>
|
||||
<div className="flex justify-between items-center px-4 py-3 gap-3">
|
||||
<div className="relative max-w-md w-full">
|
||||
<SearchIcon
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search groups by name, ID, or description..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{sortedRows.length} groups</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
<span className="tabular-nums">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const canSort = header.column.getCanSort();
|
||||
const isSorted = header.column.getIsSorted();
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{ width: header.column.columnDef.size }}
|
||||
>
|
||||
<div className={cn("flex items-center gap-1")}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
{canSort && (
|
||||
<TableHeaderSortDropdown
|
||||
sortState={
|
||||
isSorted === false ? false : (isSorted as SortState)
|
||||
}
|
||||
onSortChange={(newState) => {
|
||||
if (newState === false) {
|
||||
setSorting([]);
|
||||
} else {
|
||||
setSorting([
|
||||
{
|
||||
id: header.column.id,
|
||||
desc: newState === "desc",
|
||||
},
|
||||
]);
|
||||
}
|
||||
}}
|
||||
columnId={header.column.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columnDefs.length}
|
||||
className="text-center text-muted-foreground py-8"
|
||||
>
|
||||
Loading…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paginatedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columnDefs.length}
|
||||
className="text-center text-muted-foreground py-8"
|
||||
>
|
||||
No access groups found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedRows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<AccessGroupCreateModal
|
||||
|
|
@ -399,6 +393,6 @@ export function AccessGroupsPage() {
|
|||
}}
|
||||
confirmLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</Content>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue