mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge pull request #1 from dakshesh14/main
dev: copy shortcuts, magic login links, improved settings page
This commit is contained in:
commit
0680545445
@ -5,18 +5,18 @@ import { useRouter } from "next/router";
|
|||||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||||
// hooks
|
// hooks
|
||||||
import useUser from "lib/hooks/useUser";
|
import useUser from "lib/hooks/useUser";
|
||||||
|
import useTheme from "lib/hooks/useTheme";
|
||||||
|
import useToast from "lib/hooks/useToast";
|
||||||
// icons
|
// icons
|
||||||
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||||
import { DocumentPlusIcon, FolderPlusIcon, FolderIcon } from "@heroicons/react/24/outline";
|
import { DocumentPlusIcon, FolderPlusIcon, FolderIcon } from "@heroicons/react/24/outline";
|
||||||
// commons
|
// commons
|
||||||
import { classNames } from "constants/common";
|
import { classNames, copyTextToClipboard } from "constants/common";
|
||||||
// components
|
// components
|
||||||
import ShortcutsModal from "components/command-palette/shortcuts";
|
import ShortcutsModal from "components/command-palette/shortcuts";
|
||||||
import CreateProjectModal from "components/project/CreateProjectModal";
|
import CreateProjectModal from "components/project/CreateProjectModal";
|
||||||
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
|
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
|
||||||
import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal";
|
import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal";
|
||||||
// hooks
|
|
||||||
import useTheme from "lib/hooks/useTheme";
|
|
||||||
// types
|
// types
|
||||||
import { IIssue } from "types";
|
import { IIssue } from "types";
|
||||||
type ItemType = {
|
type ItemType = {
|
||||||
@ -40,6 +40,8 @@ const CommandPalette: React.FC = () => {
|
|||||||
|
|
||||||
const { toggleCollapsed } = useTheme();
|
const { toggleCollapsed } = useTheme();
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
const filteredIssues: IIssue[] =
|
const filteredIssues: IIssue[] =
|
||||||
query === ""
|
query === ""
|
||||||
? issues?.results ?? []
|
? issues?.results ?? []
|
||||||
@ -72,7 +74,7 @@ const CommandPalette: React.FC = () => {
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
if (e.key === "/") {
|
if (e.ctrlKey && e.key === "/") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsPaletteOpen(true);
|
setIsPaletteOpen(true);
|
||||||
} else if (e.ctrlKey && e.key === "i") {
|
} else if (e.ctrlKey && e.key === "i") {
|
||||||
@ -90,9 +92,28 @@ const CommandPalette: React.FC = () => {
|
|||||||
} else if (e.ctrlKey && e.key === "q") {
|
} else if (e.ctrlKey && e.key === "q") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsCreateCycleModalOpen(true);
|
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(() => {
|
useEffect(() => {
|
||||||
|
@ -59,7 +59,7 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||||||
{
|
{
|
||||||
title: "Navigation",
|
title: "Navigation",
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ key: "/", description: "To open navigator" },
|
{ key: "Ctrl + /", description: "To open navigator" },
|
||||||
{ key: "↑", description: "Move up" },
|
{ key: "↑", description: "Move up" },
|
||||||
{ key: "↓", description: "Move down" },
|
{ key: "↓", description: "Move down" },
|
||||||
{ key: "←", description: "Move left" },
|
{ 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 + i", description: "To open create issue modal" },
|
||||||
{ key: "Ctrl + q", description: "To open create cycle modal" },
|
{ key: "Ctrl + q", description: "To open create cycle modal" },
|
||||||
{ key: "Ctrl + h", description: "To open shortcuts guide" },
|
{ 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 }) => (
|
].map(({ title, shortcuts }) => (
|
||||||
|
@ -92,7 +92,6 @@ const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
|||||||
const checkIdentifier = (slug: string, value: string) => {
|
const checkIdentifier = (slug: string, value: string) => {
|
||||||
projectServices.checkProjectIdentifierAvailability(slug, value).then((response) => {
|
projectServices.checkProjectIdentifierAvailability(slug, value).then((response) => {
|
||||||
console.log(response);
|
console.log(response);
|
||||||
|
|
||||||
if (response.exists) setError("identifier", { message: "Identifier already exists" });
|
if (response.exists) setError("identifier", { message: "Identifier already exists" });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -130,11 +130,11 @@ const SprintView: React.FC<Props> = ({
|
|||||||
<span
|
<span
|
||||||
className="text-black rounded px-2 py-0.5 text-sm border"
|
className="text-black rounded px-2 py-0.5 text-sm border"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${issue.issue_details.state_detail.color}20`,
|
backgroundColor: `${issue.issue_details.state_detail?.color}20`,
|
||||||
borderColor: issue.issue_details.state_detail.color,
|
borderColor: issue.issue_details.state_detail?.color,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{issue.issue_details.state_detail.name}
|
{issue.issue_details.state_detail?.name}
|
||||||
</span>
|
</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Menu>
|
<Menu>
|
||||||
|
@ -1,24 +1,21 @@
|
|||||||
import React, { useContext } from "react";
|
import React from "react";
|
||||||
// swr
|
// swr
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
// react hook form
|
// react hook form
|
||||||
import { Controller } from "react-hook-form";
|
import { Controller } from "react-hook-form";
|
||||||
// headless ui
|
|
||||||
import { Listbox, Transition } from "@headlessui/react";
|
|
||||||
// service
|
// service
|
||||||
import projectServices from "lib/services/project.service";
|
import projectServices from "lib/services/project.service";
|
||||||
// hooks
|
// hooks
|
||||||
import useUser from "lib/hooks/useUser";
|
import useUser from "lib/hooks/useUser";
|
||||||
// fetch keys
|
// fetch keys
|
||||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||||
// icons
|
|
||||||
import { CheckIcon } from "@heroicons/react/20/solid";
|
|
||||||
|
|
||||||
// types
|
// types
|
||||||
import type { Control } from "react-hook-form";
|
import type { Control } from "react-hook-form";
|
||||||
import type { IIssue, WorkspaceMember } from "types";
|
import type { IIssue, WorkspaceMember } from "types";
|
||||||
import { UserIcon } from "@heroicons/react/24/outline";
|
import { UserIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
import { SearchListbox } from "ui";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
control: Control<IIssue, any>;
|
control: Control<IIssue, any>;
|
||||||
};
|
};
|
||||||
@ -38,86 +35,17 @@ const SelectAssignee: React.FC<Props> = ({ control }) => {
|
|||||||
control={control}
|
control={control}
|
||||||
name="assignees_list"
|
name="assignees_list"
|
||||||
render={({ field: { value, onChange } }) => (
|
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}
|
value={value}
|
||||||
onChange={(data: any) => {
|
onChange={onChange}
|
||||||
const valueCopy = [...(value ?? [])];
|
icon={<UserIcon className="h-4 w-4 text-gray-400" />}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
></Controller>
|
></Controller>
|
||||||
);
|
);
|
||||||
|
@ -1,12 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
// react hook form
|
// react hook form
|
||||||
import { Controller } from "react-hook-form";
|
import { Controller } from "react-hook-form";
|
||||||
// headless ui
|
|
||||||
import { Listbox, Transition } from "@headlessui/react";
|
|
||||||
// hooks
|
// hooks
|
||||||
import useUser from "lib/hooks/useUser";
|
import useUser from "lib/hooks/useUser";
|
||||||
// icons
|
|
||||||
import { CheckIcon } from "@heroicons/react/20/solid";
|
|
||||||
// types
|
// types
|
||||||
import type { IIssue } from "types";
|
import type { IIssue } from "types";
|
||||||
import type { Control } from "react-hook-form";
|
import type { Control } from "react-hook-form";
|
||||||
@ -16,12 +12,14 @@ type Props = {
|
|||||||
control: Control<IIssue, any>;
|
control: Control<IIssue, any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import { SearchListbox } from "ui";
|
||||||
|
|
||||||
const SelectParent: React.FC<Props> = ({ control }) => {
|
const SelectParent: React.FC<Props> = ({ control }) => {
|
||||||
const { issues: projectIssues } = useUser();
|
const { issues: projectIssues } = useUser();
|
||||||
|
|
||||||
const getSelectedIssueKey = (issueId: string | undefined) => {
|
const getSelectedIssueKey = (issueId: string | undefined) => {
|
||||||
const identifier = projectIssues?.results?.find((i) => i.id.toString() === issueId?.toString())
|
const identifier = projectIssues?.results?.find((i) => i.id.toString() === issueId?.toString())
|
||||||
?.project_detail.identifier;
|
?.project_detail?.identifier;
|
||||||
|
|
||||||
const sequenceId = projectIssues?.results?.find(
|
const sequenceId = projectIssues?.results?.find(
|
||||||
(i) => i.id.toString() === issueId?.toString()
|
(i) => i.id.toString() === issueId?.toString()
|
||||||
@ -36,53 +34,29 @@ const SelectParent: React.FC<Props> = ({ control }) => {
|
|||||||
control={control}
|
control={control}
|
||||||
name="parent"
|
name="parent"
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<Listbox as="div" value={value} onChange={onChange}>
|
<SearchListbox
|
||||||
{({ open }) => (
|
title="Parent issue"
|
||||||
<>
|
optionsFontsize="sm"
|
||||||
<div className="relative">
|
options={projectIssues?.results?.map((issue) => {
|
||||||
<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">
|
return {
|
||||||
<UserIcon className="h-3 w-3 flex-shrink-0" />
|
value: issue.id,
|
||||||
<span className="block truncate">{getSelectedIssueKey(value?.toString())}</span>
|
display: issue.name,
|
||||||
</Listbox.Button>
|
element: (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
<Transition
|
<div className="block truncate">
|
||||||
show={open}
|
<span className="block truncate">{`${getSelectedIssueKey(issue.id)}`}</span>
|
||||||
as={React.Fragment}
|
<span className="block truncate text-gray-400">{issue.name}</span>
|
||||||
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>
|
</div>
|
||||||
</Listbox.Options>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
),
|
||||||
)}
|
};
|
||||||
</Listbox>
|
})}
|
||||||
|
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
|
// hooks
|
||||||
import useUser from "lib/hooks/useUser";
|
import useUser from "lib/hooks/useUser";
|
||||||
// icons
|
// icons
|
||||||
import { CheckIcon } from "@heroicons/react/20/solid";
|
import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline";
|
||||||
import { ClipboardDocumentListIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
|
|
||||||
// ui
|
// ui
|
||||||
import { Spinner } from "ui";
|
import { Spinner } from "ui";
|
||||||
// types
|
// types
|
||||||
|
@ -1,16 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
// react hook form
|
// react hook form
|
||||||
import { Controller } from "react-hook-form";
|
import { Controller } from "react-hook-form";
|
||||||
// headless ui
|
|
||||||
import { Listbox, Transition } from "@headlessui/react";
|
|
||||||
// hooks
|
// hooks
|
||||||
import useUser from "lib/hooks/useUser";
|
import useUser from "lib/hooks/useUser";
|
||||||
// components
|
|
||||||
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
|
|
||||||
// icons
|
// icons
|
||||||
import { CheckIcon, PlusIcon } from "@heroicons/react/20/solid";
|
import { PlusIcon } from "@heroicons/react/20/solid";
|
||||||
// ui
|
// ui
|
||||||
import { Spinner } from "ui";
|
import { CustomListbox } from "ui";
|
||||||
// types
|
// types
|
||||||
import type { Control } from "react-hook-form";
|
import type { Control } from "react-hook-form";
|
||||||
import type { IIssue } from "types";
|
import type { IIssue } from "types";
|
||||||
@ -18,11 +14,10 @@ import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
control: Control<IIssue, any>;
|
control: Control<IIssue, any>;
|
||||||
data?: IIssue;
|
|
||||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SelectState: React.FC<Props> = ({ control, data, setIsOpen }) => {
|
const SelectState: React.FC<Props> = ({ control, setIsOpen }) => {
|
||||||
const { states } = useUser();
|
const { states } = useUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -31,72 +26,16 @@ const SelectState: React.FC<Props> = ({ control, data, setIsOpen }) => {
|
|||||||
control={control}
|
control={control}
|
||||||
name="state"
|
name="state"
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<Listbox value={value} onChange={onChange}>
|
<CustomListbox
|
||||||
{({ open }) => (
|
title="State"
|
||||||
<>
|
options={states?.map((state) => {
|
||||||
<div className="relative">
|
return { value: state.id, display: state.name };
|
||||||
<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" />
|
value={value}
|
||||||
<span className="block truncate">
|
optionsFontsize="sm"
|
||||||
{states?.find((i) => i.id === value)?.name ?? "State"}
|
onChange={onChange}
|
||||||
</span>
|
icon={<Squares2X2Icon className="h-4 w-4 text-gray-400" />}
|
||||||
</Listbox.Button>
|
footerOption={
|
||||||
|
|
||||||
<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
|
<button
|
||||||
type="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"
|
className="select-none relative py-2 pl-3 pr-9 flex items-center gap-x-2 text-gray-400 hover:text-gray-500"
|
||||||
@ -109,12 +48,8 @@ const SelectState: React.FC<Props> = ({ control, data, setIsOpen }) => {
|
|||||||
<span className="block truncate">Create state</span>
|
<span className="block truncate">Create state</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</Listbox.Options>
|
}
|
||||||
</Transition>
|
/>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Listbox>
|
|
||||||
)}
|
)}
|
||||||
></Controller>
|
></Controller>
|
||||||
</>
|
</>
|
||||||
|
@ -1,10 +1,18 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
// next
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
// swr
|
// swr
|
||||||
import { mutate } from "swr";
|
import { mutate } from "swr";
|
||||||
// react hook form
|
// react hook form
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
// fetching keys
|
// 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
|
// headless
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
// services
|
// services
|
||||||
@ -15,7 +23,7 @@ import useToast from "lib/hooks/useToast";
|
|||||||
// ui
|
// ui
|
||||||
import { Button, Input, TextArea } from "ui";
|
import { Button, Input, TextArea } from "ui";
|
||||||
// commons
|
// commons
|
||||||
import { renderDateFormat } from "constants/common";
|
import { renderDateFormat, cosineSimilarity } from "constants/common";
|
||||||
// components
|
// components
|
||||||
import SelectState from "./SelectState";
|
import SelectState from "./SelectState";
|
||||||
import SelectCycles from "./SelectCycles";
|
import SelectCycles from "./SelectCycles";
|
||||||
@ -55,6 +63,10 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||||||
const [isCycleModalOpen, setIsCycleModalOpen] = useState(false);
|
const [isCycleModalOpen, setIsCycleModalOpen] = useState(false);
|
||||||
const [isStateModalOpen, setIsStateModalOpen] = useState(false);
|
const [isStateModalOpen, setIsStateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [mostSimilarIssue, setMostSimilarIssue] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
if (data) {
|
if (data) {
|
||||||
@ -69,7 +81,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { activeWorkspace, activeProject } = useUser();
|
const { activeWorkspace, activeProject, user, issues } = useUser();
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
@ -165,6 +177,7 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
if (formData.sprints && formData.sprints !== null) {
|
if (formData.sprints && formData.sprints !== null) {
|
||||||
await addIssueToSprint(res.id, formData.sprints, formData);
|
await addIssueToSprint(res.id, formData.sprints, formData);
|
||||||
}
|
}
|
||||||
@ -175,6 +188,15 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||||||
type: "success",
|
type: "success",
|
||||||
message: `Issue ${data ? "updated" : "created"} successfully`,
|
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) => {
|
.catch((err) => {
|
||||||
Object.keys(err).map((key) => {
|
Object.keys(err).map((key) => {
|
||||||
@ -235,6 +257,10 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
}, [data, prePopulateData, reset, projectId, activeProject, isOpen, watch]);
|
}, [data, prePopulateData, reset, projectId, activeProject, isOpen, watch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => setMostSimilarIssue(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{activeProject && (
|
{activeProject && (
|
||||||
@ -293,6 +319,13 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||||||
label="Name"
|
label="Name"
|
||||||
name="name"
|
name="name"
|
||||||
rows={1}
|
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"
|
className="resize-none"
|
||||||
placeholder="Enter name"
|
placeholder="Enter name"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@ -302,6 +335,42 @@ const CreateUpdateIssuesModal: React.FC<Props> = ({
|
|||||||
required: "Name is required",
|
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>
|
||||||
<div>
|
<div>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
// next
|
|
||||||
import Link from "next/link";
|
|
||||||
// react
|
// react
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
// next
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
// swr
|
// swr
|
||||||
import useSWR, { mutate } from "swr";
|
import useSWR, { mutate } from "swr";
|
||||||
// ui
|
// ui
|
||||||
@ -274,15 +275,31 @@ const ListView: React.FC<Props> = ({
|
|||||||
value={person.member.id}
|
value={person.member.id}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center ${
|
className={`flex items-center gap-x-1 ${
|
||||||
assignees.includes(
|
assignees.includes(
|
||||||
person.member.email
|
person.member.first_name
|
||||||
)
|
)
|
||||||
? "font-medium"
|
? "font-medium"
|
||||||
: "font-normal"
|
: "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>
|
</div>
|
||||||
</Listbox.Option>
|
</Listbox.Option>
|
||||||
))}
|
))}
|
||||||
|
@ -1,45 +1,32 @@
|
|||||||
// react
|
// react
|
||||||
import React from "react";
|
import React from "react";
|
||||||
// ui
|
// swr
|
||||||
import { Listbox, Transition } from "@headlessui/react";
|
import useSWR from "swr";
|
||||||
// hooks
|
// hooks
|
||||||
import useUser from "lib/hooks/useUser";
|
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
|
// constants
|
||||||
import { addSpaceIfCamelCase, classNames } from "constants/common";
|
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 = {
|
type Props = {
|
||||||
issue: IIssue;
|
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 { 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[]>(
|
const { data: states } = useSWR<IState[]>(
|
||||||
activeWorkspace ? STATE_LIST(issue.project) : null,
|
activeWorkspace ? STATE_LIST(issue.project) : null,
|
||||||
activeWorkspace ? () => stateServices.getStates(activeWorkspace.slug, issue.project) : null
|
activeWorkspace ? () => stateServices.getStates(activeWorkspace.slug, issue.project) : null
|
||||||
@ -51,7 +38,11 @@ const ChangeStateDropdown = ({ issue }: Props) => {
|
|||||||
as="div"
|
as="div"
|
||||||
value={issue.state}
|
value={issue.state}
|
||||||
onChange={(data: string) => {
|
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"
|
className="flex-shrink-0"
|
||||||
>
|
>
|
||||||
|
@ -107,3 +107,75 @@ export const addSpaceIfCamelCase = (str: string) => {
|
|||||||
export const replaceUnderscoreIfSnakeCase = (str: string) => {
|
export const replaceUnderscoreIfSnakeCase = (str: string) => {
|
||||||
return str.replace(/_/g, " ");
|
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
|
// next
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// react
|
import Image from "next/image";
|
||||||
import React, { useState } from "react";
|
|
||||||
// services
|
// services
|
||||||
import useUser from "lib/hooks/useUser";
|
|
||||||
import userService from "lib/services/user.service";
|
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
|
// components
|
||||||
import CreateProjectModal from "components/project/CreateProjectModal";
|
import CreateProjectModal from "components/project/CreateProjectModal";
|
||||||
// types
|
|
||||||
import { IUser } from "types";
|
|
||||||
// headless ui
|
// headless ui
|
||||||
import { Dialog, Disclosure, Menu, Transition } from "@headlessui/react";
|
import { Dialog, Disclosure, Menu, Transition } from "@headlessui/react";
|
||||||
// icons
|
// icons
|
||||||
@ -25,14 +26,14 @@ import {
|
|||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
InboxIcon,
|
|
||||||
ArrowLongLeftIcon,
|
ArrowLongLeftIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
// constants
|
// constants
|
||||||
import { classNames } from "constants/common";
|
import { classNames } from "constants/common";
|
||||||
import { Spinner } from "ui";
|
// ui
|
||||||
import useTheme from "lib/hooks/useTheme";
|
import { Spinner, Tooltip } from "ui";
|
||||||
import authenticationService from "lib/services/authentication.service";
|
// types
|
||||||
|
import type { IUser } from "types";
|
||||||
|
|
||||||
const navigation = (projectId: string) => [
|
const navigation = (projectId: string) => [
|
||||||
{
|
{
|
||||||
@ -107,7 +108,7 @@ const Sidebar: React.FC = () => {
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { projects } = useUser();
|
const { projects, user } = useUser();
|
||||||
|
|
||||||
const { projectId } = router.query;
|
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 border-r border-gray-200">
|
||||||
<div className="h-full flex flex-1 flex-col pt-5">
|
<div className="h-full flex flex-1 flex-col pt-5">
|
||||||
<div className="px-2">
|
<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">
|
<Menu as="div" className="col-span-4 inline-block text-left w-full">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
@ -213,16 +218,25 @@ const Sidebar: React.FC = () => {
|
|||||||
!sidebarCollapse ? "hover:bg-gray-50 border border-gray-300 shadow-sm" : ""
|
!sidebarCollapse ? "hover:bg-gray-50 border border-gray-300 shadow-sm" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex gap-x-1 items-center">
|
<div 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">
|
<div className="h-5 w-5 p-4 flex items-center justify-center bg-gray-500 text-white rounded uppercase relative">
|
||||||
{activeWorkspace?.name?.charAt(0) ?? "N"}
|
{activeWorkspace?.logo && activeWorkspace.logo !== "" ? (
|
||||||
</p>
|
<Image
|
||||||
|
src={activeWorkspace.logo}
|
||||||
|
alt="Workspace Logo"
|
||||||
|
layout="fill"
|
||||||
|
objectFit="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
activeWorkspace?.name?.charAt(0) ?? "N"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{!sidebarCollapse && (
|
{!sidebarCollapse && (
|
||||||
<p className="truncate w-20 text-left ml-1">
|
<p className="truncate w-20 text-left ml-1">
|
||||||
{activeWorkspace?.name ?? "Loading..."}
|
{activeWorkspace?.name ?? "Loading..."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
{!sidebarCollapse && (
|
{!sidebarCollapse && (
|
||||||
<div className="flex-grow flex justify-end">
|
<div className="flex-grow flex justify-end">
|
||||||
<ChevronDownIcon className="-mr-1 ml-2 h-5 w-5" aria-hidden="true" />
|
<ChevronDownIcon className="-mr-1 ml-2 h-5 w-5" aria-hidden="true" />
|
||||||
@ -301,9 +315,19 @@ const Sidebar: React.FC = () => {
|
|||||||
</Menu>
|
</Menu>
|
||||||
{!sidebarCollapse && (
|
{!sidebarCollapse && (
|
||||||
<Menu as="div" className="inline-block text-left w-full">
|
<Menu as="div" className="inline-block text-left w-full">
|
||||||
<div className="h-full w-full">
|
<div className="h-10 w-10">
|
||||||
<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">
|
<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" />
|
<UserIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -472,12 +496,13 @@ const Sidebar: React.FC = () => {
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => toggleCollapsed()}
|
onClick={() => toggleCollapsed()}
|
||||||
>
|
>
|
||||||
|
<Tooltip content="Click to toggle sidebar" position="right">
|
||||||
<ArrowLongLeftIcon
|
<ArrowLongLeftIcon
|
||||||
className={`h-4 w-4 text-gray-500 group-hover:text-gray-900 flex-shrink-0 duration-300 ${
|
className={`h-4 w-4 text-gray-500 group-hover:text-gray-900 flex-shrink-0 duration-300 ${
|
||||||
sidebarCollapse ? "rotate-180" : ""
|
sidebarCollapse ? "rotate-180" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{!sidebarCollapse && "Collapse"}
|
</Tooltip>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
// react
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
// next
|
// next
|
||||||
import type { NextPage } from "next";
|
import type { NextPage } from "next";
|
||||||
// swr
|
// swr
|
||||||
@ -8,37 +8,65 @@ import useSWR from "swr";
|
|||||||
import ProjectLayout from "layouts/ProjectLayout";
|
import ProjectLayout from "layouts/ProjectLayout";
|
||||||
// hooks
|
// hooks
|
||||||
import useUser from "lib/hooks/useUser";
|
import useUser from "lib/hooks/useUser";
|
||||||
// components
|
|
||||||
import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal";
|
|
||||||
// ui
|
// ui
|
||||||
import { Spinner } from "ui";
|
import { Spinner } from "ui";
|
||||||
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
|
import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs";
|
||||||
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
|
import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace";
|
||||||
import HeaderButton from "ui/HeaderButton";
|
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
|
// constants
|
||||||
import ChangeStateDropdown from "components/project/issues/my-issues/ChangeStateDropdown";
|
|
||||||
import { USER_ISSUE } from "constants/fetch-keys";
|
import { USER_ISSUE } from "constants/fetch-keys";
|
||||||
import { classNames } from "constants/common";
|
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 MyIssues: NextPage = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
|
||||||
const { data: myIssues } = useSWR<IIssue[]>(
|
const { data: myIssues, mutate: mutateMyIssue } = useSWR<IIssue[]>(
|
||||||
user ? USER_ISSUE : null,
|
user ? USER_ISSUE : null,
|
||||||
user ? () => userService.userIssues() : 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 (
|
return (
|
||||||
<ProjectLayout>
|
<ProjectLayout>
|
||||||
<CreateUpdateIssuesModal isOpen={isOpen} setIsOpen={setIsOpen} />
|
|
||||||
<div className="w-full h-full flex flex-col space-y-5">
|
<div className="w-full h-full flex flex-col space-y-5">
|
||||||
{myIssues ? (
|
{myIssues ? (
|
||||||
<>
|
<>
|
||||||
@ -116,13 +144,16 @@ const MyIssues: NextPage = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-4 max-w-[15rem]">{myIssue.description}</td>
|
<td className="px-3 py-4 max-w-[15rem]">{myIssue.description}</td>
|
||||||
<td className="px-3 py-4">
|
<td className="px-3 py-4">
|
||||||
{myIssue.project_detail.name}
|
{myIssue.project_detail?.name}
|
||||||
<br />
|
<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>
|
||||||
<td className="px-3 py-4 capitalize">{myIssue.priority}</td>
|
<td className="px-3 py-4 capitalize">{myIssue.priority}</td>
|
||||||
<td className="relative px-3 py-4">
|
<td className="relative px-3 py-4">
|
||||||
<ChangeStateDropdown issue={myIssue} />
|
<ChangeStateDropdown
|
||||||
|
issue={myIssue}
|
||||||
|
updateIssues={updateMyIssues}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -150,7 +181,13 @@ const MyIssues: NextPage = () => {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
Icon={PlusIcon}
|
Icon={PlusIcon}
|
||||||
action={() => setIsOpen(true)}
|
action={() => {
|
||||||
|
const e = new KeyboardEvent("keydown", {
|
||||||
|
key: "i",
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(e);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</EmptySpace>
|
</EmptySpace>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useCallback, useState } from "react";
|
||||||
// swr
|
// swr
|
||||||
import { mutate } from "swr";
|
import { mutate } from "swr";
|
||||||
// next
|
// next
|
||||||
@ -20,11 +20,15 @@ import useUser from "lib/hooks/useUser";
|
|||||||
import useToast from "lib/hooks/useToast";
|
import useToast from "lib/hooks/useToast";
|
||||||
// fetch keys
|
// fetch keys
|
||||||
import { PROJECT_DETAILS, PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
import { PROJECT_DETAILS, PROJECTS_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||||
|
// commons
|
||||||
|
import { addSpaceIfCamelCase, debounce } from "constants/common";
|
||||||
|
// components
|
||||||
|
import CreateUpdateStateModal from "components/project/issues/BoardView/state/CreateUpdateStateModal";
|
||||||
// ui
|
// ui
|
||||||
import { Spinner, Button, Input, TextArea, Select } from "ui";
|
import { Spinner, Button, Input, TextArea, Select } from "ui";
|
||||||
import { Breadcrumbs, BreadcrumbItem } from "ui/Breadcrumbs";
|
import { Breadcrumbs, BreadcrumbItem } from "ui/Breadcrumbs";
|
||||||
// icons
|
// icons
|
||||||
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/24/outline";
|
import { ChevronDownIcon, CheckIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||||
// types
|
// types
|
||||||
import type { IProject, IWorkspace, WorkspaceMember } from "types";
|
import type { IProject, IWorkspace, WorkspaceMember } from "types";
|
||||||
|
|
||||||
@ -41,16 +45,19 @@ const ProjectSettings: NextPage = () => {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
control,
|
control,
|
||||||
|
setError,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<IProject>({
|
} = useForm<IProject>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [isCreateStateModalOpen, setIsCreateStateModalOpen] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { projectId } = router.query;
|
const { projectId } = router.query;
|
||||||
|
|
||||||
const { activeWorkspace, activeProject } = useUser();
|
const { activeWorkspace, activeProject, states } = useUser();
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
@ -81,6 +88,7 @@ const ProjectSettings: NextPage = () => {
|
|||||||
const payload: Partial<IProject> = {
|
const payload: Partial<IProject> = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
network: formData.network,
|
network: formData.network,
|
||||||
|
identifier: formData.identifier,
|
||||||
description: formData.description,
|
description: formData.description,
|
||||||
default_assignee: formData.default_assignee,
|
default_assignee: formData.default_assignee,
|
||||||
project_lead: formData.project_lead,
|
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 (
|
return (
|
||||||
<ProjectLayout>
|
<ProjectLayout>
|
||||||
<div className="w-full h-full space-y-5">
|
<div className="w-full h-full space-y-5">
|
||||||
|
<CreateUpdateStateModal
|
||||||
|
isOpen={isCreateStateModalOpen}
|
||||||
|
setIsOpen={setIsCreateStateModalOpen}
|
||||||
|
projectId={projectId as string}
|
||||||
|
/>
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<BreadcrumbItem title="Projects" link="/projects" />
|
<BreadcrumbItem title="Projects" link="/projects" />
|
||||||
<BreadcrumbItem title={`${activeProject?.name} Settings`} />
|
<BreadcrumbItem title={`${activeProject?.name} Settings`} />
|
||||||
@ -169,8 +192,20 @@ const ProjectSettings: NextPage = () => {
|
|||||||
register={register}
|
register={register}
|
||||||
placeholder="Enter identifier"
|
placeholder="Enter identifier"
|
||||||
label="Identifier"
|
label="Identifier"
|
||||||
|
onChange={(e: any) => {
|
||||||
|
if (!activeWorkspace || !e.target.value) return;
|
||||||
|
checkIdentifierAvailability(activeWorkspace.slug, e.target.value);
|
||||||
|
}}
|
||||||
validations={{
|
validations={{
|
||||||
required: "Identifier is required",
|
required: "Identifier is required",
|
||||||
|
minLength: {
|
||||||
|
value: 1,
|
||||||
|
message: "Identifier must at least be of 1 character",
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
value: 9,
|
||||||
|
message: "Identifier must at most be of 9 characters",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -358,6 +393,40 @@ const ProjectSettings: NextPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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) => {
|
await mutateWorkspaces((workspaces) => {
|
||||||
return (workspaces ?? []).map((workspace) => {
|
return (workspaces ?? []).map((workspace) => {
|
||||||
if (workspace.slug === activeWorkspace.slug) {
|
if (workspace.slug === activeWorkspace.slug) {
|
||||||
return res;
|
return {
|
||||||
|
...workspace,
|
||||||
|
...res,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return workspace;
|
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";
|
import type { Props } from "./types";
|
||||||
|
|
||||||
const SearchListbox: React.FC<Props> = ({
|
const SearchListbox: React.FC<Props> = ({
|
||||||
display,
|
title,
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
value,
|
value,
|
||||||
multiple: canSelectMultiple,
|
multiple: canSelectMultiple,
|
||||||
|
icon,
|
||||||
|
width,
|
||||||
|
optionsFontsize,
|
||||||
|
buttonClassName,
|
||||||
|
optionsClassName,
|
||||||
}) => {
|
}) => {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const filteredOptions =
|
const filteredOptions =
|
||||||
query === ""
|
query === ""
|
||||||
? options
|
? options
|
||||||
: options.filter((option) => option.name.toLowerCase().includes(query.toLowerCase()));
|
: options?.filter((option) => option.display.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
|
||||||
const props: any = {
|
const props: any = {
|
||||||
value,
|
value,
|
||||||
@ -34,22 +39,38 @@ const SearchListbox: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<Combobox as="div" {...props} className="flex-shrink-0">
|
||||||
{({ open }: any) => (
|
{({ open }: any) => (
|
||||||
<>
|
<>
|
||||||
<Combobox.Label className="sr-only"> {display} </Combobox.Label>
|
<Combobox.Label className="sr-only"> {title} </Combobox.Label>
|
||||||
<div className="relative">
|
<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">
|
<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
|
<span
|
||||||
className={classNames(
|
className={classNames(
|
||||||
value === null || value === undefined ? "" : "text-gray-900",
|
value === null || value === undefined ? "" : "text-gray-900",
|
||||||
"hidden truncate sm:ml-2 sm:block"
|
"hidden truncate sm:ml-2 sm:block"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{value
|
{Array.isArray(value)
|
||||||
? options.find((option) => option.value === value)?.name ?? "None"
|
? value
|
||||||
: `Select ${display}`}
|
.map((v) => options?.find((option) => option.value === v)?.display)
|
||||||
|
.join(", ") || title
|
||||||
|
: options?.find((option) => option.value === value)?.display || title}
|
||||||
</span>
|
</span>
|
||||||
</Combobox.Button>
|
</Combobox.Button>
|
||||||
|
|
||||||
@ -60,32 +81,63 @@ const SearchListbox: React.FC<Props> = ({
|
|||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<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.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
|
<Combobox.Input
|
||||||
className="w-full bg-transparent border-b py-2 pl-3 mb-1 focus:outline-none sm:text-sm"
|
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)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
displayValue={(assigned: any) => assigned?.name}
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
/>
|
/>
|
||||||
{filteredOptions.length > 0 ? (
|
{filteredOptions ? (
|
||||||
|
filteredOptions.length > 0 ? (
|
||||||
filteredOptions.map((option) => (
|
filteredOptions.map((option) => (
|
||||||
<Combobox.Option
|
<Combobox.Option
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
classNames(
|
`${
|
||||||
active ? "bg-gray-50" : "bg-white",
|
active ? "text-white bg-theme" : "text-gray-900"
|
||||||
"relative rounded cursor-default select-none py-2 px-3"
|
} cursor-pointer select-none relative p-2 rounded-md`
|
||||||
)
|
|
||||||
}
|
}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<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>
|
</div>
|
||||||
</Combobox.Option>
|
</Combobox.Option>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-gray-400 m-1 mt-0">No results found</div>
|
<p className="text-sm text-gray-500">No {title.toLowerCase()} found</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">Loading...</p>
|
||||||
)}
|
)}
|
||||||
</Combobox.Options>
|
</Combobox.Options>
|
||||||
</Transition>
|
</Transition>
|
||||||
@ -93,7 +145,6 @@ const SearchListbox: React.FC<Props> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
10
ui/SearchListbox/types.d.ts
vendored
10
ui/SearchListbox/types.d.ts
vendored
@ -1,10 +1,14 @@
|
|||||||
type Value = any;
|
type Value = any;
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
display: string;
|
title: string;
|
||||||
name: string;
|
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
options: Array<{ name: string; value: Value }>;
|
options?: Array<{ display: string; element?: JSX.Element; value: Value }>;
|
||||||
onChange: (value: Value) => void;
|
onChange: (value: Value) => void;
|
||||||
value: Value;
|
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 = {
|
type Props = {
|
||||||
content: string;
|
children: React.ReactNode;
|
||||||
position: string;
|
content: React.ReactNode;
|
||||||
children: any;
|
position?: "top" | "bottom" | "left" | "right";
|
||||||
className?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Tooltip: React.FC<TooltipProps> = (props) => {
|
const Tooltip: React.FC<Props> = ({ children, content, position = "top" }) => {
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative group">
|
||||||
<div className="inline-block z-99" ref={myRef}>
|
<div
|
||||||
<span
|
className={`fixed pointer-events-none transition-opacity opacity-0 group-hover:opacity-100 bg-black text-white px-3 py-1 rounded ${
|
||||||
className={`bg-black text-white p-2 rounded text-xs fixed ${
|
position === "right"
|
||||||
props.position === "top" || props.position === "bottom"
|
? "left-14"
|
||||||
? "translate-x-[-50%]"
|
: position === "left"
|
||||||
: "translate-y-[-50%]"
|
? "right-14"
|
||||||
} duration-300 ${
|
: position === "top"
|
||||||
show ? "opacity-1 pointer-events-all" : "opacity-0 pointer-events-none"
|
? "bottom-14"
|
||||||
} ${props.className}`}
|
: "top-14"
|
||||||
style={{ top: `${position.y}px`, left: `${position.x}px` }}
|
}`}
|
||||||
ref={myRef2}
|
|
||||||
>
|
>
|
||||||
{props.content}
|
<p className="truncate text-sx">{content}</p>
|
||||||
{/* Lorem ipsum, dolor sit amet consectetur adipisicing elit.Illo consequuntur libero placeat
|
<span
|
||||||
porro facere itaque vitae, iusto quos fugiat consequatur. */}
|
className={`absolute w-2 h-2 bg-black ${
|
||||||
</span>
|
position === "top"
|
||||||
{React.cloneElement(props.children, {
|
? "top-full left-1/2 transform -translate-y-1/2 -translate-x-1/2 rotate-45"
|
||||||
onMouseOver: () => setShow(true),
|
: position === "bottom"
|
||||||
onMouseOut: () => setShow(false),
|
? "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>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Tooltip.defaultProps = {
|
|
||||||
position: "top",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Tooltip;
|
export default Tooltip;
|
||||||
|
@ -2,7 +2,8 @@ export { default as Button } from "./Button";
|
|||||||
export { default as Input } from "./Input";
|
export { default as Input } from "./Input";
|
||||||
export { default as Select } from "./Select";
|
export { default as Select } from "./Select";
|
||||||
export { default as TextArea } from "./TextArea";
|
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 Spinner } from "./Spinner";
|
||||||
export { default as Tooltip } from "./Tooltip";
|
export { default as Tooltip } from "./Tooltip";
|
||||||
export { default as SearchListbox } from "./SearchListbox";
|
export { default as SearchListbox } from "./SearchListbox";
|
||||||
|
export { default as HeaderButton } from "./HeaderButton";
|
||||||
|
Loading…
Reference in New Issue
Block a user