feat(ui): control which usage views internal users can see

This commit is contained in:
Devin AI 2026-07-08 10:23:00 +00:00
parent cd6e8cdf23
commit 6c06beea4b
10 changed files with 504 additions and 24 deletions

View file

@ -110,6 +110,11 @@ class UISettings(BaseModel):
description="List of page keys that internal users (non-admins) can see in the UI sidebar. If not set, all pages are visible based on role permissions.",
)
enabled_usage_views_internal_users: Optional[List[str]] = Field(
default=None,
description="List of Usage page view keys that internal users (non-admins) can select in the Usage view dropdown. If not set, all non-admin views are visible. Admins always see every view.",
)
require_auth_for_public_ai_hub: bool = Field(
default=False,
description="If true, requires authentication for accessing the public AI Hub.",
@ -194,6 +199,7 @@ ALLOWED_UI_SETTINGS_FIELDS = {
"disable_model_add_for_internal_users",
"disable_team_admin_delete_team_user",
"enabled_ui_pages_internal_users",
"enabled_usage_views_internal_users",
"require_auth_for_public_ai_hub",
"allow_public_health_readiness_details",
"forward_client_headers_to_llm_api",

View file

@ -1093,6 +1093,85 @@ class TestProxySettingEndpoints:
stored_settings = json.loads(create_data["ui_settings"])
assert stored_settings["disable_model_add_for_internal_users"] is True
def test_update_ui_settings_persists_enabled_usage_views_internal_users(
self, mock_auth, monkeypatch
):
"""enabled_usage_views_internal_users must be allowlisted and persisted as a list."""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
mock_user_auth = UserAPIKeyAuth(
user_id="test-user-123",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
mock_prisma = MagicMock()
mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
payload = {"enabled_usage_views_internal_users": ["global"]}
try:
response = client.patch("/update/ui_settings", json=payload)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["settings"]["enabled_usage_views_internal_users"] == ["global"]
call_args = mock_prisma.db.litellm_uisettings.upsert.call_args
stored_settings = json.loads(call_args.kwargs["data"]["create"]["ui_settings"])
assert stored_settings["enabled_usage_views_internal_users"] == ["global"]
def test_update_ui_settings_resets_enabled_usage_views_to_null(
self, mock_auth, monkeypatch
):
"""Sending null for enabled_usage_views_internal_users restores default behaviour."""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
mock_user_auth = UserAPIKeyAuth(
user_id="test-user-123",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
mock_prisma = MagicMock()
mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
mock_existing = MagicMock()
mock_existing.ui_settings = json.dumps(
{"enabled_usage_views_internal_users": ["global"]}
)
mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(
return_value=mock_existing
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
payload = {"enabled_usage_views_internal_users": None}
try:
response = client.patch("/update/ui_settings", json=payload)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["settings"]["enabled_usage_views_internal_users"] is None
call_args = mock_prisma.db.litellm_uisettings.upsert.call_args
stored_settings = json.loads(call_args.kwargs["data"]["update"]["ui_settings"])
assert stored_settings["enabled_usage_views_internal_users"] is None
def test_update_ui_settings_ignores_non_allowlisted_value(
self, mock_auth, monkeypatch
):

View file

@ -5,6 +5,7 @@ import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdat
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import NotificationManager from "@/components/molecules/notifications_manager";
import PageVisibilitySettings from "./PageVisibilitySettings";
import UsageViewVisibilitySettings from "./UsageViewVisibilitySettings";
import { Alert, Card, Divider, Skeleton, Space, Switch, Typography } from "antd";
export default function UISettings() {
@ -21,6 +22,7 @@ export default function UISettings() {
const enableProjectsUIProperty = schema?.properties?.enable_projects_ui;
const enableChatUIProperty = schema?.properties?.enable_chat_ui;
const enabledPagesProperty = schema?.properties?.enabled_ui_pages_internal_users;
const enabledUsageViewsProperty = schema?.properties?.enabled_usage_views_internal_users;
const disableAgentsProperty = schema?.properties?.disable_agents_for_internal_users;
const allowAgentsTeamAdminsProperty = schema?.properties?.allow_agents_for_team_admins;
const disableVectorStoresProperty = schema?.properties?.disable_vector_stores_for_internal_users;
@ -72,6 +74,17 @@ export default function UISettings() {
});
};
const handleUpdateUsageViewVisibility = (settings: { enabled_usage_views_internal_users: string[] | null }) => {
updateSettings(settings, {
onSuccess: () => {
NotificationManager.success("Usage view visibility settings updated successfully");
},
onError: (error) => {
NotificationManager.fromBackend(error);
},
});
};
const handleToggleForwardClientHeaders = (checked: boolean) => {
updateSettings(
{ forward_client_headers_to_llm_api: checked },
@ -491,6 +504,15 @@ export default function UISettings() {
isUpdating={isUpdating}
onUpdate={handleUpdatePageVisibility}
/>
<Divider />
<UsageViewVisibilitySettings
enabledViewsInternalUsers={values.enabled_usage_views_internal_users}
enabledViewsPropertyDescription={enabledUsageViewsProperty?.description}
isUpdating={isUpdating}
onUpdate={handleUpdateUsageViewVisibility}
/>
</Space>
)}
</Card>

View file

@ -0,0 +1,88 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import UsageViewVisibilitySettings from "./UsageViewVisibilitySettings";
vi.mock("@/components/UsagePage/components/UsageViewSelect/UsageViewSelect", () => ({
getConfigurableNonAdminUsageViews: () => [
{ value: "global", label: "Your Usage", description: "View your usage" },
{ value: "organization", label: "Your Organization Usage", description: "View your organization's usage" },
{ value: "team", label: "Team Usage", description: "View usage by team" },
{ value: "tag", label: "Tag Usage", description: "View usage grouped by tags" },
],
}));
describe("UsageViewVisibilitySettings", () => {
it("should render the not-set tag when enabledViewsInternalUsers is null", () => {
render(<UsageViewVisibilitySettings enabledViewsInternalUsers={null} isUpdating={false} onUpdate={vi.fn()} />);
expect(screen.getByText("Not set (all views visible)")).toBeInTheDocument();
});
it("should show the selected view count tag when views are configured", () => {
render(
<UsageViewVisibilitySettings
enabledViewsInternalUsers={["global", "team"]}
isUpdating={false}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByText("2 views selected")).toBeInTheDocument();
});
it("should show singular 'view' when exactly one view is selected", () => {
render(
<UsageViewVisibilitySettings enabledViewsInternalUsers={["global"]} isUpdating={false} onUpdate={vi.fn()} />,
);
expect(screen.getByText("1 view selected")).toBeInTheDocument();
});
it("should save only the selected views", async () => {
const onUpdate = vi.fn();
const user = userEvent.setup();
render(
<UsageViewVisibilitySettings enabledViewsInternalUsers={["global"]} isUpdating={false} onUpdate={onUpdate} />,
);
await user.click(screen.getByRole("button", { name: /configure usage view visibility/i }));
await user.click(await screen.findByRole("button", { name: /save usage view visibility settings/i }));
expect(onUpdate).toHaveBeenCalledWith({ enabled_usage_views_internal_users: ["global"] });
});
it("should save null when no views are selected", async () => {
const onUpdate = vi.fn();
const user = userEvent.setup();
render(
<UsageViewVisibilitySettings enabledViewsInternalUsers={["global"]} isUpdating={false} onUpdate={onUpdate} />,
);
await user.click(screen.getByRole("button", { name: /configure usage view visibility/i }));
await user.click(await screen.findByRole("checkbox", { name: /your usage/i }));
await user.click(screen.getByRole("button", { name: /save usage view visibility settings/i }));
expect(onUpdate).toHaveBeenCalledWith({ enabled_usage_views_internal_users: null });
});
it("should call onUpdate with null when reset button is clicked", async () => {
const onUpdate = vi.fn();
const user = userEvent.setup();
render(<UsageViewVisibilitySettings enabledViewsInternalUsers={["team"]} isUpdating={false} onUpdate={onUpdate} />);
await user.click(screen.getByRole("button", { name: /configure usage view visibility/i }));
await user.click(await screen.findByRole("button", { name: /reset to default/i }));
expect(onUpdate).toHaveBeenCalledWith({ enabled_usage_views_internal_users: null });
});
it("should display the property description when provided", () => {
render(
<UsageViewVisibilitySettings
enabledViewsInternalUsers={null}
enabledViewsPropertyDescription="Controls which usage views are visible"
isUpdating={false}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByText("Controls which usage views are visible")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,109 @@
"use client";
import { getConfigurableNonAdminUsageViews } from "@/components/UsagePage/components/UsageViewSelect/UsageViewSelect";
import { Button, Checkbox, Collapse, Space, Tag, Typography } from "antd";
import { useMemo, useState } from "react";
interface UsageViewVisibilitySettingsProps {
enabledViewsInternalUsers: string[] | null | undefined;
enabledViewsPropertyDescription?: string;
isUpdating: boolean;
onUpdate: (settings: { enabled_usage_views_internal_users: string[] | null }) => void;
}
export default function UsageViewVisibilitySettings({
enabledViewsInternalUsers,
enabledViewsPropertyDescription,
isUpdating,
onUpdate,
}: UsageViewVisibilitySettingsProps) {
const isVisibilitySet = enabledViewsInternalUsers !== null && enabledViewsInternalUsers !== undefined;
const availableViews = useMemo(() => getConfigurableNonAdminUsageViews(), []);
const [selectedViews, setSelectedViews] = useState<string[]>(enabledViewsInternalUsers ?? []);
const [syncedFrom, setSyncedFrom] = useState(enabledViewsInternalUsers);
if (syncedFrom !== enabledViewsInternalUsers) {
setSyncedFrom(enabledViewsInternalUsers);
setSelectedViews(enabledViewsInternalUsers ?? []);
}
const handleSave = () => {
onUpdate({ enabled_usage_views_internal_users: selectedViews.length > 0 ? selectedViews : null });
};
const handleResetToDefault = () => {
setSelectedViews([]);
onUpdate({ enabled_usage_views_internal_users: null });
};
return (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Space direction="vertical" size={4}>
<Space align="center">
<Typography.Text strong>Internal User Usage View Visibility</Typography.Text>
{!isVisibilitySet && (
<Tag color="default" style={{ marginLeft: "8px" }}>
Not set (all views visible)
</Tag>
)}
{isVisibilitySet && (
<Tag color="blue" style={{ marginLeft: "8px" }}>
{selectedViews.length} view{selectedViews.length !== 1 ? "s" : ""} selected
</Tag>
)}
</Space>
{enabledViewsPropertyDescription && (
<Typography.Text type="secondary">{enabledViewsPropertyDescription}</Typography.Text>
)}
<Typography.Text type="secondary" style={{ fontSize: "12px", fontStyle: "italic" }}>
By default, all non-admin views are visible to internal users in the Usage page dropdown. Select specific
views to restrict which options they can choose.
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: "12px", color: "#8b5cf6" }}>
Note: Admins always see every usage view regardless of this setting.
</Typography.Text>
</Space>
<Collapse
items={[
{
key: "usage-view-visibility",
label: "Configure Usage View Visibility",
children: (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Checkbox.Group value={selectedViews} onChange={setSelectedViews} style={{ width: "100%" }}>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
{availableViews.map((view) => (
<div key={view.value} style={{ marginBottom: "4px" }}>
<Checkbox value={view.value}>
<Space direction="vertical" size={0}>
<Typography.Text>{view.label}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: "12px" }}>
{view.description}
</Typography.Text>
</Space>
</Checkbox>
</div>
))}
</Space>
</Checkbox.Group>
<Space>
<Button type="primary" onClick={handleSave} loading={isUpdating} disabled={isUpdating}>
Save Usage View Visibility Settings
</Button>
{isVisibilitySet && (
<Button onClick={handleResetToDefault} loading={isUpdating} disabled={isUpdating}>
Reset to Default (All Views)
</Button>
)}
</Space>
</Space>
),
},
]}
/>
</Space>
);
}

View file

@ -77,7 +77,26 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
);
};
UsageViewSelect.displayName = "UsageViewSelect";
return { UsageViewSelect };
const getVisibleUsageOptions = ({
isAdmin,
canViewTagUsage = false,
enabledViews = null,
}: {
isAdmin: boolean;
canViewTagUsage?: boolean;
enabledViews?: string[] | null;
}) => {
const base = isAdmin
? ["global", "my-usage", "organization", "team", "customer", "tag", "agent", "user", "user-agent-activity"]
: ["global", "organization", "team", ...(canViewTagUsage ? ["tag"] : [])];
if (!isAdmin && enabledViews != null) {
return base.filter((v) => enabledViews.includes(v));
}
return base;
};
const resolveActiveUsageView = (current: string, visibleViews: string[]) =>
visibleViews.length === 0 || visibleViews.includes(current) ? current : visibleViews[0];
return { UsageViewSelect, getVisibleUsageOptions, resolveActiveUsageView };
});
vi.mock("../../shared/advanced_date_picker", async () => {
@ -126,6 +145,10 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
useInfiniteUsers: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: vi.fn(() => ({ data: { values: {}, field_schema: {} } })),
}));
vi.mock("antd", async (importOriginal) => {
const React = await import("react");
const actual = await importOriginal<typeof import("antd")>();

View file

@ -50,7 +50,13 @@ import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
import SpendByProvider from "./EntityUsage/SpendByProvider";
import TopKeyView from "./EntityUsage/TopKeyView";
import UsageAIChatPanel from "./UsageAIChatPanel";
import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import {
getVisibleUsageOptions,
resolveActiveUsageView,
UsageOption,
UsageViewSelect,
} from "./UsageViewSelect/UsageViewSelect";
interface UsagePageProps {
teams: Team[];
@ -83,6 +89,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const { data: currentUser } = useCurrentUser();
const isAdmin = all_admin_roles.includes(userRole || "");
const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || "");
const { data: uiSettings } = useUISettings();
const enabledUsageViews = (uiSettings?.values?.enabled_usage_views_internal_users ?? null) as UsageOption[] | null;
const visibleUsageOptions = useMemo(
() => getVisibleUsageOptions({ isAdmin, canViewTagUsage, enabledViews: enabledUsageViews }),
[isAdmin, canViewTagUsage, enabledUsageViews],
);
// Debounced search for user selector
const [userSearchInput, setUserSearchInput] = useState("");
@ -151,8 +163,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
}
}, [isAdmin, userID]);
const effectiveUsageView = resolveActiveUsageView(usageView, visibleUsageOptions);
// For non-admins or "my-usage" view, always pass their own user_id
const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : selectedUserId;
const effectiveUserId = effectiveUsageView === "my-usage" || !isAdmin ? userID || null : selectedUserId;
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
@ -444,10 +458,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<div className="flex-1">
<div className="flex items-end justify-between gap-6 mb-4 w-full">
<UsageViewSelect
value={usageView}
value={effectiveUsageView}
onChange={(value) => setUsageView(value)}
isAdmin={isAdmin}
canViewTagUsage={canViewTagUsage}
enabledViews={enabledUsageViews}
/>
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</div>
@ -489,9 +504,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
/>
)}
{/* Your Usage / Global Usage Panel */}
{(usageView === "global" || usageView === "my-usage") && (
{(effectiveUsageView === "global" || effectiveUsageView === "my-usage") && (
<>
{isAdmin && usageView === "global" && (
{isAdmin && effectiveUsageView === "global" && (
<div className="mb-4">
<Text className="mb-2">Filter by user</Text>
<Select
@ -844,7 +859,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
)}
{/* Organization Usage Panel */}
{usageView === "organization" && (
{effectiveUsageView === "organization" && (
<EntityUsage
accessToken={accessToken}
entityType="organization"
@ -862,7 +877,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
)}
{/* Team Usage Panel */}
{usageView === "team" && (
{effectiveUsageView === "team" && (
<EntityUsage
accessToken={accessToken}
entityType="team"
@ -880,7 +895,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
)}
{/* Customer Usage Panel */}
{usageView === "customer" && (
{effectiveUsageView === "customer" && (
<EntityUsage
accessToken={accessToken}
entityType="customer"
@ -897,7 +912,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
/>
)}
{/* Tag Usage Panel */}
{usageView === "tag" && (
{effectiveUsageView === "tag" && (
<>
{showCredentialBanner && (
<Alert
@ -927,7 +942,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
/>
</>
)}
{usageView === "agent" && (
{effectiveUsageView === "agent" && (
<EntityUsage
accessToken={accessToken}
entityType="agent"
@ -941,7 +956,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
/>
)}
{/* User Usage Panel */}
{usageView === "user" && (
{effectiveUsageView === "user" && (
<EntityUsage
accessToken={accessToken}
entityType="user"
@ -953,7 +968,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
/>
)}
{/* User Agent Activity Panel */}
{usageView === "user-agent-activity" && (
{effectiveUsageView === "user-agent-activity" && (
<UserAgentActivity accessToken={accessToken} userRole={userRole} dateValue={dateValue} />
)}
</div>

View file

@ -1,6 +1,11 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UsageViewSelect } from "./UsageViewSelect";
import {
getConfigurableNonAdminUsageViews,
getVisibleUsageOptions,
resolveActiveUsageView,
UsageViewSelect,
} from "./UsageViewSelect";
vi.mock("antd", async () => {
const React = await import("react");
@ -122,4 +127,102 @@ describe("UsageViewSelect", () => {
expect(screen.queryByRole("option", { name: "Tag Usage" })).not.toBeInTheDocument();
});
it("should restrict non-admin options to enabledViews when set", () => {
render(
<UsageViewSelect
value="global"
onChange={mockOnChange}
isAdmin={false}
canViewTagUsage={true}
enabledViews={["global"]}
/>,
);
expect(screen.getByRole("option", { name: "Your Usage" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Your Organization Usage" })).not.toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Team Usage" })).not.toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Tag Usage" })).not.toBeInTheDocument();
});
it("should ignore enabledViews for admins", () => {
render(<UsageViewSelect value="global" onChange={mockOnChange} isAdmin={true} enabledViews={["global"]} />);
expect(screen.getByRole("option", { name: "Team Usage" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Organization Usage" })).toBeInTheDocument();
});
it("should show all non-admin options when enabledViews is null", () => {
render(
<UsageViewSelect
value="global"
onChange={mockOnChange}
isAdmin={false}
canViewTagUsage={true}
enabledViews={null}
/>,
);
expect(screen.getByRole("option", { name: "Your Usage" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Your Organization Usage" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Team Usage" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Tag Usage" })).toBeInTheDocument();
});
});
describe("getVisibleUsageOptions", () => {
it("should return only role-visible non-admin views by default", () => {
expect(getVisibleUsageOptions({ isAdmin: false, canViewTagUsage: true })).toEqual([
"global",
"organization",
"team",
"tag",
]);
});
it("should exclude tag when canViewTagUsage is false", () => {
expect(getVisibleUsageOptions({ isAdmin: false, canViewTagUsage: false })).toEqual([
"global",
"organization",
"team",
]);
});
it("should intersect with enabledViews for non-admins", () => {
expect(getVisibleUsageOptions({ isAdmin: false, canViewTagUsage: true, enabledViews: ["global", "team"] })).toEqual(
["global", "team"],
);
});
it("should ignore enabledViews for admins", () => {
const adminViews = getVisibleUsageOptions({ isAdmin: true, enabledViews: ["global"] });
expect(adminViews).toContain("team");
expect(adminViews).toContain("tag");
expect(adminViews).toContain("customer");
});
});
describe("getConfigurableNonAdminUsageViews", () => {
it("should list exactly the views configurable for non-admins with their non-admin labels", () => {
expect(getConfigurableNonAdminUsageViews()).toEqual([
{ value: "global", label: "Your Usage", description: "View your usage" },
{ value: "organization", label: "Your Organization Usage", description: "View your organization's usage" },
{ value: "team", label: "Team Usage", description: "View usage by team" },
{ value: "tag", label: "Tag Usage", description: "View usage grouped by tags" },
]);
});
});
describe("resolveActiveUsageView", () => {
it("should keep the current view when it is still visible", () => {
expect(resolveActiveUsageView("team", ["global", "team", "tag"])).toBe("team");
});
it("should fall back to the first visible view when the current one is hidden", () => {
expect(resolveActiveUsageView("team", ["global", "tag"])).toBe("global");
});
it("should keep the current view when there are no visible views to fall back to", () => {
expect(resolveActiveUsageView("team", [])).toBe("team");
});
});

View file

@ -26,6 +26,7 @@ export interface UsageViewSelectProps {
onChange: (value: UsageOption) => void;
isAdmin: boolean;
canViewTagUsage?: boolean;
enabledViews?: UsageOption[] | null;
title?: string;
description?: string;
"data-id"?: string;
@ -112,25 +113,59 @@ const OPTIONS: OptionConfig[] = [
adminOnly: true,
},
];
export interface UsageViewVisibilityContext {
isAdmin: boolean;
canViewTagUsage?: boolean;
enabledViews?: UsageOption[] | null;
}
const isVisibleByRole = (
option: OptionConfig,
{ isAdmin, canViewTagUsage = false }: UsageViewVisibilityContext,
): boolean => {
if (option.value === "tag" && canViewTagUsage) {
return true;
}
if (option.adminOnly && !isAdmin) {
return false;
}
return true;
};
export const getVisibleUsageOptions = (context: UsageViewVisibilityContext): UsageOption[] =>
OPTIONS.filter((option) => {
if (!isVisibleByRole(option, context)) {
return false;
}
if (!context.isAdmin && context.enabledViews != null && !context.enabledViews.includes(option.value)) {
return false;
}
return true;
}).map((option) => option.value);
export const resolveActiveUsageView = (current: UsageOption, visibleViews: UsageOption[]): UsageOption =>
visibleViews.length === 0 || visibleViews.includes(current) ? current : visibleViews[0];
export const getConfigurableNonAdminUsageViews = (): { value: UsageOption; label: string; description: string }[] =>
OPTIONS.filter((option) => isVisibleByRole(option, { isAdmin: false, canViewTagUsage: true })).map((option) => ({
value: option.value,
label: option.showForNonAdmin ?? option.label,
description: option.descriptionForNonAdmin ?? option.description,
}));
export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
value,
onChange,
isAdmin,
canViewTagUsage = false,
enabledViews = null,
title = "Usage View",
description = "Select the usage data you want to view",
"data-id": dataId,
}) => {
const getFilteredOptions = () => {
return OPTIONS.filter((option) => {
if (option.value === "tag" && canViewTagUsage) {
return true;
}
if (option.adminOnly && !isAdmin) {
return false;
}
return true;
}).map((option) => {
const visibleValues = new Set(getVisibleUsageOptions({ isAdmin, canViewTagUsage, enabledViews }));
return OPTIONS.filter((option) => visibleValues.has(option.value)).map((option) => {
let label = option.label;
let desc = option.description;
if (option.showForAdmin && option.showForNonAdmin) {

File diff suppressed because one or more lines are too long