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:
Michael Suchacz
2026-03-20 22:28:32 +01:00
committed by GitHub
parent 24ab216dd1
commit b8a5344c92
6 changed files with 566 additions and 73 deletions
+63
View File
@@ -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>