forked from github/plane
dev: copy shortcuts, magic login links, improved settings page
This commit is contained in:
parent
6037fed3f4
commit
97544c1760
@ -5,18 +5,18 @@ import { useRouter } from "next/router";
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import useTheme from "lib/hooks/useTheme";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||
import { DocumentPlusIcon, FolderPlusIcon, FolderIcon } from "@heroicons/react/24/outline";
|
||||
// commons
|
||||
import { classNames } from "constants/common";
|
||||
import { classNames, copyTextToClipboard } from "constants/common";
|
||||
// components
|
||||
import ShortcutsModal from "components/command-palette/shortcuts";
|
||||
import CreateProjectModal from "components/project/CreateProjectModal";
|
||||
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
|
||||
import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal";
|
||||
// hooks
|
||||
import useTheme from "lib/hooks/useTheme";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
type ItemType = {
|
||||
@ -40,6 +40,8 @@ const CommandPalette: React.FC = () => {
|
||||
|
||||
const { toggleCollapsed } = useTheme();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
query === ""
|
||||
? issues?.results ?? []
|
||||
@ -72,7 +74,7 @@ const CommandPalette: React.FC = () => {
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "/") {
|
||||
if (e.ctrlKey && e.key === "/") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if (e.ctrlKey && e.key === "i") {
|
||||
@ -90,9 +92,28 @@ const CommandPalette: React.FC = () => {
|
||||
} else if (e.ctrlKey && e.key === "q") {
|
||||
e.preventDefault();
|
||||
setIsCreateCycleModalOpen(true);
|
||||
} else if (e.ctrlKey && e.altKey && e.key === "c") {
|
||||
e.preventDefault();
|
||||
|
||||
if (!router.query.issueId) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
[toggleCollapsed]
|
||||
[toggleCollapsed, setToastAlert, router]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -59,7 +59,7 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
{
|
||||
title: "Navigation",
|
||||
shortcuts: [
|
||||
{ key: "/", description: "To open navigator" },
|
||||
{ key: "Ctrl + /", description: "To open navigator" },
|
||||
{ key: "↑", description: "Move up" },
|
||||
{ key: "↓", description: "Move down" },
|
||||
{ key: "←", description: "Move left" },
|
||||
@ -75,6 +75,10 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
{ key: "Ctrl + i", description: "To open create issue modal" },
|
||||
{ key: "Ctrl + q", description: "To open create cycle modal" },
|
||||
{ key: "Ctrl + h", description: "To open shortcuts guide" },
|
||||
{
|
||||
key: "Ctrl + alt + c",
|
||||
description: "To copy issue url when on issue detail page.",
|
||||
},
|
||||
],
|
||||
},
|
||||
].map(({ title, shortcuts }) => (
|
||||
|
@ -92,7 +92,6 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
const checkIdentifier = (slug: string, value: string) => {
|
||||
projectServices.checkProjectIdentifierAvailability(slug, value).then((response) => {
|
||||
console.log(response);
|
||||
|
||||
if (response.exists) setError("identifier", { message: "Identifier already exists" });
|
||||
});
|
||||
};
|
||||
|
@ -130,11 +130,11 @@ const SprintView: React.FC<Props> = ({
|
||||
<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,
|
||||
backgroundColor: `${issue.issue_details.state_detail?.color}20`,
|
||||
borderColor: issue.issue_details.state_detail?.color,
|
||||
}}
|
||||
>
|
||||
{issue.issue_details.state_detail.name}
|
||||
{issue.issue_details.state_detail?.name}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<Menu>
|
||||
|
@ -1,24 +1,21 @@
|
||||
import React, { useContext } from "react";
|
||||
import React from "react";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// react hook form
|
||||
import { Controller } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// service
|
||||
import projectServices from "lib/services/project.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// fetch keys
|
||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
// icons
|
||||
import { CheckIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
// types
|
||||
import type { Control } from "react-hook-form";
|
||||
import type { IIssue, WorkspaceMember } from "types";
|
||||
import { UserIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
import { SearchListbox } from "ui";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
};
|
||||
@ -38,86 +35,17 @@ const SelectAssignee: React.FC<Props> = ({ control }) => {
|
||||
control={control}
|
||||
name="assignees_list"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Listbox
|
||||
<SearchListbox
|
||||
title="Assignees"
|
||||
optionsFontsize="sm"
|
||||
options={people?.map((person) => {
|
||||
return { value: person.member.id, display: person.member.first_name };
|
||||
})}
|
||||
multiple={true}
|
||||
value={value}
|
||||
onChange={(data: any) => {
|
||||
const valueCopy = [...(value ?? [])];
|
||||
if (valueCopy.some((i) => i === data)) onChange(valueCopy.filter((i) => i !== data));
|
||||
else onChange([...valueCopy, data]);
|
||||
}}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<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">
|
||||
<UserIcon className="h-3 w-3" />
|
||||
<span className="block truncate">
|
||||
{value && value.length > 0
|
||||
? value
|
||||
.map(
|
||||
(id) =>
|
||||
people
|
||||
?.find((i) => i.member.id === id)
|
||||
?.member.email.substring(0, 4) + "..."
|
||||
)
|
||||
.join(", ")
|
||||
: "Assignees"}
|
||||
</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 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">
|
||||
<div className="p-1">
|
||||
{people?.map((person) => (
|
||||
<Listbox.Option
|
||||
key={person.member.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "text-white bg-theme" : "text-gray-900"
|
||||
} cursor-pointer select-none relative p-2 rounded-md`
|
||||
}
|
||||
value={person.member.id}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
selected || (value ?? []).some((i) => i === person.member.id)
|
||||
? "font-semibold"
|
||||
: "font-normal"
|
||||
} block truncate`}
|
||||
>
|
||||
{person.member.email}
|
||||
</span>
|
||||
|
||||
{selected ? (
|
||||
<span
|
||||
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||
active || (value ?? []).some((i) => i === person.member.id)
|
||||
? "text-white"
|
||||
: "text-indigo-600"
|
||||
}`}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
onChange={onChange}
|
||||
icon={<UserIcon className="h-4 w-4 text-gray-400" />}
|
||||
/>
|
||||
)}
|
||||
></Controller>
|
||||
);
|
||||
|
@ -1,12 +1,8 @@
|
||||
import React from "react";
|
||||
// react hook form
|
||||
import { Controller } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// icons
|
||||
import { CheckIcon } from "@heroicons/react/20/solid";
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
import type { Control } from "react-hook-form";
|
||||
@ -16,12 +12,14 @@ 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;
|
||||
?.project_detail?.identifier;
|
||||
|
||||
const sequenceId = projectIssues?.results?.find(
|
||||
(i) => i.id.toString() === issueId?.toString()
|
||||
@ -36,53 +34,29 @@ const SelectParent: React.FC<Props> = ({ control }) => {
|
||||
control={control}
|
||||
name="parent"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Listbox as="div" value={value} onChange={onChange}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<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">
|
||||
<UserIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="block truncate">{getSelectedIssueKey(value?.toString())}</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 mt-1 bg-white shadow-lg max-h-28 max-w-[15rem] rounded-md py-1 text-xs ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
<div className="p-1">
|
||||
{projectIssues?.results?.map((issue) => (
|
||||
<Listbox.Option
|
||||
key={issue.id}
|
||||
value={issue.id}
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none p-2 rounded-md ${
|
||||
active ? "bg-theme text-white" : "text-gray-900"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ active, selected }) => (
|
||||
<>
|
||||
<span className={`block truncate ${selected && "font-medium"}`}>
|
||||
<span className="font-medium">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</span>{" "}
|
||||
{issue.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
<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}
|
||||
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" />}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
@ -6,8 +6,7 @@ import { Listbox, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// icons
|
||||
import { CheckIcon } from "@heroicons/react/20/solid";
|
||||
import { ClipboardDocumentListIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { Spinner } from "ui";
|
||||
// types
|
||||
|
@ -1,16 +1,12 @@
|
||||
import React from "react";
|
||||
// react hook form
|
||||
import { Controller } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// components
|
||||
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
|
||||
// icons
|
||||
import { CheckIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { PlusIcon } from "@heroicons/react/20/solid";
|
||||
// ui
|
||||
import { Spinner } from "ui";
|
||||
import { CustomListbox } from "ui";
|
||||
// types
|
||||
import type { Control } from "react-hook-form";
|
||||
import type { IIssue } from "types";
|
||||
@ -18,11 +14,10 @@ import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
data?: IIssue;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
const SelectState: React.FC<Props> = ({ control, data, setIsOpen }) => {
|
||||
const SelectState: React.FC<Props> = ({ control, setIsOpen }) => {
|
||||
const { states } = useUser();
|
||||
|
||||
return (
|
||||
@ -31,90 +26,30 @@ const SelectState: React.FC<Props> = ({ control, data, setIsOpen }) => {
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<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">
|
||||
<Squares2X2Icon className="h-3 w-3" />
|
||||
<span className="block truncate">
|
||||
{states?.find((i) => i.id === value)?.name ?? "State"}
|
||||
</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 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">
|
||||
<div className="p-1">
|
||||
{states ? (
|
||||
states.filter((i) => i.id !== data?.id).length > 0 ? (
|
||||
states
|
||||
.filter((i) => i.id !== data?.id)
|
||||
.map((state) => (
|
||||
<Listbox.Option
|
||||
key={state.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "text-white bg-theme" : "text-gray-900"
|
||||
} cursor-pointer select-none relative p-2 rounded-md`
|
||||
}
|
||||
value={state.id}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
selected ? "font-semibold" : "font-normal"
|
||||
} block truncate`}
|
||||
>
|
||||
{state.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>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-400">No states found!</p>
|
||||
)
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
<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>
|
||||
</>
|
||||
|
@ -1,10 +1,18 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
// fetching keys
|
||||
import { PROJECT_ISSUES_DETAILS, PROJECT_ISSUES_LIST, CYCLE_ISSUES } from "constants/fetch-keys";
|
||||
import {
|
||||
PROJECT_ISSUES_DETAILS,
|
||||
PROJECT_ISSUES_LIST,
|
||||
CYCLE_ISSUES,
|
||||
USER_ISSUE,
|
||||
} from "constants/fetch-keys";
|
||||
// headless
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
@ -15,7 +23,7 @@ import useToast from "lib/hooks/useToast";
|
||||
// ui
|
||||
import { Button, Input, TextArea } from "ui";
|
||||
// commons
|
||||
import { renderDateFormat } from "constants/common";
|
||||
import { renderDateFormat, cosineSimilarity } from "constants/common";
|
||||
// components
|
||||
import SelectState from "./SelectState";
|
||||
import SelectCycles from "./SelectCycles";
|
||||
@ -55,6 +63,10 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
||||
const [isCycleModalOpen, setIsCycleModalOpen] = useState(false);
|
||||
const [isStateModalOpen, setIsStateModalOpen] = useState(false);
|
||||
|
||||
const [mostSimilarIssue, setMostSimilarIssue] = useState<string | undefined>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
if (data) {
|
||||
@ -69,7 +81,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const { activeWorkspace, activeProject } = useUser();
|
||||
const { activeWorkspace, activeProject, user, issues } = useUser();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@ -165,6 +177,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
if (formData.sprints && formData.sprints !== null) {
|
||||
await addIssueToSprint(res.id, formData.sprints, formData);
|
||||
}
|
||||
@ -175,6 +188,15 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
||||
type: "success",
|
||||
message: `Issue ${data ? "updated" : "created"} successfully`,
|
||||
});
|
||||
if (formData.assignees_list.some((assignee) => assignee === user?.id)) {
|
||||
mutate<IIssue[]>(
|
||||
USER_ISSUE,
|
||||
(prevData) => {
|
||||
return [res, ...(prevData ?? [])];
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
Object.keys(err).map((key) => {
|
||||
@ -235,6 +257,10 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
||||
});
|
||||
}, [data, prePopulateData, reset, projectId, activeProject, isOpen, watch]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setMostSimilarIssue(undefined);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeProject && (
|
||||
@ -293,6 +319,13 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
||||
label="Name"
|
||||
name="name"
|
||||
rows={1}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const similarIssue = issues?.results.find(
|
||||
(i) => cosineSimilarity(i.name, value) > 0.7
|
||||
);
|
||||
setMostSimilarIssue(similarIssue?.id);
|
||||
}}
|
||||
className="resize-none"
|
||||
placeholder="Enter name"
|
||||
autoComplete="off"
|
||||
@ -302,6 +335,42 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
||||
required: "Name is required",
|
||||
}}
|
||||
/>
|
||||
{mostSimilarIssue && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
Did you mean{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMostSimilarIssue(undefined);
|
||||
router.push(
|
||||
`/projects/${activeProject?.id}/issues/${mostSimilarIssue}`
|
||||
);
|
||||
handleClose();
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
<span className="italic">
|
||||
{
|
||||
issues?.results.find(
|
||||
(issue) => issue.id === mostSimilarIssue
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
?
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-blue-500"
|
||||
onClick={() => {
|
||||
setMostSimilarIssue(undefined);
|
||||
}}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<TextArea
|
||||
|
@ -1,7 +1,8 @@
|
||||
// next
|
||||
import Link from "next/link";
|
||||
// react
|
||||
import React from "react";
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
// swr
|
||||
import useSWR, { mutate } from "swr";
|
||||
// ui
|
||||
@ -274,15 +275,31 @@ const ListView: React.FC<Props> = ({
|
||||
value={person.member.id}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center ${
|
||||
className={`flex items-center gap-x-1 ${
|
||||
assignees.includes(
|
||||
person.member.email
|
||||
person.member.first_name
|
||||
)
|
||||
? "font-medium"
|
||||
: "font-normal"
|
||||
}`}
|
||||
>
|
||||
{person.member.email}
|
||||
{person.member.avatar &&
|
||||
person.member.avatar !== "" ? (
|
||||
<div className="relative w-4 h-4">
|
||||
<Image
|
||||
src={person.member.avatar}
|
||||
alt="avatar"
|
||||
className="rounded-full"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p>
|
||||
{person.member.first_name.charAt(0)}
|
||||
</p>
|
||||
)}
|
||||
<p>{person.member.first_name}</p>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
|
@ -1,45 +1,32 @@
|
||||
// react
|
||||
import React from "react";
|
||||
// ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// services
|
||||
import issuesServices from "lib/services/issues.services";
|
||||
import stateServices from "lib/services/state.services";
|
||||
// swr
|
||||
import useSWR, { mutate } from "swr";
|
||||
// types
|
||||
import { IIssue, IssueResponse, IState } from "types";
|
||||
// constants
|
||||
import { addSpaceIfCamelCase, classNames } from "constants/common";
|
||||
import { STATE_LIST, USER_ISSUE } from "constants/fetch-keys";
|
||||
import { STATE_LIST } from "constants/fetch-keys";
|
||||
// services
|
||||
import stateServices from "lib/services/state.services";
|
||||
// ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// types
|
||||
import { IIssue, IState } from "types";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
updateIssues: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
issue: Partial<IIssue>
|
||||
) => void;
|
||||
};
|
||||
|
||||
const ChangeStateDropdown = ({ issue }: Props) => {
|
||||
const ChangeStateDropdown: React.FC<Props> = ({ issue, updateIssues }) => {
|
||||
const { activeWorkspace } = useUser();
|
||||
|
||||
const partialUpdateIssue = (formData: Partial<IIssue>, projectId: string, issueId: string) => {
|
||||
if (!activeWorkspace) return;
|
||||
issuesServices
|
||||
.patchIssue(activeWorkspace.slug, projectId, issueId, formData)
|
||||
.then((response) => {
|
||||
// mutate<IssueResponse>(
|
||||
// USER_ISSUE,
|
||||
// (prevData) => ({
|
||||
// ...(prevData as IssueResponse),
|
||||
// }),
|
||||
// false
|
||||
// );
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const { data: states } = useSWR<IState[]>(
|
||||
activeWorkspace ? STATE_LIST(issue.project) : null,
|
||||
activeWorkspace ? () => stateServices.getStates(activeWorkspace.slug, issue.project) : null
|
||||
@ -51,7 +38,11 @@ const ChangeStateDropdown = ({ issue }: Props) => {
|
||||
as="div"
|
||||
value={issue.state}
|
||||
onChange={(data: string) => {
|
||||
partialUpdateIssue({ state: data }, issue.project, issue.id);
|
||||
if (!activeWorkspace) return;
|
||||
updateIssues(activeWorkspace.slug, issue.project, issue.id, {
|
||||
state: data,
|
||||
state_detail: states?.find((state) => state.id === data),
|
||||
});
|
||||
}}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
|
@ -107,3 +107,75 @@ export const addSpaceIfCamelCase = (str: string) => {
|
||||
export const replaceUnderscoreIfSnakeCase = (str: string) => {
|
||||
return str.replace(/_/g, " ");
|
||||
};
|
||||
|
||||
const fallbackCopyTextToClipboard = (text: string) => {
|
||||
var textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.top = "0";
|
||||
textArea.style.left = "0";
|
||||
textArea.style.position = "fixed";
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
// FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
|
||||
var successful = document.execCommand("copy");
|
||||
var msg = successful ? "successful" : "unsuccessful";
|
||||
console.log("Fallback: Copying text command was " + msg);
|
||||
} catch (err) {
|
||||
console.error("Fallback: Oops, unable to copy", err);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
export const copyTextToClipboard = async (text: string) => {
|
||||
if (!navigator.clipboard) {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const wordsVector = (str: string) => {
|
||||
const words = str.split(" ");
|
||||
const vector: any = {};
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
if (vector[word]) {
|
||||
vector[word] += 1;
|
||||
} else {
|
||||
vector[word] = 1;
|
||||
}
|
||||
}
|
||||
return vector;
|
||||
};
|
||||
|
||||
export const cosineSimilarity = (a: string, b: string) => {
|
||||
const vectorA = wordsVector(a.trim());
|
||||
const vectorB = wordsVector(b.trim());
|
||||
|
||||
const vectorAKeys = Object.keys(vectorA);
|
||||
const vectorBKeys = Object.keys(vectorB);
|
||||
|
||||
const union = vectorAKeys.concat(vectorBKeys);
|
||||
|
||||
let dotProduct = 0;
|
||||
let magnitudeA = 0;
|
||||
let magnitudeB = 0;
|
||||
|
||||
for (let i = 0; i < union.length; i++) {
|
||||
const key = union[i];
|
||||
const valueA = vectorA[key] || 0;
|
||||
const valueB = vectorB[key] || 0;
|
||||
dotProduct += valueA * valueB;
|
||||
magnitudeA += valueA * valueA;
|
||||
magnitudeB += valueB * valueB;
|
||||
}
|
||||
|
||||
return dotProduct / Math.sqrt(magnitudeA * magnitudeB);
|
||||
};
|
||||
|
@ -1,15 +1,16 @@
|
||||
import React, { useState } from "react";
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
import Image from "next/image";
|
||||
// services
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import userService from "lib/services/user.service";
|
||||
import authenticationService from "lib/services/authentication.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import useTheme from "lib/hooks/useTheme";
|
||||
// components
|
||||
import CreateProjectModal from "components/project/CreateProjectModal";
|
||||
// types
|
||||
import { IUser } from "types";
|
||||
// headless ui
|
||||
import { Dialog, Disclosure, Menu, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
@ -25,14 +26,14 @@ import {
|
||||
UserGroupIcon,
|
||||
UserIcon,
|
||||
XMarkIcon,
|
||||
InboxIcon,
|
||||
ArrowLongLeftIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// constants
|
||||
import { classNames } from "constants/common";
|
||||
import { Spinner } from "ui";
|
||||
import useTheme from "lib/hooks/useTheme";
|
||||
import authenticationService from "lib/services/authentication.service";
|
||||
// ui
|
||||
import { Spinner, Tooltip } from "ui";
|
||||
// types
|
||||
import type { IUser } from "types";
|
||||
|
||||
const navigation = (projectId: string) => [
|
||||
{
|
||||
@ -107,7 +108,7 @@ const Sidebar: React.FC = () => {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { projects } = useUser();
|
||||
const { projects, user } = useUser();
|
||||
|
||||
const { projectId } = router.query;
|
||||
|
||||
@ -205,7 +206,11 @@ const Sidebar: React.FC = () => {
|
||||
<div className="h-full flex flex-1 flex-col border-r border-gray-200">
|
||||
<div className="h-full flex flex-1 flex-col pt-5">
|
||||
<div className="px-2">
|
||||
<div className={`relative ${sidebarCollapse ? "flex" : "grid grid-cols-5 gap-1"}`}>
|
||||
<div
|
||||
className={`relative ${
|
||||
sidebarCollapse ? "flex" : "grid grid-cols-5 gap-1 items-center"
|
||||
}`}
|
||||
>
|
||||
<Menu as="div" className="col-span-4 inline-block text-left w-full">
|
||||
<div className="w-full">
|
||||
<Menu.Button
|
||||
@ -213,16 +218,25 @@ const Sidebar: React.FC = () => {
|
||||
!sidebarCollapse ? "hover:bg-gray-50 border border-gray-300 shadow-sm" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="flex gap-x-1 items-center">
|
||||
<p className="h-5 w-5 p-4 flex items-center justify-center bg-gray-500 text-white rounded uppercase">
|
||||
{activeWorkspace?.name?.charAt(0) ?? "N"}
|
||||
</p>
|
||||
<div className="flex gap-x-1 items-center">
|
||||
<div className="h-5 w-5 p-4 flex items-center justify-center bg-gray-500 text-white rounded uppercase relative">
|
||||
{activeWorkspace?.logo && activeWorkspace.logo !== "" ? (
|
||||
<Image
|
||||
src={activeWorkspace.logo}
|
||||
alt="Workspace Logo"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
activeWorkspace?.name?.charAt(0) ?? "N"
|
||||
)}
|
||||
</div>
|
||||
{!sidebarCollapse && (
|
||||
<p className="truncate w-20 text-left ml-1">
|
||||
{activeWorkspace?.name ?? "Loading..."}
|
||||
</p>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{!sidebarCollapse && (
|
||||
<div className="flex-grow flex justify-end">
|
||||
<ChevronDownIcon className="-mr-1 ml-2 h-5 w-5" aria-hidden="true" />
|
||||
@ -301,9 +315,19 @@ const Sidebar: React.FC = () => {
|
||||
</Menu>
|
||||
{!sidebarCollapse && (
|
||||
<Menu as="div" className="inline-block text-left w-full">
|
||||
<div className="h-full w-full">
|
||||
<Menu.Button className="grid place-items-center h-full w-full rounded-md shadow-sm px-2 py-2 bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 focus:outline-none">
|
||||
<UserIcon className="h-5 w-5" />
|
||||
<div className="h-10 w-10">
|
||||
<Menu.Button className="grid relative place-items-center h-full w-full rounded-md shadow-sm px-2 py-2 bg-white text-gray-700 hover:bg-gray-50 focus:outline-none">
|
||||
{user?.avatar && user.avatar !== "" ? (
|
||||
<Image
|
||||
src={user.avatar}
|
||||
alt="User Avatar"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<UserIcon className="h-5 w-5" />
|
||||
)}
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
@ -472,12 +496,13 @@ const Sidebar: React.FC = () => {
|
||||
}`}
|
||||
onClick={() => toggleCollapsed()}
|
||||
>
|
||||
<ArrowLongLeftIcon
|
||||
className={`h-4 w-4 text-gray-500 group-hover:text-gray-900 flex-shrink-0 duration-300 ${
|
||||
sidebarCollapse ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
{!sidebarCollapse && "Collapse"}
|
||||
<Tooltip content="Click to toggle sidebar" position="right">
|
||||
<ArrowLongLeftIcon
|
||||
className={`h-4 w-4 text-gray-500 group-hover:text-gray-900 flex-shrink-0 duration-300 ${
|
||||
sidebarCollapse ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
99
pages/magic-sign-in.tsx
Normal file
99
pages/magic-sign-in.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
// next
|
||||
import type { NextPage } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
// services
|
||||
import authenticationService from "lib/services/authentication.service";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// layouts
|
||||
import DefaultLayout from "layouts/DefaultLayout";
|
||||
|
||||
const MagicSignIn: NextPage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const [isSigningIn, setIsSigningIn] = useState(true);
|
||||
const [errorSigningIn, setErrorSignIn] = useState<string | undefined>();
|
||||
|
||||
const { password, key } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { mutateUser, mutateWorkspaces } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
setIsSigningIn(true);
|
||||
setErrorSignIn(undefined);
|
||||
if (!password || !key) return;
|
||||
authenticationService
|
||||
.magicSignIn({ token: password, key })
|
||||
.then(async (res) => {
|
||||
setIsSigningIn(false);
|
||||
await mutateUser();
|
||||
await mutateWorkspaces();
|
||||
if (res.user.is_onboarded) router.push("/");
|
||||
else router.push("/invitations");
|
||||
})
|
||||
.catch((err) => {
|
||||
setErrorSignIn(err.response.data.error);
|
||||
setIsSigningIn(false);
|
||||
});
|
||||
}, [password, key, mutateUser, mutateWorkspaces, router]);
|
||||
|
||||
return (
|
||||
<DefaultLayout
|
||||
meta={{
|
||||
title: "Magic Sign In",
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-screen flex justify-center items-center bg-gray-50 overflow-auto">
|
||||
{isSigningIn ? (
|
||||
<div className="w-full h-full flex flex-col gap-y-2 justify-center items-center">
|
||||
<h2 className="text-4xl">Signing you in...</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Please wait while we are preparing your take off.
|
||||
</p>
|
||||
</div>
|
||||
) : errorSigningIn ? (
|
||||
<div className="w-full h-full flex flex-col gap-y-2 justify-center items-center">
|
||||
<h2 className="text-4xl">Error</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
{errorSigningIn}.
|
||||
<span
|
||||
className="underline cursor-pointer"
|
||||
onClick={() => {
|
||||
authenticationService
|
||||
.emailCode({ email: (key as string).split("_")[1] })
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Email sent",
|
||||
message: "A new link/code has been send to you.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: "Unable to send email.",
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Send link again?
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col gap-y-2 justify-center items-center">
|
||||
<h2 className="text-4xl">Success</h2>
|
||||
<p className="text-sm text-gray-600">Redirecting you to the app...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MagicSignIn;
|
@ -1,5 +1,5 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
// next
|
||||
import type { NextPage } from "next";
|
||||
// swr
|
||||
@ -8,37 +8,65 @@ import useSWR from "swr";
|
||||
import ProjectLayout from "layouts/ProjectLayout";
|
||||
// hooks
|
||||
import useUser from "lib/hooks/useUser";
|
||||
// components
|
||||
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
|
||||
// ui
|
||||
import { Spinner } from "ui";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
|
||||
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
|
||||
import HeaderButton from "ui/HeaderButton";
|
||||
// icons
|
||||
import { PlusIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import userService from "lib/services/user.service";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// constants
|
||||
import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown";
|
||||
import { USER_ISSUE } from "constants/fetch-keys";
|
||||
import { classNames } from "constants/common";
|
||||
// services
|
||||
import userService from "lib/services/user.service";
|
||||
import issuesServices from "lib/services/issues.services";
|
||||
// components
|
||||
import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown";
|
||||
// icons
|
||||
import { PlusIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
|
||||
const MyIssues: NextPage = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { data: myIssues } = useSWR<IIssue[]>(
|
||||
const { data: myIssues, mutate: mutateMyIssue } = useSWR<IIssue[]>(
|
||||
user ? USER_ISSUE : null,
|
||||
user ? () => userService.userIssues() : null
|
||||
);
|
||||
|
||||
const updateMyIssues = (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
issue: Partial<IIssue>
|
||||
) => {
|
||||
mutateMyIssue((prevData) => {
|
||||
return prevData?.map((prevIssue) => {
|
||||
if (prevIssue.id === issueId) {
|
||||
return {
|
||||
...prevIssue,
|
||||
...issue,
|
||||
state_detail: {
|
||||
...prevIssue.state_detail,
|
||||
...issue.state_detail,
|
||||
},
|
||||
};
|
||||
}
|
||||
return prevIssue;
|
||||
});
|
||||
}, false);
|
||||
issuesServices
|
||||
.patchIssue(workspaceSlug, projectId, issueId, issue)
|
||||
.then((response) => {
|
||||
console.log(response);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<CreateUpdateIssuesModal isOpen={isOpen} setIsOpen={setIsOpen} />
|
||||
<div className="w-full h-full flex flex-col space-y-5">
|
||||
{myIssues ? (
|
||||
<>
|
||||
@ -116,13 +144,16 @@ const MyIssues: NextPage = () => {
|
||||
</td>
|
||||
<td className="px-3 py-4 max-w-[15rem]">{myIssue.description}</td>
|
||||
<td className="px-3 py-4">
|
||||
{myIssue.project_detail.name}
|
||||
{myIssue.project_detail?.name}
|
||||
<br />
|
||||
<span className="text-xs">{`(${myIssue.project_detail.identifier}-${myIssue.sequence_id})`}</span>
|
||||
<span className="text-xs">{`(${myIssue.project_detail?.identifier}-${myIssue.sequence_id})`}</span>
|
||||
</td>
|
||||
<td className="px-3 py-4 capitalize">{myIssue.priority}</td>
|
||||
<td className="relative px-3 py-4">
|
||||
<ChangeStateDropdown issue={myIssue} />
|
||||
<ChangeStateDropdown
|
||||
issue={myIssue}
|
||||
updateIssues={updateMyIssues}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -150,7 +181,13 @@ const MyIssues: NextPage = () => {
|
||||
</span>
|
||||
}
|
||||
Icon={PlusIcon}
|
||||
action={() => setIsOpen(true)}
|
||||
action={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "i",
|
||||
ctrlKey: true,
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
/>
|
||||
</EmptySpace>
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useCallback, useState } from "react";
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
// next
|
||||
@ -20,11 +20,15 @@ import useUser from "lib/hooks/useUser";
|
||||
import useToast from "lib/hooks/useToast";
|
||||
// fetch keys
|
||||
import { PROJECT_DETAILS, PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||
// commons
|
||||
import { addSpaceIfCamelCase, debounce } from "constants/common";
|
||||
// components
|
||||
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
|
||||
// ui
|
||||
import { Spinner, Button, Input, TextArea, Select } from "ui";
|
||||
import { Breadcrumbs, BreadcrumbItem } from "ui/Breadcrumbs";
|
||||
// icons
|
||||
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/24/outline";
|
||||
import { ChevronDownIcon, CheckIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IProject, IWorkspace, WorkspaceMember } from "types";
|
||||
|
||||
@ -41,16 +45,19 @@ const ProjectSettings: NextPage = () => {
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<IProject>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const [isCreateStateModalOpen, setIsCreateStateModalOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { projectId } = router.query;
|
||||
|
||||
const { activeWorkspace, activeProject } = useUser();
|
||||
const { activeWorkspace, activeProject, states } = useUser();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@ -81,6 +88,7 @@ const ProjectSettings: NextPage = () => {
|
||||
const payload: Partial<IProject> = {
|
||||
name: formData.name,
|
||||
network: formData.network,
|
||||
identifier: formData.identifier,
|
||||
description: formData.description,
|
||||
default_assignee: formData.default_assignee,
|
||||
project_lead: formData.project_lead,
|
||||
@ -113,9 +121,24 @@ const ProjectSettings: NextPage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const checkIdentifier = (slug: string, value: string) => {
|
||||
projectServices.checkProjectIdentifierAvailability(slug, value).then((response) => {
|
||||
console.log(response);
|
||||
if (response.exists) setError("identifier", { message: "Identifier already exists" });
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const checkIdentifierAvailability = useCallback(debounce(checkIdentifier, 1500), []);
|
||||
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<div className="w-full h-full space-y-5">
|
||||
<CreateUpdateStateModal
|
||||
isOpen={isCreateStateModalOpen}
|
||||
setIsOpen={setIsCreateStateModalOpen}
|
||||
projectId={projectId as string}
|
||||
/>
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem title="Projects" link="/projects" />
|
||||
<BreadcrumbItem title={`${activeProject?.name} Settings`} />
|
||||
@ -169,8 +192,20 @@ const ProjectSettings: NextPage = () => {
|
||||
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>
|
||||
@ -358,6 +393,40 @@ const ProjectSettings: NextPage = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<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
|
||||
className="border p-1 px-4 rounded flex items-center gap-x-2"
|
||||
key={state.id}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: state.color,
|
||||
}}
|
||||
></div>
|
||||
<h4>{addSpaceIfCamelCase(state.name)}</h4>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-x-1"
|
||||
onClick={() => setIsCreateStateModalOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 text-gray-400" />
|
||||
<span>Add State</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -1,31 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
const assignees = [
|
||||
{
|
||||
name: "Wade Cooper",
|
||||
value: "wade-cooper",
|
||||
},
|
||||
{ name: "Unassigned", value: "null" },
|
||||
];
|
||||
|
||||
import { SearchListbox } from "ui";
|
||||
|
||||
const Page = () => {
|
||||
const [assigned, setAssigned] = useState(assignees[0]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen w-full">
|
||||
<SearchListbox
|
||||
display="Assign"
|
||||
name="assignee"
|
||||
options={assignees}
|
||||
onChange={(value) => {
|
||||
setAssigned(assignees.find((assignee) => assignee.value === value) ?? assignees[0]);
|
||||
}}
|
||||
value={assigned.value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
@ -64,7 +64,10 @@ const WorkspaceSettings = () => {
|
||||
await mutateWorkspaces((workspaces) => {
|
||||
return (workspaces ?? []).map((workspace) => {
|
||||
if (workspace.slug === activeWorkspace.slug) {
|
||||
return res;
|
||||
return {
|
||||
...workspace,
|
||||
...res,
|
||||
};
|
||||
}
|
||||
return workspace;
|
||||
});
|
||||
|
163
ui/CustomListbox/index.tsx
Normal file
163
ui/CustomListbox/index.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import React from "react";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { CheckIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
import { Props } from "./types";
|
||||
|
||||
const CustomListbox: React.FC<Props> = ({
|
||||
title,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
multiple,
|
||||
icon,
|
||||
width,
|
||||
footerOption,
|
||||
optionsFontsize,
|
||||
className,
|
||||
label,
|
||||
}) => {
|
||||
return (
|
||||
<Listbox value={value} onChange={onChange} multiple={multiple}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
{label && (
|
||||
<Listbox.Label>
|
||||
<div className="text-gray-500 mb-2">{label}</div>
|
||||
</Listbox.Label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
className={`flex items-center gap-1 hover:bg-gray-100 relative 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 ${
|
||||
width === "sm"
|
||||
? "w-32"
|
||||
: width === "md"
|
||||
? "w-48"
|
||||
: width === "lg"
|
||||
? "w-64"
|
||||
: width === "xl"
|
||||
? "w-80"
|
||||
: width === "2xl"
|
||||
? "w-96"
|
||||
: width === "w-full"
|
||||
? "w-full"
|
||||
: ""
|
||||
}
|
||||
${className || "px-2 py-1"}`}
|
||||
>
|
||||
{icon ?? null}
|
||||
<span className="block truncate">
|
||||
{Array.isArray(value)
|
||||
? value.map((v) => options?.find((o) => o.value === v)?.display).join(", ") ||
|
||||
`${title}`
|
||||
: options?.find((o) => o.value === value)?.display || `${title}`}
|
||||
</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 mt-1 bg-white shadow-lg ${
|
||||
width === "sm"
|
||||
? "w-32"
|
||||
: width === "md"
|
||||
? "w-48"
|
||||
: width === "lg"
|
||||
? "w-64"
|
||||
: width === "xl"
|
||||
? "w-80"
|
||||
: width === "2xl"
|
||||
? "w-96"
|
||||
: width === "w-full"
|
||||
? "w-full"
|
||||
: ""
|
||||
} ${
|
||||
optionsFontsize === "sm"
|
||||
? "text-xs"
|
||||
: optionsFontsize === "md"
|
||||
? "text-base"
|
||||
: optionsFontsize === "lg"
|
||||
? "text-lg"
|
||||
: optionsFontsize === "xl"
|
||||
? "text-xl"
|
||||
: optionsFontsize === "2xl"
|
||||
? "text-2xl"
|
||||
: ""
|
||||
} ${
|
||||
className || ""
|
||||
} rounded-md py-1 ring-1 ring-black ring-opacity-5 focus:outline-none z-10`}
|
||||
>
|
||||
<div className="p-1">
|
||||
{options ? (
|
||||
options.length > 0 ? (
|
||||
options.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option.value}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "text-white bg-theme" : "text-gray-900"
|
||||
} cursor-pointer select-none relative p-2 rounded-md`
|
||||
}
|
||||
value={option.value}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
selected ||
|
||||
(Array.isArray(value)
|
||||
? value.includes(option.value)
|
||||
: value === option.value)
|
||||
? "font-semibold"
|
||||
: "font-normal"
|
||||
} block truncate`}
|
||||
>
|
||||
{option.display}
|
||||
</span>
|
||||
|
||||
{selected ||
|
||||
(Array.isArray(value)
|
||||
? value.includes(option.value)
|
||||
: value === option.value) ? (
|
||||
<span
|
||||
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
|
||||
active ||
|
||||
(Array.isArray(value)
|
||||
? value.includes(option.value)
|
||||
: value === option.value)
|
||||
? "text-white"
|
||||
: "text-indigo-600"
|
||||
}`}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 text-center">No options</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 text-center">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
{footerOption ?? null}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomListbox;
|
13
ui/CustomListbox/types.d.ts
vendored
Normal file
13
ui/CustomListbox/types.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
export type Props = {
|
||||
title: string;
|
||||
label?: string;
|
||||
options?: Array<{ display: string; value: any }>;
|
||||
icon?: JSX.Element;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
multiple?: boolean;
|
||||
width?: "sm" | "md" | "lg" | "xl" | "2xl" | "w-full";
|
||||
optionsFontsize?: "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
className?: string;
|
||||
footerOption?: JSX.Element;
|
||||
};
|
@ -1,52 +0,0 @@
|
||||
import React from "react";
|
||||
// headless ui
|
||||
import { Listbox } from "@headlessui/react";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
type Props = {
|
||||
value: any;
|
||||
placeholder: string | JSX.Element;
|
||||
className?: string;
|
||||
theme?: "white" | "purple";
|
||||
onChange: (value: any) => void;
|
||||
icon?: () => React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const ListBox: React.FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
icon,
|
||||
children,
|
||||
className,
|
||||
theme,
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
<Listbox.Button
|
||||
className={`p-2 rounded flex items-center gap-x-2 ${
|
||||
theme === "white"
|
||||
? "bg-white border border-gray-200"
|
||||
: "bg-purple-200"
|
||||
} ${className ? className : ""}`}
|
||||
>
|
||||
{icon && icon()}
|
||||
<p className="font-semibold">{placeholder}</p>
|
||||
<div className="flex-grow flex justify-end">
|
||||
<ChevronDownIcon width="20" height="20" />
|
||||
</div>
|
||||
</Listbox.Button>
|
||||
<Listbox.Options className="absolute mt-1 w-full bg-white border border-gray-300 flex flex-col gap-y-2 py-3 z-50">
|
||||
{children}
|
||||
</Listbox.Options>
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Listbox };
|
||||
|
||||
export default ListBox;
|
@ -7,18 +7,23 @@ import { classNames } from "constants/common";
|
||||
import type { Props } from "./types";
|
||||
|
||||
const SearchListbox: React.FC<Props> = ({
|
||||
display,
|
||||
title,
|
||||
options,
|
||||
onChange,
|
||||
value,
|
||||
multiple: canSelectMultiple,
|
||||
icon,
|
||||
width,
|
||||
optionsFontsize,
|
||||
buttonClassName,
|
||||
optionsClassName,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filteredOptions =
|
||||
query === ""
|
||||
? options
|
||||
: options.filter((option) => option.name.toLowerCase().includes(query.toLowerCase()));
|
||||
: options?.filter((option) => option.display.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const props: any = {
|
||||
value,
|
||||
@ -34,66 +39,112 @@ const SearchListbox: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-nowrap justify-end space-x-2 py-2 px-2 sm:px-3">
|
||||
<Combobox as="div" {...props} className="flex-shrink-0">
|
||||
{({ open }: any) => (
|
||||
<>
|
||||
<Combobox.Label className="sr-only"> {display} </Combobox.Label>
|
||||
<div className="relative">
|
||||
<Combobox.Button className="relative inline-flex items-center whitespace-nowrap rounded-full bg-gray-50 py-2 px-2 text-sm font-medium text-gray-500 hover:bg-gray-100 sm:px-3">
|
||||
<span
|
||||
className={classNames(
|
||||
value === null || value === undefined ? "" : "text-gray-900",
|
||||
"hidden truncate sm:ml-2 sm:block"
|
||||
)}
|
||||
>
|
||||
{value
|
||||
? options.find((option) => option.value === value)?.name ?? "None"
|
||||
: `Select ${display}`}
|
||||
</span>
|
||||
</Combobox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
<Combobox as="div" {...props} className="flex-shrink-0">
|
||||
{({ open }: any) => (
|
||||
<>
|
||||
<Combobox.Label className="sr-only"> {title} </Combobox.Label>
|
||||
<div className="relative">
|
||||
<Combobox.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 ${
|
||||
width === "sm"
|
||||
? "w-32"
|
||||
: width === "md"
|
||||
? "w-48"
|
||||
: width === "lg"
|
||||
? "w-64"
|
||||
: width === "xl"
|
||||
? "w-80"
|
||||
: width === "2xl"
|
||||
? "w-96"
|
||||
: ""
|
||||
} ${buttonClassName || ""}`}
|
||||
>
|
||||
{icon ?? null}
|
||||
<span
|
||||
className={classNames(
|
||||
value === null || value === undefined ? "" : "text-gray-900",
|
||||
"hidden truncate sm:ml-2 sm:block"
|
||||
)}
|
||||
>
|
||||
<Combobox.Options className="absolute right-0 z-10 mt-1 max-h-56 w-52 px-1 py-1 overflow-auto rounded-lg bg-white text-base shadow ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
<Combobox.Input
|
||||
className="w-full bg-transparent border-b py-2 pl-3 mb-1 focus:outline-none sm:text-sm"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
/>
|
||||
{filteredOptions.length > 0 ? (
|
||||
{Array.isArray(value)
|
||||
? value
|
||||
.map((v) => options?.find((option) => option.value === v)?.display)
|
||||
.join(", ") || title
|
||||
: options?.find((option) => option.value === value)?.display || title}
|
||||
</span>
|
||||
</Combobox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Combobox.Options
|
||||
className={`absolute mt-1 bg-white shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 focus:outline-none z-10 ${
|
||||
width === "sm"
|
||||
? "w-32"
|
||||
: width === "md"
|
||||
? "w-48"
|
||||
: width === "lg"
|
||||
? "w-64"
|
||||
: width === "xl"
|
||||
? "w-80"
|
||||
: width === "2xl"
|
||||
? "w-96"
|
||||
: ""
|
||||
}} ${
|
||||
optionsFontsize === "sm"
|
||||
? "text-xs"
|
||||
: optionsFontsize === "md"
|
||||
? "text-base"
|
||||
: optionsFontsize === "lg"
|
||||
? "text-lg"
|
||||
: optionsFontsize === "xl"
|
||||
? "text-xl"
|
||||
: optionsFontsize === "2xl"
|
||||
? "text-2xl"
|
||||
: ""
|
||||
} ${optionsClassName || ""}`}
|
||||
>
|
||||
<Combobox.Input
|
||||
className="w-full bg-transparent border-b py-2 pl-3 mb-1 focus:outline-none sm:text-sm"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
/>
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
className={({ active }) =>
|
||||
classNames(
|
||||
active ? "bg-gray-50" : "bg-white",
|
||||
"relative rounded cursor-default select-none py-2 px-3"
|
||||
)
|
||||
`${
|
||||
active ? "text-white bg-theme" : "text-gray-900"
|
||||
} cursor-pointer select-none relative p-2 rounded-md`
|
||||
}
|
||||
value={option.value}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="ml-3 block truncate font-medium">{option.name}</span>
|
||||
<span className="ml-3 block truncate font-medium">
|
||||
{option.element ?? option.display}
|
||||
</span>
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-gray-400 m-1 mt-0">No results found</div>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Combobox>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">No {title.toLowerCase()} found</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Loading...</p>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
|
10
ui/SearchListbox/types.d.ts
vendored
10
ui/SearchListbox/types.d.ts
vendored
@ -1,10 +1,14 @@
|
||||
type Value = any;
|
||||
|
||||
export type Props = {
|
||||
display: string;
|
||||
name: string;
|
||||
title: string;
|
||||
multiple?: boolean;
|
||||
options: Array<{ name: string; value: Value }>;
|
||||
options?: Array<{ display: string; element?: JSX.Element; value: Value }>;
|
||||
onChange: (value: Value) => void;
|
||||
value: Value;
|
||||
icon?: JSX.Element;
|
||||
buttonClassName?: string;
|
||||
optionsClassName?: string;
|
||||
width?: "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
optionsFontsize?: "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
};
|
||||
|
@ -1,59 +1,41 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
type TooltipProps = {
|
||||
content: string;
|
||||
position: string;
|
||||
children: any;
|
||||
className?: string;
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
position?: "top" | "bottom" | "left" | "right";
|
||||
};
|
||||
|
||||
const Tooltip: React.FC<TooltipProps> = (props) => {
|
||||
const myRef = useRef<any>();
|
||||
const myRef2 = useRef<any>();
|
||||
|
||||
const [position, setPosition] = useState<any>({});
|
||||
const [show, setShow] = useState<any>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const contentHeight = myRef2.current.clientHeight;
|
||||
|
||||
const pos = {
|
||||
x: myRef.current.offsetLeft + myRef.current.clientWidth / 2,
|
||||
y: myRef.current.offsetTop,
|
||||
};
|
||||
|
||||
setPosition(pos);
|
||||
}, []);
|
||||
|
||||
const Tooltip: React.FC<Props> = ({ children, content, position = "top" }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="inline-block z-99" ref={myRef}>
|
||||
<div className="relative group">
|
||||
<div
|
||||
className={`fixed pointer-events-none transition-opacity opacity-0 group-hover:opacity-100 bg-black text-white px-3 py-1 rounded ${
|
||||
position === "right"
|
||||
? "left-14"
|
||||
: position === "left"
|
||||
? "right-14"
|
||||
: position === "top"
|
||||
? "bottom-14"
|
||||
: "top-14"
|
||||
}`}
|
||||
>
|
||||
<p className="truncate text-sx">{content}</p>
|
||||
<span
|
||||
className={`bg-black text-white p-2 rounded text-xs fixed ${
|
||||
props.position === "top" || props.position === "bottom"
|
||||
? "translate-x-[-50%]"
|
||||
: "translate-y-[-50%]"
|
||||
} duration-300 ${
|
||||
show ? "opacity-1 pointer-events-all" : "opacity-0 pointer-events-none"
|
||||
} ${props.className}`}
|
||||
style={{ top: `${position.y}px`, left: `${position.x}px` }}
|
||||
ref={myRef2}
|
||||
>
|
||||
{props.content}
|
||||
{/* Lorem ipsum, dolor sit amet consectetur adipisicing elit.Illo consequuntur libero placeat
|
||||
porro facere itaque vitae, iusto quos fugiat consequatur. */}
|
||||
</span>
|
||||
{React.cloneElement(props.children, {
|
||||
onMouseOver: () => setShow(true),
|
||||
onMouseOut: () => setShow(false),
|
||||
})}
|
||||
className={`absolute w-2 h-2 bg-black ${
|
||||
position === "top"
|
||||
? "top-full left-1/2 transform -translate-y-1/2 -translate-x-1/2 rotate-45"
|
||||
: position === "bottom"
|
||||
? "bottom-full left-1/2 transform translate-y-1/2 -translate-x-1/2 rotate-45"
|
||||
: position === "left"
|
||||
? "left-full top-1/2 transform translate-x-1/2 -translate-y-1/2 rotate-45"
|
||||
: "right-full top-1/2 transform translate-x-1/2 -translate-y-1/2 rotate-45"
|
||||
}`}
|
||||
></span>
|
||||
</div>
|
||||
</>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Tooltip.defaultProps = {
|
||||
position: "top",
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
|
@ -2,7 +2,8 @@ export { default as Button } from "./Button";
|
||||
export { default as Input } from "./Input";
|
||||
export { default as Select } from "./Select";
|
||||
export { default as TextArea } from "./TextArea";
|
||||
export { default as ListBox } from "./ListBox";
|
||||
export { default as CustomListbox } from "./CustomListbox";
|
||||
export { default as Spinner } from "./Spinner";
|
||||
export { default as Tooltip } from "./Tooltip";
|
||||
export { default as SearchListbox } from "./SearchListbox";
|
||||
export { default as HeaderButton } from "./HeaderButton";
|
||||
|
Loading…
Reference in New Issue
Block a user