From c21e00089a6c9b3ca05b51b1f373c32d05cf7b53 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 25 Mar 2026 09:19:54 +0000 Subject: [PATCH 01/13] feat: implement `` generic component --- site/src/api/api.ts | 2 +- site/src/api/queries/groups.ts | 7 +- site/src/api/queries/organizations.ts | 9 +- .../components/AddUsersMenu/AddUsersMenu.tsx | 209 ++++++++++++++++++ .../src/pages/GroupsPage/GroupMembersPage.tsx | 120 ++++------ .../OrganizationMembersPage.tsx | 40 +++- .../OrganizationMembersPageView.stories.tsx | 21 +- .../OrganizationMembersPageView.tsx | 91 ++------ 8 files changed, 338 insertions(+), 161 deletions(-) create mode 100644 site/src/components/AddUsersMenu/AddUsersMenu.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 47dbf082882..18100173a8e 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -729,7 +729,7 @@ class ApiMethods { */ getOrganizationPaginatedMembers = async ( organization: string, - options?: TypesGen.Pagination, + options?: TypesGen.UsersRequest, ) => { const url = getURLWithSearchParams( `/api/v2/organizations/${organization}/paginated-members`, diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 516fb20b6f9..80c23584941 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -63,12 +63,17 @@ export const group = ( }; }; +export const groupMembersByOrganizationQueryKey = ( + organization: string, + groupName: string, +) => ["organization", organization, "group", groupName, "members"]; + export const getGroupMembersQueryKey = ( organization: string, groupName: string, req?: UsersRequest, ) => { - const base = [...getRootGroupQueryKey(organization, groupName), "members"]; + const base = groupMembersByOrganizationQueryKey(organization, groupName); return req ? [...base, req] : base; }; diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index af6ea6a5e6e..8497f6a158a 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -7,11 +7,11 @@ import type { CreateOrganizationRequest, GroupSyncSettings, Organization, - PaginatedMembersRequest, PaginatedMembersResponse, RoleSyncSettings, UpdateOrganizationRequest, UpdateWorkspaceSharingSettingsRequest, + UsersRequest, } from "api/typesGenerated"; import type { MetadataState } from "hooks/useEmbeddedMetadata"; import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery"; @@ -26,6 +26,7 @@ import { workspacePermissionChecks, } from "modules/permissions/workspaces"; import type { QueryClient, UseQueryOptions } from "react-query"; +import { prepareQuery } from "utils/filters"; import { meKey } from "./users"; import { cachedQuery } from "./util"; @@ -97,16 +98,14 @@ export const organizationMembers = (id: string) => { export const paginatedOrganizationMembers = ( id: string, searchParams: URLSearchParams, -): UsePaginatedQueryOptions< - PaginatedMembersResponse, - PaginatedMembersRequest -> => { +): UsePaginatedQueryOptions => { return { searchParams, queryPayload: ({ limit, offset }) => { return { limit: limit, offset: offset, + q: prepareQuery(searchParams.get("filter") ?? ""), }; }, queryKey: ({ payload }) => [...organizationMembersKey(id), payload], diff --git a/site/src/components/AddUsersMenu/AddUsersMenu.tsx b/site/src/components/AddUsersMenu/AddUsersMenu.tsx new file mode 100644 index 00000000000..02e5573d3ad --- /dev/null +++ b/site/src/components/AddUsersMenu/AddUsersMenu.tsx @@ -0,0 +1,209 @@ +import { getErrorMessage } from "api/errors"; +import { users } from "api/queries/users"; +import type { User } from "api/typesGenerated"; +import { useDebouncedValue } from "hooks/debounce"; +import { ChevronDown, UserPlusIcon } from "lucide-react"; +import type { FC } from "react"; +import { useState } from "react"; +import { keepPreviousData, useQuery } from "react-query"; +import { prepareQuery } from "utils/filters"; +import { Avatar } from "#/components/Avatar/Avatar"; +import { Button } from "#/components/Button/Button"; +import { Checkbox } from "#/components/Checkbox/Checkbox"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "#/components/Popover/Popover"; +import { SearchField } from "#/components/SearchField/SearchField"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { EmptyState } from "../EmptyState/EmptyState"; + +type AddUsersMenuProps = { + isLoading: boolean; + onSubmit: (users: readonly User[]) => Promise; + onSuccess?: (users: readonly User[]) => void | Promise; + existingUserIds: ReadonlySet; +}; + +export const AddUsersMenu: FC = ({ + isLoading, + onSubmit, + onSuccess, + existingUserIds, +}) => { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 400); + const [selectedById, setSelectedById] = useState( + () => new Map(), + ); + + const usersQuery = useQuery({ + ...users({ + q: prepareQuery(debouncedSearch), + limit: 50, + }), + enabled: open, + placeholderData: keepPreviousData, + }); + + const addableUsers = + usersQuery.data?.users.filter((u) => !existingUserIds.has(u.id)) ?? []; + + const resetPanel = () => { + setSearch(""); + setSelectedById(new Map()); + }; + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + resetPanel(); + } + }; + + const toggleUser = (user: User) => { + setSelectedById((prev) => { + const next = new Map(prev); + if (next.has(user.id)) { + next.delete(user.id); + } else { + next.set(user.id, user); + } + return next; + }); + }; + + const selectedCount = selectedById.size; + const selectedList = Array.from(selectedById.values()); + + const handleAddUsers = async () => { + if (selectedCount === 0) { + return; + } + + try { + await onSubmit(selectedList); + await onSuccess?.(selectedList); + resetPanel(); + setOpen(false); + } catch { + // Parent is expected to show a toast. Keep panel open for retry. + } + }; + + return ( + + + + + {open && ( + +
+ +
+ {usersQuery.isFetching && !usersQuery.data ? ( +
+ +
+ ) : usersQuery.error ? ( +

+ {getErrorMessage(usersQuery.error, "Unable to load users.")} +

+ ) : addableUsers.length === 0 ? ( + + ) : ( +
    + {addableUsers.map((user) => { + const checked = selectedById.has(user.id); + const checkboxId = `add-member-${user.id}`; + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+
+ + +
+
+ )} +
+ ); +}; diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index 8354eb9d1c2..c94e0859b6e 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -1,17 +1,18 @@ import type { Interpolation, Theme } from "@emotion/react"; +import { API } from "api/api"; import { getErrorDetail, getErrorMessage } from "api/errors"; -import { addMember, removeMember } from "api/queries/groups"; -import type { - Group, - OrganizationMemberWithUserData, - ReducedUser, -} from "api/typesGenerated"; -import { EllipsisVertical, UserPlusIcon } from "lucide-react"; +import { + groupMembersByOrganizationQueryKey, + removeMember, +} from "api/queries/groups"; +import type { Group, ReducedUser } from "api/typesGenerated"; +import { EllipsisVertical } from "lucide-react"; import { isEveryoneGroup } from "modules/groups"; -import { type FC, useState } from "react"; +import type { FC } from "react"; import { useMutation, useQueryClient } from "react-query"; import { useOutletContext } from "react-router"; import { toast } from "sonner"; +import { AddUsersMenu } from "#/components/AddUsersMenu/AddUsersMenu"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; import { Button } from "#/components/Button/Button"; @@ -25,8 +26,6 @@ import { EmptyState } from "#/components/EmptyState/EmptyState"; import { UsersFilter } from "#/components/Filter/UsersFilter"; import { LastSeen } from "#/components/LastSeen/LastSeen"; import { PaginationContainer } from "#/components/PaginationWidget/PaginationContainer"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { Stack } from "#/components/Stack/Stack"; import { Table, TableBody, @@ -35,7 +34,6 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; -import { MemberAutocomplete } from "#/components/UserAutocomplete/UserAutocomplete"; import type { GroupPageOutletContext } from "./GroupPage"; const GroupMembersPage: FC = () => { @@ -48,12 +46,14 @@ const GroupMembersPage: FC = () => { filterProps, } = useOutletContext(); const queryClient = useQueryClient(); - const addMemberMutation = useMutation(addMember(queryClient, organization)); + const addMemberMutation = useMutation({ + mutationFn: ({ userId }: { userId: string }) => + API.addMember(groupData.id, userId), + }); const removeMemberMutation = useMutation( removeMember(queryClient, organization), ); const canUpdateGroup = permissions ? permissions.canUpdateGroup : false; - const groupId = groupData.id; return (
@@ -61,21 +61,40 @@ const GroupMembersPage: FC = () => { {canUpdateGroup && groupData && !isEveryoneGroup(groupData) && ( - { - try { - await addMemberMutation.mutateAsync({ - groupId, - userId: member.user_id, - }); - reset(); - } catch (error) { - toast.error(getErrorMessage(error, "Failed to add member."), { + existingUserIds={new Set(members.map((m) => m.id))} + onSubmit={async (usersToAdd) => { + const addPromises = usersToAdd.map((user) => + addMemberMutation.mutateAsync({ userId: user.id }), + ); + const addAllPromise = Promise.all(addPromises); + + toast.promise(addAllPromise, { + loading: + usersToAdd.length === 1 + ? `Adding "${usersToAdd[0].username}" to "${groupData.name}"...` + : `Adding ${usersToAdd.length} members to "${groupData.name}"...`, + success: + usersToAdd.length === 1 + ? `Added "${usersToAdd[0].username}" to "${groupData.name}" successfully.` + : `Added ${usersToAdd.length} members to "${groupData.name}" successfully.`, + error: (error) => ({ + message: getErrorMessage(error, "Failed to add members."), description: getErrorDetail(error), - }); - } + }), + }); + + await addAllPromise; + }} + onSuccess={async () => { + // Only invalidate the group-members list we are updating. + await queryClient.invalidateQueries({ + queryKey: groupMembersByOrganizationQueryKey( + organization, + groupData.name, + ), + }); }} /> )} @@ -129,55 +148,6 @@ const GroupMembersPage: FC = () => { ); }; -interface AddGroupMemberProps { - isLoading: boolean; - onSubmit: (user: OrganizationMemberWithUserData, reset: () => void) => void; - organizationId: string; -} - -const AddGroupMember: FC = ({ - isLoading, - onSubmit, - organizationId, -}) => { - const [selectedUser, setSelectedUser] = - useState(null); - - const resetValues = () => { - setSelectedUser(null); - }; - - return ( -
{ - e.preventDefault(); - - if (selectedUser) { - onSubmit(selectedUser, resetValues); - } - }} - > - - { - setSelectedUser(newValue); - }} - /> - - - -
- ); -}; - interface GroupMemberRowProps { member: ReducedUser; group: Group; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index ade5275cf20..0959fc19375 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { groupsByUserIdInOrganization } from "api/queries/groups"; import { addOrganizationMember, @@ -19,6 +19,7 @@ import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { EmptyState } from "#/components/EmptyState/EmptyState"; +import { useFilter } from "#/components/Filter/Filter"; import { Stack } from "#/components/Stack/Stack"; import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; @@ -29,7 +30,7 @@ const OrganizationMembersPage: FC = () => { organization: string; }; const { organization, organizationPermissions } = useOrganizationSettings(); - const searchParamsResult = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const organizationRolesQuery = useQuery(organizationRoles(organizationName)); const groupsByUserIdQuery = useQuery( @@ -37,9 +38,15 @@ const OrganizationMembersPage: FC = () => { ); const membersQuery = usePaginatedQuery( - paginatedOrganizationMembers(organizationName, searchParamsResult[0]), + paginatedOrganizationMembers(organizationName, searchParams), ); + const useFilterResult = useFilter({ + searchParams, + onSearchParamsChange: setSearchParams, + onUpdate: membersQuery.goToFirstPage, + }); + const members = membersQuery.data?.members.map( (member: OrganizationMemberWithUserData) => { const groups = groupsByUserIdQuery.data?.get(member.user_id) ?? []; @@ -86,6 +93,9 @@ const OrganizationMembersPage: FC = () => { allAvailableRoles={organizationRolesQuery.data} canEditMembers={organizationPermissions.editMembers} canViewMembers={organizationPermissions.viewMembers} + filterProps={{ + filter: useFilterResult, + }} error={ membersQuery.error ?? organizationRolesQuery.error ?? @@ -99,8 +109,28 @@ const OrganizationMembersPage: FC = () => { me={me} members={members} membersQuery={membersQuery} - addMember={async (user: User) => { - await addMemberMutation.mutateAsync(user.id); + addMembers={async (usersToAdd: readonly User[]) => { + const addMutationPromises = usersToAdd.map((user) => + addMemberMutation.mutateAsync(user.id), + ); + const addAllMembersPromise = Promise.all(addMutationPromises); + + toast.promise(addAllMembersPromise, { + loading: + usersToAdd.length === 1 + ? `Adding "${usersToAdd[0].username}" to "${organization.display_name}"...` + : `Adding ${usersToAdd.length} members to "${organization.display_name}"...`, + success: + usersToAdd.length === 1 + ? `Added "${usersToAdd[0].username}" to "${organization.display_name}".` + : `Added ${usersToAdd.length} members to "${organization.display_name}".`, + error: (error) => ({ + message: getErrorMessage(error, "Failed to add members."), + description: getErrorDetail(error), + }), + }); + + await addAllMembersPromise; void membersQuery.refetch(); }} removeMember={setMemberToDelete} diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx index 84aa26e9e84..5f819189709 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx @@ -7,13 +7,32 @@ import { } from "testHelpers/entities"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { UsePaginatedQueryResult } from "hooks/usePaginatedQuery"; +import type { ComponentProps } from "react"; +import { + getDefaultFilterProps, + MockMenu, +} from "#/components/Filter/storyHelpers"; import { mockSuccessResult } from "#/components/PaginationWidget/PaginationContainer.mocks"; import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; +type FilterProps = ComponentProps< + typeof OrganizationMembersPageView +>["filterProps"]; + +const defaultFilterProps = getDefaultFilterProps({ + values: { + status: "active", + }, + menus: { + status: MockMenu, + }, +}); + const meta: Meta = { title: "pages/OrganizationMembersPageView", component: OrganizationMembersPageView, args: { + filterProps: defaultFilterProps, canEditMembers: true, error: undefined, isAddingMember: false, @@ -32,7 +51,7 @@ const meta: Meta = { ...mockSuccessResult, totalRecords: 2, } as UsePaginatedQueryResult, - addMember: () => Promise.resolve(), + addMembers: () => Promise.resolve(), removeMember: () => Promise.resolve(), updateMemberRoles: () => Promise.resolve(), }, diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index d2a53fd6b0d..c587587ca6f 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -1,4 +1,3 @@ -import { getErrorDetail, getErrorMessage } from "api/errors"; import type { Group, OrganizationMemberWithUserData, @@ -6,10 +5,11 @@ import type { User, } from "api/typesGenerated"; import type { PaginationResultInfo } from "hooks/usePaginatedQuery"; -import { EllipsisVertical, TriangleAlert, UserPlusIcon } from "lucide-react"; +import { EllipsisVertical, TriangleAlert } from "lucide-react"; import { UserGroupsCell } from "pages/UsersPage/UsersTable/UserGroupsCell"; -import { type FC, useState } from "react"; +import type { ComponentProps, FC } from "react"; import { toast } from "sonner"; +import { AddUsersMenu } from "#/components/AddUsersMenu/AddUsersMenu"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; @@ -20,13 +20,13 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; +import { UsersFilter } from "#/components/Filter/UsersFilter"; import { Loader } from "#/components/Loader/Loader"; import { PaginationContainer } from "#/components/PaginationWidget/PaginationContainer"; import { SettingsHeader, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; -import { Spinner } from "#/components/Spinner/Spinner"; import { Stack } from "#/components/Stack/Stack"; import { Table, @@ -36,7 +36,6 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; -import { UserAutocomplete } from "#/components/UserAutocomplete/UserAutocomplete"; import { TableColumnHelpTooltip } from "./UserTable/TableColumnHelpTooltip"; import { UserRoleCell } from "./UserTable/UserRoleCell"; @@ -44,6 +43,7 @@ interface OrganizationMembersPageViewProps { allAvailableRoles: readonly SlimRole[] | undefined; canEditMembers: boolean; canViewMembers: boolean; + filterProps: ComponentProps; error: unknown; isAddingMember: boolean; isUpdatingMemberRoles: boolean; @@ -52,7 +52,7 @@ interface OrganizationMembersPageViewProps { membersQuery: PaginationResultInfo & { isPlaceholderData: boolean; }; - addMember: (user: User) => Promise; + addMembers: (users: readonly User[]) => Promise; removeMember: (member: OrganizationMemberWithUserData) => void; updateMemberRoles: ( member: OrganizationMemberWithUserData, @@ -70,13 +70,14 @@ export const OrganizationMembersPageView: FC< allAvailableRoles, canEditMembers, canViewMembers, + filterProps, error, isAddingMember, isUpdatingMemberRoles, me, membersQuery, members, - addMember, + addMembers, removeMember, updateMemberRoles, }) => { @@ -89,12 +90,16 @@ export const OrganizationMembersPageView: FC<
{Boolean(error) && } - {canEditMembers && ( - - )} +
+ + {canEditMembers && ( + m.user_id) ?? [])} + /> + )} +
{!canViewMembers && (
@@ -200,63 +205,3 @@ export const OrganizationMembersPageView: FC<
); }; - -interface AddOrganizationMemberProps { - isLoading: boolean; - onSubmit: (user: User) => Promise; -} - -const AddOrganizationMember: FC = ({ - isLoading, - onSubmit, -}) => { - const [selectedUser, setSelectedUser] = useState(null); - - return ( -
{ - event.preventDefault(); - - if (selectedUser) { - try { - await onSubmit(selectedUser); - setSelectedUser(null); - } catch (error) { - toast.error( - getErrorMessage( - error, - selectedUser - ? `Failed to add "${selectedUser.username}" as a member.` - : "Failed to add member.", - ), - { - description: getErrorDetail(error), - }, - ); - } - } - }} - > - - { - setSelectedUser(newValue); - }} - /> - - - -
- ); -}; From 2f72731e9dbcd02aa0f6bc496a3a0e0b951b8893 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 25 Mar 2026 09:41:44 +0000 Subject: [PATCH 02/13] fix: remove magic invalidation --- site/src/api/queries/groups.ts | 4 +--- site/src/pages/GroupsPage/GroupMembersPage.tsx | 12 ++++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 80c23584941..b0bfffd1619 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -205,12 +205,10 @@ export const deleteGroup = (queryClient: QueryClient, organization: string) => { }; }; -export const addMember = (queryClient: QueryClient, organization: string) => { +export const addMember = () => { return { mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => API.addMember(groupId, userId), - onSuccess: async (updatedGroup: Group) => - invalidateGroup(queryClient, organization, updatedGroup.name), }; }; diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index c94e0859b6e..6a9369ec5f9 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -1,7 +1,7 @@ import type { Interpolation, Theme } from "@emotion/react"; -import { API } from "api/api"; import { getErrorDetail, getErrorMessage } from "api/errors"; import { + addMember, groupMembersByOrganizationQueryKey, removeMember, } from "api/queries/groups"; @@ -46,10 +46,7 @@ const GroupMembersPage: FC = () => { filterProps, } = useOutletContext(); const queryClient = useQueryClient(); - const addMemberMutation = useMutation({ - mutationFn: ({ userId }: { userId: string }) => - API.addMember(groupData.id, userId), - }); + const addMemberMutation = useMutation(addMember()); const removeMemberMutation = useMutation( removeMember(queryClient, organization), ); @@ -66,7 +63,10 @@ const GroupMembersPage: FC = () => { existingUserIds={new Set(members.map((m) => m.id))} onSubmit={async (usersToAdd) => { const addPromises = usersToAdd.map((user) => - addMemberMutation.mutateAsync({ userId: user.id }), + addMemberMutation.mutateAsync({ + groupId: groupData.id, + userId: user.id, + }), ); const addAllPromise = Promise.all(addPromises); From cca56805359741fff1fcf23df6336ccf56a8b379 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 25 Mar 2026 09:52:50 +0000 Subject: [PATCH 03/13] fix: move to using `/add users/` regex in tests --- site/src/pages/GroupsPage/GroupPage.stories.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index 3db55c4a3c9..0d7d0c1401c 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -178,7 +178,7 @@ export const MembersError: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click( - await canvas.findByRole("button", { name: "Select a user" }), + await canvas.findByRole("button", { name: /Add users/i }), ); }, }; @@ -195,7 +195,7 @@ export const NoMembers: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click( - await canvas.findByRole("button", { name: "Select a user" }), + await canvas.findByRole("button", { name: /Add users/i }), ); }, }; @@ -217,7 +217,7 @@ export const FiltersByMembers: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click( - await canvas.findByRole("button", { name: "Select a user" }), + await canvas.findByRole("button", { name: /Add users/i }), ); }, }; From b2be80bbb076507650d55457d468dcc23bfd5299 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Mon, 30 Mar 2026 13:32:45 +0000 Subject: [PATCH 04/13] fix: resolve build conflicts --- site/src/api/queries/organizations.ts | 3 +- .../components/AddUsersMenu/AddUsersMenu.tsx | 13 ++++----- .../src/pages/GroupsPage/GroupMembersPage.tsx | 29 +++++-------------- .../OrganizationMembersPage.tsx | 13 +++++++-- .../OrganizationMembersPageView.stories.tsx | 15 ++++------ .../OrganizationMembersPageView.tsx | 13 +++++---- 6 files changed, 37 insertions(+), 49 deletions(-) diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index bd6d4ea890b..d5d25de46d2 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -26,8 +26,7 @@ import { type WorkspacePermissions, workspacePermissionChecks, } from "#/modules/permissions/workspaces"; -import type { QueryClient, UseQueryOptions } from "react-query"; -import { prepareQuery } from "utils/filters"; +import { prepareQuery } from "#/utils/filters"; import { meKey } from "./users"; import { cachedQuery } from "./util"; diff --git a/site/src/components/AddUsersMenu/AddUsersMenu.tsx b/site/src/components/AddUsersMenu/AddUsersMenu.tsx index 02e5573d3ad..da5eb39d018 100644 --- a/site/src/components/AddUsersMenu/AddUsersMenu.tsx +++ b/site/src/components/AddUsersMenu/AddUsersMenu.tsx @@ -1,12 +1,9 @@ -import { getErrorMessage } from "api/errors"; -import { users } from "api/queries/users"; -import type { User } from "api/typesGenerated"; -import { useDebouncedValue } from "hooks/debounce"; import { ChevronDown, UserPlusIcon } from "lucide-react"; -import type { FC } from "react"; -import { useState } from "react"; +import { type FC, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; -import { prepareQuery } from "utils/filters"; +import { getErrorMessage } from "#/api/errors"; +import { users } from "#/api/queries/users"; +import type { User } from "#/api/typesGenerated"; import { Avatar } from "#/components/Avatar/Avatar"; import { Button } from "#/components/Button/Button"; import { Checkbox } from "#/components/Checkbox/Checkbox"; @@ -17,6 +14,8 @@ import { } from "#/components/Popover/Popover"; import { SearchField } from "#/components/SearchField/SearchField"; import { Spinner } from "#/components/Spinner/Spinner"; +import { useDebouncedValue } from "#/hooks/debounce"; +import { prepareQuery } from "#/utils/filters"; import { EmptyState } from "../EmptyState/EmptyState"; type AddUsersMenuProps = { diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index e84c562ae64..406a84674c3 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -1,34 +1,20 @@ import type { Interpolation, Theme } from "@emotion/react"; -import { getErrorDetail, getErrorMessage } from "api/errors"; -import { - addMember, - groupMembersByOrganizationQueryKey, - removeMember, -} from "api/queries/groups"; -import type { Group, ReducedUser } from "api/typesGenerated"; import { EllipsisVertical } from "lucide-react"; -import { isEveryoneGroup } from "modules/groups"; import type { FC } from "react"; import { useMutation, useQueryClient } from "react-query"; import { useOutletContext } from "react-router"; import { toast } from "sonner"; -import { AddUsersMenu } from "#/components/AddUsersMenu/AddUsersMenu"; -import { EllipsisVertical, UserPlusIcon } from "lucide-react"; -import { type FC, useState } from "react"; -import { useMutation, useQueryClient } from "react-query"; -import { useOutletContext } from "react-router"; -import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; -import { addMembers, removeMember } from "#/api/queries/groups"; -import type { - Group, - OrganizationMemberWithUserData, - ReducedUser, -} from "#/api/typesGenerated"; +import { + addMember, + groupMembersByOrganizationQueryKey, + removeMember, +} from "#/api/queries/groups"; +import type { Group, ReducedUser } from "#/api/typesGenerated"; +import { AddUsersMenu } from "#/components/AddUsersMenu/AddUsersMenu"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; import { Button } from "#/components/Button/Button"; -import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { DropdownMenu, DropdownMenuContent, @@ -38,7 +24,6 @@ import { import { EmptyState } from "#/components/EmptyState/EmptyState"; import { UsersFilter } from "#/components/Filter/UsersFilter"; import { LastSeen } from "#/components/LastSeen/LastSeen"; -import { MultiMemberSelect } from "#/components/MultiUserSelect/MultiUserSelect"; import { PaginationContainer } from "#/components/PaginationWidget/PaginationContainer"; import { Table, diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index 11db535e659..74279bafcb9 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -1,5 +1,9 @@ -import { getErrorDetail, getErrorMessage } from "api/errors"; -import { groupsByUserIdInOrganization } from "api/queries/groups"; +import { type FC, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useParams, useSearchParams } from "react-router"; +import { toast } from "sonner"; +import { getErrorDetail, getErrorMessage } from "#/api/errors"; +import { groupsByUserIdInOrganization } from "#/api/queries/groups"; import { addOrganizationMember, paginatedOrganizationMembers, @@ -31,6 +35,9 @@ const OrganizationMembersPage: FC = () => { organization: string; }; const { organization, organizationPermissions } = useOrganizationSettings(); + const { entitlements } = useDashboard(); + const searchParamsResult = useSearchParams(); + const showAISeatColumn = shouldShowAISeatColumn(entitlements); const [searchParams, setSearchParams] = useSearchParams(); const organizationRolesQuery = useQuery(organizationRoles(organizationName)); @@ -39,7 +46,7 @@ const OrganizationMembersPage: FC = () => { ); const membersQuery = usePaginatedQuery( - paginatedOrganizationMembers(organizationName, searchParams), + paginatedOrganizationMembers(organizationName, searchParamsResult[0]), ); const useFilterResult = useFilter({ diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx index a57500bc739..6f710e4a9f6 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx @@ -1,5 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ComponentProps } from "react"; import { expect, within } from "storybook/test"; +import { + getDefaultFilterProps, + MockMenu, +} from "#/components/Filter/storyHelpers"; import { mockSuccessResult } from "#/components/PaginationWidget/PaginationContainer.mocks"; import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery"; import { @@ -8,15 +13,7 @@ import { MockOwnerRole, MockUserAdminRole, MockUserOwner, -} from "testHelpers/entities"; -import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { UsePaginatedQueryResult } from "hooks/usePaginatedQuery"; -import type { ComponentProps } from "react"; -import { - getDefaultFilterProps, - MockMenu, -} from "#/components/Filter/storyHelpers"; -import { mockSuccessResult } from "#/components/PaginationWidget/PaginationContainer.mocks"; +} from "#/testHelpers/entities"; import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; type FilterProps = ComponentProps< diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index d451ca91e25..1a69762071a 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -1,14 +1,12 @@ +import { EllipsisVertical, TriangleAlert } from "lucide-react"; +import type { ComponentProps, FC } from "react"; +import { toast } from "sonner"; import type { Group, OrganizationMemberWithUserData, SlimRole, User, -} from "api/typesGenerated"; -import type { PaginationResultInfo } from "hooks/usePaginatedQuery"; -import { EllipsisVertical, TriangleAlert } from "lucide-react"; -import { UserGroupsCell } from "pages/UsersPage/UsersTable/UserGroupsCell"; -import type { ComponentProps, FC } from "react"; -import { toast } from "sonner"; +} from "#/api/typesGenerated"; import { AddUsersMenu } from "#/components/AddUsersMenu/AddUsersMenu"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Avatar } from "#/components/Avatar/Avatar"; @@ -36,6 +34,9 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; +import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery"; +import { AISeatCell } from "#/modules/users/AISeatCell"; +import { UserGroupsCell } from "#/pages/UsersPage/UsersTable/UserGroupsCell"; import { TableColumnHelpTooltip } from "./UserTable/TableColumnHelpTooltip"; import { UserRoleCell } from "./UserTable/UserRoleCell"; From 7300d260895cbc8cf6d3835457ccbe60ed32a905 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Mon, 30 Mar 2026 13:34:29 +0000 Subject: [PATCH 05/13] fix: prepend "organization" to toast --- .../OrganizationSettingsPage/OrganizationMembersPage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index 74279bafcb9..0d68cf82652 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -127,12 +127,12 @@ const OrganizationMembersPage: FC = () => { toast.promise(addAllMembersPromise, { loading: usersToAdd.length === 1 - ? `Adding "${usersToAdd[0].username}" to "${organization.display_name}"...` - : `Adding ${usersToAdd.length} members to "${organization.display_name}"...`, + ? `Adding "${usersToAdd[0].username}" to organization "${organization.display_name}"...` + : `Adding ${usersToAdd.length} members to organization "${organization.display_name}"...`, success: usersToAdd.length === 1 - ? `Added "${usersToAdd[0].username}" to "${organization.display_name}".` - : `Added ${usersToAdd.length} members to "${organization.display_name}".`, + ? `Added "${usersToAdd[0].username}" to organization "${organization.display_name}".` + : `Added ${usersToAdd.length} members to organization "${organization.display_name}".`, error: (error) => ({ message: getErrorMessage(error, "Failed to add members."), description: getErrorDetail(error), From c20dcafedcf58af43dfc78f2eefc0ad00f0f97de Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 1 Apr 2026 07:14:16 +0000 Subject: [PATCH 06/13] fix: resolve stories --- .../pages/GroupsPage/GroupPage.stories.tsx | 19 +++++++++---------- .../OrganizationMembersPageView.stories.tsx | 4 +--- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index 3ad2d68438a..066b7f483b9 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -10,7 +10,7 @@ import { getGroupQueryKey, groupPermissionsKey, } from "#/api/queries/groups"; -import { organizationMembersKey } from "#/api/queries/organizations"; +import { usersKey } from "#/api/queries/users"; import { MockDefaultOrganization, MockGroup, @@ -69,9 +69,9 @@ const permissionsQuery = (data: unknown, id?: string) => ({ data, }); -const membersQuery = (data: unknown) => ({ - key: organizationMembersKey(MockDefaultOrganization.id, { - limit: 25, +const usersQuery = (data: unknown) => ({ + key: usersKey({ + limit: 50, q: "", }), data, @@ -166,9 +166,7 @@ export const MembersError: Story = { spyOn(API, "checkAuthorization").mockResolvedValue({ canUpdateGroup: true, }); - spyOn(API, "getOrganizationPaginatedMembers").mockRejectedValue( - new Error("test members error"), - ); + spyOn(API, "getUsers").mockRejectedValue(new Error("test members error")); }, parameters: { queries: [ @@ -192,7 +190,7 @@ export const NoMembers: Story = { groupQuery(MockGroupWithoutMembers), groupMembersQuery({ users: [], count: 0 }), permissionsQuery({ canUpdateGroup: true }), - membersQuery({ members: [] }), + usersQuery({ users: [], count: 0 }), ], }, play: async ({ canvasElement }) => { @@ -212,8 +210,9 @@ export const FiltersByMembers: Story = { count: MockGroup.members.length, }), permissionsQuery({ canUpdateGroup: true }), - membersQuery({ - members: [MockOrganizationMember, MockOrganizationMember2], + usersQuery({ + users: [MockOrganizationMember, MockOrganizationMember2], + count: 2, }), ], }, diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx index 6f710e4a9f6..5326d293ca1 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx @@ -24,9 +24,7 @@ const defaultFilterProps = getDefaultFilterProps({ values: { status: "active", }, - menus: { - status: MockMenu, - }, + menus: {}, }); const meta: Meta = { From 3208234f9d6108344ae57d7aba4a024a44d2605c Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 1 Apr 2026 07:17:57 +0000 Subject: [PATCH 07/13] fix: remove unused import --- .../OrganizationMembersPageView.stories.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx index 5326d293ca1..95a48cc2fbe 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx @@ -1,10 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ComponentProps } from "react"; import { expect, within } from "storybook/test"; -import { - getDefaultFilterProps, - MockMenu, -} from "#/components/Filter/storyHelpers"; +import { getDefaultFilterProps } from "#/components/Filter/storyHelpers"; import { mockSuccessResult } from "#/components/PaginationWidget/PaginationContainer.mocks"; import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery"; import { From 6ec30aa20d74eb3574a8192a5b78d0298dc85371 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 1 Apr 2026 07:22:11 +0000 Subject: [PATCH 08/13] feat: move `AddUsersMenu` to `modules/user/` and rename --- .../AddUsersMenu.tsx => modules/users/AddUsersPopover.tsx} | 6 +++--- site/src/pages/GroupsPage/GroupMembersPage.tsx | 4 ++-- .../OrganizationMembersPageView.tsx | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) rename site/src/{components/AddUsersMenu/AddUsersMenu.tsx => modules/users/AddUsersPopover.tsx} (97%) diff --git a/site/src/components/AddUsersMenu/AddUsersMenu.tsx b/site/src/modules/users/AddUsersPopover.tsx similarity index 97% rename from site/src/components/AddUsersMenu/AddUsersMenu.tsx rename to site/src/modules/users/AddUsersPopover.tsx index da5eb39d018..9b7e75a5ea5 100644 --- a/site/src/components/AddUsersMenu/AddUsersMenu.tsx +++ b/site/src/modules/users/AddUsersPopover.tsx @@ -7,6 +7,7 @@ import type { User } from "#/api/typesGenerated"; import { Avatar } from "#/components/Avatar/Avatar"; import { Button } from "#/components/Button/Button"; import { Checkbox } from "#/components/Checkbox/Checkbox"; +import { EmptyState } from "#/components/EmptyState/EmptyState"; import { Popover, PopoverContent, @@ -16,16 +17,15 @@ import { SearchField } from "#/components/SearchField/SearchField"; import { Spinner } from "#/components/Spinner/Spinner"; import { useDebouncedValue } from "#/hooks/debounce"; import { prepareQuery } from "#/utils/filters"; -import { EmptyState } from "../EmptyState/EmptyState"; -type AddUsersMenuProps = { +type AddUsersPopoverProps = { isLoading: boolean; onSubmit: (users: readonly User[]) => Promise; onSuccess?: (users: readonly User[]) => void | Promise; existingUserIds: ReadonlySet; }; -export const AddUsersMenu: FC = ({ +export const AddUsersPopover: FC = ({ isLoading, onSubmit, onSuccess, diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index 8919554422f..279a4131e3c 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -11,7 +11,6 @@ import { removeMember, } from "#/api/queries/groups"; import type { Group, ReducedUser } from "#/api/typesGenerated"; -import { AddUsersMenu } from "#/components/AddUsersMenu/AddUsersMenu"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; import { Button } from "#/components/Button/Button"; @@ -34,6 +33,7 @@ import { TableRow, } from "#/components/Table/Table"; import { isEveryoneGroup } from "#/modules/groups"; +import { AddUsersPopover } from "#/modules/users/AddUsersPopover"; import type { GroupPageOutletContext } from "./GroupPage"; const GroupMembersPage: FC = () => { @@ -58,7 +58,7 @@ const GroupMembersPage: FC = () => { {canUpdateGroup && groupData && !isEveryoneGroup(groupData) && ( - m.id))} onSubmit={async (usersToAdd) => { diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index 52d1f5c7890..f0371132f2d 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -7,7 +7,6 @@ import type { SlimRole, User, } from "#/api/typesGenerated"; -import { AddUsersMenu } from "#/components/AddUsersMenu/AddUsersMenu"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; @@ -35,6 +34,7 @@ import { TableRow, } from "#/components/Table/Table"; import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery"; +import { AddUsersPopover } from "#/modules/users/AddUsersPopover"; import { AISeatCell } from "#/modules/users/AISeatCell"; import { UserGroupsCell } from "#/pages/UsersPage/UsersTable/UserGroupsCell"; import { TableColumnHelpPopover } from "./UserTable/TableColumnHelpPopover"; @@ -96,7 +96,7 @@ export const OrganizationMembersPageView: FC<
{canEditMembers && ( - m.user_id) ?? [])} From 8ecc4dec459cfc3198485239864f7a0feac2d7f3 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 1 Apr 2026 08:48:42 +0000 Subject: [PATCH 09/13] fix: resolve query to consumers --- site/src/modules/users/AddUsersPopover.tsx | 45 +++++++++---------- .../src/pages/GroupsPage/GroupMembersPage.tsx | 32 ++++++++++++- .../OrganizationMembersPage.tsx | 8 ++-- .../OrganizationMembersPageView.tsx | 26 +++++++++-- 4 files changed, 77 insertions(+), 34 deletions(-) diff --git a/site/src/modules/users/AddUsersPopover.tsx b/site/src/modules/users/AddUsersPopover.tsx index 9b7e75a5ea5..3925a14c491 100644 --- a/site/src/modules/users/AddUsersPopover.tsx +++ b/site/src/modules/users/AddUsersPopover.tsx @@ -1,8 +1,7 @@ import { ChevronDown, UserPlusIcon } from "lucide-react"; import { type FC, useState } from "react"; -import { keepPreviousData, useQuery } from "react-query"; +import type { UseQueryResult } from "react-query"; import { getErrorMessage } from "#/api/errors"; -import { users } from "#/api/queries/users"; import type { User } from "#/api/typesGenerated"; import { Avatar } from "#/components/Avatar/Avatar"; import { Button } from "#/components/Button/Button"; @@ -15,14 +14,20 @@ import { } from "#/components/Popover/Popover"; import { SearchField } from "#/components/SearchField/SearchField"; import { Spinner } from "#/components/Spinner/Spinner"; -import { useDebouncedValue } from "#/hooks/debounce"; -import { prepareQuery } from "#/utils/filters"; + +export type AddableUser = Pick< + User, + "id" | "username" | "name" | "email" | "avatar_url" +>; type AddUsersPopoverProps = { isLoading: boolean; - onSubmit: (users: readonly User[]) => Promise; - onSuccess?: (users: readonly User[]) => void | Promise; + onSubmit: (users: readonly AddableUser[]) => Promise; + onSuccess?: (users: readonly AddableUser[]) => void | Promise; existingUserIds: ReadonlySet; + search: string; + onSearchChange: (value: string) => void; + usersQuery: UseQueryResult; }; export const AddUsersPopover: FC = ({ @@ -30,28 +35,20 @@ export const AddUsersPopover: FC = ({ onSubmit, onSuccess, existingUserIds, + search, + onSearchChange, + usersQuery, }) => { const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const debouncedSearch = useDebouncedValue(search, 400); const [selectedById, setSelectedById] = useState( - () => new Map(), + () => new Map(), ); - const usersQuery = useQuery({ - ...users({ - q: prepareQuery(debouncedSearch), - limit: 50, - }), - enabled: open, - placeholderData: keepPreviousData, - }); - - const addableUsers = - usersQuery.data?.users.filter((u) => !existingUserIds.has(u.id)) ?? []; + const addableUsers: AddableUser[] = + usersQuery.data?.filter((u) => !existingUserIds.has(u.id)) ?? []; const resetPanel = () => { - setSearch(""); + onSearchChange(""); setSelectedById(new Map()); }; @@ -62,7 +59,7 @@ export const AddUsersPopover: FC = ({ } }; - const toggleUser = (user: User) => { + const toggleUser = (user: AddableUser) => { setSelectedById((prev) => { const next = new Map(prev); if (next.has(user.id)) { @@ -109,7 +106,7 @@ export const AddUsersPopover: FC = ({
= ({ ) : addableUsers.length === 0 ? ( { @@ -51,6 +59,23 @@ const GroupMembersPage: FC = () => { removeMember(queryClient, organization), ); const canUpdateGroup = permissions ? permissions.canUpdateGroup : false; + const [addUsersSearch, setAddUsersSearch] = useState(""); + const debouncedSearch = useDebouncedValue(addUsersSearch, 400); + + const addableMembersQuery = useQuery({ + ...organizationMembers(organization, { + q: prepareQuery(debouncedSearch), + limit: 50, + }), + select: (data) => + data.members.map((member) => ({ + ...member, + id: member.user_id, + })), + enabled: + canUpdateGroup && Boolean(groupData) && !isEveryoneGroup(groupData), + placeholderData: keepPreviousData, + }); return (
@@ -61,6 +86,9 @@ const GroupMembersPage: FC = () => { m.id))} + search={addUsersSearch} + onSearchChange={setAddUsersSearch} + usersQuery={addableMembersQuery} onSubmit={async (usersToAdd) => { const addPromises = usersToAdd.map((user) => addMemberMutation.mutateAsync({ diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index 0d68cf82652..880d0013037 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -11,10 +11,7 @@ import { updateOrganizationMemberRoles, } from "#/api/queries/organizations"; import { organizationRoles } from "#/api/queries/roles"; -import type { - OrganizationMemberWithUserData, - User, -} from "#/api/typesGenerated"; +import type { OrganizationMemberWithUserData } from "#/api/typesGenerated"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { EmptyState } from "#/components/EmptyState/EmptyState"; import { useFilter } from "#/components/Filter/Filter"; @@ -25,6 +22,7 @@ import { shouldShowAISeatColumn } from "#/modules/dashboard/entitlements"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useOrganizationSettings } from "#/modules/management/OrganizationSettingsLayout"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; +import type { AddableUser } from "#/modules/users/AddUsersPopover"; import { pageTitle } from "#/utils/page"; import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; @@ -118,7 +116,7 @@ const OrganizationMembersPage: FC = () => { me={me} members={members} membersQuery={membersQuery} - addMembers={async (usersToAdd: readonly User[]) => { + addMembers={async (usersToAdd: readonly AddableUser[]) => { const addMutationPromises = usersToAdd.map((user) => addMemberMutation.mutateAsync(user.id), ); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index f0371132f2d..54a3dc6a732 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -1,6 +1,8 @@ import { EllipsisVertical, TriangleAlert } from "lucide-react"; -import type { ComponentProps, FC } from "react"; +import { type ComponentProps, type FC, useState } from "react"; +import { keepPreviousData, useQuery } from "react-query"; import { toast } from "sonner"; +import { users } from "#/api/queries/users"; import type { Group, OrganizationMemberWithUserData, @@ -33,10 +35,15 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; +import { useDebouncedValue } from "#/hooks/debounce"; import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery"; -import { AddUsersPopover } from "#/modules/users/AddUsersPopover"; +import { + type AddableUser, + AddUsersPopover, +} from "#/modules/users/AddUsersPopover"; import { AISeatCell } from "#/modules/users/AISeatCell"; import { UserGroupsCell } from "#/pages/UsersPage/UsersTable/UserGroupsCell"; +import { prepareQuery } from "#/utils/filters"; import { TableColumnHelpPopover } from "./UserTable/TableColumnHelpPopover"; import { UserRoleCell } from "./UserTable/UserRoleCell"; @@ -54,7 +61,7 @@ interface OrganizationMembersPageViewProps { membersQuery: PaginationResultInfo & { isPlaceholderData: boolean; }; - addMembers: (users: readonly User[]) => Promise; + addMembers: (users: readonly AddableUser[]) => Promise; removeMember: (member: OrganizationMemberWithUserData) => void; updateMemberRoles: ( member: OrganizationMemberWithUserData, @@ -84,6 +91,16 @@ export const OrganizationMembersPageView: FC< removeMember, updateMemberRoles, }) => { + const [addUsersSearch, setAddUsersSearch] = useState(""); + const debouncedSearch = useDebouncedValue(addUsersSearch, 400); + const addableUsersQuery = useQuery({ + ...users({ + q: prepareQuery(debouncedSearch), + limit: 50, + }), + enabled: canEditMembers, + }); + return (
@@ -100,6 +117,9 @@ export const OrganizationMembersPageView: FC< isLoading={isAddingMember} onSubmit={addMembers} existingUserIds={new Set(members?.map((m) => m.user_id) ?? [])} + search={addUsersSearch} + onSearchChange={setAddUsersSearch} + usersQuery={addableUsersQuery} /> )}
From 8b2d5cf28cdc0a9cad80c5ed8551b007fd71456e Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 1 Apr 2026 08:53:53 +0000 Subject: [PATCH 10/13] fix: unused `keepPreviousData` --- .../OrganizationSettingsPage/OrganizationMembersPageView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index 54a3dc6a732..7addacdf1a5 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -1,6 +1,6 @@ import { EllipsisVertical, TriangleAlert } from "lucide-react"; import { type ComponentProps, type FC, useState } from "react"; -import { keepPreviousData, useQuery } from "react-query"; +import { useQuery } from "react-query"; import { toast } from "sonner"; import { users } from "#/api/queries/users"; import type { From 0e69ccf75d6ee5ba889a24f0a06f3368e3e51fa7 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 1 Apr 2026 08:55:16 +0000 Subject: [PATCH 11/13] fix: resolve type --- .../OrganizationSettingsPage/OrganizationMembersPageView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index 7addacdf1a5..7f9e8e7bc23 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -98,6 +98,7 @@ export const OrganizationMembersPageView: FC< q: prepareQuery(debouncedSearch), limit: 50, }), + select: (data) => data.users, enabled: canEditMembers, }); From aa58a015a1d2614e2eaea3b2fc96d0dc11a38741 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 9 Apr 2026 06:06:32 +0000 Subject: [PATCH 12/13] fix(site): address review feedback for AddUsersPopover PR - Fix duplicate useSearchParams in OrganizationMembersPage causing broken member filtering - Normalize add-user search limit from 50 to 25 to match existing components (UserAutocomplete, MultiMemberSelect) - Fix GroupPage stories to use organizationMembersKey instead of usersKey, and correct mock data shape - Remove dead MultiUserSelect component and stories (no imports) --- .../MultiMemberSelect.stories.tsx | 32 -- .../MultiUserSelect.stories.tsx | 129 -------- .../MultiUserSelect/MultiUserSelect.tsx | 275 ------------------ .../src/pages/GroupsPage/GroupMembersPage.tsx | 2 +- .../pages/GroupsPage/GroupPage.stories.tsx | 18 +- .../OrganizationMembersPage.tsx | 3 +- .../OrganizationMembersPageView.tsx | 2 +- 7 files changed, 13 insertions(+), 448 deletions(-) delete mode 100644 site/src/components/MultiUserSelect/MultiMemberSelect.stories.tsx delete mode 100644 site/src/components/MultiUserSelect/MultiUserSelect.stories.tsx delete mode 100644 site/src/components/MultiUserSelect/MultiUserSelect.tsx diff --git a/site/src/components/MultiUserSelect/MultiMemberSelect.stories.tsx b/site/src/components/MultiUserSelect/MultiMemberSelect.stories.tsx deleted file mode 100644 index 4671c150b41..00000000000 --- a/site/src/components/MultiUserSelect/MultiMemberSelect.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { organizationMembersKey } from "#/api/queries/organizations"; -import { MockOrganizationMember } from "#/testHelpers/entities"; -import { MultiMemberSelect } from "./MultiUserSelect"; - -const meta: Meta = { - title: "components/MultiMemberSelect", - component: MultiMemberSelect, -}; - -export default meta; -type Story = StoryObj; - -export const Loading: Story = { - args: { - organizationId: MockOrganizationMember.organization_id, - }, - parameters: { - queries: [ - { - key: organizationMembersKey(MockOrganizationMember.organization_id, { - limit: 25, - q: "", - }), - data: { - users: undefined, - count: 0, - }, - }, - ], - }, -}; diff --git a/site/src/components/MultiUserSelect/MultiUserSelect.stories.tsx b/site/src/components/MultiUserSelect/MultiUserSelect.stories.tsx deleted file mode 100644 index 49af7c0d2ad..00000000000 --- a/site/src/components/MultiUserSelect/MultiUserSelect.stories.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { spyOn } from "storybook/test"; -import { API } from "#/api/api"; -import { usersKey } from "#/api/queries/users"; -import { MockUsers } from "#/pages/UsersPage/storybookData/users"; -import { mockApiError } from "#/testHelpers/entities"; -import { MultiUserSelect } from "./MultiUserSelect"; - -const meta: Meta = { - title: "components/MultiUserSelect", - component: MultiUserSelect, -}; - -export default meta; -type Story = StoryObj; - -export const Loading: Story = { - parameters: { - queries: [ - { - key: usersKey({ limit: 25, q: "" }), - data: { - users: undefined, - count: 0, - }, - }, - ], - }, -}; - -export const WithError: Story = { - beforeEach: () => { - spyOn(API, "getUsers").mockRejectedValue( - mockApiError({ - message: "Failed to load users", - detail: "You don't have permission to access this resource.", - }), - ); - }, - args: { - selected: [], - onChange: () => undefined, - }, -}; - -export const Loaded: Story = { - args: { - selected: [MockUsers[0], MockUsers[5]], - onChange: () => undefined, - }, - parameters: { - queries: [ - { - key: usersKey({ limit: 25, q: "" }), - data: { - users: MockUsers, - count: MockUsers.length, - }, - }, - ], - }, -}; - -export const NoUsers: Story = { - args: { - selected: [], - onChange: () => undefined, - }, - parameters: { - queries: [ - { - key: usersKey({ limit: 25, q: "" }), - data: { - users: [], - count: 0, - }, - }, - ], - }, -}; - -const filteredUsers = MockUsers.filter((u) => - u.username.toLowerCase().includes("andrew"), -); - -export const FilterMatch: Story = { - args: { - filter: "andrew", - selected: [], - onChange: () => undefined, - }, - parameters: { - queries: [ - { - key: usersKey({ limit: 25, q: "andrew" }), - data: { - users: filteredUsers, - count: filteredUsers.length, - }, - }, - ], - }, -}; - -export const FilterNoMatch: Story = { - args: { - filter: "nonexistent", - selected: [], - onChange: () => undefined, - }, - parameters: { - queries: [ - { - key: usersKey({ limit: 25, q: "" }), - data: { - users: MockUsers, - count: MockUsers.length, - }, - }, - { - key: usersKey({ limit: 25, q: "nonexistent" }), - data: { - users: [], - count: 0, - }, - }, - ], - }, -}; diff --git a/site/src/components/MultiUserSelect/MultiUserSelect.tsx b/site/src/components/MultiUserSelect/MultiUserSelect.tsx deleted file mode 100644 index fa73ecec31e..00000000000 --- a/site/src/components/MultiUserSelect/MultiUserSelect.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { type FC, type ReactNode, useState } from "react"; -import { keepPreviousData, useQuery } from "react-query"; -import { organizationMembers } from "#/api/queries/organizations"; -import { users } from "#/api/queries/users"; -import type { - OrganizationMemberWithUserData, - ReducedUser, - User, -} from "#/api/typesGenerated"; -import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton"; -import { Checkbox } from "#/components/Checkbox/Checkbox"; -import { EmptyState } from "#/components/EmptyState/EmptyState"; -import { SearchField } from "#/components/SearchField/SearchField"; -import { - Table, - TableBody, - TableCell, - TableRow, -} from "#/components/Table/Table"; -import { - TableLoaderSkeleton, - TableRowSkeleton, -} from "#/components/TableLoader/TableLoader"; -import { useDebouncedFunction } from "#/hooks/debounce"; -import { useClickableTableRow } from "#/hooks/useClickableTableRow"; -import { cn } from "#/utils/cn"; -import { prepareQuery } from "#/utils/filters"; - -const DEBOUNCE_MS = 750; - -type SelectedUser = ReducedUser | OrganizationMemberWithUserData; - -type CommonMultiSelectProps = { - className?: string; - onChange: (user: T, checked: boolean) => void; - selected: T[]; - setFilter: (filter: string) => void; -}; - -type UserAutocompleteProps = CommonMultiSelectProps & { - filter: string; -}; - -export const MultiUserSelect: FC = ({ - filter, - setFilter, - ...props -}) => { - const usersQuery = useQuery({ - ...users({ - q: prepareQuery(encodeURI(filter ?? "")), - limit: 25, - }), - placeholderData: keepPreviousData, - }); - return ( - - error={usersQuery.error} - setFilter={setFilter} - users={usersQuery.data?.users} - {...props} - /> - ); -}; - -type MemberAutocompleteProps = - CommonMultiSelectProps & { - filter: string; - organizationId: string; - }; - -export const MultiMemberSelect: FC = ({ - filter, - organizationId, - setFilter, - ...props -}) => { - const membersQuery = useQuery({ - ...organizationMembers(organizationId, { - q: prepareQuery(encodeURI(filter ?? "")), - limit: 25, - }), - placeholderData: keepPreviousData, - }); - return ( - - error={membersQuery.error} - setFilter={setFilter} - users={membersQuery.data?.members} - {...props} - /> - ); -}; - -type InnerAutocompleteProps = - CommonMultiSelectProps & { - /** The error is null if not loaded or no error. */ - error: unknown; - setFilter: (filter: string) => void; - /** Users are undefined if not loaded or errored. */ - users: readonly T[] | undefined; - }; - -const InnerMultiSelect = ({ - className, - error, - onChange, - selected, - setFilter, - users, -}: InnerAutocompleteProps) => { - const [inputValue, setInputValue] = useState(""); - const { debounced, cancelDebounce } = useDebouncedFunction( - (nextFilter: string) => { - setFilter(nextFilter); - }, - DEBOUNCE_MS, - ); - - return ( -
- { - setInputValue(query); - debounced(query); - }} - onClear={() => { - cancelDebounce(); - setInputValue(""); - setFilter(""); - }} - placeholder="Search users..." - /> -
- - - - -
-
-
- ); -}; - -type UsersTable = { - error: unknown; - onChange: (user: T, checked: boolean) => void; - selected: readonly T[]; - users: readonly T[] | undefined; -}; - -const UsersTable = ({ - error, - onChange, - selected, - users, -}: UsersTable) => { - if (error) { - return ( - - - - - - ); - } - - if (!users) { - return ; - } - - if (users.length === 0) { - return ( - - - - - - ); - } - - return users.map((user) => { - const checked = selected.some((u) => userMatches(u, user)); - return ( - - -
- { - e.stopPropagation(); - }} - onCheckedChange={(checked) => { - onChange(user, Boolean(checked)); - }} - aria-label={`Select user ${user.username}`} - /> - -
-
-
- ); - }); -}; - -const TableLoader: FC = () => { - return ( - - - -
- - -
-
-
-
- ); -}; - -interface UserRowProps { - checked: boolean; - children?: ReactNode; - onChange: (user: T, checked: boolean) => void; - user: T; -} - -const UserRow = ({ - checked, - children, - onChange, - user, -}: UserRowProps) => { - const clickableProps = useClickableTableRow({ - onClick: () => onChange(user, !checked), - }); - return ( - - {children} - - ); -}; - -function userMatches(a: SelectedUser, b: SelectedUser) { - const aID = "user_id" in a ? a.user_id : a.id; - const bID = "user_id" in b ? b.user_id : b.id; - return aID && bID && aID === bID; -} diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index 9ed97b70d59..c6816ef4793 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -65,7 +65,7 @@ const GroupMembersPage: FC = () => { const addableMembersQuery = useQuery({ ...organizationMembers(organization, { q: prepareQuery(debouncedSearch), - limit: 50, + limit: 25, }), select: (data) => data.members.map((member) => ({ diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index 066b7f483b9..f0ce2c77fd7 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -10,7 +10,7 @@ import { getGroupQueryKey, groupPermissionsKey, } from "#/api/queries/groups"; -import { usersKey } from "#/api/queries/users"; +import { organizationMembersKey } from "#/api/queries/organizations"; import { MockDefaultOrganization, MockGroup, @@ -69,9 +69,9 @@ const permissionsQuery = (data: unknown, id?: string) => ({ data, }); -const usersQuery = (data: unknown) => ({ - key: usersKey({ - limit: 50, +const orgMembersQuery = (data: unknown) => ({ + key: organizationMembersKey(MockDefaultOrganization.name, { + limit: 25, q: "", }), data, @@ -166,7 +166,9 @@ export const MembersError: Story = { spyOn(API, "checkAuthorization").mockResolvedValue({ canUpdateGroup: true, }); - spyOn(API, "getUsers").mockRejectedValue(new Error("test members error")); + spyOn(API, "getOrganizationPaginatedMembers").mockRejectedValue( + new Error("test members error"), + ); }, parameters: { queries: [ @@ -190,7 +192,7 @@ export const NoMembers: Story = { groupQuery(MockGroupWithoutMembers), groupMembersQuery({ users: [], count: 0 }), permissionsQuery({ canUpdateGroup: true }), - usersQuery({ users: [], count: 0 }), + orgMembersQuery({ members: [], count: 0 }), ], }, play: async ({ canvasElement }) => { @@ -210,8 +212,8 @@ export const FiltersByMembers: Story = { count: MockGroup.members.length, }), permissionsQuery({ canUpdateGroup: true }), - usersQuery({ - users: [MockOrganizationMember, MockOrganizationMember2], + orgMembersQuery({ + members: [MockOrganizationMember, MockOrganizationMember2], count: 2, }), ], diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index 880d0013037..dc726f0c988 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -34,7 +34,6 @@ const OrganizationMembersPage: FC = () => { }; const { organization, organizationPermissions } = useOrganizationSettings(); const { entitlements } = useDashboard(); - const searchParamsResult = useSearchParams(); const showAISeatColumn = shouldShowAISeatColumn(entitlements); const [searchParams, setSearchParams] = useSearchParams(); @@ -44,7 +43,7 @@ const OrganizationMembersPage: FC = () => { ); const membersQuery = usePaginatedQuery( - paginatedOrganizationMembers(organizationName, searchParamsResult[0]), + paginatedOrganizationMembers(organizationName, searchParams), ); const useFilterResult = useFilter({ diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index 7f9e8e7bc23..8aa63dcab92 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -96,7 +96,7 @@ export const OrganizationMembersPageView: FC< const addableUsersQuery = useQuery({ ...users({ q: prepareQuery(debouncedSearch), - limit: 50, + limit: 25, }), select: (data) => data.users, enabled: canEditMembers, From 705629505deb6d085b73dc8e8130b49bb2af6adb Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 16 Apr 2026 12:34:18 +0000 Subject: [PATCH 13/13] fix(site): handle partial failures and address remaining review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use Promise.allSettled instead of Promise.all for batch member adds so partial failures don't leave the page in a broken state. When at least one add succeeds, always invalidate/refetch the member list. - Remove inner border on AddUsersPopover user list (box-in-box visual) - Add comments documenting pagination limitation for existingUserIds 🤖 Generated by Coder Agents --- site/src/modules/users/AddUsersPopover.tsx | 2 +- .../src/pages/GroupsPage/GroupMembersPage.tsx | 83 ++++++++++++------- .../OrganizationMembersPage.tsx | 58 ++++++++----- .../OrganizationMembersPageView.tsx | 3 + 4 files changed, 95 insertions(+), 51 deletions(-) diff --git a/site/src/modules/users/AddUsersPopover.tsx b/site/src/modules/users/AddUsersPopover.tsx index 3925a14c491..cbda2d06b87 100644 --- a/site/src/modules/users/AddUsersPopover.tsx +++ b/site/src/modules/users/AddUsersPopover.tsx @@ -112,7 +112,7 @@ export const AddUsersPopover: FC = ({ autoFocus />
{usersQuery.isFetching && !usersQuery.data ? ( diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index c6816ef4793..1dcbd6a74d6 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -85,44 +85,67 @@ const GroupMembersPage: FC = () => { {canUpdateGroup && groupData && !isEveryoneGroup(groupData) && ( m.id))} search={addUsersSearch} onSearchChange={setAddUsersSearch} usersQuery={addableMembersQuery} onSubmit={async (usersToAdd) => { - const addPromises = usersToAdd.map((user) => - addMemberMutation.mutateAsync({ - groupId: groupData.id, - userId: user.id, - }), + const toastId = toast.loading( + usersToAdd.length === 1 + ? `Adding "${usersToAdd[0].username}" to "${groupData.name}"...` + : `Adding ${usersToAdd.length} members to "${groupData.name}"...`, ); - const addAllPromise = Promise.all(addPromises); - toast.promise(addAllPromise, { - loading: - usersToAdd.length === 1 - ? `Adding "${usersToAdd[0].username}" to "${groupData.name}"...` - : `Adding ${usersToAdd.length} members to "${groupData.name}"...`, - success: - usersToAdd.length === 1 - ? `Added "${usersToAdd[0].username}" to "${groupData.name}" successfully.` - : `Added ${usersToAdd.length} members to "${groupData.name}" successfully.`, - error: (error) => ({ - message: getErrorMessage(error, "Failed to add members."), - description: getErrorDetail(error), - }), - }); - - await addAllPromise; - }} - onSuccess={async () => { - // Only invalidate the group-members list we are updating. - await queryClient.invalidateQueries({ - queryKey: groupMembersByOrganizationQueryKey( - organization, - groupData.name, + const results = await Promise.allSettled( + usersToAdd.map((user) => + addMemberMutation.mutateAsync({ + groupId: groupData.id, + userId: user.id, + }), ), - }); + ); + + const succeeded = results.filter((r) => r.status === "fulfilled"); + const failed = results.filter( + (r): r is PromiseRejectedResult => r.status === "rejected", + ); + + // Always refresh when at least one add succeeded + // so the member list stays in sync with the server. + if (succeeded.length > 0) { + await queryClient.invalidateQueries({ + queryKey: groupMembersByOrganizationQueryKey( + organization, + groupData.name, + ), + }); + } + + if (failed.length > 0) { + const msg = + succeeded.length > 0 + ? `Added ${succeeded.length} member(s), but ${failed.length} could not be added.` + : getErrorMessage( + failed[0].reason, + "Failed to add members.", + ); + toast.error(msg, { + id: toastId, + description: getErrorDetail(failed[0].reason), + }); + // Throw so the popover stays open for retry. + throw failed[0].reason; + } + + toast.success( + usersToAdd.length === 1 + ? `Added "${usersToAdd[0].username}" to "${groupData.name}" successfully.` + : `Added ${usersToAdd.length} members to "${groupData.name}" successfully.`, + { id: toastId }, + ); }} /> )} diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index dc726f0c988..cb7d1eacfa9 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -116,28 +116,46 @@ const OrganizationMembersPage: FC = () => { members={members} membersQuery={membersQuery} addMembers={async (usersToAdd: readonly AddableUser[]) => { - const addMutationPromises = usersToAdd.map((user) => - addMemberMutation.mutateAsync(user.id), + const toastId = toast.loading( + usersToAdd.length === 1 + ? `Adding "${usersToAdd[0].username}" to organization "${organization.display_name}"...` + : `Adding ${usersToAdd.length} members to organization "${organization.display_name}"...`, + ); + + const results = await Promise.allSettled( + usersToAdd.map((user) => addMemberMutation.mutateAsync(user.id)), ); - const addAllMembersPromise = Promise.all(addMutationPromises); - - toast.promise(addAllMembersPromise, { - loading: - usersToAdd.length === 1 - ? `Adding "${usersToAdd[0].username}" to organization "${organization.display_name}"...` - : `Adding ${usersToAdd.length} members to organization "${organization.display_name}"...`, - success: - usersToAdd.length === 1 - ? `Added "${usersToAdd[0].username}" to organization "${organization.display_name}".` - : `Added ${usersToAdd.length} members to organization "${organization.display_name}".`, - error: (error) => ({ - message: getErrorMessage(error, "Failed to add members."), - description: getErrorDetail(error), - }), - }); - await addAllMembersPromise; - void membersQuery.refetch(); + const succeeded = results.filter((r) => r.status === "fulfilled"); + const failed = results.filter( + (r): r is PromiseRejectedResult => r.status === "rejected", + ); + + // Always refresh when at least one add succeeded so + // the member list stays in sync with the server. + if (succeeded.length > 0) { + void membersQuery.refetch(); + } + + if (failed.length > 0) { + const msg = + succeeded.length > 0 + ? `Added ${succeeded.length} member(s), but ${failed.length} could not be added.` + : getErrorMessage(failed[0].reason, "Failed to add members."); + toast.error(msg, { + id: toastId, + description: getErrorDetail(failed[0].reason), + }); + // Throw so the popover stays open for retry. + throw failed[0].reason; + } + + toast.success( + usersToAdd.length === 1 + ? `Added "${usersToAdd[0].username}" to organization "${organization.display_name}".` + : `Added ${usersToAdd.length} members to organization "${organization.display_name}".`, + { id: toastId }, + ); }} removeMember={setMemberToDelete} updateMemberRoles={async ( diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index 8aa63dcab92..e3457cf1713 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -117,6 +117,9 @@ export const OrganizationMembersPageView: FC< m.user_id) ?? [])} search={addUsersSearch} onSearchChange={setAddUsersSearch}