feat: views added to cycles, fix: overflowing issues

This commit is contained in:
Aaryan Khandelwal 2022-12-13 21:22:44 +05:30
commit 9c18f6fc71
94 changed files with 5316 additions and 2277 deletions

4
apps/app/.env.example Normal file
View File

@ -0,0 +1,4 @@
NEXT_PUBLIC_API_BASE_URL = "<-- endpoint goes here -->"
NEXT_PUBLIC_GOOGLE_CLIENTID = "<-- google client id goes here -->"
NEXT_PUBLIC_GITHUB_ID = "<-- github id goes here -->"
NEXT_PUBLIC_APP_ENVIRONMENT=development

View File

@ -7,7 +7,7 @@ import { useForm } from "react-hook-form";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import issuesServices from "lib/services/issues.services";
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
// icons

View File

@ -8,7 +8,7 @@ import { SubmitHandler, useForm } from "react-hook-form";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import issuesServices from "lib/services/issues.services";
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useTheme from "lib/hooks/useTheme";
@ -22,7 +22,7 @@ import {
} from "@heroicons/react/24/outline";
// components
import ShortcutsModal from "components/command-palette/shortcuts";
import CreateProjectModal from "components/project/CreateProjectModal";
import CreateProjectModal from "components/project/create-project-modal";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal";
// ui

View File

@ -88,9 +88,7 @@ const EmailPasswordForm = ({ onSuccess }: any) => {
<div className="flex items-center justify-between mt-2">
<div className="text-sm ml-auto">
<Link href={"/forgot-password"}>
<a className="font-medium text-indigo-600 hover:text-indigo-500">
Forgot your password?
</a>
<a className="font-medium text-theme hover:text-indigo-500">Forgot your password?</a>
</Link>
</div>
</div>

View File

@ -27,7 +27,7 @@ import { getValidatedValue } from "./helpers/editor";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
export interface RichTextEditorProps {
onChange: (state: SerializedEditorState) => void;
onChange: (state: string) => void;
id: string;
value: string;
placeholder?: string;
@ -41,8 +41,7 @@ const RichTextEditor: React.FC<RichTextEditorProps> = ({
}) => {
const handleChange = (editorState: EditorState) => {
editorState.read(() => {
let editorData = editorState.toJSON();
if (onChange) onChange(editorData);
onChange(JSON.stringify(editorState.toJSON()));
});
};

View File

@ -18,10 +18,12 @@ export const getValidatedValue = (value: string) => {
const defaultValue =
'{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
console.log("Value: ", value);
if (value) {
try {
console.log(value);
return value;
const data = JSON.parse(value);
return JSON.stringify(data);
} catch (e) {
return defaultValue;
}

View File

@ -20,7 +20,7 @@ import { Button, Select, TextArea } from "ui";
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/20/solid";
// types
import { ProjectMember, WorkspaceMember } from "types";
import { IProjectMemberInvitation } from "types";
type Props = {
isOpen: boolean;
@ -28,6 +28,11 @@ type Props = {
members: any[];
};
type ProjectMember = IProjectMemberInvitation & {
member_id: string;
user_id: string;
};
const defaultValues: Partial<ProjectMember> = {
email: "",
message: "",
@ -49,7 +54,7 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
const { setToastAlert } = useToast();
const { data: people } = useSWR<WorkspaceMember[]>(
const { data: people } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null,
{
@ -211,7 +216,7 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-indigo-600"
active ? "text-white" : "text-theme"
}`}
>
<CheckIcon

View File

@ -9,19 +9,26 @@ import useToast from "lib/hooks/useToast";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
import { Button } from "ui";
import { Button, Input } from "ui";
// types
import type { IProject } from "types";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data?: IProject;
onClose: () => void;
data: IProject | null;
};
const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, data, onClose }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [selectedProject, setSelectedProject] = useState<IProject | null>(null);
const [confirmProjectName, setConfirmProjectName] = useState("");
const [confirmDeleteMyProject, setConfirmDeleteMyProject] = useState(false);
const canDelete = confirmProjectName === data?.name && confirmDeleteMyProject;
const { activeWorkspace, mutateProjects } = useUser();
const { setToastAlert } = useToast();
@ -29,13 +36,18 @@ const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) =>
const cancelButtonRef = useRef(null);
const handleClose = () => {
setIsOpen(false);
setIsDeleteLoading(false);
const timer = setTimeout(() => {
setConfirmProjectName("");
setConfirmDeleteMyProject(false);
clearTimeout(timer);
}, 350);
onClose();
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!data || !activeWorkspace) return;
if (!data || !activeWorkspace || !canDelete) return;
await projectService
.deleteProject(activeWorkspace.slug, data.id)
.then(() => {
@ -54,8 +66,14 @@ const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) =>
};
useEffect(() => {
data && setIsOpen(true);
}, [data, setIsOpen]);
if (data) setSelectedProject(data);
else {
const timer = setTimeout(() => {
setSelectedProject(null);
clearTimeout(timer);
}, 300);
}
}, [data]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
@ -104,11 +122,48 @@ const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) =>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete project - {`"`}
<span className="italic">{data?.name}</span>
<span className="italic">{selectedProject?.name}</span>
{`"`} ? All of the data related to the project will be permanently
removed. This action cannot be undone.
</p>
</div>
<div className="h-0.5 bg-gray-200 my-3" />
<div className="mt-3">
<p className="text-sm">
Enter the project name{" "}
<span className="font-semibold">{selectedProject?.name}</span> to
continue:
</p>
<Input
type="text"
placeholder="Project name"
className="mt-2"
value={confirmProjectName}
onChange={(e) => {
setConfirmProjectName(e.target.value);
}}
name="projectName"
/>
</div>
<div className="mt-3">
<p className="text-sm">
To confirm, type <span className="font-semibold">delete my project</span>{" "}
below:
</p>
<Input
type="text"
placeholder="Enter 'delete my project'"
className="mt-2"
onChange={(e) => {
if (e.target.value === "delete my project") {
setConfirmDeleteMyProject(true);
} else {
setConfirmDeleteMyProject(false);
}
}}
name="typeDelete"
/>
</div>
</div>
</div>
</div>
@ -117,7 +172,7 @@ const ConfirmProjectDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) =>
type="button"
onClick={handleDeletion}
theme="danger"
disabled={isDeleteLoading}
disabled={isDeleteLoading || !canDelete}
className="inline-flex sm:ml-3"
>
{isDeleteLoading ? "Deleting..." : "Delete"}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect } from "react";
// swr
import useSWR, { mutate } from "swr";
// react hook form
@ -8,6 +8,10 @@ import { Dialog, Transition } from "@headlessui/react";
// services
import projectServices from "lib/services/project.service";
import workspaceService from "lib/services/workspace.service";
// common
import { createSimilarString } from "constants/common";
// constants
import { NETWORK_CHOICES } from "constants/";
// fetch keys
import { PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// hooks
@ -15,21 +19,19 @@ import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// ui
import { Button, Input, TextArea, Select } from "ui";
// common
import { debounce } from "constants/common";
// types
import { IProject, WorkspaceMember } from "types";
import { IProject } from "types";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
const defaultValues: Partial<IProject> = {
name: "",
identifier: "",
description: "",
network: 0,
};
const IsGuestCondition: React.FC<{
@ -60,11 +62,16 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const { activeWorkspace, user } = useUser();
const { data: workspaceMembers } = useSWR<WorkspaceMember[]>(
const { data: workspaceMembers } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null,
{
shouldRetryOnError: false,
}
);
const [recommendedIdentifier, setRecommendedIdentifier] = useState<string[]>([]);
const { setToastAlert } = useToast();
const [isChangeIdentifierRequired, setIsChangeIdentifierRequired] = useState(true);
@ -75,12 +82,11 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
handleSubmit,
reset,
setError,
clearErrors,
watch,
setValue,
} = useForm<IProject>({
defaultValues,
reValidateMode: "onChange",
mode: "all",
});
const onSubmit = async (formData: IProject) => {
@ -111,6 +117,7 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
handleClose();
return;
}
err = err.data;
Object.keys(err).map((key) => {
const errorMessages = err[key];
setError(key as keyof IProject, {
@ -123,22 +130,30 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const projectName = watch("name") ?? "";
const projectIdentifier = watch("identifier") ?? "";
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), []);
useEffect(() => {
if (projectName && isChangeIdentifierRequired) {
setValue("identifier", projectName.replace(/ /g, "-").toUpperCase().substring(0, 3));
setValue("identifier", projectName.replace(/ /g, "").toUpperCase().substring(0, 3));
}
}, [projectName, projectIdentifier, setValue, isChangeIdentifierRequired]);
useEffect(() => {
if (!projectName) return;
const suggestedIdentifier = createSimilarString(
projectName.replace(/ /g, "").toUpperCase().substring(0, 3)
);
setRecommendedIdentifier([
suggestedIdentifier + Math.floor(Math.random() * 101),
suggestedIdentifier + Math.floor(Math.random() * 101),
projectIdentifier.toUpperCase().substring(0, 3) + Math.floor(Math.random() * 101),
projectIdentifier.toUpperCase().substring(0, 3) + Math.floor(Math.random() * 101),
]);
}, [errors.identifier, projectIdentifier, projectName]);
useEffect(() => {
return () => setIsChangeIdentifierRequired(true);
}, [isOpen]);
if (workspaceMembers) {
const isMember = workspaceMembers.find((member) => member.member.id === user?.id);
const isGuest = workspaceMembers.find(
@ -234,11 +249,7 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
placeholder="Enter Project Identifier"
error={errors.identifier}
register={register}
onChange={(e: any) => {
setIsChangeIdentifierRequired(false);
if (!activeWorkspace || !e.target.value) return;
checkIdentifierAvailability(activeWorkspace.slug, e.target.value);
}}
onChange={() => setIsChangeIdentifierRequired(false)}
validations={{
required: "Identifier is required",
minLength: {
@ -251,6 +262,27 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
},
}}
/>
{errors.identifier && (
<div className="mt-2">
<p>Ops! Identifier is already taken. Try one of the following:</p>
<div className="flex gap-x-2">
{recommendedIdentifier.map((identifier) => (
<button
key={identifier}
type="button"
className="text-sm text-gray-500 hover:text-gray-700 border p-2 py-0.5 rounded"
onClick={() => {
clearErrors("identifier");
setValue("identifier", identifier);
setIsChangeIdentifierRequired(false);
}}
>
{identifier}
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>

View File

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

View File

@ -0,0 +1,662 @@
// react
import React, { useState } from "react";
// next
import Link from "next/link";
import Image from "next/image";
// swr
import useSWR from "swr";
// services
import cycleServices from "lib/services/cycles.service";
// hooks
import useUser from "lib/hooks/useUser";
// ui
import { Spinner } from "ui";
// icons
import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
CalendarDaysIcon,
PlusIcon,
EllipsisHorizontalIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import User from "public/user.png";
// types
import {
CycleIssueResponse,
ICycle,
IIssue,
IWorkspaceMember,
NestedKeyOf,
Properties,
} from "types";
// constants
import { CYCLE_ISSUES, WORKSPACE_MEMBERS } from "constants/fetch-keys";
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
import { Menu, Transition } from "@headlessui/react";
import workspaceService from "lib/services/workspace.service";
type Props = {
properties: Properties;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
createdBy: string | null;
bgColor?: string;
openCreateIssueModal: (
sprintId: string,
issue?: IIssue,
actionType?: "create" | "edit" | "delete"
) => void;
openIssuesListModal: (cycleId: string) => void;
removeIssueFromCycle: (cycleId: string, bridgeId: string) => void;
};
const SingleCycleBoard: React.FC<Props> = ({
properties,
groupedByIssues,
selectedGroup,
groupTitle,
createdBy,
bgColor,
openCreateIssueModal,
openIssuesListModal,
removeIssueFromCycle,
}) => {
// Collapse/Expand
const [show, setState] = useState(true);
const { activeWorkspace, activeProject } = useUser();
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<div className={`rounded flex-shrink-0 h-full ${!show ? "" : "w-80 bg-gray-50 border"}`}>
<div className={`${!show ? "" : "h-full space-y-3 overflow-y-auto flex flex-col"}`}>
<div
className={`flex justify-between p-3 pb-0 ${
!show ? "flex-col bg-gray-50 rounded-md border" : ""
}`}
>
<div className={`flex items-center ${!show ? "flex-col gap-2" : "gap-1"}`}>
<div
className={`flex items-center gap-x-1 px-2 bg-slate-900 rounded-md cursor-pointer ${
!show ? "py-2 mb-2 flex-col gap-y-2" : ""
}`}
style={{
border: `2px solid ${bgColor}`,
backgroundColor: `${bgColor}20`,
}}
>
<span
className={`w-3 h-3 block rounded-full ${!show ? "" : "mr-1"}`}
style={{
backgroundColor: Boolean(bgColor) ? bgColor : undefined,
}}
/>
<h2
className={`text-[0.9rem] font-medium capitalize`}
style={{
writingMode: !show ? "vertical-rl" : "horizontal-tb",
}}
>
{groupTitle === null || groupTitle === "null"
? "None"
: createdBy
? createdBy
: addSpaceIfCamelCase(groupTitle)}
</h2>
<span className="text-gray-500 text-sm ml-0.5">
{groupedByIssues[groupTitle].length}
</span>
</div>
</div>
</div>
<div
className={`mt-3 space-y-3 h-full overflow-y-auto px-3 pb-3 ${
!show ? "hidden" : "block"
}`}
>
{groupedByIssues[groupTitle].map((childIssue, index: number) => {
const assignees = [
...(childIssue?.assignees_list ?? []),
...(childIssue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<div key={childIssue.id} className={`border rounded bg-white shadow-sm`}>
<div className="group/card relative p-2 select-none">
<div className="opacity-0 group-hover/card:opacity-100 absolute top-1 right-1">
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded text-red-500 hover:bg-red-50 duration-300 outline-none"
// onClick={() => handleDeleteIssue(childIssue.id)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
<Link href={`/projects/${childIssue.project}/issues/${childIssue.id}`}>
<a>
{properties.key && (
<div className="text-xs font-medium text-gray-500 mb-2">
{activeProject?.identifier}-{childIssue.sequence_id}
</div>
)}
<h5
className="group-hover:text-theme text-sm break-all mb-3"
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{childIssue.name}
</h5>
</a>
</Link>
<div className="flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
{properties.priority && (
<div
className={`group flex-shrink-0 flex items-center gap-1 rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
childIssue.priority === "urgent"
? "bg-red-100 text-red-600"
: childIssue.priority === "high"
? "bg-orange-100 text-orange-500"
: childIssue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: childIssue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{/* {getPriorityIcon(childIssue.priority ?? "")} */}
{childIssue.priority ?? "None"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Priority</h5>
<div
className={`capitalize ${
childIssue.priority === "urgent"
? "text-red-600"
: childIssue.priority === "high"
? "text-orange-500"
: childIssue.priority === "medium"
? "text-yellow-500"
: childIssue.priority === "low"
? "text-green-500"
: ""
}`}
>
{childIssue.priority ?? "None"}
</div>
</div>
</div>
)}
{properties.state && (
<div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: childIssue.state_detail.color }}
></span>
{addSpaceIfCamelCase(childIssue.state_detail.name)}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{childIssue.state_detail.name}</div>
</div>
</div>
)}
{properties.start_date && (
<div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{childIssue.start_date
? renderShortNumericDateFormat(childIssue.start_date)
: "N/A"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>{renderShortNumericDateFormat(childIssue.start_date ?? "")}</div>
</div>
</div>
)}
{properties.target_date && (
<div
className={`group flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
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"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">Target date</h5>
<div>{renderShortNumericDateFormat(childIssue.target_date ?? "")}</div>
<div>
{childIssue.target_date &&
(childIssue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft(
childIssue.target_date
)} days`
: findHowManyDaysLeft(childIssue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft(
childIssue.target_date
)} days`
: "Target date")}
</div>
</div>
</div>
)}
{properties.assignee && (
<div className="group flex items-center gap-1 text-xs">
{childIssue.assignee_details?.length > 0 ? (
childIssue.assignee_details?.map((assignee, index: number) => (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
index !== 0 ? "-ml-2.5" : ""
}`}
>
{assignee.avatar && assignee.avatar !== "" ? (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={assignee.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={assignee.name}
/>
</div>
) : (
<div
className={`h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full`}
>
{assignee.first_name.charAt(0)}
</div>
)}
</div>
))
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Assigned to</h5>
<div>
{childIssue.assignee_details?.length > 0
? childIssue.assignee_details
.map((assignee) => assignee.first_name)
.join(", ")
: "No one"}
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
})}
<button
type="button"
className="flex items-center text-xs font-medium hover:bg-gray-100 p-2 rounded duration-300 outline-none"
>
<PlusIcon className="h-3 w-3 mr-1" />
Create
</button>
</div>
</div>
</div>
);
// return (
// <div className={`rounded flex-shrink-0 h-full ${!show ? "" : "w-80 bg-gray-50 border"}`}>
// <div className={`${!show ? "" : "h-full space-y-3 overflow-y-auto flex flex-col"}`}>
// <div
// className={`flex justify-between p-3 pb-0 ${
// !show ? "flex-col bg-gray-50 rounded-md border" : ""
// }`}
// >
// <div className={`flex items-center ${!show ? "flex-col gap-2" : "gap-1"}`}>
// <div
// className={`flex items-center gap-x-1 rounded-md cursor-pointer ${
// !show ? "py-2 mb-2 flex-col gap-y-2" : ""
// }`}
// >
// <h2
// className={`text-[0.9rem] font-medium capitalize`}
// style={{
// writingMode: !show ? "vertical-rl" : "horizontal-tb",
// }}
// >
// {cycle.name}
// </h2>
// <span className="text-gray-500 text-sm ml-0.5">{cycleIssues?.length}</span>
// </div>
// </div>
// <div className={`flex items-center ${!show ? "flex-col pb-2" : ""}`}>
// <button
// type="button"
// className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"
// onClick={() => {
// setState(!show);
// }}
// >
// {show ? (
// <ArrowsPointingInIcon className="h-4 w-4" />
// ) : (
// <ArrowsPointingOutIcon className="h-4 w-4" />
// )}
// </button>
// <Menu as="div" className="relative inline-block">
// <Menu.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>
// <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 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
// <div className="p-1">
// <Menu.Item as="div">
// {(active) => (
// <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={() => openCreateIssueModal(cycle.id)}
// >
// Create new
// </button>
// )}
// </Menu.Item>
// <Menu.Item as="div">
// {(active) => (
// <button
// type="button"
// className="p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap"
// onClick={() => openIssuesListModal(cycle.id)}
// >
// Add an existing issue
// </button>
// )}
// </Menu.Item>
// </div>
// </Menu.Items>
// </Transition>
// </Menu>
// </div>
// </div>
// <div
// className={`mt-3 space-y-3 h-full overflow-y-auto px-3 pb-3 ${
// !show ? "hidden" : "block"
// }`}
// >
// {cycleIssues ? (
// cycleIssues.map((issue, index: number) => (
// <div key={childIssue.id} className="border rounded bg-white shadow-sm">
// <div className="group/card relative p-2 select-none">
// <div className="opacity-0 group-hover/card:opacity-100 absolute top-1 right-1">
// <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>
// <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={() =>
// openCreateIssueModal(cycle.id, childIssue, "delete")
// }
// >
// Delete permanently
// </button>
// </div>
// </Menu.Item>
// </Menu.Items>
// </Menu>
// </div>
// <Link
// href={`/projects/${childIssue.project}/issues/${childIssue.id}`}
// >
// <a>
// {properties.key && (
// <div className="text-xs font-medium text-gray-500 mb-2">
// {activeProject?.identifier}-{childIssue.sequence_id}
// </div>
// )}
// <h5
// className="group-hover:text-theme text-sm break-all mb-3"
// style={{ lineClamp: 3, WebkitLineClamp: 3 }}
// >
// {childIssue.name}
// </h5>
// </a>
// </Link>
// <div className="flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
// {properties.priority && (
// <div
// className={`group flex-shrink-0 flex items-center gap-1 rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
// childIssue.priority === "urgent"
// ? "bg-red-100 text-red-600"
// : childIssue.priority === "high"
// ? "bg-orange-100 text-orange-500"
// : childIssue.priority === "medium"
// ? "bg-yellow-100 text-yellow-500"
// : childIssue.priority === "low"
// ? "bg-green-100 text-green-500"
// : "bg-gray-100"
// }`}
// >
// {/* {getPriorityIcon(childIssue.priority ?? "")} */}
// {childIssue.priority ?? "None"}
// <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1 text-gray-900">Priority</h5>
// <div
// className={`capitalize ${
// childIssue.priority === "urgent"
// ? "text-red-600"
// : childIssue.priority === "high"
// ? "text-orange-500"
// : childIssue.priority === "medium"
// ? "text-yellow-500"
// : childIssue.priority === "low"
// ? "text-green-500"
// : ""
// }`}
// >
// {childIssue.priority ?? "None"}
// </div>
// </div>
// </div>
// )}
// {properties.state && (
// <div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
// <span
// className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
// style={{ backgroundColor: childIssue.state_detail.color }}
// ></span>
// {addSpaceIfCamelCase(childIssue.state_detail.name)}
// <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1">State</h5>
// <div>{childIssue.state_detail.name}</div>
// </div>
// </div>
// )}
// {properties.start_date && (
// <div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
// <CalendarDaysIcon className="h-4 w-4" />
// {childIssue.start_date
// ? renderShortNumericDateFormat(childIssue.start_date)
// : "N/A"}
// <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1">Started at</h5>
// <div>
// {renderShortNumericDateFormat(childIssue.start_date ?? "")}
// </div>
// </div>
// </div>
// )}
// {properties.target_date && (
// <div
// className={`group flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
// 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"}
// <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1 text-gray-900">Target date</h5>
// <div>
// {renderShortNumericDateFormat(childIssue.target_date ?? "")}
// </div>
// <div>
// {childIssue.target_date &&
// (childIssue.target_date < new Date().toISOString()
// ? `Target date has passed by ${findHowManyDaysLeft(
// childIssue.target_date
// )} days`
// : findHowManyDaysLeft(childIssue.target_date) <= 3
// ? `Target date is in ${findHowManyDaysLeft(
// childIssue.target_date
// )} days`
// : "Target date")}
// </div>
// </div>
// </div>
// )}
// {properties.assignee && (
// <div className="group flex items-center gap-1 text-xs">
// {childIssue.assignee_details?.length > 0 ? (
// childIssue.assignee_details?.map((assignee, index: number) => (
// <div
// key={index}
// className={`relative z-[1] h-5 w-5 rounded-full ${
// index !== 0 ? "-ml-2.5" : ""
// }`}
// >
// {assignee.avatar && assignee.avatar !== "" ? (
// <div className="h-5 w-5 border-2 bg-white border-white rounded-full">
// <Image
// src={assignee.avatar}
// height="100%"
// width="100%"
// className="rounded-full"
// alt={assignee.name}
// />
// </div>
// ) : (
// <div
// className={`h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full`}
// >
// {assignee.first_name.charAt(0)}
// </div>
// )}
// </div>
// ))
// ) : (
// <div className="h-5 w-5 border-2 bg-white border-white rounded-full">
// <Image
// src={User}
// height="100%"
// width="100%"
// className="rounded-full"
// alt="No user"
// />
// </div>
// )}
// <div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
// <h5 className="font-medium mb-1">Assigned to</h5>
// <div>
// {childIssue.assignee_details?.length > 0
// ? childIssue.assignee_details
// .map((assignee) => assignee.first_name)
// .join(", ")
// : "No one"}
// </div>
// </div>
// </div>
// )}
// </div>
// </div>
// </div>
// ))
// ) : (
// <div className="w-full h-full flex justify-center items-center">
// <Spinner />
// </div>
// )}
// <button
// type="button"
// className="flex items-center text-xs font-medium hover:bg-gray-100 p-2 rounded duration-300 outline-none"
// onClick={() => openCreateIssueModal(cycle.id)}
// >
// <PlusIcon className="h-3 w-3 mr-1" />
// Create
// </button>
// </div>
// </div>
// </div>
// );
};
export default SingleCycleBoard;

View File

@ -4,7 +4,7 @@ import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import cycleService from "lib/services/cycles.services";
import cycleService from "lib/services/cycles.service";
// fetch api
import { CYCLE_LIST } from "constants/fetch-keys";
// hooks

View File

@ -6,7 +6,7 @@ import { useForm } from "react-hook-form";
// headless
import { Dialog, Transition } from "@headlessui/react";
// services
import cycleService from "lib/services/cycles.services";
import cycleService from "lib/services/cycles.service";
// fetch keys
import { CYCLE_LIST } from "constants/fetch-keys";
// hooks

View File

@ -7,7 +7,7 @@ import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// services
import issuesServices from "lib/services/issues.services";
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
@ -47,7 +47,12 @@ const CycleIssuesListModal: React.FC<Props> = ({
reset();
};
const { handleSubmit, reset, control } = useForm<FormInput>({
const {
handleSubmit,
reset,
control,
formState: { isSubmitting },
} = useForm<FormInput>({
defaultValues: {
issue_ids: [],
},
@ -68,6 +73,7 @@ const CycleIssuesListModal: React.FC<Props> = ({
.bulkAddIssuesToCycle(activeWorkspace.slug, activeProject.id, cycleId, data)
.then((res) => {
console.log(res);
handleClose();
})
.catch((e) => {
console.log(e);
@ -138,36 +144,39 @@ const CycleIssuesListModal: React.FC<Props> = ({
</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>
))}
{filteredIssues.map((issue) => {
// if (issue.cycle !== cycleId)
return (
<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="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
{issue.name}
</>
)}
</Combobox.Option>
);
})}
</ul>
</li>
)}
@ -191,8 +200,13 @@ const CycleIssuesListModal: React.FC<Props> = ({
<Button type="button" theme="danger" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button type="button" size="sm" onClick={handleSubmit(handleAddToCycle)}>
Add to Cycle
<Button
type="button"
size="sm"
onClick={handleSubmit(handleAddToCycle)}
disabled={isSubmitting}
>
{isSubmitting ? "Adding..." : "Add to Cycle"}
</Button>
</div>
</form>

View File

@ -1,332 +0,0 @@
// react
import React, { useState } from "react";
// next
import Link from "next/link";
// swr
import useSWR, { mutate } from "swr";
// headless ui
import { Disclosure, Transition, Menu } from "@headlessui/react";
// services
import cycleServices from "lib/services/cycles.services";
// hooks
import useUser from "lib/hooks/useUser";
// components
import CycleIssuesListModal from "./CycleIssuesListModal";
// ui
import { Spinner } from "ui";
// icons
import { PlusIcon, EllipsisHorizontalIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
// types
import type { CycleViewProps as Props, CycleIssueResponse, IssueResponse } from "types";
// fetch keys
import { CYCLE_ISSUES } from "constants/fetch-keys";
// constants
import { addSpaceIfCamelCase, renderShortNumericDateFormat } from "constants/common";
import issuesServices from "lib/services/issues.services";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd";
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
const CycleView: React.FC<Props> = ({
cycle,
selectSprint,
workspaceSlug,
projectId,
openIssueModal,
}) => {
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
const { activeWorkspace, activeProject, issues } = useUser();
const { data: cycleIssues } = useSWR<CycleIssueResponse[]>(CYCLE_ISSUES(cycle.id), () =>
cycleServices.getCycleIssues(workspaceSlug, projectId, cycle.id)
);
const removeIssueFromCycle = (cycleId: string, bridgeId: string) => {
if (activeWorkspace && activeProject && cycleIssues) {
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 (
<>
<CycleIssuesListModal
isOpen={cycleIssuesListModal}
handleClose={() => setCycleIssuesListModal(false)}
issues={issues}
cycleId={cycle.id}
/>
<Disclosure as="div" defaultOpen>
{({ open }) => (
<div className="bg-white rounded-lg">
<div className="flex justify-between items-center bg-gray-100 px-4 py-3 rounded-t-lg">
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "transform -rotate-90" : ""}`}
/>
</span>
<h2 className="font-medium leading-5">{cycle.name}</h2>
<p className="flex gap-2 text-xs text-gray-500">
<span>
{cycle.status === "started"
? cycle.start_date
? `${renderShortNumericDateFormat(cycle.start_date)} - `
: ""
: cycle.status}
</span>
<span>
{cycle.end_date ? renderShortNumericDateFormat(cycle.end_date) : ""}
</span>
</p>
<p className="text-gray-500 text-sm ml-0.5">{cycleIssues?.length}</p>
</div>
</Disclosure.Button>
<Menu as="div" className="relative inline-block">
<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 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}
className="divide-y-2"
>
{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 px-2 py-3 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-100 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="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-
{issue.issue_details.sequence_id}
</span>
<span>{issue.issue_details.name}</span>
{/* {cycle.id} */}
</a>
</Link>
</div>
<div className="flex items-center gap-2">
<div className="flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{issue.issue_details.start_date
? renderShortNumericDateFormat(
issue.issue_details.start_date
)
: "N/A"}
</div>
<div className="flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issue.issue_details.state_detail.color,
}}
></span>
{addSpaceIfCamelCase(issue.issue_details.state_detail.name)}
</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
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>
<div className="p-3">
<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
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-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<div className="p-1">
<Menu.Item as="div">
{(active) => (
<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={() => openIssueModal(cycle.id)}
>
Create new
</button>
)}
</Menu.Item>
<Menu.Item as="div">
{(active) => (
<button
type="button"
className="p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap"
onClick={() => setCycleIssuesListModal(true)}
>
Add an existing issue
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
)}
</Disclosure>
</>
);
};
export default CycleView;

View File

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

View File

@ -1,338 +0,0 @@
import React, { useState } from "react";
// Next imports
import Link from "next/link";
import Image from "next/image";
// React beautiful dnd
import { Draggable } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// types
import { IIssue, Properties, NestedKeyOf } from "types";
// icons
import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
CalendarDaysIcon,
EllipsisHorizontalIcon,
PlusIcon,
} from "@heroicons/react/24/outline";
import User from "public/user.png";
// common
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
import { getPriorityIcon } from "constants/global";
type Props = {
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
groupedByIssues: {
[key: string]: IIssue[];
};
index: number;
setIsIssueOpen: React.Dispatch<React.SetStateAction<boolean>>;
properties: Properties;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| undefined
>
>;
bgColor?: string;
stateId: string | null;
createdBy: string | null;
};
const SingleBoard: React.FC<Props> = ({
selectedGroup,
groupTitle,
groupedByIssues,
index,
setIsIssueOpen,
properties,
setPreloadedData,
bgColor = "#0f2b16",
stateId,
createdBy,
}) => {
// Collapse/Expand
const [show, setState] = useState<any>(true);
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
return (
<Draggable draggableId={groupTitle} index={index}>
{(provided, snapshot) => (
<div
className={`rounded flex-shrink-0 h-full ${
snapshot.isDragging ? "border-theme shadow-lg" : ""
} ${!show ? "" : "w-80 bg-gray-50 border"}`}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div className={`${!show ? "" : "h-full space-y-3 overflow-y-auto flex flex-col"}`}>
<div
className={`flex justify-between p-3 pb-0 ${
!show ? "flex-col bg-gray-50 rounded-md border" : ""
}`}
>
<div className={`flex items-center ${!show ? "flex-col gap-2" : "gap-1"}`}>
<button
type="button"
{...provided.dragHandleProps}
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
!show ? "" : "rotate-90"
} ${selectedGroup !== "state_detail.name" ? "hidden" : ""}`}
>
<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 items-center gap-x-1 px-2 bg-slate-900 rounded-md cursor-pointer ${
!show ? "py-2 mb-2 flex-col gap-y-2" : ""
}`}
style={{
border: `2px solid ${bgColor}`,
backgroundColor: `${bgColor}20`,
}}
>
<span
className={`w-3 h-3 block rounded-full ${!show ? "" : "mr-1"}`}
style={{
backgroundColor: Boolean(bgColor) ? bgColor : undefined,
}}
/>
<h2
className={`text-[0.9rem] font-medium capitalize`}
style={{
writingMode: !show ? "vertical-rl" : "horizontal-tb",
}}
>
{groupTitle === null || groupTitle === "null"
? "None"
: createdBy
? createdBy
: addSpaceIfCamelCase(groupTitle)}
</h2>
<span className="text-gray-500 text-sm ml-0.5">
{groupedByIssues[groupTitle].length}
</span>
</div>
</div>
<div className={`flex items-center ${!show ? "flex-col pb-2" : ""}`}>
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"
onClick={() => {
setState(!show);
}}
>
{show ? (
<ArrowsPointingInIcon className="h-4 w-4" />
) : (
<ArrowsPointingOutIcon className="h-4 w-4" />
)}
</button>
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"
onClick={() => {
setIsIssueOpen(true);
if (selectedGroup !== null)
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}}
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
</div>
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`mt-3 space-y-3 h-full overflow-y-auto px-3 pb-3 ${
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
} ${!show ? "hidden" : "block"}`}
{...provided.droppableProps}
ref={provided.innerRef}
>
{groupedByIssues[groupTitle].map((childIssue, index: number) => (
<Draggable key={childIssue.id} draggableId={childIssue.id} index={index}>
{(provided, snapshot) => (
<Link href={`/projects/${childIssue.project}/issues/${childIssue.id}`}>
<a
className={`group block border rounded bg-white shadow-sm ${
snapshot.isDragging ? "border-indigo-600 shadow-lg bg-indigo-50" : ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div className="p-2 select-none" {...provided.dragHandleProps}>
{properties.key && (
<div className="text-xs font-medium text-gray-500 mb-2">
{childIssue.project_detail?.identifier}-{childIssue.sequence_id}
</div>
)}
<h5 className="group-hover:text-theme text-sm break-all mb-3">
{childIssue.name}
</h5>
<div className="flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
{properties.priority && (
<div
className={`rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
childIssue.priority === "urgent"
? "bg-red-100 text-red-600"
: childIssue.priority === "high"
? "bg-orange-100 text-orange-500"
: childIssue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: childIssue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{/* {getPriorityIcon(childIssue.priority ?? "")} */}
{childIssue.priority ?? "None"}
</div>
)}
{properties.state && (
<div className="flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: childIssue.state_detail.color }}
></span>
{addSpaceIfCamelCase(childIssue.state_detail.name)}
</div>
)}
{properties.start_date && (
<div className="flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{childIssue.start_date
? renderShortNumericDateFormat(childIssue.start_date)
: "N/A"}
</div>
)}
{properties.target_date && (
<div
className={`flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
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>
)}
</div>
)}
{properties.assignee && (
<div className="justify-end w-full flex items-center gap-1 text-xs">
{childIssue?.assignee_details?.length > 0 ? (
childIssue?.assignee_details?.map(
(assignee, index: number) => (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
index !== 0 ? "-ml-2.5" : ""
}`}
>
{assignee.avatar && assignee.avatar !== "" ? (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={assignee.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={assignee.name}
/>
</div>
) : (
<div
className={`h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full`}
>
{assignee.first_name.charAt(0)}
</div>
)}
</div>
)
)
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
</div>
)}
</div>
</div>
</a>
</Link>
)}
</Draggable>
))}
{provided.placeholder}
<button
type="button"
className="flex items-center text-xs font-medium hover:bg-gray-200 p-2 rounded duration-300 outline-none"
onClick={() => {
setIsIssueOpen(true);
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
>
<PlusIcon className="h-3 w-3 mr-1" />
Create
</button>
</div>
)}
</StrictModeDroppable>
</div>
</div>
)}
</Draggable>
);
};
export default SingleBoard;

View File

@ -7,22 +7,21 @@ import useSWR from "swr";
import type { DropResult } from "react-beautiful-dnd";
import { DragDropContext } from "react-beautiful-dnd";
// services
import stateServices from "lib/services/state.services";
import issuesServices from "lib/services/issues.services";
import stateServices from "lib/services/state.service";
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
// fetching keys
import { STATE_LIST } from "constants/fetch-keys";
// components
import SingleBoard from "components/project/issues/BoardView/SingleBoard";
import SingleBoard from "components/project/issues/BoardView/single-board";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
// ui
import { Spinner } from "ui";
// types
import type { IState, IIssue, Properties, NestedKeyOf, ProjectMember } from "types";
import ConfirmIssueDeletion from "../ConfirmIssueDeletion";
import { TrashIcon } from "@heroicons/react/24/outline";
import type { IState, IIssue, Properties, NestedKeyOf, IProjectMember } from "types";
import ConfirmIssueDeletion from "../confirm-issue-deletion";
type Props = {
properties: Properties;
@ -30,10 +29,19 @@ type Props = {
groupedByIssues: {
[key: string]: IIssue[];
};
members: ProjectMember[] | undefined;
members: IProjectMember[] | undefined;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
};
const BoardView: React.FC<Props> = ({ properties, selectedGroup, groupedByIssues, members }) => {
const BoardView: React.FC<Props> = ({
properties,
selectedGroup,
groupedByIssues,
members,
handleDeleteIssue,
partialUpdateIssue,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isIssueOpen, setIsIssueOpen] = useState(false);
@ -217,6 +225,8 @@ const BoardView: React.FC<Props> = ({ properties, selectedGroup, groupedByIssues
? states?.find((s) => s.name === singleGroup)?.color
: undefined
}
handleDeleteIssue={handleDeleteIssue}
partialUpdateIssue={partialUpdateIssue}
/>
))}
</div>

View File

@ -0,0 +1,609 @@
// react
import React, { useState } from "react";
// next
import Link from "next/link";
import Image from "next/image";
// swr
import useSWR from "swr";
// react-beautiful-dnd
import { Draggable } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// services
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// icons
import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
CalendarDaysIcon,
EllipsisHorizontalIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import User from "public/user.png";
// common
import { PRIORITIES } from "constants/";
import {
addSpaceIfCamelCase,
classNames,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
import { getPriorityIcon } from "constants/global";
// types
import { IIssue, Properties, NestedKeyOf, IWorkspaceMember } from "types";
type Props = {
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
groupedByIssues: {
[key: string]: IIssue[];
};
index: number;
setIsIssueOpen: React.Dispatch<React.SetStateAction<boolean>>;
properties: Properties;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| undefined
>
>;
bgColor?: string;
stateId: string | null;
createdBy: string | null;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
partialUpdateIssue: (formData: Partial<IIssue>, childIssueId: string) => void;
};
const SingleBoard: React.FC<Props> = ({
selectedGroup,
groupTitle,
groupedByIssues,
index,
setIsIssueOpen,
properties,
setPreloadedData,
bgColor = "#0f2b16",
stateId,
createdBy,
handleDeleteIssue,
partialUpdateIssue,
}) => {
// Collapse/Expand
const [show, setShow] = useState(true);
const { activeProject, activeWorkspace, states } = useUser();
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
return (
<Draggable draggableId={groupTitle} index={index}>
{(provided, snapshot) => (
<div
className={`rounded flex-shrink-0 h-full ${
snapshot.isDragging ? "border-theme shadow-lg" : ""
} ${!show ? "" : "w-80 bg-gray-50 border"}`}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div className={`${!show ? "" : "h-full space-y-3 overflow-y-auto flex flex-col"}`}>
<div
className={`flex justify-between p-3 pb-0 ${
!show ? "flex-col bg-gray-50 rounded-md border" : ""
}`}
>
<div className={`flex items-center ${!show ? "flex-col gap-2" : "gap-1"}`}>
<button
type="button"
{...provided.dragHandleProps}
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
!show ? "" : "rotate-90"
} ${selectedGroup !== "state_detail.name" ? "hidden" : ""}`}
>
<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 items-center gap-x-1 px-2 bg-slate-900 rounded-md cursor-pointer ${
!show ? "py-2 mb-2 flex-col gap-y-2" : ""
}`}
style={{
border: `2px solid ${bgColor}`,
backgroundColor: `${bgColor}20`,
}}
>
<span
className={`w-3 h-3 block rounded-full ${!show ? "" : "mr-1"}`}
style={{
backgroundColor: Boolean(bgColor) ? bgColor : undefined,
}}
/>
<h2
className={`text-[0.9rem] font-medium capitalize`}
style={{
writingMode: !show ? "vertical-rl" : "horizontal-tb",
}}
>
{groupTitle === null || groupTitle === "null"
? "None"
: createdBy
? createdBy
: addSpaceIfCamelCase(groupTitle)}
</h2>
<span className="text-gray-500 text-sm ml-0.5">
{groupedByIssues[groupTitle].length}
</span>
</div>
</div>
<div className={`flex items-center ${!show ? "flex-col pb-2" : ""}`}>
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"
onClick={() => {
setShow(!show);
}}
>
{show ? (
<ArrowsPointingInIcon className="h-4 w-4" />
) : (
<ArrowsPointingOutIcon className="h-4 w-4" />
)}
</button>
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"
onClick={() => {
setIsIssueOpen(true);
if (selectedGroup !== null)
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}}
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
</div>
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`mt-3 space-y-3 h-full overflow-y-auto px-3 pb-3 ${
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
} ${!show ? "hidden" : "block"}`}
{...provided.droppableProps}
ref={provided.innerRef}
>
{groupedByIssues[groupTitle].map((childIssue, index: number) => {
const assignees = [
...(childIssue?.assignees_list ?? []),
...(childIssue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<Draggable key={childIssue.id} draggableId={childIssue.id} index={index}>
{(provided, snapshot) => (
<div
className={`border rounded bg-white shadow-sm ${
snapshot.isDragging ? "border-theme shadow-lg bg-indigo-50" : ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
className="group/card relative p-2 select-none"
{...provided.dragHandleProps}
>
<div className="opacity-0 group-hover/card:opacity-100 absolute top-1 right-1">
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded text-red-500 hover:bg-red-50 duration-300 outline-none"
onClick={() => handleDeleteIssue(childIssue.id)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
<Link
href={`/projects/${childIssue.project}/issues/${childIssue.id}`}
>
<a>
{properties.key && (
<div className="text-xs font-medium text-gray-500 mb-2">
{activeProject?.identifier}-{childIssue.sequence_id}
</div>
)}
<h5
className="group-hover:text-theme text-sm break-all mb-3"
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{childIssue.name}
</h5>
</a>
</Link>
<div className="flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
{properties.priority && (
<Listbox
as="div"
value={childIssue.priority}
onChange={(data: string) => {
partialUpdateIssue({ priority: data }, childIssue.id);
}}
className="group relative flex-shrink-0"
>
{({ open }) => (
<>
<div>
<Listbox.Button
className={`rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 capitalize ${
childIssue.priority === "urgent"
? "bg-red-100 text-red-600"
: childIssue.priority === "high"
? "bg-orange-100 text-orange-500"
: childIssue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: childIssue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{childIssue.priority ?? "None"}
</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 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">
{PRIORITIES?.map((priority) => (
<Listbox.Option
key={priority}
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"cursor-pointer capitalize select-none px-3 py-2"
)
}
value={priority}
>
{priority}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">
Priority
</h5>
<div
className={`capitalize ${
childIssue.priority === "urgent"
? "text-red-600"
: childIssue.priority === "high"
? "text-orange-500"
: childIssue.priority === "medium"
? "text-yellow-500"
: childIssue.priority === "low"
? "text-green-500"
: ""
}`}
>
{childIssue.priority ?? "None"}
</div>
</div>
</>
)}
</Listbox>
)}
{properties.state && (
<Listbox
as="div"
value={childIssue.state}
onChange={(data: string) => {
partialUpdateIssue({ state: data }, childIssue.id);
}}
className="group relative flex-shrink-0"
>
{({ open }) => (
<>
<div>
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: childIssue.state_detail.color,
}}
></span>
{addSpaceIfCamelCase(childIssue.state_detail.name)}
</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 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) => (
<Listbox.Option
key={state.id}
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"cursor-pointer select-none px-3 py-2"
)
}
value={state.id}
>
{addSpaceIfCamelCase(state.name)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{childIssue.state_detail.name}</div>
</div>
</>
)}
</Listbox>
)}
{properties.start_date && (
<div className="group flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{childIssue.start_date
? renderShortNumericDateFormat(childIssue.start_date)
: "N/A"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>
{renderShortNumericDateFormat(childIssue.start_date ?? "")}
</div>
</div>
</div>
)}
{properties.target_date && (
<div
className={`group flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
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"}
<div className="fixed -translate-y-3/4 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">
Target date
</h5>
<div>
{renderShortNumericDateFormat(childIssue.target_date ?? "")}
</div>
<div>
{childIssue.target_date &&
(childIssue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft(
childIssue.target_date
)} days`
: findHowManyDaysLeft(childIssue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft(
childIssue.target_date
)} days`
: "Target date")}
</div>
</div>
</div>
)}
{properties.assignee && (
<Listbox
as="div"
value={childIssue.assignees}
onChange={(data: any) => {
const newData = childIssue.assignees ?? [];
if (newData.includes(data)) {
newData.splice(newData.indexOf(data), 1);
} else {
newData.push(data);
}
partialUpdateIssue(
{ assignees_list: newData },
childIssue.id
);
}}
className="group relative flex-shrink-0"
>
{({ open }) => (
<>
<div>
<Listbox.Button>
<div className="flex items-center gap-1 text-xs cursor-pointer">
{assignees.length > 0 ? (
assignees.map((assignee, index: number) => (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
index !== 0 ? "-ml-2.5" : ""
}`}
>
{assignee.avatar && assignee.avatar !== "" ? (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={assignee.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={assignee?.first_name}
/>
</div>
) : (
<div
className={`h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full`}
>
{assignee.first_name?.charAt(0)}
</div>
)}
</div>
))
) : (
<div className="h-5 w-5 border-2 bg-white border-white rounded-full">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
</div>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute left-0 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">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"cursor-pointer select-none p-2"
)
}
value={person.member.id}
>
<div
className={`flex items-center gap-x-1 ${
assignees.includes({
avatar: person.member.avatar,
first_name: person.member.first_name,
email: person.member.email,
})
? "font-medium"
: "font-normal"
}`}
>
{person.member.avatar &&
person.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<Image
src={person.member.avatar}
alt="avatar"
className="rounded-full"
layout="fill"
objectFit="cover"
/>
</div>
) : (
<div className="h-4 w-4 bg-gray-700 text-white grid place-items-center capitalize rounded-full">
{person.member.first_name &&
person.member.first_name !== ""
? person.member.first_name.charAt(0)
: person.member.email.charAt(0)}
</div>
)}
<p>
{person.member.first_name &&
person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</p>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Assigned to</h5>
<div>
{childIssue.assignee_details?.length > 0
? childIssue.assignee_details
.map((assignee) => assignee.first_name)
.join(", ")
: "No one"}
</div>
</div>
</>
)}
</Listbox>
)}
</div>
</div>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
<button
type="button"
className="flex items-center text-xs font-medium hover:bg-gray-100 p-2 rounded duration-300 outline-none"
onClick={() => {
setIsIssueOpen(true);
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
>
<PlusIcon className="h-3 w-3 mr-1" />
Create
</button>
</div>
)}
</StrictModeDroppable>
</div>
</div>
)}
</Draggable>
);
};
export default SingleBoard;

View File

@ -4,7 +4,7 @@ import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import stateServices from "lib/services/state.services";
import stateServices from "lib/services/state.service";
// fetch api
import { STATE_LIST } from "constants/fetch-keys";
// hooks
@ -43,7 +43,7 @@ const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
mutate<IState[]>(
STATE_LIST(data.project),
(prevData) => prevData?.filter((state) => state.id !== data?.id),
false,
false
);
handleClose();
})
@ -98,18 +98,15 @@ const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
/>
</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"
>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Delete State
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete state - {`"`}
<span className="italic">{data?.name}</span>
{`"`} ? All of the data related to the state will be
permanently removed. This action cannot be undone.
{`"`} ? All of the data related to the state will be permanently removed.
This action cannot be undone.
</p>
</div>
</div>

View File

@ -8,7 +8,7 @@ import { TwitterPicker } from "react-color";
// headless
import { Dialog, Popover, Transition } from "@headlessui/react";
// services
import stateService from "lib/services/state.services";
import stateService from "lib/services/state.service";
// fetch keys
import { STATE_LIST } from "constants/fetch-keys";
// hooks

View File

@ -11,7 +11,7 @@ import useUser from "lib/hooks/useUser";
import { PROJECT_MEMBERS } from "constants/fetch-keys";
// types
import type { Control } from "react-hook-form";
import type { IIssue, WorkspaceMember } from "types";
import type { IIssue } from "types";
import { UserIcon } from "@heroicons/react/24/outline";
import { SearchListbox } from "ui";
@ -23,7 +23,7 @@ type Props = {
const SelectAssignee: React.FC<Props> = ({ control }) => {
const { activeWorkspace, activeProject } = useUser();
const { data: people } = useSWR<WorkspaceMember[]>(
const { data: people } = useSWR(
activeWorkspace && activeProject ? PROJECT_MEMBERS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectServices.projectMembers(activeWorkspace.slug, activeProject.id)

View File

@ -6,7 +6,7 @@ import { useForm, Controller } from "react-hook-form";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// services
import issuesServices from "lib/services/issues.services";
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
// fetching keys
@ -106,12 +106,16 @@ const SelectLabels: React.FC<Props> = ({ control }) => {
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-pointer select-none w-full p-2 rounded-md`
} flex items-center gap-2 cursor-pointer select-none w-full p-2 rounded-md`
}
value={label.id}
>
{({ selected, active }) => (
<>
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: label.colour }}
></span>
<span
className={`${
selected || (value ?? []).some((i) => i === label.id)

View File

@ -16,7 +16,7 @@ import {
// headless
import { Dialog, Menu, Transition } from "@headlessui/react";
// services
import issuesServices from "lib/services/issues.services";
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
@ -34,11 +34,14 @@ import SelectAssignee from "./SelectAssignee";
import SelectParent from "./SelectParentIssue";
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal";
// types
import type { IIssue, IssueResponse, CycleIssueResponse } from "types";
import { EllipsisHorizontalIcon } from "@heroicons/react/24/outline";
const RichTextEditor = dynamic(() => import("components/lexical/editor"), {
ssr: false,
});
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
@ -78,10 +81,6 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
// setIssueDescriptionValue(value);
// };
const RichTextEditor = dynamic(() => import("components/lexical/editor"), {
ssr: false,
});
const router = useRouter();
const handleClose = () => {
@ -117,7 +116,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
const addIssueToSprint = async (issueId: string, sprintId: string, issueDetail: IIssue) => {
if (!activeWorkspace || !activeProject) return;
await issuesServices
.addIssueToSprint(activeWorkspace.slug, activeProject.id, sprintId, {
.addIssueToCycle(activeWorkspace.slug, activeProject.id, sprintId, {
issue: issueId,
})
.then((res) => {
@ -176,6 +175,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
const payload: Partial<IIssue> = {
...formData,
target_date: formData.target_date ? renderDateFormat(formData.target_date ?? "") : null,
// description: formData.description ? JSON.parse(formData.description) : null,
};
if (!data) {
await issuesServices

View File

@ -4,7 +4,7 @@ import React, { useState } from "react";
import Link from "next/link";
import Image from "next/image";
// swr
import useSWR, { mutate } from "swr";
import useSWR from "swr";
// headless ui
import { Disclosure, Listbox, Menu, Transition } from "@headlessui/react";
// ui
@ -20,15 +20,14 @@ import User from "public/user.png";
// components
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
// types
import { IIssue, IssueResponse, NestedKeyOf, Properties, WorkspaceMember } from "types";
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
// services
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
// fetch keys
import { PRIORITIES } from "constants/";
import { PROJECT_ISSUES_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// services
import issuesServices from "lib/services/issues.services";
import workspaceService from "lib/services/workspace.service";
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// constants
import {
addSpaceIfCamelCase,
@ -44,6 +43,7 @@ type Props = {
selectedGroup: NestedKeyOf<IIssue> | null;
setSelectedIssue: any;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
};
const ListView: React.FC<Props> = ({
@ -52,6 +52,7 @@ const ListView: React.FC<Props> = ({
selectedGroup,
setSelectedIssue,
handleDeleteIssue,
partialUpdateIssue,
}) => {
const [isCreateIssuesModalOpen, setIsCreateIssuesModalOpen] = useState(false);
const [preloadedData, setPreloadedData] = useState<
@ -60,27 +61,7 @@ const ListView: React.FC<Props> = ({
const { activeWorkspace, activeProject, states } = useUser();
const partialUpdateIssue = (formData: Partial<IIssue>, issueId: string) => {
if (!activeWorkspace || !activeProject) return;
issuesServices
.patchIssue(activeWorkspace.slug, activeProject.id, issueId, formData)
.then((response) => {
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
(prevData) => ({
...(prevData as IssueResponse),
results:
prevData?.results.map((issue) => (issue.id === response.id ? response : issue)) ?? [],
}),
false
);
})
.catch((error) => {
console.log(error);
});
};
const { data: people } = useSWR<WorkspaceMember[]>(
const { data: people } = useSWR<IWorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
@ -95,7 +76,7 @@ const ListView: React.FC<Props> = ({
}}
projectId={activeProject?.id as string}
/>
<div className="mt-4 flex flex-col space-y-5">
<div className="flex flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => (
<Disclosure key={singleGroup} as="div" defaultOpen>
{({ open }) => (
@ -155,23 +136,27 @@ const ListView: React.FC<Props> = ({
return (
<div
key={issue.id}
className="group px-4 py-3 text-sm rounded flex items-center justify-between"
className="px-4 py-3 text-sm rounded flex justify-between items-center gap-2"
>
<div className="flex items-center gap-2">
<span
className={`h-1.5 w-1.5 block rounded-full`}
className={`flex-shrink-0 h-1.5 w-1.5 block rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/projects/${activeProject?.id}/issues/${issue.id}`}>
<a className="flex items-center gap-2">
<a className="group relative flex items-center gap-2">
{properties.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>
)}
<span>{issue.name}</span>
<span className="">{issue.name}</span>
<div className="absolute bottom-full left-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md max-w-sm whitespace-nowrap">
<h5 className="font-medium mb-1">Name</h5>
<div>{issue.name}</div>
</div>
</a>
</Link>
</div>
@ -183,7 +168,7 @@ const ListView: React.FC<Props> = ({
onChange={(data: string) => {
partialUpdateIssue({ priority: data }, issue.id);
}}
className="flex-shrink-0"
className="group relative flex-shrink-0"
>
{({ open }) => (
<>
@ -229,6 +214,26 @@ const ListView: React.FC<Props> = ({
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">
Priority
</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</>
)}
</Listbox>
@ -240,7 +245,7 @@ const ListView: React.FC<Props> = ({
onChange={(data: string) => {
partialUpdateIssue({ state: data }, issue.id);
}}
className="flex-shrink-0"
className="group relative flex-shrink-0"
>
{({ open }) => (
<>
@ -280,21 +285,31 @@ const ListView: React.FC<Props> = ({
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{issue.state_detail.name}</div>
</div>
</>
)}
</Listbox>
)}
{properties.start_date && (
<div className="flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<div className="group relative flex-shrink-0 flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<CalendarDaysIcon className="h-4 w-4" />
{issue.start_date
? renderShortNumericDateFormat(issue.start_date)
: "N/A"}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Started at</h5>
<div>
{renderShortNumericDateFormat(issue.start_date ?? "")}
</div>
</div>
</div>
)}
{properties.target_date && (
<div
className={`flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
className={`group relative flex-shrink-0 group flex items-center gap-1 hover:bg-gray-100 border rounded shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
@ -307,19 +322,26 @@ const ListView: React.FC<Props> = ({
{issue.target_date
? renderShortNumericDateFormat(issue.target_date)
: "N/A"}
{issue.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">
{issue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft(
issue.target_date
)} days`
: findHowManyDaysLeft(issue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft(
issue.target_date
)} days`
: "Target date"}
</span>
)}
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1 text-gray-900">
Target date
</h5>
<div>
{renderShortNumericDateFormat(issue.target_date ?? "")}
</div>
<div>
{issue.target_date &&
(issue.target_date < new Date().toISOString()
? `Target date has passed by ${findHowManyDaysLeft(
issue.target_date
)} days`
: findHowManyDaysLeft(issue.target_date) <= 3
? `Target date is in ${findHowManyDaysLeft(
issue.target_date
)} days`
: "Target date")}
</div>
</div>
</div>
)}
{properties.assignee && (
@ -335,7 +357,7 @@ const ListView: React.FC<Props> = ({
}
partialUpdateIssue({ assignees_list: newData }, issue.id);
}}
className="relative flex-shrink-0"
className="group relative flex-shrink-0"
>
{({ open }) => (
<>
@ -397,7 +419,7 @@ const ListView: React.FC<Props> = ({
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"cursor-pointer select-none px-3 py-2"
"cursor-pointer select-none p-2"
)
}
value={person.member.id}
@ -444,6 +466,16 @@ const ListView: React.FC<Props> = ({
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">Assigned to</h5>
<div>
{issue.assignee_details?.length > 0
? issue.assignee_details
.map((assignee) => assignee.first_name)
.join(", ")
: "No one"}
</div>
</div>
</>
)}
</Listbox>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useRef, useState } from "react";
// swr
import { mutate } from "swr";
// headless ui
@ -6,7 +6,7 @@ import { Dialog, Transition } from "@headlessui/react";
// fetching keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// services
import issueServices from "lib/services/issues.services";
import issueServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
@ -26,7 +26,7 @@ type Props = {
const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { activeWorkspace } = useUser();
const { activeWorkspace, activeProject } = useUser();
const { setToastAlert } = useToast();
@ -70,7 +70,7 @@ const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) =>
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" initialFocus={cancelButtonRef} onClose={onClose}>
<Dialog as="div" className="relative z-20" initialFocus={cancelButtonRef} onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@ -97,22 +97,24 @@ const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) =>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon
className="h-6 w-6 text-red-600"
aria-hidden="true"
/>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Delete Issue
<div>
<div className="mx-auto h-16 w-16 grid place-items-center rounded-full bg-red-100">
<ExclamationTriangleIcon
className="h-8 w-8 text-red-600"
aria-hidden="true"
/>
</div>
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900 mt-3"
>
Are you sure you want to delete {`"`}
{activeProject?.identifier}-{data?.sequence_id} - {data?.name}?{`"`}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete issue - {`"`}
<span className="italic">{data?.name}</span>
{`"`} ? All of the data related to the issue will be permanently removed.
This action cannot be undone.
All of the data related to the issue will be permanently removed. This
action cannot be undone.
</p>
</div>
</div>

View File

@ -6,12 +6,14 @@ import { Listbox, Transition } from "@headlessui/react";
// react hook form
import { useForm, Controller, UseFormWatch } from "react-hook-form";
// services
import stateServices from "lib/services/state.services";
import issuesServices from "lib/services/issues.services";
import stateServices from "lib/services/state.service";
import issuesServices from "lib/services/issues.service";
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// components
import IssuesListModal from "components/project/issues/IssuesListModal";
// fetching keys
import {
PROJECT_ISSUES_LIST,
@ -37,25 +39,22 @@ import {
LinkIcon,
ArrowPathIcon,
CalendarDaysIcon,
TrashIcon,
PlusIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
// types
import type { Control } from "react-hook-form";
import type {
IIssue,
IIssueLabels,
IssueResponse,
IState,
NestedKeyOf,
WorkspaceMember,
} from "types";
import type { IIssue, IIssueLabels, IssueResponse, IState, NestedKeyOf } from "types";
import { TwitterPicker } from "react-color";
import IssuesListModal from "components/project/issues/IssuesListModal";
import { positionEditorElement } from "components/lexical/helpers/editor";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
issueDetail: IIssue | undefined;
watch: UseFormWatch<IIssue>;
setDeleteIssueModal: React.Dispatch<React.SetStateAction<boolean>>;
};
const defaultValues: Partial<IIssueLabels> = {
@ -65,13 +64,15 @@ const defaultValues: Partial<IIssueLabels> = {
const IssueDetailSidebar: React.FC<Props> = ({
control,
watch: watchIssue,
submitChanges,
issueDetail,
watch: watchIssue,
setDeleteIssueModal,
}) => {
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
const [createLabelForm, setCreateLabelForm] = useState(false);
const { activeWorkspace, activeProject, cycles, issues } = useUser();
@ -84,7 +85,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
: null
);
const { data: people } = useSWR<WorkspaceMember[]>(
const { data: people } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
@ -124,7 +125,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
name: NestedKeyOf<IIssue>;
canSelectMultipleOptions: boolean;
icon: (props: any) => JSX.Element;
options?: Array<{ label: string; value: any }>;
options?: Array<{ label: string; value: any; color?: string }>;
modal: boolean;
issuesList?: Array<IIssue>;
isOpen?: boolean;
@ -140,6 +141,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
options: states?.map((state) => ({
label: state.name,
value: state.id,
color: state.color,
})),
modal: false,
},
@ -228,364 +230,411 @@ const IssueDetailSidebar: React.FC<Props> = ({
const handleCycleChange = (cycleId: string) => {
if (activeWorkspace && activeProject && issueDetail)
issuesServices.addIssueToSprint(activeWorkspace.slug, activeProject.id, cycleId, {
issuesServices.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, {
issue: issueDetail.id,
});
};
return (
<div className="h-full w-full divide-y-2 divide-gray-100">
<div className="flex justify-between items-center pb-3">
<h4 className="text-sm font-medium">
{activeProject?.identifier}-{issueDetail?.sequence_id}
</h4>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/projects/${activeProject?.id}/issues/${issueDetail?.id}`
)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<LinkIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() =>
copyTextToClipboard(`${issueDetail?.id}`)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<ClipboardDocumentIcon className="h-3.5 w-3.5" />
</button>
<>
<div className="h-full w-full divide-y-2 divide-gray-100">
<div className="flex justify-between items-center pb-3">
<h4 className="text-sm font-medium">
{activeProject?.identifier}-{issueDetail?.sequence_id}
</h4>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/projects/${activeProject?.id}/issues/${issueDetail?.id}`
)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<LinkIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-2 hover:bg-gray-100 border rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() =>
copyTextToClipboard(`${issueDetail?.id}`)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
})
}
>
<ClipboardDocumentIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-2 hover:bg-red-50 text-red-500 border border-red-500 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 duration-300"
onClick={() => setDeleteIssueModal(true)}
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
<div className="divide-y-2 divide-gray-100">
{sidebarSections.map((section, index) => (
<div key={index} className="py-1">
{section.map((item) => (
<div key={item.label} className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<item.icon className="flex-shrink-0 h-4 w-4" />
<p>{item.label}</p>
</div>
<div className="sm:basis-1/2">
{item.name === "target_date" ? (
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<input
type="date"
value={value ?? ""}
onChange={(e: any) => {
submitChanges({ target_date: e.target.value });
onChange(e.target.value);
}}
className="hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 w-full"
/>
)}
/>
) : item.modal ? (
<Controller
control={control}
name={item.name as keyof IIssue}
render={({ field: { value, onChange } }) => (
<>
<IssuesListModal
isOpen={Boolean(item?.isOpen)}
handleClose={() => item.setIsOpen && item.setIsOpen(false)}
onChange={(val) => {
console.log(val);
// submitChanges({ [item.name]: val });
onChange(val);
<div className="divide-y-2 divide-gray-100">
{sidebarSections.map((section, index) => (
<div key={index} className="py-1">
{section.map((item) => (
<div key={item.label} className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<item.icon className="flex-shrink-0 h-4 w-4" />
<p>{item.label}</p>
</div>
<div className="sm:basis-1/2">
{item.name === "target_date" ? (
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<input
type="date"
value={value ?? ""}
onChange={(e: any) => {
submitChanges({ target_date: e.target.value });
onChange(e.target.value);
}}
issues={item?.issuesList ?? []}
title={`Select ${item.label}`}
multiple={item.canSelectMultipleOptions}
value={value}
className="hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 w-full"
/>
<button
type="button"
className="flex justify-between items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 w-full"
onClick={() => item.setIsOpen && item.setIsOpen(true)}
>
{watchIssue(`${item.name as keyof IIssue}`) &&
watchIssue(`${item.name as keyof IIssue}`) !== ""
? `${activeProject?.identifier}-
)}
/>
) : item.modal ? (
<Controller
control={control}
name={item.name as keyof IIssue}
render={({ field: { value, onChange } }) => (
<>
<IssuesListModal
isOpen={Boolean(item?.isOpen)}
handleClose={() => item.setIsOpen && item.setIsOpen(false)}
onChange={(val) => {
console.log(val);
// submitChanges({ [item.name]: val });
onChange(val);
}}
issues={item?.issuesList ?? []}
title={`Select ${item.label}`}
multiple={item.canSelectMultipleOptions}
value={value}
/>
<button
type="button"
className="flex justify-between items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300 w-full"
onClick={() => item.setIsOpen && item.setIsOpen(true)}
>
{watchIssue(`${item.name as keyof IIssue}`) &&
watchIssue(`${item.name as keyof IIssue}`) !== ""
? `${activeProject?.identifier}-
${
issues?.results.find(
(i) => i.id === watchIssue(`${item.name as keyof IIssue}`)
)?.sequence_id
}`
: `Select ${item.label}`}
</button>
</>
)}
/>
) : (
<Controller
control={control}
name={item.name as keyof IIssue}
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
multiple={item.canSelectMultipleOptions}
onChange={(value: any) => {
if (item.name === "cycle") handleCycleChange(value);
else submitChanges({ [item.name]: value });
}}
className="flex-shrink-0"
>
{({ open }) => (
<div className="relative">
<Listbox.Button className="flex justify-between items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 w-full py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className={classNames(
value ? "" : "text-gray-900",
"hidden truncate sm:block text-left",
item.label === "Priority" ? "capitalize" : ""
)}
>
{value
? Array.isArray(value)
? value
.map(
(i: any) =>
item.options?.find((option) => option.value === i)
?.label
)
.join(", ") || item.label
: item.options?.find((option) => option.value === value)
?.label
: "None"}
</span>
<ChevronDownIcon className="h-3 w-3" />
</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 right-0 mt-1 w-40 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="p-1">
{item.options ? (
item.options.length > 0 ? (
item.options.map((option) => (
<Listbox.Option
key={option.value}
className={({ active, selected }) =>
`${
active || selected
? "text-white bg-theme"
: "text-gray-900"
} ${
item.label === "Priority" && "capitalize"
} cursor-pointer select-none relative p-2 rounded-md truncate`
}
value={option.value}
>
{option.label}
</Listbox.Option>
))
) : (
<div className="text-center">No {item.label}s found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
)}
/>
)}
</div>
</div>
))}
</div>
))}
</div>
<div className="space-y-2 pt-3">
<h5 className="text-xs font-medium">Add new label</h5>
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(onSubmit)}>
<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-10 transform right-0 mt-1 px-2 max-w-xs sm:px-0">
<Controller
name="colour"
control={controlLabel}
render={({ field: { value, onChange } }) => (
<TwitterPicker color={value} onChange={(value) => onChange(value.hex)} />
: `Select ${item.label}`}
</button>
</>
)}
/>
</Popover.Panel>
</Transition>
) : (
<Controller
control={control}
name={item.name as keyof IIssue}
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
multiple={item.canSelectMultipleOptions}
onChange={(value: any) => {
if (item.name === "cycle") handleCycleChange(value);
else submitChanges({ [item.name]: value });
}}
className="flex-shrink-0"
>
{({ open }) => (
<div className="relative">
<Listbox.Button className="flex justify-between items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 w-full py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<span
className={classNames(
value ? "" : "text-gray-900",
"hidden truncate sm:block text-left",
item.label === "Priority" ? "capitalize" : ""
)}
>
{value
? Array.isArray(value)
? value
.map(
(i: any) =>
item.options?.find((option) => option.value === i)
?.label
)
.join(", ") || item.label
: item.options?.find((option) => option.value === value)
?.label
: "None"}
</span>
<ChevronDownIcon className="h-3 w-3" />
</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 right-0 mt-1 w-40 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="p-1">
{item.options ? (
item.options.length > 0 ? (
item.options.map((option) => (
<Listbox.Option
key={option.value}
className={({ active, selected }) =>
`${
active || selected
? "text-white bg-theme"
: "text-gray-900"
} ${
item.label === "Priority" && "capitalize"
} flex items-center gap-2 cursor-pointer select-none relative p-2 rounded-md truncate`
}
value={option.value}
>
{option.color && (
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: option.color }}
></span>
)}
{option.label}
</Listbox.Option>
))
) : (
<div className="text-center">No {item.label}s found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
)}
/>
)}
</div>
</div>
))}
</div>
))}
</div>
<div className="pt-3 space-y-3">
<div className="flex justify-between items-start">
<div className="flex items-center gap-x-2 text-sm basis-1/2">
<TagIcon className="w-4 h-4" />
<p>Label</p>
</div>
<div className="basis-1/2">
<div className="flex gap-1 flex-wrap">
{issueDetail?.label_details.map((label) => (
<span
key={label.id}
className="flex items-center gap-2 border rounded-2xl text-xs px-1 py-0.5 hover:bg-gray-100 cursor-pointer"
// onClick={() =>
// submitChanges({
// labels_list: issueDetail?.labels_list.filter((l) => l !== label.id),
// })
// }
>
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: label.colour ?? "green" }}
></span>
{label.name}
</span>
))}
<Controller
control={control}
name="labels_list"
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
multiple
onChange={(value: any) => submitChanges({ labels_list: value })}
className="flex-shrink-0"
>
{({ open }) => (
<>
<Listbox.Label className="sr-only">Label</Listbox.Label>
<div className="relative">
<Listbox.Button className="flex items-center gap-2 border rounded-2xl text-xs px-1 py-0.5 hover:bg-gray-100 cursor-pointer">
<span
className={classNames(
value ? "" : "text-gray-900",
"hidden sm:block text-left"
)}
>
Select Label
</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 right-0 mt-1 w-40 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="p-1">
{issueLabels ? (
issueLabels.length > 0 ? (
issueLabels.map((label: IIssueLabels) => (
<Listbox.Option
key={label.id}
className={({ active, selected }) =>
`${
active || selected
? "text-white bg-theme"
: "text-gray-900"
} flex items-center gap-2 cursor-pointer select-none relative p-2 rounded-md truncate`
}
value={label.id}
>
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: label.colour ?? "green" }}
></span>
{label.name}
</Listbox.Option>
))
) : (
<div className="text-center">No labels found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
</div>
</div>
<div className="flex justify-end">
<button
type="button"
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 text-xs font-medium"
onClick={() => setCreateLabelForm((prevData) => !prevData)}
>
{createLabelForm ? (
<>
<XMarkIcon className="h-3 w-3" /> Cancel
</>
) : (
<>
<PlusIcon className="h-3 w-3" /> Create new
</>
)}
</Popover>
</button>
</div>
<Input
id="name"
name="name"
placeholder="Title"
register={register}
validations={{
required: "This is required",
}}
autoComplete="off"
/>
<Button type="submit" disabled={isSubmitting}>
+
</Button>
</form>
<div className="flex justify-between items-center">
<div className="flex items-center gap-x-2 text-sm basis-1/2">
<TagIcon className="w-4 h-4" />
<p>Label</p>
</div>
<div className="basis-1/2">
<Controller
control={control}
name="labels_list"
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
multiple
onChange={(value: any) => submitChanges({ labels_list: value })}
className="flex-shrink-0"
>
{createLabelForm && (
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(onSubmit)}>
<div>
<Popover className="relative">
{({ open }) => (
<>
<Listbox.Label className="sr-only">Label</Listbox.Label>
<div className="relative">
<Listbox.Button className="flex justify-between items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 w-full py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300">
<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={classNames(
value ? "" : "text-gray-900",
"hidden truncate capitalize sm:block text-left"
)}
>
{value && value.length > 0
? value
.map(
(i: string) =>
issueLabels?.find((option) => option.id === i)?.name
)
.join(", ")
: "None"}
</span>
<ChevronDownIcon className="h-3 w-3" />
</Listbox.Button>
className="w-5 h-5 rounded"
style={{
backgroundColor: watch("colour") ?? "green",
}}
></span>
)}
<ChevronDownIcon className="h-3 w-3" />
</Popover.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 right-0 mt-1 w-40 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div className="p-1">
{issueLabels ? (
issueLabels.length > 0 ? (
issueLabels.map((label: IIssueLabels) => (
<Listbox.Option
key={label.id}
className={({ active, selected }) =>
`${
active || selected
? "text-white bg-theme"
: "text-gray-900"
} flex items-center gap-2 cursor-pointer select-none relative p-2 rounded-md truncate`
}
value={label.id}
>
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: label.colour ?? "green" }}
></span>
{label.name}
</Listbox.Option>
))
) : (
<div className="text-center">No labels found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
<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-10 transform right-0 mt-1 px-2 max-w-xs sm:px-0">
<Controller
name="colour"
control={controlLabel}
render={({ field: { value, onChange } }) => (
<TwitterPicker
color={value}
onChange={(value) => onChange(value.hex)}
/>
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Listbox>
)}
/>
</div>
</Popover>
</div>
<Input
id="name"
name="name"
placeholder="Title"
register={register}
validations={{
required: "This is required",
}}
autoComplete="off"
/>
<Button type="submit" theme="success" disabled={isSubmitting}>
+
</Button>
</form>
)}
</div>
</div>
</div>
</>
);
};

View File

@ -24,12 +24,12 @@ type Props = {
const activityIcons: {
[key: string]: JSX.Element;
} = {
state: <Squares2X2Icon className="h-4 w-4" />,
priority: <ChartBarIcon className="h-4 w-4" />,
name: <ChatBubbleBottomCenterTextIcon className="h-4 w-4" />,
description: <ChatBubbleBottomCenterTextIcon className="h-4 w-4" />,
target_date: <CalendarDaysIcon className="h-4 w-4" />,
parent: <UserIcon className="h-4 w-4" />,
state: <Squares2X2Icon className="h-3.5 w-3.5" />,
priority: <ChartBarIcon className="h-3.5 w-3.5" />,
name: <ChatBubbleBottomCenterTextIcon className="h-3.5 w-3.5" />,
description: <ChatBubbleBottomCenterTextIcon className="h-3.5 w-3.5" />,
target_date: <CalendarDaysIcon className="h-3.5 w-3.5" />,
parent: <UserIcon className="h-3.5 w-3.5" />,
};
const IssueActivitySection: React.FC<Props> = ({ issueActivities, states, issues }) => {

View File

@ -4,7 +4,7 @@ import { mutate } from "swr";
// react hook form
import { useForm } from "react-hook-form";
// services
import issuesServices from "lib/services/issues.services";
import issuesServices from "lib/services/issues.service";
// fetch keys
import { PROJECT_ISSUES_COMMENTS } from "constants/fetch-keys";
// components
@ -71,42 +71,6 @@ const IssueCommentSection: React.FC<Props> = ({ comments, issueId, projectId, wo
return (
<div className="space-y-5">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="bg-gray-100 rounded-md">
<div className="w-full">
<TextArea
id="comment"
name="comment"
register={register}
validations={{
required: true,
}}
mode="transparent"
error={errors.comment}
className="w-full pb-10 resize-none"
placeholder="Enter your comment"
onKeyDown={(e) => {
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
const value = e.currentTarget.value;
const start = e.currentTarget.selectionStart;
const end = e.currentTarget.selectionEnd;
setValue("comment", `${value.substring(0, start)}\r ${value.substring(end)}`);
} else if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
isSubmitting || handleSubmit(onSubmit)();
}
}}
/>
</div>
<div className="w-full flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Adding comment..." : "Add comment"}
{/* <UploadingIcon /> */}
</Button>
</div>
</div>
</form>
{comments ? (
comments.length > 0 ? (
<div className="space-y-5">
@ -127,6 +91,37 @@ const IssueCommentSection: React.FC<Props> = ({ comments, issueId, projectId, wo
<Spinner />
</div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex items-start gap-2 border rounded-md p-2">
<TextArea
id="comment"
name="comment"
register={register}
validations={{
required: true,
}}
mode="transparent"
error={errors.comment}
placeholder="Enter your comment"
onKeyDown={(e) => {
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
const value = e.currentTarget.value;
const start = e.currentTarget.selectionStart;
const end = e.currentTarget.selectionEnd;
setValue("comment", `${value.substring(0, start)}\r ${value.substring(end)}`);
} else if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
isSubmitting || handleSubmit(onSubmit)();
}
}}
/>
<Button type="submit" className="whitespace-nowrap" disabled={isSubmitting}>
{isSubmitting ? "Adding comment..." : "Add comment"}
{/* <UploadingIcon /> */}
</Button>
</div>
</form>
</div>
);
};

View File

@ -8,7 +8,7 @@ import useUser from "lib/hooks/useUser";
import { addSpaceIfCamelCase, classNames } from "constants/common";
import { STATE_LIST } from "constants/fetch-keys";
// services
import stateServices from "lib/services/state.services";
import stateServices from "lib/services/state.service";
// ui
import { Listbox, Transition } from "@headlessui/react";
// types

View File

@ -28,7 +28,7 @@ type Props = {
slug: string;
invitationsRespond: string[];
handleInvitation: (project_invitation: any, action: "accepted" | "withdraw") => void;
setDeleteProject: React.Dispatch<React.SetStateAction<IProject | undefined>>;
setDeleteProject: (id: string | null) => void;
};
const ProjectMemberInvitations: React.FC<Props> = ({
@ -68,7 +68,7 @@ const ProjectMemberInvitations: React.FC<Props> = ({
{!isMember ? (
<input
id={project.id}
className="h-3 w-3 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 mt-2 hidden"
className="h-3 w-3 rounded border-gray-300 text-theme focus:ring-indigo-500 mt-2 hidden"
aria-describedby="workspaces"
name={project.id}
checked={invitationsRespond.includes(project.id)}
@ -100,7 +100,7 @@ const ProjectMemberInvitations: React.FC<Props> = ({
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"
onClick={() => setDeleteProject(project)}
onClick={() => setDeleteProject(project.id)}
>
<TrashIcon className="h-4 w-4 text-red-500" />
</button>

View File

@ -15,7 +15,7 @@ import { Button } from "ui";
// icons
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
// types
import { IProject, WorkspaceMember } from "types";
import { IProject } from "types";
// fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
@ -27,7 +27,7 @@ type Props = {
const ControlSettings: React.FC<Props> = ({ control, isSubmitting }) => {
const { activeWorkspace } = useUser();
const { data: people } = useSWR<WorkspaceMember[]>(
const { data: people } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
@ -92,7 +92,7 @@ const ControlSettings: React.FC<Props> = ({ control, isSubmitting }) => {
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-indigo-600"
active ? "text-white" : "text-theme"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
@ -164,7 +164,7 @@ const ControlSettings: React.FC<Props> = ({ control, isSubmitting }) => {
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-indigo-600"
active ? "text-white" : "text-theme"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />

View File

@ -7,7 +7,7 @@ import { Controller, SubmitHandler, useForm } from "react-hook-form";
// react-color
import { TwitterPicker } from "react-color";
// services
import issuesServices from "lib/services/issues.services";
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
@ -47,7 +47,6 @@ const LabelsSettings: React.FC = () => {
setValue,
formState: { errors, isSubmitting },
watch,
setError,
} = useForm<IIssueLabels>({ defaultValues });
const { data: issueLabels, mutate } = useSWR<IIssueLabels[]>(
@ -117,7 +116,7 @@ const LabelsSettings: React.FC = () => {
</Button>
</div>
<div className="space-y-5">
<form
<div
className={`bg-white px-4 py-2 flex items-center gap-2 ${newLabelForm ? "" : "hidden"}`}
>
<div>
@ -193,7 +192,7 @@ const LabelsSettings: React.FC = () => {
{isSubmitting ? "Adding" : "Add"}
</Button>
)}
</form>
</div>
{issueLabels ? (
issueLabels.map((label) => {
const children = getLabelChildren(label.id);

View File

@ -0,0 +1,193 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
import Link from "next/link";
// hooks
import useToast from "lib/hooks/useToast";
import useUser from "lib/hooks/useUser";
// components
import CreateProjectModal from "components/project/create-project-modal";
// headless ui
import { Disclosure, Menu, Transition } from "@headlessui/react";
// ui
import { Spinner } from "ui";
// icons
import {
ChevronDownIcon,
ClipboardDocumentIcon,
EllipsisHorizontalIcon,
PlusIcon,
} from "@heroicons/react/24/outline";
// constants
import { classNames, copyTextToClipboard } from "constants/common";
type Props = {
navigation: (projectId: string) => Array<{
name: string;
href: string;
icon: (props: any) => JSX.Element;
}>;
sidebarCollapse: boolean;
};
const ProjectsList: React.FC<Props> = ({ navigation, sidebarCollapse }) => {
const [isCreateProjectModal, setCreateProjectModal] = useState(false);
const { projects } = useUser();
const { setToastAlert } = useToast();
const router = useRouter();
const { projectId } = router.query;
return (
<>
<CreateProjectModal isOpen={isCreateProjectModal} setIsOpen={setCreateProjectModal} />
<div
className={`h-full flex flex-col px-2 pt-5 pb-3 mt-3 space-y-2 bg-primary overflow-y-auto ${
sidebarCollapse ? "rounded-xl" : "rounded-t-3xl"
}`}
>
{projects ? (
<>
{projects.length > 0 ? (
projects.map((project) => (
<Disclosure key={project?.id} defaultOpen={projectId === project?.id}>
{({ open }) => (
<>
<div className="flex items-center">
<Disclosure.Button
className={`w-full flex items-center gap-2 font-medium rounded-md p-2 text-sm ${
sidebarCollapse ? "justify-center" : ""
}`}
>
<span className="bg-gray-700 text-white rounded h-7 w-7 grid place-items-center uppercase flex-shrink-0">
{project?.name.charAt(0)}
</span>
{!sidebarCollapse && (
<span className="flex items-center justify-between w-full">
{project?.name}
<span>
<ChevronDownIcon
className={`h-4 w-4 duration-300 ${open ? "rotate-180" : ""}`}
/>
</span>
</span>
)}
</Disclosure.Button>
{!sidebarCollapse && (
<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
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-20">
<div className="p-1">
<Menu.Item as="div">
{(active) => (
<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"
onClick={() =>
copyTextToClipboard(
`https://app.plane.so/projects/${project?.id}/issues/`
).then(() => {
setToastAlert({
title: "Link Copied",
message: "Link copied to clipboard",
type: "success",
});
})
}
>
<ClipboardDocumentIcon className="h-3 w-3" />
Copy Link
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
)}
</div>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel
className={`${
sidebarCollapse ? "" : "ml-[2.25rem]"
} flex flex-col gap-y-1`}
>
{navigation(project?.id).map((item) => (
<Link key={item.name} href={item.href}>
<a
className={classNames(
item.href === router.asPath
? "bg-gray-200 text-gray-900"
: "text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus:bg-gray-100 focus:text-gray-900",
"group flex items-center px-2 py-2 text-xs font-medium rounded-md outline-none",
sidebarCollapse ? "justify-center" : ""
)}
>
<item.icon
className={classNames(
item.href === router.asPath
? "text-gray-900"
: "text-gray-500 group-hover:text-gray-900",
"flex-shrink-0 h-4 w-4",
!sidebarCollapse ? "mr-3" : ""
)}
aria-hidden="true"
/>
{!sidebarCollapse && item.name}
</a>
</Link>
))}
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
))
) : (
<div className="text-center space-y-3">
{!sidebarCollapse && (
<h4 className="text-gray-700 text-sm">You don{"'"}t have any project yet</h4>
)}
<button
type="button"
className="group flex justify-center items-center gap-2 w-full rounded-md p-2 text-sm bg-theme text-white"
onClick={() => setCreateProjectModal(true)}
>
<PlusIcon className="h-5 w-5" />
{!sidebarCollapse && "Create Project"}
</button>
</div>
)}
</>
) : (
<div className="w-full flex justify-center">
<Spinner />
</div>
)}
</div>
</>
);
};
export default ProjectsList;

View File

@ -0,0 +1,293 @@
// react
import React from "react";
// next
import { useRouter } from "next/router";
import Link from "next/link";
import Image from "next/image";
// services
import userService from "lib/services/user.service";
import authenticationService from "lib/services/authentication.service";
// hooks
import useUser from "lib/hooks/useUser";
// headless ui
import { Menu, Transition } from "@headlessui/react";
// ui
import { Spinner } from "ui";
// icons
import {
ChevronDownIcon,
ClipboardDocumentListIcon,
Cog6ToothIcon,
HomeIcon,
PlusIcon,
RectangleStackIcon,
UserGroupIcon,
UserIcon,
} from "@heroicons/react/24/outline";
// types
import { IUser } from "types";
type Props = {
sidebarCollapse: boolean;
};
const workspaceLinks = [
{
icon: HomeIcon,
name: "Home",
href: `/workspace`,
},
{
icon: ClipboardDocumentListIcon,
name: "Projects",
href: "/projects",
},
{
icon: RectangleStackIcon,
name: "My Issues",
href: "/me/my-issues",
},
{
icon: UserGroupIcon,
name: "Members",
href: "/workspace/members",
},
// {
// icon: InboxIcon,
// name: "Inbox",
// href: "#",
// },
{
icon: Cog6ToothIcon,
name: "Settings",
href: "/workspace/settings",
},
];
const userLinks = [
{
name: "My Profile",
href: "/me/profile",
},
{
name: "Workspace Invites",
href: "/invitations",
},
];
const WorkspaceOptions: React.FC<Props> = ({ sidebarCollapse }) => {
const { workspaces, activeWorkspace, user, mutateUser } = useUser();
const router = useRouter();
return (
<div className="px-2">
<div
className={`relative ${sidebarCollapse ? "flex" : "grid grid-cols-5 gap-2 items-center"}`}
>
<Menu as="div" className="col-span-4 inline-block text-left w-full">
<div className="w-full">
<Menu.Button
className={`inline-flex justify-between items-center w-full rounded-md px-2 py-2 text-sm font-semibold text-gray-700 focus:outline-none ${
!sidebarCollapse
? "hover:bg-gray-50 focus:bg-gray-50 border border-gray-300 shadow-sm"
: ""
}`}
>
<div className="flex gap-x-1 items-center">
<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 !== "" ? (
<Image
src={activeWorkspace.logo}
alt="Workspace Logo"
layout="fill"
objectFit="cover"
/>
) : (
activeWorkspace?.name?.charAt(0) ?? "N"
)}
</div>
{!sidebarCollapse && (
<p className="truncate w-20 text-left ml-1">
{activeWorkspace?.name ?? "Loading..."}
</p>
)}
</div>
{!sidebarCollapse && (
<div className="flex-grow flex justify-end">
<ChevronDownIcon className="-mr-1 ml-2 h-5 w-5" 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 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">
{workspaces ? (
<>
{workspaces.length > 0 ? (
workspaces.map((workspace: any) => (
<Menu.Item key={workspace.id}>
{({ active }) => (
<button
type="button"
onClick={() => {
mutateUser(
(prevData) => ({
...(prevData as IUser),
last_workspace_id: workspace.id,
}),
false
);
userService
.updateUser({
last_workspace_id: workspace?.id,
})
.then((res) => {
const isInProject = router.pathname.includes("/[projectId]/");
if (isInProject) router.push("/workspace");
})
.catch((err) => console.error(err));
}}
className={`${
active ? "bg-theme text-white" : "text-gray-900"
} group flex w-full items-center rounded-md p-2 text-sm`}
>
{workspace.name}
</button>
)}
</Menu.Item>
))
) : (
<p>No workspace found!</p>
)}
<Menu.Item
as="button"
onClick={() => {
router.push("/create-workspace");
}}
className="w-full"
>
{({ 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>
</>
) : (
<div className="w-full flex justify-center">
<Spinner />
</div>
)}
</div>
</Menu.Items>
</Transition>
</Menu>
{!sidebarCollapse && (
<Menu as="div" className="inline-block text-left flex-shrink-0 w-full">
<div className="h-10 w-10">
<Menu.Button className="h-full w-full grid relative place-items-center rounded-md shadow-sm bg-white text-gray-700 hover:bg-gray-50 focus:outline-none">
{user?.avatar && user.avatar !== "" ? (
<Image src={user.avatar} alt="User Avatar" layout="fill" className="rounded-md" />
) : (
<UserIcon className="h-5 w-5" />
)}
</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-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">
{userLinks.map((item) => (
<Menu.Item key={item.name} as="div">
{(active) => (
<Link href={item.href}>
<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">
{item.name}
</a>
</Link>
)}
</Menu.Item>
))}
<Menu.Item as="div">
<button
type="button"
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"
onClick={async () => {
await authenticationService
.signOut({
refresh_token: authenticationService.getRefreshToken(),
})
.then((response) => {
console.log("user signed out", response);
})
.catch((error) => {
console.log("Failed to sign out", error);
})
.finally(() => {
mutateUser();
router.push("/signin");
});
}}
>
Sign Out
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
)}
</div>
<div className="mt-3 flex-1 space-y-1 bg-white">
{workspaceLinks.map((link, index) => (
<Link key={index} href={link.href}>
<a
className={`${
link.href === router.asPath
? "bg-theme text-white"
: "hover:bg-indigo-100 focus:bg-indigo-100"
} flex items-center gap-3 p-2 text-xs font-medium rounded-md outline-none ${
sidebarCollapse ? "justify-center" : ""
}`}
>
<link.icon
className={`${
link.href === router.asPath ? "text-white" : ""
} flex-shrink-0 h-4 w-4`}
aria-hidden="true"
/>
{!sidebarCollapse && link.name}
</a>
</Link>
))}
</div>
</div>
);
};
export default WorkspaceOptions;

View File

@ -14,7 +14,7 @@ import { Button, Input, TextArea, Select } from "ui";
// hooks
import useToast from "lib/hooks/useToast";
// types
import { WorkspaceMember } from "types";
import { IWorkspaceMemberInvitation } from "types";
type Props = {
isOpen: boolean;
@ -30,7 +30,7 @@ const ROLE = {
20: "Admin",
};
const defaultValues: Partial<WorkspaceMember> = {
const defaultValues: Partial<IWorkspaceMemberInvitation> = {
email: "",
role: 5,
message: "",
@ -57,7 +57,7 @@ const SendWorkspaceInvitationModal: React.FC<Props> = ({
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm<WorkspaceMember>({
} = useForm<IWorkspaceMemberInvitation>({
defaultValues,
reValidateMode: "onChange",
mode: "all",

View File

@ -3,10 +3,10 @@ import Image from "next/image";
// react
import { useState } from "react";
// types
import { IWorkspaceInvitation } from "types";
import { IWorkspaceMemberInvitation } from "types";
type Props = {
invitation: IWorkspaceInvitation;
invitation: IWorkspaceMemberInvitation;
invitationsRespond: string[];
handleInvitation: any;
};
@ -65,7 +65,7 @@ const SingleInvitation: React.FC<Props> = ({
setIsChecked(e.target.checked);
}}
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
className="h-4 w-4 rounded border-gray-300 text-theme focus:ring-indigo-500"
/>
</div>
</label>

View File

@ -1,4 +1,4 @@
import React, { useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
// next
import { useRouter } from "next/router";
// headless ui
@ -11,43 +11,54 @@ import useToast from "lib/hooks/useToast";
// icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui
import { Button } from "ui";
import { Button, Input } from "ui";
// types
import type { IWorkspace } from "types";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data: IWorkspace | null;
onClose: () => void;
};
const ConfirmWorkspaceDeletion: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const ConfirmWorkspaceDeletion: React.FC<Props> = ({ isOpen, data, onClose }) => {
const router = useRouter();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { activeWorkspace, mutateWorkspaces } = useUser();
const [selectedWorkspace, setSelectedWorkspace] = useState<IWorkspace | null>(null);
const [confirmProjectName, setConfirmProjectName] = useState("");
const [confirmDeleteMyProject, setConfirmDeleteMyProject] = useState(false);
const canDelete = confirmProjectName === data?.name && confirmDeleteMyProject;
const { mutateWorkspaces } = useUser();
const { setToastAlert } = useToast();
const cancelButtonRef = useRef(null);
const handleClose = () => {
setIsOpen(false);
onClose();
setIsDeleteLoading(false);
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
if (!activeWorkspace) return;
if (!data || !canDelete) return;
await workspaceService
.deleteWorkspace(activeWorkspace.slug)
.deleteWorkspace(data.slug)
.then(() => {
handleClose();
mutateWorkspaces((prevData) => {
return (prevData ?? []).filter(
(workspace: IWorkspace) => workspace.slug !== activeWorkspace.slug
);
return (prevData ?? []).filter((workspace: IWorkspace) => workspace.slug !== data.slug);
}, false);
setToastAlert({
type: "success",
message: "Workspace deleted successfully",
title: "Success",
});
router.push("/");
})
.catch((error) => {
@ -56,6 +67,16 @@ const ConfirmWorkspaceDeletion: React.FC<Props> = ({ isOpen, setIsOpen }) => {
});
};
useEffect(() => {
if (data) setSelectedWorkspace(data);
else {
const timer = setTimeout(() => {
setSelectedWorkspace(null);
clearTimeout(timer);
}, 350);
}
}, [data]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog
@ -103,11 +124,47 @@ const ConfirmWorkspaceDeletion: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete workspace - {`"`}
<span className="italic">{activeWorkspace?.name}</span>
<span className="italic">{data?.name}</span>
{`"`} ? All of the data related to the workspace will be permanently
removed. This action cannot be undone.
</p>
</div>
<div className="mt-3">
<p className="text-sm">
Enter the workspace name{" "}
<span className="font-semibold">{selectedWorkspace?.name}</span> to
continue:
</p>
<Input
type="text"
placeholder="Project name"
className="mt-2"
value={confirmProjectName}
onChange={(e) => {
setConfirmProjectName(e.target.value);
}}
name="workspaceName"
/>
</div>
<div className="mt-3">
<p className="text-sm">
To confirm, type{" "}
<span className="font-semibold">delete my workspace</span> below:
</p>
<Input
type="text"
placeholder="Enter 'delete my workspace'"
className="mt-2"
onChange={(e) => {
if (e.target.value === "delete my workspace") {
setConfirmDeleteMyProject(true);
} else {
setConfirmDeleteMyProject(false);
}
}}
name="typeDelete"
/>
</div>
</div>
</div>
</div>
@ -116,7 +173,7 @@ const ConfirmWorkspaceDeletion: React.FC<Props> = ({ isOpen, setIsOpen }) => {
type="button"
onClick={handleDeletion}
theme="danger"
disabled={isDeleteLoading}
disabled={isDeleteLoading || !canDelete}
className="inline-flex sm:ml-3"
>
{isDeleteLoading ? "Deleting..." : "Delete"}

View File

@ -15,7 +15,8 @@ export const MAGIC_LINK_SIGNIN = "/api/magic-sign-in/";
export const USER_ENDPOINT = "/api/users/me/";
export const CHANGE_PASSWORD = "/api/users/me/change-password/";
export const USER_ONBOARD_ENDPOINT = "/api/users/me/onboard/";
export const USER_ISSUES_ENDPOINT = "/api/users/me/issues/";
export const USER_ISSUES_ENDPOINT = (workspaceSlug: string) =>
`/api/workspaces/${workspaceSlug}/my-issues/`;
export const USER_WORKSPACES = "/api/users/me/workspaces";
// s3 file url
@ -24,7 +25,7 @@ export const S3_URL = `/api/file-assets/`;
// LIST USER INVITATIONS ---- RESPOND INVITATIONS IN BULK
export const USER_WORKSPACE_INVITATIONS = "/api/users/me/invitations/workspaces/";
export const USER_PROJECT_INVITATIONS = "/api/users/me/invitations/projects/";
export const LAST_ACTIVE_WORKSPACE_AND_PROJECTS = "/api/users/last-visited-workspace/";
export const USER_WORKSPACE_INVITATION = (invitationId: string) =>
`/api/users/me/invitations/${invitationId}/`;
@ -33,8 +34,6 @@ export const JOIN_WORKSPACE = (workspaceSlug: string, invitationId: string) =>
export const JOIN_PROJECT = (workspaceSlug: string) =>
`/api/workspaces/${workspaceSlug}/projects/join/`;
export const USER_ISSUES = "/api/users/me/issues/";
// workspaces
export const WORKSPACES_ENDPOINT = "/api/workspaces/";
export const WORKSPACE_DETAIL = (workspaceSlug: string) => `/api/workspaces/${workspaceSlug}/`;
@ -65,6 +64,8 @@ export const PROJECT_MEMBERS = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/`;
export const PROJECT_MEMBER_DETAIL = (workspaceSlug: string, projectId: string, memberId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`;
export const PROJECT_VIEW_ENDPOINT = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-views/`;
export const PROJECT_INVITATIONS = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/invitations/`;

View File

@ -207,3 +207,12 @@ export const cosineSimilarity = (a: string, b: string) => {
return dotProduct / Math.sqrt(magnitudeA * magnitudeB);
};
export const createSimilarString = (str: string) => {
const shuffled = str
.split("")
.sort(() => Math.random() - 0.5)
.join("");
return shuffled;
};

View File

@ -7,7 +7,7 @@ export const WORKSPACE_INVITATIONS = "WORKSPACE_INVITATIONS";
export const WORKSPACE_INVITATION = "WORKSPACE_INVITATION";
export const PROJECTS_LIST = (workspaceSlug: string) => `PROJECTS_LIST_${workspaceSlug}`;
export const PROJECT_DETAILS = "PROJECT_DETAILS";
export const PROJECT_DETAILS = (projectId: string) => `PROJECT_DETAILS_${projectId}`;
export const PROJECT_MEMBERS = (projectId: string) => `PROJECT_MEMBERS_${projectId}`;
export const PROJECT_INVITATIONS = "PROJECT_INVITATIONS";
@ -29,4 +29,4 @@ export const CYCLE_DETAIL = "CYCLE_DETAIL";
export const STATE_LIST = (projectId: string) => `STATE_LIST_${projectId}`;
export const STATE_DETAIL = "STATE_DETAIL";
export const USER_ISSUE = "USER_ISSUE";
export const USER_ISSUE = (workspaceSlug: string) => `USER_ISSUE_${workspaceSlug}`;

View File

@ -6,3 +6,5 @@ export const ROLE = {
15: "Member",
20: "Admin",
};
export const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };

View File

@ -2,3 +2,5 @@ export const TOGGLE_SIDEBAR = "TOGGLE_SIDEBAR";
export const REHYDRATE_THEME = "REHYDRATE_THEME";
export const SET_ISSUE_VIEW = "SET_ISSUE_VIEW";
export const SET_GROUP_BY_PROPERTY = "SET_GROUP_BY_PROPERTY";
export const SET_ORDER_BY_PROPERTY = "SET_ORDER_BY_PROPERTY";
export const SET_FILTER_ISSUES = "SET_FILTER_ISSUES";

View File

@ -5,6 +5,8 @@ import {
REHYDRATE_THEME,
SET_ISSUE_VIEW,
SET_GROUP_BY_PROPERTY,
SET_ORDER_BY_PROPERTY,
SET_FILTER_ISSUES,
} from "constants/theme.context.constants";
// components
import ToastAlert from "components/toast-alert";
@ -12,30 +14,30 @@ import ToastAlert from "components/toast-alert";
export const themeContext = createContext<ContextType>({} as ContextType);
// types
import type { IIssue, NestedKeyOf } from "types";
type Theme = {
collapsed: boolean;
issueView: "list" | "kanban" | null;
groupByProperty: NestedKeyOf<IIssue> | null;
};
import type { IIssue, NestedKeyOf, ProjectViewTheme as Theme } from "types";
type ReducerActionType = {
type:
| typeof TOGGLE_SIDEBAR
| typeof REHYDRATE_THEME
| typeof SET_ISSUE_VIEW
| typeof SET_ORDER_BY_PROPERTY
| typeof SET_FILTER_ISSUES
| typeof SET_GROUP_BY_PROPERTY;
payload?: Partial<Theme>;
};
type ContextType = {
collapsed: boolean;
orderBy: NestedKeyOf<IIssue> | null;
issueView: "list" | "kanban" | null;
groupByProperty: NestedKeyOf<IIssue> | null;
filterIssue: "activeIssue" | "backlogIssue" | null;
toggleCollapsed: () => void;
setIssueView: (display: "list" | "kanban") => void;
setGroupByProperty: (property: NestedKeyOf<IIssue> | null) => void;
setOrderBy: (property: NestedKeyOf<IIssue> | null) => void;
setFilterIssue: (property: "activeIssue" | "backlogIssue" | null) => void;
};
type StateType = Theme;
@ -45,6 +47,8 @@ export const initialState: StateType = {
collapsed: false,
issueView: "list",
groupByProperty: null,
orderBy: null,
filterIssue: null,
};
export const reducer: ReducerFunctionType = (state, action) => {
@ -87,6 +91,28 @@ export const reducer: ReducerFunctionType = (state, action) => {
...newState,
};
}
case SET_ORDER_BY_PROPERTY: {
const newState = {
...state,
orderBy: payload?.orderBy || null,
};
localStorage.setItem("theme", JSON.stringify(newState));
return {
...state,
...newState,
};
}
case SET_FILTER_ISSUES: {
const newState = {
...state,
filterIssue: payload?.filterIssue || null,
};
localStorage.setItem("theme", JSON.stringify(newState));
return {
...state,
...newState,
};
}
default: {
return state;
}
@ -120,6 +146,24 @@ export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({
});
}, []);
const setOrderBy = useCallback((property: NestedKeyOf<IIssue> | null) => {
dispatch({
type: SET_ORDER_BY_PROPERTY,
payload: {
orderBy: property,
},
});
}, []);
const setFilterIssue = useCallback((property: "activeIssue" | "backlogIssue" | null) => {
dispatch({
type: SET_FILTER_ISSUES,
payload: {
filterIssue: property,
},
});
}, []);
useEffect(() => {
dispatch({
type: REHYDRATE_THEME,
@ -135,6 +179,10 @@ export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({
setIssueView,
groupByProperty: state.groupByProperty,
setGroupByProperty,
orderBy: state.orderBy,
setOrderBy,
filterIssue: state.filterIssue,
setFilterIssue,
}}
>
<ToastAlert />

View File

@ -6,9 +6,9 @@ import { useRouter } from "next/router";
import useSWR from "swr";
// services
import userService from "lib/services/user.service";
import issuesServices from "lib/services/issues.services";
import stateServices from "lib/services/state.services";
import sprintsServices from "lib/services/cycles.services";
import issuesServices from "lib/services/issues.service";
import stateServices from "lib/services/state.service";
import sprintsServices from "lib/services/cycles.service";
import projectServices from "lib/services/project.service";
import workspaceService from "lib/services/workspace.service";
// constants

View File

@ -15,16 +15,14 @@ const DefaultTopBar: React.FC = () => {
<a className="flex">
<span className="sr-only">Plane</span>
<h2 className="text-2xl font-semibold">
Plan<span className="text-indigo-600">e</span>
Plan<span className="text-theme">e</span>
</h2>
</a>
</Link>
</div>
{user && (
<div>
<p className="text-sm text-gray-500">
logged in as {user.first_name}
</p>
<p className="text-sm text-gray-500">logged in as {user.first_name}</p>
</div>
)}
</div>

View File

@ -0,0 +1,21 @@
type Props = {
breadcrumbs?: JSX.Element;
left?: JSX.Element;
right?: JSX.Element;
};
const Header: React.FC<Props> = ({ breadcrumbs, left, right }) => {
return (
<>
<div className="w-full bg-gray-50 border-b border-gray-200 flex justify-between items-center px-5 py-4">
<div className="flex items-center gap-2">
{breadcrumbs}
{left}
</div>
{right}
</div>
</>
);
};
export default Header;

View File

@ -10,8 +10,6 @@ import authenticationService from "lib/services/authentication.service";
import useUser from "lib/hooks/useUser";
import useTheme from "lib/hooks/useTheme";
import useToast from "lib/hooks/useToast";
// components
import CreateProjectModal from "components/project/CreateProjectModal";
// headless ui
import { Dialog, Disclosure, Menu, Transition } from "@headlessui/react";
// icons
@ -108,7 +106,6 @@ const userLinks = [
const Sidebar: React.FC = () => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isCreateProjectModal, setCreateProjectModal] = useState(false);
const router = useRouter();
@ -124,7 +121,6 @@ const Sidebar: React.FC = () => {
return (
<nav className="h-full">
<CreateProjectModal isOpen={isCreateProjectModal} setIsOpen={setCreateProjectModal} />
<Transition.Root show={sidebarOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-40 md:hidden" onClose={setSidebarOpen}>
<Transition.Child
@ -557,7 +553,13 @@ const Sidebar: React.FC = () => {
<button
type="button"
className="group flex justify-center items-center gap-2 w-full rounded-md p-2 text-sm bg-theme text-white"
onClick={() => setCreateProjectModal(true)}
onClick={() => {
const e = new KeyboardEvent("keydown", {
ctrlKey: true,
key: "p",
});
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-5 w-5" />
{!sidebarCollapse && "Create Project"}

View File

@ -0,0 +1,201 @@
import React, { useState } from "react";
// next
import Link from "next/link";
import { useRouter } from "next/router";
// hooks
import useTheme from "lib/hooks/useTheme";
// components
import ProjectsList from "components/sidebar/projects-list";
import WorkspaceOptions from "components/sidebar/workspace-options";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// icons
import {
ArrowPathIcon,
Bars3Icon,
Cog6ToothIcon,
RectangleStackIcon,
UserGroupIcon,
XMarkIcon,
ArrowLongLeftIcon,
QuestionMarkCircleIcon,
} from "@heroicons/react/24/outline";
// constants
import { classNames } from "constants/common";
const navigation = (projectId: string) => [
{
name: "Issues",
href: `/projects/${projectId}/issues`,
icon: RectangleStackIcon,
},
{
name: "Cycles",
href: `/projects/${projectId}/cycles`,
icon: ArrowPathIcon,
},
{
name: "Members",
href: `/projects/${projectId}/members`,
icon: UserGroupIcon,
},
{
name: "Settings",
href: `/projects/${projectId}/settings`,
icon: Cog6ToothIcon,
},
];
const Sidebar: React.FC = () => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const router = useRouter();
const { projectId } = router.query;
const { collapsed: sidebarCollapse, toggleCollapsed } = useTheme();
return (
<nav className="h-screen">
<Transition.Root show={sidebarOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-40 md:hidden" onClose={setSidebarOpen}>
<Transition.Child
as={React.Fragment}
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
</Transition.Child>
<div className="fixed inset-0 z-40 flex">
<Transition.Child
as={React.Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<Dialog.Panel className="relative flex w-full max-w-xs flex-1 flex-col bg-white">
<Transition.Child
as={React.Fragment}
enter="ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute top-0 right-0 -mr-12 pt-2">
<button
type="button"
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
onClick={() => setSidebarOpen(false)}
>
<span className="sr-only">Close sidebar</span>
<XMarkIcon className="h-6 w-6 text-white" aria-hidden="true" />
</button>
</div>
</Transition.Child>
<div className="h-0 flex-1 overflow-y-auto pt-5 pb-4">
<nav className="mt-5 space-y-1 px-2">
{projectId &&
navigation(projectId as string).map((item) => (
<Link href={item.href} key={item.name}>
<a
className={classNames(
item.href === router.asPath
? "bg-gray-100 text-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900",
"group flex items-center px-2 py-2 text-base font-medium rounded-md"
)}
>
<item.icon
className={classNames(
item.href === router.asPath
? "text-gray-500"
: "text-gray-400 group-hover:text-gray-500",
"mr-4 flex-shrink-0 h-6 w-6"
)}
aria-hidden="true"
/>
{item.name}
</a>
</Link>
))}
</nav>
</div>
</Dialog.Panel>
</Transition.Child>
<div className="w-14 flex-shrink-0" />
</div>
</Dialog>
</Transition.Root>
<div
className={`${
sidebarCollapse ? "" : "w-auto md:w-64"
} h-full hidden md:inset-y-0 md:flex md:flex-col`}
>
<div className="h-full flex flex-1 flex-col border-r border-gray-200">
<div className="h-full flex flex-1 flex-col pt-2">
<WorkspaceOptions sidebarCollapse={sidebarCollapse} />
<ProjectsList navigation={navigation} sidebarCollapse={sidebarCollapse} />
<div
className={`px-2 py-2 w-full self-baseline flex items-center bg-primary ${
sidebarCollapse ? "flex-col-reverse" : ""
}`}
>
<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={() => {
const e = new KeyboardEvent("keydown", {
ctrlKey: true,
key: "h",
});
document.dispatchEvent(e);
}}
title="Help"
>
<QuestionMarkCircleIcon className="h-4 w-4 text-gray-500" />
</button>
</div>
</div>
</div>
</div>
<div className="sticky top-0 z-10 bg-white pl-1 pt-1 sm:pl-3 sm:pt-3 md:hidden">
<button
type="button"
className="-ml-0.5 -mt-0.5 inline-flex h-12 w-12 items-center justify-center rounded-md text-gray-500 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
onClick={() => setSidebarOpen(true)}
>
<span className="sr-only">Open sidebar</span>
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
</nav>
);
};
export default Sidebar;

View File

@ -0,0 +1,41 @@
// next
import Link from "next/link";
import { useRouter } from "next/router";
type Props = {
links: Array<{
label: string;
href: string;
}>;
};
const SettingsSidebar: React.FC<Props> = ({ links }) => {
const router = useRouter();
return (
<nav className="h-screen w-72 border-r border-gray-200">
<div className="h-full p-2 pt-4">
<h2 className="text-lg font-medium leading-5">Settings</h2>
<div className="mt-3">
{links.map((link, index) => (
<h4 key={index}>
<Link href={link.href}>
<a
className={`${
link.href === router.asPath
? "bg-theme text-white"
: "hover:bg-indigo-100 focus:bg-indigo-100"
} flex items-center gap-3 p-2 text-xs font-medium rounded-md outline-none`}
>
{link.label}
</a>
</Link>
</h4>
))}
</div>
</div>
</nav>
);
};
export default SettingsSidebar;

View File

@ -6,13 +6,21 @@ import { useRouter } from "next/router";
import useUser from "lib/hooks/useUser";
// layouts
import Container from "layouts/Container";
import Sidebar from "layouts/Navbar/Sidebar";
import Sidebar from "layouts/Navbar/main-sidebar";
import Header from "layouts/Navbar/Header";
// components
import CreateProjectModal from "components/project/CreateProjectModal";
import CreateProjectModal from "components/project/create-project-modal";
// types
import type { Props } from "./types";
const AppLayout: React.FC<Props> = ({ meta, children, noPadding = false, bg = "primary" }) => {
const AppLayout: React.FC<Props> = ({
meta,
children,
noPadding = false,
bg = "primary",
breadcrumbs,
right,
}) => {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
@ -28,12 +36,15 @@ const AppLayout: React.FC<Props> = ({ meta, children, noPadding = false, bg = "p
<CreateProjectModal isOpen={isOpen} setIsOpen={setIsOpen} />
<div className="h-screen w-full flex overflow-x-hidden">
<Sidebar />
<main
className={`h-full w-full min-w-0 overflow-y-auto ${noPadding ? "" : "p-5"} ${
bg === "primary" ? "bg-primary" : bg === "secondary" ? "bg-secondary" : "bg-primary"
}`}
>
{children}
<main className="h-screen w-full flex flex-col overflow-y-auto min-w-0">
<Header breadcrumbs={breadcrumbs} right={right} />
<div
className={`w-full flex-grow ${noPadding ? "" : "p-5"} ${
bg === "primary" ? "bg-primary" : bg === "secondary" ? "bg-secondary" : "bg-primary"
}`}
>
{children}
</div>
</main>
</div>
</Container>

View File

@ -0,0 +1,70 @@
// react
import React, { useEffect, useState } from "react";
// next
import { useRouter } from "next/router";
// hooks
import useUser from "lib/hooks/useUser";
// layouts
import Container from "layouts/Container";
import Sidebar from "layouts/Navbar/main-sidebar";
import SettingsSidebar from "layouts/Navbar/settings-sidebar";
import Header from "layouts/Navbar/Header";
// components
import CreateProjectModal from "components/project/create-project-modal";
// types
import { Meta } from "./types";
type Props = {
meta?: Meta;
children: React.ReactNode;
noPadding?: boolean;
bg?: "primary" | "secondary";
breadcrumbs?: JSX.Element;
right?: JSX.Element;
links: Array<{
label: string;
href: string;
}>;
};
const SettingsLayout: React.FC<Props> = ({
meta,
children,
noPadding = false,
bg = "primary",
breadcrumbs,
right,
links,
}) => {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const { user, isUserLoading } = useUser();
useEffect(() => {
if (!isUserLoading && (!user || user === null)) router.push("/signin");
}, [isUserLoading, user, router]);
return (
<Container meta={meta}>
<CreateProjectModal isOpen={isOpen} setIsOpen={setIsOpen} />
<div className="h-screen w-full flex overflow-x-hidden">
<Sidebar />
<SettingsSidebar links={links} />
<main className="h-screen w-full flex flex-col overflow-y-auto min-w-0">
<Header breadcrumbs={breadcrumbs} right={right} />
<div
className={`w-full flex-grow ${noPadding ? "" : "p-5"} ${
bg === "primary" ? "bg-primary" : bg === "secondary" ? "bg-secondary" : "bg-primary"
}`}
>
{children}
</div>
</main>
</div>
</Container>
);
};
export default SettingsLayout;

View File

@ -10,4 +10,6 @@ export type Props = {
children: React.ReactNode;
noPadding?: boolean;
bg?: "primary" | "secondary";
breadcrumbs?: JSX.Element;
right?: JSX.Element;
};

View File

@ -1,4 +1,3 @@
import { useState } from "react";
// hooks
import useTheme from "./useTheme";
import useUser from "./useUser";
@ -7,14 +6,19 @@ import { groupBy, orderArrayBy } from "constants/common";
// constants
import { PRIORITIES } from "constants/";
// types
import type { IssueResponse, IIssue, NestedKeyOf } from "types";
import type { IIssue } 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 useIssuesFilter = (projectIssues: IIssue[]) => {
const {
issueView,
setIssueView,
groupByProperty,
setGroupByProperty,
orderBy,
setOrderBy,
filterIssue,
setFilterIssue,
} = useTheme();
const { states } = useUser();
@ -27,18 +31,18 @@ const useIssuesFilter = (projectIssues?: IssueResponse) => {
?.sort((a, b) => a.sequence - b.sequence)
?.map((state) => [
state.name,
projectIssues?.results.filter((issue) => issue.state === state.name) ?? [],
projectIssues.filter((issue) => issue.state === state.name) ?? [],
]) ?? []
)
: groupByProperty === "priority"
? Object.fromEntries(
PRIORITIES.map((priority) => [
priority,
projectIssues?.results.filter((issue) => issue.priority === priority) ?? [],
projectIssues.filter((issue) => issue.priority === priority) ?? [],
])
)
: {}),
...groupBy(projectIssues?.results ?? [], groupByProperty ?? ""),
...groupBy(projectIssues ?? [], groupByProperty ?? ""),
};
if (orderBy !== null) {
@ -52,29 +56,29 @@ const useIssuesFilter = (projectIssues?: IssueResponse) => {
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);
const filteredStates = states?.filter(
(state) => state.group === "started" || state.group === "unstarted"
);
groupedByIssues = Object.fromEntries(
filteredStates
?.sort((a, b) => a.sequence - b.sequence)
?.map((state) => [
state.name,
projectIssues.filter((issue) => issue.state === state.id) ?? [],
]) ?? []
);
} 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);
const filteredStates = states?.filter(
(state) => state.group === "backlog" || state.group === "cancelled"
);
groupedByIssues = Object.fromEntries(
filteredStates
?.sort((a, b) => a.sequence - b.sequence)
?.map((state) => [
state.name,
projectIssues.filter((issue) => issue.state === state.id) ?? [],
]) ?? []
);
}
}

View File

@ -4,7 +4,7 @@ import useSWR from "swr";
// api routes
import { ISSUE_PROPERTIES_ENDPOINT } from "constants/api-routes";
// services
import issueServices from "lib/services/issues.services";
import issueServices from "lib/services/issues.service";
// hooks
import useUser from "./useUser";
// types

View File

@ -88,7 +88,7 @@ class ProjectIssuesServices extends APIService {
});
}
async addIssueToSprint(
async addIssueToCycle(
workspaceSlug: string,
projectId: string,
cycleId: string,

View File

@ -10,9 +10,12 @@ import {
PROJECT_MEMBERS,
PROJECT_MEMBER_DETAIL,
USER_PROJECT_INVITATIONS,
PROJECT_VIEW_ENDPOINT,
} from "constants/api-routes";
// services
import APIService from "lib/services/api.service";
// types
import type { IProject, IProjectMember, IProjectMemberInvitation, ProjectViewTheme } from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
@ -21,8 +24,8 @@ class ProjectServices extends APIService {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async createProject(workspace_slug: string, data: any): Promise<any> {
return this.post(PROJECTS_ENDPOINT(workspace_slug), data)
async createProject(workspacSlug: string, data: Partial<IProject>): Promise<IProject> {
return this.post(PROJECTS_ENDPOINT(workspacSlug), data)
.then((response) => {
return response?.data;
})
@ -45,8 +48,8 @@ class ProjectServices extends APIService {
});
}
async getProjects(workspace_slug: string): Promise<any> {
return this.get(PROJECTS_ENDPOINT(workspace_slug))
async getProjects(workspacSlug: string): Promise<IProject[]> {
return this.get(PROJECTS_ENDPOINT(workspacSlug))
.then((response) => {
return response?.data;
})
@ -55,8 +58,8 @@ class ProjectServices extends APIService {
});
}
async getProject(workspace_slug: string, project_id: string): Promise<any> {
return this.get(PROJECT_DETAIL(workspace_slug, project_id))
async getProject(workspacSlug: string, projectId: string): Promise<IProject> {
return this.get(PROJECT_DETAIL(workspacSlug, projectId))
.then((response) => {
return response?.data;
})
@ -65,8 +68,12 @@ class ProjectServices extends APIService {
});
}
async updateProject(workspace_slug: string, project_id: string, data: any): Promise<any> {
return this.patch(PROJECT_DETAIL(workspace_slug, project_id), data)
async updateProject(
workspacSlug: string,
projectId: string,
data: Partial<IProject>
): Promise<IProject> {
return this.patch(PROJECT_DETAIL(workspacSlug, projectId), data)
.then((response) => {
return response?.data;
})
@ -75,8 +82,8 @@ class ProjectServices extends APIService {
});
}
async deleteProject(workspace_slug: string, project_id: string): Promise<any> {
return this.delete(PROJECT_DETAIL(workspace_slug, project_id))
async deleteProject(workspacSlug: string, projectId: string): Promise<any> {
return this.delete(PROJECT_DETAIL(workspacSlug, projectId))
.then((response) => {
return response?.data;
})
@ -85,8 +92,8 @@ class ProjectServices extends APIService {
});
}
async inviteProject(workspace_slug: string, project_id: string, data: any): Promise<any> {
return this.post(INVITE_PROJECT(workspace_slug, project_id), data)
async inviteProject(workspacSlug: string, projectId: string, data: any): Promise<any> {
return this.post(INVITE_PROJECT(workspacSlug, projectId), data)
.then((response) => {
return response?.data;
})
@ -95,8 +102,8 @@ class ProjectServices extends APIService {
});
}
async joinProject(workspace_slug: string, data: any): Promise<any> {
return this.post(JOIN_PROJECT(workspace_slug), data)
async joinProject(workspacSlug: string, data: any): Promise<any> {
return this.post(JOIN_PROJECT(workspacSlug), data)
.then((response) => {
return response?.data;
})
@ -115,8 +122,8 @@ class ProjectServices extends APIService {
});
}
async projectMembers(workspace_slug: string, project_id: string): Promise<any> {
return this.get(PROJECT_MEMBERS(workspace_slug, project_id))
async projectMembers(workspacSlug: string, projectId: string): Promise<IProjectMember[]> {
return this.get(PROJECT_MEMBERS(workspacSlug, projectId))
.then((response) => {
return response?.data;
})
@ -126,12 +133,12 @@ class ProjectServices extends APIService {
}
async updateProjectMember(
workspace_slug: string,
project_id: string,
workspacSlug: string,
projectId: string,
memberId: string,
data: any
): Promise<any> {
return this.put(PROJECT_MEMBER_DETAIL(workspace_slug, project_id, memberId), data)
data: Partial<IProjectMember>
): Promise<IProjectMember> {
return this.put(PROJECT_MEMBER_DETAIL(workspacSlug, projectId, memberId), data)
.then((response) => {
return response?.data;
})
@ -141,11 +148,11 @@ class ProjectServices extends APIService {
}
async deleteProjectMember(
workspace_slug: string,
project_id: string,
workspacSlug: string,
projectId: string,
memberId: string
): Promise<any> {
return this.delete(PROJECT_MEMBER_DETAIL(workspace_slug, project_id, memberId))
return this.delete(PROJECT_MEMBER_DETAIL(workspacSlug, projectId, memberId))
.then((response) => {
return response?.data;
})
@ -154,8 +161,11 @@ class ProjectServices extends APIService {
});
}
async projectInvitations(workspace_slug: string, project_id: string): Promise<any> {
return this.get(PROJECT_INVITATIONS(workspace_slug, project_id))
async projectInvitations(
workspacSlug: string,
projectId: string
): Promise<IProjectMemberInvitation[]> {
return this.get(PROJECT_INVITATIONS(workspacSlug, projectId))
.then((response) => {
return response?.data;
})
@ -165,11 +175,11 @@ class ProjectServices extends APIService {
}
async updateProjectInvitation(
workspace_slug: string,
project_id: string,
invitation_id: string
workspacSlug: string,
projectId: string,
invitationId: string
): Promise<any> {
return this.put(PROJECT_INVITATION_DETAIL(workspace_slug, project_id, invitation_id))
return this.put(PROJECT_INVITATION_DETAIL(workspacSlug, projectId, invitationId))
.then((response) => {
return response?.data;
})
@ -177,12 +187,27 @@ class ProjectServices extends APIService {
throw error?.response?.data;
});
}
async deleteProjectInvitation(
workspace_slug: string,
project_id: string,
invitation_id: string
workspacSlug: string,
projectId: string,
invitationId: string
): Promise<any> {
return this.delete(PROJECT_INVITATION_DETAIL(workspace_slug, project_id, invitation_id))
return this.delete(PROJECT_INVITATION_DETAIL(workspacSlug, projectId, invitationId))
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async setProjectView(
workspacSlug: string,
projectId: string,
data: ProjectViewTheme
): Promise<any> {
await this.patch(PROJECT_VIEW_ENDPOINT(workspacSlug, projectId), data)
.then((response) => {
return response?.data;
})

View File

@ -16,8 +16,8 @@ class UserService extends APIService {
};
}
async userIssues(): Promise<any> {
return this.get(USER_ISSUES_ENDPOINT)
async userIssues(workspaceSlug: string): Promise<any> {
return this.get(USER_ISSUES_ENDPOINT(workspaceSlug))
.then((response) => {
return response?.data;
})

View File

@ -11,18 +11,22 @@ import {
WORKSPACE_INVITATION_DETAIL,
USER_WORKSPACE_INVITATION,
USER_WORKSPACE_INVITATIONS,
LAST_ACTIVE_WORKSPACE_AND_PROJECTS,
} from "constants/api-routes";
// services
import APIService from "lib/services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
// types
import { IWorkspace, IWorkspaceMember, IWorkspaceMemberInvitation } from "types";
class WorkspaceService extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async userWorkspaces(): Promise<any> {
async userWorkspaces(): Promise<IWorkspace[]> {
return this.get(USER_WORKSPACES)
.then((response) => {
return response?.data;
@ -32,7 +36,7 @@ class WorkspaceService extends APIService {
});
}
async createWorkspace(data: any): Promise<any> {
async createWorkspace(data: Partial<IWorkspace>): Promise<IWorkspace> {
return this.post(WORKSPACES_ENDPOINT, data)
.then((response) => {
return response?.data;
@ -42,17 +46,8 @@ class WorkspaceService extends APIService {
});
}
async updateWorkspace(workspace_slug: string, data: any): Promise<any> {
return this.patch(WORKSPACE_DETAIL(workspace_slug), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async deleteWorkspace(workspace_slug: string): Promise<any> {
return this.delete(WORKSPACE_DETAIL(workspace_slug))
async updateWorkspace(workspaceSlug: string, data: Partial<IWorkspace>): Promise<IWorkspace> {
return this.patch(WORKSPACE_DETAIL(workspaceSlug), data)
.then((response) => {
return response?.data;
})
@ -61,8 +56,8 @@ class WorkspaceService extends APIService {
});
}
async inviteWorkspace(workspace_slug: string, data: any): Promise<any> {
return this.post(INVITE_WORKSPACE(workspace_slug), data)
async deleteWorkspace(workspaceSlug: string): Promise<any> {
return this.delete(WORKSPACE_DETAIL(workspaceSlug))
.then((response) => {
return response?.data;
})
@ -70,8 +65,19 @@ class WorkspaceService extends APIService {
throw error?.response?.data;
});
}
async joinWorkspace(workspace_slug: string, InvitationId: string, data: any): Promise<any> {
return this.post(JOIN_WORKSPACE(workspace_slug, InvitationId), data, {
async inviteWorkspace(workspaceSlug: string, data: any): Promise<any> {
return this.post(INVITE_WORKSPACE(workspaceSlug), data)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async joinWorkspace(workspaceSlug: string, InvitationId: string, data: any): Promise<any> {
return this.post(JOIN_WORKSPACE(workspaceSlug, InvitationId), data, {
headers: {},
})
.then((response) => {
@ -92,7 +98,7 @@ class WorkspaceService extends APIService {
});
}
async userWorkspaceInvitations(): Promise<any> {
async userWorkspaceInvitations(): Promise<IWorkspaceMemberInvitation[]> {
return this.get(USER_WORKSPACE_INVITATIONS)
.then((response) => {
return response?.data;
@ -102,8 +108,8 @@ class WorkspaceService extends APIService {
});
}
async workspaceMembers(workspace_slug: string): Promise<any> {
return this.get(WORKSPACE_MEMBERS(workspace_slug))
async workspaceMembers(workspaceSlug: string): Promise<IWorkspaceMember[]> {
return this.get(WORKSPACE_MEMBERS(workspaceSlug))
.then((response) => {
return response?.data;
})
@ -112,8 +118,12 @@ class WorkspaceService extends APIService {
});
}
async updateWorkspaceMember(workspace_slug: string, memberId: string, data: any): Promise<any> {
return this.put(WORKSPACE_MEMBER_DETAIL(workspace_slug, memberId), data)
async updateWorkspaceMember(
workspaceSlug: string,
memberId: string,
data: Partial<IWorkspaceMember>
): Promise<IWorkspaceMember> {
return this.put(WORKSPACE_MEMBER_DETAIL(workspaceSlug, memberId), data)
.then((response) => {
return response?.data;
})
@ -122,8 +132,8 @@ class WorkspaceService extends APIService {
});
}
async deleteWorkspaceMember(workspace_slug: string, memberId: string): Promise<any> {
return this.delete(WORKSPACE_MEMBER_DETAIL(workspace_slug, memberId))
async deleteWorkspaceMember(workspaceSlug: string, memberId: string): Promise<any> {
return this.delete(WORKSPACE_MEMBER_DETAIL(workspaceSlug, memberId))
.then((response) => {
return response?.data;
})
@ -132,8 +142,8 @@ class WorkspaceService extends APIService {
});
}
async workspaceInvitations(workspace_slug: string): Promise<any> {
return this.get(WORKSPACE_INVITATIONS(workspace_slug))
async workspaceInvitations(workspaceSlug: string): Promise<IWorkspaceMemberInvitation[]> {
return this.get(WORKSPACE_INVITATIONS(workspaceSlug))
.then((response) => {
return response?.data;
})
@ -142,8 +152,8 @@ class WorkspaceService extends APIService {
});
}
async getWorkspaceInvitation(invitation_id: string): Promise<any> {
return this.get(USER_WORKSPACE_INVITATION(invitation_id), { headers: {} })
async getWorkspaceInvitation(invitationId: string): Promise<IWorkspaceMemberInvitation> {
return this.get(USER_WORKSPACE_INVITATION(invitationId), { headers: {} })
.then((response) => {
return response?.data;
})
@ -152,8 +162,11 @@ class WorkspaceService extends APIService {
});
}
async updateWorkspaceInvitation(workspace_slug: string, invitation_id: string): Promise<any> {
return this.put(WORKSPACE_INVITATION_DETAIL(workspace_slug, invitation_id))
async updateWorkspaceInvitation(
workspaceSlug: string,
invitationId: string
): Promise<IWorkspaceMemberInvitation> {
return this.put(WORKSPACE_INVITATION_DETAIL(workspaceSlug, invitationId))
.then((response) => {
return response?.data;
})
@ -161,8 +174,9 @@ class WorkspaceService extends APIService {
throw error?.response?.data;
});
}
async deleteWorkspaceInvitations(workspace_slug: string, invitation_id: string): Promise<any> {
return this.delete(WORKSPACE_INVITATION_DETAIL(workspace_slug, invitation_id))
async deleteWorkspaceInvitations(workspaceSlug: string, invitationId: string): Promise<any> {
return this.delete(WORKSPACE_INVITATION_DETAIL(workspaceSlug, invitationId))
.then((response) => {
return response?.data;
})

View File

@ -8,6 +8,8 @@ import { useForm } from "react-hook-form";
import workspaceService from "lib/services/workspace.service";
// hooks
import useUser from "lib/hooks/useUser";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts
import DefaultLayout from "layouts/DefaultLayout";
// ui
@ -66,7 +68,7 @@ const CreateWorkspace: NextPage = () => {
<DefaultLayout>
<div className="flex flex-col items-center justify-center w-full h-full px-4">
{user && (
<div className="w-96 p-2 rounded-lg bg-indigo-100 text-indigo-600 mb-10 lg:mb-20">
<div className="w-96 p-2 rounded-lg bg-indigo-100 text-theme mb-10 lg:mb-20">
<p className="text-sm text-center">logged in as {user.email}</p>
</div>
)}
@ -144,4 +146,4 @@ const CreateWorkspace: NextPage = () => {
);
};
export default CreateWorkspace;
export default withAuth(CreateWorkspace);

View File

@ -11,6 +11,8 @@ import userService from "lib/services/user.service";
import useUser from "lib/hooks/useUser";
// constants
import { USER_WORKSPACE_INVITATIONS } from "constants/api-routes";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts
import DefaultLayout from "layouts/DefaultLayout";
// components
@ -20,7 +22,7 @@ import { Button, Spinner, EmptySpace, EmptySpaceItem } from "ui";
// icons
import { CubeIcon, PlusIcon } from "@heroicons/react/24/outline";
// types
import type { IWorkspaceInvitation } from "types";
import type { IWorkspaceMemberInvitation } from "types";
import Link from "next/link";
const OnBoard: NextPage = () => {
@ -30,13 +32,12 @@ const OnBoard: NextPage = () => {
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
const { data: invitations, mutate } = useSWR<IWorkspaceInvitation[]>(
USER_WORKSPACE_INVITATIONS,
() => workspaceService.userWorkspaceInvitations()
const { data: invitations, mutate } = useSWR(USER_WORKSPACE_INVITATIONS, () =>
workspaceService.userWorkspaceInvitations()
);
const handleInvitation = (
workspace_invitation: IWorkspaceInvitation,
workspace_invitation: IWorkspaceMemberInvitation,
action: "accepted" | "withdraw"
) => {
if (action === "accepted") {
@ -81,7 +82,7 @@ const OnBoard: NextPage = () => {
>
<div className="flex min-h-full flex-col items-center justify-center p-4 sm:p-0">
{user && (
<div className="w-96 p-2 rounded-lg bg-indigo-100 text-indigo-600 mb-10">
<div className="w-96 p-2 rounded-lg bg-indigo-100 text-theme mb-10">
<p className="text-sm text-center">logged in as {user.email}</p>
</div>
)}
@ -119,7 +120,7 @@ const OnBoard: NextPage = () => {
);
}}
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
className="h-4 w-4 rounded border-gray-300 text-theme focus:ring-indigo-500"
/>
<label htmlFor={item.id} className="text-sm">
Accept
@ -204,4 +205,4 @@ const OnBoard: NextPage = () => {
);
};
export default OnBoard;
export default withAuth(OnBoard);

View File

@ -5,7 +5,7 @@ import type { NextPage } from "next";
// swr
import useSWR from "swr";
// layouts
import AppLayout from "layouts/AppLayout";
import AppLayout from "layouts/app-layout";
// hooks
import useUser from "lib/hooks/useUser";
// ui
@ -18,7 +18,9 @@ import { USER_ISSUE } from "constants/fetch-keys";
import { classNames } from "constants/common";
// services
import userService from "lib/services/user.service";
import issuesServices from "lib/services/issues.services";
import issuesServices from "lib/services/issues.service";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// components
import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown";
// icons
@ -31,11 +33,11 @@ import { Menu, Transition } from "@headlessui/react";
const MyIssues: NextPage = () => {
const [selectedWorkspace, setSelectedWorkspace] = useState<string | null>(null);
const { user, workspaces } = useUser();
const { user, workspaces, activeWorkspace } = useUser();
const { data: myIssues, mutate: mutateMyIssues } = useSWR<IIssue[]>(
user ? USER_ISSUE : null,
user ? () => userService.userIssues() : null
user && activeWorkspace ? USER_ISSUE(activeWorkspace.slug) : null,
user && activeWorkspace ? () => userService.userIssues(activeWorkspace.slug) : null
);
const updateMyIssues = (
@ -69,176 +71,107 @@ const MyIssues: NextPage = () => {
});
};
const handleWorkspaceChange = (workspaceId: string | null) => {
setSelectedWorkspace(workspaceId);
};
return (
<AppLayout>
<AppLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="My Issues" />
</Breadcrumbs>
}
right={
<HeaderButton
Icon={PlusIcon}
label="Add Issue"
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "i",
ctrlKey: true,
});
document.dispatchEvent(e);
}}
/>
}
>
<div className="w-full h-full flex flex-col space-y-5">
{myIssues ? (
<>
{myIssues.length > 0 ? (
<>
<Breadcrumbs>
<BreadcrumbItem title="My Issues" />
</Breadcrumbs>
<div className="flex items-center justify-between cursor-pointer w-full">
<h2 className="text-2xl font-medium">My Issues</h2>
<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>
<div className="flex flex-col">
<div className="overflow-x-auto ">
<div className="inline-block min-w-full align-middle px-0.5 py-2">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full">
<thead className="bg-gray-100">
<tr className="text-left">
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
NAME
</th>
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
DESCRIPTION
</th>
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
PROJECT
</th>
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
PRIORITY
</th>
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
STATUS
</th>
</tr>
</thead>
<tbody className="bg-white">
{myIssues.map((myIssue, index) => (
<tr
key={myIssue.id}
className={classNames(
index === 0 ? "border-gray-300" : "border-gray-200",
"border-t text-sm text-gray-900"
)}
</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
Icon={PlusIcon}
label="Add Issue"
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "i",
ctrlKey: true,
});
document.dispatchEvent(e);
}}
/>
</div>
</div>
<div className="mt-4 flex flex-col">
<div className="overflow-x-auto ">
<div className="inline-block min-w-full align-middle px-0.5 py-2">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full">
<thead className="bg-gray-100">
<tr className="text-left">
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
NAME
</th>
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
DESCRIPTION
</th>
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
PROJECT
</th>
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
PRIORITY
</th>
<th
scope="col"
className="px-3 py-3.5 text-sm font-semibold text-gray-900"
>
STATUS
</th>
>
<td className="px-3 py-4 text-sm font-medium text-gray-900 hover:text-theme max-w-[15rem] duration-300">
<Link href={`/projects/${myIssue.project}/issues/${myIssue.id}`}>
<a>{myIssue.name}</a>
</Link>
</td>
<td className="px-3 py-4 max-w-[15rem] truncate">
{/* {myIssue.description} */}
</td>
<td className="px-3 py-4">
{myIssue.project_detail?.name}
<br />
<span className="text-xs">{`(${myIssue.project_detail?.identifier}-${myIssue.sequence_id})`}</span>
</td>
<td className="px-3 py-4 capitalize">{myIssue.priority}</td>
<td className="relative px-3 py-4">
<ChangeStateDropdown
issue={myIssue}
updateIssues={updateMyIssues}
/>
</td>
</tr>
</thead>
<tbody className="bg-white">
{myIssues
.filter((i) =>
selectedWorkspace ? i.workspace === selectedWorkspace : true
)
.map((myIssue, index) => (
<tr
key={myIssue.id}
className={classNames(
index === 0 ? "border-gray-300" : "border-gray-200",
"border-t text-sm text-gray-900"
)}
>
<td className="px-3 py-4 text-sm font-medium text-gray-900 hover:text-theme max-w-[15rem] duration-300">
<Link
href={`/projects/${myIssue.project}/issues/${myIssue.id}`}
>
<a>{myIssue.name}</a>
</Link>
</td>
<td className="px-3 py-4 max-w-[15rem] truncate">
{/* {myIssue.description} */}
</td>
<td className="px-3 py-4">
{myIssue.project_detail?.name}
<br />
<span className="text-xs">{`(${myIssue.project_detail?.identifier}-${myIssue.sequence_id})`}</span>
</td>
<td className="px-3 py-4 capitalize">{myIssue.priority}</td>
<td className="relative px-3 py-4">
<ChangeStateDropdown
issue={myIssue}
updateIssues={updateMyIssues}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</tbody>
</table>
</div>
</div>
</div>
</>
</div>
) : (
<div className="w-full h-full flex flex-col justify-center items-center px-4">
<EmptySpace
@ -278,4 +211,4 @@ const MyIssues: NextPage = () => {
);
};
export default MyIssues;
export default withAuth(MyIssues);

View File

@ -1,22 +1,30 @@
import React, { useEffect, useState } from "react";
// next
import Link from "next/link";
import Image from "next/image";
import type { NextPage } from "next";
// swr
import useSWR from "swr";
// react hook form
import { useForm } from "react-hook-form";
// react dropzone
import Dropzone, { useDropzone } from "react-dropzone";
import Dropzone from "react-dropzone";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts
import AppLayout from "layouts/AppLayout";
import AppLayout from "layouts/app-layout";
// constants
import { USER_ISSUE, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
// services
import userService from "lib/services/user.service";
import fileServices from "lib/services/file.services";
import fileServices from "lib/services/file.service";
import workspaceService from "lib/services/workspace.service";
// ui
import { BreadcrumbItem, Breadcrumbs, Button, Input, Spinner } from "ui";
// types
import type { IIssue, IUser, IWorkspaceInvitation } from "types";
// icons
import {
ChevronRightIcon,
ClipboardDocumentListIcon,
@ -26,11 +34,8 @@ import {
UserPlusIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import useSWR from "swr";
import { USER_ISSUE, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
import useToast from "lib/hooks/useToast";
import Link from "next/link";
import workspaceService from "lib/services/workspace.service";
// types
import type { IIssue, IUser } from "types";
const defaultValues: Partial<IUser> = {
avatar: "",
@ -44,13 +49,19 @@ const Profile: NextPage = () => {
const [isImageUploading, setIsImageUploading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const { user: myProfile, mutateUser, projects } = useUser();
const { user: myProfile, mutateUser, projects, activeWorkspace } = useUser();
const { setToastAlert } = useToast();
const onSubmit = (formData: IUser) => {
const payload: Partial<IUser> = {
id: formData.id,
first_name: formData.first_name,
last_name: formData.last_name,
avatar: formData.avatar,
};
userService
.updateUser(formData)
.updateUser(payload)
.then((response) => {
mutateUser(response, false);
setIsEditing(false);
@ -75,11 +86,11 @@ const Profile: NextPage = () => {
} = useForm<IUser>({ defaultValues });
const { data: myIssues } = useSWR<IIssue[]>(
myProfile ? USER_ISSUE : null,
myProfile ? () => userService.userIssues() : null
myProfile && activeWorkspace ? USER_ISSUE(activeWorkspace.slug) : null,
myProfile && activeWorkspace ? () => userService.userIssues(activeWorkspace.slug) : null
);
const { data: invitations } = useSWR<IWorkspaceInvitation[]>(USER_WORKSPACE_INVITATIONS, () =>
const { data: invitations } = useSWR(USER_WORKSPACE_INVITATIONS, () =>
workspaceService.userWorkspaceInvitations()
);
@ -92,7 +103,7 @@ const Profile: NextPage = () => {
icon: RectangleStackIcon,
title: "My Issues",
number: myIssues?.length ?? 0,
description: "View the list of issues assigned to you across the workspace.",
description: "View the list of issues assigned to you for this workspace.",
href: "/me/my-issues",
},
{
@ -125,7 +136,8 @@ const Profile: NextPage = () => {
<>
<div className="space-y-5">
<section className="relative p-5 rounded-xl flex gap-10 bg-secondary">
<div
<button
type="button"
className="absolute top-4 right-4 bg-indigo-100 hover:bg-theme hover:text-white rounded p-1 cursor-pointer duration-300"
onClick={() => setIsEditing((prevData) => !prevData)}
>
@ -134,7 +146,7 @@ const Profile: NextPage = () => {
) : (
<PencilIcon className="h-4 w-4" />
)}
</div>
</button>
<div className="flex-shrink-0">
<Dropzone
multiple={false}
@ -241,21 +253,7 @@ const Profile: NextPage = () => {
</div>
<div>
<h4 className="text-sm text-gray-500">Email ID</h4>
{isEditing ? (
<Input
id="email"
type="email"
register={register}
error={errors.email}
name="email"
validations={{
required: "Email is required",
}}
placeholder="Enter email"
/>
) : (
<h2>{myProfile.email}</h2>
)}
<h2>{myProfile.email}</h2>
</div>
</div>
{isEditing && (
@ -307,4 +305,4 @@ const Profile: NextPage = () => {
);
};
export default Profile;
export default withAuth(Profile);

View File

@ -10,7 +10,7 @@ import useUser from "lib/hooks/useUser";
// hoc
import withAuthWrapper from "lib/hoc/withAuthWrapper";
// layouts
import AppLayout from "layouts/AppLayout";
import AppLayout from "layouts/app-layout";
// ui
import { Button } from "ui";
// swr
@ -89,7 +89,7 @@ const MyWorkspacesInvites: NextPage = () => {
)
}
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
className="h-4 w-4 rounded border-gray-300 text-theme focus:ring-indigo-500"
/>
</div>
<div className="ml-3 text-sm flex justify-between w-full">

View File

@ -1,261 +0,0 @@
import React, { useEffect, useState } from "react";
// next
import { useRouter } from "next/router";
import type { NextPage } from "next";
// swr
import useSWR, { mutate } from "swr";
// services
import issuesServices from "lib/services/issues.services";
import sprintService from "lib/services/cycles.services";
// hooks
import useUser from "lib/hooks/useUser";
// fetching keys
import { CYCLE_ISSUES, CYCLE_LIST } from "constants/fetch-keys";
// layouts
import AppLayout from "layouts/AppLayout";
// components
import CycleView from "components/project/cycles/CycleView";
import ConfirmIssueDeletion from "components/project/issues/ConfirmIssueDeletion";
import ConfirmSprintDeletion from "components/project/cycles/ConfirmCycleDeletion";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import CreateUpdateSprintsModal from "components/project/cycles/CreateUpdateCyclesModal";
// ui
import { BreadcrumbItem, Breadcrumbs, HeaderButton, Spinner, EmptySpace, EmptySpaceItem } from "ui";
// icons
import { PlusIcon } from "@heroicons/react/20/solid";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, ICycle, SelectSprintType, SelectIssue, CycleIssueResponse } from "types";
import { DragDropContext, DropResult } from "react-beautiful-dnd";
const ProjectSprints: NextPage = () => {
const [isOpen, setIsOpen] = useState(false);
const [selectedSprint, setSelectedSprint] = useState<SelectSprintType>();
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [selectedIssues, setSelectedIssues] = useState<SelectIssue>();
const [deleteIssue, setDeleteIssue] = useState<string | undefined>();
const { activeWorkspace, activeProject, issues } = useUser();
const router = useRouter();
const { projectId } = router.query;
const { data: cycles } = useSWR<ICycle[]>(
projectId && activeWorkspace ? CYCLE_LIST(projectId as string) : null,
activeWorkspace && projectId
? () => sprintService.getCycles(activeWorkspace.slug, projectId as string)
: null
);
const openIssueModal = (
cycleId: string,
issue?: IIssue,
actionType: "create" | "edit" | "delete" = "create"
) => {
const cycle = cycles?.find((cycle) => cycle.id === cycleId);
if (cycle) {
setSelectedSprint({
...cycle,
actionType: "create-issue",
});
if (issue) setSelectedIssues({ ...issue, actionType });
setIsIssueModalOpen(true);
}
};
const addIssueToSprint = (cycleId: string, issueId: string) => {
if (!activeWorkspace || !projectId) return;
issuesServices
.addIssueToSprint(activeWorkspace.slug, projectId as string, cycleId, {
issue: issueId,
})
.then((response) => {
console.log(response);
mutate(CYCLE_ISSUES(cycleId));
})
.catch((error) => {
console.log(error);
});
};
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(() => {
if (isOpen) return;
const timer = setTimeout(() => {
setSelectedSprint(undefined);
clearTimeout(timer);
}, 500);
}, [isOpen]);
useEffect(() => {
if (selectedIssues?.actionType === "delete") {
setDeleteIssue(selectedIssues.id);
}
}, [selectedIssues]);
return (
<AppLayout
meta={{
title: "Plane - Cycles",
}}
>
<CreateUpdateSprintsModal
isOpen={
isOpen &&
selectedSprint?.actionType !== "delete" &&
selectedSprint?.actionType !== "create-issue"
}
setIsOpen={setIsOpen}
data={selectedSprint}
projectId={projectId as string}
/>
<ConfirmSprintDeletion
isOpen={isOpen && !!selectedSprint && selectedSprint.actionType === "delete"}
setIsOpen={setIsOpen}
data={selectedSprint}
/>
<ConfirmIssueDeletion
handleClose={() => setDeleteIssue(undefined)}
isOpen={!!deleteIssue}
data={selectedIssues}
/>
<CreateUpdateIssuesModal
isOpen={
isIssueModalOpen &&
selectedSprint?.actionType === "create-issue" &&
selectedIssues?.actionType !== "delete"
}
data={selectedIssues}
prePopulateData={{ sprints: selectedSprint?.id }}
setIsOpen={setIsOpen}
projectId={projectId as string}
/>
{cycles ? (
cycles.length > 0 ? (
<div className="w-full space-y-5">
<Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Cycles`} />
</Breadcrumbs>
<div className="flex items-center justify-between cursor-pointer w-full">
<h2 className="text-2xl font-medium">Project Cycle</h2>
<HeaderButton Icon={PlusIcon} label="Add Cycle" onClick={() => setIsOpen(true)} />
</div>
<div className="space-y-5">
<DragDropContext onDragEnd={handleDragEnd}>
{cycles.map((cycle) => (
<CycleView
key={cycle.id}
cycle={cycle}
selectSprint={setSelectedSprint}
projectId={projectId as string}
workspaceSlug={activeWorkspace?.slug as string}
openIssueModal={openIssueModal}
addIssueToSprint={addIssueToSprint}
/>
))}
</DragDropContext>
</div>
</div>
) : (
<div className="w-full h-full flex flex-col justify-center items-center px-4">
<EmptySpace
title="You don't have any cycle yet."
description="A cycle is a fixed time period where a team commits to a set number of issues from their backlog. Cycles are usually one, two, or four weeks long."
Icon={ArrowPathIcon}
>
<EmptySpaceItem
title="Create a new cycle"
description={
<span>
Use <pre className="inline bg-gray-100 px-2 py-1 rounded">Ctrl/Command + Q</pre>{" "}
shortcut to create a new cycle
</span>
}
Icon={PlusIcon}
action={() => setIsOpen(true)}
/>
</EmptySpace>
</div>
)
) : (
<div className="w-full h-full flex justify-center items-center">
<Spinner />
</div>
)}
</AppLayout>
);
};
export default ProjectSprints;

View File

@ -0,0 +1,416 @@
// react
import React from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR, { mutate } from "swr";
// react-beautiful-dnd
import { DropResult } from "react-beautiful-dnd";
// layouots
import AppLayout from "layouts/app-layout";
// components
import CyclesListView from "components/project/cycles/ListView";
import CyclesBoardView from "components/project/cycles/BoardView";
// services
import issuesServices from "lib/services/issues.service";
import cycleServices from "lib/services/cycles.service";
import projectService from "lib/services/project.service";
// hooks
import useUser from "lib/hooks/useUser";
import useIssuesFilter from "lib/hooks/useIssuesFilter";
import useIssuesProperties from "lib/hooks/useIssuesProperties";
// headless ui
import { Menu, Popover, Transition } from "@headlessui/react";
// ui
import { BreadcrumbItem, Breadcrumbs, CustomMenu } from "ui";
// icons
import { Squares2X2Icon } from "@heroicons/react/20/solid";
import {
ArrowPathIcon,
ChevronDownIcon,
EllipsisHorizontalIcon,
ListBulletIcon,
} from "@heroicons/react/24/outline";
// types
import { CycleIssueResponse, IIssue, NestedKeyOf, Properties } from "types";
// fetch-keys
import { CYCLE_ISSUES, PROJECT_MEMBERS } from "constants/fetch-keys";
// constants
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
import Link from "next/link";
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: "Last created", key: "created_at" },
{ name: "Last updated", 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",
},
];
type Props = {};
const SingleCycle: React.FC<Props> = () => {
const { activeWorkspace, activeProject, cycles } = useUser();
const router = useRouter();
const { cycleId } = router.query;
const [properties, setProperties] = useIssuesProperties(
activeWorkspace?.slug,
activeProject?.id as string
);
const { data: cycleIssues } = useSWR<CycleIssueResponse[]>(
activeWorkspace && activeProject && cycleId ? CYCLE_ISSUES(cycleId as string) : null,
activeWorkspace && activeProject && cycleId
? () =>
cycleServices.getCycleIssues(activeWorkspace?.slug, activeProject?.id, cycleId as string)
: null
);
const cycleIssuesArray = cycleIssues?.map((issue) => {
return issue.issue_details;
});
const { data: members } = useSWR(
activeWorkspace && activeProject ? PROJECT_MEMBERS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectService.projectMembers(activeWorkspace.slug, activeProject.id)
: null,
{
onErrorRetry(err, _, __, revalidate, revalidateOpts) {
if (err?.status === 403) return;
setTimeout(() => revalidate(revalidateOpts), 5000);
},
}
);
const {
issueView,
setIssueView,
groupByProperty,
setGroupByProperty,
groupedByIssues,
setOrderBy,
setFilterIssue,
orderBy,
filterIssue,
} = useIssuesFilter(cycleIssuesArray ?? []);
const addIssueToCycle = (cycleId: string, issueId: string) => {
if (!activeWorkspace || !activeProject?.id) return;
issuesServices
.addIssueToCycle(activeWorkspace.slug, activeProject.id, cycleId, {
issue: issueId,
})
.then((response) => {
console.log(response);
mutate(CYCLE_ISSUES(cycleId));
})
.catch((error) => {
console.log(error);
});
};
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));
issuesServices
.removeIssueFromCycle(
activeWorkspace.slug,
activeProject.id,
source.droppableId,
result.draggableId.split(",")[0]
)
.then((res) => {
issuesServices
.addIssueToCycle(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);
};
const removeIssueFromCycle = (cycleId: string, bridgeId: string) => {
if (activeWorkspace && activeProject) {
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 (
<AppLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${activeProject?.name ?? "Project"} Cycles`}
link={`/projects/${activeProject?.id}/cycles`}
/>
{/* <BreadcrumbItem title={`${cycles?.find((c) => c.id === cycleId)?.name ?? "Cycle"} `} /> */}
<Menu as="div" className="relative inline-block">
<Menu.Button className="flex items-center gap-1 border ml-3 px-2 py-1 rounded hover:bg-gray-100 text-xs font-medium">
<ArrowPathIcon className="h-3 w-3" />
Cycle
</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-3 mt-2 p-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
{cycles?.map((cycle) => (
<Menu.Item key={cycle.id}>
<Link href={`/projects/${activeProject?.id}/cycles/${cycle.id}`}>
<a
className={`block text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full ${
cycle.id === cycleId ? "bg-theme text-white" : ""
}`}
>
{cycle.name}
</a>
</Link>
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
</Breadcrumbs>
}
right={
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-1">
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
issueView === "list" ? "bg-gray-200" : ""
}`}
onClick={() => {
setIssueView("list");
setGroupByProperty(null);
}}
>
<ListBulletIcon className="h-4 w-4" />
</button>
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
issueView === "kanban" ? "bg-gray-200" : ""
}`}
onClick={() => {
setIssueView("kanban");
setGroupByProperty("state_detail.name");
}}
>
<Squares2X2Icon className="h-4 w-4" />
</button>
</div>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={classNames(
open ? "bg-gray-100 text-gray-900" : "text-gray-500",
"group flex gap-2 items-center rounded-md bg-transparent text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none border p-2"
)}
>
<span>View</span>
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute mr-5 right-1/2 z-10 mt-1 w-screen max-w-xs translate-x-1/2 transform p-3 bg-white rounded-lg shadow-lg overflow-hidden">
<div className="relative flex flex-col gap-1 gap-y-4">
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Group by</h4>
<CustomMenu
label={
groupByOptions.find((option) => option.key === groupByProperty)?.name ??
"Select"
}
>
{groupByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Order by</h4>
<CustomMenu
label={
orderByOptions.find((option) => option.key === orderBy)?.name ??
"Select"
}
>
{orderByOptions.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setOrderBy(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Issue type</h4>
<CustomMenu
label={
filterIssueOptions.find((option) => option.key === filterIssue)?.name ??
"Select"
}
>
{filterIssueOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setFilterIssue(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="border-b-2"></div>
<div className="relative flex flex-col gap-1">
<h4 className="text-base text-gray-600">Properties</h4>
<div className="flex items-center gap-2 flex-wrap">
{Object.keys(properties).map((key) => (
<button
key={key}
type="button"
className={`px-2 py-1 capitalize rounded border border-theme text-xs ${
properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: ""
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{replaceUnderscoreIfSnakeCase(key)}
</button>
))}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
}
>
{issueView === "list" ? (
<CyclesListView
groupedByIssues={groupedByIssues}
selectedGroup={groupByProperty}
properties={properties}
openCreateIssueModal={() => {
return;
}}
openIssuesListModal={() => {
return;
}}
removeIssueFromCycle={removeIssueFromCycle}
/>
) : (
<div className="h-screen">
<CyclesBoardView
groupedByIssues={groupedByIssues}
properties={properties}
removeIssueFromCycle={removeIssueFromCycle}
selectedGroup={groupByProperty}
members={members}
openCreateIssueModal={() => {
return;
}}
openIssuesListModal={() => {
return;
}}
/>
</div>
)}
</AppLayout>
);
};
export default SingleCycle;

View File

@ -0,0 +1,243 @@
// react
import React, { useEffect, useState } from "react";
// next
import { useRouter } from "next/router";
import type { NextPage } from "next";
import Link from "next/link";
// swr
import useSWR from "swr";
// services
import issuesServices from "lib/services/issues.service";
import sprintService from "lib/services/cycles.service";
// hooks
import useUser from "lib/hooks/useUser";
import useIssuesProperties from "lib/hooks/useIssuesProperties";
// fetching keys
import { CYCLE_LIST } from "constants/fetch-keys";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts
import AppLayout from "layouts/app-layout";
// components
import CycleIssuesListModal from "components/project/cycles/CycleIssuesListModal";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
import ConfirmSprintDeletion from "components/project/cycles/ConfirmCycleDeletion";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import CreateUpdateSprintsModal from "components/project/cycles/CreateUpdateCyclesModal";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// ui
import { BreadcrumbItem, Breadcrumbs, HeaderButton, Spinner, EmptySpace, EmptySpaceItem } from "ui";
// icons
import { ArrowPathIcon, ChevronDownIcon, PlusIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, ICycle, SelectSprintType, SelectIssue, Properties } from "types";
// constants
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
const ProjectSprints: NextPage = () => {
const [isOpen, setIsOpen] = useState(false);
const [selectedSprint, setSelectedSprint] = useState<SelectSprintType>();
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [selectedIssues, setSelectedIssues] = useState<SelectIssue>();
const [deleteIssue, setDeleteIssue] = useState<string | undefined>();
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
const [cycleId, setCycleId] = useState("");
const { activeWorkspace, activeProject, issues } = useUser();
const router = useRouter();
const { projectId } = router.query;
const { data: cycles } = useSWR<ICycle[]>(
activeWorkspace && projectId ? CYCLE_LIST(projectId as string) : null,
activeWorkspace && projectId
? () => sprintService.getCycles(activeWorkspace.slug, projectId as string)
: null
);
const [properties, setProperties] = useIssuesProperties(
activeWorkspace?.slug,
projectId as string
);
const openCreateIssueModal = (
cycleId: string,
issue?: IIssue,
actionType: "create" | "edit" | "delete" = "create"
) => {
const cycle = cycles?.find((cycle) => cycle.id === cycleId);
if (cycle) {
setSelectedSprint({
...cycle,
actionType: "create-issue",
});
if (issue) setSelectedIssues({ ...issue, actionType });
setIsIssueModalOpen(true);
}
};
const openIssuesListModal = (cycleId: string) => {
setCycleId(cycleId);
setCycleIssuesListModal(true);
};
useEffect(() => {
if (isOpen) return;
const timer = setTimeout(() => {
setSelectedSprint(undefined);
clearTimeout(timer);
}, 500);
}, [isOpen]);
useEffect(() => {
if (selectedIssues?.actionType === "delete") {
setDeleteIssue(selectedIssues.id);
}
}, [selectedIssues]);
return (
<AppLayout
meta={{
title: "Plane - Cycles",
}}
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Cycles`} />
</Breadcrumbs>
}
right={
<div className="flex items-center gap-2">
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={classNames(
open ? "bg-gray-100 text-gray-900" : "text-gray-500",
"group flex gap-2 items-center rounded-md bg-transparent text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none border p-2"
)}
>
<span>View</span>
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute mr-5 right-1/2 z-10 mt-1 w-screen max-w-xs translate-x-1/2 transform p-4 bg-white rounded-lg shadow-lg overflow-hidden">
<div className="relative flex flex-col gap-1 gap-y-4">
<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-theme text-sm m-1 ${
properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: ""
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{replaceUnderscoreIfSnakeCase(key)}
</button>
))}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<HeaderButton Icon={PlusIcon} label="Add Cycle" onClick={() => setIsOpen(true)} />
</div>
}
>
<CreateUpdateSprintsModal
isOpen={
isOpen &&
selectedSprint?.actionType !== "delete" &&
selectedSprint?.actionType !== "create-issue"
}
setIsOpen={setIsOpen}
data={selectedSprint}
projectId={projectId as string}
/>
<ConfirmSprintDeletion
isOpen={isOpen && !!selectedSprint && selectedSprint.actionType === "delete"}
setIsOpen={setIsOpen}
data={selectedSprint}
/>
<ConfirmIssueDeletion
handleClose={() => setDeleteIssue(undefined)}
isOpen={!!deleteIssue}
data={selectedIssues}
/>
<CreateUpdateIssuesModal
isOpen={
isIssueModalOpen &&
selectedSprint?.actionType === "create-issue" &&
selectedIssues?.actionType !== "delete"
}
data={selectedIssues}
prePopulateData={{ sprints: selectedSprint?.id }}
setIsOpen={setIsOpen}
projectId={projectId as string}
/>
<CycleIssuesListModal
isOpen={cycleIssuesListModal}
handleClose={() => setCycleIssuesListModal(false)}
issues={issues}
cycleId={cycleId}
/>
{cycles ? (
cycles.length > 0 ? (
<div className="space-y-5">
{cycles.map((cycle) => (
<Link key={cycle.id} href={`/projects/${activeProject?.id}/cycles/${cycle.id}`}>
<a className="block bg-white p-3 rounded-md">{cycle.name}</a>
</Link>
))}
</div>
) : (
<div className="w-full h-full flex flex-col justify-center items-center px-4">
<EmptySpace
title="You don't have any cycle yet."
description="A cycle is a fixed time period where a team commits to a set number of issues from their backlog. Cycles are usually one, two, or four weeks long."
Icon={ArrowPathIcon}
>
<EmptySpaceItem
title="Create a new cycle"
description={
<span>
Use <pre className="inline bg-gray-100 px-2 py-1 rounded">Ctrl/Command + Q</pre>{" "}
shortcut to create a new cycle
</span>
}
Icon={PlusIcon}
action={() => setIsOpen(true)}
/>
</EmptySpace>
</div>
)
) : (
<div className="w-full h-full flex justify-center items-center">
<Spinner />
</div>
)}
</AppLayout>
);
};
export default withAuth(ProjectSprints);

View File

@ -7,22 +7,23 @@ import React, { useCallback, useEffect, useState } from "react";
// swr
import useSWR, { mutate } from "swr";
// react hook form
import { Controller, useForm } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
// headless ui
import { Disclosure, Menu, Tab, Transition } from "@headlessui/react";
// services
import issuesServices from "lib/services/issues.services";
import issuesServices from "lib/services/issues.service";
// fetch keys
import {
PROJECT_ISSUES_ACTIVITY,
PROJECT_ISSUES_COMMENTS,
PROJECT_ISSUES_LIST,
STATE_LIST,
} from "constants/fetch-keys";
// hooks
import useUser from "lib/hooks/useUser";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts
import AppLayout from "layouts/AppLayout";
import AppLayout from "layouts/app-layout";
// components
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import IssueCommentSection from "components/project/issues/issue-detail/comment/IssueCommentSection";
@ -47,13 +48,14 @@ import {
} from "@heroicons/react/24/outline";
import Link from "next/link";
import AddAsSubIssue from "components/command-palette/addAsSubIssue";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
const RichTextEditor = dynamic(() => import("components/lexical/editor"), {
ssr: false,
});
const IssueDetail: NextPage = () => {
const router = useRouter();
const { issueId, projectId } = router.query;
const { activeWorkspace, activeProject, issues, mutateIssues, states } = useUser();
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [isAddAsSubIssueOpen, setIsAddAsSubIssueOpen] = useState(false);
@ -65,19 +67,18 @@ const IssueDetail: NextPage = () => {
>(undefined);
const [issueDescriptionValue, setIssueDescriptionValue] = useState("");
const router = useRouter();
const { issueId, projectId } = router.query;
const { activeWorkspace, activeProject, issues, mutateIssues, states } = useUser();
const handleDescriptionChange: any = (value: any) => {
console.log(value);
setIssueDescriptionValue(value);
};
const RichTextEditor = dynamic(() => import("components/lexical/editor"), {
ssr: false,
});
const LexicalViewer = dynamic(() => import("components/lexical/viewer"), {
ssr: false,
});
const {
register,
formState: { errors },
@ -141,8 +142,13 @@ const IssueDetail: NextPage = () => {
false
);
const payload = {
...formData,
// description: formData.description ? JSON.parse(formData.description) : null,
};
issuesServices
.patchIssue(activeWorkspace.slug, projectId as string, issueId as string, formData)
.patchIssue(activeWorkspace.slug, projectId as string, issueId as string, payload)
.then((response) => {
console.log(response);
})
@ -157,6 +163,7 @@ const IssueDetail: NextPage = () => {
if (issueDetail)
reset({
...issueDetail,
// description: JSON.stringify(issueDetail.description),
blockers_list:
issueDetail.blockers_list ??
issueDetail.blocker_issues?.map((issue) => issue.blocker_issue_detail?.id),
@ -206,8 +213,50 @@ const IssueDetail: NextPage = () => {
}
};
// console.log(issueDetail);
return (
<AppLayout noPadding={true} bg="secondary">
<AppLayout
noPadding={true}
bg="secondary"
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${activeProject?.name ?? "Project"} Issues`}
link={`/projects/${activeProject?.id}/issues`}
/>
<BreadcrumbItem
title={`Issue ${activeProject?.identifier ?? "Project"}-${
issueDetail?.sequence_id ?? "..."
} Details`}
/>
</Breadcrumbs>
}
right={
<div className="flex items-center gap-2">
<HeaderButton
Icon={ChevronLeftIcon}
label="Previous"
className={`${!prevIssue ? "cursor-not-allowed opacity-70" : ""}`}
onClick={() => {
if (!prevIssue) return;
router.push(`/projects/${prevIssue.project}/issues/${prevIssue.id}`);
}}
/>
<HeaderButton
Icon={ChevronRightIcon}
disabled={!nextIssue}
label="Next"
className={`${!nextIssue ? "cursor-not-allowed opacity-70" : ""}`}
onClick={() => {
if (!nextIssue) return;
router.push(`/projects/${nextIssue.project}/issues/${nextIssue?.id}`);
}}
position="reverse"
/>
</div>
}
>
<CreateUpdateIssuesModal
isOpen={isOpen}
setIsOpen={setIsOpen}
@ -216,6 +265,11 @@ const IssueDetail: NextPage = () => {
...preloadedData,
}}
/>
<ConfirmIssueDeletion
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueDetail}
/>
<AddAsSubIssue
isOpen={isAddAsSubIssueOpen}
setIsOpen={setIsAddAsSubIssueOpen}
@ -224,19 +278,7 @@ const IssueDetail: NextPage = () => {
{issueDetail && activeProject ? (
<div className="flex gap-5">
<div className="basis-3/4 space-y-5 p-5">
<div className="mb-5">
<Breadcrumbs>
<BreadcrumbItem
title={`${activeProject?.name ?? "Project"} Issues`}
link={`/projects/${activeProject?.id}/issues`}
/>
<BreadcrumbItem
title={`Issue ${activeProject?.identifier ?? "Project"}-${
issueDetail?.sequence_id ?? "..."
} Details`}
/>
</Breadcrumbs>
</div>
<div className="mb-5"></div>
<div className="rounded-lg">
{issueDetail.parent !== null && issueDetail.parent !== "" ? (
<div className="bg-gray-100 flex items-center gap-2 p-2 text-xs rounded mb-5 w-min whitespace-nowrap">
@ -330,15 +372,21 @@ const IssueDetail: NextPage = () => {
{/* <Controller
name="description"
control={control}
render={({ field }) => (
render={({ field: { value, onChange } }) => (
<RichTextEditor
{...field}
// value={JSON.stringify(issueDetail.description)}
value={value}
onChange={(val) => {
debounce(() => {
console.log("Debounce");
// handleSubmit(submitChanges)();
}, 5000)();
onChange(val);
}}
id="issueDescriptionEditor"
value={JSON.parse(issueDetail.description)}
/>
)}
/> */}
{/* <LexicalViewer id="descriptionViewer" value={JSON.parse(issueDetail.description)} /> */}
</div>
<div className="mt-2">
{subIssues && subIssues.length > 0 ? (
@ -566,34 +614,13 @@ const IssueDetail: NextPage = () => {
</Tab.Group>
</div>
</div>
<div className="basis-1/4 rounded-lg space-y-5 p-5 border-l">
<div className="flex justify-end items-center gap-x-3 mb-5">
<HeaderButton
Icon={ChevronLeftIcon}
label="Previous"
className={`${!prevIssue ? "cursor-not-allowed opacity-70" : ""}`}
onClick={() => {
if (!prevIssue) return;
router.push(`/projects/${prevIssue.project}/issues/${prevIssue.id}`);
}}
/>
<HeaderButton
Icon={ChevronRightIcon}
disabled={!nextIssue}
label="Next"
className={`${!nextIssue ? "cursor-not-allowed opacity-70" : ""}`}
onClick={() => {
if (!nextIssue) return;
router.push(`/projects/${nextIssue.project}/issues/${nextIssue?.id}`);
}}
position="reverse"
/>
</div>
<div className="h-full basis-1/4 space-y-5 p-5 border-l">
<IssueDetailSidebar
control={control}
issueDetail={issueDetail}
submitChanges={submitChanges}
watch={watch}
setDeleteIssueModal={setDeleteIssueModal}
/>
</div>
</div>
@ -606,4 +633,4 @@ const IssueDetail: NextPage = () => {
);
};
export default IssueDetail;
export default withAuth(IssueDetail);

View File

@ -3,11 +3,13 @@ import React, { useEffect, useState } from "react";
import type { NextPage } from "next";
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
import useSWR, { mutate } from "swr";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// services
import issuesServices from "lib/services/issues.service";
// hooks
import useUser from "lib/hooks/useUser";
import useIssuesProperties from "lib/hooks/useIssuesProperties";
@ -18,23 +20,31 @@ import projectService from "lib/services/project.service";
// commons
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
// layouts
import AppLayout from "layouts/AppLayout";
import AppLayout from "layouts/app-layout";
// hooks
import useIssuesFilter from "lib/hooks/useIssuesFilter";
// components
import ListView from "components/project/issues/ListView";
import BoardView from "components/project/issues/BoardView";
import ConfirmIssueDeletion from "components/project/issues/ConfirmIssueDeletion";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
// ui
import { Spinner, CustomMenu, BreadcrumbItem, Breadcrumbs } from "ui";
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
import HeaderButton from "ui/HeaderButton";
import {
Spinner,
CustomMenu,
BreadcrumbItem,
Breadcrumbs,
EmptySpace,
EmptySpaceItem,
HeaderButton,
} from "ui";
// icons
import { ChevronDownIcon, ListBulletIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
import { PlusIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
// types
import type { IIssue, Properties, NestedKeyOf, ProjectMember } from "types";
import type { IIssue, Properties, NestedKeyOf, IssueResponse } from "types";
// fetch-keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [
{ name: "State", key: "state_detail.name" },
@ -86,7 +96,7 @@ const ProjectIssues: NextPage = () => {
projectId as string
);
const { data: members } = useSWR<ProjectMember[]>(
const { data: members } = useSWR(
activeWorkspace && activeProject
? PROJECT_MEMBERS(activeWorkspace.slug, activeProject.id)
: null,
@ -101,6 +111,26 @@ const ProjectIssues: NextPage = () => {
}
);
const partialUpdateIssue = (formData: Partial<IIssue>, issueId: string) => {
if (!activeWorkspace || !activeProject) return;
issuesServices
.patchIssue(activeWorkspace.slug, activeProject.id, issueId, formData)
.then((response) => {
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
(prevData) => ({
...(prevData as IssueResponse),
results:
prevData?.results.map((issue) => (issue.id === response.id ? response : issue)) ?? [],
}),
false
);
})
.catch((error) => {
console.log(error);
});
};
const {
issueView,
setIssueView,
@ -111,7 +141,7 @@ const ProjectIssues: NextPage = () => {
setFilterIssue,
orderBy,
filterIssue,
} = useIssuesFilter(projectIssues);
} = useIssuesFilter(projectIssues?.results ?? []);
useEffect(() => {
if (!isOpen) {
@ -123,7 +153,161 @@ const ProjectIssues: NextPage = () => {
}, [isOpen]);
return (
<AppLayout>
<AppLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Issues`} />
</Breadcrumbs>
}
right={
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-1">
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
issueView === "list" ? "bg-gray-200" : ""
}`}
onClick={() => {
setIssueView("list");
setGroupByProperty(null);
}}
>
<ListBulletIcon className="h-4 w-4" />
</button>
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
issueView === "kanban" ? "bg-gray-200" : ""
}`}
onClick={() => {
setIssueView("kanban");
setGroupByProperty("state_detail.name");
}}
>
<Squares2X2Icon className="h-4 w-4" />
</button>
</div>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={classNames(
open ? "bg-gray-100 text-gray-900" : "text-gray-500",
"group flex gap-2 items-center rounded-md bg-transparent text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none border p-2"
)}
>
<span>View</span>
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute mr-5 right-1/2 z-10 mt-1 w-screen max-w-xs translate-x-1/2 transform p-3 bg-white rounded-lg shadow-lg overflow-hidden">
<div className="relative flex flex-col gap-1 gap-y-4">
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Group by</h4>
<CustomMenu
label={
groupByOptions.find((option) => option.key === groupByProperty)?.name ??
"Select"
}
>
{groupByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Order by</h4>
<CustomMenu
label={
orderByOptions.find((option) => option.key === orderBy)?.name ??
"Select"
}
>
{orderByOptions.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setOrderBy(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
<div className="flex justify-between items-center">
<h4 className="text-sm text-gray-600">Issue type</h4>
<CustomMenu
label={
filterIssueOptions.find((option) => option.key === filterIssue)?.name ??
"Select"
}
>
{filterIssueOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setFilterIssue(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="border-b-2"></div>
<div className="relative flex flex-col gap-1">
<h4 className="text-base text-gray-600">Properties</h4>
<div className="flex items-center gap-2 flex-wrap">
{Object.keys(properties).map((key) => (
<button
key={key}
type="button"
className={`px-2 py-1 capitalize rounded border border-theme text-xs ${
properties[key as keyof Properties]
? "border-theme bg-theme text-white"
: ""
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{replaceUnderscoreIfSnakeCase(key)}
</button>
))}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<HeaderButton
Icon={PlusIcon}
label="Add Issue"
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "i",
ctrlKey: true,
});
document.dispatchEvent(e);
}}
/>
</div>
}
>
<CreateUpdateIssuesModal
isOpen={isOpen && selectedIssue?.actionType !== "delete"}
setIsOpen={setIsOpen}
@ -141,167 +325,6 @@ const ProjectIssues: NextPage = () => {
</div>
) : projectIssues.count > 0 ? (
<>
<div className="w-full space-y-5 mb-5">
<Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Issues`} />
</Breadcrumbs>
<div className="flex items-center justify-between w-full">
<h2 className="text-2xl font-medium">Project Issues</h2>
<div className="flex items-center md:gap-x-6 sm:gap-x-3">
<div className="flex items-center gap-x-1">
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
issueView === "list" ? "bg-gray-200" : ""
}`}
onClick={() => {
setIssueView("list");
setGroupByProperty(null);
}}
>
<ListBulletIcon className="h-4 w-4" />
</button>
<button
type="button"
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none ${
issueView === "kanban" ? "bg-gray-200" : ""
}`}
onClick={() => {
setIssueView("kanban");
setGroupByProperty("state_detail.name");
}}
>
<Squares2X2Icon className="h-4 w-4" />
</button>
</div>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={classNames(
open ? "text-gray-900" : "text-gray-500",
"group inline-flex items-center rounded-md bg-transparent text-xs font-medium hover:text-gray-900 focus:outline-none border border-gray-300 px-2 py-2"
)}
>
<span>View</span>
<ChevronDownIcon
className={classNames(
open ? "text-gray-600" : "text-gray-400",
"ml-2 h-4 w-4 group-hover:text-gray-500"
)}
aria-hidden="true"
/>
</Popover.Button>
<Transition
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 mr-5 right-1/2 z-10 mt-1 w-screen max-w-xs translate-x-1/2 transform p-4 bg-white rounded-lg shadow-lg overflow-hidden">
<div className="relative flex flex-col gap-1 gap-y-4">
<div className="flex justify-between">
<h4 className="text-base text-gray-600">Group by</h4>
<CustomMenu
label={
groupByOptions.find((option) => option.key === groupByProperty)
?.name ?? "Select"
}
>
{groupByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="flex justify-between">
<h4 className="text-base text-gray-600">Order by</h4>
<CustomMenu
label={
orderByOptions.find((option) => option.key === orderBy)?.name ??
"Select"
}
>
{orderByOptions.map((option) =>
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>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<HeaderButton
Icon={PlusIcon}
label="Add Issue"
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "i",
ctrlKey: true,
});
document.dispatchEvent(e);
}}
/>
</div>
</div>
</div>
{issueView === "list" ? (
<ListView
properties={properties}
@ -309,14 +332,17 @@ const ProjectIssues: NextPage = () => {
selectedGroup={groupByProperty}
setSelectedIssue={setSelectedIssue}
handleDeleteIssue={setDeleteIssue}
partialUpdateIssue={partialUpdateIssue}
/>
) : (
<div className="h-full">
<div className="h-screen">
<BoardView
properties={properties}
selectedGroup={groupByProperty}
groupedByIssues={groupedByIssues}
members={members}
handleDeleteIssue={setDeleteIssue}
partialUpdateIssue={partialUpdateIssue}
/>
</div>
)}

View File

@ -14,8 +14,10 @@ import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// fetching keys
import { PROJECT_MEMBERS, PROJECT_INVITATIONS } from "constants/fetch-keys";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts
import AppLayout from "layouts/AppLayout";
import AppLayout from "layouts/app-layout";
// components
import SendProjectInvitationModal from "components/project/SendProjectInvitationModal";
import ConfirmProjectMemberRemove from "components/project/ConfirmProjectMemberRemove";
@ -90,7 +92,15 @@ const ProjectMembers: NextPage = () => {
];
return (
<AppLayout>
<AppLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Members`} />
</Breadcrumbs>
}
right={<HeaderButton Icon={PlusIcon} label="Add Member" onClick={() => setIsOpen(true)} />}
>
<ConfirmProjectMemberRemove
isOpen={Boolean(selectedRemoveMember) || Boolean(selectedInviteRemoveMember)}
onClose={() => {
@ -109,8 +119,7 @@ const ProjectMembers: NextPage = () => {
selectedRemoveMember
);
mutateMembers(
(prevData: any[]) =>
prevData?.filter((item: any) => item.id !== selectedRemoveMember),
(prevData) => prevData?.filter((item: any) => item.id !== selectedRemoveMember),
false
);
}
@ -121,8 +130,7 @@ const ProjectMembers: NextPage = () => {
selectedInviteRemoveMember
);
mutateInvitations(
(prevData: any[]) =>
prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
(prevData) => prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
false
);
}
@ -140,14 +148,6 @@ const ProjectMembers: NextPage = () => {
</div>
) : (
<div className="h-full w-full space-y-5">
<Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Members`} />
</Breadcrumbs>
<div className="flex items-center justify-between cursor-pointer w-full">
<h2 className="text-2xl font-medium">Invite Members</h2>
<HeaderButton Icon={PlusIcon} label="Add Member" onClick={() => setIsOpen(true)} />
</div>
{members && members.length === 0 ? null : (
<table className="min-w-full table-fixed border border-gray-300 md:rounded-lg divide-y divide-gray-300">
<thead className="bg-gray-50">
@ -246,7 +246,7 @@ const ProjectMembers: NextPage = () => {
Active
</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-gray-900 rounded-full">
Pending
</span>
)}
@ -313,4 +313,4 @@ const ProjectMembers: NextPage = () => {
);
};
export default ProjectMembers;
export default withAuth(ProjectMembers);

View File

@ -9,8 +9,10 @@ import useSWR, { mutate } from "swr";
import { useForm } from "react-hook-form";
// headless ui
import { Tab } from "@headlessui/react";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts
import AppLayout from "layouts/AppLayout";
import SettingsLayout from "layouts/settings-layout";
// service
import projectServices from "lib/services/project.service";
// hooks
@ -24,32 +26,34 @@ import { Breadcrumbs, BreadcrumbItem } from "ui/Breadcrumbs";
// types
import type { IProject, IWorkspace } from "types";
const GeneralSettings = dynamic(() => import("components/project/settings/GeneralSettings"), {
loading: () => <p>Loading...</p>,
ssr: false,
});
const ControlSettings = dynamic(() => import("components/project/settings/ControlSettings"), {
loading: () => <p>Loading...</p>,
ssr: false,
});
const StatesSettings = dynamic(() => import("components/project/settings/StatesSettings"), {
loading: () => <p>Loading...</p>,
ssr: false,
});
const LabelsSettings = dynamic(() => import("components/project/settings/LabelsSettings"), {
loading: () => <p>Loading...</p>,
ssr: false,
});
const defaultValues: Partial<IProject> = {
name: "",
description: "",
identifier: "",
network: 0,
};
const ProjectSettings: NextPage = () => {
const GeneralSettings = dynamic(() => import("components/project/settings/GeneralSettings"), {
loading: () => <p>Loading...</p>,
ssr: false,
});
const ControlSettings = dynamic(() => import("components/project/settings/ControlSettings"), {
loading: () => <p>Loading...</p>,
ssr: false,
});
const StatesSettings = dynamic(() => import("components/project/settings/StatesSettings"), {
loading: () => <p>Loading...</p>,
ssr: false,
});
const LabelsSettings = dynamic(() => import("components/project/settings/LabelsSettings"), {
loading: () => <p>Loading...</p>,
ssr: false,
});
const {
register,
handleSubmit,
@ -70,7 +74,7 @@ const ProjectSettings: NextPage = () => {
const { setToastAlert } = useToast();
const { data: projectDetails } = useSWR<IProject>(
activeWorkspace && projectId ? PROJECT_DETAILS : null,
activeWorkspace && projectId ? PROJECT_DETAILS(projectId as string) : null,
activeWorkspace
? () => projectServices.getProject(activeWorkspace.slug, projectId as string)
: null
@ -87,7 +91,7 @@ const ProjectSettings: NextPage = () => {
}, [projectDetails, reset]);
const onSubmit = async (formData: IProject) => {
if (!activeWorkspace) return;
if (!activeWorkspace || !projectId) return;
const payload: Partial<IProject> = {
name: formData.name,
network: formData.network,
@ -99,7 +103,11 @@ const ProjectSettings: NextPage = () => {
await projectServices
.updateProject(activeWorkspace.slug, projectId as string, payload)
.then((res) => {
mutate<IProject>(PROJECT_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
mutate<IProject>(
PROJECT_DETAILS(projectId as string),
(prevData) => ({ ...prevData, ...res }),
false
);
mutate<IProject[]>(
PROJECTS_LIST(activeWorkspace.slug),
(prevData) => {
@ -124,14 +132,38 @@ const ProjectSettings: NextPage = () => {
});
};
const sidebarLinks: Array<{
label: string;
href: string;
}> = [
{
label: "General",
href: "#",
},
{
label: "Control",
href: "#",
},
{
label: "States",
href: "#",
},
{
label: "Labels",
href: "#",
},
];
return (
<AppLayout>
<div className="space-y-5 mb-5">
<SettingsLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Settings`} />
</Breadcrumbs>
</div>
}
links={sidebarLinks}
>
{projectDetails ? (
<div className="space-y-3">
<Tab.Group>
@ -177,8 +209,8 @@ const ProjectSettings: NextPage = () => {
<Spinner />
</div>
)}
</AppLayout>
</SettingsLayout>
);
};
export default ProjectSettings;
export default withAuth(ProjectSettings);

View File

@ -1,28 +1,32 @@
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
// next
import type { NextPage } from "next";
// hooks
import useUser from "lib/hooks/useUser";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts
import AppLayout from "layouts/AppLayout";
import AppLayout from "layouts/app-layout";
// components
import CreateProjectModal from "components/project/CreateProjectModal";
import ConfirmProjectDeletion from "components/project/ConfirmProjectDeletion";
import ProjectMemberInvitations from "components/project/memberInvitations";
import ConfirmProjectDeletion from "components/project/confirm-project-deletion";
// ui
import { Button, Spinner } from "ui";
// types
import { IProject } from "types";
import {
Button,
Spinner,
HeaderButton,
Breadcrumbs,
BreadcrumbItem,
EmptySpace,
EmptySpaceItem,
} from "ui";
// services
import projectService from "lib/services/project.service";
import ProjectMemberInvitations from "components/project/memberInvitations";
// icons
import { ClipboardDocumentListIcon, PlusIcon } from "@heroicons/react/24/outline";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
import HeaderButton from "ui/HeaderButton";
const Projects: NextPage = () => {
const [isOpen, setIsOpen] = useState(false);
const [deleteProject, setDeleteProject] = useState<IProject | undefined>();
const [deleteProject, setDeleteProject] = useState<string | null>(null);
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
const { projects, activeWorkspace, mutateProjects } = useUser();
@ -52,21 +56,12 @@ const Projects: NextPage = () => {
});
};
useEffect(() => {
if (isOpen) return;
const timer = setTimeout(() => {
setDeleteProject(undefined);
clearTimeout(timer);
}, 300);
}, [isOpen]);
return (
<AppLayout>
<CreateProjectModal isOpen={isOpen && !deleteProject} setIsOpen={setIsOpen} />
<ConfirmProjectDeletion
isOpen={isOpen && !!deleteProject}
setIsOpen={setIsOpen}
data={deleteProject}
isOpen={!!deleteProject}
onClose={() => setDeleteProject(null)}
data={projects?.find((item) => item.id === deleteProject) ?? null}
/>
{projects ? (
<>
@ -87,7 +82,10 @@ const Projects: NextPage = () => {
</span>
}
Icon={PlusIcon}
action={() => setIsOpen(true)}
action={() => {
const e = new KeyboardEvent("keydown", { key: "p", ctrlKey: true });
document.dispatchEvent(e);
}}
/>
</EmptySpace>
</div>
@ -98,7 +96,14 @@ const Projects: NextPage = () => {
</Breadcrumbs>
<div className="flex items-center justify-between cursor-pointer w-full">
<h2 className="text-2xl font-medium">Projects</h2>
<HeaderButton Icon={PlusIcon} label="Add Project" onClick={() => setIsOpen(true)} />
<HeaderButton
Icon={PlusIcon}
label="Add Project"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "p", ctrlKey: true });
document.dispatchEvent(e);
}}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((item) => (
@ -129,4 +134,4 @@ const Projects: NextPage = () => {
);
};
export default Projects;
export default withAuth(Projects);

View File

@ -101,7 +101,7 @@ const SignIn: NextPage = () => {
>
{isGoogleAuthenticationLoading && (
<div className="absolute top-0 left-0 w-full h-full bg-white z-50 flex items-center justify-center">
<h2 className="text-2xl text-black">Signing in with Google. Please wait...</h2>
<h2 className="text-2xl text-gray-900">Signing in with Google. Please wait...</h2>
</div>
)}
<div className="w-full h-screen flex justify-center items-center bg-gray-50 overflow-auto">

View File

@ -35,12 +35,12 @@ const WorkspaceInvitation: NextPage = () => {
const { user } = useUser();
const { data: invitationDetail, error } = useSWR(
invitationId && WORKSPACE_INVITATION,
() => invitationId && workspaceService.getWorkspaceInvitation(invitationId as string)
const { data: invitationDetail, error } = useSWR(invitationId && WORKSPACE_INVITATION, () =>
invitationId ? workspaceService.getWorkspaceInvitation(invitationId as string) : null
);
const handleAccept = () => {
if (!invitationDetail) return;
workspaceService
.joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, {
accepted: true,

View File

@ -4,7 +4,7 @@ import Link from "next/link";
// react
import React from "react";
// layouts
import AppLayout from "layouts/AppLayout";
import AppLayout from "layouts/app-layout";
// swr
import useSWR from "swr";
// hooks
@ -26,8 +26,8 @@ const Workspace: NextPage = () => {
const { user, activeWorkspace, projects } = useUser();
const { data: myIssues } = useSWR<IIssue[]>(
user ? USER_ISSUE : null,
user ? () => userService.userIssues() : null
user && activeWorkspace ? USER_ISSUE(activeWorkspace.slug) : null,
user && activeWorkspace ? () => userService.userIssues(activeWorkspace.slug) : null
);
const cards = [

View File

@ -17,7 +17,7 @@ import { WORKSPACE_INVITATIONS, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// hoc
import withAuthWrapper from "lib/hoc/withAuthWrapper";
// layouts
import AppLayout from "layouts/AppLayout";
import AppLayout from "layouts/app-layout";
// components
import SendWorkspaceInvitationModal from "components/workspace/SendWorkspaceInvitationModal";
import ConfirmWorkspaceMemberRemove from "components/workspace/ConfirmWorkspaceMemberRemove";
@ -75,6 +75,12 @@ const WorkspaceInvite: NextPage = () => {
meta={{
title: "Plane - Workspace Invite",
}}
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title={`${activeWorkspace?.name ?? "Workspace"} Members`} />
</Breadcrumbs>
}
right={<HeaderButton Icon={PlusIcon} label="Add Member" onClick={() => setIsOpen(true)} />}
>
<ConfirmWorkspaceMemberRemove
isOpen={Boolean(selectedRemoveMember) || Boolean(selectedInviteRemoveMember)}
@ -132,13 +138,6 @@ const WorkspaceInvite: NextPage = () => {
</div>
) : (
<div className="w-full space-y-5">
<Breadcrumbs>
<BreadcrumbItem title={`${activeWorkspace?.name ?? "Workspace"} Members`} />
</Breadcrumbs>
<div className="flex items-center justify-between cursor-pointer w-full">
<h2 className="text-2xl font-medium">Invite Members</h2>
<HeaderButton Icon={PlusIcon} label="Add Member" onClick={() => setIsOpen(true)} />
</div>
{members && members.length === 0 ? null : (
<>
<table className="min-w-full table-fixed border border-gray-300 md:rounded-lg divide-y divide-gray-300">
@ -234,7 +233,7 @@ const WorkspaceInvite: NextPage = () => {
Active
</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-gray-900 rounded-full">
Pending
</span>
)}

View File

@ -3,25 +3,27 @@ import React, { useEffect, useState } from "react";
import Image from "next/image";
// react hook form
import { useForm } from "react-hook-form";
// headless ui
import { Tab } from "@headlessui/react";
// react dropzone
import Dropzone from "react-dropzone";
// services
import workspaceService from "lib/services/workspace.service";
import fileServices from "lib/services/file.services";
import fileServices from "lib/services/file.service";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts
import AppLayout from "layouts/AppLayout";
import AppLayout from "layouts/app-layout";
// hooks
import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// components
import ConfirmWorkspaceDeletion from "components/workspace/ConfirmWorkspaceDeletion";
import ConfirmWorkspaceDeletion from "components/workspace/confirm-workspace-deletion";
// ui
import { Spinner, Button, Input, Select } from "ui";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
// types
import type { IWorkspace } from "types";
import { Tab } from "@headlessui/react";
const defaultValues: Partial<IWorkspace> = {
name: "",
@ -87,12 +89,20 @@ const WorkspaceSettings = () => {
meta={{
title: "Plane - Workspace Settings",
}}
>
<ConfirmWorkspaceDeletion isOpen={isOpen} setIsOpen={setIsOpen} />
<div className="space-y-5">
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title={`${activeWorkspace?.name ?? "Workspace"} Settings`} />
</Breadcrumbs>
}
>
<ConfirmWorkspaceDeletion
isOpen={isOpen}
onClose={() => {
setIsOpen(false);
}}
data={activeWorkspace ?? null}
/>
<div className="space-y-5">
{activeWorkspace ? (
<div className="space-y-8">
<Tab.Group>
@ -232,4 +242,4 @@ const WorkspaceSettings = () => {
);
};
export default WorkspaceSettings;
export default withAuth(WorkspaceSettings);

View File

@ -4,7 +4,7 @@ module.exports = {
theme: {
extend: {
colors: {
theme: "#4338ca",
theme: "#3f76ff",
primary: "#f9fafb", // gray-50
secondary: "white",
},

View File

@ -1,16 +0,0 @@
import { IWorkspace } from "./";
export interface IWorkspaceInvitation {
id: string;
workspace: IWorkspace;
created_at: Date;
updated_at: Date;
email: string;
accepted: boolean;
token: string;
message: string;
responded_at: Date;
role: number;
created_by: null;
updated_by: null;
}

View File

@ -25,7 +25,8 @@ export interface IIssue {
created_at: Date;
updated_at: Date;
name: string;
description: string;
// TODO change type of description
description: any;
priority: string | null;
start_date: string | null;
target_date: string | null;

View File

@ -1,4 +1,4 @@
import type { IWorkspace } from "./";
import type { IUserLite, IWorkspace } from "./";
export interface IProject {
id: string;
@ -15,3 +15,44 @@ export interface IProject {
created_by: string;
updated_by: string;
}
type ProjectViewTheme = {
collapsed: boolean;
issueView: "list" | "kanban" | null;
groupByProperty: NestedKeyOf<IIssue> | null;
filterIssue: "activeIssue" | "backlogIssue" | null;
orderBy: NestedKeyOf<IIssue> | null;
};
export interface IProjectMember {
member: IUserLite;
project: IProject;
workspace: IWorkspace;
comment: string;
role: 5 | 10 | 15 | 20;
view_props: ProjectViewTheme;
created_at: Date;
updated_at: Date;
created_by: string;
updated_by: string;
}
export interface IProjectMemberInvitation {
id: string;
project: IProject;
workspace: IWorkspace;
email: string;
accepted: boolean;
token: string;
message: string;
responded_at: Date;
role: 5 | 10 | 15 | 20;
created_at: Date;
updated_at: Date;
created_by: string;
updated_by: string;
}

View File

@ -30,19 +30,6 @@ export interface CycleIssueResponse {
cycle: string;
}
export type CycleViewProps = {
cycle: ICycle;
selectSprint: React.Dispatch<React.SetStateAction<SelectSprintType>>;
projectId: string;
workspaceSlug: string;
openIssueModal: (
sprintId: string,
issue?: IIssue,
actionType?: "create" | "edit" | "delete"
) => void;
addIssueToSprint: (sprintId: string, issueId: string) => void;
};
export type SelectSprintType =
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
| undefined;

View File

@ -16,3 +16,11 @@ export interface IUser {
token: string;
[...rest: string]: any;
}
export interface IUserLite {
readonly id: string;
first_name: string;
last_name: string;
email: string;
avatar: string;
}

View File

@ -1,4 +1,4 @@
import type { IUser } from "./";
import type { IUser, IUserLite } from "./";
export interface IWorkspace {
readonly id: string;
@ -13,26 +13,26 @@ export interface IWorkspace {
company_size: number;
}
export interface WorkspaceMember {
export interface IWorkspaceMemberInvitation {
readonly id: string;
email: string;
accepted: boolean;
token: string;
message: string;
responded_at: Date;
role: 5 | 10 | 15 | 20;
member: IUser;
workspace: IWorkspace | string;
workspace: IWorkspace;
}
export interface IWorkspaceMember {
readonly id: string;
user: IUserLite;
workspace: IWorkspace;
member: IUserLite;
role: 5 | 10 | 15 | 20;
company_role: string | null;
created_at: Date;
updated_at: Date;
created_by: string;
updated_by: string;
}
export interface ProjectMember {
readonly id: string;
readonly project: string;
email: string;
message: string;
role: 5 | 10 | 15 | 20;
member: any;
member_id: string;
user_id: string;
}

View File

@ -11,14 +11,12 @@ const Breadcrumbs: React.FC<BreadcrumbsProps> = ({ children }: BreadcrumbsProps)
return (
<>
<div className="flex gap-3 ml-1">
<div className="flex items-center">
<div
className="bg-indigo-50 hover:bg-indigo-100 duration-300 px-3 py-1 rounded-tl-lg rounded-tr-md rounded-br-lg rounded-bl-md skew-x-[-20deg] text-sm text-center grid place-items-center cursor-pointer"
className="border hover:bg-gray-100 rounded h-8 w-8 text-sm grid place-items-center text-center cursor-pointer"
onClick={() => router.back()}
>
<p className="skew-x-[20deg]">
<ArrowLeftIcon className="h-3 w-3" />
</p>
<ArrowLeftIcon className="h-3 w-3" />
</div>
{children}
</div>
@ -37,16 +35,16 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
<>
{link ? (
<Link href={link}>
<a className="bg-indigo-50 hover:bg-indigo-100 duration-300 px-4 py-1 rounded-tl-lg rounded-tr-md rounded-br-lg rounded-bl-md skew-x-[-20deg] text-sm text-center">
<p className={`skew-x-[20deg] ${icon ? "flex items-center gap-2" : ""}`}>
<a className="text-sm border-r-2 border-gray-300 px-3">
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
{icon ?? null}
{title}
</p>
</a>
</Link>
) : (
<div className="bg-indigo-50 px-4 py-1 rounded-tl-lg rounded-tr-md rounded-br-lg rounded-bl-md skew-x-[-20deg] text-sm text-center">
<p className={`skew-x-[20deg] ${icon ? "flex items-center gap-2" : ""}`}>
<div className="text-sm px-3">
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
{icon}
{title}
</p>

View File

@ -5,7 +5,7 @@ type Props = {
children: React.ReactNode;
type?: "button" | "submit" | "reset";
className?: string;
theme?: "primary" | "secondary" | "danger";
theme?: "primary" | "secondary" | "success" | "danger";
size?: "sm" | "rg" | "md" | "lg";
disabled?: boolean;
};
@ -37,12 +37,16 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(
theme === "primary"
? `${
disabled ? "opacity-70" : ""
} text-white shadow-sm bg-theme hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 border border-transparent`
} text-white shadow-sm bg-theme hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 border border-transparent`
: theme === "secondary"
? "border border-gray-300 bg-white"
? "border bg-white"
: theme === "success"
? `${
disabled ? "opacity-70" : ""
} text-white shadow-sm bg-green-500 hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 border border-transparent`
: `${
disabled ? "opacity-70" : ""
} text-white shadow-sm bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 border border-transparent`,
} text-white shadow-sm bg-red-500 hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 border border-transparent`,
size === "sm"
? "p-2 text-xs"
: size === "md"

View File

@ -131,7 +131,7 @@ const CustomListbox: React.FC<Props> = ({
? value.includes(option.value)
: value === option.value)
? "text-white"
: "text-indigo-600"
: "text-theme"
}`}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />

View File

@ -31,7 +31,7 @@ const EmptySpace: React.FC<EmptySpaceProps> = ({ title, description, children, I
{link ? (
<div className="mt-6 flex">
<Link href={link.href}>
<a className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
<a className="text-sm font-medium text-theme hover:text-indigo-500">
{link.text}
<span aria-hidden="true"> &rarr;</span>
</a>

View File

@ -24,7 +24,7 @@ const HeaderButton = ({
<>
<button
type="button"
className={`bg-theme text-white border border-indigo-600 text-xs flex items-center gap-x-1 p-2 rounded-md font-medium whitespace-nowrap outline-none ${
className={`border hover:bg-gray-100 text-gray-600 hover:text-gray-900 text-xs flex items-center gap-x-1 p-2 rounded-md font-medium whitespace-nowrap outline-none ${
position === "reverse" && "flex-row-reverse"
} ${className}`}
disabled={disabled}

View File

@ -57,7 +57,7 @@ const TextArea: React.FC<Props> = ({
"w-full outline-none px-3 py-2 bg-transparent",
mode === "primary" ? "border border-gray-300 rounded-md" : "",
mode === "transparent"
? "bg-transparent border-none transition-all ring-0 focus:ring-1 focus:ring-indigo-600 rounded"
? "bg-transparent border-none transition-all ring-0 focus:ring-1 focus:ring-theme rounded"
: "",
error ? "border-red-500" : "",
error && mode === "primary" ? "bg-red-100" : "",

View File

@ -8,5 +8,5 @@ export interface Props extends React.ComponentPropsWithoutRef<"textarea"> {
register?: UseFormRegister<any>;
mode?: "primary" | "transparent" | "secondary" | "disabled";
validations?: RegisterOptions;
error?: FieldError;
error?: FieldError | Merge<FieldError, FieldErrorsImpl<any>>;
}