diff --git a/front-api/routes/w/[wId]/groups/index.test.ts b/front-api/routes/w/[wId]/groups/index.test.ts new file mode 100644 index 000000000000..bf248ad60126 --- /dev/null +++ b/front-api/routes/w/[wId]/groups/index.test.ts @@ -0,0 +1,71 @@ +import { Authenticator } from "@app/lib/auth"; +import { GroupFactory } from "@app/tests/utils/GroupFactory"; +import { createPrivateApiMockRequest } from "@app/tests/utils/generic_private_api_tests"; +import { MembershipFactory } from "@app/tests/utils/MembershipFactory"; +import { UserFactory } from "@app/tests/utils/UserFactory"; +import { honoApp } from "@front-api/app"; +import { describe, expect, it } from "vitest"; + +function getGroupsRequest(wId: string, query: Record = {}) { + const qs = new URLSearchParams(query).toString(); + return honoApp.request(`/api/w/${wId}/groups${qs ? `?${qs}` : ""}`); +} + +describe("GET /api/w/:wId/groups", () => { + it("returns memberCount but no memberIds by default", async () => { + const { workspace } = await createPrivateApiMockRequest({ + role: "admin", + }); + const adminAuth = await Authenticator.internalAdminForWorkspace( + workspace.sId + ); + const alice = await UserFactory.basic(); + await MembershipFactory.associate(workspace, alice, { role: "user" }); + const sales = await GroupFactory.regularManual(workspace, "Sales"); + await GroupFactory.withMembers(adminAuth, sales, [alice]); + + const response = await getGroupsRequest(workspace.sId, { + kind: "regular_manual", + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.groups).toEqual([ + expect.objectContaining({ + sId: sales.sId, + name: "Sales", + memberCount: 1, + }), + ]); + expect(body.groups[0].memberIds).toBeUndefined(); + }); + + it("returns memberIds when withMembers=true is requested", async () => { + const { workspace } = await createPrivateApiMockRequest({ + role: "admin", + }); + const adminAuth = await Authenticator.internalAdminForWorkspace( + workspace.sId + ); + const alice = await UserFactory.basic(); + await MembershipFactory.associate(workspace, alice, { role: "user" }); + const sales = await GroupFactory.regularManual(workspace, "Sales"); + await GroupFactory.withMembers(adminAuth, sales, [alice]); + + const response = await getGroupsRequest(workspace.sId, { + kind: "regular_manual", + withMembers: "true", + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.groups).toEqual([ + expect.objectContaining({ + sId: sales.sId, + name: "Sales", + memberCount: 1, + memberIds: [alice.sId], + }), + ]); + }); +}); diff --git a/front-api/routes/w/[wId]/groups/index.ts b/front-api/routes/w/[wId]/groups/index.ts index 93bc6fb830a8..c90772d8c629 100644 --- a/front-api/routes/w/[wId]/groups/index.ts +++ b/front-api/routes/w/[wId]/groups/index.ts @@ -23,6 +23,9 @@ export type GetGroupsResponseBody = { const GetGroupsQuerySchema = z.object({ kind: z.union([GroupKindCodec, z.array(GroupKindCodec)]).optional(), spaceId: z.string().optional(), + // When "true", each group also carries its member sIds (one extra batched + // query) instead of just memberCount. + withMembers: z.enum(["true", "false"]).optional(), }); // Mounted at /api/w/:wId/groups. @@ -34,7 +37,7 @@ app.get( validate("query", GetGroupsQuerySchema), async (ctx): HandlerResult => { const auth = ctx.get("auth"); - const { kind, spaceId } = ctx.req.valid("query"); + const { kind, spaceId, withMembers } = ctx.req.valid("query"); const groupKinds: GroupKind[] = kind ? Array.isArray(kind) @@ -47,7 +50,10 @@ app.get( : await GroupResource.listAllWorkspaceGroups(auth, { groupKinds }); return ctx.json({ - groups: await GroupResource.toJSONWithMemberCounts(auth, groups), + groups: + withMembers === "true" + ? await GroupResource.fetchJSONWithMembers(auth, groups) + : await GroupResource.toJSONWithMemberCounts(auth, groups), }); } ); diff --git a/front/components/pages/workspace/AnalyticsConsumptionPage.tsx b/front/components/pages/workspace/AnalyticsConsumptionPage.tsx index 6db77aed2661..89eae1b01354 100644 --- a/front/components/pages/workspace/AnalyticsConsumptionPage.tsx +++ b/front/components/pages/workspace/AnalyticsConsumptionPage.tsx @@ -6,10 +6,7 @@ import { DEFAULT_CONSUMPTION_DIMENSION } from "@app/components/workspace/analyti import { UsageFilterPanel } from "@app/components/workspace/analytics/UsageFilterPanel"; import type { UsageFilter } from "@app/components/workspace/analytics/usageFilter"; import { toConsumptionScopeFilter } from "@app/components/workspace/analytics/usageFilter"; -import { - USAGE_FILTER_MOCK_GROUPS, - USAGE_FILTER_MOCK_OPTIONS, -} from "@app/components/workspace/analytics/usageFilterMockData"; +import { USAGE_FILTER_MOCK_OPTIONS } from "@app/components/workspace/analytics/usageFilterMockData"; import type { ConsumptionPeriodSelection } from "@app/lib/analytics/consumption_period"; import { DEFAULT_CONSUMPTION_PERIOD } from "@app/lib/analytics/consumption_period"; import { useFeatureFlags, useWorkspace } from "@app/lib/auth/AuthContext"; @@ -93,7 +90,6 @@ export function AnalyticsConsumptionPage() { diff --git a/front/components/workspace/analytics/UsageFilterPanel.tsx b/front/components/workspace/analytics/UsageFilterPanel.tsx index e951d40b5596..1d724eb063e1 100644 --- a/front/components/workspace/analytics/UsageFilterPanel.tsx +++ b/front/components/workspace/analytics/UsageFilterPanel.tsx @@ -26,7 +26,11 @@ import { UsageFilterModelComplexityControls } from "@app/components/workspace/an import { UsageFilterOptionCheckboxList } from "@app/components/workspace/analytics/usageFilterPanel/UsageFilterOptionCheckboxList"; import { UsageFilterSelectionSummary } from "@app/components/workspace/analytics/usageFilterPanel/UsageFilterSelectionSummary"; import { useUsageFilter } from "@app/components/workspace/analytics/useUsageFilter"; +import { useToggleSelectionList } from "@app/hooks/useToggleSelectionList"; +import { useGroups } from "@app/lib/swr/groups"; import { useSearchMembers } from "@app/lib/swr/memberships"; +import { MANAGEABLE_GROUP_KINDS } from "@app/types/groups"; +import { assertNever } from "@app/types/shared/utils/assert_never"; import type { LightWorkspaceType } from "@app/types/user"; import { BarChart05, @@ -37,10 +41,23 @@ import { PopoverTrigger, SearchInput, } from "@dust-tt/sparkle"; -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +// Chunk size for the infinite scroll +const FILTER_PICKER_PAGE_SIZE = 100; + +interface UsageFilterPaginationState { + hasMore: boolean; + isLoadingMore: boolean; + onLoadMore: () => void; +} interface UsageFilterPanelProps { owner: LightWorkspaceType; + // Agents/models/tools/skills/sources are still mock data (see + // usageFilterMockData.ts — sources are fake connectors standing in for a + // real db call); members and groups are fetched live below, via the generic + // member search and group listing endpoints (useSearchMembers, useGroups). categoryOptions: { agent: UsageFilterAgentOption[]; model: UsageFilterModelOption[]; @@ -48,7 +65,6 @@ interface UsageFilterPanelProps { skill: UsageFilterSkillOption[]; source: UsageFilterSourceOption[]; }; - groups: UsageFilterGroup[]; filter: UsageFilter; onFilterChange: (next: UsageFilter) => void; } @@ -56,7 +72,6 @@ interface UsageFilterPanelProps { export function UsageFilterPanel({ owner, categoryOptions, - groups, filter, onFilterChange, }: UsageFilterPanelProps) { @@ -81,24 +96,92 @@ export function UsageFilterPanel({ USAGE_MODEL_TIERS[0] ); const [searchText, setSearchText] = useState(""); + // Only used for the "member" category: narrows the displayed members down + // to those belonging to at least one of these groups. + const selectedGroups = useToggleSelectionList(); + + const isMemberCategoryActive = isOpen && activeCategory === "member"; - const { members } = useSearchMembers({ + // Every category picker supports scroll-to-load-more: + const [memberPageIndex, setMemberPageIndex] = useState(0); + const [accumulatedMemberOptions, setAccumulatedMemberOptions] = useState< + UsageFilterMemberOption[] + >([]); + const [visibleStaticCount, setVisibleStaticCount] = useState( + FILTER_PICKER_PAGE_SIZE + ); + + const resetFilterPickerPagination = useCallback(() => { + setMemberPageIndex(0); + setVisibleStaticCount(FILTER_PICKER_PAGE_SIZE); + }, []); + + // Search is applied server-side by useSearchMembers, same as the sibling + // AnalyticsFilterDropdown's member picker. + const { + members: searchedMembers, + totalMembersCount, + isMembersValidating, + } = useSearchMembers({ workspaceId: owner.sId, - searchTerm: activeCategory === "member" ? searchText : "", - pageIndex: 0, - pageSize: 100, - disabled: !isOpen || activeCategory !== "member", + searchTerm: searchText, + pageIndex: memberPageIndex, + pageSize: FILTER_PICKER_PAGE_SIZE, + disabled: !isMemberCategoryActive, + }); + + useEffect(() => { + const page = searchedMembers.map((member) => ({ + id: member.sId, + name: member.fullName, + kind: "member" as const, + image: member.image, + })); + if (memberPageIndex === 0) { + setAccumulatedMemberOptions(page); + return; + } + if (page.length === 0) { + return; + } + setAccumulatedMemberOptions((prev) => { + const existingIds = new Set(prev.map((option) => option.id)); + const newOptions = page.filter((option) => !existingIds.has(option.id)); + return newOptions.length > 0 ? [...prev, ...newOptions] : prev; + }); + }, [searchedMembers, memberPageIndex]); + + // Whether more members exist server-side, independent of the client-side + // group filter below — scrolling must keep fetching even if the current + // group filter narrows the visible list to fewer than a full page. + const hasMoreMembers = accumulatedMemberOptions.length < totalMembersCount; + + const handleLoadMoreMembers = useCallback(() => { + if (isMembersValidating || !hasMoreMembers) { + return; + } + setMemberPageIndex((current) => current + 1); + }, [isMembersValidating, hasMoreMembers]); + + const handleLoadMoreStaticOptions = useCallback(() => { + setVisibleStaticCount((current) => current + FILTER_PICKER_PAGE_SIZE); + }, []); + + const { groups: workspaceGroups } = useGroups({ + owner, + kinds: MANAGEABLE_GROUP_KINDS, + withMembers: true, + disabled: !isMemberCategoryActive, }); - const memberOptions = useMemo( + const groups = useMemo( () => - members.map((m) => ({ - id: m.sId, - name: m.fullName, - kind: "member", - image: m.image, + workspaceGroups.map((group) => ({ + id: group.sId, + name: group.name, + memberIds: group.memberIds ?? [], })), - [members] + [workspaceGroups] ); const resolvedCategoryOptions = useMemo<{ @@ -106,27 +189,93 @@ export function UsageFilterPanel({ }>( () => ({ ...categoryOptions, - member: memberOptions, + member: accumulatedMemberOptions, }), - [categoryOptions, memberOptions] + [categoryOptions, accumulatedMemberOptions] ); const activeOptions = resolvedCategoryOptions[activeCategory]; const filteredOptions = useMemo(() => { const search = searchText.trim().toLowerCase(); - return activeOptions.filter((option) => { + const selectedGroupMemberIds = + activeCategory === "member" && selectedGroups.items.length > 0 + ? new Set(selectedGroups.items.flatMap((group) => group.memberIds)) + : null; + const matchingOptions = activeOptions.filter((option) => { if (option.kind === "agent" && option.scope !== activeScope) { return false; } if (option.kind === "model" && option.tier !== activeTier) { return false; } - if (search && !option.name.toLowerCase().includes(search)) { + if (selectedGroupMemberIds && !selectedGroupMemberIds.has(option.id)) { + return false; + } + // The member category is already searched server-side by + // useSearchMembers; re-filtering client-side here would just drop + // results while the debounced search catches up. + if ( + activeCategory !== "member" && + search && + !option.name.toLowerCase().includes(search) + ) { return false; } return true; }); - }, [activeOptions, searchText, activeScope, activeTier]); + return matchingOptions; + }, [ + activeOptions, + searchText, + activeScope, + activeTier, + activeCategory, + selectedGroups.items, + ]); + + // Members are already paginated server-side into filteredOptions; the + // other categories reveal a growing window of the already-loaded + // filteredOptions as the user scrolls. + const displayedOptions = useMemo( + () => + activeCategory === "member" + ? filteredOptions + : filteredOptions.slice(0, visibleStaticCount), + [filteredOptions, activeCategory, visibleStaticCount] + ); + + const hasMoreStaticOptions = + activeCategory !== "member" && visibleStaticCount < filteredOptions.length; + + const activePagination = useMemo(() => { + switch (activeCategory) { + case "member": + return { + hasMore: hasMoreMembers, + isLoadingMore: isMembersValidating, + onLoadMore: handleLoadMoreMembers, + }; + case "agent": + case "model": + case "tool": + case "skill": + case "source": + return { + hasMore: hasMoreStaticOptions, + isLoadingMore: false, + onLoadMore: handleLoadMoreStaticOptions, + }; + default: + return assertNever(activeCategory); + } + }, [ + activeCategory, + hasMoreMembers, + isMembersValidating, + handleLoadMoreMembers, + hasMoreStaticOptions, + handleLoadMoreStaticOptions, + ]); const selectedIdsForActiveCategory = useMemo( () => @@ -156,14 +305,32 @@ export function UsageFilterPanel({ if (open) { setDraftFilter(filter); setSearchText(""); + selectedGroups.setItems([]); + resetFilterPickerPagination(); } }; const handleCategoryChange = (category: UsageFilterCategory) => { setActiveCategory(category); setSearchText(""); + resetFilterPickerPagination(); }; + const handleSearchTextChange = (text: string) => { + setSearchText(text); + resetFilterPickerPagination(); + }; + + // Category-specific "active option" controls (scope, tier, ...) all need + // to reset pagination on change; wrap their setters once instead of + // writing a dedicated handleXxxChange per category. + const withPaginationReset = + (setter: (value: T) => void) => + (value: T) => { + setter(value); + resetFilterPickerPagination(); + }; + const handleCancel = () => { setIsOpen(false); }; @@ -215,11 +382,16 @@ export function UsageFilterPanel({ {activeCategory === "member" && ( - + )} {activeCategory === "model" && ( toggleOption("model", model)} activeTier={activeTier} - onTierChange={setActiveTier} + onTierChange={withPaginationReset(setActiveTier)} /> )} {activeCategory === "agent" && ( )} toggleOption(activeCategory, option)} onSelectAll={() => selectAllFiltered(activeCategory, filteredOptions) } + hasMore={activePagination.hasMore} + isLoadingMore={activePagination.isLoadingMore} + onLoadMore={activePagination.onLoadMore} /> = @@ -139,24 +140,7 @@ export function selectAllUsageFilterOptions( return { ...filter, [category]: [...current, ...additions] }; } -export function addUsageFilterGroup( - groups: UsageFilterGroup[], - group: UsageFilterGroup -): UsageFilterGroup[] { - if (groups.some((g) => g.id === group.id)) { - return groups; - } - return [...groups, group]; -} - -export function removeUsageFilterGroup( - groups: UsageFilterGroup[], - id: string -): UsageFilterGroup[] { - return groups.filter((g) => g.id !== id); -} - -// Only "member" is wired to a real consumption scope dimension ("user") so +// Only "member" is wired to a real consumption scope dimension ("users") so // far; the other categories stay mock data and are not sent as query filters. export function toConsumptionScopeFilter( filter: UsageFilter diff --git a/front/components/workspace/analytics/usageFilterMockData.ts b/front/components/workspace/analytics/usageFilterMockData.ts index 9249fea5c7d9..d4d6f82939b7 100644 --- a/front/components/workspace/analytics/usageFilterMockData.ts +++ b/front/components/workspace/analytics/usageFilterMockData.ts @@ -1,6 +1,5 @@ import type { UsageFilterAgentOption, - UsageFilterGroup, UsageFilterModelOption, UsageFilterSkillOption, UsageFilterSourceOption, @@ -28,30 +27,9 @@ const MOCK_MODEL_LAB: Record = { "DeepSeek V4": "deepseek", }; -const MOCK_GROUP_NAMES = [ - "Engineering", - "Sales", - "Product", - "Design", - "Support", - "Finance", - "Legal", - "HR", - "Marketing", - "Leadership", - "Customer Success", - "Operations", -]; - -export const USAGE_FILTER_MOCK_GROUPS: UsageFilterGroup[] = - MOCK_GROUP_NAMES.map((name, index) => ({ - id: `group_${index + 1}`, - name, - })); - // Placeholder data for categories not yet wired to a real backend endpoint. -// Members are fetched live in UsageFilterPanel via useSearchMembers. Lists -// are long enough to exercise scrolling in the preview. +// Members and groups are fetched live in UsageFilterPanel (useSearchMembers, +// useGroups). Lists are long enough to exercise scrolling in the preview. const MOCK_ENTITY_NAMES = { agent: [ "SupportBot", diff --git a/front/components/workspace/analytics/usageFilterPanel/UsageFilterMemberGroupsControls.tsx b/front/components/workspace/analytics/usageFilterPanel/UsageFilterMemberGroupsControls.tsx index 43888e08419d..a33dae2002be 100644 --- a/front/components/workspace/analytics/usageFilterPanel/UsageFilterMemberGroupsControls.tsx +++ b/front/components/workspace/analytics/usageFilterPanel/UsageFilterMemberGroupsControls.tsx @@ -1,8 +1,4 @@ import type { UsageFilterGroup } from "@app/components/workspace/analytics/usageFilter"; -import { - addUsageFilterGroup, - removeUsageFilterGroup, -} from "@app/components/workspace/analytics/usageFilter"; import { Button, Chip, @@ -15,27 +11,28 @@ import { useState } from "react"; interface UsageFilterMemberGroupsControlsProps { groups: UsageFilterGroup[]; + selectedGroups: UsageFilterGroup[]; + onAddGroup: (group: UsageFilterGroup) => void; + onRemoveGroup: (id: string) => void; } export function UsageFilterMemberGroupsControls({ groups, + selectedGroups, + onAddGroup, + onRemoveGroup, }: UsageFilterMemberGroupsControlsProps) { const [isAddGroupOpen, setIsAddGroupOpen] = useState(false); - const [selectedGroups, setSelectedGroups] = useState([]); const availableGroups = groups.filter( (group) => !selectedGroups.some((selected) => selected.id === group.id) ); const handleAddGroup = (group: UsageFilterGroup) => { - setSelectedGroups((current) => addUsageFilterGroup(current, group)); + onAddGroup(group); setIsAddGroupOpen(false); }; - const handleRemoveGroup = (id: string) => { - setSelectedGroups((current) => removeUsageFilterGroup(current, id)); - }; - return ( <> handleRemoveGroup(group.id)} + onRemove={() => onRemoveGroup(group.id)} /> ))} diff --git a/front/components/workspace/analytics/usageFilterPanel/UsageFilterOptionCheckboxList.tsx b/front/components/workspace/analytics/usageFilterPanel/UsageFilterOptionCheckboxList.tsx index d0c54a3f9392..0bb50e07637a 100644 --- a/front/components/workspace/analytics/usageFilterPanel/UsageFilterOptionCheckboxList.tsx +++ b/front/components/workspace/analytics/usageFilterPanel/UsageFilterOptionCheckboxList.tsx @@ -1,3 +1,4 @@ +import { InfiniteScroll } from "@app/components/InfiniteScroll"; import type { UsageFilterCategory, UsageFilterOption, @@ -7,9 +8,10 @@ import { Button, Checkbox, Label, - NavigationList, NavigationListLabel, + Spinner, } from "@dust-tt/sparkle"; +import { useState } from "react"; interface UsageFilterOptionCheckboxListProps { category: UsageFilterCategory; @@ -18,6 +20,10 @@ interface UsageFilterOptionCheckboxListProps { selectedIds: Set; onToggleOption: (option: UsageFilterOption) => void; onSelectAll: () => void; + // Only set for categories backed by a paginated server fetch (members). + hasMore?: boolean; + isLoadingMore?: boolean; + onLoadMore?: () => void; } export function UsageFilterOptionCheckboxList({ @@ -27,7 +33,16 @@ export function UsageFilterOptionCheckboxList({ selectedIds, onToggleOption, onSelectAll, + hasMore = false, + isLoadingMore = false, + onLoadMore, }: UsageFilterOptionCheckboxListProps) { + // Tracked as state so InfiniteScroll re-renders once the node + // mounts and can attach its scroll listener directly to it. + const [scrollContainer, setScrollContainer] = useState( + null + ); + return ( <> } /> - +
{options.length > 0 ? ( - options.map((option) => { - const checked = selectedIds.has(option.id); - const checkboxId = `usage-filter-option-${category}-${option.id}`; - return ( -
- onToggleOption(option)} - /> - -
+ ); + })} + {onLoadMore && ( + + +
+ } + options={{ + root: scrollContainer, + rootMargin: "0px 0px 100px 0px", + }} + /> + )} + ) : (
No results
)} -
+ ); } diff --git a/front/hooks/useToggleSelectionList.ts b/front/hooks/useToggleSelectionList.ts new file mode 100644 index 000000000000..e9972b04a542 --- /dev/null +++ b/front/hooks/useToggleSelectionList.ts @@ -0,0 +1,20 @@ +import { useCallback, useState } from "react"; + +// Generic add/remove state for an unordered list of unique items, so a +// category-specific control (e.g. a group filter) doesn't need its own +// useState + add/remove handler pair. +export function useToggleSelectionList() { + const [items, setItems] = useState([]); + + const add = useCallback((item: T) => { + setItems((current) => + current.some((i) => i.id === item.id) ? current : [...current, item] + ); + }, []); + + const remove = useCallback((id: string) => { + setItems((current) => current.filter((i) => i.id !== id)); + }, []); + + return { items, add, remove, setItems }; +} diff --git a/front/lib/resources/group_resource.ts b/front/lib/resources/group_resource.ts index e8afa4d36aa4..1366244ff682 100644 --- a/front/lib/resources/group_resource.ts +++ b/front/lib/resources/group_resource.ts @@ -2959,4 +2959,33 @@ export class GroupResource extends BaseResource { memberCount: memberCounts.get(group.id) ?? 0, })); } + + /** + * Batched counterpart of `toJSONWithMemberCount` that also carries each + * group's active member sIds, resolved in two queries total regardless of + * group count. + */ + static async fetchJSONWithMembers( + auth: Authenticator, + groups: GroupResource[] + ): Promise<(GroupType & { memberIds: string[] })[]> { + const membershipsByGroup = + await GroupResource.getActiveMembershipsForGroups(auth, groups); + const userModelIds = [...new Set(Object.values(membershipsByGroup).flat())]; + const users = await UserResource.fetchByModelIds(userModelIds); + const sIdByModelId = new Map(users.map((user) => [user.id, user.sId])); + + return groups.map((group) => { + const memberIds = removeNulls( + (membershipsByGroup[group.id] ?? []).map((userModelId) => + sIdByModelId.get(userModelId) + ) + ); + return { + ...group.toJSON(), + memberCount: memberIds.length, + memberIds, + }; + }); + } } diff --git a/front/lib/swr/groups.ts b/front/lib/swr/groups.ts index d14aa43155e7..24e18dcfc003 100644 --- a/front/lib/swr/groups.ts +++ b/front/lib/swr/groups.ts @@ -24,11 +24,15 @@ export function useGroups({ owner, kinds, spaceId, + withMembers, disabled, }: { owner: LightWorkspaceType; kinds?: readonly GroupKind[]; spaceId?: string; + // Also resolves each group's member sIds (one extra batched query + // server-side) instead of just its memberCount. + withMembers?: boolean; disabled?: boolean; }) { const { fetcher } = useFetcher(); @@ -40,9 +44,12 @@ export function useGroups({ if (spaceId) { params.append("spaceId", spaceId); } + if (withMembers) { + params.append("withMembers", "true"); + } const queryString = params.toString(); return `/api/w/${owner.sId}/groups${queryString ? `?${queryString}` : ""}`; - }, [owner.sId, kinds, spaceId]); + }, [owner.sId, kinds, spaceId, withMembers]); const groupsFetcher: Fetcher = fetcher; diff --git a/front/lib/swr/memberships.ts b/front/lib/swr/memberships.ts index 91b2d84383fb..7140ce0fba0c 100644 --- a/front/lib/swr/memberships.ts +++ b/front/lib/swr/memberships.ts @@ -170,7 +170,7 @@ export function useSearchMembers< searchParams.set("role", role); } - const { data, error, mutate, mutateRegardlessOfQueryParams } = + const { data, error, isValidating, mutate, mutateRegardlessOfQueryParams } = useSWRWithDefaults( `/api/w/${workspaceId}/members/search?${searchParams.toString()}`, searchMembersFetcher, @@ -186,6 +186,7 @@ export function useSearchMembers< members: data?.members ?? emptyArray(), totalMembersCount: data?.total ?? 0, isLoading: !error && !data && !disabled, + isMembersValidating: isValidating, isError: !!error, mutate, mutateRegardlessOfQueryParams, diff --git a/front/types/groups.ts b/front/types/groups.ts index a0574305eaa9..834f45fa2d50 100644 --- a/front/types/groups.ts +++ b/front/types/groups.ts @@ -100,6 +100,8 @@ export type GroupType = { // Per-group usage spend limit (excluding seat allowance), applied per member. // null means the group carries no cap (falls back to the workspace default). poolCapAwuCredits: number | null; + // Member sIds, only populated when explicitly requested + memberIds?: string[]; }; export const GroupKindCodec = z.enum([