diff --git a/web/apps/admin/src/contexts/ConnectProvider.tsx b/web/apps/admin/src/contexts/ConnectProvider.tsx index 11393824e9..62b5d9a92f 100644 --- a/web/apps/admin/src/contexts/ConnectProvider.tsx +++ b/web/apps/admin/src/contexts/ConnectProvider.tsx @@ -4,12 +4,14 @@ import type { ReactNode } from "react"; import { TransportProvider } from "@connectrpc/connect-query"; import { jsonTransport as transport } from "~/connect/transport"; -// Create a QueryClient instance +/* Otherwise every mount refetches. Mutations invalidate their own keys; + * the search tables opt out with staleTime: 0. */ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, + staleTime: 30 * 1000, }, }, }); diff --git a/web/apps/admin/src/pages/organizations/details/index.tsx b/web/apps/admin/src/pages/organizations/details/index.tsx index 21ea5afcf5..4e6832115f 100644 --- a/web/apps/admin/src/pages/organizations/details/index.tsx +++ b/web/apps/admin/src/pages/organizations/details/index.tsx @@ -1,8 +1,13 @@ import { OrganizationDetailsView, useAdminPaths } from '@raystack/frontier/admin'; -import { useCallback, useContext, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate, useParams, Outlet, Navigate } from 'react-router-dom'; -import { useQuery } from '@connectrpc/connect-query'; -import { FrontierServiceQueries } from '@raystack/proton/frontier'; +import { createConnectQueryKey, useQuery, useTransport } from '@connectrpc/connect-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { create } from '@bufbuild/protobuf'; +import { + FrontierServiceQueries, + GetOrganizationResponseSchema, +} from '@raystack/proton/frontier'; import { AppContext } from '~/contexts/App'; import { clients } from '~/connect/clients'; import { exportCsvFromStream } from '~/utils/helper'; @@ -33,6 +38,8 @@ export default function OrganizationDetailsPage() { const paths = useAdminPaths(); const { config } = useContext(AppContext); const [countries, setCountries] = useState([]); + const queryClient = useQueryClient(); + const transport = useTransport(); const incomingOrgId = (location.state as { orgId?: string } | null)?.orgId; @@ -53,8 +60,9 @@ export default function OrganizationDetailsPage() { /* * Cold-load resolve (only when state carries no id): - * - getOrganization takes an id OR a slug and returns disabled orgs too, - * so a single call covers every URL form (server GetRaw branches on UUID) + * - getOrganization takes an id OR a slug, so a single call covers every URL + * form (server GetRaw branches on UUID) + * - disabled orgs resolve for superusers only; the console is superuser-only * - a UUID param is already the id, but we still resolve to read the slug + * state for the canonical-URL rewrite below */ @@ -77,6 +85,26 @@ export default function OrganizationDetailsPage() { const orgId = stateOrgId || (paramIsId ? urlParam : org?.id); const notFound = needsResolve && isSuccess && !org?.id; + /* The slug resolve caches under the slug, so seed the id key the view uses. + * In render, not an effect: the view mounts this commit. Empty keys only — + * this copy can be stale, and edits invalidate the id key, not the slug. */ + const primedOrgId = useRef(undefined); + if (org?.id && org.id !== urlParam && primedOrgId.current !== org.id) { + primedOrgId.current = org.id; + const orgKey = createConnectQueryKey({ + schema: FrontierServiceQueries.getOrganization, + transport, + input: { id: org.id }, + cardinality: 'finite', + }); + if (queryClient.getQueryData(orgKey) === undefined) { + queryClient.setQueryData( + orgKey, + create(GetOrganizationResponseSchema, { organization: org }), + ); + } + } + /* * Old UUID bookmark → canonical slug URL: * - one live URL per org; replace keeps the back-button sane diff --git a/web/sdk/admin/hooks/useOrgMembersMap.ts b/web/sdk/admin/hooks/useOrgMembersMap.ts new file mode 100644 index 0000000000..ca1fec98b8 --- /dev/null +++ b/web/sdk/admin/hooks/useOrgMembersMap.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@connectrpc/connect-query"; +import { FrontierServiceQueries, type User } from "@raystack/proton/frontier"; +import type { ListOrganizationUsersResponse } from "@raystack/proton/frontier"; + +// Stable identity so react-query memoizes the select. +const toMembersMap = (data?: ListOrganizationUsersResponse) => + (data?.users || []).reduce( + (acc, user) => { + acc[user.id || ""] = user; + return acc; + }, + {} as Record, + ); + +/** Org members keyed by id. Deduped across callers; empty orgId disables. */ +export const useOrgMembersMap = (orgId?: string) => + useQuery( + FrontierServiceQueries.listOrganizationUsers, + { id: orgId || "" }, + { + enabled: !!orgId, + select: toMembersMap, + }, + ); diff --git a/web/sdk/admin/views/audit-logs/index.tsx b/web/sdk/admin/views/audit-logs/index.tsx index 09a1d788df..7d00fec1d4 100644 --- a/web/sdk/admin/views/audit-logs/index.tsx +++ b/web/sdk/admin/views/audit-logs/index.tsx @@ -6,7 +6,7 @@ import { Flex, } from "@raystack/apsara"; import { useDebouncedState } from "@raystack/apsara/hooks"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import Navbar from "./navbar"; import styles from "./audit-logs.module.css"; import { getColumns } from "./columns"; @@ -48,6 +48,8 @@ const DEFAULT_SORT: DataTableSort = { name: "occurredAt", order: "desc" }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Must match DataTable's mount emit, or it refetches. + sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { fieldNameMapping: { @@ -140,12 +142,17 @@ export default function AuditLogsView({ appName, onExportCsv, onNavigate }: Audi [queryClient], ); + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const handleLoadMore = async () => { + if (!hasNextPage || isFetchingNextPage || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; try { - if (!hasNextPage) return; await fetchNextPage(); } catch (error) { console.error("Error loading more audit logs:", error); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/invoices/index.tsx b/web/sdk/admin/views/invoices/index.tsx index e6ac9797b7..d73655648a 100644 --- a/web/sdk/admin/views/invoices/index.tsx +++ b/web/sdk/admin/views/invoices/index.tsx @@ -5,7 +5,7 @@ import { EmptyState, Flex, } from "@raystack/apsara"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { PageTitle } from "../../components/PageTitle"; import { InvoicesNavabar } from "./navbar"; import styles from "./invoices.module.css"; @@ -40,6 +40,8 @@ const DEFAULT_SORT: DataTableSort = { name: "createdAt", order: "desc" }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Must match DataTable's mount emit, or it refetches. + sort: [DEFAULT_SORT], }; export type InvoicesViewProps = { @@ -89,12 +91,17 @@ export default function InvoicesView({ appName }: InvoicesViewProps = {}) { }); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const handleLoadMore = async () => { + if (!hasNextPage || isFetchingNextPage || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; try { - if (!hasNextPage) return; await fetchNextPage(); } catch (error) { console.error("Error loading more invoices:", error); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/organizations/details/apis/index.tsx b/web/sdk/admin/views/organizations/details/apis/index.tsx index 93ff54e4a7..8cf71eaa35 100644 --- a/web/sdk/admin/views/organizations/details/apis/index.tsx +++ b/web/sdk/admin/views/organizations/details/apis/index.tsx @@ -5,7 +5,7 @@ import { CodeIcon, ExclamationTriangleIcon, } from "@radix-ui/react-icons"; -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { OrganizationContext } from "../contexts/organization-context"; import { PageTitle } from "~/admin/components/PageTitle"; import { getColumns } from "./columns"; @@ -69,6 +69,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Must match DataTable's mount emit, or it refetches. + sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { fieldNameMapping: { @@ -148,12 +150,17 @@ export function OrganizationApisView() { setTableQuery(newQuery); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const handleLoadMore = async () => { + if (!hasNextPage || isFetchingNextPage || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; try { - if (!hasNextPage) return; await fetchNextPage(); } catch (error) { console.error("Error loading more service users:", error); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/organizations/details/contexts/organization-context.tsx b/web/sdk/admin/views/organizations/details/contexts/organization-context.tsx index d4f0854af1..9ab6fe88b2 100644 --- a/web/sdk/admin/views/organizations/details/contexts/organization-context.tsx +++ b/web/sdk/admin/views/organizations/details/contexts/organization-context.tsx @@ -3,7 +3,6 @@ import { OrganizationSchema, type Role, type BillingAccount, - type User, type OrganizationKyc, type BillingAccountDetails, } from "@raystack/proton/frontier"; @@ -29,8 +28,6 @@ interface OrganizationContextType { tokenBalance: string; isTokenBalanceLoading: boolean; fetchTokenBalance: () => void; - orgMembersMap: Record; - isOrgMembersMapLoading: boolean; updateKYCDetails: (kycDetails: OrganizationKyc | undefined) => void; kycDetails?: OrganizationKyc; isKYCLoading: boolean; @@ -55,8 +52,6 @@ const defaultOrganiztionContextValue = { query: "", onChange: () => {}, }, - orgMembersMap: {}, - isOrgMembersMapLoading: false, updateKYCDetails: () => {}, kycDetails: undefined, isKYCLoading: false, diff --git a/web/sdk/admin/views/organizations/details/edit/billing.tsx b/web/sdk/admin/views/organizations/details/edit/billing.tsx index af6277c802..225b4446be 100644 --- a/web/sdk/admin/views/organizations/details/edit/billing.tsx +++ b/web/sdk/admin/views/organizations/details/edit/billing.tsx @@ -126,6 +126,7 @@ export function EditBillingPanel({ open = false, onClose }: EditBillingPanelProp ); const onSubmit = async (data: BillingDetailsForm) => { + if (!organizationId || !billingId) return; try { // For prepaid, set values to 0; for postpaid, use form values const creditMinValue = data.tokenPaymentType === "prepaid" ? 0n : BigInt(data.creditMin); diff --git a/web/sdk/admin/views/organizations/details/index.tsx b/web/sdk/admin/views/organizations/details/index.tsx index 6ef9de6a56..e8e913fe5d 100644 --- a/web/sdk/admin/views/organizations/details/index.tsx +++ b/web/sdk/admin/views/organizations/details/index.tsx @@ -16,7 +16,6 @@ import { GetBillingBalanceRequestSchema, GetOrganizationKycResponseSchema, type Organization, - type User, } from "@raystack/proton/frontier"; export type OrganizationDetailsViewProps = { @@ -118,41 +117,19 @@ export const OrganizationDetailsView = ({ { enabled: !!organizationId }, ); - // Fetch organization members const { - data: orgMembersMap = {}, - isLoading: isOrgMembersMapLoading, - error: orgMembersError, + data: firstBillingAccountId = "", + isLoading: isBillingAccountsLoading, + error: billingAccountsError, } = useQuery( - FrontierServiceQueries.listOrganizationUsers, - { id: organizationId || "" }, + FrontierServiceQueries.listBillingAccounts, + { orgId: organizationId || "" }, { enabled: !!organizationId, - select: (data) => { - const users = data?.users || []; - return users.reduce( - (acc, user) => { - const id = user.id || ""; - acc[id] = user; - return acc; - }, - {} as Record, - ); - }, + select: (data) => data?.billingAccounts?.[0]?.id || "", }, ); - // Fetch billing accounts list - const { data: firstBillingAccountId = "", error: billingAccountsError } = - useQuery( - FrontierServiceQueries.listBillingAccounts, - { orgId: organizationId || "" }, - { - enabled: !!organizationId, - select: (data) => data?.billingAccounts?.[0]?.id || "", - }, - ); - // Fetch billing account details const { data: billingAccountData, @@ -177,6 +154,9 @@ export const OrganizationDetailsView = ({ const billingAccount = billingAccountData?.billingAccount; const billingAccountDetails = billingAccountData?.billingAccountDetails; + // getBillingAccount is disabled until the list yields an id. + const isBillingLoading = isBillingAccountsLoading || isBillingAccountLoading; + // Fetch billing balance const { data: tokenBalance = "0", @@ -202,9 +182,6 @@ export const OrganizationDetailsView = ({ if (kycError) { console.error("Failed to fetch KYC details:", kycError); } - if (orgMembersError) { - console.error("Failed to fetch organization members:", orgMembersError); - } if (billingAccountsError) { console.error("Failed to fetch billing accounts:", billingAccountsError); } @@ -220,14 +197,13 @@ export const OrganizationDetailsView = ({ }, [ organizationError, kycError, - orgMembersError, billingAccountsError, billingAccountError, tokenBalanceError, ]); - const isLoading = - isOrganizationLoading || isRolesLoading || isBillingAccountLoading; + // Billing waits on an id, so including it here remounted the tab mid-load. + const isLoading = isOrganizationLoading || isRolesLoading; return ( { - if (hasNextPage && !isFetchingNextPage && !isError) { + if (!hasNextPage || isFetchingNextPage || isError || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; + try { await fetchNextPage(); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/organizations/details/layout/add-tokens-dialog.tsx b/web/sdk/admin/views/organizations/details/layout/add-tokens-dialog.tsx index ae6a25b5d2..996f570afd 100644 --- a/web/sdk/admin/views/organizations/details/layout/add-tokens-dialog.tsx +++ b/web/sdk/admin/views/organizations/details/layout/add-tokens-dialog.tsx @@ -37,8 +37,13 @@ const addTokensSchema = z.object({ type AddTokenRequestType = z.infer; export const AddTokensDialog = ({ onOpenChange }: InviteUsersDialogProps) => { - const { organization, billingAccount, fetchTokenBalance, tokenProductId } = - useContext(OrganizationContext); + const { + organization, + billingAccount, + isBillingAccountLoading, + fetchTokenBalance, + tokenProductId, + } = useContext(OrganizationContext); const queryClient = useQueryClient(); const transport = useTransport(); const organisationId = organization?.id || ""; @@ -79,8 +84,14 @@ export const AddTokensDialog = ({ onOpenChange }: InviteUsersDialogProps) => { }, ); + /* + * Checkout needs an org and a billing account to target. Orgs without one + * would otherwise submit into the guard below and get no feedback at all. + */ + const canCheckout = !!organisationId && !!billingAccountId; + const onSubmit = async (product_body: AddTokenRequestType) => { - if (!organisationId) return; + if (!canCheckout) return; await delegatedCheckout( create(DelegatedCheckoutRequestSchema, { orgId: organisationId, @@ -153,6 +164,7 @@ export const AddTokensDialog = ({ onOpenChange }: InviteUsersDialogProps) => { data-test-id="add-tokens-invite-button" type="submit" loaderText="Adding..." + disabled={isBillingAccountLoading || !canCheckout || isSubmitting} loading={isSubmitting} > Add diff --git a/web/sdk/admin/views/organizations/details/members/index.tsx b/web/sdk/admin/views/organizations/details/members/index.tsx index ba06749bf7..6dbad8b089 100644 --- a/web/sdk/admin/views/organizations/details/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/members/index.tsx @@ -2,7 +2,7 @@ import { AlertDialog, Button, DataTable, EmptyState, Flex } from "@raystack/apsa import type { DataTableQuery, DataTableSort } from "@raystack/apsara"; import { PageTitle } from "~/admin/components/PageTitle"; import styles from "./members.module.css"; -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { getColumns } from "./columns"; import type { Invitation, @@ -44,6 +44,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'orgJoinedAt', order: 'desc' }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Must match DataTable's mount emit, or it refetches. + sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { fieldNameMapping: { @@ -200,9 +202,15 @@ export function OrganizationMembersView() { setTableQuery(newQuery); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const fetchMore = async () => { - if (hasNextPage && !isFetchingNextPage && !isError) { + if (!hasNextPage || isFetchingNextPage || isError || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; + try { await fetchNextPage(); + } finally { + isLoadingMoreRef.current = false; } }; @@ -241,11 +249,12 @@ export function OrganizationMembersView() { }); async function invalidateMembersQuery() { + // Keys match partially: {} would hit every org; omitting query is deliberate. await queryClient.invalidateQueries({ queryKey: createConnectQueryKey({ schema: AdminServiceQueries.searchOrganizationUsers, transport, - input: {}, + input: { id: organizationId }, cardinality: "infinite", }), }); diff --git a/web/sdk/admin/views/organizations/details/pat/index.tsx b/web/sdk/admin/views/organizations/details/pat/index.tsx index 6c323298bb..870d2d2440 100644 --- a/web/sdk/admin/views/organizations/details/pat/index.tsx +++ b/web/sdk/admin/views/organizations/details/pat/index.tsx @@ -1,7 +1,7 @@ import { DataTable, EmptyState, Flex } from "@raystack/apsara"; import type { DataTableQuery, DataTableSort } from "@raystack/apsara"; import { LockClosedIcon, ExclamationTriangleIcon } from "@radix-ui/react-icons"; -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useInfiniteQuery, useQuery } from "@connectrpc/connect-query"; import { AdminServiceQueries, @@ -26,6 +26,8 @@ const DEFAULT_SORT: DataTableSort = { name: "createdAt", order: "desc" }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Must match DataTable's mount emit, or it refetches. + sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { fieldNameMapping: { @@ -171,9 +173,15 @@ export function OrganizationPatView() { setTableQuery(newQuery); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const fetchMore = async () => { - if (hasNextPage && !isFetchingNextPage && !isError) { + if (!hasNextPage || isFetchingNextPage || isError || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; + try { await fetchNextPage(); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/organizations/details/projects/index.tsx b/web/sdk/admin/views/organizations/details/projects/index.tsx index c1d46db6d0..ec19ffb677 100644 --- a/web/sdk/admin/views/organizations/details/projects/index.tsx +++ b/web/sdk/admin/views/organizations/details/projects/index.tsx @@ -7,7 +7,7 @@ import { } from "@raystack/apsara"; import { PageTitle } from "~/admin/components/PageTitle"; import styles from "./projects.module.css"; -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { getColumns } from "./columns"; import type { SearchOrganizationProjectsResponse_OrganizationProject } from "@raystack/proton/frontier"; import { AdminServiceQueries } from "@raystack/proton/frontier"; @@ -23,11 +23,14 @@ import { import { transformDataTableQueryToRQLRequest } from '~/utils/transform-query'; import { useDebouncedValue } from '~hooks'; import { useTerminology } from "~/admin/hooks/useTerminology"; +import { useOrgMembersMap } from "~/admin/hooks/useOrgMembersMap"; const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Must match DataTable's mount emit, or it refetches. + sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { fieldNameMapping: { @@ -83,8 +86,12 @@ const ErrorState = () => { export function OrganizationProjectsView() { const t = useTerminology(); - const { organization, search, orgMembersMap, isOrgMembersMapLoading } = - useContext(OrganizationContext); + const { organization, search } = useContext(OrganizationContext); + const { + data: orgMembersMap = {}, + isLoading: isOrgMembersMapLoading, + error: orgMembersError, + } = useOrgMembersMap(organization?.id); const { onChange: onSearchChange, setVisibility: setSearchVisibility, @@ -154,9 +161,15 @@ export function OrganizationProjectsView() { setTableQuery(newQuery); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const fetchMore = async () => { - if (hasNextPage && !isFetchingNextPage && !isError) { + if (!hasNextPage || isFetchingNextPage || isError || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; + try { await fetchNextPage(); + } finally { + isLoadingMoreRef.current = false; } }; @@ -167,6 +180,12 @@ export function OrganizationProjectsView() { refetchOrgProjects(); } + useEffect(() => { + if (orgMembersError) { + console.error("Failed to fetch organization members:", orgMembersError); + } + }, [orgMembersError]); + useEffect(() => { setSearchVisibility(true); return () => { diff --git a/web/sdk/admin/views/organizations/details/projects/members/index.tsx b/web/sdk/admin/views/organizations/details/projects/members/index.tsx index 8e40fcd8e4..3a8d1fb575 100644 --- a/web/sdk/admin/views/organizations/details/projects/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/projects/members/index.tsx @@ -1,6 +1,6 @@ import { AlertDialog, DataTable, Dialog, EmptyState, Flex } from "@raystack/apsara"; import type { DataTableQuery } from "@raystack/apsara"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import Skeleton from "react-loading-skeleton"; import { AdminServiceQueries, @@ -139,14 +139,19 @@ export const ProjectMembersDialog = ({ }); }, []); + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const handleLoadMore = useCallback(async () => { + if (!hasNextPage || isFetchingNextPage || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; try { - if (!hasNextPage) return; await fetchNextPage(); } catch (error) { console.error("Error loading more project members:", error); + } finally { + isLoadingMoreRef.current = false; } - }, [hasNextPage, fetchNextPage]); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); async function refetchMembers() { await refetch(); @@ -217,7 +222,6 @@ export const ProjectMembersDialog = ({ data={data} isLoading={isLoading} mode="server" - defaultSort={{ name: "", order: "desc" }} onTableQueryChange={onTableQueryChange} onLoadMore={handleLoadMore} > diff --git a/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx b/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx index 40e101ebe7..f93ffd9d65 100644 --- a/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx +++ b/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx @@ -7,6 +7,7 @@ import { FrontierServiceQueries, ListProjectUsersRequestSchema, ListRolesRequest import { create } from "@bufbuild/protobuf"; import { handleConnectError } from "~/utils/error"; import { useTerminology } from "../../../../hooks/useTerminology"; +import { useOrgMembersMap } from "../../../../hooks/useOrgMembersMap"; interface useAddProjectMembersProps { projectId: string; @@ -15,7 +16,8 @@ interface useAddProjectMembersProps { export function useAddProjectMembers({ projectId }: useAddProjectMembersProps) { const t = useTerminology(); const memberLabel = t.member({ case: "capital" }); - const { orgMembersMap } = useContext(OrganizationContext); + const { organization } = useContext(OrganizationContext); + const { data: orgMembersMap = {} } = useOrgMembersMap(organization?.id); const [searchQuery, setSearchQuery] = useState(""); const { data: projectMembers, isLoading, refetch } = useQuery( diff --git a/web/sdk/admin/views/organizations/details/side-panel/billing-details-section.tsx b/web/sdk/admin/views/organizations/details/side-panel/billing-details-section.tsx index efd2b0f488..8c1d4146dc 100644 --- a/web/sdk/admin/views/organizations/details/side-panel/billing-details-section.tsx +++ b/web/sdk/admin/views/organizations/details/side-panel/billing-details-section.tsx @@ -14,12 +14,17 @@ import { } from "~/admin/utils/connect-timestamp"; export const BillingDetailsSection = () => { - const { billingAccount, organization } = useContext(OrganizationContext); + const { billingAccount, organization, isBillingAccountLoading } = + useContext(OrganizationContext); const organizationId = organization?.id || ""; const billingAccountId = billingAccount?.id || ""; - const { data: upcomingInvoice, isLoading, error } = useQuery( + const { + data: upcomingInvoice, + isLoading: isUpcomingInvoiceLoading, + error, + } = useQuery( FrontierServiceQueries.getUpcomingInvoice, create(GetUpcomingInvoiceRequestSchema, { orgId: organizationId, @@ -36,6 +41,9 @@ export const BillingDetailsSection = () => { console.error("Error fetching upcoming invoice:", error); } }, [error]); + + const isLoading = isBillingAccountLoading || isUpcomingInvoiceLoading; + const due_date = upcomingInvoice?.dueDate || upcomingInvoice?.periodEndAt; const stripeLink = billingAccount?.providerId diff --git a/web/sdk/admin/views/organizations/details/side-panel/tokens-details-section.tsx b/web/sdk/admin/views/organizations/details/side-panel/tokens-details-section.tsx index cc5027e301..d5a29e2e5b 100644 --- a/web/sdk/admin/views/organizations/details/side-panel/tokens-details-section.tsx +++ b/web/sdk/admin/views/organizations/details/side-panel/tokens-details-section.tsx @@ -14,6 +14,7 @@ export const TokensDetailsSection = () => { billingAccount, organization, isTokenBalanceLoading, + isBillingAccountLoading, billingAccountDetails, } = useContext(OrganizationContext); @@ -38,7 +39,7 @@ export const TokensDetailsSection = () => { } }, [error]); - const isLoading = isTokensLoading; + const isLoading = isBillingAccountLoading || isTokensLoading; return ( diff --git a/web/sdk/admin/views/organizations/details/tokens/index.tsx b/web/sdk/admin/views/organizations/details/tokens/index.tsx index cd7fb8af7a..554d5b816e 100644 --- a/web/sdk/admin/views/organizations/details/tokens/index.tsx +++ b/web/sdk/admin/views/organizations/details/tokens/index.tsx @@ -3,7 +3,7 @@ import type { DataTableQuery, DataTableSort } from "@raystack/apsara"; import styles from "./tokens.module.css"; import { CoinIcon } from "@raystack/apsara/icons"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { OrganizationContext } from "../contexts/organization-context"; import { PageTitle } from "~/admin/components/PageTitle"; import { FrontierServiceQueries } from "@raystack/proton/frontier"; @@ -18,6 +18,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Must match DataTable's mount emit, or it refetches. + sort: [DEFAULT_SORT], }; const TRANSFORM_OPTIONS = { fieldNameMapping: { @@ -141,9 +143,15 @@ export function OrganizationTokensView() { setTableQuery(newQuery); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const fetchMore = async () => { - if (hasNextPage && !isFetchingNextPage && !isError) { + if (!hasNextPage || isFetchingNextPage || isError || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; + try { await fetchNextPage(); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/organizations/list/index.tsx b/web/sdk/admin/views/organizations/list/index.tsx index 516bfe87a5..bff5a95634 100644 --- a/web/sdk/admin/views/organizations/list/index.tsx +++ b/web/sdk/admin/views/organizations/list/index.tsx @@ -1,6 +1,6 @@ import { Button, DataTable, EmptyState, Flex, type DataTableQuery, type DataTableSort } from "@raystack/apsara"; import { OrganizationIcon } from "@raystack/apsara/icons"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { OrganizationsNavabar } from "./navbar"; import styles from "./list.module.css"; import { getColumns } from "./columns"; @@ -68,6 +68,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Must match DataTable's mount emit, or it refetches. + sort: [DEFAULT_SORT], }; export type OrganizationListViewProps = { @@ -118,6 +120,7 @@ export const OrganizationListView = ({ isLoading, isFetchingNextPage, fetchNextPage, + hasNextPage, error, isError, } = useInfiniteQuery( @@ -164,11 +167,17 @@ export const OrganizationListView = ({ }); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const handleLoadMore = async () => { + if (!hasNextPage || isFetchingNextPage || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; try { await fetchNextPage(); } catch (error) { console.error("Error loading more organizations:", error); + } finally { + isLoadingMoreRef.current = false; } }; diff --git a/web/sdk/admin/views/users/list/invite-users.tsx b/web/sdk/admin/views/users/list/invite-users.tsx index 5075a3e988..db456057ee 100644 --- a/web/sdk/admin/views/users/list/invite-users.tsx +++ b/web/sdk/admin/views/users/list/invite-users.tsx @@ -55,6 +55,7 @@ export const InviteUser = () => { const t = useTerminology(); const [open, onOpenChange] = useState(false); + // This mounts with the page, so gate on open. const { data: organizations, isLoading: isOrganizationsLoading, @@ -63,6 +64,7 @@ export const InviteUser = () => { AdminServiceQueries.searchOrganizations, create(SearchOrganizationsRequestSchema, { query: {} }), { + enabled: open, select: (data) => data?.organizations || [], } ); @@ -75,6 +77,7 @@ export const InviteUser = () => { FrontierServiceQueries.listRoles, create(ListRolesRequestSchema, { scopes: [SCOPES.ORG] }), { + enabled: open, select: (data) => data?.roles || [], } ); diff --git a/web/sdk/admin/views/users/list/list.tsx b/web/sdk/admin/views/users/list/list.tsx index 7c9fd2d6b1..9144d663ac 100644 --- a/web/sdk/admin/views/users/list/list.tsx +++ b/web/sdk/admin/views/users/list/list.tsx @@ -5,6 +5,7 @@ import styles from "./list.module.css"; import { getColumns } from "./columns"; import { PageTitle } from "../../../components/PageTitle"; import UserIcon from "../../../assets/icons/UsersIcon"; +import { useRef } from "react"; import { useInfiniteQuery } from "@connectrpc/connect-query"; import { AdminServiceQueries, type User } from "@raystack/proton/frontier"; import { @@ -36,6 +37,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; const INITIAL_QUERY: DataTableQuery = { offset: 0, limit: DEFAULT_PAGE_SIZE, + // Must match DataTable's mount emit, or it refetches. + sort: [DEFAULT_SORT], }; interface UsersListProps { @@ -63,6 +66,7 @@ export const UsersList = ({ onExportUsers, onNavigateToUser }: UsersListProps) = isLoading, isFetchingNextPage, fetchNextPage, + hasNextPage, error, isError, } = useInfiniteQuery( @@ -93,11 +97,17 @@ export const UsersList = ({ onExportUsers, onNavigateToUser }: UsersListProps) = }); }; + // isFetchingNextPage lags a render; the ref doesn't. + const isLoadingMoreRef = useRef(false); const handleLoadMore = async () => { + if (!hasNextPage || isFetchingNextPage || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; try { await fetchNextPage(); } catch (error) { console.error("Error loading more users:", error); + } finally { + isLoadingMoreRef.current = false; } };