mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add inline editing of usage limit overrides (#23380)
## Summary Adds inline editing of existing per-user and per-group chat usage limit overrides from the Limits tab. Admins can now click Edit on any override row to modify the spend limit in-place, using the same form used for adding overrides. ## Changes **Backend** (`coderd/chats_test.go`) - Added `UpdateUserOverride` and `UpdateGroupOverride` test cases covering the upsert-in-place behavior. **Frontend** (3 component files + 2 story files) - `LimitsTab.tsx`: Edit state management, mutual-exclusion between user/group edit modes, and handlers that prefill the form from the existing override. - `GroupLimitsSection.tsx`: Edit button per row, read-only group identity in edit mode, Save/Cancel actions, disable states during pending operations. - `UserOverridesSection.tsx`: Same pattern as groups — Edit button, read-only user identity, Save/Cancel, proper disable states. - New Storybook stories for both sections (Default, EmptyState, AddForm, EditForm). ## UX behavior - Clicking Edit opens the inline form with the current spend limit prefilled and the entity shown as read-only. - Save uses the existing PUT upsert endpoint (no new API surface). - Cancel returns to normal list view with form state cleared. - Edit modes are mutually exclusive — editing a user override closes any open group form and vice versa. - All buttons and inputs disable during pending mutations. - Add and delete continue to work after editing.
This commit is contained in:
@@ -3716,6 +3716,35 @@ func TestChatUsageLimitOverrideRoutes(t *testing.T) {
|
||||
require.Equal(t, "Chat usage limit override not found.", sdkErr.Message)
|
||||
})
|
||||
|
||||
t.Run("UpdateUserOverride", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, _ := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
_, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
|
||||
_, err := client.UpsertChatUsageLimitOverride(ctx, member.ID, codersdk.UpsertChatUsageLimitOverrideRequest{
|
||||
SpendLimitMicros: 5_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
override, err := client.UpsertChatUsageLimitOverride(ctx, member.ID, codersdk.UpsertChatUsageLimitOverrideRequest{
|
||||
SpendLimitMicros: 10_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, member.ID, override.UserID)
|
||||
require.NotNil(t, override.SpendLimitMicros)
|
||||
require.EqualValues(t, 10_000_000, *override.SpendLimitMicros)
|
||||
|
||||
config, err := client.GetChatUsageLimitConfig(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, config.Overrides, 1)
|
||||
require.Equal(t, member.ID, config.Overrides[0].UserID)
|
||||
require.NotNil(t, config.Overrides[0].SpendLimitMicros)
|
||||
require.EqualValues(t, 10_000_000, *config.Overrides[0].SpendLimitMicros)
|
||||
})
|
||||
|
||||
t.Run("UpsertGroupOverrideIncludesMemberCount", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -3750,6 +3779,40 @@ func TestChatUsageLimitOverrideRoutes(t *testing.T) {
|
||||
require.EqualValues(t, 1, listed.MemberCount)
|
||||
})
|
||||
|
||||
t.Run("UpdateGroupOverride", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
_, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
group := dbgen.Group(t, db, database.Group{OrganizationID: firstUser.OrganizationID})
|
||||
dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: group.ID, UserID: firstUser.UserID})
|
||||
dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: group.ID, UserID: member.ID})
|
||||
|
||||
_, err := client.UpsertChatUsageLimitGroupOverride(ctx, group.ID, codersdk.UpsertChatUsageLimitGroupOverrideRequest{
|
||||
SpendLimitMicros: 5_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
override, err := client.UpsertChatUsageLimitGroupOverride(ctx, group.ID, codersdk.UpsertChatUsageLimitGroupOverrideRequest{
|
||||
SpendLimitMicros: 10_000_000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, group.ID, override.GroupID)
|
||||
require.EqualValues(t, 2, override.MemberCount)
|
||||
require.NotNil(t, override.SpendLimitMicros)
|
||||
require.EqualValues(t, 10_000_000, *override.SpendLimitMicros)
|
||||
|
||||
config, err := client.GetChatUsageLimitConfig(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, config.GroupOverrides, 1)
|
||||
require.Equal(t, group.ID, config.GroupOverrides[0].GroupID)
|
||||
require.EqualValues(t, 2, config.GroupOverrides[0].MemberCount)
|
||||
require.NotNil(t, config.GroupOverrides[0].SpendLimitMicros)
|
||||
require.EqualValues(t, 10_000_000, *config.GroupOverrides[0].SpendLimitMicros)
|
||||
})
|
||||
|
||||
t.Run("UpsertGroupOverrideMissingGroup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Group } from "api/typesGenerated";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { GroupLimitsSection } from "./GroupLimitsSection";
|
||||
|
||||
const mockGroupOverrides = [
|
||||
{
|
||||
group_id: "group-1",
|
||||
group_display_name: "Engineering",
|
||||
group_name: "engineering",
|
||||
group_avatar_url: "",
|
||||
member_count: 15,
|
||||
spend_limit_micros: 10_000_000,
|
||||
},
|
||||
{
|
||||
group_id: "group-2",
|
||||
group_display_name: "Design",
|
||||
group_name: "design",
|
||||
group_avatar_url: "",
|
||||
member_count: 8,
|
||||
spend_limit_micros: 5_000_000,
|
||||
},
|
||||
{
|
||||
group_id: "group-4",
|
||||
group_display_name: "Support",
|
||||
group_name: "support",
|
||||
group_avatar_url: "",
|
||||
member_count: 11,
|
||||
spend_limit_micros: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockAvailableGroups: Group[] = [
|
||||
{
|
||||
id: "group-3",
|
||||
name: "marketing",
|
||||
display_name: "Marketing",
|
||||
avatar_url: "",
|
||||
organization_id: "org-1",
|
||||
organization_name: "Acme",
|
||||
organization_display_name: "Acme",
|
||||
members: [],
|
||||
quota_allowance: 0,
|
||||
source: "user",
|
||||
total_member_count: 5,
|
||||
},
|
||||
{
|
||||
id: "group-5",
|
||||
name: "sales",
|
||||
display_name: "Sales",
|
||||
avatar_url: "",
|
||||
organization_id: "org-1",
|
||||
organization_name: "Acme",
|
||||
organization_display_name: "Acme",
|
||||
members: [],
|
||||
quota_allowance: 0,
|
||||
source: "user",
|
||||
total_member_count: 12,
|
||||
},
|
||||
];
|
||||
|
||||
const editingGroupOverride = {
|
||||
group_id: mockGroupOverrides[0].group_id,
|
||||
group_display_name: mockGroupOverrides[0].group_display_name,
|
||||
group_name: mockGroupOverrides[0].group_name,
|
||||
group_avatar_url: mockGroupOverrides[0].group_avatar_url,
|
||||
member_count: mockGroupOverrides[0].member_count,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof GroupLimitsSection> = {
|
||||
title: "pages/AgentsPage/LimitsTab/GroupLimitsSection",
|
||||
component: GroupLimitsSection,
|
||||
args: {
|
||||
groupOverrides: mockGroupOverrides,
|
||||
showGroupForm: false,
|
||||
onShowGroupFormChange: fn(),
|
||||
selectedGroup: null,
|
||||
onSelectedGroupChange: fn(),
|
||||
groupAmount: "",
|
||||
onGroupAmountChange: fn(),
|
||||
availableGroups: [],
|
||||
groupAutocompleteNoOptionsText: "No groups available",
|
||||
groupsLoading: false,
|
||||
editingGroupOverride: null,
|
||||
onEditGroupOverride: fn(),
|
||||
onAddGroupOverride: fn(),
|
||||
onDeleteGroupOverride: fn(),
|
||||
upsertPending: false,
|
||||
upsertError: null,
|
||||
deletePending: false,
|
||||
deleteError: null,
|
||||
groupsError: null,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof GroupLimitsSection>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const EmptyState: Story = {
|
||||
args: {
|
||||
groupOverrides: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const AddForm: Story = {
|
||||
args: {
|
||||
showGroupForm: true,
|
||||
availableGroups: mockAvailableGroups,
|
||||
},
|
||||
};
|
||||
|
||||
export const EditForm: Story = {
|
||||
args: {
|
||||
showGroupForm: true,
|
||||
editingGroupOverride,
|
||||
groupAmount: "10.00",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Save button confirms edit mode is active.
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: /save/i }),
|
||||
).toBeInTheDocument();
|
||||
// The editing group name appears in both the table row and the
|
||||
// read-only edit form identity, confirming it was populated.
|
||||
const nameElements = canvas.getAllByText(
|
||||
editingGroupOverride.group_display_name,
|
||||
);
|
||||
expect(nameElements.length).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
};
|
||||
@@ -38,6 +38,16 @@ interface GroupLimitsSectionProps {
|
||||
availableGroups: Group[];
|
||||
groupAutocompleteNoOptionsText: string;
|
||||
groupsLoading: boolean;
|
||||
editingGroupOverride: {
|
||||
group_id: string;
|
||||
group_display_name: string;
|
||||
group_name: string;
|
||||
group_avatar_url: string;
|
||||
member_count: number;
|
||||
} | null;
|
||||
onEditGroupOverride: (
|
||||
override: GroupLimitsSectionProps["groupOverrides"][number],
|
||||
) => void;
|
||||
onAddGroupOverride: () => void;
|
||||
onDeleteGroupOverride: (groupID: string) => void;
|
||||
upsertPending: boolean;
|
||||
@@ -58,6 +68,8 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
availableGroups,
|
||||
groupAutocompleteNoOptionsText,
|
||||
groupsLoading,
|
||||
editingGroupOverride,
|
||||
onEditGroupOverride,
|
||||
onAddGroupOverride,
|
||||
onDeleteGroupOverride,
|
||||
upsertPending,
|
||||
@@ -68,6 +80,7 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
}) => {
|
||||
const groupAutocompleteId = useId();
|
||||
const groupAmountId = useId();
|
||||
const isEditing = editingGroupOverride !== null;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
@@ -84,7 +97,7 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
<TableHead>Group</TableHead>
|
||||
<TableHead>Members</TableHead>
|
||||
<TableHead>Spend Limit</TableHead>
|
||||
<TableHead className="w-[80px]">Actions</TableHead>
|
||||
<TableHead className="w-[160px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -105,17 +118,28 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
: "Unlimited"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void onDeleteGroupOverride(override.group_id)
|
||||
}
|
||||
disabled={deletePending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => onEditGroupOverride(override)}
|
||||
disabled={deletePending || upsertPending}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void onDeleteGroupOverride(override.group_id)
|
||||
}
|
||||
disabled={deletePending || upsertPending || isEditing}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -139,7 +163,9 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => onShowGroupFormChange(true)}
|
||||
disabled={groupsLoading || availableGroups.length === 0}
|
||||
disabled={
|
||||
isEditing || groupsLoading || availableGroups.length === 0
|
||||
}
|
||||
>
|
||||
Add Group
|
||||
</Button>
|
||||
@@ -147,34 +173,55 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
<div className="space-y-3 rounded-lg border border-border bg-surface-secondary/40 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label htmlFor={groupAutocompleteId}>Group</Label>
|
||||
<Autocomplete
|
||||
id={groupAutocompleteId}
|
||||
value={selectedGroup}
|
||||
onChange={onSelectedGroupChange}
|
||||
options={availableGroups}
|
||||
getOptionValue={(group) => group.id}
|
||||
getOptionLabel={(group) => group.display_name || group.name}
|
||||
isOptionEqualToValue={(option, optionValue) =>
|
||||
option.id === optionValue.id
|
||||
}
|
||||
renderOption={(option, isSelected) => (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
{editingGroupOverride ? (
|
||||
<>
|
||||
<Label>Group</Label>
|
||||
<div className="rounded-md border border-border bg-surface-primary p-2">
|
||||
<AvatarData
|
||||
title={option.display_name || option.name}
|
||||
subtitle={getGroupSubtitle(option)}
|
||||
src={option.avatar_url}
|
||||
imgFallbackText={option.name}
|
||||
title={
|
||||
editingGroupOverride.group_display_name ||
|
||||
editingGroupOverride.group_name
|
||||
}
|
||||
subtitle={editingGroupOverride.group_name}
|
||||
src={editingGroupOverride.group_avatar_url}
|
||||
imgFallbackText={editingGroupOverride.group_name}
|
||||
/>
|
||||
{isSelected && <Check className="size-4 shrink-0" />}
|
||||
</div>
|
||||
)}
|
||||
placeholder="Search groups..."
|
||||
noOptionsText={groupAutocompleteNoOptionsText}
|
||||
loading={groupsLoading}
|
||||
disabled={groupsLoading}
|
||||
className="w-full"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Label htmlFor={groupAutocompleteId}>Group</Label>
|
||||
<Autocomplete
|
||||
id={groupAutocompleteId}
|
||||
value={selectedGroup}
|
||||
onChange={onSelectedGroupChange}
|
||||
options={availableGroups}
|
||||
getOptionValue={(group) => group.id}
|
||||
getOptionLabel={(group) =>
|
||||
group.display_name || group.name
|
||||
}
|
||||
isOptionEqualToValue={(option, optionValue) =>
|
||||
option.id === optionValue.id
|
||||
}
|
||||
renderOption={(option, isSelected) => (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<AvatarData
|
||||
title={option.display_name || option.name}
|
||||
subtitle={getGroupSubtitle(option)}
|
||||
src={option.avatar_url}
|
||||
imgFallbackText={option.name}
|
||||
/>
|
||||
{isSelected && <Check className="size-4 shrink-0" />}
|
||||
</div>
|
||||
)}
|
||||
placeholder="Search groups..."
|
||||
noOptionsText={groupAutocompleteNoOptionsText}
|
||||
loading={groupsLoading}
|
||||
disabled={groupsLoading}
|
||||
className="w-full"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label htmlFor={groupAmountId}>Spend Limit ($)</Label>
|
||||
@@ -182,7 +229,8 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
id={groupAmountId}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
min="0.01"
|
||||
disabled={upsertPending}
|
||||
className="h-9 min-w-0 text-[13px]"
|
||||
value={groupAmount}
|
||||
onChange={(event) => onGroupAmountChange(event.target.value)}
|
||||
@@ -195,15 +243,18 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
type="button"
|
||||
onClick={() => void onAddGroupOverride()}
|
||||
disabled={
|
||||
upsertPending ||
|
||||
selectedGroup === null ||
|
||||
!isPositiveFiniteDollarAmount(groupAmount)
|
||||
isEditing
|
||||
? upsertPending ||
|
||||
!isPositiveFiniteDollarAmount(groupAmount)
|
||||
: upsertPending ||
|
||||
selectedGroup === null ||
|
||||
!isPositiveFiniteDollarAmount(groupAmount)
|
||||
}
|
||||
>
|
||||
{upsertPending ? (
|
||||
<Spinner loading className="h-4 w-4" />
|
||||
) : null}
|
||||
Add
|
||||
{isEditing ? "Save" : "Add"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -214,6 +265,7 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
onSelectedGroupChange(null);
|
||||
onGroupAmountChange("");
|
||||
}}
|
||||
disabled={upsertPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -127,6 +127,19 @@ export const LimitsTab: FC = () => {
|
||||
const [showUserForm, setShowUserForm] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [userOverrideAmount, setUserOverrideAmount] = useState("");
|
||||
const [editingUserOverride, setEditingUserOverride] = useState<{
|
||||
user_id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
avatar_url: string;
|
||||
} | null>(null);
|
||||
const [editingGroupOverride, setEditingGroupOverride] = useState<{
|
||||
group_id: string;
|
||||
group_display_name: string;
|
||||
group_name: string;
|
||||
group_avatar_url: string;
|
||||
member_count: number;
|
||||
} | null>(null);
|
||||
|
||||
const defaultLimitValues: DefaultLimitFormValues = (() => {
|
||||
const spendLimitMicros = configQuery.data?.spend_limit_micros;
|
||||
@@ -171,6 +184,70 @@ export const LimitsTab: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowUserFormChange = (show: boolean) => {
|
||||
setShowUserForm(show);
|
||||
if (!show) {
|
||||
setEditingUserOverride(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowGroupFormChange = (show: boolean) => {
|
||||
setShowGroupForm(show);
|
||||
if (!show) {
|
||||
setEditingGroupOverride(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditUserOverride = (override: {
|
||||
user_id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
avatar_url: string;
|
||||
spend_limit_micros: number | null;
|
||||
}) => {
|
||||
setShowGroupForm(false);
|
||||
setEditingGroupOverride(null);
|
||||
setEditingUserOverride({
|
||||
user_id: override.user_id,
|
||||
name: override.name,
|
||||
username: override.username,
|
||||
avatar_url: override.avatar_url,
|
||||
});
|
||||
setSelectedUser(null);
|
||||
setUserOverrideAmount(
|
||||
override.spend_limit_micros !== null
|
||||
? microsToDollars(override.spend_limit_micros).toString()
|
||||
: "",
|
||||
);
|
||||
setShowUserForm(true);
|
||||
};
|
||||
|
||||
const handleEditGroupOverride = (override: {
|
||||
group_id: string;
|
||||
group_display_name: string;
|
||||
group_name: string;
|
||||
group_avatar_url: string;
|
||||
member_count: number;
|
||||
spend_limit_micros: number | null;
|
||||
}) => {
|
||||
setShowUserForm(false);
|
||||
setEditingUserOverride(null);
|
||||
setEditingGroupOverride({
|
||||
group_id: override.group_id,
|
||||
group_display_name: override.group_display_name,
|
||||
group_name: override.group_name,
|
||||
group_avatar_url: override.group_avatar_url,
|
||||
member_count: override.member_count,
|
||||
});
|
||||
setSelectedGroup(null);
|
||||
setGroupAmount(
|
||||
override.spend_limit_micros !== null
|
||||
? microsToDollars(override.spend_limit_micros).toString()
|
||||
: "",
|
||||
);
|
||||
setShowGroupForm(true);
|
||||
};
|
||||
|
||||
const handleSaveDefault = async ({
|
||||
enabled,
|
||||
period,
|
||||
@@ -189,14 +266,17 @@ export const LimitsTab: FC = () => {
|
||||
};
|
||||
|
||||
const handleAddOverride = async () => {
|
||||
if (!selectedUser || !isPositiveFiniteDollarAmount(userOverrideAmount)) {
|
||||
const targetUserID = editingUserOverride?.user_id ?? selectedUser?.id;
|
||||
|
||||
if (!targetUserID || !isPositiveFiniteDollarAmount(userOverrideAmount)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await upsertOverrideMutation.mutateAsync({
|
||||
userID: selectedUser.id,
|
||||
userID: targetUserID,
|
||||
req: { spend_limit_micros: dollarsToMicros(userOverrideAmount) },
|
||||
});
|
||||
setEditingUserOverride(null);
|
||||
setSelectedUser(null);
|
||||
setUserOverrideAmount("");
|
||||
setShowUserForm(false);
|
||||
@@ -206,14 +286,17 @@ export const LimitsTab: FC = () => {
|
||||
};
|
||||
|
||||
const handleAddGroupOverride = async () => {
|
||||
if (!selectedGroup || !isPositiveFiniteDollarAmount(groupAmount)) {
|
||||
const targetGroupID = editingGroupOverride?.group_id ?? selectedGroup?.id;
|
||||
|
||||
if (!targetGroupID || !isPositiveFiniteDollarAmount(groupAmount)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await upsertGroupOverrideMutation.mutateAsync({
|
||||
groupID: selectedGroup.id,
|
||||
groupID: targetGroupID,
|
||||
req: { spend_limit_micros: dollarsToMicros(groupAmount) },
|
||||
});
|
||||
setEditingGroupOverride(null);
|
||||
setSelectedGroup(null);
|
||||
setGroupAmount("");
|
||||
setShowGroupForm(false);
|
||||
@@ -319,7 +402,7 @@ export const LimitsTab: FC = () => {
|
||||
<GroupLimitsSection
|
||||
groupOverrides={groupOverrides}
|
||||
showGroupForm={showGroupForm}
|
||||
onShowGroupFormChange={setShowGroupForm}
|
||||
onShowGroupFormChange={handleShowGroupFormChange}
|
||||
selectedGroup={selectedGroup}
|
||||
onSelectedGroupChange={setSelectedGroup}
|
||||
groupAmount={groupAmount}
|
||||
@@ -329,6 +412,8 @@ export const LimitsTab: FC = () => {
|
||||
groupAutocompleteNoOptionsText
|
||||
}
|
||||
groupsLoading={groupsQuery.isLoading}
|
||||
editingGroupOverride={editingGroupOverride}
|
||||
onEditGroupOverride={handleEditGroupOverride}
|
||||
onAddGroupOverride={handleAddGroupOverride}
|
||||
onDeleteGroupOverride={handleDeleteGroupOverride}
|
||||
upsertPending={upsertGroupOverrideMutation.isPending}
|
||||
@@ -348,12 +433,16 @@ export const LimitsTab: FC = () => {
|
||||
<UserOverridesSection
|
||||
overrides={overrides}
|
||||
showUserForm={showUserForm}
|
||||
onShowUserFormChange={setShowUserForm}
|
||||
onShowUserFormChange={handleShowUserFormChange}
|
||||
selectedUser={selectedUser}
|
||||
onSelectedUserChange={setSelectedUser}
|
||||
userOverrideAmount={userOverrideAmount}
|
||||
onUserOverrideAmountChange={setUserOverrideAmount}
|
||||
selectedUserAlreadyOverridden={selectedUserAlreadyOverridden}
|
||||
selectedUserAlreadyOverridden={
|
||||
editingUserOverride ? false : selectedUserAlreadyOverridden
|
||||
}
|
||||
editingUserOverride={editingUserOverride}
|
||||
onEditUserOverride={handleEditUserOverride}
|
||||
onAddOverride={handleAddOverride}
|
||||
onDeleteOverride={handleDeleteOverride}
|
||||
upsertPending={upsertOverrideMutation.isPending}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "react-query";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { UserOverridesSection } from "./UserOverridesSection";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockOverrides = [
|
||||
{
|
||||
user_id: "user-1",
|
||||
name: "Alice Johnson",
|
||||
username: "alice",
|
||||
avatar_url: "",
|
||||
spend_limit_micros: 5_000_000,
|
||||
},
|
||||
{
|
||||
user_id: "user-2",
|
||||
name: "Bob Smith",
|
||||
username: "bob",
|
||||
avatar_url: "",
|
||||
spend_limit_micros: 10_000_000,
|
||||
},
|
||||
{
|
||||
user_id: "user-3",
|
||||
name: "Charlie Davis",
|
||||
username: "charlie",
|
||||
avatar_url: "",
|
||||
spend_limit_micros: null,
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<typeof UserOverridesSection> = {
|
||||
title: "pages/AgentsPage/LimitsTab/UserOverridesSection",
|
||||
component: UserOverridesSection,
|
||||
args: {
|
||||
overrides: mockOverrides,
|
||||
showUserForm: false,
|
||||
onShowUserFormChange: fn(),
|
||||
selectedUser: null,
|
||||
onSelectedUserChange: fn(),
|
||||
userOverrideAmount: "",
|
||||
onUserOverrideAmountChange: fn(),
|
||||
selectedUserAlreadyOverridden: false,
|
||||
editingUserOverride: null,
|
||||
onEditUserOverride: fn(),
|
||||
onAddOverride: fn(),
|
||||
onDeleteOverride: fn(),
|
||||
upsertPending: false,
|
||||
upsertError: null,
|
||||
deletePending: false,
|
||||
deleteError: null,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Story />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof UserOverridesSection>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const EmptyState: Story = {
|
||||
args: {
|
||||
overrides: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const AddForm: Story = {
|
||||
args: {
|
||||
showUserForm: true,
|
||||
overrides: mockOverrides,
|
||||
},
|
||||
};
|
||||
|
||||
export const EditForm: Story = {
|
||||
args: {
|
||||
showUserForm: true,
|
||||
editingUserOverride: {
|
||||
user_id: mockOverrides[0].user_id,
|
||||
name: mockOverrides[0].name,
|
||||
username: mockOverrides[0].username,
|
||||
avatar_url: mockOverrides[0].avatar_url,
|
||||
},
|
||||
userOverrideAmount: "5.00",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Save button confirms edit mode is active.
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: /save/i }),
|
||||
).toBeInTheDocument();
|
||||
// The editing user name appears in both the table row and the
|
||||
// read-only edit form identity, confirming it was populated.
|
||||
const nameElements = canvas.getAllByText("Alice Johnson");
|
||||
expect(nameElements.length).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
};
|
||||
@@ -33,6 +33,15 @@ interface UserOverridesSectionProps {
|
||||
userOverrideAmount: string;
|
||||
onUserOverrideAmountChange: (amount: string) => void;
|
||||
selectedUserAlreadyOverridden: boolean;
|
||||
editingUserOverride: {
|
||||
user_id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
avatar_url: string;
|
||||
} | null;
|
||||
onEditUserOverride: (
|
||||
override: UserOverridesSectionProps["overrides"][number],
|
||||
) => void;
|
||||
onAddOverride: () => void;
|
||||
onDeleteOverride: (userID: string) => void;
|
||||
upsertPending: boolean;
|
||||
@@ -50,6 +59,8 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
userOverrideAmount,
|
||||
onUserOverrideAmountChange,
|
||||
selectedUserAlreadyOverridden,
|
||||
editingUserOverride,
|
||||
onEditUserOverride,
|
||||
onAddOverride,
|
||||
onDeleteOverride,
|
||||
upsertPending,
|
||||
@@ -58,6 +69,7 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
deleteError,
|
||||
}) => {
|
||||
const userOverrideAmountId = useId();
|
||||
const isEditing = editingUserOverride !== null;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
@@ -73,7 +85,7 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Spend Limit</TableHead>
|
||||
<TableHead className="w-[80px]">Actions</TableHead>
|
||||
<TableHead className="w-[160px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -93,15 +105,26 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
: "Unlimited"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => void onDeleteOverride(override.user_id)}
|
||||
disabled={deletePending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => onEditUserOverride(override)}
|
||||
disabled={deletePending || upsertPending}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => void onDeleteOverride(override.user_id)}
|
||||
disabled={deletePending || upsertPending || isEditing}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -125,18 +148,36 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => onShowUserFormChange(true)}
|
||||
disabled={isEditing}
|
||||
>
|
||||
Add User
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-lg border border-border bg-surface-secondary/40 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<div className="flex-1">
|
||||
<UserAutocomplete
|
||||
value={selectedUser}
|
||||
onChange={onSelectedUserChange}
|
||||
label="User"
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
{editingUserOverride ? (
|
||||
<>
|
||||
<Label>User</Label>
|
||||
<div className="rounded-md border border-border bg-surface-primary p-2">
|
||||
<AvatarData
|
||||
title={
|
||||
editingUserOverride.name ||
|
||||
editingUserOverride.username
|
||||
}
|
||||
subtitle={`@${editingUserOverride.username}`}
|
||||
src={editingUserOverride.avatar_url}
|
||||
imgFallbackText={editingUserOverride.username}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<UserAutocomplete
|
||||
value={selectedUser}
|
||||
onChange={onSelectedUserChange}
|
||||
label="User"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label htmlFor={userOverrideAmountId}>Spend Limit ($)</Label>
|
||||
@@ -144,7 +185,8 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
id={userOverrideAmountId}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
min="0.01"
|
||||
disabled={upsertPending}
|
||||
className="h-9 min-w-0 text-[13px]"
|
||||
value={userOverrideAmount}
|
||||
onChange={(event) =>
|
||||
@@ -159,16 +201,19 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
type="button"
|
||||
onClick={() => void onAddOverride()}
|
||||
disabled={
|
||||
upsertPending ||
|
||||
!selectedUser ||
|
||||
selectedUserAlreadyOverridden ||
|
||||
!isPositiveFiniteDollarAmount(userOverrideAmount)
|
||||
isEditing
|
||||
? upsertPending ||
|
||||
!isPositiveFiniteDollarAmount(userOverrideAmount)
|
||||
: upsertPending ||
|
||||
!selectedUser ||
|
||||
selectedUserAlreadyOverridden ||
|
||||
!isPositiveFiniteDollarAmount(userOverrideAmount)
|
||||
}
|
||||
>
|
||||
{upsertPending ? (
|
||||
<Spinner loading className="h-4 w-4" />
|
||||
) : null}
|
||||
Add
|
||||
{isEditing ? "Save" : "Add"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -179,6 +224,7 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
onSelectedUserChange(null);
|
||||
onUserOverrideAmountChange("");
|
||||
}}
|
||||
disabled={upsertPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -186,7 +232,7 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedUserAlreadyOverridden && (
|
||||
{!isEditing && selectedUserAlreadyOverridden && (
|
||||
<p className="text-xs text-content-warning">
|
||||
This user already has an override.
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user