dev: copy shortcuts, magic login links, improved settings page

This commit is contained in:
Dakshesh Jain 2022-11-23 20:40:19 +05:30
parent 6037fed3f4
commit 97544c1760
25 changed files with 884 additions and 511 deletions

View File

@ -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(() => {

View File

@ -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 }) => (

View File

@ -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" });
});
};

View File

@ -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>

View File

@ -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>
);

View File

@ -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>
))}
<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>
</Listbox.Options>
</Transition>
</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" />}
/>
)}
/>
);

View File

@ -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

View File

@ -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,72 +26,16 @@ 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>
<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"
@ -109,12 +48,8 @@ const SelectState: React.FC<Props> = ({ control, data, setIsOpen }) => {
<span className="block truncate">Create state</span>
</span>
</button>
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
}
/>
)}
></Controller>
</>

View File

@ -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

View File

@ -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>
))}

View File

@ -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"
>

View File

@ -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);
};

View File

@ -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">
<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()}
>
<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" : ""
}`}
/>
{!sidebarCollapse && "Collapse"}
</Tooltip>
</button>
</div>
</div>

99
pages/magic-sign-in.tsx Normal file
View 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;

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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
View 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
View 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;
};

View File

@ -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;

View File

@ -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,22 +39,38 @@ 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>
<Combobox.Label className="sr-only"> {title} </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">
<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"
)}
>
{value
? options.find((option) => option.value === value)?.name ?? "None"
: `Select ${display}`}
{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>
@ -60,32 +81,63 @@ const SearchListbox: React.FC<Props> = ({
leaveFrom="opacity-100"
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
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 ? (
{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>
<p className="text-sm text-gray-500">No {title.toLowerCase()} found</p>
)
) : (
<p className="text-sm text-gray-500">Loading...</p>
)}
</Combobox.Options>
</Transition>
@ -93,7 +145,6 @@ const SearchListbox: React.FC<Props> = ({
</>
)}
</Combobox>
</div>
);
};

View File

@ -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";
};

View File

@ -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}>
<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}
<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"
}`}
>
{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),
})}
<p className="truncate text-sx">{content}</p>
<span
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;

View File

@ -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";