chore(site): replace inline add member form with dialog on <OrganizationMembersPage /> (#24429)

> 🤖 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
This commit is contained in:
Jake Howell
2026-04-21 13:43:20 +10:00
committed by GitHub
parent 9324c16c97
commit e186dc3222
4 changed files with 123 additions and 81 deletions
+5 -7
View File
@@ -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<PaginatedMembersResponse, UsersRequest> => {
return {
searchParams,
queryPayload: ({ limit, offset }) => {
return {
limit: limit,
offset: offset,
limit,
offset,
q: prepareQuery(searchParams.get("filter") ?? ""),
};
},
queryKey: ({ payload }) => organizationMembersKey(id, payload),
@@ -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}
@@ -17,7 +17,16 @@ const meta: Meta<typeof OrganizationMembersPageView> = {
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<typeof OrganizationMembersPageView> = {
...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,
@@ -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<typeof useFilter> };
isUpdatingMemberRoles: boolean;
showAISeatColumn?: boolean;
me: User;
@@ -54,7 +62,7 @@ interface OrganizationMembersPageViewProps {
membersQuery: PaginationResultInfo & {
isPlaceholderData: boolean;
};
addMember: (user: User) => Promise<void>;
addMembers: (users: User[]) => Promise<void>;
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<
<div className="flex flex-col gap-4">
{Boolean(error) && <ErrorAlert error={error} />}
{canEditMembers && (
<AddOrganizationMember
isLoading={isAddingMember}
onSubmit={addMember}
/>
)}
<div className="flex flex-row justify-between">
<UsersFilter {...filterProps} />
{canEditMembers && <AddUsersDialog onSubmit={addMembers} />}
</div>
{!canViewMembers && (
<div className="flex flex-row text-content-warning gap-2 items-center text-sm font-medium">
<TriangleAlert className="size-icon-sm" />
@@ -217,62 +222,87 @@ export const OrganizationMembersPageView: FC<
);
};
interface AddOrganizationMemberProps {
isLoading: boolean;
onSubmit: (user: User) => Promise<void>;
interface AddUsersDialogProps {
onSubmit: (users: User[]) => Promise<void>;
}
const AddOrganizationMember: FC<AddOrganizationMemberProps> = ({
isLoading,
onSubmit,
}) => {
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const AddUsersDialog: FC<AddUsersDialogProps> = ({ onSubmit }) => {
const [addUserDialogOpen, setAddUserDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [filter, setFilter] = useState("");
const [selected, setSelected] = useState<User[]>([]);
const closeDialog = () => {
setAddUserDialogOpen(false);
setFilter("");
setSelected([]);
};
return (
<form
onSubmit={async (event) => {
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),
},
);
<>
<Button size="lg" onClick={() => setAddUserDialogOpen(true)}>
<UserPlusIcon />
Add users
</Button>
<Dialog
open={addUserDialogOpen}
onOpenChange={(open) => {
if (!open) {
closeDialog();
}
}
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<UserAutocomplete
className="w-[300px]"
value={selectedUser}
onChange={(newValue) => {
setSelectedUser(newValue);
}}
/>
<Button
disabled={!selectedUser || isLoading}
type="submit"
variant="outline"
}}
>
<DialogContent
data-testid="dialog"
className="max-w-md gap-4 border-border-default bg-surface-primary p-8 text-content-primary"
>
<Spinner loading={isLoading}>
<UserPlusIcon className="size-icon-sm" />
</Spinner>
Add user
</Button>
</Stack>
</form>
<DialogTitle className="font-semibold text-content-primary">
Add user(s)
</DialogTitle>
<MultiUserSelect
filter={filter}
setFilter={setFilter}
onChange={(user, checked) => {
if (checked) {
setSelected([...selected, user]);
} else {
setSelected(selected.filter((s) => s.id !== user.id));
}
}}
selected={selected}
/>
<DialogFooter className="mt-4 flex-row justify-end gap-3">
<Button
variant="outline"
onClick={closeDialog}
disabled={submitting}
>
Cancel
</Button>
<Button
disabled={submitting || selected.length === 0}
onClick={async () => {
try {
setSubmitting(true);
await onSubmit(selected);
closeDialog();
} catch (error) {
toast.error(
getErrorMessage(error, "Failed to add members."),
{
description: getErrorDetail(error),
},
);
} finally {
setSubmitting(false);
}
}}
>
<Spinner loading={submitting} />
Add users
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};