Merge pull request #33 from makeplane/stage-release

dev:  promote staging to master
This commit is contained in:
Vamsi Kurama 2022-12-13 13:56:24 +05:30 committed by GitHub
commit 43a256f15d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
77 changed files with 1752 additions and 1707 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 // headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react"; import { Combobox, Dialog, Transition } from "@headlessui/react";
// services // services
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// icons // icons

View File

@ -8,7 +8,7 @@ import { SubmitHandler, useForm } from "react-hook-form";
// headless ui // headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react"; import { Combobox, Dialog, Transition } from "@headlessui/react";
// services // services
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useTheme from "lib/hooks/useTheme"; import useTheme from "lib/hooks/useTheme";
@ -22,7 +22,7 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// components // components
import ShortcutsModal from "components/command-palette/shortcuts"; import ShortcutsModal from "components/command-palette/shortcuts";
import CreateProjectModal from "components/project/CreateProjectModal"; import CreateProjectModal from "components/project/create-project-modal";
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal"; import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal";
// ui // ui
@ -278,15 +278,15 @@ const CommandPalette: React.FC = () => {
value={issue.id} value={issue.id}
/> />
<span <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={{ style={{
backgroundColor: issue.state_detail.color, backgroundColor: issue.state_detail.color,
}} }}
/> />
<span className="text-xs text-gray-500"> <span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id} {activeProject?.identifier}-{issue.sequence_id}
</span> </span>
{issue.name} <span>{issue.name}</span>
</div> </div>
{active && ( {active && (
<button <button
@ -297,10 +297,9 @@ const CommandPalette: React.FC = () => {
); );
handleCommandPaletteClose(); handleCommandPaletteClose();
}} }}
className="flex-shrink-0 text-gray-500"
> >
<span className="justify-self-end flex-none text-gray-500"> Jump to...
Jump to...
</span>
</button> </button>
)} )}
</> </>

View File

@ -20,7 +20,7 @@ import { Button, Select, TextArea } from "ui";
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/20/solid"; import { ChevronDownIcon, CheckIcon } from "@heroicons/react/20/solid";
// types // types
import { ProjectMember, WorkspaceMember } from "types"; import { IProjectMemberInvitation } from "types";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -28,6 +28,11 @@ type Props = {
members: any[]; members: any[];
}; };
type ProjectMember = IProjectMemberInvitation & {
member_id: string;
user_id: string;
};
const defaultValues: Partial<ProjectMember> = { const defaultValues: Partial<ProjectMember> = {
email: "", email: "",
message: "", message: "",
@ -49,9 +54,15 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { data: people } = useSWR<WorkspaceMember[]>( const { data: people } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS : null, activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null,
{
onErrorRetry(err, _, __, revalidate, revalidateOpts) {
if (err?.status === 403) return;
setTimeout(() => revalidate(revalidateOpts), 5000);
},
}
); );
const { const {

View File

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

View File

@ -1,21 +1,24 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect } from "react";
// swr // swr
import { mutate } from "swr"; import useSWR, { mutate } from "swr";
// react hook form // react hook form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// headless // headless
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services // services
import projectServices from "lib/services/project.service"; 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 // fetch keys
import { PROJECTS_LIST } from "constants/fetch-keys"; import { PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast"; import useToast from "lib/hooks/useToast";
// ui // ui
import { Button, Input, TextArea, Select } from "ui"; import { Button, Input, TextArea, Select } from "ui";
// common
import { debounce } from "constants/common";
// types // types
import { IProject } from "types"; import { IProject } from "types";
@ -24,11 +27,28 @@ type Props = {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
}; };
const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
const defaultValues: Partial<IProject> = { const defaultValues: Partial<IProject> = {
name: "", name: "",
identifier: "",
description: "", description: "",
network: 0,
};
const IsGuestCondition: React.FC<{
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
}> = ({ setIsOpen }) => {
const { setToastAlert } = useToast();
useEffect(() => {
setIsOpen(false);
setToastAlert({
title: "Error",
type: "error",
message: "You don't have permission to create project.",
});
}, [setIsOpen, setToastAlert]);
return null;
}; };
const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => { const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
@ -40,7 +60,17 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
}, 500); }, 500);
}; };
const { activeWorkspace } = useUser(); const { activeWorkspace, user } = useUser();
const { data: workspaceMembers } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null,
{
shouldRetryOnError: false,
}
);
const [recommendedIdentifier, setRecommendedIdentifier] = useState<string[]>([]);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -52,6 +82,7 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
handleSubmit, handleSubmit,
reset, reset,
setError, setError,
clearErrors,
watch, watch,
setValue, setValue,
} = useForm<IProject>({ } = useForm<IProject>({
@ -77,6 +108,16 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
handleClose(); handleClose();
}) })
.catch((err) => { .catch((err) => {
if (err.status === 403) {
setToastAlert({
title: "Error",
type: "error",
message: "You don't have permission to create project.",
});
handleClose();
return;
}
err = err.data;
Object.keys(err).map((key) => { Object.keys(err).map((key) => {
const errorMessages = err[key]; const errorMessages = err[key];
setError(key as keyof IProject, { setError(key as keyof IProject, {
@ -89,22 +130,39 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const projectName = watch("name") ?? ""; const projectName = watch("name") ?? "";
const projectIdentifier = watch("identifier") ?? ""; const projectIdentifier = watch("identifier") ?? "";
const checkIdentifier = (slug: string, value: string) => { if (workspaceMembers) {
projectServices.checkProjectIdentifierAvailability(slug, value).then((response) => { const isMember = workspaceMembers.find((member) => member.member.id === user?.id);
console.log(response); const isGuest = workspaceMembers.find(
if (response.exists) setError("identifier", { message: "Identifier already exists" }); (member) => member.member.id === user?.id && member.role === 5
}); );
};
// eslint-disable-next-line react-hooks/exhaustive-deps if ((!isMember || isGuest) && isOpen) return <IsGuestCondition setIsOpen={setIsOpen} />;
const checkIdentifierAvailability = useCallback(debounce(checkIdentifier, 1500), []); }
useEffect(() => { useEffect(() => {
if (projectName && isChangeIdentifierRequired) { 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]); }, [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]);
useEffect(() => {
return () => setIsChangeIdentifierRequired(true);
}, [isOpen]);
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}> <Dialog as="div" className="relative z-10" onClose={handleClose}>
@ -191,11 +249,7 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
placeholder="Enter Project Identifier" placeholder="Enter Project Identifier"
error={errors.identifier} error={errors.identifier}
register={register} register={register}
onChange={(e: any) => { onChange={() => setIsChangeIdentifierRequired(false)}
setIsChangeIdentifierRequired(false);
if (!activeWorkspace || !e.target.value) return;
checkIdentifierAvailability(activeWorkspace.slug, e.target.value);
}}
validations={{ validations={{
required: "Identifier is required", required: "Identifier is required",
minLength: { minLength: {
@ -203,11 +257,32 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
message: "Identifier must at least be of 1 character", message: "Identifier must at least be of 1 character",
}, },
maxLength: { maxLength: {
value: 9, value: 5,
message: "Identifier must at most be of 9 characters", message: "Identifier must at most be of 5 characters",
}, },
}} }}
/> />
{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> </div>
</div> </div>

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui // ui
import { Button } from "ui"; import { Button } from "ui";
// services // services
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast"; import useToast from "lib/hooks/useToast";

View File

@ -7,7 +7,7 @@ import useSWR, { mutate } from "swr";
// headless ui // headless ui
import { Disclosure, Transition, Menu } from "@headlessui/react"; import { Disclosure, Transition, Menu } from "@headlessui/react";
// services // services
import cycleServices from "lib/services/cycles.services"; import cycleServices from "lib/services/cycles.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// components // components
@ -22,7 +22,7 @@ import type { CycleViewProps as Props, CycleIssueResponse, IssueResponse } from
import { CYCLE_ISSUES } from "constants/fetch-keys"; import { CYCLE_ISSUES } from "constants/fetch-keys";
// constants // constants
import { renderShortNumericDateFormat } from "constants/common"; import { renderShortNumericDateFormat } from "constants/common";
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.service";
import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd"; import { Draggable } from "react-beautiful-dnd";

View File

@ -18,11 +18,10 @@ import {
ArrowsPointingOutIcon, ArrowsPointingOutIcon,
CalendarDaysIcon, CalendarDaysIcon,
EllipsisHorizontalIcon, EllipsisHorizontalIcon,
PencilIcon,
PlusIcon, PlusIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import Image from "next/image"; import Image from "next/image";
import { divide } from "lodash"; import { getPriorityIcon } from "constants/global";
type Props = { type Props = {
selectedGroup: NestedKeyOf<IIssue> | null; selectedGroup: NestedKeyOf<IIssue> | null;
@ -42,8 +41,8 @@ type Props = {
> >
>; >;
bgColor?: string; bgColor?: string;
stateId?: string; stateId: string | null;
createdBy?: string; createdBy: string | null;
}; };
const SingleBoard: React.FC<Props> = ({ const SingleBoard: React.FC<Props> = ({
@ -109,7 +108,7 @@ const SingleBoard: React.FC<Props> = ({
<span <span
className={`w-3 h-3 block rounded-full ${!show ? "" : "mr-1"}`} className={`w-3 h-3 block rounded-full ${!show ? "" : "mr-1"}`}
style={{ style={{
backgroundColor: bgColor, backgroundColor: Boolean(bgColor) ? bgColor : undefined,
}} }}
/> />
<h2 <h2
@ -151,7 +150,7 @@ const SingleBoard: React.FC<Props> = ({
setIsIssueOpen(true); setIsIssueOpen(true);
if (selectedGroup !== null) if (selectedGroup !== null)
setPreloadedData({ setPreloadedData({
state: stateId, state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle, [selectedGroup]: groupTitle,
actionType: "createIssue", actionType: "createIssue",
}); });
@ -159,17 +158,6 @@ const SingleBoard: React.FC<Props> = ({
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
<button
type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"
onClick={() =>
setPreloadedData({
actionType: "edit",
})
}
>
<PencilIcon className="h-4 w-4" />
</button>
</div> </div>
</div> </div>
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}> <StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
@ -192,131 +180,116 @@ const SingleBoard: React.FC<Props> = ({
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
> >
<div <div className="p-2 select-none" {...provided.dragHandleProps}>
className="px-2 py-3 space-y-1.5 select-none" {properties.key && (
{...provided.dragHandleProps} <div className="text-xs font-medium text-gray-500 mb-2">
> {childIssue.project_detail?.identifier}-{childIssue.sequence_id}
<span className="group-hover:text-theme text-sm break-all"> </div>
)}
<h5 className="group-hover:text-theme text-sm break-all mb-3">
{childIssue.name} {childIssue.name}
</span> </h5>
{Object.keys(properties).map( <div className="flex items-center gap-x-1 gap-y-2 text-xs flex-wrap">
(key) => {properties.priority && (
properties[key as keyof Properties] && <div
!Array.isArray(childIssue[key as keyof IIssue]) && ( 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 ${
<div childIssue.priority === "high"
key={key} ? "bg-red-100 text-red-600"
className={`${ : childIssue.priority === "medium"
key === "name" ? "bg-orange-100 text-orange-500"
? "text-sm mb-2" : childIssue.priority === "low"
: key === "description" ? "bg-green-100 text-green-500"
? "text-xs text-black" : "hidden"
: key === "priority" }`}
? `text-xs bg-gray-200 px-2 py-1 mt-2 flex items-center gap-x-1 rounded w-min whitespace-nowrap capitalize font-medium ${ >
childIssue.priority === "high" {/* {getPriorityIcon(childIssue.priority ?? "")} */}
? "bg-red-100 text-red-600" {childIssue.priority}
: childIssue.priority === "medium" </div>
? "bg-orange-100 text-orange-500" )}
: childIssue.priority === "low" {properties.state && (
? "bg-green-100 text-green-500" <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">
: "hidden" <span
}` className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
: key === "target_date" style={{ backgroundColor: childIssue.state_detail.color }}
? "text-xs bg-indigo-50 px-2 py-1 mt-2 flex items-center gap-x-1 rounded w-min whitespace-nowrap" ></span>
: "text-sm text-gray-500" {addSpaceIfCamelCase(childIssue.state_detail.name)}
} gap-1 relative </div>
`} )}
> {properties.start_date && (
{key === "start_date" && childIssue.start_date !== null && ( <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="text-sm"> <CalendarDaysIcon className="h-4 w-4" />
<CalendarDaysIcon className="h-4 w-4" /> {childIssue.start_date
{renderShortNumericDateFormat(childIssue.start_date)} - ? renderShortNumericDateFormat(childIssue.start_date)
{childIssue.target_date : "N/A"}
? renderShortNumericDateFormat(childIssue.target_date) </div>
: "None"} )}
</span> {properties.target_date && (
)} <div
{key === "target_date" && ( 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
<span ? ""
className={`flex items-center gap-x-1 group ${ : childIssue.target_date < new Date().toISOString()
childIssue.target_date === null ? "text-red-600"
? "" : findHowManyDaysLeft(childIssue.target_date) <= 3 &&
: childIssue.target_date < new Date().toISOString() "text-orange-400"
? "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" : ""
}`} }`}
> >
<CalendarDaysIcon className="h-4 w-4" /> {assignee.avatar && assignee.avatar !== "" ? (
{childIssue.target_date <div className="h-5 w-5 border-2 bg-white border-white rounded-full">
? renderShortNumericDateFormat(childIssue.target_date) <Image
: "N/A"} src={assignee.avatar}
{childIssue.target_date && ( height="100%"
<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"> width="100%"
{childIssue.target_date < new Date().toISOString() className="rounded-full"
? `Target date has passed by ${findHowManyDaysLeft( alt={assignee.name}
childIssue.target_date />
)} days` </div>
: findHowManyDaysLeft(childIssue.target_date) <= 3 ) : (
? `Target date is in ${findHowManyDaysLeft( <div
childIssue.target_date className={`h-5 w-5 bg-gray-700 text-white border-2 border-white grid place-items-center rounded-full`}
)} days` >
: "Target date"} {assignee.first_name.charAt(0)}
</span> </div>
)} )}
</span> </div>
</> )
)} )
{key === "key" && ( ) : (
<span className="text-xs"> <span>No assignee.</span>
{childIssue.project_detail?.identifier}- )}
{childIssue.sequence_id} </div>
</span> )}
)} </div>
{key === "state" && (
<>{addSpaceIfCamelCase(childIssue["state_detail"].name)}</>
)}
{key === "priority" && <>{childIssue.priority}</>}
{/* {key === "description" && <>{childIssue.description}</>} */}
{key === "assignee" ? (
<div className="flex items-center gap-1 text-xs">
{childIssue?.assignee_details?.length > 0 ? (
childIssue?.assignee_details?.map(
(assignee, 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>
)
)
) : (
<span>No assignee.</span>
)}
</div>
) : null}
</div>
)
)}
</div> </div>
</a> </a>
</Link> </Link>
@ -331,7 +304,7 @@ const SingleBoard: React.FC<Props> = ({
setIsIssueOpen(true); setIsIssueOpen(true);
if (selectedGroup !== null) { if (selectedGroup !== null) {
setPreloadedData({ setPreloadedData({
state: stateId, state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle, [selectedGroup]: groupTitle,
actionType: "createIssue", actionType: "createIssue",
}); });

View File

@ -7,8 +7,8 @@ import useSWR from "swr";
import type { DropResult } from "react-beautiful-dnd"; import type { DropResult } from "react-beautiful-dnd";
import { DragDropContext } from "react-beautiful-dnd"; import { DragDropContext } from "react-beautiful-dnd";
// services // services
import stateServices from "lib/services/state.services"; import stateServices from "lib/services/state.service";
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// fetching keys // fetching keys
@ -20,7 +20,7 @@ import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssue
// ui // ui
import { Spinner } from "ui"; import { Spinner } from "ui";
// types // types
import type { IState, IIssue, Properties, NestedKeyOf, ProjectMember } from "types"; import type { IState, IIssue, Properties, NestedKeyOf, IProjectMember } from "types";
import ConfirmIssueDeletion from "../ConfirmIssueDeletion"; import ConfirmIssueDeletion from "../ConfirmIssueDeletion";
import { TrashIcon } from "@heroicons/react/24/outline"; import { TrashIcon } from "@heroicons/react/24/outline";
@ -30,7 +30,7 @@ type Props = {
groupedByIssues: { groupedByIssues: {
[key: string]: IIssue[]; [key: string]: IIssue[];
}; };
members: ProjectMember[] | undefined; members: IProjectMember[] | undefined;
}; };
const BoardView: React.FC<Props> = ({ properties, selectedGroup, groupedByIssues, members }) => { const BoardView: React.FC<Props> = ({ properties, selectedGroup, groupedByIssues, members }) => {
@ -197,9 +197,10 @@ const BoardView: React.FC<Props> = ({ properties, selectedGroup, groupedByIssues
selectedGroup={selectedGroup} selectedGroup={selectedGroup}
groupTitle={singleGroup} groupTitle={singleGroup}
createdBy={ createdBy={
members selectedGroup === "created_by"
? members?.find((m) => m.member.id === singleGroup)?.member.first_name ? members?.find((m) => m.member.id === singleGroup)?.member
: undefined .first_name ?? "loading..."
: null
} }
groupedByIssues={groupedByIssues} groupedByIssues={groupedByIssues}
index={index} index={index}
@ -208,8 +209,8 @@ const BoardView: React.FC<Props> = ({ properties, selectedGroup, groupedByIssues
setPreloadedData={setPreloadedData} setPreloadedData={setPreloadedData}
stateId={ stateId={
selectedGroup === "state_detail.name" selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ? states?.find((s) => s.name === singleGroup)?.id ?? null
: undefined : null
} }
bgColor={ bgColor={
selectedGroup === "state_detail.name" selectedGroup === "state_detail.name"

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import { Dialog, Transition } from "@headlessui/react";
// fetching keys // fetching keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// services // services
import issueServices from "lib/services/issues.services"; import issueServices from "lib/services/issues.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast"; import useToast from "lib/hooks/useToast";

View File

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

View File

@ -6,7 +6,7 @@ import { useForm, Controller } from "react-hook-form";
// headless ui // headless ui
import { Listbox, Transition } from "@headlessui/react"; import { Listbox, Transition } from "@headlessui/react";
// services // services
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// fetching keys // fetching keys

View File

@ -14,7 +14,7 @@ type Props = {
control: Control<IIssue, any>; control: Control<IIssue, any>;
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
issues: IssueResponse | undefined; issues: IIssue[];
}; };
const SelectParent: React.FC<Props> = ({ control, isOpen, setIsOpen, issues }) => { const SelectParent: React.FC<Props> = ({ control, isOpen, setIsOpen, issues }) => {

View File

@ -16,7 +16,7 @@ import {
// headless // headless
import { Dialog, Menu, Transition } from "@headlessui/react"; import { Dialog, Menu, Transition } from "@headlessui/react";
// services // services
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast"; import useToast from "lib/hooks/useToast";
@ -392,16 +392,16 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
/> */} /> */}
</div> </div>
<div> <div>
<Input {/* <Input
id="target_date" id="target_date"
label="Due Date" label="Target Date"
name="target_date" name="target_date"
type="date" type="date"
placeholder="Enter name" placeholder="Enter name"
autoComplete="off" autoComplete="off"
error={errors.target_date} error={errors.target_date}
register={register} register={register}
/> /> */}
</div> </div>
<div className="flex items-center flex-wrap gap-2"> <div className="flex items-center flex-wrap gap-2">
<SelectState control={control} setIsOpen={setIsStateModalOpen} /> <SelectState control={control} setIsOpen={setIsStateModalOpen} />
@ -409,11 +409,25 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
<SelectPriority control={control} /> <SelectPriority control={control} />
<SelectAssignee control={control} /> <SelectAssignee control={control} />
<SelectLabels control={control} /> <SelectLabels control={control} />
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<input
type="date"
value={value ?? ""}
onChange={(e: any) => {
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"
/>
)}
/>
<SelectParent <SelectParent
control={control} control={control}
isOpen={parentIssueListModalOpen} isOpen={parentIssueListModalOpen}
setIsOpen={setParentIssueListModalOpen} setIsOpen={setParentIssueListModalOpen}
issues={issues} issues={issues?.results ?? []}
/> />
<Menu as="div" className="relative inline-block"> <Menu as="div" className="relative inline-block">
<Menu.Button className="grid place-items-center p-1 hover:bg-gray-100 border rounded-md shadow-sm cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300"> <Menu.Button className="grid place-items-center p-1 hover:bg-gray-100 border rounded-md shadow-sm cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300">

View File

@ -2,35 +2,49 @@
import React, { useState } from "react"; import React, { useState } from "react";
// headless ui // headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react"; import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// icons // icons
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
// types // types
import { IIssue, IssueResponse } from "types"; import { IIssue } from "types";
import { classNames } from "constants/common"; import { classNames } from "constants/common";
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
value?: any;
onChange: (...event: any[]) => void; onChange: (...event: any[]) => void;
issues: IssueResponse | undefined; issues: IIssue[];
title?: string;
multiple?: boolean;
}; };
const IssuesListModal: React.FC<Props> = ({ isOpen, handleClose: onClose, onChange, issues }) => { const IssuesListModal: React.FC<Props> = ({
isOpen,
handleClose: onClose,
value,
onChange,
issues,
title = "Issues",
multiple = false,
}) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [values, setValues] = useState<string[]>([]);
const { activeProject } = useUser(); const { activeProject } = useUser();
const handleClose = () => { const handleClose = () => {
onClose(); onClose();
setQuery(""); setQuery("");
setValues([]);
}; };
const filteredIssues: IIssue[] = const filteredIssues: IIssue[] =
query === "" query === ""
? issues?.results ?? [] ? issues ?? []
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? : issues?.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? [];
[];
return ( return (
<> <>
@ -59,7 +73,14 @@ const IssuesListModal: React.FC<Props> = ({ isOpen, handleClose: onClose, onChan
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all"> <Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<Combobox onChange={onChange}> <Combobox
value={value}
onChange={(val) => {
if (multiple) setValues(val);
else onChange(val);
}}
// multiple={multiple}
>
<div className="relative m-1"> <div className="relative m-1">
<MagnifyingGlassIcon <MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40" className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
@ -80,7 +101,7 @@ const IssuesListModal: React.FC<Props> = ({ isOpen, handleClose: onClose, onChan
<li className="p-2"> <li className="p-2">
{query === "" && ( {query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900"> <h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Issues {title}
</h2> </h2>
)} )}
<ul className="text-sm text-gray-700"> <ul className="text-sm text-gray-700">
@ -95,20 +116,26 @@ const IssuesListModal: React.FC<Props> = ({ isOpen, handleClose: onClose, onChan
) )
} }
onClick={() => { onClick={() => {
// setIssueIdFromList(issue.id); if (!multiple) handleClose();
handleClose();
}} }}
> >
<span {({ selected }) => (
className={`h-1.5 w-1.5 block rounded-full`} <>
style={{ {multiple ? (
backgroundColor: issue.state_detail.color, <input type="checkbox" checked={selected} readOnly />
}} ) : null}
/> <span
<span className="text-xs text-gray-500"> className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
{activeProject?.identifier}-{issue.sequence_id} style={{
</span>{" "} backgroundColor: issue.state_detail.color,
{issue.name} }}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{activeProject?.identifier}-{issue.sequence_id}
</span>{" "}
{issue.name}
</>
)}
</Combobox.Option> </Combobox.Option>
))} ))}
</ul> </ul>
@ -128,6 +155,16 @@ const IssuesListModal: React.FC<Props> = ({ isOpen, handleClose: onClose, onChan
</div> </div>
)} )}
</Combobox> </Combobox>
{multiple ? (
<div className="flex justify-end items-center gap-2 p-3">
<Button type="button" theme="danger" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button type="button" size="sm" onClick={() => onChange(values)}>
Add to Cycle
</Button>
</div>
) : null}
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>
</div> </div>

View File

@ -1,5 +1,5 @@
// react // react
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
// next // next
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
@ -10,23 +10,17 @@ import { Listbox, Transition } from "@headlessui/react";
// icons // icons
import { PencilIcon, TrashIcon } from "@heroicons/react/24/outline"; import { PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
// types // types
import { IIssue, IssueResponse, IState, NestedKeyOf, Properties, WorkspaceMember } from "types"; import { IIssue, IssueResponse, NestedKeyOf, Properties } from "types";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// fetch keys // fetch keys
import { PRIORITIES } from "constants/"; import { PRIORITIES } from "constants/";
import { PROJECT_ISSUES_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { PROJECT_ISSUES_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// services // services
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.service";
import workspaceService from "lib/services/workspace.service"; import workspaceService from "lib/services/workspace.service";
// constants // constants
import { import { addSpaceIfCamelCase, classNames, renderShortNumericDateFormat } from "constants/common";
addSpaceIfCamelCase,
classNames,
renderShortNumericDateFormat,
replaceUnderscoreIfSnakeCase,
} from "constants/common";
import IssuePreviewModal from "../PreviewModal";
// types // types
type Props = { type Props = {
@ -44,9 +38,6 @@ const ListView: React.FC<Props> = ({
setSelectedIssue, setSelectedIssue,
handleDeleteIssue, handleDeleteIssue,
}) => { }) => {
const [issuePreviewModal, setIssuePreviewModal] = useState(false);
const [previewModalIssueId, setPreviewModalIssueId] = useState<string | null>(null);
const { activeWorkspace, activeProject, states } = useUser(); const { activeWorkspace, activeProject, states } = useUser();
const partialUpdateIssue = (formData: Partial<IIssue>, issueId: string) => { const partialUpdateIssue = (formData: Partial<IIssue>, issueId: string) => {
@ -69,375 +60,355 @@ const ListView: React.FC<Props> = ({
}); });
}; };
const { data: people } = useSWR<WorkspaceMember[]>( const { data: people } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS : null, activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
); );
const handleHover = (issueId: string) => {
document.addEventListener("keydown", (e) => {
// if (e.code === "Space") {
// e.preventDefault();
// setPreviewModalIssueId(issueId);
// setIssuePreviewModal(true);
// }
});
};
return ( return (
<div className="mt-4 flex flex-col"> <div className="mt-4 flex flex-col space-y-5">
<IssuePreviewModal {Object.keys(groupedByIssues).map((singleGroup) => (
isOpen={issuePreviewModal} <div key={singleGroup} className="overflow-x-auto">
setIsOpen={setIssuePreviewModal} <div className="inline-block min-w-full p-0.5 align-middle">
issueId={previewModalIssueId} <div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
/> <table className="min-w-full">
<div className="overflow-x-auto"> {selectedGroup !== null ? (
<div className="inline-block min-w-full p-0.5 align-middle"> <thead className="bg-gray-100">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg"> <tr>
<table className="min-w-full"> <th
<thead className="bg-gray-100"> colSpan={14}
<tr> scope="col"
<th className="px-3 py-3.5 text-left uppercase text-sm font-semibold text-gray-900"
scope="col" >
className="px-3 py-3.5 text-left uppercase text-sm font-semibold text-gray-900" <div className="flex items-center gap-2">
> {selectedGroup === "state_detail.name" ? (
NAME <span
</th> className="flex-shrink-0 h-1.5 w-1.5 block rounded-full"
{Object.keys(properties).map( style={{
(key) => backgroundColor: states?.find((s) => s.name === singleGroup)?.color,
properties[key as keyof Properties] && ( }}
<th ></span>
key={key} ) : null}
scope="col"
className="px-3 py-3.5 text-left uppercase text-sm font-semibold text-gray-900"
>
{replaceUnderscoreIfSnakeCase(key)}
</th>
)
)}
<th
scope="col"
className="px-3 py-3.5 text-right text-sm font-semibold text-gray-900"
>
ACTIONS
</th>
</tr>
</thead>
<tbody className="bg-white">
{Object.keys(groupedByIssues).map((singleGroup) => (
<React.Fragment key={singleGroup}>
{selectedGroup !== null ? (
<tr className="border-t border-gray-200">
<th
colSpan={14}
scope="colgroup"
className="bg-gray-50 px-4 py-2 text-left font-medium text-gray-900 capitalize"
>
{singleGroup === null || singleGroup === "null" {singleGroup === null || singleGroup === "null"
? selectedGroup === "priority" && "No priority" ? selectedGroup === "priority" && "No priority"
: addSpaceIfCamelCase(singleGroup)} : addSpaceIfCamelCase(singleGroup)}
<span className="ml-2 text-gray-500 font-normal text-sm"> <span className="ml-2 text-gray-500 font-normal text-sm">
{groupedByIssues[singleGroup as keyof IIssue].length} {groupedByIssues[singleGroup as keyof IIssue].length}
</span> </span>
</th> </div>
</tr> </th>
) : null} </tr>
{groupedByIssues[singleGroup].length > 0 </thead>
? groupedByIssues[singleGroup].map((issue: IIssue, index: number) => { ) : (
const assignees = [ <thead className="bg-gray-100">
...(issue?.assignees_list ?? []), <tr>
...(issue?.assignees ?? []), <th
]?.map( colSpan={14}
(assignee) => scope="col"
people?.find((p) => p.member.id === assignee)?.member.email className="px-3 py-3.5 text-left uppercase text-sm font-semibold text-gray-900"
); >
ALL ISSUES
<span className="ml-2 text-gray-500 font-normal text-sm">
{groupedByIssues[singleGroup as keyof IIssue].length}
</span>
</th>
</tr>
</thead>
)}
<tbody className="bg-white">
{groupedByIssues[singleGroup].length > 0
? groupedByIssues[singleGroup].map((issue: IIssue, index: number) => {
const assignees = [
...(issue?.assignees_list ?? []),
...(issue?.assignees ?? []),
]?.map(
(assignee) => people?.find((p) => p.member.id === assignee)?.member.email
);
return ( return (
<tr <tr
key={issue.id} key={issue.id}
className={classNames( className={classNames(
index === 0 ? "border-gray-300" : "border-gray-200", index === 0 ? "border-gray-300" : "border-gray-200",
"border-t" "border-t"
)} )}
onMouseEnter={() => handleHover(issue.id)} >
> <td className="px-3 py-4 text-sm font-medium text-gray-900 w-[15rem]">
<td className="px-3 py-4 text-sm font-medium text-gray-900 w-[15rem]"> <Link href={`/projects/${issue.project}/issues/${issue.id}`}>
<Link href={`/projects/${issue.project}/issues/${issue.id}`}> <a className="hover:text-theme duration-300">{issue.name}</a>
<a className="hover:text-theme duration-300">{issue.name}</a> </Link>
</Link> </td>
</td> {Object.keys(properties).map(
{Object.keys(properties).map( (key) =>
(key) => properties[key as keyof Properties] && (
properties[key as keyof Properties] && ( <React.Fragment key={key}>
<React.Fragment key={key}> {(key as keyof Properties) === "key" ? (
{(key as keyof Properties) === "key" ? ( <td className="px-3 py-4 font-medium text-gray-900 text-xs whitespace-nowrap">
<td className="px-3 py-4 font-medium text-gray-900 text-xs whitespace-nowrap"> {activeProject?.identifier}-{issue.sequence_id}
{activeProject?.identifier}-{issue.sequence_id} </td>
</td> ) : (key as keyof Properties) === "priority" ? (
) : (key as keyof Properties) === "priority" ? ( <td className="px-3 py-4 text-sm font-medium text-gray-900 relative">
<td className="px-3 py-4 text-sm font-medium text-gray-900 relative"> <Listbox
<Listbox as="div"
as="div" value={issue.priority}
value={issue.priority} onChange={(data: string) => {
onChange={(data: string) => { partialUpdateIssue({ priority: data }, issue.id);
partialUpdateIssue({ priority: data }, issue.id); }}
}} className="flex-shrink-0"
className="flex-shrink-0" >
> {({ open }) => (
{({ open }) => ( <>
<> <div className="">
<div className=""> <Listbox.Button className="inline-flex items-center whitespace-nowrap rounded-full bg-gray-50 py-1 px-0.5 text-xs font-medium text-gray-500 hover:bg-gray-100 border">
<Listbox.Button className="inline-flex items-center whitespace-nowrap rounded-full bg-gray-50 py-1 px-0.5 text-xs font-medium text-gray-500 hover:bg-gray-100 border"> <span
<span className={classNames(
className={classNames( issue.priority ? "" : "text-gray-900",
issue.priority ? "" : "text-gray-900", "hidden truncate capitalize sm:block w-16"
"hidden truncate capitalize sm:block w-16" )}
)}
>
{issue.priority ?? "None"}
</span>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
> >
<Listbox.Options className="absolute z-10 mt-1 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"> {issue.priority ?? "None"}
{PRIORITIES?.map((priority) => ( </span>
<Listbox.Option </Listbox.Button>
key={priority}
className={({ active }) => <Transition
classNames( show={open}
active ? "bg-indigo-50" : "bg-white", as={React.Fragment}
"cursor-pointer capitalize select-none px-3 py-2" 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>
</>
)}
</Listbox>
</td>
) : (key as keyof Properties) === "assignee" ? (
<td className="px-3 py-4 text-sm font-medium text-gray-900 relative">
<Listbox
as="div"
value={issue.assignees}
onChange={(data: any) => {
const newData = issue.assignees ?? [];
if (newData.includes(data)) {
newData.splice(newData.indexOf(data), 1);
} else {
newData.push(data);
}
partialUpdateIssue(
{ assignees_list: newData },
issue.id
);
}}
className="flex-shrink-0"
>
{({ open }) => (
<>
<div>
<Listbox.Button className="rounded-full bg-gray-50 px-5 py-1 text-xs text-gray-500 hover:bg-gray-100 border">
{() => {
if (assignees.length > 0)
return (
<>
{assignees.map((assignee, index) => (
<div
key={index}
className={
"hidden truncate sm:block text-left"
}
>
{assignee}
</div>
))}
</>
);
else return <span>None</span>;
}}
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 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 px-3 py-2"
)
}
value={person.member.id}
>
<div
className={`flex items-center gap-x-1 ${
assignees.includes(
person.member.first_name
) )
} ? "font-medium"
value={priority} : "font-normal"
}`}
> >
{priority} {person.member.avatar &&
</Listbox.Option> person.member.avatar !== "" ? (
))} <div className="relative w-4 h-4">
</Listbox.Options> <Image
</Transition> src={person.member.avatar}
</div> alt="avatar"
</> className="rounded-full"
)} layout="fill"
</Listbox> objectFit="cover"
</td> />
) : (key as keyof Properties) === "assignee" ? ( </div>
<td className="px-3 py-4 text-sm font-medium text-gray-900 relative"> ) : (
<Listbox <p>
as="div" {person.member.first_name.charAt(0)}
value={issue.assignees} </p>
onChange={(data: any) => { )}
const newData = issue.assignees ?? []; <p>{person.member.first_name}</p>
if (newData.includes(data)) { </div>
newData.splice(newData.indexOf(data), 1); </Listbox.Option>
} else { ))}
newData.push(data); </Listbox.Options>
} </Transition>
partialUpdateIssue( </div>
{ assignees_list: newData }, </>
issue.id )}
); </Listbox>
}} </td>
className="flex-shrink-0" ) : (key as keyof Properties) === "state" ? (
> <td className="px-3 py-4 text-sm font-medium text-gray-900 relative">
{({ open }) => ( <Listbox
<> as="div"
<div> value={issue.state}
<Listbox.Button className="rounded-full bg-gray-50 px-5 py-1 text-xs text-gray-500 hover:bg-gray-100 border"> onChange={(data: string) => {
{() => { partialUpdateIssue({ state: data }, issue.id);
if (assignees.length > 0) }}
return ( className="flex-shrink-0"
<> >
{assignees.map((assignee, index) => ( {({ open }) => (
<div <>
key={index} <div>
className={ <Listbox.Button
"hidden truncate sm:block text-left" className="inline-flex items-center whitespace-nowrap rounded-full px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 border"
} style={{
> border: `2px solid ${issue.state_detail.color}`,
{assignee} backgroundColor: `${issue.state_detail.color}20`,
</div> }}
))} >
</> <span
); className={classNames(
else return <span>None</span>; issue.state ? "" : "text-gray-900",
}} "hidden capitalize sm:block w-16"
</Listbox.Button> )}
>
{addSpaceIfCamelCase(issue.state_detail.name)}
</span>
</Listbox.Button>
<Transition <Transition
show={open} show={open}
as={React.Fragment} as={React.Fragment}
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<Listbox.Options className="absolute z-10 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none"> <Listbox.Options className="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">
{people?.map((person) => ( {states?.map((state) => (
<Listbox.Option <Listbox.Option
key={person.id} key={state.id}
className={({ active }) => className={({ active }) =>
classNames( classNames(
active ? "bg-indigo-50" : "bg-white", active ? "bg-indigo-50" : "bg-white",
"cursor-pointer select-none px-3 py-2" "cursor-pointer select-none px-3 py-2"
) )
} }
value={person.member.id} value={state.id}
> >
<div {addSpaceIfCamelCase(state.name)}
className={`flex items-center gap-x-1 ${ </Listbox.Option>
assignees.includes( ))}
person.member.first_name </Listbox.Options>
) </Transition>
? "font-medium" </div>
: "font-normal" </>
}`} )}
> </Listbox>
{person.member.avatar && </td>
person.member.avatar !== "" ? ( ) : (key as keyof Properties) === "target_date" ? (
<div className="relative w-4 h-4"> <td className="px-3 py-4 text-sm font-medium text-gray-900 whitespace-nowrap">
<Image {issue.target_date
src={person.member.avatar} ? renderShortNumericDateFormat(issue.target_date)
alt="avatar" : "-"}
className="rounded-full" </td>
layout="fill" ) : (
objectFit="cover" <td className="px-3 py-4 text-sm font-medium text-gray-900 relative capitalize">
/> {issue[key as keyof IIssue] ??
</div> (issue[key as keyof IIssue] as any)?.name ??
) : ( "None"}
<p> </td>
{person.member.first_name.charAt(0)} )}
</p> </React.Fragment>
)} )
<p>{person.member.first_name}</p> )}
</div> <td className="px-3">
</Listbox.Option> <div className="flex justify-end items-center gap-2">
))} <button
</Listbox.Options> type="button"
</Transition> className="flex items-center bg-blue-100 text-blue-600 hover:bg-blue-200 duration-300 font-medium px-2 py-1 rounded-md text-sm outline-none"
</div> onClick={() => {
</> setSelectedIssue({
)} ...issue,
</Listbox> actionType: "edit",
</td> });
) : (key as keyof Properties) === "state" ? ( }}
<td className="px-3 py-4 text-sm font-medium text-gray-900 relative"> >
<Listbox <PencilIcon className="h-3 w-3" />
as="div" </button>
value={issue.state} <button
onChange={(data: string) => { type="button"
partialUpdateIssue({ state: data }, issue.id); className="flex items-center bg-red-100 text-red-600 hover:bg-red-200 duration-300 font-medium px-2 py-1 rounded-md text-sm outline-none"
}} onClick={() => {
className="flex-shrink-0" handleDeleteIssue(issue.id);
> }}
{({ open }) => ( >
<> <TrashIcon className="h-3 w-3" />
<div> </button>
<Listbox.Button </div>
className="inline-flex items-center whitespace-nowrap rounded-full px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 border" </td>
style={{ </tr>
border: `2px solid ${issue.state_detail.color}`, );
backgroundColor: `${issue.state_detail.color}20`, })
}} : null}
> </tbody>
<span </table>
className={classNames( </div>
issue.state ? "" : "text-gray-900",
"hidden capitalize sm:block w-16"
)}
>
{addSpaceIfCamelCase(issue.state_detail.name)}
</span>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 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>
</>
)}
</Listbox>
</td>
) : (key as keyof Properties) === "target_date" ? (
<td className="px-3 py-4 text-sm font-medium text-gray-900 whitespace-nowrap">
{issue.target_date
? renderShortNumericDateFormat(issue.target_date)
: "-"}
</td>
) : (
<td className="px-3 py-4 text-sm font-medium text-gray-900 relative capitalize">
{issue[key as keyof IIssue] ??
(issue[key as keyof IIssue] as any)?.name ??
"None"}
</td>
)}
</React.Fragment>
)
)}
<td className="px-3">
<div className="flex justify-end items-center gap-2">
<button
type="button"
className="flex items-center bg-blue-100 text-blue-600 hover:bg-blue-200 duration-300 font-medium px-2 py-1 rounded-md text-sm outline-none"
onClick={() => {
setSelectedIssue({
...issue,
actionType: "edit",
});
}}
>
<PencilIcon className="h-3 w-3" />
</button>
<button
type="button"
className="flex items-center bg-red-100 text-red-600 hover:bg-red-200 duration-300 font-medium px-2 py-1 rounded-md text-sm outline-none"
onClick={() => {
handleDeleteIssue(issue.id);
}}
>
<TrashIcon className="h-3 w-3" />
</button>
</div>
</td>
</tr>
);
})
: null}
</React.Fragment>
))}
</tbody>
</table>
</div> </div>
</div> </div>
</div> ))}
</div> </div>
); );
}; };
export default ListView; export default ListView;

View File

@ -1,138 +0,0 @@
// next
import { useRouter } from "next/router";
// react
import { Fragment } from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// hooks
import useUser from "lib/hooks/useUser";
// services
import issuesServices from "lib/services/issues.services";
import projectService from "lib/services/project.service";
// swr
import useSWR from "swr";
// types
import { IIssue, ProjectMember } from "types";
// constants
import { PROJECT_ISSUES_DETAILS, PROJECT_MEMBERS } from "constants/fetch-keys";
import { Button } from "ui";
import { ChartBarIcon, Squares2X2Icon, TagIcon, UserIcon } from "@heroicons/react/24/outline";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
issueId: string | null;
};
const IssuePreviewModal = ({ isOpen, setIsOpen, issueId }: Props) => {
const closeModal = () => {
setIsOpen(false);
};
const { activeWorkspace, activeProject } = useUser();
const router = useRouter();
const { data: issueDetails } = useSWR<IIssue | null>(
activeWorkspace && activeProject && issueId ? PROJECT_ISSUES_DETAILS(issueId) : null,
activeWorkspace && activeProject && issueId
? () => issuesServices.getIssue(activeWorkspace.slug, activeProject.id, issueId)
: null
);
const { data: users } = useSWR<ProjectMember[] | null>(
activeWorkspace && activeProject ? PROJECT_MEMBERS(activeProject.id) : null,
activeWorkspace && activeProject
? () => projectService.projectMembers(activeWorkspace.slug, activeProject.id)
: null
);
return (
<>
<Transition.Root appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-3xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title
as="h3"
className="text-xl flex flex-col gap-1 font-medium leading-6 text-gray-900"
>
{issueDetails?.project_detail.identifier}-{issueDetails?.sequence_id}{" "}
{issueDetails?.name}
<span className="text-sm text-gray-500 font-normal">
Created by{" "}
{users?.find((u) => u.id === issueDetails?.created_by)?.member.first_name}
</span>
</Dialog.Title>
<div className="mt-4">
<p className="text-sm text-gray-500">{issueDetails?.description}</p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<span className="flex items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 sm:text-sm">
<Squares2X2Icon className="h-3 w-3" />
{issueDetails?.state_detail.name}
</span>
<span className="flex items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 capitalize sm:text-sm">
<ChartBarIcon className="h-3 w-3" />
{issueDetails?.priority}
</span>
<span className="flex items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 capitalize sm:text-sm">
<TagIcon className="h-3 w-3" />
{issueDetails?.label_details && issueDetails.label_details.length > 0
? issueDetails.label_details.map((label) => (
<span key={label.id}>{label.name}</span>
))
: "None"}
</span>
<span className="flex items-center gap-1 hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 capitalize sm:text-sm">
<UserIcon className="h-3 w-3" />
{issueDetails?.assignee_details && issueDetails.assignee_details.length > 0
? issueDetails.assignee_details.map((assignee) => (
<span key={assignee.id}>{assignee.first_name}</span>
))
: "None"}
</span>
</div>
<div className="mt-4 flex gap-3 justify-end">
<Button
onClick={() =>
router.push(`/projects/${activeProject?.id}/issues/${issueId}`)
}
>
View in Detail
</Button>
<Button onClick={closeModal}>Close</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</>
);
};
export default IssuePreviewModal;

View File

@ -4,13 +4,14 @@ import useSWR from "swr";
// headless ui // headless ui
import { Listbox, Transition } from "@headlessui/react"; import { Listbox, Transition } from "@headlessui/react";
// react hook form // react hook form
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller, UseFormWatch } from "react-hook-form";
// services // services
import stateServices from "lib/services/state.services"; import stateServices from "lib/services/state.service";
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.service";
import workspaceService from "lib/services/workspace.service"; import workspaceService from "lib/services/workspace.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast";
// fetching keys // fetching keys
import { import {
PROJECT_ISSUES_LIST, PROJECT_ISSUES_LIST,
@ -20,6 +21,7 @@ import {
} from "constants/fetch-keys"; } from "constants/fetch-keys";
// commons // commons
import { classNames, copyTextToClipboard } from "constants/common"; import { classNames, copyTextToClipboard } from "constants/common";
import { PRIORITIES } from "constants/";
// ui // ui
import { Input, Button, Spinner } from "ui"; import { Input, Button, Spinner } from "ui";
import { Popover } from "@headlessui/react"; import { Popover } from "@headlessui/react";
@ -38,25 +40,33 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// types // types
import type { Control } from "react-hook-form"; import type { Control } from "react-hook-form";
import type { IIssue, IIssueLabels, IssueResponse, IState, WorkspaceMember } from "types"; import type { IIssue, IIssueLabels, IssueResponse, IState, NestedKeyOf } from "types";
import { TwitterPicker } from "react-color"; import { TwitterPicker } from "react-color";
import useToast from "lib/hooks/useToast"; import IssuesListModal from "components/project/issues/IssuesListModal";
type Props = { type Props = {
control: Control<IIssue, any>; control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void; submitChanges: (formData: Partial<IIssue>) => void;
issueDetail: IIssue | undefined; issueDetail: IIssue | undefined;
watch: UseFormWatch<IIssue>;
}; };
const PRIORITIES = ["high", "medium", "low"];
const defaultValues: Partial<IIssueLabels> = { const defaultValues: Partial<IIssueLabels> = {
name: "", name: "",
colour: "#ff0000", colour: "#ff0000",
}; };
const IssueDetailSidebar: React.FC<Props> = ({ control, submitChanges, issueDetail }) => { const IssueDetailSidebar: React.FC<Props> = ({
const { activeWorkspace, activeProject, cycles } = useUser(); control,
watch: watchIssue,
submitChanges,
issueDetail,
}) => {
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
const { activeWorkspace, activeProject, cycles, issues } = useUser();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -67,20 +77,11 @@ const IssueDetailSidebar: React.FC<Props> = ({ control, submitChanges, issueDeta
: null : null
); );
const { data: people } = useSWR<WorkspaceMember[]>( const { data: people } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS : null, activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
); );
const { data: projectIssues } = useSWR<IssueResponse>(
activeProject && activeWorkspace
? PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id)
: null,
activeProject && activeWorkspace
? () => issuesServices.getIssues(activeWorkspace.slug, activeProject.id)
: null
);
const { data: issueLabels, mutate: issueLabelMutate } = useSWR<IIssueLabels[]>( const { data: issueLabels, mutate: issueLabelMutate } = useSWR<IIssueLabels[]>(
activeProject && activeWorkspace ? PROJECT_ISSUE_LABELS(activeProject.id) : null, activeProject && activeWorkspace ? PROJECT_ISSUE_LABELS(activeProject.id) : null,
activeProject && activeWorkspace activeProject && activeWorkspace
@ -110,7 +111,19 @@ const IssueDetailSidebar: React.FC<Props> = ({ control, submitChanges, issueDeta
}); });
}; };
const sidebarSections = [ const sidebarSections: Array<
Array<{
label: string;
name: NestedKeyOf<IIssue>;
canSelectMultipleOptions: boolean;
icon: (props: any) => JSX.Element;
options?: Array<{ label: string; value: any }>;
modal: boolean;
issuesList?: Array<IIssue>;
isOpen?: boolean;
setIsOpen?: (arg: boolean) => void;
}>
> = [
[ [
{ {
label: "Status", label: "Status",
@ -121,6 +134,7 @@ const IssueDetailSidebar: React.FC<Props> = ({ control, submitChanges, issueDeta
label: state.name, label: state.name,
value: state.id, value: state.id,
})), })),
modal: false,
}, },
{ {
label: "Assignees", label: "Assignees",
@ -131,6 +145,7 @@ const IssueDetailSidebar: React.FC<Props> = ({ control, submitChanges, issueDeta
label: person.member.first_name, label: person.member.first_name,
value: person.member.id, value: person.member.id,
})), })),
modal: false,
}, },
{ {
label: "Priority", label: "Priority",
@ -141,34 +156,52 @@ const IssueDetailSidebar: React.FC<Props> = ({ control, submitChanges, issueDeta
label: property, label: property,
value: property, value: property,
})), })),
modal: false,
}, },
], ],
[ [
{ {
label: "Blocker", label: "Parent",
name: "blockers_list", name: "parent",
canSelectMultipleOptions: true, canSelectMultipleOptions: false,
icon: UserIcon, icon: UserIcon,
options: projectIssues?.results?.map((issue) => ({ issuesList:
label: issue.name, issues?.results.filter(
value: issue.id, (i) =>
})), i.id !== issueDetail?.id &&
i.id !== issueDetail?.parent &&
i.parent !== issueDetail?.id
) ?? [],
modal: true,
isOpen: isParentModalOpen,
setIsOpen: setIsParentModalOpen,
}, },
// {
// label: "Blocker",
// name: "blockers_list",
// canSelectMultipleOptions: true,
// icon: UserIcon,
// issuesList: issues?.results.filter((i) => i.id !== issueDetail?.id) ?? [],
// modal: true,
// isOpen: isBlockerModalOpen,
// setIsOpen: setIsBlockerModalOpen,
// },
// {
// label: "Blocked",
// name: "blocked_list",
// canSelectMultipleOptions: true,
// icon: UserIcon,
// issuesList: issues?.results.filter((i) => i.id !== issueDetail?.id) ?? [],
// modal: true,
// isOpen: isBlockedModalOpen,
// setIsOpen: setIsBlockedModalOpen,
// },
{ {
label: "Blocked", label: "Target Date",
name: "blocked_list",
canSelectMultipleOptions: true,
icon: UserIcon,
options: projectIssues?.results?.map((issue) => ({
label: issue.name,
value: issue.id,
})),
},
{
label: "Due Date",
name: "target_date", name: "target_date",
canSelectMultipleOptions: true, canSelectMultipleOptions: true,
icon: CalendarDaysIcon, icon: CalendarDaysIcon,
modal: false,
}, },
], ],
[ [
@ -181,6 +214,7 @@ const IssueDetailSidebar: React.FC<Props> = ({ control, submitChanges, issueDeta
label: cycle.name, label: cycle.name,
value: cycle.id, value: cycle.id,
})), })),
modal: false,
}, },
], ],
]; ];
@ -249,12 +283,12 @@ const IssueDetailSidebar: React.FC<Props> = ({ control, submitChanges, issueDeta
{sidebarSections.map((section, index) => ( {sidebarSections.map((section, index) => (
<div key={index} className="py-1"> <div key={index} className="py-1">
{section.map((item) => ( {section.map((item) => (
<div key={item.label} className="flex justify-between items-center gap-x-2 py-2"> <div key={item.label} className="flex items-center py-2 flex-wrap">
<div className="flex items-center gap-x-2 text-sm"> <div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<item.icon className="h-4 w-4" /> <item.icon className="flex-shrink-0 h-4 w-4" />
<p>{item.label}</p> <p>{item.label}</p>
</div> </div>
<div> <div className="sm:basis-1/2">
{item.name === "target_date" ? ( {item.name === "target_date" ? (
<Controller <Controller
control={control} control={control}
@ -262,15 +296,52 @@ const IssueDetailSidebar: React.FC<Props> = ({ control, submitChanges, issueDeta
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<input <input
type="date" type="date"
value={""} value={value ?? ""}
onChange={(e: any) => { onChange={(e: any) => {
submitChanges({ target_date: e.target.value }); submitChanges({ target_date: e.target.value });
onChange(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" 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);
}}
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 <Controller
control={control} control={control}
@ -288,11 +359,11 @@ const IssueDetailSidebar: React.FC<Props> = ({ control, submitChanges, issueDeta
> >
{({ open }) => ( {({ open }) => (
<div className="relative"> <div className="relative">
<Listbox.Button className="relative 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-sm duration-300"> <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 <span
className={classNames( className={classNames(
value ? "" : "text-gray-900", value ? "" : "text-gray-900",
"hidden truncate sm:block w-16 text-left", "hidden truncate sm:block text-left",
item.label === "Priority" ? "capitalize" : "" item.label === "Priority" ? "capitalize" : ""
)} )}
> >
@ -419,12 +490,12 @@ const IssueDetailSidebar: React.FC<Props> = ({ control, submitChanges, issueDeta
+ +
</Button> </Button>
</form> </form>
<div className="flex justify-between items-center gap-x-2"> <div className="flex justify-between items-center">
<div className="flex items-center gap-x-2 text-sm"> <div className="flex items-center gap-x-2 text-sm basis-1/2">
<TagIcon className="w-4 h-4" /> <TagIcon className="w-4 h-4" />
<p>Label</p> <p>Label</p>
</div> </div>
<div> <div className="basis-1/2">
<Controller <Controller
control={control} control={control}
name="labels_list" name="labels_list"
@ -440,11 +511,11 @@ const IssueDetailSidebar: React.FC<Props> = ({ control, submitChanges, issueDeta
<> <>
<Listbox.Label className="sr-only">Label</Listbox.Label> <Listbox.Label className="sr-only">Label</Listbox.Label>
<div className="relative"> <div className="relative">
<Listbox.Button className="relative 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-sm duration-300"> <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 <span
className={classNames( className={classNames(
value ? "" : "text-gray-900", value ? "" : "text-gray-900",
"hidden truncate capitalize sm:block w-16 text-left" "hidden truncate capitalize sm:block text-left"
)} )}
> >
{value && value.length > 0 {value && value.length > 0

View File

@ -64,14 +64,14 @@ const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion
<div key={comment.id}> <div key={comment.id}>
<div className="w-full h-full flex justify-between"> <div className="w-full h-full flex justify-between">
<div className="flex gap-x-2 w-full"> <div className="flex gap-x-2 w-full">
<div className="flex-shrink-0 -ml-1.5"> <div className="flex-shrink-0">
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? ( {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
<Image <Image
src={comment.actor_detail.avatar} src={comment.actor_detail.avatar}
alt={comment.actor_detail.name} alt={comment.actor_detail.name}
height={30} height={30}
width={30} width={30}
className="rounded-full" className="rounded"
/> />
) : ( ) : (
<div <div
@ -82,11 +82,7 @@ const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion
)} )}
</div> </div>
<div className="w-full"> <div className="w-full">
<p> <div>
{comment.actor_detail.first_name} {comment.actor_detail.last_name}
</p>
<p className="text-xs text-gray-500">{timeAgo(comment.created_at)}</p>
<div className="w-full mt-2">
{isEditing ? ( {isEditing ? (
<form className="flex flex-col gap-2" onSubmit={handleSubmit(onEnter)}> <form className="flex flex-col gap-2" onSubmit={handleSubmit(onEnter)}>
<TextArea <TextArea
@ -120,13 +116,19 @@ const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion
) : ( ) : (
<> <>
{comment.comment.split("\n").map((item, index) => ( {comment.comment.split("\n").map((item, index) => (
<p key={index} className="text-sm text-gray-600"> <p key={index} className="text-sm">
{item} {item}
</p> </p>
))} ))}
</> </>
)} )}
</div> </div>
<p className="text-xs text-gray-500 flex items-center gap-2 mt-1">
<span>
{comment.actor_detail.first_name} {comment.actor_detail.last_name}
</span>
<span>{timeAgo(comment.created_at)}</span>
</p>
</div> </div>
</div> </div>
{user?.id === comment.actor && ( {user?.id === comment.actor && (

View File

@ -4,7 +4,7 @@ import { mutate } from "swr";
// react hook form // react hook form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// services // services
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.service";
// fetch keys // fetch keys
import { PROJECT_ISSUES_COMMENTS } from "constants/fetch-keys"; import { PROJECT_ISSUES_COMMENTS } from "constants/fetch-keys";
// components // components
@ -72,7 +72,7 @@ const IssueCommentSection: React.FC<Props> = ({ comments, issueId, projectId, wo
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="p-2 bg-indigo-50 rounded-md"> <div className="bg-gray-100 rounded-md">
<div className="w-full"> <div className="w-full">
<TextArea <TextArea
id="comment" id="comment"

View File

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

View File

@ -3,10 +3,14 @@ import React, { useState } from "react";
// next // next
import Link from "next/link"; import Link from "next/link";
import useSWR from "swr"; import useSWR from "swr";
import _ from "lodash"; // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// Services // Services
import projectService from "lib/services/project.service"; import projectService from "lib/services/project.service";
// fetch keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
// commons
import { renderShortNumericDateFormat } from "constants/common";
// icons // icons
import { import {
CalendarDaysIcon, CalendarDaysIcon,
@ -17,25 +21,41 @@ import {
PlusIcon, PlusIcon,
TrashIcon, TrashIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { renderShortNumericDateFormat } from "constants/common"; // types
import type { IProject } from "types";
type Props = {
project: IProject;
slug: string;
invitationsRespond: string[];
handleInvitation: (project_invitation: any, action: "accepted" | "withdraw") => void;
setDeleteProject: (id: string | null) => void;
};
const ProjectMemberInvitations = ({ const ProjectMemberInvitations: React.FC<Props> = ({
project, project,
slug, slug,
invitationsRespond, invitationsRespond,
handleInvitation, handleInvitation,
setDeleteProject, setDeleteProject,
}: any) => { }) => {
const { user } = useUser(); const { user } = useUser();
const { data: members } = useSWR("PROJECT_MEMBERS", () =>
const { data: members } = useSWR<any[]>(PROJECT_MEMBERS(project.id), () =>
projectService.projectMembers(slug, project.id) projectService.projectMembers(slug, project.id)
); );
const isMember = const isMember = members?.some((item: any) => item.member.id === (user as any)?.id);
_.filter(members, (item: any) => item.member.id === (user as any).id).length === 1;
const [selected, setSelected] = useState<any>(false); const [selected, setSelected] = useState<any>(false);
if (!members) {
return (
<div className="w-full h-36 flex flex-col px-4 py-3 rounded-md bg-white">
<div className="w-full h-full bg-gray-50 animate-pulse" />
</div>
);
}
return ( return (
<> <>
<div <div
@ -80,7 +100,7 @@ const ProjectMemberInvitations = ({
<button <button
type="button" type="button"
className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none" className="h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none"
onClick={() => setDeleteProject(project)} onClick={() => setDeleteProject(project.id)}
> >
<TrashIcon className="h-4 w-4 text-red-500" /> <TrashIcon className="h-4 w-4 text-red-500" />
</button> </button>

View File

@ -15,7 +15,7 @@ import { Button } from "ui";
// icons // icons
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline"; import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
// types // types
import { IProject, WorkspaceMember } from "types"; import { IProject } from "types";
// fetch-keys // fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
@ -27,7 +27,7 @@ type Props = {
const ControlSettings: React.FC<Props> = ({ control, isSubmitting }) => { const ControlSettings: React.FC<Props> = ({ control, isSubmitting }) => {
const { activeWorkspace } = useUser(); const { activeWorkspace } = useUser();
const { data: people } = useSWR<WorkspaceMember[]>( const { data: people } = useSWR(
activeWorkspace ? WORKSPACE_MEMBERS : null, activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
); );

View File

@ -7,7 +7,7 @@ import projectServices from "lib/services/project.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// ui // ui
import { Input, Select, TextArea } from "ui"; import { Button, Input, Select, TextArea } from "ui";
// types // types
import { IProject } from "types"; import { IProject } from "types";
// constants // constants
@ -17,11 +17,12 @@ type Props = {
register: UseFormRegister<IProject>; register: UseFormRegister<IProject>;
errors: any; errors: any;
setError: UseFormSetError<IProject>; setError: UseFormSetError<IProject>;
isSubmitting: boolean;
}; };
const NETWORK_CHOICES = { "0": "Secret", "2": "Public" }; const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
const GeneralSettings: React.FC<Props> = ({ register, errors, setError }) => { const GeneralSettings: React.FC<Props> = ({ register, errors, setError, isSubmitting }) => {
const { activeWorkspace } = useUser(); const { activeWorkspace } = useUser();
const checkIdentifier = (slug: string, value: string) => { const checkIdentifier = (slug: string, value: string) => {
@ -111,6 +112,11 @@ const GeneralSettings: React.FC<Props> = ({ register, errors, setError }) => {
}} }}
/> />
</div> </div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</Button>
</div>
</section> </section>
</> </>
); );

View File

@ -7,7 +7,7 @@ import { Controller, SubmitHandler, useForm } from "react-hook-form";
// react-color // react-color
import { TwitterPicker } from "react-color"; import { TwitterPicker } from "react-color";
// services // services
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// headless ui // headless ui
@ -47,6 +47,7 @@ const LabelsSettings: React.FC = () => {
setValue, setValue,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
watch, watch,
setError,
} = useForm<IIssueLabels>({ defaultValues }); } = useForm<IIssueLabels>({ defaultValues });
const { data: issueLabels, mutate } = useSWR<IIssueLabels[]>( const { data: issueLabels, mutate } = useSWR<IIssueLabels[]>(
@ -163,23 +164,33 @@ const LabelsSettings: React.FC = () => {
)} )}
</Popover> </Popover>
</div> </div>
<Input <div className="w-full flex flex-col justify-center">
type="text" <Input
id="labelName" type="text"
name="name" id="labelName"
register={register} name="name"
placeholder="Lable title" register={register}
/> placeholder="Lable title"
validations={{
required: "Label title is required",
}}
error={errors.name}
/>
</div>
<Button type="button" theme="secondary" onClick={() => setNewLabelForm(false)}> <Button type="button" theme="secondary" onClick={() => setNewLabelForm(false)}>
Cancel Cancel
</Button> </Button>
{isUpdating ? ( {isUpdating ? (
<Button type="button" onClick={handleSubmit(handleLabelUpdate)}> <Button
Update type="button"
onClick={handleSubmit(handleLabelUpdate)}
disabled={isSubmitting}
>
{isSubmitting ? "Updating" : "Update"}
</Button> </Button>
) : ( ) : (
<Button type="button" onClick={handleSubmit(handleNewLabel)}> <Button type="button" onClick={handleSubmit(handleNewLabel)} disabled={isSubmitting}>
Add {isSubmitting ? "Adding" : "Add"}
</Button> </Button>
)} )}
</form> </form>
@ -193,7 +204,7 @@ const LabelsSettings: React.FC = () => {
<div className="bg-white p-2 flex items-center justify-between text-gray-900 rounded-md"> <div className="bg-white p-2 flex items-center justify-between text-gray-900 rounded-md">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`h-1.5 w-1.5 rounded-full`} className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{ style={{
backgroundColor: label.colour, backgroundColor: label.colour,
}} }}

View File

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

View File

@ -3,10 +3,10 @@ import Image from "next/image";
// react // react
import { useState } from "react"; import { useState } from "react";
// types // types
import { IWorkspaceInvitation } from "types"; import { IWorkspaceMemberInvitation } from "types";
type Props = { type Props = {
invitation: IWorkspaceInvitation; invitation: IWorkspaceMemberInvitation;
invitationsRespond: string[]; invitationsRespond: string[];
handleInvitation: any; handleInvitation: any;
}; };

View File

@ -1,4 +1,4 @@
import React, { useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
// next // next
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// headless ui // headless ui
@ -11,43 +11,54 @@ import useToast from "lib/hooks/useToast";
// icons // icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui // ui
import { Button } from "ui"; import { Button, Input } from "ui";
// types // types
import type { IWorkspace } from "types"; import type { IWorkspace } from "types";
type Props = { type Props = {
isOpen: boolean; 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 router = useRouter();
const [isDeleteLoading, setIsDeleteLoading] = useState(false); 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 { setToastAlert } = useToast();
const cancelButtonRef = useRef(null); const cancelButtonRef = useRef(null);
const handleClose = () => { const handleClose = () => {
setIsOpen(false); onClose();
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const handleDeletion = async () => { const handleDeletion = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
if (!activeWorkspace) return; if (!data || !canDelete) return;
await workspaceService await workspaceService
.deleteWorkspace(activeWorkspace.slug) .deleteWorkspace(data.slug)
.then(() => { .then(() => {
handleClose(); handleClose();
mutateWorkspaces((prevData) => { mutateWorkspaces((prevData) => {
return (prevData ?? []).filter( return (prevData ?? []).filter((workspace: IWorkspace) => workspace.slug !== data.slug);
(workspace: IWorkspace) => workspace.slug !== activeWorkspace.slug
);
}, false); }, false);
setToastAlert({
type: "success",
message: "Workspace deleted successfully",
title: "Success",
});
router.push("/"); router.push("/");
}) })
.catch((error) => { .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 ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog <Dialog
@ -103,11 +124,47 @@ const ConfirmWorkspaceDeletion: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Are you sure you want to delete workspace - {`"`} 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 {`"`} ? All of the data related to the workspace will be permanently
removed. This action cannot be undone. removed. This action cannot be undone.
</p> </p>
</div> </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> </div>
</div> </div>
@ -116,7 +173,7 @@ const ConfirmWorkspaceDeletion: React.FC<Props> = ({ isOpen, setIsOpen }) => {
type="button" type="button"
onClick={handleDeletion} onClick={handleDeletion}
theme="danger" theme="danger"
disabled={isDeleteLoading} disabled={isDeleteLoading || !canDelete}
className="inline-flex sm:ml-3" className="inline-flex sm:ml-3"
> >
{isDeleteLoading ? "Deleting..." : "Delete"} {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 USER_ENDPOINT = "/api/users/me/";
export const CHANGE_PASSWORD = "/api/users/me/change-password/"; export const CHANGE_PASSWORD = "/api/users/me/change-password/";
export const USER_ONBOARD_ENDPOINT = "/api/users/me/onboard/"; 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"; export const USER_WORKSPACES = "/api/users/me/workspaces";
// s3 file url // s3 file url
@ -24,7 +25,7 @@ export const S3_URL = `/api/file-assets/`;
// LIST USER INVITATIONS ---- RESPOND INVITATIONS IN BULK // LIST USER INVITATIONS ---- RESPOND INVITATIONS IN BULK
export const USER_WORKSPACE_INVITATIONS = "/api/users/me/invitations/workspaces/"; export const USER_WORKSPACE_INVITATIONS = "/api/users/me/invitations/workspaces/";
export const USER_PROJECT_INVITATIONS = "/api/users/me/invitations/projects/"; 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) => export const USER_WORKSPACE_INVITATION = (invitationId: string) =>
`/api/users/me/invitations/${invitationId}/`; `/api/users/me/invitations/${invitationId}/`;
@ -33,8 +34,6 @@ export const JOIN_WORKSPACE = (workspaceSlug: string, invitationId: string) =>
export const JOIN_PROJECT = (workspaceSlug: string) => export const JOIN_PROJECT = (workspaceSlug: string) =>
`/api/workspaces/${workspaceSlug}/projects/join/`; `/api/workspaces/${workspaceSlug}/projects/join/`;
export const USER_ISSUES = "/api/users/me/issues/";
// workspaces // workspaces
export const WORKSPACES_ENDPOINT = "/api/workspaces/"; export const WORKSPACES_ENDPOINT = "/api/workspaces/";
export const WORKSPACE_DETAIL = (workspaceSlug: string) => `/api/workspaces/${workspaceSlug}/`; 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/`; `/api/workspaces/${workspaceSlug}/projects/${projectId}/members/`;
export const PROJECT_MEMBER_DETAIL = (workspaceSlug: string, projectId: string, memberId: string) => export const PROJECT_MEMBER_DETAIL = (workspaceSlug: string, projectId: string, memberId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`; `/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) => export const PROJECT_INVITATIONS = (workspaceSlug: string, projectId: string) =>
`/api/workspaces/${workspaceSlug}/projects/${projectId}/invitations/`; `/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); 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

@ -2,12 +2,12 @@ export const CURRENT_USER = "CURRENT_USER";
export const USER_WORKSPACE_INVITATIONS = "USER_WORKSPACE_INVITATIONS"; export const USER_WORKSPACE_INVITATIONS = "USER_WORKSPACE_INVITATIONS";
export const USER_WORKSPACES = "USER_WORKSPACES"; export const USER_WORKSPACES = "USER_WORKSPACES";
export const WORKSPACE_MEMBERS = "WORKSPACE_MEMBERS"; export const WORKSPACE_MEMBERS = (workspaceSlug: string) => `WORKSPACE_MEMBERS_${workspaceSlug}`;
export const WORKSPACE_INVITATIONS = "WORKSPACE_INVITATIONS"; export const WORKSPACE_INVITATIONS = "WORKSPACE_INVITATIONS";
export const WORKSPACE_INVITATION = "WORKSPACE_INVITATION"; export const WORKSPACE_INVITATION = "WORKSPACE_INVITATION";
export const PROJECTS_LIST = (workspaceSlug: string) => `PROJECTS_LIST_${workspaceSlug}`; 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_MEMBERS = (projectId: string) => `PROJECT_MEMBERS_${projectId}`;
export const PROJECT_INVITATIONS = "PROJECT_INVITATIONS"; 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_LIST = (projectId: string) => `STATE_LIST_${projectId}`;
export const STATE_DETAIL = "STATE_DETAIL"; export const STATE_DETAIL = "STATE_DETAIL";
export const USER_ISSUE = "USER_ISSUE"; export const USER_ISSUE = (workspaceSlug: string) => `USER_ISSUE_${workspaceSlug}`;

View File

@ -0,0 +1,12 @@
export const getPriorityIcon = (priority: string) => {
switch (priority) {
case "urgent":
return <span className="material-symbols-rounded">signal_cellular_alt</span>;
case "high":
return <span className="material-symbols-rounded">signal_cellular_alt_2_bar</span>;
case "medium":
return <span className="material-symbols-rounded">signal_cellular_alt_1_bar</span>;
default:
return <span>N/A</span>;
}
};

View File

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

View File

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

View File

@ -8,11 +8,11 @@ import useUser from "lib/hooks/useUser";
import Container from "layouts/Container"; import Container from "layouts/Container";
import Sidebar from "layouts/Navbar/Sidebar"; import Sidebar from "layouts/Navbar/Sidebar";
// components // components
import CreateProjectModal from "components/project/CreateProjectModal"; import CreateProjectModal from "components/project/create-project-modal";
// types // types
import type { Props } from "./types"; import type { Props } from "./types";
const AdminLayout: React.FC<Props> = ({ meta, children }) => { const AppLayout: React.FC<Props> = ({ meta, children, noPadding = false, bg = "primary" }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const router = useRouter(); const router = useRouter();
@ -28,10 +28,16 @@ const AdminLayout: React.FC<Props> = ({ meta, children }) => {
<CreateProjectModal isOpen={isOpen} setIsOpen={setIsOpen} /> <CreateProjectModal isOpen={isOpen} setIsOpen={setIsOpen} />
<div className="h-screen w-full flex overflow-x-hidden"> <div className="h-screen w-full flex overflow-x-hidden">
<Sidebar /> <Sidebar />
<main className="h-full w-full min-w-0 p-5 bg-primary overflow-y-auto">{children}</main> <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>
</div> </div>
</Container> </Container>
); );
}; };
export default AdminLayout; export default AppLayout;

View File

@ -9,8 +9,7 @@ import authenticationService from "lib/services/authentication.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useTheme from "lib/hooks/useTheme"; import useTheme from "lib/hooks/useTheme";
// components import useToast from "lib/hooks/useToast";
import CreateProjectModal from "components/project/CreateProjectModal";
// headless ui // headless ui
import { Dialog, Disclosure, Menu, Transition } from "@headlessui/react"; import { Dialog, Disclosure, Menu, Transition } from "@headlessui/react";
// icons // icons
@ -107,7 +106,6 @@ const userLinks = [
const Sidebar: React.FC = () => { const Sidebar: React.FC = () => {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [isCreateProjectModal, setCreateProjectModal] = useState(false);
const router = useRouter(); const router = useRouter();
@ -119,9 +117,10 @@ const Sidebar: React.FC = () => {
const { collapsed: sidebarCollapse, toggleCollapsed } = useTheme(); const { collapsed: sidebarCollapse, toggleCollapsed } = useTheme();
const { setToastAlert } = useToast();
return ( return (
<nav className="h-full"> <nav className="h-full">
<CreateProjectModal isOpen={isCreateProjectModal} setIsOpen={setCreateProjectModal} />
<Transition.Root show={sidebarOpen} as={React.Fragment}> <Transition.Root show={sidebarOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-40 md:hidden" onClose={setSidebarOpen}> <Dialog as="div" className="relative z-40 md:hidden" onClose={setSidebarOpen}>
<Transition.Child <Transition.Child
@ -282,9 +281,11 @@ const Sidebar: React.FC = () => {
last_workspace_id: workspace?.id, last_workspace_id: workspace?.id,
}) })
.then((res) => { .then((res) => {
router.push("/workspace"); const isInProject =
router.pathname.includes("/[projectId]/");
if (isInProject) router.push("/workspace");
}) })
.catch((err) => console.log); .catch((err) => console.error(err));
}} }}
className={`${ className={`${
active ? "bg-theme text-white" : "text-gray-900" active ? "bg-theme text-white" : "text-gray-900"
@ -478,7 +479,13 @@ const Sidebar: React.FC = () => {
onClick={() => onClick={() =>
copyTextToClipboard( copyTextToClipboard(
`https://app.plane.so/projects/${project?.id}/issues/` `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" /> <ClipboardDocumentIcon className="h-3 w-3" />
@ -546,7 +553,13 @@ const Sidebar: React.FC = () => {
<button <button
type="button" type="button"
className="group flex justify-center items-center gap-2 w-full rounded-md p-2 text-sm bg-theme text-white" 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" /> <PlusIcon className="h-5 w-5" />
{!sidebarCollapse && "Create Project"} {!sidebarCollapse && "Create Project"}

View File

@ -8,4 +8,6 @@ export type Meta = {
export type Props = { export type Props = {
meta?: Meta; meta?: Meta;
children: React.ReactNode; children: React.ReactNode;
noPadding?: boolean;
bg?: "primary" | "secondary";
}; };

View File

@ -4,7 +4,7 @@ import type { NextPage } from "next";
// redirect // redirect
import redirect from "lib/redirect"; import redirect from "lib/redirect";
const withAuth = (WrappedComponent: NextPage) => { const withAuth = (WrappedComponent: NextPage, getBackToSameRoute: boolean = true) => {
const Wrapper: NextPage<any> = (props) => { const Wrapper: NextPage<any> = (props) => {
return <WrappedComponent {...props} />; return <WrappedComponent {...props} />;
}; };
@ -17,7 +17,8 @@ const withAuth = (WrappedComponent: NextPage) => {
const token = cookies?.split("accessToken=")?.[1]?.split(";")?.[0]; const token = cookies?.split("accessToken=")?.[1]?.split(";")?.[0];
if (!token) { if (!token) {
redirect(ctx, "/signin"); if (getBackToSameRoute) redirect(ctx, "/signin?next=" + ctx?.asPath);
else redirect(ctx, "/signin");
} }
const pageProps = const pageProps =

View File

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

View File

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

View File

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

View File

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

View File

@ -11,18 +11,27 @@ import {
WORKSPACE_INVITATION_DETAIL, WORKSPACE_INVITATION_DETAIL,
USER_WORKSPACE_INVITATION, USER_WORKSPACE_INVITATION,
USER_WORKSPACE_INVITATIONS, USER_WORKSPACE_INVITATIONS,
LAST_ACTIVE_WORKSPACE_AND_PROJECTS,
} from "constants/api-routes"; } from "constants/api-routes";
// services // services
import APIService from "lib/services/api.service"; import APIService from "lib/services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env; const { NEXT_PUBLIC_API_BASE_URL } = process.env;
// types
import {
ILastActiveWorkspaceDetails,
IWorkspace,
IWorkspaceMember,
IWorkspaceMemberInvitation,
} from "types";
class WorkspaceService extends APIService { class WorkspaceService extends APIService {
constructor() { constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
} }
async userWorkspaces(): Promise<any> { async userWorkspaces(): Promise<IWorkspace[]> {
return this.get(USER_WORKSPACES) return this.get(USER_WORKSPACES)
.then((response) => { .then((response) => {
return response?.data; return response?.data;
@ -32,7 +41,7 @@ class WorkspaceService extends APIService {
}); });
} }
async createWorkspace(data: any): Promise<any> { async createWorkspace(data: Partial<IWorkspace>): Promise<IWorkspace> {
return this.post(WORKSPACES_ENDPOINT, data) return this.post(WORKSPACES_ENDPOINT, data)
.then((response) => { .then((response) => {
return response?.data; return response?.data;
@ -42,17 +51,8 @@ class WorkspaceService extends APIService {
}); });
} }
async updateWorkspace(workspace_slug: string, data: any): Promise<any> { async updateWorkspace(workspaceSlug: string, data: Partial<IWorkspace>): Promise<IWorkspace> {
return this.patch(WORKSPACE_DETAIL(workspace_slug), data) return this.patch(WORKSPACE_DETAIL(workspaceSlug), 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))
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })
@ -61,8 +61,8 @@ class WorkspaceService extends APIService {
}); });
} }
async inviteWorkspace(workspace_slug: string, data: any): Promise<any> { async deleteWorkspace(workspaceSlug: string): Promise<any> {
return this.post(INVITE_WORKSPACE(workspace_slug), data) return this.delete(WORKSPACE_DETAIL(workspaceSlug))
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })
@ -70,8 +70,19 @@ class WorkspaceService extends APIService {
throw error?.response?.data; 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: {}, headers: {},
}) })
.then((response) => { .then((response) => {
@ -92,7 +103,17 @@ class WorkspaceService extends APIService {
}); });
} }
async userWorkspaceInvitations(): Promise<any> { async getLastActiveWorkspaceAndProjects(): Promise<ILastActiveWorkspaceDetails> {
return this.get(LAST_ACTIVE_WORKSPACE_AND_PROJECTS)
.then((response) => {
return response?.data;
})
.catch((error) => {
throw error?.response?.data;
});
}
async userWorkspaceInvitations(): Promise<IWorkspaceMemberInvitation[]> {
return this.get(USER_WORKSPACE_INVITATIONS) return this.get(USER_WORKSPACE_INVITATIONS)
.then((response) => { .then((response) => {
return response?.data; return response?.data;
@ -102,8 +123,8 @@ class WorkspaceService extends APIService {
}); });
} }
async workspaceMembers(workspace_slug: string): Promise<any> { async workspaceMembers(workspaceSlug: string): Promise<IWorkspaceMember[]> {
return this.get(WORKSPACE_MEMBERS(workspace_slug)) return this.get(WORKSPACE_MEMBERS(workspaceSlug))
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })
@ -112,8 +133,12 @@ class WorkspaceService extends APIService {
}); });
} }
async updateWorkspaceMember(workspace_slug: string, memberId: string, data: any): Promise<any> { async updateWorkspaceMember(
return this.put(WORKSPACE_MEMBER_DETAIL(workspace_slug, memberId), data) workspaceSlug: string,
memberId: string,
data: Partial<IWorkspaceMember>
): Promise<IWorkspaceMember> {
return this.put(WORKSPACE_MEMBER_DETAIL(workspaceSlug, memberId), data)
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })
@ -122,8 +147,8 @@ class WorkspaceService extends APIService {
}); });
} }
async deleteWorkspaceMember(workspace_slug: string, memberId: string): Promise<any> { async deleteWorkspaceMember(workspaceSlug: string, memberId: string): Promise<any> {
return this.delete(WORKSPACE_MEMBER_DETAIL(workspace_slug, memberId)) return this.delete(WORKSPACE_MEMBER_DETAIL(workspaceSlug, memberId))
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })
@ -132,8 +157,8 @@ class WorkspaceService extends APIService {
}); });
} }
async workspaceInvitations(workspace_slug: string): Promise<any> { async workspaceInvitations(workspaceSlug: string): Promise<IWorkspaceMemberInvitation[]> {
return this.get(WORKSPACE_INVITATIONS(workspace_slug)) return this.get(WORKSPACE_INVITATIONS(workspaceSlug))
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })
@ -142,8 +167,8 @@ class WorkspaceService extends APIService {
}); });
} }
async getWorkspaceInvitation(invitation_id: string): Promise<any> { async getWorkspaceInvitation(invitationId: string): Promise<IWorkspaceMemberInvitation> {
return this.get(USER_WORKSPACE_INVITATION(invitation_id), { headers: {} }) return this.get(USER_WORKSPACE_INVITATION(invitationId), { headers: {} })
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })
@ -152,8 +177,11 @@ class WorkspaceService extends APIService {
}); });
} }
async updateWorkspaceInvitation(workspace_slug: string, invitation_id: string): Promise<any> { async updateWorkspaceInvitation(
return this.put(WORKSPACE_INVITATION_DETAIL(workspace_slug, invitation_id)) workspaceSlug: string,
invitationId: string
): Promise<IWorkspaceMemberInvitation> {
return this.put(WORKSPACE_INVITATION_DETAIL(workspaceSlug, invitationId))
.then((response) => { .then((response) => {
return response?.data; return response?.data;
}) })
@ -161,8 +189,9 @@ class WorkspaceService extends APIService {
throw error?.response?.data; 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) => { .then((response) => {
return response?.data; return response?.data;
}) })

View File

@ -8,6 +8,8 @@ import { useForm } from "react-hook-form";
import workspaceService from "lib/services/workspace.service"; import workspaceService from "lib/services/workspace.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts // layouts
import DefaultLayout from "layouts/DefaultLayout"; import DefaultLayout from "layouts/DefaultLayout";
// ui // ui
@ -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"; import useUser from "lib/hooks/useUser";
// constants // constants
import { USER_WORKSPACE_INVITATIONS } from "constants/api-routes"; import { USER_WORKSPACE_INVITATIONS } from "constants/api-routes";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts // layouts
import DefaultLayout from "layouts/DefaultLayout"; import DefaultLayout from "layouts/DefaultLayout";
// components // components
@ -20,7 +22,7 @@ import { Button, Spinner, EmptySpace, EmptySpaceItem } from "ui";
// icons // icons
import { CubeIcon, PlusIcon } from "@heroicons/react/24/outline"; import { CubeIcon, PlusIcon } from "@heroicons/react/24/outline";
// types // types
import type { IWorkspaceInvitation } from "types"; import type { IWorkspaceMemberInvitation } from "types";
import Link from "next/link"; import Link from "next/link";
const OnBoard: NextPage = () => { const OnBoard: NextPage = () => {
@ -30,13 +32,12 @@ const OnBoard: NextPage = () => {
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]); const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
const { data: invitations, mutate } = useSWR<IWorkspaceInvitation[]>( const { data: invitations, mutate } = useSWR(USER_WORKSPACE_INVITATIONS, () =>
USER_WORKSPACE_INVITATIONS, workspaceService.userWorkspaceInvitations()
() => workspaceService.userWorkspaceInvitations()
); );
const handleInvitation = ( const handleInvitation = (
workspace_invitation: IWorkspaceInvitation, workspace_invitation: IWorkspaceMemberInvitation,
action: "accepted" | "withdraw" action: "accepted" | "withdraw"
) => { ) => {
if (action === "accepted") { if (action === "accepted") {
@ -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 // swr
import useSWR from "swr"; import useSWR from "swr";
// layouts // layouts
import AdminLayout from "layouts/AdminLayout"; import AppLayout from "layouts/AppLayout";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// ui // ui
@ -18,7 +18,9 @@ import { USER_ISSUE } from "constants/fetch-keys";
import { classNames } from "constants/common"; import { classNames } from "constants/common";
// services // services
import userService from "lib/services/user.service"; 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 // components
import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown"; import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown";
// icons // icons
@ -31,11 +33,11 @@ import { Menu, Transition } from "@headlessui/react";
const MyIssues: NextPage = () => { const MyIssues: NextPage = () => {
const [selectedWorkspace, setSelectedWorkspace] = useState<string | null>(null); const [selectedWorkspace, setSelectedWorkspace] = useState<string | null>(null);
const { user, workspaces } = useUser(); const { user, workspaces, activeWorkspace } = useUser();
const { data: myIssues, mutate: mutateMyIssues } = useSWR<IIssue[]>( const { data: myIssues, mutate: mutateMyIssues } = useSWR<IIssue[]>(
user ? USER_ISSUE : null, user && activeWorkspace ? USER_ISSUE(activeWorkspace.slug) : null,
user ? () => userService.userIssues() : null user && activeWorkspace ? () => userService.userIssues(activeWorkspace.slug) : null
); );
const updateMyIssues = ( const updateMyIssues = (
@ -74,7 +76,7 @@ const MyIssues: NextPage = () => {
}; };
return ( return (
<AdminLayout> <AppLayout>
<div className="w-full h-full flex flex-col space-y-5"> <div className="w-full h-full flex flex-col space-y-5">
{myIssues ? ( {myIssues ? (
<> <>
@ -274,8 +276,8 @@ const MyIssues: NextPage = () => {
</div> </div>
)} )}
</div> </div>
</AdminLayout> </AppLayout>
); );
}; };
export default MyIssues; export default withAuth(MyIssues);

View File

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

View File

@ -10,7 +10,7 @@ import useUser from "lib/hooks/useUser";
// hoc // hoc
import withAuthWrapper from "lib/hoc/withAuthWrapper"; import withAuthWrapper from "lib/hoc/withAuthWrapper";
// layouts // layouts
import AdminLayout from "layouts/AdminLayout"; import AppLayout from "layouts/AppLayout";
// ui // ui
import { Button } from "ui"; import { Button } from "ui";
// swr // swr
@ -52,7 +52,7 @@ const MyWorkspacesInvites: NextPage = () => {
}; };
return ( return (
<AdminLayout <AppLayout
meta={{ meta={{
title: "Plane - My Workspace Invites", title: "Plane - My Workspace Invites",
}} }}
@ -147,7 +147,7 @@ const MyWorkspacesInvites: NextPage = () => {
)} )}
</div> </div>
</div> </div>
</AdminLayout> </AppLayout>
); );
}; };

View File

@ -5,14 +5,16 @@ import type { NextPage } from "next";
// swr // swr
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// services // services
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.service";
import sprintService from "lib/services/cycles.services"; import sprintService from "lib/services/cycles.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// fetching keys // fetching keys
import { CYCLE_ISSUES, CYCLE_LIST } from "constants/fetch-keys"; import { CYCLE_ISSUES, CYCLE_LIST } from "constants/fetch-keys";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts // layouts
import AdminLayout from "layouts/AdminLayout"; import AppLayout from "layouts/AppLayout";
// components // components
import CycleView from "components/project/cycles/CycleView"; import CycleView from "components/project/cycles/CycleView";
import ConfirmIssueDeletion from "components/project/issues/ConfirmIssueDeletion"; import ConfirmIssueDeletion from "components/project/issues/ConfirmIssueDeletion";
@ -165,7 +167,7 @@ const ProjectSprints: NextPage = () => {
}, [selectedIssues]); }, [selectedIssues]);
return ( return (
<AdminLayout <AppLayout
meta={{ meta={{
title: "Plane - Cycles", title: "Plane - Cycles",
}} }}
@ -254,8 +256,8 @@ const ProjectSprints: NextPage = () => {
<Spinner /> <Spinner />
</div> </div>
)} )}
</AdminLayout> </AppLayout>
); );
}; };
export default ProjectSprints; export default withAuth(ProjectSprints);

View File

@ -11,8 +11,7 @@ import { Controller, useForm } from "react-hook-form";
// headless ui // headless ui
import { Disclosure, Menu, Tab, Transition } from "@headlessui/react"; import { Disclosure, Menu, Tab, Transition } from "@headlessui/react";
// services // services
import issuesServices from "lib/services/issues.services"; import issuesServices from "lib/services/issues.service";
import stateServices from "lib/services/state.services";
// fetch keys // fetch keys
import { import {
PROJECT_ISSUES_ACTIVITY, PROJECT_ISSUES_ACTIVITY,
@ -22,8 +21,10 @@ import {
} from "constants/fetch-keys"; } from "constants/fetch-keys";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts // layouts
import AdminLayout from "layouts/AdminLayout"; import AppLayout from "layouts/AppLayout";
// components // components
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
import IssueCommentSection from "components/project/issues/issue-detail/comment/IssueCommentSection"; import IssueCommentSection from "components/project/issues/issue-detail/comment/IssueCommentSection";
@ -38,7 +39,7 @@ import { Spinner, TextArea } from "ui";
import HeaderButton from "ui/HeaderButton"; import HeaderButton from "ui/HeaderButton";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
// types // types
import { IIssue, IIssueComment, IssueResponse, IState } from "types"; import { IIssue, IIssueComment, IssueResponse } from "types";
// icons // icons
import { import {
ChevronLeftIcon, ChevronLeftIcon,
@ -208,7 +209,7 @@ const IssueDetail: NextPage = () => {
}; };
return ( return (
<AdminLayout> <AppLayout noPadding={true} bg="secondary">
<CreateUpdateIssuesModal <CreateUpdateIssuesModal
isOpen={isOpen} isOpen={isOpen}
setIsOpen={setIsOpen} setIsOpen={setIsOpen}
@ -222,46 +223,23 @@ const IssueDetail: NextPage = () => {
setIsOpen={setIsAddAsSubIssueOpen} setIsOpen={setIsAddAsSubIssueOpen}
parent={issueDetail} parent={issueDetail}
/> />
<div className="flex items-center justify-between w-full 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 className="flex items-center gap-x-3">
<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>
{issueDetail && activeProject ? ( {issueDetail && activeProject ? (
<div className="grid grid-cols-4 gap-5"> <div className="flex gap-5">
<div className="col-span-3 space-y-5"> <div className="basis-3/4 space-y-5 p-5">
<div className="bg-secondary rounded-lg p-4"> <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="rounded-lg">
{issueDetail.parent !== null && issueDetail.parent !== "" ? ( {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"> <div className="bg-gray-100 flex items-center gap-2 p-2 text-xs rounded mb-5 w-min whitespace-nowrap">
<Link href={`/projects/${activeProject.id}/issues/${issueDetail.parent}`}> <Link href={`/projects/${activeProject.id}/issues/${issueDetail.parent}`}>
@ -273,7 +251,8 @@ const IssueDetail: NextPage = () => {
}} }}
/> />
<span className="flex-shrink-0 text-gray-600"> <span className="flex-shrink-0 text-gray-600">
{activeProject.identifier}-{issueDetail.sequence_id} {activeProject.identifier}-
{issues?.results.find((i) => i.id === issueDetail.parent)?.sequence_id}
</span> </span>
<span className="font-medium truncate"> <span className="font-medium truncate">
{issues?.results {issues?.results
@ -365,7 +344,7 @@ const IssueDetail: NextPage = () => {
</div> </div>
<div className="mt-2"> <div className="mt-2">
{subIssues && subIssues.length > 0 ? ( {subIssues && subIssues.length > 0 ? (
<Disclosure> <Disclosure defaultOpen={true}>
{({ open }) => ( {({ open }) => (
<> <>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@ -437,7 +416,7 @@ const IssueDetail: NextPage = () => {
{subIssues.map((subIssue) => ( {subIssues.map((subIssue) => (
<div <div
key={subIssue.id} key={subIssue.id}
className="flex justify-between items-center gap-2 p-2 hover:bg-gray-100" className="group flex justify-between items-center gap-2 rounded p-2 hover:bg-gray-100"
> >
<Link href={`/projects/${activeProject.id}/issues/${subIssue.id}`}> <Link href={`/projects/${activeProject.id}/issues/${subIssue.id}`}>
<a className="flex items-center gap-2 rounded text-xs"> <a className="flex items-center gap-2 rounded text-xs">
@ -455,9 +434,9 @@ const IssueDetail: NextPage = () => {
</span> </span>
</a> </a>
</Link> </Link>
<div> <div className="opacity-0 group-hover:opacity-100">
<Menu as="div" className="relative inline-block"> <Menu as="div" className="relative inline-block">
<Menu.Button className="grid relative place-items-center focus:outline-none"> <Menu.Button className="grid relative place-items-center hover:bg-gray-200 p-1 focus:outline-none">
<EllipsisHorizontalIcon className="h-4 w-4" /> <EllipsisHorizontalIcon className="h-4 w-4" />
</Menu.Button> </Menu.Button>
@ -553,7 +532,7 @@ const IssueDetail: NextPage = () => {
)} )}
</div> </div>
</div> </div>
<div className="bg-secondary rounded-lg p-4 space-y-5"> <div className="bg-secondary rounded-lg space-y-5">
<Tab.Group> <Tab.Group>
<Tab.List className="flex gap-x-3"> <Tab.List className="flex gap-x-3">
{["Comments", "Activity"].map((item) => ( {["Comments", "Activity"].map((item) => (
@ -589,11 +568,34 @@ const IssueDetail: NextPage = () => {
</Tab.Group> </Tab.Group>
</div> </div>
</div> </div>
<div className="sticky top-0 h-min bg-secondary p-4 rounded-lg"> <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>
<IssueDetailSidebar <IssueDetailSidebar
control={control} control={control}
issueDetail={issueDetail} issueDetail={issueDetail}
submitChanges={submitChanges} submitChanges={submitChanges}
watch={watch}
/> />
</div> </div>
</div> </div>
@ -602,8 +604,8 @@ const IssueDetail: NextPage = () => {
<Spinner /> <Spinner />
</div> </div>
)} )}
</AdminLayout> </AppLayout>
); );
}; };
export default IssueDetail; export default withAuth(IssueDetail);

View File

@ -18,7 +18,7 @@ import projectService from "lib/services/project.service";
// commons // commons
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common"; import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
// layouts // layouts
import AdminLayout from "layouts/AdminLayout"; import AppLayout from "layouts/AppLayout";
// hooks // hooks
import useIssuesFilter from "lib/hooks/useIssuesFilter"; import useIssuesFilter from "lib/hooks/useIssuesFilter";
// components // components
@ -34,7 +34,7 @@ import HeaderButton from "ui/HeaderButton";
import { ChevronDownIcon, ListBulletIcon, RectangleStackIcon } from "@heroicons/react/24/outline"; import { ChevronDownIcon, ListBulletIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
import { PlusIcon, Squares2X2Icon } from "@heroicons/react/20/solid"; import { PlusIcon, Squares2X2Icon } from "@heroicons/react/20/solid";
// types // types
import type { IIssue, Properties, NestedKeyOf, ProjectMember } from "types"; import type { IIssue, Properties, NestedKeyOf } from "types";
const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [ const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> = [
{ name: "State", key: "state_detail.name" }, { name: "State", key: "state_detail.name" },
@ -44,8 +44,8 @@ const groupByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> | null }> =
]; ];
const orderByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> }> = [ const orderByOptions: Array<{ name: string; key: NestedKeyOf<IIssue> }> = [
{ name: "Created", key: "created_at" }, { name: "Last created", key: "created_at" },
{ name: "Update", key: "updated_at" }, { name: "Last updated", key: "updated_at" },
{ name: "Priority", key: "priority" }, { name: "Priority", key: "priority" },
]; ];
@ -86,11 +86,19 @@ const ProjectIssues: NextPage = () => {
projectId as string projectId as string
); );
const { data: members } = useSWR<ProjectMember[]>( const { data: members } = useSWR(
activeWorkspace && activeProject ? PROJECT_MEMBERS : null, activeWorkspace && activeProject
? PROJECT_MEMBERS(activeWorkspace.slug, activeProject.id)
: null,
activeWorkspace && activeProject activeWorkspace && activeProject
? () => projectService.projectMembers(activeWorkspace.slug, activeProject.id) ? () => projectService.projectMembers(activeWorkspace.slug, activeProject.id)
: null : null,
{
onErrorRetry(err, _, __, revalidate, revalidateOpts) {
if (err?.status === 403) return;
setTimeout(() => revalidate(revalidateOpts), 5000);
},
}
); );
const { const {
@ -115,7 +123,7 @@ const ProjectIssues: NextPage = () => {
}, [isOpen]); }, [isOpen]);
return ( return (
<AdminLayout> <AppLayout>
<CreateUpdateIssuesModal <CreateUpdateIssuesModal
isOpen={isOpen && selectedIssue?.actionType !== "delete"} isOpen={isOpen && selectedIssue?.actionType !== "delete"}
setIsOpen={setIsOpen} setIsOpen={setIsOpen}
@ -173,7 +181,7 @@ const ProjectIssues: NextPage = () => {
<Popover.Button <Popover.Button
className={classNames( className={classNames(
open ? "text-gray-900" : "text-gray-500", open ? "text-gray-900" : "text-gray-500",
"group inline-flex items-center rounded-md bg-transparent text-base font-medium hover:text-gray-900 focus:outline-none border border-gray-300 px-3 py-1" "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> <span>View</span>
@ -195,85 +203,83 @@ const ProjectIssues: NextPage = () => {
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<Popover.Panel className="absolute mr-5 right-1/2 z-10 mt-3 w-screen max-w-xs translate-x-1/2 transform px-2 sm:px-0 bg-gray-0 backdrop-filter backdrop-blur-xl bg-opacity-100 rounded-lg shadow-lg overflow-hidden"> <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="overflow-hidden py-8 px-4"> <div className="relative flex flex-col gap-1 gap-y-4">
<div className="relative flex flex-col gap-1 gap-y-4"> <div className="flex justify-between">
<div className="flex justify-between"> <h4 className="text-base text-gray-600">Group by</h4>
<h4 className="text-base text-gray-600">Group by</h4> <CustomMenu
<CustomMenu label={
label={ groupByOptions.find((option) => option.key === groupByProperty)
groupByOptions.find((option) => option.key === groupByProperty) ?.name ?? "Select"
?.name ?? "Select" }
} >
> {groupByOptions.map((option) => (
{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 <CustomMenu.MenuItem
key={option.key} key={option.key}
onClick={() => setGroupByProperty(option.key)} onClick={() => setOrderBy(option.key)}
> >
{option.name} {option.name}
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
))} )
</CustomMenu> )}
</div> </CustomMenu>
<div className="flex justify-between"> </div>
<h4 className="text-base text-gray-600">Order by</h4> <div className="flex justify-between">
<CustomMenu <h4 className="text-base text-gray-600">Issue type</h4>
label={ <CustomMenu
orderByOptions.find((option) => option.key === orderBy)?.name ?? label={
"Select" filterIssueOptions.find((option) => option.key === filterIssue)
} ?.name ?? "Select"
> }
{orderByOptions.map((option) => >
groupByProperty === "priority" && {filterIssueOptions.map((option) => (
option.key === "priority" ? null : ( <CustomMenu.MenuItem
<CustomMenu.MenuItem key={option.key}
key={option.key} onClick={() => setFilterIssue(option.key)}
onClick={() => setOrderBy(option.key)} >
> {option.name}
{option.name} </CustomMenu.MenuItem>
</CustomMenu.MenuItem> ))}
) </CustomMenu>
)} </div>
</CustomMenu> <div className="border-b-2"></div>
</div> <div className="relative flex flex-col gap-1">
<div className="flex justify-between"> <h4 className="text-base text-gray-600">Properties</h4>
<h4 className="text-base text-gray-600">Issue type</h4> <div>
<CustomMenu {Object.keys(properties).map((key) => (
label={ <button
filterIssueOptions.find((option) => option.key === filterIssue) key={key}
?.name ?? "Select" type="button"
} className={`px-2 py-1 inline capitalize rounded border border-indigo-600 text-sm m-1 ${
> properties[key as keyof Properties]
{filterIssueOptions.map((option) => ( ? "border-indigo-600 bg-indigo-600 text-white"
<CustomMenu.MenuItem : ""
key={option.key} }`}
onClick={() => setFilterIssue(option.key)} onClick={() => setProperties(key as keyof Properties)}
> >
{option.name} {replaceUnderscoreIfSnakeCase(key)}
</CustomMenu.MenuItem> </button>
))} ))}
</CustomMenu>
</div>
<div className="border-b-2"></div>
<div className="relative flex flex-col gap-1">
<h4 className="text-base text-gray-600">Properties</h4>
<div>
{Object.keys(properties).map((key) => (
<button
key={key}
type="button"
className={`px-2 py-1 inline capitalize rounded border border-indigo-600 text-sm m-1 ${
properties[key as keyof Properties]
? "border-indigo-600 bg-indigo-600 text-white"
: ""
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{replaceUnderscoreIfSnakeCase(key)}
</button>
))}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -336,7 +342,7 @@ const ProjectIssues: NextPage = () => {
</EmptySpace> </EmptySpace>
</div> </div>
)} )}
</AdminLayout> </AppLayout>
); );
}; };

View File

@ -14,8 +14,10 @@ import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast"; import useToast from "lib/hooks/useToast";
// fetching keys // fetching keys
import { PROJECT_MEMBERS, PROJECT_INVITATIONS } from "constants/fetch-keys"; import { PROJECT_MEMBERS, PROJECT_INVITATIONS } from "constants/fetch-keys";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts // layouts
import AdminLayout from "layouts/AdminLayout"; import AppLayout from "layouts/AppLayout";
// components // components
import SendProjectInvitationModal from "components/project/SendProjectInvitationModal"; import SendProjectInvitationModal from "components/project/SendProjectInvitationModal";
import ConfirmProjectMemberRemove from "components/project/ConfirmProjectMemberRemove"; import ConfirmProjectMemberRemove from "components/project/ConfirmProjectMemberRemove";
@ -53,7 +55,13 @@ const ProjectMembers: NextPage = () => {
activeWorkspace && projectId ? PROJECT_MEMBERS(projectId as string) : null, activeWorkspace && projectId ? PROJECT_MEMBERS(projectId as string) : null,
activeWorkspace && projectId activeWorkspace && projectId
? () => projectService.projectMembers(activeWorkspace.slug, projectId as any) ? () => projectService.projectMembers(activeWorkspace.slug, projectId as any)
: null : null,
{
onErrorRetry(err, _, __, revalidate, revalidateOpts) {
if (err?.status === 403) return;
setTimeout(() => revalidate(revalidateOpts), 5000);
},
}
); );
const { data: projectInvitations, mutate: mutateInvitations } = useSWR( const { data: projectInvitations, mutate: mutateInvitations } = useSWR(
activeWorkspace && projectId ? PROJECT_INVITATIONS : null, activeWorkspace && projectId ? PROJECT_INVITATIONS : null,
@ -84,7 +92,7 @@ const ProjectMembers: NextPage = () => {
]; ];
return ( return (
<AdminLayout> <AppLayout>
<ConfirmProjectMemberRemove <ConfirmProjectMemberRemove
isOpen={Boolean(selectedRemoveMember) || Boolean(selectedInviteRemoveMember)} isOpen={Boolean(selectedRemoveMember) || Boolean(selectedInviteRemoveMember)}
onClose={() => { onClose={() => {
@ -103,8 +111,7 @@ const ProjectMembers: NextPage = () => {
selectedRemoveMember selectedRemoveMember
); );
mutateMembers( mutateMembers(
(prevData: any[]) => (prevData) => prevData?.filter((item: any) => item.id !== selectedRemoveMember),
prevData?.filter((item: any) => item.id !== selectedRemoveMember),
false false
); );
} }
@ -115,8 +122,7 @@ const ProjectMembers: NextPage = () => {
selectedInviteRemoveMember selectedInviteRemoveMember
); );
mutateInvitations( mutateInvitations(
(prevData: any[]) => (prevData) => prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
false false
); );
} }
@ -235,7 +241,7 @@ const ProjectMembers: NextPage = () => {
)} )}
</td> </td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 sm:pl-6"> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 sm:pl-6">
{member.status ? ( {member.member ? (
<span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full"> <span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full">
Active Active
</span> </span>
@ -261,7 +267,7 @@ const ProjectMembers: NextPage = () => {
className="w-full text-left py-2 pl-2" className="w-full text-left py-2 pl-2"
type="button" type="button"
onClick={() => { onClick={() => {
if (!member.status) { if (!member.member) {
setToastAlert({ setToastAlert({
type: "error", type: "error",
message: "You can't edit a pending invitation.", message: "You can't edit a pending invitation.",
@ -282,7 +288,7 @@ const ProjectMembers: NextPage = () => {
className="w-full text-left py-2 pl-2" className="w-full text-left py-2 pl-2"
type="button" type="button"
onClick={() => { onClick={() => {
if (member.status) { if (member.member) {
setSelectedRemoveMember(member.id); setSelectedRemoveMember(member.id);
} else { } else {
setSelectedInviteRemoveMember(member.id); setSelectedInviteRemoveMember(member.id);
@ -303,8 +309,8 @@ const ProjectMembers: NextPage = () => {
)} )}
</div> </div>
)} )}
</AdminLayout> </AppLayout>
); );
}; };
export default ProjectMembers; export default withAuth(ProjectMembers);

View File

@ -1,52 +1,60 @@
import React, { useEffect, useCallback, useState } from "react"; import React, { useEffect, useState } from "react";
// next // next
import type { NextPage } from "next"; import type { NextPage } from "next";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic";
// swr // swr
import { mutate } from "swr"; import useSWR, { mutate } from "swr";
// swr
import useSWR from "swr";
// react hook form // react hook form
import { useForm, Controller } from "react-hook-form"; import { useForm } from "react-hook-form";
// headless ui // headless ui
import { Listbox, Tab, Transition } from "@headlessui/react"; import { Tab } from "@headlessui/react";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts // layouts
import AdminLayout from "layouts/AdminLayout"; import AppLayout from "layouts/AppLayout";
// service // service
import projectServices from "lib/services/project.service"; import projectServices from "lib/services/project.service";
import workspaceService from "lib/services/workspace.service";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast"; import useToast from "lib/hooks/useToast";
// fetch keys // fetch keys
import { PROJECT_DETAILS, PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { PROJECT_DETAILS, PROJECTS_LIST } from "constants/fetch-keys";
// commons
import { addSpaceIfCamelCase, debounce } from "constants/common";
// components
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
// ui // ui
import { Spinner, Button, Input, TextArea, Select } from "ui"; import { Spinner } from "ui";
import { Breadcrumbs, BreadcrumbItem } from "ui/Breadcrumbs"; import { Breadcrumbs, BreadcrumbItem } from "ui/Breadcrumbs";
// icons
import {
ChevronDownIcon,
CheckIcon,
PlusIcon,
PencilSquareIcon,
RectangleGroupIcon,
PencilIcon,
} from "@heroicons/react/24/outline";
// types // types
import type { IProject, IWorkspace, WorkspaceMember } from "types"; import type { IProject, IWorkspace } from "types";
const defaultValues: Partial<IProject> = { const defaultValues: Partial<IProject> = {
name: "", name: "",
description: "", description: "",
identifier: "",
network: 0,
}; };
const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
const ProjectSettings: NextPage = () => { const ProjectSettings: NextPage = () => {
// FIXME: instead of using dynamic import inside component use it outside
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 { const {
register, register,
handleSubmit, handleSubmit,
@ -58,30 +66,21 @@ const ProjectSettings: NextPage = () => {
defaultValues, defaultValues,
}); });
const [isCreateStateModalOpen, setIsCreateStateModalOpen] = useState(false);
const [selectedState, setSelectedState] = useState<string | undefined>();
const [newGroupForm, setNewGroupForm] = useState(false);
const router = useRouter(); const router = useRouter();
const { projectId } = router.query; const { projectId } = router.query;
const { activeWorkspace, activeProject, states } = useUser(); const { activeWorkspace, activeProject } = useUser();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { data: projectDetails } = useSWR<IProject>( const { data: projectDetails } = useSWR<IProject>(
activeWorkspace && projectId ? PROJECT_DETAILS : null, activeWorkspace && projectId ? PROJECT_DETAILS(projectId as string) : null,
activeWorkspace activeWorkspace
? () => projectServices.getProject(activeWorkspace.slug, projectId as string) ? () => projectServices.getProject(activeWorkspace.slug, projectId as string)
: null : null
); );
const { data: people } = useSWR<WorkspaceMember[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
);
useEffect(() => { useEffect(() => {
projectDetails && projectDetails &&
reset({ reset({
@ -93,7 +92,7 @@ const ProjectSettings: NextPage = () => {
}, [projectDetails, reset]); }, [projectDetails, reset]);
const onSubmit = async (formData: IProject) => { const onSubmit = async (formData: IProject) => {
if (!activeWorkspace) return; if (!activeWorkspace || !projectId) return;
const payload: Partial<IProject> = { const payload: Partial<IProject> = {
name: formData.name, name: formData.name,
network: formData.network, network: formData.network,
@ -105,7 +104,11 @@ const ProjectSettings: NextPage = () => {
await projectServices await projectServices
.updateProject(activeWorkspace.slug, projectId as string, payload) .updateProject(activeWorkspace.slug, projectId as string, payload)
.then((res) => { .then((res) => {
mutate<IProject>(PROJECT_DETAILS, (prevData) => ({ ...prevData, ...res }), false); mutate<IProject>(
PROJECT_DETAILS(projectId as string),
(prevData) => ({ ...prevData, ...res }),
false
);
mutate<IProject[]>( mutate<IProject[]>(
PROJECTS_LIST(activeWorkspace.slug), PROJECTS_LIST(activeWorkspace.slug),
(prevData) => { (prevData) => {
@ -130,28 +133,9 @@ const ProjectSettings: NextPage = () => {
}); });
}; };
const checkIdentifier = (slug: string, value: string) => {
projectServices.checkProjectIdentifierAvailability(slug, value).then((response) => {
console.log(response);
if (response.exists) setError("identifier", { message: "Identifier already exists" });
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const checkIdentifierAvailability = useCallback(debounce(checkIdentifier, 1500), []);
return ( return (
<AdminLayout> <AppLayout>
<div className="space-y-5 mb-5"> <div className="space-y-5 mb-5">
<CreateUpdateStateModal
isOpen={isCreateStateModalOpen || Boolean(selectedState)}
handleClose={() => {
setSelectedState(undefined);
setIsCreateStateModalOpen(false);
}}
projectId={projectId as string}
data={selectedState ? states?.find((state) => state.id === selectedState) : undefined}
/>
<Breadcrumbs> <Breadcrumbs>
<BreadcrumbItem title="Projects" link="/projects" /> <BreadcrumbItem title="Projects" link="/projects" />
<BreadcrumbItem title={`${activeProject?.name ?? "Project"} Settings`} /> <BreadcrumbItem title={`${activeProject?.name ?? "Project"} Settings`} />
@ -159,386 +143,51 @@ const ProjectSettings: NextPage = () => {
</div> </div>
{projectDetails ? ( {projectDetails ? (
<div className="space-y-3"> <div className="space-y-3">
<form onSubmit={handleSubmit(onSubmit)} className="mt-3"> <Tab.Group>
<Tab.Group> <Tab.List className="flex items-center gap-x-4 gap-y-2 flex-wrap mb-8">
<Tab.List className="flex items-center gap-x-4 gap-y-2 flex-wrap"> {["General", "Control", "States", "Labels"].map((tab, index) => (
{["General", "Control", "States", "Labels"].map((tab, index) => ( <Tab
<Tab key={index}
key={index} className={({ selected }) =>
className={({ selected }) => `px-6 py-2 border-2 border-theme hover:bg-theme hover:text-white text-xs font-medium rounded-md outline-none duration-300 ${
`px-6 py-2 border-2 border-theme hover:bg-theme hover:text-white text-xs font-medium rounded-md duration-300 ${ selected ? "bg-theme text-white" : ""
selected ? "bg-theme text-white" : "" }`
}` }
} >
> {tab}
{tab} </Tab>
</Tab> ))}
))} </Tab.List>
</Tab.List> <Tab.Panels>
<Tab.Panels className="mt-8"> <form onSubmit={handleSubmit(onSubmit)}>
<Tab.Panel> <Tab.Panel>
<section className="space-y-5"> <GeneralSettings
<div> register={register}
<h3 className="text-lg font-medium leading-6 text-gray-900">General</h3> errors={errors}
<p className="mt-1 text-sm text-gray-500"> setError={setError}
This information will be displayed to every member of the project. isSubmitting={isSubmitting}
</p> />
</div>
<div className="grid grid-cols-4 gap-3">
<div className="col-span-2">
<Input
id="name"
name="name"
error={errors.name}
register={register}
placeholder="Project Name"
label="Name"
validations={{
required: "Name is required",
}}
/>
</div>
<div>
<Select
name="network"
id="network"
options={Object.keys(NETWORK_CHOICES).map((key) => ({
value: key,
label: NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES],
}))}
label="Network"
register={register}
validations={{
required: "Network is required",
}}
/>
</div>
<div>
<Input
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Enter identifier"
label="Identifier"
onChange={(e: any) => {
if (!activeWorkspace || !e.target.value) return;
checkIdentifierAvailability(activeWorkspace.slug, e.target.value);
}}
validations={{
required: "Identifier is required",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 9,
message: "Identifier must at most be of 9 characters",
},
}}
/>
</div>
</div>
<div>
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
label="Description"
placeholder="Enter project description"
validations={{
required: "Description is required",
}}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</Button>
</div>
</section>
</Tab.Panel> </Tab.Panel>
<Tab.Panel> <Tab.Panel>
<section className="space-y-5"> <ControlSettings control={control} isSubmitting={isSubmitting} />
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">Control</h3>
<p className="mt-1 text-sm text-gray-500">Set the control for the project.</p>
</div>
<div className="flex justify-between gap-3">
<div className="w-full md:w-1/2">
<Controller
control={control}
name="project_lead"
render={({ field: { onChange, value } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<Listbox.Label>
<div className="text-gray-500 mb-2">Project Lead</div>
</Listbox.Label>
<div className="relative">
<Listbox.Button className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">
{people?.find((person) => person.member.id === value)
?.member.first_name ?? "Select Lead"}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-default select-none relative py-2 pl-3 pr-9`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.first_name}
</span>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-indigo-600"
}`}
>
<CheckIcon
className="h-5 w-5"
aria-hidden="true"
/>
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
<div className="w-full md:w-1/2">
<Controller
control={control}
name="default_assignee"
render={({ field: { value, onChange } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<Listbox.Label>
<div className="text-gray-500 mb-2">Default Assignee</div>
</Listbox.Label>
<div className="relative">
<Listbox.Button className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">
{people?.find((p) => p.member.id === value)?.member
.first_name ?? "Select Default Assignee"}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`${
active ? "text-white bg-theme" : "text-gray-900"
} cursor-default select-none relative py-2 pl-3 pr-9`
}
value={person.member.id}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.first_name}
</span>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-indigo-600"
}`}
>
<CheckIcon
className="h-5 w-5"
aria-hidden="true"
/>
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</Button>
</div>
</section>
</Tab.Panel> </Tab.Panel>
<Tab.Panel> </form>
<section className="space-y-5"> <Tab.Panel>
<div> <StatesSettings projectId={projectId} />
<h3 className="text-lg font-medium leading-6 text-gray-900">State</h3> </Tab.Panel>
<p className="mt-1 text-sm text-gray-500"> <Tab.Panel>
Manage the state of this project. <LabelsSettings />
</p> </Tab.Panel>
</div> </Tab.Panels>
<div className="flex justify-between gap-3"> </Tab.Group>
<div className="w-full space-y-5">
{states?.map((state) => (
<div
key={state.id}
className="bg-white px-4 py-2 rounded flex justify-between items-center"
>
<div className="flex items-center gap-x-2">
<div
className="w-3 h-3 rounded-full"
style={{
backgroundColor: state.color,
}}
></div>
<h4>{addSpaceIfCamelCase(state.name)}</h4>
</div>
<div>
<button type="button" onClick={() => setSelectedState(state.id)}>
<PencilSquareIcon className="h-5 w-5 text-gray-400" />
</button>
</div>
</div>
))}
<Button
type="button"
className="flex items-center gap-x-1"
onClick={() => setIsCreateStateModalOpen(true)}
>
<PlusIcon className="h-4 w-4" />
<span>Add State</span>
</Button>
</div>
</div>
</section>
</Tab.Panel>
<Tab.Panel>
<section className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">Labels</h3>
<p className="mt-1 text-sm text-gray-500">
Manage the labels of this project.
</p>
</div>
<Button
className="flex items-center gap-x-1"
onClick={() => setNewGroupForm(true)}
>
<PlusIcon className="h-4 w-4" />
New group
</Button>
</div>
<div className="space-y-5">
<div
className={`bg-white px-4 py-2 flex items-center gap-2 ${
newGroupForm ? "" : "hidden"
}`}
>
<Input type="text" name="groupName" />
<Button
type="button"
theme="secondary"
onClick={() => setNewGroupForm(false)}
>
Cancel
</Button>
<Button type="button">Save</Button>
</div>
{["", ""].map((group, index) => (
<div key={index} className="bg-white p-4 text-gray-900 rounded-md">
<h3 className="font-medium leading-5 flex items-center gap-2">
<RectangleGroupIcon className="h-5 w-5" />
This is the label group title
</h3>
<div className="pl-5 mt-4">
<div className="group text-sm flex justify-between items-center p-2 hover:bg-gray-100 rounded">
<h5 className="flex items-center gap-2">
<div className="w-2 h-2 bg-red-600 rounded-full"></div>
This is the label title
</h5>
<div className="hidden group-hover:block">
<PencilIcon className="h-3 w-3" />
</div>
</div>
</div>
</div>
))}
</div>
</section>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</form>
</div> </div>
) : ( ) : (
<div className="h-full w-full flex justify-center items-center"> <div className="h-full w-full flex justify-center items-center">
<Spinner /> <Spinner />
</div> </div>
)} )}
</AdminLayout> </AppLayout>
); );
}; };
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 // next
import type { NextPage } from "next"; import type { NextPage } from "next";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
// hoc
import withAuth from "lib/hoc/withAuthWrapper";
// layouts // layouts
import AdminLayout from "layouts/AdminLayout"; import AppLayout from "layouts/AppLayout";
// components // components
import CreateProjectModal from "components/project/CreateProjectModal"; import ProjectMemberInvitations from "components/project/memberInvitations";
import ConfirmProjectDeletion from "components/project/ConfirmProjectDeletion"; import ConfirmProjectDeletion from "components/project/confirm-project-deletion";
// ui // ui
import { Button, Spinner } from "ui"; import {
// types Button,
import { IProject } from "types"; Spinner,
HeaderButton,
Breadcrumbs,
BreadcrumbItem,
EmptySpace,
EmptySpaceItem,
} from "ui";
// services // services
import projectService from "lib/services/project.service"; import projectService from "lib/services/project.service";
import ProjectMemberInvitations from "components/project/memberInvitations"; // icons
import { ClipboardDocumentListIcon, PlusIcon } from "@heroicons/react/24/outline"; 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 Projects: NextPage = () => {
const [isOpen, setIsOpen] = useState(false); const [deleteProject, setDeleteProject] = useState<string | null>(null);
const [deleteProject, setDeleteProject] = useState<IProject | undefined>();
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]); const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
const { projects, activeWorkspace, mutateProjects } = useUser(); 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 ( return (
<AdminLayout> <AppLayout>
<CreateProjectModal isOpen={isOpen && !deleteProject} setIsOpen={setIsOpen} />
<ConfirmProjectDeletion <ConfirmProjectDeletion
isOpen={isOpen && !!deleteProject} isOpen={!!deleteProject}
setIsOpen={setIsOpen} onClose={() => setDeleteProject(null)}
data={deleteProject} data={projects?.find((item) => item.id === deleteProject) ?? null}
/> />
{projects ? ( {projects ? (
<> <>
@ -87,7 +82,10 @@ const Projects: NextPage = () => {
</span> </span>
} }
Icon={PlusIcon} Icon={PlusIcon}
action={() => setIsOpen(true)} action={() => {
const e = new KeyboardEvent("keydown", { key: "p", ctrlKey: true });
document.dispatchEvent(e);
}}
/> />
</EmptySpace> </EmptySpace>
</div> </div>
@ -98,7 +96,14 @@ const Projects: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
<div className="flex items-center justify-between cursor-pointer w-full"> <div className="flex items-center justify-between cursor-pointer w-full">
<h2 className="text-2xl font-medium">Projects</h2> <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>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((item) => ( {projects.map((item) => (
@ -125,8 +130,8 @@ const Projects: NextPage = () => {
<Spinner /> <Spinner />
</div> </div>
)} )}
</AdminLayout> </AppLayout>
); );
}; };
export default Projects; export default withAuth(Projects);

View File

@ -43,8 +43,14 @@ const SignIn: NextPage = () => {
async (res: any) => { async (res: any) => {
await mutateUser(); await mutateUser();
await mutateWorkspaces(); await mutateWorkspaces();
if (res.user.is_onboarded) router.push("/"); const nextLocation = router.asPath.split("?next=")[1];
else router.push("/invitations");
if (nextLocation) {
router.push(nextLocation as string);
} else {
if (res.user.is_onboarded) router.push("/");
else router.push("/invitations");
}
}, },
[mutateUser, mutateWorkspaces, router] [mutateUser, mutateWorkspaces, router]
); );

View File

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

View File

@ -4,7 +4,7 @@ import Link from "next/link";
// react // react
import React from "react"; import React from "react";
// layouts // layouts
import AdminLayout from "layouts/AdminLayout"; import AppLayout from "layouts/AppLayout";
// swr // swr
import useSWR from "swr"; import useSWR from "swr";
// hooks // hooks
@ -26,8 +26,8 @@ const Workspace: NextPage = () => {
const { user, activeWorkspace, projects } = useUser(); const { user, activeWorkspace, projects } = useUser();
const { data: myIssues } = useSWR<IIssue[]>( const { data: myIssues } = useSWR<IIssue[]>(
user ? USER_ISSUE : null, user && activeWorkspace ? USER_ISSUE(activeWorkspace.slug) : null,
user ? () => userService.userIssues() : null user && activeWorkspace ? () => userService.userIssues(activeWorkspace.slug) : null
); );
const cards = [ const cards = [
@ -46,7 +46,7 @@ const Workspace: NextPage = () => {
const hours = new Date().getHours(); const hours = new Date().getHours();
return ( return (
<AdminLayout> <AppLayout>
<div className="h-full w-full space-y-5"> <div className="h-full w-full space-y-5">
{user ? ( {user ? (
<div className="font-medium text-2xl"> <div className="font-medium text-2xl">
@ -167,7 +167,7 @@ const Workspace: NextPage = () => {
</div> </div>
</div> </div>
</div> </div>
</AdminLayout> </AppLayout>
); );
}; };

View File

@ -17,7 +17,7 @@ import { WORKSPACE_INVITATIONS, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// hoc // hoc
import withAuthWrapper from "lib/hoc/withAuthWrapper"; import withAuthWrapper from "lib/hoc/withAuthWrapper";
// layouts // layouts
import AdminLayout from "layouts/AdminLayout"; import AppLayout from "layouts/AppLayout";
// components // components
import SendWorkspaceInvitationModal from "components/workspace/SendWorkspaceInvitationModal"; import SendWorkspaceInvitationModal from "components/workspace/SendWorkspaceInvitationModal";
import ConfirmWorkspaceMemberRemove from "components/workspace/ConfirmWorkspaceMemberRemove"; import ConfirmWorkspaceMemberRemove from "components/workspace/ConfirmWorkspaceMemberRemove";
@ -41,7 +41,7 @@ const WorkspaceInvite: NextPage = () => {
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { data: workspaceMembers, mutate: mutateMembers } = useSWR<any[]>( const { data: workspaceMembers, mutate: mutateMembers } = useSWR<any[]>(
activeWorkspace ? WORKSPACE_MEMBERS : null, activeWorkspace ? WORKSPACE_MEMBERS(activeWorkspace.slug) : null,
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
); );
const { data: workspaceInvitations, mutate: mutateInvitations } = useSWR<any[]>( const { data: workspaceInvitations, mutate: mutateInvitations } = useSWR<any[]>(
@ -71,7 +71,7 @@ const WorkspaceInvite: NextPage = () => {
]; ];
return ( return (
<AdminLayout <AppLayout
meta={{ meta={{
title: "Plane - Workspace Invite", title: "Plane - Workspace Invite",
}} }}
@ -229,7 +229,7 @@ const WorkspaceInvite: NextPage = () => {
)} )}
</td> </td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 sm:pl-6"> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500 sm:pl-6">
{member.status ? ( {member.member ? (
<span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full"> <span className="p-0.5 px-2 text-sm bg-green-700 text-white rounded-full">
Active Active
</span> </span>
@ -277,7 +277,7 @@ const WorkspaceInvite: NextPage = () => {
className="w-full text-left py-2 pl-2" className="w-full text-left py-2 pl-2"
type="button" type="button"
onClick={() => { onClick={() => {
if (member.status) { if (member.member) {
setSelectedRemoveMember(member.id); setSelectedRemoveMember(member.id);
} else { } else {
setSelectedInviteRemoveMember(member.id); setSelectedInviteRemoveMember(member.id);
@ -299,8 +299,8 @@ const WorkspaceInvite: NextPage = () => {
)} )}
</div> </div>
)} )}
</AdminLayout> </AppLayout>
); );
}; };
export default withAuthWrapper(WorkspaceInvite); export default withAuthWrapper(WorkspaceInvite);

View File

@ -3,25 +3,28 @@ import React, { useEffect, useState } from "react";
import Image from "next/image"; import Image from "next/image";
// react hook form // react hook form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// headless ui
import { Tab } from "@headlessui/react";
// react dropzone // react dropzone
import Dropzone from "react-dropzone"; import Dropzone from "react-dropzone";
// services // services
import workspaceService from "lib/services/workspace.service"; 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 // layouts
import AdminLayout from "layouts/AdminLayout"; import AppLayout from "layouts/AppLayout";
// hooks // hooks
import useUser from "lib/hooks/useUser"; import useUser from "lib/hooks/useUser";
import useToast from "lib/hooks/useToast"; import useToast from "lib/hooks/useToast";
// components // components
import ConfirmWorkspaceDeletion from "components/workspace/ConfirmWorkspaceDeletion"; import ConfirmWorkspaceDeletion from "components/workspace/confirm-workspace-deletion";
// ui // ui
import { Spinner, Button, Input, Select } from "ui"; import { Spinner, Button, Input, Select } from "ui";
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
// types // types
import type { IWorkspace } from "types"; import type { IWorkspace } from "types";
import { Tab } from "@headlessui/react";
const defaultValues: Partial<IWorkspace> = { const defaultValues: Partial<IWorkspace> = {
name: "", name: "",
@ -83,12 +86,18 @@ const WorkspaceSettings = () => {
}; };
return ( return (
<AdminLayout <AppLayout
meta={{ meta={{
title: "Plane - Workspace Settings", title: "Plane - Workspace Settings",
}} }}
> >
<ConfirmWorkspaceDeletion isOpen={isOpen} setIsOpen={setIsOpen} /> <ConfirmWorkspaceDeletion
isOpen={isOpen}
onClose={() => {
setIsOpen(false);
}}
data={activeWorkspace ?? null}
/>
<div className="space-y-5"> <div className="space-y-5">
<Breadcrumbs> <Breadcrumbs>
<BreadcrumbItem title={`${activeWorkspace?.name ?? "Workspace"} Settings`} /> <BreadcrumbItem title={`${activeWorkspace?.name ?? "Workspace"} Settings`} />
@ -228,8 +237,8 @@ const WorkspaceSettings = () => {
</div> </div>
)} )}
</div> </div>
</AdminLayout> </AppLayout>
); );
}; };
export default WorkspaceSettings; export default withAuth(WorkspaceSettings);

View File

@ -1,5 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800&display=swap"); @import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,0,0");
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;

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

@ -27,7 +27,7 @@ export interface IIssue {
name: string; name: string;
description: string; description: string;
priority: string | null; priority: string | null;
start_date: null; start_date: string | null;
target_date: string | null; target_date: string | null;
sequence_id: number; sequence_id: number;
attachments: any[]; attachments: any[];

View File

@ -1,4 +1,4 @@
import type { IWorkspace } from "./"; import type { IUserLite, IWorkspace } from "./";
export interface IProject { export interface IProject {
id: string; id: string;
@ -15,3 +15,44 @@ export interface IProject {
created_by: string; created_by: string;
updated_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

@ -16,3 +16,11 @@ export interface IUser {
token: string; token: string;
[...rest: string]: any; [...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 { export interface IWorkspace {
readonly id: string; readonly id: string;
@ -13,26 +13,31 @@ export interface IWorkspace {
company_size: number; company_size: number;
} }
export interface WorkspaceMember { export interface IWorkspaceMemberInvitation {
readonly id: string; readonly id: string;
email: string; email: string;
accepted: boolean;
token: string;
message: string; message: string;
responded_at: Date;
role: 5 | 10 | 15 | 20; role: 5 | 10 | 15 | 20;
member: IUser; workspace: IWorkspace;
workspace: IWorkspace | string; }
export interface IWorkspaceMember {
readonly id: string;
user: IUserLite;
workspace: IWorkspace;
member: IUserLite;
role: 5 | 10 | 15 | 20;
company_role: string | null;
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;
created_by: string; created_by: string;
updated_by: string; updated_by: string;
} }
export interface ProjectMember { export interface ILastActiveWorkspaceDetails {
readonly id: string; workspace_details: IWorkspace;
readonly project: string; project_details: IProject[];
email: string;
message: string;
role: 5 | 10 | 15 | 20;
member: any;
member_id: string;
user_id: string;
} }

View File

@ -9,12 +9,7 @@ type EmptySpaceProps = {
title: string; title: string;
description: string; description: string;
children: any; children: any;
Icon?: ( Icon?: (props: any) => JSX.Element;
props: React.SVGProps<SVGSVGElement> & {
title?: string | undefined;
titleId?: string | undefined;
}
) => JSX.Element;
link?: { text: string; href: string }; link?: { text: string; href: string };
}; };