Skip to content
Draft
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
7 changes: 6 additions & 1 deletion web/apps/admin/src/contexts/ConnectProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@ import type { ReactNode } from "react";
import { TransportProvider } from "@connectrpc/connect-query";
import { jsonTransport as transport } from "~/connect/transport";

// Create a QueryClient instance
/*
* staleTime 0 + refetchOnMount refetches on every mount, so navigating
* re-requested roles, plans and products each time. Mutations invalidate their
* own keys, and the search tables opt out with an explicit staleTime: 0.
*/
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
staleTime: 30 * 1000,
},
},
});
Expand Down
33 changes: 30 additions & 3 deletions web/apps/admin/src/pages/organizations/details/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -33,6 +38,8 @@ export default function OrganizationDetailsPage() {
const paths = useAdminPaths();
const { config } = useContext(AppContext);
const [countries, setCountries] = useState<string[]>([]);
const queryClient = useQueryClient();
const transport = useTransport();

const incomingOrgId = (location.state as { orgId?: string } | null)?.orgId;

Expand Down Expand Up @@ -77,6 +84,26 @@ export default function OrganizationDetailsPage() {
const orgId = stateOrgId || (paramIsId ? urlParam : org?.id);
const notFound = needsResolve && isSuccess && !org?.id;

/*
* The view fetches by id; resolving from a slug keys the cache by the slug.
* Seed the id key so it doesn't refetch the org we already have. During
* render, not in an effect: the view mounts in this commit and its effects
* run first.
*/
const primedOrgId = useRef<string | undefined>(undefined);
if (org?.id && primedOrgId.current !== org.id) {
primedOrgId.current = org.id;
queryClient.setQueryData(
createConnectQueryKey({
schema: FrontierServiceQueries.getOrganization,
transport,
input: { id: org.id },
cardinality: 'finite',
}),
create(GetOrganizationResponseSchema, { organization: org }),
);
}

/*
* Old UUID bookmark → canonical slug URL:
* - one live URL per org; replace keeps the back-button sane
Expand Down
28 changes: 28 additions & 0 deletions web/sdk/admin/hooks/useOrgMembersMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useQuery } from "@connectrpc/connect-query";
import { FrontierServiceQueries, type User } from "@raystack/proton/frontier";
import type { ListOrganizationUsersResponse } from "@raystack/proton/frontier";

/* Module scope keeps the identity stable, so react-query can memoize it. */
const toMembersMap = (data?: ListOrganizationUsersResponse) =>
(data?.users || []).reduce(
(acc, user) => {
acc[user.id || ""] = user;
return acc;
},
{} as Record<string, User>,
);

/**
* The organization's members keyed by id — the full, unpaginated list, so it
* is fetched by the views that need it rather than for every org page.
* react-query dedupes it between callers. Pass empty to disable.
*/
export const useOrgMembersMap = (orgId?: string) =>
useQuery(
FrontierServiceQueries.listOrganizationUsers,
{ id: orgId || "" },
{
enabled: !!orgId,
select: toMembersMap,
},
);
4 changes: 3 additions & 1 deletion web/sdk/admin/views/audit-logs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
const INITIAL_QUERY: DataTableQuery = {
offset: 0,
limit: DEFAULT_PAGE_SIZE,
// Seeded so DataTable's mount emit matches this, instead of forcing a refetch.
sort: [DEFAULT_SORT],
};
const TRANSFORM_OPTIONS = {
fieldNameMapping: {
Expand All @@ -65,7 +67,7 @@
/** App name displayed in the page title. */
appName?: string;
/** Callback to export audit logs as CSV with the current query filters applied. */
onExportCsv?: (query: RQLRequest) => Promise<void>;

Check warning on line 70 in web/sdk/admin/views/audit-logs/index.tsx

View workflow job for this annotation

GitHub Actions / JS SDK Lint

'query' is defined but never used
/** Navigate to a link in an audit entry (e.g. org/user page). `state` carries the org id. */
onNavigate?: (path: string, state?: { orgId?: string }) => void;
};
Expand Down Expand Up @@ -141,8 +143,8 @@
);

const handleLoadMore = async () => {
if (!hasNextPage || isFetchingNextPage) return;
try {
if (!hasNextPage) return;
await fetchNextPage();
} catch (error) {
console.error("Error loading more audit logs:", error);
Expand Down
4 changes: 3 additions & 1 deletion web/sdk/admin/views/invoices/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ const DEFAULT_SORT: DataTableSort = { name: "createdAt", order: "desc" };
const INITIAL_QUERY: DataTableQuery = {
offset: 0,
limit: DEFAULT_PAGE_SIZE,
// Seeded so DataTable's mount emit matches this, instead of forcing a refetch.
sort: [DEFAULT_SORT],
};

export type InvoicesViewProps = {
Expand Down Expand Up @@ -90,8 +92,8 @@ export default function InvoicesView({ appName }: InvoicesViewProps = {}) {
};

const handleLoadMore = async () => {
if (!hasNextPage || isFetchingNextPage) return;
try {
if (!hasNextPage) return;
await fetchNextPage();
} catch (error) {
console.error("Error loading more invoices:", error);
Expand Down
4 changes: 3 additions & 1 deletion web/sdk/admin/views/organizations/details/apis/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' };
const INITIAL_QUERY: DataTableQuery = {
offset: 0,
limit: DEFAULT_PAGE_SIZE,
// Seeded so DataTable's mount emit matches this, instead of forcing a refetch.
sort: [DEFAULT_SORT],
};
const TRANSFORM_OPTIONS = {
fieldNameMapping: {
Expand Down Expand Up @@ -149,8 +151,8 @@ export function OrganizationApisView() {
};

const handleLoadMore = async () => {
if (!hasNextPage || isFetchingNextPage) return;
try {
if (!hasNextPage) return;
await fetchNextPage();
} catch (error) {
console.error("Error loading more service users:", error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
OrganizationSchema,
type Role,
type BillingAccount,
type User,
type OrganizationKyc,
type BillingAccountDetails,
} from "@raystack/proton/frontier";
Expand All @@ -29,8 +28,6 @@ interface OrganizationContextType {
tokenBalance: string;
isTokenBalanceLoading: boolean;
fetchTokenBalance: () => void;
orgMembersMap: Record<string, User>;
isOrgMembersMapLoading: boolean;
updateKYCDetails: (kycDetails: OrganizationKyc | undefined) => void;
kycDetails?: OrganizationKyc;
isKYCLoading: boolean;
Expand All @@ -55,8 +52,6 @@ const defaultOrganiztionContextValue = {
query: "",
onChange: () => {},
},
orgMembersMap: {},
isOrgMembersMapLoading: false,
updateKYCDetails: () => {},
kycDetails: undefined,
isKYCLoading: false,
Expand Down
42 changes: 7 additions & 35 deletions web/sdk/admin/views/organizations/details/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
GetBillingBalanceRequestSchema,
GetOrganizationKycResponseSchema,
type Organization,
type User,
} from "@raystack/proton/frontier";

export type OrganizationDetailsViewProps = {
Expand Down Expand Up @@ -142,30 +141,6 @@ export const OrganizationDetailsView = ({

const roles = [...defaultRoles, ...organizationRoles];

// Fetch organization members
const {
data: orgMembersMap = {},
isLoading: isOrgMembersMapLoading,
error: orgMembersError,
} = useQuery(
FrontierServiceQueries.listOrganizationUsers,
{ id: 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<string, User>,
);
},
},
);

// Fetch billing accounts list
const { data: firstBillingAccountId = "", error: billingAccountsError } =
useQuery(
Expand Down Expand Up @@ -232,9 +207,6 @@ export const OrganizationDetailsView = ({
if (orgRolesError) {
console.error("Failed to fetch organization roles:", orgRolesError);
}
if (orgMembersError) {
console.error("Failed to fetch organization members:", orgMembersError);
}
if (billingAccountsError) {
console.error("Failed to fetch billing accounts:", billingAccountsError);
}
Expand All @@ -252,17 +224,19 @@ export const OrganizationDetailsView = ({
kycError,
defaultRolesError,
orgRolesError,
orgMembersError,
billingAccountsError,
billingAccountError,
tokenBalanceError,
]);

/*
* Only queries enabled from the first render, so the gate flips once:
* - billing waits on an id from listBillingAccounts, so it re-entered
* loading after the gate opened and remounted the tab mid-load
* - the side panel renders its own skeletons meanwhile
*/
const isLoading =
isOrganizationLoading ||
isDefaultRolesLoading ||
isOrgRolesLoading ||
isBillingAccountLoading;
isOrganizationLoading || isDefaultRolesLoading || isOrgRolesLoading;
return (
<OrganizationContext.Provider
value={{
Expand All @@ -276,8 +250,6 @@ export const OrganizationDetailsView = ({
tokenBalance,
isTokenBalanceLoading,
fetchTokenBalance,
orgMembersMap,
isOrgMembersMapLoading,
updateKYCDetails,
kycDetails,
isKYCLoading,
Expand Down
2 changes: 2 additions & 0 deletions web/sdk/admin/views/organizations/details/invoices/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' };
const INITIAL_QUERY: DataTableQuery = {
offset: 0,
limit: DEFAULT_PAGE_SIZE,
// Seeded so DataTable's mount emit matches this, instead of forcing a refetch.
sort: [DEFAULT_SORT],
};
const TRANSFORM_OPTIONS = {
fieldNameMapping: {
Expand Down
8 changes: 7 additions & 1 deletion web/sdk/admin/views/organizations/details/members/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'orgJoinedAt', order: 'desc' };
const INITIAL_QUERY: DataTableQuery = {
offset: 0,
limit: DEFAULT_PAGE_SIZE,
// Seeded so DataTable's mount emit matches this, instead of forcing a refetch.
sort: [DEFAULT_SORT],
};
const TRANSFORM_OPTIONS = {
fieldNameMapping: {
Expand Down Expand Up @@ -191,11 +193,15 @@ export function OrganizationMembersView() {
});

async function invalidateMembersQuery() {
/*
* Keyed on the org: keys match partially, so an empty input would
* invalidate every org. Omitting `query` still covers this org's variants.
*/
await queryClient.invalidateQueries({
queryKey: createConnectQueryKey({
schema: AdminServiceQueries.searchOrganizationUsers,
transport,
input: {},
input: { id: organizationId },
cardinality: "infinite",
}),
});
Expand Down
2 changes: 2 additions & 0 deletions web/sdk/admin/views/organizations/details/pat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const DEFAULT_SORT: DataTableSort = { name: "createdAt", order: "desc" };
const INITIAL_QUERY: DataTableQuery = {
offset: 0,
limit: DEFAULT_PAGE_SIZE,
// Seeded so DataTable's mount emit matches this, instead of forcing a refetch.
sort: [DEFAULT_SORT],
};
const TRANSFORM_OPTIONS = {
fieldNameMapping: {
Expand Down
10 changes: 8 additions & 2 deletions web/sdk/admin/views/organizations/details/projects/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
// Seeded so DataTable's mount emit matches this, instead of forcing a refetch.
sort: [DEFAULT_SORT],
};
const TRANSFORM_OPTIONS = {
fieldNameMapping: {
Expand Down Expand Up @@ -83,8 +86,11 @@ 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,
} = useOrgMembersMap(organization?.id);
const {
onChange: onSearchChange,
setVisibility: setSearchVisibility,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,13 @@ export const ProjectMembersDialog = ({
}, []);

const handleLoadMore = useCallback(async () => {
if (!hasNextPage || isFetchingNextPage) return;
try {
if (!hasNextPage) return;
await fetchNextPage();
} catch (error) {
console.error("Error loading more project members:", error);
}
}, [hasNextPage, fetchNextPage]);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);

async function refetchMembers() {
await refetch();
Expand Down Expand Up @@ -217,7 +217,6 @@ export const ProjectMembersDialog = ({
data={data}
isLoading={isLoading}
mode="server"
defaultSort={{ name: "", order: "desc" }}
onTableQueryChange={onTableQueryChange}
onLoadMore={handleLoadMore}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Comment on lines +19 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include organization member loading in the returned loading state.

If listProjectUsers resolves before useOrgMembersMap, eligibleMembers is empty while isLoading is false. The member picker can display an incorrect empty state.

Proposed fix
-  const { data: orgMembersMap = {} } = useOrgMembersMap(organization?.id);
+  const {
+    data: orgMembersMap = {},
+    isLoading: isOrgMembersMapLoading,
+  } = useOrgMembersMap(organization?.id);
...
-    isLoading,
+    isLoading: isLoading || isOrgMembersMapLoading,

const [searchQuery, setSearchQuery] = useState<string>("");

const { data: projectMembers, isLoading, refetch } = useQuery(
Expand Down
2 changes: 2 additions & 0 deletions web/sdk/admin/views/organizations/details/tokens/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const DEFAULT_SORT: DataTableSort = { name: 'createdAt', order: 'desc' };
const INITIAL_QUERY: DataTableQuery = {
offset: 0,
limit: DEFAULT_PAGE_SIZE,
// Seeded so DataTable's mount emit matches this, instead of forcing a refetch.
sort: [DEFAULT_SORT],
};
const TRANSFORM_OPTIONS = {
fieldNameMapping: {
Expand Down
Loading
Loading