feat: modules, style: kanban board, shortcut modals

This commit is contained in:
Aaryan Khandelwal 2022-12-22 21:49:46 +05:30
parent 9539fca585
commit f6ca842d30
43 changed files with 2741 additions and 558 deletions

View File

@ -1,18 +1,22 @@
// react
import React, { useState, useCallback, useEffect } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// react hook form
import { SubmitHandler, useForm } from "react-hook-form";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useTheme from "lib/hooks/useTheme";
import useToast from "lib/hooks/useToast";
// components
import ShortcutsModal from "components/command-palette/shortcuts";
import CreateProjectModal from "components/project/create-project-modal";
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal";
import BulkDeleteIssuesModal from "components/common/bulk-delete-issues-modal";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// icons
import {
FolderIcon,
@ -20,25 +24,10 @@ import {
ClipboardDocumentListIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/24/outline";
// components
import ShortcutsModal from "components/command-palette/shortcuts";
import CreateProjectModal from "components/project/create-project-modal";
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
// ui
import { Button } from "ui";
// types
import { IIssue, IssueResponse } from "types";
// fetch keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// constants
import { IIssue } from "types";
// common
import { classNames, copyTextToClipboard } from "constants/common";
import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal";
type FormInput = {
issue_ids: string[];
cycleId: string;
};
const CommandPalette: React.FC = () => {
const [query, setQuery] = useState("");
@ -49,8 +38,9 @@ const CommandPalette: React.FC = () => {
const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false);
const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false);
const [isCreateModuleModalOpen, setisCreateModuleModalOpen] = useState(false);
const [isBulkDeleteIssuesModalOpen, setIsBulkDeleteIssuesModalOpen] = useState(false);
const { activeWorkspace, activeProject, issues } = useUser();
const { activeProject, issues } = useUser();
const router = useRouter();
@ -64,8 +54,6 @@ const CommandPalette: React.FC = () => {
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
const { register, handleSubmit, reset } = useForm<FormInput>();
const quickActions = [
{
name: "Add new issue...",
@ -88,7 +76,6 @@ const CommandPalette: React.FC = () => {
const handleCommandPaletteClose = () => {
setIsPaletteOpen(false);
setQuery("");
reset();
};
const handleKeyDown = useCallback(
@ -114,6 +101,9 @@ const CommandPalette: React.FC = () => {
} else if ((e.ctrlKey || e.metaKey) && e.key === "m") {
e.preventDefault();
setisCreateModuleModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "d") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === "c") {
e.preventDefault();
@ -138,47 +128,6 @@ const CommandPalette: React.FC = () => {
[toggleCollapsed, setToastAlert, router]
);
const handleDelete: SubmitHandler<FormInput> = (data) => {
if (!data.issue_ids || data.issue_ids.length === 0) {
setToastAlert({
title: "Error",
type: "error",
message: "Please select atleast one issue",
});
return;
}
if (activeWorkspace && activeProject) {
issuesServices
.bulkDeleteIssues(activeWorkspace.slug, activeProject.id, data)
.then((res) => {
setToastAlert({
title: "Success",
type: "success",
message: res.message,
});
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
(prevData) => {
return {
...(prevData as IssueResponse),
count: (prevData?.results ?? []).filter(
(p) => !data.issue_ids.some((id) => p.id === id)
).length,
results: (prevData?.results ?? []).filter(
(p) => !data.issue_ids.some((id) => p.id === id)
),
};
},
false
);
})
.catch((e) => {
console.log(e);
});
}
};
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
@ -207,6 +156,10 @@ const CommandPalette: React.FC = () => {
setIsOpen={setIsIssueModalOpen}
projectId={activeProject?.id}
/>
<BulkDeleteIssuesModal
isOpen={isBulkDeleteIssuesModalOpen}
setIsOpen={setIsBulkDeleteIssuesModalOpen}
/>
<Transition.Root
show={isPaletteOpen}
as={React.Fragment}
@ -283,12 +236,6 @@ const CommandPalette: React.FC = () => {
{({ active }) => (
<>
<div className="flex items-center gap-2">
<input
type="checkbox"
{...register("issue_ids")}
id={`issue-${issue.id}`}
value={issue.id}
/>
<span
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{
@ -377,9 +324,6 @@ const CommandPalette: React.FC = () => {
</Combobox>
<div className="flex justify-end items-center gap-2 p-3">
<Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm">
Delete selected issues
</Button>
<div>
<Button type="button" size="sm" onClick={handleCommandPaletteClose}>
Close

View File

@ -30,6 +30,8 @@ const shortcuts = [
{ keys: "ctrl,p", description: "To create project" },
{ keys: "ctrl,i", description: "To create issue" },
{ keys: "ctrl,q", description: "To create cycle" },
{ keys: "ctrl,m", description: "To create module" },
{ keys: "ctrl,d", description: "To bulk delete issues" },
{ keys: "ctrl,h", description: "To open shortcuts guide" },
{
keys: "ctrl,alt,c",

View File

@ -22,6 +22,7 @@ import {
import { PRIORITIES } from "constants/";
import useUser from "lib/hooks/useUser";
import React from "react";
import { getPriorityIcon } from "constants/global";
type Props = {
issue: IIssue;
@ -95,7 +96,7 @@ const SingleIssue: React.FC<Props> = ({
<>
<div>
<Listbox.Button
className={`rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
className={`grid place-items-center rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
@ -107,7 +108,7 @@ const SingleIssue: React.FC<Props> = ({
: "bg-gray-100"
}`}
>
{issue.priority ?? "None"}
{getPriorityIcon(issue?.priority ?? "None")}
</Listbox.Button>
<Transition
@ -124,18 +125,19 @@ const SingleIssue: React.FC<Props> = ({
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"cursor-pointer capitalize select-none px-3 py-2"
"flex items-center gap-2 cursor-pointer capitalize select-none px-3 py-2"
)
}
value={priority}
>
{getPriorityIcon(priority)}
{priority}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
{/* <div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Priority</h5>
<div
className={`capitalize ${
@ -152,7 +154,7 @@ const SingleIssue: React.FC<Props> = ({
>
{issue.priority ?? "None"}
</div>
</div>
</div> */}
</>
)}
</Listbox>
@ -210,10 +212,10 @@ const SingleIssue: React.FC<Props> = ({
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
{/* <div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{issue.state_detail.name}</div>
</div>
</div> */}
</>
)}
</Listbox>
@ -222,10 +224,10 @@ const SingleIssue: React.FC<Props> = ({
<div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{issue.start_date ? renderShortNumericDateFormat(issue.start_date) : "N/A"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
{/* <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>{renderShortNumericDateFormat(issue.start_date ?? "")}</div>
</div>
</div> */}
</div>
)}
{properties.due_date && (
@ -240,7 +242,7 @@ const SingleIssue: React.FC<Props> = ({
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
{/* <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Target date</h5>
<div>{renderShortNumericDateFormat(issue.target_date ?? "")}</div>
<div>
@ -251,7 +253,7 @@ const SingleIssue: React.FC<Props> = ({
? `Due date is in ${findHowManyDaysLeft(issue.target_date)} days`
: "Due date")}
</div>
</div>
</div> */}
</div>
)}
{properties.assignee && (
@ -373,14 +375,14 @@ const SingleIssue: React.FC<Props> = ({
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
{/* <div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Assigned to</h5>
<div>
{issue.assignee_details?.length > 0
? issue.assignee_details.map((assignee) => assignee.first_name).join(", ")
: "No one"}
</div>
</div>
</div> */}
</>
)}
</Listbox>

View File

@ -0,0 +1,230 @@
// react
import React, { useState } from "react";
// swr
import { mutate } from "swr";
// react hook form
import { SubmitHandler, useForm } from "react-hook-form";
// services
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// icons
import { FolderIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IssueResponse } from "types";
// fetch keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// common
import { classNames } from "constants/common";
type FormInput = {
issue_ids: string[];
cycleId: string;
};
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const [query, setQuery] = useState("");
const { activeWorkspace, activeProject, issues } = useUser();
const { setToastAlert } = useToast();
const filteredIssues: IIssue[] =
query === ""
? issues?.results ?? []
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
const { register, handleSubmit, reset } = useForm<FormInput>();
const handleClose = () => {
setIsOpen(false);
setQuery("");
reset();
};
const handleDelete: SubmitHandler<FormInput> = (data) => {
if (!data.issue_ids || data.issue_ids.length === 0) {
setToastAlert({
title: "Error",
type: "error",
message: "Please select atleast one issue",
});
return;
}
if (activeWorkspace && activeProject) {
issuesServices
.bulkDeleteIssues(activeWorkspace.slug, activeProject.id, data)
.then((res) => {
setToastAlert({
title: "Success",
type: "success",
message: res.message,
});
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
(prevData) => {
return {
...(prevData as IssueResponse),
count: (prevData?.results ?? []).filter(
(p) => !data.issue_ids.some((id) => p.id === id)
).length,
results: (prevData?.results ?? []).filter(
(p) => !data.issue_ids.some((id) => p.id === id)
),
};
},
false
);
})
.catch((e) => {
console.log(e);
});
}
};
return (
<>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<form>
<Combobox>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
placeholder="Search..."
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 && (
<>
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select issues
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={{
name: issue.name,
url: `/projects/${issue.project}/issues/${issue.id}`,
}}
className={({ active }) =>
classNames(
"flex items-center justify-between cursor-pointer select-none rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ active }) => (
<>
<div className="flex items-center gap-2">
<input
type="checkbox"
{...register("issue_ids")}
id={`issue-${issue.id}`}
value={issue.id}
/>
<span
className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
</>
)}
</Combobox.Option>
))}
</ul>
</li>
</>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<FolderIcon
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<p className="mt-4 text-sm text-gray-900">
We couldn{"'"}t find any issue with that term. Please try again.
</p>
</div>
)}
</Combobox>
<div className="flex justify-end items-center gap-2 p-3">
<Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm">
Delete selected issues
</Button>
<div>
<Button type="button" size="sm" onClick={handleClose}>
Close
</Button>
</div>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</>
);
};
export default BulkDeleteIssuesModal;

View File

@ -2,44 +2,42 @@
import React, { useState } from "react";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// services
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// icons
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IssueResponse } from "types";
// constants
// common
import { classNames } from "constants/common";
import { mutate } from "swr";
import { CYCLE_ISSUES } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
handleClose: () => void;
issues: IssueResponse | undefined;
cycleId: string;
};
type FormInput = {
issues: string[];
};
const CycleIssuesListModal: React.FC<Props> = ({
type Props = {
isOpen: boolean;
handleClose: () => void;
type: string;
issues: IIssue[];
handleOnSubmit: (data: FormInput) => void;
};
const ExistingIssuesListModal: React.FC<Props> = ({
isOpen,
handleClose: onClose,
issues,
cycleId,
handleOnSubmit,
type,
}) => {
const [query, setQuery] = useState("");
const { activeWorkspace, activeProject } = useUser();
const { activeProject } = useUser();
const { setToastAlert } = useToast();
@ -60,7 +58,7 @@ const CycleIssuesListModal: React.FC<Props> = ({
},
});
const handleAddToCycle: SubmitHandler<FormInput> = (data) => {
const onSubmit: SubmitHandler<FormInput> = (data) => {
if (!data.issues || data.issues.length === 0) {
setToastAlert({
title: "Error",
@ -70,25 +68,14 @@ const CycleIssuesListModal: React.FC<Props> = ({
return;
}
if (activeWorkspace && activeProject) {
issuesServices
.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, data)
.then((res) => {
console.log(res);
mutate(CYCLE_ISSUES(cycleId));
handleClose();
})
.catch((e) => {
console.log(e);
});
}
handleOnSubmit(data);
handleClose();
};
const filteredIssues: IIssue[] =
query === ""
? issues?.results ?? []
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
? issues ?? []
: issues.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? [];
return (
<>
@ -143,12 +130,12 @@ const CycleIssuesListModal: React.FC<Props> = ({
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select issues to add to cycle
Select issues to add to {type}
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => {
if (!issue.issue_cycle)
if ((type === "cycle" && !issue.issue_cycle) || type === "module")
return (
<Combobox.Option
key={issue.id}
@ -206,10 +193,10 @@ const CycleIssuesListModal: React.FC<Props> = ({
<Button
type="button"
size="sm"
onClick={handleSubmit(handleAddToCycle)}
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting}
>
{isSubmitting ? "Adding..." : "Add to Cycle"}
{isSubmitting ? "Adding..." : `Add to ${type}`}
</Button>
</div>
</form>
@ -222,4 +209,4 @@ const CycleIssuesListModal: React.FC<Props> = ({
);
};
export default CycleIssuesListModal;
export default ExistingIssuesListModal;

View File

@ -8,8 +8,6 @@ import workspaceService from "lib/services/workspace.service";
import useUser from "lib/hooks/useUser";
// components
import SingleIssue from "components/common/board-view/single-issue";
// headless ui
import { Menu, Transition } from "@headlessui/react";
// ui
import { CustomMenu } from "ui";
// icons
@ -19,7 +17,7 @@ import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
// fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// common
import { addSpaceIfCamelCase, classNames } from "constants/common";
import { addSpaceIfCamelCase } from "constants/common";
type Props = {
properties: Properties;
@ -46,7 +44,7 @@ type Props = {
stateId: string | null;
};
const SingleCycleBoard: React.FC<Props> = ({
const SingleModuleBoard: React.FC<Props> = ({
properties,
groupedByIssues,
selectedGroup,
@ -207,4 +205,4 @@ const SingleCycleBoard: React.FC<Props> = ({
);
};
export default SingleCycleBoard;
export default SingleModuleBoard;

View File

@ -35,6 +35,7 @@ type Props = {
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
removeIssueFromCycle: (bridgeId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
@ -52,6 +53,7 @@ const CyclesListView: React.FC<Props> = ({
openIssuesListModal,
properties,
removeIssueFromCycle,
handleDeleteIssue,
setPreloadedData,
}) => {
const { activeWorkspace, activeProject, states } = useUser();
@ -264,7 +266,11 @@ const CyclesListView: React.FC<Props> = ({
>
Remove from cycle
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>Delete permanently</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => handleDeleteIssue(issue.id)}
>
Delete permanently
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>

View File

@ -115,7 +115,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
}, 500);
};
const addIssueToCycle = async (issueId: string, cycleId: string, issueDetail: IIssue) => {
const addIssueToCycle = async (issueId: string, cycleId: string) => {
if (!activeWorkspace || !activeProject) return;
await issuesServices
.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, {
@ -169,7 +169,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
mutate<IssueResponse>(PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id));
if (formData.sprints && formData.sprints !== null) {
await addIssueToCycle(res.id, formData.sprints, formData);
await addIssueToCycle(res.id, formData.sprints);
}
handleClose();
resetForm();
@ -209,7 +209,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
false
);
if (formData.sprints && formData.sprints !== null) {
await addIssueToCycle(res.id, formData.sprints, formData);
await addIssueToCycle(res.id, formData.sprints);
}
handleClose();
resetForm();

View File

@ -51,7 +51,7 @@ const SelectProject: React.FC<Props> = ({ control }) => {
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="p-1">
<div className="py-1">
{projects ? (
projects.length > 0 ? (
projects.map((project) => (
@ -59,8 +59,8 @@ const SelectProject: React.FC<Props> = ({ control }) => {
key={project.id}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-pointer select-none p-2 rounded-md`
active ? "bg-indigo-50" : ""
} text-gray-900 cursor-pointer select-none p-2`
}
value={project.id}
>

View File

@ -15,7 +15,7 @@ import { Listbox, Transition } from "@headlessui/react";
// ui
import { Spinner } from "ui";
// icons
import { ArrowPathIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
import { UserGroupIcon } from "@heroicons/react/24/outline";
import User from "public/user.png";
// types
import { IIssue } from "types";
@ -39,7 +39,7 @@ const SelectAssignee: React.FC<Props> = ({ control, submitChanges }) => {
return (
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<ArrowPathIcon className="flex-shrink-0 h-4 w-4" />
<UserGroupIcon className="flex-shrink-0 h-4 w-4" />
<p>Assignees</p>
</div>
<div className="sm:basis-1/2">
@ -128,7 +128,7 @@ const SelectAssignee: React.FC<Props> = ({ control, submitChanges }) => {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Listbox.Options className="absolute z-10 right-0 mt-1 w-auto bg-white shadow-lg max-h-48 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<Listbox.Options className="absolute z-10 left-0 mt-1 w-auto bg-white shadow-lg max-h-48 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="py-1">
{people ? (
people.length > 0 ? (

View File

@ -79,7 +79,7 @@ const SelectBlocked: React.FC<Props> = ({ issueDetail, issuesList, watch }) => {
});
});
// handleClose();
handleClose();
};
const removeBlocked = (issueId: string) => {

View File

@ -0,0 +1,96 @@
// components
import SingleBoard from "components/project/modules/board-view/single-board";
// ui
import { Spinner } from "ui";
// types
import { IIssue, IProjectMember, NestedKeyOf, Properties } from "types";
import useUser from "lib/hooks/useUser";
type Props = {
groupedByIssues: {
[key: string]: IIssue[];
};
properties: Properties;
selectedGroup: NestedKeyOf<IIssue> | null;
members: IProjectMember[] | undefined;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
removeIssueFromModule: (issueId: string) => void;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| undefined
>
>;
};
const ModulesBoardView: React.FC<Props> = ({
groupedByIssues,
properties,
selectedGroup,
members,
openCreateIssueModal,
openIssuesListModal,
removeIssueFromModule,
partialUpdateIssue,
handleDeleteIssue,
setPreloadedData,
}) => {
const { states } = useUser();
return (
<>
{groupedByIssues ? (
<div className="h-full w-full">
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full">
<div className="flex gap-x-4 h-full overflow-x-auto overflow-y-hidden pb-3">
{Object.keys(groupedByIssues).map((singleGroup) => (
<SingleBoard
key={singleGroup}
selectedGroup={selectedGroup}
groupTitle={singleGroup}
createdBy={
selectedGroup === "created_by"
? members?.find((m) => m.member.id === singleGroup)?.member.first_name ??
"loading..."
: null
}
groupedByIssues={groupedByIssues}
bgColor={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.color
: undefined
}
properties={properties}
removeIssueFromModule={removeIssueFromModule}
openIssuesListModal={openIssuesListModal}
openCreateIssueModal={openCreateIssueModal}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue}
setPreloadedData={setPreloadedData}
stateId={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null
}
/>
))}
</div>
</div>
</div>
</div>
) : (
<div className="h-full w-full flex justify-center items-center">
<Spinner />
</div>
)}
</>
);
};
export default ModulesBoardView;

View File

@ -0,0 +1,208 @@
// react
import React, { useState } from "react";
// swr
import useSWR from "swr";
// services
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
// components
import SingleIssue from "components/common/board-view/single-issue";
// ui
import { CustomMenu } from "ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
// fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// common
import { addSpaceIfCamelCase } from "constants/common";
type Props = {
properties: Properties;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
createdBy: string | null;
bgColor?: string;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
removeIssueFromModule: (bridgeId: string) => void;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| undefined
>
>;
stateId: string | null;
};
const SingleCycleBoard: React.FC<Props> = ({
properties,
groupedByIssues,
selectedGroup,
groupTitle,
createdBy,
bgColor,
openCreateIssueModal,
openIssuesListModal,
removeIssueFromModule,
partialUpdateIssue,
handleDeleteIssue,
setPreloadedData,
stateId,
}) => {
// Collapse/Expand
const [show, setState] = useState(true);
const { activeWorkspace } = useUser();
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<div className={`rounded flex-shrink-0 h-full ${!show ? "" : "w-80 bg-gray-50 border"}`}>
<div className={`${!show ? "" : "h-full space-y-3 overflow-y-auto flex flex-col"}`}>
<div
className={`flex justify-between p-3 pb-0 ${
!show ? "flex-col bg-gray-50 rounded-md border" : ""
}`}
>
<div
className={`w-full flex justify-between items-center ${
!show ? "flex-col gap-2" : "gap-1"
}`}
>
<div
className={`flex items-center gap-x-1 px-2 bg-slate-900 rounded-md cursor-pointer ${
!show ? "py-2 mb-2 flex-col gap-y-2" : ""
}`}
style={{
border: `2px solid ${bgColor}`,
backgroundColor: `${bgColor}20`,
}}
>
<h2
className={`text-[0.9rem] font-medium capitalize`}
style={{
writingMode: !show ? "vertical-rl" : "horizontal-tb",
}}
>
{groupTitle === null || groupTitle === "null"
? "None"
: createdBy
? createdBy
: addSpaceIfCamelCase(groupTitle)}
</h2>
<span className="text-gray-500 text-sm ml-0.5">
{groupedByIssues[groupTitle].length}
</span>
</div>
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
<div
className={`mt-3 space-y-3 h-full overflow-y-auto px-3 pb-3 ${
!show ? "hidden" : "block"
}`}
>
{groupedByIssues[groupTitle].map((childIssue, index: number) => {
const assignees = [
...(childIssue?.assignees_list ?? []),
...(childIssue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<SingleIssue
key={childIssue.id}
issue={childIssue}
properties={properties}
assignees={assignees}
people={people}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue}
/>
);
})}
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
className="mt-1"
optionsPosition="left"
withoutBorder
>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
);
};
export default SingleCycleBoard;

View File

@ -0,0 +1,147 @@
// react
import React, { useEffect, useRef, useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// services
import modulesService from "lib/services/modules.service";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// types
import type { IModule } from "types";
// fetch-keys
import { MODULE_LIST } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data?: IModule;
};
const ConfirmModuleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { activeWorkspace } = useUser();
const router = useRouter();
const cancelButtonRef = useRef(null);
const handleClose = () => {
setIsOpen(false);
setIsDeleteLoading(false);
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!activeWorkspace || !data) return;
await modulesService
.deleteModule(activeWorkspace.slug, data.project, data.id)
.then(() => {
mutate(MODULE_LIST(data.project));
router.push(`/projects/${data.project}/modules`);
handleClose();
})
.catch((error) => {
console.log(error);
setIsDeleteLoading(false);
});
};
useEffect(() => {
data && setIsOpen(true);
}, [data, setIsOpen]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog
as="div"
className="relative z-10"
initialFocus={cancelButtonRef}
onClose={handleClose}
>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Delete Module
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete module - {`"`}
<span className="italic">{data?.name}</span>
{`?"`} All of the data related to the module will be permanently removed.
This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<Button
type="button"
onClick={handleDeletion}
theme="danger"
disabled={isDeleteLoading}
className="inline-flex sm:ml-3"
>
{isDeleteLoading ? "Deleting..." : "Delete"}
</Button>
<Button
type="button"
theme="secondary"
className="inline-flex sm:ml-3"
onClick={handleClose}
ref={cancelButtonRef}
>
Cancel
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default ConfirmModuleDeletion;

View File

@ -17,6 +17,9 @@ import type { IModule } from "types";
import { renderDateFormat } from "constants/common";
// fetch keys
import { MODULE_LIST } from "constants/fetch-keys";
import SelectLead from "./select-lead";
import SelectMembers from "./select-members";
import SelectStatus from "./select-status";
type Props = {
isOpen: boolean;
@ -28,6 +31,9 @@ type Props = {
const defaultValues: Partial<IModule> = {
name: "",
description: "",
status: null,
lead: null,
members_list: [],
};
const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, projectId }) => {
@ -45,6 +51,7 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
register,
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
setError,
} = useForm<IModule>({
@ -140,7 +147,7 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<Dialog.Panel className="relative transform rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
@ -172,26 +179,6 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
register={register}
/>
</div>
<div>
<Select
id="status"
name="status"
label="Status"
error={errors.status}
register={register}
validations={{
required: "Status is required",
}}
options={[
{ label: "Backlog", value: "backlog" },
{ label: "Planned", value: "planned" },
{ label: "In Progress", value: "in-progress" },
{ label: "Paused", value: "paused" },
{ label: "Completed", value: "completed" },
{ label: "Cancelled", value: "cancelled" },
]}
/>
</div>
<div className="flex gap-x-2">
<div className="w-full">
<Input
@ -216,6 +203,11 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
/>
</div>
</div>
<div className="flex items-center flex-wrap gap-2">
<SelectStatus control={control} />
<SelectLead control={control} />
<SelectMembers control={control} />
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">

View File

@ -0,0 +1,61 @@
// react
import React from "react";
// swr
import useSWR from "swr";
// react hook form
import { Controller } from "react-hook-form";
import type { Control } from "react-hook-form";
// service
import projectServices from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { SearchListbox } from "ui";
// icons
import { UserIcon } from "@heroicons/react/24/outline";
// types
import type { IModule } from "types";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
control: Control<IModule, any>;
};
const SelectLead: React.FC<Props> = ({ control }) => {
const { activeWorkspace, activeProject } = useUser();
const { data: people } = useSWR(
activeWorkspace && activeProject ? PROJECT_MEMBERS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectServices.projectMembers(activeWorkspace.slug, activeProject.id)
: null
);
return (
<Controller
control={control}
name="lead"
render={({ field: { value, onChange } }) => (
<SearchListbox
title="Lead"
optionsFontsize="sm"
options={people?.map((person) => {
return {
value: person.member.id,
display:
person.member.first_name && person.member.first_name !== ""
? person.member.first_name
: person.member.email,
};
})}
value={value}
onChange={onChange}
icon={<UserIcon className="h-3 w-3 text-gray-500" />}
/>
)}
/>
);
};
export default SelectLead;

View File

@ -0,0 +1,62 @@
// react
import React from "react";
// swr
import useSWR from "swr";
// react hook form
import { Controller } from "react-hook-form";
import type { Control } from "react-hook-form";
// service
import projectServices from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { SearchListbox } from "ui";
// icons
import { UserIcon } from "@heroicons/react/24/outline";
// types
import type { IModule } from "types";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
control: Control<IModule, any>;
};
const SelectMembers: React.FC<Props> = ({ control }) => {
const { activeWorkspace, activeProject } = useUser();
const { data: people } = useSWR(
activeWorkspace && activeProject ? PROJECT_MEMBERS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectServices.projectMembers(activeWorkspace.slug, activeProject.id)
: null
);
return (
<Controller
control={control}
name="members_list"
render={({ field: { value, onChange } }) => (
<SearchListbox
title="Members"
optionsFontsize="sm"
options={people?.map((person) => {
return {
value: person.member.id,
display:
person.member.first_name && person.member.first_name !== ""
? person.member.first_name
: person.member.email,
};
})}
multiple={true}
value={value}
onChange={onChange}
icon={<UserIcon className="h-3 w-3 text-gray-500" />}
/>
)}
/>
);
};
export default SelectMembers;

View File

@ -0,0 +1,39 @@
// react
import React from "react";
// react hook form
import { Controller } from "react-hook-form";
import type { Control } from "react-hook-form";
// ui
import { CustomListbox } from "ui";
// icons
import { Squares2X2Icon } from "@heroicons/react/24/outline";
// types
import type { IModule } from "types";
import { MODULE_STATUS } from "constants/";
type Props = {
control: Control<IModule, any>;
};
const SelectStatus: React.FC<Props> = ({ control }) => {
return (
<Controller
control={control}
name="status"
render={({ field: { value, onChange } }) => (
<CustomListbox
title="State"
options={MODULE_STATUS.map((status) => {
return { value: status.value, display: status.label };
})}
value={value}
optionsFontsize="sm"
onChange={onChange}
icon={<Squares2X2Icon className="h-3 w-3 text-gray-400" />}
/>
)}
/>
);
};
export default SelectStatus;

View File

@ -0,0 +1,328 @@
// react
import React from "react";
// next
import Link from "next/link";
// swr
import useSWR from "swr";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { CustomMenu, Spinner } from "ui";
// icons
import { PlusIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
// fetch keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// constants
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
import workspaceService from "lib/services/workspace.service";
type Props = {
groupedByIssues: {
[key: string]: (IIssue & { bridge?: string })[];
};
properties: Properties;
selectedGroup: NestedKeyOf<IIssue> | null;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
removeIssueFromModule: (issueId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| undefined
>
>;
};
const ModulesListView: React.FC<Props> = ({
groupedByIssues,
selectedGroup,
openCreateIssueModal,
openIssuesListModal,
properties,
removeIssueFromModule,
handleDeleteIssue,
setPreloadedData,
}) => {
const { activeWorkspace, activeProject, states } = useUser();
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<div className="flex flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => {
const stateId =
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null;
return (
<Disclosure key={singleGroup} as="div" defaultOpen>
{({ open }) => (
<div className="bg-white rounded-lg">
<div className="bg-gray-100 px-4 py-3 rounded-t-lg">
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "transform -rotate-90" : ""}`}
/>
</span>
{selectedGroup !== null ? (
<h2 className="font-medium leading-5 capitalize">
{singleGroup === null || singleGroup === "null"
? selectedGroup === "priority" && "No priority"
: addSpaceIfCamelCase(singleGroup)}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<p className="text-gray-500 text-sm">
{groupedByIssues[singleGroup as keyof IIssue].length}
</p>
</div>
</Disclosure.Button>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="divide-y-2">
{groupedByIssues[singleGroup] ? (
groupedByIssues[singleGroup].length > 0 ? (
groupedByIssues[singleGroup].map((issue) => {
const assignees = [
...(issue?.assignees_list ?? []),
...(issue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find(
(p) => p.member.id === assignee
)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<div
key={issue.id}
className="px-4 py-3 text-sm rounded flex justify-between items-center gap-2"
>
<div className="flex items-center gap-2">
<span
className={`flex-shrink-0 h-1.5 w-1.5 block rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/projects/${activeProject?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
)}
<span>{issue.name}</span>
{/* <div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
<h5 className="font-medium mb-1">Name</h5>
<div>{issue.name}</div>
</div> */}
</a>
</Link>
</div>
<div className="flex-shrink-0 flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
{properties.priority && (
<div
className={`group relative flex-shrink-0 flex items-center gap-1 text-xs rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{/* {getPriorityIcon(issue.priority ?? "")} */}
{issue.priority ?? "None"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Priority</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</div>
)}
{properties.state && (
<div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issue?.state_detail?.color,
}}
></span>
{addSpaceIfCamelCase(issue?.state_detail.name)}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{issue?.state_detail.name}</div>
</div>
</div>
)}
{properties.start_date && (
<div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{issue.start_date
? renderShortNumericDateFormat(issue.start_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>
{renderShortNumericDateFormat(issue.start_date ?? "")}
</div>
</div>
</div>
)}
{properties.due_date && (
<div
className={`group relative flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 &&
"text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date
? renderShortNumericDateFormat(issue.target_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Due date</h5>
<div>
{renderShortNumericDateFormat(issue.target_date ?? "")}
</div>
<div>
{issue.target_date &&
(issue.target_date < new Date().toISOString()
? `Due date has passed by ${findHowManyDaysLeft(
issue.target_date
)} days`
: findHowManyDaysLeft(issue.target_date) <= 3
? `Due date is in ${findHowManyDaysLeft(
issue.target_date
)} days`
: "Due date")}
</div>
</div>
</div>
)}
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem
onClick={() => openCreateIssueModal(issue, "edit")}
>
Edit
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => removeIssueFromModule(issue.bridge ?? "")}
>
Remove from module
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => handleDeleteIssue(issue.id)}
>
Delete permanently
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
);
})
) : (
<p className="text-sm px-4 py-3 text-gray-500">No issues.</p>
)
) : (
<div className="h-full w-full flex items-center justify-center">
<Spinner />
</div>
)}
</div>
</Disclosure.Panel>
</Transition>
<div className="p-3">
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
optionsPosition="left"
withoutBorder
>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: singleGroup,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
)}
</Disclosure>
);
})}
</div>
);
};
export default ModulesListView;

View File

@ -0,0 +1,246 @@
// react
import { useEffect } from "react";
// swr
import useSWR, { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// services
import modulesService from "lib/services/modules.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// components
import SelectMembers from "components/project/modules/module-detail-sidebar/select-members";
import SelectStatus from "components/project/modules/module-detail-sidebar/select-status";
// ui
import { Spinner } from "ui";
// icons
import {
CalendarDaysIcon,
ClipboardDocumentIcon,
LinkIcon,
PlusIcon,
TrashIcon,
UserIcon,
} from "@heroicons/react/24/outline";
// types
import { IModule } from "types";
// fetch-keys
import { MODULE_DETAIL } from "constants/fetch-keys";
// common
import { copyTextToClipboard } from "constants/common";
const defaultValues: Partial<IModule> = {
members_list: [],
start_date: new Date().toString(),
target_date: new Date().toString(),
status: null,
};
type Props = {
module?: IModule;
isOpen: boolean;
handleDeleteModule: () => void;
};
const ModuleDetailSidebar: React.FC<Props> = ({ module, isOpen, handleDeleteModule }) => {
const { activeWorkspace, activeProject } = useUser();
const { setToastAlert } = useToast();
const { reset, watch, control } = useForm({
defaultValues,
});
const submitChanges = (data: Partial<IModule>) => {
if (!activeWorkspace || !activeProject || !module) return;
modulesService
.patchModule(activeWorkspace.slug, activeProject.id, module.id, data)
.then((res) => {
console.log(res);
mutate(MODULE_DETAIL);
})
.catch((e) => {
console.log(e);
});
};
useEffect(() => {
if (module)
reset({
...module,
members_list: module.members_list ?? module.members_detail?.map((member) => member.id),
});
}, [module, reset]);
return (
<>
<div
className={`fixed top-0 ${
isOpen ? "right-0" : "-right-[24rem]"
} z-30 bg-gray-50 border-l h-full p-5 w-[24rem] overflow-y-auto duration-300`}
>
{module ? (
<>
<div className="flex justify-between items-center pb-3">
<h4 className="text-sm font-medium">{module.name}</h4>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/projects/${activeProject?.id}/modules/${module.id}`
)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<LinkIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() =>
copyTextToClipboard(module.id)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<ClipboardDocumentIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-2 hover:bg-red-50 text-red-500 border border-red-500 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() => handleDeleteModule()}
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="divide-y-2 divide-gray-100 text-xs">
<div className="py-1">
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserIcon className="flex-shrink-0 h-4 w-4" />
<p>Lead</p>
</div>
<div className="sm:basis-1/2">
{module.lead_detail.first_name !== "" ? (
<>
{module.lead_detail.first_name} {module.lead_detail.last_name}
</>
) : (
module.lead_detail.email
)}
</div>
</div>
<SelectMembers control={control} submitChanges={submitChanges} />
</div>
<div className="py-1">
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<CalendarDaysIcon className="flex-shrink-0 h-4 w-4" />
<p>Start date</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<input
type="date"
id="issueDate"
value={value ?? ""}
onChange={onChange}
className="hover:bg-gray-100 bg-transparent border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 w-full"
/>
)}
/>
</div>
</div>
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<CalendarDaysIcon className="flex-shrink-0 h-4 w-4" />
<p>End date</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<input
type="date"
value={value ?? ""}
onChange={(e: any) => {
submitChanges({ target_date: e.target.value });
onChange(e.target.value);
}}
className="hover:bg-gray-100 bg-transparent border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 w-full"
/>
)}
/>
</div>
</div>
</div>
<div className="py-1">
<SelectStatus control={control} submitChanges={submitChanges} watch={watch} />
</div>
<div className="py-1">
<div className="flex justify-between items-center gap-2">
<h4>Links</h4>
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none"
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-2 space-y-2">
<div className="flex gap-2 border bg-gray-100 rounded-md p-2">
<div className="mt-0.5">
<LinkIcon className="h-3.5 w-3.5" />
</div>
<div>
<h5>Aaryan Khandelwal</h5>
<p className="text-gray-500 mt-0.5">
Added 2 days ago by aaryan.khandelwal@caravel.tech
</p>
</div>
</div>
</div>
</div>
</div>
</>
) : (
<div className="h-full w-full flex justify-center items-center">
<Spinner />
</div>
)}
</div>
</>
);
};
export default ModuleDetailSidebar;

View File

@ -0,0 +1,188 @@
// react
import React from "react";
// next
import Image from "next/image";
// swr
import useSWR from "swr";
// react-hook-form
import { Control, Controller } from "react-hook-form";
// services
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// ui
import { Spinner } from "ui";
// icons
import { UserGroupIcon } from "@heroicons/react/24/outline";
import User from "public/user.png";
// types
import { IModule } from "types";
// constants
import { classNames } from "constants/common";
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
type Props = {
control: Control<Partial<IModule>, any>;
submitChanges: (formData: Partial<IModule>) => void;
};
const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
const { activeWorkspace } = useUser();
const { data: people } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<UserGroupIcon className="flex-shrink-0 h-4 w-4" />
<p>Assignees</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="members_list"
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
multiple={true}
onChange={(value: any) => {
submitChanges({ members_list: value });
}}
className="flex-shrink-0"
>
{({ open }) => (
<div className="relative">
<Listbox.Button className="w-full flex items-center gap-1 text-xs cursor-pointer">
<span
className={classNames(
value ? "" : "text-gray-900",
"hidden truncate sm:block text-left"
)}
>
<div className="flex items-center gap-1 text-xs cursor-pointer">
{value && Array.isArray(value) ? (
<>
{value.length > 0 ? (
value.map((assignee, index: number) => {
const person = people?.find(
(p) => p.member.id === assignee
)?.member;
return (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
index !== 0 ? "-ml-2.5" : ""
}`}
>
{person && person.avatar && person.avatar !== "" ? (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={person.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={person.first_name}
/>
</div>
) : (
<div
className={`h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full capitalize`}
>
{person?.first_name && person.first_name !== ""
? person.first_name.charAt(0)
: person?.email.charAt(0)}
</div>
)}
</div>
);
})
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
</>
) : null}
</div>
</span>
</Listbox.Button>
<Transition
show={open}
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"
>
<Listbox.Options className="absolute z-10 left-0 mt-1 w-auto bg-white shadow-lg max-h-48 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="py-1">
{people ? (
people.length > 0 ? (
people.map((option) => (
<Listbox.Option
key={option.member.id}
className={({ active, selected }) =>
`${
active || selected ? "bg-indigo-50" : ""
} flex items-center gap-2 text-gray-900 cursor-pointer select-none p-2 truncate`
}
value={option.member.id}
>
{option.member.avatar && option.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<Image
src={option.member.avatar}
alt="avatar"
className="rounded-full"
layout="fill"
objectFit="cover"
/>
</div>
) : (
<div className="flex-shrink-0 h-4 w-4 bg-gray-700 text-white grid place-items-center capitalize rounded-full">
{option.member.first_name && option.member.first_name !== ""
? option.member.first_name.charAt(0)
: option.member.email.charAt(0)}
</div>
)}
{option.member.first_name && option.member.first_name !== ""
? option.member.first_name
: option.member.email}
</Listbox.Option>
))
) : (
<div className="text-center">No assignees found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
)}
/>
</div>
</div>
);
};
export default SelectMembers;

View File

@ -0,0 +1,63 @@
// react
import React from "react";
// react-hook-form
import { Control, Controller, UseFormWatch } from "react-hook-form";
// ui
import { CustomSelect } from "ui";
// icons
import { Squares2X2Icon } from "@heroicons/react/24/outline";
// types
import { IModule } from "types";
// common
import { classNames } from "constants/common";
// constants
import { MODULE_STATUS } from "constants/";
type Props = {
control: Control<Partial<IModule>, any>;
submitChanges: (formData: Partial<IModule>) => void;
watch: UseFormWatch<Partial<IModule>>;
};
const SelectStatus: React.FC<Props> = ({ control, submitChanges, watch }) => {
return (
<div className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<Squares2X2Icon className="flex-shrink-0 h-4 w-4" />
<p>Status</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="status"
render={({ field: { value } }) => (
<CustomSelect
label={
<span
className={classNames(
value ? "" : "text-gray-900",
"text-left capitalize flex items-center gap-2"
)}
>
{watch("status")}
</span>
}
value={value}
onChange={(value: any) => {
submitChanges({ status: value });
}}
>
{MODULE_STATUS.map((option) => (
<CustomSelect.Option key={option.value} value={option.value}>
{option.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
);
};
export default SelectStatus;

View File

@ -0,0 +1,102 @@
// next
import Image from "next/image";
import Link from "next/link";
// icons
import User from "public/user.png";
// types
import { IModule } from "types";
// common
import { renderShortNumericDateFormat } from "constants/common";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
type Props = {
module: IModule;
};
const SingleModuleCard: React.FC<Props> = ({ module }) => {
return (
<div key={module.id} className="border bg-white p-3 rounded-md">
<Link href={`/projects/${module.project}/modules/${module.id}`}>
<a>{module.name}</a>
</Link>
<div className="grid grid-cols-4 gap-2 text-xs mt-4">
<div className="space-y-2">
<h6 className="text-gray-500">LEAD</h6>
<div>
{module.lead_detail?.avatar && module.lead_detail.avatar !== "" ? (
<div className="h-5 w-5 border-2 border-white rounded-full">
<Image
src={module.lead_detail.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={module.lead_detail.first_name}
/>
</div>
) : (
<div className="h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full capitalize">
{module.lead_detail?.first_name && module.lead_detail.first_name !== ""
? module.lead_detail.first_name.charAt(0)
: module.lead_detail?.email.charAt(0)}
</div>
)}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">MEMBERS</h6>
<div className="flex items-center gap-1 text-xs">
{module.members && module.members.length > 0 ? (
module?.members_detail?.map((member, index: number) => (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${index !== 0 ? "-ml-2.5" : ""}`}
>
{member?.avatar && member.avatar !== "" ? (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={member.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={member?.first_name}
/>
</div>
) : (
<div className="h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full capitalize">
{member?.first_name && member.first_name !== ""
? member.first_name.charAt(0)
: member?.email?.charAt(0)}
</div>
)}
</div>
))
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">END DATE</h6>
<div className="flex items-center gap-1 border rounded shadow-sm px-1.5 py-0.5 cursor-pointer text-xs w-min whitespace-nowrap">
<CalendarDaysIcon className="h-3 w-3" />
{renderShortNumericDateFormat(module.target_date ?? "")}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">STATUS</h6>
<div className="capitalize">{module.status}</div>
</div>
</div>
</div>
);
};
export default SingleModuleCard;

View File

@ -141,6 +141,6 @@ export const MODULE_ISSUE_DETAIL = (
workspaceSlug: string,
projectId: string,
moduleId: string,
issueId: string
bridgeId: string
) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/${issueId}/`;
`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/${bridgeId}/`;

View File

@ -1,4 +1,6 @@
export const getPriorityIcon = (priority: string, className: string) => {
export const getPriorityIcon = (priority: string, className?: string) => {
if (!className || className === "") className = "text-xs";
switch (priority) {
case "urgent":
return <span className={`material-symbols-rounded ${className}`}>error</span>;
@ -13,6 +15,6 @@ export const getPriorityIcon = (priority: string, className: string) => {
<span className={`material-symbols-rounded ${className}`}>signal_cellular_alt_1_bar</span>
);
default:
return null;
return "None";
}
};

View File

@ -19,6 +19,15 @@ export const GROUP_CHOICES = {
cancelled: "Cancelled",
};
export const MODULE_STATUS = [
{ label: "Backlog", value: "backlog" },
{ label: "Planned", value: "planned" },
{ label: "In Progress", value: "in-progress" },
{ label: "Paused", value: "paused" },
{ label: "Completed", value: "completed" },
{ label: "Cancelled", value: "cancelled" },
];
export const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [
{ name: "State", key: "state_detail.name" },
{ name: "Priority", key: "priority" },

View File

@ -19,11 +19,13 @@ import {
PROJECT_ISSUES_LIST,
STATE_LIST,
CYCLE_LIST,
MODULE_LIST,
} from "constants/fetch-keys";
// types
import type { KeyedMutator } from "swr";
import type { IUser, IWorkspace, IProject, IssueResponse, ICycle, IState } from "types";
import type { IUser, IWorkspace, IProject, IssueResponse, ICycle, IState, IModule } from "types";
import modulesService from "lib/services/modules.service";
interface IUserContextProps {
user?: IUser;
@ -40,6 +42,8 @@ interface IUserContextProps {
mutateIssues: KeyedMutator<IssueResponse>;
cycles?: ICycle[];
mutateCycles: KeyedMutator<ICycle[]>;
modules?: IModule[];
mutateModules: KeyedMutator<IModule[]>;
states?: IState[];
mutateStates: KeyedMutator<IState[]>;
}
@ -99,6 +103,13 @@ export const UserProvider = ({ children }: { children: ReactElement }) => {
: null
);
const { data: modules, mutate: mutateModules } = useSWR<IModule[]>(
activeWorkspace && activeProject ? MODULE_LIST(activeProject.id) : null,
activeWorkspace && activeProject
? () => modulesService.getModules(activeWorkspace.slug, activeProject.id)
: null
);
useEffect(() => {
if (!projects) return;
const activeProject = projects.find((project) => project.id === projectId);
@ -143,6 +154,8 @@ export const UserProvider = ({ children }: { children: ReactElement }) => {
mutateIssues,
cycles,
mutateCycles,
modules,
mutateModules,
states,
mutateStates,
setActiveProject,

View File

@ -50,6 +50,16 @@ class ProjectIssuesServices extends APIService {
});
}
async getModuleDetails(workspaceSlug: string, projectId: string, moduleId: string): Promise<any> {
return this.get(MODULE_DETAIL(workspaceSlug, projectId, moduleId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async patchModule(
workspaceSlug: string,
projectId: string,
@ -85,11 +95,11 @@ class ProjectIssuesServices extends APIService {
});
}
async addIssueToModule(
async addIssuesToModule(
workspaceSlug: string,
projectId: string,
moduleId: string,
data: any
data: { issues: string[] }
): Promise<any> {
return this.post(MODULE_ISSUES(workspaceSlug, projectId, moduleId), data)
.then((response) => {
@ -104,9 +114,9 @@ class ProjectIssuesServices extends APIService {
workspaceSlug: string,
projectId: string,
moduleId: string,
issueId: string
bridgeId: string
): Promise<any> {
return this.delete(MODULE_ISSUE_DETAIL(workspaceSlug, projectId, moduleId, issueId))
return this.delete(MODULE_ISSUE_DETAIL(workspaceSlug, projectId, moduleId, bridgeId))
.then((response) => {
return response?.data;
})

View File

@ -243,11 +243,11 @@ const MyIssues: NextPage = () => {
{issue.project_detail.identifier}-{issue.sequence_id}
</span>
)} */}
<span className="">{issue.name}</span>
<div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
<span>{issue.name}</span>
{/* <div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
<h5 className="font-medium mb-1">Name</h5>
<div>{issue.name}</div>
</div>
</div> */}
</a>
</Link>
</div>

View File

@ -12,8 +12,8 @@ import AppLayout from "layouts/app-layout";
import CyclesListView from "components/project/cycles/list-view";
import CyclesBoardView from "components/project/cycles/board-view";
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
import CycleIssuesListModal from "components/project/cycles/cycle-issues-list-modal";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
import ExistingIssuesListModal from "components/common/existing-issues-list-modal";
// constants
import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/";
// services
@ -67,7 +67,7 @@ const SingleCycle: React.FC = () => {
: null
);
const cycleIssuesArray = cycleIssues?.map((issue) => {
return { bridge: issue.id, ...issue.issue_details };
return { bridge: issue.id, ...issue.issue_detail };
});
const { data: members } = useSWR(
@ -163,6 +163,20 @@ const SingleCycle: React.FC = () => {
// console.log(result);
};
const handleAddIssuesToCycle = (data: { issues: string[] }) => {
if (activeWorkspace && activeProject) {
issuesServices
.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId as string, data)
.then((res) => {
console.log(res);
mutate(CYCLE_ISSUES(cycleId as string));
})
.catch((e) => {
console.log(e);
});
}
};
const removeIssueFromCycle = (bridgeId: string) => {
if (activeWorkspace && activeProject) {
mutate<CycleIssueResponse[]>(
@ -191,11 +205,12 @@ const SingleCycle: React.FC = () => {
setIsOpen={setIsIssueModalOpen}
projectId={activeProject?.id}
/>
<CycleIssuesListModal
<ExistingIssuesListModal
isOpen={cycleIssuesListModal}
handleClose={() => setCycleIssuesListModal(false)}
issues={issues}
cycleId={cycleId as string}
type="cycle"
issues={issues?.results ?? []}
handleOnSubmit={handleAddIssuesToCycle}
/>
<ConfirmIssueDeletion
handleClose={() => setDeleteIssue(undefined)}
@ -375,23 +390,22 @@ const SingleCycle: React.FC = () => {
openCreateIssueModal={openCreateIssueModal}
openIssuesListModal={openIssuesListModal}
removeIssueFromCycle={removeIssueFromCycle}
handleDeleteIssue={setDeleteIssue}
setPreloadedData={setPreloadedData}
/>
) : (
<div className="h-screen">
<CyclesBoardView
groupedByIssues={groupedByIssues}
properties={properties}
removeIssueFromCycle={removeIssueFromCycle}
selectedGroup={groupByProperty}
members={members}
openCreateIssueModal={openCreateIssueModal}
openIssuesListModal={openIssuesListModal}
handleDeleteIssue={setDeleteIssue}
partialUpdateIssue={partialUpdateIssue}
setPreloadedData={setPreloadedData}
/>
</div>
<CyclesBoardView
groupedByIssues={groupedByIssues}
properties={properties}
removeIssueFromCycle={removeIssueFromCycle}
selectedGroup={groupByProperty}
members={members}
openCreateIssueModal={openCreateIssueModal}
openIssuesListModal={openIssuesListModal}
handleDeleteIssue={setDeleteIssue}
partialUpdateIssue={partialUpdateIssue}
setPreloadedData={setPreloadedData}
/>
)}
</AppLayout>
</>

View File

@ -334,6 +334,7 @@ const ProjectIssues: NextPage = () => {
/>
</div>
)}
<div></div>
</>
) : (
<div className="h-full w-full grid place-items-center px-4 sm:px-0">

View File

@ -0,0 +1,407 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR, { mutate } from "swr";
// services
import modulesService from "lib/services/modules.service";
import projectService from "lib/services/project.service";
import issuesService from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useIssuesFilter from "lib/hooks/useIssuesFilter";
import useIssuesProperties from "lib/hooks/useIssuesProperties";
// layouts
import AppLayout from "layouts/app-layout";
// components
import ExistingIssuesListModal from "components/common/existing-issues-list-modal";
import ModulesBoardView from "components/project/modules/board-view";
import ModulesListView from "components/project/modules/list-view";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
import ModuleDetailSidebar from "components/project/modules/module-detail-sidebar";
import ConfirmModuleDeletion from "components/project/modules/confirm-module-deleteion";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// ui
import { BreadcrumbItem, Breadcrumbs, CustomMenu } from "ui";
// icons
import {
ArrowLeftIcon,
ArrowPathIcon,
ChevronDownIcon,
ListBulletIcon,
} from "@heroicons/react/24/outline";
import { Squares2X2Icon } from "@heroicons/react/20/solid";
// types
import { IIssue, IModule, ModuleIssueResponse, Properties, SelectModuleType } from "types";
// fetch-keys
import { MODULE_DETAIL, MODULE_ISSUES, PROJECT_MEMBERS } from "constants/fetch-keys";
// common
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
// constants
import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/";
const SingleModule = () => {
const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false);
const [deleteIssue, setDeleteIssue] = useState<string | undefined>(undefined);
const [moduleSidebar, setModuleSidebar] = useState(false);
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
const [selectedModuleForDelete, setSelectedModuleForDelete] = useState<SelectModuleType>();
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined);
const { activeWorkspace, activeProject, issues, modules } = useUser();
const router = useRouter();
const { moduleId } = router.query;
const [properties, setProperties] = useIssuesProperties(
activeWorkspace?.slug,
activeProject?.id as string
);
const { data: moduleIssues } = useSWR<ModuleIssueResponse[]>(
activeWorkspace && activeProject && moduleId ? MODULE_ISSUES(moduleId as string) : null,
activeWorkspace && activeProject && moduleId
? () =>
modulesService.getModuleIssues(
activeWorkspace?.slug,
activeProject?.id,
moduleId as string
)
: null
);
const moduleIssuesArray = moduleIssues?.map((issue) => {
return { bridge: issue.id, ...issue.issue_detail };
});
const { data: moduleDetail } = useSWR<IModule>(
MODULE_DETAIL,
activeWorkspace && activeProject && moduleId
? () =>
modulesService.getModuleDetails(
activeWorkspace?.slug,
activeProject?.id,
moduleId as string
)
: null
);
const {
issueView,
groupByProperty,
setGroupByProperty,
groupedByIssues,
setOrderBy,
setFilterIssue,
orderBy,
filterIssue,
setIssueViewToKanban,
setIssueViewToList,
} = useIssuesFilter(moduleIssuesArray ?? []);
const { data: members } = useSWR(
activeWorkspace && activeProject ? PROJECT_MEMBERS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectService.projectMembers(activeWorkspace.slug, activeProject.id)
: null,
{
onErrorRetry(err, _, __, revalidate, revalidateOpts) {
if (err?.status === 403) return;
setTimeout(() => revalidate(revalidateOpts), 5000);
},
}
);
const handleAddIssuesToModule = (data: { issues: string[] }) => {
if (activeWorkspace && activeProject) {
modulesService
.addIssuesToModule(activeWorkspace.slug, activeProject.id, moduleId as string, data)
.then((res) => {
console.log(res);
})
.catch((e) => console.log(e));
}
};
const partialUpdateIssue = (formData: Partial<IIssue>, issueId: string) => {
if (!activeWorkspace || !activeProject) return;
issuesService
.patchIssue(activeWorkspace.slug, activeProject.id, issueId, formData)
.then((response) => {
mutate(MODULE_ISSUES(moduleId as string));
})
.catch((error) => {
console.log(error);
});
};
const openCreateIssueModal = () => {};
const openIssuesListModal = () => {
setModuleIssuesListModal(true);
};
const removeIssueFromModule = (issueId: string) => {
if (!activeWorkspace || !activeProject) return;
modulesService
.removeIssueFromModule(activeWorkspace.slug, activeProject.id, moduleId as string, issueId)
.then((res) => {
console.log(res);
mutate(MODULE_ISSUES(moduleId as string));
})
.catch((e) => {
console.log(e);
});
};
const handleDeleteModule = () => {
if (!moduleDetail) return;
setSelectedModuleForDelete({ ...moduleDetail, actionType: "delete" });
setModuleDeleteModal(true);
};
return (
<>
<ExistingIssuesListModal
isOpen={moduleIssuesListModal}
handleClose={() => setModuleIssuesListModal(false)}
type="module"
issues={issues?.results ?? []}
handleOnSubmit={handleAddIssuesToModule}
/>
<ConfirmIssueDeletion
handleClose={() => setDeleteIssue(undefined)}
isOpen={!!deleteIssue}
data={issues?.results.find((issue) => issue.id === deleteIssue)}
/>
<ConfirmModuleDeletion
isOpen={
moduleDeleteModal &&
!!selectedModuleForDelete &&
selectedModuleForDelete.actionType === "delete"
}
setIsOpen={setModuleDeleteModal}
data={selectedModuleForDelete}
/>
<AppLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${activeProject?.name ?? "Project"} Modules`}
link={`/projects/${activeProject?.id}/cycles`}
/>
</Breadcrumbs>
}
left={
<CustomMenu
label={
<>
<ArrowPathIcon className="h-3 w-3" />
{modules?.find((c) => c.id === moduleId)?.name}
</>
}
className="ml-1.5"
width="auto"
>
{modules?.map((module) => (
<CustomMenu.MenuItem
key={module.id}
renderAs="a"
href={`/projects/${activeProject?.id}/modules/${module.id}`}
>
{module.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
}
right={
<div
className={`flex items-center gap-2 ${moduleSidebar ? "mr-[24rem]" : ""} duration-300`}
>
<div className="flex items-center gap-x-1">
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none ${
issueView === "list" ? "bg-gray-100" : ""
}`}
onClick={() => setIssueViewToList()}
>
<ListBulletIcon className="h-4 w-4" />
</button>
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none ${
issueView === "kanban" ? "bg-gray-100" : ""
}`}
onClick={() => setIssueViewToKanban()}
>
<Squares2X2Icon className="h-4 w-4" />
</button>
</div>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={classNames(
open ? "bg-gray-100 text-gray-900" : "text-gray-500",
"group flex gap-2 items-center rounded-md bg-transparent text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none border p-2"
)}
>
<span>View</span>
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform p-3 bg-white rounded-lg shadow-lg overflow-hidden">
<div className="relative flex flex-col gap-1 gap-y-4">
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Group by</h4>
<CustomMenu
label={
groupByOptions.find((option) => option.key === groupByProperty)
?.name ?? "Select"
}
width="auto"
>
{groupByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Order by</h4>
<CustomMenu
label={
orderByOptions.find((option) => option.key === orderBy)?.name ??
"Select"
}
width="auto"
>
{orderByOptions.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setOrderBy(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Issue type</h4>
<CustomMenu
label={
filterIssueOptions.find((option) => option.key === filterIssue)
?.name ?? "Select"
}
width="auto"
>
{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 flex flex-col gap-1">
<h4 className="text-base text-gray-600">Properties</h4>
<div className="flex items-center gap-2 flex-wrap">
{Object.keys(properties).map((key) => (
<button
key={key}
type="button"
className={`px-2 py-1 capitalize rounded border border-theme text-xs ${
properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: ""
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{replaceUnderscoreIfSnakeCase(key)}
</button>
))}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none ${
moduleSidebar ? "rotate-180" : ""
}`}
onClick={() => setModuleSidebar((prevData) => !prevData)}
>
<ArrowLeftIcon className="h-4 w-4" />
</button>
</div>
}
>
<div className={`h-full ${moduleSidebar ? "mr-[28rem]" : ""} duration-300`}>
{issueView === "list" ? (
<ModulesListView
groupedByIssues={groupedByIssues}
selectedGroup={groupByProperty}
properties={properties}
openCreateIssueModal={openCreateIssueModal}
openIssuesListModal={openIssuesListModal}
removeIssueFromModule={removeIssueFromModule}
handleDeleteIssue={setDeleteIssue}
setPreloadedData={setPreloadedData}
/>
) : (
<ModulesBoardView
groupedByIssues={groupedByIssues}
properties={properties}
removeIssueFromModule={removeIssueFromModule}
selectedGroup={groupByProperty}
members={members}
openCreateIssueModal={openCreateIssueModal}
openIssuesListModal={openIssuesListModal}
handleDeleteIssue={setDeleteIssue}
partialUpdateIssue={partialUpdateIssue}
setPreloadedData={setPreloadedData}
/>
)}
</div>
<ModuleDetailSidebar
module={moduleDetail}
isOpen={moduleSidebar}
handleDeleteModule={handleDeleteModule}
/>
</AppLayout>
</>
);
};
export default SingleModule;

View File

@ -10,6 +10,8 @@ import withAuth from "lib/hoc/withAuthWrapper";
import modulesService from "lib/services/modules.service";
// hooks
import useUser from "lib/hooks/useUser";
// components
import SingleModuleCard from "components/project/modules/single-module-card";
// ui
import { BreadcrumbItem, Breadcrumbs, EmptySpace, EmptySpaceItem, HeaderButton, Spinner } from "ui";
// icons
@ -17,7 +19,7 @@ import { PlusIcon, RectangleGroupIcon } from "@heroicons/react/24/outline";
// types
import { IModule } from "types/modules";
// fetch-keys
import { MODULE_LIST } from "constants/fetch-keys";
import { MODULE_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
const ProjectModules: NextPage = () => {
const { activeWorkspace, activeProject } = useUser();
@ -62,12 +64,11 @@ const ProjectModules: NextPage = () => {
{modules ? (
modules.length > 0 ? (
<div className="space-y-5">
{modules.map((module) => (
<div key={module.id} className="bg-white p-3 rounded-md">
<h3>{module.name}</h3>
<p className="text-gray-500 text-sm mt-2">{module.description}</p>
</div>
))}
<div className="grid grid-cols-3 gap-2">
{modules.map((module) => (
<SingleModuleCard key={module.id} module={module} />
))}
</div>
</div>
) : (
<div className="w-full h-full flex flex-col justify-center items-center px-4">

View File

@ -119,150 +119,160 @@ const ControlSettings = () => {
<h3 className="text-3xl font-bold leading-6 text-gray-900">Control</h3>
<p className="mt-4 text-sm text-gray-500">Set the control for the project.</p>
</div>
<div className="grid grid-cols-2 gap-16">
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Project Lead</h4>
<p className="text-sm text-gray-500 mb-3">Select the project leader.</p>
<Controller
control={control}
name="project_lead"
render={({ field: { onChange, value } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="relative w-full flex justify-between items-center gap-4 border border-gray-300 rounded-md shadow-sm p-3 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">
{people?.find((person) => person.member.id === value)?.member
.first_name ?? "Select Lead"}
</span>
<ChevronDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</Listbox.Button>
<div className="grid grid-cols-12 gap-16">
<div className="col-span-5 space-y-16">
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Project Lead</h4>
<p className="text-sm text-gray-500 mb-3">Select the project leader.</p>
<Controller
control={control}
name="project_lead"
render={({ field: { onChange, value } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="relative w-full flex justify-between items-center gap-4 border border-gray-300 rounded-md shadow-sm p-3 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">
{people?.find((person) => person.member.id === value)?.member
.first_name ?? "Select Lead"}
</span>
<ChevronDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-20 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`${
active ? "bg-indigo-50" : ""
} text-gray-900 cursor-default select-none relative px-3 py-2`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</span>
{selected ? (
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-20 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`${
active ? "bg-indigo-50" : ""
} text-gray-900 cursor-default select-none relative px-3 py-2`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-theme"
}`}
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
{person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-theme"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
</div>
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Default Assignee</h4>
<p className="text-sm text-gray-500 mb-3">
Select the default assignee for the project.
</p>
<Controller
control={control}
name="default_assignee"
render={({ field: { value, onChange } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="relative w-full flex justify-between items-center gap-4 border border-gray-300 rounded-md shadow-sm p-3 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">
{people?.find((p) => p.member.id === value)?.member.first_name ??
"Select Default Assignee"}
</span>
<ChevronDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</Listbox.Button>
<div className="col-span-5 space-y-16">
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Default Assignee</h4>
<p className="text-sm text-gray-500 mb-3">
Select the default assignee for the project.
</p>
<Controller
control={control}
name="default_assignee"
render={({ field: { value, onChange } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<div className="relative">
<Listbox.Button className="relative w-full flex justify-between items-center gap-4 border border-gray-300 rounded-md shadow-sm p-3 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">
{people?.find((p) => p.member.id === value)?.member.first_name ??
"Select Default Assignee"}
</span>
<ChevronDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-20 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`${
active ? "bg-indigo-50" : ""
} text-gray-900 cursor-default select-none relative px-3 py-2`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</span>
{selected ? (
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-20 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`${
active ? "bg-indigo-50" : ""
} text-gray-900 cursor-default select-none relative px-3 py-2`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-theme"
}`}
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
{person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-theme"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
</div>
</div>
<div>

View File

@ -136,97 +136,101 @@ const GeneralSettings = () => {
This information will be displayed to every member of the project.
</p>
</div>
<div className="grid grid-cols-2 gap-16">
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Icon & Name</h4>
<p className="text-sm text-gray-500 mb-3">
Select an icon and a name for the project.
</p>
<div className="flex gap-2">
<Controller
control={control}
name="icon"
render={({ field: { value, onChange } }) => (
<EmojiIconPicker
label={value ? String.fromCodePoint(parseInt(value)) : "Icon"}
value={value}
onChange={onChange}
/>
)}
/>
<div className="grid grid-cols-12 gap-16">
<div className="col-span-5 space-y-16">
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Icon & Name</h4>
<p className="text-sm text-gray-500 mb-3">
Select an icon and a name for the project.
</p>
<div className="flex gap-2">
<Controller
control={control}
name="icon"
render={({ field: { value, onChange } }) => (
<EmojiIconPicker
label={value ? String.fromCodePoint(parseInt(value)) : "Icon"}
value={value}
onChange={onChange}
/>
)}
/>
<Input
id="name"
name="name"
error={errors.name}
register={register}
placeholder="Project Name"
size="lg"
className="w-auto"
validations={{
required: "Name is required",
}}
/>
</div>
</div>
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Identifier</h4>
<p className="text-sm text-gray-500 mb-3">
Create a 1-6 characters{"'"} identifier for the project.
</p>
<Input
id="name"
name="name"
error={errors.name}
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Project Name"
placeholder="Enter identifier"
className="w-40"
size="lg"
className="w-auto"
onChange={(e: any) => {
if (!activeWorkspace || !e.target.value) return;
checkIdentifierAvailability(activeWorkspace.slug, e.target.value);
}}
validations={{
required: "Name is required",
required: "Identifier is required",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 9,
message: "Identifier must at most be of 9 characters",
},
}}
/>
</div>
</div>
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Description</h4>
<p className="text-sm text-gray-500 mb-3">Give a description to the project.</p>
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
placeholder="Enter project description"
validations={{}}
/>
</div>
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Identifier</h4>
<p className="text-sm text-gray-500 mb-3">
Create a 1-6 characters{"'"} identifier for the project.
</p>
<Input
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Enter identifier"
className="w-40"
size="lg"
onChange={(e: any) => {
if (!activeWorkspace || !e.target.value) return;
checkIdentifierAvailability(activeWorkspace.slug, e.target.value);
}}
validations={{
required: "Identifier is required",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 9,
message: "Identifier must at most be of 9 characters",
},
}}
/>
</div>
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Network</h4>
<p className="text-sm text-gray-500 mb-3">Select privacy type for the project.</p>
<Select
name="network"
id="network"
options={Object.keys(NETWORK_CHOICES).map((key) => ({
value: key,
label: NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES],
}))}
size="lg"
register={register}
validations={{
required: "Network is required",
}}
className="w-40"
/>
<div className="col-span-5 space-y-16">
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Description</h4>
<p className="text-sm text-gray-500 mb-3">Give a description to the project.</p>
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
placeholder="Enter project description"
validations={{}}
/>
</div>
<div>
<h4 className="text-md leading-6 text-gray-900 mb-1">Network</h4>
<p className="text-sm text-gray-500 mb-3">Select privacy type for the project.</p>
<Select
name="network"
id="network"
options={Object.keys(NETWORK_CHOICES).map((key) => ({
value: key,
label: NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES],
}))}
size="lg"
register={register}
validations={{
required: "Network is required",
}}
className="w-40"
/>
</div>
</div>
</div>
<div>

View File

@ -26,7 +26,7 @@ import {
UserIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
import { EmptySpace, EmptySpaceItem } from "ui/empty-space";
const WorkspaceInvitation: NextPage = () => {
const router = useRouter();

View File

@ -1,26 +1,27 @@
// next
import type { NextPage } from "next";
import Link from "next/link";
// react
import React from "react";
// layouts
import AppLayout from "layouts/app-layout";
// next
import Link from "next/link";
import type { NextPage } from "next";
// swr
import useSWR from "swr";
// services
import userService from "lib/services/user.service";
// hooks
import useUser from "lib/hooks/useUser";
// hoc
import withAuthWrapper from "lib/hoc/withAuthWrapper";
// fetch keys
import { USER_ISSUE } from "constants/fetch-keys";
// services
import userService from "lib/services/user.service";
// layouts
import AppLayout from "layouts/app-layout";
// ui
import { Spinner } from "ui";
// icons
import { ArrowRightIcon, CalendarDaysIcon } from "@heroicons/react/24/outline";
// types
import type { IIssue } from "types";
// fetch-keys
import { USER_ISSUE } from "constants/fetch-keys";
// common
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
@ -112,10 +113,6 @@ const Workspace: NextPage = () => {
</span>
)} */}
<span className="">{issue.name}</span>
<div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
<h5 className="font-medium mb-1">Name</h5>
<div>{issue.name}</div>
</div>
</a>
</Link>
</div>

View File

@ -19,7 +19,7 @@ export interface ICycle {
export interface CycleIssueResponse {
id: string;
issue_details: IIssue;
issue_detail: IIssue;
created_at: Date;
updated_at: Date;
created_by: string;

View File

@ -1,4 +1,4 @@
import type { IUser, IIssue, IProject } from ".";
import type { IUser, IUserLite, IIssue, IProject } from ".";
export interface IModule {
created_at: Date;
@ -9,13 +9,29 @@ export interface IModule {
id: string;
lead: string | null;
lead_detail: IUserLite;
members: string[];
members_list: string[];
members_detail: IUserLite[];
name: string;
project: string;
project_detail: IProject;
start_date: Date | null;
status: "backlog" | "planned" | "in-progress" | "paused" | "completed" | "cancelled";
target_date: Date | null;
start_date: string | null;
status: "backlog" | "planned" | "in-progress" | "paused" | "completed" | "cancelled" | null;
target_date: string | null;
updated_at: Date;
updated_by: string;
workspace: string;
}
export interface ModuleIssueResponse {
created_at: Date;
created_by: string;
id: string;
issue: string;
issue_detail: IIssue;
module: string;
module_detail: IModule;
project: string;
updated_at: Date;
updated_by: string;
workspace: string;

View File

@ -20,7 +20,7 @@ const CustomListbox: React.FC<Props> = ({
label,
}) => {
return (
<Listbox value={value} onChange={onChange} multiple={multiple}>
<Listbox as="div" className="relative" value={value} onChange={onChange} multiple={multiple}>
{({ open }) => (
<>
{label && (
@ -28,9 +28,42 @@ const CustomListbox: React.FC<Props> = ({
<div className="text-gray-500 mb-2">{label}</div>
</Listbox.Label>
)}
<div className="relative">
<Listbox.Button
className={`flex items-center gap-1 hover:bg-gray-100 text-xs relative border rounded-md shadow-sm cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300 ${
<Listbox.Button
className={`flex items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
width === "sm"
? "w-32"
: width === "md"
? "w-48"
: width === "lg"
? "w-64"
: width === "xl"
? "w-80"
: width === "2xl"
? "w-96"
: width === "w-full"
? "w-full"
: ""
}
${className || "px-2 py-1"}`}
>
{icon ?? null}
<span className="block truncate">
{Array.isArray(value)
? value.map((v) => options?.find((o) => o.value === v)?.display).join(", ") ||
`${title}`
: options?.find((o) => o.value === value)?.display || `${title}`}
</span>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className={`absolute mt-1 bg-white shadow-lg max-h-32 overflow-auto ${
width === "sm"
? "w-32"
: width === "md"
@ -44,122 +77,87 @@ const CustomListbox: React.FC<Props> = ({
: width === "w-full"
? "w-full"
: ""
}
${className || "px-2 py-1"}`}
} ${
optionsFontsize === "sm"
? "text-xs"
: optionsFontsize === "md"
? "text-base"
: optionsFontsize === "lg"
? "text-lg"
: optionsFontsize === "xl"
? "text-xl"
: optionsFontsize === "2xl"
? "text-2xl"
: ""
} rounded-md py-1 ring-1 ring-black ring-opacity-5 focus:outline-none z-10`}
>
{icon ?? null}
<span className="block truncate text-xs">
{Array.isArray(value)
? value.map((v) => options?.find((o) => o.value === v)?.display).join(", ") ||
`${title}`
: options?.find((o) => o.value === value)?.display || `${title}`}
</span>
</Listbox.Button>
<div className="py-1">
{options ? (
options.length > 0 ? (
options.map((option) => (
<Listbox.Option
key={option.value}
className={({ active }) =>
`${
active ? "bg-indigo-50" : ""
} text-gray-900 cursor-pointer select-none relative p-2`
}
value={option.value}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ||
(Array.isArray(value)
? value.includes(option.value)
: value === option.value)
? "font-semibold"
: "font-normal"
} flex items-center gap-2 truncate`}
>
{option.color && (
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: option.color,
}}
></span>
)}
{option.display}
</span>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className={`absolute mt-1 bg-white shadow-lg max-h-32 overflow-auto ${
width === "sm"
? "w-32"
: width === "md"
? "w-48"
: width === "lg"
? "w-64"
: width === "xl"
? "w-80"
: width === "2xl"
? "w-96"
: width === "w-full"
? "w-full"
: ""
} ${
optionsFontsize === "sm"
? "text-xs"
: optionsFontsize === "md"
? "text-base"
: optionsFontsize === "lg"
? "text-lg"
: optionsFontsize === "xl"
? "text-xl"
: optionsFontsize === "2xl"
? "text-2xl"
: ""
} rounded-md py-1 ring-1 ring-black ring-opacity-5 focus:outline-none z-10`}
>
<div className="py-1">
{options ? (
options.length > 0 ? (
options.map((option) => (
<Listbox.Option
key={option.value}
className={({ active }) =>
`${
active ? "bg-indigo-50" : ""
} text-gray-900 cursor-pointer select-none relative p-2`
}
value={option.value}
>
{({ selected, active }) => (
<>
{selected ||
(Array.isArray(value)
? value.includes(option.value)
: value === option.value) ? (
<span
className={`${
selected ||
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ||
(Array.isArray(value)
? value.includes(option.value)
: value === option.value)
? "font-semibold"
: "font-normal"
} flex items-center gap-2 truncate`}
? "text-white"
: "text-theme"
}`}
>
{option.color && (
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: option.color,
}}
></span>
)}
{option.display}
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
{selected ||
(Array.isArray(value)
? value.includes(option.value)
: value === option.value) ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ||
(Array.isArray(value)
? value.includes(option.value)
: value === option.value)
? "text-white"
: "text-theme"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))
) : (
<p className="text-sm text-gray-500 text-center">No options</p>
)
) : null}
</>
)}
</Listbox.Option>
))
) : (
<p className="text-sm text-gray-500 text-center">Loading...</p>
)}
</div>
{footerOption ?? null}
</Listbox.Options>
</Transition>
</div>
<p className="text-sm text-gray-500 text-center">No options</p>
)
) : (
<p className="text-sm text-gray-500 text-center">Loading...</p>
)}
</div>
{footerOption ?? null}
</Listbox.Options>
</Transition>
</>
)}
</Listbox>

View File

@ -24,7 +24,7 @@ const EmptySpace: React.FC<EmptySpaceProps> = ({ title, description, children, I
) : null}
<h2 className="text-lg font-medium text-gray-900">{title}</h2>
<p className="mt-1 text-sm text-gray-500">{description}</p>
<div className="mt-1 text-sm text-gray-500">{description}</div>
<ul role="list" className="mt-6 divide-y divide-gray-200 border-t border-b border-gray-200">
{children}
</ul>
@ -80,7 +80,7 @@ const EmptySpaceItem: React.FC<EmptySpaceItemProps> = ({
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900">{title}</div>
{description ? <p className="text-sm text-gray-500">{description}</p> : null}
{description ? <div className="text-sm text-gray-500">{description}</div> : null}
</div>
<div className="flex-shrink-0 self-center">
<ChevronRightIcon

View File

@ -10,5 +10,5 @@ export { default as Tooltip } from "./Tooltip";
export { default as SearchListbox } from "./search-listbox";
export { default as HeaderButton } from "./HeaderButton";
export * from "./Breadcrumbs";
export * from "./EmptySpace";
export * from "./empty-space";
export { default as EmojiIconPicker } from "./emoji-icon-picker";

View File

@ -44,7 +44,7 @@ const SearchListbox: React.FC<Props> = ({
<>
<Combobox.Label className="sr-only">{title}</Combobox.Label>
<Combobox.Button
className={`flex items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300 ${
className={`flex items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
buttonClassName || ""
}`}
>