mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
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:
+12
-1
@@ -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: "",
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user