Better UX for adding new API config profiles

This commit is contained in:
Roo Code 2025-02-07 15:57:35 -05:00
parent 14d7691ded
commit 85dacb03a3
3 changed files with 399 additions and 83 deletions

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Improve the user experience for adding a new configuration profile

View file

@ -1,8 +1,9 @@
import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { memo, useEffect, useRef, useState } from "react"
import { memo, useEffect, useReducer, useRef } from "react"
import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage"
import { Dropdown } from "vscrui"
import type { DropdownOption } from "vscrui"
import { Dialog, DialogContent } from "../ui/dialog"
interface ApiConfigManagerProps {
currentApiConfigName?: string
@ -13,6 +14,86 @@ interface ApiConfigManagerProps {
onUpsertConfig: (configName: string) => void
}
type State = {
isRenaming: boolean
isCreating: boolean
inputValue: string
newProfileName: string
error: string | null
}
type Action =
| { type: "START_RENAME"; payload: string }
| { type: "CANCEL_EDIT" }
| { type: "SET_INPUT"; payload: string }
| { type: "SET_NEW_NAME"; payload: string }
| { type: "START_CREATE" }
| { type: "CANCEL_CREATE" }
| { type: "SET_ERROR"; payload: string | null }
| { type: "RESET_STATE" }
const initialState: State = {
isRenaming: false,
isCreating: false,
inputValue: "",
newProfileName: "",
error: null,
}
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "START_RENAME":
return {
...state,
isRenaming: true,
inputValue: action.payload,
error: null,
}
case "CANCEL_EDIT":
return {
...state,
isRenaming: false,
inputValue: "",
error: null,
}
case "SET_INPUT":
return {
...state,
inputValue: action.payload,
error: null,
}
case "SET_NEW_NAME":
return {
...state,
newProfileName: action.payload,
error: null,
}
case "START_CREATE":
return {
...state,
isCreating: true,
newProfileName: "",
error: null,
}
case "CANCEL_CREATE":
return {
...state,
isCreating: false,
newProfileName: "",
error: null,
}
case "SET_ERROR":
return {
...state,
error: action.payload,
}
case "RESET_STATE":
return initialState
default:
return state
}
}
const ApiConfigManager = ({
currentApiConfigName = "",
listApiConfigMeta = [],
@ -21,55 +102,93 @@ const ApiConfigManager = ({
onRenameConfig,
onUpsertConfig,
}: ApiConfigManagerProps) => {
const [editState, setEditState] = useState<"new" | "rename" | null>(null)
const [inputValue, setInputValue] = useState("")
const inputRef = useRef<HTMLInputElement>()
const [state, dispatch] = useReducer(reducer, initialState)
const inputRef = useRef<any>(null)
const newProfileInputRef = useRef<any>(null)
// Focus input when entering edit mode
useEffect(() => {
if (editState) {
setTimeout(() => inputRef.current?.focus(), 0)
const validateName = (name: string, isNewProfile: boolean): string | null => {
const trimmed = name.trim()
if (!trimmed) return "Name cannot be empty"
const nameExists = listApiConfigMeta?.some((config) => config.name.toLowerCase() === trimmed.toLowerCase())
// For new profiles, any existing name is invalid
if (isNewProfile && nameExists) {
return "A profile with this name already exists"
}
}, [editState])
// Reset edit state when current profile changes
// For rename, only block if trying to rename to a different existing profile
if (!isNewProfile && nameExists && trimmed.toLowerCase() !== currentApiConfigName?.toLowerCase()) {
return "A profile with this name already exists"
}
return null
}
// Focus input when entering rename mode
useEffect(() => {
setEditState(null)
setInputValue("")
if (state.isRenaming) {
const timeoutId = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(timeoutId)
}
}, [state.isRenaming])
// Focus input when opening new dialog
useEffect(() => {
if (state.isCreating) {
const timeoutId = setTimeout(() => newProfileInputRef.current?.focus(), 0)
return () => clearTimeout(timeoutId)
}
}, [state.isCreating])
// Reset state when current profile changes
useEffect(() => {
dispatch({ type: "RESET_STATE" })
}, [currentApiConfigName])
const handleAdd = () => {
const newConfigName = currentApiConfigName + " (copy)"
onUpsertConfig(newConfigName)
dispatch({ type: "START_CREATE" })
}
const handleStartRename = () => {
setEditState("rename")
setInputValue(currentApiConfigName || "")
dispatch({ type: "START_RENAME", payload: currentApiConfigName || "" })
}
const handleCancel = () => {
setEditState(null)
setInputValue("")
dispatch({ type: "CANCEL_EDIT" })
}
const handleSave = () => {
const trimmedValue = inputValue.trim()
if (!trimmedValue) return
const trimmedValue = state.inputValue.trim()
const error = validateName(trimmedValue, false)
if (editState === "new") {
onUpsertConfig(trimmedValue)
} else if (editState === "rename" && currentApiConfigName) {
if (error) {
dispatch({ type: "SET_ERROR", payload: error })
return
}
if (state.isRenaming && currentApiConfigName) {
if (currentApiConfigName === trimmedValue) {
setEditState(null)
setInputValue("")
dispatch({ type: "CANCEL_EDIT" })
return
}
onRenameConfig(currentApiConfigName, trimmedValue)
}
setEditState(null)
setInputValue("")
dispatch({ type: "CANCEL_EDIT" })
}
const handleNewProfileSave = () => {
const trimmedValue = state.newProfileName.trim()
const error = validateName(trimmedValue, true)
if (error) {
dispatch({ type: "SET_ERROR", payload: error })
return
}
onUpsertConfig(trimmedValue)
dispatch({ type: "CANCEL_CREATE" })
}
const handleDelete = () => {
@ -93,49 +212,62 @@ const ApiConfigManager = ({
<span style={{ fontWeight: "500" }}>Configuration Profile</span>
</label>
{editState ? (
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
<VSCodeTextField
ref={inputRef as any}
value={inputValue}
onInput={(e: any) => setInputValue(e.target.value)}
placeholder={editState === "new" ? "Enter profile name" : "Enter new name"}
style={{ flexGrow: 1 }}
onKeyDown={(e: any) => {
if (e.key === "Enter" && inputValue.trim()) {
handleSave()
} else if (e.key === "Escape") {
handleCancel()
}
}}
/>
<VSCodeButton
appearance="icon"
disabled={!inputValue.trim()}
onClick={handleSave}
title="Save"
style={{
padding: 0,
margin: 0,
height: "28px",
width: "28px",
minWidth: "28px",
}}>
<span className="codicon codicon-check" />
</VSCodeButton>
<VSCodeButton
appearance="icon"
onClick={handleCancel}
title="Cancel"
style={{
padding: 0,
margin: 0,
height: "28px",
width: "28px",
minWidth: "28px",
}}>
<span className="codicon codicon-close" />
</VSCodeButton>
{state.isRenaming ? (
<div
data-testid="rename-form"
style={{ display: "flex", gap: "4px", alignItems: "center", flexDirection: "column" }}>
<div style={{ display: "flex", gap: "4px", alignItems: "center", width: "100%" }}>
<VSCodeTextField
ref={inputRef}
value={state.inputValue}
onInput={(e: unknown) => {
const target = e as { target: { value: string } }
dispatch({ type: "SET_INPUT", payload: target.target.value })
}}
placeholder="Enter new name"
style={{ flexGrow: 1 }}
onKeyDown={(e: unknown) => {
const event = e as { key: string }
if (event.key === "Enter" && state.inputValue.trim()) {
handleSave()
} else if (event.key === "Escape") {
handleCancel()
}
}}
/>
<VSCodeButton
appearance="icon"
disabled={!state.inputValue.trim()}
onClick={handleSave}
title="Save"
style={{
padding: 0,
margin: 0,
height: "28px",
width: "28px",
minWidth: "28px",
}}>
<span className="codicon codicon-check" />
</VSCodeButton>
<VSCodeButton
appearance="icon"
onClick={handleCancel}
title="Cancel"
style={{
padding: 0,
margin: 0,
height: "28px",
width: "28px",
minWidth: "28px",
}}>
<span className="codicon codicon-close" />
</VSCodeButton>
</div>
{state.error && (
<p className="text-red-500 text-sm mt-2" data-testid="error-message">
{state.error}
</p>
)}
</div>
) : (
<>
@ -211,6 +343,57 @@ const ApiConfigManager = ({
</p>
</>
)}
<Dialog
open={state.isCreating}
onOpenChange={(open: boolean) => dispatch({ type: open ? "START_CREATE" : "CANCEL_CREATE" })}
aria-labelledby="new-profile-title">
<DialogContent className="p-4 max-w-sm">
<h2 id="new-profile-title" className="text-lg font-semibold mb-4">
New Configuration Profile
</h2>
<button
className="absolute right-4 top-4"
aria-label="Close dialog"
onClick={() => dispatch({ type: "CANCEL_CREATE" })}>
<span className="codicon codicon-close" />
</button>
<VSCodeTextField
ref={newProfileInputRef}
value={state.newProfileName}
onInput={(e: unknown) => {
const target = e as { target: { value: string } }
dispatch({ type: "SET_NEW_NAME", payload: target.target.value })
}}
placeholder="Enter profile name"
style={{ width: "100%" }}
onKeyDown={(e: unknown) => {
const event = e as { key: string }
if (event.key === "Enter" && state.newProfileName.trim()) {
handleNewProfileSave()
} else if (event.key === "Escape") {
dispatch({ type: "CANCEL_CREATE" })
}
}}
/>
{state.error && (
<p className="text-red-500 text-sm mt-2" data-testid="error-message">
{state.error}
</p>
)}
<div className="flex justify-end gap-2 mt-4">
<VSCodeButton appearance="secondary" onClick={() => dispatch({ type: "CANCEL_CREATE" })}>
Cancel
</VSCodeButton>
<VSCodeButton
appearance="primary"
disabled={!state.newProfileName.trim()}
onClick={handleNewProfileSave}>
Create Profile
</VSCodeButton>
</div>
</DialogContent>
</Dialog>
</div>
</div>
)

View file

@ -1,4 +1,4 @@
import { render, screen, fireEvent } from "@testing-library/react"
import { render, screen, fireEvent, within } from "@testing-library/react"
import ApiConfigManager from "../ApiConfigManager"
// Mock VSCode components
@ -8,11 +8,12 @@ jest.mock("@vscode/webview-ui-toolkit/react", () => ({
{children}
</button>
),
VSCodeTextField: ({ value, onInput, placeholder }: any) => (
VSCodeTextField: ({ value, onInput, placeholder, onKeyDown }: any) => (
<input
value={value}
onChange={(e) => onInput(e)}
placeholder={placeholder}
onKeyDown={onKeyDown}
ref={undefined} // Explicitly set ref to undefined to avoid warning
/>
),
@ -32,6 +33,16 @@ jest.mock("vscrui", () => ({
),
}))
// Mock Dialog component
jest.mock("@/components/ui/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) => (
<div role="dialog" aria-modal="true" style={{ display: open ? "block" : "none" }} data-testid="dialog">
{children}
</div>
),
DialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
}))
describe("ApiConfigManager", () => {
const mockOnSelectConfig = jest.fn()
const mockOnDeleteConfig = jest.fn()
@ -54,34 +65,74 @@ describe("ApiConfigManager", () => {
jest.clearAllMocks()
})
it("immediately creates a copy when clicking add button", () => {
const getRenameForm = () => screen.getByTestId("rename-form")
const getDialogContent = () => screen.getByTestId("dialog-content")
it("opens new profile dialog when clicking add button", () => {
render(<ApiConfigManager {...defaultProps} />)
// Find and click the add button
const addButton = screen.getByTitle("Add profile")
fireEvent.click(addButton)
// Verify that onUpsertConfig was called with the correct name
expect(mockOnUpsertConfig).toHaveBeenCalledTimes(1)
expect(mockOnUpsertConfig).toHaveBeenCalledWith("Default Config (copy)")
expect(screen.getByTestId("dialog")).toBeVisible()
expect(screen.getByText("New Configuration Profile")).toBeInTheDocument()
})
it("creates copy with correct name when current config has spaces", () => {
render(<ApiConfigManager {...defaultProps} currentApiConfigName="My Test Config" />)
it("creates new profile with entered name", () => {
render(<ApiConfigManager {...defaultProps} />)
// Open dialog
const addButton = screen.getByTitle("Add profile")
fireEvent.click(addButton)
expect(mockOnUpsertConfig).toHaveBeenCalledWith("My Test Config (copy)")
// Enter new profile name
const input = screen.getByPlaceholderText("Enter profile name")
fireEvent.input(input, { target: { value: "New Profile" } })
// Click create button
const createButton = screen.getByText("Create Profile")
fireEvent.click(createButton)
expect(mockOnUpsertConfig).toHaveBeenCalledWith("New Profile")
})
it("handles empty current config name gracefully", () => {
render(<ApiConfigManager {...defaultProps} currentApiConfigName="" />)
it("shows error when creating profile with existing name", () => {
render(<ApiConfigManager {...defaultProps} />)
// Open dialog
const addButton = screen.getByTitle("Add profile")
fireEvent.click(addButton)
expect(mockOnUpsertConfig).toHaveBeenCalledWith(" (copy)")
// Enter existing profile name
const input = screen.getByPlaceholderText("Enter profile name")
fireEvent.input(input, { target: { value: "Default Config" } })
// Click create button to trigger validation
const createButton = screen.getByText("Create Profile")
fireEvent.click(createButton)
// Verify error message
const dialogContent = getDialogContent()
const errorMessage = within(dialogContent).getByTestId("error-message")
expect(errorMessage).toHaveTextContent("A profile with this name already exists")
expect(mockOnUpsertConfig).not.toHaveBeenCalled()
})
it("prevents creating profile with empty name", () => {
render(<ApiConfigManager {...defaultProps} />)
// Open dialog
const addButton = screen.getByTitle("Add profile")
fireEvent.click(addButton)
// Enter empty name
const input = screen.getByPlaceholderText("Enter profile name")
fireEvent.input(input, { target: { value: " " } })
// Verify create button is disabled
const createButton = screen.getByText("Create Profile")
expect(createButton).toBeDisabled()
expect(mockOnUpsertConfig).not.toHaveBeenCalled()
})
it("allows renaming the current config", () => {
@ -102,6 +153,45 @@ describe("ApiConfigManager", () => {
expect(mockOnRenameConfig).toHaveBeenCalledWith("Default Config", "New Name")
})
it("shows error when renaming to existing config name", () => {
render(<ApiConfigManager {...defaultProps} />)
// Start rename
const renameButton = screen.getByTitle("Rename profile")
fireEvent.click(renameButton)
// Find input and enter existing name
const input = screen.getByDisplayValue("Default Config")
fireEvent.input(input, { target: { value: "Another Config" } })
// Save to trigger validation
const saveButton = screen.getByTitle("Save")
fireEvent.click(saveButton)
// Verify error message
const renameForm = getRenameForm()
const errorMessage = within(renameForm).getByTestId("error-message")
expect(errorMessage).toHaveTextContent("A profile with this name already exists")
expect(mockOnRenameConfig).not.toHaveBeenCalled()
})
it("prevents renaming to empty name", () => {
render(<ApiConfigManager {...defaultProps} />)
// Start rename
const renameButton = screen.getByTitle("Rename profile")
fireEvent.click(renameButton)
// Find input and enter empty name
const input = screen.getByDisplayValue("Default Config")
fireEvent.input(input, { target: { value: " " } })
// Verify save button is disabled
const saveButton = screen.getByTitle("Save")
expect(saveButton).toBeDisabled()
expect(mockOnRenameConfig).not.toHaveBeenCalled()
})
it("allows selecting a different config", () => {
render(<ApiConfigManager {...defaultProps} />)
@ -149,4 +239,42 @@ describe("ApiConfigManager", () => {
// Verify we're back to normal view
expect(screen.queryByDisplayValue("New Name")).not.toBeInTheDocument()
})
it("handles keyboard events in new profile dialog", () => {
render(<ApiConfigManager {...defaultProps} />)
// Open dialog
const addButton = screen.getByTitle("Add profile")
fireEvent.click(addButton)
const input = screen.getByPlaceholderText("Enter profile name")
// Test Enter key
fireEvent.input(input, { target: { value: "New Profile" } })
fireEvent.keyDown(input, { key: "Enter" })
expect(mockOnUpsertConfig).toHaveBeenCalledWith("New Profile")
// Test Escape key
fireEvent.keyDown(input, { key: "Escape" })
expect(screen.getByTestId("dialog")).not.toBeVisible()
})
it("handles keyboard events in rename mode", () => {
render(<ApiConfigManager {...defaultProps} />)
// Start rename
const renameButton = screen.getByTitle("Rename profile")
fireEvent.click(renameButton)
const input = screen.getByDisplayValue("Default Config")
// Test Enter key
fireEvent.input(input, { target: { value: "New Name" } })
fireEvent.keyDown(input, { key: "Enter" })
expect(mockOnRenameConfig).toHaveBeenCalledWith("Default Config", "New Name")
// Test Escape key
fireEvent.keyDown(input, { key: "Escape" })
expect(screen.queryByDisplayValue("New Name")).not.toBeInTheDocument()
})
})