refactor: parent issue select (#1546)

* refactor: parent issue select

* fix: sibling issues list
This commit is contained in:
Aaryan Khandelwal 2023-07-18 15:36:03 +05:30 committed by GitHub
parent 93da220c4a
commit 059b8c793a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 122 additions and 239 deletions

View File

@ -380,7 +380,6 @@ export const CommandPalette: React.FC = () => {
user={user}
/>
)}
<CreateUpdateIssueModal
isOpen={isIssueModalOpen}
handleClose={() => setIsIssueModalOpen(false)}

View File

@ -1,6 +1,5 @@
import React, { ChangeEvent, FC, useState, useEffect, useRef } from "react";
import React, { FC, useState, useEffect, useRef } from "react";
import Link from "next/link";
import dynamic from "next/dynamic";
import { useRouter } from "next/router";
@ -12,12 +11,12 @@ import aiService from "services/ai.service";
import useToast from "hooks/use-toast";
// components
import { GptAssistantModal } from "components/core";
import { ParentIssuesListModal } from "components/issues";
import {
IssueAssigneeSelect,
IssueDateSelect,
IssueEstimateSelect,
IssueLabelSelect,
IssueParentSelect,
IssuePrioritySelect,
IssueProjectSelect,
IssueStateSelect,
@ -35,10 +34,8 @@ import {
} from "components/ui";
// icons
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
// helpers
import { cosineSimilarity } from "helpers/string.helper";
// types
import type { ICurrentUserResponse, IIssue } from "types";
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
// rich-text-editor
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
@ -72,6 +69,7 @@ const defaultValues: Partial<IIssue> = {
description_html: "<p></p>",
estimate_point: null,
state: "",
parent: null,
priority: null,
assignees: [],
assignees_list: [],
@ -82,7 +80,6 @@ const defaultValues: Partial<IIssue> = {
export interface IssueFormProps {
handleFormSubmit: (values: Partial<IIssue>) => Promise<void>;
initialData?: Partial<IIssue>;
issues: IIssue[];
projectId: string;
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
createMore: boolean;
@ -108,7 +105,6 @@ export interface IssueFormProps {
export const IssueForm: FC<IssueFormProps> = ({
handleFormSubmit,
initialData,
issues = [],
projectId,
setActiveProject,
createMore,
@ -118,11 +114,10 @@ export const IssueForm: FC<IssueFormProps> = ({
user,
fieldsToShow,
}) => {
// states
const [mostSimilarIssue, setMostSimilarIssue] = useState<IIssue | undefined>();
const [stateModal, setStateModal] = useState(false);
const [labelModal, setLabelModal] = useState(false);
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
@ -151,12 +146,6 @@ export const IssueForm: FC<IssueFormProps> = ({
const issueName = watch("name");
const handleTitleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const similarIssue = issues?.find((i: IIssue) => cosineSimilarity(i.name, value) > 0.7);
setMostSimilarIssue(similarIssue);
};
const handleCreateUpdateIssue = async (formData: Partial<IIssue>) => {
await handleFormSubmit(formData);
@ -283,26 +272,28 @@ export const IssueForm: FC<IssueFormProps> = ({
</div>
{watch("parent") &&
watch("parent") !== "" &&
(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) &&
selectedParentIssue && (
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-80 p-2 text-xs">
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: issues.find((i) => i.id === watch("parent"))?.state_detail
.color,
backgroundColor: selectedParentIssue.state__color,
}}
/>
<span className="flex-shrink-0 text-custom-text-200">
{/* {projects?.find((p) => p.id === projectId)?.identifier}- */}
{issues.find((i) => i.id === watch("parent"))?.sequence_id}
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
</span>
<span className="truncate font-medium">
{issues.find((i) => i.id === watch("parent"))?.name.substring(0, 50)}
{selectedParentIssue.name.substring(0, 50)}
</span>
<XMarkIcon
className="h-3 w-3 cursor-pointer"
onClick={() => setValue("parent", null)}
onClick={() => {
setValue("parent", null);
setSelectedParentIssue(null);
}}
/>
</div>
</div>
@ -314,7 +305,6 @@ export const IssueForm: FC<IssueFormProps> = ({
<Input
id="name"
name="name"
onChange={handleTitleChange}
className="resize-none text-xl"
placeholder="Title"
autoComplete="off"
@ -328,38 +318,11 @@ export const IssueForm: FC<IssueFormProps> = ({
},
}}
/>
{mostSimilarIssue && (
<div className="flex items-center gap-x-2">
<p className="text-sm text-custom-text-200">
<Link
href={`/${workspaceSlug}/projects/${projectId}/issues/${mostSimilarIssue.id}`}
>
<a target="_blank" type="button" className="inline text-left">
<span>Did you mean </span>
<span className="italic">
{mostSimilarIssue.project_detail.identifier}-
{mostSimilarIssue.sequence_id}: {mostSimilarIssue.name}{" "}
</span>
?
</a>
</Link>
</p>
<button
type="button"
className="text-sm text-custom-primary"
onClick={() => {
setMostSimilarIssue(undefined);
}}
>
No
</button>
</div>
)}
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && (
<div className="relative">
<div className="-mb-2 flex justify-end">
<div className="flex justify-end">
{issueName && issueName !== "" && (
<button
type="button"
@ -495,11 +458,20 @@ export const IssueForm: FC<IssueFormProps> = ({
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
<IssueParentSelect
<Controller
control={control}
name="parent"
render={({ field: { onChange } }) => (
<ParentIssuesListModal
isOpen={parentIssueListModalOpen}
setIsOpen={setParentIssueListModalOpen}
issues={issues ?? []}
handleClose={() => setParentIssueListModalOpen(false)}
onChange={(issue) => {
onChange(issue.id);
setSelectedParentIssue(issue);
}}
projectId={projectId}
/>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (

View File

@ -58,7 +58,7 @@ export const IssueMainContent: React.FC<Props> = ({
<>
<div className="rounded-lg">
{issueDetails?.parent && issueDetails.parent !== "" ? (
<div className="mb-5 flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-80 p-2 text-xs">
<div className="mb-5 flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-90 p-2 text-xs">
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${issueDetails.parent}`}>
<a className="flex items-center gap-2 text-custom-text-200">
<span
@ -76,24 +76,36 @@ export const IssueMainContent: React.FC<Props> = ({
</a>
</Link>
<CustomMenu ellipsis position="left">
{siblingIssues && siblingIssues.length > 0 ? (
siblingIssues.map((issue: IIssue) => (
<CustomMenu.MenuItem key={issue.id}>
<Link
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${issue.id}`}
<CustomMenu position="left" ellipsis>
{siblingIssues && siblingIssues.sub_issues.length > 0 ? (
<>
<h2 className="text-custom-text-200 px-1 mb-2">Sibling issues</h2>
{siblingIssues.sub_issues.map((issue) => {
if (issue.id !== issueDetails.id)
return (
<CustomMenu.MenuItem
key={issue.id}
renderAs="a"
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${
issue.id
}`}
>
<a>
{issueDetails.project_detail.identifier}-{issue.sequence_id}
</a>
</Link>
</CustomMenu.MenuItem>
))
);
})}
</>
) : (
<CustomMenu.MenuItem className="flex items-center gap-2 whitespace-nowrap p-2 text-left text-xs text-custom-text-200">
No other sibling issues
</CustomMenu.MenuItem>
<p className="flex items-center gap-2 whitespace-nowrap px-1 text-left text-xs text-custom-text-200 py-1">
No sibling issues
</p>
)}
<CustomMenu.MenuItem
renderAs="button"
onClick={() => submitChanges({ parent: null })}
>
Remove parent issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
) : null}

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
@ -94,15 +94,6 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""],
};
const { data: issues } = useSWR(
workspaceSlug && activeProject
? PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? "")
: null,
workspaceSlug && activeProject
? () => issuesService.getIssues(workspaceSlug as string, activeProject ?? "")
: null
);
useEffect(() => {
if (projects && projects.length > 0)
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
@ -317,6 +308,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
else await updateIssue(payload);
};
if (!projects || projects.length === 0) return null;
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={() => handleClose()}>
@ -345,7 +338,6 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
>
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
<IssueForm
issues={issues ?? []}
handleFormSubmit={handleFormSubmit}
initialData={data ?? prePopulateData}
createMore={createMore}

View File

@ -21,7 +21,8 @@ type Props = {
isOpen: boolean;
handleClose: () => void;
value?: any;
onChange: (...event: any[]) => void;
onChange: (issue: ISearchIssueResponse) => void;
projectId: string;
issueId?: string;
customDisplay?: JSX.Element;
};
@ -31,6 +32,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({
handleClose: onClose,
value,
onChange,
projectId,
issueId,
customDisplay,
}) => {
@ -42,7 +44,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({
const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug } = router.query;
const handleClose = () => {
onClose();
@ -109,7 +111,13 @@ export const ParentIssuesListModal: React.FC<Props> = ({
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform rounded-xl border border-custom-border-200 bg-custom-background-100 shadow-2xl transition-all">
<Combobox value={value} onChange={onChange}>
<Combobox
value={value}
onChange={(val) => {
onChange(val);
handleClose();
}}
>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-custom-text-100 text-opacity-40"
@ -165,13 +173,12 @@ export const ParentIssuesListModal: React.FC<Props> = ({
{issues.map((issue) => (
<Combobox.Option
key={issue.id}
value={issue.id}
value={issue}
className={({ active, selected }) =>
`flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
active ? "bg-custom-background-80 text-custom-text-100" : ""
} ${selected ? "text-custom-text-100" : ""}`
}
onClick={handleClose}
>
<>
<span

View File

@ -1,8 +1,7 @@
export * from "./assignee";
export * from "./date";
export * from "./estimate"
export * from "./estimate";
export * from "./label";
export * from "./parent";
export * from "./priority";
export * from "./project";
export * from "./state";

View File

@ -1,27 +0,0 @@
import React from "react";
import { Controller, Control } from "react-hook-form";
// components
import { ParentIssuesListModal } from "components/issues";
// types
import type { IIssue } from "types";
type Props = {
control: Control<IIssue, any>;
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
issues: IIssue[];
};
export const IssueParentSelect: React.FC<Props> = ({ control, isOpen, setIsOpen, issues }) => (
<Controller
control={control}
name="parent"
render={({ field: { onChange } }) => (
<ParentIssuesListModal
isOpen={isOpen}
handleClose={() => setIsOpen(false)}
onChange={onChange}
/>
)}
/>
);

View File

@ -2,51 +2,31 @@ import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { Control, Controller, UseFormWatch } from "react-hook-form";
// fetch keys
// icons
import { UserIcon } from "@heroicons/react/24/outline";
// services
import issuesServices from "services/issues.service";
// components
import { ParentIssuesListModal } from "components/issues";
// icons
// types
import { IIssue, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
import { IIssue, ISearchIssueResponse, UserAuth } from "types";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
customDisplay: JSX.Element;
watch: UseFormWatch<IIssue>;
onChange: (value: string) => void;
issueDetails: IIssue | undefined;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarParentSelect: React.FC<Props> = ({
control,
submitChanges,
customDisplay,
watch,
onChange,
issueDetails,
userAuth,
disabled = false,
}) => {
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
: null
);
const { projectId, issueId } = router.query;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
@ -57,22 +37,15 @@ export const SidebarParentSelect: React.FC<Props> = ({
<p>Parent</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="parent"
render={({ field: { value, onChange } }) => (
<ParentIssuesListModal
isOpen={isParentModalOpen}
handleClose={() => setIsParentModalOpen(false)}
onChange={(val) => {
submitChanges({ parent: val });
onChange(val);
onChange={(issue) => {
onChange(issue.id);
setSelectedParentIssue(issue);
}}
issueId={issueId as string}
value={value}
customDisplay={customDisplay}
/>
)}
projectId={projectId as string}
/>
<button
type="button"
@ -82,10 +55,10 @@ export const SidebarParentSelect: React.FC<Props> = ({
onClick={() => setIsParentModalOpen(true)}
disabled={isNotAllowed}
>
{watch("parent") && watch("parent") !== "" ? (
`${issues?.find((i) => i.id === watch("parent"))?.project_detail?.identifier}-${
issues?.find((i) => i.id === watch("parent"))?.sequence_id
}`
{selectedParentIssue ? (
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
) : issueDetails?.parent ? (
`${issueDetails.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}`
) : (
<span className="text-custom-text-200">Select issue</span>
)}

View File

@ -33,13 +33,7 @@ import {
// ui
import { CustomDatePicker, Icon } from "components/ui";
// icons
import {
LinkIcon,
CalendarDaysIcon,
TrashIcon,
PlusIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { LinkIcon, CalendarDaysIcon, TrashIcon, PlusIcon } from "@heroicons/react/24/outline";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
@ -337,31 +331,22 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
{showSecondSection && (
<div className="py-1">
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
<SidebarParentSelect
<Controller
control={control}
submitChanges={submitChanges}
customDisplay={
issueDetail?.parent_detail ? (
<button
type="button"
className="flex items-center gap-2 rounded bg-custom-background-80 px-3 py-2 text-xs"
onClick={() => submitChanges({ parent: null })}
>
<span className="text-custom-text-200">Selected:</span>{" "}
{issueDetail.parent_detail?.name}
<XMarkIcon className="h-3 w-3" />
</button>
) : (
<div className="inline-block rounded bg-custom-background-90 px-3 py-2 text-xs text-custom-text-200">
No parent selected
</div>
)
}
watch={watchIssue}
name="parent"
render={({ field: { onChange } }) => (
<SidebarParentSelect
onChange={(val: string) => {
submitChanges({ parent: val });
onChange(val);
}}
issueDetails={issueDetail}
userAuth={memberRole}
disabled={uneditable}
/>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
<SidebarBlockerSelect
issueId={issueId as string}

View File

@ -272,7 +272,7 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user, disabled = false }
key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
>
<a className="group flex items-center justify-between gap-2 rounded p-2 hover:bg-custom-background-100">
<a className="group flex items-center justify-between gap-2 rounded p-2 hover:bg-custom-background-90">
<div className="flex items-center gap-2 rounded text-xs">
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
@ -316,6 +316,7 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user, disabled = false }
Add sub-issue
</>
}
buttonClassName="whitespace-nowrap"
position="left"
noBorder
noChevron

View File

@ -175,7 +175,11 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
</div>
<div className="flex justify-end gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<DangerButton onClick={handleDeletion} loading={isDeleteLoading || !canDelete}>
<DangerButton
onClick={handleDeletion}
disabled={!canDelete}
loading={isDeleteLoading}
>
{isDeleteLoading ? "Deleting..." : "Delete Project"}
</DangerButton>
</div>

View File

@ -106,7 +106,7 @@ const CustomMenu = ({
);
type MenuItemProps = {
children: JSX.Element | string;
children: React.ReactNode;
renderAs?: "button" | "a";
href?: string;
onClick?: (args?: any) => void;

View File

@ -49,45 +49,6 @@ export const copyTextToClipboard = async (text: string) => {
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);
};
export const generateRandomColor = (string: string): string => {
if (!string) return "rgb(var(--color-primary-100))";

View File

@ -9,6 +9,7 @@ import type {
IIssueComment,
IIssueLabels,
IIssueViewOptions,
ISubIssueResponse,
} from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
@ -420,7 +421,11 @@ class ProjectIssuesServices extends APIService {
});
}
async subIssues(workspaceSlug: string, projectId: string, issueId: string) {
async subIssues(
workspaceSlug: string,
projectId: string,
issueId: string
): Promise<ISubIssueResponse> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/sub-issues/`
)