feat(site): add group member AI budget columns (#26574)

Adds AI budget and Budget type columns to the group members table, shown when
aibridge is enabled and the ai-gateway-cost-control experiment is on. A member's
spend, limit, and source come from an ai_cost_control object embedded in the
group members and groups responses, so no extra request is made.

- Add AI budget and Budget type columns, gated by the aibridge feature and the
  ai-gateway-cost-control experiment
- Read ai_cost_control inline from the group and member lists instead of calling
  a separate spend endpoint
- Share an AIBudgetUsage component (spend vs budget with severity colors) and an
  InfoIconTooltip for the column headers
- When another group governs a member's budget, grey the spend and name that
  group in a tooltip; otherwise render the spend (severity-colored) against a
  white limit
- Resolve a member's effective group in the AI budget override dialog, marking
  only the governing group "(default)" and none when no group governs them
- Defer the override's custom-budget error until the field is touched

Closes AIGOV-291
This commit is contained in:
Ehab Younes
2026-06-25 15:41:46 +03:00
committed by GitHub
parent 1ae96fcf8a
commit 75993c2ea0
16 changed files with 728 additions and 244 deletions
+31 -18
View File
@@ -200,13 +200,28 @@ type WatchInboxNotificationsParams = Readonly<{
read_status?: "read" | "unread" | "all";
}>;
// TODO(AIGOV-290): replace with the generated type from typesGenerated.ts once
// the GET /organizations/{org}/groups/ai/spend endpoint exists in the backend.
export type OrganizationGroupAISpend = Readonly<{
group_id: string;
// TODO(AIGOV-290): drop once `ai_cost_control` is generated onto Group.
export type GroupAICostControl = Readonly<{
current_spend_micros: number;
spend_limit_micros: number | null;
}>;
export type GroupWithAICostControl = TypesGen.Group &
Readonly<{ ai_cost_control?: GroupAICostControl }>;
// TODO(AIGOV-291): drop once `ai_cost_control` is generated onto ReducedUser.
export type GroupMemberAICostControl = Readonly<{
current_spend_micros: number;
spend_limit_micros: number | null;
effective_group_id: string | null;
limit_source: "group" | "override" | null;
}>;
export type GroupMemberWithAICostControl = TypesGen.ReducedUser &
Readonly<{ ai_cost_control?: GroupMemberAICostControl }>;
export type GroupMembersResponseWithAICostControl = Omit<
TypesGen.GroupMembersResponse,
"users"
> &
Readonly<{ users: readonly GroupMemberWithAICostControl[] }>;
export function watchInboxNotifications(
params?: WatchInboxNotificationsParams,
@@ -2210,25 +2225,13 @@ class ApiMethods {
*/
getGroupsByOrganization = async (
organization: string,
): Promise<TypesGen.Group[]> => {
): Promise<GroupWithAICostControl[]> => {
const response = await this.axios.get(
`/api/v2/organizations/${organization}/groups`,
);
return response.data;
};
/**
* @param organization Can be the organization's ID or name
*/
getOrganizationGroupsAISpend = async (
organization: string,
): Promise<OrganizationGroupAISpend[]> => {
const response = await this.axios.get(
`/api/v2/organizations/${organization}/groups/ai/spend`,
);
return response.data;
};
/**
* @param organization Can be the organization's ID or name
*/
@@ -2243,6 +2246,16 @@ class ApiMethods {
return response.data;
};
getGroupById = async (
groupId: string,
req: TypesGen.GroupRequest,
signal?: AbortSignal,
): Promise<TypesGen.Group> => {
const url = getURLWithSearchParams(`/api/v2/groups/${groupId}`, req);
const response = await this.axios.get(url, { signal });
return response.data;
};
/**
* @param organization Can be the organization's ID or name
*/
@@ -2265,7 +2278,7 @@ class ApiMethods {
groupName: string,
filter?: UsersRequest,
signal?: AbortSignal,
): Promise<TypesGen.GroupMembersResponse> => {
): Promise<GroupMembersResponseWithAICostControl> => {
const url = getURLWithSearchParams(
`/api/v2/organizations/${organization}/groups/${groupName}/members`,
filter,
+31 -17
View File
@@ -1,11 +1,14 @@
import type { QueryClient, UseQueryOptions } from "react-query";
import { API, type OrganizationGroupAISpend } from "#/api/api";
import {
API,
type GroupMembersResponseWithAICostControl,
type GroupWithAICostControl,
} from "#/api/api";
import { isApiError } from "#/api/errors";
import type {
CreateGroupRequest,
Group,
GroupAIBudget,
GroupMembersResponse,
GroupRequest,
PatchGroupRequest,
UsersRequest,
@@ -35,19 +38,7 @@ export const groupsByOrganization = (organization: string) => {
return {
queryKey: getGroupsByOrganizationQueryKey(organization),
queryFn: () => API.getGroupsByOrganization(organization),
} satisfies UseQueryOptions<Group[]>;
};
const getOrganizationGroupsAISpendQueryKey = (organization: string) => [
...getGroupsByOrganizationQueryKey(organization),
"aiSpend",
];
export const organizationGroupsAISpend = (organization: string) => {
return {
queryKey: getOrganizationGroupsAISpendQueryKey(organization),
queryFn: () => API.getOrganizationGroupsAISpend(organization),
} satisfies UseQueryOptions<OrganizationGroupAISpend[]>;
} satisfies UseQueryOptions<GroupWithAICostControl[]>;
};
const getRootGroupQueryKey = (organization: string, groupName: string) => [
@@ -57,6 +48,22 @@ const getRootGroupQueryKey = (organization: string, groupName: string) => [
groupName,
];
export const getGroupByIdQueryKey = (groupId: string, req: GroupRequest) => [
"group",
groupId,
req,
];
export const groupById = (
groupId: string,
req: GroupRequest,
): UseQueryOptions<Group> => {
return {
queryKey: getGroupByIdQueryKey(groupId, req),
queryFn: ({ signal }) => API.getGroupById(groupId, req, signal),
};
};
export const getGroupQueryKey = (
organization: string,
groupName: string,
@@ -90,7 +97,10 @@ export function groupMembers(
organization: string,
groupName: string,
searchParams: URLSearchParams,
): UsePaginatedQueryOptions<GroupMembersResponse, UsersRequest> {
): UsePaginatedQueryOptions<
GroupMembersResponseWithAICostControl,
UsersRequest
> {
return {
searchParams,
queryPayload: ({ limit, offset }) => {
@@ -121,7 +131,11 @@ export function groupsByUserIdInOrganization(organization: string) {
return {
...groupsByOrganization(organization),
select: selectGroupsByUserId,
} satisfies UseQueryOptions<Group[], unknown, GroupsByUserId>;
} satisfies UseQueryOptions<
GroupWithAICostControl[],
unknown,
GroupsByUserId
>;
}
function selectGroupsByUserId(groups: Group[]): GroupsByUserId {
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect } from "storybook/test";
import { AIBudgetUsage } from "./AIBudgetUsage";
// Spend and limit are in micros (1_000_000 = $1).
const meta: Meta<typeof AIBudgetUsage> = {
title: "pages/OrganizationGroupsPage/AIBudgetUsage",
component: AIBudgetUsage,
};
export default meta;
type Story = StoryObj<typeof AIBudgetUsage>;
// No limit: spend shown against "unlimited".
export const Unlimited: Story = {
args: { currentSpend: 25_492_000_000, spendLimit: null },
play: async ({ canvasElement }) => {
await expect(canvasElement).toHaveTextContent("$25,492 / unlimited USD");
},
};
// Well under budget: spend rendered in the normal (secondary) color.
export const UnderBudget: Story = {
args: { currentSpend: 10_000_000, spendLimit: 50_000_000 },
play: async ({ canvasElement }) => {
await expect(canvasElement).toHaveTextContent("$10 / $50 USD");
},
};
// >=85% of budget: spend rendered in the warning color.
export const NearLimit: Story = {
args: { currentSpend: 46_000_000, spendLimit: 50_000_000 },
play: async ({ canvasElement }) => {
await expect(canvasElement).toHaveTextContent("$46 / $50 USD");
},
};
// Over budget: spend rendered in the destructive color.
export const OverBudget: Story = {
args: { currentSpend: 75_000_000, spendLimit: 50_000_000 },
play: async ({ canvasElement }) => {
await expect(canvasElement).toHaveTextContent("$75 / $50 USD");
},
};
// Zero budget with spend: treated as exceeded.
export const ZeroBudget: Story = {
args: { currentSpend: 5_000_000, spendLimit: 0 },
play: async ({ canvasElement }) => {
await expect(canvasElement).toHaveTextContent("$5 / $0 USD");
},
};
@@ -0,0 +1,31 @@
import type { FC } from "react";
import { getSeverity, severityTextClassName } from "#/utils/budget";
import { formatBudgetUSD } from "#/utils/currency";
/** Spend against budget. Highlights spend once it nears or exceeds the limit; values in micros. */
export const AIBudgetUsage: FC<{
currentSpend: number;
spendLimit: number | null;
}> = ({ currentSpend, spendLimit }) => {
if (spendLimit === null) {
return (
<span className="whitespace-nowrap">
{formatBudgetUSD(currentSpend)}{" "}
<span className="text-content-disabled">/ unlimited USD</span>
</span>
);
}
const severity = getSeverity(currentSpend, spendLimit);
return (
<span className="whitespace-nowrap">
<span className={severityTextClassName(severity)}>
{formatBudgetUSD(currentSpend)}
</span>{" "}
<span className="text-content-primary">
/ {formatBudgetUSD(spendLimit)}
</span>{" "}
<span className="text-content-disabled">USD</span>
</span>
);
};
+120 -17
View File
@@ -1,14 +1,17 @@
import { EllipsisVerticalIcon, UserPlusIcon } from "lucide-react";
import { type FC, useState } from "react";
import { useMutation, useQueryClient } from "react-query";
import { type FC, type ReactNode, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useOutletContext } from "react-router";
import { toast } from "sonner";
import type {
GroupMemberAICostControl,
GroupMemberWithAICostControl,
} from "#/api/api";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { addMembers, removeMember } from "#/api/queries/groups";
import { addMembers, groupById, removeMember } from "#/api/queries/groups";
import type {
Group,
OrganizationMemberWithUserData,
ReducedUser,
} from "#/api/typesGenerated";
import { Avatar } from "#/components/Avatar/Avatar";
import { AvatarData } from "#/components/Avatar/AvatarData";
@@ -43,7 +46,10 @@ import { useDashboard } from "#/modules/dashboard/useDashboard";
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
import { isEveryoneGroup } from "#/modules/groups";
import { cn } from "#/utils/cn";
import { formatBudgetUSD } from "#/utils/currency";
import { AIBudgetUsage } from "./AIBudgetUsage";
import type { GroupPageOutletContext } from "./GroupPage";
import { InfoIconTooltip } from "./InfoIconTooltip";
import { UserAIBudgetOverrideDialog } from "./UserAIBudgetOverrideDialog";
const GroupMembersPage: FC = () => {
@@ -61,7 +67,8 @@ const GroupMembersPage: FC = () => {
removeMember(queryClient, organization),
);
const canUpdateGroup = permissions ? permissions.canUpdateGroup : false;
const [budgetUser, setBudgetUser] = useState<ReducedUser | null>(null);
const [budgetUser, setBudgetUser] =
useState<GroupMemberWithAICostControl | null>(null);
const { experiments } = useDashboard();
// TODO(AIGOV-443): remove the ai-gateway-cost-control experiment gate once
@@ -92,8 +99,28 @@ const GroupMembersPage: FC = () => {
<Table aria-label="Group members">
<TableHeader>
<TableRow>
<TableHead className="w-2/5">User</TableHead>
<TableHead className="w-3/5">Status</TableHead>
<TableHead className={aibridgeVisible ? undefined : "w-2/5"}>
User
</TableHead>
<TableHead className={aibridgeVisible ? undefined : "w-3/5"}>
Status
</TableHead>
{aibridgeVisible && (
<>
<TableHead>
<div className="flex items-center gap-1">
AI budget
<InfoIconTooltip message="A member's AI spend against their budget for the current period." />
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-1">
Budget type
<InfoIconTooltip message="Whether a member's budget comes from their group or an individual override." />
</div>
</TableHead>
</>
)}
<TableHead className="w-auto" />
</TableRow>
</TableHeader>
@@ -112,7 +139,7 @@ const GroupMembersPage: FC = () => {
group={groupData}
key={member.id}
canUpdate={canUpdateGroup}
aiBudgetVisible={aibridgeVisible}
showAIBudget={aibridgeVisible}
onManageAIBudget={() => setBudgetUser(member)}
onRemove={async () => {
const mutation = removeMemberMutation.mutateAsync({
@@ -144,9 +171,8 @@ const GroupMembersPage: FC = () => {
}
}}
user={budgetUser}
// TODO(#26401): pass the member's effective group, not the page's
// group, once the effective-group API exists.
currentGroup={groupData}
effectiveGroupId={budgetUser.ai_cost_control?.effective_group_id}
/>
)}
</div>
@@ -246,10 +272,10 @@ const AddUsersDialog: FC<AddUsersDialogProps> = ({
};
interface GroupMemberRowProps {
member: ReducedUser;
member: GroupMemberWithAICostControl;
group: Group;
canUpdate: boolean;
aiBudgetVisible: boolean;
showAIBudget: boolean;
onManageAIBudget: () => void;
onRemove: () => void;
}
@@ -258,13 +284,13 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
member,
group,
canUpdate,
aiBudgetVisible,
showAIBudget,
onManageAIBudget,
onRemove,
}) => {
return (
<TableRow key={member.id}>
<TableCell width="59%">
<TableCell width={showAIBudget ? undefined : "59%"}>
<AvatarData
avatar={
<Avatar
@@ -280,7 +306,7 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
/>
</TableCell>
<TableCell
width="40%"
width={showAIBudget ? undefined : "40%"}
className={cn(
"capitalize",
member.status === "suspended" ? "text-content-secondary" : "",
@@ -289,7 +315,14 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
<div>{member.status}</div>
<LastSeen at={member.last_seen_at} className="text-xs" />
</TableCell>
<TableCell width="1%">
{showAIBudget && (
<GroupMemberAIBudgetCells
group={group}
userID={member.id}
costControl={member.ai_cost_control}
/>
)}
<TableCell className="w-1 whitespace-nowrap">
{canUpdate && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -299,7 +332,7 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{aiBudgetVisible && (
{showAIBudget && (
<DropdownMenuItem onClick={onManageAIBudget}>
AI Budget
</DropdownMenuItem>
@@ -319,4 +352,74 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
);
};
const GroupMemberAIBudgetCells: FC<{
group: Group;
userID: string;
costControl: GroupMemberAICostControl | undefined;
}> = ({ group, userID, costControl }) => {
// Limit and type apply only when this group is the member's effective source.
const onEffectiveGroup = costControl?.effective_group_id === group.id;
let budget: ReactNode = "-";
let type: ReactNode = "-";
if (costControl) {
// Another group sets this member's budget; surface their spend only.
budget = onEffectiveGroup ? (
<AIBudgetUsage
currentSpend={costControl.current_spend_micros}
spendLimit={costControl.spend_limit_micros}
/>
) : (
<span className="inline-flex items-center gap-1 text-content-disabled">
{formatBudgetUSD(costControl.current_spend_micros)}
<MemberBudgetSourceTooltip groupId={costControl.effective_group_id} />
</span>
);
if (onEffectiveGroup && costControl.limit_source) {
type = budgetTypeLabels[costControl.limit_source];
}
}
return (
<>
<TableCell
data-testid={`member-ai-budget-${userID}`}
className="whitespace-nowrap tabular-nums"
>
{budget}
</TableCell>
<TableCell>{type}</TableCell>
</>
);
};
// Names the group whose budget governs a member, resolving the id to a name.
const MemberBudgetSourceTooltip: FC<{ groupId: string | null }> = ({
groupId,
}) => {
const { data: group } = useQuery({
...groupById(groupId ?? "", { exclude_members: true }),
enabled: Boolean(groupId),
});
const name = group?.display_name || group?.name;
return (
<InfoIconTooltip
className="text-content-disabled"
message={
name
? `This member's AI budget is set by the "${name}" group.`
: "This member's AI budget is set by another group."
}
/>
);
};
const budgetTypeLabels: Record<
NonNullable<GroupMemberAICostControl["limit_source"]>,
string
> = {
group: "Group",
override: "Individual",
};
export default GroupMembersPage;
+275 -6
View File
@@ -4,8 +4,13 @@ import {
reactRouterOutlet,
reactRouterParameters,
} from "storybook-addon-remix-react-router";
import { API } from "#/api/api";
import {
API,
type GroupMemberAICostControl,
type GroupMemberWithAICostControl,
} from "#/api/api";
import {
getGroupByIdQueryKey,
getGroupMembersQueryKey,
getGroupQueryKey,
getGroupsForUserQueryKey,
@@ -14,13 +19,15 @@ import {
} from "#/api/queries/groups";
import { organizationMembersKey } from "#/api/queries/organizations";
import { getUserAIBudgetOverrideQueryKey } from "#/api/queries/users";
import type { UserAIBudgetOverride } from "#/api/typesGenerated";
import type { ReducedUser, UserAIBudgetOverride } from "#/api/typesGenerated";
import {
MockDefaultOrganization,
MockGroup,
MockGroup2,
MockGroupWithoutMembers,
MockOrganizationMember,
MockOrganizationMember2,
MockUserMember,
MockUserOwner,
} from "#/testHelpers/entities";
import { withDashboardProvider } from "#/testHelpers/storybook";
@@ -234,12 +241,159 @@ export const FiltersByMembers: Story = {
const mockOwnerOverride: UserAIBudgetOverride = {
user_id: MockUserOwner.id,
group_id: MockGroup.id,
group_id: MockGroup2.id,
spend_limit_micros: 12_000_000_000,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
// Member row with inline AI cost control; defaults to the page's group.
const memberWithSpend = (
user: ReducedUser,
overrides: Partial<GroupMemberAICostControl> = {},
): GroupMemberWithAICostControl => ({
...user,
ai_cost_control: {
current_spend_micros: 1_345_000_000,
spend_limit_micros: 9_000_000_000,
effective_group_id: MockGroupWithoutMembers.id,
limit_source: "override",
...overrides,
},
});
const memberWithoutSpend: GroupMemberWithAICostControl = {
...MockUserMember,
id: "no-spend-user",
username: "no-spend",
};
export const WithMemberAIBudget: Story = {
parameters: {
features: ["aibridge"],
experiments: ["ai-gateway-cost-control"],
queries: [
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({
users: [
// Override source, no limit.
memberWithSpend(MockUserOwner, { spend_limit_micros: null }),
// Group source, finite limit.
memberWithSpend(MockUserMember, {
current_spend_micros: 5_492_000_000,
spend_limit_micros: 7_000_000_000,
limit_source: "group",
}),
// No cost control exercises the missing-spend "-" fallback.
memberWithoutSpend,
],
count: 3,
}),
permissionsQuery({ canUpdateGroup: true }),
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText("AI budget")).toBeInTheDocument();
await expect(await canvas.findByText("Budget type")).toBeInTheDocument();
// Override source, no limit.
await expect(
await canvas.findByTestId(`member-ai-budget-${MockUserOwner.id}`),
).toHaveTextContent("$1,345 / unlimited USD");
await expect(await canvas.findByText("Individual")).toBeInTheDocument();
// Group source, finite limit.
await expect(
await canvas.findByTestId(`member-ai-budget-${MockUserMember.id}`),
).toHaveTextContent("$5,492 / $7,000 USD");
await expect(await canvas.findByText("Group")).toBeInTheDocument();
// No spend reported for this member.
await expect(
await canvas.findByTestId(`member-ai-budget-${memberWithoutSpend.id}`),
).toHaveTextContent("-");
// Column header tooltips.
const body = within(document.body);
await userEvent.click(
within(canvas.getByText("AI budget")).getByRole("button", {
name: "More info",
}),
);
await expect(
await body.findByText(
"A member's AI spend against their budget for the current period.",
),
).toBeInTheDocument();
await userEvent.click(
within(canvas.getByText("Budget type")).getByRole("button", {
name: "More info",
}),
);
await expect(
await body.findByText(
"Whether a member's budget comes from their group or an individual override.",
),
).toBeInTheDocument();
},
};
// Budget governed by another group (effective_group_id points elsewhere): only
// the member's spend shows, with no limit or type.
export const WithMemberAIBudgetFromAnotherGroup: Story = {
parameters: {
features: ["aibridge"],
experiments: ["ai-gateway-cost-control"],
queries: [
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({
users: [
memberWithSpend(MockUserOwner, {
effective_group_id: MockGroup2.id,
limit_source: "group",
}),
],
count: 1,
}),
permissionsQuery({ canUpdateGroup: true }),
{
key: getGroupByIdQueryKey(MockGroup2.id, { exclude_members: true }),
data: MockGroup2,
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
const cell = await canvas.findByTestId(
`member-ai-budget-${MockUserOwner.id}`,
);
await expect(cell).toHaveTextContent("$1,345");
await expect(cell).not.toHaveTextContent("USD");
await expect(canvas.queryByText("Group")).not.toBeInTheDocument();
// The info tooltip names the group that sets the budget.
await userEvent.click(
within(cell).getByRole("button", { name: "More info" }),
);
await expect(await body.findByText(/developer/)).toBeInTheDocument();
},
};
// AI Bridge hidden: neither the AI budget nor the budget type column renders.
export const WithoutMemberAIBudgetColumn: Story = {
parameters: {
queries: [
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({ users: [MockUserOwner], count: 1 }),
permissionsQuery({ canUpdateGroup: true }),
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByRole("table", { name: "Group members" });
expect(canvas.queryByText("AI budget")).not.toBeInTheDocument();
expect(canvas.queryByText("Budget type")).not.toBeInTheDocument();
},
};
export const OpenAIBudgetFromMemberMenu: Story = {
parameters: {
features: ["aibridge"],
@@ -247,10 +401,19 @@ export const OpenAIBudgetFromMemberMenu: Story = {
queries: [
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({
users: MockGroup.members,
count: MockGroup.members.length,
users: [
memberWithSpend(MockUserOwner, {
effective_group_id: MockGroup2.id,
}),
MockUserMember,
],
count: 2,
}),
permissionsQuery({ canUpdateGroup: true }),
{
key: getGroupByIdQueryKey(MockGroup2.id, { exclude_members: true }),
data: MockGroup2,
},
{
key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id),
data: mockOwnerOverride,
@@ -263,7 +426,7 @@ export const OpenAIBudgetFromMemberMenu: Story = {
data: [MockGroup],
},
{
key: groupAIBudget(MockGroupWithoutMembers.id).queryKey,
key: groupAIBudget(MockGroup2.id).queryKey,
data: null,
},
],
@@ -281,5 +444,111 @@ export const OpenAIBudgetFromMemberMenu: Story = {
await expect(
await body.findByText("Custom monthly budget"),
).toBeInTheDocument();
await expect(await body.findByText("developer")).toBeInTheDocument();
},
};
// effective_group_id null: spend greys out, dialog marks no "(default)".
export const WithMemberAIBudgetWithoutEffectiveGroup: Story = {
parameters: {
features: ["aibridge"],
experiments: ["ai-gateway-cost-control"],
queries: [
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({
users: [
memberWithSpend(MockUserOwner, {
effective_group_id: null,
limit_source: "group",
}),
],
count: 1,
}),
permissionsQuery({ canUpdateGroup: true }),
{ key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null },
{
key: getGroupsForUserQueryKey(
MockUserOwner.id,
MockGroupWithoutMembers.organization_id,
),
data: [MockGroup2],
},
{ key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, data: null },
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
const cell = await canvas.findByTestId(
`member-ai-budget-${MockUserOwner.id}`,
);
await expect(cell).toHaveTextContent("$1,345");
await expect(cell).not.toHaveTextContent("USD");
// Generic fallback when no group name resolves.
await userEvent.click(
within(cell).getByRole("button", { name: "More info" }),
);
await expect(
await body.findByText(/set by another group/),
).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await userEvent.click(
canvas.getAllByRole("button", { name: "Open menu" })[0],
);
await userEvent.click(
await body.findByRole("menuitem", { name: "AI Budget" }),
);
await userEvent.click(await body.findByText("Override group budget"));
await expect(
await body.findByText("Custom monthly budget"),
).toBeInTheDocument();
await expect(body.queryByText(/\(default\)/)).not.toBeInTheDocument();
},
};
// Governed by the viewed group: the dialog marks it "(default)".
export const OpenAIBudgetForCurrentGroupMember: Story = {
parameters: {
features: ["aibridge"],
experiments: ["ai-gateway-cost-control"],
queries: [
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({
users: [
memberWithSpend(MockUserOwner, {
effective_group_id: MockGroupWithoutMembers.id,
limit_source: "group",
}),
],
count: 1,
}),
permissionsQuery({ canUpdateGroup: true }),
{ key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null },
{
key: getGroupsForUserQueryKey(
MockUserOwner.id,
MockGroupWithoutMembers.organization_id,
),
data: [MockGroup2],
},
{ key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, data: null },
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(
canvas.getAllByRole("button", { name: "Open menu" })[0],
);
await userEvent.click(
await body.findByRole("menuitem", { name: "AI Budget" }),
);
await userEvent.click(await body.findByText("Override group budget"));
await expect(
await body.findByText("Front-End (default)"),
).toBeInTheDocument();
},
};
+3 -2
View File
@@ -9,6 +9,7 @@ import {
useSearchParams,
} from "react-router";
import { toast } from "sonner";
import type { GroupMemberWithAICostControl } from "#/api/api";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import {
deleteGroup,
@@ -16,7 +17,7 @@ import {
groupMembers,
groupPermissions,
} from "#/api/queries/groups";
import type { Group, ReducedUser } from "#/api/typesGenerated";
import type { Group } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Avatar } from "#/components/Avatar/Avatar";
import { AvatarData } from "#/components/Avatar/AvatarData";
@@ -37,7 +38,7 @@ import { pageTitle } from "#/utils/page";
export type GroupPageOutletContext = {
group: Group;
members: readonly ReducedUser[];
members: readonly GroupMemberWithAICostControl[];
permissions: { canUpdateGroup: boolean };
organization: string;
groupQuery: ReturnType<typeof useQuery>;
+2 -24
View File
@@ -4,10 +4,7 @@ import { useQuery } from "react-query";
import { Link as RouterLink } from "react-router";
import { toast } from "sonner";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import {
groupsByOrganization,
organizationGroupsAISpend,
} from "#/api/queries/groups";
import { groupsByOrganization } from "#/api/queries/groups";
import { organizationsPermissions } from "#/api/queries/organizations";
import { Button } from "#/components/Button/Button";
import { EmptyState } from "#/components/EmptyState/EmptyState";
@@ -40,10 +37,6 @@ const GroupsPage: FC = () => {
...organizationsPermissions([organization?.id ?? ""]),
enabled: Boolean(organization),
});
const aiSpendQuery = useQuery({
...organizationGroupsAISpend(organization?.name ?? ""),
enabled: Boolean(organization) && groupsEnabled && aibridgeVisible,
});
useEffect(() => {
if (groupsQuery.error) {
@@ -67,17 +60,6 @@ const GroupsPage: FC = () => {
}
}, [permissionsQuery.error]);
useEffect(() => {
if (aiSpendQuery.error) {
toast.error(
getErrorMessage(aiSpendQuery.error, "Unable to load AI budget."),
{
description: getErrorDetail(aiSpendQuery.error),
},
);
}
}, [aiSpendQuery.error]);
if (!organization) {
return <EmptyState message="Organization not found" />;
}
@@ -126,11 +108,7 @@ const GroupsPage: FC = () => {
groups={groupsQuery.data}
canCreateGroup={permissions.createGroup}
groupsEnabled={groupsEnabled}
aiBudget={
aibridgeVisible
? { spend: aiSpendQuery.data, isLoading: aiSpendQuery.isLoading }
: undefined
}
showAIBudget={aibridgeVisible}
/>
</div>
);
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import type { Group } from "#/api/typesGenerated";
import type { GroupAICostControl, GroupWithAICostControl } from "#/api/api";
import { MockGroup } from "#/testHelpers/entities";
import { GroupsPageView } from "./GroupsPageView";
@@ -12,11 +12,16 @@ const meta: Meta<typeof GroupsPageView> = {
export default meta;
type Story = StoryObj<typeof GroupsPageView>;
const aiGroup = (id: string, name: string): Group => ({
const aiGroup = (
id: string,
name: string,
ai_cost_control?: GroupAICostControl,
): GroupWithAICostControl => ({
...MockGroup,
id,
name,
display_name: name,
ai_cost_control,
});
export const NotEnabled: Story = {
@@ -39,57 +44,39 @@ export const WithAIBudgets: Story = {
args: {
canCreateGroup: true,
groupsEnabled: true,
showAIBudget: true,
groups: [
aiGroup("ai-unlimited", "Unlimited"),
aiGroup("ai-under", "Under budget"),
aiGroup("ai-warning", "Near limit"),
aiGroup("ai-at-limit", "At limit"),
aiGroup("ai-over", "Over budget"),
aiGroup("ai-zero-budget", "Zero budget"),
aiGroup("ai-zero-both", "Zero spend and budget"),
aiGroup("ai-unlimited", "Unlimited", {
current_spend_micros: 25_492_000_000,
spend_limit_micros: null,
}),
aiGroup("ai-under", "Under budget", {
current_spend_micros: 10_000_000,
spend_limit_micros: 50_000_000,
}),
aiGroup("ai-warning", "Near limit", {
current_spend_micros: 46_000_000,
spend_limit_micros: 50_000_000,
}),
aiGroup("ai-at-limit", "At limit", {
current_spend_micros: 50_000_000,
spend_limit_micros: 50_000_000,
}),
aiGroup("ai-over", "Over budget", {
current_spend_micros: 75_000_000,
spend_limit_micros: 50_000_000,
}),
aiGroup("ai-zero-budget", "Zero budget", {
current_spend_micros: 5_000_000,
spend_limit_micros: 0,
}),
aiGroup("ai-zero-both", "Zero spend and budget", {
current_spend_micros: 0,
spend_limit_micros: 0,
}),
// No cost control exercises the missing-spend "-" fallback.
aiGroup("ai-no-data", "No data"),
],
aiBudget: {
isLoading: false,
// "ai-no-data" is omitted to exercise the missing-spend "-" fallback.
spend: [
{
group_id: "ai-unlimited",
current_spend_micros: 25_492_000_000,
spend_limit_micros: null,
},
{
group_id: "ai-under",
current_spend_micros: 10_000_000,
spend_limit_micros: 50_000_000,
},
{
group_id: "ai-warning",
current_spend_micros: 46_000_000,
spend_limit_micros: 50_000_000,
},
{
group_id: "ai-at-limit",
current_spend_micros: 50_000_000,
spend_limit_micros: 50_000_000,
},
{
group_id: "ai-over",
current_spend_micros: 75_000_000,
spend_limit_micros: 50_000_000,
},
{
group_id: "ai-zero-budget",
current_spend_micros: 5_000_000,
spend_limit_micros: 0,
},
{
group_id: "ai-zero-both",
current_spend_micros: 0,
spend_limit_micros: 0,
},
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -114,23 +101,23 @@ export const WithAIBudgets: Story = {
},
};
// Groups still loading: the table shows skeleton rows including the AI column.
export const WithAIBudgetsLoading: Story = {
args: {
groups: [MockGroup],
groups: undefined,
canCreateGroup: true,
groupsEnabled: true,
aiBudget: { spend: undefined, isLoading: true },
showAIBudget: true,
},
};
// Spend unavailable (request failed or returned nothing): groups fall back to
// "-". The error toast is fired by the GroupsPage container, not this view.
// Cost control unset for a group: the cell falls back to "-".
export const WithAIBudgetsSpendUnavailable: Story = {
args: {
groups: [aiGroup("ai-unavailable", "Spend unavailable")],
canCreateGroup: true,
groupsEnabled: true,
aiBudget: { spend: undefined, isLoading: false },
showAIBudget: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -146,7 +133,7 @@ export const WithoutAIBudgetColumn: Story = {
groups: [aiGroup("ai-hidden", "No AI column")],
canCreateGroup: true,
groupsEnabled: true,
aiBudget: undefined,
showAIBudget: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
+27 -85
View File
@@ -1,15 +1,13 @@
import { ChevronRightIcon, PlusIcon } from "lucide-react";
import type { FC } from "react";
import { Link as RouterLink, useNavigate } from "react-router";
import type { OrganizationGroupAISpend } from "#/api/api";
import type { Group } from "#/api/typesGenerated";
import type { GroupWithAICostControl } from "#/api/api";
import { Avatar } from "#/components/Avatar/Avatar";
import { AvatarData } from "#/components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { InfoTooltip } from "#/components/InfoTooltip/InfoTooltip";
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import {
@@ -25,32 +23,22 @@ import {
TableRowSkeleton,
} from "#/components/TableLoader/TableLoader";
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
import { getSeverity, severityTextClassName } from "#/utils/budget";
import { microsToDollars, usdBudgetFormatter } from "#/utils/currency";
import { docs } from "#/utils/docs";
import { AIBudgetUsage } from "./AIBudgetUsage";
import { InfoIconTooltip } from "./InfoIconTooltip";
type GroupsPageViewProps = {
groups: Group[] | undefined;
groups: GroupWithAICostControl[] | undefined;
canCreateGroup: boolean;
groupsEnabled: boolean;
// Present when the AI budget column should be shown.
aiBudget?: {
spend: readonly OrganizationGroupAISpend[] | undefined;
isLoading: boolean;
};
};
// Per-group spend resolved for rendering; present only when the column shows.
type AIBudgetColumn = {
spendByGroupID: ReadonlyMap<string, OrganizationGroupAISpend>;
isLoading: boolean;
showAIBudget: boolean;
};
export const GroupsPageView: FC<GroupsPageViewProps> = ({
groups,
canCreateGroup,
groupsEnabled,
aiBudget,
showAIBudget,
}) => {
if (!groupsEnabled) {
return (
@@ -62,26 +50,19 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
);
}
const aiBudgetColumn: AIBudgetColumn | undefined = aiBudget && {
spendByGroupID: new Map(
aiBudget.spend?.map((spend) => [spend.group_id, spend]),
),
isLoading: aiBudget.isLoading,
};
return (
<Table aria-label="Groups">
<TableHeader>
<TableRow>
<TableHead className="w-2/5">Name</TableHead>
<TableHead className={aiBudgetColumn ? "w-1/5" : "w-3/5"}>
<TableHead className={showAIBudget ? "w-1/5" : "w-3/5"}>
Users
</TableHead>
{aiBudgetColumn && (
{showAIBudget && (
<TableHead className="w-2/5">
<div className="flex items-center gap-1">
AI budget
<InfoTooltip message="Current AI spend compared to the group's AI budget for the active period." />
<InfoIconTooltip message="Current AI spend compared to the group's AI budget for the active period." />
</div>
</TableHead>
)}
@@ -92,7 +73,7 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
<GroupsTableBody
groups={groups}
canCreateGroup={canCreateGroup}
aiBudgetColumn={aiBudgetColumn}
showAIBudget={showAIBudget}
/>
</TableBody>
</Table>
@@ -100,18 +81,18 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
};
interface GroupsTableBodyProps {
groups: Group[] | undefined;
groups: GroupWithAICostControl[] | undefined;
canCreateGroup: boolean;
aiBudgetColumn: AIBudgetColumn | undefined;
showAIBudget: boolean;
}
const GroupsTableBody: FC<GroupsTableBodyProps> = ({
groups,
canCreateGroup,
aiBudgetColumn,
showAIBudget,
}) => {
if (groups === undefined) {
return <TableLoader showAIBudget={aiBudgetColumn !== undefined} />;
return <TableLoader showAIBudget={showAIBudget} />;
}
if (groups.length === 0) {
return (
@@ -142,22 +123,18 @@ const GroupsTableBody: FC<GroupsTableBodyProps> = ({
return (
<>
{groups.map((group) => (
<GroupRow
key={group.id}
group={group}
aiBudgetColumn={aiBudgetColumn}
/>
<GroupRow key={group.id} group={group} showAIBudget={showAIBudget} />
))}
</>
);
};
interface GroupRowProps {
group: Group;
aiBudgetColumn: AIBudgetColumn | undefined;
group: GroupWithAICostControl;
showAIBudget: boolean;
}
const GroupRow: FC<GroupRowProps> = ({ group, aiBudgetColumn }) => {
const GroupRow: FC<GroupRowProps> = ({ group, showAIBudget }) => {
const navigate = useNavigate();
const rowProps = useClickableTableRow({
onClick: () => navigate(group.name),
@@ -203,12 +180,16 @@ const GroupRow: FC<GroupRowProps> = ({ group, aiBudgetColumn }) => {
)}
</TableCell>
{aiBudgetColumn && (
{showAIBudget && (
<TableCell>
<GroupAIBudgetCell
aiSpend={aiBudgetColumn.spendByGroupID.get(group.id)}
isLoading={aiBudgetColumn.isLoading}
/>
{group.ai_cost_control ? (
<AIBudgetUsage
currentSpend={group.ai_cost_control.current_spend_micros}
spendLimit={group.ai_cost_control.spend_limit_micros}
/>
) : (
"-"
)}
</TableCell>
)}
@@ -221,41 +202,6 @@ const GroupRow: FC<GroupRowProps> = ({ group, aiBudgetColumn }) => {
);
};
const GroupAIBudgetCell: FC<{
aiSpend: OrganizationGroupAISpend | undefined;
isLoading: boolean;
}> = ({ aiSpend, isLoading }) => {
if (isLoading) {
return <Skeleton variant="text" width="50%" />;
}
if (aiSpend === undefined) {
return "-";
}
const { current_spend_micros, spend_limit_micros } = aiSpend;
if (spend_limit_micros === null) {
return (
<span className="whitespace-nowrap">
{formatBudgetUSD(current_spend_micros)}{" "}
<span className="text-content-disabled">/ unlimited USD</span>
</span>
);
}
const severity = getSeverity(current_spend_micros, spend_limit_micros);
return (
<span className="whitespace-nowrap">
<span className={severityTextClassName(severity)}>
{formatBudgetUSD(current_spend_micros)}
</span>{" "}
/ {formatBudgetUSD(spend_limit_micros)}{" "}
<span className="text-content-disabled">USD</span>
</span>
);
};
const TableLoader: FC<{ showAIBudget: boolean }> = ({ showAIBudget }) => {
return (
<TableLoaderSkeleton>
@@ -280,7 +226,3 @@ const TableLoader: FC<{ showAIBudget: boolean }> = ({ showAIBudget }) => {
</TableLoaderSkeleton>
);
};
function formatBudgetUSD(micros: number): string {
return usdBudgetFormatter.format(microsToDollars(micros));
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { InfoIconTooltip } from "./InfoIconTooltip";
const meta: Meta<typeof InfoIconTooltip> = {
title: "pages/OrganizationGroupsPage/InfoIconTooltip",
component: InfoIconTooltip,
args: { message: "Spend compared to the budget for the active period." },
};
export default meta;
type Story = StoryObj<typeof InfoIconTooltip>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "More info" }));
await expect(
await within(document.body).findByText(
"Spend compared to the budget for the active period.",
),
).toBeInTheDocument();
},
};
// Muted icon, used where it sits next to greyed content.
export const Muted: Story = {
args: { className: "text-content-disabled" },
};
@@ -0,0 +1,23 @@
import { InfoIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
import {
HelpPopover,
HelpPopoverContent,
HelpPopoverIconTrigger,
HelpPopoverText,
} from "#/components/HelpPopover/HelpPopover";
/** An (i) info tooltip. `className` sets the icon color. */
export const InfoIconTooltip: FC<{
message: ReactNode;
className?: string;
}> = ({ message, className = "text-content-secondary" }) => (
<HelpPopover>
<HelpPopoverIconTrigger size="small" hoverEffect={false}>
<InfoIcon className={className} />
</HelpPopoverIconTrigger>
<HelpPopoverContent>
<HelpPopoverText>{message}</HelpPopoverText>
</HelpPopoverContent>
</HelpPopover>
);
@@ -116,6 +116,21 @@ export const Uncapped: Story = {
).not.toBeInTheDocument();
},
);
await step(
"the empty field flags an error only after it's touched",
async () => {
const budgetInput = body.getByLabelText("Custom monthly budget");
await expect(
body.queryByText("Enter a monthly budget of 0 or more."),
).not.toBeInTheDocument();
await userEvent.click(budgetInput);
await userEvent.tab();
await expect(
await body.findByText("Enter a monthly budget of 0 or more."),
).toBeInTheDocument();
},
);
},
};
@@ -204,6 +219,8 @@ export const SubmitRequiresValueOrUncheck: Story = {
await step("clearing the budget blocks submit", async () => {
await userEvent.clear(budgetInput);
// Blur to surface the error, matching the touched-then-validate flow.
await userEvent.tab();
await expect(
await body.findByText("Enter a monthly budget of 0 or more."),
).toBeInTheDocument();
@@ -9,7 +9,7 @@ import {
import { useMutation, useQuery, useQueryClient } from "react-query";
import { toast } from "sonner";
import { getErrorDetail } from "#/api/errors";
import { groupAIBudget, groupsForUser } from "#/api/queries/groups";
import { groupAIBudget, groupById, groupsForUser } from "#/api/queries/groups";
import {
deleteUserAIBudgetOverride,
saveUserAIBudgetOverride,
@@ -55,8 +55,8 @@ import { Spinner } from "#/components/Spinner/Spinner";
import { cn } from "#/utils/cn";
import {
dollarsToMicros,
formatBudgetUSD,
microsToDollars,
usdBudgetFormatter,
} from "#/utils/currency";
interface UserAIBudgetOverrideDialogProps {
@@ -64,12 +64,22 @@ interface UserAIBudgetOverrideDialogProps {
onOpenChange: (open: boolean) => void;
user: ReducedUser;
currentGroup: Group;
effectiveGroupId?: string | null;
}
export const UserAIBudgetOverrideDialog: FC<
UserAIBudgetOverrideDialogProps
> = ({ open, onOpenChange, user, currentGroup }) => {
> = ({ open, onOpenChange, user, currentGroup, effectiveGroupId }) => {
const queryClient = useQueryClient();
const shouldLoadEffectiveGroup =
!!effectiveGroupId && effectiveGroupId !== currentGroup.id;
const effectiveGroupQuery = useQuery({
...groupById(effectiveGroupId ?? "", { exclude_members: true }),
enabled: open && shouldLoadEffectiveGroup,
});
const budgetGroup = shouldLoadEffectiveGroup
? effectiveGroupQuery.data
: currentGroup;
const budgetOverrideQuery = useQuery({
...userAIBudgetOverride(user.id),
enabled: open,
@@ -79,8 +89,8 @@ export const UserAIBudgetOverrideDialog: FC<
enabled: open,
});
const groupBudgetQuery = useQuery({
...groupAIBudget(currentGroup.id),
enabled: open,
...groupAIBudget(budgetGroup?.id ?? currentGroup.id),
enabled: open && budgetGroup !== undefined,
});
const saveMutation = useMutation(
saveUserAIBudgetOverride(queryClient, user.id),
@@ -90,10 +100,12 @@ export const UserAIBudgetOverrideDialog: FC<
);
const loadError =
effectiveGroupQuery.error ??
budgetOverrideQuery.error ??
userGroupsQuery.error ??
groupBudgetQuery.error;
const isLoading =
effectiveGroupQuery.isLoading ||
budgetOverrideQuery.isLoading ||
userGroupsQuery.isLoading ||
groupBudgetQuery.isLoading;
@@ -134,10 +146,15 @@ export const UserAIBudgetOverrideDialog: FC<
<Spinner loading />
Loading AI budget...
</div>
) : (
) : budgetGroup ? (
<OverrideForm
user={user}
currentGroup={currentGroup}
currentGroup={budgetGroup}
defaultGroupId={
effectiveGroupId === undefined
? currentGroup.id
: effectiveGroupId
}
override={budgetOverrideQuery.data ?? null}
groupBudget={groupBudgetQuery.data ?? null}
userGroups={userGroupsQuery.data ?? []}
@@ -146,7 +163,7 @@ export const UserAIBudgetOverrideDialog: FC<
onRemove={deleteMutation.mutateAsync}
onClose={() => onOpenChange(false)}
/>
)}
) : null}
</DialogContent>
</Dialog>
);
@@ -155,6 +172,8 @@ export const UserAIBudgetOverrideDialog: FC<
interface OverrideFormProps {
user: ReducedUser;
currentGroup: Group;
// Group marked "(default)" in the picker; null marks none.
defaultGroupId: string | null;
override: UserAIBudgetOverride | null;
groupBudget: GroupAIBudget | null;
userGroups: readonly Group[];
@@ -168,6 +187,7 @@ interface OverrideFormProps {
const OverrideForm: FC<OverrideFormProps> = ({
user,
currentGroup,
defaultGroupId,
override,
groupBudget,
userGroups,
@@ -187,6 +207,7 @@ const OverrideForm: FC<OverrideFormProps> = ({
const seedMicros = (override ?? groupBudget)?.spend_limit_micros;
return seedMicros === undefined ? "" : String(microsToDollars(seedMicros));
});
const [budgetTouched, setBudgetTouched] = useState(false);
const [selectedGroupId, setSelectedGroupId] = useState(
override?.group_id ?? currentGroup.id,
);
@@ -208,7 +229,8 @@ const OverrideForm: FC<OverrideFormProps> = ({
// A "0" budget is valid and disables AI; empty or negative is not.
const budgetAmount = Number(budgetDollars);
const budgetValid = budgetDollars.trim() !== "" && budgetAmount >= 0;
const budgetInvalid = overrideEnabled && !budgetValid;
// Hold the error until the field is touched, so it doesn't flag immediately.
const budgetInvalid = overrideEnabled && budgetTouched && !budgetValid;
const budgetDisablesAI = budgetValid && budgetAmount === 0;
// Footer shows only when there's something to save or remove.
const showFooter = overrideEnabled || override !== null;
@@ -217,7 +239,7 @@ const OverrideForm: FC<OverrideFormProps> = ({
!isSubmitting && (overrideEnabled ? budgetValid : override !== null);
const groupLabel = (group: Group) =>
group.id === currentGroup.id
group.id === defaultGroupId
? `${groupDisplayName(group)} (default)`
: groupDisplayName(group);
@@ -309,6 +331,7 @@ const OverrideForm: FC<OverrideFormProps> = ({
id={budgetId}
value={budgetDollars}
onChange={(event) => setBudgetDollars(event.target.value)}
onBlur={() => setBudgetTouched(true)}
type="number"
min="0"
step="1"
@@ -415,5 +438,4 @@ const Bold: FC<{ children: ReactNode }> = ({ children }) => (
const groupDisplayName = (group: Group): string =>
group.display_name || group.name;
const formatUSD = (micros: number): string =>
`${usdBudgetFormatter.format(microsToDollars(micros))} USD`;
const formatUSD = (micros: number): string => `${formatBudgetUSD(micros)} USD`;
+5 -7
View File
@@ -332,15 +332,13 @@ export const handlers = [
// Groups
http.get("/api/v2/organizations/:organizationId/groups", () => {
return HttpResponse.json([MockGroup]);
}),
http.get("/api/v2/organizations/:organizationId/groups/ai/spend", () => {
return HttpResponse.json([
{
group_id: MockGroup.id,
current_spend_micros: 25_492_000_000,
spend_limit_micros: null,
...MockGroup,
ai_cost_control: {
current_spend_micros: 25_492_000_000,
spend_limit_micros: null,
},
},
]);
}),
+5
View File
@@ -28,6 +28,11 @@ export function microsToDollars(micros: number): number {
return micros / MICROS_PER_DOLLAR;
}
/** Formats micros as a whole-dollar USD budget, e.g. "$1,345". */
export function formatBudgetUSD(micros: number): string {
return usdBudgetFormatter.format(microsToDollars(micros));
}
export function dollarsToMicros(dollars: string | number): number {
if (typeof dollars === "string" && dollars.trim() === "") {
return 0;