From 6c44622a02552fc63fe8769200342a881b7c4ea7 Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Wed, 16 Sep 2026 15:06:38 +0530 Subject: [PATCH 1/3] fix: search and filter org users on the server so results beyond the first page are found The org users and guests pages filtered members client-side, so a search or role filter only matched the 50 rows already loaded. Members are now queried with the API's `searchPattern` and `role` params (debounced, ILIKE-escaped), while invitations, which have no server filter, keep loading pages until exhausted whenever a filter is active. The table stays mounted while a new search loads: previous rows are kept as placeholder data and `InfiniteScrollTable` shows a footer spinner instead of the empty state. The spinner only appears for the initial load, placeholder data, or next-page fetches, not for background refetches. --- .../user-management/pagination.spec.ts | 113 +++++++++++ .../user-management/pagination.ts | 23 +++ .../user-management/selectors.spec.ts | 176 ++++++++++++++++++ .../user-management/selectors.ts | 55 ++++-- .../table/users/OrgUsersTable.svelte | 48 ++++- .../[organization]/-/users/+page.svelte | 145 ++++++++------- .../-/users/guests/+page.svelte | 156 +++++++++------- .../table/InfiniteScrollTable.spec.ts | 70 +++++++ .../table/InfiniteScrollTable.svelte | 37 +++- 9 files changed, 656 insertions(+), 167 deletions(-) create mode 100644 web-admin/src/features/organizations/user-management/pagination.spec.ts create mode 100644 web-admin/src/features/organizations/user-management/pagination.ts create mode 100644 web-admin/src/features/organizations/user-management/selectors.spec.ts create mode 100644 web-common/src/components/table/InfiniteScrollTable.spec.ts diff --git a/web-admin/src/features/organizations/user-management/pagination.spec.ts b/web-admin/src/features/organizations/user-management/pagination.spec.ts new file mode 100644 index 000000000000..0264b3b3336a --- /dev/null +++ b/web-admin/src/features/organizations/user-management/pagination.spec.ts @@ -0,0 +1,113 @@ +import { InfiniteQueryObserver, QueryClient } from "@tanstack/query-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadNextInvitePageForFilter } from "./pagination"; + +const clients: QueryClient[] = []; + +function createObserver(total: number) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + clients.push(client); + const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => + Promise.resolve({ + emails: Array.from( + { length: Math.min(50, total - pageParam) }, + (_, i) => `user${pageParam + i}@example.com`, + ), + nextPageToken: pageParam + 50 < total ? pageParam + 50 : undefined, + }), + ); + const observer = new InfiniteQueryObserver(client, { + queryKey: ["users"], + queryFn, + initialPageParam: 0, + getNextPageParam: (page) => page.nextPageToken, + }); + return { observer, queryFn }; +} + +afterEach(() => { + clients.splice(0).forEach((client) => client.clear()); +}); + +describe("loading invitation pages for organization user filters", () => { + it.each([ + ["invitations", 168], + ["pending invitations", 101], + ["guest invitations", 151], + ])("finds %s beyond the first page without scrolling", async (_, total) => { + const { observer, queryFn } = createObserver(total); + let hasActiveFilters = false; + const unsubscribe = observer.subscribe((query) => { + loadNextInvitePageForFilter(query, hasActiveFilters); + }); + + await vi.waitFor(() => + expect(observer.getCurrentResult().isSuccess).toBe(true), + ); + expect(queryFn).toHaveBeenCalledTimes(1); + const matchingEmails = () => + observer + .getCurrentResult() + .data?.pages.flatMap((page) => + page.emails.filter( + (email) => email === `user${total - 1}@example.com`, + ), + ); + expect(matchingEmails()).toEqual([]); + + hasActiveFilters = true; + loadNextInvitePageForFilter(observer.getCurrentResult(), hasActiveFilters); + + await vi.waitFor(() => + expect(observer.getCurrentResult().hasNextPage).toBe(false), + ); + expect(matchingEmails()).toEqual([`user${total - 1}@example.com`]); + expect(queryFn).toHaveBeenCalledTimes(Math.ceil(total / 50)); + unsubscribe(); + }); + + it("stops loading remaining pages when filters are cleared", async () => { + const { observer, queryFn } = createObserver(168); + let hasActiveFilters = false; + const unsubscribe = observer.subscribe((query) => { + loadNextInvitePageForFilter(query, hasActiveFilters); + }); + await vi.waitFor(() => + expect(observer.getCurrentResult().isSuccess).toBe(true), + ); + + hasActiveFilters = true; + loadNextInvitePageForFilter(observer.getCurrentResult(), hasActiveFilters); + hasActiveFilters = false; + + await vi.waitFor(() => + expect(observer.getCurrentResult().isFetching).toBe(false), + ); + expect(queryFn).toHaveBeenCalledTimes(2); + expect(observer.getCurrentResult().hasNextPage).toBe(true); + unsubscribe(); + }); + + it("does not repeatedly fetch a failing page", async () => { + const { observer, queryFn } = createObserver(168); + let hasActiveFilters = false; + const unsubscribe = observer.subscribe((query) => { + loadNextInvitePageForFilter(query, hasActiveFilters); + }); + await vi.waitFor(() => + expect(observer.getCurrentResult().isSuccess).toBe(true), + ); + + queryFn.mockRejectedValueOnce(new Error("Network error")); + hasActiveFilters = true; + loadNextInvitePageForFilter(observer.getCurrentResult(), hasActiveFilters); + + await vi.waitFor(() => + expect(observer.getCurrentResult().isError).toBe(true), + ); + expect(queryFn).toHaveBeenCalledTimes(2); + unsubscribe(); + }); +}); diff --git a/web-admin/src/features/organizations/user-management/pagination.ts b/web-admin/src/features/organizations/user-management/pagination.ts new file mode 100644 index 000000000000..04638aeb6861 --- /dev/null +++ b/web-admin/src/features/organizations/user-management/pagination.ts @@ -0,0 +1,23 @@ +type PaginatedQuery = { + hasNextPage: boolean; + isFetching: boolean; + isSuccess: boolean; + fetchNextPage: () => Promise; +}; + +// Only invitations need client-side filtering: ListOrganizationInvites has no +// search or role parameters. Members use the API's searchPattern and role filters. +// Continue fetching invitations even when none of the loaded rows match. +export function loadNextInvitePageForFilter( + query: PaginatedQuery, + hasActiveFilters: boolean, +) { + if ( + hasActiveFilters && + query.hasNextPage && + query.isSuccess && + !query.isFetching + ) { + void query.fetchNextPage(); + } +} diff --git a/web-admin/src/features/organizations/user-management/selectors.spec.ts b/web-admin/src/features/organizations/user-management/selectors.spec.ts new file mode 100644 index 000000000000..525338120457 --- /dev/null +++ b/web-admin/src/features/organizations/user-management/selectors.spec.ts @@ -0,0 +1,176 @@ +import { InfiniteQueryObserver, QueryClient } from "@tanstack/query-core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { httpClient } from "@rilldata/web-admin/client/http-client"; +import type { V1ListOrganizationMemberUsersResponse } from "@rilldata/web-admin/client"; +import { getOrgUserMembersQueryOptions } from "./selectors"; +import { invalidateOrgMemberUsers } from "./utils"; + +vi.mock("@rilldata/web-admin/client/http-client", () => ({ + httpClient: vi.fn(), +})); + +const request = vi.mocked(httpClient); +const filters = { organization: "acme", guestOnly: false }; +let client: QueryClient; + +beforeEach(() => { + request.mockReset(); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +}); +afterEach(() => client.clear()); + +describe("organization member search", () => { + it("searches all members on the server and only loads more matches on demand", async () => { + request.mockResolvedValueOnce({ + members: [{ userEmail: "user1999@example.com" }], + nextPageToken: "matching-page-2", + }); + const observer = new InfiniteQueryObserver( + client, + getOrgUserMembersQueryOptions({ + ...filters, + searchText: "1999", + role: "viewer", + }), + ); + const unsubscribe = observer.subscribe(() => {}); + await vi.waitFor(() => + expect(observer.getCurrentResult().isSuccess).toBe(true), + ); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + searchPattern: "%1999%", + role: "viewer", + pageSize: 50, + }), + }), + ); + expect( + observer.getCurrentResult().data?.pages[0].members?.[0].userEmail, + ).toBe("user1999@example.com"); + + request.mockResolvedValueOnce({ members: [], nextPageToken: "" }); + await observer.fetchNextPage(); + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + pageToken: "matching-page-2", + searchPattern: "%1999%", + role: "viewer", + }), + }), + ); + unsubscribe(); + }); + + it("keeps previous rows while a new search/role loads, then replaces them", async () => { + const oldRow = { userEmail: "old@example.com", roleName: "viewer" }; + const newRow = { userEmail: "new@example.com", roleName: "editor" }; + request.mockResolvedValueOnce({ + members: [oldRow], + nextPageToken: "old-page-2", + }); + const observer = new InfiniteQueryObserver( + client, + getOrgUserMembersQueryOptions(filters), + ); + const unsubscribe = observer.subscribe(() => {}); + await vi.waitFor(() => + expect(observer.getCurrentResult().isSuccess).toBe(true), + ); + + let resolveSearch!: (value: V1ListOrganizationMemberUsersResponse) => void; + request.mockReturnValueOnce( + new Promise((resolve) => { + resolveSearch = resolve; + }), + ); + observer.setOptions( + getOrgUserMembersQueryOptions({ + ...filters, + searchText: "new", + role: "editor", + }), + ); + expect(observer.getCurrentResult().isPlaceholderData).toBe(true); + expect(observer.getCurrentResult().isFetching).toBe(true); + expect(observer.getCurrentResult().data?.pages[0].members).toEqual([ + oldRow, + ]); + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + searchPattern: "%new%", + role: "editor", + pageToken: undefined, + }), + }), + ); + + resolveSearch({ members: [newRow], nextPageToken: "" }); + await vi.waitFor(() => + expect(observer.getCurrentResult().isPlaceholderData).toBe(false), + ); + expect(observer.getCurrentResult().data?.pages[0].members).toEqual([ + newRow, + ]); + + request.mockResolvedValueOnce({ members: [newRow], nextPageToken: "" }); + await invalidateOrgMemberUsers(client, "acme"); + // Only the active filtered page is refetched after a mutation. + expect(request).toHaveBeenCalledTimes(3); + unsubscribe(); + }); + + it("does not retain another organization's rows", async () => { + request.mockResolvedValueOnce({ + members: [{ userEmail: "private@acme.com" }], + }); + const observer = new InfiniteQueryObserver( + client, + getOrgUserMembersQueryOptions(filters), + ); + const unsubscribe = observer.subscribe(() => {}); + await vi.waitFor(() => + expect(observer.getCurrentResult().isSuccess).toBe(true), + ); + request.mockReturnValueOnce(new Promise(() => {})); + observer.setOptions( + getOrgUserMembersQueryOptions({ ...filters, organization: "other" }), + ); + expect(observer.getCurrentResult().data).toBeUndefined(); + unsubscribe(); + }); + + it("escapes literal ILIKE characters and keeps the guest role", async () => { + request.mockResolvedValue({ members: [] }); + await client.fetchInfiniteQuery( + getOrgUserMembersQueryOptions({ + ...filters, + guestOnly: true, + searchText: "a_b%\\c", + role: "editor", + }), + ); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + searchPattern: "%a\\_b\\%\\\\c%", + role: "guest", + }), + }), + ); + }); + + it("does not fetch members for pending-only views", () => { + const observer = new InfiniteQueryObserver( + client, + getOrgUserMembersQueryOptions({ ...filters, enabled: false }), + ); + const unsubscribe = observer.subscribe(() => {}); + expect(request).not.toHaveBeenCalled(); + unsubscribe(); + }); +}); diff --git a/web-admin/src/features/organizations/user-management/selectors.ts b/web-admin/src/features/organizations/user-management/selectors.ts index ecd3c78d4e26..037d337b0145 100644 --- a/web-admin/src/features/organizations/user-management/selectors.ts +++ b/web-admin/src/features/organizations/user-management/selectors.ts @@ -5,10 +5,11 @@ import { getAdminServiceListOrganizationMemberUsergroupsQueryOptions, getAdminServiceListUsergroupsForOrganizationAndUserQueryOptions, createAdminServiceListOrganizationMemberUsergroupsInfinite, + getAdminServiceListOrganizationMemberUsersInfiniteQueryOptions, } from "@rilldata/web-admin/client"; import { OrgUserRoles } from "@rilldata/web-common/features/users/roles.ts"; -import { createQuery } from "@tanstack/svelte-query"; -import { type Readable, derived } from "svelte/store"; +import { createInfiniteQuery, createQuery } from "@tanstack/svelte-query"; +import { type Readable, derived, readable } from "svelte/store"; const PAGE_SIZE = 50; @@ -77,33 +78,57 @@ export function getUserGroupsForUsersInOrg( const INFINITE_PAGE_SIZE = 50; -export function getOrgUserMembers({ - organization, - guestOnly, -}: { +export type OrgUserMemberFilters = { organization: string; guestOnly: boolean; -}) { - return createAdminServiceListOrganizationMemberUsersInfinite( + searchText?: string; + role?: string; + enabled?: boolean; +}; + +export function getOrgUserMembersQueryOptions({ + organization, + guestOnly, + searchText = "", + role, + enabled = true, +}: OrgUserMemberFilters) { + return getAdminServiceListOrganizationMemberUsersInfiniteQueryOptions( organization, { pageSize: INFINITE_PAGE_SIZE, - role: guestOnly ? OrgUserRoles.Guest : undefined, + role: guestOnly ? OrgUserRoles.Guest : role, + // Preserve literal, case-insensitive substring matching with SQL ILIKE. + searchPattern: searchText + ? `%${searchText.replace(/[\\%_]/g, "\\$&")}%` + : undefined, includeCounts: true, }, { query: { - getNextPageParam: (lastPage) => { - if (lastPage.nextPageToken !== "") { - return lastPage.nextPageToken; - } - return undefined; - }, + enabled: !!organization && enabled, + initialPageParam: undefined, + getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, + // Keep the table mounted during search/role changes, but never show + // another organization's users as placeholder data. + placeholderData: (previousData, previousQuery) => + previousQuery?.queryKey[1] === `/v1/orgs/${organization}/members` + ? previousData + : undefined, }, }, ); } +export function getOrgUserMembers( + filters: OrgUserMemberFilters | Readable, +) { + const filtersStore = "subscribe" in filters ? filters : readable(filters); + return createInfiniteQuery( + derived(filtersStore, getOrgUserMembersQueryOptions), + ); +} + export function getOrgAdminMembers(organization: string) { return createAdminServiceListOrganizationMemberUsersInfinite( organization, diff --git a/web-admin/src/features/organizations/user-management/table/users/OrgUsersTable.svelte b/web-admin/src/features/organizations/user-management/table/users/OrgUsersTable.svelte index 22f45574158b..577d40c1915b 100644 --- a/web-admin/src/features/organizations/user-management/table/users/OrgUsersTable.svelte +++ b/web-admin/src/features/organizations/user-management/table/users/OrgUsersTable.svelte @@ -20,6 +20,7 @@ import { ExternalLinkIcon } from "lucide-svelte"; import InfiniteScrollTable from "@rilldata/web-common/components/table/InfiniteScrollTable.svelte"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; + import { loadNextInvitePageForFilter } from "@rilldata/web-admin/features/organizations/user-management/pagination"; export let organization: string; export let data: OrgUser[]; @@ -36,6 +37,10 @@ export let billingContact: string | undefined; export let scrollToTopTrigger: any = null; export let guestOnly: boolean; + export let hasActiveFilters = false; + export let isSearchPending = false; + export let showMembers = true; + export let showInvites = true; export let onAttemptRemoveBillingContactUser: () => void; export let onAttemptChangeBillingContactUserRole: () => void; @@ -45,6 +50,24 @@ $: safeData = Array.isArray(data) ? data : []; + $: loadNextInvitePageForFilter(invitesQuery, showInvites && hasActiveFilters); + // Show the footer spinner only while rows are still arriving: the initial + // load, placeholder rows for a new search or role, or the next page. + // Background refetches after mutations or refocus must not show it, since + // they refetch every loaded page and would also pause infinite scrolling. + $: isLoadingMembers = + usersQuery.isPending || + usersQuery.isPlaceholderData || + usersQuery.isFetchingNextPage; + $: isLoadingInvites = + invitesQuery.isPending || + invitesQuery.isFetchingNextPage || + (hasActiveFilters && invitesQuery.isSuccess && invitesQuery.hasNextPage); + $: isLoading = + isSearchPending || + (showMembers && isLoadingMembers) || + (showInvites && isLoadingInvites); + const UserCell = >{ accessorKey: "user", header: m.users_table_header_user(), @@ -139,11 +162,22 @@ : [UserCell, RoleCell, UserGroupCell]; function handleLoadMore() { - if (usersQuery.hasNextPage) { - usersQuery.fetchNextPage(); + if ( + showMembers && + usersQuery.hasNextPage && + !usersQuery.isFetching && + !usersQuery.isPlaceholderData && + !usersQuery.isError + ) { + void usersQuery.fetchNextPage(); } - if (invitesQuery.hasNextPage) { - invitesQuery.fetchNextPage(); + if ( + showInvites && + invitesQuery.hasNextPage && + !invitesQuery.isFetching && + !invitesQuery.isError + ) { + void invitesQuery.fetchNextPage(); } } @@ -161,9 +195,9 @@ { + debouncedSearchText = value; + }, 250); + $: updateSearch(searchText); + onDestroy(updateSearch.cancel); + + const memberFilters = writable({ + organization: "", + guestOnly: false, + }); let filterSelection: "all" | "members" | "guests" | "pending" = "all"; let roleFilter: "all" | "admin" | "editor" | "viewer" = "all"; let scrollToTopTrigger: unknown = null; $: { - // Update trigger when filter selection or role filter changes to scroll to top - scrollToTopTrigger = { filterSelection, roleFilter }; + // Scroll to the top when the search or filters change. + scrollToTopTrigger = { searchText, filterSelection, roleFilter }; } $: organization = $page.params.organization; - $: orgMemberUsersInfiniteQuery = getOrgUserMembers({ + $: memberFilters.set({ organization, guestOnly: false, + searchText: debouncedSearchText, + role: roleFilter === "all" ? undefined : roleFilter, + enabled: filterSelection !== "pending", }); + const orgMemberUsersInfiniteQuery = getOrgUserMembers(memberFilters); $: orgInvitesInfiniteQuery = getOrgUserInvites(organization); $: allOrgMemberUsersRows = @@ -79,14 +97,15 @@ ...coerceInvitesToUsers(allOrgInvitesRows), ]; - // Filter by user type and role - // Filter by search text + // Members are searched by the API. Filter invites locally, leaving member + // placeholder rows visible until a new search or role query finishes. $: filteredUsers = combinedRows .filter((user) => { if (user.roleName === OrgUserRoles.Guest) return false; - const searchLower = searchText.toLowerCase(); + const searchLower = debouncedSearchText.toLowerCase(); const matchesSearch = + !user.pendingAcceptance || (user.userEmail?.toLowerCase() || "").includes(searchLower) || ("userName" in user && (user.userName?.toLowerCase() || "").includes(searchLower)); @@ -110,6 +129,7 @@ // Filter by selected role const matchesRoleFilter = + !user.pendingAcceptance || roleFilter === "all" || (roleFilter === "admin" && user.roleName === OrgUserRoles.Admin) || (roleFilter === "editor" && user.roleName === OrgUserRoles.Editor) || @@ -129,69 +149,64 @@
- {#if $orgMemberUsersInfiniteQuery.isLoading || $orgInvitesInfiniteQuery.isLoading} - - {:else if $orgMemberUsersInfiniteQuery.isError || $orgInvitesInfiniteQuery.isError} + {#if $orgMemberUsersInfiniteQuery.isError || $orgInvitesInfiniteQuery.isError}
{m.users_error_loading_members()} {$orgMemberUsersInfiniteQuery.error ?? $orgInvitesInfiniteQuery.error}
- {:else if $orgMemberUsersInfiniteQuery.isSuccess && $orgInvitesInfiniteQuery.isSuccess} -
-
- - - -
-
- - (isRemovingBillingContactDialogOpen = true)} - onAttemptChangeBillingContactUserRole={() => - (isChangingBillingContactRoleDialogOpen = true)} - onEditUserGroup={(groupName) => { - editingUserGroupName = groupName; - isEditUserGroupDialogOpen = true; - }} - onManageGroups={(user: OrgUserRow) => { - manageGroupsUser = { - email: user.userEmail ?? "", - userId: user.userId ?? "", - pendingAcceptance: !!user.pendingAcceptance, - usergroups: user.usergroups ?? [], - }; - isManageGroupsDialogOpen = true; - }} - onConvertToMember={() => {}} - /> -
-
{/if} +
+
+ + + +
+
+ + (isRemovingBillingContactDialogOpen = true)} + onAttemptChangeBillingContactUserRole={() => + (isChangingBillingContactRoleDialogOpen = true)} + onEditUserGroup={(groupName) => { + editingUserGroupName = groupName; + isEditUserGroupDialogOpen = true; + }} + onManageGroups={(user: OrgUserRow) => { + manageGroupsUser = { + email: user.userEmail ?? "", + userId: user.userId ?? "", + pendingAcceptance: !!user.pendingAcceptance, + usergroups: user.usergroups ?? [], + }; + isManageGroupsDialogOpen = true; + }} + onConvertToMember={() => {}} + /> +
+
{ + debouncedSearchText = value; + }, 250); + $: updateSearch(searchText); + onDestroy(updateSearch.cancel); + + const memberFilters = writable({ + organization: "", + guestOnly: true, + }); let filterSelection: "all" | "members" | "guests" | "pending" = "all"; - let scrollToTopTrigger = null; + let scrollToTopTrigger: unknown = null; $: { - // Update trigger when filter selection changes to scroll to top - scrollToTopTrigger = filterSelection; + // Scroll to the top when the search or filters change. + scrollToTopTrigger = { searchText, filterSelection }; } $: organization = $page.params.organization; - $: orgMemberUsersInfiniteQuery = getOrgUserMembers({ + $: memberFilters.set({ organization, guestOnly: true, + searchText: debouncedSearchText, + enabled: filterSelection !== "pending", }); + const orgMemberUsersInfiniteQuery = getOrgUserMembers(memberFilters); $: orgInvitesInfiniteQuery = getOrgUserInvites(organization); $: allOrgMemberUsersRows = @@ -78,11 +95,11 @@ ...coerceInvitesToUsers(allOrgInvitesRows), ]; - // Filter by role - // Filter by search text + // Members are searched by the API. Only invitations need local text matching. $: filteredUsers = combinedRows.filter((user) => { - const searchLower = searchText.toLowerCase(); + const searchLower = debouncedSearchText.toLowerCase(); const matchesSearch = + !user.pendingAcceptance || (user.userEmail?.toLowerCase() || "").includes(searchLower) || ("userName" in user && (user.userName?.toLowerCase() || "").includes(searchLower)); @@ -112,74 +129,71 @@
- {#if $orgMemberUsersInfiniteQuery.isLoading || $orgInvitesInfiniteQuery.isLoading} - - {:else if $orgMemberUsersInfiniteQuery.isError || $orgInvitesInfiniteQuery.isError} + {#if $orgMemberUsersInfiniteQuery.isError || $orgInvitesInfiniteQuery.isError}
{m.users_error_loading_members()} {$orgMemberUsersInfiniteQuery.error ?? $orgInvitesInfiniteQuery.error}
- {:else if $orgMemberUsersInfiniteQuery.isSuccess && $orgInvitesInfiniteQuery.isSuccess} -
-
- - - -
-
- {}} - onAttemptChangeBillingContactUserRole={() => {}} - onEditUserGroup={(groupName) => { - editingUserGroupName = groupName; - isEditUserGroupDialogOpen = true; - }} - onManageGroups={(user: OrgUserRow) => { - manageGroupsUser = { - email: user.userEmail ?? "", - userId: user.userId ?? "", - pendingAcceptance: !!user.pendingAcceptance, - usergroups: user.usergroups ?? [], - }; - isManageGroupsDialogOpen = true; - }} - onConvertToMember={(user) => { - convertGuestUser = user; - convertGuestDialogOpen = true; - }} - /> -
-
{/if} +
+
+ + + +
+
+ {}} + onAttemptChangeBillingContactUserRole={() => {}} + onEditUserGroup={(groupName) => { + editingUserGroupName = groupName; + isEditUserGroupDialogOpen = true; + }} + onManageGroups={(user: OrgUserRow) => { + manageGroupsUser = { + email: user.userEmail ?? "", + userId: user.userId ?? "", + pendingAcceptance: !!user.pendingAcceptance, + usergroups: user.usergroups ?? [], + }; + isManageGroupsDialogOpen = true; + }} + onConvertToMember={(user) => { + convertGuestUser = user; + convertGuestDialogOpen = true; + }} + /> +
+
vi.unstubAllGlobals()); + +describe("InfiniteScrollTable loading", () => { + it("keeps existing rows and the table mounted while loading", async () => { + const { rerender, container } = render(InfiniteScrollTable, props); + const table = screen.getByRole("table"); + expect(screen.getByText("existing@example.com")).toBeInTheDocument(); + await rerender({ ...props, isFetchingNextPage: true }); + expect(screen.getByRole("table")).toBe(table); + expect(screen.getByText("existing@example.com")).toBeInTheDocument(); + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + }); + + it("shows no-results only after loading finishes", async () => { + const { rerender } = render(InfiniteScrollTable, { + ...props, + data: [], + isFetchingNextPage: true, + }); + expect(screen.queryByText("No items found")).not.toBeInTheDocument(); + await rerender({ ...props, data: [] }); + expect(screen.getByText("No items found")).toBeInTheDocument(); + }); + + it("continues loading when a page has no visible matches", async () => { + const callbacks: IntersectionObserverCallback[] = []; + const observe = vi.fn(); + vi.stubGlobal( + "IntersectionObserver", + class { + observe = observe; + disconnect = vi.fn(); + constructor(callback: IntersectionObserverCallback) { + callbacks.push(callback); + } + }, + ); + const onLoadMore = vi.fn(); + const pagedProps = { ...props, data: [], hasNextPage: true, onLoadMore }; + const { rerender } = render(InfiniteScrollTable, pagedProps); + expect(observe).toHaveBeenCalledTimes(1); + const intersect = () => + callbacks.at(-1)!( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + intersect(); + expect(onLoadMore).toHaveBeenCalledTimes(1); + await rerender({ ...pagedProps, isFetchingNextPage: true }); + intersect(); + expect(onLoadMore).toHaveBeenCalledTimes(1); + await rerender(pagedProps); + expect(observe).toHaveBeenCalledTimes(2); + intersect(); + expect(onLoadMore).toHaveBeenCalledTimes(2); + expect(screen.queryByText("No items found")).not.toBeInTheDocument(); + }); +}); diff --git a/web-common/src/components/table/InfiniteScrollTable.svelte b/web-common/src/components/table/InfiniteScrollTable.svelte index 67032efd1850..c1d5e8cdee2c 100644 --- a/web-common/src/components/table/InfiniteScrollTable.svelte +++ b/web-common/src/components/table/InfiniteScrollTable.svelte @@ -21,6 +21,7 @@ getSortedRowModel, } from "tanstack-table-8-svelte-5"; import ArrowDown from "@rilldata/web-common/components/icons/ArrowDown.svelte"; + import DelayedSpinner from "@rilldata/web-common/features/entity-management/DelayedSpinner.svelte"; export let data: any[]; export let columns: ColumnDef[]; @@ -117,7 +118,14 @@ if (sentinelEl) observer.observe(sentinelEl); } - $: if (scrollContainerEl && sentinelEl) { + // Re-observe after a page loads: the sentinel may still be visible when + // client-side filters remove all rows from that page. + $: if ( + scrollContainerEl && + sentinelEl && + hasNextPage && + !isFetchingNextPage + ) { setupObserver(); } @@ -194,7 +202,7 @@ {/each} - {#if rows.length === 0} + {#if rows.length === 0 && !isFetchingNextPage && !hasNextPage} {/each} - {#if hasNextPage} - - -
- - - {/if} + {/if} + {#if isFetchingNextPage} + + + + + + {/if} + {#if hasNextPage} + + +
+ + {/if} From 4a2a59f4d38d3db6b0491e2d47eec4de84912932 Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Wed, 16 Sep 2026 20:42:32 +0530 Subject: [PATCH 2/3] test: e2e coverage for org user search and filters beyond the first page Invites more than one page of plus-addressed variants of the e2e admin account and checks that search finds an invite beyond the first page, that the pending invites filter loads every page, and that search combines with the role filter and the empty state. --- web-admin/tests/org-users.spec.ts | 156 ++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 web-admin/tests/org-users.spec.ts diff --git a/web-admin/tests/org-users.spec.ts b/web-admin/tests/org-users.spec.ts new file mode 100644 index 000000000000..957a895d7d40 --- /dev/null +++ b/web-admin/tests/org-users.spec.ts @@ -0,0 +1,156 @@ +import { expect, type Page } from "@playwright/test"; +import { execAsync } from "@rilldata/web-common/tests/utils/spawn"; +import dotenv from "dotenv"; +import path from "path"; +import { fileURLToPath } from "url"; +import { test } from "./setup/base"; +import { RILL_ORG_NAME } from "./setup/constants"; + +// The users page loads invites 50 at a time. Invite more than one page so the +// search and filter tests have to reach rows that are not loaded initially. +const INVITE_PAGE_SIZE = 50; +const INVITE_COUNT = INVITE_PAGE_SIZE + 10; + +test.describe.serial("Org users search and filters", () => { + // Load environment variables from our root `.env` file + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + dotenv.config({ path: path.resolve(__dirname, "../../.env") }); + + const adminEmail = process.env.RILL_DEVTOOL_E2E_ADMIN_ACCOUNT_EMAIL ?? ""; + const [adminLocalPart, adminDomain] = adminEmail.split("@"); + + // Plus-addressed variants of the admin account, e.g. `e2e-admin+invite-007@rilldata.com`. + // These accounts have not signed up, so they show up as pending invites. + const inviteEmail = (suffix: string) => + `${adminLocalPart}+invite-${suffix}@${adminDomain}`; + // Invites are listed in email order, so `zz` sorts this one after every + // numbered invite and it only exists beyond the first page. + const targetEmail = inviteEmail("zz-target"); + const inviteEmails = [ + ...Array.from({ length: INVITE_COUNT - 1 }, (_, i) => + inviteEmail(String(i).padStart(3, "0")), + ), + targetEmail, + ]; + + const usersUrl = `/${RILL_ORG_NAME}/-/users`; + const pendingRows = (page: Page) => + page.getByRole("row", { name: "Pending invitation" }); + const searchBox = (page: Page) => + page.getByRole("textbox", { name: "Search" }); + + async function selectFilter(page: Page, trigger: string, option: string) { + await page.getByRole("button", { name: trigger, exact: true }).click(); + await page.getByRole("menuitemcheckbox", { name: option }).click(); + } + + test.beforeAll(async () => { + test.setTimeout(120_000); + if (!adminEmail) { + throw new Error( + "Missing required environment variables for authentication", + ); + } + + await execAsync( + `rill sudo quota set --org ${RILL_ORG_NAME} --outstanding-invites ${INVITE_COUNT * 2}`, + ); + // Invite in small parallel batches: fast enough without flooding the admin service. + for (let i = 0; i < inviteEmails.length; i += 10) { + await Promise.all( + inviteEmails + .slice(i, i + 10) + .map((email) => + execAsync( + `rill user add --org ${RILL_ORG_NAME} --email ${email} --role viewer`, + ), + ), + ); + } + }); + + test.afterAll(async () => { + test.setTimeout(120_000); + // Teardown deletes the org, but remove the invites anyway so the suite can + // be re-run against a long-lived dev environment. + for (let i = 0; i < inviteEmails.length; i += 10) { + await Promise.all( + inviteEmails + .slice(i, i + 10) + .map((email) => + execAsync( + `rill user remove --org ${RILL_ORG_NAME} --email ${email}`, + ).catch(() => undefined), + ), + ); + } + }); + + test("should find a pending invite beyond the first page by search", async ({ + page, + }) => { + await page.goto(usersUrl); + + // Only the first page of invites is loaded initially, without the target. + await expect(page.getByRole("row", { name: adminEmail })).toBeVisible(); + await expect(pendingRows(page)).toHaveCount(INVITE_PAGE_SIZE); + await expect(page.getByRole("row", { name: targetEmail })).toHaveCount(0); + + await searchBox(page).fill("zz-target"); + + await expect(page.getByRole("row", { name: targetEmail })).toBeVisible(); + await expect(pendingRows(page)).toHaveCount(1); + await expect(page.getByRole("row", { name: adminEmail })).toHaveCount(0); + + // Clearing the search restores the unfiltered list. The invite pages + // fetched for the search stay cached, so every invite is listed now. + await searchBox(page).fill(""); + await expect(page.getByRole("row", { name: adminEmail })).toBeVisible(); + await expect(pendingRows(page)).toHaveCount(INVITE_COUNT); + }); + + test("should show every pending invite under the pending invites filter", async ({ + page, + }) => { + await page.goto(usersUrl); + await expect(page.getByRole("row", { name: adminEmail })).toBeVisible(); + + await selectFilter(page, "All users", "Pending invites"); + + // Every page of invites is loaded, not just the first one. + await expect(pendingRows(page)).toHaveCount(INVITE_COUNT); + await expect(page.getByRole("row", { name: targetEmail })).toBeVisible(); + await expect(page.getByRole("row", { name: adminEmail })).toHaveCount(0); + + await selectFilter(page, "Pending invites", "Members"); + await expect(page.getByRole("row", { name: adminEmail })).toBeVisible(); + await expect(pendingRows(page)).toHaveCount(0); + }); + + test("should combine search with the role filter", async ({ page }) => { + await page.goto(usersUrl); + await expect(page.getByRole("row", { name: adminEmail })).toBeVisible(); + + // Matches the ten invites numbered 000 to 009 and no members. + await searchBox(page).fill("invite-00"); + await expect(pendingRows(page)).toHaveCount(10); + await expect(page.getByRole("row", { name: adminEmail })).toHaveCount(0); + + // The invites were created as viewers, so the viewer role keeps them. + await selectFilter(page, "All Roles", "Viewers"); + await expect(pendingRows(page)).toHaveCount(10); + + // No admin matches the search, so the table shows the empty state. + await selectFilter(page, "Viewers", "Admins"); + await expect(page.getByText("No users found")).toBeVisible(); + + // Clearing the search with the admin role still selected shows the admin. + await searchBox(page).fill(""); + await expect(page.getByRole("row", { name: adminEmail })).toBeVisible(); + await expect(pendingRows(page)).toHaveCount(0); + + // Searching for the admin by email keeps the row. + await searchBox(page).fill(adminEmail); + await expect(page.getByRole("row", { name: adminEmail })).toBeVisible(); + }); +}); From ebd0f728a08609768fbaafbb5bf3fc6715d7a7be Mon Sep 17 00:00:00 2001 From: Nishant Bangarwa Date: Wed, 16 Sep 2026 20:44:21 +0530 Subject: [PATCH 3/3] test: drop the invite pagination unit test now that the e2e spec covers it --- .../user-management/pagination.spec.ts | 113 ------------------ 1 file changed, 113 deletions(-) delete mode 100644 web-admin/src/features/organizations/user-management/pagination.spec.ts diff --git a/web-admin/src/features/organizations/user-management/pagination.spec.ts b/web-admin/src/features/organizations/user-management/pagination.spec.ts deleted file mode 100644 index 0264b3b3336a..000000000000 --- a/web-admin/src/features/organizations/user-management/pagination.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { InfiniteQueryObserver, QueryClient } from "@tanstack/query-core"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { loadNextInvitePageForFilter } from "./pagination"; - -const clients: QueryClient[] = []; - -function createObserver(total: number) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - clients.push(client); - const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => - Promise.resolve({ - emails: Array.from( - { length: Math.min(50, total - pageParam) }, - (_, i) => `user${pageParam + i}@example.com`, - ), - nextPageToken: pageParam + 50 < total ? pageParam + 50 : undefined, - }), - ); - const observer = new InfiniteQueryObserver(client, { - queryKey: ["users"], - queryFn, - initialPageParam: 0, - getNextPageParam: (page) => page.nextPageToken, - }); - return { observer, queryFn }; -} - -afterEach(() => { - clients.splice(0).forEach((client) => client.clear()); -}); - -describe("loading invitation pages for organization user filters", () => { - it.each([ - ["invitations", 168], - ["pending invitations", 101], - ["guest invitations", 151], - ])("finds %s beyond the first page without scrolling", async (_, total) => { - const { observer, queryFn } = createObserver(total); - let hasActiveFilters = false; - const unsubscribe = observer.subscribe((query) => { - loadNextInvitePageForFilter(query, hasActiveFilters); - }); - - await vi.waitFor(() => - expect(observer.getCurrentResult().isSuccess).toBe(true), - ); - expect(queryFn).toHaveBeenCalledTimes(1); - const matchingEmails = () => - observer - .getCurrentResult() - .data?.pages.flatMap((page) => - page.emails.filter( - (email) => email === `user${total - 1}@example.com`, - ), - ); - expect(matchingEmails()).toEqual([]); - - hasActiveFilters = true; - loadNextInvitePageForFilter(observer.getCurrentResult(), hasActiveFilters); - - await vi.waitFor(() => - expect(observer.getCurrentResult().hasNextPage).toBe(false), - ); - expect(matchingEmails()).toEqual([`user${total - 1}@example.com`]); - expect(queryFn).toHaveBeenCalledTimes(Math.ceil(total / 50)); - unsubscribe(); - }); - - it("stops loading remaining pages when filters are cleared", async () => { - const { observer, queryFn } = createObserver(168); - let hasActiveFilters = false; - const unsubscribe = observer.subscribe((query) => { - loadNextInvitePageForFilter(query, hasActiveFilters); - }); - await vi.waitFor(() => - expect(observer.getCurrentResult().isSuccess).toBe(true), - ); - - hasActiveFilters = true; - loadNextInvitePageForFilter(observer.getCurrentResult(), hasActiveFilters); - hasActiveFilters = false; - - await vi.waitFor(() => - expect(observer.getCurrentResult().isFetching).toBe(false), - ); - expect(queryFn).toHaveBeenCalledTimes(2); - expect(observer.getCurrentResult().hasNextPage).toBe(true); - unsubscribe(); - }); - - it("does not repeatedly fetch a failing page", async () => { - const { observer, queryFn } = createObserver(168); - let hasActiveFilters = false; - const unsubscribe = observer.subscribe((query) => { - loadNextInvitePageForFilter(query, hasActiveFilters); - }); - await vi.waitFor(() => - expect(observer.getCurrentResult().isSuccess).toBe(true), - ); - - queryFn.mockRejectedValueOnce(new Error("Network error")); - hasActiveFilters = true; - loadNextInvitePageForFilter(observer.getCurrentResult(), hasActiveFilters); - - await vi.waitFor(() => - expect(observer.getCurrentResult().isError).toBe(true), - ); - expect(queryFn).toHaveBeenCalledTimes(2); - unsubscribe(); - }); -});