refactor(site): add tabbed layout for single group page (#22486)

## Summary

Replaces the standalone **Settings** button on the single-group page
with a tabbed layout containing **Group members** and **Group settings**
tabs.

This uses the new figma designs here:
https://www.figma.com/design/klGTlHSPQwI4KBvAMdebrx/Customer-Usage-Controls-for-AI-Governance-Add-On?node-id=51-4907&m=dev

<img width="797" height="371" alt="Screenshot 2026-03-02 at 22 53 28"
src="https://github.com/user-attachments/assets/88d2ca8e-928f-404d-8569-ec4aba6c2ce4"
/>



### What changed

| File | Change |
|------|--------|
| `site/src/router.tsx` | Nested `settings` route under `:groupName`
layout route; added `GroupMembersPage` lazy import |
| `site/src/pages/GroupsPage/GroupPage.tsx` | Converted to shared
layout: header + tabs (`Tabs`/`TabsList`/`TabLink`) + `<Outlet />` with
context. Removed settings button and member-management code |
| `site/src/pages/GroupsPage/GroupMembersPage.tsx` | **New file** —
extracted member-management UI (add/remove members, member table) from
GroupPage; consumes data via `useOutletContext` |
| `site/src/pages/GroupsPage/GroupSettingsPage.tsx` | Switched from
independent group query to outlet context; removed duplicate
loading/error/title handling |
| `site/src/pages/GroupsPage/GroupSettingsPageView.tsx` | Removed
duplicate `ResourcePageHeader`; renders only the settings form |
| `site/e2e/tests/organizationGroups.spec.ts` | Updated selectors from
`"Settings"` link to `"Group settings"` link |

### How it works

- `:groupName` route now renders `GroupPage` as a **layout route** with
header, tabs, and `<Outlet />`.
- Index child route renders `GroupMembersPage` (member table +
add/remove).
- `settings` child route renders `GroupSettingsPage` (group settings
form).
- Shared group data + permissions are passed via React Router outlet
context, eliminating duplicate queries.
- URL structure is unchanged: `/organizations/:org/groups/:groupName`
(members) and `.../settings` (settings).

### Verification

- `pnpm exec tsc --noEmit` — passes
- `pnpm exec biome check --error-on-warnings` on all touched files —
passes
This commit is contained in:
Jaayden Halko
2026-03-02 18:12:40 +00:00
committed by GitHub
parent a33ca95df2
commit 3daac86efe
11 changed files with 594 additions and 504 deletions
+2 -2
View File
@@ -112,7 +112,7 @@ test("change quota settings", async ({ page }) => {
await login(page, orgUserAdmin);
await page.goto(`/organizations/${org.name}/groups/${group.name}`);
await page.getByRole("link", { name: "Settings", exact: true }).click();
await page.getByRole("link", { name: "Group settings" }).click();
await expectUrl(page).toHavePathName(
`/organizations/${org.name}/groups/${group.name}/settings`,
);
@@ -127,6 +127,6 @@ test("change quota settings", async ({ page }) => {
);
// ...and that setting should persist if we go back
await page.getByRole("link", { name: "Settings", exact: true }).click();
await page.getByRole("link", { name: "Group settings" }).click();
await expect(page.getByLabel("Quota Allowance")).toHaveValue("100");
});
+18 -12
View File
@@ -127,7 +127,7 @@ export const createGroup = (queryClient: QueryClient, organization: string) => {
};
};
export const patchGroup = (queryClient: QueryClient) => {
export const patchGroup = (queryClient: QueryClient, organization: string) => {
return {
mutationFn: ({
groupId,
@@ -135,40 +135,46 @@ export const patchGroup = (queryClient: QueryClient) => {
}: PatchGroupRequest & { groupId: string }) =>
API.patchGroup(groupId, request),
onSuccess: async (updatedGroup: Group) =>
invalidateGroup(queryClient, "default", updatedGroup.id),
invalidateGroup(queryClient, organization, updatedGroup.name),
};
};
export const deleteGroup = (queryClient: QueryClient) => {
export const deleteGroup = (queryClient: QueryClient, organization: string) => {
return {
mutationFn: API.deleteGroup,
onSuccess: async (_: unknown, groupId: string) =>
invalidateGroup(queryClient, "default", groupId),
mutationFn: ({ groupId }: { groupId: string; groupName: string }) =>
API.deleteGroup(groupId),
onSuccess: async (
_: unknown,
{ groupName }: { groupId: string; groupName: string },
) => invalidateGroup(queryClient, organization, groupName),
};
};
export const addMember = (queryClient: QueryClient) => {
export const addMember = (queryClient: QueryClient, organization: string) => {
return {
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
API.addMember(groupId, userId),
onSuccess: async (updatedGroup: Group) =>
invalidateGroup(queryClient, "default", updatedGroup.id),
invalidateGroup(queryClient, organization, updatedGroup.name),
};
};
export const removeMember = (queryClient: QueryClient) => {
export const removeMember = (
queryClient: QueryClient,
organization: string,
) => {
return {
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
API.removeMember(groupId, userId),
onSuccess: async (updatedGroup: Group) =>
invalidateGroup(queryClient, "default", updatedGroup.id),
invalidateGroup(queryClient, organization, updatedGroup.name),
};
};
const invalidateGroup = (
queryClient: QueryClient,
organization: string,
groupId: string,
groupName: string,
) =>
Promise.all([
queryClient.invalidateQueries({ queryKey: groupsQueryKey }),
@@ -176,7 +182,7 @@ const invalidateGroup = (
queryKey: getGroupsByOrganizationQueryKey(organization),
}),
queryClient.invalidateQueries({
queryKey: getGroupQueryKey(organization, groupId),
queryKey: getGroupQueryKey(organization, groupName),
}),
]);
@@ -61,23 +61,3 @@ export const PageHeaderCaption: FC<PropsWithChildren> = ({ children }) => {
</span>
);
};
interface ResourcePageHeaderProps extends Omit<PageHeaderProps, "children"> {
displayName?: string;
name: string;
}
export const ResourcePageHeader: FC<ResourcePageHeaderProps> = ({
displayName,
name,
...props
}) => {
const title = displayName || name;
return (
<PageHeader {...props}>
<PageHeaderTitle>{title}</PageHeaderTitle>
{name !== title && <PageHeaderSubtitle>{name}</PageHeaderSubtitle>}
</PageHeader>
);
};
@@ -1,20 +1,10 @@
import TextField from "@mui/material/TextField";
import { isApiValidationError } from "api/errors";
import type { CreateGroupRequest } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import {
FormFields,
FormFooter,
FormSection,
HorizontalForm,
} from "components/Form/Form";
import { IconField } from "components/IconField/IconField";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "components/SettingsHeader/SettingsHeader";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import type { FC } from "react";
@@ -54,42 +44,84 @@ export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
});
const getFieldHelpers = getFormHelpers<CreateGroupRequest>(form, error);
const onCancel = () => navigate(-1);
const nameField = getFieldHelpers("name");
const displayNameField = getFieldHelpers("display_name", {
helperText: "Keep empty to default to the name.",
});
return (
<>
<SettingsHeader>
<SettingsHeaderTitle>New Group</SettingsHeaderTitle>
<SettingsHeaderDescription>
Create a group in this organization.
</SettingsHeaderDescription>
</SettingsHeader>
<div className="flex flex-col items-start w-full max-w-xl">
<div className="flex flex-row items-start pb-6">
<h1 className="m-0 flex items-center gap-2 text-3xl font-semibold leading-tight">
New Group
</h1>
</div>
<HorizontalForm onSubmit={form.handleSubmit}>
<FormSection
title="Group settings"
description="Set a name and avatar for this group."
>
<FormFields>
<form
className="flex flex-col w-full max-w-xl gap-10 rounded-lg border border-solid border-border-default p-6"
onSubmit={form.handleSubmit}
>
<section className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-medium text-content-primary m-0">
Group settings
</h2>
<p className="text-sm leading-relaxed text-content-secondary m-0">
Set a name and avatar for this group.
</p>
</div>
<div className="flex flex-col gap-6">
{Boolean(error) && !isApiValidationError(error) && (
<ErrorAlert error={error} />
)}
<TextField
{...getFieldHelpers("name")}
autoFocus
fullWidth
label="Name"
onChange={onChangeTrimmed(form)}
autoComplete="name"
/>
<TextField
{...getFieldHelpers("display_name", {
helperText: "Optional: keep empty to default to the name.",
})}
fullWidth
label="Display Name"
autoComplete="display_name"
/>
<div className="flex flex-col items-start gap-2">
<Label htmlFor={nameField.id}>Name</Label>
<Input
id={nameField.id}
name={nameField.name}
value={nameField.value}
onChange={onChangeTrimmed(form)}
onBlur={nameField.onBlur}
autoFocus
autoComplete="name"
aria-invalid={nameField.error}
/>
{nameField.helperText && (
<span
className={`text-xs text-left ${
nameField.error
? "text-content-destructive"
: "text-content-secondary"
}`}
>
{nameField.helperText}
</span>
)}
</div>
<div className="flex flex-col items-start gap-2">
<Label htmlFor={displayNameField.id}>Display Name</Label>
<Input
id={displayNameField.id}
name={displayNameField.name}
value={displayNameField.value}
onChange={displayNameField.onChange}
onBlur={displayNameField.onBlur}
autoComplete="display_name"
aria-invalid={displayNameField.error}
/>
{displayNameField.helperText && (
<span
className={`text-xs text-left ${
displayNameField.error
? "text-content-destructive"
: "text-content-secondary"
}`}
>
{displayNameField.helperText}
</span>
)}
</div>
<IconField
{...getFieldHelpers("avatar_url")}
onChange={onChangeTrimmed(form)}
@@ -97,20 +129,19 @@ export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
label="Avatar URL"
onPickEmoji={(value) => form.setFieldValue("avatar_url", value)}
/>
</FormFields>
</FormSection>
</div>
</section>
<FormFooter>
<footer className="flex items-center justify-end space-x-2">
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
<Spinner loading={isLoading} />
Save
Create group
</Button>
</FormFooter>
</HorizontalForm>
</>
</footer>
</form>
</div>
);
};
@@ -0,0 +1,266 @@
import type { Interpolation, Theme } from "@emotion/react";
import { getErrorDetail, getErrorMessage } from "api/errors";
import { addMember, removeMember } from "api/queries/groups";
import type {
Group,
OrganizationMemberWithUserData,
ReducedUser,
} from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { EmptyState } from "components/EmptyState/EmptyState";
import { LastSeen } from "components/LastSeen/LastSeen";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/Table/Table";
import {
PaginationStatus,
TableToolbar,
} from "components/TableToolbar/TableToolbar";
import { MemberAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { EllipsisVertical, UserPlusIcon } from "lucide-react";
import { isEveryoneGroup } from "modules/groups";
import { type FC, useState } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useOutletContext } from "react-router";
import { toast } from "sonner";
import type { GroupPageOutletContext } from "./GroupPage";
const GroupMembersPage: FC = () => {
const {
group: groupData,
organization,
permissions,
groupQuery,
} = useOutletContext<GroupPageOutletContext>();
const queryClient = useQueryClient();
const addMemberMutation = useMutation(addMember(queryClient, organization));
const removeMemberMutation = useMutation(
removeMember(queryClient, organization),
);
const canUpdateGroup = permissions ? permissions.canUpdateGroup : false;
const groupId = groupData.id;
return (
<div className="flex flex-col w-full gap-1 pb-8">
{canUpdateGroup && groupData && !isEveryoneGroup(groupData) && (
<AddGroupMember
isLoading={addMemberMutation.isPending}
organizationId={groupData.organization_id}
onSubmit={async (member, reset) => {
try {
await addMemberMutation.mutateAsync({
groupId,
userId: member.user_id,
});
reset();
await groupQuery.refetch();
} catch (error) {
toast.error(getErrorMessage(error, "Failed to add member."), {
description: getErrorDetail(error),
});
}
}}
/>
)}
<TableToolbar>
<PaginationStatus
isLoading={false}
showing={groupData?.members.length ?? 0}
total={groupData?.members.length ?? 0}
label="members"
/>
</TableToolbar>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-2/5">User</TableHead>
<TableHead className="w-3/5">Status</TableHead>
<TableHead className="w-auto" />
</TableRow>
</TableHeader>
<TableBody>
{groupData?.members.length === 0 ? (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No members yet"
description="Add a member using the controls above"
/>
</TableCell>
</TableRow>
) : (
groupData?.members.map((member) => (
<GroupMemberRow
member={member}
group={groupData}
key={member.id}
canUpdate={canUpdateGroup}
onRemove={async () => {
const mutation = removeMemberMutation.mutateAsync(
{
groupId: groupData.id,
userId: member.id,
},
{
onSuccess: () => {
groupQuery.refetch();
},
},
);
toast.promise(mutation, {
loading: `Removing member "${member.username}" from "${groupData.name}"...`,
success: `Member "${member.username}" has been removed from "${groupData.name}" successfully.`,
error: (error) => ({
message: `Failed to remove member "${member.username}" from "${groupData.name}".`,
description: getErrorDetail(error),
}),
});
}}
/>
))
)}
</TableBody>
</Table>
</div>
);
};
interface AddGroupMemberProps {
isLoading: boolean;
onSubmit: (user: OrganizationMemberWithUserData, reset: () => void) => void;
organizationId: string;
}
const AddGroupMember: FC<AddGroupMemberProps> = ({
isLoading,
onSubmit,
organizationId,
}) => {
const [selectedUser, setSelectedUser] =
useState<OrganizationMemberWithUserData | null>(null);
const resetValues = () => {
setSelectedUser(null);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (selectedUser) {
onSubmit(selectedUser, resetValues);
}
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<MemberAutocomplete
css={styles.autoComplete}
value={selectedUser}
organizationId={organizationId}
onChange={(newValue) => {
setSelectedUser(newValue);
}}
/>
<Button disabled={!selectedUser || isLoading} type="submit">
<Spinner loading={isLoading}>
<UserPlusIcon className="size-icon-sm" />
</Spinner>
Add user
</Button>
</Stack>
</form>
);
};
interface GroupMemberRowProps {
member: ReducedUser;
group: Group;
canUpdate: boolean;
onRemove: () => void;
}
const GroupMemberRow: FC<GroupMemberRowProps> = ({
member,
group,
canUpdate,
onRemove,
}) => {
return (
<TableRow key={member.id}>
<TableCell width="59%">
<AvatarData
avatar={
<Avatar
size="lg"
fallback={member.username}
src={member.avatar_url}
/>
}
title={member.username}
subtitle={member.email}
/>
</TableCell>
<TableCell
width="40%"
css={[styles.status, member.status === "suspended" && styles.suspended]}
>
<div>{member.status}</div>
<LastSeen at={member.last_seen_at} css={{ fontSize: 12 }} />
</TableCell>
<TableCell width="1%">
{canUpdate && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon-lg" variant="subtle" aria-label="Open menu">
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={onRemove}
disabled={group.id === group.organization_id}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);
};
const styles = {
autoComplete: {
width: 300,
},
status: {
textTransform: "capitalize",
},
suspended: (theme) => ({
color: theme.palette.text.secondary,
}),
} satisfies Record<string, Interpolation<Theme>>;
export default GroupMembersPage;
@@ -9,7 +9,11 @@ import { API } from "api/api";
import { getGroupQueryKey, groupPermissionsKey } from "api/queries/groups";
import { organizationMembersKey } from "api/queries/organizations";
import { spyOn, userEvent, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import {
reactRouterOutlet,
reactRouterParameters,
} from "storybook-addon-remix-react-router";
import GroupMembersPage from "./GroupMembersPage";
import GroupPage from "./GroupPage";
const meta: Meta<typeof GroupPage> = {
@@ -23,7 +27,10 @@ const meta: Meta<typeof GroupPage> = {
groupName: MockGroup.name,
},
},
routing: { path: "/organizations/:organization/groups/:groupName" },
routing: reactRouterOutlet(
{ path: "/organizations/:organization/groups/:groupName" },
<GroupMembersPage />,
),
}),
},
};
+57 -271
View File
@@ -1,69 +1,36 @@
import type { Interpolation, Theme } from "@emotion/react";
import { getErrorDetail, getErrorMessage } from "api/errors";
import {
addMember,
deleteGroup,
group,
groupPermissions,
removeMember,
} from "api/queries/groups";
import type {
Group,
OrganizationMemberWithUserData,
ReducedUser,
} from "api/typesGenerated";
import { deleteGroup, group, groupPermissions } from "api/queries/groups";
import type { Group } 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/Dialogs/DeleteDialog/DeleteDialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { EmptyState } from "components/EmptyState/EmptyState";
import { LastSeen } from "components/LastSeen/LastSeen";
import { Loader } from "components/Loader/Loader";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "components/SettingsHeader/SettingsHeader";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/Table/Table";
import {
PaginationStatus,
TableToolbar,
} from "components/TableToolbar/TableToolbar";
import { MemberAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import {
EllipsisVertical,
SettingsIcon,
TrashIcon,
UserPlusIcon,
} from "lucide-react";
import { isEveryoneGroup } from "modules/groups";
import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
import { TrashIcon } from "lucide-react";
import { type FC, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Link as RouterLink, useNavigate, useParams } from "react-router";
import { Outlet, useLocation, useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { pageTitle } from "utils/page";
export type GroupPageOutletContext = {
group: Group;
permissions: { canUpdateGroup: boolean };
organization: string;
groupQuery: ReturnType<typeof useQuery>;
};
const GroupPage: FC = () => {
const { organization = "default", groupName } = useParams() as {
organization?: string;
groupName: string;
};
const location = useLocation();
const queryClient = useQueryClient();
const navigate = useNavigate();
const groupQuery = useQuery(group(organization, groupName));
@@ -72,9 +39,9 @@ const GroupPage: FC = () => {
...groupPermissions(groupData?.id ?? ""),
enabled: !!groupData,
});
const addMemberMutation = useMutation(addMember(queryClient));
const removeMemberMutation = useMutation(removeMember(queryClient));
const deleteGroupMutation = useMutation(deleteGroup(queryClient));
const deleteGroupMutation = useMutation(
deleteGroup(queryClient, organization),
);
const [isDeletingGroup, setIsDeletingGroup] = useState(false);
const isLoading = groupQuery.isLoading || !groupData || !permissions;
const canUpdateGroup = permissions ? permissions.canUpdateGroup : false;
@@ -97,7 +64,11 @@ const GroupPage: FC = () => {
</>
);
}
const groupId = groupData.id;
const activeTab = location.pathname.endsWith("/settings")
? "settings"
: "members";
return (
<>
@@ -106,7 +77,7 @@ const GroupPage: FC = () => {
<div className="flex align-baseline justify-between w-full">
<SettingsHeader>
<SettingsHeaderTitle>
{groupData?.display_name || groupData?.name || "Unknown Group"}
{groupData.display_name || groupData.name || "Unknown Group"}
</SettingsHeaderTitle>
<SettingsHeaderDescription>
Manage members for this group.
@@ -114,109 +85,42 @@ const GroupPage: FC = () => {
</SettingsHeader>
{canUpdateGroup && (
<Stack direction="row" spacing={2}>
<Button variant="outline" asChild>
<RouterLink to="settings">
<SettingsIcon />
Settings
</RouterLink>
</Button>
<Button
variant="destructive"
disabled={groupData?.id === groupData?.organization_id}
onClick={() => {
setIsDeletingGroup(true);
}}
>
<TrashIcon />
Delete&hellip;
</Button>
</Stack>
<Button
variant="destructive"
disabled={groupData.id === groupData.organization_id}
onClick={() => {
setIsDeletingGroup(true);
}}
>
<TrashIcon />
Delete&hellip;
</Button>
)}
</div>
<div className="flex flex-col w-full gap-1">
{canUpdateGroup && groupData && !isEveryoneGroup(groupData) && (
<AddGroupMember
isLoading={addMemberMutation.isPending}
organizationId={groupData.organization_id}
onSubmit={async (member, reset) => {
try {
await addMemberMutation.mutateAsync({
groupId,
userId: member.user_id,
});
reset();
await groupQuery.refetch();
} catch (error) {
toast.error(getErrorMessage(error, "Failed to add member."), {
description: getErrorDetail(error),
});
}
}}
/>
<div className="flex flex-col gap-10 w-full">
{canUpdateGroup && (
<Tabs active={activeTab}>
<TabsList className="w-full justify-start">
<TabLink to="." value="members">
Group members
</TabLink>
<TabLink to="settings" value="settings">
Group settings
</TabLink>
</TabsList>
</Tabs>
)}
<TableToolbar>
<PaginationStatus
isLoading={Boolean(isLoading)}
showing={groupData?.members.length ?? 0}
total={groupData?.members.length ?? 0}
label="members"
/>
</TableToolbar>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-2/5">User</TableHead>
<TableHead className="w-3/5">Status</TableHead>
<TableHead className="w-auto" />
</TableRow>
</TableHeader>
<TableBody>
{groupData?.members.length === 0 ? (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No members yet"
description="Add a member using the controls above"
/>
</TableCell>
</TableRow>
) : (
groupData?.members.map((member) => (
<GroupMemberRow
member={member}
group={groupData}
key={member.id}
canUpdate={canUpdateGroup}
onRemove={async () => {
const mutation = removeMemberMutation.mutateAsync(
{
groupId: groupData.id,
userId: member.id,
},
{
onSuccess: () => {
groupQuery.refetch();
},
},
);
toast.promise(mutation, {
loading: `Removing member "${member.username}" from "${groupData.name}"...`,
success: `Member "${member.username}" has been removed from "${groupData.name}" successfully.`,
error: (error) => ({
message: `Failed to remove member "${member.username}" from "${groupData.name}".`,
description: getErrorDetail(error),
}),
});
}}
/>
))
)}
</TableBody>
</Table>
<Outlet
context={
{
group: groupData,
permissions: { canUpdateGroup },
organization,
groupQuery,
} satisfies GroupPageOutletContext
}
/>
</div>
{groupQuery.data && (
@@ -227,7 +131,10 @@ const GroupPage: FC = () => {
entity="group"
onConfirm={async () => {
try {
await deleteGroupMutation.mutateAsync(groupId);
await deleteGroupMutation.mutateAsync({
groupId,
groupName: groupData.name,
});
toast.success(
`Group "${groupQuery.data.name}" deleted successfully.`,
);
@@ -253,125 +160,4 @@ const GroupPage: FC = () => {
);
};
interface AddGroupMemberProps {
isLoading: boolean;
onSubmit: (user: OrganizationMemberWithUserData, reset: () => void) => void;
organizationId: string;
}
const AddGroupMember: FC<AddGroupMemberProps> = ({
isLoading,
onSubmit,
organizationId,
}) => {
const [selectedUser, setSelectedUser] =
useState<OrganizationMemberWithUserData | null>(null);
const resetValues = () => {
setSelectedUser(null);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (selectedUser) {
onSubmit(selectedUser, resetValues);
}
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<MemberAutocomplete
css={styles.autoComplete}
value={selectedUser}
organizationId={organizationId}
onChange={(newValue) => {
setSelectedUser(newValue);
}}
/>
<Button disabled={!selectedUser || isLoading} type="submit">
<Spinner loading={isLoading}>
<UserPlusIcon className="size-icon-sm" />
</Spinner>
Add user
</Button>
</Stack>
</form>
);
};
interface GroupMemberRowProps {
member: ReducedUser;
group: Group;
canUpdate: boolean;
onRemove: () => void;
}
const GroupMemberRow: FC<GroupMemberRowProps> = ({
member,
group,
canUpdate,
onRemove,
}) => {
return (
<TableRow key={member.id}>
<TableCell width="59%">
<AvatarData
avatar={
<Avatar
size="lg"
fallback={member.username}
src={member.avatar_url}
/>
}
title={member.username}
subtitle={member.email}
/>
</TableCell>
<TableCell
width="40%"
css={[styles.status, member.status === "suspended" && styles.suspended]}
>
<div>{member.status}</div>
<LastSeen at={member.last_seen_at} css={{ fontSize: 12 }} />
</TableCell>
<TableCell width="1%">
{canUpdate && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon-lg" variant="subtle" aria-label="Open menu">
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={onRemove}
disabled={group.id === group.organization_id}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);
};
const styles = {
autoComplete: {
width: 300,
},
status: {
textTransform: "capitalize",
},
suspended: (theme) => ({
color: theme.palette.text.secondary,
}),
} satisfies Record<string, Interpolation<Theme>>;
export default GroupPage;
+38 -63
View File
@@ -1,12 +1,10 @@
import { getErrorDetail, getErrorMessage } from "api/errors";
import { group, patchGroup } from "api/queries/groups";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { patchGroup } from "api/queries/groups";
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useParams } from "react-router";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate, useOutletContext, useParams } from "react-router";
import { toast } from "sonner";
import { pageTitle } from "utils/page";
import type { GroupPageOutletContext } from "./GroupPage";
import GroupSettingsPageView from "./GroupSettingsPageView";
const GroupSettingsPage: FC = () => {
@@ -14,69 +12,46 @@ const GroupSettingsPage: FC = () => {
organization?: string;
groupName: string;
};
const { group: groupData } = useOutletContext<GroupPageOutletContext>();
const queryClient = useQueryClient();
const groupQuery = useQuery(group(organization, groupName));
const patchGroupMutation = useMutation(patchGroup(queryClient));
const patchGroupMutation = useMutation(patchGroup(queryClient, organization));
const navigate = useNavigate();
const navigateToGroup = () => {
navigate(`/organizations/${organization}/groups/${groupName}`);
};
const title = <title>{pageTitle("Settings Group")}</title>;
if (groupQuery.error) {
return <ErrorAlert error={groupQuery.error} />;
}
if (groupQuery.isLoading || !groupQuery.data) {
return (
<>
{title}
<Loader />
</>
);
}
const groupId = groupQuery.data.id;
return (
<>
{title}
<GroupSettingsPageView
onCancel={navigateToGroup}
onSubmit={async (data) => {
await patchGroupMutation.mutateAsync(
{
groupId,
...data,
add_users: [],
remove_users: [],
<GroupSettingsPageView
onCancel={() => navigate("..")}
onSubmit={async (data) => {
await patchGroupMutation.mutateAsync(
{
groupId: groupData.id,
...data,
add_users: [],
remove_users: [],
},
{
onSuccess: () => {
navigate(`/organizations/${organization}/groups/${data.name}`);
},
{
onSuccess: () => {
navigate(`../${data.name}`);
},
onError: (error) => {
toast.error(
getErrorMessage(
error,
`Failed to update group "${groupName}".`,
),
{
description: getErrorDetail(error),
},
);
},
onError: (error) => {
toast.error(
getErrorMessage(
error,
`Failed to update group "${groupName}".`,
),
{
description: getErrorDetail(error),
},
);
},
);
}}
group={groupQuery.data}
formErrors={groupQuery.error}
isLoading={groupQuery.isLoading}
isUpdating={patchGroupMutation.isPending}
/>
</>
},
);
}}
group={groupData}
formErrors={undefined}
isLoading={false}
isUpdating={patchGroupMutation.isPending}
/>
);
};
export default GroupSettingsPage;
@@ -1,15 +1,8 @@
import TextField from "@mui/material/TextField";
import type { Group } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
FormFields,
FormFooter,
FormSection,
HorizontalForm,
} from "components/Form/Form";
import { IconField } from "components/IconField/IconField";
import { Loader } from "components/Loader/Loader";
import { ResourcePageHeader } from "components/PageHeader/PageHeader";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import { isEveryoneGroup } from "modules/groups";
@@ -59,35 +52,75 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
onSubmit,
});
const getFieldHelpers = getFormHelpers<FormData>(form, errors);
const nameField = getFieldHelpers("name");
const displayNameField = getFieldHelpers("display_name", {
helperText: "Keep empty to default to the name.",
});
const quotaField = getFieldHelpers("quota_allowance", {
helperText: `This group gives ${form.values.quota_allowance} quota credits to each
of its members.`,
});
return (
<HorizontalForm onSubmit={form.handleSubmit}>
<FormSection
title="Group settings"
description="Set a name and avatar for this group."
>
<FormFields>
<TextField
{...getFieldHelpers("name")}
onChange={onChangeTrimmed(form)}
autoComplete="name"
autoFocus
fullWidth
label="Name"
disabled={isEveryoneGroup(group)}
/>
<form className="flex flex-col gap-10 pb-8" onSubmit={form.handleSubmit}>
<section className="flex flex-col gap-4 max-w-md">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold text-content-primary m-0">
General
</h2>
</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col items-start gap-2">
<Label htmlFor={nameField.id}>Name</Label>
<Input
id={nameField.id}
name={nameField.name}
value={nameField.value}
onChange={onChangeTrimmed(form)}
onBlur={nameField.onBlur}
autoComplete="name"
autoFocus
disabled={isEveryoneGroup(group)}
aria-invalid={nameField.error}
/>
{nameField.helperText && (
<span
className={`text-xs text-left ${
nameField.error
? "text-content-destructive"
: "text-content-secondary"
}`}
>
{nameField.helperText}
</span>
)}
</div>
{!isEveryoneGroup(group) && (
<>
<TextField
{...getFieldHelpers("display_name", {
helperText: "Optional: keep empty to default to the name.",
})}
autoComplete="display_name"
autoFocus
fullWidth
label="Display Name"
disabled={isEveryoneGroup(group)}
/>
<div className="flex flex-col items-start gap-2">
<Label htmlFor={displayNameField.id}>Display name</Label>
<Input
id={displayNameField.id}
name={displayNameField.name}
value={displayNameField.value}
onChange={displayNameField.onChange}
onBlur={displayNameField.onBlur}
autoComplete="display_name"
disabled={isEveryoneGroup(group)}
aria-invalid={displayNameField.error}
/>
{displayNameField.helperText && (
<span
className={`text-xs text-left ${
displayNameField.error
? "text-content-destructive"
: "text-content-secondary"
}`}
>
{displayNameField.helperText}
</span>
)}
</div>
<IconField
{...getFieldHelpers("avatar_url")}
onChange={onChangeTrimmed(form)}
@@ -97,28 +130,46 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
/>
</>
)}
</FormFields>
</FormSection>
<FormSection
title="Quota"
description="You can use quotas to restrict how many resources a user can create."
>
<FormFields>
<TextField
{...getFieldHelpers("quota_allowance", {
helperText: `This group gives ${form.values.quota_allowance} quota credits to each
of its members.`,
})}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
type="number"
label="Quota Allowance"
/>
</FormFields>
</FormSection>
</div>
</section>
<section className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold text-content-primary m-0">
Quotas
</h2>
<p className="text-sm leading-none m-0 text-content-secondary">
You can use quotas to restrict how many resources a user can create.
</p>
</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col items-start gap-2">
<Label htmlFor={quotaField.id}>Quota Allowance</Label>
<Input
id={quotaField.id}
name={quotaField.name}
value={quotaField.value}
onChange={onChangeTrimmed(form)}
onBlur={quotaField.onBlur}
type="number"
aria-invalid={quotaField.error}
className="w-40"
/>
{quotaField.helperText && (
<span
className={`text-xs text-left ${
quotaField.error
? "text-content-destructive"
: "text-content-secondary"
}`}
>
{quotaField.helperText}
</span>
)}
</div>
</div>
</section>
<FormFooter className="mt-8">
<footer className="flex items-center justify-start space-x-2">
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
@@ -127,8 +178,8 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
<Spinner loading={isLoading} />
Save
</Button>
</FormFooter>
</HorizontalForm>
</footer>
</form>
);
};
@@ -146,28 +197,16 @@ const GroupSettingsPageView: FC<SettingsGroupPageViewProps> = ({
onSubmit,
group,
formErrors,
isLoading,
isUpdating,
}) => {
if (isLoading) {
return <Loader />;
}
return (
<>
<ResourcePageHeader
displayName={group!.display_name}
name={group!.name}
css={{ paddingTop: 8 }}
/>
<UpdateGroupForm
group={group!}
onCancel={onCancel}
errors={formErrors}
isLoading={isUpdating}
onSubmit={onSubmit}
/>
</>
<UpdateGroupForm
group={group!}
onCancel={onCancel}
errors={formErrors}
isLoading={isUpdating}
onSubmit={onSubmit}
/>
);
};
+2 -7
View File
@@ -9,7 +9,6 @@ import {
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "components/SettingsHeader/SettingsHeader";
import { Stack } from "components/Stack/Stack";
import { PlusIcon } from "lucide-react";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { RequirePermission } from "modules/permissions/RequirePermission";
@@ -80,11 +79,7 @@ const GroupsPage: FC = () => {
<div className="w-full max-w-screen-2xl pb-10">
{title}
<Stack
alignItems="baseline"
direction="row"
justifyContent="space-between"
>
<div className="flex max-w-full flex-row items-baseline justify-between gap-4">
<SettingsHeader>
<SettingsHeaderTitle>Groups</SettingsHeaderTitle>
<SettingsHeaderDescription>
@@ -101,7 +96,7 @@ const GroupsPage: FC = () => {
</RouterLink>
</Button>
)}
</Stack>
</div>
<GroupsPageView
groups={groupsQuery.data}
+7 -2
View File
@@ -259,6 +259,9 @@ const CreateGroupPage = lazy(
() => import("./pages/GroupsPage/CreateGroupPage"),
);
const GroupPage = lazy(() => import("./pages/GroupsPage/GroupPage"));
const GroupMembersPage = lazy(
() => import("./pages/GroupsPage/GroupMembersPage"),
);
const GroupSettingsPage = lazy(
() => import("./pages/GroupsPage/GroupSettingsPage"),
);
@@ -410,8 +413,10 @@ const groupsRouter = () => {
<Route index element={<GroupsPage />} />
<Route path="create" element={<CreateGroupPage />} />
<Route path=":groupName" element={<GroupPage />} />
<Route path=":groupName/settings" element={<GroupSettingsPage />} />
<Route path=":groupName" element={<GroupPage />}>
<Route index element={<GroupMembersPage />} />
<Route path="settings" element={<GroupSettingsPage />} />
</Route>
</Route>
</Route>
);