[WEB-2202] chore: user favorites mutation and code refactor (#5330)

* chore: fav item drag and drop improvement

* chore: user favorite type updated

* chore: user favorites helper function added

* dev: favorite item common component added

* dev: favorite item component added and code refactor

* fix: build error

* chore: code refactor

* chore: code refactor

* chore: code refactor
This commit is contained in:
Anmol Singh Bhatia 2024-08-08 20:11:18 +05:30 committed by GitHub
parent a2098ffb5e
commit 48cb0f5afc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 381 additions and 224 deletions

View file

@ -15,6 +15,7 @@ export type IFavorite = {
name: string;
entity_type: string;
entity_data: {
id?: string;
name: string;
logo_props?: TLogoProps | undefined;
};

View file

@ -20,7 +20,7 @@ import { useFavorite } from "@/hooks/store/use-favorite";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
// constants
import { FavoriteItem } from "./favorite-item";
import { FavoriteRoot } from "./favorite-items";
import { getDestinationStateSequence } from "./favorites.helpers";
import { NewFavoriteFolder } from "./new-fav-folder";
@ -314,8 +314,9 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
})}
>
{favorite.children.map((child) => (
<FavoriteItem
<FavoriteRoot
key={child.id}
workspaceSlug={workspaceSlug.toString()}
favorite={child}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}

View file

@ -1,220 +0,0 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Briefcase, FileText, Layers, MoreHorizontal, Star } from "lucide-react";
// ui
import { IFavorite } from "@plane/types";
import { ContrastIcon, CustomMenu, DiceIcon, DragHandle, FavoriteFolderIcon, LayersIcon, Tooltip } from "@plane/ui";
// components
import { Logo } from "@/components/common";
import { SidebarNavItem } from "@/components/sidebar";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
const iconClassName = `flex-shrink-0 size-4 stroke-[1.5] m-auto`;
const ICONS: Record<string, JSX.Element> = {
page: <FileText className={iconClassName} />,
project: <Briefcase className={iconClassName} />,
view: <Layers className={iconClassName} />,
module: <DiceIcon className={iconClassName} />,
cycle: <ContrastIcon className={iconClassName} />,
issue: <LayersIcon className={iconClassName} />,
folder: <FavoriteFolderIcon className={iconClassName} />,
};
export const FavoriteItem = observer(
({
favoriteMap,
favorite,
handleRemoveFromFavorites,
handleRemoveFromFavoritesFolder,
}: {
favorite: IFavorite;
favoriteMap: Record<string, IFavorite>;
handleRemoveFromFavorites: (favorite: IFavorite) => void;
handleRemoveFromFavoritesFolder: (favoriteId: string) => void;
}) => {
// store hooks
const { sidebarCollapsed } = useAppTheme();
const { isMobile } = usePlatformOS();
//state
const [isDragging, setIsDragging] = useState(false);
const [isMenuActive, setIsMenuActive] = useState(false);
// router params
const { workspaceSlug } = useParams();
// derived values
//ref
const elementRef = useRef<HTMLDivElement>(null);
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const getIcon = () => (
<>
<div className="hidden group-hover:flex items-center justify-center size-5">
{ICONS[favorite.entity_type] || <FileText />}
</div>
<div className="flex items-center justify-center size-5 group-hover:hidden">
{favorite.entity_data?.logo_props?.in_use ? (
<Logo
logo={favorite.entity_data?.logo_props}
size={16}
type={favorite.entity_type === "project" ? "material" : "lucide"}
/>
) : (
ICONS[favorite.entity_type] || <FileText />
)}
</div>
</>
);
const getLink = () => {
switch (favorite.entity_type) {
case "project":
return `/${workspaceSlug}/projects/${favorite.project_id}/issues`;
case "cycle":
return `/${workspaceSlug}/projects/${favorite.project_id}/cycles/${favorite.entity_identifier}`;
case "module":
return `/${workspaceSlug}/projects/${favorite.project_id}/modules/${favorite.entity_identifier}`;
case "view":
return `/${workspaceSlug}/projects/${favorite.project_id}/views/${favorite.entity_identifier}`;
case "page":
return `/${workspaceSlug}/projects/${favorite.project_id}/pages/${favorite.entity_identifier}`;
default:
return `/${workspaceSlug}`;
}
};
useEffect(() => {
const element = elementRef.current;
if (!element) return;
return combine(
draggable({
element,
// dragHandle: element,
canDrag: () => true,
getInitialData: () => ({ id: favorite.id, type: "CHILD" }),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
}),
dropTargetForElements({
element,
onDragStart: () => {
setIsDragging(true);
},
onDragEnter: () => {
setIsDragging(true);
},
onDragLeave: () => {
setIsDragging(false);
},
onDrop: ({ source }) => {
setIsDragging(false);
const sourceId = source?.data?.id as string | undefined;
if (!sourceId || !favoriteMap[sourceId].parent) return;
handleRemoveFromFavoritesFolder(sourceId);
},
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef?.current, isDragging]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
return (
<>
{sidebarCollapsed ? (
<div ref={elementRef}>
<Link
href={getLink()}
className={cn(
"group/project-item cursor-pointer relative group w-full flex items-center justify-center gap-1.5 rounded px-2 py-1 outline-none text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90 truncate p-0 size-8 aspect-square mx-auto"
)}
>
<span className="flex items-center justify-center size-5">{getIcon()}</span>
</Link>
</div>
) : (
<div
ref={elementRef}
className={cn(
"group/project-item cursor-pointer relative group flex items-center justify-between w-full gap-1.5 rounded px-2 py-1 outline-none text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90",
{
"bg-custom-sidebar-background-90": isMenuActive,
}
)}
>
<Tooltip
isMobile={isMobile}
tooltipContent={favorite.sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"}
position="top-right"
disabled={isDragging}
>
<button
type="button"
className={cn(
"hidden group-hover/project-item:flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab",
{
"cursor-not-allowed opacity-60": favorite.sort_order === null,
"cursor-grabbing": isDragging,
}
)}
ref={dragHandleRef}
>
<DragHandle className="bg-transparent" />
</button>
</Tooltip>
<Link href={getLink()} className="flex items-center gap-1.5 truncate w-full">
<div className="flex items-center justify-center size-5">{getIcon()}</div>
<span className="text-sm leading-5 font-medium flex-1 truncate">
{favorite.entity_data ? favorite.entity_data.name : favorite.name}
</span>
</Link>
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="size-4" />
</span>
}
className={cn(
"opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
{
"opacity-100 pointer-events-auto": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
>
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
<span className="flex items-center justify-start gap-2">
<Star className="h-3.5 w-3.5 fill-yellow-500 stroke-yellow-500" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
)}
</>
);
}
);

View file

@ -0,0 +1,41 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
// ui
import { DragHandle, Tooltip } from "@plane/ui";
// helper
import { cn } from "@/helpers/common.helper";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
sort_order: number | null;
isDragging: boolean;
};
export const FavoriteItemDragHandle: FC<Props> = observer((props) => {
const { sort_order, isDragging } = props;
// store hooks
const { isMobile } = usePlatformOS();
return (
<Tooltip
isMobile={isMobile}
tooltipContent={sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"}
position="top-right"
disabled={isDragging}
>
<div
className={cn(
"hidden group-hover/project-item:flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab",
{
"cursor-not-allowed opacity-60": sort_order === null,
"cursor-grabbing": isDragging,
}
)}
>
<DragHandle className="bg-transparent" />
</div>
</Tooltip>
);
});

View file

@ -0,0 +1,48 @@
"use client";
import React, { FC } from "react";
import { MoreHorizontal, Star } from "lucide-react";
import { IFavorite } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
type Props = {
ref: React.MutableRefObject<HTMLDivElement | null>;
isMenuActive: boolean;
favorite: IFavorite;
onChange: (value: boolean) => void;
handleRemoveFromFavorites: (favorite: IFavorite) => void;
};
export const FavoriteItemQuickAction: FC<Props> = (props) => {
const { ref, isMenuActive, onChange, handleRemoveFromFavorites, favorite } = props;
return (
<CustomMenu
customButton={
<span
ref={ref}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
onClick={() => onChange(!isMenuActive)}
>
<MoreHorizontal className="size-4" />
</span>
}
className={cn(
"opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
{
"opacity-100 pointer-events-auto": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
>
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
<span className="flex items-center justify-start gap-2">
<Star className="h-3.5 w-3.5 fill-yellow-500 stroke-yellow-500 flex-shrink-0" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
);
};

View file

@ -0,0 +1,25 @@
"use client";
import React, { FC } from "react";
import Link from "next/link";
type Props = {
href: string;
title: string;
icon: JSX.Element;
isSidebarCollapsed: boolean;
};
export const FavoriteItemTitle: FC<Props> = (props) => {
const { href, title, icon, isSidebarCollapsed } = props;
const linkClass = "flex items-center gap-1.5 truncate w-full";
const collapsedClass =
"group/project-item cursor-pointer relative group w-full flex items-center justify-center gap-1.5 rounded px-2 py-1 outline-none text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90 truncate p-0 size-8 aspect-square mx-auto";
return (
<Link href={href} className={isSidebarCollapsed ? collapsedClass : linkClass} draggable>
<span className="flex items-center justify-center size-5">{icon}</span>
{!isSidebarCollapsed && <span className="text-sm leading-5 font-medium flex-1 truncate">{title}</span>}
</Link>
);
};

View file

@ -0,0 +1,34 @@
"use client";
import React, { FC } from "react";
// helpers
import { cn } from "@/helpers/common.helper";
type Props = {
children: React.ReactNode;
elementRef: React.RefObject<HTMLDivElement>;
isMenuActive?: boolean;
sidebarCollapsed?: boolean;
};
export const FavoriteItemWrapper: FC<Props> = (props) => {
const { children, elementRef, isMenuActive = false, sidebarCollapsed = false } = props;
return (
<>
{sidebarCollapsed ? (
<div ref={elementRef}>{children}</div>
) : (
<div
ref={elementRef}
className={cn(
"group/project-item cursor-pointer relative group flex items-center justify-between w-full gap-1.5 rounded px-2 py-1 outline-none text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90",
{
"bg-custom-sidebar-background-90": isMenuActive,
}
)}
>
{children}
</div>
)}
</>
);
};

View file

@ -0,0 +1,49 @@
"use client";
// lucide
import { Briefcase, FileText, Layers } from "lucide-react";
// types
import { IFavorite, TLogoProps } from "@plane/types";
// ui
import { ContrastIcon, DiceIcon, FavoriteFolderIcon } from "@plane/ui";
import { Logo } from "@/components/common";
const iconClassName = `flex-shrink-0 size-4 stroke-[1.5] m-auto`;
export const FAVORITE_ITEM_ICON: Record<string, JSX.Element> = {
page: <FileText className={iconClassName} />,
project: <Briefcase className={iconClassName} />,
view: <Layers className={iconClassName} />,
module: <DiceIcon className={iconClassName} />,
cycle: <ContrastIcon className={iconClassName} />,
folder: <FavoriteFolderIcon className={iconClassName} />,
};
export const getFavoriteItemIcon = (type: string, logo?: TLogoProps | undefined) => (
<>
<div className="hidden group-hover:flex items-center justify-center size-5">
{FAVORITE_ITEM_ICON[type] || <FileText className={iconClassName} />}
</div>
<div className="flex items-center justify-center size-5 group-hover:hidden">
{logo?.in_use ? (
<Logo logo={logo} size={16} type={type === "project" ? "material" : "lucide"} />
) : (
FAVORITE_ITEM_ICON[type] || <FileText className={iconClassName} />
)}
</div>
</>
);
const entityPaths: Record<string, string> = {
project: "issues",
cycle: "cycles",
module: "modules",
view: "views",
page: "pages",
};
export const generateFavoriteItemLink = (workspaceSlug: string, favorite: IFavorite) => {
const entityPath = entityPaths[favorite.entity_type];
return entityPath
? `/${workspaceSlug}/projects/${favorite.project_id}/${entityPath}/${favorite.entity_identifier || ""}`
: `/${workspaceSlug}`;
};

View file

@ -0,0 +1,5 @@
export * from "./favorite-item-drag-handle";
export * from "./favorite-item-quick-action";
export * from "./favorite-item-wrapper";
export * from "./favorite-item-title";
export * from "./helper";

View file

@ -0,0 +1,2 @@
export * from "./common";
export * from "./root";

View file

@ -0,0 +1,106 @@
"use client";
import React, { FC, useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react";
// ui
import { IFavorite } from "@plane/types";
// components
import {
FavoriteItemDragHandle,
FavoriteItemQuickAction,
FavoriteItemWrapper,
FavoriteItemTitle,
} from "@/components/workspace/sidebar/favorites";
// hooks
import { useAppTheme } from "@/hooks/store";
import { useFavoriteItemDetails } from "@/hooks/use-favorite-item-details";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
type Props = {
workspaceSlug: string;
favorite: IFavorite;
favoriteMap: Record<string, IFavorite>;
handleRemoveFromFavorites: (favorite: IFavorite) => void;
handleRemoveFromFavoritesFolder: (favoriteId: string) => void;
};
export const FavoriteRoot: FC<Props> = observer((props) => {
// props
const { workspaceSlug, favorite, favoriteMap, handleRemoveFromFavorites, handleRemoveFromFavoritesFolder } = props;
// store hooks
const { sidebarCollapsed } = useAppTheme();
//state
const [isDragging, setIsDragging] = useState(false);
const [isMenuActive, setIsMenuActive] = useState(false);
//ref
const elementRef = useRef<HTMLDivElement>(null);
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const handleQuickAction = (value: boolean) => setIsMenuActive(value);
const { itemLink, itemIcon, itemTitle } = useFavoriteItemDetails(workspaceSlug, favorite);
// drag and drop
useEffect(() => {
const element = elementRef.current;
if (!element) return;
return combine(
draggable({
element,
dragHandle: elementRef.current,
canDrag: () => true,
getInitialData: () => ({ id: favorite.id, type: "CHILD" }),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
}),
dropTargetForElements({
element,
onDragStart: () => {
setIsDragging(true);
},
onDragEnter: () => {
setIsDragging(true);
},
onDragLeave: () => {
setIsDragging(false);
},
onDrop: ({ source }) => {
setIsDragging(false);
const sourceId = source?.data?.id as string | undefined;
if (!sourceId || !favoriteMap[sourceId].parent) return;
handleRemoveFromFavoritesFolder(sourceId);
},
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef?.current, isDragging]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
return (
<>
<FavoriteItemWrapper elementRef={elementRef} isMenuActive={isMenuActive} sidebarCollapsed={sidebarCollapsed}>
{!sidebarCollapsed && <FavoriteItemDragHandle isDragging={isDragging} sort_order={favorite.sort_order} />}
<FavoriteItemTitle href={itemLink} icon={itemIcon} title={itemTitle} isSidebarCollapsed={!!sidebarCollapsed} />
{!sidebarCollapsed && (
<FavoriteItemQuickAction
favorite={favorite}
ref={actionSectionRef}
isMenuActive={isMenuActive}
onChange={handleQuickAction}
handleRemoveFromFavorites={handleRemoveFromFavorites}
/>
)}
</FavoriteItemWrapper>
</>
);
});

View file

@ -22,7 +22,7 @@ import useLocalStorage from "@/hooks/use-local-storage";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { FavoriteFolder } from "./favorite-folder";
import { FavoriteItem } from "./favorite-item";
import { FavoriteRoot } from "./favorite-items";
import { NewFavoriteFolder } from "./new-fav-folder";
export const SidebarFavoritesMenu = observer(() => {
@ -196,7 +196,8 @@ export const SidebarFavoritesMenu = observer(() => {
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
/>
) : (
<FavoriteItem
<FavoriteRoot
workspaceSlug={workspaceSlug.toString()}
favorite={fav}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}

View file

@ -0,0 +1,5 @@
export * from "./favorite-folder";
export * from "./favorite-items";
export * from "./favorites-menu";
export * from "./favorites.helpers";
export * from "./new-fav-folder";

View file

@ -1,4 +1,5 @@
export * from "./dropdown";
export * from "./favorites";
export * from "./help-section";
export * from "./projects-list-item";
export * from "./projects-list";

View file

@ -0,0 +1,58 @@
import { IFavorite } from "@plane/types";
import {
generateFavoriteItemLink,
getFavoriteItemIcon,
} from "@/components/workspace/sidebar/favorites/favorite-items/common";
import { useProject, usePage, useProjectView, useCycle, useModule } from "@/hooks/store";
export const useFavoriteItemDetails = (workspaceSlug: string, favorite: IFavorite) => {
const favoriteItemId = favorite.entity_data.id;
const favoriteItemLogoProps = favorite?.entity_data?.logo_props;
const favoriteItemName = favorite?.entity_data.name || favorite?.name;
const favoriteItemEntityType = favorite?.entity_type;
// store hooks
const { getViewById } = useProjectView();
const { currentProjectDetails } = useProject();
const { getCycleById } = useCycle();
const { getModuleById } = useModule();
// derived values
const pageDetail = usePage(favoriteItemId ?? "");
const viewDetails = getViewById(favoriteItemId ?? "");
const cycleDetail = getCycleById(favoriteItemId ?? "");
const moduleDetail = getModuleById(favoriteItemId ?? "");
let itemIcon;
let itemTitle;
const itemLink = generateFavoriteItemLink(workspaceSlug.toString(), favorite);
switch (favoriteItemEntityType) {
case "project":
itemTitle = currentProjectDetails?.name || favoriteItemName;
itemIcon = getFavoriteItemIcon("project", currentProjectDetails?.logo_props || favoriteItemLogoProps);
break;
case "page":
itemTitle = pageDetail.name || favoriteItemName;
itemIcon = getFavoriteItemIcon("page", pageDetail?.logo_props || favoriteItemLogoProps);
break;
case "view":
itemTitle = viewDetails?.name || favoriteItemName;
itemIcon = getFavoriteItemIcon("view", viewDetails?.logo_props || favoriteItemLogoProps);
break;
case "cycle":
itemTitle = cycleDetail?.name || favoriteItemName;
itemIcon = getFavoriteItemIcon("cycle");
break;
case "module":
itemTitle = moduleDetail?.name || favoriteItemName;
itemIcon = getFavoriteItemIcon("module");
break;
default:
itemTitle = favoriteItemName;
itemIcon = getFavoriteItemIcon(favoriteItemEntityType);
break;
}
return { itemIcon, itemTitle, itemLink };
};