diff --git a/web/apps/admin/src/pages/organizations/details/index.tsx b/web/apps/admin/src/pages/organizations/details/index.tsx index 21ea5afcf5..24373c02b4 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, useLayoutEffect, 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,25 @@ export default function OrganizationDetailsPage() { const orgId = stateOrgId || (paramIsId ? urlParam : org?.id); const notFound = needsResolve && isSuccess && !org?.id; + /* Resolve caches under the slug, so seed the id key the view reads. Layout, + * not passive: the view subscribes to that key in a passive effect this same + * commit. Empty keys only — this copy can go stale, edits invalidate the id. */ + useLayoutEffect(() => { + if (!org?.id || org.id === urlParam) return; + 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 }), + ); + } + }, [org, urlParam, queryClient, transport]); + /* * Old UUID bookmark → canonical slug URL: * - one live URL per org; replace keeps the back-button sane diff --git a/web/sdk/admin/hooks/useLoadMore.ts b/web/sdk/admin/hooks/useLoadMore.ts new file mode 100644 index 0000000000..1eace07a71 --- /dev/null +++ b/web/sdk/admin/hooks/useLoadMore.ts @@ -0,0 +1,49 @@ +import { useCallback, useRef } from "react"; + +export interface UseLoadMoreOptions { + hasNextPage?: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => Promise; + /** Skip while the query is errored, so scrolling cannot retry a failed page. */ + isError?: boolean; + /** Names the rows in the console message, e.g. "audit logs". */ + label: string; +} + +/* + Guarded "load more" for a server table's infinite query. + - VirtualizedContent calls this straight from onScroll and react-query + notifies observers on a macrotask, so hasNextPage/isFetchingNextPage are + still last render's values through a scroll burst + - fetchNextPage defaults to cancelRefetch: true, so an unguarded repeat aborts + the in-flight page and re-issues it; only the ref flips in time to stop that + - the render-derived flags stay as a cheap first filter +*/ +export const useLoadMore = ({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, + isError, + label, +}: UseLoadMoreOptions) => { + const isLoadingMoreRef = useRef(false); + + return useCallback(async () => { + if ( + !hasNextPage || + isFetchingNextPage || + isError || + isLoadingMoreRef.current + ) { + return; + } + isLoadingMoreRef.current = true; + try { + await fetchNextPage(); + } catch (error) { + console.error(`Error loading more ${label}:`, error); + } finally { + isLoadingMoreRef.current = false; + } + }, [hasNextPage, isFetchingNextPage, isError, fetchNextPage, label]); +}; diff --git a/web/sdk/admin/hooks/useOrgMembersMap.ts b/web/sdk/admin/hooks/useOrgMembersMap.ts new file mode 100644 index 0000000000..413b003555 --- /dev/null +++ b/web/sdk/admin/hooks/useOrgMembersMap.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@connectrpc/connect-query"; +import { FrontierServiceQueries, type User } from "@raystack/proton/frontier"; +import type { ListOrganizationUsersResponse } from "@raystack/proton/frontier"; +import { SHARED_QUERY_STALE_TIME } from "~/admin/utils/constants"; + +// 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, + staleTime: SHARED_QUERY_STALE_TIME, + select: toMembersMap, + }, + ); diff --git a/web/sdk/admin/hooks/useOrganizationRoles.ts b/web/sdk/admin/hooks/useOrganizationRoles.ts index 10f0bb20d6..b4525dc8be 100644 --- a/web/sdk/admin/hooks/useOrganizationRoles.ts +++ b/web/sdk/admin/hooks/useOrganizationRoles.ts @@ -6,7 +6,7 @@ import { ListRolesRequestSchema, ListOrganizationRolesRequestSchema, } from "@raystack/proton/frontier"; -import { SCOPES } from "~/admin/utils/constants"; +import { SCOPES, SHARED_QUERY_STALE_TIME } from "~/admin/utils/constants"; interface UseOrganizationRolesOptions { /** Skip both fetches while false. Defaults to true. */ @@ -16,7 +16,7 @@ interface UseOrganizationRolesOptions { /* Roles assignable within an org: the platform's defaults plus the org's custom ones. Both halves are needed — a role id can come from either. - - react-query caches per key, so repeat callers share one fetch + - nothing writes roles, so repeat callers share one cached fetch - pass undefined/empty to skip the org-scoped half */ export const useOrganizationRoles = ( @@ -32,6 +32,7 @@ export const useOrganizationRoles = ( create(ListRolesRequestSchema, { scopes: [SCOPES.ORG] }), { enabled, + staleTime: SHARED_QUERY_STALE_TIME, select: (data) => data?.roles || [], }, ); @@ -48,6 +49,7 @@ export const useOrganizationRoles = ( }), { enabled: enabled && !!orgId, + staleTime: SHARED_QUERY_STALE_TIME, select: (data) => data?.roles || [], }, ); diff --git a/web/sdk/admin/hooks/useServerTableQuery.ts b/web/sdk/admin/hooks/useServerTableQuery.ts new file mode 100644 index 0000000000..8e6d735a4a --- /dev/null +++ b/web/sdk/admin/hooks/useServerTableQuery.ts @@ -0,0 +1,91 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import type { DataTableQuery, DataTableSort } from "@raystack/apsara"; +import type { RQLRequest } from "@raystack/proton/frontier"; + +import { DEFAULT_PAGE_SIZE } from "~/utils/connect-pagination"; +import { + transformDataTableQueryToRQLRequest, + type TransformOptions, +} from "~/utils/transform-query"; +import { useDebouncedValue } from "~hooks"; + +export interface ServerTableQueryOptions { + /** Sort applied until the user picks another. Must match the table's `defaultSort`. */ + defaultSort?: DataTableSort; + /** Field name mapping for the RQL request. Read through a ref, so an inline object is fine. */ + transformOptions?: TransformOptions; + /** Search owned outside the table, e.g. the organization page's shared box. */ + search?: string; + /** Adjust the query before it becomes a request, e.g. converting units. */ + // eslint-disable-next-line no-unused-vars -- callback param name is for type documentation + mapQuery?: (query: DataTableQuery) => DataTableQuery; + /** Debounce applied to the request, not to the table's own state. */ + debounceMs?: number; +} + +export interface ServerTableQuery { + /** Pass to DataTable's `query` prop. Updates immediately. */ + tableQuery: DataTableQuery; + /** Pass to the RPC. Trails `tableQuery` by `debounceMs`. */ + rqlQuery: RQLRequest; + /** Pass to DataTable's `onTableQueryChange` prop. */ + // eslint-disable-next-line no-unused-vars -- callback param name is for type documentation + onTableQueryChange: (query: DataTableQuery) => void; +} + +/** + * Query state for a `mode="server"` DataTable. + * + * The initial query carries `defaultSort` on purpose. DataTable seeds its own + * state from that prop and emits it on mount unconditionally; if the initial + * query here disagreed, that emit would change the request and every table + * would fetch its first page twice. Keep the `defaultSort` passed to DataTable + * and the one passed here identical. + */ +export function useServerTableQuery({ + defaultSort, + transformOptions, + search, + mapQuery, + debounceMs = 200, +}: ServerTableQueryOptions = {}): ServerTableQuery { + const [tableQuery, setTableQuery] = useState(() => ({ + offset: 0, + limit: DEFAULT_PAGE_SIZE, + sort: defaultSort ? [defaultSort] : [], + })); + + /* + * Field mappings are fixed per view, so read them through a ref. Callers + * passing an inline object would otherwise change the memo's identity every + * render, restarting the debounce timer and never letting it settle. + */ + const transformOptionsRef = useRef(transformOptions); + transformOptionsRef.current = transformOptions; + const mapQueryRef = useRef(mapQuery); + mapQueryRef.current = mapQuery; + + const computedQuery = useMemo(() => { + const mapped = mapQueryRef.current + ? mapQueryRef.current(tableQuery) + : tableQuery; + const rql = transformDataTableQueryToRQLRequest( + mapped, + transformOptionsRef.current, + ); + return search === undefined ? rql : { ...rql, search }; + }, [tableQuery, search]); + + const rqlQuery = useDebouncedValue(computedQuery, debounceMs); + + /* Any change to filters, sort or search starts again from the first page. */ + const onTableQueryChange = useCallback((query: DataTableQuery) => { + setTableQuery({ + ...query, + offset: 0, + limit: query.limit || DEFAULT_PAGE_SIZE, + }); + }, []); + + return { tableQuery, rqlQuery, onTableQueryChange }; +} diff --git a/web/sdk/admin/utils/constants.ts b/web/sdk/admin/utils/constants.ts index 5f3fde2d64..02b37c6ec2 100644 --- a/web/sdk/admin/utils/constants.ts +++ b/web/sdk/admin/utils/constants.ts @@ -17,6 +17,10 @@ export const DEFAULT_ROLES = { export const NULL_DATE = "0001-01-01T00:00:00Z"; +/* Not a client-wide default: only safe where every writer invalidates the + * key, so each query opts in and says why. */ +export const SHARED_QUERY_STALE_TIME = 30 * 1000; + export interface AdminTerminologyConfig { organization?: EntityTerminologies; project?: EntityTerminologies; diff --git a/web/sdk/admin/views/audit-logs/index.tsx b/web/sdk/admin/views/audit-logs/index.tsx index 09a1d788df..8d806641cf 100644 --- a/web/sdk/admin/views/audit-logs/index.tsx +++ b/web/sdk/admin/views/audit-logs/index.tsx @@ -1,12 +1,10 @@ import { DataTable, - type DataTableQuery, type DataTableSort, EmptyState, Flex, } from "@raystack/apsara"; -import { useDebouncedState } from "@raystack/apsara/hooks"; -import { useCallback, useMemo, useState } from "react"; +import { useEffect, useCallback, useMemo, useState } from "react"; import Navbar from "./navbar"; import styles from "./audit-logs.module.css"; import { getColumns } from "./columns"; @@ -21,14 +19,14 @@ import { import { getConnectNextPageParam, getGroupCountMapFromFirstPage, - DEFAULT_PAGE_SIZE, } from "~/utils/connect-pagination"; -import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import SidePanelDetails from "./sidepanel-details"; import { useQueryClient } from "@tanstack/react-query"; import { AUDIT_LOG_QUERY_KEY } from "./util"; import { useTerminology } from "../../hooks/useTerminology"; +import { useLoadMore } from "~/admin/hooks/useLoadMore"; +import { useServerTableQuery } from "~/admin/hooks/useServerTableQuery"; const NoAuditLogs = () => { return ( @@ -45,10 +43,6 @@ const NoAuditLogs = () => { }; const DEFAULT_SORT: DataTableSort = { name: "occurredAt", order: "desc" }; -const INITIAL_QUERY: DataTableQuery = { - offset: 0, - limit: DEFAULT_PAGE_SIZE, -}; const TRANSFORM_OPTIONS = { fieldNameMapping: { occurredAt: "occurred_at", @@ -73,19 +67,19 @@ export type AuditLogsViewProps = { export default function AuditLogsView({ appName, onExportCsv, onNavigate }: AuditLogsViewProps = {}) { const t = useTerminology(); const queryClient = useQueryClient(); - const [tableQuery, setTableQuery] = useDebouncedState<{ - query: DataTableQuery; - rqlRequest: RQLRequest; - }>( - { - query: INITIAL_QUERY, - rqlRequest: transformDataTableQueryToRQLRequest( - INITIAL_QUERY, - TRANSFORM_OPTIONS, - ), - }, - 200, - ); + const { + tableQuery, + rqlQuery, + onTableQueryChange, + } = useServerTableQuery({ + defaultSort: DEFAULT_SORT, + transformOptions: TRANSFORM_OPTIONS, + }); + + /* The navbar's CSV export reads the live request off this key. */ + useEffect(() => { + queryClient.setQueryData(AUDIT_LOG_QUERY_KEY, rqlQuery); + }, [queryClient, rqlQuery]); const [sidePanelOpen, setSidePanelOpen] = useState(false); const [selectedAuditLog, setSelectedAuditLog] = useState( null, @@ -101,13 +95,13 @@ export default function AuditLogsView({ appName, onExportCsv, onNavigate }: Audi hasNextPage, } = useInfiniteQuery( AdminServiceQueries.listAuditRecords, - { query: tableQuery.rqlRequest }, + { query: rqlQuery }, { pageParamKey: "query", getNextPageParam: lastPage => getConnectNextPageParam( lastPage, - { query: tableQuery.rqlRequest }, + { query: rqlQuery }, "auditRecords", ), staleTime: 0, @@ -120,34 +114,13 @@ export default function AuditLogsView({ appName, onExportCsv, onNavigate }: Audi const data = infiniteData?.pages?.flatMap(page => page?.auditRecords || []) || []; - const onTableQueryChange = useCallback( - (query: DataTableQuery) => { - const updatedQuery = { - ...query, - offset: 0, - limit: query.limit || DEFAULT_PAGE_SIZE, - }; - const updatedRQLRequest = transformDataTableQueryToRQLRequest( - updatedQuery, - TRANSFORM_OPTIONS, - ); - queryClient.setQueryData(AUDIT_LOG_QUERY_KEY, updatedRQLRequest); - setTableQuery({ - query: updatedQuery, - rqlRequest: updatedRQLRequest, - }); - }, - [queryClient], - ); - - const handleLoadMore = async () => { - try { - if (!hasNextPage) return; - await fetchNextPage(); - } catch (error) { - console.error("Error loading more audit logs:", error); - } - }; + const handleLoadMore = useLoadMore({ + hasNextPage, + isFetchingNextPage, + isError, + fetchNextPage, + label: "audit logs", + }); const columns = useMemo( () => @@ -194,7 +167,7 @@ export default function AuditLogsView({ appName, onExportCsv, onNavigate }: Audi <> - + { const t = useTerminology(); @@ -37,10 +35,6 @@ const NoInvoices = () => { }; const DEFAULT_SORT: DataTableSort = { name: "createdAt", order: "desc" }; -const INITIAL_QUERY: DataTableQuery = { - offset: 0, - limit: DEFAULT_PAGE_SIZE, -}; export type InvoicesViewProps = { /** App name displayed in the page title. */ @@ -49,11 +43,16 @@ export type InvoicesViewProps = { export default function InvoicesView({ appName }: InvoicesViewProps = {}) { const t = useTerminology(); - const [tableQuery, setTableQuery] = useState(INITIAL_QUERY); - - const query = transformDataTableQueryToRQLRequest(tableQuery, { - fieldNameMapping: { - createdAt: "created_at", + const { + tableQuery, + rqlQuery: query, + onTableQueryChange, + } = useServerTableQuery({ + defaultSort: DEFAULT_SORT, + transformOptions: { + fieldNameMapping: { + createdAt: "created_at", + }, }, }); @@ -81,22 +80,13 @@ export default function InvoicesView({ appName }: InvoicesViewProps = {}) { const data = infiniteData?.pages?.flatMap(page => page?.invoices || []) || []; - const onTableQueryChange = (newQuery: DataTableQuery) => { - setTableQuery({ - ...newQuery, - offset: 0, - limit: newQuery.limit || DEFAULT_PAGE_SIZE, - }); - }; - - const handleLoadMore = async () => { - try { - if (!hasNextPage) return; - await fetchNextPage(); - } catch (error) { - console.error("Error loading more invoices:", error); - } - }; + const handleLoadMore = useLoadMore({ + hasNextPage, + isFetchingNextPage, + isError, + fetchNextPage, + label: "invoices", + }); const columns = getColumns({ t }); diff --git a/web/sdk/admin/views/organizations/details/apis/index.tsx b/web/sdk/admin/views/organizations/details/apis/index.tsx index 93ff54e4a7..55f7c2e5a6 100644 --- a/web/sdk/admin/views/organizations/details/apis/index.tsx +++ b/web/sdk/admin/views/organizations/details/apis/index.tsx @@ -1,5 +1,5 @@ import { DataTable, EmptyState, Flex } from "@raystack/apsara"; -import type { DataTableQuery, DataTableSort } from "@raystack/apsara"; +import type { DataTableSort } from "@raystack/apsara"; import styles from "./apis.module.css"; import { CodeIcon, @@ -18,11 +18,10 @@ import { import { getConnectNextPageParam, getGroupCountMapFromFirstPage, - DEFAULT_PAGE_SIZE, } from "~/utils/connect-pagination"; -import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query"; -import { useDebouncedValue } from "~hooks"; import { useTerminology } from "~/admin/hooks/useTerminology"; +import { useLoadMore } from "~/admin/hooks/useLoadMore"; +import { useServerTableQuery } from "~/admin/hooks/useServerTableQuery"; const NoCredentials = () => { return ( @@ -66,10 +65,6 @@ const ErrorState = () => { }; const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' }; -const INITIAL_QUERY: DataTableQuery = { - offset: 0, - limit: DEFAULT_PAGE_SIZE, -}; const TRANSFORM_OPTIONS = { fieldNameMapping: { createdAt: "created_at", @@ -86,18 +81,15 @@ export function OrganizationApisView() { query: searchQuery, } = search; - const [tableQuery, setTableQuery] = useState(INITIAL_QUERY); - - const computedQuery = useMemo(() => { - const tempQuery = transformDataTableQueryToRQLRequest(tableQuery, TRANSFORM_OPTIONS); - return { - ...tempQuery, - search: searchQuery || "", - }; - }, [tableQuery, searchQuery]); - - const query = useDebouncedValue(computedQuery, 200); - + const { + tableQuery, + rqlQuery: query, + onTableQueryChange, + } = useServerTableQuery({ + defaultSort: DEFAULT_SORT, + transformOptions: TRANSFORM_OPTIONS, + search: searchQuery || "", + }); const [selectedServiceUser, setSelectedServiceUser] = useState( @@ -144,18 +136,13 @@ export function OrganizationApisView() { const data = infiniteData?.pages?.flatMap(page => page?.organizationServiceUsers || []) || []; - const onTableQueryChange = (newQuery: DataTableQuery) => { - setTableQuery(newQuery); - }; - - const handleLoadMore = async () => { - try { - if (!hasNextPage) return; - await fetchNextPage(); - } catch (error) { - console.error("Error loading more service users:", error); - } - }; + const handleLoadMore = useLoadMore({ + hasNextPage, + isFetchingNextPage, + isError, + fetchNextPage, + label: "service users", + }); const loading = isLoading || isFetchingNextPage; 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..56c62402ce 100644 --- a/web/sdk/admin/views/organizations/details/edit/billing.tsx +++ b/web/sdk/admin/views/organizations/details/edit/billing.tsx @@ -125,7 +125,12 @@ export function EditBillingPanel({ open = false, onClose }: EditBillingPanelProp }, ); + /* Reachable without a billing account: the navbar's Edit menu is ungated. + * The submit below returns silently there, so the button must not look live. */ + const canSave = !!organizationId && !!billingId; + const onSubmit = async (data: BillingDetailsForm) => { + if (!canSave) return; try { // For prepaid, set values to 0; for postpaid, use form values const creditMinValue = data.tokenPaymentType === "prepaid" ? 0n : BigInt(data.creditMin); @@ -263,7 +268,7 @@ export function EditBillingPanel({ open = false, onClose }: EditBillingPanelProp