Merge branch 'master' of github.com:makeplane/plane into build/merge_frontend_backend

This commit is contained in:
pablohashescobar 2022-12-12 22:22:46 +05:30
commit ef216ca714
60 changed files with 3064 additions and 1182 deletions

View File

@ -29,7 +29,7 @@ Coming soon.
The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects. The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects.
To chat with other community members you can join the [Plane Discord](https://discord.com/invite/8SR2N9PAcJ). To chat with other community members you can join the [Plane Discord](https://discord.com/invite/q9HKAdau).
Our Code of Conduct applies to all Plane community channels. Our Code of Conduct applies to all Plane community channels.

View File

@ -1,3 +1,4 @@
// react
import React, { useState } from "react"; import React, { useState } from "react";
// swr // swr
import { mutate } from "swr"; import { mutate } from "swr";
@ -5,23 +6,23 @@ import { mutate } from "swr";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// headless ui // headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react"; import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import issuesServices from "lib/services/issues.services";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// icons // icons
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { RectangleStackIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { FolderIcon } from "@heroicons/react/24/outline";
// commons // commons
import { classNames } from "constants/common"; import { classNames } from "constants/common";
// types // types
import { IIssue, IssueResponse } from "types"; import { IIssue, IssueResponse } from "types";
import { Button } from "ui"; // constants
import { PROJECT_ISSUES_DETAILS, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
import issuesServices from "lib/services/issues.services";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
parentId: string; parent: IIssue | undefined;
}; };
type FormInput = { type FormInput = {
@ -29,7 +30,7 @@ type FormInput = {
cycleId: string; cycleId: string;
}; };
const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parentId }) => { const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const { activeWorkspace, activeProject, issues } = useUser(); const { activeWorkspace, activeProject, issues } = useUser();
@ -41,24 +42,19 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parentId }) => {
[]; [];
const { const {
register,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
handleSubmit,
control,
reset, reset,
setError,
} = useForm<FormInput>(); } = useForm<FormInput>();
const handleCommandPaletteClose = () => { const handleCommandPaletteClose = () => {
setIsOpen(false); setIsOpen(false);
setQuery(""); setQuery("");
reset();
}; };
const addAsSubIssue = (issueId: string) => { const addAsSubIssue = (issueId: string) => {
if (activeWorkspace && activeProject) { if (activeWorkspace && activeProject) {
issuesServices issuesServices
.patchIssue(activeWorkspace.slug, activeProject.id, issueId, { parent: parentId }) .patchIssue(activeWorkspace.slug, activeProject.id, issueId, { parent: parent?.id })
.then((res) => { .then((res) => {
mutate<IssueResponse>( mutate<IssueResponse>(
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id), PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
@ -78,118 +74,113 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parentId }) => {
}; };
return ( return (
<> <Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear> <Dialog as="div" className="relative z-10" onClose={handleCommandPaletteClose}>
<Dialog as="div" className="relative z-10" onClose={handleCommandPaletteClose}> <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 <Transition.Child
as={React.Fragment} as={React.Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0 scale-95"
enterTo="opacity-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0" leaveTo="opacity-0 scale-95"
> >
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" /> <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">
</Transition.Child> <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={(e) => setQuery(e.target.value)}
/>
</div>
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20"> <Combobox.Options
<Transition.Child static
as={React.Fragment} className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
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">
<Combobox
// onChange={(item: ItemType) => {
// const { url, onClick } = item;
// if (url) router.push(url);
// else if (onClick) onClick();
// handleCommandPaletteClose();
// }}
> >
<div className="relative m-1"> {filteredIssues.length > 0 && (
<MagnifyingGlassIcon <>
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40" <li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Issues
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => {
if (
(issue.parent === "" || issue.parent === null) && // issue does not have any other parent
issue.id !== parent?.id && // issue is not itself
issue.id !== parent?.parent // issue is not it's parent
)
return (
<Combobox.Option
key={issue.id}
value={{
name: issue.name,
}}
className={({ active }) =>
classNames(
"flex items-center gap-2 cursor-pointer select-none rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
onClick={() => {
addAsSubIssue(issue.id);
setIsOpen(false);
}}
>
<span
className={`h-1.5 w-1.5 block rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
{issue.name}
</Combobox.Option>
);
})}
</ul>
</li>
</>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<RectangleStackIcon
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
aria-hidden="true" aria-hidden="true"
/> />
<Combobox.Input <p className="mt-4 text-sm text-gray-900">
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" We couldn{"'"}t find any issue with that term. Please try again.
placeholder="Search..." </p>
onChange={(e) => setQuery(e.target.value)}
/>
</div> </div>
)}
<Combobox.Options </Combobox>
static </Dialog.Panel>
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto" </Transition.Child>
> </div>
{filteredIssues.length > 0 && ( </Dialog>
<> </Transition.Root>
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Issues
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => {
if (issue.parent === "" || issue.parent === null)
return (
<Combobox.Option
key={issue.id}
value={{
name: issue.name,
}}
className={({ active }) =>
classNames(
"flex items-center gap-2 cursor-pointer select-none rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
onClick={() => {
addAsSubIssue(issue.id);
setIsOpen(false);
}}
>
<span
className={`h-1.5 w-1.5 block rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
{issue.name}
</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>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</>
); );
}; };

View File

@ -4,40 +4,35 @@ import { useRouter } from "next/router";
// swr // swr
import { mutate } from "swr"; import { mutate } from "swr";
// react hook form // react hook form
import { Controller, SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
// headless ui // headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react"; import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import issuesServices from "lib/services/issues.services";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useTheme from "lib/hooks/useTheme"; import useTheme from "lib/hooks/useTheme";
import useToast from "lib/hooks/useToast"; import useToast from "lib/hooks/useToast";
// icons // icons
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { import {
FolderIcon, FolderIcon,
RectangleStackIcon, RectangleStackIcon,
ClipboardDocumentListIcon, ClipboardDocumentListIcon,
ArrowPathIcon, MagnifyingGlassIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// commons
import { classNames, copyTextToClipboard } from "constants/common";
// components // components
import ShortcutsModal from "components/command-palette/shortcuts"; import ShortcutsModal from "components/command-palette/shortcuts";
import CreateProjectModal from "components/project/CreateProjectModal"; import CreateProjectModal from "components/project/CreateProjectModal";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal"; import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal";
// ui
import { Button } from "ui";
// types // types
import { IIssue, IProject, IssueResponse } from "types"; import { IIssue, IssueResponse } from "types";
import { Button, SearchListbox } from "ui";
import issuesServices from "lib/services/issues.services";
// fetch keys // fetch keys
import { PROJECTS_LIST, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// constants
type ItemType = { import { classNames, copyTextToClipboard } from "constants/common";
name: string;
url?: string;
onClick?: () => void;
};
type FormInput = { type FormInput = {
issue_ids: string[]; issue_ids: string[];
@ -45,8 +40,6 @@ type FormInput = {
}; };
const CommandPalette: React.FC = () => { const CommandPalette: React.FC = () => {
const router = useRouter();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isPaletteOpen, setIsPaletteOpen] = useState(false); const [isPaletteOpen, setIsPaletteOpen] = useState(false);
@ -55,7 +48,9 @@ const CommandPalette: React.FC = () => {
const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false); const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false);
const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false); const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false);
const { activeWorkspace, activeProject, issues, cycles } = useUser(); const { activeWorkspace, activeProject, issues } = useUser();
const router = useRouter();
const { toggleCollapsed } = useTheme(); const { toggleCollapsed } = useTheme();
@ -67,14 +62,7 @@ const CommandPalette: React.FC = () => {
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? : issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[]; [];
const { const { register, handleSubmit, reset } = useForm<FormInput>();
register,
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
setError,
} = useForm<FormInput>();
const quickActions = [ const quickActions = [
{ {
@ -103,25 +91,25 @@ const CommandPalette: React.FC = () => {
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === "/") { if ((e.ctrlKey || e.metaKey) && e.key === "/") {
e.preventDefault(); e.preventDefault();
setIsPaletteOpen(true); setIsPaletteOpen(true);
} else if (e.ctrlKey && e.key === "i") { } else if ((e.ctrlKey || e.metaKey) && e.key === "i") {
e.preventDefault(); e.preventDefault();
setIsIssueModalOpen(true); setIsIssueModalOpen(true);
} else if (e.ctrlKey && e.key === "p") { } else if ((e.ctrlKey || e.metaKey) && e.key === "p") {
e.preventDefault(); e.preventDefault();
setIsProjectModalOpen(true); setIsProjectModalOpen(true);
} else if (e.ctrlKey && e.key === "b") { } else if ((e.ctrlKey || e.metaKey) && e.key === "b") {
e.preventDefault(); e.preventDefault();
toggleCollapsed(); toggleCollapsed();
} else if (e.ctrlKey && e.key === "h") { } else if ((e.ctrlKey || e.metaKey) && e.key === "h") {
e.preventDefault(); e.preventDefault();
setIsShortcutsModalOpen(true); setIsShortcutsModalOpen(true);
} else if (e.ctrlKey && e.key === "q") { } else if ((e.ctrlKey || e.metaKey) && e.key === "q") {
e.preventDefault(); e.preventDefault();
setIsCreateCycleModalOpen(true); setIsCreateCycleModalOpen(true);
} else if (e.ctrlKey && e.altKey && e.key === "c") { } else if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === "c") {
e.preventDefault(); e.preventDefault();
if (!router.query.issueId) return; if (!router.query.issueId) return;
@ -186,37 +174,6 @@ const CommandPalette: React.FC = () => {
} }
}; };
const handleAddToCycle: 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 (!data.cycleId) {
setToastAlert({
title: "Error",
type: "error",
message: "Please select a cycle",
});
return;
}
if (activeWorkspace && activeProject) {
issuesServices
.bulkAddIssuesToCycle(activeWorkspace.slug, activeProject.id, data.cycleId, data)
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});
}
};
useEffect(() => { useEffect(() => {
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
@ -269,14 +226,7 @@ const CommandPalette: React.FC = () => {
> >
<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"> <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> <form>
<Combobox <Combobox>
// onChange={(item: ItemType) => {
// const { url, onClick } = item;
// if (url) router.push(url);
// else if (onClick) onClick();
// handleCommandPaletteClose();
// }}
>
<div className="relative m-1"> <div className="relative m-1">
<MagnifyingGlassIcon <MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40" className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
@ -305,42 +255,53 @@ const CommandPalette: React.FC = () => {
{filteredIssues.map((issue) => ( {filteredIssues.map((issue) => (
<Combobox.Option <Combobox.Option
key={issue.id} key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={{ value={{
name: issue.name, name: issue.name,
url: `/projects/${issue.project}/issues/${issue.id}`, url: `/projects/${issue.project}/issues/${issue.id}`,
}} }}
className={({ active }) => className={({ active }) =>
classNames( classNames(
"flex cursor-pointer select-none items-center rounded-md px-3 py-2", "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 ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
) )
} }
> >
{({ active }) => ( {({ active }) => (
<> <>
{/* <FolderIcon <div className="flex items-center gap-2">
className={classNames( <input
"h-6 w-6 flex-none text-gray-900 text-opacity-40", type="checkbox"
active ? "text-opacity-100" : "" {...register("issue_ids")}
)} id={`issue-${issue.id}`}
aria-hidden="true" value={issue.id}
/> */} />
<input <span
type="checkbox" className={`h-1.5 w-1.5 block rounded-full`}
{...register("issue_ids")} style={{
id={`issue-${issue.id}`} backgroundColor: issue.state_detail.color,
value={issue.id} }}
/> />
<label <span className="text-xs text-gray-500">
htmlFor={`issue-${issue.id}`} {activeProject?.identifier}-{issue.sequence_id}
className="ml-3 flex-auto truncate"
>
{issue.name}
</label>
{active && (
<span className="ml-3 flex-none text-gray-500">
Jump to...
</span> </span>
{issue.name}
</div>
{active && (
<button
type="button"
onClick={() => {
router.push(
`/projects/${activeProject?.id}/issues/${issue.id}`
);
handleCommandPaletteClose();
}}
>
<span className="justify-self-end flex-none text-gray-500">
Jump to...
</span>
</button>
)} )}
</> </>
)} )}
@ -405,31 +366,9 @@ const CommandPalette: React.FC = () => {
</Combobox> </Combobox>
<div className="flex justify-between items-center gap-2 p-3"> <div className="flex justify-between items-center gap-2 p-3">
<div className="flex items-center gap-2"> <Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm">
<Controller Delete selected
control={control} </Button>
name="cycleId"
render={({ field: { value, onChange } }) => (
<SearchListbox
title="Cycle"
optionsFontsize="sm"
options={cycles?.map((cycle) => {
return { value: cycle.id, display: cycle.name };
})}
multiple={false}
value={value}
onChange={onChange}
icon={<ArrowPathIcon className="h-4 w-4 text-gray-400" />}
/>
)}
/>
<Button onClick={handleSubmit(handleAddToCycle)} size="sm">
Add to Cycle
</Button>
<Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm">
Delete
</Button>
</div>
<div> <div>
<Button type="button" size="sm" onClick={handleCommandPaletteClose}> <Button type="button" size="sm" onClick={handleCommandPaletteClose}>
Close Close

View File

@ -1,4 +1,10 @@
import { EditorState, LexicalEditor, $getRoot, $getSelection } from "lexical"; import {
EditorState,
$getRoot,
$getSelection,
SerializedEditorState,
LexicalEditor,
} from "lexical";
import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
@ -21,7 +27,7 @@ import { getValidatedValue } from "./helpers/editor";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
export interface RichTextEditorProps { export interface RichTextEditorProps {
onChange: (state: string) => void; onChange: (state: SerializedEditorState) => void;
id: string; id: string;
value: string; value: string;
placeholder?: string; placeholder?: string;
@ -33,11 +39,18 @@ const RichTextEditor: React.FC<RichTextEditorProps> = ({
value, value,
placeholder = "Enter some text...", placeholder = "Enter some text...",
}) => { }) => {
function handleChange(state: EditorState, editor: LexicalEditor) { const handleChange = (editorState: EditorState) => {
state.read(() => { editorState.read(() => {
onChange(JSON.stringify(state.toJSON())); let editorData = editorState.toJSON();
if (onChange) onChange(editorData);
}); });
} };
// function handleChange(state: EditorState, editor: LexicalEditor) {
// state.read(() => {
// onChange(state.toJSON());
// });
// }
return ( return (
<LexicalComposer <LexicalComposer

View File

@ -6,9 +6,7 @@ export const positionEditorElement = (editor: any, rect: any) => {
editor.style.left = "-1000px"; editor.style.left = "-1000px";
} else { } else {
editor.style.opacity = "1"; editor.style.opacity = "1";
editor.style.top = `${ editor.style.top = `${rect.top + rect.height + window.pageYOffset + 10}px`;
rect.top + rect.height + window.pageYOffset + 10
}px`;
editor.style.left = `${ editor.style.left = `${
rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2 rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2
}px`; }px`;
@ -22,9 +20,9 @@ export const getValidatedValue = (value: string) => {
if (value) { if (value) {
try { try {
const json = JSON.parse(value); console.log(value);
return JSON.stringify(json); return value;
} catch (error) { } catch (e) {
return defaultValue; return defaultValue;
} }
} }

View File

@ -1,4 +1,3 @@
import { FC } from "react";
import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
@ -17,14 +16,11 @@ import { getValidatedValue } from "./helpers/editor";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
export interface RichTextViewerProps { export interface RichTextViewerProps {
id: string;
value: string; value: string;
id: string;
} }
const RichTextViewer: FC<RichTextViewerProps> = (props) => { const RichTextViewer: React.FC<RichTextViewerProps> = ({ value, id }) => {
// props
const { value, id } = props;
return ( return (
<LexicalComposer <LexicalComposer
initialConfig={{ initialConfig={{
@ -37,7 +33,7 @@ const RichTextViewer: FC<RichTextViewerProps> = (props) => {
<div className="relative"> <div className="relative">
<RichTextPlugin <RichTextPlugin
contentEditable={ contentEditable={
<ContentEditable className='className="h-[450px] outline-none py-[15px] resize-none overflow-hidden text-ellipsis' /> <ContentEditable className='className="h-[450px] outline-none resize-none overflow-hidden text-ellipsis' />
} }
ErrorBoundary={LexicalErrorBoundary} ErrorBoundary={LexicalErrorBoundary}
placeholder={ placeholder={

View File

@ -0,0 +1,116 @@
import React, { useRef, useState } from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
import { Button } from "ui";
type Props = {
isOpen: boolean;
onClose: () => void;
handleDelete: () => void;
data?: any;
};
const ConfirmProjectMemberRemove: React.FC<Props> = ({ isOpen, onClose, data, handleDelete }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const cancelButtonRef = useRef(null);
const handleClose = () => {
onClose();
setIsDeleteLoading(false);
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
handleDelete();
handleClose();
};
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">
Remove {data?.email}?
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to remove member - {`"`}
<span className="italic">{data?.email}</span>
{`"`} ? They will no longer have access to this project. 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 ? "Removing..." : "Remove"}
</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 ConfirmProjectMemberRemove;

View File

@ -12,6 +12,7 @@ import useToast from "lib/hooks/useToast";
import projectService from "lib/services/project.service"; import projectService from "lib/services/project.service";
import workspaceService from "lib/services/workspace.service"; import workspaceService from "lib/services/workspace.service";
// constants // constants
import { ROLE } from "constants/";
import { PROJECT_INVITATIONS, WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { PROJECT_INVITATIONS, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// ui // ui
import { Button, Select, TextArea } from "ui"; import { Button, Select, TextArea } from "ui";
@ -30,13 +31,9 @@ type Props = {
const defaultValues: Partial<ProjectMember> = { const defaultValues: Partial<ProjectMember> = {
email: "", email: "",
message: "", message: "",
}; role: 5,
member_id: "",
const ROLE = { user_id: "",
5: "Guest",
10: "Viewer",
15: "Member",
20: "Admin",
}; };
const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, members }) => { const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, members }) => {

View File

@ -0,0 +1,208 @@
// react
import React, { useState } from "react";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// services
import issuesServices from "lib/services/issues.services";
// 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
import { classNames } from "constants/common";
type Props = {
isOpen: boolean;
handleClose: () => void;
issues: IssueResponse | undefined;
cycleId: string;
};
type FormInput = {
issue_ids: string[];
};
const CycleIssuesListModal: React.FC<Props> = ({
isOpen,
handleClose: onClose,
issues,
cycleId,
}) => {
const [query, setQuery] = useState("");
const { activeWorkspace, activeProject } = useUser();
const { setToastAlert } = useToast();
const handleClose = () => {
onClose();
setQuery("");
reset();
};
const { handleSubmit, reset, control } = useForm<FormInput>({
defaultValues: {
issue_ids: [],
},
});
const handleAddToCycle: 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
.bulkAddIssuesToCycle(activeWorkspace.slug, activeProject.id, cycleId, data)
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});
}
};
const filteredIssues: IIssue[] =
query === ""
? issues?.results ?? []
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
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>
<Controller
control={control}
name="issue_ids"
render={({ field }) => (
<Combobox as="div" {...field} multiple>
<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={(e) => setQuery(e.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 to add to cycle
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={issue.id}
className={({ active }) =>
classNames(
"flex items-center gap-2 cursor-pointer select-none w-full rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ selected }) => (
<>
<input type="checkbox" checked={selected} readOnly />
<span
className={`h-1.5 w-1.5 block rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
{issue.name}
</>
)}
</Combobox.Option>
))}
</ul>
</li>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<RectangleStackIcon
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 type="button" theme="danger" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button type="button" size="sm" onClick={handleSubmit(handleAddToCycle)}>
Add to Cycle
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</>
);
};
export default CycleIssuesListModal;

View File

@ -1,258 +1,314 @@
import React from "react"; // react
import React, { useState } from "react";
// next // next
import { useRouter } from "next/router"; import Link from "next/link";
// swr // swr
import useSWR from "swr"; import useSWR, { mutate } from "swr";
// headless ui // headless ui
import { Disclosure, Transition, Menu, Listbox } from "@headlessui/react"; import { Disclosure, Transition, Menu } from "@headlessui/react";
// fetch keys
import { PROJECT_ISSUES_LIST, CYCLE_ISSUES } from "constants/fetch-keys";
// services // services
import issuesServices from "lib/services/issues.services";
import cycleServices from "lib/services/cycles.services"; import cycleServices from "lib/services/cycles.services";
// commons // hooks
import { classNames, renderShortNumericDateFormat } from "constants/common"; import useUser from "lib/hooks/useUser";
// components
import CycleIssuesListModal from "./CycleIssuesListModal";
// ui // ui
import { Spinner } from "ui"; import { Spinner } from "ui";
// icons // icons
import { PlusIcon, EllipsisHorizontalIcon, ChevronDownIcon } from "@heroicons/react/20/solid"; import { PlusIcon, EllipsisHorizontalIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
// types // types
import type { ICycle, SprintViewProps as Props, SprintIssueResponse, IssueResponse } from "types"; import type { CycleViewProps as Props, CycleIssueResponse, IssueResponse } from "types";
// fetch keys
import { CYCLE_ISSUES } from "constants/fetch-keys";
// constants
import { renderShortNumericDateFormat } from "constants/common";
import issuesServices from "lib/services/issues.services";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd";
const SprintView: React.FC<Props> = ({ const CycleView: React.FC<Props> = ({
sprint, cycle,
selectSprint, selectSprint,
workspaceSlug, workspaceSlug,
projectId, projectId,
openIssueModal, openIssueModal,
addIssueToSprint,
}) => { }) => {
const router = useRouter(); const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
const { data: sprintIssues } = useSWR<SprintIssueResponse[]>(CYCLE_ISSUES(sprint.id), () => const { activeWorkspace, activeProject, issues } = useUser();
cycleServices.getCycleIssues(workspaceSlug, projectId, sprint.id)
const { data: cycleIssues } = useSWR<CycleIssueResponse[]>(CYCLE_ISSUES(cycle.id), () =>
cycleServices.getCycleIssues(workspaceSlug, projectId, cycle.id)
); );
const { data: projectIssues } = useSWR<IssueResponse>( const removeIssueFromCycle = (cycleId: string, bridgeId: string) => {
projectId && workspaceSlug ? PROJECT_ISSUES_LIST(workspaceSlug, projectId) : null, if (activeWorkspace && activeProject && cycleIssues) {
workspaceSlug ? () => issuesServices.getIssues(workspaceSlug, projectId) : null mutate<CycleIssueResponse[]>(
); CYCLE_ISSUES(cycleId),
(prevData) => prevData?.filter((p) => p.id !== bridgeId),
false
);
issuesServices
.removeIssueFromCycle(activeWorkspace.slug, activeProject.id, cycleId, bridgeId)
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});
}
};
return ( return (
<div className="w-full flex flex-col gap-y-4 pb-5 relative"> <>
<Disclosure defaultOpen> <CycleIssuesListModal
isOpen={cycleIssuesListModal}
handleClose={() => setCycleIssuesListModal(false)}
issues={issues}
cycleId={cycle.id}
/>
<Disclosure as="div" defaultOpen>
{({ open }) => ( {({ open }) => (
<div className="bg-gray-50 py-5 px-5 rounded"> <div className="bg-white px-4 py-2 rounded-lg space-y-3">
<div className="w-full h-full space-y-6 overflow-auto pb-10"> <div className="flex items-center">
<div className="w-full flex items-center"> <Disclosure.Button className="w-full">
<Disclosure.Button className="w-full"> <div className="flex items-center gap-x-2">
<div className="flex items-center gap-x-2"> <span>
<ChevronDownIcon
width={22}
className={`text-gray-500 ${!open ? "transform -rotate-90" : ""}`}
/>
</span>
<h2 className="font-medium leading-5">{cycle.name}</h2>
<p className="flex gap-2 text-xs text-gray-500">
<span> <span>
<ChevronDownIcon {cycle.status === "started"
width={22} ? cycle.start_date
className={`text-gray-500 ${!open ? "transform -rotate-90" : ""}`} ? `${renderShortNumericDateFormat(cycle.start_date)} - `
/>
</span>
<h2 className="text-xl">{sprint.name}</h2>
<p className="font-light text-gray-500">
{sprint.status === "started"
? sprint.start_date
? `${renderShortNumericDateFormat(sprint.start_date)} - `
: "" : ""
: sprint.status} : cycle.status}
{sprint.end_date ? renderShortNumericDateFormat(sprint.end_date) : ""} </span>
</p> <span>
</div> {cycle.end_date ? renderShortNumericDateFormat(cycle.end_date) : ""}
</Disclosure.Button> </span>
</p>
<div className="relative">
<Menu>
<Menu.Button>
<EllipsisHorizontalIcon width="16" height="16" />
</Menu.Button>
<Menu.Items className="absolute z-20 w-28 bg-white rounded border cursor-pointer -left-24">
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
className="w-full text-left py-2 pl-2"
type="button"
onClick={() => selectSprint({ ...sprint, actionType: "edit" })}
>
Edit
</button>
</div>
</Menu.Item>
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
className="w-full text-left py-2 pl-2"
type="button"
onClick={() => selectSprint({ ...sprint, actionType: "delete" })}
>
Delete
</button>
</div>
</Menu.Item>
</Menu.Items>
</Menu>
</div> </div>
</div> </Disclosure.Button>
<Menu as="div" className="relative inline-block">
<Menu.Button className="grid place-items-center rounded p-1 hover:bg-gray-100 focus:outline-none">
<EllipsisHorizontalIcon className="h-4 w-4" />
</Menu.Button>
<Menu.Items className="absolute origin-top-right right-0 mt-1 p-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<Menu.Item>
<button
type="button"
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
onClick={() => selectSprint({ ...cycle, actionType: "edit" })}
>
Edit
</button>
</Menu.Item>
<Menu.Item>
<button
type="button"
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
onClick={() => selectSprint({ ...cycle, actionType: "delete" })}
>
Delete
</button>
</Menu.Item>
</Menu.Items>
</Menu>
</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>
<StrictModeDroppable droppableId={cycle.id}>
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{cycleIssues ? (
cycleIssues.length > 0 ? (
cycleIssues.map((issue, index) => (
<Draggable
key={issue.id}
draggableId={`${issue.id},${issue.issue}`} // bridge id, issue id
index={index}
>
{(provided, snapshot) => (
<div
className={`group p-2 hover:bg-gray-100 text-sm rounded flex items-center justify-between ${
snapshot.isDragging
? "bg-gray-100 shadow-lg border border-theme"
: ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div className="flex items-center gap-2">
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 rotate-90 outline-none`}
{...provided.dragHandleProps}
>
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600" />
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600 mt-[-0.7rem]" />
</button>
<span
className={`h-1.5 w-1.5 block rounded-full`}
style={{
backgroundColor: issue.issue_details.state_detail.color,
}}
/>
<Link
href={`/projects/${projectId}/issues/${issue.issue_details.id}`}
>
<a className="flex items-center gap-2">
<span className="text-xs text-gray-500">
{activeProject?.identifier}-
{issue.issue_details.sequence_id}
</span>
{issue.issue_details.name}
{/* {cycle.id} */}
</a>
</Link>
</div>
<div className="flex items-center gap-2">
<span
className="text-black rounded-md px-2 py-0.5 text-sm"
style={{
backgroundColor: `${issue.issue_details.state_detail?.color}20`,
border: `2px solid ${issue.issue_details.state_detail?.color}`,
}}
>
{issue.issue_details.state_detail?.name}
</span>
<Menu as="div" className="relative">
<Menu.Button
as="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none`}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</Menu.Button>
<Menu.Items className="absolute origin-top-right right-0.5 mt-1 p-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<Menu.Item>
<button
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
type="button"
onClick={() =>
openIssueModal(cycle.id, issue.issue_details, "edit")
}
>
Edit
</button>
</Menu.Item>
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
type="button"
onClick={() =>
removeIssueFromCycle(issue.cycle, issue.id)
}
>
Remove from cycle
</button>
</div>
</Menu.Item>
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
type="button"
onClick={() =>
openIssueModal(
cycle.id,
issue.issue_details,
"delete"
)
}
>
Delete permanently
</button>
</div>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
</div>
)}
</Draggable>
))
) : (
<p className="text-sm text-gray-500">This cycle has no issue.</p>
)
) : (
<div className="w-full h-full flex items-center justify-center">
<Spinner />
</div>
)}
{provided.placeholder}
</div>
)}
</StrictModeDroppable>
</Disclosure.Panel>
</Transition>
<Menu as="div" className="relative inline-block">
<Menu.Button className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 text-xs font-medium">
<PlusIcon className="h-3 w-3" />
Add issue
</Menu.Button>
<Transition <Transition
show={open} as={React.Fragment}
enter="transition duration-100 ease-out" enter="transition ease-out duration-100"
enterFrom="transform opacity-0" enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100" enterTo="transform opacity-100 scale-100"
leave="transition duration-75 ease-out" leave="transition ease-in duration-75"
leaveFrom="transform opacity-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0" leaveTo="transform opacity-0 scale-95"
> >
<Disclosure.Panel> <Menu.Items className="absolute left-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<div className="space-y-3"> <div className="p-1">
{sprintIssues ? ( <Menu.Item as="div">
sprintIssues.length > 0 ? ( {(active) => (
sprintIssues.map((issue) => ( <button
<div type="button"
key={issue.id} className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
className="p-4 bg-white border border-gray-200 rounded flex items-center justify-between" onClick={() => openIssueModal(cycle.id)}
> >
<button Create new
type="button" </button>
onClick={() => )}
router.push( </Menu.Item>
`/projects/${projectId}/issues/${issue.issue_details.id}` <Menu.Item as="div">
) {(active) => (
} <button
> type="button"
<p>{issue.issue_details.name}</p> className="p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap"
</button> onClick={() => setCycleIssuesListModal(true)}
<div className="flex items-center gap-x-4"> >
<span Add an existing issue
className="text-black rounded px-2 py-0.5 text-sm border" </button>
style={{ )}
backgroundColor: `${issue.issue_details.state_detail?.color}20`, </Menu.Item>
borderColor: issue.issue_details.state_detail?.color,
}}
>
{issue.issue_details.state_detail?.name}
</span>
<div className="relative">
<Menu>
<Menu.Button>
<EllipsisHorizontalIcon width="16" height="16" />
</Menu.Button>
<Menu.Items className="absolute z-20 w-28 bg-white rounded border cursor-pointer -left-24">
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
className="w-full text-left py-2 pl-2"
type="button"
onClick={() =>
openIssueModal(sprint.id, issue.issue_details, "edit")
}
>
Edit
</button>
</div>
</Menu.Item>
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
className="w-full text-left py-2 pl-2"
type="button"
onClick={() =>
openIssueModal(sprint.id, issue.issue_details, "delete")
}
>
Delete
</button>
</div>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
</div>
</div>
))
) : (
<p className="text-sm text-gray-500">This cycle has no issues.</p>
)
) : (
<div className="w-full h-full flex items-center justify-center">
<Spinner />
</div>
)}
</div> </div>
</Disclosure.Panel> </Menu.Items>
</Transition> </Transition>
<div className="flex flex-col gap-y-2"> </Menu>
<button
className="text-indigo-600 flex items-center gap-x-2"
onClick={() => openIssueModal(sprint.id)}
>
<div className="bg-theme text-white rounded-full p-0.5">
<PlusIcon width="18" height="18" />
</div>
<p>Add Issue</p>
</button>
<div className="ml-1">
<Menu as="div" className="inline-block text-left">
<div>
<Menu.Button className="inline-flex w-full items-center justify-center rounded-md text-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
<div className="text-indigo-600 flex items-center gap-x-2">
<p>Add Existing Issue</p>
</div>
<ChevronDownIcon
className="-mr-1 ml-2 h-5 w-5 text-indigo-600"
aria-hidden="true"
/>
</Menu.Button>
</div>
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute left-5 z-20 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{projectIssues?.results.map((issue) => (
<Menu.Item
key={issue.id}
as="div"
onClick={() => {
addIssueToSprint(sprint.id, issue.id);
}}
>
{({ active }) => (
<p
className={classNames(
active ? "bg-gray-100 text-gray-900" : "text-gray-700",
"block px-4 py-2 text-sm"
)}
>
{issue.name}
</p>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
</div>
</div> </div>
)} )}
</Disclosure> </Disclosure>
</div> </>
); );
}; };
export default SprintView; export default CycleView;

View File

@ -5,7 +5,11 @@ import Link from "next/link";
import { Draggable } from "react-beautiful-dnd"; import { Draggable } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// common // common
import { addSpaceIfCamelCase, renderShortNumericDateFormat } from "constants/common"; import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
// types // types
import { IIssue, Properties, NestedKeyOf } from "types"; import { IIssue, Properties, NestedKeyOf } from "types";
// icons // icons
@ -23,7 +27,9 @@ import { divide } from "lodash";
type Props = { type Props = {
selectedGroup: NestedKeyOf<IIssue> | null; selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string; groupTitle: string;
groupedByIssues: any; groupedByIssues: {
[key: string]: IIssue[];
};
index: number; index: number;
setIsIssueOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsIssueOpen: React.Dispatch<React.SetStateAction<boolean>>;
properties: Properties; properties: Properties;
@ -69,7 +75,7 @@ const SingleBoard: React.FC<Props> = ({
{(provided, snapshot) => ( {(provided, snapshot) => (
<div <div
className={`rounded flex-shrink-0 h-full ${ className={`rounded flex-shrink-0 h-full ${
snapshot.isDragging ? "border-indigo-600 shadow-lg" : "" snapshot.isDragging ? "border-theme shadow-lg" : ""
} ${!show ? "" : "w-80 bg-gray-50 border"}`} } ${!show ? "" : "w-80 bg-gray-50 border"}`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
@ -158,25 +164,12 @@ const SingleBoard: React.FC<Props> = ({
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none" className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"
onClick={() => onClick={() =>
setPreloadedData({ setPreloadedData({
// ...state,
actionType: "edit", actionType: "edit",
}) })
} }
> >
<PencilIcon className="h-4 w-4" /> <PencilIcon className="h-4 w-4" />
</button> </button>
{/* <button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300"
onClick={() =>
setSelectedState({
...state,
actionType: "delete",
})
}
>
<TrashIcon className="h-4 w-4 text-red-500" />
</button> */}
</div> </div>
</div> </div>
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}> <StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
@ -188,7 +181,7 @@ const SingleBoard: React.FC<Props> = ({
{...provided.droppableProps} {...provided.droppableProps}
ref={provided.innerRef} ref={provided.innerRef}
> >
{groupedByIssues[groupTitle].map((childIssue: any, index: number) => ( {groupedByIssues[groupTitle].map((childIssue, index: number) => (
<Draggable key={childIssue.id} draggableId={childIssue.id} index={index}> <Draggable key={childIssue.id} draggableId={childIssue.id} index={index}>
{(provided, snapshot) => ( {(provided, snapshot) => (
<Link href={`/projects/${childIssue.project}/issues/${childIssue.id}`}> <Link href={`/projects/${childIssue.project}/issues/${childIssue.id}`}>
@ -203,6 +196,9 @@ const SingleBoard: React.FC<Props> = ({
className="px-2 py-3 space-y-1.5 select-none" className="px-2 py-3 space-y-1.5 select-none"
{...provided.dragHandleProps} {...provided.dragHandleProps}
> >
<span className="group-hover:text-theme text-sm break-all">
{childIssue.name}
</span>
{Object.keys(properties).map( {Object.keys(properties).map(
(key) => (key) =>
properties[key as keyof Properties] && properties[key as keyof Properties] &&
@ -227,34 +223,66 @@ const SingleBoard: React.FC<Props> = ({
: key === "target_date" : key === "target_date"
? "text-xs bg-indigo-50 px-2 py-1 mt-2 flex items-center gap-x-1 rounded w-min whitespace-nowrap" ? "text-xs bg-indigo-50 px-2 py-1 mt-2 flex items-center gap-x-1 rounded w-min whitespace-nowrap"
: "text-sm text-gray-500" : "text-sm text-gray-500"
} gap-1 } gap-1 relative
`} `}
> >
{key === "target_date" ? ( {key === "start_date" && childIssue.start_date !== null && (
<> <span className="text-sm">
<CalendarDaysIcon className="h-4 w-4" />{" "} <CalendarDaysIcon className="h-4 w-4" />
{renderShortNumericDateFormat(childIssue.start_date)} -
{childIssue.target_date {childIssue.target_date
? renderShortNumericDateFormat(childIssue.target_date) ? renderShortNumericDateFormat(childIssue.target_date)
: "N/A"} : "None"}
</> </span>
) : (
""
)} )}
{key === "name" && ( {key === "target_date" && (
<span className="group-hover:text-theme"> <>
{childIssue.name} <span
className={`flex items-center gap-x-1 group ${
childIssue.target_date === null
? ""
: childIssue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(childIssue.target_date) <=
3 && "text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{childIssue.target_date
? renderShortNumericDateFormat(childIssue.target_date)
: "N/A"}
{childIssue.target_date && (
<span className="absolute -top-full mb-2 left-4 border transition-opacity opacity-0 group-hover:opacity-100 bg-white rounded px-2 py-1">
{childIssue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft(
childIssue.target_date
)} days`
: findHowManyDaysLeft(childIssue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft(
childIssue.target_date
)} days`
: "Target date"}
</span>
)}
</span>
</>
)}
{key === "key" && (
<span className="text-xs">
{childIssue.project_detail?.identifier}-
{childIssue.sequence_id}
</span> </span>
)} )}
{key === "state" && ( {key === "state" && (
<>{addSpaceIfCamelCase(childIssue["state_detail"].name)}</> <>{addSpaceIfCamelCase(childIssue["state_detail"].name)}</>
)} )}
{key === "priority" && <>{childIssue.priority}</>} {key === "priority" && <>{childIssue.priority}</>}
{key === "description" && <>{childIssue.description}</>} {/* {key === "description" && <>{childIssue.description}</>} */}
{key === "assignee" ? ( {key === "assignee" ? (
<div className="flex items-center gap-1 text-xs"> <div className="flex items-center gap-1 text-xs">
{childIssue?.assignee_details?.length > 0 ? ( {childIssue?.assignee_details?.length > 0 ? (
childIssue?.assignee_details?.map( childIssue?.assignee_details?.map(
(assignee: any, index: number) => ( (assignee, index: number) => (
<div <div
key={index} key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${ className={`relative z-[1] h-5 w-5 rounded-full ${
@ -282,7 +310,7 @@ const SingleBoard: React.FC<Props> = ({
) )
) )
) : ( ) : (
<span>None</span> <span>No assignee.</span>
)} )}
</div> </div>
) : null} ) : null}
@ -290,29 +318,6 @@ const SingleBoard: React.FC<Props> = ({
) )
)} )}
</div> </div>
{/* <div
className={`p-2 bg-indigo-50 flex items-center justify-between ${
snapshot.isDragging ? "bg-indigo-200" : ""
}`}
>
<button
type="button"
className="flex flex-col"
{...provided.dragHandleProps}
>
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600" />
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600 mt-[-0.7rem]" />
</button>
<div className="flex gap-1 items-center">
<button type="button">
<HeartIcon className="h-4 w-4 text-yellow-500" />
</button>
<button type="button">
<CheckCircleIcon className="h-4 w-4 text-green-500" />
</button>
</div>
</div> */}
</a> </a>
</Link> </Link>
)} )}

View File

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

View File

@ -44,7 +44,7 @@ const SelectAssignee: React.FC<Props> = ({ control }) => {
multiple={true} multiple={true}
value={value} value={value}
onChange={onChange} onChange={onChange}
icon={<UserIcon className="h-4 w-4 text-gray-400" />} icon={<UserIcon className="h-3 w-3 text-gray-500" />}
/> />
)} )}
/> />

View File

@ -33,7 +33,7 @@ const SelectSprint: React.FC<Props> = ({ control, setIsOpen }) => {
<> <>
<div className="relative"> <div className="relative">
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative 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"> <Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative 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">
<ArrowPathIcon className="h-3 w-3" /> <ArrowPathIcon className="h-3 w-3 text-gray-500" />
<span className="block truncate"> <span className="block truncate">
{cycles?.find((i) => i.id.toString() === value?.toString())?.name ?? "Cycle"} {cycles?.find((i) => i.id.toString() === value?.toString())?.name ?? "Cycle"}
</span> </span>

View File

@ -83,7 +83,7 @@ const SelectLabels: React.FC<Props> = ({ control }) => {
<> <>
<div className="relative"> <div className="relative">
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative 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"> <Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative 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">
<TagIcon className="h-3 w-3" /> <TagIcon className="h-3 w-3 text-gray-500" />
<span className="block truncate"> <span className="block truncate">
{value && value.length > 0 {value && value.length > 0
? value.map((id) => issueLabels?.find((i) => i.id === id)?.name).join(", ") ? value.map((id) => issueLabels?.find((i) => i.id === id)?.name).join(", ")

View File

@ -0,0 +1,37 @@
import React, { useEffect, useState } from "react";
// react hook form
import { Controller, Control } from "react-hook-form";
// hooks
import useUser from "lib/hooks/useUser";
// types
import type { IIssue, IssueResponse } from "types";
// icons
import { UserIcon } from "@heroicons/react/24/outline";
// components
import IssuesListModal from "components/project/issues/IssuesListModal";
type Props = {
control: Control<IIssue, any>;
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
issues: IssueResponse | undefined;
};
const SelectParent: React.FC<Props> = ({ control, isOpen, setIsOpen, issues }) => {
return (
<Controller
control={control}
name="parent"
render={({ field: { value, onChange } }) => (
<IssuesListModal
isOpen={isOpen}
handleClose={() => setIsOpen(false)}
onChange={onChange}
issues={issues}
/>
)}
/>
);
};
export default SelectParent;

View File

@ -1,66 +0,0 @@
import React from "react";
// react hook form
import { Controller } from "react-hook-form";
// hooks
import useUser from "lib/hooks/useUser";
// types
import type { IIssue } from "types";
import type { Control } from "react-hook-form";
import { UserIcon } from "@heroicons/react/24/outline";
type Props = {
control: Control<IIssue, any>;
};
import { SearchListbox } from "ui";
const SelectParent: React.FC<Props> = ({ control }) => {
const { issues: projectIssues } = useUser();
const getSelectedIssueKey = (issueId: string | undefined) => {
const identifier = projectIssues?.results?.find((i) => i.id.toString() === issueId?.toString())
?.project_detail?.identifier;
const sequenceId = projectIssues?.results?.find(
(i) => i.id.toString() === issueId?.toString()
)?.sequence_id;
if (issueId) return `${identifier}-${sequenceId}`;
else return "Parent issue";
};
return (
<Controller
control={control}
name="parent"
render={({ field: { value, onChange } }) => (
<SearchListbox
title="Parent issue"
optionsFontsize="sm"
options={projectIssues?.results?.map((issue) => {
return {
value: issue.id,
display: issue.name,
element: (
<div className="flex items-center space-x-3">
<div className="block truncate">
<span className="block truncate">{`${getSelectedIssueKey(issue.id)}`}</span>
<span className="block truncate text-gray-400">{issue.name}</span>
</div>
</div>
),
};
})}
value={value}
width="xs"
buttonClassName="max-h-30 overflow-y-scroll"
optionsClassName="max-h-30 overflow-y-scroll"
onChange={onChange}
icon={<UserIcon className="h-4 w-4 text-gray-400" />}
/>
)}
/>
);
};
export default SelectParent;

View File

@ -5,6 +5,8 @@ import { Controller } from "react-hook-form";
import { Listbox, Transition } from "@headlessui/react"; import { Listbox, Transition } from "@headlessui/react";
// icons // icons
import { CheckIcon } from "@heroicons/react/20/solid"; import { CheckIcon } from "@heroicons/react/20/solid";
// constants
import { PRIORITIES } from "constants/";
// types // types
import type { IIssue } from "types"; import type { IIssue } from "types";
@ -15,8 +17,6 @@ type Props = {
control: Control<IIssue, any>; control: Control<IIssue, any>;
}; };
const PRIORITIES = ["high", "medium", "low"];
const SelectPriority: React.FC<Props> = ({ control }) => { const SelectPriority: React.FC<Props> = ({ control }) => {
return ( return (
<Controller <Controller
@ -28,8 +28,10 @@ const SelectPriority: React.FC<Props> = ({ control }) => {
<> <>
<div className="relative"> <div className="relative">
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative 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"> <Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative 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">
<ChartBarIcon className="h-3 w-3" /> <ChartBarIcon className="h-3 w-3 text-gray-500" />
<span className="block capitalize">{value ?? "Priority"}</span> <span className="block capitalize">
{value && value !== "" ? value : "Priority"}
</span>
</Listbox.Button> </Listbox.Button>
<Transition <Transition

View File

@ -21,38 +21,36 @@ const SelectState: React.FC<Props> = ({ control, setIsOpen }) => {
const { states } = useUser(); const { states } = useUser();
return ( return (
<> <Controller
<Controller control={control}
control={control} name="state"
name="state" render={({ field: { value, onChange } }) => (
render={({ field: { value, onChange } }) => ( <CustomListbox
<CustomListbox title="State"
title="State" options={states?.map((state) => {
options={states?.map((state) => { return { value: state.id, display: state.name };
return { value: state.id, display: state.name }; })}
})} value={value}
value={value} optionsFontsize="sm"
optionsFontsize="sm" onChange={onChange}
onChange={onChange} icon={<Squares2X2Icon className="h-4 w-4 text-gray-400" />}
icon={<Squares2X2Icon className="h-4 w-4 text-gray-400" />} footerOption={
footerOption={ <button
<button type="button"
type="button" className="select-none relative py-2 pl-3 pr-9 flex items-center gap-x-2 text-gray-400 hover:text-gray-500"
className="select-none relative py-2 pl-3 pr-9 flex items-center gap-x-2 text-gray-400 hover:text-gray-500" onClick={() => setIsOpen(true)}
onClick={() => setIsOpen(true)} >
> <span>
<span> <PlusIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
<PlusIcon className="h-5 w-5 text-gray-400" aria-hidden="true" /> </span>
</span> <span>
<span> <span className="block truncate">Create state</span>
<span className="block truncate">Create state</span> </span>
</span> </button>
</button> }
} />
/> )}
)} />
></Controller>
</>
); );
}; };

View File

@ -1,11 +1,11 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
// next // next
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic";
// swr // swr
import { mutate } from "swr"; import { mutate } from "swr";
// react hook form // react hook form
import { useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// fetching keys // fetching keys
import { import {
PROJECT_ISSUES_DETAILS, PROJECT_ISSUES_DETAILS,
@ -14,7 +14,7 @@ import {
USER_ISSUE, USER_ISSUE,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
// headless // headless
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Menu, Transition } from "@headlessui/react";
// services // services
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.services";
// hooks // hooks
@ -31,12 +31,13 @@ import SelectLabels from "./SelectLabels";
import SelectProject from "./SelectProject"; import SelectProject from "./SelectProject";
import SelectPriority from "./SelectPriority"; import SelectPriority from "./SelectPriority";
import SelectAssignee from "./SelectAssignee"; import SelectAssignee from "./SelectAssignee";
import SelectParent from "./SelectParentIssues"; import SelectParent from "./SelectParentIssue";
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal"; import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal"; import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal";
// types // types
import type { IIssue, IssueResponse, SprintIssueResponse } from "types"; import type { IIssue, IssueResponse, CycleIssueResponse } from "types";
import { EllipsisHorizontalIcon } from "@heroicons/react/24/outline";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -48,8 +49,13 @@ type Props = {
}; };
const defaultValues: Partial<IIssue> = { const defaultValues: Partial<IIssue> = {
project: "",
name: "", name: "",
description: "", // description: "",
state: "",
sprints: null,
priority: null,
labels_list: [],
}; };
const CreateUpdateIssuesModal: React.FC<Props> = ({ const CreateUpdateIssuesModal: React.FC<Props> = ({
@ -62,9 +68,20 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
}) => { }) => {
const [isCycleModalOpen, setIsCycleModalOpen] = useState(false); const [isCycleModalOpen, setIsCycleModalOpen] = useState(false);
const [isStateModalOpen, setIsStateModalOpen] = useState(false); const [isStateModalOpen, setIsStateModalOpen] = useState(false);
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
const [mostSimilarIssue, setMostSimilarIssue] = useState<string | undefined>(); const [mostSimilarIssue, setMostSimilarIssue] = useState<string | undefined>();
// const [issueDescriptionValue, setIssueDescriptionValue] = useState("");
// const handleDescriptionChange: any = (value: any) => {
// console.log(value);
// setIssueDescriptionValue(value);
// };
const RichTextEditor = dynamic(() => import("components/lexical/editor"), {
ssr: false,
});
const router = useRouter(); const router = useRouter();
const handleClose = () => { const handleClose = () => {
@ -74,13 +91,6 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
} }
}; };
const resetForm = () => {
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
const { activeWorkspace, activeProject, user, issues } = useUser(); const { activeWorkspace, activeProject, user, issues } = useUser();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -97,6 +107,13 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
defaultValues, defaultValues,
}); });
const resetForm = () => {
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
};
const addIssueToSprint = async (issueId: string, sprintId: string, issueDetail: IIssue) => { const addIssueToSprint = async (issueId: string, sprintId: string, issueDetail: IIssue) => {
if (!activeWorkspace || !activeProject) return; if (!activeWorkspace || !activeProject) return;
await issuesServices await issuesServices
@ -104,8 +121,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
issue: issueId, issue: issueId,
}) })
.then((res) => { .then((res) => {
console.log("add to sprint", res); mutate<CycleIssueResponse[]>(
mutate<SprintIssueResponse[]>(
CYCLE_ISSUES(sprintId), CYCLE_ISSUES(sprintId),
(prevData) => { (prevData) => {
const targetResponse = prevData?.find((t) => t.cycle === sprintId); const targetResponse = prevData?.find((t) => t.cycle === sprintId);
@ -118,7 +134,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
{ {
cycle: sprintId, cycle: sprintId,
issue_details: issueDetail, issue_details: issueDetail,
} as SprintIssueResponse, } as CycleIssueResponse,
]; ];
} }
}, },
@ -166,17 +182,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
.createIssues(activeWorkspace.slug, activeProject.id, payload) .createIssues(activeWorkspace.slug, activeProject.id, payload)
.then(async (res) => { .then(async (res) => {
console.log(res); console.log(res);
mutate<IssueResponse>( mutate<IssueResponse>(PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id));
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
(prevData) => {
return {
...(prevData as IssueResponse),
results: [res, ...(prevData?.results ?? [])],
count: (prevData?.count ?? 0) + 1,
};
},
false
);
if (formData.sprints && formData.sprints !== null) { if (formData.sprints && formData.sprints !== null) {
await addIssueToSprint(res.id, formData.sprints, formData); await addIssueToSprint(res.id, formData.sprints, formData);
@ -189,13 +195,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
message: `Issue ${data ? "updated" : "created"} successfully`, message: `Issue ${data ? "updated" : "created"} successfully`,
}); });
if (formData.assignees_list.some((assignee) => assignee === user?.id)) { if (formData.assignees_list.some((assignee) => assignee === user?.id)) {
mutate<IIssue[]>( mutate<IIssue[]>(USER_ISSUE);
USER_ISSUE,
(prevData) => {
return [res, ...(prevData ?? [])];
},
false
);
} }
}) })
.catch((err) => { .catch((err) => {
@ -261,6 +261,8 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
return () => setMostSimilarIssue(undefined); return () => setMostSimilarIssue(undefined);
}, []); }, []);
// console.log(watch("parent"));
return ( return (
<> <>
{activeProject && ( {activeProject && (
@ -381,6 +383,13 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
error={errors.description} error={errors.description}
register={register} register={register}
/> />
{/* <Controller
name="description"
control={control}
render={({ field }) => (
<RichTextEditor {...field} id="issueDescriptionEditor" />
)}
/> */}
</div> </div>
<div> <div>
<Input <Input
@ -398,9 +407,48 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
<SelectState control={control} setIsOpen={setIsStateModalOpen} /> <SelectState control={control} setIsOpen={setIsStateModalOpen} />
<SelectCycles control={control} setIsOpen={setIsCycleModalOpen} /> <SelectCycles control={control} setIsOpen={setIsCycleModalOpen} />
<SelectPriority control={control} /> <SelectPriority control={control} />
<SelectLabels control={control} />
<SelectAssignee control={control} /> <SelectAssignee control={control} />
<SelectParent control={control} /> <SelectLabels control={control} />
<SelectParent
control={control}
isOpen={parentIssueListModalOpen}
setIsOpen={setParentIssueListModalOpen}
issues={issues}
/>
<Menu as="div" className="relative inline-block">
<Menu.Button className="grid place-items-center p-1 hover:bg-gray-100 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">
<EllipsisHorizontalIcon className="h-5 w-5" />
</Menu.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-right absolute right-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
<div className="p-1">
<Menu.Item as="div">
<button
type="button"
className="p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap "
onClick={() => setParentIssueListModalOpen(true)}
>
{watch("parent") && watch("parent") !== ""
? `${activeProject?.identifier}-${
issues?.results.find((i) => i.id === watch("parent"))
?.sequence_id
}`
: "Select Parent Issue"}
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,140 @@
// react
import React, { useState } from "react";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// icons
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IssueResponse } from "types";
import { classNames } from "constants/common";
import useUser from "lib/hooks/useUser";
type Props = {
isOpen: boolean;
handleClose: () => void;
onChange: (...event: any[]) => void;
issues: IssueResponse | undefined;
};
const IssuesListModal: React.FC<Props> = ({ isOpen, handleClose: onClose, onChange, issues }) => {
const [query, setQuery] = useState("");
const { activeProject } = useUser();
const handleClose = () => {
onClose();
setQuery("");
};
const filteredIssues: IIssue[] =
query === ""
? issues?.results ?? []
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
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">
<Combobox onChange={onChange}>
<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={(e) => setQuery(e.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">
Issues
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
value={issue.id}
className={({ active }) =>
classNames(
"flex items-center gap-2 cursor-pointer select-none rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
onClick={() => {
// setIssueIdFromList(issue.id);
handleClose();
}}
>
<span
className={`h-1.5 w-1.5 block rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>{" "}
{issue.name}
</Combobox.Option>
))}
</ul>
</li>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<RectangleStackIcon
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>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</>
);
};
export default IssuesListModal;

View File

@ -14,6 +14,7 @@ import { IIssue, IssueResponse, IState, NestedKeyOf, Properties, WorkspaceMember
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// fetch keys // fetch keys
import { PRIORITIES } from "constants/";
import { PROJECT_ISSUES_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { PROJECT_ISSUES_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// services // services
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.services";
@ -36,8 +37,6 @@ type Props = {
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>; handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
}; };
const PRIORITIES = ["high", "medium", "low"];
const ListView: React.FC<Props> = ({ const ListView: React.FC<Props> = ({
properties, properties,
groupedByIssues, groupedByIssues,
@ -175,10 +174,6 @@ const ListView: React.FC<Props> = ({
<td className="px-3 py-4 font-medium text-gray-900 text-xs whitespace-nowrap"> <td className="px-3 py-4 font-medium text-gray-900 text-xs whitespace-nowrap">
{activeProject?.identifier}-{issue.sequence_id} {activeProject?.identifier}-{issue.sequence_id}
</td> </td>
) : (key as keyof Properties) === "description" ? (
<td className="px-3 py-4 font-medium text-gray-900 truncate text-xs max-w-[15rem]">
{issue.description}
</td>
) : (key as keyof Properties) === "priority" ? ( ) : (key as keyof Properties) === "priority" ? (
<td className="px-3 py-4 text-sm font-medium text-gray-900 relative"> <td className="px-3 py-4 text-sm font-medium text-gray-900 relative">
<Listbox <Listbox
@ -389,10 +384,6 @@ const ListView: React.FC<Props> = ({
)} )}
</Listbox> </Listbox>
</td> </td>
) : (key as keyof Properties) === "children" ? (
<td className="px-3 py-4 text-sm font-medium text-gray-900">
No children.
</td>
) : (key as keyof Properties) === "target_date" ? ( ) : (key as keyof Properties) === "target_date" ? (
<td className="px-3 py-4 text-sm font-medium text-gray-900 whitespace-nowrap"> <td className="px-3 py-4 text-sm font-medium text-gray-900 whitespace-nowrap">
{issue.target_date {issue.target_date
@ -449,4 +440,4 @@ const ListView: React.FC<Props> = ({
); );
}; };
export default ListView; export default ListView;

View File

@ -1,18 +1,24 @@
// next // next
import Image from "next/image"; import Image from "next/image";
// ui
import { Spinner } from "ui";
// icons
import { import {
CalendarDaysIcon, CalendarDaysIcon,
ChartBarIcon, ChartBarIcon,
ChatBubbleBottomCenterTextIcon, ChatBubbleBottomCenterTextIcon,
Squares2X2Icon, Squares2X2Icon,
UserIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// types
import { IssueResponse, IState } from "types";
// constants
import { addSpaceIfCamelCase, timeAgo } from "constants/common"; import { addSpaceIfCamelCase, timeAgo } from "constants/common";
import { IIssue, IState } from "types";
import { Spinner } from "ui";
type Props = { type Props = {
issueActivities: any[] | undefined; issueActivities: any[] | undefined;
states: IState[] | undefined; states: IState[] | undefined;
issues: IssueResponse | undefined;
}; };
const activityIcons: { const activityIcons: {
@ -23,9 +29,10 @@ const activityIcons: {
name: <ChatBubbleBottomCenterTextIcon className="h-4 w-4" />, name: <ChatBubbleBottomCenterTextIcon className="h-4 w-4" />,
description: <ChatBubbleBottomCenterTextIcon className="h-4 w-4" />, description: <ChatBubbleBottomCenterTextIcon className="h-4 w-4" />,
target_date: <CalendarDaysIcon className="h-4 w-4" />, target_date: <CalendarDaysIcon className="h-4 w-4" />,
parent: <UserIcon className="h-4 w-4" />,
}; };
const IssueActivitySection: React.FC<Props> = ({ issueActivities, states }) => { const IssueActivitySection: React.FC<Props> = ({ issueActivities, states, issues }) => {
return ( return (
<> <>
{issueActivities ? ( {issueActivities ? (
@ -92,6 +99,10 @@ const IssueActivitySection: React.FC<Props> = ({ issueActivities, states }) => {
states?.find((s) => s.id === activity.old_value)?.name ?? "" states?.find((s) => s.id === activity.old_value)?.name ?? ""
) )
: "None" : "None"
: activity.field === "parent"
? activity.old_value
? issues?.results.find((i) => i.id === activity.old_value)?.name
: "None"
: activity.old_value ?? "None"} : activity.old_value ?? "None"}
</div> </div>
<div> <div>
@ -102,6 +113,10 @@ const IssueActivitySection: React.FC<Props> = ({ issueActivities, states }) => {
states?.find((s) => s.id === activity.new_value)?.name ?? "" states?.find((s) => s.id === activity.new_value)?.name ?? ""
) )
: "None" : "None"
: activity.field === "parent"
? activity.new_value
? issues?.results.find((i) => i.id === activity.new_value)?.name
: "None"
: activity.new_value ?? "None"} : activity.new_value ?? "None"}
</div> </div>
</div> </div>

View File

@ -73,7 +73,7 @@ const ChangeStateDropdown: React.FC<Props> = ({ issue, updateIssues }) => {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" 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"> <Listbox.Options className="fixed 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">
{states?.map((state) => ( {states?.map((state) => (
<Listbox.Option <Listbox.Option
key={state.id} key={state.id}

View File

@ -0,0 +1,197 @@
// react
import React from "react";
// 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 { Button } from "ui";
// icons
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
// types
import { IProject, WorkspaceMember } from "types";
// fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
type Props = {
control: Control<IProject, any>;
isSubmitting: boolean;
};
const ControlSettings: React.FC<Props> = ({ control, isSubmitting }) => {
const { activeWorkspace } = useUser();
const { data: people } = useSWR<WorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<>
<section className="space-y-5">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">Control</h3>
<p className="mt-1 text-sm text-gray-500">Set the control for the project.</p>
</div>
<div className="flex justify-between gap-3">
<div className="w-full md:w-1/2">
<Controller
control={control}
name="project_lead"
render={({ field: { onChange, value } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<Listbox.Label>
<div className="text-gray-500 mb-2">Project Lead</div>
</Listbox.Label>
<div className="relative">
<Listbox.Button className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 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>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</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 z-10 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 ? "text-white bg-theme" : "text-gray-900"
} cursor-default select-none relative py-2 pl-3 pr-9`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.first_name}
</span>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-indigo-600"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
<div className="w-full md:w-1/2">
<Controller
control={control}
name="default_assignee"
render={({ field: { value, onChange } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<Listbox.Label>
<div className="text-gray-500 mb-2">Default Assignee</div>
</Listbox.Label>
<div className="relative">
<Listbox.Button className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 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>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</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 z-10 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 ? "text-white bg-theme" : "text-gray-900"
} cursor-default select-none relative py-2 pl-3 pr-9`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.first_name}
</span>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-indigo-600"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</Button>
</div>
</section>
</>
);
};
export default ControlSettings;

View File

@ -0,0 +1,119 @@
// react
import { useCallback } from "react";
// react-hook-form
import { UseFormRegister, UseFormSetError } from "react-hook-form";
// services
import projectServices from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { Input, Select, TextArea } from "ui";
// types
import { IProject } from "types";
// constants
import { debounce } from "constants/common";
type Props = {
register: UseFormRegister<IProject>;
errors: any;
setError: UseFormSetError<IProject>;
};
const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
const GeneralSettings: React.FC<Props> = ({ register, errors, setError }) => {
const { activeWorkspace } = useUser();
const checkIdentifier = (slug: string, value: string) => {
projectServices.checkProjectIdentifierAvailability(slug, value).then((response) => {
console.log(response);
if (response.exists) setError("identifier", { message: "Identifier already exists" });
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const checkIdentifierAvailability = useCallback(debounce(checkIdentifier, 1500), []);
return (
<>
<section className="space-y-5">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">General</h3>
<p className="mt-1 text-sm text-gray-500">
This information will be displayed to every member of the project.
</p>
</div>
<div className="grid grid-cols-4 gap-3">
<div className="col-span-2">
<Input
id="name"
name="name"
error={errors.name}
register={register}
placeholder="Project Name"
label="Name"
validations={{
required: "Name is required",
}}
/>
</div>
<div>
<Select
name="network"
id="network"
options={Object.keys(NETWORK_CHOICES).map((key) => ({
value: key,
label: NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES],
}))}
label="Network"
register={register}
validations={{
required: "Network is required",
}}
/>
</div>
<div>
<Input
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Enter identifier"
label="Identifier"
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>
<div>
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
label="Description"
placeholder="Enter project description"
validations={{
required: "Description is required",
}}
/>
</div>
</section>
</>
);
};
export default GeneralSettings;

View File

@ -0,0 +1,275 @@
// react
import React, { useState } from "react";
// swr
import useSWR from "swr";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// react-color
import { TwitterPicker } from "react-color";
// services
import issuesServices from "lib/services/issues.services";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
import { Popover, Transition, Menu } from "@headlessui/react";
// ui
import { Button, Input, Spinner } from "ui";
// icons
import {
ChevronDownIcon,
EllipsisHorizontalIcon,
PencilIcon,
PlusIcon,
RectangleGroupIcon,
} from "@heroicons/react/24/outline";
// types
import { IIssueLabels } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
const defaultValues: Partial<IIssueLabels> = {
name: "",
colour: "#ff0000",
};
const LabelsSettings: React.FC = () => {
const [newLabelForm, setNewLabelForm] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [labelIdForUpdate, setLabelidForUpdate] = useState<string | null>(null);
const { activeWorkspace, activeProject } = useUser();
const {
register,
handleSubmit,
reset,
control,
setValue,
formState: { errors, isSubmitting },
watch,
} = useForm<IIssueLabels>({ defaultValues });
const { data: issueLabels, mutate } = useSWR<IIssueLabels[]>(
activeProject && activeWorkspace ? PROJECT_ISSUE_LABELS(activeProject.id) : null,
activeProject && activeWorkspace
? () => issuesServices.getIssueLabels(activeWorkspace.slug, activeProject.id)
: null
);
const handleNewLabel: SubmitHandler<IIssueLabels> = (formData) => {
if (!activeWorkspace || !activeProject || isSubmitting) return;
issuesServices
.createIssueLabel(activeWorkspace.slug, activeProject.id, formData)
.then((res) => {
console.log(res);
reset(defaultValues);
mutate((prevData) => [...(prevData ?? []), res], false);
setNewLabelForm(false);
});
};
const handleLabelUpdate: SubmitHandler<IIssueLabels> = (formData) => {
if (!activeWorkspace || !activeProject || isSubmitting) return;
issuesServices
.patchIssueLabel(activeWorkspace.slug, activeProject.id, labelIdForUpdate ?? "", formData)
.then((res) => {
console.log(res);
reset(defaultValues);
mutate(
(prevData) =>
prevData?.map((p) => (p.id === labelIdForUpdate ? { ...p, ...formData } : p)),
false
);
setNewLabelForm(false);
});
};
const handleLabelDelete = (labelId: string) => {
if (activeWorkspace && activeProject) {
mutate((prevData) => prevData?.filter((p) => p.id !== labelId), false);
issuesServices
.deleteIssueLabel(activeWorkspace.slug, activeProject.id, labelId)
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});
}
};
const getLabelChildren = (labelId: string) => {
return issueLabels?.filter((l) => l.parent === labelId);
};
return (
<>
<section className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">Labels</h3>
<p className="mt-1 text-sm text-gray-500">Manage the labels of this project.</p>
</div>
<Button className="flex items-center gap-x-1" onClick={() => setNewLabelForm(true)}>
<PlusIcon className="h-4 w-4" />
New label
</Button>
</div>
<div className="space-y-5">
<form
className={`bg-white px-4 py-2 flex items-center gap-2 ${newLabelForm ? "" : "hidden"}`}
>
<div>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`bg-white flex items-center gap-1 rounded-md p-1 outline-none focus:ring-2 focus:ring-indigo-500`}
>
{watch("colour") && watch("colour") !== "" && (
<span
className="w-6 h-6 rounded"
style={{
backgroundColor: watch("colour") ?? "green",
}}
></span>
)}
<ChevronDownIcon className="h-4 w-4" />
</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 z-20 transform left-0 mt-1 px-2 max-w-xs sm:px-0">
<Controller
name="colour"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker
color={value}
onChange={(value) => onChange(value.hex)}
/>
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<Input
type="text"
id="labelName"
name="name"
register={register}
placeholder="Lable title"
/>
<Button type="button" theme="secondary" onClick={() => setNewLabelForm(false)}>
Cancel
</Button>
{isUpdating ? (
<Button type="button" onClick={handleSubmit(handleLabelUpdate)}>
Update
</Button>
) : (
<Button type="button" onClick={handleSubmit(handleNewLabel)}>
Add
</Button>
)}
</form>
{issueLabels ? (
issueLabels.map((label) => {
const children = getLabelChildren(label.id);
return (
<React.Fragment key={label.id}>
{children && children.length === 0 ? (
<div className="bg-white p-2 flex items-center justify-between text-gray-900 rounded-md">
<div className="flex items-center gap-2">
<span
className={`h-1.5 w-1.5 rounded-full`}
style={{
backgroundColor: label.colour,
}}
/>
<p className="text-sm">{label.name}</p>
</div>
<div>
<Menu as="div" className="relative">
<Menu.Button
as="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none`}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</Menu.Button>
<Menu.Items className="absolute origin-top-right right-0.5 mt-1 p-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<Menu.Item>
<button
type="button"
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
onClick={() => {
setNewLabelForm(true);
setValue("colour", label.colour);
setValue("name", label.name);
setIsUpdating(true);
setLabelidForUpdate(label.id);
}}
>
Edit
</button>
</Menu.Item>
<Menu.Item>
<div className="hover:bg-gray-100 border-b last:border-0">
<button
type="button"
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
onClick={() => handleLabelDelete(label.id)}
>
Delete
</button>
</div>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
</div>
) : (
<div className="bg-white p-4 text-gray-900 rounded-md">
<h3 className="font-medium leading-5 flex items-center gap-2">
<RectangleGroupIcon className="h-5 w-5" />
This is the label group title
</h3>
<div className="pl-5 mt-4">
<div className="group text-sm flex justify-between items-center p-2 hover:bg-gray-100 rounded">
<h5 className="flex items-center gap-2">
<div className="w-2 h-2 bg-red-600 rounded-full"></div>
This is the label title
</h5>
<button type="button" className="hidden group-hover:block">
<PencilIcon className="h-3 w-3" />
</button>
</div>
</div>
</div>
)}
</React.Fragment>
);
})
) : (
<div className="flex justify-center py-4">
<Spinner />
</div>
)}
</div>
</section>
</>
);
};
export default LabelsSettings;

View File

@ -0,0 +1,78 @@
// react
import { useState } from "react";
// hooks
import useUser from "lib/hooks/useUser";
// components
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
// ui
import { Button } from "ui";
// icons
import { PencilSquareIcon, PlusIcon } from "@heroicons/react/24/outline";
// constants
import { addSpaceIfCamelCase } from "constants/common";
type Props = {
projectId: string | string[] | undefined;
};
const StatesSettings: React.FC<Props> = ({ projectId }) => {
const [isCreateStateModal, setIsCreateStateModal] = useState(false);
const [selectedState, setSelectedState] = useState<string | undefined>();
const { states } = useUser();
return (
<>
<CreateUpdateStateModal
isOpen={isCreateStateModal || Boolean(selectedState)}
handleClose={() => {
setSelectedState(undefined);
setIsCreateStateModal(false);
}}
projectId={projectId as string}
data={selectedState ? states?.find((state) => state.id === selectedState) : undefined}
/>
<section className="space-y-5">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">State</h3>
<p className="mt-1 text-sm text-gray-500">Manage the state of this project.</p>
</div>
<div className="flex justify-between gap-3">
<div className="w-full space-y-5">
{states?.map((state) => (
<div
key={state.id}
className="bg-white px-4 py-2 rounded flex justify-between items-center"
>
<div className="flex items-center gap-x-2">
<div
className="w-3 h-3 rounded-full"
style={{
backgroundColor: state.color,
}}
></div>
<h4>{addSpaceIfCamelCase(state.name)}</h4>
</div>
<div>
<button type="button" onClick={() => setSelectedState(state.id)}>
<PencilSquareIcon className="h-5 w-5 text-gray-400" />
</button>
</div>
</div>
))}
<Button
type="button"
className="flex items-center gap-x-1"
onClick={() => setIsCreateStateModal(true)}
>
<PlusIcon className="h-4 w-4" />
<span>Add State</span>
</Button>
</div>
</div>
</section>
</>
);
};
export default StatesSettings;

View File

@ -0,0 +1,116 @@
import React, { useRef, useState } from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
import { Button } from "ui";
type Props = {
isOpen: boolean;
onClose: () => void;
handleDelete: () => void;
data?: any;
};
const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data, handleDelete }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const cancelButtonRef = useRef(null);
const handleClose = () => {
onClose();
setIsDeleteLoading(false);
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
handleDelete();
handleClose();
};
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">
Remove {data?.email}?
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to remove member - {`"`}
<span className="italic">{data?.email}</span>
{`"`} ? They will no longer have access to this workspace. 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 ? "Removing..." : "Remove"}
</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 ConfirmWorkspaceMemberRemove;

View File

@ -100,6 +100,8 @@ export const ISSUE_ACTIVITIES = (workspaceSlug: string, projectId: string, issue
export const ISSUE_LABELS = (workspaceSlug: string, projectId: string) => export const ISSUE_LABELS = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-labels/`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-labels/`;
export const ISSUE_LABEL_DETAILS = (workspaceSlug: string, projectId: string, labelId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-labels/${labelId}/`;
export const FILTER_STATE_ISSUES = (workspaceSlug: string, projectId: string, state: string) => export const FILTER_STATE_ISSUES = (workspaceSlug: string, projectId: string, state: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/?state=${state}`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/?state=${state}`;
@ -122,3 +124,10 @@ export const CYCLES_ENDPOINT = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`;
export const CYCLE_DETAIL = (workspaceSlug: string, projectId: string, cycleId: string) => export const CYCLE_DETAIL = (workspaceSlug: string, projectId: string, cycleId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/`;
export const REMOVE_ISSUE_FROM_CYCLE = (
workspaceSlug: string,
projectId: string,
cycleId: string,
bridgeId: string
) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/${bridgeId}/`;

View File

@ -1,3 +1,5 @@
import { NestedKeyOf } from "types";
export const classNames = (...classes: string[]) => { export const classNames = (...classes: string[]) => {
return classes.filter(Boolean).join(" "); return classes.filter(Boolean).join(" ");
}; };
@ -30,6 +32,32 @@ export const groupBy = (array: any[], key: string) => {
}, {}); }, {});
}; };
export const orderArrayBy = (
array: any[],
key: string,
ordering: "ascending" | "descending" = "ascending"
) => {
const innerKey = key.split("."); // split the key by dot
return array.sort((a, b) => {
const keyA = innerKey.reduce((obj, i) => obj[i], a); // get the value of the inner key
const keyB = innerKey.reduce((obj, i) => obj[i], b); // get the value of the inner key
if (keyA < keyB) {
return ordering === "ascending" ? -1 : 1;
}
if (keyA > keyB) {
return ordering === "ascending" ? 1 : -1;
}
return 0;
});
};
export const findHowManyDaysLeft = (date: string | Date) => {
const today = new Date();
const eventDate = new Date(date);
const timeDiff = Math.abs(eventDate.getTime() - today.getTime());
return Math.ceil(timeDiff / (1000 * 3600 * 24));
};
export const timeAgo = (time: any) => { export const timeAgo = (time: any) => {
switch (typeof time) { switch (typeof time) {
case "number": case "number":

View File

@ -0,0 +1,8 @@
export const PRIORITIES = ["urgent", "high", "medium", "low"];
export const ROLE = {
5: "Guest",
10: "Viewer",
15: "Member",
20: "Admin",
};

View File

@ -87,7 +87,7 @@ export const ToastContextProvider: React.FC<{ children: React.ReactNode }> = ({
const timer = setTimeout(() => { const timer = setTimeout(() => {
removeAlert(id); removeAlert(id);
clearTimeout(timer); clearTimeout(timer);
}, 5000); }, 3000);
}, },
[removeAlert] [removeAlert]
); );

View File

@ -211,7 +211,7 @@ const Sidebar: React.FC = () => {
<div className="px-2"> <div className="px-2">
<div <div
className={`relative ${ className={`relative ${
sidebarCollapse ? "flex" : "grid grid-cols-5 gap-1 items-center" sidebarCollapse ? "flex" : "grid grid-cols-5 gap-2 items-center"
}`} }`}
> >
<Menu as="div" className="col-span-4 inline-block text-left w-full"> <Menu as="div" className="col-span-4 inline-block text-left w-full">
@ -224,7 +224,7 @@ const Sidebar: React.FC = () => {
}`} }`}
> >
<div className="flex gap-x-1 items-center"> <div className="flex gap-x-1 items-center">
<div className="h-5 w-5 p-4 flex items-center justify-center bg-gray-500 text-white rounded uppercase relative"> <div className="h-5 w-5 p-4 flex items-center justify-center bg-gray-700 text-white rounded uppercase relative">
{activeWorkspace?.logo && activeWorkspace.logo !== "" ? ( {activeWorkspace?.logo && activeWorkspace.logo !== "" ? (
<Image <Image
src={activeWorkspace.logo} src={activeWorkspace.logo}
@ -259,7 +259,7 @@ const Sidebar: React.FC = () => {
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items className="origin-top-left fixed max-w-[15rem] ml-2 left-0 mt-2 w-full rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50"> <Menu.Items className="origin-top-left fixed max-w-[15rem] ml-2 left-0 mt-2 w-full rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-20">
<div className="p-1"> <div className="p-1">
{workspaces ? ( {workspaces ? (
<> <>
@ -298,14 +298,22 @@ const Sidebar: React.FC = () => {
) : ( ) : (
<p>No workspace found!</p> <p>No workspace found!</p>
)} )}
<Menu.Item> <Menu.Item
{(active) => ( as="button"
<Link href="/create-workspace"> onClick={() => {
<a className="flex items-center gap-x-1 p-2 w-full text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-sm"> router.push("/create-workspace");
<PlusIcon className="w-5 h-5" /> }}
<span>Create Workspace</span> className="w-full"
</a> >
</Link> {({ active }) => (
<a
className={`flex items-center gap-x-1 p-2 w-full text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-sm ${
active ? "bg-theme text-white" : "text-gray-900"
}`}
>
<PlusIcon className="w-5 h-5" />
<span>Create Workspace</span>
</a>
)} )}
</Menu.Item> </Menu.Item>
</> </>
@ -319,16 +327,15 @@ const Sidebar: React.FC = () => {
</Transition> </Transition>
</Menu> </Menu>
{!sidebarCollapse && ( {!sidebarCollapse && (
<Menu as="div" className="inline-block text-left w-full"> <Menu as="div" className="inline-block text-left flex-shrink-0 w-full">
<div className="h-10 w-10"> <div className="h-10 w-10">
<Menu.Button className="grid relative place-items-center h-full w-full rounded-md shadow-sm px-2 py-2 bg-white text-gray-700 hover:bg-gray-50 focus:outline-none"> <Menu.Button className="grid relative place-items-center h-full w-full rounded-md shadow-sm bg-white text-gray-700 hover:bg-gray-50 focus:outline-none">
{user?.avatar && user.avatar !== "" ? ( {user?.avatar && user.avatar !== "" ? (
<Image <Image
src={user.avatar} src={user.avatar}
alt="User Avatar" alt="User Avatar"
layout="fill" layout="fill"
objectFit="cover" className="rounded-md"
className="rounded-full"
/> />
) : ( ) : (
<UserIcon className="h-5 w-5" /> <UserIcon className="h-5 w-5" />
@ -345,7 +352,7 @@ const Sidebar: React.FC = () => {
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items className="origin-top-right absolute left-0 mt-2 w-full rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50"> <Menu.Items className="origin-top-right absolute left-0 mt-2 w-full rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-20">
<div className="p-1"> <div className="p-1">
{userLinks.map((item) => ( {userLinks.map((item) => (
<Menu.Item key={item.name} as="div"> <Menu.Item key={item.name} as="div">
@ -462,7 +469,7 @@ const Sidebar: React.FC = () => {
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items className="origin-top-right absolute right-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50"> <Menu.Items className="origin-top-right absolute right-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-20">
<div className="p-1"> <div className="p-1">
<Menu.Item as="div"> <Menu.Item as="div">
{(active) => ( {(active) => (
@ -553,24 +560,29 @@ const Sidebar: React.FC = () => {
</div> </div>
)} )}
</div> </div>
<div className="px-2 py-2 bg-gray-50 w-full self-baseline flex items-center gap-x-2"> <div
<Tooltip content="Click to toggle sidebar" position="right"> className={`px-2 py-2 bg-gray-50 w-full self-baseline flex items-center ${
<button sidebarCollapse ? "flex-col-reverse" : ""
type="button" }`}
className={`flex items-center gap-3 px-2 py-2 text-xs font-medium rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus:bg-gray-100 focus:text-gray-900 outline-none ${ >
sidebarCollapse ? "justify-center w-full" : ""
}`}
onClick={() => toggleCollapsed()}
>
<ArrowLongLeftIcon
className={`h-4 w-4 text-gray-500 group-hover:text-gray-900 flex-shrink-0 duration-300 ${
sidebarCollapse ? "rotate-180" : ""
}`}
/>
</button>
</Tooltip>
<button <button
type="button" type="button"
className={`flex items-center gap-3 px-2 py-2 text-xs font-medium rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 outline-none ${
sidebarCollapse ? "justify-center w-full" : ""
}`}
onClick={() => toggleCollapsed()}
>
<ArrowLongLeftIcon
className={`h-4 w-4 text-gray-500 group-hover:text-gray-900 flex-shrink-0 duration-300 ${
sidebarCollapse ? "rotate-180" : ""
}`}
/>
</button>
<button
type="button"
className={`flex items-center gap-3 px-2 py-2 text-xs font-medium rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 outline-none ${
sidebarCollapse ? "justify-center w-full" : ""
}`}
onClick={() => { onClick={() => {
const e = new KeyboardEvent("keydown", { const e = new KeyboardEvent("keydown", {
ctrlKey: true, ctrlKey: true,

View File

@ -0,0 +1,94 @@
import { useState } from "react";
// hooks
import useTheme from "./useTheme";
import useUser from "./useUser";
// commons
import { groupBy, orderArrayBy } from "constants/common";
// constants
import { PRIORITIES } from "constants/";
// types
import type { IssueResponse, IIssue, NestedKeyOf } from "types";
const useIssuesFilter = (projectIssues?: IssueResponse) => {
const { issueView, setIssueView, groupByProperty, setGroupByProperty } = useTheme();
const [orderBy, setOrderBy] = useState<NestedKeyOf<IIssue> | null>(null);
const [filterIssue, setFilterIssue] = useState<"activeIssue" | "backlogIssue" | null>(null);
const { states } = useUser();
let groupedByIssues: {
[key: string]: IIssue[];
} = {
...(groupByProperty === "state_detail.name"
? Object.fromEntries(
states
?.sort((a, b) => a.sequence - b.sequence)
?.map((state) => [
state.name,
projectIssues?.results.filter((issue) => issue.state === state.name) ?? [],
]) ?? []
)
: groupByProperty === "priority"
? Object.fromEntries(
PRIORITIES.map((priority) => [
priority,
projectIssues?.results.filter((issue) => issue.priority === priority) ?? [],
])
)
: {}),
...groupBy(projectIssues?.results ?? [], groupByProperty ?? ""),
};
if (orderBy !== null) {
groupedByIssues = Object.fromEntries(
Object.entries(groupedByIssues).map(([key, value]) => [
key,
orderArrayBy(value, orderBy, "descending"),
])
);
}
if (filterIssue !== null) {
if (filterIssue === "activeIssue") {
groupedByIssues = Object.keys(groupedByIssues).reduce((acc, key) => {
const value = groupedByIssues[key];
const filteredValue = value.filter(
(issue) =>
issue.state_detail.group === "started" || issue.state_detail.group === "unstarted"
);
if (filteredValue.length > 0) {
acc[key] = filteredValue;
}
return acc;
}, {} as typeof groupedByIssues);
} else if (filterIssue === "backlogIssue") {
groupedByIssues = Object.keys(groupedByIssues).reduce((acc, key) => {
const value = groupedByIssues[key];
const filteredValue = value.filter(
(issue) =>
issue.state_detail.group === "backlog" || issue.state_detail.group === "cancelled"
);
if (filteredValue.length > 0) {
acc[key] = filteredValue;
}
return acc;
}, {} as typeof groupedByIssues);
}
}
return {
groupedByIssues,
issueView,
setIssueView,
groupByProperty,
setGroupByProperty,
orderBy,
setOrderBy,
filterIssue,
setFilterIssue,
} as const;
};
export default useIssuesFilter;

View File

@ -1,4 +1,4 @@
import { useState, useContext, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
// swr // swr
import useSWR from "swr"; import useSWR from "swr";
// api routes // api routes
@ -11,19 +11,12 @@ import useUser from "./useUser";
import { IssuePriorities, Properties } from "types"; import { IssuePriorities, Properties } from "types";
const initialValues: Properties = { const initialValues: Properties = {
name: true,
key: true, key: true,
parent: false,
project: false,
state: true, state: true,
assignee: true, assignee: true,
description: false,
priority: false, priority: false,
start_date: false, start_date: false,
target_date: false, target_date: false,
sequence_id: false,
attachments: false,
children: false,
cycle: false, cycle: false,
}; };
@ -80,7 +73,22 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
[workspaceSlug, projectId, issueProperties, user] [workspaceSlug, projectId, issueProperties, user]
); );
return [properties, updateIssueProperties] as const; const newProperties = Object.keys(properties).reduce((obj: any, key) => {
if (
key !== "children" &&
key !== "name" &&
key !== "parent" &&
key !== "project" &&
key !== "description" &&
key !== "attachments" &&
key !== "sequence_id"
) {
obj[key] = properties[key as keyof Properties];
}
return obj;
}, {});
return [newProperties, updateIssueProperties] as const;
}; };
export default useIssuesProperties; export default useIssuesProperties;

View File

@ -10,8 +10,8 @@ class ProjectCycleServices extends APIService {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
} }
async createCycle(workspace_slug: string, projectId: string, data: any): Promise<any> { async createCycle(workspaceSlug: string, projectId: string, data: any): Promise<any> {
return this.post(CYCLES_ENDPOINT(workspace_slug, projectId), data) return this.post(CYCLES_ENDPOINT(workspaceSlug, projectId), data)
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })
@ -20,8 +20,8 @@ class ProjectCycleServices extends APIService {
}); });
} }
async getCycles(workspace_slug: string, projectId: string): Promise<any> { async getCycles(workspaceSlug: string, projectId: string): Promise<any> {
return this.get(CYCLES_ENDPOINT(workspace_slug, projectId)) return this.get(CYCLES_ENDPOINT(workspaceSlug, projectId))
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })
@ -30,18 +30,8 @@ class ProjectCycleServices extends APIService {
}); });
} }
async getCycleIssues(workspace_slug: string, projectId: string, cycleId: string): Promise<any> { async getCycleIssues(workspaceSlug: string, projectId: string, cycleId: string): Promise<any> {
return this.get(CYCLE_DETAIL(workspace_slug, projectId, cycleId)) return this.get(CYCLE_DETAIL(workspaceSlug, projectId, cycleId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async getCycle(workspace_slug: string, projectId: string, cycleId: string): Promise<any> {
return this.get(CYCLE_DETAIL(workspace_slug, projectId, cycleId))
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })
@ -51,13 +41,13 @@ class ProjectCycleServices extends APIService {
} }
async updateCycle( async updateCycle(
workspace_slug: string, workspaceSlug: string,
projectId: string, projectId: string,
cycleId: string, cycleId: string,
data: any data: any
): Promise<any> { ): Promise<any> {
return this.put( return this.put(
CYCLE_DETAIL(workspace_slug, projectId, cycleId).replace("cycle-issues/", ""), CYCLE_DETAIL(workspaceSlug, projectId, cycleId).replace("cycle-issues/", ""),
data data
) )
.then((response) => { .then((response) => {
@ -68,10 +58,8 @@ class ProjectCycleServices extends APIService {
}); });
} }
async deleteCycle(workspace_slug: string, projectId: string, cycleId: string): Promise<any> { async deleteCycle(workspaceSlug: string, projectId: string, cycleId: string): Promise<any> {
return this.delete( return this.delete(CYCLE_DETAIL(workspaceSlug, projectId, cycleId).replace("cycle-issues/", ""))
CYCLE_DETAIL(workspace_slug, projectId, cycleId).replace("cycle-issues/", "")
)
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })

View File

@ -10,6 +10,8 @@ import {
ISSUE_LABELS, ISSUE_LABELS,
BULK_DELETE_ISSUES, BULK_DELETE_ISSUES,
BULK_ADD_ISSUES_TO_CYCLE, BULK_ADD_ISSUES_TO_CYCLE,
REMOVE_ISSUE_FROM_CYCLE,
ISSUE_LABEL_DETAILS,
} from "constants/api-routes"; } from "constants/api-routes";
// services // services
import APIService from "lib/services/api.service"; import APIService from "lib/services/api.service";
@ -103,6 +105,21 @@ class ProjectIssuesServices extends APIService {
}); });
} }
async removeIssueFromCycle(
workspaceSlug: string,
projectId: string,
cycleId: string,
bridgeId: string
) {
return this.delete(REMOVE_ISSUE_FROM_CYCLE(workspaceSlug, projectId, cycleId, bridgeId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async createIssueProperties(workspaceSlug: string, projectId: string, data: any): Promise<any> { async createIssueProperties(workspaceSlug: string, projectId: string, data: any): Promise<any> {
return this.post(ISSUE_PROPERTIES_ENDPOINT(workspaceSlug, projectId), data) return this.post(ISSUE_PROPERTIES_ENDPOINT(workspaceSlug, projectId), data)
.then((response) => { .then((response) => {
@ -198,6 +215,31 @@ class ProjectIssuesServices extends APIService {
}); });
} }
async patchIssueLabel(
workspaceSlug: string,
projectId: string,
labelId: string,
data: any
): Promise<any> {
return this.patch(ISSUE_LABEL_DETAILS(workspaceSlug, projectId, labelId), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async deleteIssueLabel(workspaceSlug: string, projectId: string, labelId: string): Promise<any> {
return this.delete(ISSUE_LABEL_DETAILS(workspaceSlug, projectId, labelId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async updateIssue( async updateIssue(
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,

View File

@ -124,12 +124,14 @@ class ProjectServices extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async updateProjectMember( async updateProjectMember(
workspace_slug: string, workspace_slug: string,
project_id: string, project_id: string,
memberId: string memberId: string,
data: any
): Promise<any> { ): Promise<any> {
return this.put(PROJECT_MEMBER_DETAIL(workspace_slug, project_id, memberId)) return this.put(PROJECT_MEMBER_DETAIL(workspace_slug, project_id, memberId), data)
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })
@ -137,6 +139,7 @@ class ProjectServices extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async deleteProjectMember( async deleteProjectMember(
workspace_slug: string, workspace_slug: string,
project_id: string, project_id: string,

View File

@ -111,8 +111,9 @@ class WorkspaceService extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async updateWorkspaceMember(workspace_slug: string, memberId: string): Promise<any> {
return this.put(WORKSPACE_MEMBER_DETAIL(workspace_slug, memberId)) async updateWorkspaceMember(workspace_slug: string, memberId: string, data: any): Promise<any> {
return this.put(WORKSPACE_MEMBER_DETAIL(workspace_slug, memberId), data)
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })
@ -120,6 +121,7 @@ class WorkspaceService extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async deleteWorkspaceMember(workspace_slug: string, memberId: string): Promise<any> { async deleteWorkspaceMember(workspace_slug: string, memberId: string): Promise<any> {
return this.delete(WORKSPACE_MEMBER_DETAIL(workspace_slug, memberId)) return this.delete(WORKSPACE_MEMBER_DETAIL(workspace_slug, memberId))
.then((response) => { .then((response) => {

View File

@ -1,26 +0,0 @@
import React from "react";
import dynamic from "next/dynamic";
const RichTextEditor = dynamic(() => import("../components/lexical/editor"), {
ssr: false,
});
const LexicalViewer = dynamic(() => import("../components/lexical/viewer"), {
ssr: false,
});
const Home = () => {
const [value, setValue] = React.useState("");
const onChange: any = (value: any) => {
console.log(value);
setValue(value);
};
return (
<>
<RichTextEditor onChange={onChange} value={value} id="editor" />
<LexicalViewer id="institution_viewer" value={value} />
</>
);
};
export default Home;

View File

@ -1,5 +1,5 @@
// react // react
import React from "react"; import React, { useState } from "react";
// next // next
import type { NextPage } from "next"; import type { NextPage } from "next";
// swr // swr
@ -22,15 +22,18 @@ import issuesServices from "lib/services/issues.services";
// components // components
import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown"; import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown";
// icons // icons
import { PlusIcon, RectangleStackIcon } from "@heroicons/react/24/outline"; import { ChevronDownIcon, PlusIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
import Link from "next/link"; import Link from "next/link";
import { Menu, Transition } from "@headlessui/react";
const MyIssues: NextPage = () => { const MyIssues: NextPage = () => {
const { user } = useUser(); const [selectedWorkspace, setSelectedWorkspace] = useState<string | null>(null);
const { data: myIssues, mutate: mutateMyIssue } = useSWR<IIssue[]>( const { user, workspaces } = useUser();
const { data: myIssues, mutate: mutateMyIssues } = useSWR<IIssue[]>(
user ? USER_ISSUE : null, user ? USER_ISSUE : null,
user ? () => userService.userIssues() : null user ? () => userService.userIssues() : null
); );
@ -41,7 +44,7 @@ const MyIssues: NextPage = () => {
issueId: string, issueId: string,
issue: Partial<IIssue> issue: Partial<IIssue>
) => { ) => {
mutateMyIssue((prevData) => { mutateMyIssues((prevData) => {
return prevData?.map((prevIssue) => { return prevData?.map((prevIssue) => {
if (prevIssue.id === issueId) { if (prevIssue.id === issueId) {
return { return {
@ -66,6 +69,10 @@ const MyIssues: NextPage = () => {
}); });
}; };
const handleWorkspaceChange = (workspaceId: string | null) => {
setSelectedWorkspace(workspaceId);
};
return ( return (
<AdminLayout> <AdminLayout>
<div className="w-full h-full flex flex-col space-y-5"> <div className="w-full h-full flex flex-col space-y-5">
@ -79,6 +86,63 @@ const MyIssues: NextPage = () => {
<div className="flex items-center justify-between cursor-pointer w-full"> <div className="flex items-center justify-between cursor-pointer w-full">
<h2 className="text-2xl font-medium">My Issues</h2> <h2 className="text-2xl font-medium">My Issues</h2>
<div className="flex items-center gap-x-3"> <div className="flex items-center gap-x-3">
<Menu as="div" className="relative inline-block w-40">
<div className="w-full">
<Menu.Button className="inline-flex justify-between items-center w-full rounded-md shadow-sm p-2 border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-100 focus:outline-none">
<span className="flex gap-x-1 items-center">
{workspaces?.find((w) => w.id === selectedWorkspace)?.name ??
"All workspaces"}
</span>
<div className="flex-grow flex justify-end">
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</div>
</Menu.Button>
</div>
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-left absolute left-0 mt-2 w-full rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
<div className="p-1">
<Menu.Item>
{({ active }) => (
<button
type="button"
className={`${
active ? "bg-theme text-white" : "text-gray-900"
} group flex w-full items-center rounded-md p-2 text-xs`}
onClick={() => handleWorkspaceChange(null)}
>
All workspaces
</button>
)}
</Menu.Item>
{workspaces &&
workspaces.map((workspace) => (
<Menu.Item key={workspace.id}>
{({ active }) => (
<button
type="button"
className={`${
active ? "bg-theme text-white" : "text-gray-900"
} group flex w-full items-center rounded-md p-2 text-xs`}
onClick={() => handleWorkspaceChange(workspace.id)}
>
{workspace.name}
</button>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
<HeaderButton <HeaderButton
Icon={PlusIcon} Icon={PlusIcon}
label="Add Issue" label="Add Issue"
@ -132,36 +196,42 @@ const MyIssues: NextPage = () => {
</tr> </tr>
</thead> </thead>
<tbody className="bg-white"> <tbody className="bg-white">
{myIssues.map((myIssue, index) => ( {myIssues
<tr .filter((i) =>
key={myIssue.id} selectedWorkspace ? i.workspace === selectedWorkspace : true
className={classNames( )
index === 0 ? "border-gray-300" : "border-gray-200", .map((myIssue, index) => (
"border-t text-sm text-gray-900" <tr
)} key={myIssue.id}
> className={classNames(
<td className="px-3 py-4 text-sm font-medium text-gray-900 hover:text-theme max-w-[15rem] duration-300"> index === 0 ? "border-gray-300" : "border-gray-200",
<Link href={`/projects/${myIssue.project}/issues/${myIssue.id}`}> "border-t text-sm text-gray-900"
<a>{myIssue.name}</a> )}
</Link> >
</td> <td className="px-3 py-4 text-sm font-medium text-gray-900 hover:text-theme max-w-[15rem] duration-300">
<td className="px-3 py-4 max-w-[15rem] truncate"> <Link
{myIssue.description} href={`/projects/${myIssue.project}/issues/${myIssue.id}`}
</td> >
<td className="px-3 py-4"> <a>{myIssue.name}</a>
{myIssue.project_detail?.name} </Link>
<br /> </td>
<span className="text-xs">{`(${myIssue.project_detail?.identifier}-${myIssue.sequence_id})`}</span> <td className="px-3 py-4 max-w-[15rem] truncate">
</td> {/* {myIssue.description} */}
<td className="px-3 py-4 capitalize">{myIssue.priority}</td> </td>
<td className="relative px-3 py-4"> <td className="px-3 py-4">
<ChangeStateDropdown {myIssue.project_detail?.name}
issue={myIssue} <br />
updateIssues={updateMyIssues} <span className="text-xs">{`(${myIssue.project_detail?.identifier}-${myIssue.sequence_id})`}</span>
/> </td>
</td> <td className="px-3 py-4 capitalize">{myIssue.priority}</td>
</tr> <td className="relative px-3 py-4">
))} <ChangeStateDropdown
issue={myIssue}
updateIssues={updateMyIssues}
/>
</td>
</tr>
))}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -25,7 +25,8 @@ import { BreadcrumbItem, Breadcrumbs, HeaderButton, Spinner, EmptySpace, EmptySp
import { PlusIcon } from "@heroicons/react/20/solid"; import { PlusIcon } from "@heroicons/react/20/solid";
import { ArrowPathIcon } from "@heroicons/react/24/outline"; import { ArrowPathIcon } from "@heroicons/react/24/outline";
// types // types
import { IIssue, ICycle, SelectSprintType, SelectIssue } from "types"; import { IIssue, ICycle, SelectSprintType, SelectIssue, CycleIssueResponse } from "types";
import { DragDropContext, DropResult } from "react-beautiful-dnd";
const ProjectSprints: NextPage = () => { const ProjectSprints: NextPage = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -35,7 +36,7 @@ const ProjectSprints: NextPage = () => {
const [selectedIssues, setSelectedIssues] = useState<SelectIssue>(); const [selectedIssues, setSelectedIssues] = useState<SelectIssue>();
const [deleteIssue, setDeleteIssue] = useState<string | undefined>(); const [deleteIssue, setDeleteIssue] = useState<string | undefined>();
const { activeWorkspace, activeProject } = useUser(); const { activeWorkspace, activeProject, issues } = useUser();
const router = useRouter(); const router = useRouter();
@ -80,6 +81,75 @@ const ProjectSprints: NextPage = () => {
}); });
}; };
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const { source, destination } = result;
if (source.droppableId === destination.droppableId) return;
if (activeWorkspace && activeProject) {
// remove issue from the source cycle
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(source.droppableId),
(prevData) => prevData?.filter((p) => p.id !== result.draggableId.split(",")[0]),
false
);
// add issue to the destination cycle
mutate(CYCLE_ISSUES(destination.droppableId));
// mutate<CycleIssueResponse[]>(
// CYCLE_ISSUES(destination.droppableId),
// (prevData) => {
// const issueDetails = issues?.results.find(
// (i) => i.id === result.draggableId.split(",")[1]
// );
// const targetResponse = prevData?.find((t) => t.cycle === destination.droppableId);
// console.log(issueDetails, targetResponse, prevData);
// if (targetResponse) {
// console.log("if");
// targetResponse.issue_details = issueDetails as IIssue;
// return prevData;
// } else {
// console.log("else");
// return [
// ...(prevData ?? []),
// {
// cycle: destination.droppableId,
// issue_details: issueDetails,
// } as CycleIssueResponse,
// ];
// }
// },
// false
// );
issuesServices
.removeIssueFromCycle(
activeWorkspace.slug,
activeProject.id,
source.droppableId,
result.draggableId.split(",")[0]
)
.then((res) => {
issuesServices
.addIssueToSprint(activeWorkspace.slug, activeProject.id, destination.droppableId, {
issue: result.draggableId.split(",")[1],
})
.then((res) => {
console.log(res);
})
.catch((e) => {
console.log(e);
});
})
.catch((e) => {
console.log(e);
});
}
// console.log(result);
};
useEffect(() => { useEffect(() => {
if (isOpen) return; if (isOpen) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
@ -142,18 +212,20 @@ const ProjectSprints: NextPage = () => {
<h2 className="text-2xl font-medium">Project Cycle</h2> <h2 className="text-2xl font-medium">Project Cycle</h2>
<HeaderButton Icon={PlusIcon} label="Add Cycle" onClick={() => setIsOpen(true)} /> <HeaderButton Icon={PlusIcon} label="Add Cycle" onClick={() => setIsOpen(true)} />
</div> </div>
<div className="h-full w-full"> <div className="space-y-5">
{cycles.map((cycle) => ( <DragDropContext onDragEnd={handleDragEnd}>
<CycleView {cycles.map((cycle) => (
key={cycle.id} <CycleView
sprint={cycle} key={cycle.id}
selectSprint={setSelectedSprint} cycle={cycle}
projectId={projectId as string} selectSprint={setSelectedSprint}
workspaceSlug={activeWorkspace?.slug as string} projectId={projectId as string}
openIssueModal={openIssueModal} workspaceSlug={activeWorkspace?.slug as string}
addIssueToSprint={addIssueToSprint} openIssueModal={openIssueModal}
/> addIssueToSprint={addIssueToSprint}
))} />
))}
</DragDropContext>
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -7,7 +7,7 @@ import React, { useCallback, useEffect, useState } from "react";
// swr // swr
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// react hook form // react hook form
import { useForm, Controller } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// headless ui // headless ui
import { Disclosure, Menu, Tab, Transition } from "@headlessui/react"; import { Disclosure, Menu, Tab, Transition } from "@headlessui/react";
// services // services
@ -17,7 +17,6 @@ import stateServices from "lib/services/state.services";
import { import {
PROJECT_ISSUES_ACTIVITY, PROJECT_ISSUES_ACTIVITY,
PROJECT_ISSUES_COMMENTS, PROJECT_ISSUES_COMMENTS,
PROJECT_ISSUES_DETAILS,
PROJECT_ISSUES_LIST, PROJECT_ISSUES_LIST,
STATE_LIST, STATE_LIST,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
@ -55,7 +54,7 @@ const IssueDetail: NextPage = () => {
const { issueId, projectId } = router.query; const { issueId, projectId } = router.query;
const { activeWorkspace, activeProject, issues, mutateIssues } = useUser(); const { activeWorkspace, activeProject, issues, mutateIssues, states } = useUser();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isAddAsSubIssueOpen, setIsAddAsSubIssueOpen] = useState(false); const [isAddAsSubIssueOpen, setIsAddAsSubIssueOpen] = useState(false);
@ -76,12 +75,17 @@ const IssueDetail: NextPage = () => {
ssr: false, ssr: false,
}); });
const LexicalViewer = dynamic(() => import("components/lexical/viewer"), {
ssr: false,
});
const { const {
register, register,
formState: { errors }, formState: { errors },
handleSubmit, handleSubmit,
reset, reset,
control, control,
watch,
} = useForm<IIssue>({ } = useForm<IIssue>({
defaultValues: { defaultValues: {
name: "", name: "",
@ -93,6 +97,7 @@ const IssueDetail: NextPage = () => {
blocked_list: [], blocked_list: [],
target_date: new Date().toString(), target_date: new Date().toString(),
cycle: "", cycle: "",
labels_list: [],
}, },
}); });
@ -120,16 +125,10 @@ const IssueDetail: NextPage = () => {
: null : null
); );
const { data: states } = useSWR<IState[]>(
activeWorkspace && activeProject ? STATE_LIST(activeProject.id) : null,
activeWorkspace && activeProject
? () => stateServices.getStates(activeWorkspace.slug, activeProject.id)
: null
);
const submitChanges = useCallback( const submitChanges = useCallback(
(formData: Partial<IIssue>) => { (formData: Partial<IIssue>) => {
if (!activeWorkspace || !activeProject || !issueId) return; if (!activeWorkspace || !activeProject || !issueId) return;
mutateIssues( mutateIssues(
(prevData) => ({ (prevData) => ({
...(prevData as IssueResponse), ...(prevData as IssueResponse),
@ -142,6 +141,7 @@ const IssueDetail: NextPage = () => {
}), }),
false false
); );
issuesServices issuesServices
.patchIssue(activeWorkspace.slug, projectId as string, issueId as string, formData) .patchIssue(activeWorkspace.slug, projectId as string, issueId as string, formData)
.then((response) => { .then((response) => {
@ -181,8 +181,11 @@ const IssueDetail: NextPage = () => {
const nextIssue = issues?.results[issues?.results.findIndex((issue) => issue.id === issueId) + 1]; const nextIssue = issues?.results[issues?.results.findIndex((issue) => issue.id === issueId) + 1];
const subIssues = (issues && issues.results.filter((i) => i.parent === issueDetail?.id)) ?? []; const subIssues = (issues && issues.results.filter((i) => i.parent === issueDetail?.id)) ?? [];
const siblingIssues =
issueDetail &&
issues?.results.filter((i) => i.parent === issueDetail.parent && i.id !== issueDetail.id);
const handleRemove = (issueId: string) => { const handleSubIssueRemove = (issueId: string) => {
if (activeWorkspace && activeProject) { if (activeWorkspace && activeProject) {
issuesServices issuesServices
.patchIssue(activeWorkspace.slug, activeProject.id, issueId, { parent: null }) .patchIssue(activeWorkspace.slug, activeProject.id, issueId, { parent: null })
@ -217,7 +220,7 @@ const IssueDetail: NextPage = () => {
<AddAsSubIssue <AddAsSubIssue
isOpen={isAddAsSubIssueOpen} isOpen={isAddAsSubIssueOpen}
setIsOpen={setIsAddAsSubIssueOpen} setIsOpen={setIsAddAsSubIssueOpen}
parentId={issueDetail?.id ?? ""} parent={issueDetail}
/> />
<div className="flex items-center justify-between w-full mb-5"> <div className="flex items-center justify-between w-full mb-5">
@ -259,20 +262,66 @@ const IssueDetail: NextPage = () => {
<div className="grid grid-cols-4 gap-5"> <div className="grid grid-cols-4 gap-5">
<div className="col-span-3 space-y-5"> <div className="col-span-3 space-y-5">
<div className="bg-secondary rounded-lg p-4"> <div className="bg-secondary rounded-lg p-4">
{/* <Controller {issueDetail.parent !== null && issueDetail.parent !== "" ? (
control={control} <div className="bg-gray-100 flex items-center gap-2 p-2 text-xs rounded mb-5 w-min whitespace-nowrap">
name="description" <Link href={`/projects/${activeProject.id}/issues/${issueDetail.parent}`}>
render={({ field: { value, onChange } }) => ( <a className="flex items-center gap-2">
<RichTextEditor <span
onChange={(state: string) => { className={`h-1.5 w-1.5 block rounded-full`}
handleDescriptionChange(state); style={{
onChange(issueDescriptionValue); backgroundColor: issueDetail.state_detail.color,
}} }}
value={issueDescriptionValue} />
id="editor" <span className="flex-shrink-0 text-gray-600">
/> {activeProject.identifier}-{issueDetail.sequence_id}
)} </span>
/> */} <span className="font-medium truncate">
{issues?.results
.find((i) => i.id === issueDetail.parent)
?.name.substring(0, 50)}
</span>
</a>
</Link>
<Menu as="div" className="relative inline-block">
<Menu.Button className="grid relative place-items-center hover:bg-gray-200 rounded p-1 focus:outline-none">
<EllipsisHorizontalIcon className="h-4 w-4" />
</Menu.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute left-0 mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
<div className="p-1">
{siblingIssues && siblingIssues.length > 0 ? (
siblingIssues.map((issue) => (
<Menu.Item as="div" key={issue.id}>
<Link href={`/projects/${activeProject.id}/issues/${issue.id}`}>
<a className="flex items-center gap-2 p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap">
{activeProject.identifier}-{issue.sequence_id}
</a>
</Link>
</Menu.Item>
))
) : (
<Menu.Item
as="div"
className="flex items-center gap-2 p-2 text-left text-gray-900 text-xs whitespace-nowrap"
>
No other sub-issues
</Menu.Item>
)}
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
) : null}
<div> <div>
<TextArea <TextArea
id="name" id="name"
@ -301,6 +350,18 @@ const IssueDetail: NextPage = () => {
mode="transparent" mode="transparent"
register={register} register={register}
/> />
{/* <Controller
name="description"
control={control}
render={({ field }) => (
<RichTextEditor
{...field}
id="issueDescriptionEditor"
value={JSON.parse(issueDetail.description)}
/>
)}
/> */}
{/* <LexicalViewer id="descriptionViewer" value={JSON.parse(issueDetail.description)} /> */}
</div> </div>
<div className="mt-2"> <div className="mt-2">
{subIssues && subIssues.length > 0 ? ( {subIssues && subIssues.length > 0 ? (
@ -327,7 +388,7 @@ const IssueDetail: NextPage = () => {
}} }}
> >
<PlusIcon className="h-3 w-3" /> <PlusIcon className="h-3 w-3" />
Add new Create new
</button> </button>
<Menu as="div" className="relative inline-block"> <Menu as="div" className="relative inline-block">
@ -349,6 +410,7 @@ const IssueDetail: NextPage = () => {
<Menu.Item as="div"> <Menu.Item as="div">
{(active) => ( {(active) => (
<button <button
type="button"
className="flex items-center gap-2 p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap" className="flex items-center gap-2 p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap"
onClick={() => setIsAddAsSubIssueOpen(true)} onClick={() => setIsAddAsSubIssueOpen(true)}
> >
@ -373,57 +435,59 @@ const IssueDetail: NextPage = () => {
> >
<Disclosure.Panel className="flex flex-col gap-y-1 mt-3"> <Disclosure.Panel className="flex flex-col gap-y-1 mt-3">
{subIssues.map((subIssue) => ( {subIssues.map((subIssue) => (
<Link <div
key={subIssue.id} key={subIssue.id}
href={`/projects/${activeProject.id}/issues/${subIssue.id}`} className="flex justify-between items-center gap-2 p-2 hover:bg-gray-100"
> >
<a className="p-2 flex justify-between items-center rounded text-xs hover:bg-gray-100"> <Link href={`/projects/${activeProject.id}/issues/${subIssue.id}`}>
<div className="flex items-center gap-2"> <a className="flex items-center gap-2 rounded text-xs">
<span <span
className={`h-1.5 w-1.5 block rounded-full`} className={`h-1.5 w-1.5 block rounded-full`}
style={{ style={{
backgroundColor: subIssue.state_detail.color, backgroundColor: subIssue.state_detail.color,
}} }}
/> />
<span className="text-gray-600"> <span className="flex-shrink-0 text-gray-600">
{activeProject.identifier}-{subIssue.sequence_id} {activeProject.identifier}-{subIssue.sequence_id}
</span> </span>
<span className="font-medium">{subIssue.name}</span> <span className="font-medium max-w-sm break-all">
</div> {subIssue.name}
<div> </span>
<Menu as="div" className="relative inline-block"> </a>
<Menu.Button className="grid relative place-items-center focus:outline-none"> </Link>
<EllipsisHorizontalIcon className="h-4 w-4" /> <div>
</Menu.Button> <Menu as="div" className="relative inline-block">
<Menu.Button className="grid relative place-items-center focus:outline-none">
<EllipsisHorizontalIcon className="h-4 w-4" />
</Menu.Button>
<Transition <Transition
as={React.Fragment} as={React.Fragment}
enter="transition ease-out duration-100" enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95" enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100" enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75" leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items className="origin-top-right absolute right-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50"> <Menu.Items className="origin-top-right absolute right-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
<div className="p-1"> <div className="p-1">
<Menu.Item as="div"> <Menu.Item as="div">
{(active) => ( {(active) => (
<button <button
className="flex items-center gap-2 p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap" className="flex items-center gap-2 p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap"
onClick={() => handleRemove(subIssue.id)} onClick={() => handleSubIssueRemove(subIssue.id)}
> >
Remove as sub-issue Remove as sub-issue
</button> </button>
)} )}
</Menu.Item> </Menu.Item>
</div> </div>
</Menu.Items> </Menu.Items>
</Transition> </Transition>
</Menu> </Menu>
</div> </div>
</a> </div>
</Link>
))} ))}
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </Transition>
@ -446,7 +510,7 @@ const IssueDetail: NextPage = () => {
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items className="origin-top-right absolute left-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10"> <Menu.Items className="absolute origin-top-right left-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<div className="p-1"> <div className="p-1">
<Menu.Item as="div"> <Menu.Item as="div">
{(active) => ( {(active) => (
@ -461,7 +525,7 @@ const IssueDetail: NextPage = () => {
}); });
}} }}
> >
Add new Create new
</button> </button>
)} )}
</Menu.Item> </Menu.Item>
@ -515,7 +579,11 @@ const IssueDetail: NextPage = () => {
/> />
</Tab.Panel> </Tab.Panel>
<Tab.Panel> <Tab.Panel>
<IssueActivitySection issueActivities={issueActivities} states={states} /> <IssueActivitySection
issueActivities={issueActivities}
states={states}
issues={issues}
/>
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@ -1,4 +1,3 @@
// react
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
// next // next
import type { NextPage } from "next"; import type { NextPage } from "next";
@ -6,52 +5,77 @@ import { useRouter } from "next/router";
// swr // swr
import useSWR from "swr"; import useSWR from "swr";
// headless ui // headless ui
import { Menu, Popover, Transition } from "@headlessui/react"; import { Popover, Transition } from "@headlessui/react";
// services // hoc
import stateServices from "lib/services/state.services"; import withAuth from "lib/hoc/withAuthWrapper";
import issuesServices from "lib/services/issues.services";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useTheme from "lib/hooks/useTheme";
import useIssuesProperties from "lib/hooks/useIssuesProperties"; import useIssuesProperties from "lib/hooks/useIssuesProperties";
// fetching keys // api routes
import { PROJECT_ISSUES_LIST, STATE_LIST } from "constants/fetch-keys"; import { PROJECT_MEMBERS } from "constants/api-routes";
// services
import projectService from "lib/services/project.service";
// commons // commons
import { groupBy } from "constants/common"; import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
// layouts // layouts
import AdminLayout from "layouts/AdminLayout"; import AdminLayout from "layouts/AdminLayout";
// hooks
import useIssuesFilter from "lib/hooks/useIssuesFilter";
// components // components
import ListView from "components/project/issues/ListView"; import ListView from "components/project/issues/ListView";
import BoardView from "components/project/issues/BoardView"; import BoardView from "components/project/issues/BoardView";
import ConfirmIssueDeletion from "components/project/issues/ConfirmIssueDeletion"; import ConfirmIssueDeletion from "components/project/issues/ConfirmIssueDeletion";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
// ui // ui
import { Spinner } from "ui"; import { Spinner, CustomMenu, BreadcrumbItem, Breadcrumbs } from "ui";
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace"; import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
import HeaderButton from "ui/HeaderButton"; import HeaderButton from "ui/HeaderButton";
import { BreadcrumbItem, Breadcrumbs } from "ui";
// icons // icons
import { ChevronDownIcon, ListBulletIcon, RectangleStackIcon } from "@heroicons/react/24/outline"; import { ChevronDownIcon, ListBulletIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
import { PlusIcon, EyeIcon, EyeSlashIcon, Squares2X2Icon } from "@heroicons/react/20/solid"; import { PlusIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
// types // types
import type { IIssue, IssueResponse, Properties, IState, NestedKeyOf, ProjectMember } from "types"; import type { IIssue, Properties, NestedKeyOf, ProjectMember } from "types";
import { PROJECT_MEMBERS } from "constants/api-routes";
import projectService from "lib/services/project.service";
const PRIORITIES = ["high", "medium", "low"]; const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [
{ name: "State", key: "state_detail.name" },
{ name: "Priority", key: "priority" },
{ name: "Created By", key: "created_by" },
{ name: "None", key: null },
];
const orderByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> }> = [
{ name: "Created", key: "created_at" },
{ name: "Update", key: "updated_at" },
{ name: "Priority", key: "priority" },
];
const filterIssueOptions: Array<{
name: string;
key: "activeIssue" | "backlogIssue" | null;
}> = [
{
name: "All",
key: null,
},
{
name: "Active Issues",
key: "activeIssue",
},
{
name: "Backlog Issues",
key: "backlogIssue",
},
];
const ProjectIssues: NextPage = () => { const ProjectIssues: NextPage = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { issueView, setIssueView, groupByProperty, setGroupByProperty } = useTheme();
const [selectedIssue, setSelectedIssue] = useState< const [selectedIssue, setSelectedIssue] = useState<
(IIssue & { actionType: "edit" | "delete" }) | undefined (IIssue & { actionType: "edit" | "delete" }) | undefined
>(undefined); >(undefined);
const [editIssue, setEditIssue] = useState<string | undefined>();
const [deleteIssue, setDeleteIssue] = useState<string | undefined>(undefined); const [deleteIssue, setDeleteIssue] = useState<string | undefined>(undefined);
const { activeWorkspace, activeProject, issues } = useUser(); const { activeWorkspace, activeProject, issues: projectIssues } = useUser();
const router = useRouter(); const router = useRouter();
@ -62,22 +86,6 @@ const ProjectIssues: NextPage = () => {
projectId as string projectId as string
); );
const { data: projectIssues } = useSWR<IssueResponse>(
projectId && activeWorkspace
? PROJECT_ISSUES_LIST(activeWorkspace.slug, projectId as string)
: null,
activeWorkspace && projectId
? () => issuesServices.getIssues(activeWorkspace.slug, projectId as string)
: null
);
const { data: states } = useSWR<IState[]>(
activeWorkspace && activeProject ? STATE_LIST(activeProject.id) : null,
activeWorkspace && activeProject
? () => stateServices.getStates(activeWorkspace.slug, activeProject.id)
: null
);
const { data: members } = useSWR<ProjectMember[]>( const { data: members } = useSWR<ProjectMember[]>(
activeWorkspace && activeProject ? PROJECT_MEMBERS : null, activeWorkspace && activeProject ? PROJECT_MEMBERS : null,
activeWorkspace && activeProject activeWorkspace && activeProject
@ -85,6 +93,18 @@ const ProjectIssues: NextPage = () => {
: null : null
); );
const {
issueView,
setIssueView,
groupByProperty,
setGroupByProperty,
groupedByIssues,
setOrderBy,
setFilterIssue,
orderBy,
filterIssue,
} = useIssuesFilter(projectIssues);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@ -94,35 +114,6 @@ const ProjectIssues: NextPage = () => {
} }
}, [isOpen]); }, [isOpen]);
const groupedByIssues: {
[key: string]: IIssue[];
} = {
...(groupByProperty === "state_detail.name"
? Object.fromEntries(
states
?.sort((a, b) => a.sequence - b.sequence)
?.map((state) => [
state.name,
projectIssues?.results.filter((issue) => issue.state === state.name) ?? [],
]) ?? []
)
: groupByProperty === "priority"
? Object.fromEntries(
PRIORITIES.map((priority) => [
priority,
projectIssues?.results.filter((issue) => issue.priority === priority) ?? [],
])
)
: {}),
...groupBy(projectIssues?.results ?? [], groupByProperty ?? ""),
};
const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> }> = [
{ name: "State", key: "state_detail.name" },
{ name: "Priority", key: "priority" },
{ name: "Created By", key: "created_by" },
];
return ( return (
<AdminLayout> <AdminLayout>
<CreateUpdateIssuesModal <CreateUpdateIssuesModal
@ -149,7 +140,7 @@ const ProjectIssues: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<h2 className="text-2xl font-medium">Project Issues</h2> <h2 className="text-2xl font-medium">Project Issues</h2>
<div className="flex items-center gap-x-3"> <div className="flex items-center md:gap-x-6 sm:gap-x-3">
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<button <button
type="button" type="button"
@ -176,70 +167,23 @@ const ProjectIssues: NextPage = () => {
<Squares2X2Icon className="h-4 w-4" /> <Squares2X2Icon className="h-4 w-4" />
</button> </button>
</div> </div>
<Menu as="div" className="relative inline-block w-40">
<div className="w-full">
<Menu.Button className="inline-flex justify-between items-center w-full rounded-md shadow-sm p-2 border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-100 focus:outline-none">
<span className="flex gap-x-1 items-center">
{groupByOptions.find((option) => option.key === groupByProperty)?.name ??
"No Grouping"}
</span>
<div className="flex-grow flex justify-end">
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</div>
</Menu.Button>
</div>
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-left absolute left-0 mt-2 w-full rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
<div className="p-1">
{groupByOptions.map((option) => (
<Menu.Item key={option.key}>
{({ active }) => (
<button
type="button"
className={`${
active ? "bg-theme text-white" : "text-gray-900"
} group flex w-full items-center rounded-md p-2 text-xs`}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</button>
)}
</Menu.Item>
))}
{issueView === "list" ? (
<Menu.Item>
{({ active }) => (
<button
type="button"
className={`hover:bg-theme hover:text-white ${
active ? "bg-theme text-white" : "text-gray-900"
} group flex w-full items-center rounded-md p-2 text-xs`}
onClick={() => setGroupByProperty(null)}
>
No grouping
</button>
)}
</Menu.Item>
) : null}
</div>
</Menu.Items>
</Transition>
</Menu>
<Popover className="relative"> <Popover className="relative">
{({ open }) => ( {({ open }) => (
<> <>
<Popover.Button className="inline-flex justify-between items-center rounded-md shadow-sm p-2 border border-gray-300 text-xs font-semibold text-gray-700 hover:bg-gray-100 focus:outline-none w-40"> <Popover.Button
<span>Properties</span> className={classNames(
<ChevronDownIcon className="h-4 w-4" /> open ? "text-gray-900" : "text-gray-500",
"group inline-flex items-center rounded-md bg-transparent text-base font-medium hover:text-gray-900 focus:outline-none border border-gray-300 px-3 py-1"
)}
>
<span>View</span>
<ChevronDownIcon
className={classNames(
open ? "text-gray-600" : "text-gray-400",
"ml-2 h-4 w-4 group-hover:text-gray-500"
)}
aria-hidden="true"
/>
</Popover.Button> </Popover.Button>
<Transition <Transition
@ -251,25 +195,86 @@ const ProjectIssues: NextPage = () => {
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<Popover.Panel className="absolute left-1/2 z-10 mt-1 -translate-x-1/2 transform px-2 sm:px-0 w-full"> <Popover.Panel className="absolute mr-5 right-1/2 z-10 mt-3 w-screen max-w-xs translate-x-1/2 transform px-2 sm:px-0 bg-gray-0 backdrop-filter backdrop-blur-xl bg-opacity-100 rounded-lg shadow-lg overflow-hidden">
<div className="overflow-hidden rounded-lg shadow-lg ring-1 ring-black ring-opacity-5"> <div className="overflow-hidden py-8 px-4">
<div className="relative grid bg-white p-1"> <div className="relative flex flex-col gap-1 gap-y-4">
{Object.keys(properties).map((key) => ( <div className="flex justify-between">
<button <h4 className="text-base text-gray-600">Group by</h4>
key={key} <CustomMenu
className={`text-gray-900 hover:bg-theme hover:text-white flex justify-between w-full items-center rounded-md p-2 text-xs`} label={
onClick={() => setProperties(key as keyof Properties)} groupByOptions.find((option) => option.key === groupByProperty)
?.name ?? "Select"
}
> >
<p className="capitalize">{key.replace("_", " ")}</p> {groupByOptions.map((option) => (
<span className="self-end"> <CustomMenu.MenuItem
{properties[key as keyof Properties] ? ( key={option.key}
<EyeIcon width="18" height="18" /> onClick={() => setGroupByProperty(option.key)}
) : ( >
<EyeSlashIcon width="18" height="18" /> {option.name}
)} </CustomMenu.MenuItem>
</span> ))}
</button> </CustomMenu>
))} </div>
<div className="flex justify-between">
<h4 className="text-base text-gray-600">Order by</h4>
<CustomMenu
label={
orderByOptions.find((option) => option.key === orderBy)?.name ??
"Select"
}
>
{orderByOptions.map((option) =>
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">
<h4 className="text-base text-gray-600">Issue type</h4>
<CustomMenu
label={
filterIssueOptions.find((option) => option.key === filterIssue)
?.name ?? "Select"
}
>
{filterIssueOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setFilterIssue(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="border-b-2"></div>
<div className="relative flex flex-col gap-1">
<h4 className="text-base text-gray-600">Properties</h4>
<div>
{Object.keys(properties).map((key) => (
<button
key={key}
type="button"
className={`px-2 py-1 inline capitalize rounded border border-indigo-600 text-sm m-1 ${
properties[key as keyof Properties]
? "border-indigo-600 bg-indigo-600 text-white"
: ""
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{replaceUnderscoreIfSnakeCase(key)}
</button>
))}
</div>
</div>
</div> </div>
</div> </div>
</Popover.Panel> </Popover.Panel>
@ -335,4 +340,4 @@ const ProjectIssues: NextPage = () => {
); );
}; };
export default ProjectIssues; export default withAuth(ProjectIssues);

View File

@ -1,7 +1,8 @@
import React, { useState } from "react"; import React, { useState } from "react";
// next // next
import { useRouter } from "next/router"; import Image from "next/image";
import type { NextPage } from "next"; import type { NextPage } from "next";
import { useRouter } from "next/router";
// swr // swr
import useSWR from "swr"; import useSWR from "swr";
// headless ui // headless ui
@ -10,19 +11,20 @@ import { Menu } from "@headlessui/react";
import projectService from "lib/services/project.service"; import projectService from "lib/services/project.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// fetching keys // fetching keys
import { PROJECT_MEMBERS, PROJECT_INVITATIONS } from "constants/fetch-keys"; import { PROJECT_MEMBERS, PROJECT_INVITATIONS } from "constants/fetch-keys";
// layouts // layouts
import AdminLayout from "layouts/AdminLayout"; import AdminLayout from "layouts/AdminLayout";
// components // components
import SendProjectInvitationModal from "components/project/SendProjectInvitationModal"; import SendProjectInvitationModal from "components/project/SendProjectInvitationModal";
import ConfirmProjectMemberRemove from "components/project/ConfirmProjectMemberRemove";
// ui // ui
import { Spinner, Button } from "ui"; import { Spinner, CustomListbox } from "ui";
// icons // icons
import { PlusIcon, EllipsisHorizontalIcon } from "@heroicons/react/20/solid"; import { PlusIcon, EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
import HeaderButton from "ui/HeaderButton"; import HeaderButton from "ui/HeaderButton";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
import Image from "next/image";
const ROLE = { const ROLE = {
5: "Guest", 5: "Guest",
@ -33,8 +35,16 @@ const ROLE = {
const ProjectMembers: NextPage = () => { const ProjectMembers: NextPage = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [selectedMember, setSelectedMember] = useState<string | null>(null);
const [selectedRemoveMember, setSelectedRemoveMember] = useState<string | null>(null);
const [selectedInviteRemoveMember, setSelectedInviteRemoveMember] = useState<string | null>(null);
const { activeWorkspace, activeProject } = useUser(); const { activeWorkspace, activeProject } = useUser();
const { setToastAlert } = useToast();
const router = useRouter(); const router = useRouter();
const { projectId } = router.query; const { projectId } = router.query;
@ -75,6 +85,48 @@ const ProjectMembers: NextPage = () => {
return ( return (
<AdminLayout> <AdminLayout>
<ConfirmProjectMemberRemove
isOpen={Boolean(selectedRemoveMember) || Boolean(selectedInviteRemoveMember)}
onClose={() => {
setSelectedRemoveMember(null);
setSelectedInviteRemoveMember(null);
}}
data={members.find(
(item) => item.id === selectedRemoveMember || item.id === selectedInviteRemoveMember
)}
handleDelete={async () => {
if (!activeWorkspace || !projectId) return;
if (selectedRemoveMember) {
await projectService.deleteProjectMember(
activeWorkspace.slug,
projectId as string,
selectedRemoveMember
);
mutateMembers(
(prevData: any[]) =>
prevData?.filter((item: any) => item.id !== selectedRemoveMember),
false
);
}
if (selectedInviteRemoveMember) {
await projectService.deleteProjectInvitation(
activeWorkspace.slug,
projectId as string,
selectedInviteRemoveMember
);
mutateInvitations(
(prevData: any[]) =>
prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
false
);
}
setToastAlert({
type: "success",
message: "Member removed successfully",
title: "Success",
});
}}
/>
<SendProjectInvitationModal isOpen={isOpen} setIsOpen={setIsOpen} members={members} /> <SendProjectInvitationModal isOpen={isOpen} setIsOpen={setIsOpen} members={members} />
{!projectMembers || !projectInvitations ? ( {!projectMembers || !projectInvitations ? (
<div className="h-full w-full grid place-items-center px-4 sm:px-0"> <div className="h-full w-full grid place-items-center px-4 sm:px-0">
@ -137,14 +189,55 @@ const ProjectMembers: NextPage = () => {
{member.email ?? "No email has been added."} {member.email ?? "No email has been added."}
</td> </td>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6"> <td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{ROLE[member.role as keyof typeof ROLE] ?? "None"} {selectedMember === member.id ? (
<CustomListbox
options={Object.keys(ROLE).map((key) => ({
value: key,
display: ROLE[parseInt(key) as keyof typeof ROLE],
}))}
title={ROLE[member.role as keyof typeof ROLE] ?? "Select Role"}
value={member.role}
onChange={(value) => {
if (!activeWorkspace || !projectId) return;
projectService
.updateProjectMember(
activeWorkspace.slug,
projectId as string,
member.id,
{
role: value,
}
)
.then((res) => {
setToastAlert({
type: "success",
message: "Member role updated successfully.",
title: "Success",
});
mutateMembers(
(prevData: any) =>
prevData.map((m: any) => {
return m.id === selectedMember
? { ...m, ...res, role: value }
: m;
}),
false
);
setSelectedMember(null);
})
.catch((err) => {
console.log(err);
});
}}
/>
) : (
ROLE[member.role as keyof typeof ROLE] ?? "None"
)}
</td> </td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 sm:pl-6"> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 sm:pl-6">
{member?.member ? ( {member.status ? (
"Member"
) : member.status ? (
<span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full"> <span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full">
Accepted Active
</span> </span>
) : ( ) : (
<span className="p-0.5 px-2 text-sm bg-yellow-400 text-black rounded-full"> <span className="p-0.5 px-2 text-sm bg-yellow-400 text-black rounded-full">
@ -167,7 +260,17 @@ const ProjectMembers: NextPage = () => {
<button <button
className="w-full text-left py-2 pl-2" className="w-full text-left py-2 pl-2"
type="button" type="button"
onClick={() => {}} onClick={() => {
if (!member.status) {
setToastAlert({
type: "error",
message: "You can't edit a pending invitation.",
title: "Error",
});
} else {
setSelectedMember(member.id);
}
}}
> >
Edit Edit
</button> </button>
@ -178,20 +281,12 @@ const ProjectMembers: NextPage = () => {
<button <button
className="w-full text-left py-2 pl-2" className="w-full text-left py-2 pl-2"
type="button" type="button"
onClick={async () => { onClick={() => {
member.member if (member.status) {
? (await projectService.deleteProjectMember( setSelectedRemoveMember(member.id);
activeWorkspace?.slug as string, } else {
projectId as any, setSelectedInviteRemoveMember(member.id);
member.id }
),
await mutateMembers())
: (await projectService.deleteProjectInvitation(
activeWorkspace?.slug as string,
projectId as any,
member.id
),
await mutateInvitations());
}} }}
> >
Remove Remove

View File

@ -252,6 +252,11 @@ const ProjectSettings: NextPage = () => {
}} }}
/> />
</div> </div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</Button>
</div>
</section> </section>
</Tab.Panel> </Tab.Panel>
<Tab.Panel> <Tab.Panel>
@ -536,4 +541,4 @@ const ProjectSettings: NextPage = () => {
); );
}; };
export default ProjectSettings; export default ProjectSettings;

View File

@ -14,7 +14,7 @@ import useUser from "lib/hooks/useUser";
// layouts // layouts
import DefaultLayout from "layouts/DefaultLayout"; import DefaultLayout from "layouts/DefaultLayout";
// ui // ui
import { Button } from "ui"; import { Button, Spinner } from "ui";
// icons // icons
import { import {
ChartBarIcon, ChartBarIcon,
@ -35,8 +35,9 @@ const WorkspaceInvitation: NextPage = () => {
const { user } = useUser(); const { user } = useUser();
const { data: invitationDetail, error } = useSWR(WORKSPACE_INVITATION, () => const { data: invitationDetail, error } = useSWR(
workspaceService.getWorkspaceInvitation(invitationId as string) invitationId && WORKSPACE_INVITATION,
() => invitationId && workspaceService.getWorkspaceInvitation(invitationId as string)
); );
const handleAccept = () => { const handleAccept = () => {
@ -93,7 +94,7 @@ const WorkspaceInvitation: NextPage = () => {
</> </>
)} )}
</> </>
) : ( ) : error ? (
<EmptySpace <EmptySpace
title="This invitation link is not active anymore." title="This invitation link is not active anymore."
description="Your workspace is where you'll create projects, collaborate on your issues, and organize different streams of work in your Plane account." description="Your workspace is where you'll create projects, collaborate on your issues, and organize different streams of work in your Plane account."
@ -131,6 +132,10 @@ const WorkspaceInvitation: NextPage = () => {
}} }}
/> />
</EmptySpace> </EmptySpace>
) : (
<div className="w-full h-full flex justify-center items-center">
<Spinner />
</div>
)} )}
</div> </div>
</DefaultLayout> </DefaultLayout>

View File

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

View File

@ -8,9 +8,11 @@ import useSWR from "swr";
import { Menu } from "@headlessui/react"; import { Menu } from "@headlessui/react";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// services // services
import workspaceService from "lib/services/workspace.service"; import workspaceService from "lib/services/workspace.service";
// constants // constants
import { ROLE } from "constants/";
import { WORKSPACE_INVITATIONS, WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { WORKSPACE_INVITATIONS, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// hoc // hoc
import withAuthWrapper from "lib/hoc/withAuthWrapper"; import withAuthWrapper from "lib/hoc/withAuthWrapper";
@ -18,26 +20,26 @@ import withAuthWrapper from "lib/hoc/withAuthWrapper";
import AdminLayout from "layouts/AdminLayout"; import AdminLayout from "layouts/AdminLayout";
// components // components
import SendWorkspaceInvitationModal from "components/workspace/SendWorkspaceInvitationModal"; import SendWorkspaceInvitationModal from "components/workspace/SendWorkspaceInvitationModal";
import ConfirmWorkspaceMemberRemove from "components/workspace/ConfirmWorkspaceMemberRemove";
// ui // ui
import { Spinner, Button } from "ui"; import { Spinner, CustomListbox } from "ui";
// icons // icons
import { PlusIcon, EllipsisHorizontalIcon } from "@heroicons/react/20/solid"; import { PlusIcon, EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
import HeaderButton from "ui/HeaderButton"; import HeaderButton from "ui/HeaderButton";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
// types
const ROLE = {
5: "Guest",
10: "Viewer",
15: "Member",
20: "Admin",
};
const WorkspaceInvite: NextPage = () => { const WorkspaceInvite: NextPage = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [selectedMember, setSelectedMember] = useState<string | null>(null);
const [selectedRemoveMember, setSelectedRemoveMember] = useState<string | null>(null);
const [selectedInviteRemoveMember, setSelectedInviteRemoveMember] = useState<string | null>(null);
const { activeWorkspace } = useUser(); const { activeWorkspace } = useUser();
const { setToastAlert } = useToast();
const { data: workspaceMembers, mutate: mutateMembers } = useSWR<any[]>( const { data: workspaceMembers, mutate: mutateMembers } = useSWR<any[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null, activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
@ -74,6 +76,50 @@ const WorkspaceInvite: NextPage = () => {
title: "Plane - Workspace Invite", title: "Plane - Workspace Invite",
}} }}
> >
<ConfirmWorkspaceMemberRemove
isOpen={Boolean(selectedRemoveMember) || Boolean(selectedInviteRemoveMember)}
onClose={() => {
setSelectedRemoveMember(null);
setSelectedInviteRemoveMember(null);
}}
data={
selectedRemoveMember
? members.find((item) => item.id === selectedRemoveMember)
: selectedInviteRemoveMember
? members.find((item) => item.id === selectedInviteRemoveMember)
: null
}
handleDelete={async () => {
if (!activeWorkspace) return;
if (selectedRemoveMember) {
await workspaceService.deleteWorkspaceMember(
activeWorkspace.slug,
selectedRemoveMember
);
mutateMembers(
(prevData) => prevData?.filter((item) => item.id !== selectedRemoveMember),
false
);
}
if (selectedInviteRemoveMember) {
await workspaceService.deleteWorkspaceInvitations(
activeWorkspace.slug,
selectedInviteRemoveMember
);
mutateInvitations(
(prevData) => prevData?.filter((item) => item.id !== selectedInviteRemoveMember),
false
);
}
setToastAlert({
type: "success",
title: "Success",
message: "Member removed successfully",
});
setSelectedRemoveMember(null);
setSelectedInviteRemoveMember(null);
}}
/>
<SendWorkspaceInvitationModal <SendWorkspaceInvitationModal
isOpen={isOpen} isOpen={isOpen}
setIsOpen={setIsOpen} setIsOpen={setIsOpen}
@ -141,16 +187,51 @@ const WorkspaceInvite: NextPage = () => {
{member.email ?? "No email has been added."} {member.email ?? "No email has been added."}
</td> </td>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6"> <td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{ROLE[member.role as keyof typeof ROLE] ?? "None"} {selectedMember === member.id ? (
<CustomListbox
options={Object.keys(ROLE).map((key) => ({
display: ROLE[parseInt(key) as keyof typeof ROLE],
value: key,
}))}
title={ROLE[member.role as keyof typeof ROLE] ?? "None"}
value={member.role}
onChange={(value) => {
workspaceService
.updateWorkspaceMember(activeWorkspace?.slug as string, member.id, {
role: value,
})
.then(() => {
mutateMembers(
(prevData) =>
prevData?.map((m) => {
return m.id === selectedMember ? { ...m, role: value } : m;
}),
false
);
setToastAlert({
title: "Success",
type: "success",
message: "Member role updated successfully.",
});
setSelectedMember(null);
})
.catch(() => {
setToastAlert({
title: "Error",
type: "error",
message: "An error occurred while updating member role.",
});
});
}}
/>
) : (
ROLE[member.role as keyof typeof ROLE] ?? "None"
)}
</td> </td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 sm:pl-6"> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 sm:pl-6">
{member?.member ? ( {member.status ? (
<span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full"> <span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full">
Accepted Active
</span>
) : member.status ? (
<span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full">
Accepted
</span> </span>
) : ( ) : (
<span className="p-0.5 px-2 text-sm bg-yellow-400 text-black rounded-full"> <span className="p-0.5 px-2 text-sm bg-yellow-400 text-black rounded-full">
@ -173,7 +254,18 @@ const WorkspaceInvite: NextPage = () => {
<button <button
className="w-full text-left py-2 pl-2" className="w-full text-left py-2 pl-2"
type="button" type="button"
onClick={() => {}} onClick={() => {
if (!member.status || !member.member) {
setToastAlert({
type: "error",
title: "Error",
message: "You can't edit this member.",
});
return;
} else {
setSelectedMember(member.id);
}
}}
> >
Edit Edit
</button> </button>
@ -184,26 +276,12 @@ const WorkspaceInvite: NextPage = () => {
<button <button
className="w-full text-left py-2 pl-2" className="w-full text-left py-2 pl-2"
type="button" type="button"
onClick={async () => { onClick={() => {
member.member if (member.status) {
? (await workspaceService.deleteWorkspaceMember( setSelectedRemoveMember(member.id);
activeWorkspace?.slug as string, } else {
member.id setSelectedInviteRemoveMember(member.id);
), }
await mutateMembers((prevData) => [
...(prevData ?? [])?.filter(
(m: any) => m.id !== member.id
),
]),
false)
: (await workspaceService.deleteWorkspaceInvitations(
activeWorkspace?.slug as string,
member.id
),
await mutateInvitations((prevData) => [
...(prevData ?? []).filter((m) => m.id !== member.id),
false,
]));
}} }}
> >
Remove Remove
@ -225,4 +303,4 @@ const WorkspaceInvite: NextPage = () => {
); );
}; };
export default withAuthWrapper(WorkspaceInvite); export default withAuthWrapper(WorkspaceInvite);

View File

@ -98,18 +98,11 @@ export type IssuePriorities = {
export type Properties = { export type Properties = {
key: boolean; key: boolean;
name: boolean;
parent: boolean;
project: boolean;
state: boolean; state: boolean;
assignee: boolean; assignee: boolean;
description: boolean;
priority: boolean; priority: boolean;
start_date: boolean; start_date: boolean;
target_date: boolean; target_date: boolean;
sequence_id: boolean;
attachments: boolean;
children: boolean;
cycle: boolean; cycle: boolean;
}; };

View File

@ -17,7 +17,7 @@ export interface ICycle {
issue: string; issue: string;
} }
export interface SprintIssueResponse { export interface CycleIssueResponse {
id: string; id: string;
issue_details: IIssue; issue_details: IIssue;
created_at: Date; created_at: Date;
@ -30,8 +30,8 @@ export interface SprintIssueResponse {
cycle: string; cycle: string;
} }
export type SprintViewProps = { export type CycleViewProps = {
sprint: ICycle; cycle: ICycle;
selectSprint: React.Dispatch<React.SetStateAction<SelectSprintType>>; selectSprint: React.Dispatch<React.SetStateAction<SelectSprintType>>;
projectId: string; projectId: string;
workspaceSlug: string; workspaceSlug: string;

View File

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

View File

@ -7,7 +7,7 @@ import { CheckIcon } from "@heroicons/react/20/solid";
import { Props } from "./types"; import { Props } from "./types";
const CustomListbox: React.FC<Props> = ({ const CustomListbox: React.FC<Props> = ({
title, title = "",
options, options,
value, value,
onChange, onChange,
@ -90,8 +90,6 @@ const CustomListbox: React.FC<Props> = ({
: optionsFontsize === "2xl" : optionsFontsize === "2xl"
? "text-2xl" ? "text-2xl"
: "" : ""
} ${
className || ""
} rounded-md py-1 ring-1 ring-black ring-opacity-5 focus:outline-none z-10`} } rounded-md py-1 ring-1 ring-black ring-opacity-5 focus:outline-none z-10`}
> >
<div className="p-1"> <div className="p-1">

View File

@ -1,5 +1,5 @@
export type Props = { export type Props = {
title: string; title?: string;
label?: string; label?: string;
options?: Array<{ display: string; value: any }>; options?: Array<{ display: string; value: any }>;
icon?: JSX.Element; icon?: JSX.Element;

View File

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

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

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

View File

@ -45,24 +45,14 @@ const SearchListbox: React.FC<Props> = ({
<Combobox.Label className="sr-only">{title}</Combobox.Label> <Combobox.Label className="sr-only">{title}</Combobox.Label>
<Combobox.Button <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 sm:text-sm duration-300 ${
width === "sm" buttonClassName || ""
? "w-32" }`}
: width === "md"
? "w-48"
: width === "lg"
? "w-64"
: width === "xl"
? "w-80"
: width === "2xl"
? "w-96"
: ""
} ${buttonClassName || ""}`}
> >
{icon ?? null} {icon ?? null}
<span <span
className={classNames( className={classNames(
value === null || value === undefined ? "" : "text-gray-900", value === null || value === undefined ? "" : "text-gray-900",
"hidden truncate sm:ml-2 sm:block" "hidden truncate sm:block"
)} )}
> >
{Array.isArray(value) {Array.isArray(value)

View File

@ -6,7 +6,7 @@ const Select: React.FC<Props> = ({
id, id,
label, label,
value, value,
className, className = "",
name, name,
register, register,
disabled, disabled,
@ -27,7 +27,7 @@ const Select: React.FC<Props> = ({
value={value} value={value}
{...(register && register(name, validations))} {...(register && register(name, validations))}
disabled={disabled} disabled={disabled}
className="mt-1 block w-full px-3 py-2 text-base border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md bg-transparent" className={`mt-1 block w-full px-3 py-2 text-base border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md bg-transparent ${className}`}
> >
{options.map((option, index) => ( {options.map((option, index) => (
<option value={option.value} key={index}> <option value={option.value} key={index}>

View File

@ -3,6 +3,7 @@ export { default as Input } from "./Input";
export { default as Select } from "./Select"; export { default as Select } from "./Select";
export { default as TextArea } from "./TextArea"; export { default as TextArea } from "./TextArea";
export { default as CustomListbox } from "./CustomListbox"; export { default as CustomListbox } from "./CustomListbox";
export { default as CustomMenu } from "./CustomMenu";
export { default as Spinner } from "./Spinner"; export { default as Spinner } from "./Spinner";
export { default as Tooltip } from "./Tooltip"; export { default as Tooltip } from "./Tooltip";
export { default as SearchListbox } from "./SearchListbox"; export { default as SearchListbox } from "./SearchListbox";