[WEB-3759] chore: header revamp for cycles, modules, pages and views (#6875)

* chore: header revamp for cycles, modules, pages and views

* chore: moved list fetch to layout level
This commit is contained in:
Vamsi Krishna
2025-04-09 14:56:57 +05:30
committed by GitHub
parent 2b411de1e3
commit 993c7899b6
17 changed files with 269 additions and 453 deletions
+8
View File
@@ -26,3 +26,11 @@ export type TLogoProps = {
export type TNameDescriptionLoader = "submitting" | "submitted" | "saved";
export type TFetchStatus = "partial" | "complete" | undefined;
export type ICustomSearchSelectOption = {
value: any;
query: string;
content: React.ReactNode;
disabled?: boolean;
tooltip?: string | React.ReactNode;
};
+2 -9
View File
@@ -1,5 +1,6 @@
// FIXME: fix this!!!
import { Placement } from "@blueprintjs/popover2";
import { ICustomSearchSelectOption } from "@plane/types";
export interface IDropdownProps {
customButtonClassName?: string;
@@ -44,15 +45,7 @@ interface CustomSearchSelectProps {
onChange: any;
onClose?: () => void;
noResultsMessage?: string;
options:
| {
value: any;
query: string;
content: React.ReactNode;
disabled?: boolean;
tooltip?: string | React.ReactNode;
}[]
| undefined;
options?: ICustomSearchSelectOption[];
}
interface SingleValueProps {
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
@@ -18,12 +18,18 @@ import {
// i18n
import { useTranslation } from "@plane/i18n";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
import {
ICustomSearchSelectOption,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
} from "@plane/types";
// ui
import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip, Header } from "@plane/ui";
import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink } from "@/components/common";
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
import { CycleQuickActions } from "@/components/cycles";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
@@ -69,6 +75,8 @@ const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => {
};
export const CycleIssuesHeader: React.FC = observer(() => {
// refs
const parentRef = useRef<HTMLDivElement>(null);
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
// router
@@ -159,6 +167,25 @@ export const CycleIssuesHeader: React.FC = observer(() => {
EUserPermissionsLevel.PROJECT
);
const switcherOptions = currentProjectCycleIds
?.map((id) => {
const _cycle = id === cycleId ? cycleDetails : getCycleById(id);
if (!_cycle) return;
const cycleLink = `/${workspaceSlug}/projects/${projectId}/cycles/${_cycle.id}`;
return {
value: _cycle.id,
query: _cycle.name,
content: (
<Link href={cycleLink}>
<SwitcherLabel name={_cycle.name} LabelIcon={ContrastIcon} />
</Link>
),
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
const workItemsCount = getGroupIssueCount(undefined, undefined, false);
const issuesCount = getGroupIssueCount(undefined, undefined, false);
return (
@@ -201,33 +228,29 @@ export const CycleIssuesHeader: React.FC = observer(() => {
<Breadcrumbs.BreadcrumbItem
type="component"
component={
<CustomMenu
<CustomSearchSelect
options={switcherOptions}
value={cycleId}
onChange={() => {}}
label={
<>
<ContrastIcon className="h-3 w-3" />
<div className="flex w-auto max-w-[70px] items-center gap-2 truncate sm:max-w-[200px]">
<p className="truncate">{cycleDetails?.name && cycleDetails.name}</p>
{issuesCount && issuesCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issuesCount} ${
issuesCount > 1 ? "work items" : "work item"
} in this cycle`}
position="bottom"
>
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
{issuesCount}
</span>
</Tooltip>
) : null}
</div>
</>
<div className="flex items-center gap-1">
<SwitcherLabel name={cycleDetails?.name} LabelIcon={ContrastIcon} />
{workItemsCount && workItemsCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${workItemsCount} ${
workItemsCount > 1 ? "work items" : "work item"
} in this cycle`}
position="bottom"
>
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
{workItemsCount}
</span>
</Tooltip>
) : null}
</div>
}
className="ml-1.5 flex-shrink-0 truncate"
placement="bottom-start"
>
{currentProjectCycleIds?.map((cycleId) => <CycleDropdownOption key={cycleId} cycleId={cycleId} />)}
</CustomMenu>
/>
}
/>
</Breadcrumbs>
@@ -302,19 +325,19 @@ export const CycleIssuesHeader: React.FC = observer(() => {
)}
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
className="p-1 rounded outline-none hover:bg-custom-sidebar-background-80 bg-custom-background-80/70"
onClick={toggleSidebar}
>
<ArrowRight className={`h-4 w-4 duration-300 ${isSidebarCollapsed ? "-rotate-180" : ""}`} />
<PanelRight className={cn("h-4 w-4", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
</button>
<CycleQuickActions
parentRef={parentRef}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
customClassName="flex-shrink-0 flex items-center justify-center size-6 bg-custom-background-80/70 rounded"
/>
</div>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 md:hidden"
onClick={toggleSidebar}
>
<PanelRight className={cn("h-4 w-4", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
</button>
</Header.RightItem>
</Header>
</>
@@ -16,12 +16,17 @@ import {
EUserPermissionsLevel,
} from "@plane/constants";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
import {
ICustomSearchSelectOption,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
} from "@plane/types";
// ui
import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip, Header } from "@plane/ui";
import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink } from "@/components/common";
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
@@ -155,7 +160,24 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
EUserPermissionsLevel.PROJECT
);
const issuesCount = getGroupIssueCount(undefined, undefined, false);
const workItemsCount = getGroupIssueCount(undefined, undefined, false);
const switcherOptions = projectModuleIds
?.map((id) => {
const _module = id === moduleId ? moduleDetails : getModuleById(id);
if (!_module) return;
const moduleLink = `/${workspaceSlug}/projects/${projectId}/modules/${_module.id}`;
return {
value: _module.id,
query: _module.name,
content: (
<Link href={moduleLink}>
<SwitcherLabel name={_module.name} LabelIcon={DiceIcon} />
</Link>
),
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
return (
<>
@@ -196,33 +218,29 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
<Breadcrumbs.BreadcrumbItem
type="component"
component={
<CustomMenu
<CustomSearchSelect
options={switcherOptions}
label={
<>
<DiceIcon className="h-3 w-3" />
<div className="flex w-auto max-w-[70px] items-center gap-2 truncate sm:max-w-[200px]">
<p className="truncate">{moduleDetails?.name && moduleDetails.name}</p>
{issuesCount && issuesCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issuesCount} ${
issuesCount > 1 ? "work items" : "work item"
} in this module`}
position="bottom"
>
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
{issuesCount}
</span>
</Tooltip>
) : null}
</div>
</>
<div className="flex items-center gap-1">
<SwitcherLabel name={moduleDetails?.name} LabelIcon={DiceIcon} />
{workItemsCount && workItemsCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${workItemsCount} ${
workItemsCount > 1 ? "work items" : "work item"
} in this module`}
position="bottom"
>
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
{workItemsCount}
</span>
</Tooltip>
) : null}
</div>
}
className="ml-1.5 flex-shrink-0"
placement="bottom-start"
>
{projectModuleIds?.map((moduleId) => <ModuleDropdownOption key={moduleId} moduleId={moduleId} />)}
</CustomMenu>
value={moduleId}
onChange={() => {}}
/>
}
/>
</Breadcrumbs>
@@ -1,27 +1,23 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { FileText } from "lucide-react";
import { FileText, Layers } from "lucide-react";
// types
import { TLogoProps } from "@plane/types";
import { ICustomSearchSelectOption } from "@plane/types";
// ui
import { Breadcrumbs, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, Tooltip, setToast, Header } from "@plane/ui";
import { Breadcrumbs, Header, CustomSearchSelect } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
import { PageEditInformationPopover } from "@/components/pages";
// helpers
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
import { getPageName } from "@/helpers/page.helper";
// hooks
import { useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
// plane web hooks
import { EPageStoreType, usePage } from "@/plane-web/hooks/store";
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
export interface IPagesHeaderProps {
showButton?: boolean;
@@ -29,42 +25,36 @@ export interface IPagesHeaderProps {
export const PageDetailsHeader = observer(() => {
// router
const { workspaceSlug, pageId } = useParams();
// state
const [isOpen, setIsOpen] = useState(false);
const { workspaceSlug, pageId, projectId } = useParams();
// store hooks
const { currentProjectDetails, loader } = useProject();
const page = usePage({
pageId: pageId?.toString() ?? "",
storeType: EPageStoreType.PROJECT,
});
if (!page) return null;
const { getPageById, getCurrentProjectPageIds } = usePageStore(EPageStoreType.PROJECT);
// derived values
const { name, logo_props, updatePageLogo, isContentEditable } = page;
// use platform
const { isMobile } = usePlatformOS();
const projectPageIds = getCurrentProjectPageIds(projectId?.toString());
const handlePageLogoUpdate = async (data: TLogoProps) => {
if (data) {
updatePageLogo(data)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Logo Updated successfully.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
});
});
}
};
if (!page) return null;
const switcherOptions = projectPageIds
.map((id) => {
const _page = id === pageId ? page : getPageById(id);
if (!_page) return;
const pageLink = `/${workspaceSlug}/projects/${projectId}/pages/${_page.id}`;
return {
value: _page.id,
query: _page.name,
content: (
<Link href={pageLink} className="flex gap-2 items-center justify-between">
<SwitcherLabel logo_props={_page.logo_props} name={_page.name} LabelIcon={Layers} />
</Link>
),
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
const pageTitle = getPageName(name);
if (!page) return null;
return (
<Header>
@@ -99,60 +89,14 @@ export const PageDetailsHeader = observer(() => {
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<li className="flex items-center space-x-2" tabIndex={-1}>
<div className="flex flex-wrap items-center gap-2.5">
<div className="flex cursor-default items-center gap-1 text-sm font-medium text-custom-text-100">
<div className="flex h-5 w-5 items-center justify-center overflow-hidden">
<EmojiIconPicker
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center"
buttonClassName="flex items-center justify-center"
label={
<>
{logo_props?.in_use ? (
<Logo logo={logo_props} size={16} type="lucide" />
) : (
<FileText className="h-4 w-4 text-custom-text-300" />
)}
</>
}
onChange={(val) => {
let logoValue = {};
if (val?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
};
else if (val?.type === "icon") logoValue = val.value;
handlePageLogoUpdate({
in_use: val?.type,
[val?.type]: logoValue,
}).finally(() => setIsOpen(false));
}}
defaultIconColor={
logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined
}
defaultOpen={
logo_props?.in_use && logo_props?.in_use === "emoji"
? EmojiIconPickerTypes.EMOJI
: EmojiIconPickerTypes.ICON
}
disabled={!isContentEditable}
/>
</div>
<Tooltip tooltipContent={pageTitle} position="bottom" isMobile={isMobile}>
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">
{pageTitle}
</div>
</Tooltip>
</div>
</div>
</li>
type="component"
component={
<CustomSearchSelect
value={pageId}
options={switcherOptions}
label={<SwitcherLabel logo_props={page.logo_props} name={page.name} LabelIcon={Layers} />}
onChange={() => {}}
/>
}
/>
</Breadcrumbs>
@@ -1,11 +1,22 @@
"use client";
// component
import { useParams } from "next/navigation";
import useSWR from "swr";
import { AppHeader, ContentWrapper } from "@/components/core";
// plane web hooks
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
// local components
import { PageDetailsHeader } from "./header";
export default function ProjectPageDetailsLayout({ children }: { children: React.ReactNode }) {
const { workspaceSlug, projectId } = useParams();
const { fetchPagesList } = usePageStore(EPageStoreType.PROJECT);
// fetching pages list
useSWR(
workspaceSlug && projectId ? `PROJECT_PAGES_${projectId}` : null,
workspaceSlug && projectId ? () => fetchPagesList(workspaceSlug.toString(), projectId.toString()) : null
);
return (
<>
<AppHeader header={<PageDetailsHeader />} />
@@ -16,17 +16,21 @@ import {
EUserPermissionsLevel,
} from "@plane/constants";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
import {
ICustomSearchSelectOption,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
} from "@plane/types";
// ui
import { Breadcrumbs, Button, CustomMenu, Tooltip, Header } from "@plane/ui";
import { Breadcrumbs, Button, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// constants
import { ViewQuickActions } from "@/components/views";
// helpers
import { isIssueFilterActive } from "@/helpers/filter.helper";
import { truncateText } from "@/helpers/string.helper";
// hooks
import {
useCommandPalette,
@@ -143,6 +147,23 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
if (!viewDetails) return;
const switcherOptions = projectViewIds
?.map((id) => {
const _view = id === viewId ? viewDetails : getViewById(id);
if (!_view) return;
const viewLink = `/${workspaceSlug}/projects/${projectId}/views/${_view.id}`;
return {
value: _view.id,
query: _view.name,
content: (
<Link href={viewLink}>
<SwitcherLabel logo_props={_view.logo_props} name={_view.name} LabelIcon={Layers} />
</Link>
),
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
return (
<Header>
<Header.LeftItem>
@@ -161,42 +182,12 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
<Breadcrumbs.BreadcrumbItem
type="component"
component={
<CustomMenu
label={
<>
{viewDetails?.logo_props?.in_use ? (
<Logo logo={viewDetails.logo_props} size={12} type="lucide" />
) : (
<Layers height={12} width={12} />
)}
{viewDetails?.name && truncateText(viewDetails.name, 40)}
</>
}
className="ml-1.5"
placement="bottom-start"
>
{projectViewIds?.map((viewId) => {
const view = getViewById(viewId);
if (!view) return;
return (
<CustomMenu.MenuItem key={viewId}>
<Link
href={`/${workspaceSlug}/projects/${projectId}/views/${viewId}`}
className="flex items-center gap-1.5"
>
{view?.logo_props?.in_use ? (
<Logo logo={view.logo_props} size={12} type="lucide" />
) : (
<Layers height={12} width={12} />
)}
{truncateText(view.name, 40)}
</Link>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
<CustomSearchSelect
options={switcherOptions}
value={viewId}
label={<SwitcherLabel logo_props={viewDetails.logo_props} name={viewDetails.name} LabelIcon={Layers} />}
onChange={() => {}}
/>
}
/>
</Breadcrumbs>
@@ -210,17 +201,8 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
) : (
<></>
)}
<div className="hidden md:block">
<ViewQuickActions
parentRef={parentRef}
projectId={projectId.toString()}
view={viewDetails}
workspaceSlug={workspaceSlug.toString()}
/>
</div>
</Header.LeftItem>
<Header.RightItem>
<Header.RightItem className="items-center">
{!viewDetails?.is_locked ? (
<>
<LayoutSelection
@@ -287,6 +269,15 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
) : (
<></>
)}
<div className="hidden md:block">
<ViewQuickActions
parentRef={parentRef}
customClassName="flex-shrink-0 flex items-center justify-center size-6 bg-custom-background-80/70 rounded"
projectId={projectId.toString()}
view={viewDetails}
workspaceSlug={workspaceSlug.toString()}
/>
</div>
</Header.RightItem>
</Header>
);
+1
View File
@@ -6,3 +6,4 @@ export * from "./logo";
export * from "./pro-icon";
export * from "./count-chip";
export * from "./activity";
export * from "./switcher-label";
@@ -0,0 +1,27 @@
import { FC } from "react";
import { TLogoProps } from "@plane/types";
import { ISvgIcons, Logo } from "@plane/ui";
import { getFileURL } from "@plane/utils";
import { truncateText } from "@/helpers/string.helper";
type TSwitcherLabelProps = {
logo_props?: TLogoProps;
logo_url?: string;
name?: string;
LabelIcon: FC<ISvgIcons>;
};
export const SwitcherLabel: FC<TSwitcherLabelProps> = (props) => {
const { logo_props, name, LabelIcon, logo_url } = props;
return (
<div className="flex items-center gap-1 text-custom-text-200">
{logo_props?.in_use ? (
<Logo logo={logo_props} size={12} type="lucide" />
) : logo_url ? (
<img src={getFileURL(logo_url)} alt="logo" className="rounded-sm w-3 h-3 object-cover" />
) : (
<LabelIcon height={12} width={12} />
)}
{truncateText(name ?? "", 40)}
</div>
);
};
@@ -75,44 +75,6 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const handleRestoreCycle = async () => {
if (!workspaceSlug || !projectId) return;
await restoreCycle(workspaceSlug.toString(), projectId.toString(), cycleDetails.id)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("project_cycles.action.restore.success.title"),
message: t("project_cycles.action.restore.success.description"),
});
router.push(`/${workspaceSlug.toString()}/projects/${projectId.toString()}/archives/cycles`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("project_cycles.action.restore.failed.title"),
message: t("project_cycles.action.restore.failed.description"),
})
);
};
const handleCopyText = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("common.link_copied_to_clipboard"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.errors.default.message"),
});
});
};
const submitChanges = async (data: Partial<ICycle>, changedProperty: string) => {
if (!workspaceSlug || !projectId || !cycleDetails.id) return;
@@ -224,62 +186,6 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
<ChevronRight className="h-3 w-3 stroke-2 text-white" />
</button>
</div>
<div className="flex items-center gap-3">
{!isArchived && (
<button onClick={handleCopyText} className="size-4">
<LinkIcon className="size-3.5 text-custom-text-300" />
</button>
)}
{isEditingAllowed && (
<CustomMenu
placement="bottom-end"
customButtonClassName="size-4"
customButton={<EllipsisIcon className="size-3.5 text-custom-text-300" />}
>
{!isArchived && (
<CustomMenu.MenuItem onClick={() => setArchiveCycleModal(true)} disabled={!isCompleted}>
{isCompleted ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
{t("common.archive")}
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>{t("common.archive")}</p>
<p className="text-xs text-custom-text-400">
{t("project_cycles.only_completed_cycles_can_be_archived")}
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreCycle}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>{t("project_cycles.action.restore.title")}</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("CYCLE_PAGE_SIDEBAR");
setCycleDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>{t("delete")}</span>
</span>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
</div>
<div className="flex flex-col gap-2 w-full">
<div className="flex items-start justify-between gap-3 pt-2">
+3 -2
View File
@@ -24,10 +24,11 @@ type Props = {
cycleId: string;
projectId: string;
workspaceSlug: string;
customClassName?: string;
};
export const CycleQuickActions: React.FC<Props> = observer((props) => {
const { parentRef, cycleId, projectId, workspaceSlug } = props;
const { parentRef, cycleId, projectId, workspaceSlug, customClassName } = props;
// router
const router = useAppRouter();
// states
@@ -188,7 +189,7 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
</div>
)}
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu ellipsis placement="bottom-end" closeOnSelect maxHeight="lg">
<CustomMenu ellipsis placement="bottom-end" closeOnSelect maxHeight="lg" buttonClassName={customClassName}>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
@@ -4,18 +4,7 @@ import React, { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import {
ArchiveRestoreIcon,
CalendarClock,
ChevronDown,
ChevronRight,
Info,
LinkIcon,
Plus,
SquareUser,
Trash2,
Users,
} from "lucide-react";
import { CalendarClock, ChevronDown, ChevronRight, Info, Plus, SquareUser, Users } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane types
import {
@@ -30,18 +19,7 @@ import {
import { useTranslation } from "@plane/i18n";
import { ILinkDetails, IModule, ModuleLink } from "@plane/types";
// plane ui
import {
CustomMenu,
Loader,
LayersIcon,
CustomSelect,
ModuleStatusIcon,
TOAST_TYPE,
setToast,
ArchiveIcon,
TextArea,
} from "@plane/ui";
import { copyUrlToClipboard } from "@plane/utils";
import { Loader, LayersIcon, CustomSelect, ModuleStatusIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui";
// components
import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns";
import {
@@ -55,7 +33,6 @@ import {
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// hooks
import { useModule, useEventTracker, useProjectEstimates, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web constants
import { EEstimateSystem } from "@/plane-web/constants/estimates";
@@ -82,23 +59,18 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
const [moduleLinkModal, setModuleLinkModal] = useState(false);
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<ILinkDetails | null>(null);
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
// store hooks
const { t } = useTranslation();
const { allowPermissions } = useUserPermissions();
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink, restoreModule } =
useModule();
const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker();
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule();
const { captureModuleEvent, captureEvent } = useEventTracker();
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
// derived values
const moduleDetails = getModuleById(moduleId);
const moduleState = moduleDetails?.status?.toLocaleLowerCase();
const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
const areEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId.toString());
const estimateType = areEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
const isEstimatePointValid = estimateType && estimateType?.type == EEstimateSystem.POINTS ? true : false;
@@ -175,24 +147,6 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
});
};
const handleCopyText = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${moduleId}`)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied",
message: "Module link copied to clipboard",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Some error occurred",
});
});
};
const handleDateChange = async (startDate: Date | undefined, targetDate: Date | undefined) => {
submitChanges({
start_date: startDate ? renderFormattedPayloadDate(startDate) : null,
@@ -205,30 +159,6 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
});
};
const handleRestoreModule = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (!workspaceSlug || !projectId || !moduleId) return;
await restoreModule(workspaceSlug.toString(), projectId.toString(), moduleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Restore success",
message: "Your module can be found in project modules.",
});
router.push(`/${workspaceSlug}/projects/${projectId}/archives/modules`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Module could not be restored. Please try again.",
})
);
};
useEffect(() => {
if (moduleDetails)
reset({
@@ -309,56 +239,6 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
<ChevronRight className="h-3 w-3 stroke-2 text-white" />
</button>
</div>
<div className="flex items-center gap-3.5">
{!isArchived && (
<button onClick={handleCopyText}>
<LinkIcon className="h-3 w-3 text-custom-text-300" />
</button>
)}
{isEditingAllowed && (
<CustomMenu placement="bottom-end" ellipsis>
{!isArchived && (
<CustomMenu.MenuItem onClick={() => setArchiveModuleModal(true)} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
{t("project_module.archive_module")}
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive module</p>
<p className="text-xs text-custom-text-400">
{t("project_module.quick_actions.archive_module_description")}
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreModule}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>{t("project_module.restore_module")}</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Module peek-overview");
setModuleDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>{t("project_module.delete_module")}</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
<div className="flex flex-col gap-3">
@@ -24,10 +24,11 @@ type Props = {
moduleId: string;
projectId: string;
workspaceSlug: string;
customClassName?: string;
};
export const ModuleQuickActions: React.FC<Props> = observer((props) => {
const { parentRef, moduleId, projectId, workspaceSlug } = props;
const { parentRef, moduleId, projectId, workspaceSlug, customClassName } = props;
// router
const router = useAppRouter();
// states
@@ -167,7 +168,7 @@ export const ModuleQuickActions: React.FC<Props> = observer((props) => {
</div>
)}
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu ellipsis placement="bottom-end" closeOnSelect>
<CustomMenu ellipsis placement="bottom-end" closeOnSelect buttonClassName={customClassName}>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
+2 -2
View File
@@ -17,9 +17,9 @@ type TPagesListRoot = {
export const PagesListRoot: FC<TPagesListRoot> = observer((props) => {
const { pageType, storeType } = props;
// store hooks
const { getCurrentProjectFilteredPageIds } = usePageStore(storeType);
const { getCurrentProjectFilteredPageIdsByTab } = usePageStore(storeType);
// derived values
const filteredPageIds = getCurrentProjectFilteredPageIds(pageType);
const filteredPageIds = getCurrentProjectFilteredPageIdsByTab(pageType);
if (!filteredPageIds) return <></>;
return (
@@ -26,13 +26,13 @@ export const PagesListMainContent: React.FC<Props> = observer((props) => {
// plane hooks
const { t } = useTranslation();
// store hooks
const { loader, isAnyPageAvailable, getCurrentProjectFilteredPageIds, getCurrentProjectPageIds, filters } =
const { loader, isAnyPageAvailable, getCurrentProjectFilteredPageIdsByTab, getCurrentProjectPageIdsByTab, filters } =
usePageStore(storeType);
const { toggleCreatePageModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
// derived values
const pageIds = getCurrentProjectPageIds(pageType);
const filteredPageIds = getCurrentProjectFilteredPageIds(pageType);
const pageIds = getCurrentProjectPageIdsByTab(pageType);
const filteredPageIds = getCurrentProjectFilteredPageIdsByTab(pageType);
const canPerformEmptyStateActions = allowPermissions(
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
EUserPermissionsLevel.PROJECT
+3 -2
View File
@@ -22,10 +22,11 @@ type Props = {
projectId: string;
view: IProjectView;
workspaceSlug: string;
customClassName?: string;
};
export const ViewQuickActions: React.FC<Props> = observer((props) => {
const { parentRef, projectId, view, workspaceSlug } = props;
const { parentRef, projectId, view, workspaceSlug, customClassName } = props;
// states
const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false);
const [deleteViewModal, setDeleteViewModal] = useState(false);
@@ -95,7 +96,7 @@ export const ViewQuickActions: React.FC<Props> = observer((props) => {
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<PublishViewModal isOpen={isPublishModalOpen} onClose={() => setPublishModalOpen(false)} view={view} />
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu ellipsis placement="bottom-end" closeOnSelect>
<CustomMenu ellipsis placement="bottom-end" closeOnSelect buttonClassName={customClassName}>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
+18 -7
View File
@@ -37,8 +37,9 @@ export interface IProjectPageStore {
isAnyPageAvailable: boolean;
canCurrentUserCreatePage: boolean;
// helper actions
getCurrentProjectPageIds: (pageType: TPageNavigationTabs) => string[] | undefined;
getCurrentProjectFilteredPageIds: (pageType: TPageNavigationTabs) => string[] | undefined;
getCurrentProjectPageIdsByTab: (pageType: TPageNavigationTabs) => string[] | undefined;
getCurrentProjectPageIds: (projectId: string) => string[];
getCurrentProjectFilteredPageIdsByTab: (pageType: TPageNavigationTabs) => string[] | undefined;
getPageById: (pageId: string) => TProjectPage | undefined;
updateFilters: <T extends keyof TPageFilters>(filterKey: T, filterValue: TPageFilters[T]) => void;
clearAllFilters: () => void;
@@ -46,7 +47,7 @@ export interface IProjectPageStore {
fetchPagesList: (
workspaceSlug: string,
projectId: string,
pageType: TPageNavigationTabs
pageType?: TPageNavigationTabs
) => Promise<TPage[] | undefined>;
fetchPageDetails: (workspaceSlug: string, projectId: string, pageId: string) => Promise<TPage | undefined>;
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
@@ -125,7 +126,7 @@ export class ProjectPageStore implements IProjectPageStore {
* @description get the current project page ids based on the pageType
* @param {TPageNavigationTabs} pageType
*/
getCurrentProjectPageIds = computedFn((pageType: TPageNavigationTabs) => {
getCurrentProjectPageIdsByTab = computedFn((pageType: TPageNavigationTabs) => {
const { projectId } = this.store.router;
if (!projectId) return undefined;
// helps to filter pages based on the pageType
@@ -137,11 +138,21 @@ export class ProjectPageStore implements IProjectPageStore {
return pages ?? undefined;
});
/**
* @description get the current project page ids
* @param {string} projectId
*/
getCurrentProjectPageIds = computedFn((projectId: string) => {
if (!projectId) return [];
const pages = Object.values(this?.data || {}).filter((page) => page.project_ids?.includes(projectId));
return pages.map((page) => page.id) as string[];
});
/**
* @description get the current project filtered page ids based on the pageType
* @param {TPageNavigationTabs} pageType
*/
getCurrentProjectFilteredPageIds = computedFn((pageType: TPageNavigationTabs) => {
getCurrentProjectFilteredPageIdsByTab = computedFn((pageType: TPageNavigationTabs) => {
const { projectId } = this.store.router;
if (!projectId) return undefined;
@@ -183,11 +194,11 @@ export class ProjectPageStore implements IProjectPageStore {
/**
* @description fetch all the pages
*/
fetchPagesList = async (workspaceSlug: string, projectId: string, pageType: TPageNavigationTabs) => {
fetchPagesList = async (workspaceSlug: string, projectId: string, pageType?: TPageNavigationTabs) => {
try {
if (!workspaceSlug || !projectId) return undefined;
const currentPageIds = this.getCurrentProjectPageIds(pageType);
const currentPageIds = pageType ? this.getCurrentProjectPageIdsByTab(pageType) : undefined;
runInAction(() => {
this.loader = currentPageIds && currentPageIds.length > 0 ? `mutation-loader` : `init-loader`;
this.error = undefined;