Merge branch 'main' of https://github.com/dakshesh14/plane
This commit is contained in:
commit
f0aacd755c
40 changed files with 2092 additions and 832 deletions
208
apps/app/components/project/cycles/CycleIssuesListModal.tsx
Normal file
208
apps/app/components/project/cycles/CycleIssuesListModal.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
// react-hook-form
|
||||
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.services";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, IssueResponse } from "types";
|
||||
// constants
|
||||
import { classNames } from "constants/common";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
issues: IssueResponse | undefined;
|
||||
cycleId: string;
|
||||
};
|
||||
|
||||
type FormInput = {
|
||||
issue_ids: string[];
|
||||
};
|
||||
|
||||
const CycleIssuesListModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
handleClose: onClose,
|
||||
issues,
|
||||
cycleId,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { activeWorkspace, activeProject } = useUser();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setQuery("");
|
||||
reset();
|
||||
};
|
||||
|
||||
const { handleSubmit, reset, control } = useForm<FormInput>({
|
||||
defaultValues: {
|
||||
issue_ids: [],
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddToCycle: SubmitHandler<FormInput> = (data) => {
|
||||
if (!data.issue_ids || data.issue_ids.length === 0) {
|
||||
setToastAlert({
|
||||
title: "Error",
|
||||
type: "error",
|
||||
message: "Please select atleast one issue",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeWorkspace && activeProject) {
|
||||
issuesServices
|
||||
.bulkAddIssuesToCycle(activeWorkspace.slug, activeProject.id, cycleId, data)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
query === ""
|
||||
? issues?.results ?? []
|
||||
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
|
||||
[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
<Dialog as="div" className="relative z-10" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
|
||||
<form>
|
||||
<Controller
|
||||
control={control}
|
||||
name="issue_ids"
|
||||
render={({ field }) => (
|
||||
<Combobox as="div" {...field} multiple>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
|
||||
placeholder="Search..."
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Combobox.Options
|
||||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 && (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Select issues to add to cycle
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{filteredIssues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={issue.id}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex items-center gap-2 cursor-pointer select-none w-full rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<input type="checkbox" checked={selected} readOnly />
|
||||
<span
|
||||
className={`h-1.5 w-1.5 block rounded-full`}
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">
|
||||
{activeProject?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.name}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
|
||||
{query !== "" && filteredIssues.length === 0 && (
|
||||
<div className="py-14 px-6 text-center sm:px-14">
|
||||
<RectangleStackIcon
|
||||
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="mt-4 text-sm text-gray-900">
|
||||
We couldn{"'"}t find any issue with that term. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Combobox>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end items-center gap-2 p-3">
|
||||
<Button type="button" theme="danger" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleSubmit(handleAddToCycle)}>
|
||||
Add to Cycle
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CycleIssuesListModal;
|
||||
|
|
@ -1,258 +1,314 @@
|
|||
import React from "react";
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
import useSWR, { mutate } from "swr";
|
||||
// headless ui
|
||||
import { Disclosure, Transition, Menu, Listbox } from "@headlessui/react";
|
||||
// fetch keys
|
||||
import { PROJECT_ISSUES_LIST, CYCLE_ISSUES } from "constants/fetch-keys";
|
||||
import { Disclosure, Transition, Menu } from "@headlessui/react";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.services";
|
||||
import cycleServices from "lib/services/cycles.services";
|
||||
// commons
|
||||
import { classNames, renderShortNumericDateFormat } from "constants/common";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// components
|
||||
import CycleIssuesListModal from "./CycleIssuesListModal";
|
||||
// ui
|
||||
import { Spinner } from "ui";
|
||||
// icons
|
||||
import { PlusIcon, EllipsisHorizontalIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
|
||||
// types
|
||||
import type { ICycle, SprintViewProps as Props, SprintIssueResponse, IssueResponse } from "types";
|
||||
import type { CycleViewProps as Props, CycleIssueResponse, IssueResponse } from "types";
|
||||
// fetch keys
|
||||
import { CYCLE_ISSUES } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { renderShortNumericDateFormat } from "constants/common";
|
||||
import issuesServices from "lib/services/issues.services";
|
||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||
import { Draggable } from "react-beautiful-dnd";
|
||||
|
||||
const SprintView: React.FC<Props> = ({
|
||||
sprint,
|
||||
const CycleView: React.FC<Props> = ({
|
||||
cycle,
|
||||
selectSprint,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
openIssueModal,
|
||||
addIssueToSprint,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
|
||||
|
||||
const { data: sprintIssues } = useSWR<SprintIssueResponse[]>(CYCLE_ISSUES(sprint.id), () =>
|
||||
cycleServices.getCycleIssues(workspaceSlug, projectId, sprint.id)
|
||||
const { activeWorkspace, activeProject, issues } = useUser();
|
||||
|
||||
const { data: cycleIssues } = useSWR<CycleIssueResponse[]>(CYCLE_ISSUES(cycle.id), () =>
|
||||
cycleServices.getCycleIssues(workspaceSlug, projectId, cycle.id)
|
||||
);
|
||||
|
||||
const { data: projectIssues } = useSWR<IssueResponse>(
|
||||
projectId && workspaceSlug ? PROJECT_ISSUES_LIST(workspaceSlug, projectId) : null,
|
||||
workspaceSlug ? () => issuesServices.getIssues(workspaceSlug, projectId) : null
|
||||
);
|
||||
const removeIssueFromCycle = (cycleId: string, bridgeId: string) => {
|
||||
if (activeWorkspace && activeProject && cycleIssues) {
|
||||
mutate<CycleIssueResponse[]>(
|
||||
CYCLE_ISSUES(cycleId),
|
||||
(prevData) => prevData?.filter((p) => p.id !== bridgeId),
|
||||
false
|
||||
);
|
||||
|
||||
issuesServices
|
||||
.removeIssueFromCycle(activeWorkspace.slug, activeProject.id, cycleId, bridgeId)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-y-4 pb-5 relative">
|
||||
<Disclosure defaultOpen>
|
||||
<>
|
||||
<CycleIssuesListModal
|
||||
isOpen={cycleIssuesListModal}
|
||||
handleClose={() => setCycleIssuesListModal(false)}
|
||||
issues={issues}
|
||||
cycleId={cycle.id}
|
||||
/>
|
||||
<Disclosure as="div" defaultOpen>
|
||||
{({ open }) => (
|
||||
<div className="bg-gray-50 py-5 px-5 rounded">
|
||||
<div className="w-full h-full space-y-6 overflow-auto pb-10">
|
||||
<div className="w-full flex items-center">
|
||||
<Disclosure.Button className="w-full">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="bg-white px-4 py-2 rounded-lg space-y-3">
|
||||
<div className="flex items-center">
|
||||
<Disclosure.Button className="w-full">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>
|
||||
<ChevronDownIcon
|
||||
width={22}
|
||||
className={`text-gray-500 ${!open ? "transform -rotate-90" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
<h2 className="font-medium leading-5">{cycle.name}</h2>
|
||||
<p className="flex gap-2 text-xs text-gray-500">
|
||||
<span>
|
||||
<ChevronDownIcon
|
||||
width={22}
|
||||
className={`text-gray-500 ${!open ? "transform -rotate-90" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
<h2 className="text-xl">{sprint.name}</h2>
|
||||
<p className="font-light text-gray-500">
|
||||
{sprint.status === "started"
|
||||
? sprint.start_date
|
||||
? `${renderShortNumericDateFormat(sprint.start_date)} - `
|
||||
{cycle.status === "started"
|
||||
? cycle.start_date
|
||||
? `${renderShortNumericDateFormat(cycle.start_date)} - `
|
||||
: ""
|
||||
: sprint.status}
|
||||
{sprint.end_date ? renderShortNumericDateFormat(sprint.end_date) : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
|
||||
<div className="relative">
|
||||
<Menu>
|
||||
<Menu.Button>
|
||||
<EllipsisHorizontalIcon width="16" height="16" />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute z-20 w-28 bg-white rounded border cursor-pointer -left-24">
|
||||
<Menu.Item>
|
||||
<div className="hover:bg-gray-100 border-b last:border-0">
|
||||
<button
|
||||
className="w-full text-left py-2 pl-2"
|
||||
type="button"
|
||||
onClick={() => selectSprint({ ...sprint, actionType: "edit" })}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<div className="hover:bg-gray-100 border-b last:border-0">
|
||||
<button
|
||||
className="w-full text-left py-2 pl-2"
|
||||
type="button"
|
||||
onClick={() => selectSprint({ ...sprint, actionType: "delete" })}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
: cycle.status}
|
||||
</span>
|
||||
<span>
|
||||
{cycle.end_date ? renderShortNumericDateFormat(cycle.end_date) : ""}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
|
||||
<Menu as="div" className="relative inline-block">
|
||||
<Menu.Button className="grid place-items-center rounded p-1 hover:bg-gray-100 focus:outline-none">
|
||||
<EllipsisHorizontalIcon className="h-4 w-4" />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute origin-top-right right-0 mt-1 p-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
|
||||
<Menu.Item>
|
||||
<button
|
||||
type="button"
|
||||
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
|
||||
onClick={() => selectSprint({ ...cycle, actionType: "edit" })}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<button
|
||||
type="button"
|
||||
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
|
||||
onClick={() => selectSprint({ ...cycle, actionType: "delete" })}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform opacity-100"
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Disclosure.Panel>
|
||||
<StrictModeDroppable droppableId={cycle.id}>
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} {...provided.droppableProps}>
|
||||
{cycleIssues ? (
|
||||
cycleIssues.length > 0 ? (
|
||||
cycleIssues.map((issue, index) => (
|
||||
<Draggable
|
||||
key={issue.id}
|
||||
draggableId={`${issue.id},${issue.issue}`} // bridge id, issue id
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`group p-2 hover:bg-gray-100 text-sm rounded flex items-center justify-between ${
|
||||
snapshot.isDragging
|
||||
? "bg-gray-100 shadow-lg border border-theme"
|
||||
: ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 rotate-90 outline-none`}
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600" />
|
||||
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600 mt-[-0.7rem]" />
|
||||
</button>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 block rounded-full`}
|
||||
style={{
|
||||
backgroundColor: issue.issue_details.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<Link
|
||||
href={`/projects/${projectId}/issues/${issue.issue_details.id}`}
|
||||
>
|
||||
<a className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{activeProject?.identifier}-
|
||||
{issue.issue_details.sequence_id}
|
||||
</span>
|
||||
{issue.issue_details.name}
|
||||
{/* {cycle.id} */}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-black rounded-md px-2 py-0.5 text-sm"
|
||||
style={{
|
||||
backgroundColor: `${issue.issue_details.state_detail?.color}20`,
|
||||
border: `2px solid ${issue.issue_details.state_detail?.color}`,
|
||||
}}
|
||||
>
|
||||
{issue.issue_details.state_detail?.name}
|
||||
</span>
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button
|
||||
as="button"
|
||||
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-200 duration-300 outline-none`}
|
||||
>
|
||||
<EllipsisHorizontalIcon className="h-4 w-4" />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute origin-top-right right-0.5 mt-1 p-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
|
||||
<Menu.Item>
|
||||
<button
|
||||
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openIssueModal(cycle.id, issue.issue_details, "edit")
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<div className="hover:bg-gray-100 border-b last:border-0">
|
||||
<button
|
||||
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
removeIssueFromCycle(issue.cycle, issue.id)
|
||||
}
|
||||
>
|
||||
Remove from cycle
|
||||
</button>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<div className="hover:bg-gray-100 border-b last:border-0">
|
||||
<button
|
||||
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openIssueModal(
|
||||
cycle.id,
|
||||
issue.issue_details,
|
||||
"delete"
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete permanently
|
||||
</button>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">This cycle has no issue.</p>
|
||||
)
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
<Menu as="div" className="relative inline-block">
|
||||
<Menu.Button className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 text-xs font-medium">
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add issue
|
||||
</Menu.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform opacity-100"
|
||||
leaveTo="transform opacity-0"
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Disclosure.Panel>
|
||||
<div className="space-y-3">
|
||||
{sprintIssues ? (
|
||||
sprintIssues.length > 0 ? (
|
||||
sprintIssues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="p-4 bg-white border border-gray-200 rounded flex items-center justify-between"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/projects/${projectId}/issues/${issue.issue_details.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<p>{issue.issue_details.name}</p>
|
||||
</button>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<span
|
||||
className="text-black rounded px-2 py-0.5 text-sm border"
|
||||
style={{
|
||||
backgroundColor: `${issue.issue_details.state_detail?.color}20`,
|
||||
borderColor: issue.issue_details.state_detail?.color,
|
||||
}}
|
||||
>
|
||||
{issue.issue_details.state_detail?.name}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<Menu>
|
||||
<Menu.Button>
|
||||
<EllipsisHorizontalIcon width="16" height="16" />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute z-20 w-28 bg-white rounded border cursor-pointer -left-24">
|
||||
<Menu.Item>
|
||||
<div className="hover:bg-gray-100 border-b last:border-0">
|
||||
<button
|
||||
className="w-full text-left py-2 pl-2"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openIssueModal(sprint.id, issue.issue_details, "edit")
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<div className="hover:bg-gray-100 border-b last:border-0">
|
||||
<button
|
||||
className="w-full text-left py-2 pl-2"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openIssueModal(sprint.id, issue.issue_details, "delete")
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">This cycle has no issues.</p>
|
||||
)
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
<Menu.Items className="absolute left-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
|
||||
<div className="p-1">
|
||||
<Menu.Item as="div">
|
||||
{(active) => (
|
||||
<button
|
||||
type="button"
|
||||
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
|
||||
onClick={() => openIssueModal(cycle.id)}
|
||||
>
|
||||
Create new
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item as="div">
|
||||
{(active) => (
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap"
|
||||
onClick={() => setCycleIssuesListModal(true)}
|
||||
>
|
||||
Add an existing issue
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<button
|
||||
className="text-indigo-600 flex items-center gap-x-2"
|
||||
onClick={() => openIssueModal(sprint.id)}
|
||||
>
|
||||
<div className="bg-theme text-white rounded-full p-0.5">
|
||||
<PlusIcon width="18" height="18" />
|
||||
</div>
|
||||
<p>Add Issue</p>
|
||||
</button>
|
||||
|
||||
<div className="ml-1">
|
||||
<Menu as="div" className="inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button className="inline-flex w-full items-center justify-center rounded-md text-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
|
||||
<div className="text-indigo-600 flex items-center gap-x-2">
|
||||
<p>Add Existing Issue</p>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className="-mr-1 ml-2 h-5 w-5 text-indigo-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute left-5 z-20 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
{projectIssues?.results.map((issue) => (
|
||||
<Menu.Item
|
||||
key={issue.id}
|
||||
as="div"
|
||||
onClick={() => {
|
||||
addIssueToSprint(sprint.id, issue.id);
|
||||
}}
|
||||
>
|
||||
{({ active }) => (
|
||||
<p
|
||||
className={classNames(
|
||||
active ? "bg-gray-100 text-gray-900" : "text-gray-700",
|
||||
"block px-4 py-2 text-sm"
|
||||
)}
|
||||
>
|
||||
{issue.name}
|
||||
</p>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SprintView;
|
||||
export default CycleView;
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ const SingleBoard: React.FC<Props> = ({
|
|||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`rounded flex-shrink-0 h-full ${
|
||||
snapshot.isDragging ? "border-indigo-600 shadow-lg" : ""
|
||||
snapshot.isDragging ? "border-theme shadow-lg" : ""
|
||||
} ${!show ? "" : "w-80 bg-gray-50 border"}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
|
|
@ -196,7 +196,7 @@ const SingleBoard: React.FC<Props> = ({
|
|||
className="px-2 py-3 space-y-1.5 select-none"
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<span className="group-hover:text-theme break-all">
|
||||
<span className="group-hover:text-theme text-sm break-all">
|
||||
{childIssue.name}
|
||||
</span>
|
||||
{Object.keys(properties).map(
|
||||
|
|
@ -277,6 +277,7 @@ const SingleBoard: React.FC<Props> = ({
|
|||
<>{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 ? (
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const SelectAssignee: React.FC<Props> = ({ control }) => {
|
|||
multiple={true}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
icon={<UserIcon className="h-4 w-4 text-gray-400" />}
|
||||
icon={<UserIcon className="h-3 w-3 text-gray-500" />}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const SelectSprint: React.FC<Props> = ({ control, setIsOpen }) => {
|
|||
<>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300">
|
||||
<ArrowPathIcon className="h-3 w-3" />
|
||||
<ArrowPathIcon className="h-3 w-3 text-gray-500" />
|
||||
<span className="block truncate">
|
||||
{cycles?.find((i) => i.id.toString() === value?.toString())?.name ?? "Cycle"}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ const SelectLabels: React.FC<Props> = ({ control }) => {
|
|||
<>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300">
|
||||
<TagIcon className="h-3 w-3" />
|
||||
<TagIcon className="h-3 w-3 text-gray-500" />
|
||||
<span className="block truncate">
|
||||
{value && value.length > 0
|
||||
? value.map((id) => issueLabels?.find((i) => i.id === id)?.name).join(", ")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
// react hook form
|
||||
import { Controller, Control } from "react-hook-form";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// types
|
||||
import type { IIssue, IssueResponse } from "types";
|
||||
// icons
|
||||
import { UserIcon } from "@heroicons/react/24/outline";
|
||||
// components
|
||||
import IssuesListModal from "components/project/issues/IssuesListModal";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
issues: IssueResponse | undefined;
|
||||
};
|
||||
|
||||
const SelectParent: React.FC<Props> = ({ control, isOpen, setIsOpen, issues }) => {
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssuesListModal
|
||||
isOpen={isOpen}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
onChange={onChange}
|
||||
issues={issues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectParent;
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import React from "react";
|
||||
// react hook form
|
||||
import { Controller } from "react-hook-form";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { UserIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
};
|
||||
|
||||
import { SearchListbox } from "ui";
|
||||
|
||||
const SelectParent: React.FC<Props> = ({ control }) => {
|
||||
const { issues: projectIssues } = useUser();
|
||||
|
||||
const getSelectedIssueKey = (issueId: string | undefined) => {
|
||||
const identifier = projectIssues?.results?.find((i) => i.id.toString() === issueId?.toString())
|
||||
?.project_detail?.identifier;
|
||||
|
||||
const sequenceId = projectIssues?.results?.find(
|
||||
(i) => i.id.toString() === issueId?.toString()
|
||||
)?.sequence_id;
|
||||
|
||||
if (issueId) return `${identifier}-${sequenceId}`;
|
||||
else return "Parent issue";
|
||||
};
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SearchListbox
|
||||
title="Parent issue"
|
||||
optionsFontsize="sm"
|
||||
options={projectIssues?.results?.map((issue) => {
|
||||
return {
|
||||
value: issue.id,
|
||||
display: issue.name,
|
||||
element: (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="block truncate">
|
||||
<span className="block truncate">{`${getSelectedIssueKey(issue.id)}`}</span>
|
||||
<span className="block truncate text-gray-400">{issue.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
value={value}
|
||||
width="xs"
|
||||
buttonClassName="max-h-30 overflow-y-scroll"
|
||||
optionsClassName="max-h-30 overflow-y-scroll"
|
||||
onChange={onChange}
|
||||
icon={<UserIcon className="h-4 w-4 text-gray-400" />}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectParent;
|
||||
|
|
@ -28,8 +28,10 @@ const SelectPriority: React.FC<Props> = ({ control }) => {
|
|||
<>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="flex items-center gap-1 hover:bg-gray-100 relative border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300">
|
||||
<ChartBarIcon className="h-3 w-3" />
|
||||
<span className="block capitalize">{value ?? "Priority"}</span>
|
||||
<ChartBarIcon className="h-3 w-3 text-gray-500" />
|
||||
<span className="block capitalize">
|
||||
{value && value !== "" ? value : "Priority"}
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
|
|
|
|||
|
|
@ -21,38 +21,36 @@ const SelectState: React.FC<Props> = ({ control, setIsOpen }) => {
|
|||
const { states } = useUser();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomListbox
|
||||
title="State"
|
||||
options={states?.map((state) => {
|
||||
return { value: state.id, display: state.name };
|
||||
})}
|
||||
value={value}
|
||||
optionsFontsize="sm"
|
||||
onChange={onChange}
|
||||
icon={<Squares2X2Icon className="h-4 w-4 text-gray-400" />}
|
||||
footerOption={
|
||||
<button
|
||||
type="button"
|
||||
className="select-none relative py-2 pl-3 pr-9 flex items-center gap-x-2 text-gray-400 hover:text-gray-500"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<span>
|
||||
<PlusIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block truncate">Create state</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
></Controller>
|
||||
</>
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomListbox
|
||||
title="State"
|
||||
options={states?.map((state) => {
|
||||
return { value: state.id, display: state.name };
|
||||
})}
|
||||
value={value}
|
||||
optionsFontsize="sm"
|
||||
onChange={onChange}
|
||||
icon={<Squares2X2Icon className="h-4 w-4 text-gray-400" />}
|
||||
footerOption={
|
||||
<button
|
||||
type="button"
|
||||
className="select-none relative py-2 pl-3 pr-9 flex items-center gap-x-2 text-gray-400 hover:text-gray-500"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<span>
|
||||
<PlusIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block truncate">Create state</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// fetching keys
|
||||
import {
|
||||
PROJECT_ISSUES_DETAILS,
|
||||
|
|
@ -14,7 +14,7 @@ import {
|
|||
USER_ISSUE,
|
||||
} from "constants/fetch-keys";
|
||||
// headless
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { Dialog, Menu, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.services";
|
||||
// hooks
|
||||
|
|
@ -31,12 +31,13 @@ import SelectLabels from "./SelectLabels";
|
|||
import SelectProject from "./SelectProject";
|
||||
import SelectPriority from "./SelectPriority";
|
||||
import SelectAssignee from "./SelectAssignee";
|
||||
import SelectParent from "./SelectParentIssues";
|
||||
import SelectParent from "./SelectParentIssue";
|
||||
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
|
||||
import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal";
|
||||
|
||||
// types
|
||||
import type { IIssue, IssueResponse, SprintIssueResponse } from "types";
|
||||
import type { IIssue, IssueResponse, CycleIssueResponse } from "types";
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
|
|
@ -48,8 +49,13 @@ type Props = {
|
|||
};
|
||||
|
||||
const defaultValues: Partial<IIssue> = {
|
||||
project: "",
|
||||
name: "",
|
||||
description: "",
|
||||
// description: "",
|
||||
state: "",
|
||||
sprints: null,
|
||||
priority: null,
|
||||
labels_list: [],
|
||||
};
|
||||
|
||||
const CreateUpdateIssuesModal: React.FC<Props> = ({
|
||||
|
|
@ -62,9 +68,20 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
}) => {
|
||||
const [isCycleModalOpen, setIsCycleModalOpen] = useState(false);
|
||||
const [isStateModalOpen, setIsStateModalOpen] = useState(false);
|
||||
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
|
||||
|
||||
const [mostSimilarIssue, setMostSimilarIssue] = useState<string | undefined>();
|
||||
|
||||
// const [issueDescriptionValue, setIssueDescriptionValue] = useState("");
|
||||
// const handleDescriptionChange: any = (value: any) => {
|
||||
// console.log(value);
|
||||
// setIssueDescriptionValue(value);
|
||||
// };
|
||||
|
||||
const RichTextEditor = dynamic(() => import("components/lexical/editor"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleClose = () => {
|
||||
|
|
@ -74,13 +91,6 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
const timeout = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timeout);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const { activeWorkspace, activeProject, user, issues } = useUser();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
|
@ -97,6 +107,13 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
defaultValues,
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
const timeout = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timeout);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const addIssueToSprint = async (issueId: string, sprintId: string, issueDetail: IIssue) => {
|
||||
if (!activeWorkspace || !activeProject) return;
|
||||
await issuesServices
|
||||
|
|
@ -104,8 +121,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
issue: issueId,
|
||||
})
|
||||
.then((res) => {
|
||||
console.log("add to sprint", res);
|
||||
mutate<SprintIssueResponse[]>(
|
||||
mutate<CycleIssueResponse[]>(
|
||||
CYCLE_ISSUES(sprintId),
|
||||
(prevData) => {
|
||||
const targetResponse = prevData?.find((t) => t.cycle === sprintId);
|
||||
|
|
@ -118,7 +134,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
{
|
||||
cycle: sprintId,
|
||||
issue_details: issueDetail,
|
||||
} as SprintIssueResponse,
|
||||
} as CycleIssueResponse,
|
||||
];
|
||||
}
|
||||
},
|
||||
|
|
@ -166,17 +182,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
.createIssues(activeWorkspace.slug, activeProject.id, payload)
|
||||
.then(async (res) => {
|
||||
console.log(res);
|
||||
mutate<IssueResponse>(
|
||||
PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id),
|
||||
(prevData) => {
|
||||
return {
|
||||
...(prevData as IssueResponse),
|
||||
results: [res, ...(prevData?.results ?? [])],
|
||||
count: (prevData?.count ?? 0) + 1,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
mutate<IssueResponse>(PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id));
|
||||
|
||||
if (formData.sprints && formData.sprints !== null) {
|
||||
await addIssueToSprint(res.id, formData.sprints, formData);
|
||||
|
|
@ -189,13 +195,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
message: `Issue ${data ? "updated" : "created"} successfully`,
|
||||
});
|
||||
if (formData.assignees_list.some((assignee) => assignee === user?.id)) {
|
||||
mutate<IIssue[]>(
|
||||
USER_ISSUE,
|
||||
(prevData) => {
|
||||
return [res, ...(prevData ?? [])];
|
||||
},
|
||||
false
|
||||
);
|
||||
mutate<IIssue[]>(USER_ISSUE);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -261,6 +261,8 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
return () => setMostSimilarIssue(undefined);
|
||||
}, []);
|
||||
|
||||
// console.log(watch("parent"));
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeProject && (
|
||||
|
|
@ -381,6 +383,13 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
error={errors.description}
|
||||
register={register}
|
||||
/>
|
||||
{/* <Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<RichTextEditor {...field} id="issueDescriptionEditor" />
|
||||
)}
|
||||
/> */}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
|
|
@ -398,9 +407,48 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||
<SelectState control={control} setIsOpen={setIsStateModalOpen} />
|
||||
<SelectCycles control={control} setIsOpen={setIsCycleModalOpen} />
|
||||
<SelectPriority control={control} />
|
||||
<SelectLabels control={control} />
|
||||
<SelectAssignee control={control} />
|
||||
<SelectParent control={control} />
|
||||
<SelectLabels control={control} />
|
||||
<SelectParent
|
||||
control={control}
|
||||
isOpen={parentIssueListModalOpen}
|
||||
setIsOpen={setParentIssueListModalOpen}
|
||||
issues={issues}
|
||||
/>
|
||||
<Menu as="div" className="relative inline-block">
|
||||
<Menu.Button className="grid place-items-center p-1 hover:bg-gray-100 border rounded-md shadow-sm cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm duration-300">
|
||||
<EllipsisHorizontalIcon className="h-5 w-5" />
|
||||
</Menu.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="origin-top-right absolute right-0 mt-2 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
|
||||
<div className="p-1">
|
||||
<Menu.Item as="div">
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-left text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap "
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
{watch("parent") && watch("parent") !== ""
|
||||
? `${activeProject?.identifier}-${
|
||||
issues?.results.find((i) => i.id === watch("parent"))
|
||||
?.sequence_id
|
||||
}`
|
||||
: "Select Parent Issue"}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
140
apps/app/components/project/issues/IssuesListModal.tsx
Normal file
140
apps/app/components/project/issues/IssuesListModal.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, IssueResponse } from "types";
|
||||
import { classNames } from "constants/common";
|
||||
import useUser from "lib/hooks/useUser";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
onChange: (...event: any[]) => void;
|
||||
issues: IssueResponse | undefined;
|
||||
};
|
||||
|
||||
const IssuesListModal: React.FC<Props> = ({ isOpen, handleClose: onClose, onChange, issues }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { activeProject } = useUser();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
query === ""
|
||||
? issues?.results ?? []
|
||||
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
|
||||
[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
<Dialog as="div" className="relative z-10" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
|
||||
<Combobox onChange={onChange}>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm outline-none"
|
||||
placeholder="Search..."
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Combobox.Options
|
||||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 && (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
|
||||
Issues
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{filteredIssues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
value={issue.id}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
"flex items-center gap-2 cursor-pointer select-none rounded-md px-3 py-2",
|
||||
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
// setIssueIdFromList(issue.id);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 block rounded-full`}
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">
|
||||
{activeProject?.identifier}-{issue.sequence_id}
|
||||
</span>{" "}
|
||||
{issue.name}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
|
||||
{query !== "" && filteredIssues.length === 0 && (
|
||||
<div className="py-14 px-6 text-center sm:px-14">
|
||||
<RectangleStackIcon
|
||||
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="mt-4 text-sm text-gray-900">
|
||||
We couldn{"'"}t find any issue with that term. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Combobox>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssuesListModal;
|
||||
|
|
@ -440,4 +440,4 @@ const ListView: React.FC<Props> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default ListView;
|
||||
export default ListView;
|
||||
|
|
@ -1,18 +1,24 @@
|
|||
// next
|
||||
import Image from "next/image";
|
||||
// ui
|
||||
import { Spinner } from "ui";
|
||||
// icons
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
ChartBarIcon,
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
Squares2X2Icon,
|
||||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IssueResponse, IState } from "types";
|
||||
// constants
|
||||
import { addSpaceIfCamelCase, timeAgo } from "constants/common";
|
||||
import { IIssue, IState } from "types";
|
||||
import { Spinner } from "ui";
|
||||
|
||||
type Props = {
|
||||
issueActivities: any[] | undefined;
|
||||
states: IState[] | undefined;
|
||||
issues: IssueResponse | undefined;
|
||||
};
|
||||
|
||||
const activityIcons: {
|
||||
|
|
@ -23,9 +29,10 @@ const activityIcons: {
|
|||
name: <ChatBubbleBottomCenterTextIcon className="h-4 w-4" />,
|
||||
description: <ChatBubbleBottomCenterTextIcon className="h-4 w-4" />,
|
||||
target_date: <CalendarDaysIcon className="h-4 w-4" />,
|
||||
parent: <UserIcon className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const IssueActivitySection: React.FC<Props> = ({ issueActivities, states }) => {
|
||||
const IssueActivitySection: React.FC<Props> = ({ issueActivities, states, issues }) => {
|
||||
return (
|
||||
<>
|
||||
{issueActivities ? (
|
||||
|
|
@ -92,6 +99,10 @@ const IssueActivitySection: React.FC<Props> = ({ issueActivities, states }) => {
|
|||
states?.find((s) => s.id === activity.old_value)?.name ?? ""
|
||||
)
|
||||
: "None"
|
||||
: activity.field === "parent"
|
||||
? activity.old_value
|
||||
? issues?.results.find((i) => i.id === activity.old_value)?.name
|
||||
: "None"
|
||||
: activity.old_value ?? "None"}
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -102,6 +113,10 @@ const IssueActivitySection: React.FC<Props> = ({ issueActivities, states }) => {
|
|||
states?.find((s) => s.id === activity.new_value)?.name ?? ""
|
||||
)
|
||||
: "None"
|
||||
: activity.field === "parent"
|
||||
? activity.new_value
|
||||
? issues?.results.find((i) => i.id === activity.new_value)?.name
|
||||
: "None"
|
||||
: activity.new_value ?? "None"}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ const ChangeStateDropdown: React.FC<Props> = ({ issue, updateIssues }) => {
|
|||
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">
|
||||
<Listbox.Options className="fixed z-10 mt-1 bg-white shadow-lg max-h-28 rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
{states?.map((state) => (
|
||||
<Listbox.Option
|
||||
key={state.id}
|
||||
|
|
|
|||
197
apps/app/components/project/settings/ControlSettings.tsx
Normal file
197
apps/app/components/project/settings/ControlSettings.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// react
|
||||
import React from "react";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// react-hook-form
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
// services
|
||||
import workspaceService from "lib/services/workspace.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
// icons
|
||||
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IProject, WorkspaceMember } from "types";
|
||||
// fetch-keys
|
||||
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
control: Control<IProject, any>;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
|
||||
const ControlSettings: React.FC<Props> = ({ control, isSubmitting }) => {
|
||||
const { activeWorkspace } = useUser();
|
||||
|
||||
const { data: people } = useSWR<WorkspaceMember[]>(
|
||||
activeWorkspace ? WORKSPACE_MEMBERS : null,
|
||||
activeWorkspace ? () => workspaceService.workspaceMembers(activeWorkspace.slug) : null
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Control</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Set the control for the project.</p>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<div className="w-full md:w-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="project_lead"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Listbox.Label>
|
||||
<div className="text-gray-500 mb-2">Project Lead</div>
|
||||
</Listbox.Label>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||
<span className="block truncate">
|
||||
{people?.find((person) => person.member.id === value)?.member
|
||||
.first_name ?? "Select Lead"}
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<ChevronDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
{people?.map((person) => (
|
||||
<Listbox.Option
|
||||
key={person.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "text-white bg-theme" : "text-gray-900"
|
||||
} cursor-default select-none relative py-2 pl-3 pr-9`
|
||||
}
|
||||
value={person.member.id}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
selected ? "font-semibold" : "font-normal"
|
||||
} block truncate`}
|
||||
>
|
||||
{person.member.first_name}
|
||||
</span>
|
||||
|
||||
{selected ? (
|
||||
<span
|
||||
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||
active ? "text-white" : "text-indigo-600"
|
||||
}`}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="default_assignee"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Listbox.Label>
|
||||
<div className="text-gray-500 mb-2">Default Assignee</div>
|
||||
</Listbox.Label>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||
<span className="block truncate">
|
||||
{people?.find((p) => p.member.id === value)?.member.first_name ??
|
||||
"Select Default Assignee"}
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<ChevronDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
{people?.map((person) => (
|
||||
<Listbox.Option
|
||||
key={person.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "text-white bg-theme" : "text-gray-900"
|
||||
} cursor-default select-none relative py-2 pl-3 pr-9`
|
||||
}
|
||||
value={person.member.id}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
selected ? "font-semibold" : "font-normal"
|
||||
} block truncate`}
|
||||
>
|
||||
{person.member.first_name}
|
||||
</span>
|
||||
|
||||
{selected ? (
|
||||
<span
|
||||
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||
active ? "text-white" : "text-indigo-600"
|
||||
}`}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Updating Project..." : "Update Project"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlSettings;
|
||||
119
apps/app/components/project/settings/GeneralSettings.tsx
Normal file
119
apps/app/components/project/settings/GeneralSettings.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// react
|
||||
import { useCallback } from "react";
|
||||
// react-hook-form
|
||||
import { UseFormRegister, UseFormSetError } from "react-hook-form";
|
||||
// services
|
||||
import projectServices from "lib/services/project.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// ui
|
||||
import { Input, Select, TextArea } from "ui";
|
||||
// types
|
||||
import { IProject } from "types";
|
||||
// constants
|
||||
import { debounce } from "constants/common";
|
||||
|
||||
type Props = {
|
||||
register: UseFormRegister<IProject>;
|
||||
errors: any;
|
||||
setError: UseFormSetError<IProject>;
|
||||
};
|
||||
|
||||
const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
|
||||
|
||||
const GeneralSettings: React.FC<Props> = ({ register, errors, setError }) => {
|
||||
const { activeWorkspace } = useUser();
|
||||
|
||||
const checkIdentifier = (slug: string, value: string) => {
|
||||
projectServices.checkProjectIdentifierAvailability(slug, value).then((response) => {
|
||||
console.log(response);
|
||||
if (response.exists) setError("identifier", { message: "Identifier already exists" });
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const checkIdentifierAvailability = useCallback(debounce(checkIdentifier, 1500), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">General</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
This information will be displayed to every member of the project.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
placeholder="Project Name"
|
||||
label="Name"
|
||||
validations={{
|
||||
required: "Name is required",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
name="network"
|
||||
id="network"
|
||||
options={Object.keys(NETWORK_CHOICES).map((key) => ({
|
||||
value: key,
|
||||
label: NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES],
|
||||
}))}
|
||||
label="Network"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Network is required",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
id="identifier"
|
||||
name="identifier"
|
||||
error={errors.identifier}
|
||||
register={register}
|
||||
placeholder="Enter identifier"
|
||||
label="Identifier"
|
||||
onChange={(e: any) => {
|
||||
if (!activeWorkspace || !e.target.value) return;
|
||||
checkIdentifierAvailability(activeWorkspace.slug, e.target.value);
|
||||
}}
|
||||
validations={{
|
||||
required: "Identifier is required",
|
||||
minLength: {
|
||||
value: 1,
|
||||
message: "Identifier must at least be of 1 character",
|
||||
},
|
||||
maxLength: {
|
||||
value: 9,
|
||||
message: "Identifier must at most be of 9 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<TextArea
|
||||
id="description"
|
||||
name="description"
|
||||
error={errors.description}
|
||||
register={register}
|
||||
label="Description"
|
||||
placeholder="Enter project description"
|
||||
validations={{
|
||||
required: "Description is required",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSettings;
|
||||
275
apps/app/components/project/settings/LabelsSettings.tsx
Normal file
275
apps/app/components/project/settings/LabelsSettings.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
// react
|
||||
import React, { useState } from "react";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// react-hook-form
|
||||
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
||||
// react-color
|
||||
import { TwitterPicker } from "react-color";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.services";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// headless ui
|
||||
import { Popover, Transition, Menu } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button, Input, Spinner } from "ui";
|
||||
// icons
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
RectangleGroupIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
|
||||
const defaultValues: Partial<IIssueLabels> = {
|
||||
name: "",
|
||||
colour: "#ff0000",
|
||||
};
|
||||
|
||||
const LabelsSettings: React.FC = () => {
|
||||
const [newLabelForm, setNewLabelForm] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [labelIdForUpdate, setLabelidForUpdate] = useState<string | null>(null);
|
||||
|
||||
const { activeWorkspace, activeProject } = useUser();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
watch,
|
||||
} = useForm<IIssueLabels>({ defaultValues });
|
||||
|
||||
const { data: issueLabels, mutate } = useSWR<IIssueLabels[]>(
|
||||
activeProject && activeWorkspace ? PROJECT_ISSUE_LABELS(activeProject.id) : null,
|
||||
activeProject && activeWorkspace
|
||||
? () => issuesServices.getIssueLabels(activeWorkspace.slug, activeProject.id)
|
||||
: null
|
||||
);
|
||||
|
||||
const handleNewLabel: SubmitHandler<IIssueLabels> = (formData) => {
|
||||
if (!activeWorkspace || !activeProject || isSubmitting) return;
|
||||
issuesServices
|
||||
.createIssueLabel(activeWorkspace.slug, activeProject.id, formData)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
reset(defaultValues);
|
||||
mutate((prevData) => [...(prevData ?? []), res], false);
|
||||
setNewLabelForm(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLabelUpdate: SubmitHandler<IIssueLabels> = (formData) => {
|
||||
if (!activeWorkspace || !activeProject || isSubmitting) return;
|
||||
issuesServices
|
||||
.patchIssueLabel(activeWorkspace.slug, activeProject.id, labelIdForUpdate ?? "", formData)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
reset(defaultValues);
|
||||
mutate(
|
||||
(prevData) =>
|
||||
prevData?.map((p) => (p.id === labelIdForUpdate ? { ...p, ...formData } : p)),
|
||||
false
|
||||
);
|
||||
setNewLabelForm(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLabelDelete = (labelId: string) => {
|
||||
if (activeWorkspace && activeProject) {
|
||||
mutate((prevData) => prevData?.filter((p) => p.id !== labelId), false);
|
||||
issuesServices
|
||||
.deleteIssueLabel(activeWorkspace.slug, activeProject.id, labelId)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getLabelChildren = (labelId: string) => {
|
||||
return issueLabels?.filter((l) => l.parent === labelId);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Labels</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Manage the labels of this project.</p>
|
||||
</div>
|
||||
<Button className="flex items-center gap-x-1" onClick={() => setNewLabelForm(true)}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
New label
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<form
|
||||
className={`bg-white px-4 py-2 flex items-center gap-2 ${newLabelForm ? "" : "hidden"}`}
|
||||
>
|
||||
<div>
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`bg-white flex items-center gap-1 rounded-md p-1 outline-none focus:ring-2 focus:ring-indigo-500`}
|
||||
>
|
||||
{watch("colour") && watch("colour") !== "" && (
|
||||
<span
|
||||
className="w-6 h-6 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("colour") ?? "green",
|
||||
}}
|
||||
></span>
|
||||
)}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute z-20 transform left-0 mt-1 px-2 max-w-xs sm:px-0">
|
||||
<Controller
|
||||
name="colour"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TwitterPicker
|
||||
color={value}
|
||||
onChange={(value) => onChange(value.hex)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
id="labelName"
|
||||
name="name"
|
||||
register={register}
|
||||
placeholder="Lable title"
|
||||
/>
|
||||
<Button type="button" theme="secondary" onClick={() => setNewLabelForm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{isUpdating ? (
|
||||
<Button type="button" onClick={handleSubmit(handleLabelUpdate)}>
|
||||
Update
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" onClick={handleSubmit(handleNewLabel)}>
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
{issueLabels ? (
|
||||
issueLabels.map((label) => {
|
||||
const children = getLabelChildren(label.id);
|
||||
|
||||
return (
|
||||
<React.Fragment key={label.id}>
|
||||
{children && children.length === 0 ? (
|
||||
<div className="bg-white p-2 flex items-center justify-between text-gray-900 rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full`}
|
||||
style={{
|
||||
backgroundColor: label.colour,
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm">{label.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button
|
||||
as="button"
|
||||
className={`h-7 w-7 p-1 grid place-items-center rounded hover:bg-gray-100 duration-300 outline-none`}
|
||||
>
|
||||
<EllipsisHorizontalIcon className="h-4 w-4" />
|
||||
</Menu.Button>
|
||||
<Menu.Items className="absolute origin-top-right right-0.5 mt-1 p-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
|
||||
<Menu.Item>
|
||||
<button
|
||||
type="button"
|
||||
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
|
||||
onClick={() => {
|
||||
setNewLabelForm(true);
|
||||
setValue("colour", label.colour);
|
||||
setValue("name", label.name);
|
||||
setIsUpdating(true);
|
||||
setLabelidForUpdate(label.id);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<div className="hover:bg-gray-100 border-b last:border-0">
|
||||
<button
|
||||
type="button"
|
||||
className="text-left p-2 text-gray-900 hover:bg-theme hover:text-white rounded-md text-xs whitespace-nowrap w-full"
|
||||
onClick={() => handleLabelDelete(label.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white p-4 text-gray-900 rounded-md">
|
||||
<h3 className="font-medium leading-5 flex items-center gap-2">
|
||||
<RectangleGroupIcon className="h-5 w-5" />
|
||||
This is the label group title
|
||||
</h3>
|
||||
<div className="pl-5 mt-4">
|
||||
<div className="group text-sm flex justify-between items-center p-2 hover:bg-gray-100 rounded">
|
||||
<h5 className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-red-600 rounded-full"></div>
|
||||
This is the label title
|
||||
</h5>
|
||||
<button type="button" className="hidden group-hover:block">
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelsSettings;
|
||||
78
apps/app/components/project/settings/StatesSettings.tsx
Normal file
78
apps/app/components/project/settings/StatesSettings.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// react
|
||||
import { useState } from "react";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// components
|
||||
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
|
||||
// ui
|
||||
import { Button } from "ui";
|
||||
// icons
|
||||
import { PencilSquareIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
// constants
|
||||
import { addSpaceIfCamelCase } from "constants/common";
|
||||
|
||||
type Props = {
|
||||
projectId: string | string[] | undefined;
|
||||
};
|
||||
|
||||
const StatesSettings: React.FC<Props> = ({ projectId }) => {
|
||||
const [isCreateStateModal, setIsCreateStateModal] = useState(false);
|
||||
const [selectedState, setSelectedState] = useState<string | undefined>();
|
||||
|
||||
const { states } = useUser();
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateStateModal
|
||||
isOpen={isCreateStateModal || Boolean(selectedState)}
|
||||
handleClose={() => {
|
||||
setSelectedState(undefined);
|
||||
setIsCreateStateModal(false);
|
||||
}}
|
||||
projectId={projectId as string}
|
||||
data={selectedState ? states?.find((state) => state.id === selectedState) : undefined}
|
||||
/>
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">State</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Manage the state of this project.</p>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<div className="w-full space-y-5">
|
||||
{states?.map((state) => (
|
||||
<div
|
||||
key={state.id}
|
||||
className="bg-white px-4 py-2 rounded flex justify-between items-center"
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: state.color,
|
||||
}}
|
||||
></div>
|
||||
<h4>{addSpaceIfCamelCase(state.name)}</h4>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" onClick={() => setSelectedState(state.id)}>
|
||||
<PencilSquareIcon className="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
className="flex items-center gap-x-1"
|
||||
onClick={() => setIsCreateStateModal(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
<span>Add State</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatesSettings;
|
||||
Loading…
Add table
Add a link
Reference in a new issue