From 8c269335747f8503d6d3c8210da006c9ad536f8c Mon Sep 17 00:00:00 2001 From: Martin Robledo Date: Thu, 29 Dec 2022 16:51:37 -0600 Subject: [PATCH 1/5] feat: archive and unarchive projects --- app/core/components/ArchiveProject/index.tsx | 72 ++++++++++++++++++ .../components/UnarchiveProject/index.tsx | 72 ++++++++++++++++++ app/models/project.server.ts | 73 +++++++++++++++++++ app/routes/projects/$projectId/index.tsx | 37 ++++++++-- app/routes/projects/archive.tsx | 20 +++++ app/routes/projects/unarchive.tsx | 21 ++++++ 6 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 app/core/components/ArchiveProject/index.tsx create mode 100644 app/core/components/UnarchiveProject/index.tsx create mode 100644 app/routes/projects/archive.tsx create mode 100644 app/routes/projects/unarchive.tsx diff --git a/app/core/components/ArchiveProject/index.tsx b/app/core/components/ArchiveProject/index.tsx new file mode 100644 index 00000000..7f8e7b4c --- /dev/null +++ b/app/core/components/ArchiveProject/index.tsx @@ -0,0 +1,72 @@ +import { Archive } from "@mui/icons-material"; +import { + Button, + 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. + + + + + + +
+
+ + ); +}; diff --git a/app/core/components/UnarchiveProject/index.tsx b/app/core/components/UnarchiveProject/index.tsx new file mode 100644 index 00000000..0d85eeea --- /dev/null +++ b/app/core/components/UnarchiveProject/index.tsx @@ -0,0 +1,72 @@ +import { Unarchive } from "@mui/icons-material"; +import { + Button, + 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. + + + + + + +
+
+ + ); +}; diff --git a/app/models/project.server.ts b/app/models/project.server.ts index 779b11b5..e352f7d0 100644 --- a/app/models/project.server.ts +++ b/app/models/project.server.ts @@ -1,3 +1,4 @@ +import { CompressOutlined } from "@mui/icons-material"; import type { Profiles, Projects } from "@prisma/client"; import { Prisma } from "@prisma/client"; import { defaultStatus } from "~/constants"; @@ -738,3 +739,75 @@ 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; +} diff --git a/app/routes/projects/$projectId/index.tsx b/app/routes/projects/$projectId/index.tsx index 1a194c0d..2257f94c 100644 --- a/app/routes/projects/$projectId/index.tsx +++ b/app/routes/projects/$projectId/index.tsx @@ -30,6 +30,8 @@ import { Grid, Box, Button, + IconButton, + Tooltip, } from "@mui/material"; import { EditSharp, ThumbUpSharp, ThumbDownSharp } from "@mui/icons-material"; import { @@ -51,6 +53,8 @@ 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"; type LoaderData = { isAdmin: boolean; @@ -179,21 +183,38 @@ export default function ProjectDetailsPage() {
-
-
+ +
{(isTeamMember || isAdmin) && ( - - - - - + + + + + + + )}
-
+ +
+ {(isTeamMember || isAdmin) && + (!project.isArchived ? ( + + ) : ( + + ))} +
+

{project.name}

+ {project.isArchived &&

(Archived)

}
{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/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; + } +}; From 8e439d242d041d800bb08a651c435b7aca2aefb6 Mon Sep 17 00:00:00 2001 From: Martin Robledo Date: Mon, 2 Jan 2023 10:50:29 -0600 Subject: [PATCH 2/5] hide archive projects --- app/models/project.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/project.server.ts b/app/models/project.server.ts index e352f7d0..a3b66693 100644 --- a/app/models/project.server.ts +++ b/app/models/project.server.ts @@ -488,7 +488,7 @@ 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" From f0948ec73624f2093f37b03daf7df30c728fd134 Mon Sep 17 00:00:00 2001 From: Martin Robledo Date: Mon, 2 Jan 2023 18:07:11 -0600 Subject: [PATCH 3/5] identify archived projects in my proposals and change animation of archive and unarchive buttons --- app/core/components/ArchiveProject/index.tsx | 14 ++++++++++++++ app/core/components/ProposalCard/index.tsx | 5 ++++- app/core/components/UnarchiveProject/index.tsx | 12 ++++++++++++ app/models/project.server.ts | 2 ++ app/routes/projects/$projectId/index.tsx | 6 ++++-- app/routes/projects/index.tsx | 1 + 6 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/core/components/ArchiveProject/index.tsx b/app/core/components/ArchiveProject/index.tsx index 19ea43eb..60ae033e 100644 --- a/app/core/components/ArchiveProject/index.tsx +++ b/app/core/components/ArchiveProject/index.tsx @@ -1,6 +1,7 @@ import { Archive } from "@mui/icons-material"; import { Button, + CircularProgress, Dialog, DialogActions, DialogContent, @@ -8,6 +9,7 @@ import { IconButton, Tooltip, } from "@mui/material"; +import { blue } from "@mui/material/colors"; import { Form, useTransition } from "@remix-run/react"; import { useEffect, useState } from "react"; @@ -57,6 +59,7 @@ export const ArchiveProject = ({ projectId }: { projectId?: string }) => { + + {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 index cd97a645..a0e5f68a 100644 --- a/app/core/components/UnarchiveProject/index.tsx +++ b/app/core/components/UnarchiveProject/index.tsx @@ -1,6 +1,7 @@ import { Unarchive } from "@mui/icons-material"; import { Button, + CircularProgress, Dialog, DialogActions, DialogContent, @@ -64,6 +65,17 @@ export const UnarchiveProject = ({ projectId }: { projectId?: string }) => { > Unarchive it + {isButtonDisabled && ( + + )} diff --git a/app/models/project.server.ts b/app/models/project.server.ts index 9d026d96..c8cbea36 100644 --- a/app/models/project.server.ts +++ b/app/models/project.server.ts @@ -37,6 +37,7 @@ interface SearchProjectsOutput { projectMembers: number; owner: string; tierName: string; + isArchived: boolean; } interface ProjectWhereInput { @@ -645,6 +646,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 diff --git a/app/routes/projects/$projectId/index.tsx b/app/routes/projects/$projectId/index.tsx index 01d75a84..1a0e7b85 100644 --- a/app/routes/projects/$projectId/index.tsx +++ b/app/routes/projects/$projectId/index.tsx @@ -167,7 +167,6 @@ export default function ProjectDetailsPage() { return ( <>
- {project.isArchived &&
} -

{project.name}

+

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

+ Last update:{" "} {project.updatedAt && diff --git a/app/routes/projects/index.tsx b/app/routes/projects/index.tsx index 5b8bc8e6..7dd86a03 100644 --- a/app/routes/projects/index.tsx +++ b/app/routes/projects/index.tsx @@ -563,6 +563,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() From 2d856e0cbef6982d43cb5db5949a946398d1cc3e Mon Sep 17 00:00:00 2001 From: Martin Robledo Date: Tue, 3 Jan 2023 17:14:45 -0600 Subject: [PATCH 4/5] adding a new tab for admins to display archive projects --- app/core/components/ArchiveProject/index.tsx | 3 +- app/models/project.server.ts | 25 +++++++++++--- .../manager/filter-tags/test/labels.test.tsx | 4 --- app/routes/projects/index.tsx | 34 ++++++++++++++++--- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/app/core/components/ArchiveProject/index.tsx b/app/core/components/ArchiveProject/index.tsx index 60ae033e..97cfc928 100644 --- a/app/core/components/ArchiveProject/index.tsx +++ b/app/core/components/ArchiveProject/index.tsx @@ -9,7 +9,6 @@ import { IconButton, Tooltip, } from "@mui/material"; -import { blue } from "@mui/material/colors"; import { Form, useTransition } from "@remix-run/react"; import { useEffect, useState } from "react"; @@ -65,7 +64,7 @@ export const ArchiveProject = ({ projectId }: { projectId?: string }) => { type="submit" className="primary warning" > - Yes,archive it + Yes, archive it {isButtonDisabled && ( 0) { where = Prisma.sql`${where} AND p.status IN (${Prisma.join(status)})`; @@ -846,3 +852,14 @@ export async function unarchiveProject( 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/index.tsx b/app/routes/projects/index.tsx index 7dd86a03..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); From cb0b4c71eaeac81945cb56ccf9d499e51c6b4363 Mon Sep 17 00:00:00 2001 From: Martin Robledo Date: Wed, 4 Jan 2023 15:22:38 -0600 Subject: [PATCH 5/5] doing changes --- app/core/components/ArchiveProject/index.tsx | 3 ++- .../components/UnarchiveProject/index.tsx | 3 ++- app/routes/projects/$projectId/index.tsx | 27 ++++++++++++------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/app/core/components/ArchiveProject/index.tsx b/app/core/components/ArchiveProject/index.tsx index 97cfc928..b16c6aed 100644 --- a/app/core/components/ArchiveProject/index.tsx +++ b/app/core/components/ArchiveProject/index.tsx @@ -62,7 +62,8 @@ export const ArchiveProject = ({ projectId }: { projectId?: string }) => { diff --git a/app/core/components/UnarchiveProject/index.tsx b/app/core/components/UnarchiveProject/index.tsx index a0e5f68a..3c426f85 100644 --- a/app/core/components/UnarchiveProject/index.tsx +++ b/app/core/components/UnarchiveProject/index.tsx @@ -61,7 +61,8 @@ export const UnarchiveProject = ({ projectId }: { projectId?: string }) => { diff --git a/app/routes/projects/$projectId/index.tsx b/app/routes/projects/$projectId/index.tsx index 1a0e7b85..7724d987 100644 --- a/app/routes/projects/$projectId/index.tsx +++ b/app/routes/projects/$projectId/index.tsx @@ -192,22 +192,29 @@ export default function ProjectDetailsPage() {
{(isTeamMember || isAdmin) && ( - - - - - + <> + {!project?.isArchived ? ( + + ) : ( + + )} + + + + + + )} - {(isTeamMember || isAdmin) && + {/* {(isTeamMember || isAdmin) && (!project?.isArchived ? ( ) : ( - ))} + ))} */}

{project.description}