diff --git a/app/core/components/ArchiveProject/index.tsx b/app/core/components/ArchiveProject/index.tsx new file mode 100644 index 00000000..b16c6aed --- /dev/null +++ b/app/core/components/ArchiveProject/index.tsx @@ -0,0 +1,86 @@ +import { Archive } from "@mui/icons-material"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Tooltip, +} from "@mui/material"; +import { Form, useTransition } from "@remix-run/react"; +import { useEffect, useState } from "react"; + +export const ArchiveProject = ({ projectId }: { projectId?: string }) => { + const [open, setOpen] = useState(false); + const [isButtonDisabled, setisButtonDisabled] = useState(true); + + const handleClickOpen = () => { + setOpen(true); + + setTimeout(() => setisButtonDisabled(false), 5000); + }; + + const handleClose = () => { + setisButtonDisabled(true); + setOpen(false); + }; + + const transition = useTransition(); + useEffect(() => { + if (transition.type == "actionRedirect") { + setOpen(false); + } + }, [transition]); + + return ( + <> + + + + + + + + + Are you sure you want to archive this proposal? + +
+ + You can unarchive the project later. + + + + + + + {isButtonDisabled && ( + + )} + +
+
+ + ); +}; diff --git a/app/core/components/ProposalCard/index.tsx b/app/core/components/ProposalCard/index.tsx index 26b52777..cc385e0a 100644 --- a/app/core/components/ProposalCard/index.tsx +++ b/app/core/components/ProposalCard/index.tsx @@ -19,6 +19,7 @@ interface IProps { votesCount?: number | null; skills?: { name: string }[]; isOwner?: boolean; + isArchived?: boolean; tierName: String; projectMembers?: number | null; } @@ -26,9 +27,10 @@ interface IProps { export const ProposalCard = (props: IProps) => { const stopEvent = (event: React.MouseEvent) => event.stopPropagation(); + return ( <> - + @@ -48,6 +50,7 @@ export const ProposalCard = (props: IProps) => {
{props.title} + {props.isArchived &&

(Archived)

}
{props.date} diff --git a/app/core/components/UnarchiveProject/index.tsx b/app/core/components/UnarchiveProject/index.tsx new file mode 100644 index 00000000..3c426f85 --- /dev/null +++ b/app/core/components/UnarchiveProject/index.tsx @@ -0,0 +1,85 @@ +import { Unarchive } from "@mui/icons-material"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Tooltip, +} from "@mui/material"; +import { Form, useTransition } from "@remix-run/react"; +import { useEffect, useState } from "react"; + +export const UnarchiveProject = ({ projectId }: { projectId?: string }) => { + const [open, setOpen] = useState(false); + const [isButtonDisabled, setisButtonDisabled] = useState(true); + + const handleClickOpen = () => { + setOpen(true); + + setTimeout(() => setisButtonDisabled(false), 5000); + }; + + const handleClose = () => { + setisButtonDisabled(true); + setOpen(false); + }; + + const transition = useTransition(); + useEffect(() => { + if (transition.type == "actionRedirect") { + setOpen(false); + } + }, [transition]); + + return ( + <> + + + + + + + + + Are you sure you want to unarchive this proposal? + +
+ + This action will unarchive the project and will be available again. + + + + + + {isButtonDisabled && ( + + )} + +
+
+ + ); +}; diff --git a/app/models/project.server.ts b/app/models/project.server.ts index 2d41f166..8126c29f 100644 --- a/app/models/project.server.ts +++ b/app/models/project.server.ts @@ -1,8 +1,10 @@ +import { CompressOutlined } from "@mui/icons-material"; import type { Profiles, Projects } from "@prisma/client"; import { Prisma } from "@prisma/client"; import { defaultStatus } from "~/constants"; import { joinCondition, prisma as db } from "~/db.server"; +import { log } from "console"; interface SearchProjectsInput { profileId: Profiles["id"]; @@ -36,6 +38,7 @@ interface SearchProjectsOutput { projectMembers: number; owner: string; tierName: string; + isArchived: boolean; } interface ProjectWhereInput { @@ -520,13 +523,18 @@ export async function searchProjects({ skip = 0, take = 50, }: SearchProjectsInput) { - let where = Prisma.sql`WHERE p.id IS NOT NULL`; + let where = Prisma.sql`WHERE p.id IS NOT NULL AND p."isArchived" = false`; let having = Prisma.empty; if (search && search !== "") { - search === "myProposals" - ? (where = Prisma.sql`WHERE pm."profileId" = ${profileId}`) - : (where = Prisma.sql`WHERE "tsColumn" @@ websearch_to_tsquery('english', ${search})`); + if (search === "myProposals") { + where = Prisma.sql`WHERE pm."profileId" = ${profileId}`; + } else if (search === "archivedProjects") { + where = Prisma.sql`WHERE p."isArchived" = true`; + } else { + where = Prisma.sql`WHERE "tsColumn" @@ websearch_to_tsquery('english', ${search})`; + } } + console.log(where); if (status.length > 0) { where = Prisma.sql`${where} AND p.status IN (${Prisma.join(status)})`; @@ -644,6 +652,7 @@ export async function searchProjects({ p."updatedAt", p."ownerId", p."tierName", + p."isArchived", COUNT(DISTINCT pm."profileId") as "projectMembers" FROM "Projects" p INNER JOIN "ProjectStatus" s on s.name = p.status @@ -771,3 +780,86 @@ export async function deleteProject(projectId: string, isAdmin: boolean) { } return true; } + +export async function archiveProject( + projectId: string, + profileId: string, + isAdmin: boolean +) { + const currentProject = await db.projects.findUniqueOrThrow({ + where: { id: projectId }, + select: { + ownerId: true, + }, + }); + + const projectMembers = await db.projectMembers.findMany({ + where: { projectId }, + select: { + profileId: true, + }, + }); + + if (!isAdmin) + validateIsTeamMember(profileId, projectMembers, currentProject.ownerId); + + const project = await db.projects.update({ + where: { id: projectId }, + data: { + updatedAt: new Date(), + isArchived: true, + }, + include: { + projectStatus: true, + }, + }); + + return project; +} + +export async function unarchiveProject( + projectId: string, + profileId: string, + isAdmin: boolean +) { + const currentProject = await db.projects.findUniqueOrThrow({ + where: { id: projectId }, + select: { + ownerId: true, + }, + }); + + const projectMembers = await db.projectMembers.findMany({ + where: { projectId }, + select: { + profileId: true, + }, + }); + + if (!isAdmin) + validateIsTeamMember(profileId, projectMembers, currentProject.ownerId); + + const project = await db.projects.update({ + where: { id: projectId }, + data: { + updatedAt: new Date(), + isArchived: false, + }, + include: { + projectStatus: true, + }, + }); + + return project; +} + +export async function existArchivedProjects() { + const archiveProjects = await db.projects.findMany({ + where: { isArchived: true }, + }); + if (archiveProjects.length > 0) { + return true; + } else { + return false; + } +} diff --git a/app/routes/manager/filter-tags/test/labels.test.tsx b/app/routes/manager/filter-tags/test/labels.test.tsx index ccfaf0d3..ac2fdd5d 100644 --- a/app/routes/manager/filter-tags/test/labels.test.tsx +++ b/app/routes/manager/filter-tags/test/labels.test.tsx @@ -43,10 +43,6 @@ describe("Labels test", () => { }); test("Path loader", async () => { - let request = new Request( - "http://localhost:3000/manager/filter-tags/labels" - ); - const response = await loader(); expect(response).toBeInstanceOf(Response); diff --git a/app/routes/projects/$projectId/index.tsx b/app/routes/projects/$projectId/index.tsx index 2d2e2380..7724d987 100644 --- a/app/routes/projects/$projectId/index.tsx +++ b/app/routes/projects/$projectId/index.tsx @@ -21,9 +21,10 @@ import { Grid, Box, Button, + IconButton, + Tooltip, Container, Paper, - IconButton, Typography, } from "@mui/material"; import { EditSharp, ThumbUpSharp, ThumbDownSharp } from "@mui/icons-material"; @@ -38,6 +39,9 @@ import { } from "~/models/votes.server"; import RelatedProjectsSection from "~/core/components/RelatedProjectsSection"; import Header from "~/core/layouts/Header"; +import { ArchiveProject } from "~/core/components/ArchiveProject"; +import { UnarchiveProject } from "~/core/components/UnarchiveProject"; + import MembershipStatusModal from "~/core/components/MembershipStatusModal"; type voteProject = { @@ -174,7 +178,10 @@ export default function ProjectDetailsPage() { > -

{project.name}

+

+ {project.name} {project.isArchived && <>(Archived)} +

+ Last update:{" "} {project.updatedAt && @@ -185,13 +192,29 @@ export default function ProjectDetailsPage() {
{(isTeamMember || isAdmin) && ( - - - + <> + {!project?.isArchived ? ( + + ) : ( + + )} + + + + + + )} + + {/* {(isTeamMember || isAdmin) && + (!project?.isArchived ? ( + + ) : ( + + ))} */}

{project.description}

diff --git a/app/routes/projects/archive.tsx b/app/routes/projects/archive.tsx new file mode 100644 index 00000000..368da693 --- /dev/null +++ b/app/routes/projects/archive.tsx @@ -0,0 +1,20 @@ +import type { ActionFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { requireProfile, requireUser } from "~/session.server"; +import { archiveProject } from "~/models/project.server"; +import { adminRoleName } from "~/constants"; + +export const action: ActionFunction = async ({ request }) => { + let formData = await request.formData(); + let projectId: string = formData.get("projectId") as string; + const profile = await requireProfile(request); + const user = await requireUser(request); + const isAdmin = user.role == adminRoleName; + + try { + await archiveProject(projectId, profile.id, isAdmin); + return redirect(`/projects/${projectId}`); + } catch (e) { + console.log(e); + } +}; diff --git a/app/routes/projects/index.tsx b/app/routes/projects/index.tsx index 5b8bc8e6..53434d17 100644 --- a/app/routes/projects/index.tsx +++ b/app/routes/projects/index.tsx @@ -23,17 +23,19 @@ import ExpandMore from "@mui/icons-material/ExpandMore"; import FilterAltIcon from "@mui/icons-material/FilterAlt"; import CloseIcon from "@mui/icons-material/Close"; import { SortInput } from "app/core/components/SortInput"; -import { searchProjects } from "~/models/project.server"; -import { requireProfile } from "~/session.server"; +import { existArchivedProjects, searchProjects } from "~/models/project.server"; +import { requireProfile, requireUser } from "~/session.server"; import type { ProjectStatus } from "~/models/status.server"; import { getProjectStatuses } from "~/models/status.server"; -import { ongoingStage, ideaStage } from "~/constants"; +import { ongoingStage, ideaStage, adminRoleName } from "~/constants"; import Link from "~/core/components/Link"; type LoaderData = { data: Awaited>; ongoingStatuses: ProjectStatus[]; ideaStatuses: ProjectStatus[]; + existArchived: boolean; + isAdmin: boolean; }; const ITEMS_PER_PAGE = 50; @@ -57,6 +59,9 @@ interface Tab { export const loader: LoaderFunction = async ({ request }) => { const profile = await requireProfile(request); const url = new URL(request.url); + const existArchived = await existArchivedProjects(); + const user = await requireUser(request); + const isAdmin = user.role == adminRoleName; const page = Number(url.searchParams.get("page") || 0); const search = url.searchParams.get("q") || ""; const status = url.searchParams.getAll("status"); @@ -93,7 +98,7 @@ export const loader: LoaderFunction = async ({ request }) => { // return json({ data, ongoingStatuses, ideaStatuses }); return new Response( JSON.stringify( - { data, ongoingStatuses, ideaStatuses }, + { data, ongoingStatuses, ideaStatuses, existArchived, isAdmin }, (key, value) => (typeof value === "bigint" ? value.toString() : value) // return everything else unchanged ), { @@ -127,10 +132,13 @@ export default function Projects() { }, ongoingStatuses, ideaStatuses, + existArchived, + isAdmin, } = useLoaderData() as LoaderData; const myPropQuery = "myProposals"; const activeProjectsSearchParams = new URLSearchParams(); const ideasSearchParams = new URLSearchParams(); + ongoingStatuses.forEach((status) => { activeProjectsSearchParams.append("status", status.name); }); @@ -152,8 +160,24 @@ export default function Projects() { title: "Ideas", searchParams: ideasSearchParams, }; - const tabs: Array = [myProposalsTab, activeProjectsTab, ideasTab]; + const archivedTab = { + name: "archived", + title: "Archived Projects", + searchParams: new URLSearchParams({ q: "archivedProjects" }), + }; + + const tabs: Array = [myProposalsTab, activeProjectsTab, ideasTab]; + if (isAdmin) { + if (existArchived) { + tabs.push(archivedTab); + } else { + const index = tabs.indexOf(archivedTab); + if (index > -1) { + tabs.splice(index, 1); + } + } + } const goToPreviousPage = () => { searchParams.set("page", String(page - 1)); setSearchParams(searchParams); @@ -563,6 +587,7 @@ export default function Projects() { description={item.description} status={item.status} color={item.color} + isArchived={item.isArchived} votesCount={Number(item.votesCount)} skills={item.searchSkills .trim() diff --git a/app/routes/projects/unarchive.tsx b/app/routes/projects/unarchive.tsx new file mode 100644 index 00000000..3f7c591b --- /dev/null +++ b/app/routes/projects/unarchive.tsx @@ -0,0 +1,21 @@ +import type { ActionFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { requireProfile, requireUser } from "~/session.server"; +import { unarchiveProject } from "~/models/project.server"; +import { adminRoleName } from "~/constants"; + +export const action: ActionFunction = async ({ request }) => { + let formData = await request.formData(); + let projectId: string = formData.get("projectId") as string; + const profile = await requireProfile(request); + const user = await requireUser(request); + const isAdmin = user.role == adminRoleName; + + try { + await unarchiveProject(projectId, profile.id, isAdmin); + return redirect(`/projects/${projectId}`); + } catch (e) { + console.log(e); + return null; + } +}; diff --git a/package-lock.json b/package-lock.json index 03e2d7ee..097827a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@remix-validated-form/with-zod": "^2.0.5", "@uiw/react-md-editor": "^3.20.1", "bcryptjs": "^2.4.3", + "ci": "^2.2.0", "date-fns": "^2.29.2", "marked-react": "^1.2.0", "react": "^17.0.2", @@ -6144,6 +6145,17 @@ "node": ">=10" } }, + "node_modules/ci": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ci/-/ci-2.2.0.tgz", + "integrity": "sha512-lBkEN6XclyW0jnprtFQ+dsbP+9zwmo37Z1cV38h4FSDgI2QzFqwknJnVSvRxK9UXkPC4ZcVOVFyCVrNylTX52Q==", + "bin": { + "ci": "lib/ci.js" + }, + "funding": { + "url": "https://github.com/privatenumber/ci?sponsor=1" + } + }, "node_modules/ci-info": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.7.1.tgz", @@ -25085,6 +25097,11 @@ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "dev": true }, + "ci": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ci/-/ci-2.2.0.tgz", + "integrity": "sha512-lBkEN6XclyW0jnprtFQ+dsbP+9zwmo37Z1cV38h4FSDgI2QzFqwknJnVSvRxK9UXkPC4ZcVOVFyCVrNylTX52Q==" + }, "ci-info": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.7.1.tgz", diff --git a/package.json b/package.json index bdf47d0a..732fba23 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@remix-validated-form/with-zod": "^2.0.5", "@uiw/react-md-editor": "^3.20.1", "bcryptjs": "^2.4.3", + "ci": "^2.2.0", "date-fns": "^2.29.2", "marked-react": "^1.2.0", "react": "^17.0.2",