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
+1 -1
View File
@@ -5,4 +5,4 @@ export {
useRemoveUserFromGroup,
useUpdateGroup
} from "./mutations";
export { useGetGroupById, useListGroupUsers } from "./queries";
export { useGetGroupById, useListGroupProjects, useListGroupUsers } from "./queries";
+66 -2
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;
}
});
};
+14
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"
}
@@ -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) });
}
@@ -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>
)}
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -0,0 +1 @@
export { GroupProjectsSection } from "./GroupProjectsSection";
@@ -1 +1,2 @@
export { GroupDetailsSection } from "./GroupDetailsSection";
export { GroupProjectsSection } from "./GroupProjectsSection";