diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 0c29d4b5e1d..2265e4b02bd 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; }; @@ -200,17 +205,10 @@ export const deleteGroup = (queryClient: QueryClient, organization: string) => { }; }; -export const addMembers = (queryClient: QueryClient, organization: string) => { +export const addMember = () => { return { - mutationFn: ({ - groupId, - userIds, - }: { - groupId: string; - userIds: string[]; - }) => API.addMembers(groupId, userIds), - onSuccess: async (updatedGroup: Group) => - invalidateGroup(queryClient, organization, updatedGroup.name), + mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => + API.addMember(groupId, userId), }; }; diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index e15f625ba8b..d5d25de46d2 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -8,7 +8,6 @@ import type { CreateOrganizationRequest, GroupSyncSettings, Organization, - PaginatedMembersRequest, PaginatedMembersResponse, RoleSyncSettings, UpdateOrganizationRequest, @@ -27,6 +26,7 @@ import { type WorkspacePermissions, workspacePermissionChecks, } from "#/modules/permissions/workspaces"; +import { prepareQuery } from "#/utils/filters"; import { meKey } from "./users"; import { cachedQuery } from "./util"; @@ -95,16 +95,14 @@ export const organizationMembers = (id: string, req: UsersRequest) => { 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/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/modules/users/AddUsersPopover.tsx b/site/src/modules/users/AddUsersPopover.tsx new file mode 100644 index 00000000000..cbda2d06b87 --- /dev/null +++ b/site/src/modules/users/AddUsersPopover.tsx @@ -0,0 +1,205 @@ +import { ChevronDown, UserPlusIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import type { UseQueryResult } from "react-query"; +import { getErrorMessage } from "#/api/errors"; +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, + PopoverTrigger, +} from "#/components/Popover/Popover"; +import { SearchField } from "#/components/SearchField/SearchField"; +import { Spinner } from "#/components/Spinner/Spinner"; + +export type AddableUser = Pick< + User, + "id" | "username" | "name" | "email" | "avatar_url" +>; + +type AddUsersPopoverProps = { + isLoading: boolean; + 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 = ({ + isLoading, + onSubmit, + onSuccess, + existingUserIds, + search, + onSearchChange, + usersQuery, +}) => { + const [open, setOpen] = useState(false); + const [selectedById, setSelectedById] = useState( + () => new Map(), + ); + + const addableUsers: AddableUser[] = + usersQuery.data?.filter((u) => !existingUserIds.has(u.id)) ?? []; + + const resetPanel = () => { + onSearchChange(""); + setSelectedById(new Map()); + }; + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + resetPanel(); + } + }; + + const toggleUser = (user: AddableUser) => { + 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 e42734c26b1..1dcbd6a74d6 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -1,20 +1,25 @@ import type { Interpolation, Theme } from "@emotion/react"; -import { EllipsisVertical, UserPlusIcon } from "lucide-react"; +import { EllipsisVertical } from "lucide-react"; import { type FC, useState } from "react"; -import { useMutation, useQueryClient } from "react-query"; +import { + keepPreviousData, + useMutation, + useQuery, + 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 { organizationMembers } from "#/api/queries/organizations"; +import type { Group, ReducedUser } from "#/api/typesGenerated"; 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,7 +29,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, @@ -34,7 +38,10 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; +import { useDebouncedValue } from "#/hooks/debounce"; import { isEveryoneGroup } from "#/modules/groups"; +import { AddUsersPopover } from "#/modules/users/AddUsersPopover"; +import { prepareQuery } from "#/utils/filters"; import type { GroupPageOutletContext } from "./GroupPage"; const GroupMembersPage: FC = () => { @@ -47,11 +54,28 @@ const GroupMembersPage: FC = () => { filterProps, } = useOutletContext(); const queryClient = useQueryClient(); - const addMembersMutation = useMutation(addMembers(queryClient, organization)); + const addMemberMutation = useMutation(addMember()); const removeMemberMutation = useMutation( 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: 25, + }), + select: (data) => + data.members.map((member) => ({ + ...member, + id: member.user_id, + })), + enabled: + canUpdateGroup && Boolean(groupData) && !isEveryoneGroup(groupData), + placeholderData: keepPreviousData, + }); return (
@@ -59,13 +83,69 @@ const GroupMembersPage: FC = () => { {canUpdateGroup && groupData && !isEveryoneGroup(groupData) && ( - { - await addMembersMutation.mutateAsync({ - groupId: groupData.id, - userIds: users.map((u) => u.user_id), - }); + m.id))} + search={addUsersSearch} + onSearchChange={setAddUsersSearch} + usersQuery={addableMembersQuery} + onSubmit={async (usersToAdd) => { + const toastId = toast.loading( + usersToAdd.length === 1 + ? `Adding "${usersToAdd[0].username}" to "${groupData.name}"...` + : `Adding ${usersToAdd.length} members to "${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 }, + ); }} /> )} @@ -119,68 +199,6 @@ const GroupMembersPage: FC = () => { ); }; -interface AddUsersDialogProps { - onSubmit: (users: OrganizationMemberWithUserData[]) => Promise; - organizationId: string; -} - -const AddUsersDialog: FC = ({ - onSubmit, - organizationId, -}) => { - const [addUserDialogOpen, setAddUserDialogOpen] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [filter, setFilter] = useState(""); - const [selected, setSelected] = useState( - [], - ); - return ( - <> - - { - if (checked) { - setSelected([...selected, user]); - } else { - setSelected(selected.filter((s) => s.user_id !== user.user_id)); - } - }} - selected={selected} - /> - } - 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); - } - }} - /> - - ); -}; - interface GroupMemberRowProps { member: ReducedUser; group: Group; diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index c44a5a0baec..f0ce2c77fd7 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -69,8 +69,8 @@ const permissionsQuery = (data: unknown, id?: string) => ({ data, }); -const membersQuery = (data: unknown) => ({ - key: organizationMembersKey(MockDefaultOrganization.id, { +const orgMembersQuery = (data: unknown) => ({ + key: organizationMembersKey(MockDefaultOrganization.name, { limit: 25, q: "", }), @@ -181,7 +181,7 @@ export const MembersError: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click( - await canvas.findByRole("button", { name: "Add users" }), + await canvas.findByRole("button", { name: /Add users/i }), ); }, }; @@ -192,13 +192,13 @@ export const NoMembers: Story = { groupQuery(MockGroupWithoutMembers), groupMembersQuery({ users: [], count: 0 }), permissionsQuery({ canUpdateGroup: true }), - membersQuery({ members: [] }), + orgMembersQuery({ members: [], count: 0 }), ], }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click( - await canvas.findByRole("button", { name: "Add users" }), + await canvas.findByRole("button", { name: /Add users/i }), ); }, }; @@ -212,15 +212,16 @@ export const FiltersByMembers: Story = { count: MockGroup.members.length, }), permissionsQuery({ canUpdateGroup: true }), - membersQuery({ + orgMembersQuery({ members: [MockOrganizationMember, MockOrganizationMember2], + count: 2, }), ], }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click( - await canvas.findByRole("button", { name: "Add users" }), + await canvas.findByRole("button", { name: /Add users/i }), ); }, }; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index a82da422000..cb7d1eacfa9 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -2,7 +2,7 @@ import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; -import { getErrorMessage } from "#/api/errors"; +import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { groupsByUserIdInOrganization } from "#/api/queries/groups"; import { addOrganizationMember, @@ -11,12 +11,10 @@ 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"; import { Stack } from "#/components/Stack/Stack"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { usePaginatedQuery } from "#/hooks/usePaginatedQuery"; @@ -24,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"; @@ -35,8 +34,8 @@ const OrganizationMembersPage: FC = () => { }; const { organization, organizationPermissions } = useOrganizationSettings(); const { entitlements } = useDashboard(); - const searchParamsResult = useSearchParams(); const showAISeatColumn = shouldShowAISeatColumn(entitlements); + const [searchParams, setSearchParams] = useSearchParams(); const organizationRolesQuery = useQuery(organizationRoles(organizationName)); const groupsByUserIdQuery = useQuery( @@ -44,9 +43,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) ?? []; @@ -93,6 +98,9 @@ const OrganizationMembersPage: FC = () => { allAvailableRoles={organizationRolesQuery.data} canEditMembers={organizationPermissions.editMembers} canViewMembers={organizationPermissions.viewMembers} + filterProps={{ + filter: useFilterResult, + }} error={ membersQuery.error ?? organizationRolesQuery.error ?? @@ -107,9 +115,47 @@ const OrganizationMembersPage: FC = () => { me={me} members={members} membersQuery={membersQuery} - addMember={async (user: User) => { - await addMemberMutation.mutateAsync(user.id); - void membersQuery.refetch(); + addMembers={async (usersToAdd: readonly AddableUser[]) => { + 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 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.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx index b863feee269..95a48cc2fbe 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ComponentProps } from "react"; import { expect, within } from "storybook/test"; +import { getDefaultFilterProps } from "#/components/Filter/storyHelpers"; import { mockSuccessResult } from "#/components/PaginationWidget/PaginationContainer.mocks"; import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery"; import { @@ -11,10 +13,22 @@ import { } from "#/testHelpers/entities"; import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; +type FilterProps = ComponentProps< + typeof OrganizationMembersPageView +>["filterProps"]; + +const defaultFilterProps = getDefaultFilterProps({ + values: { + status: "active", + }, + menus: {}, +}); + const meta: Meta = { title: "pages/OrganizationMembersPageView", component: OrganizationMembersPageView, args: { + filterProps: defaultFilterProps, canEditMembers: true, error: undefined, isAddingMember: false, @@ -33,7 +47,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 07634fd26e3..e3457cf1713 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -1,7 +1,8 @@ -import { EllipsisVertical, TriangleAlert, UserPlusIcon } from "lucide-react"; -import { type FC, useState } from "react"; +import { EllipsisVertical, TriangleAlert } from "lucide-react"; +import { type ComponentProps, type FC, useState } from "react"; +import { useQuery } from "react-query"; import { toast } from "sonner"; -import { getErrorDetail, getErrorMessage } from "#/api/errors"; +import { users } from "#/api/queries/users"; import type { Group, OrganizationMemberWithUserData, @@ -18,13 +19,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, @@ -34,10 +35,15 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; -import { UserAutocomplete } from "#/components/UserAutocomplete/UserAutocomplete"; +import { useDebouncedValue } from "#/hooks/debounce"; import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery"; +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"; @@ -45,6 +51,7 @@ interface OrganizationMembersPageViewProps { allAvailableRoles: readonly SlimRole[] | undefined; canEditMembers: boolean; canViewMembers: boolean; + filterProps: ComponentProps; error: unknown; isAddingMember: boolean; isUpdatingMemberRoles: boolean; @@ -54,7 +61,7 @@ interface OrganizationMembersPageViewProps { membersQuery: PaginationResultInfo & { isPlaceholderData: boolean; }; - addMember: (user: User) => Promise; + addMembers: (users: readonly AddableUser[]) => Promise; removeMember: (member: OrganizationMemberWithUserData) => void; updateMemberRoles: ( member: OrganizationMemberWithUserData, @@ -72,6 +79,7 @@ export const OrganizationMembersPageView: FC< allAvailableRoles, canEditMembers, canViewMembers, + filterProps, error, isAddingMember, isUpdatingMemberRoles, @@ -79,10 +87,21 @@ export const OrganizationMembersPageView: FC< me, membersQuery, members, - addMember, + addMembers, removeMember, updateMemberRoles, }) => { + const [addUsersSearch, setAddUsersSearch] = useState(""); + const debouncedSearch = useDebouncedValue(addUsersSearch, 400); + const addableUsersQuery = useQuery({ + ...users({ + q: prepareQuery(debouncedSearch), + limit: 25, + }), + select: (data) => data.users, + enabled: canEditMembers, + }); + return (
@@ -92,12 +111,22 @@ export const OrganizationMembersPageView: FC<
{Boolean(error) && } - {canEditMembers && ( - - )} +
+ + {canEditMembers && ( + m.user_id) ?? [])} + search={addUsersSearch} + onSearchChange={setAddUsersSearch} + usersQuery={addableUsersQuery} + /> + )} +
{!canViewMembers && (
@@ -216,63 +245,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); - }} - /> - - - -
- ); -};