Merge branch 'develop' of github.com:makeplane/plane into fix/date-time-zone-misalignment

This commit is contained in:
rahulramesha 2024-03-18 12:44:20 +05:30
commit 5a90cc0ccd
54 changed files with 768 additions and 336 deletions

View File

@ -1,7 +1,7 @@
export type TIssueReaction = {
actor_id: string;
actor: string;
id: string;
issue_id: string;
issue: string;
reaction: string;
};

View File

@ -114,7 +114,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
type="button"
onClick={(e) => {
e.stopPropagation();
openDropdown();
isOpen ? closeDropdown() : openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
className={customButtonClassName}
@ -132,7 +132,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
type="button"
onClick={(e) => {
e.stopPropagation();
openDropdown();
isOpen ? closeDropdown() : openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
disabled={disabled}
@ -158,7 +158,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
} ${buttonClassName}`}
onClick={(e) => {
e.stopPropagation();
openDropdown();
isOpen ? closeDropdown() : openDropdown();
if (menuButtonOnClick) menuButtonOnClick();
}}
tabIndex={customButtonTabIndex}

View File

@ -4,9 +4,19 @@ import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import useSWR from "swr";
import { Dialog, Transition } from "@headlessui/react";
// icons
import { FolderPlus, Search, Settings } from "lucide-react";
// hooks
import { useApplication, useEventTracker, useProject } from "hooks/store";
import { usePlatformOS } from "hooks/use-platform-os";
import useDebounce from "hooks/use-debounce";
// services
import { IssueService } from "services/issue";
import { WorkspaceService } from "services/workspace.service";
// ui
import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui";
// components
import { EmptyState } from "components/empty-state";
import {
CommandPaletteThemeActions,
ChangeIssueAssignee,
@ -18,18 +28,13 @@ import {
CommandPaletteWorkspaceSettingsActions,
CommandPaletteSearchResults,
} from "components/command-palette";
import { ISSUE_DETAILS } from "constants/fetch-keys";
import { useApplication, useEventTracker, useProject } from "hooks/store";
import { usePlatformOS } from "hooks/use-platform-os";
// services
import useDebounce from "hooks/use-debounce";
import { IssueService } from "services/issue";
import { WorkspaceService } from "services/workspace.service";
// types
import { IWorkspaceSearchResults } from "@plane/types";
// fetch-keys
// constants
import { EmptyStateType } from "constants/empty-state";
import { ISSUE_DETAILS } from "constants/fetch-keys";
// services
const workspaceService = new WorkspaceService();
const issueService = new IssueService();
@ -244,7 +249,9 @@ export const CommandModal: React.FC = observer(() => {
)}
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="my-4 text-center text-sm text-custom-text-200">No results found.</div>
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<EmptyState type={EmptyStateType.COMMAND_K_SEARCH_EMPTY_STATE} layout="screen-simple" />
</div>
)}
{(isLoading || isSearching) && (

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, FC } from "react";
import React, { useCallback, useEffect, FC, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import useSWR from "swr";
@ -23,24 +23,29 @@ import { EIssuesStoreType } from "constants/issue";
import { copyTextToClipboard } from "helpers/string.helper";
import { useApplication, useEventTracker, useIssues, useUser } from "hooks/store";
import { IssueService } from "services/issue";
import { EUserProjectRoles } from "constants/project";
import { EUserWorkspaceRoles } from "constants/workspace";
// services
const issueService = new IssueService();
export const CommandPalette: FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId, issueId, cycleId, moduleId } = router.query;
// store hooks
const {
commandPalette,
theme: { toggleSidebar },
} = useApplication();
const { setTrackElement } = useEventTracker();
const { currentUser } = useUser();
const {
currentUser,
membership: { currentWorkspaceRole, currentProjectRole },
} = useUser();
const {
issues: { removeIssue },
} = useIssues(EIssuesStoreType.PROJECT);
const {
toggleCommandPaletteModal,
isCreateIssueModalOpen,
@ -91,6 +96,105 @@ export const CommandPalette: FC = observer(() => {
});
}, [issueId]);
// auth
const canPerformProjectCreateActions = useCallback(
(showToast: boolean = true) => {
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
if (!isAllowed && showToast)
setToast({
type: TOAST_TYPE.ERROR,
title: "You don't have permission to perform this action.",
});
return isAllowed;
},
[currentProjectRole]
);
const canPerformWorkspaceCreateActions = useCallback(
(showToast: boolean = true) => {
const isAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
console.log("currentWorkspaceRole", currentWorkspaceRole);
console.log("isAllowed", isAllowed);
if (!isAllowed && showToast)
setToast({
type: TOAST_TYPE.ERROR,
title: "You don't have permission to perform this action.",
});
return isAllowed;
},
[currentWorkspaceRole]
);
const shortcutsList: {
global: Record<string, { title: string; description: string; action: () => void }>;
workspace: Record<string, { title: string; description: string; action: () => void }>;
project: Record<string, { title: string; description: string; action: () => void }>;
} = useMemo(
() => ({
global: {
c: {
title: "Create a new issue",
description: "Create a new issue in the current project",
action: () => toggleCreateIssueModal(true),
},
h: {
title: "Show shortcuts",
description: "Show all the available shortcuts",
action: () => toggleShortcutModal(true),
},
},
workspace: {
p: {
title: "Create a new project",
description: "Create a new project in the current workspace",
action: () => toggleCreateProjectModal(true),
},
},
project: {
d: {
title: "Create a new page",
description: "Create a new page in the current project",
action: () => toggleCreatePageModal(true),
},
m: {
title: "Create a new module",
description: "Create a new module in the current project",
action: () => toggleCreateModuleModal(true),
},
q: {
title: "Create a new cycle",
description: "Create a new cycle in the current project",
action: () => toggleCreateCycleModal(true),
},
v: {
title: "Create a new view",
description: "Create a new view in the current project",
action: () => toggleCreateViewModal(true),
},
backspace: {
title: "Bulk delete issues",
description: "Bulk delete issues in the current project",
action: () => toggleBulkDeleteIssueModal(true),
},
delete: {
title: "Bulk delete issues",
description: "Bulk delete issues in the current project",
action: () => toggleBulkDeleteIssueModal(true),
},
},
}),
[
toggleBulkDeleteIssueModal,
toggleCreateCycleModal,
toggleCreateIssueModal,
toggleCreateModuleModal,
toggleCreatePageModal,
toggleCreateProjectModal,
toggleCreateViewModal,
toggleShortcutModal,
]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const { key, ctrlKey, metaKey, altKey } = e;
@ -102,7 +206,7 @@ export const CommandPalette: FC = observer(() => {
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
(e.target as Element).classList?.contains("ProseMirror")
(e.target as Element)?.classList?.contains("ProseMirror")
)
return;
@ -119,42 +223,37 @@ export const CommandPalette: FC = observer(() => {
}
} else if (!isAnyModalOpen) {
setTrackElement("Shortcut key");
if (keyPressed === "c") {
toggleCreateIssueModal(true);
} else if (keyPressed === "p") {
toggleCreateProjectModal(true);
} else if (keyPressed === "h") {
toggleShortcutModal(true);
} else if (keyPressed === "v" && workspaceSlug && projectId) {
toggleCreateViewModal(true);
} else if (keyPressed === "d" && workspaceSlug && projectId) {
toggleCreatePageModal(true);
} else if (keyPressed === "q" && workspaceSlug && projectId) {
toggleCreateCycleModal(true);
} else if (keyPressed === "m" && workspaceSlug && projectId) {
toggleCreateModuleModal(true);
} else if (keyPressed === "backspace" || keyPressed === "delete") {
if (Object.keys(shortcutsList.global).includes(keyPressed)) shortcutsList.global[keyPressed].action();
// workspace authorized actions
else if (
Object.keys(shortcutsList.workspace).includes(keyPressed) &&
workspaceSlug &&
canPerformWorkspaceCreateActions()
)
shortcutsList.workspace[keyPressed].action();
// project authorized actions
else if (
Object.keys(shortcutsList.project).includes(keyPressed) &&
projectId &&
canPerformProjectCreateActions()
) {
e.preventDefault();
toggleBulkDeleteIssueModal(true);
// actions that can be performed only inside a project
shortcutsList.project[keyPressed].action();
}
}
},
[
canPerformProjectCreateActions,
canPerformWorkspaceCreateActions,
copyIssueUrlToClipboard,
toggleCreateProjectModal,
toggleCreateViewModal,
toggleCreatePageModal,
toggleShortcutModal,
toggleCreateCycleModal,
toggleCreateModuleModal,
toggleBulkDeleteIssueModal,
isAnyModalOpen,
projectId,
setTrackElement,
shortcutsList,
toggleCommandPaletteModal,
toggleSidebar,
toggleCreateIssueModal,
projectId,
workspaceSlug,
isAnyModalOpen,
setTrackElement,
]
);
@ -169,18 +268,11 @@ export const CommandPalette: FC = observer(() => {
return (
<>
<ShortcutsModal
isOpen={isShortcutModalOpen}
onClose={() => {
toggleShortcutModal(false);
}}
/>
<ShortcutsModal isOpen={isShortcutModalOpen} onClose={() => toggleShortcutModal(false)} />
{workspaceSlug && (
<CreateProjectModal
isOpen={isCreateProjectModalOpen}
onClose={() => {
toggleCreateProjectModal(false);
}}
onClose={() => toggleCreateProjectModal(false)}
workspaceSlug={workspaceSlug.toString()}
/>
)}
@ -194,9 +286,7 @@ export const CommandPalette: FC = observer(() => {
/>
<CreateUpdateModuleModal
isOpen={isCreateModuleModalOpen}
onClose={() => {
toggleCreateModuleModal(false);
}}
onClose={() => toggleCreateModuleModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
@ -236,9 +326,7 @@ export const CommandPalette: FC = observer(() => {
<BulkDeleteIssuesModal
isOpen={isBulkDeleteIssueModalOpen}
onClose={() => {
toggleBulkDeleteIssueModal(false);
}}
onClose={() => toggleBulkDeleteIssueModal(false)}
user={currentUser}
/>
<CommandModal />

View File

@ -5,22 +5,22 @@ import { SubmitHandler, useForm } from "react-hook-form";
import useSWR from "swr";
import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import { Search } from "lucide-react";
import { Button, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui";
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
import { EIssuesStoreType } from "constants/issue";
import { useIssues, useProject } from "hooks/store";
import { IssueService } from "services/issue";
// ui
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// icons
import { Search } from "lucide-react";
// types
import { IUser, TIssue } from "@plane/types";
// fetch keys
// store hooks
import { useIssues, useProject } from "hooks/store";
// components
import { BulkDeleteIssuesModalItem } from "./bulk-delete-issues-modal-item";
import { EmptyState } from "components/empty-state";
// constants
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
import { EIssuesStoreType } from "constants/issue";
import { EmptyStateType } from "constants/empty-state";
type FormInput = {
delete_issue_ids: string[];
@ -178,12 +178,15 @@ export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayersIcon height="56" width="56" />
<h3 className="text-custom-text-200">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-custom-background-80 px-2 py-1">C</pre>.
</h3>
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<EmptyState
type={
query === ""
? EmptyStateType.ISSUE_RELATION_EMPTY_STATE
: EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE
}
layout="screen-simple"
/>
</div>
)}
</Combobox.Options>

View File

@ -2,12 +2,14 @@ import React, { useEffect, useState } from "react";
import { Combobox, Dialog, Transition } from "@headlessui/react";
import { Rocket, Search, X } from "lucide-react";
// services
import { Button, LayersIcon, Loader, ToggleSwitch, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
import { ProjectService } from "services/project";
// hooks
import useDebounce from "hooks/use-debounce";
import { usePlatformOS } from "hooks/use-platform-os";
import { ProjectService } from "services/project";
// components
import { IssueSearchModalEmptyState } from "./issue-search-modal-empty-state";
// ui
import { Button, Loader, ToggleSwitch, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
// types
import { ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types";
@ -40,7 +42,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
const [isSearching, setIsSearching] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const { isMobile } = usePlatformOS();
const { isMobile } = usePlatformOS();
const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
const handleClose = () => {
@ -192,15 +194,12 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
</h5>
)}
{!isSearching && issues.length === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayersIcon height="52" width="52" />
<h3 className="text-custom-text-200">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-custom-background-80 px-2 py-1 text-sm">C</pre>.
</h3>
</div>
)}
<IssueSearchModalEmptyState
debouncedSearchTerm={debouncedSearchTerm}
isSearching={isSearching}
issues={issues}
searchTerm={searchTerm}
/>
{isSearching ? (
<Loader className="space-y-3 p-3">

View File

@ -4,3 +4,4 @@ export * from "./gpt-assistant-popover";
export * from "./link-modal";
export * from "./user-image-upload-modal";
export * from "./workspace-image-upload-modal";
export * from "./issue-search-modal-empty-state";

View File

@ -0,0 +1,36 @@
import React from "react";
// components
import { EmptyState } from "components/empty-state";
// types
import { ISearchIssueResponse } from "@plane/types";
// constants
import { EmptyStateType } from "constants/empty-state";
interface EmptyStateProps {
issues: ISearchIssueResponse[];
searchTerm: string;
debouncedSearchTerm: string;
isSearching: boolean;
}
export const IssueSearchModalEmptyState: React.FC<EmptyStateProps> = ({
issues,
searchTerm,
debouncedSearchTerm,
isSearching,
}) => {
const renderEmptyState = (type: EmptyStateType) => (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<EmptyState type={type} layout="screen-simple" />
</div>
);
const emptyState =
issues.length === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && !isSearching
? renderEmptyState(EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE)
: issues.length === 0
? renderEmptyState(EmptyStateType.ISSUE_RELATION_EMPTY_STATE)
: null;
return emptyState;
};

View File

@ -22,12 +22,14 @@ export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
<div className="h-full w-full">
<div className="flex h-full w-full justify-between">
<div className="h-full w-full flex flex-col p-8 space-y-8 vertical-scrollbar scrollbar-lg">
<CyclesBoardMap
cycleIds={cycleIds}
peekCycle={peekCycle}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
{cycleIds.length > 0 && (
<CyclesBoardMap
cycleIds={cycleIds}
peekCycle={peekCycle}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
)}
{completedCycleIds.length !== 0 && (
<Disclosure as="div" className="space-y-4">
<Disclosure.Button className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded flex items-center gap-1">

View File

@ -38,7 +38,7 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
{peekCycle && (
<div
ref={ref}
className="flex h-full w-full max-w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300 fixed md:relative right-0 z-[9]"
className="flex h-full w-full max-w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 duration-300 fixed md:relative right-0 z-[9]"
style={{
boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",

View File

@ -42,6 +42,8 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});
// derived values
const activeLayout = currentProjectDisplayFilters?.layout ?? "list";
const handleFilters = useCallback(
(key: keyof TCycleFilters, value: string | string[]) => {
@ -140,9 +142,7 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
<button
type="button"
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
currentProjectDisplayFilters?.layout == layout.key
? "bg-custom-background-100 shadow-custom-shadow-2xs"
: ""
activeLayout == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
}`}
onClick={() =>
updateDisplayFilters(projectId, {
@ -153,9 +153,7 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
<layout.icon
strokeWidth={2}
className={`h-3.5 w-3.5 ${
currentProjectDisplayFilters?.layout == layout.key
? "text-custom-text-100"
: "text-custom-text-200"
activeLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"
}`}
/>
</button>

View File

@ -6,6 +6,7 @@ import { DateRangeDropdown, ProjectDropdown } from "components/dropdowns";
// ui
// helpers
import { getDate, renderFormattedPayloadDate } from "helpers/date-time.helper";
import { shouldRenderProject } from "helpers/project.helper";
// types
import { ICycle } from "@plane/types";
@ -66,6 +67,7 @@ export const CycleForm: React.FC<Props> = (props) => {
setActiveProject(val);
}}
buttonVariant="background-with-text"
renderCondition={(project) => shouldRenderProject(project)}
tabIndex={7}
/>
)}

View File

@ -15,6 +15,7 @@ import { ProjectLogo } from "components/project";
// types
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
import { TDropdownProps } from "./types";
import { IProject } from "@plane/types";
// constants
type Props = TDropdownProps & {
@ -23,6 +24,7 @@ type Props = TDropdownProps & {
dropdownArrowClassName?: string;
onChange: (val: string) => void;
onClose?: () => void;
renderCondition?: (project: IProject) => boolean;
value: string | null;
};
@ -41,6 +43,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
onClose,
placeholder = "Project",
placement,
renderCondition,
showTooltip = false,
tabIndex,
value,
@ -71,7 +74,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
const options = joinedProjectIds?.map((projectId) => {
const projectDetails = getProjectById(projectId);
if (renderCondition && projectDetails && !renderCondition(projectDetails)) return;
return {
value: projectId,
query: `${projectDetails?.name}`,
@ -89,7 +92,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
});
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
query === "" ? options : options?.filter((o) => o?.query.toLowerCase().includes(query.toLowerCase()));
const selectedProject = value ? getProjectById(value) : null;
@ -205,24 +208,27 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
filteredOptions.map((option) => {
if (!option) return;
return (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
);
})
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)

View File

@ -16,7 +16,7 @@ import { cn } from "helpers/common.helper";
export type EmptyStateProps = {
type: EmptyStateType;
size?: "sm" | "md" | "lg";
layout?: "widget-simple" | "screen-detailed" | "screen-simple";
layout?: "screen-detailed" | "screen-simple";
additionalPath?: string;
primaryButtonOnClick?: () => void;
primaryButtonLink?: string;
@ -149,6 +149,28 @@ export const EmptyState: React.FC<EmptyStateProps> = (props) => {
</div>
</div>
)}
{layout === "screen-simple" && (
<div className="text-center flex flex-col gap-2.5 items-center">
<div className="h-28 w-28">
<Image
src={resolvedEmptyStatePath}
alt={key || "button image"}
width={96}
height={96}
layout="responsive"
lazyBoundary="100%"
/>
</div>
{description ? (
<>
<h3 className="text-lg font-medium text-custom-text-300 whitespace-pre-line">{title}</h3>
<p className="text-base font-medium text-custom-text-400 whitespace-pre-line">{description}</p>
</>
) : (
<h3 className="text-sm font-medium text-custom-text-400 whitespace-pre-line">{title}</h3>
)}
</div>
)}
</>
);
};

View File

@ -2,15 +2,19 @@ import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { Combobox, Dialog, Transition } from "@headlessui/react";
// hooks
import { useProject, useProjectState } from "hooks/store";
// icons
import { Search } from "lucide-react";
// components
import { EmptyState } from "components/empty-state";
// ui
import { Button, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui";
// fetch-keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
import { useProject, useProjectState } from "hooks/store";
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// services
import { IssueService } from "services/issue";
// constants
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
import { EmptyStateType } from "constants/empty-state";
type Props = {
isOpen: boolean;
@ -158,12 +162,15 @@ export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayersIcon height="56" width="56" />
<h3 className="text-sm text-custom-text-200">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-custom-background-80 px-2 py-1">C</pre>.
</h3>
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<EmptyState
type={
query === ""
? EmptyStateType.ISSUE_RELATION_EMPTY_STATE
: EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE
}
layout="screen-simple"
/>
</div>
)}
</Combobox.Options>

View File

@ -21,23 +21,21 @@ export const IssueAttachmentsList: FC<TIssueAttachmentsList> = observer((props)
const {
attachment: { getAttachmentsByIssueId },
} = useIssueDetail();
// derived values
const issueAttachments = getAttachmentsByIssueId(issueId);
if (!issueAttachments) return <></>;
return (
<>
{issueAttachments &&
issueAttachments.length > 0 &&
issueAttachments.map((attachmentId) => (
<IssueAttachmentsDetail
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
handleAttachmentOperations={handleAttachmentOperations}
/>
))}
{issueAttachments?.map((attachmentId) => (
<IssueAttachmentsDetail
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
handleAttachmentOperations={handleAttachmentOperations}
/>
))}
</>
);
});

View File

@ -69,7 +69,7 @@ export const IssueAttachmentDeleteModal: FC<Props> = (props) => {
</div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Delete Attachment
Delete attachment
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-custom-text-200">
@ -94,7 +94,7 @@ export const IssueAttachmentDeleteModal: FC<Props> = (props) => {
}}
disabled={loader}
>
{loader ? "Deleting..." : "Delete"}
{loader ? "Deleting" : "Delete"}
</Button>
</div>
</Dialog.Panel>

View File

@ -101,7 +101,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
return (
<div className="relative py-3 space-y-3">
<h3 className="text-lg">Attachments</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<IssueAttachmentUpload
workspaceSlug={workspaceSlug}
disabled={disabled}

View File

@ -79,7 +79,7 @@ export const IssueReaction: FC<TIssueReaction> = observer((props) => {
const reactionUsers = (reactionIds?.[reaction] || [])
.map((reactionId) => {
const reactionDetails = getReactionById(reactionId);
return reactionDetails ? getUserDetails(reactionDetails.actor_id)?.display_name : null;
return reactionDetails ? getUserDetails(reactionDetails.actor)?.display_name : null;
})
.filter((displayName): displayName is string => !!displayName);

View File

@ -62,12 +62,14 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
const isCompletedCycleSnapshotAvailable = !isEmpty(cycleDetails?.progress_snapshot ?? {});
const emptyStateType = isCompletedCycleSnapshotAvailable
const isCompletedAndEmpty = isCompletedCycleSnapshotAvailable || cycleDetails?.status.toLowerCase() === "completed";
const emptyStateType = isCompletedAndEmpty
? EmptyStateType.PROJECT_CYCLE_COMPLETED_NO_ISSUES
: isEmptyFilters
? EmptyStateType.PROJECT_EMPTY_FILTER
: EmptyStateType.PROJECT_CYCLE_NO_ISSUES;
const additionalPath = isCompletedCycleSnapshotAvailable ? undefined : activeLayout ?? "list";
const additionalPath = isCompletedAndEmpty ? undefined : activeLayout ?? "list";
const emptyStateSize = isEmptyFilters ? "lg" : "sm";
return (
@ -86,7 +88,7 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
additionalPath={additionalPath}
size={emptyStateSize}
primaryButtonOnClick={
!isCompletedCycleSnapshotAvailable && !isEmptyFilters
!isCompletedAndEmpty && !isEmptyFilters
? () => {
setTrackElement("Cycle issue empty state");
toggleCreateIssueModal(true, EIssuesStoreType.CYCLE);
@ -94,9 +96,7 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
: undefined
}
secondaryButtonOnClick={
!isCompletedCycleSnapshotAvailable && isEmptyFilters
? handleClearAllFilters
: () => setCycleIssuesListModal(true)
!isCompletedAndEmpty && isEmptyFilters ? handleClearAllFilters : () => setCycleIssuesListModal(true)
}
/>
</div>

View File

@ -55,7 +55,10 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
const handleCreateDraftIssue = async () => {
if (!changesMade || !workspaceSlug || !projectId) return;
const payload = { ...changesMade };
const payload = {
...changesMade,
name: changesMade.name?.trim() === "" ? "Untitled" : changesMade.name?.trim(),
};
await issueDraftService
.createDraftIssue(workspaceSlug.toString(), projectId.toString(), payload)

View File

@ -30,6 +30,7 @@ import { FileService } from "services/file.service";
// ui
// helpers
import { getChangedIssuefields } from "helpers/issue.helper";
import { shouldRenderProject } from "helpers/project.helper";
// types
import type { TIssue, ISearchIssueResponse } from "@plane/types";
@ -304,7 +305,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
handleFormChange();
}}
buttonVariant="border-with-text"
// TODO: update tabIndex logic
renderCondition={(project) => shouldRenderProject(project)}
tabIndex={getTabIndex("project_id")}
/>
</div>

View File

@ -3,14 +3,16 @@ import { useRouter } from "next/router";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import { Rocket, Search } from "lucide-react";
import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui";
import useDebounce from "hooks/use-debounce";
import { ProjectService } from "services/project";
// hooks
import useDebounce from "hooks/use-debounce";
import { usePlatformOS } from "hooks/use-platform-os";
// components
import { IssueSearchModalEmptyState } from "components/core";
// ui
import { Loader, ToggleSwitch, Tooltip } from "@plane/ui";
// icons
import { Rocket, Search } from "lucide-react";
// types
import { ISearchIssueResponse } from "@plane/types";
@ -151,15 +153,12 @@ export const ParentIssuesListModal: React.FC<Props> = ({
</h5>
)}
{!isSearching && issues.length === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayersIcon height="52" width="52" />
<h3 className="text-custom-text-200">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-custom-background-80 px-2 py-1 text-sm">C</pre>.
</h3>
</div>
)}
<IssueSearchModalEmptyState
debouncedSearchTerm={debouncedSearchTerm}
isSearching={isSearching}
issues={issues}
searchTerm={searchTerm}
/>
{isSearching ? (
<Loader className="space-y-3 p-3">

View File

@ -1,6 +1,6 @@
import { FC } from "react";
import Link from "next/link";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { MoveRight, MoveDiagonal, Link2, Trash2, RotateCcw } from "lucide-react";
// ui
import {
@ -74,8 +74,6 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
handleRestoreIssue,
isSubmitting,
} = props;
// router
const router = useRouter();
// store hooks
const { currentUser } = useUser();
const {
@ -101,10 +99,6 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
});
});
};
const redirectToIssueDetail = () => {
router.push({ pathname: `/${issueLink}` });
removeRoutePeekId();
};
// auth
const isArchivingAllowed = !isArchived && !disabled;
const isInArchivableGroup =
@ -122,9 +116,9 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
<button onClick={redirectToIssueDetail}>
<Link href={`/${issueLink}`} onClick={() => removeRoutePeekId()}>
<MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
</Link>
{currentMode && (
<div className="flex flex-shrink-0 items-center gap-2">
<CustomSelect

View File

@ -1,5 +1,6 @@
export * from "./header";
export * from "./issue-attachments";
export * from "./issue-detail";
export * from "./properties";
export * from "./root";
export * from "./view";
export * from "./header";

View File

@ -0,0 +1,111 @@
import { useMemo } from "react";
// hooks
import { useEventTracker, useIssueDetail } from "hooks/store";
// components
import { IssueAttachmentUpload, IssueAttachmentsList, TAttachmentOperations } from "components/issues";
// ui
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
type Props = {
disabled: boolean;
issueId: string;
projectId: string;
workspaceSlug: string;
};
export const PeekOverviewIssueAttachments: React.FC<Props> = (props) => {
const { disabled, issueId, projectId, workspaceSlug } = props;
// store hooks
const { captureIssueEvent } = useEventTracker();
const {
attachment: { createAttachment, removeAttachment },
} = useIssueDetail();
const handleAttachmentOperations: TAttachmentOperations = useMemo(
() => ({
create: async (data: FormData) => {
try {
const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data);
setPromiseToast(attachmentUploadPromise, {
loading: "Uploading attachment...",
success: {
title: "Attachment uploaded",
message: () => "The attachment has been successfully uploaded",
},
error: {
title: "Attachment not uploaded",
message: () => "The attachment could not be uploaded",
},
});
const res = await attachmentUploadPromise;
captureIssueEvent({
eventName: "Issue attachment added",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "attachment",
change_details: res.id,
},
});
} catch (error) {
captureIssueEvent({
eventName: "Issue attachment added",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
});
}
},
remove: async (attachmentId: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await removeAttachment(workspaceSlug, projectId, issueId, attachmentId);
setToast({
message: "The attachment has been successfully removed",
type: TOAST_TYPE.SUCCESS,
title: "Attachment removed",
});
captureIssueEvent({
eventName: "Issue attachment deleted",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "attachment",
change_details: "",
},
});
} catch (error) {
captureIssueEvent({
eventName: "Issue attachment deleted",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "attachment",
change_details: "",
},
});
setToast({
message: "The Attachment could not be removed",
type: TOAST_TYPE.ERROR,
title: "Attachment not removed",
});
}
},
}),
[workspaceSlug, projectId, issueId, captureIssueEvent, createAttachment, removeAttachment]
);
return (
<div>
<h6 className="text-sm font-medium">Attachments</h6>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-2 mt-3">
<IssueAttachmentUpload
workspaceSlug={workspaceSlug}
disabled={disabled}
handleAttachmentOperations={handleAttachmentOperations}
/>
<IssueAttachmentsList
issueId={issueId}
disabled={disabled}
handleAttachmentOperations={handleAttachmentOperations}
/>
</div>
</div>
);
};

View File

@ -55,7 +55,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
: undefined;
return (
<>
<div className="space-y-2">
<span className="text-base font-medium text-custom-text-400">
{projectDetails?.identifier}-{issue?.sequence_id}
</span>
@ -89,6 +89,6 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
currentUser={currentUser}
/>
)}
</>
</div>
);
});

View File

@ -61,7 +61,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
maxDate?.setDate(maxDate.getDate());
return (
<div className="mt-1">
<div>
<h6 className="text-sm font-medium">Properties</h6>
{/* TODO: render properties using a common component */}
<div className={`w-full space-y-2 mt-3 ${disabled ? "opacity-60" : ""}`}>

View File

@ -11,13 +11,15 @@ import {
PeekOverviewProperties,
TIssueOperations,
ArchiveIssueModal,
PeekOverviewIssueAttachments,
} from "components/issues";
// hooks
import { useIssueDetail } from "hooks/store";
import { useIssueDetail, useUser } from "hooks/store";
import useKeypress from "hooks/use-keypress";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// store hooks
import { IssueActivity } from "../issue-detail/issue-activity";
import { SubIssuesRoot } from "../sub-issues";
interface IIssueView {
workspaceSlug: string;
@ -37,6 +39,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
// ref
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
// store hooks
const { currentUser } = useUser();
const {
setPeekIssue,
isAnyModalOpen,
@ -147,7 +150,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
issue && (
<>
{["side-peek", "modal"].includes(peekMode) ? (
<div className="relative flex flex-col gap-3 px-8 py-5">
<div className="relative flex flex-col gap-3 px-8 py-5 space-y-3">
<PeekOverviewIssueDetails
workspaceSlug={workspaceSlug}
projectId={projectId}
@ -158,6 +161,23 @@ export const IssueView: FC<IIssueView> = observer((props) => {
setIsSubmitting={(value) => setIsSubmitting(value)}
/>
{currentUser && (
<SubIssuesRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={issueId}
currentUser={currentUser}
disabled={disabled || is_archived}
/>
)}
<PeekOverviewIssueAttachments
disabled={disabled || is_archived}
issueId={issueId}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<PeekOverviewProperties
workspaceSlug={workspaceSlug}
projectId={projectId}
@ -169,9 +189,9 @@ export const IssueView: FC<IIssueView> = observer((props) => {
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
</div>
) : (
<div className={`vertical-scrollbar flex h-full w-full overflow-auto`}>
<div className="vertical-scrollbar flex h-full w-full overflow-auto">
<div className="relative h-full w-full space-y-6 overflow-auto p-4 py-5">
<div>
<div className="space-y-3">
<PeekOverviewIssueDetails
workspaceSlug={workspaceSlug}
projectId={projectId}
@ -182,6 +202,23 @@ export const IssueView: FC<IIssueView> = observer((props) => {
setIsSubmitting={(value) => setIsSubmitting(value)}
/>
{currentUser && (
<SubIssuesRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={issueId}
currentUser={currentUser}
disabled={disabled || is_archived}
/>
)}
<PeekOverviewIssueAttachments
disabled={disabled || is_archived}
issueId={issueId}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
</div>
</div>

View File

@ -1,6 +1,6 @@
import React from "react";
import { observer } from "mobx-react-lite";
import { ChevronDown, ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
// components
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
import { useIssueDetail, useProject, useProjectState } from "hooks/store";
@ -11,6 +11,7 @@ import { IssueProperty } from "./properties";
// ui
// types
import { TSubIssueOperations } from "./root";
import { cn } from "helpers/common.helper";
// import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
export interface ISubIssues {
@ -90,11 +91,12 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
setSubIssueHelpers(parentIssueId, "issue_visibility", issueId);
}}
>
{subIssueHelpers.issue_visibility.includes(issue.id) ? (
<ChevronDown width={14} strokeWidth={2} />
) : (
<ChevronRight width={14} strokeWidth={2} />
)}
<ChevronRight
className={cn("h-3 w-3 transition-all", {
"rotate-90": subIssueHelpers.issue_visibility.includes(issue.id),
})}
strokeWidth={2}
/>
</div>
)}
</>

View File

@ -1,9 +1,9 @@
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { Plus, ChevronRight, ChevronDown, Loader } from "lucide-react";
import { Plus, ChevronRight, Loader, Pencil } from "lucide-react";
// hooks
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
import { CircularProgressIndicator, CustomMenu, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui";
import { ExistingIssuesListModal } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { copyTextToClipboard } from "helpers/string.helper";
@ -11,7 +11,7 @@ import { useEventTracker, useIssueDetail } from "hooks/store";
// components
import { IUser, TIssue } from "@plane/types";
import { IssueList } from "./issues-list";
import { ProgressBar } from "./progressbar";
import { cn } from "helpers/common.helper";
// ui
// helpers
// types
@ -53,6 +53,10 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
updateSubIssue,
removeSubIssue,
deleteSubIssue,
isCreateIssueModalOpen,
toggleCreateIssueModal,
isSubIssuesModalOpen,
toggleSubIssuesModal,
} = useIssueDetail();
const { setTrackElement, captureIssueEvent } = useEventTracker();
// state
@ -310,55 +314,81 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
<>
{subIssues && subIssues?.length > 0 ? (
<>
<div className="relative flex items-center gap-4 text-xs">
<div
className="flex cursor-pointer select-none items-center gap-1 rounded border border-custom-border-100 p-1.5 px-2 shadow transition-all hover:bg-custom-background-80"
onClick={handleFetchSubIssues}
>
<div className="flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center">
{subIssueHelpers.preview_loader.includes(parentIssueId) ? (
<Loader width={14} strokeWidth={2} className="animate-spin" />
) : subIssueHelpers.issue_visibility.includes(parentIssueId) ? (
<ChevronDown width={16} strokeWidth={2} />
) : (
<ChevronRight width={14} strokeWidth={2} />
)}
<div className="relative flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<button
type="button"
className="flex items-center gap-1 rounded py-1 px-2 transition-all hover:bg-custom-background-80 font-medium"
onClick={handleFetchSubIssues}
>
<div className="flex flex-shrink-0 items-center justify-center">
{subIssueHelpers.preview_loader.includes(parentIssueId) ? (
<Loader strokeWidth={2} className="h-3 w-3 animate-spin" />
) : (
<ChevronRight
className={cn("h-3 w-3 transition-all", {
"rotate-90": subIssueHelpers.issue_visibility.includes(parentIssueId),
})}
strokeWidth={2}
/>
)}
</div>
<div>Sub-issues</div>
</button>
<div className="flex items-center gap-2 text-custom-text-300">
<CircularProgressIndicator
size={16}
percentage={
subIssuesDistribution?.completed?.length && subIssues.length
? (subIssuesDistribution?.completed?.length / subIssues.length) * 100
: 0
}
strokeWidth={3}
/>
<span>
{subIssuesDistribution?.completed?.length ?? 0}/{subIssues.length} Done
</span>
</div>
<div>Sub-issues</div>
<div>({subIssues?.length || 0})</div>
</div>
<div className="w-full max-w-[250px] select-none">
<ProgressBar
total={subIssues?.length || 0}
done={
((subIssuesDistribution?.cancelled ?? []).length || 0) +
((subIssuesDistribution?.completed ?? []).length || 0)
}
/>
</div>
{!disabled && (
<div className="ml-auto flex flex-shrink-0 select-none items-center gap-2">
<div
className="cursor-pointer rounded border border-custom-border-100 p-1.5 px-2 shadow transition-all hover:bg-custom-background-80"
<CustomMenu
label={
<>
<Plus className="h-3 w-3" />
Add sub-issue
</>
}
buttonClassName="whitespace-nowrap"
placement="bottom-end"
noBorder
noChevron
>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail add sub-issue");
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("create", parentIssueId, null);
toggleCreateIssueModal(true);
}}
>
Add sub-issue
</div>
<div
className="cursor-pointer rounded border border-custom-border-100 p-1.5 px-2 shadow transition-all hover:bg-custom-background-80"
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
<span>Create new</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail add sub-issue");
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("existing", parentIssueId, null);
toggleSubIssuesModal(true);
}}
>
Add an existing issue
</div>
</div>
<div className="flex items-center gap-2">
<LayersIcon className="h-3 w-3" />
<span>Add existing</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
@ -379,62 +409,74 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
) : (
!disabled && (
<div className="flex items-center justify-between">
<div className="py-2 text-xs italic text-custom-text-300">No Sub-Issues yet</div>
<div>
<CustomMenu
label={
<>
<Plus className="h-3 w-3" />
Add sub-issue
</>
}
buttonClassName="whitespace-nowrap"
placement="bottom-end"
noBorder
noChevron
<div className="text-xs italic text-custom-text-300">No sub-issues yet</div>
<CustomMenu
label={
<>
<Plus className="h-3 w-3" />
Add sub-issue
</>
}
buttonClassName="whitespace-nowrap"
placement="bottom-end"
noBorder
noChevron
>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("create", parentIssueId, null);
toggleCreateIssueModal(true);
}}
>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("create", parentIssueId, null);
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("existing", parentIssueId, null);
}}
>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
<span>Create new</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("existing", parentIssueId, null);
toggleSubIssuesModal(true);
}}
>
<div className="flex items-center gap-2">
<LayersIcon className="h-3 w-3" />
<span>Add existing</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
)
)}
{/* issue create, add from existing , update and delete modals */}
{issueCrudState?.create?.toggle && issueCrudState?.create?.parentIssueId && (
{issueCrudState?.create?.toggle && issueCrudState?.create?.parentIssueId && isCreateIssueModalOpen && (
<CreateUpdateIssueModal
isOpen={issueCrudState?.create?.toggle}
data={{
parent_id: issueCrudState?.create?.parentIssueId,
}}
onClose={() => handleIssueCrudState("create", null, null)}
onClose={() => {
handleIssueCrudState("create", null, null);
toggleCreateIssueModal(false);
}}
onSubmit={async (_issue: TIssue) => {
await subIssueOperations.addSubIssue(workspaceSlug, projectId, parentIssueId, [_issue.id]);
}}
/>
)}
{issueCrudState?.existing?.toggle && issueCrudState?.existing?.parentIssueId && (
{issueCrudState?.existing?.toggle && issueCrudState?.existing?.parentIssueId && isSubIssuesModalOpen && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={issueCrudState?.existing?.toggle}
handleClose={() => handleIssueCrudState("existing", null, null)}
handleClose={() => {
handleIssueCrudState("existing", null, null);
toggleSubIssuesModal(false);
}}
searchParams={{ sub_issue: true, issue_id: issueCrudState?.existing?.parentIssueId }}
handleOnSubmit={(_issue) =>
subIssueOperations.addSubIssue(

View File

@ -7,6 +7,7 @@ import { ModuleStatusSelect } from "components/modules";
// ui
// helpers
import { getDate, renderFormattedPayloadDate } from "helpers/date-time.helper";
import { shouldRenderProject } from "helpers/project.helper";
// types
import { IModule } from "@plane/types";
@ -78,6 +79,7 @@ export const ModuleForm: React.FC<Props> = (props) => {
setActiveProject(val);
}}
buttonVariant="border-with-text"
renderCondition={(project) => shouldRenderProject(project)}
tabIndex={10}
/>
</div>

View File

@ -39,7 +39,7 @@ export const ModulePeekOverview: React.FC<Props> = observer(({ projectId, worksp
{peekModule && (
<div
ref={ref}
className="flex h-full w-full max-w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300 absolute md:relative right-0 z-[9]"
className="flex h-full w-full max-w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 duration-300 absolute md:relative right-0 z-[9]"
style={{
boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",

View File

@ -1,21 +1,22 @@
import React, { Fragment } from "react";
import { observer } from "mobx-react-lite";
import { Popover, Transition } from "@headlessui/react";
import { Bell } from "lucide-react";
// hooks
import { Tooltip } from "@plane/ui";
import { EmptyState } from "components/common";
import { SnoozeNotificationModal, NotificationCard, NotificationHeader } from "components/notifications";
import { NotificationsLoader } from "components/ui";
import { getNumberCount } from "helpers/string.helper";
import { useApplication } from "hooks/store";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
import useUserNotification from "hooks/use-user-notifications";
import { usePlatformOS } from "hooks/use-platform-os";
// icons
import { Bell } from "lucide-react";
// components
// images
import emptyNotification from "public/empty-state/notification.svg";
import { Tooltip } from "@plane/ui";
import { EmptyState } from "components/empty-state";
import { NotificationsLoader } from "components/ui";
import { SnoozeNotificationModal, NotificationCard, NotificationHeader } from "components/notifications";
// constants
import { EmptyStateType } from "constants/empty-state";
// helpers
import { getNumberCount } from "helpers/string.helper";
export const NotificationPopover = observer(() => {
// states
@ -59,6 +60,16 @@ export const NotificationPopover = observer(() => {
if (selectedNotificationForSnooze === null) setIsActive(false);
});
const currentTabEmptyState = snoozed
? EmptyStateType.NOTIFICATION_SNOOZED_EMPTY_STATE
: archived
? EmptyStateType.NOTIFICATION_ARCHIVED_EMPTY_STATE
: selectedTab === "created"
? EmptyStateType.NOTIFICATION_CREATED_EMPTY_STATE
: selectedTab === "watching"
? EmptyStateType.NOTIFICATION_SUBSCRIBED_EMPTY_STATE
: EmptyStateType.NOTIFICATION_MY_ISSUE_EMPTY_STATE;
return (
<>
<SnoozeNotificationModal
@ -70,7 +81,13 @@ export const NotificationPopover = observer(() => {
/>
<Popover ref={notificationPopoverRef} className="md:relative w-full">
<>
<Tooltip tooltipContent="Notifications" position="right" className="ml-2" disabled={!isSidebarCollapsed} isMobile={isMobile}>
<Tooltip
tooltipContent="Notifications"
position="right"
className="ml-2"
disabled={!isSidebarCollapsed}
isMobile={isMobile}
>
<button
className={`group relative flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
isActive
@ -184,11 +201,7 @@ export const NotificationPopover = observer(() => {
</div>
) : (
<div className="grid h-full w-full scale-75 place-items-center overflow-hidden">
<EmptyState
title="You're updated with all the notifications"
description="You have read all the notifications."
image={emptyNotification}
/>
<EmptyState type={currentTabEmptyState} layout="screen-simple" />
</div>
)
) : (

View File

@ -1,15 +1,8 @@
import { useEffect, Fragment, FC, useState } from "react";
import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { setToast, TOAST_TYPE } from "@plane/ui";
// components
import { CreateProjectForm } from "./create-project-form";
import { ProjectFeatureUpdate } from "./project-feature-update";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
// hooks
import { useUser } from "hooks/store";
type Props = {
isOpen: boolean;
@ -23,32 +16,11 @@ enum EProjectCreationSteps {
FEATURE_SELECTION = "FEATURE_SELECTION",
}
interface IIsGuestCondition {
onClose: () => void;
}
const IsGuestCondition: FC<IIsGuestCondition> = ({ onClose }) => {
useEffect(() => {
onClose();
setToast({
title: "Error",
type: TOAST_TYPE.ERROR,
message: "You don't have permission to create project.",
});
}, [onClose]);
return null;
};
export const CreateProjectModal: FC<Props> = observer((props) => {
export const CreateProjectModal: FC<Props> = (props) => {
const { isOpen, onClose, setToFavorite = false, workspaceSlug } = props;
// states
const [currentStep, setCurrentStep] = useState<EProjectCreationSteps>(EProjectCreationSteps.CREATE_PROJECT);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
// hooks
const {
membership: { currentWorkspaceRole },
} = useUser();
useEffect(() => {
if (isOpen) {
@ -57,9 +29,6 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
}
}, [isOpen]);
if (currentWorkspaceRole && isOpen)
if (currentWorkspaceRole < EUserWorkspaceRoles.MEMBER) return <IsGuestCondition onClose={onClose} />;
const handleNextStep = (projectId: string) => {
if (!projectId) return;
setCreatedProjectId(projectId);
@ -111,4 +80,4 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
</Dialog>
</Transition.Root>
);
});
};

View File

@ -55,10 +55,11 @@ export const WorkspaceSidebarMenu = observer(() => {
isMobile={isMobile}
>
<div
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${link.highlight(router.asPath, `/${workspaceSlug}`)
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
link.highlight(router.asPath, `/${workspaceSlug}`)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${themeStore?.sidebarCollapsed ? "justify-center" : ""}`}
} ${themeStore?.sidebarCollapsed ? "justify-center" : ""}`}
>
{
<link.Icon
@ -67,7 +68,7 @@ export const WorkspaceSidebarMenu = observer(() => {
})}
/>
}
<p className="leading-5">{!themeStore?.sidebarCollapsed && link.label}</p>
{!themeStore?.sidebarCollapsed && <p className="leading-5">{link.label}</p>}
{!themeStore?.sidebarCollapsed && link.key === "active-cycles" && (
<Crown className="h-3.5 w-3.5 text-amber-400" />
)}

View File

@ -59,7 +59,6 @@ export enum EmptyStateType {
PROJECT_DRAFT_NO_ISSUES = "project-draft-no-issues",
VIEWS_EMPTY_SEARCH = "views-empty-search",
PROJECTS_EMPTY_SEARCH = "projects-empty-search",
COMMANDK_EMPTY_SEARCH = "commandK-empty-search",
MEMBERS_EMPTY_SEARCH = "members-empty-search",
PROJECT_MODULE_ISSUES = "project-module-issues",
PROJECT_MODULE = "project-module",
@ -71,6 +70,18 @@ export enum EmptyStateType {
PROJECT_PAGE_SHARED = "project-page-shared",
PROJECT_PAGE_ARCHIVED = "project-page-archived",
PROJECT_PAGE_RECENT = "project-page-recent",
COMMAND_K_SEARCH_EMPTY_STATE = "command-k-search-empty-state",
ISSUE_RELATION_SEARCH_EMPTY_STATE = "issue-relation-search-empty-state",
ISSUE_RELATION_EMPTY_STATE = "issue-relation-empty-state",
ISSUE_COMMENT_EMPTY_STATE = "issue-comment-empty-state",
NOTIFICATION_MY_ISSUE_EMPTY_STATE = "notification-my-issues-empty-state",
NOTIFICATION_CREATED_EMPTY_STATE = "notification-created-empty-state",
NOTIFICATION_SUBSCRIBED_EMPTY_STATE = "notification-subscribed-empty-state",
NOTIFICATION_ARCHIVED_EMPTY_STATE = "notification-archived-empty-state",
NOTIFICATION_SNOOZED_EMPTY_STATE = "notification-snoozed-empty-state",
NOTIFICATION_UNREAD_EMPTY_STATE = "notification-unread-empty-state",
}
const emptyStateDetails = {
@ -384,11 +395,6 @@ const emptyStateDetails = {
description: "No projects detected with the matching criteria. Create a new project instead.",
path: "/empty-state/search/project",
},
[EmptyStateType.COMMANDK_EMPTY_SEARCH]: {
key: EmptyStateType.COMMANDK_EMPTY_SEARCH,
title: "No results found. ",
path: "/empty-state/search/search",
},
[EmptyStateType.MEMBERS_EMPTY_SEARCH]: {
key: EmptyStateType.MEMBERS_EMPTY_SEARCH,
title: "No matching members",
@ -504,6 +510,66 @@ const emptyStateDetails = {
accessType: "project",
access: EUserProjectRoles.MEMBER,
},
[EmptyStateType.COMMAND_K_SEARCH_EMPTY_STATE]: {
key: EmptyStateType.COMMAND_K_SEARCH_EMPTY_STATE,
title: "No results found",
path: "/empty-state/search/search",
},
[EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE]: {
key: EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE,
title: "No maching issues found",
path: "/empty-state/search/search",
},
[EmptyStateType.ISSUE_RELATION_EMPTY_STATE]: {
key: EmptyStateType.ISSUE_RELATION_EMPTY_STATE,
title: "No issues found",
path: "/empty-state/search/issues",
},
[EmptyStateType.ISSUE_COMMENT_EMPTY_STATE]: {
key: EmptyStateType.ISSUE_COMMENT_EMPTY_STATE,
title: "No comments yet",
description: "Comments can be used as a discussion and follow-up space for the issues",
path: "/empty-state/search/comments",
},
[EmptyStateType.NOTIFICATION_MY_ISSUE_EMPTY_STATE]: {
key: EmptyStateType.NOTIFICATION_MY_ISSUE_EMPTY_STATE,
title: "No issues assigned",
description: "Updates for issues assigned to you can be \n seen here",
path: "/empty-state/search/notification",
},
[EmptyStateType.NOTIFICATION_CREATED_EMPTY_STATE]: {
key: EmptyStateType.NOTIFICATION_CREATED_EMPTY_STATE,
title: "No updates to issues",
description: "Updates to issues created by you can be \n seen here",
path: "/empty-state/search/notification",
},
[EmptyStateType.NOTIFICATION_SUBSCRIBED_EMPTY_STATE]: {
key: EmptyStateType.NOTIFICATION_SUBSCRIBED_EMPTY_STATE,
title: "No updates to issues",
description: "Updates to any issue you are \n subscribed to can be seen here",
path: "/empty-state/search/notification",
},
[EmptyStateType.NOTIFICATION_UNREAD_EMPTY_STATE]: {
key: EmptyStateType.NOTIFICATION_UNREAD_EMPTY_STATE,
title: "No unread notifications",
description: "Congratulations, you are up-to-date \n with everything happening in the issues \n you care about",
path: "/empty-state/search/notification",
},
[EmptyStateType.NOTIFICATION_SNOOZED_EMPTY_STATE]: {
key: EmptyStateType.NOTIFICATION_SNOOZED_EMPTY_STATE,
title: "No snoozed notifications yet",
description: "Any notification you snooze for later will \n be available here to act upon",
path: "/empty-state/search/snooze",
},
[EmptyStateType.NOTIFICATION_ARCHIVED_EMPTY_STATE]: {
key: EmptyStateType.NOTIFICATION_ARCHIVED_EMPTY_STATE,
title: "No archived notifications yet",
description: "Any notification you archive will be \n available here to help you focus",
path: "/empty-state/search/archive",
},
} as const;
export const EMPTY_STATE_DETAILS: Record<EmptyStateType, EmptyStateDetails> = emptyStateDetails;

View File

@ -3,6 +3,8 @@ import sortBy from "lodash/sortBy";
import { satisfiesDateFilter } from "helpers/filter.helper";
// types
import { IProject, TProjectDisplayFilters, TProjectFilters, TProjectOrderByOptions } from "@plane/types";
// constants
import { EUserProjectRoles } from "constants/project";
/**
* Updates the sort order of the project.
@ -51,6 +53,14 @@ export const orderJoinedProjects = (
export const projectIdentifierSanitizer = (identifier: string): string =>
identifier.replace(/[^ÇŞĞIİÖÜA-Za-z0-9]/g, "");
/**
* @description Checks if the project should be rendered or not based on the user role
* @param {IProject} project
* @returns {boolean}
*/
export const shouldRenderProject = (project: IProject): boolean =>
!!project.member_role && project.member_role >= EUserProjectRoles.MEMBER;
/**
* @description filters projects based on the filter
* @param {IProject} project

View File

@ -54,12 +54,12 @@ const ProfileActivityPage: NextPageWithLayout = observer(() => {
currentUser?.id === userId && !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
return (
<div className="h-full w-full px-5 py-5 md:px-9 flex flex-col overflow-hidden">
<div className="flex items-center justify-between gap-2">
<div className="h-full w-full py-5 flex flex-col overflow-hidden">
<div className="flex items-center justify-between gap-2 px-5 md:px-9">
<h3 className="text-lg font-medium">Recent activity</h3>
{canDownloadActivity && <DownloadActivityButton />}
</div>
<div className="h-full flex flex-col overflow-y-auto">
<div className="h-full flex flex-col overflow-y-auto vertical-scrollbar scrollbar-md px-5 md:px-9">
{activityPages}
{pageCount < totalPages && resultsCount !== 0 && (
<div className="flex items-center justify-center text-xs w-full">

View File

@ -47,7 +47,7 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
const pageTitle = project?.name ? `${project?.name} - Cycles` : undefined;
// selected display filters
const cycleTab = currentProjectDisplayFilters?.active_tab;
const cycleLayout = currentProjectDisplayFilters?.layout;
const cycleLayout = currentProjectDisplayFilters?.layout ?? "list";
const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => {
if (!projectId) return;
@ -120,14 +120,12 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
<ActiveCycleRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleTab && cycleLayout && (
<CyclesView
layout={cycleLayout}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
peekCycle={peekCycle?.toString()}
/>
)}
<CyclesView
layout={cycleLayout}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
peekCycle={peekCycle?.toString()}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -84,7 +84,7 @@ export class IssueReactionStore implements IIssueReactionStore {
if (reactions?.[reaction])
reactions?.[reaction].map((reactionId) => {
const currentReaction = this.getReactionById(reactionId);
if (currentReaction && currentReaction.actor_id === userId) _userReactions.push(currentReaction);
if (currentReaction && currentReaction.actor === userId) _userReactions.push(currentReaction);
});
});
@ -151,7 +151,7 @@ export class IssueReactionStore implements IIssueReactionStore {
) => {
try {
const userReactions = this.reactionsByUser(issueId, userId);
const currentReaction = find(userReactions, { actor_id: userId, reaction: reaction });
const currentReaction = find(userReactions, { actor: userId, reaction: reaction });
if (currentReaction && currentReaction.id) {
runInAction(() => {

View File

@ -44,20 +44,24 @@ export interface IIssueDetail
IIssueCommentReactionStoreActions {
// observables
peekIssue: TPeekIssue | undefined;
isCreateIssueModalOpen: boolean;
isIssueLinkModalOpen: boolean;
isParentIssueModalOpen: boolean;
isDeleteIssueModalOpen: boolean;
isArchiveIssueModalOpen: boolean;
isRelationModalOpen: TIssueRelationTypes | null;
isSubIssuesModalOpen: boolean;
// computed
isAnyModalOpen: boolean;
// actions
setPeekIssue: (peekIssue: TPeekIssue | undefined) => void;
toggleCreateIssueModal: (value: boolean) => void;
toggleIssueLinkModal: (value: boolean) => void;
toggleParentIssueModal: (value: boolean) => void;
toggleDeleteIssueModal: (value: boolean) => void;
toggleArchiveIssueModal: (value: boolean) => void;
toggleRelationModal: (value: TIssueRelationTypes | null) => void;
toggleSubIssuesModal: (value: boolean) => void;
// store
rootIssueStore: IIssueRootStore;
issue: IIssueStore;
@ -75,11 +79,13 @@ export interface IIssueDetail
export class IssueDetail implements IIssueDetail {
// observables
peekIssue: TPeekIssue | undefined = undefined;
isCreateIssueModalOpen: boolean = false;
isIssueLinkModalOpen: boolean = false;
isParentIssueModalOpen: boolean = false;
isDeleteIssueModalOpen: boolean = false;
isArchiveIssueModalOpen: boolean = false;
isRelationModalOpen: TIssueRelationTypes | null = null;
isSubIssuesModalOpen: boolean = false;
// store
rootIssueStore: IIssueRootStore;
issue: IIssueStore;
@ -97,20 +103,24 @@ export class IssueDetail implements IIssueDetail {
makeObservable(this, {
// observables
peekIssue: observable,
isCreateIssueModalOpen: observable,
isIssueLinkModalOpen: observable.ref,
isParentIssueModalOpen: observable.ref,
isDeleteIssueModalOpen: observable.ref,
isArchiveIssueModalOpen: observable.ref,
isRelationModalOpen: observable.ref,
isSubIssuesModalOpen: observable.ref,
// computed
isAnyModalOpen: computed,
// action
setPeekIssue: action,
toggleCreateIssueModal: action,
toggleIssueLinkModal: action,
toggleParentIssueModal: action,
toggleDeleteIssueModal: action,
toggleArchiveIssueModal: action,
toggleRelationModal: action,
toggleSubIssuesModal: action,
});
// store
@ -130,21 +140,25 @@ export class IssueDetail implements IIssueDetail {
// computed
get isAnyModalOpen() {
return (
this.isCreateIssueModalOpen ||
this.isIssueLinkModalOpen ||
this.isParentIssueModalOpen ||
this.isDeleteIssueModalOpen ||
this.isArchiveIssueModalOpen ||
Boolean(this.isRelationModalOpen)
Boolean(this.isRelationModalOpen) ||
this.isSubIssuesModalOpen
);
}
// actions
setPeekIssue = (peekIssue: TPeekIssue | undefined) => (this.peekIssue = peekIssue);
toggleCreateIssueModal = (value: boolean) => (this.isCreateIssueModalOpen = value);
toggleIssueLinkModal = (value: boolean) => (this.isIssueLinkModalOpen = value);
toggleParentIssueModal = (value: boolean) => (this.isParentIssueModalOpen = value);
toggleDeleteIssueModal = (value: boolean) => (this.isDeleteIssueModalOpen = value);
toggleArchiveIssueModal = (value: boolean) => (this.isArchiveIssueModalOpen = value);
toggleRelationModal = (value: TIssueRelationTypes | null) => (this.isRelationModalOpen = value);
toggleSubIssuesModal = (value: boolean) => (this.isSubIssuesModalOpen = value);
// issue
fetchIssue = async (