mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(ui): scope prompt row delete and info view to the row's environment
This commit is contained in:
parent
78e7262722
commit
dcba64ab72
8 changed files with 117 additions and 34 deletions
|
|
@ -65,11 +65,13 @@ describe("PromptTable", () => {
|
|||
expect(within(rows[1]).getByText("prompt-older")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onPromptClick when the prompt ID is clicked", async () => {
|
||||
it("should call onPromptClick with the row's environment, defaulting to development", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PromptTable {...defaultProps} />);
|
||||
await user.click(screen.getByRole("button", { name: "prompt-newer" }));
|
||||
expect(mockOnPromptClick).toHaveBeenCalledWith("prompt-newer");
|
||||
expect(mockOnPromptClick).toHaveBeenCalledWith("prompt-newer", "production");
|
||||
await user.click(screen.getByRole("button", { name: "prompt-older" }));
|
||||
expect(mockOnPromptClick).toHaveBeenCalledWith("prompt-older", "development");
|
||||
});
|
||||
|
||||
it("should label the environment and default missing environments to development", () => {
|
||||
|
|
@ -83,7 +85,7 @@ describe("PromptTable", () => {
|
|||
render(<PromptTable {...defaultProps} />);
|
||||
await user.click(screen.getByTestId("prompt-actions-prompt-newer"));
|
||||
await user.click(await screen.findByTestId("prompt-action-delete"));
|
||||
expect(mockOnDeleteClick).toHaveBeenCalledWith("prompt-newer", "prompt-newer");
|
||||
expect(mockOnDeleteClick).toHaveBeenCalledWith("prompt-newer", "prompt-newer", "production");
|
||||
});
|
||||
|
||||
it("should copy the prompt ID through the actions menu", async () => {
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import { ModelGroupInfo } from "./prompt_utils";
|
|||
interface PromptTableProps {
|
||||
promptsList: PromptSpec[];
|
||||
isLoading: boolean;
|
||||
onPromptClick?: (id: string) => void;
|
||||
onDeleteClick?: (id: string, name: string) => void;
|
||||
onPromptClick?: (id: string, environment: string) => void;
|
||||
onDeleteClick?: (id: string, name: string, environment: string) => void;
|
||||
accessToken: string | null;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ function PromptModelCell({ prompt, modelHubData }: { prompt: PromptSpec; modelHu
|
|||
interface PromptRowActionsProps {
|
||||
prompt: PromptSpec;
|
||||
isAdmin: boolean;
|
||||
onDeleteClick?: (id: string, name: string) => void;
|
||||
onDeleteClick?: (id: string, name: string, environment: string) => void;
|
||||
}
|
||||
|
||||
function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsProps) {
|
||||
|
|
@ -91,7 +91,13 @@ function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsPr
|
|||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
data-testid="prompt-action-delete"
|
||||
onClick={() => onDeleteClick?.(prompt.prompt_id, prompt.prompt_id || "Unknown Prompt")}
|
||||
onClick={() =>
|
||||
onDeleteClick?.(
|
||||
prompt.prompt_id,
|
||||
prompt.prompt_id || "Unknown Prompt",
|
||||
prompt.environment || "development",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
|
|
@ -106,8 +112,8 @@ function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsPr
|
|||
interface PromptTableColumnsDeps {
|
||||
modelHubData: Map<string, ModelGroupInfo>;
|
||||
isAdmin: boolean;
|
||||
onPromptClick?: (id: string) => void;
|
||||
onDeleteClick?: (id: string, name: string) => void;
|
||||
onPromptClick?: (id: string, environment: string) => void;
|
||||
onDeleteClick?: (id: string, name: string, environment: string) => void;
|
||||
}
|
||||
|
||||
export const getPromptTableColumns = ({
|
||||
|
|
@ -128,7 +134,11 @@ export const getPromptTableColumns = ({
|
|||
title={row.original.prompt_id}
|
||||
titleClassName="font-mono text-xs font-normal"
|
||||
className="max-w-60"
|
||||
onClick={onPromptClick ? () => onPromptClick(row.original.prompt_id) : undefined}
|
||||
onClick={
|
||||
onPromptClick
|
||||
? () => onPromptClick(row.original.prompt_id, row.original.environment || "development")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,21 +15,31 @@ vi.mock("./PromptTable", () => ({
|
|||
__esModule: true,
|
||||
default: ({
|
||||
isLoading,
|
||||
onPromptClick,
|
||||
onDeleteClick,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
onDeleteClick: (id: string, name: string) => void;
|
||||
onPromptClick: (id: string, environment: string) => void;
|
||||
onDeleteClick: (id: string, name: string, environment: string) => void;
|
||||
}) => (
|
||||
<div data-testid="prompt-table">
|
||||
{isLoading ? "table-loading" : "table-loaded"}
|
||||
<button type="button" onClick={() => onDeleteClick("prompt-1", "my-prompt")}>
|
||||
<button type="button" onClick={() => onPromptClick("prompt-1", "staging")}>
|
||||
row-open
|
||||
</button>
|
||||
<button type="button" onClick={() => onDeleteClick("prompt-1", "my-prompt", "staging")}>
|
||||
row-delete
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./prompt_info", () => ({ __esModule: true, default: () => <div>prompt-info-view</div> }));
|
||||
vi.mock("./prompt_info", () => ({
|
||||
__esModule: true,
|
||||
default: ({ initialEnvironment }: { initialEnvironment?: string }) => (
|
||||
<div>prompt-info-view:{initialEnvironment ?? "none"}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./add_prompt_form", () => ({
|
||||
__esModule: true,
|
||||
default: ({ visible }: { visible: boolean }) => (visible ? <div>add-prompt-form</div> : null),
|
||||
|
|
@ -143,6 +153,22 @@ describe("PromptsPanel toolbar", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("PromptsPanel row navigation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetPromptsList.mockResolvedValue({ prompts: [] } as never);
|
||||
});
|
||||
|
||||
it("should open the info view preselected to the clicked row's environment", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel("Admin");
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "row-open" }));
|
||||
|
||||
expect(screen.getByText("prompt-info-view:staging")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PromptsPanel delete confirmation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -156,13 +182,13 @@ describe("PromptsPanel delete confirmation", () => {
|
|||
|
||||
await user.click(await screen.findByRole("button", { name: "row-delete" }));
|
||||
|
||||
expect(await screen.findByText(/delete prompt: my-prompt/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/the staging copy of prompt: my-prompt/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/cannot be undone/i)).toBeInTheDocument();
|
||||
expect(mockDeletePromptCall).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^delete$/i }));
|
||||
|
||||
await waitFor(() => expect(mockDeletePromptCall).toHaveBeenCalledWith("sk-test", "prompt-1"));
|
||||
await waitFor(() => expect(mockDeletePromptCall).toHaveBeenCalledWith("sk-test", "prompt-1", "staging"));
|
||||
});
|
||||
|
||||
it("should abandon the delete when the confirmation is dismissed", async () => {
|
||||
|
|
@ -170,11 +196,11 @@ describe("PromptsPanel delete confirmation", () => {
|
|||
renderPanel("Admin");
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "row-delete" }));
|
||||
await screen.findByText(/delete prompt: my-prompt/i);
|
||||
await screen.findByText(/the staging copy of prompt: my-prompt/i);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/delete prompt: my-prompt/i)).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByText(/the staging copy of prompt: my-prompt/i)).not.toBeInTheDocument());
|
||||
expect(mockDeletePromptCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -189,14 +215,14 @@ describe("PromptsPanel delete confirmation", () => {
|
|||
renderPanel("Admin");
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "row-delete" }));
|
||||
await screen.findByText(/delete prompt: my-prompt/i);
|
||||
await screen.findByText(/the staging copy of prompt: my-prompt/i);
|
||||
await user.click(screen.getByRole("button", { name: /^delete$/i }));
|
||||
await waitFor(() => expect(mockDeletePromptCall).toHaveBeenCalledWith("sk-test", "prompt-1"));
|
||||
await waitFor(() => expect(mockDeletePromptCall).toHaveBeenCalledWith("sk-test", "prompt-1", "staging"));
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.getByText(/delete prompt: my-prompt/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/the staging copy of prompt: my-prompt/i)).toBeInTheDocument();
|
||||
|
||||
finishDelete();
|
||||
await waitFor(() => expect(screen.queryByText(/delete prompt: my-prompt/i)).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByText(/the staging copy of prompt: my-prompt/i)).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,11 +41,12 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState<string | undefined>(undefined);
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
|
||||
const [selectedPromptEnvironment, setSelectedPromptEnvironment] = useState<string | undefined>(undefined);
|
||||
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
|
||||
const [showEditorView, setShowEditorView] = useState(false);
|
||||
const [editPromptData, setEditPromptData] = useState<any>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [promptToDelete, setPromptToDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
const [promptToDelete, setPromptToDelete] = useState<{ id: string; name: string; environment: string } | null>(null);
|
||||
|
||||
// Admin Viewer follows the read-parity rule: see prompts, no writes.
|
||||
const canModify = userRole ? isProxyAdminRole(userRole) : false;
|
||||
|
|
@ -71,8 +72,9 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
|
|||
fetchPrompts();
|
||||
}, [accessToken, selectedEnvironment]);
|
||||
|
||||
const handlePromptClick = (promptId: string) => {
|
||||
const handlePromptClick = (promptId: string, environment: string) => {
|
||||
setSelectedPromptId(promptId);
|
||||
setSelectedPromptEnvironment(environment);
|
||||
};
|
||||
|
||||
const handleAddPrompt = () => {
|
||||
|
|
@ -111,8 +113,8 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
|
|||
setSelectedPromptId(null);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (promptId: string, promptName: string) => {
|
||||
setPromptToDelete({ id: promptId, name: promptName });
|
||||
const handleDeleteClick = (promptId: string, promptName: string, environment: string) => {
|
||||
setPromptToDelete({ id: promptId, name: promptName, environment });
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
|
|
@ -120,8 +122,8 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
|
|||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deletePromptCall(accessToken, promptToDelete.id);
|
||||
toast.success(`Prompt "${promptToDelete.name}" deleted successfully`);
|
||||
await deletePromptCall(accessToken, promptToDelete.id, promptToDelete.environment);
|
||||
toast.success(`Prompt "${promptToDelete.name}" deleted successfully from ${promptToDelete.environment}`);
|
||||
fetchPrompts(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Error deleting prompt:", error);
|
||||
|
|
@ -148,6 +150,7 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
|
|||
) : selectedPromptId ? (
|
||||
<PromptInfoView
|
||||
promptId={selectedPromptId}
|
||||
initialEnvironment={selectedPromptEnvironment}
|
||||
onClose={() => setSelectedPromptId(null)}
|
||||
accessToken={accessToken}
|
||||
isAdmin={canModify}
|
||||
|
|
@ -219,7 +222,8 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Prompt</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete prompt: {promptToDelete.name} ? This action cannot be undone.
|
||||
Are you sure you want to delete the {promptToDelete.environment} copy of prompt: {promptToDelete.name}?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,35 @@ const promptWithoutTemplate = {
|
|||
environments: [],
|
||||
};
|
||||
|
||||
describe("PromptInfoView environment scoping", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(networking.getPromptInfo).mockReset().mockResolvedValue(promptWithoutTemplate);
|
||||
vi.mocked(networking.getPromptVersions).mockReset().mockResolvedValue({ prompts: [] });
|
||||
});
|
||||
|
||||
it("fetches the initial environment it was opened with", async () => {
|
||||
render(
|
||||
<PromptInfoView
|
||||
promptId="support-reply"
|
||||
initialEnvironment="staging"
|
||||
onClose={vi.fn()}
|
||||
accessToken="sk-test"
|
||||
isAdmin={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByRole("tab", { name: "Raw JSON" });
|
||||
expect(networking.getPromptInfo).toHaveBeenCalledWith("sk-test", "support-reply", "staging");
|
||||
});
|
||||
|
||||
it("fetches the serve default when opened without an environment", async () => {
|
||||
render(<PromptInfoView promptId="support-reply" onClose={vi.fn()} accessToken="sk-test" isAdmin={true} />);
|
||||
|
||||
await screen.findByRole("tab", { name: "Raw JSON" });
|
||||
expect(networking.getPromptInfo).toHaveBeenCalledWith("sk-test", "support-reply", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PromptInfoView tabs", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(networking.getPromptInfo).mockReset().mockResolvedValue(promptWithoutTemplate);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
|||
|
||||
export interface PromptInfoProps {
|
||||
promptId: string;
|
||||
initialEnvironment?: string;
|
||||
onClose: () => void;
|
||||
accessToken: string | null;
|
||||
isAdmin: boolean;
|
||||
|
|
@ -27,7 +28,15 @@ export interface PromptInfoProps {
|
|||
onEdit?: (promptData: any) => void;
|
||||
}
|
||||
|
||||
const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessToken, isAdmin, onDelete, onEdit }) => {
|
||||
const PromptInfoView: React.FC<PromptInfoProps> = ({
|
||||
promptId,
|
||||
initialEnvironment,
|
||||
onClose,
|
||||
accessToken,
|
||||
isAdmin,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}) => {
|
||||
const [promptData, setPromptData] = useState<PromptSpec | null>(null);
|
||||
const [promptTemplate, setPromptTemplate] = useState<PromptTemplateBase | null>(null);
|
||||
const [rawApiResponse, setRawApiResponse] = useState<any>(null);
|
||||
|
|
@ -43,7 +52,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
|
|||
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
|
||||
const [loadingVersions, setLoadingVersions] = useState(false);
|
||||
|
||||
// Initial fetch — no environment filter, gets default + all environments list
|
||||
// Fetches the requested environment (or the serve-time default when omitted) plus the environments list
|
||||
const fetchPromptInfo = async (environment?: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
|
@ -89,7 +98,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
|
|||
setSelectedEnv(null);
|
||||
setEnvironments([]);
|
||||
setVersionHistory([]);
|
||||
fetchPromptInfo();
|
||||
fetchPromptInfo(initialEnvironment);
|
||||
}, [promptId, accessToken]);
|
||||
|
||||
// When environment changes (user clicks tab), re-fetch — skip initial mount
|
||||
|
|
@ -493,7 +502,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
|
|||
<DialogTitle>Delete Prompt</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p>
|
||||
Are you sure you want to delete prompt: <strong>{basePromptId}</strong>?
|
||||
Are you sure you want to delete prompt: <strong>{basePromptId}</strong> from every environment?
|
||||
</p>
|
||||
<p>This action cannot be undone.</p>
|
||||
<DialogFooter>
|
||||
|
|
|
|||
|
|
@ -4625,9 +4625,12 @@ export const updatePromptCall = async (accessToken: string, promptId: string, pr
|
|||
}
|
||||
};
|
||||
|
||||
export const deletePromptCall = async (accessToken: string, promptId: string) => {
|
||||
export const deletePromptCall = async (accessToken: string, promptId: string, environment?: string) => {
|
||||
try {
|
||||
const data = await apiClient.delete(`/prompts/${promptId}`, { accessToken });
|
||||
const data = await apiClient.delete(`/prompts/${promptId}`, {
|
||||
accessToken,
|
||||
query: { environment: environment || undefined },
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete prompt:", error);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue