mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor(site): move Add users into <SettingsHeader /> (#27906)
Move the Add users action into SettingsHeader on the organization Members page and group detail page, matching the Users page layout. It no longer sits inline with the filters. | Old | New | | --- | --- | | <img width="2934" height="1742" alt="ORGANIZATION_MEMBERS_OLD" src="https://github.com/user-attachments/assets/c09ccb0b-cce5-4b95-a63b-40c42dd2e1ac" /> | <img width="2934" height="1742" alt="ORGANIZATION_MEMBERS_NEW" src="https://github.com/user-attachments/assets/a20089b7-04f0-47dc-8153-8f5cca8999cf" /> |
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
import dayjs from "dayjs";
|
||||
import { EllipsisVerticalIcon, UserPlusIcon } from "lucide-react";
|
||||
import { EllipsisVerticalIcon } from "lucide-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorDetail, getErrorMessage } from "#/api/errors";
|
||||
import {
|
||||
addMembers,
|
||||
groupAIBudget,
|
||||
groupMembersAISpend,
|
||||
removeMember,
|
||||
@@ -15,18 +14,11 @@ import { meAISpend } from "#/api/queries/users";
|
||||
import type {
|
||||
Group,
|
||||
GroupMemberAISpend,
|
||||
OrganizationMemberWithUserData,
|
||||
ReducedUser,
|
||||
} from "#/api/typesGenerated";
|
||||
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,
|
||||
@@ -35,9 +27,7 @@ import {
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -49,7 +39,6 @@ import {
|
||||
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
import { isEveryoneGroup } from "#/modules/groups";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { formatBudgetUSD } from "#/utils/currency";
|
||||
import { SpendEstimateDocsLink } from "./AICostControl";
|
||||
@@ -75,7 +64,6 @@ const GroupMembersPage: FC = () => {
|
||||
filterProps,
|
||||
} = useOutletContext<GroupPageOutletContext>();
|
||||
const queryClient = useQueryClient();
|
||||
const addMembersMutation = useMutation(addMembers(queryClient, organization));
|
||||
const removeMemberMutation = useMutation(
|
||||
removeMember(queryClient, organization),
|
||||
);
|
||||
@@ -137,21 +125,7 @@ const GroupMembersPage: FC = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-1 pb-8">
|
||||
<div className="flex flex-row justify-between">
|
||||
<UsersFilter {...filterProps} />
|
||||
|
||||
{canUpdateGroup && groupData && !isEveryoneGroup(groupData) && (
|
||||
<AddUsersDialog
|
||||
organizationId={groupData.organization_id}
|
||||
onSubmit={async (users) => {
|
||||
await addMembersMutation.mutateAsync({
|
||||
groupId: groupData.id,
|
||||
userIds: users.map((u) => u.user_id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<UsersFilter {...filterProps} />
|
||||
|
||||
<PaginationContainer query={membersQuery} paginationUnitLabel="members">
|
||||
<Table aria-label="Group members">
|
||||
@@ -247,98 +221,6 @@ const GroupMembersPage: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
interface AddUsersDialogProps {
|
||||
onSubmit: (users: OrganizationMemberWithUserData[]) => Promise<void>;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const AddUsersDialog: FC<AddUsersDialogProps> = ({
|
||||
onSubmit,
|
||||
organizationId,
|
||||
}) => {
|
||||
const [addUserDialogOpen, setAddUserDialogOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [selected, setSelected] = useState<OrganizationMemberWithUserData[]>(
|
||||
[],
|
||||
);
|
||||
const closeDialog = () => {
|
||||
setAddUserDialogOpen(false);
|
||||
setFilter("");
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="lg" onClick={() => setAddUserDialogOpen(true)}>
|
||||
<UserPlusIcon />
|
||||
Add users
|
||||
</Button>
|
||||
<Dialog
|
||||
open={addUserDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeDialog();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
data-testid="dialog"
|
||||
className="max-w-md gap-4 border-border-default bg-surface-primary p-8 text-content-primary"
|
||||
>
|
||||
<DialogTitle className="font-semibold text-content-primary">
|
||||
Add user(s)
|
||||
</DialogTitle>
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface GroupMemberRowProps {
|
||||
member: MemberWithSpend;
|
||||
group: Group;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { TrashIcon, UserPlusIcon } from "lucide-react";
|
||||
import { type ComponentProps, type FC, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import {
|
||||
@@ -11,28 +11,42 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { getErrorDetail, getErrorMessage } from "#/api/errors";
|
||||
import {
|
||||
addMembers,
|
||||
deleteGroup,
|
||||
group,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
} from "#/api/queries/groups";
|
||||
import type { Group, ReducedUser } from "#/api/typesGenerated";
|
||||
import type {
|
||||
Group,
|
||||
OrganizationMemberWithUserData,
|
||||
ReducedUser,
|
||||
} from "#/api/typesGenerated";
|
||||
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 { DeleteDialog } from "#/components/Dialog/DeleteDialog/DeleteDialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from "#/components/Dialog/Dialog";
|
||||
import { useFilter } from "#/components/Filter/Filter";
|
||||
import type { UsersFilter } from "#/components/Filter/UsersFilter";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import { MultiMemberSelect } from "#/components/MultiUserSelect/MultiUserSelect";
|
||||
import type { PaginationResult } from "#/components/PaginationWidget/PaginationContainer";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderDescription,
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { LinkTabs, LinkTabsList, TabLink } from "#/components/Tabs/Tabs";
|
||||
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
|
||||
import { isEveryoneGroup } from "#/modules/groups";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { AIBudgetPeriod } from "./AIBudgetPeriod";
|
||||
|
||||
@@ -75,6 +89,7 @@ const GroupPage: FC = () => {
|
||||
const deleteGroupMutation = useMutation(
|
||||
deleteGroup(queryClient, organization),
|
||||
);
|
||||
const addMembersMutation = useMutation(addMembers(queryClient, organization));
|
||||
const [isDeletingGroup, setIsDeletingGroup] = useState(false);
|
||||
const isLoading =
|
||||
groupQuery.isLoading ||
|
||||
@@ -113,40 +128,53 @@ const GroupPage: FC = () => {
|
||||
<>
|
||||
{title}
|
||||
|
||||
<div className="flex align-baseline justify-between w-full">
|
||||
<SettingsHeader>
|
||||
<AvatarData
|
||||
avatar={
|
||||
<Avatar
|
||||
src={groupData.avatar_url}
|
||||
fallback={groupData.display_name || groupData.name}
|
||||
size="lg"
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<SettingsHeaderTitle>
|
||||
{groupData.display_name || groupData.name || "Unknown Group"}
|
||||
</SettingsHeaderTitle>
|
||||
}
|
||||
/>
|
||||
<SettingsHeaderDescription>
|
||||
Manage members for this group.
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
|
||||
{canUpdateGroup && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={groupData.id === groupData.organization_id}
|
||||
onClick={() => {
|
||||
setIsDeletingGroup(true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete…
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<SettingsHeader
|
||||
actions={
|
||||
canUpdateGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEveryoneGroup(groupData) && (
|
||||
<AddUsersDialog
|
||||
organizationId={groupData.organization_id}
|
||||
onSubmit={async (users) => {
|
||||
await addMembersMutation.mutateAsync({
|
||||
groupId: groupData.id,
|
||||
userIds: users.map((u) => u.user_id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={groupData.id === groupData.organization_id}
|
||||
onClick={() => {
|
||||
setIsDeletingGroup(true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete…
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<AvatarData
|
||||
avatar={
|
||||
<Avatar
|
||||
src={groupData.avatar_url}
|
||||
fallback={groupData.display_name || groupData.name}
|
||||
size="lg"
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<SettingsHeaderTitle>
|
||||
{groupData.display_name || groupData.name || "Unknown Group"}
|
||||
</SettingsHeaderTitle>
|
||||
}
|
||||
/>
|
||||
<SettingsHeaderDescription>
|
||||
Manage members for this group.
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
<div className="flex flex-col gap-10 w-full">
|
||||
{canUpdateGroup && (
|
||||
<LinkTabs
|
||||
@@ -219,4 +247,96 @@ const GroupPage: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
interface AddUsersDialogProps {
|
||||
onSubmit: (users: OrganizationMemberWithUserData[]) => Promise<void>;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const AddUsersDialog: FC<AddUsersDialogProps> = ({
|
||||
onSubmit,
|
||||
organizationId,
|
||||
}) => {
|
||||
const [addUserDialogOpen, setAddUserDialogOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [selected, setSelected] = useState<OrganizationMemberWithUserData[]>(
|
||||
[],
|
||||
);
|
||||
const closeDialog = () => {
|
||||
setAddUserDialogOpen(false);
|
||||
setFilter("");
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setAddUserDialogOpen(true)}>
|
||||
<UserPlusIcon />
|
||||
Add users
|
||||
</Button>
|
||||
<Dialog
|
||||
open={addUserDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeDialog();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
data-testid="dialog"
|
||||
className="max-w-md gap-4 border-border-default bg-surface-primary p-8 text-content-primary"
|
||||
>
|
||||
<DialogTitle className="font-semibold text-content-primary">
|
||||
Add user(s)
|
||||
</DialogTitle>
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupPage;
|
||||
|
||||
@@ -50,17 +50,16 @@ export const OrganizationMembersPageView: React.FC<
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-screen-2xl pb-10">
|
||||
<SettingsHeader>
|
||||
<SettingsHeader
|
||||
actions={canEditMembers && <AddUsersDialog onSubmit={addMembers} />}
|
||||
>
|
||||
<SettingsHeaderTitle>Members</SettingsHeaderTitle>
|
||||
</SettingsHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{Boolean(error) && <ErrorAlert error={error} />}
|
||||
|
||||
<div className="flex flex-row justify-between">
|
||||
<UsersFilter {...filterProps} />
|
||||
{canEditMembers && <AddUsersDialog onSubmit={addMembers} />}
|
||||
</div>
|
||||
<UsersFilter {...filterProps} />
|
||||
{!canViewMembers && (
|
||||
<div className="flex flex-row text-content-warning gap-2 items-center text-sm font-medium">
|
||||
<TriangleAlertIcon className="size-icon-sm" />
|
||||
@@ -94,7 +93,7 @@ const AddUsersDialog: React.FC<AddUsersDialogProps> = ({ onSubmit }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="lg" onClick={() => setAddUserDialogOpen(true)}>
|
||||
<Button onClick={() => setAddUserDialogOpen(true)}>
|
||||
<UserPlusIcon />
|
||||
Add users
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user