diff --git a/apps/app/components/command-palette/index.tsx b/apps/app/components/command-palette/index.tsx index 3c41d96e1..5c718b704 100644 --- a/apps/app/components/command-palette/index.tsx +++ b/apps/app/components/command-palette/index.tsx @@ -103,25 +103,25 @@ const CommandPalette: React.FC = () => { const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (e.ctrlKey && e.key === "/") { + if ((e.ctrlKey || e.metaKey) && e.key === "/") { e.preventDefault(); setIsPaletteOpen(true); - } else if (e.ctrlKey && e.key === "i") { + } else if ((e.ctrlKey || e.metaKey) && e.key === "i") { e.preventDefault(); setIsIssueModalOpen(true); - } else if (e.ctrlKey && e.key === "p") { + } else if ((e.ctrlKey || e.metaKey) && e.key === "p") { e.preventDefault(); setIsProjectModalOpen(true); - } else if (e.ctrlKey && e.key === "b") { + } else if ((e.ctrlKey || e.metaKey) && e.key === "b") { e.preventDefault(); toggleCollapsed(); - } else if (e.ctrlKey && e.key === "h") { + } else if ((e.ctrlKey || e.metaKey) && e.key === "h") { e.preventDefault(); setIsShortcutsModalOpen(true); - } else if (e.ctrlKey && e.key === "q") { + } else if ((e.ctrlKey || e.metaKey) && e.key === "q") { e.preventDefault(); setIsCreateCycleModalOpen(true); - } else if (e.ctrlKey && e.altKey && e.key === "c") { + } else if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === "c") { e.preventDefault(); if (!router.query.issueId) return; diff --git a/apps/app/components/project/issues/BoardView/SingleBoard.tsx b/apps/app/components/project/issues/BoardView/SingleBoard.tsx index 928261f64..78e86201d 100644 --- a/apps/app/components/project/issues/BoardView/SingleBoard.tsx +++ b/apps/app/components/project/issues/BoardView/SingleBoard.tsx @@ -5,7 +5,11 @@ import Link from "next/link"; import { Draggable } from "react-beautiful-dnd"; import StrictModeDroppable from "components/dnd/StrictModeDroppable"; // common -import { addSpaceIfCamelCase, renderShortNumericDateFormat } from "constants/common"; +import { + addSpaceIfCamelCase, + findHowManyDaysLeft, + renderShortNumericDateFormat, +} from "constants/common"; // types import { IIssue, Properties, NestedKeyOf } from "types"; // icons @@ -23,7 +27,9 @@ import { divide } from "lodash"; type Props = { selectedGroup: NestedKeyOf | null; groupTitle: string; - groupedByIssues: any; + groupedByIssues: { + [key: string]: IIssue[]; + }; index: number; setIsIssueOpen: React.Dispatch>; properties: Properties; @@ -158,25 +164,12 @@ const SingleBoard: React.FC = ({ className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none" onClick={() => setPreloadedData({ - // ...state, actionType: "edit", }) } > - {/* */} @@ -188,7 +181,7 @@ const SingleBoard: React.FC = ({ {...provided.droppableProps} ref={provided.innerRef} > - {groupedByIssues[groupTitle].map((childIssue: any, index: number) => ( + {groupedByIssues[groupTitle].map((childIssue, index: number) => ( {(provided, snapshot) => ( @@ -203,6 +196,9 @@ const SingleBoard: React.FC = ({ className="px-2 py-3 space-y-1.5 select-none" {...provided.dragHandleProps} > + + {childIssue.name} + {Object.keys(properties).map( (key) => properties[key as keyof Properties] && @@ -227,34 +223,65 @@ const SingleBoard: React.FC = ({ : key === "target_date" ? "text-xs bg-indigo-50 px-2 py-1 mt-2 flex items-center gap-x-1 rounded w-min whitespace-nowrap" : "text-sm text-gray-500" - } gap-1 + } gap-1 relative `} > - {key === "target_date" ? ( - <> - {" "} + {key === "start_date" && childIssue.start_date !== null && ( + + + {renderShortNumericDateFormat(childIssue.start_date)} - {childIssue.target_date ? renderShortNumericDateFormat(childIssue.target_date) - : "N/A"} - - ) : ( - "" + : "None"} + )} - {key === "name" && ( - - {childIssue.name} + {key === "target_date" && ( + <> + + + {childIssue.target_date + ? renderShortNumericDateFormat(childIssue.target_date) + : "N/A"} + {childIssue.target_date && ( + + {childIssue.target_date < new Date().toISOString() + ? `Target date has passed by ${findHowManyDaysLeft( + childIssue.target_date + )} days` + : findHowManyDaysLeft(childIssue.target_date) <= 3 + ? `Target date is in ${findHowManyDaysLeft( + childIssue.target_date + )} days` + : "Target date"} + + )} + + + )} + {key === "key" && ( + + {childIssue.project_detail?.identifier}- + {childIssue.sequence_id} )} {key === "state" && ( <>{addSpaceIfCamelCase(childIssue["state_detail"].name)} )} {key === "priority" && <>{childIssue.priority}} - {key === "description" && <>{childIssue.description}} {key === "assignee" ? (
{childIssue?.assignee_details?.length > 0 ? ( childIssue?.assignee_details?.map( - (assignee: any, index: number) => ( + (assignee, index: number) => (
= ({ ) ) ) : ( - None + No assignee. )}
) : null} @@ -290,29 +317,6 @@ const SingleBoard: React.FC = ({ ) )}
- - {/*
- -
- - -
-
*/} )} diff --git a/apps/app/components/project/issues/BoardView/index.tsx b/apps/app/components/project/issues/BoardView/index.tsx index 557ec406f..010b9cbdc 100644 --- a/apps/app/components/project/issues/BoardView/index.tsx +++ b/apps/app/components/project/issues/BoardView/index.tsx @@ -67,8 +67,6 @@ const BoardView: React.FC = ({ properties, selectedGroup, groupedByIssues setIssueDeletionData(removedItem); setIsIssueDeletionOpen(true); - - console.log(removedItem); } else { if (type === "state") { const newStates = Array.from(states ?? []); @@ -168,21 +166,6 @@ const BoardView: React.FC = ({ properties, selectedGroup, groupedByIssues return ( <> - {/* } - projectId={projectId as string} - /> */} - {/* } - /> */} setIsIssueDeletionOpen(false)} @@ -199,21 +182,6 @@ const BoardView: React.FC = ({ properties, selectedGroup, groupedByIssues {groupedByIssues ? (
- {/* - {(provided, snapshot) => ( - - )} - */}
{(provided) => ( diff --git a/apps/app/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx b/apps/app/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx index b53dfe777..10cee4c76 100644 --- a/apps/app/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx +++ b/apps/app/components/project/issues/CreateUpdateIssueModal/SelectPriority.tsx @@ -5,6 +5,8 @@ import { Controller } from "react-hook-form"; import { Listbox, Transition } from "@headlessui/react"; // icons import { CheckIcon } from "@heroicons/react/20/solid"; +// constants +import { PRIORITIES } from "constants/"; // types import type { IIssue } from "types"; @@ -15,8 +17,6 @@ type Props = { control: Control; }; -const PRIORITIES = ["high", "medium", "low"]; - const SelectPriority: React.FC = ({ control }) => { return ( >; }; -const PRIORITIES = ["high", "medium", "low"]; - const ListView: React.FC = ({ properties, groupedByIssues, diff --git a/apps/app/constants/common.ts b/apps/app/constants/common.ts index 7dd866885..1937d65e3 100644 --- a/apps/app/constants/common.ts +++ b/apps/app/constants/common.ts @@ -1,3 +1,5 @@ +import { NestedKeyOf } from "types"; + export const classNames = (...classes: string[]) => { return classes.filter(Boolean).join(" "); }; @@ -30,6 +32,32 @@ export const groupBy = (array: any[], key: string) => { }, {}); }; +export const orderArrayBy = ( + array: any[], + key: string, + ordering: "ascending" | "descending" = "ascending" +) => { + const innerKey = key.split("."); // split the key by dot + return array.sort((a, b) => { + const keyA = innerKey.reduce((obj, i) => obj[i], a); // get the value of the inner key + const keyB = innerKey.reduce((obj, i) => obj[i], b); // get the value of the inner key + if (keyA < keyB) { + return ordering === "ascending" ? -1 : 1; + } + if (keyA > keyB) { + return ordering === "ascending" ? 1 : -1; + } + return 0; + }); +}; + +export const findHowManyDaysLeft = (date: string | Date) => { + const today = new Date(); + const eventDate = new Date(date); + const timeDiff = Math.abs(eventDate.getTime() - today.getTime()); + return Math.ceil(timeDiff / (1000 * 3600 * 24)); +}; + export const timeAgo = (time: any) => { switch (typeof time) { case "number": diff --git a/apps/app/constants/index.ts b/apps/app/constants/index.ts new file mode 100644 index 000000000..60f9994db --- /dev/null +++ b/apps/app/constants/index.ts @@ -0,0 +1 @@ +export const PRIORITIES = ["urgent", "high", "medium", "low"]; diff --git a/apps/app/lib/hooks/useIssuesFilter.tsx b/apps/app/lib/hooks/useIssuesFilter.tsx new file mode 100644 index 000000000..4573f0214 --- /dev/null +++ b/apps/app/lib/hooks/useIssuesFilter.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +// hooks +import useTheme from "./useTheme"; +import useUser from "./useUser"; +// commons +import { groupBy, orderArrayBy } from "constants/common"; +// constants +import { PRIORITIES } from "constants/"; +// types +import type { IssueResponse, IIssue, NestedKeyOf } from "types"; + +const useIssuesFilter = (projectIssues?: IssueResponse) => { + const { issueView, setIssueView, groupByProperty, setGroupByProperty } = useTheme(); + + const [orderBy, setOrderBy] = useState | null>(null); + + const [filterIssue, setFilterIssue] = useState<"activeIssue" | "backlogIssue" | null>(null); + + const { states } = useUser(); + + let groupedByIssues: { + [key: string]: IIssue[]; + } = { + ...(groupByProperty === "state_detail.name" + ? Object.fromEntries( + states + ?.sort((a, b) => a.sequence - b.sequence) + ?.map((state) => [ + state.name, + projectIssues?.results.filter((issue) => issue.state === state.name) ?? [], + ]) ?? [] + ) + : groupByProperty === "priority" + ? Object.fromEntries( + PRIORITIES.map((priority) => [ + priority, + projectIssues?.results.filter((issue) => issue.priority === priority) ?? [], + ]) + ) + : {}), + ...groupBy(projectIssues?.results ?? [], groupByProperty ?? ""), + }; + + if (orderBy !== null) { + groupedByIssues = Object.fromEntries( + Object.entries(groupedByIssues).map(([key, value]) => [ + key, + orderArrayBy(value, orderBy, "descending"), + ]) + ); + } + + if (filterIssue !== null) { + if (filterIssue === "activeIssue") { + groupedByIssues = Object.keys(groupedByIssues).reduce((acc, key) => { + const value = groupedByIssues[key]; + const filteredValue = value.filter( + (issue) => + issue.state_detail.group === "started" || issue.state_detail.group === "unstarted" + ); + if (filteredValue.length > 0) { + acc[key] = filteredValue; + } + return acc; + }, {} as typeof groupedByIssues); + } else if (filterIssue === "backlogIssue") { + groupedByIssues = Object.keys(groupedByIssues).reduce((acc, key) => { + const value = groupedByIssues[key]; + const filteredValue = value.filter( + (issue) => + issue.state_detail.group === "backlog" || issue.state_detail.group === "cancelled" + ); + if (filteredValue.length > 0) { + acc[key] = filteredValue; + } + return acc; + }, {} as typeof groupedByIssues); + } + } + + return { + groupedByIssues, + issueView, + setIssueView, + groupByProperty, + setGroupByProperty, + orderBy, + setOrderBy, + filterIssue, + setFilterIssue, + } as const; +}; + +export default useIssuesFilter; diff --git a/apps/app/lib/hooks/useIssuesProperties.tsx b/apps/app/lib/hooks/useIssuesProperties.tsx index 8b7f3b286..14de06969 100644 --- a/apps/app/lib/hooks/useIssuesProperties.tsx +++ b/apps/app/lib/hooks/useIssuesProperties.tsx @@ -1,4 +1,4 @@ -import { useState, useContext, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback } from "react"; // swr import useSWR from "swr"; // api routes @@ -80,7 +80,22 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => { [workspaceSlug, projectId, issueProperties, user] ); - return [properties, updateIssueProperties] as const; + const newProperties = Object.keys(properties).reduce((obj: any, key) => { + if ( + key !== "children" && + key !== "name" && + key !== "parent" && + key !== "project" && + key !== "description" && + key !== "attachments" && + key !== "sequence_id" + ) { + obj[key] = properties[key as keyof Properties]; + } + return obj; + }, {}); + + return [newProperties, updateIssueProperties] as const; }; export default useIssuesProperties; diff --git a/apps/app/pages/projects/[projectId]/issues/index.tsx b/apps/app/pages/projects/[projectId]/issues/index.tsx index 0375d677a..de6744121 100644 --- a/apps/app/pages/projects/[projectId]/issues/index.tsx +++ b/apps/app/pages/projects/[projectId]/issues/index.tsx @@ -1,4 +1,3 @@ -// react import React, { useEffect, useState } from "react"; // next import type { NextPage } from "next"; @@ -6,52 +5,75 @@ import { useRouter } from "next/router"; // swr import useSWR from "swr"; // headless ui -import { Menu, Popover, Transition } from "@headlessui/react"; -// services -import stateServices from "lib/services/state.services"; -import issuesServices from "lib/services/issues.services"; +import { Popover, Transition } from "@headlessui/react"; +// hoc +import withAuth from "lib/hoc/withAuthWrapper"; // hooks import useUser from "lib/hooks/useUser"; -import useTheme from "lib/hooks/useTheme"; import useIssuesProperties from "lib/hooks/useIssuesProperties"; -// fetching keys -import { PROJECT_ISSUES_LIST, STATE_LIST } from "constants/fetch-keys"; +// api routes +import { PROJECT_MEMBERS } from "constants/api-routes"; +// services +import projectService from "lib/services/project.service"; // commons -import { groupBy } from "constants/common"; +import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common"; // layouts import AdminLayout from "layouts/AdminLayout"; +// hooks +import useIssuesFilter from "lib/hooks/useIssuesFilter"; // components import ListView from "components/project/issues/ListView"; import BoardView from "components/project/issues/BoardView"; import ConfirmIssueDeletion from "components/project/issues/ConfirmIssueDeletion"; import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; // ui -import { Spinner } from "ui"; +import { Spinner, CustomMenu, BreadcrumbItem, Breadcrumbs } from "ui"; import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace"; import HeaderButton from "ui/HeaderButton"; -import { BreadcrumbItem, Breadcrumbs } from "ui"; // icons import { ChevronDownIcon, ListBulletIcon, RectangleStackIcon } from "@heroicons/react/24/outline"; -import { PlusIcon, EyeIcon, EyeSlashIcon, Squares2X2Icon } from "@heroicons/react/20/solid"; +import { PlusIcon, Squares2X2Icon } from "@heroicons/react/20/solid"; // types -import type { IIssue, IssueResponse, Properties, IState, NestedKeyOf, ProjectMember } from "types"; -import { PROJECT_MEMBERS } from "constants/api-routes"; -import projectService from "lib/services/project.service"; +import type { IIssue, Properties, NestedKeyOf, ProjectMember } from "types"; -const PRIORITIES = ["high", "medium", "low"]; +const groupByOptions: Array<{ name: string; key: NestedKeyOf }> = [ + { name: "State", key: "state_detail.name" }, + { name: "Priority", key: "priority" }, + { name: "Created By", key: "created_by" }, +]; + +const orderByOptions: Array<{ name: string; key: NestedKeyOf }> = [ + { name: "Created", key: "created_at" }, + { name: "Update", key: "updated_at" }, +]; + +const filterIssueOptions: Array<{ + name: string; + key: "activeIssue" | "backlogIssue" | null; +}> = [ + { + name: "All", + key: null, + }, + { + name: "Active Issues", + key: "activeIssue", + }, + { + name: "Backlog Issues", + key: "backlogIssue", + }, +]; const ProjectIssues: NextPage = () => { const [isOpen, setIsOpen] = useState(false); - const { issueView, setIssueView, groupByProperty, setGroupByProperty } = useTheme(); - const [selectedIssue, setSelectedIssue] = useState< (IIssue & { actionType: "edit" | "delete" }) | undefined >(undefined); - const [editIssue, setEditIssue] = useState(); const [deleteIssue, setDeleteIssue] = useState(undefined); - const { activeWorkspace, activeProject, issues } = useUser(); + const { activeWorkspace, activeProject, issues: projectIssues } = useUser(); const router = useRouter(); @@ -62,22 +84,6 @@ const ProjectIssues: NextPage = () => { projectId as string ); - const { data: projectIssues } = useSWR( - projectId && activeWorkspace - ? PROJECT_ISSUES_LIST(activeWorkspace.slug, projectId as string) - : null, - activeWorkspace && projectId - ? () => issuesServices.getIssues(activeWorkspace.slug, projectId as string) - : null - ); - - const { data: states } = useSWR( - activeWorkspace && activeProject ? STATE_LIST(activeProject.id) : null, - activeWorkspace && activeProject - ? () => stateServices.getStates(activeWorkspace.slug, activeProject.id) - : null - ); - const { data: members } = useSWR( activeWorkspace && activeProject ? PROJECT_MEMBERS : null, activeWorkspace && activeProject @@ -85,6 +91,18 @@ const ProjectIssues: NextPage = () => { : null ); + const { + issueView, + setIssueView, + groupByProperty, + setGroupByProperty, + groupedByIssues, + setOrderBy, + setFilterIssue, + orderBy, + filterIssue, + } = useIssuesFilter(projectIssues); + useEffect(() => { if (!isOpen) { const timer = setTimeout(() => { @@ -94,35 +112,6 @@ const ProjectIssues: NextPage = () => { } }, [isOpen]); - const groupedByIssues: { - [key: string]: IIssue[]; - } = { - ...(groupByProperty === "state_detail.name" - ? Object.fromEntries( - states - ?.sort((a, b) => a.sequence - b.sequence) - ?.map((state) => [ - state.name, - projectIssues?.results.filter((issue) => issue.state === state.name) ?? [], - ]) ?? [] - ) - : groupByProperty === "priority" - ? Object.fromEntries( - PRIORITIES.map((priority) => [ - priority, - projectIssues?.results.filter((issue) => issue.priority === priority) ?? [], - ]) - ) - : {}), - ...groupBy(projectIssues?.results ?? [], groupByProperty ?? ""), - }; - - const groupByOptions: Array<{ name: string; key: NestedKeyOf }> = [ - { name: "State", key: "state_detail.name" }, - { name: "Priority", key: "priority" }, - { name: "Created By", key: "created_by" }, - ]; - return ( {

Project Issues

-
+
- -
- - - {groupByOptions.find((option) => option.key === groupByProperty)?.name ?? - "No Grouping"} - -
-
-
-
- - - -
- {groupByOptions.map((option) => ( - - {({ active }) => ( - - )} - - ))} - {issueView === "list" ? ( - - {({ active }) => ( - - )} - - ) : null} -
-
-
-
{({ open }) => ( <> - - Properties - + + View + { leaveFrom="opacity-100 translate-y-0" leaveTo="opacity-0 translate-y-1" > - -
-
- {Object.keys(properties).map((key) => ( - - ))} + {groupByOptions.map((option) => ( + setGroupByProperty(option.key)} + > + {option.name} + + ))} + +
+
+

Order by

+ option.key === orderBy)?.name ?? + "Select" + } + > + {orderByOptions.map((option) => ( + setOrderBy(option.key)} + > + {option.name} + + ))} + +
+
+

Issue type

+ option.key === filterIssue) + ?.name ?? "Select" + } + > + {filterIssueOptions.map((option) => ( + setFilterIssue(option.key)} + > + {option.name} + + ))} + +
+
+
+

Properties

+
+ {Object.keys(properties).map((key) => ( + + ))} +
+
@@ -335,4 +335,4 @@ const ProjectIssues: NextPage = () => { ); }; -export default ProjectIssues; +export default withAuth(ProjectIssues); diff --git a/apps/app/pages/workspace/index.tsx b/apps/app/pages/workspace/index.tsx index db86a80ea..8138f3a3d 100644 --- a/apps/app/pages/workspace/index.tsx +++ b/apps/app/pages/workspace/index.tsx @@ -103,7 +103,9 @@ const Workspace: NextPage = () => { {issue.name} - {issue.sequence_id} + + {issue.project_detail?.identifier}-{issue.sequence_id} + { + return ( + +
+ + {label} + +
+ + + +
{children}
+
+
+
+ ); +}; + +const MenuItem: React.FC = ({ children, renderAs, href, onClick }) => { + return ( + + {({ active }) => + renderAs === "a" ? ( + + + {children} + + + ) : ( + + ) + } + + ); +}; + +CustomMenu.MenuItem = MenuItem; + +export default CustomMenu; diff --git a/apps/app/ui/CustomMenu/types.d.ts b/apps/app/ui/CustomMenu/types.d.ts new file mode 100644 index 000000000..e8ccfc517 --- /dev/null +++ b/apps/app/ui/CustomMenu/types.d.ts @@ -0,0 +1,11 @@ +export type Props = { + children: React.ReactNode; + label: string; +}; + +export type MenuItemProps = { + children: string; + renderAs?: "button" | "a"; + href?: string; + onClick?: () => void; +}; diff --git a/apps/app/ui/index.ts b/apps/app/ui/index.ts index c5096b403..96c097668 100644 --- a/apps/app/ui/index.ts +++ b/apps/app/ui/index.ts @@ -3,6 +3,7 @@ export { default as Input } from "./Input"; export { default as Select } from "./Select"; export { default as TextArea } from "./TextArea"; export { default as CustomListbox } from "./CustomListbox"; +export { default as CustomMenu } from "./CustomMenu"; export { default as Spinner } from "./Spinner"; export { default as Tooltip } from "./Tooltip"; export { default as SearchListbox } from "./SearchListbox";