feat(site/src/pages/GroupsPage): update group AI budget UI (#26659)

Update the group AI budget settings UI to match the unlimited,
no-budget, and finite-budget states. The field now uses a USD input
suffix, the label reads "Monthly limit per member", and unlimited or $0
budgets show explanatory helper text with an info alert.
This commit is contained in:
Ehab Younes
2026-06-29 00:52:47 +03:00
committed by GitHub
parent 9efbcc7601
commit c782cbce77
2 changed files with 113 additions and 51 deletions
@@ -37,6 +37,9 @@ export const WithAIBudget: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("AI budget")).toBeInTheDocument();
await expect(canvas.getByLabelText("Monthly limit per member")).toHaveValue(
1000,
);
const helper = canvas.getByText(/month maximum/i);
await expect(helper).toHaveTextContent(
"$7,000/month maximum, based on 7 members.",
@@ -52,7 +55,11 @@ export const AIBudgetUncapped: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText("Leave empty for uncapped spend."),
canvas.getByLabelText("Monthly limit per member"),
).toHaveAttribute("placeholder", "unlimited");
await expect(canvas.getByText("unlimited budget")).toBeInTheDocument();
await expect(
canvas.getByText("Members in this group have no spending cap."),
).toBeInTheDocument();
},
};
@@ -65,11 +72,13 @@ export const AIBudgetDisabled: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// A budget of 0 is valid and reads as disabled spend.
const helper = canvas.getByText(/month maximum/i);
await expect(helper).toHaveTextContent(
"$0/month maximum, based on 7 members.",
await expect(canvas.getByLabelText("Monthly limit per member")).toHaveValue(
0,
);
await expect(canvas.getByText("no budget")).toBeInTheDocument();
await expect(
canvas.getByText("A $0 limit disables AI access for this group."),
).toBeInTheDocument();
},
};
@@ -96,7 +105,7 @@ export const SaveWithBudget: Story = {
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Monthly budget per member (USD)");
const input = canvas.getByLabelText("Monthly limit per member");
await userEvent.type(input, "25");
await userEvent.click(canvas.getByRole("button", { name: "Save" }));
// onSubmit fires asynchronously with (values, formikHelpers).
@@ -1,11 +1,17 @@
import { useFormik } from "formik";
import type { FC } from "react";
import type { FC, ReactNode } from "react";
import * as Yup from "yup";
import type { Group } from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import { IconField } from "#/components/IconField/IconField";
import { Input } from "#/components/Input/Input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "#/components/InputGroup/InputGroup";
import { Label } from "#/components/Label/Label";
import { Spinner } from "#/components/Spinner/Spinner";
import { isEveryoneGroup } from "#/modules/groups";
@@ -21,24 +27,88 @@ type FormData = {
display_name: string;
avatar_url: string;
quota_allowance: number;
// Per-member AI budget, in dollars. "" is no budget (uncapped); 0 disables.
// Per-member AI budget, in dollars. "" is unlimited; 0 disables.
monthly_budget_per_member: string;
};
const validationSchema = Yup.object({
name: nameValidator("Name"),
quota_allowance: Yup.number().required().min(0).integer(),
// Optional: empty is uncapped. A value must be zero or more (0 disables).
// Optional: empty is unlimited. A value must be zero or more; 0 disables.
monthly_budget_per_member: Yup.number()
.transform((value, original) => (original === "" ? undefined : value))
.min(0, "Enter an amount of zero or more."),
});
interface AIBudgetFeedbackProps {
error: boolean;
helperText?: ReactNode;
monthlyBudgetPerMember: string;
memberCount: number;
}
const AIBudgetFeedback: FC<AIBudgetFeedbackProps> = ({
error,
helperText,
monthlyBudgetPerMember,
memberCount,
}) => {
if (error) {
return (
<span className="text-left text-xs text-content-destructive">
{helperText}
</span>
);
}
const budgetValue = monthlyBudgetPerMember.trim();
const budgetAmount = Number(budgetValue);
// Empty means unlimited spend; $0 disables AI access. Both states show an
// explanatory alert alongside the summary line.
if (budgetValue === "" || budgetAmount === 0) {
const { label, message } =
budgetValue === ""
? {
label: "unlimited budget",
message: "Members in this group have no spending cap.",
}
: {
label: "no budget",
message: "A $0 limit disables AI access for this group.",
};
return (
<>
<span className="text-left text-xs text-content-secondary">
This group has{" "}
<span className="font-medium text-content-primary">{label}</span>.
</span>
<Alert severity="info">{message}</Alert>
</>
);
}
if (Number.isFinite(budgetAmount) && budgetAmount > 0) {
return (
<span className="text-left text-xs text-content-secondary">
<span className="font-medium text-content-primary">
{usdBudgetFormatter.format(budgetAmount * memberCount)}
</span>
/month maximum, based on{" "}
<span className="font-medium text-content-primary">{memberCount}</span>{" "}
{memberCount === 1 ? "member" : "members"}.
</span>
);
}
return null;
};
interface UpdateGroupFormProps {
group: Group;
/** Whether the AI add-on settings are shown (gated by the aibridge feature). */
showAISettings: boolean;
/** Per-member AI budget in dollars, or null when none is set. */
/** Per-member AI budget in dollars, or null for unlimited spend. */
initialBudgetDollars: number | null;
errors: unknown;
onSubmit: (data: FormData) => void;
@@ -77,11 +147,6 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
of its members.`,
});
const budgetField = getFieldHelpers("monthly_budget_per_member");
const budgetDollars = form.values.monthly_budget_per_member;
const memberCount = group.total_member_count;
const monthlyMaximum = usdBudgetFormatter.format(
Number(budgetDollars) * memberCount,
);
return (
<form className="flex flex-col gap-10 pb-8" onSubmit={form.handleSubmit}>
@@ -158,7 +223,7 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
{showAISettings && (
<section className="flex flex-col gap-8 max-w-md">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold text-content-primary m-0">
<h2 className="m-0 text-xl font-semibold text-content-primary">
AI budget
</h2>
<Badge variant="purple" size="sm">
@@ -167,42 +232,30 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col items-start gap-2">
<Label htmlFor={budgetField.id}>
Monthly budget per member (USD)
</Label>
<Input
id={budgetField.id}
name={budgetField.name}
value={budgetField.value}
onChange={(event) =>
form.setFieldValue(budgetField.name, event.target.value)
}
onBlur={budgetField.onBlur}
type="number"
min="0"
step="1"
aria-invalid={budgetField.error}
<Label htmlFor={budgetField.id}>Monthly limit per member</Label>
<InputGroup>
<InputGroupInput
id={budgetField.id}
name={budgetField.name}
value={budgetField.value}
onChange={(event) =>
form.setFieldValue(budgetField.name, event.target.value)
}
onBlur={budgetField.onBlur}
type="number"
min="0"
step="1"
placeholder="unlimited"
aria-invalid={budgetField.error}
/>
<InputGroupAddon align="inline-end">USD</InputGroupAddon>
</InputGroup>
<AIBudgetFeedback
error={budgetField.error}
helperText={budgetField.helperText}
monthlyBudgetPerMember={form.values.monthly_budget_per_member}
memberCount={group.total_member_count}
/>
{budgetField.error ? (
<span className="text-xs text-left text-content-destructive">
{budgetField.helperText}
</span>
) : budgetDollars.trim() !== "" ? (
<span className="text-xs text-left text-content-secondary">
<span className="font-medium text-content-primary">
{monthlyMaximum}
</span>
/month maximum, based on{" "}
<span className="font-medium text-content-primary">
{memberCount}
</span>{" "}
{memberCount === 1 ? "member" : "members"}.
</span>
) : (
<span className="text-xs text-left text-content-secondary">
Leave empty for uncapped spend.
</span>
)}
</div>
</div>
</section>