feat: adds group projects table

This commit is contained in:
Piyush Gupta
2025-11-20 15:05:21 +05:30
parent 59d43ed2eb
commit 844093d89f
16 changed files with 848 additions and 10 deletions

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas";
import { EFilterReturnedUsers } from "@app/ee/services/group/group-types";
import { GroupsSchema, OrgMembershipRole, ProjectsSchema, UsersSchema } from "@app/db/schemas";
import { EFilterReturnedProjects, EFilterReturnedUsers } from "@app/ee/services/group/group-types";
import { ApiDocsTags, GROUPS } from "@app/lib/api-docs";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -203,6 +203,55 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "GET",
url: "/:id/projects",
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.Groups],
params: z.object({
id: z.string().trim().describe(GROUPS.LIST_PROJECTS.id)
}),
querystring: z.object({
offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_PROJECTS.offset),
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_PROJECTS.limit),
search: z.string().trim().optional().describe(GROUPS.LIST_PROJECTS.search),
filter: z.nativeEnum(EFilterReturnedProjects).optional().describe(GROUPS.LIST_PROJECTS.filterProjects)
}),
response: {
200: z.object({
projects: ProjectsSchema.pick({
id: true,
name: true,
slug: true,
description: true,
type: true
})
.merge(
z.object({
joinedGroupAt: z.date().nullable()
})
)
.array(),
totalCount: z.number()
})
}
},
handler: async (req) => {
const { projects, totalCount } = await server.services.group.listGroupProjects({
id: req.params.id,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.query
});
return { projects, totalCount };
}
});
server.route({
method: "POST",
url: "/:id/users/:username",

View File

@@ -5,7 +5,7 @@ import { AccessScope, TableName, TGroups } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex";
import { EFilterReturnedUsers } from "./group-types";
import { EFilterReturnedProjects, EFilterReturnedUsers } from "./group-types";
export type TGroupDALFactory = ReturnType<typeof groupDALFactory>;
@@ -166,6 +166,81 @@ export const groupDALFactory = (db: TDbClient) => {
}
};
const findAllGroupProjects = async ({
orgId,
groupId,
offset,
limit,
search,
filter
}: {
orgId: string;
groupId: string;
offset?: number;
limit?: number;
search?: string;
filter?: EFilterReturnedProjects;
}) => {
try {
const normalizedSearch = search?.trim();
const query = db
.replicaNode()(TableName.Project)
.where(`${TableName.Project}.orgId`, orgId)
.leftJoin(TableName.Membership, (bd) => {
bd.on(`${TableName.Project}.id`, "=", `${TableName.Membership}.scopeProjectId`)
.andOn(`${TableName.Membership}.actorGroupId`, "=", db.raw("?", [groupId]))
.andOn(`${TableName.Membership}.scope`, "=", db.raw("?", [AccessScope.Project]));
})
.select(
db.ref("id").withSchema(TableName.Project),
db.ref("name").withSchema(TableName.Project),
db.ref("slug").withSchema(TableName.Project),
db.ref("description").withSchema(TableName.Project),
db.ref("type").withSchema(TableName.Project),
db.ref("createdAt").withSchema(TableName.Membership).as("joinedGroupAt"),
db.raw(`count(*) OVER() as "totalCount"`)
)
.orderBy(`${TableName.Project}.name`, "asc")
.offset(offset ?? 0);
if (limit) {
void query.limit(limit);
}
if (normalizedSearch && normalizedSearch.toLowerCase() !== "undefined") {
void query.andWhereRaw(
`CONCAT_WS(' ', "${TableName.Project}"."name", "${TableName.Project}"."slug", "${TableName.Project}"."description") ilike ?`,
[`%${normalizedSearch}%`]
);
}
switch (filter) {
case EFilterReturnedProjects.ASSIGNED_PROJECTS:
void query.whereNotNull(`${TableName.Membership}.id`);
break;
case EFilterReturnedProjects.UNASSIGNED_PROJECTS:
void query.whereNull(`${TableName.Membership}.id`);
break;
default:
break;
}
const projects = await query;
return {
projects: projects.map(({ joinedGroupAt, ...project }) => ({
...project,
joinedGroupAt
})),
// @ts-expect-error col select is raw and not strongly typed
totalCount: Number(projects?.[0]?.totalCount ?? 0)
};
} catch (error) {
throw new DatabaseError({ error, name: "Find all group projects" });
}
};
const findGroupsByProjectId = async (projectId: string, tx?: Knex) => {
try {
const docs = await (tx || db.replicaNode())(TableName.Groups)
@@ -230,6 +305,7 @@ export const groupDALFactory = (db: TDbClient) => {
findGroups,
findByOrgId,
findAllGroupPossibleMembers,
findAllGroupProjects,
findGroupsByProjectId,
findById,
findOne

View File

@@ -24,6 +24,7 @@ import {
TCreateGroupDTO,
TDeleteGroupDTO,
TGetGroupByIdDTO,
TListGroupProjectsDTO,
TListGroupUsersDTO,
TRemoveUserFromGroupDTO,
TUpdateGroupDTO
@@ -34,7 +35,14 @@ type TGroupServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findUserByUsername">;
groupDAL: Pick<
TGroupDALFactory,
"create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction"
| "create"
| "findOne"
| "update"
| "delete"
| "findAllGroupPossibleMembers"
| "findById"
| "transaction"
| "findAllGroupProjects"
>;
membershipGroupDAL: Pick<TMembershipGroupDALFactory, "find" | "findOne" | "create">;
membershipRoleDAL: Pick<TMembershipRoleDALFactory, "create" | "delete">;
@@ -367,6 +375,51 @@ export const groupServiceFactory = ({
return { users: members, totalCount };
};
const listGroupProjects = async ({
id,
offset,
limit,
search,
filter,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TListGroupProjectsDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
const { permission } = await permissionService.getOrgPermission({
scope: OrganizationActionScope.Any,
actor,
actorId,
orgId: actorOrgId,
actorAuthMethod,
actorOrgId
});
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups);
const group = await groupDAL.findOne({
orgId: actorOrgId,
id
});
if (!group)
throw new NotFoundError({
message: `Failed to find group with ID ${id}`
});
const { projects, totalCount } = await groupDAL.findAllGroupProjects({
orgId: group.orgId,
groupId: group.id,
offset,
limit,
search,
filter
});
return { projects, totalCount };
};
const addUserToGroup = async ({ id, username, actor, actorId, actorAuthMethod, actorOrgId }: TAddUserToGroupDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
@@ -535,6 +588,7 @@ export const groupServiceFactory = ({
updateGroup,
deleteGroup,
listGroupUsers,
listGroupProjects,
addUserToGroup,
removeUserFromGroup,
getGroupById

View File

@@ -42,6 +42,14 @@ export type TListGroupUsersDTO = {
filter?: EFilterReturnedUsers;
} & TGenericPermission;
export type TListGroupProjectsDTO = {
id: string;
offset: number;
limit: number;
search?: string;
filter?: EFilterReturnedProjects;
} & TGenericPermission;
export type TListProjectGroupUsersDTO = TListGroupUsersDTO & {
projectId: string;
};
@@ -111,3 +119,8 @@ export enum EFilterReturnedUsers {
EXISTING_MEMBERS = "existingMembers",
NON_MEMBERS = "nonMembers"
}
export enum EFilterReturnedProjects {
ASSIGNED_PROJECTS = "assignedProjects",
UNASSIGNED_PROJECTS = "unassignedProjects"
}

View File

@@ -106,6 +106,14 @@ export const GROUPS = {
filterUsers:
"Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization."
},
LIST_PROJECTS: {
id: "The ID of the group to list projects for.",
offset: "The offset to start from. If you enter 10, it will start from the 10th project.",
limit: "The number of projects to return.",
search: "The text string that project name or slug will be filtered by.",
filterProjects:
"Whether to filter the list of returned projects. 'assignedProjects' will only return projects assigned to the group, 'unassignedProjects' will only return projects not assigned to the group, undefined will return all projects in the organization."
},
ADD_USER: {
id: "The ID of the group to add the user to.",
username: "The username of the user to add to the group."

View File

@@ -5,4 +5,4 @@ export {
useRemoveUserFromGroup,
useUpdateGroup
} from "./mutations";
export { useGetGroupById, useListGroupUsers } from "./queries";
export { useGetGroupById, useListGroupProjects, useListGroupUsers } from "./queries";

View File

@@ -2,7 +2,13 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { EFilterReturnedUsers, TGroup, TGroupUser } from "./types";
import {
EFilterReturnedProjects,
EFilterReturnedUsers,
TGroup,
TGroupProject,
TGroupUser
} from "./types";
export const groupKeys = {
getGroupById: (groupId: string) => [{ groupId }, "group"] as const,
@@ -41,7 +47,22 @@ export const groupKeys = {
...groupKeys.forGroupUserMemberships(slug),
projectId,
{ offset, limit, search, filter }
] as const
] as const,
allGroupProjects: () => ["group-projects"] as const,
forGroupProjects: (groupId: string) => [...groupKeys.allGroupProjects(), groupId] as const,
specificGroupProjects: ({
groupId,
offset,
limit,
search,
filter
}: {
groupId: string;
offset: number;
limit: number;
search: string;
filter?: EFilterReturnedProjects;
}) => [...groupKeys.forGroupProjects(groupId), { offset, limit, search, filter }] as const
};
export const useGetGroupById = (groupId: string) => {
@@ -148,3 +169,46 @@ export const useListProjectGroupUsers = ({
}
});
};
export const useListGroupProjects = ({
id,
offset = 0,
limit = 10,
search,
filter
}: {
id: string;
offset: number;
limit: number;
search: string;
filter?: EFilterReturnedProjects;
}) => {
return useQuery({
queryKey: groupKeys.specificGroupProjects({
groupId: id,
offset,
limit,
search,
filter
}),
enabled: Boolean(id),
placeholderData: (previousData) => previousData,
queryFn: async () => {
const params = new URLSearchParams({
offset: String(offset),
limit: String(limit),
search,
...(filter && { filter })
});
const { data } = await apiRequest.get<{ projects: TGroupProject[]; totalCount: number }>(
`/api/v1/groups/${id}/projects`,
{
params
}
);
return data;
}
});
};

View File

@@ -52,7 +52,21 @@ export type TGroupUser = {
joinedGroupAt: Date;
};
export type TGroupProject = {
id: string;
name: string;
slug: string;
description: string;
type: string;
joinedGroupAt: Date;
};
export enum EFilterReturnedUsers {
EXISTING_MEMBERS = "existingMembers",
NON_MEMBERS = "nonMembers"
}
export enum EFilterReturnedProjects {
ASSIGNED_PROJECTS = "assignedProjects",
UNASSIGNED_PROJECTS = "unassignedProjects"
}

View File

@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { groupKeys } from "../groups/queries";
import { userKeys } from "../users/query-keys";
import { projectKeys } from "./query-keys";
import {
@@ -30,10 +31,11 @@ export const useAddGroupToWorkspace = () => {
return groupMembership;
},
onSuccess: (_, { projectId }) => {
onSuccess: (_, { projectId, groupId }) => {
queryClient.invalidateQueries({
queryKey: projectKeys.getProjectGroupMemberships(projectId)
});
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupProjects(groupId) });
}
});
};
@@ -77,11 +79,13 @@ export const useDeleteGroupFromWorkspace = () => {
} = await apiRequest.delete(`/api/v1/projects/${projectId}/groups/${groupId}`);
return groupMembership;
},
onSuccess: (_, { projectId, username }) => {
onSuccess: (_, { projectId, username, groupId }) => {
queryClient.invalidateQueries({
queryKey: projectKeys.getProjectGroupMemberships(projectId)
});
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupProjects(groupId) });
if (username) {
queryClient.invalidateQueries({ queryKey: userKeys.listUserGroupMemberships(username) });
}

View File

@@ -27,6 +27,7 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { GroupCreateUpdateModal } from "./components/GroupCreateUpdateModal";
import { GroupDetailsSection } from "./components/GroupDetailsSection";
import { GroupMembersSection } from "./components/GroupMembersSection";
import { GroupProjectsSection } from "./components/GroupProjectsSection";
export enum TabSections {
Member = "members",
@@ -154,7 +155,10 @@ const Page = () => {
<div className="w-full md:w-96">
<GroupDetailsSection groupId={groupId} handlePopUpOpen={handlePopUpOpen} />
</div>
<GroupMembersSection groupId={groupId} groupSlug={data.group.slug} />
<div className="flex grow flex-col gap-4">
<GroupMembersSection groupId={groupId} groupSlug={data.group.slug} />
<GroupProjectsSection groupId={groupId} groupSlug={data.group.slug} />
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,178 @@
import { useState } from "react";
import { faFolder, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
import { useDebounce, useResetPageHelper } from "@app/hooks";
import {
useAddGroupToWorkspace as useAddProjectToGroup,
useListGroupProjects
} from "@app/hooks/api";
import { EFilterReturnedProjects } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["addGroupProjects"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["addGroupProjects"]>,
state?: boolean
) => void;
};
export const AddGroupProjectModal = ({ popUp, handlePopUpToggle }: Props) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [searchProjectFilter, setSearchProjectFilter] = useState("");
const [debouncedSearch] = useDebounce(searchProjectFilter);
const popUpData = popUp?.addGroupProjects?.data as {
groupId: string;
slug: string;
};
const offset = (page - 1) * perPage;
const { data, isPending } = useListGroupProjects({
id: popUpData?.groupId,
offset,
limit: perPage,
search: debouncedSearch,
filter: EFilterReturnedProjects.UNASSIGNED_PROJECTS
});
const { totalCount = 0 } = data ?? {};
useResetPageHelper({
totalCount,
offset,
setPage
});
const { mutateAsync: addProjectToGroupMutateAsync, isPending: isAdding } = useAddProjectToGroup();
const handleAddProject = async (projectId: string, projectName: string) => {
if (!popUpData?.groupId) {
createNotification({
text: "Some data is missing, please refresh the page and try again",
type: "error"
});
return;
}
await addProjectToGroupMutateAsync({
groupId: popUpData.groupId,
projectId
});
createNotification({
text: `Successfully assigned project ${projectName} to the group`,
type: "success"
});
};
return (
<Modal
isOpen={popUp?.addGroupProjects?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addGroupProjects", isOpen);
}}
>
<ModalContent title="Add Group Projects">
<Input
value={searchProjectFilter}
onChange={(e) => setSearchProjectFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search projects..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>Project</Th>
<Th>Type</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="group-projects" />}
{!isPending &&
data?.projects?.map((project) => {
return (
<Tr className="items-center" key={`group-project-${project.id}`}>
<Td>
<p>{project.name}</p>
{project.description && (
<p className="text-sm text-mineshaft-400">{project.description}</p>
)}
</Td>
<Td>
<p className="capitalize">{project.type?.replace("-", " ") || "-"}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionGroupActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isAdding}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAddProject(project.id, project.name)}
>
Assign
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isPending && !data?.projects?.length && (
<EmptyState
title={
debouncedSearch
? "No projects match search"
: "All projects are already assigned to the group"
}
icon={faFolder}
/>
)}
</TableContainer>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,79 @@
import { faEllipsisV, faFolderMinus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
import { TGroupProject } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
project: TGroupProject;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeProjectFromGroup"]>,
data?: object
) => void;
};
export const GroupProjectRow = ({ project, handlePopUpOpen }: Props) => {
return (
<Tr className="items-center" key={`group-project-${project.id}`}>
<Td>
<p>{project.name}</p>
</Td>
<Td>
<p className="capitalize">{project.type?.replace("-", " ") || "-"}</p>
</Td>
<Td>
<Tooltip content={new Date(project.joinedGroupAt).toLocaleString()}>
<p>{new Date(project.joinedGroupAt).toLocaleDateString()}</p>
</Tooltip>
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => {
return (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faFolderMinus} />}
onClick={() =>
handlePopUpOpen("removeProjectFromGroup", {
projectId: project.id,
projectName: project.name
})
}
isDisabled={!isAllowed}
>
Remove Project From Group
</DropdownMenuItem>
);
}}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
};

View File

@@ -0,0 +1,90 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton } from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
import { useDeleteGroupFromWorkspace as useRemoveProjectFromGroup } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { AddGroupProjectModal } from "../AddGroupProjectModal";
import { GroupProjectsTable } from "./GroupProjectsTable";
type Props = {
groupId: string;
groupSlug: string;
};
export const GroupProjectsSection = ({ groupId, groupSlug }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"addGroupProjects",
"removeProjectFromGroup"
] as const);
const { mutateAsync: removeProjectFromGroupMutateAsync } = useRemoveProjectFromGroup();
const handleRemoveProjectFromGroup = async (projectId: string, projectName: string) => {
await removeProjectFromGroupMutateAsync({
groupId,
projectId
});
createNotification({
text: `Successfully removed project ${projectName} from the group`,
type: "success"
});
handlePopUpToggle("removeProjectFromGroup", false);
};
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Group Projects</h3>
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<IconButton
isDisabled={!isAllowed}
ariaLabel="add project"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("addGroupProjects", {
groupId,
slug: groupSlug
});
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
)}
</OrgPermissionCan>
</div>
<div className="py-4">
<GroupProjectsTable
groupId={groupId}
groupSlug={groupSlug}
handlePopUpOpen={handlePopUpOpen}
/>
</div>
<AddGroupProjectModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.removeProjectFromGroup.isOpen}
title={`Are you sure you want to remove ${
(popUp?.removeProjectFromGroup?.data as { projectName: string })?.projectName || ""
} from the group?`}
onChange={(isOpen) => handlePopUpToggle("removeProjectFromGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => {
const projectData = popUp?.removeProjectFromGroup?.data as {
projectId: string;
projectName: string;
};
return handleRemoveProjectFromGroup(projectData.projectId, projectData.projectName);
}}
/>
</div>
);
};

View File

@@ -0,0 +1,203 @@
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useListGroupProjects } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { EFilterReturnedProjects } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { GroupProjectRow } from "./GroupProjectRow";
type Props = {
groupId: string;
groupSlug: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeProjectFromGroup", "addGroupProjects"]>,
data?: object
) => void;
};
enum GroupProjectsOrderBy {
Name = "name"
}
export const GroupProjectsTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => {
const {
search,
debouncedSearch,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection
} = usePagination(GroupProjectsOrderBy.Name, {
initPerPage: getUserTablePreference("groupProjectsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("groupProjectsTable", PreferenceKey.PerPage, newPerPage);
};
const { data: groupMemberships, isPending } = useListGroupProjects({
id: groupId,
offset,
limit: perPage,
search: debouncedSearch,
filter: EFilterReturnedProjects.ASSIGNED_PROJECTS
});
const filteredGroupProjects = useMemo(() => {
return groupMemberships && groupMemberships?.projects
? groupMemberships?.projects
?.filter((project) => {
const projectSearchString = `${project.name || ""} ${project.slug || ""} ${
project.description || ""
}`;
return projectSearchString.toLowerCase().includes(search.trim().toLowerCase());
})
.sort((a, b) => {
const [projectOne, projectTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
const projectOneComparisonString = projectOne.name || "";
const projectTwoComparisonString = projectTwo.name || "";
const comparison = projectOneComparisonString
.toLowerCase()
.localeCompare(projectTwoComparisonString.toLowerCase());
return comparison;
})
: [];
}, [groupMemberships, orderDirection, search]);
useResetPageHelper({
totalCount: filteredGroupProjects?.length,
offset,
setPage
});
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search projects..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th>Type</Th>
<Th>Added On</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="group-project-memberships" />}
{!isPending &&
filteredGroupProjects.slice(offset, perPage * page).map((project) => {
return (
<GroupProjectRow
key={`group-project-${project.id}`}
project={project}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{Boolean(filteredGroupProjects.length) && (
<Pagination
count={filteredGroupProjects.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredGroupProjects?.length && (
<EmptyState
title={
groupMemberships?.projects.length
? "No projects match this search..."
: "This group does not have any projects assigned yet"
}
icon={groupMemberships?.projects.length ? faSearch : faFolder}
/>
)}
{!groupMemberships?.projects.length && (
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<div className="mb-4 flex items-center justify-center">
<Button
variant="solid"
colorSchema="secondary"
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("addGroupProjects", {
groupId,
slug: groupSlug
});
}}
>
Add projects
</Button>
</div>
)}
</OrgPermissionCan>
)}
</TableContainer>
</div>
);
};

View File

@@ -0,0 +1 @@
export { GroupProjectsSection } from "./GroupProjectsSection";

View File

@@ -1 +1,2 @@
export { GroupDetailsSection } from "./GroupDetailsSection";
export { GroupProjectsSection } from "./GroupProjectsSection";