mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 />,
|
||||
),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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…
|
||||
</Button>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={groupData.id === groupData.organization_id}
|
||||
onClick={() => {
|
||||
setIsDeletingGroup(true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete…
|
||||
</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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user