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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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