diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index ec235d34e..2186438ca 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -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", diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 6fb02207d..9c5fe5d3a 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -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; @@ -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 diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 956d7853a..dcd06a4b9 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -24,6 +24,7 @@ import { TCreateGroupDTO, TDeleteGroupDTO, TGetGroupByIdDTO, + TListGroupProjectsDTO, TListGroupUsersDTO, TRemoveUserFromGroupDTO, TUpdateGroupDTO @@ -34,7 +35,14 @@ type TGroupServiceFactoryDep = { userDAL: Pick; groupDAL: Pick< TGroupDALFactory, - "create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction" + | "create" + | "findOne" + | "update" + | "delete" + | "findAllGroupPossibleMembers" + | "findById" + | "transaction" + | "findAllGroupProjects" >; membershipGroupDAL: Pick; membershipRoleDAL: Pick; @@ -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 diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index 4b0742201..d48083442 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -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" +} diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 15ec747ef..5054226af 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -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." diff --git a/frontend/src/hooks/api/groups/index.tsx b/frontend/src/hooks/api/groups/index.tsx index c23a55832..eebe2ccc3 100644 --- a/frontend/src/hooks/api/groups/index.tsx +++ b/frontend/src/hooks/api/groups/index.tsx @@ -5,4 +5,4 @@ export { useRemoveUserFromGroup, useUpdateGroup } from "./mutations"; -export { useGetGroupById, useListGroupUsers } from "./queries"; +export { useGetGroupById, useListGroupProjects, useListGroupUsers } from "./queries"; diff --git a/frontend/src/hooks/api/groups/queries.tsx b/frontend/src/hooks/api/groups/queries.tsx index ca524066f..334c12844 100644 --- a/frontend/src/hooks/api/groups/queries.tsx +++ b/frontend/src/hooks/api/groups/queries.tsx @@ -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; + } + }); +}; diff --git a/frontend/src/hooks/api/groups/types.ts b/frontend/src/hooks/api/groups/types.ts index 6bc82b39e..1c16a331b 100644 --- a/frontend/src/hooks/api/groups/types.ts +++ b/frontend/src/hooks/api/groups/types.ts @@ -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" +} diff --git a/frontend/src/hooks/api/projects/mutations.tsx b/frontend/src/hooks/api/projects/mutations.tsx index e2a80ff53..416287736 100644 --- a/frontend/src/hooks/api/projects/mutations.tsx +++ b/frontend/src/hooks/api/projects/mutations.tsx @@ -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) }); } diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx index e7c517d6c..d925f20b0 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx @@ -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 = () => {
- +
+ + +
)} diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx new file mode 100644 index 000000000..aeaa39d6c --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx @@ -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 ( + { + handlePopUpToggle("addGroupProjects", isOpen); + }} + > + + setSearchProjectFilter(e.target.value)} + leftIcon={} + placeholder="Search projects..." + /> + + + + + + + + + + {isPending && } + {!isPending && + data?.projects?.map((project) => { + return ( + + + + + + ); + })} + +
ProjectType +
+

{project.name}

+ {project.description && ( +

{project.description}

+ )} +
+

{project.type?.replace("-", " ") || "-"}

+
+ + {(isAllowed) => { + return ( + + ); + }} + +
+ {!isPending && totalCount > 0 && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isPending && !data?.projects?.length && ( + + )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectRow.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectRow.tsx new file mode 100644 index 000000000..c260de599 --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectRow.tsx @@ -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 ( + + +

{project.name}

+ + +

{project.type?.replace("-", " ") || "-"}

+ + + +

{new Date(project.joinedGroupAt).toLocaleDateString()}

+
+ + + + + + + + + + + + {(isAllowed) => { + return ( + } + onClick={() => + handlePopUpOpen("removeProjectFromGroup", { + projectId: project.id, + projectName: project.name + }) + } + isDisabled={!isAllowed} + > + Remove Project From Group + + ); + }} + + + + + + + ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx new file mode 100644 index 000000000..22b42f750 --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx @@ -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 ( +
+
+

Group Projects

+ + {(isAllowed) => ( + { + handlePopUpOpen("addGroupProjects", { + groupId, + slug: groupSlug + }); + }} + > + + + )} + +
+
+ +
+ + handlePopUpToggle("removeProjectFromGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => { + const projectData = popUp?.removeProjectFromGroup?.data as { + projectId: string; + projectName: string; + }; + + return handleRemoveProjectFromGroup(projectData.projectId, projectData.projectName); + }} + /> +
+ ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsTable.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsTable.tsx new file mode 100644 index 000000000..189fa7476 --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsTable.tsx @@ -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 ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search projects..." + /> + + + + + + + + + + + {isPending && } + {!isPending && + filteredGroupProjects.slice(offset, perPage * page).map((project) => { + return ( + + ); + })} + +
+
+ Name + + + +
+
TypeAdded On +
+ {Boolean(filteredGroupProjects.length) && ( + + )} + {!isPending && !filteredGroupProjects?.length && ( + + )} + {!groupMemberships?.projects.length && ( + + {(isAllowed) => ( +
+ +
+ )} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/index.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/index.tsx new file mode 100644 index 000000000..d61ad6124 --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/index.tsx @@ -0,0 +1 @@ +export { GroupProjectsSection } from "./GroupProjectsSection"; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/index.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/index.tsx index 003c47910..7a1d71454 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/index.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/index.tsx @@ -1 +1,2 @@ export { GroupDetailsSection } from "./GroupDetailsSection"; +export { GroupProjectsSection } from "./GroupProjectsSection";