feat: adds pagination in organizations table

This commit is contained in:
Piyush Gupta
2025-10-07 22:59:48 +05:30
parent 464a18ccb2
commit b7b5939c16
7 changed files with 198 additions and 126 deletions
+11 -2
View File
@@ -230,7 +230,10 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
createdAt: z.date() createdAt: z.date()
}) })
.array() .array()
}).array() }).array(),
meta: z.object({
total: z.number()
})
}) })
} }
}, },
@@ -243,9 +246,15 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
const organizations = await server.services.superAdmin.getOrganizations({ const organizations = await server.services.superAdmin.getOrganizations({
...req.query ...req.query
}); });
const count = await server.services.superAdmin.countOrganizations({
...req.query
});
return { return {
organizations organizations,
meta: {
total: count
}
}; };
} }
}); });
+22
View File
@@ -138,6 +138,27 @@ export const orgDALFactory = (db: TDbClient) => {
} }
}; };
const countOrganizationsByFilter = async ({ searchTerm }: { searchTerm: string }) => {
interface CountResult {
count: string;
}
try {
const count = await db
.replicaNode()(TableName.Organization)
.where((qb) => {
if (searchTerm) {
void qb.whereILike(`${TableName.Organization}.name`, `%${searchTerm}%`);
}
})
.count("*")
.first();
return parseInt((count as unknown as CountResult).count || "0", 10);
} catch (error) {
throw new DatabaseError({ error, name: "Count organizations by filter" });
}
};
const findOrgById = async (orgId: string) => { const findOrgById = async (orgId: string) => {
try { try {
const org = (await db const org = (await db
@@ -670,6 +691,7 @@ export const orgDALFactory = (db: TDbClient) => {
findOrgBySlug, findOrgBySlug,
findAllOrgsByUserId, findAllOrgsByUserId,
findOrganizationsByFilter, findOrganizationsByFilter,
countOrganizationsByFilter,
ghostUserExists, ghostUserExists,
findOrgMembersByUsername, findOrgMembersByUsername,
findOrgMembersByRole, findOrgMembersByRole,
@@ -55,6 +55,7 @@ import {
TAdminGetUsersDTO, TAdminGetUsersDTO,
TAdminIntegrationConfig, TAdminIntegrationConfig,
TAdminSignUpDTO, TAdminSignUpDTO,
TCountOrganizationsDTO,
TCreateOrganizationDTO, TCreateOrganizationDTO,
TGetOrganizationsDTO, TGetOrganizationsDTO,
TResendOrgInviteDTO TResendOrgInviteDTO
@@ -750,6 +751,13 @@ export const superAdminServiceFactory = ({
return organizations; return organizations;
}; };
const countOrganizations = async ({ searchTerm }: TCountOrganizationsDTO) => {
const count = await orgDAL.countOrganizationsByFilter({
searchTerm
});
return count;
};
const createOrganization = async ( const createOrganization = async (
{ name, inviteAdminEmails: emails }: TCreateOrganizationDTO, { name, inviteAdminEmails: emails }: TCreateOrganizationDTO,
actor: OrgServiceActor actor: OrgServiceActor
@@ -1146,6 +1154,7 @@ export const superAdminServiceFactory = ({
invalidateCache, invalidateCache,
checkIfInvalidatingCache, checkIfInvalidatingCache,
getOrganizations, getOrganizations,
countOrganizations,
deleteOrganization, deleteOrganization,
deleteOrganizationMembership, deleteOrganizationMembership,
initializeAdminIntegrationConfigSync, initializeAdminIntegrationConfigSync,
@@ -34,6 +34,10 @@ export type TGetOrganizationsDTO = {
searchTerm: string; searchTerm: string;
}; };
export type TCountOrganizationsDTO = {
searchTerm: string;
};
export type TCreateOrganizationDTO = { export type TCreateOrganizationDTO = {
name: string; name: string;
inviteAdminEmails: string[]; inviteAdminEmails: string[];
+7 -10
View File
@@ -16,9 +16,9 @@ import {
AdminGetOrganizationsFilters, AdminGetOrganizationsFilters,
AdminGetUsersFilters, AdminGetUsersFilters,
AdminIntegrationsConfig, AdminIntegrationsConfig,
OrganizationWithProjects,
TGetEnvOverrides, TGetEnvOverrides,
TGetInvalidatingCacheStatus, TGetInvalidatingCacheStatus,
TGetOrganizationsResponse,
TGetServerRootKmsEncryptionDetails, TGetServerRootKmsEncryptionDetails,
TServerConfig TServerConfig
} from "./types"; } from "./types";
@@ -49,24 +49,21 @@ export const fetchServerConfig = async () => {
}; };
export const useAdminGetOrganizations = (filters: AdminGetOrganizationsFilters) => { export const useAdminGetOrganizations = (filters: AdminGetOrganizationsFilters) => {
return useInfiniteQuery({ return useQuery({
initialPageParam: 0,
queryKey: adminQueryKeys.getOrganizations(filters), queryKey: adminQueryKeys.getOrganizations(filters),
queryFn: async ({ pageParam }) => { queryFn: async () => {
const { data } = await apiRequest.get<{ organizations: OrganizationWithProjects[] }>( const { data } = await apiRequest.get<TGetOrganizationsResponse>(
"/api/v1/admin/organization-management/organizations", "/api/v1/admin/organization-management/organizations",
{ {
params: { params: {
...filters, ...filters
offset: pageParam
} }
} }
); );
return data.organizations; return { organizations: data.organizations, totalCount: data.meta.total };
}, },
getNextPageParam: (lastPage, pages) => placeholderData: (previousData) => previousData
lastPage.length !== 0 ? pages.length * filters.limit : undefined
}); });
}; };
+14 -2
View File
@@ -34,6 +34,13 @@ export type OrganizationWithProjects = Organization & {
}[]; }[];
}; };
export type TGetOrganizationsResponse = {
organizations: OrganizationWithProjects[];
meta: {
total: number;
};
};
export type TServerConfig = { export type TServerConfig = {
initialized: boolean; initialized: boolean;
allowSignUp: boolean; allowSignUp: boolean;
@@ -81,8 +88,9 @@ export type TCreateAdminUserDTO = {
}; };
export type AdminGetOrganizationsFilters = { export type AdminGetOrganizationsFilters = {
limit: number; searchTerm?: string;
searchTerm: string; limit?: number;
offset?: number;
}; };
export type AdminGetUsersFilters = { export type AdminGetUsersFilters = {
@@ -162,3 +170,7 @@ export type TResendOrgInviteDTO = {
organizationId: string; organizationId: string;
membershipId: string; membershipId: string;
}; };
export enum AdminOrganizationOrderBy {
Name = "name"
}
@@ -48,6 +48,11 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { useUser } from "@app/context"; import { useUser } from "@app/context";
import { OrgMembershipRole } from "@app/helpers/roles"; import { OrgMembershipRole } from "@app/helpers/roles";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { import {
useAdminDeleteOrganization, useAdminDeleteOrganization,
@@ -57,7 +62,7 @@ import {
useServerAdminAccessOrg, useServerAdminAccessOrg,
useServerAdminResendOrgInvite useServerAdminResendOrgInvite
} from "@app/hooks/api"; } from "@app/hooks/api";
import { OrganizationWithProjects } from "@app/hooks/api/admin/types"; import { AdminOrganizationOrderBy, OrganizationWithProjects } from "@app/hooks/api/admin/types";
import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrderByDirection } from "@app/hooks/api/generic/types";
import { OrgMembershipStatus } from "@app/hooks/api/organization/types"; import { OrgMembershipStatus } from "@app/hooks/api/organization/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -443,13 +448,32 @@ const OrganizationsPanelTable = ({
const { user } = useUser(); const { user } = useUser();
const navigate = useNavigate(); const navigate = useNavigate();
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
useAdminGetOrganizations({ const { offset, limit, setPage, perPage, page, setPerPage } =
limit: 20, usePagination<AdminOrganizationOrderBy>(AdminOrganizationOrderBy.Name, {
searchTerm: debouncedSearchTerm initPerPage: getUserTablePreference("ResourceOverviewOrgsTable", PreferenceKey.PerPage, 10)
}); });
const isEmpty = !isPending && !data?.pages?.[0].length; const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("ResourceOverviewOrgsTable", PreferenceKey.PerPage, newPerPage);
};
const { data, isPending } = useAdminGetOrganizations({
limit,
offset,
searchTerm: debouncedSearchTerm
});
const { organizations, totalCount = 0 } = data ?? {};
const isEmpty = !isPending && !totalCount;
useResetPageHelper({
totalCount,
offset,
setPage
});
const { mutateAsync: accessOrganization } = useServerAdminAccessOrg(); const { mutateAsync: accessOrganization } = useServerAdminAccessOrg();
@@ -499,125 +523,120 @@ const OrganizationsPanelTable = ({
<TBody> <TBody>
{isPending && <TableSkeleton columns={4} innerKey="organizations" />} {isPending && <TableSkeleton columns={4} innerKey="organizations" />}
{!isPending && {!isPending &&
data?.pages?.map((orgs) => organizations?.map((org) => {
orgs.map((org) => { const isMember = org.members.find((member) => member.user.id === user.id);
const isMember = org.members.find((member) => member.user.id === user.id);
return ( return (
<Tr key={`org-${org.id}`} className="w-full"> <Tr key={`org-${org.id}`} className="w-full">
<Td className="w-1/2 max-w-0"> <Td className="w-1/2 max-w-0">
<div className="flex items-center gap-x-1.5"> <div className="flex items-center gap-x-1.5">
{org.name ? ( {org.name ? (
<p className="truncate">{org.name}</p> <p className="truncate">{org.name}</p>
) : ( ) : (
<span className="text-mineshaft-400">Not Set</span> <span className="text-mineshaft-400">Not Set</span>
)} )}
</div> </div>
</Td> </Td>
<Td className="w-1/3"> <Td className="w-1/3">
<button <button
type="button" type="button"
onClick={() => handlePopUpOpen("viewMembers", { organization: org })} onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
className="flex items-center hover:underline" className="flex items-center hover:underline"
> >
<Tooltip className="text-center" content="View Members"> <Tooltip className="text-center" content="View Members">
<FontAwesomeIcon <FontAwesomeIcon
icon={faEye} icon={faEye}
className="mr-1.5 text-mineshaft-300" className="mr-1.5 text-mineshaft-300"
size="sm" size="sm"
/> />
</Tooltip>
{org.members.length} {org.members.length === 1 ? "Member" : "Members"}
{!org.members.some(
(member) =>
member.role === OrgMembershipRole.Admin &&
member.status === OrgMembershipStatus.Accepted
) && (
<Tooltip content="No admins have accepted their invitations.">
<div className="ml-1.5">
<FontAwesomeIcon className="text-yellow" icon={faWarning} />
</div>
</Tooltip> </Tooltip>
{org.members.length} {org.members.length === 1 ? "Member" : "Members"} )}
{!org.members.some( </button>
(member) => </Td>
member.role === OrgMembershipRole.Admin && <Td className="w-1/3">
member.status === OrgMembershipStatus.Accepted {org.projects.length} {org.projects.length === 1 ? "Project" : "Projects"}
) && ( </Td>
<Tooltip content="No admins have accepted their invitations."> <Td>
<div className="ml-1.5"> <div className="flex justify-end gap-x-1">
<FontAwesomeIcon className="text-yellow" icon={faWarning} /> {isMember && (
</div> <Tooltip
</Tooltip> className="text-center"
)} content="You are a member of this organization"
</button> >
</Td> <div>
<Td className="w-1/3"> <FontAwesomeIcon
{org.projects.length} {org.projects.length === 1 ? "Project" : "Projects"} className="text-mineshaft-400"
</Td> icon={faUserCheck}
<Td> size="sm"
<div className="flex justify-end gap-x-1"> />
{isMember && ( </div>
<Tooltip </Tooltip>
className="text-center" )}
content="You are a member of this organization" <DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
> >
<div> <FontAwesomeIcon icon={faEllipsisV} />
<FontAwesomeIcon </IconButton>
className="text-mineshaft-400" </DropdownMenuTrigger>
icon={faUserCheck} <DropdownMenuContent sideOffset={2} align="end">
size="sm" {!isMember && (
/>
</div>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
{!isMember && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleAccessOrg(org.id);
}}
icon={<FontAwesomeIcon icon={faUserPlus} />}
>
Join Organization
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handlePopUpOpen("deleteOrganization", { handleAccessOrg(org.id);
orgId: org.id,
orgName: org.name
});
}} }}
icon={<FontAwesomeIcon icon={faTrash} />} icon={<FontAwesomeIcon icon={faUserPlus} />}
> >
Delete Organization Join Organization
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> )}
</DropdownMenu> <DropdownMenuItem
</div> onClick={(e) => {
</Td> e.stopPropagation();
</Tr> handlePopUpOpen("deleteOrganization", {
); orgId: org.id,
}) orgName: org.name
)} });
}}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Organization
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
</Tr>
);
})}
</TBody> </TBody>
</Table> </Table>
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />} {!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
</TableContainer> </TableContainer>
{!isEmpty && ( {!isPending && totalCount > 0 && (
<Button <Pagination
className="mt-4 py-3 text-sm" count={totalCount}
isFullWidth page={page}
variant="outline_bg" perPage={perPage}
isLoading={isFetchingNextPage} onChangePage={(newPage) => setPage(newPage)}
isDisabled={isFetchingNextPage || !hasNextPage} onChangePerPage={handlePerPageChange}
onClick={() => fetchNextPage()} />
>
{hasNextPage ? "Load More" : "End of list"}
</Button>
)} )}
</div> </div>
<ViewMembersModal <ViewMembersModal