feat(usage): redesign the create-spending-limit modal (frontend 1/2) (#13783)

This commit is contained in:
roshan authored and GitHub committed 2026-08-07 19:17:25 +00:00
1 parent 08b75ae8c9
commit a7ba98fef8
2 files changed
+662 -116

No files matched your search

@@ -1,19 +1,99 @@
import { render, screen, setupUser } from "@tests/setup/test-utils";
import { fireEvent, render, screen, waitFor } from "@tests/setup/test-utils";
import CreateRateLimitModal from "@/app/admin/token-rate-limits/CreateRateLimitModal";
test("requires at least one enforcement budget", async () => {
const user = setupUser();
beforeEach(() => {
global.fetch = jest
.fn()
.mockResolvedValue({ ok: true, json: async () => [] } as Response);
});
test("requires a cost budget when no token budget is set", async () => {
const onSubmit = jest.fn().mockResolvedValue(undefined);
render(
<CreateRateLimitModal isOpen setIsOpen={jest.fn()} onSubmit={onSubmit} />
);
await user.type(screen.getByLabelText("Time Window (UTC Days)"), "1");
await user.click(screen.getByRole("button", { name: "Create" }));
const modal = screen.getByRole("dialog");
const resetPeriod = screen.getByRole("textbox", { name: /Reset period/ });
expect(modal).toHaveAttribute("data-width", "sm");
expect(modal).toHaveAttribute("data-height", "fit");
expect(resetPeriod).toHaveAttribute("type", "text");
expect(resetPeriod).toHaveAttribute("inputmode", "numeric");
expect(resetPeriod).toHaveValue("30");
fireEvent.click(screen.getByRole("button", { name: "Create limit" }));
expect(
await screen.findByText("Set a token budget and/or a cost budget")
await screen.findByText("Enter a cost budget, a token budget, or both")
).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
test("formats and submits a token-only budget", async () => {
const onSubmit = jest.fn().mockResolvedValue(undefined);
render(
<CreateRateLimitModal isOpen setIsOpen={jest.fn()} onSubmit={onSubmit} />
);
const tokenBudget = screen.getByRole("textbox", {
name: /Token budget/,
});
fireEvent.change(tokenBudget, {
target: { value: "1500000000" },
});
expect(tokenBudget).toHaveValue("1,500,000,000");
fireEvent.click(screen.getByRole("button", { name: "Create limit" }));
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
"global",
720,
1_500_000,
null,
undefined
)
);
});
test("rejects a cost budget that rounds below one cent", async () => {
const onSubmit = jest.fn().mockResolvedValue(undefined);
render(
<CreateRateLimitModal isOpen setIsOpen={jest.fn()} onSubmit={onSubmit} />
);
fireEvent.change(screen.getByRole("textbox", { name: /Cost budget/ }), {
target: { value: "0.001" },
});
fireEvent.click(screen.getByRole("button", { name: "Create limit" }));
expect(
await screen.findByText("Cost budget must be at least $0.01")
).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
test("supports arrow-key navigation between scope options", async () => {
render(
<CreateRateLimitModal
isOpen
setIsOpen={jest.fn()}
onSubmit={jest.fn().mockResolvedValue(undefined)}
/>
);
const workspace = screen.getByRole("radio", { name: "Workspace" });
const perUser = screen.getByRole("radio", { name: "Per user" });
workspace.focus();
fireEvent.keyDown(workspace, { key: "ArrowRight" });
await waitFor(() => expect(perUser).toHaveAttribute("aria-checked", "true"));
expect(perUser).toHaveFocus();
});
@@ -1,17 +1,347 @@
"use client";
import * as Yup from "yup";
import { Button } from "@opal/components";
import { useEffect, useState } from "react";
import { Modal } from "@opal/components";
import { Form, Formik } from "formik";
import { SelectorFormField, TextFormField } from "@/components/Field";
import { UserGroup } from "@/lib/types";
import {
Button,
InputTypeIn,
LineItemButton,
Modal,
Popover,
SelectCard,
Text,
} from "@opal/components";
import React, { useEffect, useState } from "react";
import { Form, Formik, useField, useFormikContext } from "formik";
import { Scope } from "./types";
import { toast } from "@opal/layouts";
import { SvgSettings } from "@opal/icons";
import {
ContentAction,
InputErrorText,
InputVertical,
Section,
toast,
} from "@opal/layouts";
import { SvgCheck, SvgGlobe, SvgUser, SvgUsers } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import InputTypeInField from "@/refresh-components/form/InputTypeInField";
const HOURS_PER_DAY = 24;
// 1T tokens stored as thousands (1e9) stays well inside the column's int32.
const MAX_TOKEN_BUDGET = 1_000_000_000_000;
// Keeps *100 finite: an unbounded value can overflow to Infinity, which JSON.stringify serializes as null.
const MAX_COST_BUDGET_DOLLARS = 10_000_000_000;
interface RateLimitFormValues {
enabled: boolean;
period_days: string;
token_budget: string;
cost_budget_dollars: string;
target_scope: Scope;
user_group_id: number | undefined;
}
interface ScopeOptionConfig {
value: Scope;
icon: IconFunctionComponent;
title: string;
}
const SCOPE_OPTIONS: ScopeOptionConfig[] = [
{ value: Scope.GLOBAL, icon: SvgGlobe, title: "Workspace" },
{ value: Scope.USER, icon: SvgUser, title: "Per user" },
{ value: Scope.USER_GROUP, icon: SvgUsers, title: "User group" },
];
interface ScopeOptionProps extends React.HTMLAttributes<HTMLElement> {
option: ScopeOptionConfig;
selected: boolean;
onSelect: () => void;
ref?: React.Ref<HTMLDivElement>;
}
function handleRadioOptionKeyDown(
event: React.KeyboardEvent<HTMLElement>,
onSelect: () => void
): void {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect();
return;
}
if (
event.key !== "ArrowRight" &&
event.key !== "ArrowDown" &&
event.key !== "ArrowLeft" &&
event.key !== "ArrowUp" &&
event.key !== "Home" &&
event.key !== "End"
) {
return;
}
event.preventDefault();
const group = event.currentTarget.closest('[role="radiogroup"]');
const options = Array.from(
group?.querySelectorAll<HTMLElement>('[role="radio"]') ?? []
);
const currentIndex = options.indexOf(event.currentTarget);
const nextIndex =
event.key === "Home"
? 0
: event.key === "End"
? options.length - 1
: event.key === "ArrowRight" || event.key === "ArrowDown"
? (currentIndex + 1) % options.length
: (currentIndex - 1 + options.length) % options.length;
options[nextIndex]?.focus();
options[nextIndex]?.click();
}
function ScopeOption({
option,
selected,
onSelect,
ref,
...rest
}: ScopeOptionProps) {
return (
<SelectCard
state={selected ? "selected" : "empty"}
padding="sm"
rounding="sm"
role="radio"
aria-checked={selected}
aria-label={option.title}
tabIndex={selected ? 0 : -1}
ref={ref}
{...rest}
onClick={(event) => {
rest.onClick?.(event);
onSelect();
}}
onKeyDown={(event) => {
rest.onKeyDown?.(event);
handleRadioOptionKeyDown(event, onSelect);
}}
>
<ContentAction
sizePreset="main-ui"
variant="section"
icon={option.icon}
title={option.title}
padding="fit"
color="interactive"
center
rightChildren={
selected ? (
<SvgCheck
size={16}
className="shrink-0 stroke-action-selection-05"
/>
) : undefined
}
/>
</SelectCard>
);
}
interface GroupScopeOptionProps {
option: ScopeOptionConfig;
selected: boolean;
groups: { name: string; value: number }[];
selectedGroupId: number | undefined;
onSelectScope: () => void;
onSelectGroup: (groupId: number) => void;
}
interface GroupMenuContentProps {
groups: { name: string; value: number }[];
selectedGroupId: number | undefined;
onSelectGroup: (groupId: number) => void;
}
function GroupMenuContent({
groups,
selectedGroupId,
onSelectGroup,
}: GroupMenuContentProps) {
return (
<Popover.Content align="start" side="bottom" width="lg">
<Popover.Menu>
{groups.length === 0
? [
<div className="p-2" key="empty">
<Text font="secondary-body" color="text-03" as="p">
No user groups yet. Create one under Users &amp; Groups.
</Text>
</div>,
]
: groups.map((group) => (
<Popover.Close asChild key={group.value}>
<LineItemButton
onClick={() => onSelectGroup(group.value)}
rounding="md"
selectVariant="select-heavy"
sizePreset="main-ui"
state={
String(group.value) === String(selectedGroupId)
? "selected"
: "empty"
}
title={group.name}
variant="section"
width="full"
/>
</Popover.Close>
))}
</Popover.Menu>
</Popover.Content>
);
}
function GroupScopeOption({
option,
selected,
groups,
selectedGroupId,
onSelectScope,
onSelectGroup,
}: GroupScopeOptionProps) {
return (
<Popover>
<Popover.Trigger asChild>
<ScopeOption
option={option}
selected={selected}
onSelect={onSelectScope}
/>
</Popover.Trigger>
<GroupMenuContent
groups={groups}
selectedGroupId={selectedGroupId}
onSelectGroup={onSelectGroup}
/>
</Popover>
);
}
function TokenBudgetField() {
const [field, meta, helpers] = useField<string>("token_budget");
const digits = String(field.value ?? "")
.replace(/\D/g, "")
.slice(0, 15);
const formattedValue = digits
? new Intl.NumberFormat("en-US").format(Number(digits))
: "";
return (
<InputTypeIn
id="token_budget"
name="token_budget"
value={formattedValue}
inputMode="numeric"
pattern="[0-9,]*"
maxLength={19}
placeholder="No token limit"
onChange={(event) => {
const nextDigits = event.target.value.replace(/\D/g, "").slice(0, 15);
void helpers.setValue(nextDigits);
}}
onBlur={() => void helpers.setTouched(true)}
variant={meta.touched && meta.error ? "error" : "primary"}
rightChildren={
<div className="pr-1">
<Text font="secondary-action" color="text-03" nowrap>
tokens
</Text>
</div>
}
/>
);
}
function formatDollarBudgetInput(raw: string): string | null {
const amount = Number(raw);
if (raw === "" || !Number.isFinite(amount) || amount <= 0) return null;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
}
function formatTokenBudgetInput(raw: string): string | null {
const amount = Number(raw);
if (raw === "" || !Number.isFinite(amount) || amount <= 0) return null;
return `${new Intl.NumberFormat("en-US").format(amount)} tokens`;
}
interface GroupPickerProps {
groups: { name: string; value: number }[];
selectedGroupId: number | undefined;
onSelectGroup: (groupId: number) => void;
}
function GroupPicker({
groups,
selectedGroupId,
onSelectGroup,
}: GroupPickerProps) {
const selectedGroup = groups.find(
(group) => String(group.value) === String(selectedGroupId)
);
return (
<Popover>
<Popover.Trigger asChild>
<Button prominence="secondary" width="full">
{selectedGroup?.name ?? "Choose a group"}
</Button>
</Popover.Trigger>
<GroupMenuContent
groups={groups}
selectedGroupId={selectedGroupId}
onSelectGroup={onSelectGroup}
/>
</Popover>
);
}
interface LimitSummaryProps {
groupName?: string;
}
function LimitSummary({ groupName }: LimitSummaryProps) {
const { values } = useFormikContext<RateLimitFormValues>();
const period = Number(values.period_days);
if (!Number.isInteger(period) || period < 1) return null;
const budgets = [
formatDollarBudgetInput(values.cost_budget_dollars),
formatTokenBudgetInput(values.token_budget),
].filter((budget): budget is string => budget !== null);
if (budgets.length === 0) return null;
const budgetText = budgets.join(" or ");
const periodText = period === 1 ? "every day" : `every ${period} days`;
const subjectText =
values.target_scope === Scope.GLOBAL
? "Everyone in the workspace shares"
: values.target_scope === Scope.USER
? "Each user can spend"
: groupName
? `Members of ${groupName} share`
: "Group members share";
const summary = `${subjectText} up to ${budgetText} ${periodText}. Usage is blocked until the period resets.`;
return (
<div className="rounded-08 bg-background-tint-01 p-2">
<Text font="secondary-body" color="text-03" as="p">
{summary}
</Text>
</div>
);
}
interface CreateRateLimitModalProps {
isOpen: boolean;
@@ -21,7 +351,7 @@ interface CreateRateLimitModalProps {
period_hours: number,
token_budget: number | null,
cost_budget_cents: number | null,
group_id: number
group_id?: number
) => Promise<void>;
forSpecificScope?: Scope;
forSpecificUserGroup?: number;
@@ -34,44 +364,55 @@ export default function CreateRateLimitModal({
forSpecificScope,
forSpecificUserGroup,
}: CreateRateLimitModalProps) {
const [modalUserGroups, setModalUserGroups] = useState([]);
const [shouldFetchUserGroups, setShouldFetchUserGroups] = useState(
forSpecificScope === Scope.USER_GROUP
);
const [modalUserGroups, setModalUserGroups] = useState<
{ name: string; value: number }[]
>([]);
const groupScopeAvailable =
forSpecificScope === undefined || forSpecificScope === Scope.USER_GROUP;
useEffect(() => {
if (!isOpen || !groupScopeAvailable || forSpecificUserGroup !== undefined) {
return;
}
let stale = false;
const fetchData = async () => {
try {
const response = await fetch("/api/manage/admin/user-group");
const data = await response.json();
const options = data.map((userGroup: UserGroup) => ({
const response = await fetch("/api/manage/user-groups/minimal");
if (!response.ok) {
throw new Error(response.statusText || "Request failed");
}
const data = (await response.json()) as Array<{
id: number;
name: string;
}>;
if (stale) return;
const options = data.map((userGroup) => ({
name: userGroup.name,
value: userGroup.id,
}));
setModalUserGroups(options);
setShouldFetchUserGroups(false);
} catch (error) {
toast.error(`Failed to fetch user groups: ${error}`);
console.error("Failed to fetch user groups:", error);
if (!stale) toast.error(`Failed to fetch user groups: ${error}`);
}
};
if (shouldFetchUserGroups) {
fetchData();
}
}, [shouldFetchUserGroups]);
fetchData();
return () => {
stale = true;
};
}, [isOpen, groupScopeAvailable, forSpecificUserGroup]);
return (
<Modal open={isOpen} onOpenChange={() => setIsOpen(false)}>
<Modal.Content width="sm" height="sm">
<Modal open={isOpen} onOpenChange={setIsOpen}>
<Modal.Content width="sm" height="fit">
<Modal.Header
icon={SvgSettings}
title="Create a Token Rate Limit"
title="Create spending limit"
onClose={() => setIsOpen(false)}
/>
<Formik
<Formik<RateLimitFormValues>
initialValues={{
enabled: true,
period_days: "",
period_days: "30",
token_budget: "",
cost_budget_dollars: "",
target_scope: forSpecificScope || Scope.GLOBAL,
@@ -79,36 +420,51 @@ export default function CreateRateLimitModal({
}}
validationSchema={Yup.object().shape({
period_days: Yup.number()
.required("Time Window is a required field")
.integer("Time Window must be a whole number of days")
.min(1, "Time Window must be at least 1 day"),
token_budget: Yup.number()
// Empty (no token budget) is allowed — a cost-only limit. Without
// this, "" coerces to NaN and trips .min(1) even when only a cost
// budget is set. Mirrors the cost_budget_dollars transform below.
.transform((value, original) =>
original === "" ? undefined : value
)
.min(1, "Token Budget must be at least 1")
.test(
"budget-required",
"Set a token budget and/or a cost budget",
(value, context) =>
value != null || context.parent.cost_budget_dollars != null
),
.required("Enter a reset period")
.integer("Enter a whole number of days")
.min(1, "Use at least 1 day"),
cost_budget_dollars: Yup.number()
// Empty (no cost budget) is allowed; a 0 would make the gate fire
// on the first request (cost_since >= 0 is always true).
.transform((value, original) =>
original === "" ? undefined : value
)
.moreThan(0, "Cost Budget must be greater than 0"),
.moreThan(0, "Cost budget must be greater than 0")
.max(
MAX_COST_BUDGET_DOLLARS,
`The maximum cost budget is $${new Intl.NumberFormat("en-US").format(MAX_COST_BUDGET_DOLLARS)}`
)
.test(
"minimum-cents",
"Cost budget must be at least $0.01",
(value) => value == null || Math.round(value * 100) > 0
)
.when("token_budget", {
is: (value: string | undefined) =>
value === undefined || value === "",
then: (schema) =>
schema.required(
"Enter a cost budget, a token budget, or both"
),
otherwise: (schema) => schema.notRequired(),
}),
token_budget: Yup.number()
.transform((value, original) =>
original === "" ? undefined : value
)
.notRequired()
.integer("Enter a whole number of tokens")
.min(1_000, "Use at least 1,000 tokens")
.max(MAX_TOKEN_BUDGET, "The maximum token budget is 1 trillion")
.test(
"whole-thousands",
"Use increments of 1,000 tokens",
(value) => value == null || value % 1_000 === 0
),
target_scope: Yup.string().required(
"Target Scope is a required field"
),
user_group_id: Yup.string().test(
"user_group_id",
"User Group is a required field",
"Select a user group",
(value, context) => {
return (
context.parent.target_scope !== "user_group" ||
@@ -119,10 +475,10 @@ export default function CreateRateLimitModal({
),
})}
onSubmit={async (values) => {
// Empty token field → null (cost-only); the gate skips a null budget.
// Sending 0 would mean "0-token limit" and block every request.
const tokenBudget =
values.token_budget === "" ? null : Number(values.token_budget);
values.token_budget === ""
? null
: Number(values.token_budget) / 1_000;
const costBudgetCents =
values.cost_budget_dollars === ""
? null
@@ -132,66 +488,176 @@ export default function CreateRateLimitModal({
Number(values.period_days) * HOURS_PER_DAY,
tokenBudget,
costBudgetCents,
Number(values.user_group_id)
values.target_scope === Scope.USER_GROUP
? Number(values.user_group_id)
: undefined
);
}}
>
{({ isSubmitting, values, setFieldValue }) => (
<Form className="flex flex-col h-full min-h-0 overflow-visible">
<Modal.Body>
{!forSpecificScope && (
<SelectorFormField
name="target_scope"
label="Target Scope"
options={[
{ name: "Global", value: Scope.GLOBAL },
{ name: "User", value: Scope.USER },
{ name: "User Group", value: Scope.USER_GROUP },
]}
includeDefault={false}
onSelect={(selected) => {
setFieldValue("target_scope", selected);
if (selected === Scope.USER_GROUP) {
setShouldFetchUserGroups(true);
}
}}
/>
)}
{forSpecificUserGroup === undefined &&
values.target_scope === Scope.USER_GROUP && (
<SelectorFormField
name="user_group_id"
label="User Group"
options={modalUserGroups}
includeDefault={false}
/>
)}
<TextFormField
name="period_days"
label="Time Window (UTC Days)"
type="number"
placeholder=""
/>
<TextFormField
name="token_budget"
label="Token Budget (Thousands, optional)"
type="number"
placeholder=""
/>
<TextFormField
name="cost_budget_dollars"
label="Cost Budget (USD per period, optional)"
type="number"
placeholder=""
/>
</Modal.Body>
<Modal.Footer>
<Button disabled={isSubmitting} type="submit">
Create
</Button>
</Modal.Footer>
</Form>
)}
{({ isSubmitting, values, setFieldValue, errors, touched }) => {
const selectedGroupName = modalUserGroups.find(
(group) => String(group.value) === String(values.user_group_id)
)?.name;
const scopeCaption =
values.target_scope === Scope.GLOBAL
? "Everyone in the workspace shares one budget."
: values.target_scope === Scope.USER
? "Every user gets their own budget."
: selectedGroupName
? `Members of ${selectedGroupName} share one budget.`
: "Members of the chosen group share one budget.";
return (
<Form className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Modal.Body>
<Section alignItems="stretch" height="auto" gap={1}>
{!forSpecificScope && (
<InputVertical
title="Applies to"
subDescription={scopeCaption}
>
<div
role="radiogroup"
aria-label="Applies to"
className="grid w-full grid-cols-3 gap-1"
>
{SCOPE_OPTIONS.map((option) =>
option.value === Scope.USER_GROUP &&
forSpecificUserGroup === undefined ? (
<GroupScopeOption
key={option.value}
option={option}
selected={values.target_scope === option.value}
groups={modalUserGroups}
selectedGroupId={values.user_group_id}
onSelectScope={() =>
void setFieldValue(
"target_scope",
option.value
)
}
onSelectGroup={(groupId) =>
void setFieldValue("user_group_id", groupId)
}
/>
) : (
<ScopeOption
key={option.value}
option={option}
selected={values.target_scope === option.value}
onSelect={() =>
void setFieldValue(
"target_scope",
option.value
)
}
/>
)
)}
</div>
{touched.user_group_id && errors.user_group_id && (
<InputErrorText>
{errors.user_group_id}
</InputErrorText>
)}
</InputVertical>
)}
{forSpecificScope === Scope.USER_GROUP &&
forSpecificUserGroup === undefined && (
<InputVertical
title="User group"
description="Choose which group shares this budget"
>
<GroupPicker
groups={modalUserGroups}
selectedGroupId={values.user_group_id}
onSelectGroup={(groupId) =>
void setFieldValue("user_group_id", groupId)
}
/>
{touched.user_group_id && errors.user_group_id && (
<InputErrorText>
{errors.user_group_id}
</InputErrorText>
)}
</InputVertical>
)}
<InputVertical
withLabel="cost_budget_dollars"
title="Cost budget"
description="Maximum spend per reset period. Set a cost budget, a token budget, or both."
>
<InputTypeInField
name="cost_budget_dollars"
inputMode="decimal"
prefixText="$"
placeholder="No cost limit"
rightChildren={
<div className="pr-1">
<Text
font="secondary-action"
color="text-03"
nowrap
>
USD
</Text>
</div>
}
/>
</InputVertical>
<InputVertical
withLabel="token_budget"
title="Token budget"
description="Maximum tokens per reset period"
>
<TokenBudgetField />
</InputVertical>
<InputVertical
withLabel="period_days"
title="Reset period"
description="Budgets reset at midnight UTC"
>
<InputTypeInField
name="period_days"
inputMode="numeric"
pattern="[0-9]*"
rightChildren={
<div className="pr-1">
<Text
font="secondary-action"
color="text-03"
nowrap
>
days
</Text>
</div>
}
/>
</InputVertical>
<LimitSummary groupName={selectedGroupName} />
</Section>
</Modal.Body>
<Modal.Footer>
<Button
prominence="tertiary"
disabled={isSubmitting}
type="button"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button disabled={isSubmitting} type="submit">
Create limit
</Button>
</Modal.Footer>
</Form>
);
}}
</Formik>
</Modal.Content>
</Modal>