Merge branch 'develop' of github.com:makeplane/plane into fix/date-time-zone-misalignment
@ -1,7 +1,7 @@
|
||||
export type TIssueReaction = {
|
||||
actor_id: string;
|
||||
actor: string;
|
||||
id: string;
|
||||
issue_id: string;
|
||||
issue: string;
|
||||
reaction: string;
|
||||
};
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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) && (
|
||||
|
@ -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 />
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
};
|
@ -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">
|
||||
|
@ -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)",
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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";
|
||||
|
111
web/components/issues/peek-overview/issue-attachments.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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" : ""}`}>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
@ -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(
|
||||
|
@ -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>
|
||||
|
@ -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)",
|
||||
|
@ -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>
|
||||
)
|
||||
) : (
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
@ -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" />
|
||||
)}
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
BIN
web/public/empty-state/search/archive-dark.webp
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
web/public/empty-state/search/archive-light.webp
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
web/public/empty-state/search/comments-dark.webp
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
web/public/empty-state/search/comments-light.webp
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
web/public/empty-state/search/issue-dark.webp
Normal file
After Width: | Height: | Size: 3.0 KiB |
BIN
web/public/empty-state/search/issues-light.webp
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
web/public/empty-state/search/notification-dark.webp
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
web/public/empty-state/search/notification-light.webp
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
web/public/empty-state/search/search-dark.webp
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
web/public/empty-state/search/search-light.webp
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
web/public/empty-state/search/snooze-dark.webp
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
web/public/empty-state/search/snooze-light.webp
Normal file
After Width: | Height: | Size: 2.7 KiB |
@ -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(() => {
|
||||
|
@ -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 (
|
||||
|