feat: add multi-user dialog select for adding group members (#23396)

Instead of the single-user dropdown we had before.
This commit is contained in:
Asher
2026-03-26 10:42:04 -08:00
committed by GitHub
parent 1b2fab8306
commit ea4070c0ce
10 changed files with 528 additions and 80 deletions
+12 -1
View File
@@ -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: "",
+8 -3
View File
@@ -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),
};
+7 -9
View File
@@ -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),
};
};
@@ -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<typeof MultiMemberSelect> = {
title: "components/MultiMemberSelect",
component: MultiMemberSelect,
};
export default meta;
type Story = StoryObj<typeof MultiMemberSelect>;
export const Loading: Story = {
args: {
organizationId: MockOrganizationMember.organization_id,
},
parameters: {
queries: [
{
key: organizationMembersKey(MockOrganizationMember.organization_id, {
limit: 25,
q: "",
}),
data: {
users: undefined,
count: 0,
},
},
],
},
};
@@ -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<typeof MultiUserSelect> = {
title: "components/MultiUserSelect",
component: MultiUserSelect,
};
export default meta;
type Story = StoryObj<typeof MultiUserSelect>;
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,
},
},
],
},
};
@@ -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<T extends SelectedUser> = {
className?: string;
onChange: (user: T, checked: boolean) => void;
selected: T[];
setFilter: (filter: string) => void;
};
type UserAutocompleteProps = CommonMultiSelectProps<User> & {
filter: string;
};
export const MultiUserSelect: FC<UserAutocompleteProps> = ({
filter,
setFilter,
...props
}) => {
const usersQuery = useQuery({
...users({
q: prepareQuery(encodeURI(filter ?? "")),
limit: 25,
}),
placeholderData: keepPreviousData,
});
return (
<InnerMultiSelect<User>
error={usersQuery.error}
setFilter={setFilter}
users={usersQuery.data?.users}
{...props}
/>
);
};
type MemberAutocompleteProps =
CommonMultiSelectProps<OrganizationMemberWithUserData> & {
filter: string;
organizationId: string;
};
export const MultiMemberSelect: FC<MemberAutocompleteProps> = ({
filter,
organizationId,
setFilter,
...props
}) => {
const membersQuery = useQuery({
...organizationMembers(organizationId, {
q: prepareQuery(encodeURI(filter ?? "")),
limit: 25,
}),
placeholderData: keepPreviousData,
});
return (
<InnerMultiSelect<OrganizationMemberWithUserData>
error={membersQuery.error}
setFilter={setFilter}
users={membersQuery.data?.members}
{...props}
/>
);
};
type InnerAutocompleteProps<T extends SelectedUser> =
CommonMultiSelectProps<T> & {
/** 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 = <T extends SelectedUser>({
className,
error,
onChange,
selected,
setFilter,
users,
}: InnerAutocompleteProps<T>) => {
const [inputValue, setInputValue] = useState("");
const { debounced, cancelDebounce } = useDebouncedFunction(
(nextFilter: string) => {
setFilter(nextFilter);
},
DEBOUNCE_MS,
);
return (
<div className={cn("flex flex-col gap-2", className)}>
<SearchField
className="w-full"
value={inputValue}
aria-label="Search users"
onChange={(query) => {
setInputValue(query);
debounced(query);
}}
onClear={() => {
cancelDebounce();
setInputValue("");
setFilter("");
}}
placeholder="Search users..."
/>
<div className="max-h-[360px] overflow-auto">
<Table>
<TableBody className="[&_td]:h-[72px]">
<UsersTable
error={error}
onChange={onChange}
selected={selected}
users={users}
/>
</TableBody>
</Table>
</div>
</div>
);
};
type UsersTable<T extends SelectedUser> = {
error: unknown;
onChange: (user: T, checked: boolean) => void;
selected: readonly T[];
users: readonly T[] | undefined;
};
const UsersTable = <T extends SelectedUser>({
error,
onChange,
selected,
users,
}: UsersTable<T>) => {
if (error) {
return (
<TableRow>
<TableCell colSpan={999}>
<ErrorAlert error={error} />
</TableCell>
</TableRow>
);
}
if (!users) {
return <TableLoader />;
}
if (users.length === 0) {
return (
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No users found" isCompact />
</TableCell>
</TableRow>
);
}
return users.map((user) => {
const checked = selected.some((u) => userMatches(u, user));
return (
<UserRow
key={user.username}
user={user}
checked={checked}
onChange={onChange}
>
<TableCell className="border-0">
<div className="flex items-center gap-5">
<Checkbox
data-testid={`checkbox-${user.username}`}
checked={checked}
onClick={(e) => {
e.stopPropagation();
}}
onCheckedChange={(checked) => {
onChange(user, Boolean(checked));
}}
aria-label={`Select user ${user.username}`}
/>
<AvatarData
title={user.username}
subtitle={user.email}
src={user.avatar_url}
/>
</div>
</TableCell>
</UserRow>
);
});
};
const TableLoader: FC = () => {
return (
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell className="w-2/6">
<div className="flex items-center gap-5">
<Checkbox disabled />
<AvatarDataSkeleton />
</div>
</TableCell>
</TableRowSkeleton>
</TableLoaderSkeleton>
);
};
interface UserRowProps<T extends SelectedUser> {
checked: boolean;
children?: ReactNode;
onChange: (user: T, checked: boolean) => void;
user: T;
}
const UserRow = <T extends SelectedUser>({
checked,
children,
onChange,
user,
}: UserRowProps<T>) => {
const clickableProps = useClickableTableRow({
onClick: () => onChange(user, !checked),
});
return (
<TableRow
{...clickableProps}
data-testid={`user-${user.username}`}
className={cn([
checked ? "bg-muted hover:bg-muted" : undefined,
clickableProps.className,
])}
>
{children}
</TableRow>
);
};
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;
}
@@ -78,7 +78,7 @@ export const MemberAutocomplete: FC<MemberAutocompleteProps> = ({
const [filter, setFilter] = useState<string>();
const membersQuery = useQuery({
...organizationMembers(organizationId),
...organizationMembers(organizationId, { limit: 0 }),
enabled: filter !== undefined,
placeholderData: keepPreviousData,
});
@@ -51,7 +51,7 @@ export const UserOrGroupAutocomplete: FC<UserOrGroupAutocompleteProps> = ({
// 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,
});
+61 -61
View File
@@ -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<GroupPageOutletContext>();
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 (
<div className="flex flex-col w-full gap-1 pb-8">
@@ -61,21 +59,13 @@ const GroupMembersPage: FC = () => {
<UsersFilter {...filterProps} />
{canUpdateGroup && groupData && !isEveryoneGroup(groupData) && (
<AddGroupMember
isLoading={addMemberMutation.isPending}
<AddUsersDialog
organizationId={groupData.organization_id}
onSubmit={async (member, reset) => {
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<void>;
organizationId: string;
}
const AddGroupMember: FC<AddGroupMemberProps> = ({
isLoading,
const AddUsersDialog: FC<AddUsersDialogProps> = ({
onSubmit,
organizationId,
}) => {
const [selectedUser, setSelectedUser] =
useState<OrganizationMemberWithUserData | null>(null);
const resetValues = () => {
setSelectedUser(null);
};
const [addUserDialogOpen, setAddUserDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [filter, setFilter] = useState("");
const [selected, setSelected] = useState<OrganizationMemberWithUserData[]>(
[],
);
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (selectedUser) {
onSubmit(selectedUser, resetValues);
<>
<Button size="lg" onClick={() => setAddUserDialogOpen(true)}>
<UserPlusIcon />
Add users
</Button>
<ConfirmDialog
open={addUserDialogOpen}
title="Add users"
disabled={submitting}
description={
<MultiMemberSelect
organizationId={organizationId}
filter={filter}
setFilter={setFilter}
onChange={(user, checked) => {
if (checked) {
setSelected([...selected, user]);
} else {
setSelected(selected.filter((s) => s.user_id !== user.user_id));
}
}}
selected={selected}
/>
}
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<MemberAutocomplete
css={styles.autoComplete}
value={selectedUser}
organizationId={organizationId}
onChange={(newValue) => {
setSelectedUser(newValue);
}}
/>
<Button disabled={!selectedUser || isLoading} type="submit">
<Spinner loading={isLoading}>
<UserPlusIcon className="size-icon-sm" />
</Spinner>
Add user
</Button>
</Stack>
</form>
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<GroupMemberRowProps> = ({
};
const styles = {
autoComplete: {
width: 300,
},
status: {
textTransform: "capitalize",
},
@@ -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" }),
);
},
};