diff --git a/coderd/chats_test.go b/coderd/chats_test.go index cc381d3567..aa7748fd6c 100644 --- a/coderd/chats_test.go +++ b/coderd/chats_test.go @@ -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() diff --git a/site/src/pages/AgentsPage/LimitsTab/GroupLimitsSection.stories.tsx b/site/src/pages/AgentsPage/LimitsTab/GroupLimitsSection.stories.tsx new file mode 100644 index 0000000000..8c87515340 --- /dev/null +++ b/site/src/pages/AgentsPage/LimitsTab/GroupLimitsSection.stories.tsx @@ -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 = { + 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; + +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); + }, +}; diff --git a/site/src/pages/AgentsPage/LimitsTab/GroupLimitsSection.tsx b/site/src/pages/AgentsPage/LimitsTab/GroupLimitsSection.tsx index a314560bbc..2956244d59 100644 --- a/site/src/pages/AgentsPage/LimitsTab/GroupLimitsSection.tsx +++ b/site/src/pages/AgentsPage/LimitsTab/GroupLimitsSection.tsx @@ -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 = ({ availableGroups, groupAutocompleteNoOptionsText, groupsLoading, + editingGroupOverride, + onEditGroupOverride, onAddGroupOverride, onDeleteGroupOverride, upsertPending, @@ -68,6 +80,7 @@ export const GroupLimitsSection: FC = ({ }) => { const groupAutocompleteId = useId(); const groupAmountId = useId(); + const isEditing = editingGroupOverride !== null; return (
@@ -84,7 +97,7 @@ export const GroupLimitsSection: FC = ({ Group Members Spend Limit - Actions + Actions @@ -105,17 +118,28 @@ export const GroupLimitsSection: FC = ({ : "Unlimited"} - +
+ + +
))} @@ -139,7 +163,9 @@ export const GroupLimitsSection: FC = ({ size="sm" type="button" onClick={() => onShowGroupFormChange(true)} - disabled={groupsLoading || availableGroups.length === 0} + disabled={ + isEditing || groupsLoading || availableGroups.length === 0 + } > Add Group @@ -147,34 +173,55 @@ export const GroupLimitsSection: FC = ({
- - group.id} - getOptionLabel={(group) => group.display_name || group.name} - isOptionEqualToValue={(option, optionValue) => - option.id === optionValue.id - } - renderOption={(option, isSelected) => ( -
+ {editingGroupOverride ? ( + <> + +
- {isSelected && }
- )} - placeholder="Search groups..." - noOptionsText={groupAutocompleteNoOptionsText} - loading={groupsLoading} - disabled={groupsLoading} - className="w-full" - /> + + ) : ( + <> + + group.id} + getOptionLabel={(group) => + group.display_name || group.name + } + isOptionEqualToValue={(option, optionValue) => + option.id === optionValue.id + } + renderOption={(option, isSelected) => ( +
+ + {isSelected && } +
+ )} + placeholder="Search groups..." + noOptionsText={groupAutocompleteNoOptionsText} + loading={groupsLoading} + disabled={groupsLoading} + className="w-full" + /> + + )}
@@ -182,7 +229,8 @@ export const GroupLimitsSection: FC = ({ 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 = ({ type="button" onClick={() => void onAddGroupOverride()} disabled={ - upsertPending || - selectedGroup === null || - !isPositiveFiniteDollarAmount(groupAmount) + isEditing + ? upsertPending || + !isPositiveFiniteDollarAmount(groupAmount) + : upsertPending || + selectedGroup === null || + !isPositiveFiniteDollarAmount(groupAmount) } > {upsertPending ? ( ) : null} - Add + {isEditing ? "Save" : "Add"} diff --git a/site/src/pages/AgentsPage/LimitsTab/LimitsTab.tsx b/site/src/pages/AgentsPage/LimitsTab/LimitsTab.tsx index a8804ef211..17193a5180 100644 --- a/site/src/pages/AgentsPage/LimitsTab/LimitsTab.tsx +++ b/site/src/pages/AgentsPage/LimitsTab/LimitsTab.tsx @@ -127,6 +127,19 @@ export const LimitsTab: FC = () => { const [showUserForm, setShowUserForm] = useState(false); const [selectedUser, setSelectedUser] = useState(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 = () => { { groupAutocompleteNoOptionsText } groupsLoading={groupsQuery.isLoading} + editingGroupOverride={editingGroupOverride} + onEditGroupOverride={handleEditGroupOverride} onAddGroupOverride={handleAddGroupOverride} onDeleteGroupOverride={handleDeleteGroupOverride} upsertPending={upsertGroupOverrideMutation.isPending} @@ -348,12 +433,16 @@ export const LimitsTab: FC = () => { = { + 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) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +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); + }, +}; diff --git a/site/src/pages/AgentsPage/LimitsTab/UserOverridesSection.tsx b/site/src/pages/AgentsPage/LimitsTab/UserOverridesSection.tsx index 939384a885..749a493bd6 100644 --- a/site/src/pages/AgentsPage/LimitsTab/UserOverridesSection.tsx +++ b/site/src/pages/AgentsPage/LimitsTab/UserOverridesSection.tsx @@ -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 = ({ userOverrideAmount, onUserOverrideAmountChange, selectedUserAlreadyOverridden, + editingUserOverride, + onEditUserOverride, onAddOverride, onDeleteOverride, upsertPending, @@ -58,6 +69,7 @@ export const UserOverridesSection: FC = ({ deleteError, }) => { const userOverrideAmountId = useId(); + const isEditing = editingUserOverride !== null; return (
@@ -73,7 +85,7 @@ export const UserOverridesSection: FC = ({ User Spend Limit - Actions + Actions @@ -93,15 +105,26 @@ export const UserOverridesSection: FC = ({ : "Unlimited"} - +
+ + +
))} @@ -125,18 +148,36 @@ export const UserOverridesSection: FC = ({ size="sm" type="button" onClick={() => onShowUserFormChange(true)} + disabled={isEditing} > Add User ) : (
-
- +
+ {editingUserOverride ? ( + <> + +
+ +
+ + ) : ( + + )}
@@ -144,7 +185,8 @@ export const UserOverridesSection: FC = ({ 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 = ({ type="button" onClick={() => void onAddOverride()} disabled={ - upsertPending || - !selectedUser || - selectedUserAlreadyOverridden || - !isPositiveFiniteDollarAmount(userOverrideAmount) + isEditing + ? upsertPending || + !isPositiveFiniteDollarAmount(userOverrideAmount) + : upsertPending || + !selectedUser || + selectedUserAlreadyOverridden || + !isPositiveFiniteDollarAmount(userOverrideAmount) } > {upsertPending ? ( ) : null} - Add + {isEditing ? "Save" : "Add"} @@ -186,7 +232,7 @@ export const UserOverridesSection: FC = ({
)} - {selectedUserAlreadyOverridden && ( + {!isEditing && selectedUserAlreadyOverridden && (

This user already has an override.