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; + }} + /> +
+
{ + // 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(); + }); +}); diff --git a/web-common/src/components/table/InfiniteScrollTable.spec.ts b/web-common/src/components/table/InfiniteScrollTable.spec.ts new file mode 100644 index 000000000000..25239e9eea69 --- /dev/null +++ b/web-common/src/components/table/InfiniteScrollTable.spec.ts @@ -0,0 +1,70 @@ +import { render, screen } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import InfiniteScrollTable from "./InfiniteScrollTable.svelte"; + +const props = { + data: [{ email: "existing@example.com" }], + columns: [{ accessorKey: "email", header: "Email" }], + hasNextPage: false, + isFetchingNextPage: false, + onLoadMore: vi.fn(), +}; + +afterEach(() => 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}