feat: filter, sort issues, command as shortcut for mac device

This commit is contained in:
Dakshesh Jain 2022-12-02 17:17:13 +05:30
parent 71cd84e65c
commit 9701697af2
13 changed files with 437 additions and 430 deletions

View File

@ -85,25 +85,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;

View File

@ -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<IIssue> | null;
groupTitle: string;
groupedByIssues: any;
groupedByIssues: {
[key: string]: IIssue[];
};
index: number;
setIsIssueOpen: React.Dispatch<React.SetStateAction<boolean>>;
properties: Properties;
@ -103,9 +109,6 @@ const SingleBoard: React.FC<Props> = ({
border: `2px solid ${bgColor}`,
backgroundColor: `${bgColor}20`,
}}
onClick={() => {
// setInput(true);
}}
>
<span
className={`w-3 h-3 block rounded-full ${!show ? "" : "mr-1"}`}
@ -167,25 +170,12 @@ const SingleBoard: React.FC<Props> = ({
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",
})
}
>
<PencilIcon className="h-4 w-4" />
</button>
{/* <button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300"
onClick={() =>
setSelectedState({
...state,
actionType: "delete",
})
}
>
<TrashIcon className="h-4 w-4 text-red-500" />
</button> */}
</div>
</div>
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
@ -197,7 +187,7 @@ const SingleBoard: React.FC<Props> = ({
{...provided.droppableProps}
ref={provided.innerRef}
>
{groupedByIssues[groupTitle].map((childIssue: any, index: number) => (
{groupedByIssues[groupTitle].map((childIssue, index: number) => (
<Draggable key={childIssue.id} draggableId={childIssue.id} index={index}>
{(provided, snapshot) => (
<Link href={`/projects/${childIssue.project}/issues/${childIssue.id}`}>
@ -212,6 +202,9 @@ const SingleBoard: React.FC<Props> = ({
className="px-2 py-3 space-y-1.5 select-none"
{...provided.dragHandleProps}
>
<span className="group-hover:text-theme break-all">
{childIssue.name}
</span>
{Object.keys(properties).map(
(key) =>
properties[key as keyof Properties] &&
@ -236,34 +229,65 @@ const SingleBoard: React.FC<Props> = ({
: 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" ? (
<>
<CalendarDaysIcon className="h-4 w-4" />{" "}
{key === "start_date" && childIssue.start_date !== null && (
<span className="text-sm">
<CalendarDaysIcon className="h-4 w-4" />
{renderShortNumericDateFormat(childIssue.start_date)} -
{childIssue.target_date
? renderShortNumericDateFormat(childIssue.target_date)
: "N/A"}
</>
) : (
""
: "None"}
</span>
)}
{key === "name" && (
<span className="group-hover:text-theme">
{childIssue.name}
{key === "target_date" && (
<>
<span
className={`flex items-center gap-x-1 group ${
childIssue.target_date === null
? ""
: childIssue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(childIssue.target_date) <=
3 && "text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{childIssue.target_date
? renderShortNumericDateFormat(childIssue.target_date)
: "N/A"}
{childIssue.target_date && (
<span className="absolute -top-full mb-2 left-4 border transition-opacity opacity-0 group-hover:opacity-100 bg-white rounded px-2 py-1">
{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"}
</span>
)}
</span>
</>
)}
{key === "key" && (
<span className="text-xs">
{childIssue.project_detail?.identifier}-
{childIssue.sequence_id}
</span>
)}
{key === "state" && (
<>{addSpaceIfCamelCase(childIssue["state_detail"].name)}</>
)}
{key === "priority" && <>{childIssue.priority}</>}
{key === "description" && <>{childIssue.description}</>}
{key === "assignee" ? (
<div className="flex items-center gap-1 text-xs">
{childIssue?.assignee_details?.length > 0 ? (
childIssue?.assignee_details?.map(
(assignee: any, index: number) => (
(assignee, index: number) => (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
@ -291,7 +315,7 @@ const SingleBoard: React.FC<Props> = ({
)
)
) : (
<span>None</span>
<span>No assignee.</span>
)}
</div>
) : null}
@ -299,29 +323,6 @@ const SingleBoard: React.FC<Props> = ({
)
)}
</div>
{/* <div
className={`p-2 bg-indigo-50 flex items-center justify-between ${
snapshot.isDragging ? "bg-indigo-200" : ""
}`}
>
<button
type="button"
className="flex flex-col"
{...provided.dragHandleProps}
>
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600" />
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600 mt-[-0.7rem]" />
</button>
<div className="flex gap-1 items-center">
<button type="button">
<HeartIcon className="h-4 w-4 text-yellow-500" />
</button>
<button type="button">
<CheckCircleIcon className="h-4 w-4 text-green-500" />
</button>
</div>
</div> */}
</a>
</Link>
)}

View File

@ -67,8 +67,6 @@ const BoardView: React.FC<Props> = ({ 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<Props> = ({ properties, selectedGroup, groupedByIssues
return (
<>
{/* <CreateUpdateStateModal
isOpen={
isOpen &&
preloadedData?.actionType !== "delete" &&
preloadedData?.actionType !== "createIssue"
}
setIsOpen={setIsOpen}
data={preloadedData as Partial<IIssue>}
projectId={projectId as string}
/> */}
{/* <ConfirmStateDeletion
isOpen={isOpen && preloadedData?.actionType === "delete"}
setIsOpen={setIsOpen}
data={preloadedData as Partial<IIssue>}
/> */}
<ConfirmIssueDeletion
isOpen={isIssueDeletionOpen}
handleClose={() => setIsIssueDeletionOpen(false)}
@ -199,21 +182,6 @@ const BoardView: React.FC<Props> = ({ properties, selectedGroup, groupedByIssues
{groupedByIssues ? (
<div className="h-full w-full">
<DragDropContext onDragEnd={handleOnDragEnd}>
{/* <StrictModeDroppable droppableId="trashBox">
{(provided, snapshot) => (
<button
type="button"
className={`fixed bottom-2 right-8 z-10 px-2 py-1 flex items-center gap-2 rounded-lg mb-5 text-red-600 text-sm bg-red-100 border-2 border-transparent ${
snapshot.isDraggingOver ? "border-red-600" : ""
}`}
{...provided.droppableProps}
ref={provided.innerRef}
>
<TrashIcon className="h-3 w-3" />
Drop to delete
</button>
)}
</StrictModeDroppable> */}
<div className="h-full w-full overflow-hidden">
<StrictModeDroppable droppableId="state" type="state" direction="horizontal">
{(provided) => (

View File

@ -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":

View File

@ -0,0 +1,92 @@
import { useState } from "react";
// hooks
import useTheme from "./useTheme";
import useUser from "./useUser";
// commons
import { groupBy, orderArrayBy } from "constants/common";
// types
import type { IssueResponse, IIssue, NestedKeyOf } from "types";
const PRIORITIES = ["high", "medium", "low"];
const useIssuesFilter = (projectIssues?: IssueResponse) => {
const { issueView, setIssueView, groupByProperty, setGroupByProperty } = useTheme();
const [orderBy, setOrderBy] = useState<NestedKeyOf<IIssue> | 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)])
);
}
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;

View File

@ -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;

View File

@ -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<IIssue> }> = [
{ name: "State", key: "state_detail.name" },
{ name: "Priority", key: "priority" },
{ name: "Created By", key: "created_by" },
];
const orderByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> }> = [
{ 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<string | undefined>();
const [deleteIssue, setDeleteIssue] = useState<string | undefined>(undefined);
const { activeWorkspace, activeProject } = useUser();
const { activeWorkspace, activeProject, issues: projectIssues } = useUser();
const router = useRouter();
@ -62,22 +84,6 @@ const ProjectIssues: NextPage = () => {
projectId as string
);
const { data: projectIssues } = useSWR<IssueResponse>(
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<IState[]>(
activeWorkspace && activeProject ? STATE_LIST(activeProject.id) : null,
activeWorkspace && activeProject
? () => stateServices.getStates(activeWorkspace.slug, activeProject.id)
: null
);
const { data: members } = useSWR<ProjectMember[]>(
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<IIssue> }> = [
{ name: "State", key: "state_detail.name" },
{ name: "Priority", key: "priority" },
{ name: "Created By", key: "created_by" },
];
return (
<AdminLayout>
<CreateUpdateIssuesModal
@ -149,7 +138,7 @@ const ProjectIssues: NextPage = () => {
</Breadcrumbs>
<div className="flex items-center justify-between w-full">
<h2 className="text-2xl font-medium">Project Issues</h2>
<div className="flex items-center gap-x-3">
<div className="flex items-center md:gap-x-6 sm:gap-x-3">
<div className="flex items-center gap-x-1">
<button
type="button"
@ -176,70 +165,23 @@ const ProjectIssues: NextPage = () => {
<Squares2X2Icon className="h-4 w-4" />
</button>
</div>
<Menu as="div" className="relative inline-block w-40">
<div className="w-full">
<Menu.Button className="inline-flex justify-between items-center w-full rounded-md shadow-sm p-2 border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-100 focus:outline-none">
<span className="flex gap-x-1 items-center">
{groupByOptions.find((option) => option.key === groupByProperty)?.name ??
"No Grouping"}
</span>
<div className="flex-grow flex justify-end">
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</div>
</Menu.Button>
</div>
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-left absolute left-0 mt-2 w-full rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
<div className="p-1">
{groupByOptions.map((option) => (
<Menu.Item key={option.key}>
{({ active }) => (
<button
type="button"
className={`${
active ? "bg-theme text-white" : "text-gray-900"
} group flex w-full items-center rounded-md p-2 text-xs`}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</button>
)}
</Menu.Item>
))}
{issueView === "list" ? (
<Menu.Item>
{({ active }) => (
<button
type="button"
className={`hover:bg-theme hover:text-white ${
active ? "bg-theme text-white" : "text-gray-900"
} group flex w-full items-center rounded-md p-2 text-xs`}
onClick={() => setGroupByProperty(null)}
>
No grouping
</button>
)}
</Menu.Item>
) : null}
</div>
</Menu.Items>
</Transition>
</Menu>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button className="inline-flex justify-between items-center rounded-md shadow-sm p-2 border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-100 focus:outline-none w-40">
<span>Properties</span>
<ChevronDownIcon className="h-4 w-4" />
<Popover.Button
className={classNames(
open ? "text-gray-900" : "text-gray-500",
"group inline-flex items-center rounded-md bg-transparent text-base font-medium hover:text-gray-900 focus:outline-none border border-gray-300 px-3 py-1"
)}
>
<span>View</span>
<ChevronDownIcon
className={classNames(
open ? "text-gray-600" : "text-gray-400",
"ml-2 h-4 w-4 group-hover:text-gray-500"
)}
aria-hidden="true"
/>
</Popover.Button>
<Transition
@ -251,25 +193,83 @@ const ProjectIssues: NextPage = () => {
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-1/2 z-10 mt-1 -translate-x-1/2 transform px-2 sm:px-0 w-full">
<div className="overflow-hidden rounded-lg shadow-lg ring-1 ring-black ring-opacity-5">
<div className="relative grid bg-white p-1">
{Object.keys(properties).map((key) => (
<button
key={key}
className={`text-gray-900 hover:bg-theme hover:text-white flex justify-between w-full items-center rounded-md p-2 text-xs`}
onClick={() => setProperties(key as keyof Properties)}
<Popover.Panel className="absolute mr-5 right-1/2 z-10 mt-3 w-screen max-w-xs translate-x-1/2 transform px-2 sm:px-0 bg-white rounded-lg shadow-lg overflow-hidden">
<div className="overflow-hidden py-8 px-4">
<div className="relative flex flex-col gap-1 gap-y-4">
<div className="flex justify-between">
<h4 className="text-base text-gray-600">Group by</h4>
<CustomMenu
label={
groupByOptions.find((option) => option.key === groupByProperty)
?.name ?? "Select"
}
>
<p className="capitalize">{key.replace("_", " ")}</p>
<span className="self-end">
{properties[key as keyof Properties] ? (
<EyeIcon width="18" height="18" />
) : (
<EyeSlashIcon width="18" height="18" />
)}
</span>
</button>
))}
{groupByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="flex justify-between">
<h4 className="text-base text-gray-600">Order by</h4>
<CustomMenu
label={
orderByOptions.find((option) => option.key === orderBy)?.name ??
"Select"
}
>
{orderByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setOrderBy(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="flex justify-between">
<h4 className="text-base text-gray-600">Issue type</h4>
<CustomMenu
label={
filterIssueOptions.find((option) => option.key === filterIssue)
?.name ?? "Select"
}
>
{filterIssueOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setFilterIssue(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="border-b-2"></div>
<div className="relative bg-white flex flex-col gap-1">
<h4 className="text-base text-gray-600">Properties</h4>
<div>
{Object.keys(properties).map((key) => (
<button
key={key}
type="button"
className={`px-2 py-1 inline capitalize rounded border border-indigo-600 text-sm m-1 ${
properties[key as keyof Properties]
? "border-indigo-600 bg-indigo-600 text-white"
: ""
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{replaceUnderscoreIfSnakeCase(key)}
</button>
))}
</div>
</div>
</div>
</div>
</Popover.Panel>
@ -335,4 +335,4 @@ const ProjectIssues: NextPage = () => {
);
};
export default ProjectIssues;
export default withAuth(ProjectIssues);

View File

@ -103,7 +103,9 @@ const Workspace: NextPage = () => {
<a className="hover:text-theme duration-300">{issue.name}</a>
</Link>
</td>
<td className="px-3 py-4">{issue.sequence_id}</td>
<td className="px-3 py-4">
{issue.project_detail?.identifier}-{issue.sequence_id}
</td>
<td className="px-3 py-4">
<span
className="rounded px-2 py-1 text-xs font-medium"

View File

@ -11,4 +11,5 @@ export interface IState {
project: string;
workspace: string;
sequence: number;
group: "backlog" | "unstarted" | "started" | "completed" | "cancelled";
}

View File

@ -0,0 +1,74 @@
import React from "react";
// next
import Link from "next/link";
// headless ui
import { Menu, Transition } from "@headlessui/react";
// icons
import { ChevronDownIcon } from "@heroicons/react/20/solid";
// commons
import { classNames } from "constants/common";
// types
import type { MenuItemProps, Props } from "./types";
const CustomMenu = ({ children, label }: Props) => {
return (
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="inline-flex w-32 justify-between gap-x-4 rounded-md border border-gray-300 bg-white px-4 py-1 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
<span className="truncate w-20">{label}</span>
<ChevronDownIcon className="-mr-1 ml-2 h-5 w-5" aria-hidden="true" />
</Menu.Button>
</div>
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">{children}</div>
</Menu.Items>
</Transition>
</Menu>
);
};
const MenuItem: React.FC<MenuItemProps> = ({ children, renderAs, href, onClick }) => {
return (
<Menu.Item>
{({ active }) =>
renderAs === "a" ? (
<Link href={href ?? ""}>
<a
className={classNames(
active ? "bg-gray-100 text-gray-900" : "text-gray-700",
"block px-4 py-2 text-sm"
)}
>
{children}
</a>
</Link>
) : (
<button
type="button"
onClick={onClick}
className={classNames(
active ? "bg-gray-100 text-gray-900" : "text-gray-700",
"block w-full px-4 py-2 text-left text-sm"
)}
>
{children}
</button>
)
}
</Menu.Item>
);
};
CustomMenu.MenuItem = MenuItem;
export default CustomMenu;

11
apps/app/ui/CustomMenu/types.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
export type Props = {
children: React.ReactNode;
label: string;
};
export type MenuItemProps = {
children: string;
renderAs?: "button" | "a";
href?: string;
onClick?: () => void;
};

View File

@ -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";

186
yarn.lock
View File

@ -77,187 +77,6 @@
resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8"
integrity sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==
"@lexical/clipboard@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/clipboard/-/clipboard-0.5.0.tgz#3d835289c0e1543a13a5fd032294aa2614e4373a"
integrity sha512-JFvdH4N/80GxC0jhaiO/fdUOeYcX8pMFrcrpBDeNIcBN/9eF8Rn/czvoPLLNB9Kcbz8d8XXqabKEGCz2hFL//w==
dependencies:
"@lexical/html" "0.5.0"
"@lexical/list" "0.5.0"
"@lexical/selection" "0.5.0"
"@lexical/utils" "0.5.0"
"@lexical/code@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/code/-/code-0.5.0.tgz#05c92e3b077af3148a494b44f5663dea14f7631f"
integrity sha512-GmqRaQ8EBtlu13ObSZYiGDzIsrkwRyyqI2HRVBrPo2iszLBpby+7uIncAVQVkxt1JNYOKE2n4JfxK8TSYyMtYQ==
dependencies:
"@lexical/utils" "0.5.0"
prismjs "^1.27.0"
"@lexical/dragon@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/dragon/-/dragon-0.5.0.tgz#54ec8812e3fb907af5913c5d0436b8d28fa4efe8"
integrity sha512-Gf0jN8hjlF8E71wAsvbRpR1u9oS6RUjUw3VWp/Qa+IrtjBFFVzdTUloUs3cjMX9E/MFRJgt3wPsaKx2IuLBWQw==
"@lexical/hashtag@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/hashtag/-/hashtag-0.5.0.tgz#dfe39ea73d1c4658c724419ef1113e27fb75a7f3"
integrity sha512-3MT72y72BmK4q7Rtb9gP3n83UL4vWC078T9io4zyPxKEI1Mh3UAVuRwh6Ypn0FeH94XvmuZAGVdoOC/nTd1now==
dependencies:
"@lexical/utils" "0.5.0"
"@lexical/history@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/history/-/history-0.5.0.tgz#5a13077e012f27f783beadca1c3fe545a2968f20"
integrity sha512-DCQgh1aQ1KS5JVYPU6GYr52BN0MQqmoXfFtf5uYCX9CbSAC0hDSK8ZPqwFW7jINqe6GwXxy7bo32j7E0A5023A==
dependencies:
"@lexical/utils" "0.5.0"
"@lexical/html@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/html/-/html-0.5.0.tgz#5eb2ccb9ffb7c24fff097db369d81431d7a98833"
integrity sha512-uJAof6gXTLOH9JnmPJ+wxILFtu7I/eCebFyVMjV53sqaeLsQ3pDfBTUe4RO+NciC+XBQ1WVpZgCM8Yx5c5cMmQ==
dependencies:
"@lexical/selection" "0.5.0"
"@lexical/link@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/link/-/link-0.5.0.tgz#fa5f3baa1122eb2a1be12ac7a30977eb4c557e1e"
integrity sha512-XB8e+UPI9jeqsi7+Wr0n9SToljiS+gZmJ5gXANtR6lSZPtpcSUPs1iJZU2A2dNKXdvsZwSPCFdPL6ogFaaRvvQ==
dependencies:
"@lexical/utils" "0.5.0"
"@lexical/link@^0.6.3":
version "0.6.3"
resolved "https://registry.yarnpkg.com/@lexical/link/-/link-0.6.3.tgz#e09b670e69be7ea4509654aacec87e74b834d84f"
integrity sha512-duP+8OYEsIJ5AZLO5Y/cND+oNajvlc0geggmzrJ/XRcFiQAWXJ9BsmEeg6KZFzl2+Whkz3Zdkfu/1h80qllktA==
dependencies:
"@lexical/utils" "0.6.3"
"@lexical/list@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/list/-/list-0.5.0.tgz#6ab4e1789d037af43f3d01012d2533c7c94daf15"
integrity sha512-TYXe4FtNL7Lk3XDEhPyUbT0Pb1TU58qZywGCdrtuRjPnF4oDvRXgg9EhYWfHzYwdsyhNgaHId+Fq41CjrwTMYg==
dependencies:
"@lexical/utils" "0.5.0"
"@lexical/list@0.6.3":
version "0.6.3"
resolved "https://registry.yarnpkg.com/@lexical/list/-/list-0.6.3.tgz#6389d051549860b53b93f53d537e116150ba20c7"
integrity sha512-zrQwX9J9hmLRjh4VkDykiv4P7et86ez85wAcvcoZNSwRGdLRMDxJLOyzJI6njr3CrebEKzHWVCsEcpn5T8bZcw==
dependencies:
"@lexical/utils" "0.6.3"
"@lexical/mark@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/mark/-/mark-0.5.0.tgz#59c2a2a9f0ecfa063d48ce6f4a50e6d8bc6fcae1"
integrity sha512-leeqegWD4hqUdfYNsxB5iwsWozX2oc6mnJzcJfR4UB3Ksr0zH2xHc/z3Zp+CTeGuK5Tzppq5yGS+4cQ5xNpVgQ==
dependencies:
"@lexical/utils" "0.5.0"
"@lexical/markdown@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/markdown/-/markdown-0.5.0.tgz#3424a98e8600bc719f99bb4ab2484f0cf0e3c0f7"
integrity sha512-02RLx7PdVzvYxvx65FTbXkW6KcjQZ1waAaMDNKdtBV9r9Mv2Y2XunCUjErYHQ1JN9JkGGv0+JuliRT7qZTsF+Q==
dependencies:
"@lexical/code" "0.5.0"
"@lexical/link" "0.5.0"
"@lexical/list" "0.5.0"
"@lexical/rich-text" "0.5.0"
"@lexical/text" "0.5.0"
"@lexical/utils" "0.5.0"
"@lexical/offset@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/offset/-/offset-0.5.0.tgz#445aae1c74198dd4ed7d59669735925621139e0a"
integrity sha512-ie4AFbvtt0CFBqaMcb0/gUuhoTt+YwbFXPFo1hW+oDVpmo3rJsEJKVsHhftBvHIP+/G5QlgPIhVmnlcSvEteTw==
"@lexical/overflow@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/overflow/-/overflow-0.5.0.tgz#70daabdb96a3de9bf2f052ab2e38917aaf6e3b18"
integrity sha512-N+BQvgODU9lS7VK4FlxIRhGeASwsxfdkECtZ5iomHfqqNEI0WPLHbCTCkwS10rjfH1NrkXC314Y0SG2F7Ncv9Q==
"@lexical/plain-text@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/plain-text/-/plain-text-0.5.0.tgz#385ac7c67b34116578e45fe34cca41e9f62cbcad"
integrity sha512-t1rnVnSXbPs9jLN/36/xZLNAlF9jwv8rSh6GHsjRIYiWX/MovNmgPmhNq/nkc+gRFZ2FKTFjdz3UeAUF4xQZMw==
"@lexical/react@^0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/react/-/react-0.5.0.tgz#f227dd71f95e49bf817d648b9ffd5a31850876bc"
integrity sha512-bba0KXslxjf6M8XXJhx1rsrq9UV/6eo73WCZel2K+tGz8NEn1HCRTebQoebmRikzEQatEa3SoB6R47drMlk7Yw==
dependencies:
"@lexical/clipboard" "0.5.0"
"@lexical/code" "0.5.0"
"@lexical/dragon" "0.5.0"
"@lexical/hashtag" "0.5.0"
"@lexical/history" "0.5.0"
"@lexical/link" "0.5.0"
"@lexical/list" "0.5.0"
"@lexical/mark" "0.5.0"
"@lexical/markdown" "0.5.0"
"@lexical/overflow" "0.5.0"
"@lexical/plain-text" "0.5.0"
"@lexical/rich-text" "0.5.0"
"@lexical/selection" "0.5.0"
"@lexical/table" "0.5.0"
"@lexical/text" "0.5.0"
"@lexical/utils" "0.5.0"
"@lexical/yjs" "0.5.0"
"@lexical/rich-text@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/rich-text/-/rich-text-0.5.0.tgz#9b7ddacdd74a49761f15486ed2846c5117a55d14"
integrity sha512-JhgMn70K410j3T/2WefPpEswZ+hWF3aJMNu7zkrCf2wB+KdrrGYoeNSZUzg2r4e6BuJgS117KlD99+MDnokCuw==
"@lexical/selection@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/selection/-/selection-0.5.0.tgz#fe94f06fb17d9f5848921a0bbce10774398d3486"
integrity sha512-6I5qlqkYDIbDZPGwSOuvpWQUrqMY6URaKwrWsijQZMnNNKscGpC7IKb7sSDKn6YkLm7tuqig3hf2p+6hshkyWg==
"@lexical/table@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/table/-/table-0.5.0.tgz#cf33fac7c2e0b520ab4747f9322cce434d117cb6"
integrity sha512-VNHWSsTFDSHNzLdQOR9qgKx4tvTuiDz6w0GfwBnMP4Ro2iKKtNowmZO4wDEZtVlUHvLMuOGuYqipOtKEDKbD4w==
dependencies:
"@lexical/utils" "0.5.0"
"@lexical/table@0.6.3":
version "0.6.3"
resolved "https://registry.yarnpkg.com/@lexical/table/-/table-0.6.3.tgz#10eb7f1edd0269da18352145854ba0fc41d709f1"
integrity sha512-7ces57Y9wBwr2UXccXguyFC87QF6epdp2EZybb8yVEoWvMM5z51CnWELEbADYv5lnevPS2LC8LtJV3v1iSPZbA==
dependencies:
"@lexical/utils" "0.6.3"
"@lexical/text@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/text/-/text-0.5.0.tgz#803e34d9b1e2430e8062d2db50c7452d4e03c86e"
integrity sha512-RqhOBU2Ecg0WVW8p1d3OB2a8sQyvh3suADdr7We50+Dn/k1M+jhKVWiQnf07ve4/yqYTj6/9/8AAg7kuNS2P/A==
"@lexical/utils@0.5.0", "@lexical/utils@^0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/utils/-/utils-0.5.0.tgz#4f03e8090e65cde5e81801ab08a2450e2e03733a"
integrity sha512-FhQ+thPFTOyBxyRGcd3yJuYh/rvD8ro43DaelWD1KpSlwQ/YuWpdxsSuMqJ32ERpl+bmPPFP2kjkBofxSw1Quw==
dependencies:
"@lexical/list" "0.5.0"
"@lexical/table" "0.5.0"
"@lexical/utils@0.6.3":
version "0.6.3"
resolved "https://registry.yarnpkg.com/@lexical/utils/-/utils-0.6.3.tgz#85276f9ef095d23634cb3f2d059f5781cee656ec"
integrity sha512-LijfKzH9Fdl30eZ/fWDigLsRPs/rz8sZAnRg6UsJGKR1SS3PeWLoO4RRWhnNzpMypVX8UdvbKv1DxMjQn3d/kw==
dependencies:
"@lexical/list" "0.6.3"
"@lexical/table" "0.6.3"
"@lexical/yjs@0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@lexical/yjs/-/yjs-0.5.0.tgz#a6a6e12f5eceaa5a37ae7999b97ba8ce217987b6"
integrity sha512-2io4GqnRoSh6Nu9bzsDOlwPFJYjXZ9SdgU4ZioH2VvyW4wVstd+ZF2QVcUJlhuwgQr6DzuvM/pqN914IufLzpw==
dependencies:
"@lexical/offset" "0.5.0"
"@next/env@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.2.tgz#cc1a0a445bd254499e30f632968c03192455f4cc"
@ -2094,11 +1913,6 @@ prelude-ls@^1.2.1:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prismjs@^1.27.0:
version "1.29.0"
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12"
integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==
prop-types@^15.5.10, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"