From ea4070c0ce8b249443703f340bb83acedb2d2e57 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 26 Mar 2026 10:42:04 -0800 Subject: [PATCH] feat: add multi-user dialog select for adding group members (#23396) Instead of the single-user dropdown we had before. --- site/src/api/api.ts | 13 +- site/src/api/queries/groups.ts | 11 +- site/src/api/queries/organizations.ts | 16 +- .../MultiMemberSelect.stories.tsx | 32 +++ .../MultiUserSelect.stories.tsx | 129 +++++++++ .../MultiUserSelect/MultiUserSelect.tsx | 270 ++++++++++++++++++ .../UserAutocomplete/UserAutocomplete.tsx | 2 +- .../UserOrGroupAutocomplete.tsx | 2 +- .../src/pages/GroupsPage/GroupMembersPage.tsx | 122 ++++---- .../pages/GroupsPage/GroupPage.stories.tsx | 11 +- 10 files changed, 528 insertions(+), 80 deletions(-) create mode 100644 site/src/components/MultiUserSelect/MultiMemberSelect.stories.tsx create mode 100644 site/src/components/MultiUserSelect/MultiUserSelect.stories.tsx create mode 100644 site/src/components/MultiUserSelect/MultiUserSelect.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 609ceb1d5e..456cc45228 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -732,7 +732,7 @@ class ApiMethods { */ getOrganizationPaginatedMembers = async ( organization: string, - options?: TypesGen.Pagination, + options?: TypesGen.UsersRequest, ) => { const url = getURLWithSearchParams( `/api/v2/organizations/${organization}/paginated-members`, @@ -2181,6 +2181,17 @@ class ApiMethods { return response.data; }; + addMembers = async (groupId: string, userIds: string[]) => { + return this.patchGroup(groupId, { + name: "", + add_users: userIds, + remove_users: [], + display_name: null, + avatar_url: null, + quota_allowance: null, + }); + }; + addMember = async (groupId: string, userId: string) => { return this.patchGroup(groupId, { name: "", diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index a6fbb93cde..f278da03ee 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -200,10 +200,15 @@ export const deleteGroup = (queryClient: QueryClient, organization: string) => { }; }; -export const addMember = (queryClient: QueryClient, organization: string) => { +export const addMembers = (queryClient: QueryClient, organization: string) => { return { - mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => - API.addMember(groupId, userId), + mutationFn: ({ + groupId, + userIds, + }: { + groupId: string; + userIds: string[]; + }) => API.addMembers(groupId, userIds), onSuccess: async (updatedGroup: Group) => invalidateGroup(queryClient, organization, updatedGroup.name), }; diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index d4959da468..8aab000852 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -25,6 +25,7 @@ import type { RoleSyncSettings, UpdateOrganizationRequest, UpdateWorkspaceSharingSettingsRequest, + UsersRequest, } from "#/api/typesGenerated"; import { meKey } from "./users"; import { cachedQuery } from "./util"; @@ -69,28 +70,25 @@ export const deleteOrganization = (queryClient: QueryClient) => { }; }; -export const organizationMembersKey = (id: string) => [ +export const organizationMembersKey = (id: string, req: UsersRequest) => [ "organization", id, "members", + req, ]; /** * Creates a query configuration to fetch all members of an organization. * - * Unlike the paginated version, this function sets the `limit` parameter to 0, - * which instructs the API to return all organization members in a single request - * without pagination. - * * @param id - The unique identifier of the organization * @returns A query configuration object for use with React Query * * @see paginatedOrganizationMembers - For fetching members with pagination support */ -export const organizationMembers = (id: string) => { +export const organizationMembers = (id: string, req: UsersRequest) => { return { - queryFn: () => API.getOrganizationPaginatedMembers(id, { limit: 0 }), - queryKey: organizationMembersKey(id), + queryFn: () => API.getOrganizationPaginatedMembers(id, req), + queryKey: organizationMembersKey(id, req), }; }; @@ -109,7 +107,7 @@ export const paginatedOrganizationMembers = ( offset: offset, }; }, - queryKey: ({ payload }) => [...organizationMembersKey(id), payload], + queryKey: ({ payload }) => organizationMembersKey(id, payload), queryFn: ({ payload }) => API.getOrganizationPaginatedMembers(id, payload), }; }; diff --git a/site/src/components/MultiUserSelect/MultiMemberSelect.stories.tsx b/site/src/components/MultiUserSelect/MultiMemberSelect.stories.tsx new file mode 100644 index 0000000000..6db831c3c2 --- /dev/null +++ b/site/src/components/MultiUserSelect/MultiMemberSelect.stories.tsx @@ -0,0 +1,32 @@ +import { MockOrganizationMember } from "testHelpers/entities"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { organizationMembersKey } from "api/queries/organizations"; +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 new file mode 100644 index 0000000000..a6e4d97c0f --- /dev/null +++ b/site/src/components/MultiUserSelect/MultiUserSelect.stories.tsx @@ -0,0 +1,129 @@ +import { mockApiError } from "testHelpers/entities"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { API } from "api/api"; +import { usersKey } from "api/queries/users"; +import { MockUsers } from "pages/UsersPage/storybookData/users"; +import { spyOn } from "storybook/test"; +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 new file mode 100644 index 0000000000..7dc7b87c45 --- /dev/null +++ b/site/src/components/MultiUserSelect/MultiUserSelect.tsx @@ -0,0 +1,270 @@ +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 { type FC, type ReactNode, useState } from "react"; +import { keepPreviousData, useQuery } from "react-query"; +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/components/UserAutocomplete/UserAutocomplete.tsx b/site/src/components/UserAutocomplete/UserAutocomplete.tsx index e990acf13b..a641277e28 100644 --- a/site/src/components/UserAutocomplete/UserAutocomplete.tsx +++ b/site/src/components/UserAutocomplete/UserAutocomplete.tsx @@ -78,7 +78,7 @@ export const MemberAutocomplete: FC = ({ const [filter, setFilter] = useState(); const membersQuery = useQuery({ - ...organizationMembers(organizationId), + ...organizationMembers(organizationId, { limit: 0 }), enabled: filter !== undefined, placeholderData: keepPreviousData, }); diff --git a/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx b/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx index c8db04e127..a0641c5a96 100644 --- a/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx +++ b/site/src/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete.tsx @@ -51,7 +51,7 @@ export const UserOrGroupAutocomplete: FC = ({ // This allows regular org members to see other members in their org // for workspace sharing, without needing site-wide user:read permission. const membersQuery = useQuery({ - ...organizationMembers(organizationId), + ...organizationMembers(organizationId, { limit: 0 }), enabled: open, placeholderData: keepPreviousData, }); diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index ef4b37dbaf..b4c87e0215 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -6,7 +6,7 @@ import { useMutation, useQueryClient } from "react-query"; import { useOutletContext } from "react-router"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; -import { addMember, removeMember } from "#/api/queries/groups"; +import { addMembers, removeMember } from "#/api/queries/groups"; import type { Group, OrganizationMemberWithUserData, @@ -15,6 +15,7 @@ import type { 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, @@ -24,9 +25,8 @@ 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 { Spinner } from "#/components/Spinner/Spinner"; -import { Stack } from "#/components/Stack/Stack"; import { Table, TableBody, @@ -35,7 +35,6 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; -import { MemberAutocomplete } from "#/components/UserAutocomplete/UserAutocomplete"; import type { GroupPageOutletContext } from "./GroupPage"; const GroupMembersPage: FC = () => { @@ -48,12 +47,11 @@ const GroupMembersPage: FC = () => { filterProps, } = useOutletContext(); const queryClient = useQueryClient(); - const addMemberMutation = useMutation(addMember(queryClient, organization)); + const addMembersMutation = useMutation(addMembers(queryClient, organization)); const removeMemberMutation = useMutation( removeMember(queryClient, organization), ); const canUpdateGroup = permissions ? permissions.canUpdateGroup : false; - const groupId = groupData.id; return (
@@ -61,21 +59,13 @@ 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."), { - description: getErrorDetail(error), - }); - } + onSubmit={async (users) => { + await addMembersMutation.mutateAsync({ + groupId: groupData.id, + userIds: users.map((u) => u.user_id), + }); }} /> )} @@ -129,52 +119,65 @@ const GroupMembersPage: FC = () => { ); }; -interface AddGroupMemberProps { - isLoading: boolean; - onSubmit: (user: OrganizationMemberWithUserData, reset: () => void) => void; +interface AddUsersDialogProps { + onSubmit: (users: OrganizationMemberWithUserData[]) => Promise; organizationId: string; } -const AddGroupMember: FC = ({ - isLoading, +const AddUsersDialog: FC = ({ onSubmit, organizationId, }) => { - const [selectedUser, setSelectedUser] = - useState(null); - - const resetValues = () => { - setSelectedUser(null); - }; - + const [addUserDialogOpen, setAddUserDialogOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [filter, setFilter] = useState(""); + const [selected, setSelected] = useState( + [], + ); return ( -
{ - e.preventDefault(); - - if (selectedUser) { - onSubmit(selectedUser, resetValues); + <> + + { + if (checked) { + setSelected([...selected, user]); + } else { + setSelected(selected.filter((s) => s.user_id !== user.user_id)); + } + }} + selected={selected} + /> } - }} - > - - { - setSelectedUser(newValue); - }} - /> - - - - + hideCancel={false} + cancelText="Cancel" + confirmText="Add users" + onClose={() => setAddUserDialogOpen(false)} + onConfirm={async () => { + try { + setSubmitting(true); + await onSubmit(selected); + setAddUserDialogOpen(false); + } catch (error) { + toast.error(getErrorMessage(error, "Failed to add members."), { + description: getErrorDetail(error), + }); + } finally { + setSubmitting(false); + } + }} + /> + ); }; @@ -241,9 +244,6 @@ const GroupMemberRow: FC = ({ }; const styles = { - autoComplete: { - width: 300, - }, status: { textTransform: "capitalize", }, diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index e7054fb7ec..5b60a4baf0 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -70,7 +70,10 @@ const permissionsQuery = (data: unknown, id?: string) => ({ }); const membersQuery = (data: unknown) => ({ - key: organizationMembersKey(MockDefaultOrganization.id), + key: organizationMembersKey(MockDefaultOrganization.id, { + limit: 25, + q: "", + }), data, }); @@ -178,7 +181,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" }), ); }, }; @@ -195,7 +198,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" }), ); }, }; @@ -217,7 +220,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" }), ); }, };