diff --git a/icons/drag.svg b/icons/drag.svg new file mode 100644 index 00000000..d9db1f3e --- /dev/null +++ b/icons/drag.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/package-lock.json b/package-lock.json index 1553f963..0b2883a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "vite-project", "version": "0.14.1", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@hey-api/client-fetch": "^0.7.1", "@jsonforms/core": "^3.5.1", "@jsonforms/react": "^3.5.1", @@ -16,7 +18,11 @@ "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", +<<<<<<< HEAD "@stacklok/ui-kit": "^1.0.1-4", +======= + "@stacklok/ui-kit": "^1.0.1-2", +>>>>>>> 79c794a (chore: add dnd-kit, uuid and upgrade ui-kit) "@tanstack/react-query": "^5.64.1", "@tanstack/react-query-devtools": "^5.66.0", "@types/lodash": "^4.17.15", @@ -39,6 +45,7 @@ "tailwind-variants": "^0.3.1", "tailwindcss-animate": "^1.0.7", "ts-pattern": "^5.6.2", + "uuid": "^11.0.5", "zod": "^3.24.1" }, "devDependencies": { @@ -416,6 +423,59 @@ "tough-cookie": "^4.1.4" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", @@ -14300,6 +14360,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index 41b7c15d..ce63d81e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "generate-icons": "npx @svgr/cli --typescript --no-dimensions --replace-attr-values '#2E323A=currentColor' --jsx-runtime automatic --out-dir ./src/components/icons/ -- icons" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@hey-api/client-fetch": "^0.7.1", "@jsonforms/core": "^3.5.1", "@jsonforms/react": "^3.5.1", @@ -52,6 +54,7 @@ "tailwind-variants": "^0.3.1", "tailwindcss-animate": "^1.0.7", "ts-pattern": "^5.6.2", + "uuid": "^11.0.5", "zod": "^3.24.1" }, "devDependencies": { diff --git a/src/api/generated/types.gen.ts b/src/api/generated/types.gen.ts index 6334e21d..5f2dc9be 100644 --- a/src/api/generated/types.gen.ts +++ b/src/api/generated/types.gen.ts @@ -35,11 +35,11 @@ export type Alert = { | { [key: string]: unknown } - | null - trigger_type: string - trigger_category: string | null - timestamp: string -} + | null; + trigger_type: string; + trigger_category: AlertSeverity; + timestamp: string; +}; /** * Represents an alert with it's respective conversation. @@ -59,6 +59,11 @@ export type AlertConversation = { timestamp: string } +export enum AlertSeverity { + INFO = "info", + CRITICAL = "critical", +} + /** * Represents a chat message. */ @@ -140,7 +145,9 @@ export type ModelByProvider = { * Represents the different types of matchers we support. */ export enum MuxMatcherType { - CATCH_ALL = 'catch_all', + CATCH_ALL = "catch_all", + FILENAME_MATCH = "filename_match", + REQUEST_TYPE_MATCH = "request_type_match", } /** diff --git a/src/api/openapi.json b/src/api/openapi.json index e587d68e..cde65b55 100644 --- a/src/api/openapi.json +++ b/src/api/openapi.json @@ -8,7 +8,9 @@ "paths": { "/health": { "get": { - "tags": ["System"], + "tags": [ + "System" + ], "summary": "Health Check", "operationId": "health_check_health_get", "responses": { @@ -25,7 +27,10 @@ }, "/api/v1/provider-endpoints": { "get": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "List Provider Endpoints", "description": "List all provider endpoints.", "operationId": "v1_list_provider_endpoints", @@ -75,7 +80,10 @@ } }, "post": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "Add Provider Endpoint", "description": "Add a provider endpoint.", "operationId": "v1_add_provider_endpoint", @@ -115,7 +123,10 @@ }, "/api/v1/provider-endpoints/models": { "get": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "List All Models For All Providers", "description": "List all models for all providers.", "operationId": "v1_list_all_models_for_all_providers", @@ -139,7 +150,10 @@ }, "/api/v1/provider-endpoints/{provider_id}/models": { "get": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "List Models By Provider", "description": "List models by provider.", "operationId": "v1_list_models_by_provider", @@ -185,7 +199,10 @@ }, "/api/v1/provider-endpoints/{provider_id}": { "get": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "Get Provider Endpoint", "description": "Get a provider endpoint by ID.", "operationId": "v1_get_provider_endpoint", @@ -225,7 +242,10 @@ } }, "put": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "Update Provider Endpoint", "description": "Update a provider endpoint by ID.", "operationId": "v1_update_provider_endpoint", @@ -275,7 +295,10 @@ } }, "delete": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "Delete Provider Endpoint", "description": "Delete a provider endpoint by id.", "operationId": "v1_delete_provider_endpoint", @@ -315,7 +338,10 @@ }, "/api/v1/provider-endpoints/{provider_id}/auth-material": { "put": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "Configure Auth Material", "description": "Configure auth material for a provider.", "operationId": "v1_configure_auth_material", @@ -360,7 +386,10 @@ }, "/api/v1/workspaces": { "get": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "List Workspaces", "description": "List all workspaces.", "operationId": "v1_list_workspaces", @@ -378,7 +407,10 @@ } }, "post": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Create Workspace", "description": "Create a new workspace.", "operationId": "v1_create_workspace", @@ -418,7 +450,10 @@ }, "/api/v1/workspaces/active": { "get": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "List Active Workspaces", "description": "List all active workspaces.\n\nIn it's current form, this function will only return one workspace. That is,\nthe globally active workspace.", "operationId": "v1_list_active_workspaces", @@ -436,7 +471,10 @@ } }, "post": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Activate Workspace", "description": "Activate a workspace by name.", "operationId": "v1_activate_workspace", @@ -485,7 +523,10 @@ }, "/api/v1/workspaces/{workspace_name}": { "delete": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Delete Workspace", "description": "Delete a workspace by name.", "operationId": "v1_delete_workspace", @@ -524,7 +565,10 @@ }, "/api/v1/workspaces/archive": { "get": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "List Archived Workspaces", "description": "List all archived workspaces.", "operationId": "v1_list_archived_workspaces", @@ -544,7 +588,10 @@ }, "/api/v1/workspaces/archive/{workspace_name}/recover": { "post": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Recover Workspace", "description": "Recover an archived workspace by name.", "operationId": "v1_recover_workspace", @@ -578,7 +625,10 @@ }, "/api/v1/workspaces/archive/{workspace_name}": { "delete": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Hard Delete Workspace", "description": "Hard delete an archived workspace by name.", "operationId": "v1_hard_delete_workspace", @@ -617,7 +667,10 @@ }, "/api/v1/workspaces/{workspace_name}/alerts": { "get": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Get Workspace Alerts", "description": "Get alerts for a workspace.", "operationId": "v1_get_workspace_alerts", @@ -669,7 +722,10 @@ }, "/api/v1/workspaces/{workspace_name}/messages": { "get": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Get Workspace Messages", "description": "Get messages for a workspace.", "operationId": "v1_get_workspace_messages", @@ -714,7 +770,10 @@ }, "/api/v1/workspaces/{workspace_name}/custom-instructions": { "get": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Get Workspace Custom Instructions", "description": "Get the custom instructions of a workspace.", "operationId": "v1_get_workspace_custom_instructions", @@ -753,7 +812,10 @@ } }, "put": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Set Workspace Custom Instructions", "operationId": "v1_set_workspace_custom_instructions", "parameters": [ @@ -794,7 +856,10 @@ } }, "delete": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Delete Workspace Custom Instructions", "operationId": "v1_delete_workspace_custom_instructions", "parameters": [ @@ -827,7 +892,11 @@ }, "/api/v1/workspaces/{workspace_name}/muxes": { "get": { - "tags": ["CodeGate API", "Workspaces", "Muxes"], + "tags": [ + "CodeGate API", + "Workspaces", + "Muxes" + ], "summary": "Get Workspace Muxes", "description": "Get the mux rules of a workspace.\n\nThe list is ordered in order of priority. That is, the first rule in the list\nhas the highest priority.", "operationId": "v1_get_workspace_muxes", @@ -870,7 +939,11 @@ } }, "put": { - "tags": ["CodeGate API", "Workspaces", "Muxes"], + "tags": [ + "CodeGate API", + "Workspaces", + "Muxes" + ], "summary": "Set Workspace Muxes", "description": "Set the mux rules of a workspace.", "operationId": "v1_set_workspace_muxes", @@ -918,7 +991,10 @@ }, "/api/v1/alerts_notification": { "get": { - "tags": ["CodeGate API", "Dashboard"], + "tags": [ + "CodeGate API", + "Dashboard" + ], "summary": "Stream Sse", "description": "Send alerts event", "operationId": "v1_stream_sse", @@ -936,7 +1012,10 @@ }, "/api/v1/version": { "get": { - "tags": ["CodeGate API", "Dashboard"], + "tags": [ + "CodeGate API", + "Dashboard" + ], "summary": "Version Check", "operationId": "v1_version_check", "responses": { @@ -953,7 +1032,11 @@ }, "/api/v1/workspaces/{workspace_name}/token-usage": { "get": { - "tags": ["CodeGate API", "Workspaces", "Token Usage"], + "tags": [ + "CodeGate API", + "Workspaces", + "Token Usage" + ], "summary": "Get Workspace Token Usage", "description": "Get the token usage of a workspace.", "operationId": "v1_get_workspace_token_usage", @@ -1003,7 +1086,9 @@ } }, "type": "object", - "required": ["name"], + "required": [ + "name" + ], "title": "ActivateWorkspaceRequest" }, "ActiveWorkspace": { @@ -1021,7 +1106,11 @@ } }, "type": "object", - "required": ["name", "is_active", "last_updated"], + "required": [ + "name", + "is_active", + "last_updated" + ], "title": "ActiveWorkspace" }, "AddProviderEndpointRequest": { @@ -1072,7 +1161,10 @@ } }, "type": "object", - "required": ["name", "provider_type"], + "required": [ + "name", + "provider_type" + ], "title": "AddProviderEndpointRequest", "description": "Represents a request to add a provider endpoint." }, @@ -1115,15 +1207,7 @@ "title": "Trigger Type" }, "trigger_category": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Trigger Category" + "$ref": "#/components/schemas/AlertSeverity" }, "timestamp": { "type": "string", @@ -1211,6 +1295,14 @@ "title": "AlertConversation", "description": "Represents an alert with it's respective conversation." }, + "AlertSeverity": { + "type": "string", + "enum": [ + "info", + "critical" + ], + "title": "AlertSeverity" + }, "ChatMessage": { "properties": { "message": { @@ -1228,7 +1320,11 @@ } }, "type": "object", - "required": ["message", "timestamp", "message_id"], + "required": [ + "message", + "timestamp", + "message_id" + ], "title": "ChatMessage", "description": "Represents a chat message." }, @@ -1281,7 +1377,11 @@ } }, "type": "object", - "required": ["code", "language", "filepath"], + "required": [ + "code", + "language", + "filepath" + ], "title": "CodeSnippet", "description": "Represents a code snippet with its programming language.\n\nArgs:\n language: The programming language identifier (e.g., 'python', 'javascript')\n code: The actual code content" }, @@ -1303,7 +1403,9 @@ } }, "type": "object", - "required": ["auth_type"], + "required": [ + "auth_type" + ], "title": "ConfigureAuthMaterial", "description": "Represents a request to configure auth material for a provider." }, @@ -1389,7 +1491,9 @@ } }, "type": "object", - "required": ["name"], + "required": [ + "name" + ], "title": "CreateOrRenameWorkspaceRequest" }, "CustomInstructions": { @@ -1400,7 +1504,9 @@ } }, "type": "object", - "required": ["prompt"], + "required": [ + "prompt" + ], "title": "CustomInstructions" }, "HTTPValidationError": { @@ -1427,7 +1533,9 @@ } }, "type": "object", - "required": ["workspaces"], + "required": [ + "workspaces" + ], "title": "ListActiveWorkspacesResponse" }, "ListWorkspacesResponse": { @@ -1441,7 +1549,9 @@ } }, "type": "object", - "required": ["workspaces"], + "required": [ + "workspaces" + ], "title": "ListWorkspacesResponse" }, "ModelByProvider": { @@ -1460,13 +1570,21 @@ } }, "type": "object", - "required": ["name", "provider_id", "provider_name"], + "required": [ + "name", + "provider_id", + "provider_name" + ], "title": "ModelByProvider", "description": "Represents a model supported by a provider.\n\nNote that these are auto-discovered by the provider." }, "MuxMatcherType": { "type": "string", - "enum": ["catch_all"], + "enum": [ + "catch_all", + "filename_match", + "request_type_match" + ], "title": "MuxMatcherType", "description": "Represents the different types of matchers we support." }, @@ -1496,13 +1614,21 @@ } }, "type": "object", - "required": ["provider_id", "model", "matcher_type"], + "required": [ + "provider_id", + "model", + "matcher_type" + ], "title": "MuxRule", "description": "Represents a mux rule for a provider." }, "ProviderAuthType": { "type": "string", - "enum": ["none", "passthrough", "api_key"], + "enum": [ + "none", + "passthrough", + "api_key" + ], "title": "ProviderAuthType", "description": "Represents the different types of auth we support for providers." }, @@ -1543,7 +1669,10 @@ } }, "type": "object", - "required": ["name", "provider_type"], + "required": [ + "name", + "provider_type" + ], "title": "ProviderEndpoint", "description": "Represents a provider's endpoint configuration. This\nallows us to persist the configuration for each provider,\nso we can use this for muxing messages." }, @@ -1578,13 +1707,19 @@ } }, "type": "object", - "required": ["question", "answer"], + "required": [ + "question", + "answer" + ], "title": "QuestionAnswer", "description": "Represents a question and answer pair." }, "QuestionType": { "type": "string", - "enum": ["chat", "fim"], + "enum": [ + "chat", + "fim" + ], "title": "QuestionType" }, "TokenUsage": { @@ -1628,7 +1763,10 @@ } }, "type": "object", - "required": ["tokens_by_model", "token_usage"], + "required": [ + "tokens_by_model", + "token_usage" + ], "title": "TokenUsageAggregate", "description": "Represents the tokens used. Includes the information of the tokens used by model.\n`used_tokens` are the total tokens used in the `tokens_by_model` list." }, @@ -1646,7 +1784,11 @@ } }, "type": "object", - "required": ["provider_type", "model", "token_usage"], + "required": [ + "provider_type", + "model", + "token_usage" + ], "title": "TokenUsageByModel", "description": "Represents the tokens used by a model." }, @@ -1676,7 +1818,11 @@ } }, "type": "object", - "required": ["loc", "msg", "type"], + "required": [ + "loc", + "msg", + "type" + ], "title": "ValidationError" }, "Workspace": { @@ -1691,7 +1837,10 @@ } }, "type": "object", - "required": ["name", "is_active"], + "required": [ + "name", + "is_active" + ], "title": "Workspace" } } diff --git a/src/components/SortableArea.tsx b/src/components/SortableArea.tsx new file mode 100644 index 00000000..a9596183 --- /dev/null +++ b/src/components/SortableArea.tsx @@ -0,0 +1,106 @@ +import { + closestCenter, + DndContext, + DragEndEvent, + KeyboardSensor, + PointerSensor, + UniqueIdentifier, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { useSortable } from '@dnd-kit/sortable' +import { Drag } from './icons' + +type Props = { + children: (item: T, index: number) => React.ReactNode + setItems: (items: T[]) => void + items: T[] + disableDragByIndex?: number +} + +function ItemWrapper({ + children, + id, + hasDragDisabled, +}: { + children: React.ReactNode + id: UniqueIdentifier + hasDragDisabled: boolean +}) { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id }) + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + return ( + + {hasDragDisabled ? ( + + ) : ( + + + + )} + {children} + + ) +} + +export function SortableArea({ + children, + setItems, + items, + disableDragByIndex, +}: Props) { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + + if (over == null) { + // The item was dropped in it's original place + return + } + + if (active.id !== over.id) { + const oldIndex = items.findIndex(({ id }) => id === active.id) + const newIndex = items.findIndex(({ id }) => id === over.id) + + setItems(arrayMove(items, oldIndex, newIndex)) + } + } + + return ( + + + {items.map((item, index) => ( + + {children(item, index)} + + ))} + + + ) +} diff --git a/src/components/icons/Drag.tsx b/src/components/icons/Drag.tsx new file mode 100644 index 00000000..2363a7f0 --- /dev/null +++ b/src/components/icons/Drag.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from "react"; +const SvgDrag = (props: SVGProps) => ( + + + +); +export default SvgDrag; diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts index 6c0e0fb2..0448acf4 100644 --- a/src/components/icons/index.ts +++ b/src/components/icons/index.ts @@ -3,3 +3,4 @@ export { default as Copilot } from './Copilot' export { default as Discord } from './Discord' export { default as Github } from './Github' export { default as Youtube } from './Youtube' +export { default as Drag } from "./Drag"; diff --git a/src/features/providers/hooks/use-invalidate-providers-queries.ts b/src/features/providers/hooks/use-invalidate-providers-queries.ts index 2967ab4c..3c147681 100644 --- a/src/features/providers/hooks/use-invalidate-providers-queries.ts +++ b/src/features/providers/hooks/use-invalidate-providers-queries.ts @@ -1,4 +1,7 @@ -import { v1ListProviderEndpointsQueryKey } from '@/api/generated/@tanstack/react-query.gen' +import { + v1ListAllModelsForAllProvidersQueryKey, + v1ListProviderEndpointsQueryKey, +} from '@/api/generated/@tanstack/react-query.gen' import { useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { invalidateQueries } from '../../../lib/react-query-utils' @@ -7,7 +10,10 @@ export function useInvalidateProvidersQueries() { const queryClient = useQueryClient() const invalidate = useCallback(async () => { - invalidateQueries(queryClient, [v1ListProviderEndpointsQueryKey]) + invalidateQueries(queryClient, [ + v1ListProviderEndpointsQueryKey, + v1ListAllModelsForAllProvidersQueryKey, + ]) }, [queryClient]) return invalidate diff --git a/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx b/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx new file mode 100644 index 00000000..b564dd12 --- /dev/null +++ b/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx @@ -0,0 +1,104 @@ +import { render } from '@/lib/test-utils' +import { screen, waitFor } from '@testing-library/react' +import { WorkspaceMuxingModel } from '../workspace-muxing-model' +import userEvent from '@testing-library/user-event' + +test('renders muxing model', async () => { + render( + + ) + expect(screen.getByText(/model muxing/i)).toBeVisible() + expect( + screen.getByText( + /select the model you would like to use in this workspace. This section applies only if you are using the MUX endpoint./i + ) + ).toBeVisible() + expect( + screen.getByRole('link', { + name: /learn more/i, + }) + ).toBeVisible() + + await userEvent.type( + screen.getByRole('textbox', { + name: /filter by/i, + }), + '.tsx' + ) + + await userEvent.click(screen.getByTestId(/workspace-models-dropdown/i)) + await userEvent.click( + screen.getByRole('option', { + name: /claude-3.6/i, + }) + ) + + expect(screen.getByRole('button', { name: /add filter/i })).toBeVisible() + expect(screen.getByRole('link', { name: /manage providers/i })).toBeVisible() + expect(screen.getByRole('button', { name: /revert changes/i })).toBeVisible() + expect(screen.getByRole('button', { name: /save/i })).toBeVisible() +}) + +test('disabled muxing fields and buttons for archived workspace', async () => { + render( + + ) + + expect(await screen.findByRole('button', { name: /save/i })).toBeDisabled() + expect(screen.getByTestId(/workspace-models-dropdown/i)).toBeDisabled() + expect( + await screen.findByRole('button', { name: /add filter/i }) + ).toBeDisabled() +}) + +test('submit additional model overrides', async () => { + render( + + ) + + expect(screen.getAllByRole('textbox', { name: /filter by/i }).length).toEqual( + 1 + ) + + await userEvent.click(screen.getByTestId(/workspace-models-dropdown/i)) + await userEvent.click( + screen.getByRole('option', { + name: /claude-3.6/i, + }) + ) + await waitFor(() => { + expect(screen.getByText(/claude-3.6/i)).toBeVisible() + }) + + await userEvent.click(screen.getByRole('button', { name: /add filter/i })) + const textFields = await screen.findAllByRole('textbox', { + name: /filter by/i, + }) + expect(textFields.length).toEqual(2) + const modelsButton = await screen.findAllByTestId( + /workspace-models-dropdown/i + ) + expect(modelsButton.length).toEqual(2) + + await userEvent.type(textFields[1] as HTMLFormElement, '.ts') + + await userEvent.click( + (await screen.findByRole('button', { + name: /select a model/i, + })) as HTMLFormElement + ) + + await userEvent.click( + screen.getByRole('option', { + name: /chatgpt-4p/i, + }) + ) + + await userEvent.click(screen.getByRole('button', { name: /save/i })) + + await waitFor(() => { + expect( + screen.getByText(/muxing rules for fake-workspace updated/i) + ).toBeVisible() + }) +}) diff --git a/src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx b/src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx deleted file mode 100644 index cd87b3c2..00000000 --- a/src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { render } from '@/lib/test-utils' -import { screen, waitFor } from '@testing-library/react' -import { WorkspacePreferredModel } from '../workspace-preferred-model' -import userEvent from '@testing-library/user-event' - -test('render model overrides', async () => { - render( - - ) - expect(screen.getByText(/preferred model/i)).toBeVisible() - expect( - screen.getByText( - /select the model you would like to use in this workspace./i - ) - ).toBeVisible() - expect( - screen.getByRole('button', { name: /select the model/i }) - ).toBeVisible() - - await waitFor(() => { - expect(screen.getByRole('button', { name: /save/i })).toBeVisible() - }) -}) - -test('submit preferred model', async () => { - render( - - ) - - await userEvent.click( - screen.getByRole('button', { name: /select the model/i }) - ) - - await userEvent.click( - screen.getByRole('option', { - name: 'anthropic/claude-3.5', - }) - ) - - await userEvent.click(screen.getByRole('button', { name: /save/i })) - - await waitFor(() => { - expect(screen.getByText(/preferred model for fake-workspace updated/i)) - }) -}) diff --git a/src/features/workspace/components/workspace-custom-instructions.tsx b/src/features/workspace/components/workspace-custom-instructions.tsx index 6d303caa..7ebb8997 100644 --- a/src/features/workspace/components/workspace-custom-instructions.tsx +++ b/src/features/workspace/components/workspace-custom-instructions.tsx @@ -125,10 +125,10 @@ function useCustomInstructionsValue({ }) { const initialFormValues = useMemo( () => ({ prompt: initialValue }), - [initialValue] - ) - const formState = useFormState(initialFormValues) - const { values, updateFormValues } = formState + [initialValue], + ); + const formState = useFormState(initialFormValues); + const { values, setInitialValues } = formState; // Subscribe to changes in the workspace system prompt value in the query cache useEffect(() => { @@ -145,14 +145,14 @@ function useCustomInstructionsValue({ const prompt: string | null = getCustomInstructionsFromEvent(event) if (prompt === values.prompt || prompt === null) return - updateFormValues({ prompt }) + setInitialValues({ prompt }); } }) return () => { - return unsubscribe() - } - }, [options, queryClient, updateFormValues, values.prompt]) + return unsubscribe(); + }; + }, [options, queryClient, setInitialValues, values.prompt]); return { ...formState } } @@ -309,15 +309,17 @@ export function WorkspaceCustomInstructions({ mutateAsync( { ...options, body: { prompt: value } }, { - onSuccess: () => + onSuccess: () => { + formState.setInitialValues({ prompt: values.prompt }); invalidateQueries(queryClient, [ v1GetWorkspaceCustomInstructionsQueryKey, - ]), - } - ) + ]); + }, + }, + ); }, - [mutateAsync, options, queryClient] - ) + [formState, mutateAsync, options, queryClient, values.prompt], + ); return ( void +} + +function groupModelsByProviderName( + models: ModelByProvider[] +): OptionsSchema<'listbox', string>[] { + return map(groupBy(models, 'provider_name'), (items, providerName) => ({ + id: providerName, + textValue: providerName, + items: items.map((item) => ({ + id: `${item.provider_id}/${item.name}`, + textValue: item.name, + })), + })) +} + +function filterModels({ + groupedModels, + searchItem, +}: { + searchItem: string + groupedModels: OptionsSchema<'listbox', string>[] +}) { + const test = groupedModels + .map((modelData) => { + if (!searchItem) return modelData + const filteredModels = modelData.items?.filter((item) => { + return item.textValue.includes(searchItem) + }) + + const data = { + ...modelData, + items: filteredModels, + } + return data + }) + .filter((item) => (item.items ? item.items.length > 0 : false)) + + return test +} + +export function WorkspaceModelsDropdown({ + rule, + isArchived, + models = [], + onChange, +}: Props) { + const [isOpen, setIsOpen] = useState(false) + const [searchItem, setSearchItem] = useState('') + const groupedModels = groupModelsByProviderName(models) + const currentProvider = models.find((p) => p.provider_id === rule.provider_id) + const currentModel = + currentProvider && rule.model + ? `${currentProvider?.provider_name}/${rule.model}` + : '' + const selectedKey = `${rule.provider_id}/${rule.model}` + + return ( + + setIsOpen(test)}> + + {currentModel || 'Select a model'} + + + + + + } /> + + + { + if (v === 'all') { + return + } + const selectedValue = v.values().next().value + if (!selectedValue && typeof selectedValue !== 'string') return + if (typeof selectedValue === 'string') { + const [provider_id, modelName] = selectedValue.split('/') + if (!provider_id || !modelName) return + onChange({ + model: modelName, + provider_id, + }) + setIsOpen(false) + } + }} + className="-mx-1 mt-2 max-h-72 overflow-auto" + renderEmptyState={() => ( + No models found + )} + > + {({ items, id, textValue }) => ( + + )} + + + + + ) +} diff --git a/src/features/workspace/components/workspace-muxing-model.tsx b/src/features/workspace/components/workspace-muxing-model.tsx new file mode 100644 index 00000000..44e8fe6b --- /dev/null +++ b/src/features/workspace/components/workspace-muxing-model.tsx @@ -0,0 +1,268 @@ +import { + Alert, + Button, + Card, + CardBody, + CardFooter, + Form, + Input, + Label, + Link, + LinkButton, + Text, + TextField, + Tooltip, + TooltipInfoButton, + TooltipTrigger, +} from '@stacklok/ui-kit' +import { twMerge } from 'tailwind-merge' +import { useMutationPreferredModelWorkspace } from '../hooks/use-mutation-preferred-model-workspace' +import { + MuxMatcherType, + V1ListAllModelsForAllProvidersResponse, +} from '@/api/generated' +import { FormEvent } from 'react' +import { + LayersThree01, + LinkExternal01, + Plus, + Trash01, +} from '@untitled-ui/icons-react' +import { SortableArea } from '@/components/SortableArea' +import { WorkspaceModelsDropdown } from './workspace-models-dropdown' +import { useQueryListAllModelsForAllProviders } from '@/hooks/use-query-list-all-models-for-all-providers' +import { useQueryMuxingRulesWorkspace } from '../hooks/use-query-muxing-rules-workspace' +import { + PreferredMuxRule, + useMuxingRulesFormState, +} from '../hooks/use-muxing-rules-form-workspace' +import { FormButtons } from '@/components/FormButtons' + +function MissingProviderBanner() { + return ( + // TODO needs to update the related ui-kit component that diverges from the design + + + Configure a provider + + + ) +} + +type SortableItemProps = { + index: number + rule: PreferredMuxRule + models: V1ListAllModelsForAllProvidersResponse + isArchived: boolean + showRemoveButton: boolean + isDefaultRule: boolean + setRuleItem: (rule: PreferredMuxRule) => void + removeRule: (index: number) => void +} + +function SortableItem({ + rule, + index, + setRuleItem, + removeRule, + models, + showRemoveButton, + isArchived, + isDefaultRule, +}: SortableItemProps) { + const placeholder = isDefaultRule ? 'Catch-all' : 'e.g. file type, file name' + return ( + + + { + setRuleItem({ ...rule, matcher }) + }} + > + + + + + + setRuleItem({ ...rule, provider_id, model }) + } + /> + {showRemoveButton && !isDefaultRule ? ( + removeRule(index)} + > + + + ) : ( + + )} + + + ) +} + +export function WorkspaceMuxingModel({ + className, + workspaceName, + isArchived, +}: { + className?: string + workspaceName: string + isArchived: boolean | undefined +}) { + const { data: muxingRules, isPending } = + useQueryMuxingRulesWorkspace(workspaceName) + const { addRule, setRules, setRuleItem, removeRule, formState } = + useMuxingRulesFormState(muxingRules) + const { + values: { rules }, + } = formState + + const { mutateAsync } = useMutationPreferredModelWorkspace() + const { data: providerModels = [] } = useQueryListAllModelsForAllProviders() + const isModelsEmpty = !isPending && providerModels.length === 0 + const showRemoveButton = rules.length > 1 + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + mutateAsync( + { + path: { workspace_name: workspaceName }, + body: rules.map(({ id, ...rest }) => { + void id + + return rest.matcher + ? { ...rest, matcher_type: MuxMatcherType.FILENAME_MATCH } + : { ...rest } + }), + }, + { + onSuccess: () => { + formState.setInitialValues({ rules }) + }, + } + ) + } + + if (isModelsEmpty) { + return ( + + + Model Muxing + + + + ) + } + + return ( + + + + + Model Muxing + + Select the model you would like to use in this workspace. This + section applies only if you are using the MUX endpoint. + + Learn more + + + + + + + + + + Filter by + + + + Filters are applied in top-down order. The first rule that + matches each prompt determines the chosen model. An empty + filter applies to all prompts. + + + + + + Preferred Model + + + + {(rule, index) => { + const isDefaultRule = rules.length - 1 === index + return ( + + ) + }} + + + + + + + Add Filter + + + + Manage providers + + + + + + + ) +} diff --git a/src/features/workspace/components/workspace-name.tsx b/src/features/workspace/components/workspace-name.tsx index 8b9ac5a2..805e2171 100644 --- a/src/features/workspace/components/workspace-name.tsx +++ b/src/features/workspace/components/workspace-name.tsx @@ -6,13 +6,13 @@ import { Input, Label, TextField, -} from '@stacklok/ui-kit' -import { useMutationCreateWorkspace } from '../hooks/use-mutation-create-workspace' -import { useNavigate } from 'react-router-dom' -import { twMerge } from 'tailwind-merge' -import { useFormState } from '@/hooks/useFormState' -import { FormButtons } from '@/components/FormButtons' -import { FormEvent } from 'react' +} from "@stacklok/ui-kit"; +import { useMutationCreateWorkspace } from "../hooks/use-mutation-create-workspace"; +import { useNavigate } from "react-router-dom"; +import { twMerge } from "tailwind-merge"; +import { useFormState } from "@/hooks/useFormState"; +import { FormButtons } from "@/components/FormButtons"; +import { FormEvent, useEffect } from "react"; export function WorkspaceName({ className, @@ -28,10 +28,14 @@ export function WorkspaceName({ const errorMsg = error?.detail ? `${error?.detail}` : '' const formState = useFormState({ workspaceName, - }) - const { values, updateFormValues } = formState - const isDefault = workspaceName === 'default' - const isUneditable = isArchived || isPending || isDefault + }); + const { values, updateFormValues, setInitialValues } = formState; + const isDefault = workspaceName === "default"; + const isUneditable = isArchived || isPending || isDefault; + + useEffect(() => { + setInitialValues({ workspaceName }); + }, [setInitialValues, workspaceName]); const handleSubmit = (event: FormEvent) => { event.preventDefault() @@ -39,10 +43,13 @@ export function WorkspaceName({ mutateAsync( { body: { name: workspaceName, rename_to: values.workspaceName } }, { - onSuccess: () => navigate(`/workspace/${values.workspaceName}`), - } - ) - } + onSuccess: () => { + formState.setInitialValues({ workspaceName: values.workspaceName }); + navigate(`/workspace/${values.workspaceName}`); + }, + }, + ); + }; return ( - - Add Provider - - - ) -} - -export function WorkspacePreferredModel({ - className, - workspaceName, - isArchived, -}: { - className?: string - workspaceName: string - isArchived: boolean | undefined -}) { - const queryClient = useQueryClient() - const { formState, isPending } = usePreferredModelWorkspace(workspaceName) - const { mutateAsync } = useMutationPreferredModelWorkspace() - const { data: providerModels = [] } = useQueryListAllModelsForAllProviders() - const isModelsEmpty = !isPending && providerModels.length === 0 - - const handleSubmit = (event: FormEvent) => { - event.preventDefault() - mutateAsync( - { - path: { workspace_name: workspaceName }, - body: [ - { - matcher: '', - matcher_type: MuxMatcherType.CATCH_ALL, - ...formState.values.preferredModel, - }, - ], - }, - { - onSuccess: () => - invalidateQueries(queryClient, [v1GetWorkspaceMuxesQueryKey]), - } - ) - } - - return ( - - - - - Preferred Model - - Select the model you would like to use in this workspace. This - section applies only if you are using the{' '} - - MUX endpoint. - - - - {isModelsEmpty && } - - - { - const preferredModelProvider = providerModels.find( - (item) => item.name === model - ) - if (preferredModelProvider) { - formState.updateFormValues({ - preferredModel: { - model: preferredModelProvider.name, - provider_id: preferredModelProvider.provider_id, - }, - }) - } - }} - items={providerModels.map((model) => ({ - textValue: `${model.provider_name}/${model.name}`, - id: model.name, - provider: model.provider_id, - }))} - > - - - - - - - - - - - ) -} diff --git a/src/features/workspace/hooks/use-mutation-create-workspace.ts b/src/features/workspace/hooks/use-mutation-create-workspace.ts index d05ac0cc..400d4f43 100644 --- a/src/features/workspace/hooks/use-mutation-create-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-create-workspace.ts @@ -1,14 +1,28 @@ -import { v1CreateWorkspaceMutation } from '@/api/generated/@tanstack/react-query.gen' -import { useInvalidateWorkspaceQueries } from './use-invalidate-workspace-queries' -import { useToastMutation } from '@/hooks/use-toast-mutation' +import { + v1CreateWorkspaceMutation, + v1GetWorkspaceCustomInstructionsQueryKey, + v1GetWorkspaceMuxesQueryKey, +} from "@/api/generated/@tanstack/react-query.gen"; +import { useInvalidateWorkspaceQueries } from "./use-invalidate-workspace-queries"; +import { useToastMutation } from "@/hooks/use-toast-mutation"; +import { useQueryClient } from "@tanstack/react-query"; +import { removeQueriesByIds } from "@/lib/react-query-utils"; export function useMutationCreateWorkspace() { - const invalidate = useInvalidateWorkspaceQueries() + const queryClient = useQueryClient(); + const invalidate = useInvalidateWorkspaceQueries(); return useToastMutation({ ...v1CreateWorkspaceMutation(), onSuccess: async () => { - await invalidate() + removeQueriesByIds({ + queryClient, + queryKeyFns: [ + v1GetWorkspaceMuxesQueryKey, + v1GetWorkspaceCustomInstructionsQueryKey, + ], + }); + await invalidate(); }, successMsg: (variables) => variables.body.rename_to diff --git a/src/features/workspace/hooks/use-mutation-preferred-model-workspace.ts b/src/features/workspace/hooks/use-mutation-preferred-model-workspace.ts index 3ae1a267..51710e87 100644 --- a/src/features/workspace/hooks/use-mutation-preferred-model-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-preferred-model-workspace.ts @@ -10,6 +10,6 @@ export function useMutationPreferredModelWorkspace() { await invalidate() }, successMsg: (variables) => - `Preferred model for ${variables.path.workspace_name} updated`, - }) + `Muxing rules for ${variables.path.workspace_name} updated`, + }); } diff --git a/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts b/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts new file mode 100644 index 00000000..59c2847c --- /dev/null +++ b/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts @@ -0,0 +1,77 @@ +import { MuxMatcherType, MuxRule } from "@/api/generated"; +import { useFormState } from "@/hooks/useFormState"; +import { isEqual } from "lodash"; +import { useCallback, useEffect, useRef } from "react"; +import { v4 as uuidv4 } from "uuid"; + +export type PreferredMuxRule = MuxRule & { id: string }; + +type MuxingRulesFormState = { + rules: PreferredMuxRule[]; +}; + +const DEFAULT_STATE: PreferredMuxRule = { + id: uuidv4(), + provider_id: "", + model: "", + matcher: "", + matcher_type: MuxMatcherType.CATCH_ALL, +}; + +export const useMuxingRulesFormState = (initialValues: MuxRule[]) => { + const formState = useFormState({ + rules: [{ ...DEFAULT_STATE, id: uuidv4() }], + }); + const { values, updateFormValues, setInitialValues } = formState; + const lastValuesRef = useRef(values.rules); + + useEffect(() => { + const newValues = + initialValues.length === 0 + ? [DEFAULT_STATE] + : initialValues.map((item) => ({ ...item, id: uuidv4() })); + + if (!isEqual(lastValuesRef.current, newValues)) { + lastValuesRef.current = newValues; + setInitialValues({ rules: newValues }); + } + }, [initialValues, setInitialValues]); + + const addRule = useCallback(() => { + const newRules = [ + ...values.rules.slice(0, values.rules.length - 1), + { ...DEFAULT_STATE, id: uuidv4() }, + ...values.rules.slice(values.rules.length - 1), + ]; + updateFormValues({ + rules: newRules, + }); + }, [updateFormValues, values.rules]); + + const setRules = useCallback( + (rules: PreferredMuxRule[]) => { + updateFormValues({ rules }); + }, + [updateFormValues], + ); + + const setRuleItem = useCallback( + (rule: PreferredMuxRule) => { + updateFormValues({ + rules: values.rules.map((item) => (item.id === rule.id ? rule : item)), + }); + }, + [updateFormValues, values.rules], + ); + + const removeRule = useCallback( + (ruleIndex: number) => { + updateFormValues({ + rules: values.rules.filter((_, index) => index !== ruleIndex), + }); + }, + [updateFormValues, values.rules], + ); + + return { addRule, setRules, setRuleItem, removeRule, values, formState }; +}; diff --git a/src/features/workspace/hooks/use-preferred-preferred-model.ts b/src/features/workspace/hooks/use-preferred-preferred-model.ts deleted file mode 100644 index 78ad4eab..00000000 --- a/src/features/workspace/hooks/use-preferred-preferred-model.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { MuxRule, V1GetWorkspaceMuxesData } from '@/api/generated' -import { v1GetWorkspaceMuxesOptions } from '@/api/generated/@tanstack/react-query.gen' -import { useFormState } from '@/hooks/useFormState' -import { useQuery } from '@tanstack/react-query' -import { useMemo } from 'react' - -type ModelRule = Omit & {} - -const DEFAULT_STATE = { - provider_id: '', - model: '', -} as const satisfies ModelRule - -const usePreferredModel = (options: { - path: { - workspace_name: string - } -}) => { - return useQuery({ - ...v1GetWorkspaceMuxesOptions(options), - }) -} - -export const usePreferredModelWorkspace = (workspaceName: string) => { - const options: V1GetWorkspaceMuxesData & - Omit = useMemo( - () => ({ - path: { workspace_name: workspaceName }, - }), - [workspaceName] - ) - const { data, isPending } = usePreferredModel(options) - const providerModel = data?.[0] - const formState = useFormState<{ preferredModel: ModelRule }>({ - preferredModel: providerModel ?? DEFAULT_STATE, - }) - - return { isPending, formState } -} diff --git a/src/features/workspace/hooks/use-query-muxing-rules-workspace.ts b/src/features/workspace/hooks/use-query-muxing-rules-workspace.ts new file mode 100644 index 00000000..b42d0fba --- /dev/null +++ b/src/features/workspace/hooks/use-query-muxing-rules-workspace.ts @@ -0,0 +1,20 @@ +import { V1GetWorkspaceMuxesData } from "@/api/generated"; +import { v1GetWorkspaceMuxesOptions } from "@/api/generated/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +export const useQueryMuxingRulesWorkspace = (workspace_name: string) => { + const options: V1GetWorkspaceMuxesData & + Omit = useMemo( + () => ({ + path: { workspace_name }, + }), + [workspace_name], + ); + + const { data = [], ...rest } = useQuery({ + ...v1GetWorkspaceMuxesOptions(options), + }); + + return { data: data, ...rest }; +}; diff --git a/src/hooks/useFormState.ts b/src/hooks/useFormState.ts index c668feda..14c0d0a2 100644 --- a/src/hooks/useFormState.ts +++ b/src/hooks/useFormState.ts @@ -1,57 +1,46 @@ -import { isEqual } from 'lodash' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { isEqual } from "lodash"; +import { useCallback, useMemo, useRef, useState } from "react"; export type FormState = { - values: T - updateFormValues: (newState: Partial) => void - resetForm: () => void - isDirty: boolean -} - -function useDeepMemo(value: T): T { - const ref = useRef(value) - if (!isEqual(ref.current, value)) { - ref.current = value - } - return ref.current -} + values: T; + updateFormValues: (newState: Partial) => void; + setInitialValues: (newState: T) => void; + resetForm: () => void; + isDirty: boolean; +}; export function useFormState>( initialValues: Values ): FormState { - const memoizedInitialValues = useDeepMemo(initialValues) - + const memoizedInitialValues = useRef(initialValues); // this could be replaced with some form library later - const [values, setValues] = useState(memoizedInitialValues) - const [originalValues, setOriginalValues] = useState(values) + const [values, setValues] = useState(memoizedInitialValues.current); - useEffect(() => { - // this logic supports the use case when the initialValues change - // due to an async request for instance - setOriginalValues(memoizedInitialValues) - setValues(memoizedInitialValues) - }, [memoizedInitialValues]) + const setInitialValues = useCallback((newInitialValues: Values) => { + memoizedInitialValues.current = newInitialValues; + setValues(newInitialValues); + }, []); const updateFormValues = useCallback((newState: Partial) => { - setValues((prevState: Values) => ({ - ...prevState, - ...newState, - })) - }, []) + setValues((prevState: Values) => { + if (isEqual(newState, prevState)) return prevState; + return { ...prevState, ...newState }; + }); + }, []); const resetForm = useCallback(() => { - setValues(originalValues) - }, [originalValues]) + setValues(memoizedInitialValues.current); + }, [memoizedInitialValues]); const isDirty = useMemo( - () => !isEqual(values, originalValues), - [values, originalValues] - ) + () => !isEqual(values, memoizedInitialValues.current), + [values, memoizedInitialValues], + ); const formState = useMemo( - () => ({ values, updateFormValues, resetForm, isDirty }), - [values, updateFormValues, resetForm, isDirty] - ) + () => ({ values, updateFormValues, resetForm, isDirty, setInitialValues }), + [values, updateFormValues, resetForm, isDirty, setInitialValues], + ); return formState } diff --git a/src/lib/react-query-utils.ts b/src/lib/react-query-utils.ts index f920ac4a..b7a33fbb 100644 --- a/src/lib/react-query-utils.ts +++ b/src/lib/react-query-utils.ts @@ -72,3 +72,25 @@ export function getQueryCacheConfig( return lifetime satisfies never } } + +export function removeQueriesByIds({ + queryClient, + queryKeyFns, +}: { + queryClient: QueryClient; + queryKeyFns: QueryKeyFn[]; +}) { + const allQueries = queryClient.getQueriesData({}); + + const queriesToRemove = allQueries.filter(([key]) => + key.some((item) => + queryKeyFns.some( + (fn) => getQueryKeyFnId(fn) === (item as { _id: string })._id, + ), + ), + ); + + return queriesToRemove.forEach(([queryKey]) => + queryClient.removeQueries({ queryKey }), + ); +} diff --git a/src/mocks/msw/handlers.ts b/src/mocks/msw/handlers.ts index 8a931761..fbf0e0f8 100644 --- a/src/mocks/msw/handlers.ts +++ b/src/mocks/msw/handlers.ts @@ -105,20 +105,8 @@ export const handlers = [ mswEndpoint('/api/v1/workspaces/:workspace_name/custom-instructions'), () => new HttpResponse(null, { status: 204 }) ), - http.get(mswEndpoint('/api/v1/workspaces/:workspace_name/muxes'), () => - HttpResponse.json([ - { - provider_id: 'openai', - model: 'gpt-3.5-turbo', - matcher_type: 'file_regex', - matcher: '.*\\.txt', - }, - { - provider_id: 'anthropic', - model: 'davinci', - matcher_type: 'catch_all', - }, - ]) + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/muxes"), () => + HttpResponse.json([]), ), http.put( mswEndpoint('/api/v1/workspaces/:workspace_name/muxes'), diff --git a/src/mocks/msw/mockers/alert.mock.ts b/src/mocks/msw/mockers/alert.mock.ts index 7d77e3cc..e8a4e262 100644 --- a/src/mocks/msw/mockers/alert.mock.ts +++ b/src/mocks/msw/mockers/alert.mock.ts @@ -1,5 +1,5 @@ -import { Alert } from '@/api/generated' -import { faker } from '@faker-js/faker' +import { Alert, AlertSeverity } from "@/api/generated"; +import { faker } from "@faker-js/faker"; const ALERT_SECRET_FIELDS = { trigger_string: 'foo', @@ -24,7 +24,7 @@ const getBaseAlert = ({ id: faker.string.uuid(), prompt_id: faker.string.uuid(), code_snippet: null, - trigger_category: 'critical', + trigger_category: AlertSeverity.CRITICAL, timestamp: timestamp, }) diff --git a/src/routes/route-workspace.tsx b/src/routes/route-workspace.tsx index 9cea7aa1..3de4e14f 100644 --- a/src/routes/route-workspace.tsx +++ b/src/routes/route-workspace.tsx @@ -1,15 +1,14 @@ import { BreadcrumbHome } from '@/components/BreadcrumbHome' import { ArchiveWorkspace } from '@/features/workspace/components/archive-workspace' - -import { PageHeading } from '@/components/heading' -import { WorkspaceName } from '@/features/workspace/components/workspace-name' -import { Alert, Breadcrumb, Breadcrumbs } from '@stacklok/ui-kit' -import { useParams } from 'react-router-dom' -import { useArchivedWorkspaces } from '@/features/workspace/hooks/use-archived-workspaces' -import { useRestoreWorkspaceButton } from '@/features/workspace/hooks/use-restore-workspace-button' -import { WorkspaceCustomInstructions } from '@/features/workspace/components/workspace-custom-instructions' -import { WorkspacePreferredModel } from '@/features/workspace/components/workspace-preferred-model' -import { PageContainer } from '@/components/page-container' +import { PageHeading } from "@/components/heading"; +import { WorkspaceName } from "@/features/workspace/components/workspace-name"; +import { Alert, Breadcrumb, Breadcrumbs } from "@stacklok/ui-kit"; +import { useParams } from "react-router-dom"; +import { useArchivedWorkspaces } from "@/features/workspace/hooks/use-archived-workspaces"; +import { useRestoreWorkspaceButton } from "@/features/workspace/hooks/use-restore-workspace-button"; +import { WorkspaceCustomInstructions } from "@/features/workspace/components/workspace-custom-instructions"; +import { WorkspaceMuxingModel } from "@/features/workspace/components/workspace-muxing-model"; +import { PageContainer } from "@/components/page-container"; function WorkspaceArchivedBanner({ name }: { name: string }) { const restoreButtonProps = useRestoreWorkspaceButton({ workspaceName: name }) @@ -54,7 +53,7 @@ export function RouteWorkspace() { className="mb-4" workspaceName={name} /> -
No models found