diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index ec235d34e..4696bef26 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -1,8 +1,14 @@ 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, + EGroupProjectsOrderBy +} from "@app/ee/services/group/group-types"; import { ApiDocsTags, GROUPS } from "@app/lib/api-docs"; +import { OrderByDirection } from "@app/lib/types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -11,6 +17,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", method: "POST", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -40,6 +49,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ url: "/:id", method: "GET", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -69,6 +81,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", method: "GET", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -93,6 +108,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ url: "/:id", method: "PATCH", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -128,6 +146,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ url: "/:id", method: "DELETE", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -155,6 +176,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:id/users", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -163,7 +187,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { id: z.string().trim().describe(GROUPS.LIST_USERS.id) }), querystring: z.object({ - offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset), + offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_USERS.offset), limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), @@ -203,9 +227,72 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:id/projects", + config: { + rateLimit: readLimit + }, + 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).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), + orderBy: z + .nativeEnum(EGroupProjectsOrderBy) + .default(EGroupProjectsOrderBy.Name) + .describe(GROUPS.LIST_PROJECTS.orderBy), + orderDirection: z + .nativeEnum(OrderByDirection) + .default(OrderByDirection.ASC) + .describe(GROUPS.LIST_PROJECTS.orderDirection) + }), + 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", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -241,6 +328,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", url: "/:id/users/:username", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 6fb02207d..ced8410b7 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -4,8 +4,9 @@ import { TDbClient } from "@app/db"; 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 { OrderByDirection } from "@app/lib/types"; -import { EFilterReturnedUsers } from "./group-types"; +import { EFilterReturnedProjects, EFilterReturnedUsers, EGroupProjectsOrderBy } from "./group-types"; export type TGroupDALFactory = ReturnType; @@ -166,6 +167,89 @@ export const groupDALFactory = (db: TDbClient) => { } }; + const findAllGroupProjects = async ({ + orgId, + groupId, + offset, + limit, + search, + filter, + orderBy, + orderDirection + }: { + orgId: string; + groupId: string; + offset?: number; + limit?: number; + search?: string; + filter?: EFilterReturnedProjects; + orderBy?: EGroupProjectsOrderBy; + orderDirection?: OrderByDirection; + }) => { + try { + 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"`) + ) + .offset(offset ?? 0); + + if (orderBy) { + void query.orderByRaw( + `LOWER(${TableName.Project}.??) ${orderDirection === OrderByDirection.ASC ? "asc" : "desc"}`, + [orderBy] + ); + } + + if (limit) { + void query.limit(limit); + } + + if (search) { + void query.andWhereRaw( + `CONCAT_WS(' ', "${TableName.Project}"."name", "${TableName.Project}"."slug", "${TableName.Project}"."description") ilike ?`, + [`%${search}%`] + ); + } + + 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 +314,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..1a6a046a6 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,55 @@ export const groupServiceFactory = ({ return { users: members, totalCount }; }; + const listGroupProjects = async ({ + id, + offset, + limit, + search, + filter, + orderBy, + orderDirection, + 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, + orderBy, + orderDirection + }); + + 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 +592,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..335b6d72b 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -2,7 +2,7 @@ import { Knex } from "knex"; import { TGroups } from "@app/db/schemas"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; -import { TGenericPermission } from "@app/lib/types"; +import { OrderByDirection, TGenericPermission } from "@app/lib/types"; import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -42,6 +42,16 @@ export type TListGroupUsersDTO = { filter?: EFilterReturnedUsers; } & TGenericPermission; +export type TListGroupProjectsDTO = { + id: string; + offset: number; + limit: number; + search?: string; + filter?: EFilterReturnedProjects; + orderBy?: EGroupProjectsOrderBy; + orderDirection?: OrderByDirection; +} & TGenericPermission; + export type TListProjectGroupUsersDTO = TListGroupUsersDTO & { projectId: string; }; @@ -111,3 +121,12 @@ export enum EFilterReturnedUsers { EXISTING_MEMBERS = "existingMembers", NON_MEMBERS = "nonMembers" } + +export enum EFilterReturnedProjects { + ASSIGNED_PROJECTS = "assignedProjects", + UNASSIGNED_PROJECTS = "unassignedProjects" +} + +export enum EGroupProjectsOrderBy { + Name = "name" +} diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 8bd2827fc..970f9a1a0 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -106,6 +106,16 @@ 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.", + orderBy: "The column to order projects by.", + orderDirection: "The direction to order projects in." + }, 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..2e5b9f684 100644 --- a/frontend/src/hooks/api/groups/queries.tsx +++ b/frontend/src/hooks/api/groups/queries.tsx @@ -2,7 +2,14 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { EFilterReturnedUsers, TGroup, TGroupUser } from "./types"; +import { OrderByDirection } from "../generic/types"; +import { + EFilterReturnedProjects, + EFilterReturnedUsers, + TGroup, + TGroupProject, + TGroupUser +} from "./types"; export const groupKeys = { getGroupById: (groupId: string) => [{ groupId }, "group"] as const, @@ -41,6 +48,29 @@ export const groupKeys = { ...groupKeys.forGroupUserMemberships(slug), projectId, { offset, limit, search, filter } + ] as const, + allGroupProjects: () => ["group-projects"] as const, + forGroupProjects: (groupId: string) => [...groupKeys.allGroupProjects(), groupId] as const, + specificGroupProjects: ({ + groupId, + offset, + limit, + search, + filter, + orderBy, + orderDirection + }: { + groupId: string; + offset: number; + limit: number; + search: string; + filter?: EFilterReturnedProjects; + orderBy?: string; + orderDirection?: OrderByDirection; + }) => + [ + ...groupKeys.forGroupProjects(groupId), + { offset, limit, search, filter, orderBy, orderDirection } ] as const }; @@ -148,3 +178,54 @@ export const useListProjectGroupUsers = ({ } }); }; + +export const useListGroupProjects = ({ + id, + offset = 0, + limit = 10, + search, + filter, + orderBy, + orderDirection +}: { + id: string; + offset: number; + limit: number; + search: string; + orderBy?: string; + orderDirection?: OrderByDirection; + filter?: EFilterReturnedProjects; +}) => { + return useQuery({ + queryKey: groupKeys.specificGroupProjects({ + groupId: id, + offset, + limit, + search, + filter, + orderBy, + orderDirection + }), + enabled: Boolean(id), + placeholderData: (previousData) => previousData, + queryFn: async () => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit), + search, + ...(filter && { filter }), + ...(orderBy && { orderBy }), + ...(orderDirection && { orderDirection }) + }); + + 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..c7040ec54 --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx @@ -0,0 +1,181 @@ +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 { getProjectTitle } from "@app/helpers/project"; +import { useDebounce, useResetPageHelper } from "@app/hooks"; +import { + useAddGroupToWorkspace as useAddProjectToGroup, + useListGroupProjects +} from "@app/hooks/api"; +import { EFilterReturnedProjects } from "@app/hooks/api/groups/types"; +import { ProjectType } from "@app/hooks/api/projects/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 the group to project ${projectName}`, + 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}

+ )} +
+

{getProjectTitle(project.type as ProjectType)}

+
+ + {(isAllowed) => { + return ( + + ); + }} + +
+ {!isPending && totalCount > 0 && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isPending && !data?.projects?.length && ( + + )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx index c78b5404e..024bc54d6 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx @@ -46,7 +46,7 @@ export const GroupMembersSection = ({ groupId, groupSlug }: Props) => { return (
-

Group Members

+

Members

{(isAllowed) => ( , + data?: object + ) => void; +}; + +export const GroupProjectRow = ({ project, handlePopUpOpen }: Props) => { + return ( + + +

{project.name}

+ + +

{getProjectTitle(project.type as ProjectType)}

+ + + +

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

+
+ + + + + + + + + + + + {(isAllowed) => { + return ( + } + onClick={() => + handlePopUpOpen("removeProjectFromGroup", { + projectId: project.id, + projectName: project.name + }) + } + isDisabled={!isAllowed} + > + Remove group from project + + ); + }} + + + + + + + ); +}; 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..d6997c6cb --- /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 the group from project ${projectName}`, + type: "success" + }); + + handlePopUpToggle("removeProjectFromGroup", false); + }; + + return ( +
+
+

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..688e5a692 --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsTable.tsx @@ -0,0 +1,183 @@ +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, + orderBy, + 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, + orderBy, + orderDirection, + filter: EFilterReturnedProjects.ASSIGNED_PROJECTS + }); + + const totalCount = groupMemberships?.totalCount ?? 0; + const isEmpty = !isPending && totalCount === 0; + const projects = groupMemberships?.projects ?? []; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + return ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search projects..." + /> + + + + + + + + + + + {isPending && } + {!isPending && + projects.map((project) => { + return ( + + ); + })} + +
+
+ Name + + + +
+
TypeAdded On +
+ {!isEmpty && ( + + )} + {isEmpty && ( + + )} + {isEmpty && ( + + {(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";