mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add server-side search and pagination for groups page (#27994)
Repeat of #27604, which I had mistakenly manually merged into #27603, instead of letting Graphite handle it after #27603 merges into main. Reattempting with GitHub stacks now instead of Graphite! See #27604 for details/review.
This commit is contained in:
@@ -2230,6 +2230,22 @@ class ApiMethods {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @param organization Can be the organization's ID or name
|
||||
* @param options Pagination and search options
|
||||
*/
|
||||
getOrganizationPaginatedGroups = async (
|
||||
organization: string,
|
||||
options?: TypesGen.PaginatedGroupsRequest,
|
||||
): Promise<TypesGen.PaginatedGroupsResponse> => {
|
||||
const url = getURLWithSearchParams(
|
||||
`/api/v2/organizations/${organization}/paginated-groups`,
|
||||
options,
|
||||
);
|
||||
const response = await this.axios.get(url);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param organization Can be the organization's ID or name
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
GroupMembersResponse,
|
||||
GroupRequest,
|
||||
OrganizationGroupsAISpend,
|
||||
PaginatedGroupsRequest,
|
||||
PaginatedGroupsResponse,
|
||||
PatchGroupRequest,
|
||||
UsersRequest,
|
||||
} from "#/api/typesGenerated";
|
||||
@@ -93,6 +95,37 @@ export const groupMembersAISpend = (
|
||||
} satisfies UseQueryOptions<GroupMembersAISpend>;
|
||||
};
|
||||
|
||||
const getPaginatedGroupsByOrganizationQueryKey = (
|
||||
organization: string,
|
||||
req?: PaginatedGroupsRequest,
|
||||
) => {
|
||||
// Nested under the org groups key so create/patch/delete invalidations,
|
||||
// which target ["organization", org, "groups"], also cover this list.
|
||||
const base = [...getGroupsByOrganizationQueryKey(organization), "paginated"];
|
||||
return req ? [...base, req] : base;
|
||||
};
|
||||
|
||||
export function paginatedGroupsByOrganization(
|
||||
organization: string,
|
||||
searchParams: URLSearchParams,
|
||||
): UsePaginatedQueryOptions<PaginatedGroupsResponse, PaginatedGroupsRequest> {
|
||||
return {
|
||||
searchParams,
|
||||
queryPayload: ({ limit, offset }) => {
|
||||
return {
|
||||
limit,
|
||||
offset,
|
||||
q: prepareQuery(searchParams.get("filter") ?? ""),
|
||||
};
|
||||
},
|
||||
|
||||
queryKey: ({ payload }) =>
|
||||
getPaginatedGroupsByOrganizationQueryKey(organization, payload),
|
||||
queryFn: ({ payload }) =>
|
||||
API.getOrganizationPaginatedGroups(organization, payload),
|
||||
};
|
||||
}
|
||||
|
||||
const getRootGroupQueryKey = (organization: string, groupName: string) => [
|
||||
"organization",
|
||||
organization,
|
||||
@@ -167,6 +200,33 @@ export function groupMembers(
|
||||
};
|
||||
}
|
||||
|
||||
export const getGroupMemberAvatarsQueryKey = (
|
||||
organization: string,
|
||||
groupName: string,
|
||||
limit: number,
|
||||
) => [...getGroupMembersQueryKey(organization, groupName), "avatars", limit];
|
||||
|
||||
/** Number of member avatars previewed per group row in list views. */
|
||||
export const GROUP_MEMBER_AVATAR_LIMIT = 5;
|
||||
|
||||
/**
|
||||
* A capped page of a group's members for avatar previews in list views. The
|
||||
* paginated groups endpoint no longer returns rosters, so rows fetch a small
|
||||
* preview lazily. Nests under the group members key so membership mutations
|
||||
* invalidate it.
|
||||
*/
|
||||
export const groupMemberAvatars = (
|
||||
organization: string,
|
||||
groupName: string,
|
||||
limit: number,
|
||||
): UseQueryOptions<GroupMembersResponse> => {
|
||||
return {
|
||||
queryKey: getGroupMemberAvatarsQueryKey(organization, groupName, limit),
|
||||
queryFn: ({ signal }) =>
|
||||
API.getGroupMembers(organization, groupName, { limit }, signal),
|
||||
};
|
||||
};
|
||||
|
||||
export type GroupsByUserId = Readonly<Map<string, readonly Group[]>>;
|
||||
|
||||
export function groupsByUserId() {
|
||||
|
||||
@@ -210,14 +210,16 @@ export const Filter: FC<FilterProps> = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PresetMenu
|
||||
value={filter.query}
|
||||
onSelect={(query) => filter.update(query)}
|
||||
presets={presets}
|
||||
learnMoreLink={learnMoreLink}
|
||||
learnMoreLabel2={learnMoreLabel2}
|
||||
learnMoreLink2={learnMoreLink2}
|
||||
/>
|
||||
{presets.length > 0 && (
|
||||
<PresetMenu
|
||||
value={filter.query}
|
||||
onSelect={(query) => filter.update(query)}
|
||||
presets={presets}
|
||||
learnMoreLink={learnMoreLink}
|
||||
learnMoreLabel2={learnMoreLabel2}
|
||||
learnMoreLink2={learnMoreLink2}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<SearchField
|
||||
ref={textboxInputRef}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FC } from "react";
|
||||
import { Filter, type useFilter } from "#/components/Filter/Filter";
|
||||
|
||||
interface GroupsFilterProps {
|
||||
filter: ReturnType<typeof useFilter>;
|
||||
}
|
||||
|
||||
// GroupsFilter renders a search-only filter. Groups support free-text search
|
||||
// against name and display name, so there are no presets or option menus.
|
||||
export const GroupsFilter: FC<GroupsFilterProps> = ({ filter }) => {
|
||||
return (
|
||||
<Filter
|
||||
presets={[]}
|
||||
isLoading={false}
|
||||
filter={filter}
|
||||
optionsSkeleton={null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,15 @@
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type FC, useEffect } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorDetail, getErrorMessage } from "#/api/errors";
|
||||
import {
|
||||
groupsByOrganization,
|
||||
organizationGroupsAISpend,
|
||||
paginatedGroupsByOrganization,
|
||||
} from "#/api/queries/groups";
|
||||
import { organizationsPermissions } from "#/api/queries/organizations";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { EmptyState } from "#/components/EmptyState/EmptyState";
|
||||
import { useFilter } from "#/components/Filter/Filter";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import {
|
||||
SettingsHeader,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
|
||||
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
@@ -29,17 +29,22 @@ const GroupsPage: FC = () => {
|
||||
const { template_rbac: groupsEnabled, aibridge } = useFeatureVisibility();
|
||||
const { organization, showOrganizations } = useGroupsSettings();
|
||||
const aibridgeVisible = Boolean(aibridge);
|
||||
const groupsQuery = useQuery({
|
||||
...groupsByOrganization(organization?.name ?? ""),
|
||||
enabled: Boolean(organization),
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const groupsQuery = usePaginatedQuery(
|
||||
paginatedGroupsByOrganization(organization?.name ?? "", searchParams),
|
||||
);
|
||||
const filter = useFilter({
|
||||
searchParams,
|
||||
onSearchParamsChange: setSearchParams,
|
||||
onUpdate: groupsQuery.goToFirstPage,
|
||||
});
|
||||
const groupIds = groupsQuery.data?.map((group) => group.id) ?? [];
|
||||
const groupIds = groupsQuery.data?.groups.map((group) => group.id) ?? [];
|
||||
const groupsSpendQuery = useQuery({
|
||||
...organizationGroupsAISpend(organization?.name ?? "", groupIds),
|
||||
enabled: aibridgeVisible && Boolean(organization) && groupIds.length > 0,
|
||||
});
|
||||
const groupsWithSpend = joinGroupsSpend(
|
||||
groupsQuery.data,
|
||||
groupsQuery.data?.groups,
|
||||
groupsSpendQuery.data,
|
||||
);
|
||||
const permissionsQuery = useQuery({
|
||||
@@ -113,15 +118,6 @@ const GroupsPage: FC = () => {
|
||||
{showOrganizations ? "organization" : "deployment"}.
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
|
||||
{groupsEnabled && permissions.createGroup && (
|
||||
<Button asChild>
|
||||
<RouterLink to="create">
|
||||
<PlusIcon className="size-icon-sm" />
|
||||
Create group
|
||||
</RouterLink>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GroupsPageView
|
||||
@@ -130,6 +126,8 @@ const GroupsPage: FC = () => {
|
||||
canCreateGroup={permissions.createGroup}
|
||||
groupsEnabled={groupsEnabled}
|
||||
showAIBudget={aibridgeVisible}
|
||||
filterProps={{ filter }}
|
||||
groupsQuery={groupsQuery}
|
||||
permissions={authPermissions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ComponentProps } from "react";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { MockGroup, MockPermissions } from "#/testHelpers/entities";
|
||||
import {
|
||||
GROUP_MEMBER_AVATAR_LIMIT,
|
||||
getGroupMemberAvatarsQueryKey,
|
||||
} from "#/api/queries/groups";
|
||||
import { getDefaultFilterProps } from "#/components/Filter/storyHelpers";
|
||||
import type { UsersFilter } from "#/components/Filter/UsersFilter";
|
||||
import {
|
||||
mockInitialRenderResult,
|
||||
mockSuccessResult,
|
||||
} from "#/components/PaginationWidget/PaginationContainer.mocks";
|
||||
import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery";
|
||||
import {
|
||||
MockGroup,
|
||||
MockOrganization,
|
||||
MockPermissions,
|
||||
MockUserMember,
|
||||
MockUserOwner,
|
||||
} from "#/testHelpers/entities";
|
||||
import { GroupsPageView, type GroupWithSpend } from "./GroupsPageView";
|
||||
|
||||
type FilterProps = ComponentProps<typeof UsersFilter>;
|
||||
|
||||
const meta: Meta<typeof GroupsPageView> = {
|
||||
title: "pages/OrganizationGroupsPage",
|
||||
component: GroupsPageView,
|
||||
args: {
|
||||
canCreateGroup: true,
|
||||
groupsEnabled: true,
|
||||
filterProps: getDefaultFilterProps<FilterProps>({
|
||||
values: {},
|
||||
menus: {},
|
||||
}),
|
||||
groupsQuery: {
|
||||
...mockSuccessResult,
|
||||
totalRecords: 1,
|
||||
} as UsePaginatedQueryResult,
|
||||
permissions: MockPermissions,
|
||||
},
|
||||
};
|
||||
@@ -19,17 +49,36 @@ const mockGroupWithSpend: GroupWithSpend = {
|
||||
spend: undefined,
|
||||
};
|
||||
|
||||
// AI-budget and pagination stories aren't about membership, so give their
|
||||
// groups no members. Rows with a zero count skip the per-row avatar fetch.
|
||||
const aiGroup = (id: string, name: string): GroupWithSpend => ({
|
||||
...mockGroupWithSpend,
|
||||
id,
|
||||
name,
|
||||
display_name: name,
|
||||
total_member_count: 0,
|
||||
});
|
||||
|
||||
// Seeds the per-row member avatar preview query for a group so the row renders
|
||||
// avatars deterministically without hitting the network.
|
||||
const seedAvatars = (
|
||||
groupName: string,
|
||||
users: ReadonlyArray<typeof MockUserOwner>,
|
||||
totalCount: number,
|
||||
) => ({
|
||||
key: getGroupMemberAvatarsQueryKey(
|
||||
MockOrganization.name,
|
||||
groupName,
|
||||
GROUP_MEMBER_AVATAR_LIMIT,
|
||||
),
|
||||
data: { users, count: totalCount },
|
||||
});
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const NotEnabled: Story = {
|
||||
args: {
|
||||
groups: [{ ...mockGroupWithSpend }],
|
||||
canCreateGroup: true,
|
||||
groups: [mockGroupWithSpend],
|
||||
groupsEnabled: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
@@ -59,16 +108,87 @@ export const NotEnabledWithoutLicenseAccess: Story = {
|
||||
|
||||
export const WithGroups: Story = {
|
||||
args: {
|
||||
groups: [{ ...mockGroupWithSpend }],
|
||||
canCreateGroup: true,
|
||||
groupsEnabled: true,
|
||||
groups: [mockGroupWithSpend],
|
||||
},
|
||||
parameters: {
|
||||
queries: [seedAvatars(MockGroup.name, [MockUserOwner, MockUserMember], 2)],
|
||||
},
|
||||
};
|
||||
|
||||
// A group with more members than fit in the preview: the row shows the capped
|
||||
// avatars plus a "+N" badge derived from total_member_count.
|
||||
export const WithMemberAvatars: Story = {
|
||||
args: {
|
||||
groups: [
|
||||
{
|
||||
...mockGroupWithSpend,
|
||||
id: "with-members",
|
||||
name: "with-members",
|
||||
display_name: "With members",
|
||||
total_member_count: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
seedAvatars(
|
||||
"with-members",
|
||||
Array.from({ length: GROUP_MEMBER_AVATAR_LIMIT }, (_, i) => ({
|
||||
...MockUserOwner,
|
||||
id: `preview-${i}`,
|
||||
username: `member-${i}`,
|
||||
})),
|
||||
8,
|
||||
),
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByText("+3")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("8 members")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
const totalRecords = 15;
|
||||
const totalPages = 3;
|
||||
const limit = totalRecords / totalPages;
|
||||
|
||||
// Multiple pages of results with the search field in use.
|
||||
export const WithSearchAndPagination: Story = {
|
||||
args: {
|
||||
groups: Array.from({ length: limit }, (_, i) =>
|
||||
aiGroup(`group-${i}`, `Group ${i}`),
|
||||
),
|
||||
filterProps: getDefaultFilterProps<FilterProps>({
|
||||
query: "group",
|
||||
values: {},
|
||||
menus: {},
|
||||
used: true,
|
||||
}),
|
||||
groupsQuery: {
|
||||
...mockSuccessResult,
|
||||
totalRecords,
|
||||
totalPages,
|
||||
limit,
|
||||
hasNextPage: true,
|
||||
} as UsePaginatedQueryResult,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByLabelText("Filter")).toHaveValue("group");
|
||||
},
|
||||
};
|
||||
|
||||
// Groups still loading: the pagination + table render their loading states.
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
groups: undefined,
|
||||
groupsQuery: mockInitialRenderResult as UsePaginatedQueryResult,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAIBudgets: Story = {
|
||||
args: {
|
||||
canCreateGroup: true,
|
||||
groupsEnabled: true,
|
||||
showAIBudget: true,
|
||||
groups: [
|
||||
{
|
||||
@@ -165,9 +285,8 @@ export const WithAIBudgets: Story = {
|
||||
export const WithAIBudgetsLoading: Story = {
|
||||
args: {
|
||||
groups: undefined,
|
||||
canCreateGroup: true,
|
||||
groupsEnabled: true,
|
||||
showAIBudget: true,
|
||||
groupsQuery: mockInitialRenderResult as UsePaginatedQueryResult,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -211,8 +330,6 @@ export const WithAIBudgetsSpendError: Story = {
|
||||
export const WithAIBudgetsSpendUnavailable: Story = {
|
||||
args: {
|
||||
groups: [aiGroup("ai-unavailable", "Spend unavailable")],
|
||||
canCreateGroup: true,
|
||||
groupsEnabled: true,
|
||||
showAIBudget: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
@@ -227,8 +344,6 @@ export const WithAIBudgetsSpendUnavailable: Story = {
|
||||
export const WithoutAIBudgetColumn: Story = {
|
||||
args: {
|
||||
groups: [aiGroup("ai-hidden", "No AI column")],
|
||||
canCreateGroup: true,
|
||||
groupsEnabled: true,
|
||||
showAIBudget: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
@@ -240,8 +355,9 @@ export const WithoutAIBudgetColumn: Story = {
|
||||
export const WithDisplayGroup: Story = {
|
||||
args: {
|
||||
groups: [{ ...mockGroupWithSpend, name: "front-end" }],
|
||||
canCreateGroup: true,
|
||||
groupsEnabled: true,
|
||||
},
|
||||
parameters: {
|
||||
queries: [seedAvatars("front-end", [MockUserOwner, MockUserMember], 2)],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -249,14 +365,32 @@ export const EmptyGroup: Story = {
|
||||
args: {
|
||||
groups: [],
|
||||
canCreateGroup: false,
|
||||
groupsEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyGroupWithPermission: Story = {
|
||||
args: {
|
||||
groups: [],
|
||||
canCreateGroup: true,
|
||||
groupsEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
// A search that matches nothing shows filter-aware copy, not the
|
||||
// create-first-group empty state.
|
||||
export const NoSearchResults: Story = {
|
||||
args: {
|
||||
groups: [],
|
||||
filterProps: getDefaultFilterProps<FilterProps>({
|
||||
query: "nomatch",
|
||||
values: {},
|
||||
menus: {},
|
||||
used: true,
|
||||
}),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByText("No groups match your search"),
|
||||
).toBeInTheDocument();
|
||||
expect(canvas.queryByText("No groups yet")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { ChevronRightIcon, PlusIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link as RouterLink, useNavigate } from "react-router";
|
||||
import type { Group, OrganizationGroupsAISpend } from "#/api/typesGenerated";
|
||||
import {
|
||||
GROUP_MEMBER_AVATAR_LIMIT,
|
||||
groupMemberAvatars,
|
||||
} from "#/api/queries/groups";
|
||||
import type {
|
||||
OrganizationGroupsAISpend,
|
||||
PaginatedGroup,
|
||||
} from "#/api/typesGenerated";
|
||||
import { AIBudgetUsage } from "#/components/AIBudgetUsage/AIBudgetUsage";
|
||||
import { Avatar } from "#/components/Avatar/Avatar";
|
||||
import { AvatarData } from "#/components/Avatar/AvatarData";
|
||||
import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { EmptyState } from "#/components/EmptyState/EmptyState";
|
||||
import type { useFilter } from "#/components/Filter/Filter";
|
||||
import { GroupsFilter } from "#/components/Filter/GroupsFilter";
|
||||
import { PaginationContainer } from "#/components/PaginationWidget/PaginationContainer";
|
||||
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
|
||||
import { Skeleton } from "#/components/Skeleton/Skeleton";
|
||||
import {
|
||||
@@ -24,6 +36,7 @@ import {
|
||||
TableRowSkeleton,
|
||||
} from "#/components/TableLoader/TableLoader";
|
||||
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
|
||||
import type { PaginationResultInfo } from "#/hooks/usePaginatedQuery";
|
||||
import type { Permissions } from "#/modules/permissions";
|
||||
import { docs } from "#/utils/docs";
|
||||
import { SpendEstimateDocsLink } from "./AICostControl";
|
||||
@@ -31,13 +44,16 @@ import { StatusIconTooltip } from "./StatusIconTooltip";
|
||||
|
||||
const EM_DASH = "\u2014";
|
||||
|
||||
export type GroupWithSpend = Group & {
|
||||
// Stable keys for the avatar loading skeletons (indexes would trip lint).
|
||||
const AVATAR_SKELETON_KEYS = ["a", "b", "c", "d", "e"];
|
||||
|
||||
export type GroupWithSpend = PaginatedGroup & {
|
||||
readonly spend: OrganizationGroupsAISpend["groups"][number] | undefined;
|
||||
};
|
||||
|
||||
/** Attach each group's spend, when present, so rows get a single object. */
|
||||
export const joinGroupsSpend = (
|
||||
groups: Group[] | undefined,
|
||||
groups: readonly PaginatedGroup[] | undefined,
|
||||
groupsSpend: OrganizationGroupsAISpend | undefined,
|
||||
): GroupWithSpend[] | undefined => {
|
||||
if (groups === undefined) {
|
||||
@@ -59,6 +75,10 @@ type GroupsPageViewProps = {
|
||||
canCreateGroup: boolean;
|
||||
groupsEnabled: boolean;
|
||||
showAIBudget: boolean;
|
||||
filterProps: { filter: ReturnType<typeof useFilter> };
|
||||
groupsQuery: PaginationResultInfo & {
|
||||
isPlaceholderData: boolean;
|
||||
};
|
||||
permissions: Permissions;
|
||||
};
|
||||
|
||||
@@ -68,6 +88,8 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
|
||||
canCreateGroup,
|
||||
groupsEnabled,
|
||||
showAIBudget,
|
||||
filterProps,
|
||||
groupsQuery,
|
||||
permissions,
|
||||
}) => {
|
||||
if (!groupsEnabled) {
|
||||
@@ -82,46 +104,64 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Table aria-label="Groups">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-2/5">Name</TableHead>
|
||||
<TableHead className={showAIBudget ? "w-1/5" : "w-3/5"}>
|
||||
Users
|
||||
</TableHead>
|
||||
{showAIBudget && (
|
||||
<TableHead className="w-2/5">
|
||||
<div className="flex items-center gap-1">
|
||||
AI spend
|
||||
{spendError ? (
|
||||
<StatusIconTooltip
|
||||
kind="warning"
|
||||
message="AI spend couldn't be loaded, so budgets aren't shown."
|
||||
/>
|
||||
) : (
|
||||
<StatusIconTooltip
|
||||
message={
|
||||
<>
|
||||
Approximate AI spend compared to the group's AI budget
|
||||
for the active period. <SpendEstimateDocsLink />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="w-auto" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<GroupsTableBody
|
||||
groups={groups}
|
||||
canCreateGroup={canCreateGroup}
|
||||
showAIBudget={showAIBudget}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row justify-between">
|
||||
<GroupsFilter {...filterProps} />
|
||||
{canCreateGroup && (
|
||||
<Button asChild>
|
||||
<RouterLink to="create">
|
||||
<PlusIcon className="size-icon-sm" />
|
||||
Create group
|
||||
</RouterLink>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PaginationContainer query={groupsQuery} paginationUnitLabel="groups">
|
||||
<Table aria-label="Groups">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-2/5">Name</TableHead>
|
||||
<TableHead className={showAIBudget ? "w-1/5" : "w-3/5"}>
|
||||
Users
|
||||
</TableHead>
|
||||
{showAIBudget && (
|
||||
<TableHead className="w-2/5">
|
||||
<div className="flex items-center gap-1">
|
||||
AI spend
|
||||
{spendError ? (
|
||||
<StatusIconTooltip
|
||||
kind="warning"
|
||||
message="AI spend couldn't be loaded, so budgets aren't shown."
|
||||
/>
|
||||
) : (
|
||||
<StatusIconTooltip
|
||||
message={
|
||||
<>
|
||||
Approximate AI spend compared to the group's AI
|
||||
budget for the active period.{" "}
|
||||
<SpendEstimateDocsLink />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="w-auto" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<GroupsTableBody
|
||||
groups={groups}
|
||||
canCreateGroup={canCreateGroup}
|
||||
showAIBudget={showAIBudget}
|
||||
filterUsed={filterProps.filter.used}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PaginationContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -129,17 +169,33 @@ interface GroupsTableBodyProps {
|
||||
groups: GroupWithSpend[] | undefined;
|
||||
canCreateGroup: boolean;
|
||||
showAIBudget: boolean;
|
||||
filterUsed: boolean;
|
||||
}
|
||||
|
||||
const GroupsTableBody: FC<GroupsTableBodyProps> = ({
|
||||
groups,
|
||||
canCreateGroup,
|
||||
showAIBudget,
|
||||
filterUsed,
|
||||
}) => {
|
||||
if (groups === undefined) {
|
||||
return <TableLoader showAIBudget={showAIBudget} />;
|
||||
}
|
||||
if (groups.length === 0) {
|
||||
// When a search returned no matches, don't nudge the user to create a
|
||||
// first group; the org may already have groups that simply don't match.
|
||||
if (filterUsed) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No groups match your search"
|
||||
description="Try a different search term."
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TableEmpty
|
||||
message="No groups yet"
|
||||
@@ -180,8 +236,23 @@ const GroupRow: FC<GroupRowProps> = ({ group, showAIBudget }) => {
|
||||
const rowProps = useClickableTableRow({
|
||||
onClick: () => navigate(group.name),
|
||||
});
|
||||
const memberAvatars = group.members.slice(0, 5);
|
||||
const remainingAvatars = group.members.length - memberAvatars.length;
|
||||
|
||||
// The list endpoint returns only total_member_count, so fetch a small
|
||||
// avatar preview per visible row instead of a full roster.
|
||||
const membersQuery = useQuery({
|
||||
...groupMemberAvatars(
|
||||
group.organization_name,
|
||||
group.name,
|
||||
GROUP_MEMBER_AVATAR_LIMIT,
|
||||
),
|
||||
enabled: group.total_member_count > 0,
|
||||
});
|
||||
const memberAvatars = membersQuery.data?.users ?? [];
|
||||
const remainingAvatars = group.total_member_count - memberAvatars.length;
|
||||
const skeletonCount = Math.min(
|
||||
group.total_member_count,
|
||||
GROUP_MEMBER_AVATAR_LIMIT,
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow data-testid={`group-${group.id}`} {...rowProps}>
|
||||
@@ -196,12 +267,24 @@ const GroupRow: FC<GroupRowProps> = ({ group, showAIBudget }) => {
|
||||
/>
|
||||
}
|
||||
title={group.display_name || group.name}
|
||||
subtitle={`${group.members.length} members`}
|
||||
subtitle={`${group.total_member_count} members`}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{group.members.length > 0 ? (
|
||||
{group.total_member_count === 0 || membersQuery.isError ? (
|
||||
EM_DASH
|
||||
) : membersQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{AVATAR_SKELETON_KEYS.slice(0, skeletonCount).map((key) => (
|
||||
<Skeleton
|
||||
key={key}
|
||||
variant="circular"
|
||||
className="size-[--avatar-default]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{memberAvatars.map((member) => (
|
||||
<Avatar
|
||||
@@ -216,8 +299,6 @@ const GroupRow: FC<GroupRowProps> = ({ group, showAIBudget }) => {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
EM_DASH
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user