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:
Jake Howell
2026-08-08 18:03:43 +10:00
committed by GitHub
parent ad225269aa
commit b2cfc4e530
3 changed files with 163 additions and 162 deletions
+2 -120
View File
@@ -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;
+156 -36
View File
@@ -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&hellip;
</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&hellip;
</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>