Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions web-admin/src/features/organizations/user-management/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
type PaginatedQuery = {
hasNextPage: boolean;
isFetching: boolean;
isSuccess: boolean;
fetchNextPage: () => Promise<unknown>;
};

// 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();
}
}
176 changes: 176 additions & 0 deletions web-admin/src/features/organizations/user-management/selectors.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
55 changes: 40 additions & 15 deletions web-admin/src/features/organizations/user-management/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<OrgUserMemberFilters>,
) {
const filtersStore = "subscribe" in filters ? filters : readable(filters);
return createInfiniteQuery(
derived(filtersStore, getOrgUserMembersQueryOptions),
);
}

export function getOrgAdminMembers(organization: string) {
return createAdminServiceListOrganizationMemberUsersInfinite(
organization,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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;
Expand All @@ -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 = <ColumnDef<OrgUser, any>>{
accessorKey: "user",
header: m.users_table_header_user(),
Expand Down Expand Up @@ -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();
}
}

Expand All @@ -161,9 +195,9 @@
<InfiniteScrollTable
data={safeData}
{columns}
hasNextPage={usersQuery.hasNextPage || invitesQuery.hasNextPage}
isFetchingNextPage={usersQuery.isFetchingNextPage ||
invitesQuery.isFetchingNextPage}
hasNextPage={(showMembers && usersQuery.hasNextPage && !usersQuery.isError) ||
(showInvites && invitesQuery.hasNextPage && !invitesQuery.isError)}
isFetchingNextPage={isLoading}
onLoadMore={handleLoadMore}
maxHeight={dynamicTableMaxHeight}
emptyStateMessage={m.users_table_empty()}
Expand Down
Loading
Loading