mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
dev: label store implementation
This commit is contained in:
parent
550473bb02
commit
f8ab0aa72b
@ -1,13 +1,12 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import useSWR from "swr";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
// headless ui
|
// headless ui
|
||||||
import { Combobox, Transition } from "@headlessui/react";
|
import { Combobox, Transition } from "@headlessui/react";
|
||||||
// services
|
|
||||||
import issuesServices from "services/issues.service";
|
|
||||||
// ui
|
// ui
|
||||||
import { IssueLabelsList } from "components/ui";
|
import { IssueLabelsList } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
@ -19,9 +18,7 @@ import {
|
|||||||
TagIcon,
|
TagIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
// types
|
// types
|
||||||
import type { IIssueLabels } from "types";
|
import type { LabelLite } from "types";
|
||||||
// fetch-keys
|
|
||||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@ -30,179 +27,180 @@ type Props = {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IssueLabelSelect: React.FC<Props> = ({ setIsOpen, value, onChange, projectId }) => {
|
export const IssueLabelSelect: React.FC<Props> = observer(
|
||||||
// states
|
({ setIsOpen, value, onChange, projectId }) => {
|
||||||
const [query, setQuery] = useState("");
|
// states
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
const { label: labelStore } = useMobxStore();
|
||||||
projectId ? PROJECT_ISSUE_LABELS(projectId) : null,
|
const { isLabelsLoading: isLoading, labels, loadLabels, getLabelChildren } = labelStore;
|
||||||
workspaceSlug && projectId
|
|
||||||
? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredOptions =
|
useEffect(() => {
|
||||||
query === ""
|
if (workspaceSlug && projectId) loadLabels(workspaceSlug.toString(), projectId);
|
||||||
? issueLabels
|
}, [workspaceSlug, projectId, loadLabels]);
|
||||||
: issueLabels?.filter((l) => l.name.toLowerCase().includes(query.toLowerCase()));
|
|
||||||
|
|
||||||
return (
|
const filteredOptions: LabelLite[] = labels?.filter((l) =>
|
||||||
<Combobox
|
l.name.toLowerCase().includes(query.toLowerCase())
|
||||||
as="div"
|
);
|
||||||
value={value}
|
|
||||||
onChange={(val) => onChange(val)}
|
|
||||||
className="relative flex-shrink-0"
|
|
||||||
multiple
|
|
||||||
>
|
|
||||||
{({ open }: any) => (
|
|
||||||
<>
|
|
||||||
<Combobox.Button className="flex cursor-pointer items-center rounded-md border border-custom-border-200 text-xs shadow-sm duration-200 hover:bg-custom-background-80">
|
|
||||||
{value && value.length > 0 ? (
|
|
||||||
<span className="flex items-center justify-center gap-2 px-3 py-1 text-xs">
|
|
||||||
<IssueLabelsList
|
|
||||||
labels={value.map((v) => issueLabels?.find((l) => l.id === v)?.color) ?? []}
|
|
||||||
length={3}
|
|
||||||
showLength={true}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center justify-center gap-2 px-2.5 py-1 text-xs">
|
|
||||||
<TagIcon className="h-3.5 w-3.5 text-custom-text-200" />
|
|
||||||
<span className=" text-custom-text-200">Label</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Combobox.Button>
|
|
||||||
|
|
||||||
<Transition
|
return (
|
||||||
show={open}
|
<Combobox
|
||||||
as={React.Fragment}
|
as="div"
|
||||||
enter="transition ease-out duration-200"
|
value={value}
|
||||||
enterFrom="opacity-0 translate-y-1"
|
onChange={(val) => onChange(val)}
|
||||||
enterTo="opacity-100 translate-y-0"
|
className="relative flex-shrink-0"
|
||||||
leave="transition ease-in duration-150"
|
multiple
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
>
|
||||||
leaveTo="opacity-0 translate-y-1"
|
{({ open }: any) => (
|
||||||
>
|
<>
|
||||||
<Combobox.Options
|
<Combobox.Button className="flex cursor-pointer items-center rounded-md border border-custom-border-200 text-xs shadow-sm duration-200 hover:bg-custom-background-80">
|
||||||
className={`absolute z-10 mt-1 max-h-52 min-w-[8rem] overflow-auto rounded-md border-none
|
{value && value.length > 0 ? (
|
||||||
bg-custom-background-90 px-2 py-2 text-xs shadow-md focus:outline-none`}
|
<span className="flex items-center justify-center gap-2 px-3 py-1 text-xs">
|
||||||
|
<IssueLabelsList
|
||||||
|
labels={value.map((v) => labels?.find((l) => l.id === v)?.color) ?? []}
|
||||||
|
length={3}
|
||||||
|
showLength={true}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center justify-center gap-2 px-2.5 py-1 text-xs">
|
||||||
|
<TagIcon className="h-3.5 w-3.5 text-custom-text-200" />
|
||||||
|
<span className=" text-custom-text-200">Label</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Combobox.Button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
show={open}
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] border-custom-border-200 bg-custom-background-90 px-2">
|
<Combobox.Options
|
||||||
<MagnifyingGlassIcon className="h-3 w-3 text-custom-text-200" />
|
className={`absolute z-10 mt-1 max-h-52 min-w-[8rem] overflow-auto rounded-md border-none
|
||||||
<Combobox.Input
|
bg-custom-background-90 px-2 py-2 text-xs shadow-md focus:outline-none`}
|
||||||
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 focus:outline-none"
|
>
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] border-custom-border-200 bg-custom-background-90 px-2">
|
||||||
placeholder="Search for label..."
|
<MagnifyingGlassIcon className="h-3 w-3 text-custom-text-200" />
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
<Combobox.Input
|
||||||
/>
|
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 focus:outline-none"
|
||||||
</div>
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
<div className="py-1.5">
|
placeholder="Search for label..."
|
||||||
{issueLabels && filteredOptions ? (
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
filteredOptions.length > 0 ? (
|
/>
|
||||||
filteredOptions.map((label) => {
|
</div>
|
||||||
const children = issueLabels?.filter((l) => l.parent === label.id);
|
<div className="py-1.5">
|
||||||
|
{!isLoading && filteredOptions ? (
|
||||||
|
filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((label) => {
|
||||||
|
const children = getLabelChildren(label.id);
|
||||||
|
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
if (!label.parent)
|
if (!label.parent)
|
||||||
return (
|
return (
|
||||||
<Combobox.Option
|
<Combobox.Option
|
||||||
key={label.id}
|
key={label.id}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`${
|
`${
|
||||||
active ? "bg-custom-background-80" : ""
|
active ? "bg-custom-background-80" : ""
|
||||||
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200`
|
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200`
|
||||||
}
|
}
|
||||||
value={label.id}
|
value={label.id}
|
||||||
>
|
>
|
||||||
{({ selected }) => (
|
{({ selected }) => (
|
||||||
<div className="flex w-full justify-between gap-2 rounded">
|
<div className="flex w-full justify-between gap-2 rounded">
|
||||||
<div className="flex items-center justify-start gap-2">
|
<div className="flex items-center justify-start gap-2">
|
||||||
<span
|
<span
|
||||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: label.color,
|
backgroundColor: label.color,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>{label.name}</span>
|
<span>{label.name}</span>
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center rounded p-1">
|
|
||||||
<CheckIcon
|
|
||||||
className={`h-3 w-3 ${
|
|
||||||
selected ? "opacity-100" : "opacity-0"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Combobox.Option>
|
|
||||||
);
|
|
||||||
} else
|
|
||||||
return (
|
|
||||||
<div className="border-y border-custom-border-200">
|
|
||||||
<div className="flex select-none items-center gap-2 truncate p-2 text-custom-text-100">
|
|
||||||
<RectangleGroupIcon className="h-3 w-3" /> {label.name}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{children.map((child) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={child.id}
|
|
||||||
className={({ active }) =>
|
|
||||||
`${
|
|
||||||
active ? "bg-custom-background-80" : ""
|
|
||||||
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200`
|
|
||||||
}
|
|
||||||
value={child.id}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<div className="flex w-full justify-between gap-2 rounded">
|
|
||||||
<div className="flex items-center justify-start gap-2">
|
|
||||||
<span
|
|
||||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: child?.color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{child.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center rounded p-1">
|
|
||||||
<CheckIcon
|
|
||||||
className={`h-3 w-3 ${
|
|
||||||
selected ? "opacity-100" : "opacity-0"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex items-center justify-center rounded p-1">
|
||||||
</Combobox.Option>
|
<CheckIcon
|
||||||
))}
|
className={`h-3 w-3 ${
|
||||||
|
selected ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
);
|
||||||
|
} else
|
||||||
|
return (
|
||||||
|
<div className="border-y border-custom-border-200">
|
||||||
|
<div className="flex select-none items-center gap-2 truncate p-2 text-custom-text-100">
|
||||||
|
<RectangleGroupIcon className="h-3 w-3" /> {label.name}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{children.map((child) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={child.id}
|
||||||
|
className={({ active }) =>
|
||||||
|
`${
|
||||||
|
active ? "bg-custom-background-80" : ""
|
||||||
|
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200`
|
||||||
|
}
|
||||||
|
value={child.id}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<div className="flex w-full justify-between gap-2 rounded">
|
||||||
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: child?.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{child.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center rounded p-1">
|
||||||
|
<CheckIcon
|
||||||
|
className={`h-3 w-3 ${
|
||||||
|
selected ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})
|
||||||
})
|
) : (
|
||||||
|
<p className="px-2 text-xs text-custom-text-200">No labels found</p>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p className="px-2 text-xs text-custom-text-200">No labels found</p>
|
<p className="px-2 text-xs text-custom-text-200">Loading...</p>
|
||||||
)
|
)}
|
||||||
) : (
|
<button
|
||||||
<p className="px-2 text-xs text-custom-text-200">Loading...</p>
|
type="button"
|
||||||
)}
|
className="flex w-full select-none items-center rounded py-2 px-1 hover:bg-custom-background-80"
|
||||||
<button
|
onClick={() => setIsOpen(true)}
|
||||||
type="button"
|
>
|
||||||
className="flex w-full select-none items-center rounded py-2 px-1 hover:bg-custom-background-80"
|
<span className="flex items-center justify-start gap-1 text-custom-text-200">
|
||||||
onClick={() => setIsOpen(true)}
|
<PlusIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
>
|
<span>Create New Label</span>
|
||||||
<span className="flex items-center justify-start gap-1 text-custom-text-200">
|
</span>
|
||||||
<PlusIcon className="h-4 w-4" aria-hidden="true" />
|
</button>
|
||||||
<span>Create New Label</span>
|
</div>
|
||||||
</span>
|
</Combobox.Options>
|
||||||
</button>
|
</Transition>
|
||||||
</div>
|
</>
|
||||||
</Combobox.Options>
|
)}
|
||||||
</Transition>
|
</Combobox>
|
||||||
</>
|
);
|
||||||
)}
|
}
|
||||||
</Combobox>
|
);
|
||||||
);
|
|
||||||
};
|
|
||||||
|
@ -2,7 +2,9 @@ import React, { useEffect } from "react";
|
|||||||
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { mutate } from "swr";
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
// react-hook-form
|
// react-hook-form
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
@ -10,16 +12,13 @@ import { Controller, useForm } from "react-hook-form";
|
|||||||
import { TwitterPicker } from "react-color";
|
import { TwitterPicker } from "react-color";
|
||||||
// headless ui
|
// headless ui
|
||||||
import { Dialog, Popover, Transition } from "@headlessui/react";
|
import { Dialog, Popover, Transition } from "@headlessui/react";
|
||||||
// services
|
|
||||||
import issuesService from "services/issues.service";
|
|
||||||
// ui
|
// ui
|
||||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||||
// types
|
// types
|
||||||
import type { ICurrentUserResponse, IIssueLabels, IState } from "types";
|
import type { ICurrentUserResponse, IIssueLabels, LabelForm } from "types";
|
||||||
// constants
|
// constants
|
||||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
|
||||||
import { LABEL_COLOR_OPTIONS, getRandomLabelColor } from "constants/label";
|
import { LABEL_COLOR_OPTIONS, getRandomLabelColor } from "constants/label";
|
||||||
|
|
||||||
// types
|
// types
|
||||||
@ -31,179 +30,172 @@ type Props = {
|
|||||||
user: ICurrentUserResponse | undefined;
|
user: ICurrentUserResponse | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultValues: Partial<IState> = {
|
const defaultValues: Partial<LabelForm> = {
|
||||||
name: "",
|
name: "",
|
||||||
color: "rgb(var(--color-text-200))",
|
color: "rgb(var(--color-text-200))",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CreateLabelModal: React.FC<Props> = ({
|
export const CreateLabelModal: React.FC<Props> = observer(
|
||||||
isOpen,
|
({ isOpen, projectId, handleClose, user, onSuccess }) => {
|
||||||
projectId,
|
const router = useRouter();
|
||||||
handleClose,
|
const { workspaceSlug } = router.query;
|
||||||
user,
|
|
||||||
onSuccess,
|
|
||||||
}) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug } = router.query;
|
|
||||||
|
|
||||||
const {
|
const { label: labelStore } = useMobxStore();
|
||||||
register,
|
const { createLabel } = labelStore;
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
handleSubmit,
|
|
||||||
watch,
|
|
||||||
control,
|
|
||||||
reset,
|
|
||||||
setValue,
|
|
||||||
} = useForm<IIssueLabels>({
|
|
||||||
defaultValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
const {
|
||||||
if (isOpen) setValue("color", getRandomLabelColor());
|
register,
|
||||||
}, [setValue, isOpen]);
|
formState: { errors, isSubmitting },
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
} = useForm<IIssueLabels>({
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
const onClose = () => {
|
useEffect(() => {
|
||||||
handleClose();
|
if (isOpen) setValue("color", getRandomLabelColor());
|
||||||
reset(defaultValues);
|
}, [setValue, isOpen]);
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (formData: IIssueLabels) => {
|
const onClose = () => {
|
||||||
if (!workspaceSlug) return;
|
handleClose();
|
||||||
|
reset(defaultValues);
|
||||||
|
};
|
||||||
|
|
||||||
await issuesService
|
const onSubmit = async (formData: LabelForm) => {
|
||||||
.createIssueLabel(workspaceSlug as string, projectId as string, formData, user)
|
if (!workspaceSlug || !user) return;
|
||||||
.then((res) => {
|
|
||||||
mutate<IIssueLabels[]>(
|
|
||||||
PROJECT_ISSUE_LABELS(projectId),
|
|
||||||
(prevData) => [res, ...(prevData ?? [])],
|
|
||||||
false
|
|
||||||
);
|
|
||||||
onClose();
|
|
||||||
if (onSuccess) onSuccess(res);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.log(error);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
await createLabel(workspaceSlug.toString(), projectId as string, formData, user)
|
||||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
.then((response: any) => {
|
||||||
<Dialog as="div" className="relative z-30" onClose={onClose}>
|
onClose();
|
||||||
<Transition.Child
|
if (onSuccess) onSuccess(response);
|
||||||
as={React.Fragment}
|
})
|
||||||
enter="ease-out duration-300"
|
.catch((error) => {
|
||||||
enterFrom="opacity-0"
|
console.log(error);
|
||||||
enterTo="opacity-100"
|
});
|
||||||
leave="ease-in duration-200"
|
};
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
return (
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||||
<Transition.Child
|
<Dialog as="div" className="relative z-30" onClose={onClose}>
|
||||||
as={React.Fragment}
|
<Transition.Child
|
||||||
enter="ease-out duration-300"
|
as={React.Fragment}
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
enter="ease-out duration-300"
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
enterFrom="opacity-0"
|
||||||
leave="ease-in duration-200"
|
enterTo="opacity-100"
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leave="ease-in duration-200"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveFrom="opacity-100"
|
||||||
>
|
leaveTo="opacity-0"
|
||||||
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-90 px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
>
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
|
||||||
<div>
|
</Transition.Child>
|
||||||
<Dialog.Title
|
|
||||||
as="h3"
|
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||||
className="text-lg font-medium leading-6 text-custom-text-100"
|
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||||
>
|
<Transition.Child
|
||||||
Create Label
|
as={React.Fragment}
|
||||||
</Dialog.Title>
|
enter="ease-out duration-300"
|
||||||
<div className="mt-8 flex items-center gap-2">
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
<Popover className="relative">
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
{({ open, close }) => (
|
leave="ease-in duration-200"
|
||||||
<>
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
<Popover.Button
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
className={`group inline-flex items-center rounded-sm py-2 text-base font-medium hover:text-custom-text-100 focus:outline-none ${
|
>
|
||||||
open ? "text-custom-text-100" : "text-custom-text-200"
|
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-90 px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||||
}`}
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
>
|
<div>
|
||||||
{watch("color") && watch("color") !== "" && (
|
<Dialog.Title
|
||||||
<span
|
as="h3"
|
||||||
className="ml-2 h-5 w-5 rounded"
|
className="text-lg font-medium leading-6 text-custom-text-100"
|
||||||
style={{
|
>
|
||||||
backgroundColor: watch("color") ?? "black",
|
Create Label
|
||||||
}}
|
</Dialog.Title>
|
||||||
/>
|
<div className="mt-8 flex items-center gap-2">
|
||||||
)}
|
<Popover className="relative">
|
||||||
<ChevronDownIcon
|
{({ open, close }) => (
|
||||||
className={`ml-2 h-5 w-5 group-hover:text-custom-text-200 ${
|
<>
|
||||||
open ? "text-gray-600" : "text-gray-400"
|
<Popover.Button
|
||||||
|
className={`group inline-flex items-center rounded-sm py-2 text-base font-medium hover:text-custom-text-100 focus:outline-none ${
|
||||||
|
open ? "text-custom-text-100" : "text-custom-text-200"
|
||||||
}`}
|
}`}
|
||||||
aria-hidden="true"
|
>
|
||||||
/>
|
{watch("color") && watch("color") !== "" && (
|
||||||
</Popover.Button>
|
<span
|
||||||
|
className="ml-2 h-5 w-5 rounded"
|
||||||
<Transition
|
style={{
|
||||||
as={React.Fragment}
|
backgroundColor: watch("color") ?? "black",
|
||||||
enter="transition ease-out duration-200"
|
}}
|
||||||
enterFrom="opacity-0 translate-y-1"
|
/>
|
||||||
enterTo="opacity-100 translate-y-0"
|
)}
|
||||||
leave="transition ease-in duration-150"
|
<ChevronDownIcon
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
className={`ml-2 h-5 w-5 group-hover:text-custom-text-200 ${
|
||||||
leaveTo="opacity-0 translate-y-1"
|
open ? "text-gray-600" : "text-gray-400"
|
||||||
>
|
}`}
|
||||||
<Popover.Panel className="fixed left-5 z-50 mt-3 w-screen max-w-xs transform px-2 sm:px-0">
|
aria-hidden="true"
|
||||||
<Controller
|
|
||||||
name="color"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<TwitterPicker
|
|
||||||
color={value}
|
|
||||||
colors={LABEL_COLOR_OPTIONS}
|
|
||||||
onChange={(value) => {
|
|
||||||
onChange(value.hex);
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</Popover.Panel>
|
</Popover.Button>
|
||||||
</Transition>
|
|
||||||
</>
|
<Transition
|
||||||
)}
|
as={React.Fragment}
|
||||||
</Popover>
|
enter="transition ease-out duration-200"
|
||||||
<div className="flex w-full flex-col gap-0.5 justify-center">
|
enterFrom="opacity-0 translate-y-1"
|
||||||
<Input
|
enterTo="opacity-100 translate-y-0"
|
||||||
type="text"
|
leave="transition ease-in duration-150"
|
||||||
id="name"
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
name="name"
|
leaveTo="opacity-0 translate-y-1"
|
||||||
placeholder="Label title"
|
>
|
||||||
autoComplete="off"
|
<Popover.Panel className="fixed left-5 z-50 mt-3 w-screen max-w-xs transform px-2 sm:px-0">
|
||||||
error={errors.name}
|
<Controller
|
||||||
register={register}
|
name="color"
|
||||||
width="full"
|
control={control}
|
||||||
validations={{
|
render={({ field: { value, onChange } }) => (
|
||||||
required: "Label title is required",
|
<TwitterPicker
|
||||||
}}
|
color={value}
|
||||||
/>
|
colors={LABEL_COLOR_OPTIONS}
|
||||||
|
onChange={(value) => {
|
||||||
|
onChange(value.hex);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Popover.Panel>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
<div className="flex w-full flex-col gap-0.5 justify-center">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="Label title"
|
||||||
|
autoComplete="off"
|
||||||
|
error={errors.name}
|
||||||
|
register={register}
|
||||||
|
width="full"
|
||||||
|
validations={{
|
||||||
|
required: "Label title is required",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
<div className="mt-5 flex justify-end gap-2">
|
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
{isSubmitting ? "Creating Label..." : "Create Label"}
|
||||||
{isSubmitting ? "Creating Label..." : "Create Label"}
|
</PrimaryButton>
|
||||||
</PrimaryButton>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</form>
|
</Dialog.Panel>
|
||||||
</Dialog.Panel>
|
</Transition.Child>
|
||||||
</Transition.Child>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Dialog>
|
||||||
</Dialog>
|
</Transition.Root>
|
||||||
</Transition.Root>
|
);
|
||||||
);
|
}
|
||||||
};
|
);
|
||||||
|
@ -2,7 +2,9 @@ import React, { forwardRef, useEffect } from "react";
|
|||||||
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { mutate } from "swr";
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
// react-hook-form
|
// react-hook-form
|
||||||
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
||||||
@ -12,23 +14,20 @@ import useUserAuth from "hooks/use-user-auth";
|
|||||||
import { TwitterPicker } from "react-color";
|
import { TwitterPicker } from "react-color";
|
||||||
// headless ui
|
// headless ui
|
||||||
import { Popover, Transition } from "@headlessui/react";
|
import { Popover, Transition } from "@headlessui/react";
|
||||||
// services
|
|
||||||
import issuesService from "services/issues.service";
|
|
||||||
// ui
|
// ui
|
||||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||||
// types
|
// types
|
||||||
import { IIssueLabels } from "types";
|
import { IIssueLabels, LabelLite } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
|
||||||
import { getRandomLabelColor, LABEL_COLOR_OPTIONS } from "constants/label";
|
import { getRandomLabelColor, LABEL_COLOR_OPTIONS } from "constants/label";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
labelForm: boolean;
|
labelForm: boolean;
|
||||||
setLabelForm: React.Dispatch<React.SetStateAction<boolean>>;
|
setLabelForm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
isUpdating: boolean;
|
isUpdating: boolean;
|
||||||
labelToUpdate: IIssueLabels | null;
|
labelToUpdate: LabelLite | null;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -37,13 +36,16 @@ const defaultValues: Partial<IIssueLabels> = {
|
|||||||
color: "rgb(var(--color-text-200))",
|
color: "rgb(var(--color-text-200))",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CreateUpdateLabelInline = forwardRef<HTMLDivElement, Props>(
|
export const CreateUpdateLabelInline = observer(
|
||||||
function CreateUpdateLabelInline(props, ref) {
|
forwardRef<HTMLDivElement, Props>(function CreateUpdateLabelInline(props, ref) {
|
||||||
const { labelForm, setLabelForm, isUpdating, labelToUpdate, onClose } = props;
|
const { labelForm, setLabelForm, isUpdating, labelToUpdate, onClose } = props;
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const { label: labelStore } = useMobxStore();
|
||||||
|
const { createLabel, updateLabel } = labelStore;
|
||||||
|
|
||||||
const { user } = useUserAuth();
|
const { user } = useUserAuth();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -65,41 +67,27 @@ export const CreateUpdateLabelInline = forwardRef<HTMLDivElement, Props>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
|
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
|
||||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
if (!workspaceSlug || !projectId || isSubmitting || !user) return;
|
||||||
|
|
||||||
await issuesService
|
await createLabel(workspaceSlug.toString(), projectId.toString(), formData, user).finally(
|
||||||
.createIssueLabel(workspaceSlug as string, projectId as string, formData, user)
|
() => {
|
||||||
.then((res) => {
|
|
||||||
mutate<IIssueLabels[]>(
|
|
||||||
PROJECT_ISSUE_LABELS(projectId as string),
|
|
||||||
(prevData) => [res, ...(prevData ?? [])],
|
|
||||||
false
|
|
||||||
);
|
|
||||||
handleClose();
|
handleClose();
|
||||||
});
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLabelUpdate: SubmitHandler<IIssueLabels> = async (formData) => {
|
const handleLabelUpdate: SubmitHandler<IIssueLabels> = async (formData) => {
|
||||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
if (!workspaceSlug || !projectId || isSubmitting || !user) return;
|
||||||
|
|
||||||
await issuesService
|
await updateLabel(
|
||||||
.patchIssueLabel(
|
workspaceSlug.toString(),
|
||||||
workspaceSlug as string,
|
projectId.toString(),
|
||||||
projectId as string,
|
labelToUpdate?.id ?? "",
|
||||||
labelToUpdate?.id ?? "",
|
formData,
|
||||||
formData,
|
user
|
||||||
user
|
).finally(() => {
|
||||||
)
|
handleClose();
|
||||||
.then(() => {
|
});
|
||||||
reset(defaultValues);
|
|
||||||
mutate<IIssueLabels[]>(
|
|
||||||
PROJECT_ISSUE_LABELS(projectId as string),
|
|
||||||
(prevData) =>
|
|
||||||
prevData?.map((p) => (p.id === labelToUpdate?.id ? { ...p, ...formData } : p)),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
handleClose();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -212,5 +200,5 @@ export const CreateUpdateLabelInline = forwardRef<HTMLDivElement, Props>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
@ -2,36 +2,37 @@ import React, { useState } from "react";
|
|||||||
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { mutate } from "swr";
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
// headless ui
|
// headless ui
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
// icons
|
// icons
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
// services
|
|
||||||
import issuesService from "services/issues.service";
|
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// ui
|
// ui
|
||||||
import { DangerButton, SecondaryButton } from "components/ui";
|
import { DangerButton, SecondaryButton } from "components/ui";
|
||||||
// types
|
// types
|
||||||
import type { ICurrentUserResponse, IIssueLabels } from "types";
|
import type { ICurrentUserResponse, LabelLite } from "types";
|
||||||
// fetch-keys
|
|
||||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
data: IIssueLabels | null;
|
data: LabelLite | null;
|
||||||
user: ICurrentUserResponse | undefined;
|
user: ICurrentUserResponse | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteLabelModal: React.FC<Props> = ({ isOpen, onClose, data, user }) => {
|
export const DeleteLabelModal: React.FC<Props> = observer(({ isOpen, onClose, data, user }) => {
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const { label: labelStore } = useMobxStore();
|
||||||
|
const { deleteLabel } = labelStore;
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@ -40,28 +41,21 @@ export const DeleteLabelModal: React.FC<Props> = ({ isOpen, onClose, data, user
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeletion = async () => {
|
const handleDeletion = async () => {
|
||||||
if (!workspaceSlug || !projectId || !data) return;
|
if (!workspaceSlug || !projectId || !data || !user) return;
|
||||||
|
|
||||||
setIsDeleteLoading(true);
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
mutate<IIssueLabels[]>(
|
await deleteLabel(workspaceSlug.toString(), projectId.toString(), data.id, user)
|
||||||
PROJECT_ISSUE_LABELS(projectId.toString()),
|
|
||||||
(prevData) => (prevData ?? []).filter((p) => p.id !== data.id),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
await issuesService
|
|
||||||
.deleteIssueLabel(workspaceSlug.toString(), projectId.toString(), data.id, user)
|
|
||||||
.then(() => handleClose())
|
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setIsDeleteLoading(false);
|
|
||||||
|
|
||||||
mutate(PROJECT_ISSUE_LABELS(projectId.toString()));
|
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
title: "Error!",
|
title: "Error!",
|
||||||
message: "Label could not be deleted. Please try again.",
|
message: "Label could not be deleted. Please try again.",
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
handleClose();
|
||||||
|
setIsDeleteLoading(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -130,4 +124,4 @@ export const DeleteLabelModal: React.FC<Props> = ({ isOpen, onClose, data, user
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</Transition.Root>
|
</Transition.Root>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -2,178 +2,159 @@ import React, { useState } from "react";
|
|||||||
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import useSWR, { mutate } from "swr";
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
// headless ui
|
// headless ui
|
||||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||||
// icons
|
// icons
|
||||||
import { RectangleStackIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
import { RectangleStackIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||||
// services
|
|
||||||
import issuesService from "services/issues.service";
|
|
||||||
// types
|
// types
|
||||||
import { ICurrentUserResponse, IIssueLabels } from "types";
|
import { ICurrentUserResponse, LabelLite } from "types";
|
||||||
// constants
|
|
||||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
parent: IIssueLabels | undefined;
|
parent: LabelLite | undefined;
|
||||||
user: ICurrentUserResponse | undefined;
|
user: ICurrentUserResponse | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LabelsListModal: React.FC<Props> = ({ isOpen, handleClose, parent, user }) => {
|
export const LabelsListModal: React.FC<Props> = observer(
|
||||||
const [query, setQuery] = useState("");
|
({ isOpen, handleClose, parent, user }) => {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
const { data: issueLabels, mutate } = useSWR<IIssueLabels[]>(
|
const { label: labelStore } = useMobxStore();
|
||||||
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
|
const { updateLabel, getLabelChildren, getFilteredLabels } = labelStore;
|
||||||
workspaceSlug && projectId
|
|
||||||
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredLabels: IIssueLabels[] =
|
const filteredLabels = getFilteredLabels(query);
|
||||||
query === ""
|
|
||||||
? issueLabels ?? []
|
|
||||||
: issueLabels?.filter((l) => l.name.toLowerCase().includes(query.toLowerCase())) ?? [];
|
|
||||||
|
|
||||||
const handleModalClose = () => {
|
const handleModalClose = () => {
|
||||||
handleClose();
|
handleClose();
|
||||||
setQuery("");
|
setQuery("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const addChildLabel = async (label: IIssueLabels) => {
|
const addChildLabel = async (label: LabelLite) => {
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId || !user) return;
|
||||||
|
|
||||||
mutate(
|
updateLabel(
|
||||||
(prevData: any) =>
|
workspaceSlug.toString(),
|
||||||
prevData?.map((l: any) => {
|
projectId.toString(),
|
||||||
if (l.id === label.id) return { ...l, parent: parent?.id ?? "" };
|
|
||||||
|
|
||||||
return l;
|
|
||||||
}),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
await issuesService
|
|
||||||
.patchIssueLabel(
|
|
||||||
workspaceSlug as string,
|
|
||||||
projectId as string,
|
|
||||||
label.id,
|
label.id,
|
||||||
{
|
{
|
||||||
parent: parent?.id ?? "",
|
parent: parent?.id ?? "",
|
||||||
},
|
},
|
||||||
user
|
user
|
||||||
)
|
);
|
||||||
.then(() => mutate());
|
};
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||||
<Dialog as="div" className="relative z-20" onClose={handleModalClose}>
|
<Dialog as="div" className="relative z-20" onClose={handleModalClose}>
|
||||||
<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-custom-backdrop bg-opacity-50 transition-opacity" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
|
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={React.Fragment}
|
as={React.Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
enterFrom="opacity-0 scale-95"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100 scale-100"
|
enterTo="opacity-100"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform rounded-xl border border-custom-border-200 bg-custom-background-100 shadow-2xl transition-all">
|
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||||
<Combobox>
|
</Transition.Child>
|
||||||
<div className="relative m-1">
|
|
||||||
<MagnifyingGlassIcon
|
|
||||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-custom-text-100 text-opacity-40"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<Combobox.Input
|
|
||||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-custom-text-100 outline-none focus:ring-0 sm:text-sm"
|
|
||||||
placeholder="Search..."
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto">
|
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||||
{filteredLabels.length > 0 && (
|
<Transition.Child
|
||||||
<li className="p-2">
|
as={React.Fragment}
|
||||||
{query === "" && (
|
enter="ease-out duration-300"
|
||||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-custom-text-100">
|
enterFrom="opacity-0 scale-95"
|
||||||
Labels
|
enterTo="opacity-100 scale-100"
|
||||||
</h2>
|
leave="ease-in duration-200"
|
||||||
)}
|
leaveFrom="opacity-100 scale-100"
|
||||||
<ul className="text-sm text-gray-700">
|
leaveTo="opacity-0 scale-95"
|
||||||
{filteredLabels.map((label) => {
|
>
|
||||||
const children = issueLabels?.filter((l) => l.parent === label.id);
|
<Dialog.Panel className="relative mx-auto max-w-2xl transform rounded-xl border border-custom-border-200 bg-custom-background-100 shadow-2xl transition-all">
|
||||||
|
<Combobox>
|
||||||
if (
|
<div className="relative m-1">
|
||||||
(label.parent === "" || label.parent === null) && // issue does not have any other parent
|
<MagnifyingGlassIcon
|
||||||
label.id !== parent?.id && // issue is not itself
|
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-custom-text-100 text-opacity-40"
|
||||||
children?.length === 0 // issue doesn't have any othe children
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<Combobox.Option
|
|
||||||
key={label.id}
|
|
||||||
value={{
|
|
||||||
name: label.name,
|
|
||||||
}}
|
|
||||||
className={({ active }) =>
|
|
||||||
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
|
|
||||||
active ? "bg-custom-background-80 text-custom-text-100" : ""
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
addChildLabel(label);
|
|
||||||
handleClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: label.color !== "" ? label.color : "#000000",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{label.name}
|
|
||||||
</Combobox.Option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</Combobox.Options>
|
|
||||||
|
|
||||||
{query !== "" && filteredLabels.length === 0 && (
|
|
||||||
<div className="py-14 px-6 text-center sm:px-14">
|
|
||||||
<RectangleStackIcon
|
|
||||||
className="mx-auto h-6 w-6 text-custom-text-100 text-opacity-40"
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<p className="mt-4 text-sm text-custom-text-100">
|
<Combobox.Input
|
||||||
We couldn{"'"}t find any label with that term. Please try again.
|
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-custom-text-100 outline-none focus:ring-0 sm:text-sm"
|
||||||
</p>
|
placeholder="Search..."
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</Combobox>
|
<Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto">
|
||||||
</Dialog.Panel>
|
{filteredLabels.length > 0 && (
|
||||||
</Transition.Child>
|
<li className="p-2">
|
||||||
</div>
|
{query === "" && (
|
||||||
</Dialog>
|
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-custom-text-100">
|
||||||
</Transition.Root>
|
Labels
|
||||||
);
|
</h2>
|
||||||
};
|
)}
|
||||||
|
<ul className="text-sm text-gray-700">
|
||||||
|
{filteredLabels.map((label) => {
|
||||||
|
const children = getLabelChildren(label.id);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(label.parent === "" || label.parent === null) && // issue does not have any other parent
|
||||||
|
label.id !== parent?.id && // issue is not itself
|
||||||
|
children?.length === 0 // issue doesn't have any othe children
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Combobox.Option
|
||||||
|
key={label.id}
|
||||||
|
value={{
|
||||||
|
name: label.name,
|
||||||
|
}}
|
||||||
|
className={({ active }) =>
|
||||||
|
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
|
||||||
|
active ? "bg-custom-background-80 text-custom-text-100" : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
addChildLabel(label);
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: label.color !== "" ? label.color : "#000000",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{label.name}
|
||||||
|
</Combobox.Option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</Combobox.Options>
|
||||||
|
|
||||||
|
{query !== "" && filteredLabels.length === 0 && (
|
||||||
|
<div className="py-14 px-6 text-center sm:px-14">
|
||||||
|
<RectangleStackIcon
|
||||||
|
className="mx-auto h-6 w-6 text-custom-text-100 text-opacity-40"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<p className="mt-4 text-sm text-custom-text-100">
|
||||||
|
We couldn{"'"}t find any label with that term. Please try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Combobox>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
@ -2,12 +2,12 @@ import React from "react";
|
|||||||
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { mutate } from "swr";
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
// headless ui
|
// headless ui
|
||||||
import { Disclosure, Transition } from "@headlessui/react";
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
// services
|
|
||||||
import issuesService from "services/issues.service";
|
|
||||||
// ui
|
// ui
|
||||||
import { CustomMenu } from "components/ui";
|
import { CustomMenu } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
@ -20,159 +20,142 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
// types
|
// types
|
||||||
import { ICurrentUserResponse, IIssueLabels } from "types";
|
import { ICurrentUserResponse, LabelLite } from "types";
|
||||||
// fetch-keys
|
|
||||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label: IIssueLabels;
|
label: LabelLite;
|
||||||
labelChildren: IIssueLabels[];
|
labelChildren: LabelLite[];
|
||||||
addLabelToGroup: (parentLabel: IIssueLabels) => void;
|
addLabelToGroup: (parentLabel: LabelLite) => void;
|
||||||
editLabel: (label: IIssueLabels) => void;
|
editLabel: (label: LabelLite) => void;
|
||||||
handleLabelDelete: () => void;
|
handleLabelDelete: (label: LabelLite) => void;
|
||||||
user: ICurrentUserResponse | undefined;
|
user: ICurrentUserResponse | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SingleLabelGroup: React.FC<Props> = ({
|
export const SingleLabelGroup: React.FC<Props> = observer(
|
||||||
label,
|
({ label, labelChildren, addLabelToGroup, editLabel, handleLabelDelete, user }) => {
|
||||||
labelChildren,
|
const router = useRouter();
|
||||||
addLabelToGroup,
|
const { workspaceSlug, projectId } = router.query;
|
||||||
editLabel,
|
|
||||||
handleLabelDelete,
|
|
||||||
user,
|
|
||||||
}) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug, projectId } = router.query;
|
|
||||||
|
|
||||||
const removeFromGroup = (label: IIssueLabels) => {
|
const { label: labelStore } = useMobxStore();
|
||||||
if (!workspaceSlug || !projectId) return;
|
const { updateLabel } = labelStore;
|
||||||
|
|
||||||
mutate<IIssueLabels[]>(
|
const removeFromGroup = (label: LabelLite) => {
|
||||||
PROJECT_ISSUE_LABELS(projectId as string),
|
if (!workspaceSlug || !projectId || !user) return;
|
||||||
(prevData) =>
|
|
||||||
prevData?.map((l) => {
|
|
||||||
if (l.id === label.id) return { ...l, parent: null };
|
|
||||||
|
|
||||||
return l;
|
updateLabel(
|
||||||
}),
|
workspaceSlug.toString(),
|
||||||
false
|
projectId.toString(),
|
||||||
);
|
|
||||||
|
|
||||||
issuesService
|
|
||||||
.patchIssueLabel(
|
|
||||||
workspaceSlug as string,
|
|
||||||
projectId as string,
|
|
||||||
label.id,
|
label.id,
|
||||||
{
|
{
|
||||||
parent: null,
|
parent: null,
|
||||||
},
|
},
|
||||||
user
|
user
|
||||||
)
|
);
|
||||||
.then(() => {
|
};
|
||||||
mutate(PROJECT_ISSUE_LABELS(projectId as string));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Disclosure
|
<Disclosure
|
||||||
as="div"
|
as="div"
|
||||||
className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5 text-custom-text-100"
|
className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5 text-custom-text-100"
|
||||||
defaultOpen
|
defaultOpen
|
||||||
>
|
>
|
||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<>
|
<>
|
||||||
<div className="flex cursor-pointer items-center justify-between gap-2">
|
<div className="flex cursor-pointer items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>
|
|
||||||
<RectangleGroupIcon className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<h6>{label.name}</h6>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CustomMenu ellipsis>
|
|
||||||
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<PlusIcon className="h-4 w-4" />
|
|
||||||
<span>Add more labels</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem onClick={() => editLabel(label)}>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<PencilIcon className="h-4 w-4" />
|
|
||||||
<span>Edit label</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem onClick={handleLabelDelete}>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
<span>Delete label</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
</CustomMenu>
|
|
||||||
<Disclosure.Button>
|
|
||||||
<span>
|
<span>
|
||||||
<ChevronDownIcon
|
<RectangleGroupIcon className="h-4 w-4" />
|
||||||
className={`h-4 w-4 text-custom-text-100 ${!open ? "rotate-90 transform" : ""}`}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
</Disclosure.Button>
|
<h6>{label.name}</h6>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Transition
|
|
||||||
show={open}
|
|
||||||
enter="transition duration-100 ease-out"
|
|
||||||
enterFrom="transform opacity-0"
|
|
||||||
enterTo="transform opacity-100"
|
|
||||||
leave="transition duration-75 ease-out"
|
|
||||||
leaveFrom="transform opacity-100"
|
|
||||||
leaveTo="transform opacity-0"
|
|
||||||
>
|
|
||||||
<Disclosure.Panel>
|
|
||||||
<div className="mt-3 ml-6 space-y-3">
|
|
||||||
{labelChildren.map((child) => (
|
|
||||||
<div
|
|
||||||
key={child.id}
|
|
||||||
className="group flex items-center justify-between rounded-md border border-custom-border-200 p-2 text-sm"
|
|
||||||
>
|
|
||||||
<h5 className="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
child.color && child.color !== "" ? child.color : "#000000",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{child.name}
|
|
||||||
</h5>
|
|
||||||
<div className="pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100">
|
|
||||||
<CustomMenu ellipsis>
|
|
||||||
<CustomMenu.MenuItem onClick={() => removeFromGroup(child)}>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<XMarkIcon className="h-4 w-4" />
|
|
||||||
<span>Remove from group</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem onClick={() => editLabel(child)}>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<PencilIcon className="h-4 w-4" />
|
|
||||||
<span>Edit label</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem onClick={handleLabelDelete}>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
<span>Delete label</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
</CustomMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</Disclosure.Panel>
|
<div className="flex items-center gap-2">
|
||||||
</Transition>
|
<CustomMenu ellipsis>
|
||||||
</>
|
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
|
||||||
)}
|
<span className="flex items-center justify-start gap-2">
|
||||||
</Disclosure>
|
<PlusIcon className="h-4 w-4" />
|
||||||
);
|
<span>Add more labels</span>
|
||||||
};
|
</span>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem onClick={() => editLabel(label)}>
|
||||||
|
<span className="flex items-center justify-start gap-2">
|
||||||
|
<PencilIcon className="h-4 w-4" />
|
||||||
|
<span>Edit label</span>
|
||||||
|
</span>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem onClick={() => handleLabelDelete(label)}>
|
||||||
|
<span className="flex items-center justify-start gap-2">
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
<span>Delete label</span>
|
||||||
|
</span>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
</CustomMenu>
|
||||||
|
<Disclosure.Button>
|
||||||
|
<span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={`h-4 w-4 text-custom-text-100 ${
|
||||||
|
!open ? "rotate-90 transform" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Disclosure.Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Transition
|
||||||
|
show={open}
|
||||||
|
enter="transition duration-100 ease-out"
|
||||||
|
enterFrom="transform opacity-0"
|
||||||
|
enterTo="transform opacity-100"
|
||||||
|
leave="transition duration-75 ease-out"
|
||||||
|
leaveFrom="transform opacity-100"
|
||||||
|
leaveTo="transform opacity-0"
|
||||||
|
>
|
||||||
|
<Disclosure.Panel>
|
||||||
|
<div className="mt-3 ml-6 space-y-3">
|
||||||
|
{labelChildren.map((child) => (
|
||||||
|
<div
|
||||||
|
key={child.id}
|
||||||
|
className="group flex items-center justify-between rounded-md border border-custom-border-200 p-2 text-sm"
|
||||||
|
>
|
||||||
|
<h5 className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
child.color && child.color !== "" ? child.color : "#000000",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{child.name}
|
||||||
|
</h5>
|
||||||
|
<div className="pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||||
|
<CustomMenu ellipsis>
|
||||||
|
<CustomMenu.MenuItem onClick={() => removeFromGroup(child)}>
|
||||||
|
<span className="flex items-center justify-start gap-2">
|
||||||
|
<XMarkIcon className="h-4 w-4" />
|
||||||
|
<span>Remove from group</span>
|
||||||
|
</span>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem onClick={() => editLabel(child)}>
|
||||||
|
<span className="flex items-center justify-start gap-2">
|
||||||
|
<PencilIcon className="h-4 w-4" />
|
||||||
|
<span>Edit label</span>
|
||||||
|
</span>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem onClick={() => handleLabelDelete(child)}>
|
||||||
|
<span className="flex items-center justify-start gap-2">
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
<span>Delete label</span>
|
||||||
|
</span>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
</CustomMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Disclosure.Panel>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Disclosure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
@ -3,14 +3,14 @@ import React from "react";
|
|||||||
// ui
|
// ui
|
||||||
import { CustomMenu } from "components/ui";
|
import { CustomMenu } from "components/ui";
|
||||||
// types
|
// types
|
||||||
import { IIssueLabels } from "types";
|
import { LabelLite } from "types";
|
||||||
//icons
|
//icons
|
||||||
import { RectangleGroupIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
|
import { RectangleGroupIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label: IIssueLabels;
|
label: LabelLite;
|
||||||
addLabelToGroup: (parentLabel: IIssueLabels) => void;
|
addLabelToGroup: (parentLabel: LabelLite) => void;
|
||||||
editLabel: (label: IIssueLabels) => void;
|
editLabel: (label: LabelLite) => void;
|
||||||
handleLabelDelete: () => void;
|
handleLabelDelete: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,14 +1,17 @@
|
|||||||
import React, { useState, useRef } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
|
||||||
// hooks
|
// hooks
|
||||||
import useUserAuth from "hooks/use-user-auth";
|
import useUserAuth from "hooks/use-user-auth";
|
||||||
// services
|
// services
|
||||||
import projectService from "services/project.service";
|
import projectService from "services/project.service";
|
||||||
import issuesService from "services/issues.service";
|
|
||||||
// layouts
|
// layouts
|
||||||
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
|
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
|
||||||
// components
|
// components
|
||||||
@ -28,10 +31,10 @@ import { PlusIcon } from "@heroicons/react/24/outline";
|
|||||||
// images
|
// images
|
||||||
import emptyLabel from "public/empty-state/label.svg";
|
import emptyLabel from "public/empty-state/label.svg";
|
||||||
// types
|
// types
|
||||||
import { IIssueLabels } from "types";
|
import { LabelLite } from "types";
|
||||||
import type { NextPage } from "next";
|
import type { NextPage } from "next";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { PROJECT_DETAILS, PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
import { PROJECT_DETAILS } from "constants/fetch-keys";
|
||||||
// helper
|
// helper
|
||||||
import { truncateText } from "helpers/string.helper";
|
import { truncateText } from "helpers/string.helper";
|
||||||
|
|
||||||
@ -41,18 +44,21 @@ const LabelsSettings: NextPage = () => {
|
|||||||
|
|
||||||
// edit label
|
// edit label
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [labelToUpdate, setLabelToUpdate] = useState<IIssueLabels | null>(null);
|
const [labelToUpdate, setLabelToUpdate] = useState<LabelLite | null>(null);
|
||||||
|
|
||||||
// labels list modal
|
// labels list modal
|
||||||
const [labelsListModal, setLabelsListModal] = useState(false);
|
const [labelsListModal, setLabelsListModal] = useState(false);
|
||||||
const [parentLabel, setParentLabel] = useState<IIssueLabels | undefined>(undefined);
|
const [parentLabel, setParentLabel] = useState<LabelLite | undefined>(undefined);
|
||||||
|
|
||||||
// delete label
|
// delete label
|
||||||
const [selectDeleteLabel, setSelectDeleteLabel] = useState<IIssueLabels | null>(null);
|
const [selectDeleteLabel, setSelectDeleteLabel] = useState<LabelLite | null>(null);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const { label } = useMobxStore();
|
||||||
|
const { labels, isLabelsLoading: isLoading, getLabelChildren, loadLabels } = label;
|
||||||
|
|
||||||
const { user } = useUserAuth();
|
const { user } = useUserAuth();
|
||||||
|
|
||||||
const scrollToRef = useRef<HTMLDivElement>(null);
|
const scrollToRef = useRef<HTMLDivElement>(null);
|
||||||
@ -64,24 +70,21 @@ const LabelsSettings: NextPage = () => {
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: issueLabels } = useSWR(
|
useEffect(() => {
|
||||||
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
|
if (workspaceSlug && projectId) loadLabels(workspaceSlug.toString(), projectId.toString());
|
||||||
workspaceSlug && projectId
|
}, [loadLabels, projectId, workspaceSlug]);
|
||||||
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const newLabel = () => {
|
const newLabel = () => {
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
setLabelForm(true);
|
setLabelForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addLabelToGroup = (parentLabel: IIssueLabels) => {
|
const addLabelToGroup = (parentLabel: LabelLite) => {
|
||||||
setLabelsListModal(true);
|
setLabelsListModal(true);
|
||||||
setParentLabel(parentLabel);
|
setParentLabel(parentLabel);
|
||||||
};
|
};
|
||||||
|
|
||||||
const editLabel = (label: IIssueLabels) => {
|
const editLabel = (label: LabelLite) => {
|
||||||
setLabelForm(true);
|
setLabelForm(true);
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
setLabelToUpdate(label);
|
setLabelToUpdate(label);
|
||||||
@ -142,10 +145,10 @@ const LabelsSettings: NextPage = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<>
|
<>
|
||||||
{issueLabels ? (
|
{!isLoading ? (
|
||||||
issueLabels.length > 0 ? (
|
labels.length > 0 ? (
|
||||||
issueLabels.map((label) => {
|
labels.map((label) => {
|
||||||
const children = issueLabels?.filter((l) => l.parent === label.id);
|
const children = getLabelChildren(label.id);
|
||||||
|
|
||||||
if (children && children.length === 0) {
|
if (children && children.length === 0) {
|
||||||
if (!label.parent)
|
if (!label.parent)
|
||||||
@ -176,7 +179,7 @@ const LabelsSettings: NextPage = () => {
|
|||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
handleLabelDelete={() => setSelectDeleteLabel(label)}
|
handleLabelDelete={setSelectDeleteLabel}
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -210,4 +213,4 @@ const LabelsSettings: NextPage = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LabelsSettings;
|
export default observer(LabelsSettings);
|
||||||
|
158
apps/app/store/label.ts
Normal file
158
apps/app/store/label.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
// mobx
|
||||||
|
import { action, observable, runInAction, makeAutoObservable } from "mobx";
|
||||||
|
|
||||||
|
// services
|
||||||
|
import issueService from "services/issues.service";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import type { IIssueLabels, LabelLite, ICurrentUserResponse, LabelForm } from "types";
|
||||||
|
|
||||||
|
class LabelStore {
|
||||||
|
labels: LabelLite[] = [];
|
||||||
|
isLabelsLoading: boolean = false;
|
||||||
|
rootStore: any | null = null;
|
||||||
|
|
||||||
|
constructor(_rootStore: any | null = null) {
|
||||||
|
makeAutoObservable(this, {
|
||||||
|
labels: observable.ref,
|
||||||
|
loadLabels: action,
|
||||||
|
isLabelsLoading: observable,
|
||||||
|
createLabel: action,
|
||||||
|
updateLabel: action,
|
||||||
|
deleteLabel: action,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rootStore = _rootStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Fetch all labels of a project and hydrate labels field
|
||||||
|
*/
|
||||||
|
|
||||||
|
loadLabels = async (workspaceSlug: string, projectId: string) => {
|
||||||
|
this.isLabelsLoading = true;
|
||||||
|
try {
|
||||||
|
const labelsResponse: IIssueLabels[] = await issueService.getIssueLabels(
|
||||||
|
workspaceSlug,
|
||||||
|
projectId
|
||||||
|
);
|
||||||
|
runInAction(() => {
|
||||||
|
this.labels = labelsResponse.map((label) => ({
|
||||||
|
id: label.id,
|
||||||
|
name: label.name,
|
||||||
|
description: label.description,
|
||||||
|
color: label.color,
|
||||||
|
parent: label.parent,
|
||||||
|
}));
|
||||||
|
this.isLabelsLoading = false;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.isLabelsLoading = false;
|
||||||
|
console.error("Fetching labels error", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getLabelById = (labelId: string) => this.labels.find((label) => label.id === labelId);
|
||||||
|
|
||||||
|
getLabelChildren = (labelId: string) => this.labels.filter((label) => label.parent === labelId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For provided query, this function returns all labels that contain query in their name from the labels store.
|
||||||
|
* @param query - query string
|
||||||
|
* @returns {LabelLite[]} array of labels that contain query in their name
|
||||||
|
* @example
|
||||||
|
* getFilteredLabels("labe") // [{ id: "1", name: "label1", description: "", color: "", parent: null }]
|
||||||
|
*/
|
||||||
|
getFilteredLabels = (query: string): LabelLite[] =>
|
||||||
|
this.labels.filter((label) => label.name.includes(query));
|
||||||
|
|
||||||
|
createLabel = async (
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
labelForm: LabelForm,
|
||||||
|
user: ICurrentUserResponse
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const labelResponse: IIssueLabels = await issueService.createIssueLabel(
|
||||||
|
workspaceSlug,
|
||||||
|
projectId,
|
||||||
|
labelForm,
|
||||||
|
user
|
||||||
|
);
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.labels = [
|
||||||
|
...this.labels,
|
||||||
|
{
|
||||||
|
id: labelResponse.id,
|
||||||
|
name: labelResponse.name,
|
||||||
|
description: labelResponse.description,
|
||||||
|
color: labelResponse.color,
|
||||||
|
parent: labelResponse.parent,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
this.labels.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
});
|
||||||
|
return labelResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Creating label error", error);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateLabel = async (
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
labelId: string,
|
||||||
|
labelForm: Partial<LabelForm>,
|
||||||
|
user: ICurrentUserResponse
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const labelResponse: IIssueLabels = await issueService.patchIssueLabel(
|
||||||
|
workspaceSlug,
|
||||||
|
projectId,
|
||||||
|
labelId,
|
||||||
|
labelForm,
|
||||||
|
user
|
||||||
|
);
|
||||||
|
|
||||||
|
const _labels = [...this.labels].map((label) => {
|
||||||
|
if (label.id === labelId) {
|
||||||
|
return {
|
||||||
|
id: labelResponse.id,
|
||||||
|
name: labelResponse.name,
|
||||||
|
description: labelResponse.description,
|
||||||
|
color: labelResponse.color,
|
||||||
|
parent: labelResponse.parent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
});
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.labels = _labels;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Updating label error", error);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteLabel = async (
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
labelId: string,
|
||||||
|
user: ICurrentUserResponse
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
issueService.deleteIssueLabel(workspaceSlug, projectId, labelId, user);
|
||||||
|
runInAction(() => {
|
||||||
|
this.labels = this.labels.filter((label) => label.id !== labelId);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Deleting label error", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LabelStore;
|
@ -3,6 +3,7 @@ import { enableStaticRendering } from "mobx-react-lite";
|
|||||||
// store imports
|
// store imports
|
||||||
import UserStore from "./user";
|
import UserStore from "./user";
|
||||||
import ThemeStore from "./theme";
|
import ThemeStore from "./theme";
|
||||||
|
import LabelStore from "./label";
|
||||||
import ProjectPublishStore, { IProjectPublishStore } from "./project-publish";
|
import ProjectPublishStore, { IProjectPublishStore } from "./project-publish";
|
||||||
|
|
||||||
enableStaticRendering(typeof window === "undefined");
|
enableStaticRendering(typeof window === "undefined");
|
||||||
@ -10,11 +11,13 @@ enableStaticRendering(typeof window === "undefined");
|
|||||||
export class RootStore {
|
export class RootStore {
|
||||||
user;
|
user;
|
||||||
theme;
|
theme;
|
||||||
|
label: LabelStore;
|
||||||
projectPublish: IProjectPublishStore;
|
projectPublish: IProjectPublishStore;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.user = new UserStore(this);
|
this.user = new UserStore(this);
|
||||||
this.theme = new ThemeStore(this);
|
this.theme = new ThemeStore(this);
|
||||||
|
this.label = new LabelStore(this);
|
||||||
this.projectPublish = new ProjectPublishStore(this);
|
this.projectPublish = new ProjectPublishStore(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
14
apps/app/types/issues.d.ts
vendored
14
apps/app/types/issues.d.ts
vendored
@ -164,6 +164,20 @@ export interface IIssueLabels {
|
|||||||
parent: string | null;
|
parent: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LabelForm {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
parent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Issue label's lite version
|
||||||
|
*/
|
||||||
|
export interface LabelLite extends LabelForm {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IIssueActivity {
|
export interface IIssueActivity {
|
||||||
actor: string;
|
actor: string;
|
||||||
actor_detail: IUserLite;
|
actor_detail: IUserLite;
|
||||||
|
Loading…
Reference in New Issue
Block a user