Merge pull request #4629 from Infisical/feat/pagination-in-resource-overview-tables

feat: adds pagination to resource overview tables
This commit is contained in:
Piyush Gupta
2025-10-09 21:17:25 +05:30
committed by GitHub
14 changed files with 489 additions and 416 deletions

View File

@@ -172,7 +172,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
email: true,
id: true,
superAdmin: true
}).array()
}).array(),
total: z.number()
})
}
},
@@ -182,13 +183,11 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
});
},
handler: async (req) => {
const users = await server.services.superAdmin.getUsers({
const result = await server.services.superAdmin.getUsers({
...req.query
});
return {
users
};
return result;
}
});
@@ -230,7 +229,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
createdAt: z.date()
})
.array()
}).array()
}).array(),
total: z.number()
})
}
},
@@ -240,13 +240,11 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
});
},
handler: async (req) => {
const organizations = await server.services.superAdmin.getOrganizations({
const result = await server.services.superAdmin.getOrganizations({
...req.query
});
return {
organizations
};
return result;
}
});
@@ -337,7 +335,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
.extend({
isInstanceAdmin: z.boolean()
})
.array()
.array(),
total: z.number()
})
}
},
@@ -347,13 +346,11 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
});
},
handler: async (req) => {
const identities = await server.services.superAdmin.getIdentities({
const result = await server.services.superAdmin.getIdentities({
...req.query
});
return {
identities
};
return result;
}
});

View File

@@ -50,11 +50,23 @@ export const identityDALFactory = (db: TDbClient) => {
});
}
const countQuery = query.clone();
if (sortBy) {
query = query.orderBy(sortBy);
}
return await query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Identity));
const [identities, totalResult] = await Promise.all([
query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Identity)),
countQuery.countDistinct(`${TableName.Identity}.id`, { as: "count" }).first()
]);
const total = Number(totalResult?.count || 0);
return {
identities,
total
};
} catch (error) {
throw new DatabaseError({ error, name: "Get identities by filter" });
}

View File

@@ -43,8 +43,6 @@ export const orgDALFactory = (db: TDbClient) => {
sortBy?: keyof TOrganizations;
}) => {
try {
const query = db.replicaNode()(TableName.Organization);
// Build the subquery for limited organization IDs
const orgSubquery = db.replicaNode().select("id").from(TableName.Organization);
@@ -54,37 +52,45 @@ export const orgDALFactory = (db: TDbClient) => {
});
}
const countQuery = orgSubquery.clone();
if (sortBy) {
void orgSubquery.orderBy(sortBy);
}
void orgSubquery.limit(limit).offset(offset);
// Main query with joins, limited to the subquery results
const docs = await query
.whereIn(`${TableName.Organization}.id`, orgSubquery)
.leftJoin(TableName.Project, `${TableName.Organization}.id`, `${TableName.Project}.orgId`)
.leftJoin(TableName.OrgMembership, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`)
.leftJoin(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.OrgRoles, `${TableName.OrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
.where((qb) => {
void qb.where(`${TableName.Users}.isGhost`, false).orWhereNull(`${TableName.Users}.id`);
})
.select(selectAllTableCols(TableName.Organization))
.select(db.ref("name").withSchema(TableName.Project).as("projectName"))
.select(db.ref("id").withSchema(TableName.Project).as("projectId"))
.select(db.ref("slug").withSchema(TableName.Project).as("projectSlug"))
.select(db.ref("createdAt").withSchema(TableName.Project).as("projectCreatedAt"))
.select(db.ref("email").withSchema(TableName.Users).as("userEmail"))
.select(db.ref("username").withSchema(TableName.Users).as("username"))
.select(db.ref("firstName").withSchema(TableName.Users).as("firstName"))
.select(db.ref("lastName").withSchema(TableName.Users).as("lastName"))
.select(db.ref("id").withSchema(TableName.Users).as("userId"))
.select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"))
.select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole"))
.select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId"))
.select(db.ref("status").withSchema(TableName.OrgMembership).as("orgMembershipStatus"))
.select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName"));
const buildBaseQuery = (orgIdSubquery: Knex.QueryBuilder) => {
return db
.replicaNode()(TableName.Organization)
.whereIn(`${TableName.Organization}.id`, orgIdSubquery)
.leftJoin(TableName.Project, `${TableName.Organization}.id`, `${TableName.Project}.orgId`)
.leftJoin(TableName.OrgMembership, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`)
.leftJoin(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.OrgRoles, `${TableName.OrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
.where((qb) => {
void qb.where(`${TableName.Users}.isGhost`, false).orWhereNull(`${TableName.Users}.id`);
});
};
const [docs, totalResult] = await Promise.all([
buildBaseQuery(orgSubquery)
.select(selectAllTableCols(TableName.Organization))
.select(db.ref("name").withSchema(TableName.Project).as("projectName"))
.select(db.ref("id").withSchema(TableName.Project).as("projectId"))
.select(db.ref("slug").withSchema(TableName.Project).as("projectSlug"))
.select(db.ref("createdAt").withSchema(TableName.Project).as("projectCreatedAt"))
.select(db.ref("email").withSchema(TableName.Users).as("userEmail"))
.select(db.ref("username").withSchema(TableName.Users).as("username"))
.select(db.ref("firstName").withSchema(TableName.Users).as("firstName"))
.select(db.ref("lastName").withSchema(TableName.Users).as("lastName"))
.select(db.ref("id").withSchema(TableName.Users).as("userId"))
.select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"))
.select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole"))
.select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId"))
.select(db.ref("status").withSchema(TableName.OrgMembership).as("orgMembershipStatus"))
.select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName")),
buildBaseQuery(countQuery).countDistinct(`${TableName.Organization}.id`, { as: "count" }).first()
]);
const formattedDocs = sqlNestRelationships({
data: docs,
@@ -132,7 +138,12 @@ export const orgDALFactory = (db: TDbClient) => {
]
});
return formattedDocs;
const total = Number(totalResult?.count || 0);
return {
organizations: formattedDocs,
total
};
} catch (error) {
throw new DatabaseError({ error, name: "Find organizations by filter" });
}

View File

@@ -741,13 +741,12 @@ export const superAdminServiceFactory = ({
};
const getOrganizations = async ({ offset, limit, searchTerm }: TGetOrganizationsDTO) => {
const organizations = await orgDAL.findOrganizationsByFilter({
return orgDAL.findOrganizationsByFilter({
offset,
searchTerm,
sortBy: "name",
limit
});
return organizations;
};
const createOrganization = async (
@@ -1015,7 +1014,7 @@ export const superAdminServiceFactory = ({
};
const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => {
const identities = await identityDAL.getIdentitiesByFilter({
const result = await identityDAL.getIdentitiesByFilter({
limit,
offset,
searchTerm,
@@ -1023,10 +1022,13 @@ export const superAdminServiceFactory = ({
});
const serverCfg = await getServerCfg();
return identities.map((identity) => ({
...identity,
isInstanceAdmin: Boolean(serverCfg?.adminIdentityIds?.includes(identity.id))
}));
return {
identities: result.identities.map((identity) => ({
...identity,
isInstanceAdmin: Boolean(serverCfg?.adminIdentityIds?.includes(identity.id))
})),
total: result.total
};
};
const grantServerAdminAccessToUser = async (userId: string) => {

View File

@@ -60,11 +60,20 @@ export const userDALFactory = (db: TDbClient) => {
query = query.where("superAdmin", true);
}
const countQuery = query.clone();
if (sortBy) {
query = query.orderBy(sortBy);
}
return await query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Users));
const [users, totalResult] = await Promise.all([
query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Users)),
countQuery.count("*", { as: "count" }).first()
]);
const total = Number(totalResult?.count || 0);
return { users, total };
} catch (error) {
throw new DatabaseError({ error, name: "Get users by filter" });
}

View File

@@ -1,25 +1,18 @@
import {
DefaultError,
InfiniteData,
UndefinedInitialDataInfiniteOptions,
useInfiniteQuery,
useQuery,
UseQueryOptions
} from "@tanstack/react-query";
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { Identity } from "@app/hooks/api/identities/types";
import { User } from "../types";
import {
AdminGetIdentitiesFilters,
AdminGetOrganizationsFilters,
AdminGetUsersFilters,
AdminIntegrationsConfig,
OrganizationWithProjects,
TGetEnvOverrides,
TGetIdentitiesResponse,
TGetInvalidatingCacheStatus,
TGetOrganizationsResponse,
TGetServerRootKmsEncryptionDetails,
TGetUsersResponse,
TServerConfig
} from "./types";
@@ -49,24 +42,21 @@ export const fetchServerConfig = async () => {
};
export const useAdminGetOrganizations = (filters: AdminGetOrganizationsFilters) => {
return useInfiniteQuery({
initialPageParam: 0,
return useQuery({
queryKey: adminQueryKeys.getOrganizations(filters),
queryFn: async ({ pageParam }) => {
const { data } = await apiRequest.get<{ organizations: OrganizationWithProjects[] }>(
queryFn: async () => {
const { data } = await apiRequest.get<TGetOrganizationsResponse>(
"/api/v1/admin/organization-management/organizations",
{
params: {
...filters,
offset: pageParam
...filters
}
}
);
return data.organizations;
return { organizations: data.organizations, totalCount: data.total };
},
getNextPageParam: (lastPage, pages) =>
lastPage.length !== 0 ? pages.length * filters.limit : undefined
placeholderData: (previousData) => previousData
});
};
@@ -90,59 +80,41 @@ export const useGetServerConfig = ({
enabled: options?.enabled ?? true
});
export const useAdminGetUsers = (
filters: AdminGetUsersFilters,
options?: Partial<
UndefinedInitialDataInfiniteOptions<
User[],
DefaultError,
InfiniteData<User[]>,
ReturnType<typeof adminQueryKeys.getUsers>,
number
>
>
) => {
return useInfiniteQuery({
initialPageParam: 0,
export const useAdminGetUsers = (filters: AdminGetUsersFilters) => {
return useQuery({
queryKey: adminQueryKeys.getUsers(filters),
queryFn: async ({ pageParam }) => {
const { data } = await apiRequest.get<{ users: User[] }>(
queryFn: async () => {
const { data } = await apiRequest.get<TGetUsersResponse>(
"/api/v1/admin/user-management/users",
{
params: {
...filters,
offset: pageParam
...filters
}
}
);
return data.users;
return { users: data.users, totalCount: data.total };
},
getNextPageParam: (lastPage, pages) =>
lastPage.length !== 0 ? pages.length * filters.limit : undefined,
...options
placeholderData: (previousData) => previousData
});
};
export const useAdminGetIdentities = (filters: AdminGetIdentitiesFilters) => {
return useInfiniteQuery({
initialPageParam: 0,
return useQuery({
queryKey: adminQueryKeys.getIdentities(filters),
queryFn: async ({ pageParam }) => {
const { data } = await apiRequest.get<{ identities: Identity[] }>(
queryFn: async () => {
const { data } = await apiRequest.get<TGetIdentitiesResponse>(
"/api/v1/admin/identity-management/identities",
{
params: {
...filters,
offset: pageParam
...filters
}
}
);
return data.identities;
return { identities: data.identities, totalCount: data.total };
},
getNextPageParam: (lastPage, pages) =>
lastPage.length !== 0 ? pages.length * filters.limit : undefined
placeholderData: (previousData) => previousData
});
};

View File

@@ -1,6 +1,7 @@
import { Identity } from "@app/hooks/api/identities/types";
import { OrgMembershipStatus } from "@app/hooks/api/organization/types";
import { Organization } from "../types";
import { Organization, User } from "../types";
export enum LoginMethod {
EMAIL = "email",
@@ -34,6 +35,21 @@ export type OrganizationWithProjects = Organization & {
}[];
};
export type TGetOrganizationsResponse = {
organizations: OrganizationWithProjects[];
total: number;
};
export type TGetIdentitiesResponse = {
identities: Identity[];
total: number;
};
export type TGetUsersResponse = {
users: User[];
total: number;
};
export type TServerConfig = {
initialized: boolean;
allowSignUp: boolean;
@@ -81,19 +97,22 @@ export type TCreateAdminUserDTO = {
};
export type AdminGetOrganizationsFilters = {
limit: number;
searchTerm: string;
searchTerm?: string;
limit?: number;
offset?: number;
};
export type AdminGetUsersFilters = {
limit: number;
searchTerm: string;
adminsOnly: boolean;
limit?: number;
offset?: number;
searchTerm?: string;
adminsOnly?: boolean;
};
export type AdminGetIdentitiesFilters = {
limit: number;
searchTerm: string;
limit?: number;
offset?: number;
searchTerm?: string;
};
export type AdminIntegrationsConfig = {

View File

@@ -103,29 +103,29 @@ export type TWorkspaceUser = {
organization: string;
roles: (
| {
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: false;
temporaryRange: null;
temporaryMode: null;
temporaryAccessEndTime: null;
temporaryAccessStartTime: null;
}
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: false;
temporaryRange: null;
temporaryMode: null;
temporaryAccessEndTime: null;
temporaryAccessStartTime: null;
}
| {
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: true;
temporaryRange: string;
temporaryMode: ProjectUserMembershipTemporaryMode;
temporaryAccessEndTime: string;
temporaryAccessStartTime: string;
}
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: true;
temporaryRange: string;
temporaryMode: ProjectUserMembershipTemporaryMode;
temporaryAccessEndTime: string;
temporaryAccessStartTime: string;
}
)[];
status: "invited" | "accepted" | "verified" | "completed";
deniedPermissions: any[];

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@@ -55,18 +55,14 @@ const Content = ({ onClose }: ContentProps) => {
const [searchUserFilter, setSearchUserFilter] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const { data, isFetching } = useAdminGetUsers(
{
limit: 20,
searchTerm: debouncedSearchTerm,
adminsOnly: false
},
{
placeholderData: (prev) => prev
}
);
const { data, isPending } = useAdminGetUsers({
limit: 20,
searchTerm: debouncedSearchTerm,
adminsOnly: false
});
const users = useMemo(() => data?.pages.flat().filter((user) => !user.superAdmin) ?? [], [data]);
const { users: usersData = [] } = data ?? {};
const users = usersData.filter((user) => !user.superAdmin);
const onSubmit = async ({ user }: FormData) => {
try {
@@ -91,7 +87,7 @@ const Content = ({ onClose }: ContentProps) => {
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="User">
<FilterableSelect
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching}
isLoading={searchUserFilter !== debouncedSearchTerm || isPending}
className="w-full"
placeholder="Search users..."
options={users}

View File

@@ -11,7 +11,6 @@ import {
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { InfiniteData } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
@@ -28,6 +27,7 @@ import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -39,7 +39,12 @@ import {
Tr
} from "@app/components/v2";
import { useSubscription, useUser } from "@app/context";
import { useDebounce, usePopUp } from "@app/hooks";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp } from "@app/hooks";
import {
useAdminBulkDeleteUsers,
useAdminDeleteUser,
@@ -54,15 +59,17 @@ const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions f
const ServerAdminsPanelTable = ({
handlePopUpOpen,
users: usersPages,
users,
isPending,
searchUserFilter,
setSearchUserFilter,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
selectedUsers,
setSelectedUsers
setSelectedUsers,
totalCount,
page,
perPage,
setPage,
handlePerPageChange
}: {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
@@ -75,20 +82,20 @@ const ServerAdminsPanelTable = ({
}
) => void;
isPending: boolean;
users: InfiniteData<User[], unknown> | undefined;
users?: User[];
searchUserFilter: string;
setSearchUserFilter: (filter: string) => void;
selectedUsers: User[];
setSelectedUsers: Dispatch<SetStateAction<User[]>>;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
hasNextPage: boolean;
totalCount: number;
page: number;
perPage: number;
setPage: Dispatch<SetStateAction<number>>;
handlePerPageChange: (newPerPage: number) => void;
}) => {
const { subscription } = useSubscription();
const users = usersPages?.pages.flat();
const isEmpty = !isPending && !users?.length;
const isEmpty = !isPending && totalCount === 0;
const selectedUserIds = selectedUsers.map((user) => user.id);
@@ -241,16 +248,13 @@ const ServerAdminsPanelTable = ({
{!isPending && isEmpty && <EmptyState title="No users found" icon={faUsers} />}
</TableContainer>
{!isEmpty && (
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="outline_bg"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of List"}
</Button>
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
</div>
</>
@@ -278,18 +282,24 @@ export const ServerAdminsTable = () => {
const [searchUserFilter, setSearchUserFilter] = useState("");
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const {
data: users,
isPending,
isFetchingNextPage,
hasNextPage,
fetchNextPage
} = useAdminGetUsers({
limit: 20,
const { offset, limit, setPage, perPage, page, setPerPage } = usePagination("", {
initPerPage: getUserTablePreference("ServerAdminUsersTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("ServerAdminUsersTable", PreferenceKey.PerPage, newPerPage);
};
const { data, isPending } = useAdminGetUsers({
limit,
offset,
searchTerm: debouncedSearchTerm,
adminsOnly: true
});
const { users = [], totalCount = 0 } = data ?? {};
const handleRemoveUser = async () => {
const { id } = popUp?.removeUser?.data as { id: string; username: string };
@@ -389,9 +399,11 @@ export const ServerAdminsTable = () => {
searchUserFilter={searchUserFilter}
setSearchUserFilter={setSearchUserFilter}
isPending={isPending}
isFetchingNextPage={isFetchingNextPage}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
page={page}
perPage={perPage}
setPage={setPage}
handlePerPageChange={handlePerPageChange}
totalCount={totalCount}
/>
<DeleteActionModal
isOpen={popUp.removeUser.isOpen}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@@ -72,18 +72,13 @@ const Content = ({ onClose }: ContentProps) => {
const [searchUserFilter, setSearchUserFilter] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const { data, isFetching } = useAdminGetUsers(
{
limit: 20,
searchTerm: debouncedSearchTerm,
adminsOnly: false
},
{
placeholderData: (prev) => prev
}
);
const { data, isPending } = useAdminGetUsers({
limit: 20,
searchTerm: debouncedSearchTerm,
adminsOnly: false
});
const users = useMemo(() => data?.pages.flat() ?? [], [data]);
const { users = [] } = data ?? {};
const onSubmit = async ({ name, invitees }: FormData) => {
try {
@@ -155,7 +150,7 @@ const Content = ({ onClose }: ContentProps) => {
})
.includes(input)
}
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching}
isLoading={searchUserFilter !== debouncedSearchTerm || isPending}
className="w-full"
placeholder="Search users or invite new ones..."
isMulti

View File

@@ -11,7 +11,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import {
Badge,
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
@@ -20,6 +19,7 @@ import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -29,7 +29,12 @@ import {
THead,
Tr
} from "@app/components/v2";
import { useDebounce, usePopUp } from "@app/hooks";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useAdminRemoveIdentitySuperAdminAccess } from "@app/hooks/api/admin";
import { useAdminGetIdentities } from "@app/hooks/api/admin/queries";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -48,14 +53,34 @@ const IdentityPanelTable = ({
const [searchIdentityFilter, setSearchIdentityFilter] = useState("");
const [debouncedSearchTerm] = useDebounce(searchIdentityFilter, 500);
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetIdentities(
{
limit: 20,
searchTerm: debouncedSearchTerm
}
);
const { offset, limit, setPage, perPage, page, setPerPage } = usePagination("", {
initPerPage: getUserTablePreference(
"ResourceOverviewIdentitiesTable",
PreferenceKey.PerPage,
10
)
});
const isEmpty = !isPending && !data?.pages?.[0].length;
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("ResourceOverviewIdentitiesTable", PreferenceKey.PerPage, newPerPage);
};
const { data, isPending } = useAdminGetIdentities({
limit,
offset,
searchTerm: debouncedSearchTerm
});
const { identities, totalCount = 0 } = data ?? {};
const isEmpty = !isPending && !totalCount;
useResetPageHelper({
totalCount,
offset,
setPage
});
return (
<>
@@ -80,75 +105,68 @@ const IdentityPanelTable = ({
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="identities" />}
{!isPending &&
data?.pages?.map((identities) =>
identities.map(({ name, id, isInstanceAdmin }) => (
<Tr key={`identity-${id}`} className="w-full">
<Td>
{name}
{isInstanceAdmin && (
<Badge variant="primary" className="ml-2">
Server Admin
</Badge>
)}
</Td>
<Td>
{isInstanceAdmin && (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
{isInstanceAdmin && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeServerAdmin", { name, id });
}}
icon={
<div className="relative">
<FontAwesomeIcon icon={faShieldHalved} />
<FontAwesomeIcon
className="absolute -bottom-[0.01rem] -right-1"
size="2xs"
icon={faXmark}
/>
</div>
}
>
Remove Server Admin
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</Td>
</Tr>
))
)}
identities?.map(({ name, id, isInstanceAdmin }) => (
<Tr key={`identity-${id}`} className="w-full">
<Td>
{name}
{isInstanceAdmin && (
<Badge variant="primary" className="ml-2">
Server Admin
</Badge>
)}
</Td>
<Td>
{isInstanceAdmin && (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeServerAdmin", { name, id });
}}
icon={
<div className="relative">
<FontAwesomeIcon icon={faShieldHalved} />
<FontAwesomeIcon
className="absolute -bottom-[0.01rem] -right-1"
size="2xs"
icon={faXmark}
/>
</div>
}
>
Remove Server Admin
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</Td>
</Tr>
))}
</TBody>
</Table>
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faWrench} />}
</TableContainer>
{!isEmpty && (
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="outline_bg"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of List"}
</Button>
{!isPending && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
</div>
</>

View File

@@ -48,6 +48,11 @@ import {
} from "@app/components/v2";
import { useUser } from "@app/context";
import { OrgMembershipRole } from "@app/helpers/roles";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import {
useAdminDeleteOrganization,
@@ -443,13 +448,31 @@ const OrganizationsPanelTable = ({
const { user } = useUser();
const navigate = useNavigate();
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
useAdminGetOrganizations({
limit: 20,
searchTerm: debouncedSearchTerm
});
const isEmpty = !isPending && !data?.pages?.[0].length;
const { offset, limit, setPage, perPage, page, setPerPage } = usePagination("", {
initPerPage: getUserTablePreference("ResourceOverviewOrgsTable", PreferenceKey.PerPage, 10)
});
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();
@@ -499,125 +522,120 @@ const OrganizationsPanelTable = ({
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="organizations" />}
{!isPending &&
data?.pages?.map((orgs) =>
orgs.map((org) => {
const isMember = org.members.find((member) => member.user.id === user.id);
organizations?.map((org) => {
const isMember = org.members.find((member) => member.user.id === user.id);
return (
<Tr key={`org-${org.id}`} className="w-full">
<Td className="w-1/2 max-w-0">
<div className="flex items-center gap-x-1.5">
{org.name ? (
<p className="truncate">{org.name}</p>
) : (
<span className="text-mineshaft-400">Not Set</span>
)}
</div>
</Td>
<Td className="w-1/3">
<button
type="button"
onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
className="flex items-center hover:underline"
>
<Tooltip className="text-center" content="View Members">
<FontAwesomeIcon
icon={faEye}
className="mr-1.5 text-mineshaft-300"
size="sm"
/>
return (
<Tr key={`org-${org.id}`} className="w-full">
<Td className="w-1/2 max-w-0">
<div className="flex items-center gap-x-1.5">
{org.name ? (
<p className="truncate">{org.name}</p>
) : (
<span className="text-mineshaft-400">Not Set</span>
)}
</div>
</Td>
<Td className="w-1/3">
<button
type="button"
onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
className="flex items-center hover:underline"
>
<Tooltip className="text-center" content="View Members">
<FontAwesomeIcon
icon={faEye}
className="mr-1.5 text-mineshaft-300"
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>
{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>
)}
</button>
</Td>
<Td className="w-1/3">
{org.projects.length} {org.projects.length === 1 ? "Project" : "Projects"}
</Td>
<Td>
<div className="flex justify-end gap-x-1">
{isMember && (
<Tooltip
className="text-center"
content="You are a member of this organization"
)}
</button>
</Td>
<Td className="w-1/3">
{org.projects.length} {org.projects.length === 1 ? "Project" : "Projects"}
</Td>
<Td>
<div className="flex justify-end gap-x-1">
{isMember && (
<Tooltip
className="text-center"
content="You are a member of this organization"
>
<div>
<FontAwesomeIcon
className="text-mineshaft-400"
icon={faUserCheck}
size="sm"
/>
</div>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<div>
<FontAwesomeIcon
className="text-mineshaft-400"
icon={faUserCheck}
size="sm"
/>
</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>
)}
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
{!isMember && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteOrganization", {
orgId: org.id,
orgName: org.name
});
handleAccessOrg(org.id);
}}
icon={<FontAwesomeIcon icon={faTrash} />}
icon={<FontAwesomeIcon icon={faUserPlus} />}
>
Delete Organization
Join Organization
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
</Tr>
);
})
)}
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteOrganization", {
orgId: org.id,
orgName: org.name
});
}}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Organization
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
</TableContainer>
{!isEmpty && (
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="outline_bg"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of list"}
</Button>
{!isPending && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
</div>
<ViewMembersModal

View File

@@ -13,7 +13,6 @@ import {
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { InfiniteData } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
@@ -31,6 +30,7 @@ import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -42,7 +42,12 @@ import {
Tr
} from "@app/components/v2";
import { useSubscription, useUser } from "@app/context";
import { useDebounce, usePopUp } from "@app/hooks";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp } from "@app/hooks";
import {
useAdminBulkDeleteUsers,
useAdminDeleteUser,
@@ -58,17 +63,19 @@ const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions f
const UserPanelTable = ({
handlePopUpOpen,
users: usersPages,
users,
isPending,
adminsOnly,
searchUserFilter,
setSearchUserFilter,
setAdminsOnly,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
selectedUsers,
setSelectedUsers
setSelectedUsers,
totalCount,
page,
perPage,
setPage,
handlePerPageChange
}: {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
@@ -81,22 +88,22 @@ const UserPanelTable = ({
}
) => void;
isPending: boolean;
users: InfiniteData<User[], unknown> | undefined;
users: User[] | undefined;
adminsOnly: boolean;
setAdminsOnly: (adminsOnly: boolean) => void;
searchUserFilter: string;
setSearchUserFilter: (filter: string) => void;
selectedUsers: User[];
setSelectedUsers: Dispatch<SetStateAction<User[]>>;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
hasNextPage: boolean;
totalCount: number;
page: number;
perPage: number;
setPage: Dispatch<SetStateAction<number>>;
handlePerPageChange: (newPerPage: number) => void;
}) => {
const { subscription } = useSubscription();
const users = usersPages?.pages.flat();
const isEmpty = !isPending && !users?.length;
const isEmpty = !isPending && totalCount === 0;
const isTableFiltered = Boolean(adminsOnly);
const selectedUserIds = selectedUsers.map((user) => user.id);
@@ -302,16 +309,13 @@ const UserPanelTable = ({
{!isPending && isEmpty && <EmptyState title="No users found" icon={faUsers} />}
</TableContainer>
{!isEmpty && (
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="outline_bg"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of List"}
</Button>
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
</div>
</>
@@ -341,18 +345,24 @@ export const UserIdentitiesTable = () => {
const [adminsOnly, setAdminsOnly] = useState(false);
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const {
data: users,
isPending,
isFetchingNextPage,
hasNextPage,
fetchNextPage
} = useAdminGetUsers({
limit: 20,
const { offset, limit, setPage, perPage, page, setPerPage } = usePagination("", {
initPerPage: getUserTablePreference("ResourceOverviewUsersTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("ResourceOverviewUsersTable", PreferenceKey.PerPage, newPerPage);
};
const { data, isPending } = useAdminGetUsers({
limit,
offset,
searchTerm: debouncedSearchTerm,
adminsOnly
});
const { users, totalCount = 0 } = data ?? {};
const handleRemoveUser = async () => {
const { id } = popUp?.removeUser?.data as { id: string; username: string };
@@ -479,9 +489,11 @@ export const UserIdentitiesTable = () => {
isPending={isPending}
adminsOnly={adminsOnly}
setAdminsOnly={setAdminsOnly}
isFetchingNextPage={isFetchingNextPage}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
page={page}
perPage={perPage}
setPage={setPage}
handlePerPageChange={handlePerPageChange}
totalCount={totalCount}
/>
<DeleteActionModal
isOpen={popUp.removeUser.isOpen}