diff --git a/admin/core/components/auth-header.tsx b/admin/core/components/auth-header.tsx index 5edcb611818..b97dd7c9eae 100644 --- a/admin/core/components/auth-header.tsx +++ b/admin/core/components/auth-header.tsx @@ -67,9 +67,8 @@ export const InstanceHeader: FC = observer(() => { {breadcrumbItems.length >= 0 && (
- { {breadcrumbItems.map( (item) => item.title && ( - } + component={} /> ) )} diff --git a/packages/constants/src/project.ts b/packages/constants/src/project.ts index df22641e8d5..590dbb89ecc 100644 --- a/packages/constants/src/project.ts +++ b/packages/constants/src/project.ts @@ -149,3 +149,12 @@ export const DEFAULT_PROJECT_FORM_VALUES: Partial = { network: 2, project_lead: null, }; + +export enum EProjectFeatureKey { + WORK_ITEMS = "work_items", + CYCLES = "cycles", + MODULES = "modules", + VIEWS = "views", + PAGES = "pages", + INTAKE = "intake", +} diff --git a/packages/ui/src/breadcrumbs/breadcrumbs.stories.tsx b/packages/ui/src/breadcrumbs/breadcrumbs.stories.tsx new file mode 100644 index 00000000000..9b8cb81408e --- /dev/null +++ b/packages/ui/src/breadcrumbs/breadcrumbs.stories.tsx @@ -0,0 +1,233 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Home, Settings, Briefcase, GridIcon, Layers2, FileIcon } from "lucide-react"; +import * as React from "react"; +import { ContrastIcon, EpicIcon, LayersIcon } from "../icons"; +import { Breadcrumbs } from "./breadcrumbs"; +import { BreadcrumbNavigationDropdown } from "./navigation-dropdown"; + +const meta: Meta = { + title: "UI/Breadcrumbs", + component: Breadcrumbs, + tags: ["autodocs"], + argTypes: { + isLoading: { + control: "boolean", + description: "Shows loading state of breadcrumbs", + }, + onBack: { + action: "onBack", + description: "Callback function when back button is clicked", + }, + }, +}; + +type TBreadcrumbBlockProps = { + href?: string; + label?: string; + icon?: React.ReactNode; + disableTooltip?: boolean; +}; + +// TODO: remove this component and use web Link component +const BreadcrumbBlock: React.FC = (props) => { + const { label, icon, disableTooltip = false } = props; + + return ( + <> + + {icon &&
{icon}
} + {label &&
{label}
} +
+ + ); +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: [ + } />, + } />, + } + />, + ], + }, +}; + +export const WithLoading: Story = { + args: { + isLoading: true, + children: [ + } />, + } />, + ], + }, +}; + +export const WithCustomComponent: Story = { + args: { + children: [ + } />, + + + Custom Component +
+ } + />, + ], + }, +}; + +export const SingleItem: Story = { + args: { + children: [} />], + }, +}; + +export const WithNavigationDropdown: Story = { + args: { + children: [ + } />, + console.log("Project Alpha selected"), + }, + { + key: "project-2", + title: "Project Beta", + + action: () => console.log("Project Beta selected"), + }, + { + key: "project-3", + title: "Project Gamma", + + action: () => console.log("Project Gamma selected"), + }, + ]} + /> + } + showSeparator={false} + />, + } />, + ], + }, +}; + +export const WithNavigationDropdownAndIcons: Story = { + args: { + children: [ + } />} + />, + console.log("Project Alpha selected"), + }, + { + key: "project-2", + title: "Project Beta", + icon: Briefcase, + + // disabled: true, + action: () => console.log("Project Beta selected"), + }, + { + key: "project-3", + title: "Project Gamma", + icon: Briefcase, + + action: () => console.log("Project Gamma selected"), + }, + ]} + /> + } + showSeparator={false} + />, + console.log("Feature Alpha selected"), + }, + { + key: "feature-2", + title: "Work items", + icon: LayersIcon, + + // disabled: true, + action: () => console.log("Feature Beta selected"), + }, + { + key: "feature-3", + title: "Cycles", + icon: ContrastIcon, + + action: () => console.log("Feature Gamma selected"), + }, + { + key: "feature-3", + title: "Modules", + icon: GridIcon, + + action: () => console.log("Feature Gamma selected"), + }, + { + key: "feature-3", + title: "Views", + icon: Layers2, + + action: () => console.log("Feature Gamma selected"), + }, + { + key: "feature-3", + title: "Pages", + icon: FileIcon, + + action: () => console.log("Feature Gamma selected"), + }, + ]} + /> + } + showSeparator={false} + />, + } />} + isLast + />, + ], + }, +}; diff --git a/packages/ui/src/breadcrumbs/breadcrumbs.tsx b/packages/ui/src/breadcrumbs/breadcrumbs.tsx index 03182569110..af0ba9b4f35 100644 --- a/packages/ui/src/breadcrumbs/breadcrumbs.tsx +++ b/packages/ui/src/breadcrumbs/breadcrumbs.tsx @@ -1,13 +1,25 @@ -import * as React from "react"; import { ChevronRight } from "lucide-react"; +import * as React from "react"; +import { cn } from "../../helpers"; +import { Tooltip } from "../tooltip"; type BreadcrumbsProps = { + className?: string; children: React.ReactNode; onBack?: () => void; isLoading?: boolean; }; -const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps) => { +export const BreadcrumbItemLoader = () => ( +
+
+ + +
+
+); + +const Breadcrumbs = ({ className, children, onBack, isLoading = false }: BreadcrumbsProps) => { const [isSmallScreen, setIsSmallScreen] = React.useState(false); React.useEffect(() => { @@ -22,35 +34,31 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps) const childrenArray = React.Children.toArray(children); - const BreadcrumbItemLoader = ( -
- - -
- ); - return ( -
+
{!isSmallScreen && ( <> - {childrenArray.map((child, index) => ( - - {index > 0 && !isSmallScreen && ( -
-
- )} -
0 ? "hidden sm:flex" : "flex"}`}> - {isLoading ? BreadcrumbItemLoader : child} -
-
- ))} + {childrenArray.map((child, index) => { + if (isLoading) { + return ( + <> + + + ); + } + if (React.isValidElement(child)) { + return React.cloneElement(child, { + isLast: index === childrenArray.length - 1, + }); + } + return child; + })} )} {isSmallScreen && childrenArray.length > 1 && ( <> -
+
{onBack && ( ... @@ -58,8 +66,16 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps) )}
-
- {isLoading ? BreadcrumbItemLoader : childrenArray[childrenArray.length - 1]} +
+ {isLoading ? ( + + ) : React.isValidElement(childrenArray[childrenArray.length - 1]) ? ( + React.cloneElement(childrenArray[childrenArray.length - 1] as React.ReactElement, { + isLast: true, + }) + ) : ( + childrenArray[childrenArray.length - 1] + )}
)} @@ -68,17 +84,107 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps) ); }; -type Props = { - type?: "text" | "component"; +// breadcrumb item +type BreadcrumbItemProps = { component?: React.ReactNode; - link?: JSX.Element; + showSeparator?: boolean; + isLast?: boolean; +}; + +const BreadcrumbItem: React.FC = (props) => { + const { component, showSeparator = true, isLast = false } = props; + return ( +
+ {component} + {showSeparator && !isLast && } +
+ ); +}; + +// breadcrumb icon +type BreadcrumbIconProps = { + children: React.ReactNode; + className?: string; +}; + +const BreadcrumbIcon: React.FC = (props) => { + const { children, className } = props; + return
{children}
; +}; + +// breadcrumb label +type BreadcrumbLabelProps = { + children: React.ReactNode; + className?: string; +}; + +const BreadcrumbLabel: React.FC = (props) => { + const { children, className } = props; + return ( +
+ {children} +
+ ); }; -const BreadcrumbItem: React.FC = (props) => { - const { type = "text", component, link } = props; - return <>{type !== "text" ?
{component}
: link}; +// breadcrumb separator +type BreadcrumbSeparatorProps = { + className?: string; + containerClassName?: string; + iconClassName?: string; + showDivider?: boolean; +}; + +const BreadcrumbSeparator: React.FC = (props) => { + const { className, containerClassName, iconClassName, showDivider = false } = props; + return ( +
+ {showDivider && } +
+ +
+
+ ); +}; + +// breadcrumb wrapper +type BreadcrumbItemWrapperProps = { + label?: string; + disableTooltip?: boolean; + children: React.ReactNode; + className?: string; + type?: "link" | "text"; + isLast?: boolean; +}; + +const BreadcrumbItemWrapper: React.FC = (props) => { + const { label, disableTooltip = false, children, className, type = "link", isLast = false } = props; + return ( + +
+ {children} +
+
+ ); }; -Breadcrumbs.BreadcrumbItem = BreadcrumbItem; +Breadcrumbs.Item = BreadcrumbItem; +Breadcrumbs.Icon = BreadcrumbIcon; +Breadcrumbs.Label = BreadcrumbLabel; +Breadcrumbs.Separator = BreadcrumbSeparator; +Breadcrumbs.ItemWrapper = BreadcrumbItemWrapper; -export { Breadcrumbs, BreadcrumbItem }; +export { Breadcrumbs, BreadcrumbItem, BreadcrumbIcon, BreadcrumbLabel, BreadcrumbSeparator, BreadcrumbItemWrapper }; diff --git a/packages/ui/src/breadcrumbs/index.ts b/packages/ui/src/breadcrumbs/index.ts index 05a8bdbf1b6..192bd57510a 100644 --- a/packages/ui/src/breadcrumbs/index.ts +++ b/packages/ui/src/breadcrumbs/index.ts @@ -1,2 +1,3 @@ export * from "./breadcrumbs"; export * from "./navigation-dropdown"; +export * from "./navigation-search-dropdown"; diff --git a/packages/ui/src/breadcrumbs/navigation-dropdown.tsx b/packages/ui/src/breadcrumbs/navigation-dropdown.tsx index a716ca65e19..503e13eb2fe 100644 --- a/packages/ui/src/breadcrumbs/navigation-dropdown.tsx +++ b/packages/ui/src/breadcrumbs/navigation-dropdown.tsx @@ -1,42 +1,54 @@ "use client"; +import { CheckIcon } from "lucide-react"; import * as React from "react"; -import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import { cn } from "../../helpers"; // ui import { CustomMenu, TContextMenuItem } from "../dropdowns"; -// helpers -import { cn } from "../../helpers"; +import { Tooltip } from "../tooltip"; +import { Breadcrumbs } from "./breadcrumbs"; type TBreadcrumbNavigationDropdownProps = { selectedItemKey: string; navigationItems: TContextMenuItem[]; navigationDisabled?: boolean; + handleOnClick?: () => void; + isLast?: boolean; }; export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdownProps) => { - const { selectedItemKey, navigationItems, navigationDisabled = false } = props; + const { selectedItemKey, navigationItems, navigationDisabled = false, handleOnClick, isLast = false } = props; + const [isOpen, setIsOpen] = React.useState(false); // derived values const selectedItem = navigationItems.find((item) => item.key === selectedItemKey); const selectedItemIcon = selectedItem?.icon ? ( - + ) : undefined; // if no selected item, return null if (!selectedItem) return null; - const NavigationButton = ({ className }: { className?: string }) => ( -
  • - {selectedItemIcon && ( -
    {selectedItemIcon}
    - )} -
    {selectedItem.title}
    -
  • + const NavigationButton = () => ( + + + ); if (navigationDisabled) { @@ -46,13 +58,37 @@ export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdow return ( - - -
    + <> + + + } placement="bottom-start" + className="h-full rounded" + customButtonClassName={cn( + "group flex items-center gap-0.5 rounded hover:bg-custom-background-90 outline-none cursor-pointer h-full rounded", + { + "bg-custom-background-90": isOpen, + } + )} closeOnSelect + menuButtonOnClick={() => { + setIsOpen(!isOpen); + }} + onMenuClose={() => { + setIsOpen(false); + }} > {navigationItems.map((item) => { if (item.shouldRender === false) return null; @@ -74,7 +110,7 @@ export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdow )} disabled={item.disabled} > - {item.icon && } + {item.icon && }
    {item.title}
    {item.description && ( diff --git a/packages/ui/src/breadcrumbs/navigation-search-dropdown.tsx b/packages/ui/src/breadcrumbs/navigation-search-dropdown.tsx new file mode 100644 index 00000000000..0439d1d3333 --- /dev/null +++ b/packages/ui/src/breadcrumbs/navigation-search-dropdown.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; +import { useState } from "react"; +import { ICustomSearchSelectOption } from "@plane/types"; +import { cn } from "../../helpers"; +import { CustomSearchSelect } from "../dropdowns"; +import { Tooltip } from "../tooltip"; +import { Breadcrumbs } from "./breadcrumbs"; + +type TBreadcrumbNavigationSearchDropdownProps = { + icon?: React.JSX.Element; + title?: string; + selectedItem: string; + navigationItems: ICustomSearchSelectOption[]; + onChange?: (value: string) => void; + navigationDisabled?: boolean; + isLast?: boolean; + handleOnClick?: () => void; + disableRootHover?: boolean; +}; + +export const BreadcrumbNavigationSearchDropdown: React.FC = (props) => { + const { + icon, + title, + selectedItem, + navigationItems, + onChange, + navigationDisabled = false, + isLast = false, + handleOnClick, + } = props; + // state + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + return ( + { + setIsDropdownOpen(true); + }} + onClose={() => { + setIsDropdownOpen(false); + }} + options={navigationItems} + value={selectedItem} + onChange={(value: string) => { + if (value !== selectedItem) { + onChange?.(value); + } + }} + customButton={ + <> + + + + + + } + disabled={navigationDisabled} + className="h-full rounded" + customButtonClassName={cn( + "group flex items-center gap-0.5 rounded hover:bg-custom-background-90 outline-none cursor-pointer h-full rounded", + { + "bg-custom-background-90": isDropdownOpen, + } + )} + /> + ); +}; diff --git a/packages/ui/src/dropdowns/custom-search-select.tsx b/packages/ui/src/dropdowns/custom-search-select.tsx index e592f0dc2bc..d26163e695d 100644 --- a/packages/ui/src/dropdowns/custom-search-select.tsx +++ b/packages/ui/src/dropdowns/custom-search-select.tsx @@ -61,6 +61,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { const openDropdown = () => { setIsOpen(true); if (referenceElement) referenceElement.focus(); + if (onOpen) onOpen(); }; const closeDropdown = () => { @@ -95,11 +96,14 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { +
    + {viewDetails && } + {isDefaultView && defaultViewDetails && ( + + )} +
    diff --git a/web/ce/components/breadcrumbs/common.tsx b/web/ce/components/breadcrumbs/common.tsx new file mode 100644 index 00000000000..5b2f573cbc3 --- /dev/null +++ b/web/ce/components/breadcrumbs/common.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { FC } from "react"; +// plane imports +import { EProjectFeatureKey } from "@plane/constants"; +// local components +import { ProjectFeatureBreadcrumb } from "./project-feature"; +import { ProjectBreadcrumb } from "./project"; + +type TCommonProjectBreadcrumbProps = { + workspaceSlug: string; + projectId: string; + featureKey?: EProjectFeatureKey; + isLast?: boolean; +}; + +export const CommonProjectBreadcrumbs: FC = (props) => { + const { workspaceSlug, projectId, featureKey, isLast = false } = props; + return ( + <> + + {featureKey && ( + + )} + + ); +}; diff --git a/web/ce/components/breadcrumbs/index.ts b/web/ce/components/breadcrumbs/index.ts index 9ff8c7dff40..aad2cb35224 100644 --- a/web/ce/components/breadcrumbs/index.ts +++ b/web/ce/components/breadcrumbs/index.ts @@ -1 +1,3 @@ +export * from "./common"; +export * from "./project-feature"; export * from "./project"; diff --git a/web/ce/components/breadcrumbs/project-feature.tsx b/web/ce/components/breadcrumbs/project-feature.tsx new file mode 100644 index 00000000000..c606a2d3f31 --- /dev/null +++ b/web/ce/components/breadcrumbs/project-feature.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +// ui +import { EProjectFeatureKey } from "@plane/constants"; +import { BreadcrumbNavigationDropdown, Breadcrumbs, ISvgIcons } from "@plane/ui"; +// components +import { SwitcherLabel } from "@/components/common"; +import { TNavigationItem } from "@/components/workspace"; +// hooks +import { useProject } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +// local components +import { getProjectFeatureNavigation } from "../projects/navigation"; + +type TProjectFeatureBreadcrumbProps = { + workspaceSlug: string; + projectId: string; + featureKey: EProjectFeatureKey; + isLast?: boolean; + additionalNavigationItems?: TNavigationItem[]; +}; + +export const ProjectFeatureBreadcrumb = observer((props: TProjectFeatureBreadcrumbProps) => { + const { workspaceSlug, projectId, featureKey, isLast = false, additionalNavigationItems } = props; + // router + const router = useAppRouter(); + // store hooks + const { getPartialProjectById } = useProject(); + // derived values + const project = getPartialProjectById(projectId); + + if (!project) return null; + + const navigationItems = getProjectFeatureNavigation(workspaceSlug, projectId, project); + + // if additional navigation items are provided, add them to the navigation items + const allNavigationItems = [...(additionalNavigationItems || []), ...navigationItems]; + + return ( + <> + item.shouldRender) + .map((item) => ({ + key: item.key, + title: item.name, + customContent: } />, + action: () => router.push(item.href), + icon: item.icon as FC, + }))} + handleOnClick={() => { + router.push( + `/${workspaceSlug}/projects/${projectId}/${featureKey === EProjectFeatureKey.WORK_ITEMS ? "issues" : featureKey}/` + ); + }} + isLast={isLast} + /> + } + showSeparator={false} + isLast={isLast} + /> + + ); +}); diff --git a/web/ce/components/breadcrumbs/project.tsx b/web/ce/components/breadcrumbs/project.tsx index 3b49bb211b5..e59f948df7d 100644 --- a/web/ce/components/breadcrumbs/project.tsx +++ b/web/ce/components/breadcrumbs/project.tsx @@ -2,38 +2,73 @@ import { observer } from "mobx-react"; import { Briefcase } from "lucide-react"; -// ui -import { Breadcrumbs, Logo } from "@plane/ui"; +// plane imports +import { ICustomSearchSelectOption } from "@plane/types"; +import { BreadcrumbNavigationSearchDropdown, Breadcrumbs, Logo } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common"; +import { SwitcherLabel } from "@/components/common"; // hooks import { useProject } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { TProject } from "@/plane-web/types"; + +type TProjectBreadcrumbProps = { + workspaceSlug: string; + projectId: string; + handleOnClick?: () => void; +}; + +export const ProjectBreadcrumb = observer((props: TProjectBreadcrumbProps) => { + const { workspaceSlug, projectId, handleOnClick } = props; + // router + const router = useAppRouter(); + // store hooks + const { joinedProjectIds, getPartialProjectById } = useProject(); + const currentProjectDetails = getPartialProjectById(projectId); -export const ProjectBreadcrumb = observer(() => { // store hooks - const { currentProjectDetails } = useProject(); + + if (!currentProjectDetails) return null; + + // derived values + const switcherOptions = joinedProjectIds + .map((projectId) => { + const project = getPartialProjectById(projectId); + return { + value: projectId, + query: project?.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + // helpers + const renderIcon = (projectDetails: TProject) => ( + + + + ); return ( - - - - ) - ) : ( - - - - ) - } - /> - } - /> + <> + { + router.push(`/${workspaceSlug}/projects/${value}/issues`); + }} + title={currentProjectDetails?.name} + icon={renderIcon(currentProjectDetails)} + handleOnClick={() => { + if (handleOnClick) handleOnClick(); + else router.push(`/${workspaceSlug}/projects/${currentProjectDetails.id}/issues/`); + }} + /> + } + showSeparator={false} + /> + ); }); diff --git a/web/ce/components/issues/header.tsx b/web/ce/components/issues/header.tsx index 7e7073cd9be..3250ad4d5eb 100644 --- a/web/ce/components/issues/header.tsx +++ b/web/ce/components/issues/header.tsx @@ -4,13 +4,20 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // icons import { Circle, ExternalLink } from "lucide-react"; -import { EIssuesStoreType, EUserPermissions, EUserPermissionsLevel, SPACE_BASE_PATH, SPACE_BASE_URL } from "@plane/constants"; +import { + EIssuesStoreType, + EProjectFeatureKey, + EUserPermissions, + EUserPermissionsLevel, + SPACE_BASE_PATH, + SPACE_BASE_URL, +} from "@plane/constants"; // plane constants import { useTranslation } from "@plane/i18n"; // ui -import { Breadcrumbs, Button, LayersIcon, Tooltip, Header } from "@plane/ui"; +import { Breadcrumbs, Button, Tooltip, Header } from "@plane/ui"; // components -import { BreadcrumbLink, CountChip } from "@/components/common"; +import { CountChip } from "@/components/common"; // constants import HeaderFilters from "@/components/issues/filters"; // helpers @@ -20,7 +27,7 @@ import { useIssues } from "@/hooks/store/use-issues"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web -import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; +import { CommonProjectBreadcrumbs } from "../breadcrumbs/common"; export const IssuesHeader = observer(() => { // router @@ -52,18 +59,13 @@ export const IssuesHeader = observer(() => { return (
    -
    - router.back()} isLoading={loader === "init-loader"}> - - - } - /> - } +
    + router.back()} isLoading={loader === "init-loader"} className="flex-grow-0"> + {issuesCount && issuesCount > 0 ? ( diff --git a/web/ce/components/projects/navigation/helper.tsx b/web/ce/components/projects/navigation/helper.tsx new file mode 100644 index 00000000000..1a99262c6a1 --- /dev/null +++ b/web/ce/components/projects/navigation/helper.tsx @@ -0,0 +1,77 @@ +import { FileText, Layers } from "lucide-react"; +import { EUserPermissions, EProjectFeatureKey } from "@plane/constants"; +import { ContrastIcon, DiceIcon, Intake, LayersIcon } from "@plane/ui"; +import { TNavigationItem } from "@/components/workspace"; + +export const getProjectFeatureNavigation = ( + workspaceSlug: string, + projectId: string, + project: { + cycle_view: boolean; + module_view: boolean; + issue_views_view: boolean; + page_view: boolean; + inbox_view: boolean; + } +): TNavigationItem[] => [ + { + i18n_key: "sidebar.work_items", + key: EProjectFeatureKey.WORK_ITEMS, + name: "Work items", + href: `/${workspaceSlug}/projects/${projectId}/issues`, + icon: LayersIcon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: true, + sortOrder: 1, + }, + { + i18n_key: "sidebar.cycles", + key: EProjectFeatureKey.CYCLES, + name: "Cycles", + href: `/${workspaceSlug}/projects/${projectId}/cycles`, + icon: ContrastIcon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + shouldRender: project.cycle_view, + sortOrder: 2, + }, + { + i18n_key: "sidebar.modules", + key: EProjectFeatureKey.MODULES, + name: "Modules", + href: `/${workspaceSlug}/projects/${projectId}/modules`, + icon: DiceIcon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + shouldRender: project.module_view, + sortOrder: 3, + }, + { + i18n_key: "sidebar.views", + key: EProjectFeatureKey.VIEWS, + name: "Views", + href: `/${workspaceSlug}/projects/${projectId}/views`, + icon: Layers, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: project.issue_views_view, + sortOrder: 4, + }, + { + i18n_key: "sidebar.pages", + key: EProjectFeatureKey.PAGES, + name: "Pages", + href: `/${workspaceSlug}/projects/${projectId}/pages`, + icon: FileText, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: project.page_view, + sortOrder: 5, + }, + { + i18n_key: "sidebar.intake", + key: EProjectFeatureKey.INTAKE, + name: "Intake", + href: `/${workspaceSlug}/projects/${projectId}/intake`, + icon: Intake, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: project.inbox_view, + sortOrder: 6, + }, +]; diff --git a/web/ce/components/projects/navigation/index.ts b/web/ce/components/projects/navigation/index.ts new file mode 100644 index 00000000000..b9755e783ec --- /dev/null +++ b/web/ce/components/projects/navigation/index.ts @@ -0,0 +1 @@ +export * from "./helper"; diff --git a/web/ce/components/projects/settings/intake/header.tsx b/web/ce/components/projects/settings/intake/header.tsx index 32a93894f1e..9b0c994b514 100644 --- a/web/ce/components/projects/settings/intake/header.tsx +++ b/web/ce/components/projects/settings/intake/header.tsx @@ -5,16 +5,15 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { RefreshCcw } from "lucide-react"; // ui -import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Breadcrumbs, Button, Intake, Header } from "@plane/ui"; +import { Breadcrumbs, Button, Header } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common"; import { InboxIssueCreateModalRoot } from "@/components/inbox"; // hooks import { useProject, useProjectInbox, useUserPermissions } from "@/hooks/store"; // plane web -import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs"; export const ProjectInboxHeader: FC = observer(() => { // states @@ -37,13 +36,13 @@ export const ProjectInboxHeader: FC = observer(() => { return (
    -
    +
    - - - } />} + diff --git a/web/core/components/common/breadcrumb-link.tsx b/web/core/components/common/breadcrumb-link.tsx index bf421a50c2d..1dc85e3e2ae 100644 --- a/web/core/components/common/breadcrumb-link.tsx +++ b/web/core/components/common/breadcrumb-link.tsx @@ -1,44 +1,75 @@ "use client"; -import { ReactNode } from "react"; +import React, { ReactNode, useMemo, FC } from "react"; +import { observer } from "mobx-react-lite"; import Link from "next/link"; -import { Tooltip } from "@plane/ui"; +import { Breadcrumbs } from "@plane/ui"; import { usePlatformOS } from "@/hooks/use-platform-os"; type Props = { - label?: string | ReactNode; + label?: string; href?: string; - icon?: React.ReactNode | undefined; + icon?: React.ReactNode; disableTooltip?: boolean; + isLast?: boolean; }; -export const BreadcrumbLink: React.FC = (props) => { - const { href, label, icon, disableTooltip = false } = props; - const { isMobile } = usePlatformOS(); +const IconWrapper = React.memo(({ icon }: { icon: React.ReactNode }) => ( +
    {icon}
    +)); + +IconWrapper.displayName = "IconWrapper"; + +const LabelWrapper = React.memo(({ label }: { label: ReactNode }) => ( +
    {label}
    +)); + +LabelWrapper.displayName = "LabelWrapper"; + +const BreadcrumbContent = React.memo(({ icon, label }: { icon?: React.ReactNode; label?: ReactNode }) => { + if (!icon && !label) return null; + return ( - -
  • -
    - {href ? ( - - {icon && ( -
    {icon}
    - )} - {label && ( -
    {label}
    - )} - - ) : ( -
    - {icon &&
    {icon}
    } -
    {label}
    -
    - )} -
    -
  • -
    + <> + {icon && } + {label && } + ); -}; +}); + +BreadcrumbContent.displayName = "BreadcrumbContent"; + +const ItemWrapper = React.memo(({ children, ...props }: React.ComponentProps) => ( + {children} +)); + +ItemWrapper.displayName = "ItemWrapper"; + +export const BreadcrumbLink: FC = observer((props) => { + const { href, label, icon, disableTooltip = false, isLast = false } = props; + const { isMobile } = usePlatformOS(); + + const itemWrapperProps = useMemo( + () => ({ + label: label?.toString(), + disableTooltip: isMobile || disableTooltip, + type: (href && href !== "" ? "link" : "text") as "link" | "text", + isLast, + }), + [href, label, isMobile, disableTooltip, isLast] + ); + + const content = useMemo(() => , [icon, label]); + + if (href) { + return ( + + {content} + + ); + } + + return {content}; +}); + +BreadcrumbLink.displayName = "BreadcrumbLink"; diff --git a/web/core/components/common/switcher-label.tsx b/web/core/components/common/switcher-label.tsx index 6bb4c346f1c..d1f1fc4bc83 100644 --- a/web/core/components/common/switcher-label.tsx +++ b/web/core/components/common/switcher-label.tsx @@ -2,6 +2,32 @@ import { FC } from "react"; import { TLogoProps } from "@plane/types"; import { ISvgIcons, Logo } from "@plane/ui"; import { getFileURL, truncateText } from "@plane/utils"; + +type TSwitcherIconProps = { + logo_props?: TLogoProps; + logo_url?: string; + LabelIcon: FC; + size?: number; +}; + +export const SwitcherIcon: FC = ({ logo_props, logo_url, LabelIcon, size = 12 }) => { + if (logo_props?.in_use) { + return ; + } + + if (logo_url) { + return ( + logo + ); + } + return ; +}; + type TSwitcherLabelProps = { logo_props?: TLogoProps; logo_url?: string; @@ -13,13 +39,7 @@ export const SwitcherLabel: FC = (props) => { const { logo_props, name, LabelIcon, logo_url } = props; return (
    - {logo_props?.in_use ? ( - - ) : logo_url ? ( - logo - ) : ( - - )} + {truncateText(name ?? "", 40)}
    ); diff --git a/web/core/components/project/header.tsx b/web/core/components/project/header.tsx index 17adc130ee4..e40280373c3 100644 --- a/web/core/components/project/header.tsx +++ b/web/core/components/project/header.tsx @@ -37,16 +37,15 @@ export const ProjectsBaseHeader = observer(() => {
    - } /> } /> - {isArchived && } />} + {isArchived && } />} diff --git a/web/core/components/workspace-notifications/sidebar/header/root.tsx b/web/core/components/workspace-notifications/sidebar/header/root.tsx index cdda51fbfa6..7ed9ea5289d 100644 --- a/web/core/components/workspace-notifications/sidebar/header/root.tsx +++ b/web/core/components/workspace-notifications/sidebar/header/root.tsx @@ -26,9 +26,8 @@ export const NotificationSidebarHeader: FC = observe
    - } diff --git a/web/core/components/workspace/views/default-view-quick-action.tsx b/web/core/components/workspace/views/default-view-quick-action.tsx index e7f0e276a3d..869c2d09656 100644 --- a/web/core/components/workspace/views/default-view-quick-action.tsx +++ b/web/core/components/workspace/views/default-view-quick-action.tsx @@ -1,19 +1,16 @@ "use client"; import { observer } from "mobx-react"; -import Link from "next/link"; import { ExternalLink, LinkIcon } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; // ui import { TStaticViewTypes } from "@plane/types"; -import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; +import { CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; import { copyUrlToClipboard, cn } from "@plane/utils"; // helpers type Props = { - parentRef: React.RefObject; workspaceSlug: string; - globalViewId: string | undefined; view: { key: TStaticViewTypes; i18n_label: string; @@ -21,7 +18,7 @@ type Props = { }; export const DefaultWorkspaceViewQuickActions: React.FC = observer((props) => { - const { parentRef, globalViewId, view, workspaceSlug } = props; + const { workspaceSlug, view } = props; const { t } = useTranslation(); @@ -53,43 +50,11 @@ export const DefaultWorkspaceViewQuickActions: React.FC = observer((props return ( <> - - - {view.key === globalViewId ? ( - - {t(view.i18n_label)} - - ) : ( - - - {t(view.i18n_label)} - - - )} - - } + ellipsis placement="bottom-end" - menuItemsClassName="z-20" closeOnSelect + buttonClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-custom-background-80/70 rounded" > {MENU_ITEMS.map((item) => { if (item.shouldRender === false) return null; diff --git a/web/core/components/workspace/views/header.tsx b/web/core/components/workspace/views/header.tsx index f449c553b5e..e7312980062 100644 --- a/web/core/components/workspace/views/header.tsx +++ b/web/core/components/workspace/views/header.tsx @@ -37,13 +37,7 @@ const ViewTab = observer((props: { viewId: string }) => { return (
    - +
    ); }); @@ -63,12 +57,7 @@ const DefaultViewTab = (props: { if (!workspaceSlug || !globalViewId) return null; return (
    - +
    ); }; diff --git a/web/core/components/workspace/views/quick-action.tsx b/web/core/components/workspace/views/quick-action.tsx index e0950a78ac0..4db3d537cd3 100644 --- a/web/core/components/workspace/views/quick-action.tsx +++ b/web/core/components/workspace/views/quick-action.tsx @@ -2,13 +2,12 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; -import { ExternalLink, LinkIcon, Pencil, Trash2, Lock } from "lucide-react"; +import { ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react"; // types -import { EViewAccess, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { IWorkspaceView } from "@plane/types"; -import { ContextMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; +import { CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; import { copyUrlToClipboard, cn } from "@plane/utils"; // components import { CreateUpdateWorkspaceViewModal, DeleteGlobalViewModal } from "@/components/workspace"; @@ -18,15 +17,12 @@ import { CreateUpdateWorkspaceViewModal, DeleteGlobalViewModal } from "@/compone import { useUser, useUserPermissions } from "@/hooks/store"; type Props = { - parentRef: React.RefObject; workspaceSlug: string; - globalViewId: string; - viewId: string; view: IWorkspaceView; }; export const WorkspaceViewQuickActions: React.FC = observer((props) => { - const { parentRef, view, globalViewId, viewId, workspaceSlug } = props; + const { workspaceSlug, view } = props; // states const [updateViewModal, setUpdateViewModal] = useState(false); const [deleteViewModal, setDeleteViewModal] = useState(false); @@ -78,42 +74,53 @@ export const WorkspaceViewQuickActions: React.FC = observer((props) => { }, ]; - const isSelected = viewId === globalViewId; - const isPrivateView = view.access === EViewAccess.PRIVATE; - - let customButton = ( -
    - - {view.name} - - {isPrivateView && ( - - )} -
    - ); - - if (!isSelected) { - customButton = ( - - {customButton} - - ); - } - return ( <> setUpdateViewModal(false)} /> setDeleteViewModal(false)} /> - - - {customButton} + + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + disabled={item.disabled} + > + {item.icon && } +
    +
    {item.title}
    + {item.description && ( +

    + {item.description} +

    + )} +
    +
    + ); + })} +
    ); });