mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user