From e186dc3222b789840796aada1073e8ea1636eb79 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Tue, 21 Apr 2026 13:43:20 +1000 Subject: [PATCH] chore(site): replace inline add member form with dialog on `` (#24429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was written by Coder Agent on behalf of Jake Howell Replace the single-select inline UserAutocomplete form with a multi-select Dialog (matching the GroupMembersPage pattern from #24287). Changes: - Replace AddOrganizationMember inline form with AddUsersDialog using MultiUserSelect for multi-user selection in a modal - Batch-add multiple users via Promise.all in the page callback - Remove isAddingMember prop (dialog manages its own loading state) - Update stories to match new interface --- site/src/api/queries/organizations.ts | 12 +- .../OrganizationMembersPage.tsx | 17 +- .../OrganizationMembersPageView.stories.tsx | 19 ++- .../OrganizationMembersPageView.tsx | 156 +++++++++++------- 4 files changed, 123 insertions(+), 81 deletions(-) diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index e1076628b6..1dcaac3659 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -9,7 +9,6 @@ import type { CreateOrganizationRequest, GroupSyncSettings, Organization, - PaginatedMembersRequest, PaginatedMembersResponse, RoleSyncSettings, UpdateOrganizationRequest, @@ -28,6 +27,7 @@ import { type WorkspacePermissions, workspacePermissionChecks, } from "#/modules/permissions/workspaces"; +import { prepareQuery } from "#/utils/filters"; import { meKey } from "./users"; import { cachedQuery } from "./util"; @@ -96,16 +96,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, + limit, + offset, + q: prepareQuery(searchParams.get("filter") ?? ""), }; }, queryKey: ({ payload }) => organizationMembersKey(id, payload), diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index a82da42200..7e148675cd 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -17,6 +17,7 @@ import type { } 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"; @@ -46,6 +47,11 @@ const OrganizationMembersPage: FC = () => { const membersQuery = usePaginatedQuery( paginatedOrganizationMembers(organizationName, searchParamsResult[0]), ); + const filterProps = useFilter({ + searchParams: searchParamsResult[0], + onSearchParamsChange: searchParamsResult[1], + onUpdate: membersQuery.goToFirstPage, + }); const members = membersQuery.data?.members.map( (member: OrganizationMemberWithUserData) => { @@ -93,6 +99,7 @@ const OrganizationMembersPage: FC = () => { allAvailableRoles={organizationRolesQuery.data} canEditMembers={organizationPermissions.editMembers} canViewMembers={organizationPermissions.viewMembers} + filterProps={{ filter: filterProps }} error={ membersQuery.error ?? organizationRolesQuery.error ?? @@ -101,14 +108,18 @@ const OrganizationMembersPage: FC = () => { removeMemberMutation.error ?? updateMemberRolesMutation.error } - isAddingMember={addMemberMutation.isPending} isUpdatingMemberRoles={updateMemberRolesMutation.isPending} showAISeatColumn={showAISeatColumn} me={me} members={members} membersQuery={membersQuery} - addMember={async (user: User) => { - await addMemberMutation.mutateAsync(user.id); + addMembers={async (users: User[]) => { + // TODO: Replace with a batch endpoint (POST /organizations/{org}/members) + // to add all users in a single request instead of N individual calls. + // See branch jakehwll/devex-112-organizations-batch-endpoint. + await Promise.all( + users.map((user) => addMemberMutation.mutateAsync(user.id)), + ); void membersQuery.refetch(); }} removeMember={setMemberToDelete} diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx index b863feee26..2c9e85b496 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx @@ -17,7 +17,16 @@ const meta: Meta = { args: { canEditMembers: true, error: undefined, - isAddingMember: false, + filterProps: { + filter: { + query: "", + values: {}, + update: () => {}, + debounceUpdate: () => {}, + cancelDebounce: () => {}, + used: false, + }, + }, isUpdatingMemberRoles: false, canViewMembers: true, me: MockUserOwner, @@ -33,7 +42,7 @@ const meta: Meta = { ...mockSuccessResult, totalRecords: 2, } as UsePaginatedQueryResult, - addMember: () => Promise.resolve(), + addMembers: () => Promise.resolve(), removeMember: () => Promise.resolve(), updateMemberRoles: () => Promise.resolve(), }, @@ -90,12 +99,6 @@ export const NoEdit: Story = { }, }; -export const AddingMember: Story = { - args: { - isAddingMember: true, - }, -}; - export const UpdatingMember: Story = { args: { isUpdatingMemberRoles: true, diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index 07634fd26e..cabbda7979 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -12,13 +12,22 @@ import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogTitle, +} from "#/components/Dialog/Dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; +import type { useFilter } from "#/components/Filter/Filter"; +import { UsersFilter } from "#/components/Filter/UsersFilter"; import { Loader } from "#/components/Loader/Loader"; +import { MultiUserSelect } from "#/components/MultiUserSelect/MultiUserSelect"; import { PaginationContainer } from "#/components/PaginationWidget/PaginationContainer"; import { SettingsHeader, @@ -34,7 +43,6 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; -import { UserAutocomplete } from "#/components/UserAutocomplete/UserAutocomplete"; import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery"; import { AISeatCell } from "#/modules/users/AISeatCell"; import { UserGroupsCell } from "#/pages/UsersPage/UsersTable/UserGroupsCell"; @@ -46,7 +54,7 @@ interface OrganizationMembersPageViewProps { canEditMembers: boolean; canViewMembers: boolean; error: unknown; - isAddingMember: boolean; + filterProps: { filter: ReturnType }; isUpdatingMemberRoles: boolean; showAISeatColumn?: boolean; me: User; @@ -54,7 +62,7 @@ interface OrganizationMembersPageViewProps { membersQuery: PaginationResultInfo & { isPlaceholderData: boolean; }; - addMember: (user: User) => Promise; + addMembers: (users: User[]) => Promise; removeMember: (member: OrganizationMemberWithUserData) => void; updateMemberRoles: ( member: OrganizationMemberWithUserData, @@ -73,13 +81,13 @@ export const OrganizationMembersPageView: FC< canEditMembers, canViewMembers, error, - isAddingMember, + filterProps, isUpdatingMemberRoles, showAISeatColumn, me, membersQuery, members, - addMember, + addMembers, removeMember, updateMemberRoles, }) => { @@ -92,13 +100,10 @@ export const OrganizationMembersPageView: FC<
{Boolean(error) && } - {canEditMembers && ( - - )} - +
+ + {canEditMembers && } +
{!canViewMembers && (
@@ -217,62 +222,87 @@ export const OrganizationMembersPageView: FC< ); }; -interface AddOrganizationMemberProps { - isLoading: boolean; - onSubmit: (user: User) => Promise; +interface AddUsersDialogProps { + onSubmit: (users: User[]) => Promise; } -const AddOrganizationMember: FC = ({ - isLoading, - onSubmit, -}) => { - const [selectedUser, setSelectedUser] = useState(null); +const AddUsersDialog: FC = ({ onSubmit }) => { + const [addUserDialogOpen, setAddUserDialogOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [filter, setFilter] = useState(""); + const [selected, setSelected] = useState([]); + const closeDialog = () => { + setAddUserDialogOpen(false); + setFilter(""); + setSelected([]); + }; 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), - }, - ); + <> + + { + if (!open) { + closeDialog(); } - } - }} - > - - { - setSelectedUser(newValue); - }} - /> - - - - + + Add user(s) + + { + if (checked) { + setSelected([...selected, user]); + } else { + setSelected(selected.filter((s) => s.id !== user.id)); + } + }} + selected={selected} + /> + + + + + +
+ ); };