From 74a5bcb3a9db7afa43d96a6c51fcf537cfb64624 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:16:42 -0400 Subject: [PATCH] Add persistent combo ordering and reorder sidebar items (#1095) Integrated into release/v3.5.9 with openapi.yaml version fix --- README.md | 1 + docs/ARCHITECTURE.md | 2 +- docs/USER_GUIDE.md | 2 + docs/openapi.yaml | 2 +- src/app/(dashboard)/dashboard/combos/page.tsx | 180 +++++++++++++++--- src/app/api/combos/reorder/route.ts | 51 +++++ src/lib/db/combos.ts | 141 ++++++++++++-- src/lib/db/core.ts | 33 +++- src/lib/db/jsonMigration.ts | 21 +- .../db/migrations/020_combo_sort_order.sql | 16 ++ src/lib/localDb.ts | 1 + src/shared/constants/sidebarVisibility.ts | 8 +- src/shared/validation/schemas.ts | 14 ++ tests/e2e/combos-flow.spec.ts | 131 +++++++++++++ tests/unit/db-combos-crud.test.mjs | 38 +++- tests/unit/sidebar-visibility.test.mjs | 40 ++++ 16 files changed, 608 insertions(+), 73 deletions(-) create mode 100644 src/app/api/combos/reorder/route.ts create mode 100644 src/lib/db/migrations/020_combo_sort_order.sql create mode 100644 tests/unit/sidebar-visibility.test.mjs diff --git a/README.md b/README.md index 7bf3527b06..7e6c6b806c 100644 --- a/README.md +++ b/README.md @@ -486,6 +486,7 @@ Developers who want all responses in a specific language, with a specific tone, - **9 Routing Strategies** — Global strategies that determine how requests are distributed - **Wildcard Router** — `provider/*` patterns route dynamically to any provider - **Combo Enable/Disable Toggle** — Toggle combos directly from the dashboard +- **Manual Combo Ordering** — Drag combo cards by handle and persist the order in SQLite - **Provider Toggle** — Enable/disable all connections for a provider with one click - **Blocked Providers** — Exclude specific providers from `/v1/models` listing diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c633c9d498..05dbce91d9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -73,7 +73,7 @@ Main pages under `src/app/(dashboard)/dashboard/`: - `/dashboard` — quick start + provider overview - `/dashboard/endpoint` — endpoint proxy + MCP + A2A + API endpoint tabs - `/dashboard/providers` — provider connections and credentials -- `/dashboard/combos` — combo strategies, templates, model routing rules +- `/dashboard/combos` — combo strategies, templates, model routing rules, manual persisted ordering - `/dashboard/costs` — cost aggregation and pricing visibility - `/dashboard/analytics` — usage analytics and evaluations - `/dashboard/limits` — quota/rate controls diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e526d7c554..a42d7c29ed 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -221,6 +221,8 @@ Models: kr/claude-sonnet-4.5, kr/claude-haiku-4.5 ## 🎨 Combos +You can reorder combo cards directly in **Dashboard → Combos** by dragging the handle on each card. The order is stored in SQLite and restored on reload. + ### Example 1: Maximize Subscription → Cheap Backup ``` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 636c8967c2..a193412ad6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.5.8 + version: 3.5.9 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 8270d64ca3..fbc7513896 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -331,6 +331,13 @@ function getI18nOrFallback(t, key, fallback) { return fallback; } +function moveArrayItem(items, fromIndex, toIndex) { + const nextItems = [...items]; + const [movedItem] = nextItems.splice(fromIndex, 1); + nextItems.splice(toIndex, 0, movedItem); + return nextItems; +} + function getStrategyGuideText(t, strategy, field) { const strategyFallback = STRATEGY_GUIDANCE_FALLBACK[strategy] || STRATEGY_GUIDANCE_FALLBACK.priority; @@ -388,6 +395,9 @@ export default function CombosPage() { const [providerNodes, setProviderNodes] = useState([]); const [showUsageGuide, setShowUsageGuide] = useState(true); const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState(""); + const [comboDragIndex, setComboDragIndex] = useState(null); + const [comboDragOverIndex, setComboDragOverIndex] = useState(null); + const [savingComboOrder, setSavingComboOrder] = useState(false); useEffect(() => { fetchData(); @@ -560,6 +570,75 @@ export default function CombosPage() { } catch {} }; + const resetComboDragState = () => { + setComboDragIndex(null); + setComboDragOverIndex(null); + }; + + const handleComboDragStart = (e, index) => { + if (savingComboOrder || combos.length < 2) { + e.preventDefault(); + return; + } + setComboDragIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", combos[index]?.id || `${index}`); + if (e.currentTarget instanceof HTMLElement) { + setTimeout(() => { + e.currentTarget.style.opacity = "0.5"; + }, 0); + } + }; + + const handleComboDragEnd = (e) => { + if (e.currentTarget instanceof HTMLElement) { + e.currentTarget.style.opacity = "1"; + } + resetComboDragState(); + }; + + const handleComboDragOver = (e, index) => { + e.preventDefault(); + if (comboDragIndex === null || comboDragIndex === index) return; + e.dataTransfer.dropEffect = "move"; + setComboDragOverIndex(index); + }; + + const handleComboDrop = async (e, dropIndex) => { + e.preventDefault(); + const fromIndex = comboDragIndex; + resetComboDragState(); + + if (fromIndex === null || fromIndex === dropIndex) return; + + const previousCombos = combos; + const nextCombos = moveArrayItem(combos, fromIndex, dropIndex); + setCombos(nextCombos); + setSavingComboOrder(true); + + try { + const res = await fetch("/api/combos/reorder", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ comboIds: nextCombos.map((combo) => combo.id) }), + }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error?.message || data.error || "Failed to reorder combos"); + } + + if (Array.isArray(data.combos)) { + setCombos(data.combos); + } + } catch { + setCombos(previousCombos); + notify.error(getI18nOrFallback(t, "failedReorder", "Failed to save combo order")); + } finally { + setSavingComboOrder(false); + } + }; + if (loading) { return (
@@ -655,23 +734,34 @@ export default function CombosPage() { /> ) : (
- {combos.map((combo) => ( - ( +
setEditingCombo(combo)} - onDelete={() => handleDelete(combo.id)} - onDuplicate={() => handleDuplicate(combo)} - onTest={() => handleTestCombo(combo)} - testing={testingCombo === combo.name} - onProxy={() => setProxyTargetCombo(combo)} - hasProxy={!!proxyConfig?.combos?.[combo.id]} - onToggle={() => handleToggleCombo(combo)} - /> + data-testid={`combo-card-${combo.id}`} + onDragOver={(e) => handleComboDragOver(e, index)} + onDrop={(e) => handleComboDrop(e, index)} + > + setEditingCombo(combo)} + onDelete={() => handleDelete(combo.id)} + onDuplicate={() => handleDuplicate(combo)} + onTest={() => handleTestCombo(combo)} + testing={testingCombo === combo.name} + onProxy={() => setProxyTargetCombo(combo)} + hasProxy={!!proxyConfig?.combos?.[combo.id]} + onToggle={() => handleToggleCombo(combo)} + dragDisabled={savingComboOrder || combos.length < 2} + isDragged={comboDragIndex === index} + isDropTarget={comboDragOverIndex === index && comboDragIndex !== index} + onDragStart={(e) => handleComboDragStart(e, index)} + onDragEnd={handleComboDragEnd} + /> +
))}
)} @@ -977,6 +1067,11 @@ function ComboCard({ hasProxy, onToggle, providerNodes, + dragDisabled, + isDragged, + isDropTarget, + onDragStart, + onDragEnd, }) { const strategy = combo.strategy || "priority"; const models = combo.models || []; @@ -997,9 +1092,33 @@ function ComboCard({ }; return ( - +
+ + {/* Icon */}
layers @@ -1222,17 +1341,20 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { agentContextCache: boolean; }; - const getEmptyCreateDraftSnapshot = (): CreateDraftSnapshot => ({ - name: "", - models: [], - strategy: "priority", - config: {}, - showAdvanced: false, - nameError: "", - agentSystemMessage: "", - agentToolFilter: "", - agentContextCache: false, - }); + const getEmptyCreateDraftSnapshot = useCallback( + (): CreateDraftSnapshot => ({ + name: "", + models: [], + strategy: "priority", + config: {}, + showAdvanced: false, + nameError: "", + agentSystemMessage: "", + agentToolFilter: "", + agentContextCache: false, + }), + [] + ); const t = useTranslations("combos"); const tc = useTranslations("common"); @@ -1486,7 +1608,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { return () => { cancelled = true; }; - }, [combo, isOpen, resetFormForCombo]); + }, [combo, getEmptyCreateDraftSnapshot, isOpen, resetFormForCombo]); useEffect(() => { if (!strategyChangeMountedRef.current) { diff --git a/src/app/api/combos/reorder/route.ts b/src/app/api/combos/reorder/route.ts new file mode 100644 index 0000000000..174c87e5cd --- /dev/null +++ b/src/app/api/combos/reorder/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { reorderCombos, isCloudEnabled } from "@/lib/localDb"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/lib/cloudSync"; +import { reorderCombosSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; + +// POST /api/combos/reorder - Persist combo ordering +export async function POST(request) { + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); + } + + try { + const validation = validateBody(reorderCombosSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const combos = await reorderCombos(validation.data.comboIds); + await syncToCloudIfEnabled(); + + return NextResponse.json({ combos }); + } catch (error) { + console.log("Error reordering combos:", error); + return NextResponse.json({ error: "Failed to reorder combos" }, { status: 500 }); + } +} + +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud:", error); + } +} diff --git a/src/lib/db/combos.ts b/src/lib/db/combos.ts index d52832e284..05494bfc8b 100644 --- a/src/lib/db/combos.ts +++ b/src/lib/db/combos.ts @@ -17,33 +17,57 @@ function getSerializedData(value: unknown): string | null { return typeof row.data === "string" ? row.data : null; } +function getSortOrder(value: unknown): number | null { + const row = asRecord(value); + return typeof row.sort_order === "number" ? row.sort_order : null; +} + +function withSortOrder(payload: string, sortOrder: number | null): JsonRecord { + const parsed = JSON.parse(payload) as JsonRecord; + if (typeof sortOrder === "number") { + parsed.sortOrder = sortOrder; + } + return parsed; +} + +function getNextSortOrder() { + const db = getDbInstance(); + const row = db.prepare("SELECT COALESCE(MAX(sort_order), 0) AS sort_order FROM combos").get(); + const sortOrder = getSortOrder(row); + return (sortOrder ?? 0) + 1; +} + export async function getCombos() { const db = getDbInstance(); return db - .prepare("SELECT data FROM combos ORDER BY name") + .prepare("SELECT data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC") .all() - .map((row) => getSerializedData(row)) - .filter((row): row is string => row !== null) - .map((row) => JSON.parse(row)); + .map((row) => { + const payload = getSerializedData(row); + if (!payload) return null; + return withSortOrder(payload, getSortOrder(row)); + }) + .filter((row): row is JsonRecord => row !== null); } export async function getComboById(id: string) { const db = getDbInstance(); - const row = db.prepare("SELECT data FROM combos WHERE id = ?").get(id); + const row = db.prepare("SELECT data, sort_order FROM combos WHERE id = ?").get(id); const payload = getSerializedData(row); - return payload ? JSON.parse(payload) : null; + return payload ? withSortOrder(payload, getSortOrder(row)) : null; } export async function getComboByName(name: string) { const db = getDbInstance(); - const row = db.prepare("SELECT data FROM combos WHERE name = ?").get(name); + const row = db.prepare("SELECT data, sort_order FROM combos WHERE name = ?").get(name); const payload = getSerializedData(row); - return payload ? JSON.parse(payload) : null; + return payload ? withSortOrder(payload, getSortOrder(row)) : null; } export async function createCombo(data: JsonRecord) { const db = getDbInstance(); const now = new Date().toISOString(); + const sortOrder = typeof data.sortOrder === "number" ? data.sortOrder : getNextSortOrder(); const combo = { id: uuidv4(), @@ -52,13 +76,14 @@ export async function createCombo(data: JsonRecord) { strategy: data.strategy || "priority", config: data.config || {}, isHidden: Boolean(data.isHidden), + sortOrder, createdAt: now, updatedAt: now, }; db.prepare( - "INSERT INTO combos (id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)" - ).run(combo.id, combo.name, JSON.stringify(combo), now, now); + "INSERT INTO combos (id, name, data, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ).run(combo.id, combo.name, JSON.stringify(combo), sortOrder, now, now); backupDbFile("pre-write"); return combo; @@ -66,23 +91,99 @@ export async function createCombo(data: JsonRecord) { export async function updateCombo(id: string, data: JsonRecord) { const db = getDbInstance(); - const existing = db.prepare("SELECT data FROM combos WHERE id = ?").get(id); + const existing = db.prepare("SELECT data, sort_order FROM combos WHERE id = ?").get(id); if (!existing) return null; const serializedCurrent = getSerializedData(existing); if (!serializedCurrent) return null; - const current = JSON.parse(serializedCurrent); - const merged = { ...current, ...data, updatedAt: new Date().toISOString() }; + const current = withSortOrder(serializedCurrent, getSortOrder(existing)); + const sortOrder = + typeof data.sortOrder === "number" + ? data.sortOrder + : typeof current.sortOrder === "number" + ? current.sortOrder + : getNextSortOrder(); + const merged: JsonRecord = { + ...current, + ...data, + sortOrder, + updatedAt: new Date().toISOString(), + }; + const currentName = typeof current.name === "string" ? current.name : ""; + const nextName = + typeof merged["name"] === "string" && merged["name"].trim().length > 0 + ? merged["name"] + : currentName; - db.prepare("UPDATE combos SET name = ?, data = ?, updated_at = ? WHERE id = ?").run( - merged.name, - JSON.stringify(merged), - merged.updatedAt, - id - ); + db.prepare( + "UPDATE combos SET name = ?, data = ?, sort_order = ?, updated_at = ? WHERE id = ?" + ).run(nextName, JSON.stringify({ ...merged, name: nextName }), sortOrder, merged.updatedAt, id); backupDbFile("pre-write"); - return merged; + return { ...merged, name: nextName }; +} + +export async function reorderCombos(comboIds: string[]) { + const db = getDbInstance(); + const rows = db + .prepare( + "SELECT id, name, data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC" + ) + .all(); + if (rows.length === 0) return []; + + const existingIds = new Set( + rows + .map((row) => { + const record = asRecord(row); + return typeof record.id === "string" ? record.id : null; + }) + .filter((id): id is string => id !== null) + ); + + const seen = new Set(); + const requestedIds = comboIds.filter((id) => { + if (!existingIds.has(id) || seen.has(id)) return false; + seen.add(id); + return true; + }); + + const orderedIds = [ + ...requestedIds, + ...rows + .map((row) => { + const record = asRecord(row); + return typeof record.id === "string" ? record.id : null; + }) + .filter((id): id is string => id !== null && !seen.has(id)), + ]; + + const update = db.prepare( + "UPDATE combos SET data = ?, sort_order = ?, updated_at = ? WHERE id = ?" + ); + const now = new Date().toISOString(); + const rowById = new Map( + rows.map((row) => { + const record = asRecord(row); + return [String(record.id), row]; + }) + ); + + const reorderTransaction = db.transaction(() => { + orderedIds.forEach((id, index) => { + const row = rowById.get(id); + const payload = row ? getSerializedData(row) : null; + if (!payload) return; + const combo = withSortOrder(payload, getSortOrder(row)); + const sortOrder = index + 1; + const updatedCombo = { ...combo, sortOrder, updatedAt: now }; + update.run(JSON.stringify(updatedCombo), sortOrder, now, id); + }); + }); + + reorderTransaction(); + backupDbFile("pre-write"); + return getCombos(); } export async function deleteCombo(id: string) { diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 90e66d4420..6512505037 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -111,6 +111,7 @@ const SCHEMA_SQL = ` id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, data TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -406,6 +407,11 @@ function ensureCallLogsColumns(db: SqliteDatabase) { } } +function hasColumn(db: SqliteDatabase, tableName: string, columnName: string): boolean { + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: string }>; + return rows.some((row) => row.name === columnName); +} + export function getDbInstance(): SqliteDatabase { const existing = getDb(); if (existing) return existing; @@ -512,6 +518,12 @@ export function getDbInstance(): SqliteDatabase { INSERT OR IGNORE INTO _omniroute_migrations (version, name) VALUES ('001', 'initial_schema'); `); + if (hasColumn(db, "combos", "sort_order")) { + db.prepare("INSERT OR IGNORE INTO _omniroute_migrations (version, name) VALUES (?, ?)").run( + "020", + "combo_sort_order" + ); + } runMigrations(db); // Auto-migrate from db.json if exists @@ -702,16 +714,21 @@ function migrateFromJson(db: SqliteDatabase, jsonPath: string) { // 4. Combos const insertCombo = db.prepare(` - INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at) - VALUES (@id, @name, @data, @createdAt, @updatedAt) + INSERT OR REPLACE INTO combos (id, name, data, sort_order, created_at, updated_at) + VALUES (@id, @name, @data, @sortOrder, @createdAt, @updatedAt) `); - for (const combo of data.combos || []) { + for (const [index, combo] of (data.combos || []).entries()) { + const normalizedCombo = { + ...combo, + sortOrder: typeof combo.sortOrder === "number" ? combo.sortOrder : index + 1, + }; insertCombo.run({ - id: combo.id, - name: combo.name, - data: JSON.stringify(combo), - createdAt: combo.createdAt || new Date().toISOString(), - updatedAt: combo.updatedAt || new Date().toISOString(), + id: normalizedCombo.id, + name: normalizedCombo.name, + data: JSON.stringify(normalizedCombo), + sortOrder: normalizedCombo.sortOrder, + createdAt: normalizedCombo.createdAt || new Date().toISOString(), + updatedAt: normalizedCombo.updatedAt || new Date().toISOString(), }); } diff --git a/src/lib/db/jsonMigration.ts b/src/lib/db/jsonMigration.ts index 0ab294a561..b3128cdd9c 100644 --- a/src/lib/db/jsonMigration.ts +++ b/src/lib/db/jsonMigration.ts @@ -75,8 +75,8 @@ export function runJsonMigration( ); const insertCombo = db.prepare(` - INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at) - VALUES (@id, @name, @data, @createdAt, @updatedAt) + INSERT OR REPLACE INTO combos (id, name, data, sort_order, created_at, updated_at) + VALUES (@id, @name, @data, @sortOrder, @createdAt, @updatedAt) `); const insertKey = db.prepare(` @@ -171,13 +171,18 @@ export function runJsonMigration( } // 5. Combos - for (const combo of data.combos ?? []) { + for (const [index, combo] of (data.combos ?? []).entries()) { + const normalizedCombo = { + ...combo, + sortOrder: typeof combo.sortOrder === "number" ? combo.sortOrder : index + 1, + }; insertCombo.run({ - id: combo.id, - name: combo.name, - data: JSON.stringify(combo), - createdAt: combo.createdAt ?? new Date().toISOString(), - updatedAt: combo.updatedAt ?? new Date().toISOString(), + id: normalizedCombo.id, + name: normalizedCombo.name, + data: JSON.stringify(normalizedCombo), + sortOrder: normalizedCombo.sortOrder, + createdAt: normalizedCombo.createdAt ?? new Date().toISOString(), + updatedAt: normalizedCombo.updatedAt ?? new Date().toISOString(), }); } diff --git a/src/lib/db/migrations/020_combo_sort_order.sql b/src/lib/db/migrations/020_combo_sort_order.sql new file mode 100644 index 0000000000..4f07e4781d --- /dev/null +++ b/src/lib/db/migrations/020_combo_sort_order.sql @@ -0,0 +1,16 @@ +ALTER TABLE combos ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0; + +WITH ordered_combos AS ( + SELECT + id, + ROW_NUMBER() OVER ( + ORDER BY created_at ASC, updated_at ASC, name COLLATE NOCASE ASC + ) AS next_sort_order + FROM combos +) +UPDATE combos +SET sort_order = ( + SELECT next_sort_order + FROM ordered_combos + WHERE ordered_combos.id = combos.id +); diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 3f10285bed..d85ac54ae7 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -75,6 +75,7 @@ export { getComboByName, createCombo, updateCombo, + reorderCombos, deleteCombo, } from "./db/combos"; diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index b6e68bef2d..ebf27f05ec 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -7,8 +7,8 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "auto-combo", "costs", "analytics", - "limits", "cache", + "limits", "cli-tools", "agents", "memory", @@ -17,8 +17,8 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "playground", "media", "search-tools", - "health", "logs", + "health", "audit", "settings", "docs", @@ -55,8 +55,8 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ { id: "auto-combo", href: "/dashboard/auto-combo", i18nKey: "autoCombo", icon: "auto_awesome" }, { id: "costs", href: "/dashboard/costs", i18nKey: "costs", icon: "account_balance_wallet" }, { id: "analytics", href: "/dashboard/analytics", i18nKey: "analytics", icon: "analytics" }, - { id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" }, { id: "cache", href: "/dashboard/cache", i18nKey: "cache", icon: "cached" }, + { id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" }, { id: "media", href: "/dashboard/cache/media", i18nKey: "media", icon: "perm_media" }, ]; @@ -79,8 +79,8 @@ const DEBUG_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ ]; const SYSTEM_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ - { id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" }, { id: "logs", href: "/dashboard/logs", i18nKey: "logs", icon: "description" }, + { id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" }, { id: "audit", href: "/dashboard/audit", i18nKey: "auditLog", icon: "history" }, { id: "settings", href: "/dashboard/settings", i18nKey: "settings", icon: "settings" }, ]; diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 4b9b129d67..89391e293c 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -925,6 +925,20 @@ export const updateComboSchema = z } }); +export const reorderCombosSchema = z + .object({ + comboIds: z.array(z.string().trim().min(1).max(200)).min(1).max(1000), + }) + .superRefine((value, ctx) => { + if (new Set(value.comboIds).size !== value.comboIds.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "comboIds must be unique", + path: ["comboIds"], + }); + } + }); + export const testComboSchema = z.object({ comboName: z.string().trim().min(1, "comboName is required"), }); diff --git a/tests/e2e/combos-flow.spec.ts b/tests/e2e/combos-flow.spec.ts index dd8382c178..92a8c26c04 100644 --- a/tests/e2e/combos-flow.spec.ts +++ b/tests/e2e/combos-flow.spec.ts @@ -7,6 +7,7 @@ type ComboStub = { models: unknown[]; config: Record; isActive: boolean; + sortOrder?: number; }; type ComboCreatePayload = { @@ -210,4 +211,134 @@ test.describe("Combos flow", () => { const testResultsModal = page.getByRole("dialog").last(); await expect(testResultsModal).toContainText(/qa-test-model/i); }); + + test("allows dragging combo cards to persist manual order", async ({ page }) => { + const state: { + combos: ComboStub[]; + reorderRequests: number; + } = { + combos: [ + { + id: "combo-1", + name: "alpha-combo", + strategy: "priority", + models: ["openai/alpha"], + config: {}, + isActive: true, + sortOrder: 1, + }, + { + id: "combo-2", + name: "bravo-combo", + strategy: "priority", + models: ["openai/bravo"], + config: {}, + isActive: true, + sortOrder: 2, + }, + { + id: "combo-3", + name: "charlie-combo", + strategy: "priority", + models: ["openai/charlie"], + config: {}, + isActive: true, + sortOrder: 3, + }, + ], + reorderRequests: 0, + }; + + await page.route("**/api/combos/metrics", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ metrics: {} }), + }); + }); + + await page.route("**/api/settings/proxy", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ combos: {} }), + }); + }); + + await page.route("**/api/providers", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ connections: [] }), + }); + }); + + await page.route("**/api/provider-nodes", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ nodes: [] }), + }); + }); + + await page.route("**/api/combos/reorder", async (route) => { + state.reorderRequests += 1; + const payload = route.request().postDataJSON() as { comboIds?: string[] }; + const nextIds = Array.isArray(payload?.comboIds) ? payload.comboIds : []; + const comboById = new Map(state.combos.map((combo) => [combo.id, combo])); + state.combos = nextIds + .map((id, index) => { + const combo = comboById.get(id); + return combo ? { ...combo, sortOrder: index + 1 } : null; + }) + .filter((combo): combo is ComboStub => combo !== null); + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ combos: state.combos }), + }); + }); + + await page.route("**/api/combos", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ combos: state.combos }), + }); + }); + + await page.goto("/dashboard/combos"); + await page.waitForLoadState("networkidle"); + + const redirectedToLogin = page.url().includes("/login"); + test.skip(redirectedToLogin, "Authentication enabled without a login fixture."); + + const comboCards = page.locator('[data-testid^="combo-card-"]'); + await expect + .poll(async () => + comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid"))) + ) + .toEqual(["combo-card-combo-1", "combo-card-combo-2", "combo-card-combo-3"]); + + await page + .getByTestId("combo-drag-handle-combo-3") + .dragTo(page.getByTestId("combo-card-combo-1")); + + await expect.poll(() => state.reorderRequests).toBe(1); + await expect + .poll(async () => + comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid"))) + ) + .toEqual(["combo-card-combo-3", "combo-card-combo-1", "combo-card-combo-2"]); + + await page.reload(); + await page.waitForLoadState("networkidle"); + + await expect + .poll(async () => + comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid"))) + ) + .toEqual(["combo-card-combo-3", "combo-card-combo-1", "combo-card-combo-2"]); + }); }); diff --git a/tests/unit/db-combos-crud.test.mjs b/tests/unit/db-combos-crud.test.mjs index 8989210374..c97f9eb714 100644 --- a/tests/unit/db-combos-crud.test.mjs +++ b/tests/unit/db-combos-crud.test.mjs @@ -47,11 +47,12 @@ test("createCombo stores default strategy and supports lookup by id and name", a }); assert.equal(combo.strategy, "priority"); + assert.equal(combo.sortOrder, 1); assert.deepEqual(await combosDb.getComboById(combo.id), combo); assert.deepEqual(await combosDb.getComboByName("Priority Combo"), combo); }); -test("getCombos returns parsed combos sorted by name", async () => { +test("getCombos returns parsed combos in persisted sort order", async () => { await combosDb.createCombo({ name: "Zulu", models: [{ provider: "openai", model: "gpt-4.1" }], @@ -65,7 +66,11 @@ test("getCombos returns parsed combos sorted by name", async () => { assert.deepEqual( combos.map((combo) => combo.name), - ["Alpha", "Zulu"] + ["Zulu", "Alpha"] + ); + assert.deepEqual( + combos.map((combo) => combo.sortOrder), + [1, 2] ); }); @@ -91,6 +96,35 @@ test("updateCombo merges fields while preserving immutable data", async () => { assert.deepEqual(await combosDb.getComboById(combo.id), updated); }); +test("reorderCombos persists manual combo ordering in sqlite", async () => { + const alpha = await combosDb.createCombo({ + name: "Alpha", + models: [{ provider: "openai", model: "gpt-4.1" }], + }); + const bravo = await combosDb.createCombo({ + name: "Bravo", + models: [{ provider: "anthropic", model: "claude-3-7-sonnet" }], + }); + const charlie = await combosDb.createCombo({ + name: "Charlie", + models: [{ provider: "google", model: "gemini-2.5-pro" }], + }); + + const reordered = await combosDb.reorderCombos([charlie.id, alpha.id, bravo.id]); + + assert.deepEqual( + reordered.map((combo) => combo.name), + ["Charlie", "Alpha", "Bravo"] + ); + assert.deepEqual( + reordered.map((combo) => combo.sortOrder), + [1, 2, 3] + ); + assert.equal((await combosDb.getComboById(charlie.id))?.sortOrder, 1); + assert.equal((await combosDb.getComboById(alpha.id))?.sortOrder, 2); + assert.equal((await combosDb.getComboById(bravo.id))?.sortOrder, 3); +}); + test("deleteCombo reports missing ids and removes existing rows", async () => { const combo = await combosDb.createCombo({ name: "Delete Me", diff --git a/tests/unit/sidebar-visibility.test.mjs b/tests/unit/sidebar-visibility.test.mjs new file mode 100644 index 0000000000..78b52d9de8 --- /dev/null +++ b/tests/unit/sidebar-visibility.test.mjs @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const sidebarVisibility = await import("../../src/shared/constants/sidebarVisibility.ts"); + +test("system sidebar items place logs before health", () => { + const systemSection = sidebarVisibility.SIDEBAR_SECTIONS.find( + (section) => section.id === "system" + ); + + assert.ok(systemSection, "expected system sidebar section to exist"); + assert.deepEqual( + systemSection.items.map((item) => item.id), + ["logs", "health", "audit", "settings"] + ); +}); + +test("primary sidebar items place limits after cache", () => { + const primarySection = sidebarVisibility.SIDEBAR_SECTIONS.find( + (section) => section.id === "primary" + ); + + assert.ok(primarySection, "expected primary sidebar section to exist"); + assert.deepEqual( + primarySection.items.map((item) => item.id), + [ + "home", + "endpoints", + "api-manager", + "providers", + "combos", + "auto-combo", + "costs", + "analytics", + "cache", + "limits", + "media", + ] + ); +});