mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
fix(site): use search params for model edit/add navigation (#23277)
This commit is contained in:
@@ -626,6 +626,13 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getUser = async (userIdOrName: string): Promise<TypesGen.User> => {
|
||||
const response = await this.axios.get<TypesGen.User>(
|
||||
`/api/v2/users/${encodeURIComponent(userIdOrName)}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get users for workspace owner selection. Requires
|
||||
* permission to create workspaces for other users in the
|
||||
|
||||
@@ -33,6 +33,15 @@ export function usersKey(req: UsersRequest) {
|
||||
return ["users", req] as const;
|
||||
}
|
||||
|
||||
export const userByNameKey = (username: string) => ["user", username] as const;
|
||||
|
||||
export const userByName = (username: string): UseQueryOptions<User> => {
|
||||
return {
|
||||
queryKey: userByNameKey(username),
|
||||
queryFn: () => API.getUser(username),
|
||||
};
|
||||
};
|
||||
|
||||
export function paginatedUsers(
|
||||
searchParams: URLSearchParams,
|
||||
): UsePaginatedQueryOptions<GetUsersResponse, UsersRequest> {
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
StarIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { type FC, type ReactNode, useMemo } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
import { cn } from "utils/cn";
|
||||
import { SectionHeader } from "../SectionHeader";
|
||||
import type { ProviderState } from "./ChatModelAdminPanel";
|
||||
@@ -72,7 +73,61 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
onUpdateModel,
|
||||
onDeleteModel,
|
||||
}) => {
|
||||
const [view, setView] = useState<ModelView>({ mode: "list" });
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Whether the current form entry was pushed by an in-app click
|
||||
// (as opposed to a direct-entry URL like a bookmark or shared link).
|
||||
// When true, navigate(-1) is safe; otherwise we fall back to
|
||||
// clearing params with replace to avoid leaving the app.
|
||||
const canGoBack =
|
||||
(location.state as { pushed?: boolean } | null)?.pushed === true;
|
||||
|
||||
// Derive the current view from URL search params so that
|
||||
// browser back/forward navigation works as expected.
|
||||
const view: ModelView = useMemo(() => {
|
||||
const editModelId = searchParams.get("model");
|
||||
if (editModelId) {
|
||||
const model = modelConfigs.find((m) => m.id === editModelId);
|
||||
return model ? { mode: "edit", model } : { mode: "list" };
|
||||
}
|
||||
const addProvider = searchParams.get("newModel");
|
||||
if (addProvider) {
|
||||
return { mode: "add", provider: addProvider };
|
||||
}
|
||||
return { mode: "list" };
|
||||
}, [searchParams, modelConfigs]);
|
||||
|
||||
// Clear model-related search params and return to the list.
|
||||
const clearModelView = () => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("model");
|
||||
next.delete("newModel");
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Navigate back to the list after a destructive or
|
||||
// completion action (create/delete) where the form entry
|
||||
// is stale. Uses navigate(-1) when safe, otherwise clears
|
||||
// the params with replace.
|
||||
const exitModelView = () => {
|
||||
if (canGoBack) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("model");
|
||||
next.delete("newModel");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// When the form is open it takes over the full panel.
|
||||
if (view.mode === "add" || view.mode === "edit") {
|
||||
@@ -106,18 +161,18 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
isDeleting={isDeleting}
|
||||
onCreateModel={async (req) => {
|
||||
await onCreateModel(req);
|
||||
setView({ mode: "list" });
|
||||
exitModelView();
|
||||
}}
|
||||
onUpdateModel={async (id, req) => {
|
||||
await onUpdateModel(id, req);
|
||||
setView({ mode: "list" });
|
||||
clearModelView();
|
||||
}}
|
||||
onCancel={() => setView({ mode: "list" })}
|
||||
onCancel={clearModelView}
|
||||
onDeleteModel={
|
||||
editingModel
|
||||
? async (id) => {
|
||||
await onDeleteModel(id);
|
||||
setView({ mode: "list" });
|
||||
exitModelView();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -147,7 +202,10 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
key={ps.provider}
|
||||
onClick={() => {
|
||||
onSelectedProviderChange(ps.provider);
|
||||
setView({ mode: "add", provider: ps.provider });
|
||||
setSearchParams(
|
||||
{ newModel: ps.provider },
|
||||
{ state: { pushed: true } },
|
||||
);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
@@ -240,7 +298,12 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
{/* Clickable row content */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView({ mode: "edit", model: modelConfig })}
|
||||
onClick={() =>
|
||||
setSearchParams(
|
||||
{ model: modelConfig.id },
|
||||
{ state: { pushed: true } },
|
||||
)
|
||||
}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3.5 bg-transparent border-0 p-0 text-left transition-colors hover:opacity-80"
|
||||
>
|
||||
<ProviderIcon
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { CheckCircleIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { type FC, type ReactNode, useMemo } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
import { cn } from "utils/cn";
|
||||
import { SectionHeader } from "../SectionHeader";
|
||||
import type { ProviderState } from "./ChatModelAdminPanel";
|
||||
@@ -39,7 +40,33 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
||||
onDeleteProvider,
|
||||
onSelectedProviderChange,
|
||||
}) => {
|
||||
const [view, setView] = useState<ProviderView>({ mode: "list" });
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const canGoBack =
|
||||
(location.state as { pushed?: boolean } | null)?.pushed === true;
|
||||
|
||||
// Derive the current view from URL search params so that
|
||||
// browser back/forward navigation works as expected.
|
||||
const view: ProviderView = useMemo(() => {
|
||||
const providerParam = searchParams.get("provider");
|
||||
if (providerParam) {
|
||||
const exists = providerStates.some((ps) => ps.provider === providerParam);
|
||||
return exists
|
||||
? { mode: "detail", provider: providerParam }
|
||||
: { mode: "list" };
|
||||
}
|
||||
return { mode: "list" };
|
||||
}, [searchParams, providerStates]);
|
||||
|
||||
// Clear provider search param and return to the list.
|
||||
const clearProviderView = () => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("provider");
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ── Detail view ───────────────────────────────────────────
|
||||
const detailProvider =
|
||||
@@ -47,11 +74,6 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
||||
? providerStates.find((ps) => ps.provider === view.provider)
|
||||
: undefined;
|
||||
|
||||
// Provider disappeared (e.g. data refreshed) — fall back to list.
|
||||
if (view.mode === "detail" && !detailProvider) {
|
||||
setView({ mode: "list" });
|
||||
}
|
||||
|
||||
if (view.mode === "detail" && detailProvider) {
|
||||
return (
|
||||
<ProviderForm
|
||||
@@ -62,9 +84,20 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
||||
onUpdateProvider={onUpdateProvider}
|
||||
onDeleteProvider={async (id) => {
|
||||
await onDeleteProvider(id);
|
||||
setView({ mode: "list" });
|
||||
if (canGoBack) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("provider");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
}}
|
||||
onBack={() => setView({ mode: "list" })}
|
||||
onBack={clearProviderView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -98,10 +131,10 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
||||
aria-label={providerState.label}
|
||||
onClick={() => {
|
||||
onSelectedProviderChange(providerState.provider);
|
||||
setView({
|
||||
mode: "detail",
|
||||
provider: providerState.provider,
|
||||
});
|
||||
setSearchParams(
|
||||
{ provider: providerState.provider },
|
||||
{ state: { pushed: true } },
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3.5 bg-transparent border-0 p-0 px-3 py-3 text-left transition-colors hover:bg-surface-secondary/30",
|
||||
|
||||
@@ -2,10 +2,120 @@ import { MockUserOwner } from "testHelpers/entities";
|
||||
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import { userByNameKey } from "api/queries/users";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import dayjs from "dayjs";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { SettingsPageContent } from "./SettingsPageContent";
|
||||
|
||||
// ── Usage mock helpers ─────────────────────────────────────────
|
||||
|
||||
const mockUsers: TypesGen.ChatCostUserRollup[] = [
|
||||
{
|
||||
user_id: "user-1",
|
||||
username: "alice",
|
||||
name: "Alice Liddell",
|
||||
avatar_url: "",
|
||||
total_cost_micros: 2_500_000,
|
||||
message_count: 42,
|
||||
chat_count: 5,
|
||||
total_input_tokens: 200_000,
|
||||
total_output_tokens: 300_000,
|
||||
total_cache_read_tokens: 10_000,
|
||||
total_cache_creation_tokens: 5_000,
|
||||
},
|
||||
{
|
||||
user_id: "user-2",
|
||||
username: "bob",
|
||||
name: "Bob Builder",
|
||||
avatar_url: "",
|
||||
total_cost_micros: 1_000_000,
|
||||
message_count: 18,
|
||||
chat_count: 3,
|
||||
total_input_tokens: 80_000,
|
||||
total_output_tokens: 120_000,
|
||||
total_cache_read_tokens: 4_000,
|
||||
total_cache_creation_tokens: 2_000,
|
||||
},
|
||||
];
|
||||
|
||||
const mockUsersResponse: TypesGen.ChatCostUsersResponse = {
|
||||
start_date: "2026-02-10T00:00:00Z",
|
||||
end_date: "2026-03-12T00:00:00Z",
|
||||
count: mockUsers.length,
|
||||
users: mockUsers,
|
||||
};
|
||||
|
||||
const mockUserProfile: TypesGen.User = {
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
name: "Alice Liddell",
|
||||
email: "alice@example.com",
|
||||
avatar_url: "",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-06-01T00:00:00Z",
|
||||
status: "active",
|
||||
organization_ids: [],
|
||||
roles: [],
|
||||
last_seen_at: "2026-03-11T10:00:00Z",
|
||||
login_type: "password",
|
||||
};
|
||||
|
||||
const mockCostSummary: TypesGen.ChatCostSummary = {
|
||||
start_date: "2026-02-10T00:00:00Z",
|
||||
end_date: "2026-03-12T00:00:00Z",
|
||||
total_cost_micros: 2_500_000,
|
||||
priced_message_count: 40,
|
||||
unpriced_message_count: 2,
|
||||
total_input_tokens: 200_000,
|
||||
total_output_tokens: 300_000,
|
||||
total_cache_read_tokens: 10_000,
|
||||
total_cache_creation_tokens: 5_000,
|
||||
by_model: [
|
||||
{
|
||||
model_config_id: "model-1",
|
||||
display_name: "GPT-4.1",
|
||||
provider: "OpenAI",
|
||||
model: "gpt-4.1",
|
||||
total_cost_micros: 2_000_000,
|
||||
message_count: 30,
|
||||
total_input_tokens: 150_000,
|
||||
total_output_tokens: 250_000,
|
||||
total_cache_read_tokens: 8_000,
|
||||
total_cache_creation_tokens: 4_000,
|
||||
},
|
||||
],
|
||||
by_chat: [
|
||||
{
|
||||
root_chat_id: "chat-1",
|
||||
chat_title: "Refactor auth module",
|
||||
total_cost_micros: 1_200_000,
|
||||
message_count: 15,
|
||||
total_input_tokens: 80_000,
|
||||
total_output_tokens: 120_000,
|
||||
total_cache_read_tokens: 3_000,
|
||||
total_cache_creation_tokens: 1_500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Set up spies for all usage-related API methods. The behaviour mocks
|
||||
* (system prompt, desktop, custom prompt) are still inherited from
|
||||
* the meta-level `beforeEach`.
|
||||
*/
|
||||
const setupUsageSpies = (opts?: {
|
||||
usersResponse?: TypesGen.ChatCostUsersResponse;
|
||||
}) => {
|
||||
spyOn(API, "getChatCostUsers").mockResolvedValue(
|
||||
opts?.usersResponse ?? mockUsersResponse,
|
||||
);
|
||||
spyOn(API, "getUser").mockResolvedValue(mockUserProfile);
|
||||
spyOn(API, "getChatCostSummary").mockResolvedValue(mockCostSummary);
|
||||
};
|
||||
|
||||
// ── Meta ───────────────────────────────────────────────────────
|
||||
|
||||
const meta = {
|
||||
title: "pages/AgentsPage/SettingsPageContent",
|
||||
component: SettingsPageContent,
|
||||
@@ -41,6 +151,8 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SettingsPageContent>;
|
||||
|
||||
// ── Behavior tab stories ───────────────────────────────────────
|
||||
|
||||
export const DesktopSetting: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -68,3 +180,129 @@ export const TogglesDesktop: Story = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ── Usage tab stories ──────────────────────────────────────────
|
||||
|
||||
export const UsageUserList: Story = {
|
||||
args: {
|
||||
activeSection: "usage",
|
||||
canManageChatModelConfigs: true,
|
||||
},
|
||||
beforeEach: () => {
|
||||
setupUsageSpies();
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// The section header should be visible.
|
||||
await canvas.findByText("Usage");
|
||||
|
||||
// Both users should appear in the table.
|
||||
await expect(await canvas.findByText("Alice Liddell")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Bob Builder")).toBeInTheDocument();
|
||||
|
||||
// Verify the search field is present.
|
||||
await expect(
|
||||
canvas.getByPlaceholderText("Filter by username"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const UsageEmpty: Story = {
|
||||
args: {
|
||||
activeSection: "usage",
|
||||
canManageChatModelConfigs: true,
|
||||
},
|
||||
beforeEach: () => {
|
||||
setupUsageSpies({
|
||||
usersResponse: {
|
||||
start_date: "2026-02-10T00:00:00Z",
|
||||
end_date: "2026-03-12T00:00:00Z",
|
||||
count: 0,
|
||||
users: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText("Usage");
|
||||
await expect(
|
||||
await canvas.findByText("No usage data for this period."),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const UsageUserDrillIn: Story = {
|
||||
args: {
|
||||
activeSection: "usage",
|
||||
canManageChatModelConfigs: true,
|
||||
},
|
||||
parameters: {
|
||||
queries: [{ key: userByNameKey("user-1"), data: mockUserProfile }],
|
||||
},
|
||||
beforeEach: () => {
|
||||
setupUsageSpies();
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
// Wait for the user list to load.
|
||||
await userEvent.click(await body.findByText("Alice Liddell"));
|
||||
|
||||
// The detail view should show the user header with name
|
||||
// and username subtitle.
|
||||
await expect(await body.findByText("Alice Liddell")).toBeInTheDocument();
|
||||
await expect(body.getByText("@alice")).toBeInTheDocument();
|
||||
|
||||
// The user profile was pre-seeded in the query cache via
|
||||
// parameters.queries, so the detail header should show the
|
||||
// user ID from that data.
|
||||
await expect(
|
||||
body.getByText(`User ID: ${mockUserProfile.id}`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// The cost summary should have been fetched.
|
||||
await waitFor(() => {
|
||||
expect(API.getChatCostSummary).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The Back button should be visible.
|
||||
await expect(body.getByText("Back")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const UsageUserDrillInAndBack: Story = {
|
||||
args: {
|
||||
activeSection: "usage",
|
||||
canManageChatModelConfigs: true,
|
||||
},
|
||||
parameters: {
|
||||
queries: [{ key: userByNameKey("user-1"), data: mockUserProfile }],
|
||||
},
|
||||
beforeEach: () => {
|
||||
setupUsageSpies();
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
// Click Alice's row to drill in.
|
||||
await userEvent.click(await body.findByText("Alice Liddell"));
|
||||
|
||||
// Wait for the detail view to appear.
|
||||
await body.findByText("@alice");
|
||||
|
||||
// Click Back to return to the list.
|
||||
await userEvent.click(body.getByText("Back"));
|
||||
|
||||
// The user list should be visible again with both users.
|
||||
await expect(await body.findByText("Alice Liddell")).toBeInTheDocument();
|
||||
await expect(body.getByText("Bob Builder")).toBeInTheDocument();
|
||||
|
||||
// The search field should be present, confirming we're
|
||||
// back on the list view.
|
||||
await expect(
|
||||
body.getByPlaceholderText("Filter by username"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
updateChatSystemPrompt,
|
||||
updateUserChatCustomPrompt,
|
||||
} from "api/queries/chats";
|
||||
import { userByName } from "api/queries/users";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { Button } from "components/Button/Button";
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "react-query";
|
||||
import { useSearchParams } from "react-router";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import { formatTokenCount } from "utils/analytics";
|
||||
import { formatCostMicros } from "utils/currency";
|
||||
@@ -121,8 +123,7 @@ interface UsageContentProps {
|
||||
}
|
||||
|
||||
const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
const [selectedUser, setSelectedUser] =
|
||||
useState<TypesGen.ChatCostUserRollup | null>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [usernameFilter, setUsernameFilter] = useState("");
|
||||
const debouncedUsername = useDebouncedValue(usernameFilter, 300);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -147,14 +148,21 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const selectedUserId = searchParams.get("user");
|
||||
const selectedUserQuery = useQuery({
|
||||
...userByName(selectedUserId ?? ""),
|
||||
enabled: selectedUserId !== null,
|
||||
});
|
||||
const selectedUser = selectedUserQuery.data ?? null;
|
||||
|
||||
const summaryQuery = useQuery({
|
||||
...chatCostSummary(selectedUser?.user_id ?? "me", {
|
||||
...chatCostSummary(selectedUserId ?? "me", {
|
||||
start_date: dateRange.startDate,
|
||||
end_date: dateRange.endDate,
|
||||
}),
|
||||
enabled: selectedUser !== null,
|
||||
enabled: selectedUserId !== null,
|
||||
});
|
||||
|
||||
const totalCount = usersQuery.data?.count ?? 0;
|
||||
const hasPreviousPage = page > 1;
|
||||
const hasNextPage = offset + pageSize < totalCount;
|
||||
@@ -163,7 +171,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
<SectionHeader
|
||||
label="Usage"
|
||||
description={
|
||||
selectedUser
|
||||
selectedUserId
|
||||
? "Review deployment chat usage for a specific user."
|
||||
: "Review deployment chat usage and drill into individual users."
|
||||
}
|
||||
@@ -176,18 +184,72 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
/>
|
||||
);
|
||||
|
||||
if (selectedUser) {
|
||||
if (selectedUserId) {
|
||||
const clearUser = () => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("user");
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const backButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearUser}
|
||||
className="mb-4 inline-flex cursor-pointer items-center gap-0.5 bg-transparent border-0 p-0 text-sm text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
{" "}
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
);
|
||||
|
||||
if (selectedUserQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
{backButton}
|
||||
{header}
|
||||
</div>
|
||||
<div className="flex min-h-[240px] items-center justify-center">
|
||||
<Spinner size="lg" loading className="text-content-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedUserQuery.isError || !selectedUser) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
{backButton}
|
||||
{header}
|
||||
</div>
|
||||
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 text-center">
|
||||
<p className="m-0 text-sm text-content-secondary">
|
||||
{getErrorMessage(
|
||||
selectedUserQuery.error,
|
||||
"Failed to load user profile.",
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => void selectedUserQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedUser(null)}
|
||||
className="mb-4 inline-flex cursor-pointer items-center gap-0.5 bg-transparent border-0 p-0 text-sm text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
{backButton}
|
||||
{header}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-border-default bg-surface-secondary px-4 py-3">
|
||||
@@ -198,11 +260,10 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
imgFallbackText={selectedUser.username}
|
||||
/>
|
||||
<div className="min-w-0 text-xs text-content-secondary">
|
||||
<div>User ID: {selectedUser.user_id}</div>
|
||||
<div>User ID: {selectedUser.id}</div>
|
||||
<div>{dateRange.rangeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatCostSummaryView
|
||||
summary={summaryQuery.data}
|
||||
isLoading={summaryQuery.isLoading}
|
||||
@@ -305,7 +366,9 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
<UserRow
|
||||
key={user.user_id}
|
||||
user={user}
|
||||
onSelect={setSelectedUser}
|
||||
onSelect={(u) => {
|
||||
setSearchParams({ user: u.user_id });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
Reference in New Issue
Block a user