mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user