[WEB-1093] chore: padding and borders consistency (#4315)

* chore: global list layout and list item component added

* chore: project view list layout consistency

* chore: project view sub header consistency

* chore: pages list layout consistency

* chore: project view sub header improvement

* chore: list layout item component improvement

* chore: module list layout consistency

* chore: cycle list layout consistency

* chore: issue list layout consistency

* chore: header height consistency

* chore: sub header consistency

* chore: list layout improvement

* chore: inbox sidebar improvement

* fix: cycle quick action

* chore: inbox selected issue improvement

* chore: label option removed from pages filter

* chore: inbox create issue modal improvement
This commit is contained in:
Anmol Singh Bhatia 2024-04-30 17:21:24 +05:30 committed by GitHub
parent 1b79517f07
commit 87a606446f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1230 additions and 1081 deletions

View File

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

View File

@ -0,0 +1,53 @@
import React, { FC } from "react";
import Link from "next/link";
// ui
import { Tooltip } from "@plane/ui";
interface IListItemProps {
title: string;
itemLink: string;
onItemClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
prependTitleElement?: JSX.Element;
appendTitleElement?: JSX.Element;
actionableItems?: JSX.Element;
isMobile?: boolean;
}
export const ListItem: FC<IListItemProps> = (props) => {
const {
title,
prependTitleElement,
appendTitleElement,
actionableItems,
itemLink,
onItemClick,
isMobile = false,
} = props;
return (
<div className="relative">
<Link href={itemLink} onClick={onItemClick}>
<div className="group h-24 sm:h-[52px] flex w-full flex-col items-center justify-between gap-3 sm:gap-5 px-6 py-4 sm:py-0 text-sm border-b border-custom-border-200 bg-custom-background-100 hover:bg-custom-background-90 sm:flex-row">
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden">
<div className="flex items-center gap-4 truncate">
{prependTitleElement && <span className="flex items-center flex-shrink-0">{prependTitleElement}</span>}
<Tooltip tooltipContent={title} position="top" isMobile={isMobile}>
<span className="truncate text-sm">{title}</span>
</Tooltip>
</div>
{appendTitleElement && <span className="flex items-center flex-shrink-0">{appendTitleElement}</span>}
</div>
</div>
<span className="h-6 w-96 flex-shrink-0" />
</div>
</Link>
{actionableItems && (
<div className="absolute right-5 bottom-4 flex items-center gap-1.5">
<div className="relative flex items-center gap-4 sm:w-auto sm:flex-shrink-0 sm:justify-end">
{actionableItems}
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,10 @@
import React, { FC } from "react";
interface IListContainer {
children: React.ReactNode;
}
export const ListLayout: FC<IListContainer> = (props) => {
const { children } = props;
return <div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">{children}</div>;
};

View File

@ -76,7 +76,7 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
}; };
return ( return (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-b border-custom-border-200 px-4 sm:px-5 sm:pb-0"> <div className="h-[50px] flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-b border-custom-border-200 px-6 sm:pb-0">
<Tab.List as="div" className="flex items-center overflow-x-scroll"> <Tab.List as="div" className="flex items-center overflow-x-scroll">
{CYCLE_TABS_LIST.map((tab) => ( {CYCLE_TABS_LIST.map((tab) => (
<Tab <Tab

View File

@ -0,0 +1,160 @@
import React, { FC, MouseEvent } from "react";
import { observer } from "mobx-react";
import { User2 } from "lucide-react";
// types
import { ICycle, TCycleGroups } from "@plane/types";
// ui
import { Avatar, AvatarGroup, Tooltip, setPromiseToast } from "@plane/ui";
// components
import { FavoriteStar } from "@/components/core";
import { CycleQuickActions } from "@/components/cycles";
// constants
import { CYCLE_STATUS } from "@/constants/cycle";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker";
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useCycle, useEventTracker, useMember, useUser } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
workspaceSlug: string;
projectId: string;
cycleId: string;
cycleDetails: ICycle;
isArchived: boolean;
};
export const CycleListItemAction: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, cycleId, cycleDetails, isArchived } = props;
// hooks
const { isMobile } = usePlatformOS();
// store hooks
const { addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { captureEvent } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
const { getUserDetails } = useMember();
// derived values
const endDate = getDate(cycleDetails.end_date);
const startDate = getDate(cycleDetails.start_date);
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const renderDate = cycleDetails.start_date || cycleDetails.end_date;
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
// handlers
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then(
() => {
captureEvent(CYCLE_FAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
}
);
setPromiseToast(addToFavoritePromise, {
loading: "Adding cycle to favorites...",
success: {
title: "Success!",
message: () => "Cycle added to favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't add the cycle to favorites. Please try again.",
},
});
};
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const removeFromFavoritePromise = removeCycleFromFavorites(
workspaceSlug?.toString(),
projectId.toString(),
cycleId
).then(() => {
captureEvent(CYCLE_UNFAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
});
setPromiseToast(removeFromFavoritePromise, {
loading: "Removing cycle from favorites...",
success: {
title: "Success!",
message: () => "Cycle removed from favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't remove the cycle from favorites. Please try again.",
},
});
};
return (
<>
<div className="text-xs text-custom-text-300 flex-shrink-0">
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
</div>
{currentCycle && (
<div
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
: `${currentCycle.label}`}
</div>
)}
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignee_ids?.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div>
</Tooltip>
{isEditingAllowed && !isArchived && (
<FavoriteStar
onClick={(e) => {
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
else handleAddToFavorites(e);
}}
selected={!!cycleDetails.is_favorite}
/>
)}
<CycleQuickActions
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
isArchived={isArchived}
/>
</>
);
});

View File

@ -1,24 +1,17 @@
import { FC, MouseEvent } from "react"; import { FC, MouseEvent } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// icons // icons
import { Check, Info, User2 } from "lucide-react"; import { Check, Info } from "lucide-react";
// types // types
import type { TCycleGroups } from "@plane/types"; import type { TCycleGroups } from "@plane/types";
// ui // ui
import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui"; import { CircularProgressIndicator } from "@plane/ui";
// components // components
import { FavoriteStar } from "@/components/core"; import { ListItem } from "@/components/core/list";
import { CycleQuickActions } from "@/components/cycles"; import { CycleListItemAction } from "@/components/cycles/list";
// constants
import { CYCLE_STATUS } from "@/constants/cycle";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker";
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
// hooks // hooks
import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store"; import { useCycle } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
type TCyclesListItem = { type TCyclesListItem = {
@ -33,75 +26,36 @@ type TCyclesListItem = {
}; };
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => { export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const { cycleId, workspaceSlug, projectId, isArchived } = props; const { cycleId, workspaceSlug, projectId, isArchived = false } = props;
// router // router
const router = useRouter(); const router = useRouter();
// hooks // hooks
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
// store hooks // store hooks
const { captureEvent } = useEventTracker(); const { getCycleById } = useCycle();
const {
membership: { currentProjectRole },
} = useUser();
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { getUserDetails } = useMember();
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { // derived values
e.preventDefault(); const cycleDetails = getCycleById(cycleId);
if (!workspaceSlug || !projectId) return;
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( if (!cycleDetails) return null;
() => {
captureEvent(CYCLE_FAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
}
);
setPromiseToast(addToFavoritePromise, { // computed
loading: "Adding cycle to favorites...", // TODO: change this logic once backend fix the response
success: { const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
title: "Success!", const isCompleted = cycleStatus === "completed";
message: () => "Cycle added to favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't add the cycle to favorites. Please try again.",
},
});
};
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => { const cycleTotalIssues =
e.preventDefault(); cycleDetails.backlog_issues +
if (!workspaceSlug || !projectId) return; cycleDetails.unstarted_issues +
cycleDetails.started_issues +
cycleDetails.completed_issues +
cycleDetails.cancelled_issues;
const removeFromFavoritePromise = removeCycleFromFavorites( const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100;
workspaceSlug?.toString(),
projectId.toString(),
cycleId
).then(() => {
captureEvent(CYCLE_UNFAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
});
setPromiseToast(removeFromFavoritePromise, { const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
loading: "Removing cycle from favorites...",
success: {
title: "Success!",
message: () => "Cycle removed from favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't remove the cycle from favorites. Please try again.",
},
});
};
// handlers
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => { const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
const { query } = router; const { query } = router;
e.preventDefault(); e.preventDefault();
@ -121,53 +75,17 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
} }
}; };
const cycleDetails = getCycleById(cycleId);
if (!cycleDetails) return null;
// computed
// TODO: change this logic once backend fix the response
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
const isCompleted = cycleStatus === "completed";
const endDate = getDate(cycleDetails.end_date);
const startDate = getDate(cycleDetails.start_date);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const cycleTotalIssues =
cycleDetails.backlog_issues +
cycleDetails.unstarted_issues +
cycleDetails.started_issues +
cycleDetails.completed_issues +
cycleDetails.cancelled_issues;
const renderDate = cycleDetails.start_date || cycleDetails.end_date;
// const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100;
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
return ( return (
<div className="relative"> <ListItem
<Link title={cycleDetails?.name ?? ""}
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`} itemLink={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
onClick={(e) => { onItemClick={(e) => {
if (isArchived) { if (isArchived) {
openCycleOverview(e); openCycleOverview(e);
} }
}} }}
> prependTitleElement={
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 md:flex-row"> <CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden">
<div className="flex-shrink-0">
<CircularProgressIndicator size={38} percentage={progress}>
{isCompleted ? ( {isCompleted ? (
progress === 100 ? ( progress === 100 ? (
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" /> <Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
@ -180,80 +98,25 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
<span className="text-xs text-custom-text-300">{`${progress}%`}</span> <span className="text-xs text-custom-text-300">{`${progress}%`}</span>
)} )}
</CircularProgressIndicator> </CircularProgressIndicator>
</div> }
appendTitleElement={
<div className="relative flex items-center gap-2.5 overflow-hidden"> <button
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" /> onClick={openCycleOverview}
<Tooltip tooltipContent={cycleDetails.name} position="top" isMobile={isMobile}> className={`z-[5] flex-shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
<span className="line-clamp-1 inline-block overflow-hidden truncate text-base font-medium"> >
{cycleDetails.name}
</span>
</Tooltip>
</div>
<button onClick={openCycleOverview} className="invisible z-[5] flex-shrink-0 group-hover:visible">
<Info className="h-4 w-4 text-custom-text-400" /> <Info className="h-4 w-4 text-custom-text-400" />
</button> </button>
</div> }
<div className="text-xs text-custom-text-300 flex-shrink-0"> actionableItems={
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} <CycleListItemAction
</div>
</div>
<span className="h-6 w-52 flex-shrink-0" />
</div>
</Link>
<div className="absolute right-5 bottom-8 flex items-center gap-1.5">
<div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 md:w-auto md:flex-shrink-0 md:justify-end">
{currentCycle && (
<div
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
: `${currentCycle.label}`}
</div>
)}
<div className="relative flex flex-shrink-0 items-center gap-3">
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignee_ids?.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div>
</Tooltip>
{isEditingAllowed && !isArchived && (
<FavoriteStar
onClick={(e) => {
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
else handleAddToFavorites(e);
}}
selected={!!cycleDetails.is_favorite}
/>
)}
<CycleQuickActions
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId}
cycleId={cycleId}
cycleDetails={cycleDetails}
isArchived={isArchived} isArchived={isArchived}
/> />
</div> }
</div> isMobile={isMobile}
</div> />
</div>
); );
}); });

View File

@ -1,3 +1,4 @@
export * from "./cycles-list-item"; export * from "./cycles-list-item";
export * from "./cycles-list-map"; export * from "./cycles-list-map";
export * from "./root"; export * from "./root";
export * from "./cycle-list-item-action";

View File

@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { Disclosure } from "@headlessui/react"; import { Disclosure } from "@headlessui/react";
// components // components
import { ListLayout } from "@/components/core/list";
import { CyclePeekOverview, CyclesListMap } from "@/components/cycles"; import { CyclePeekOverview, CyclesListMap } from "@/components/cycles";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
@ -21,7 +22,7 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
return ( return (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between"> <div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg"> <ListLayout>
<CyclesListMap <CyclesListMap
cycleIds={cycleIds} cycleIds={cycleIds}
projectId={projectId} projectId={projectId}
@ -52,7 +53,7 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
</Disclosure.Panel> </Disclosure.Panel>
</Disclosure> </Disclosure>
)} )}
</div> </ListLayout>
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} isArchived={isArchived} /> <CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} isArchived={isArchived} />
</div> </div>
</div> </div>

View File

@ -168,14 +168,17 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
<hr className="my-2 border-custom-border-200" />
{!isCompleted && isEditingAllowed && ( {!isCompleted && isEditingAllowed && (
<>
<hr className="my-2 border-custom-border-200" />
<CustomMenu.MenuItem onClick={handleDeleteCycle}> <CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
<span>Delete cycle</span> <span>Delete cycle</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
</>
)} )}
</CustomMenu> </CustomMenu>
</> </>

View File

@ -1,15 +1,16 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// icons
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
// hooks
// ui // ui
import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui"; import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui";
// helpers
// components // components
import { BreadcrumbLink } from "@/components/common"; import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project"; import { ProjectLogo } from "@/components/project";
// constants
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useApplication, useEventTracker, useProject, useUser } from "@/hooks/store"; import { useApplication, useEventTracker, useProject, useUser } from "@/hooks/store";
export const CyclesHeader: FC = observer(() => { export const CyclesHeader: FC = observer(() => {
@ -30,8 +31,7 @@ export const CyclesHeader: FC = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
return ( return (
<div className="relative z-10 items-center justify-between gap-x-2 gap-y-4"> <div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div> <div>
<Breadcrumbs onBack={router.back}> <Breadcrumbs onBack={router.back}>
@ -53,9 +53,7 @@ export const CyclesHeader: FC = observer(() => {
/> />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
link={ link={<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />}
<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />
}
/> />
</Breadcrumbs> </Breadcrumbs>
</div> </div>
@ -76,6 +74,5 @@ export const CyclesHeader: FC = observer(() => {
</div> </div>
)} )}
</div> </div>
</div>
); );
}); });

View File

@ -1,33 +1,21 @@
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { ListFilter, Plus, Search, X } from "lucide-react"; // icons
import { TModuleFilters } from "@plane/types"; import { Plus } from "lucide-react";
// hooks
import { Breadcrumbs, Button, Tooltip, DiceIcon } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common";
import { FiltersDropdown } from "@/components/issues";
import { ModuleFiltersSelection, ModuleOrderByDropdown } from "@/components/modules";
import { ProjectLogo } from "@/components/project";
import { MODULE_VIEW_LAYOUTS } from "@/constants/module";
import { EUserProjectRoles } from "@/constants/project";
import { cn } from "@/helpers/common.helper";
import { useApplication, useEventTracker, useMember, useModuleFilter, useProject, useUser } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
// components
// constants
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// ui // ui
// helpers import { Breadcrumbs, Button, DiceIcon } from "@plane/ui";
// types // components
import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project";
// constants
import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useApplication, useEventTracker, useProject, useUser } from "@/hooks/store";
export const ModulesListHeader: React.FC = observer(() => { export const ModulesListHeader: React.FC = observer(() => {
// refs
const inputRef = useRef<HTMLInputElement>(null);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug } = router.query;
// store hooks // store hooks
const { commandPalette: commandPaletteStore } = useApplication(); const { commandPalette: commandPaletteStore } = useApplication();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
@ -35,54 +23,6 @@ export const ModulesListHeader: React.FC = observer(() => {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const { isMobile } = usePlatformOS();
const {
workspace: { workspaceMemberIds },
} = useMember();
const {
currentProjectDisplayFilters: displayFilters,
currentProjectFilters: filters,
searchQuery,
updateDisplayFilters,
updateFilters,
updateSearchQuery,
} = useModuleFilter();
// states
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});
const handleFilters = useCallback(
(key: keyof TModuleFilters, value: string | string[]) => {
if (!projectId) return;
const newValues = filters?.[key] ?? [];
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
else {
if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(projectId.toString(), { [key]: newValues });
},
[filters, projectId, updateFilters]
);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};
// auth // auth
const canUserCreateModule = const canUserCreateModule =
@ -117,97 +57,6 @@ export const ModulesListHeader: React.FC = observer(() => {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center">
{!isSearchOpen && (
<button
type="button"
className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
// updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
<div className="hidden md:flex items-center gap-1 rounded bg-custom-background-80 p-1">
{MODULE_VIEW_LAYOUTS.map((layout) => (
<Tooltip key={layout.key} tooltipContent={layout.title} isMobile={isMobile}>
<button
type="button"
className={cn(
"group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100",
{
"bg-custom-background-100 shadow-custom-shadow-2xs": displayFilters?.layout === layout.key,
}
)}
onClick={() => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), { layout: layout.key });
}}
>
<layout.icon
strokeWidth={2}
className={cn("h-3.5 w-3.5 text-custom-text-200", {
"text-custom-text-100": displayFilters?.layout === layout.key,
})}
/>
</button>
</Tooltip>
))}
</div>
<ModuleOrderByDropdown
value={displayFilters?.order_by}
onChange={(val) => {
if (!projectId || val === displayFilters?.order_by) return;
updateDisplayFilters(projectId.toString(), {
order_by: val,
});
}}
/>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<ModuleFiltersSelection
displayFilters={displayFilters ?? {}}
filters={filters ?? {}}
handleDisplayFiltersUpdate={(val) => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), val);
}}
handleFiltersUpdate={handleFilters}
memberIds={workspaceMemberIds ?? undefined}
/>
</FiltersDropdown>
{canUserCreateModule && ( {canUserCreateModule && (
<Button <Button
variant="primary" variant="primary"

View File

@ -116,8 +116,8 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)} onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined} projectDetails={currentProjectDetails ?? undefined}
/> />
<div className="relative z-[15] items-center gap-x-2 gap-y-4">
<div className="flex items-center gap-2 p-4 bg-custom-sidebar-background-100"> <div className="relative z-[15] flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Breadcrumbs onBack={() => router.back()}> <Breadcrumbs onBack={() => router.back()}>
@ -146,9 +146,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
link={ link={<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />}
<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />
}
/> />
</Breadcrumbs> </Breadcrumbs>
{issueCount && issueCount > 0 ? ( {issueCount && issueCount > 0 ? (
@ -234,7 +232,6 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
</> </>
)} )}
</div> </div>
</div>
</> </>
); );
}); });

View File

@ -136,9 +136,12 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
/> />
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} /> <InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} />
<div className="relative flex justify-between items-center gap-3"> <div className="relative flex justify-between items-center gap-3">
<div className="flex cursor-pointer items-center gap-1" onClick={() => setCreateMore((prevData) => !prevData)}> <div
className="flex cursor-pointer items-center gap-1.5"
onClick={() => setCreateMore((prevData) => !prevData)}
>
<ToggleSwitch value={createMore} onChange={() => {}} size="sm" />
<span className="text-xs">Create more</span> <span className="text-xs">Create more</span>
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
</div> </div>
<div className="relative flex items-center gap-3"> <div className="relative flex items-center gap-3">
<Button variant="neutral-primary" size="sm" type="button" onClick={handleModalClose}> <Button variant="neutral-primary" size="sm" type="button" onClick={handleModalClose}>

View File

@ -50,7 +50,7 @@ export const InboxIssueListItem: FC<InboxIssueListItemProps> = observer((props)
<div <div
className={cn( className={cn(
`flex flex-col gap-2 relative border border-t-transparent border-l-transparent border-r-transparent border-b-custom-border-200 p-4 hover:bg-custom-primary/5 cursor-pointer transition-all`, `flex flex-col gap-2 relative border border-t-transparent border-l-transparent border-r-transparent border-b-custom-border-200 p-4 hover:bg-custom-primary/5 cursor-pointer transition-all`,
{ "bg-custom-primary/5 border-custom-primary-100 border": inboxIssueId === issue.id } { "border-custom-primary-100 border": inboxIssueId === issue.id }
)} )}
> >
<div className="space-y-1"> <div className="space-y-1">

View File

@ -65,14 +65,14 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
}); });
return ( return (
<div className="bg-custom-background-100 flex-shrink-0 w-full h-full border-r border-custom-border-300"> <div className="bg-custom-background-100 flex-shrink-0 w-full h-full border-r border-custom-border-300 ">
<div className="relative w-full h-full flex flex-col overflow-hidden"> <div className="relative w-full h-full flex flex-col overflow-hidden">
<div className="border-b border-custom-border-300 flex-shrink-0 w-full h-[50px] relative flex items-center gap-2 pr-3 whitespace-nowrap"> <div className="border-b border-custom-border-300 flex-shrink-0 w-full h-[50px] relative flex items-center gap-2 whitespace-nowrap px-3">
{tabNavigationOptions.map((option) => ( {tabNavigationOptions.map((option) => (
<div <div
key={option?.key} key={option?.key}
className={cn( className={cn(
`text-sm relative flex items-center gap-1 h-[50px] px-2 cursor-pointer transition-all font-medium`, `text-sm relative flex items-center gap-1 h-[50px] px-3 cursor-pointer transition-all font-medium`,
currentTab === option?.key ? `text-custom-primary-100` : `hover:text-custom-text-200` currentTab === option?.key ? `text-custom-primary-100` : `hover:text-custom-text-200`
)} )}
onClick={() => { onClick={() => {

View File

@ -47,7 +47,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
return ( return (
<div <div
className={cn( className={cn(
"min-h-12 relative flex flex-col md:flex-row md:items-center gap-3 bg-custom-background-100 p-3 text-sm", "min-h-[52px] relative flex flex-col md:flex-row md:items-center gap-3 bg-custom-background-100 p-3 text-sm",
{ {
"border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id), "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id),
"last:border-b-transparent": !getIsIssuePeeked(issue.id), "last:border-b-transparent": !getIsIssuePeeked(issue.id),

View File

@ -12,6 +12,8 @@ export * from "./module-card-item";
export * from "./module-list-item"; export * from "./module-list-item";
export * from "./module-peek-overview"; export * from "./module-peek-overview";
export * from "./quick-actions"; export * from "./quick-actions";
export * from "./module-list-item-action";
export * from "./module-view-header";
// archived modules // archived modules
export * from "./archived-modules"; export * from "./archived-modules";

View File

@ -0,0 +1,168 @@
import React, { FC } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// icons
import { User2 } from "lucide-react";
// types
import { IModule } from "@plane/types";
// ui
import { Avatar, AvatarGroup, Tooltip, setPromiseToast } from "@plane/ui";
// components
import { FavoriteStar } from "@/components/core";
import { ModuleQuickActions } from "@/components/modules";
// constants
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
import { MODULE_STATUS } from "@/constants/module";
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { getDate, renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useEventTracker, useMember, useModule, useUser } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
moduleId: string;
moduleDetails: IModule;
isArchived: boolean;
};
export const ModuleListItemAction: FC<Props> = observer((props) => {
const { moduleId, moduleDetails, isArchived } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store hooks
const {
membership: { currentProjectRole },
} = useUser();
const { addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember();
const { captureEvent } = useEventTracker();
const { isMobile } = usePlatformOS();
// derived values
const endDate = getDate(moduleDetails.target_date);
const startDate = getDate(moduleDetails.start_date);
const renderDate = moduleDetails.start_date || moduleDetails.target_date;
const moduleStatus = MODULE_STATUS.find((status) => status.value === moduleDetails.status);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
// handlers
const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const addToFavoritePromise = addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).then(
() => {
captureEvent(MODULE_FAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
});
}
);
setPromiseToast(addToFavoritePromise, {
loading: "Adding module to favorites...",
success: {
title: "Success!",
message: () => "Module added to favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't add the module to favorites. Please try again.",
},
});
};
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const removeFromFavoritePromise = removeModuleFromFavorites(
workspaceSlug.toString(),
projectId.toString(),
moduleId
).then(() => {
captureEvent(MODULE_UNFAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
});
});
setPromiseToast(removeFromFavoritePromise, {
loading: "Removing module from favorites...",
success: {
title: "Success!",
message: () => "Module removed from favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't remove the module from favorites. Please try again.",
},
});
};
return (
<>
{moduleStatus && (
<span
className="flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
style={{
color: moduleStatus.color,
backgroundColor: `${moduleStatus.color}20`,
}}
>
{moduleStatus.label}
</span>
)}
{renderDate && (
<span className=" text-xs text-custom-text-300">
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
</span>
)}
<Tooltip tooltipContent={`${moduleDetails?.member_ids?.length || 0} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center gap-1">
{moduleDetails.member_ids.length > 0 ? (
<AvatarGroup showTooltip={false}>
{moduleDetails.member_ids.map((member_id) => {
const member = getUserDetails(member_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div>
</Tooltip>
{isEditingAllowed && !isArchived && (
<FavoriteStar
onClick={(e) => {
if (moduleDetails.is_favorite) handleRemoveFromFavorites(e);
else handleAddToFavorites(e);
}}
selected={moduleDetails.is_favorite}
/>
)}
{workspaceSlug && projectId && (
<ModuleQuickActions
moduleId={moduleId}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
isArchived={isArchived}
/>
)}
</>
);
});

View File

@ -1,21 +1,15 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Check, Info, User2 } from "lucide-react"; // icons
import { Check, Info } from "lucide-react";
// ui // ui
import { Avatar, AvatarGroup, CircularProgressIndicator, Tooltip, setPromiseToast } from "@plane/ui"; import { CircularProgressIndicator } from "@plane/ui";
// components // components
import { FavoriteStar } from "@/components/core"; import { ListItem } from "@/components/core/list";
import { ModuleQuickActions } from "@/components/modules"; import { ModuleListItemAction } from "@/components/modules";
// constants
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
import { MODULE_STATUS } from "@/constants/module";
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { getDate, renderFormattedDate } from "@/helpers/date-time.helper";
// hooks // hooks
import { useModule, useUser, useEventTracker, useMember } from "@/hooks/store"; import { useModule } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = { type Props = {
@ -27,76 +21,24 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
const { moduleId, isArchived = false } = props; const { moduleId, isArchived = false } = props;
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug } = router.query;
// store hooks // store hooks
const { const { getModuleById } = useModule();
membership: { currentProjectRole }, const { isMobile } = usePlatformOS();
} = useUser();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember();
const { captureEvent } = useEventTracker();
// derived values // derived values
const moduleDetails = getModuleById(moduleId); const moduleDetails = getModuleById(moduleId);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const { isMobile } = usePlatformOS();
const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const addToFavoritePromise = addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).then( if (!moduleDetails) return null;
() => {
captureEvent(MODULE_FAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
});
}
);
setPromiseToast(addToFavoritePromise, { const completionPercentage =
loading: "Adding module to favorites...", ((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100;
success: {
title: "Success!",
message: () => "Module added to favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't add the module to favorites. Please try again.",
},
});
};
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => { const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const removeFromFavoritePromise = removeModuleFromFavorites( const completedModuleCheck = moduleDetails.status === "completed";
workspaceSlug.toString(),
projectId.toString(),
moduleId
).then(() => {
captureEvent(MODULE_UNFAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
});
});
setPromiseToast(removeFromFavoritePromise, {
loading: "Removing module from favorites...",
success: {
title: "Success!",
message: () => "Module removed from favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't remove the module from favorites. Please try again.",
},
});
};
// handlers
const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => { const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -116,40 +58,17 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
} }
}; };
if (!moduleDetails) return null;
const completionPercentage =
((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100;
const endDate = getDate(moduleDetails.target_date);
const startDate = getDate(moduleDetails.start_date);
const renderDate = moduleDetails.start_date || moduleDetails.target_date;
// const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
const moduleStatus = MODULE_STATUS.find((status) => status.value === moduleDetails.status);
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
const completedModuleCheck = moduleDetails.status === "completed";
return ( return (
<div className="relative"> <ListItem
<Link title={moduleDetails?.name ?? ""}
href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`} itemLink={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}
onClick={(e) => { onItemClick={(e) => {
if (isArchived) { if (isArchived) {
openModuleOverview(e); openModuleOverview(e);
} }
}} }}
> prependTitleElement={
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 sm:flex-row"> <CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden">
<div className="flex items-center gap-4 truncate">
<span className="flex-shrink-0">
<CircularProgressIndicator size={38} percentage={progress}>
{completedModuleCheck ? ( {completedModuleCheck ? (
progress === 100 ? ( progress === 100 ? (
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" /> <Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
@ -162,80 +81,19 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
<span className="text-xs text-custom-text-300">{`${progress}%`}</span> <span className="text-xs text-custom-text-300">{`${progress}%`}</span>
)} )}
</CircularProgressIndicator> </CircularProgressIndicator>
</span> }
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}> appendTitleElement={
<span className="truncate text-base font-medium">{moduleDetails.name}</span> <button
</Tooltip> onClick={openModuleOverview}
</div> className={`z-[5] flex-shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
<button onClick={openModuleOverview} className="z-[5] hidden flex-shrink-0 group-hover:flex"> >
<Info className="h-4 w-4 text-custom-text-400" /> <Info className="h-4 w-4 text-custom-text-400" />
</button> </button>
</div> }
</div> actionableItems={
<span className="h-6 w-52 flex-shrink-0" /> <ModuleListItemAction moduleId={moduleId} moduleDetails={moduleDetails} isArchived={isArchived} />
</div> }
</Link> isMobile={isMobile}
<div className="absolute right-5 bottom-8 flex items-center gap-1.5">
<div className="flex flex-shrink-0 items-center justify-center">
{moduleStatus && (
<span
className="flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
style={{
color: moduleStatus.color,
backgroundColor: `${moduleStatus.color}20`,
}}
>
{moduleStatus.label}
</span>
)}
</div>
<div className="relative flex w-full items-center justify-between gap-2.5 sm:w-auto sm:flex-shrink-0 sm:justify-end ">
<div className="text-xs text-custom-text-300">
{renderDate && (
<span className=" text-xs text-custom-text-300">
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
</span>
)}
</div>
<div className="relative flex flex-shrink-0 items-center gap-3">
<Tooltip tooltipContent={`${moduleDetails?.member_ids?.length || 0} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center gap-1">
{moduleDetails.member_ids.length > 0 ? (
<AvatarGroup showTooltip={false}>
{moduleDetails.member_ids.map((member_id) => {
const member = getUserDetails(member_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div>
</Tooltip>
{isEditingAllowed && !isArchived && (
<FavoriteStar
onClick={(e) => {
if (moduleDetails.is_favorite) handleRemoveFromFavorites(e);
else handleAddToFavorites(e);
}}
selected={moduleDetails.is_favorite}
/> />
)}
{workspaceSlug && projectId && (
<ModuleQuickActions
moduleId={moduleId}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
isArchived={isArchived}
/>
)}
</div>
</div>
</div>
</div>
); );
}); });

View File

@ -0,0 +1,178 @@
import React, { FC, useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { ListFilter, Search, X } from "lucide-react";
import { cn } from "@plane/editor-core";
// types
import { TModuleFilters } from "@plane/types";
// ui
import { Tooltip } from "@plane/ui";
// components
import { FiltersDropdown } from "@/components/issues";
import { ModuleFiltersSelection, ModuleOrderByDropdown } from "@/components/modules/dropdowns";
// constants
import { MODULE_VIEW_LAYOUTS } from "@/constants/module";
// hooks
import { useMember, useModuleFilter } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
export const ModuleViewHeader: FC = observer(() => {
// refs
const inputRef = useRef<HTMLInputElement>(null);
// router
const router = useRouter();
const { projectId } = router.query;
// hooks
const { isMobile } = usePlatformOS();
// store hooks
const {
workspace: { workspaceMemberIds },
} = useMember();
const {
currentProjectDisplayFilters: displayFilters,
currentProjectFilters: filters,
searchQuery,
updateDisplayFilters,
updateFilters,
updateSearchQuery,
} = useModuleFilter();
// states
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
// handlers
const handleFilters = useCallback(
(key: keyof TModuleFilters, value: string | string[]) => {
if (!projectId) return;
const newValues = filters?.[key] ?? [];
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
else {
if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(projectId.toString(), { [key]: newValues });
},
[filters, projectId, updateFilters]
);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});
return (
<div className="hidden h-full sm:flex items-center gap-3 self-end">
<div className="flex items-center">
{!isSearchOpen && (
<button
type="button"
className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
// updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
<ModuleOrderByDropdown
value={displayFilters?.order_by}
onChange={(val) => {
if (!projectId || val === displayFilters?.order_by) return;
updateDisplayFilters(projectId.toString(), {
order_by: val,
});
}}
/>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<ModuleFiltersSelection
displayFilters={displayFilters ?? {}}
filters={filters ?? {}}
handleDisplayFiltersUpdate={(val) => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), val);
}}
handleFiltersUpdate={handleFilters}
memberIds={workspaceMemberIds ?? undefined}
/>
</FiltersDropdown>
<div className="hidden md:flex items-center gap-1 rounded bg-custom-background-80 p-1">
{MODULE_VIEW_LAYOUTS.map((layout) => (
<Tooltip key={layout.key} tooltipContent={layout.title} isMobile={isMobile}>
<button
type="button"
className={cn(
"group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100",
{
"bg-custom-background-100 shadow-custom-shadow-2xs": displayFilters?.layout === layout.key,
}
)}
onClick={() => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), { layout: layout.key });
}}
>
<layout.icon
strokeWidth={2}
className={cn("h-3.5 w-3.5 text-custom-text-200", {
"text-custom-text-100": displayFilters?.layout === layout.key,
})}
/>
</button>
</Tooltip>
))}
</div>
</div>
);
});

View File

@ -2,6 +2,7 @@ import { observer } from "mobx-react-lite";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// components // components
import { ListLayout } from "@/components/core/list";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "@/components/modules"; import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "@/components/modules";
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "@/components/ui"; import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "@/components/ui";
@ -69,11 +70,11 @@ export const ModulesListView: React.FC = observer(() => {
{displayFilters?.layout === "list" && ( {displayFilters?.layout === "list" && (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between"> <div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg"> <ListLayout>
{filteredModuleIds.map((moduleId) => ( {filteredModuleIds.map((moduleId) => (
<ModuleListItem key={moduleId} moduleId={moduleId} /> <ModuleListItem key={moduleId} moduleId={moduleId} />
))} ))}
</div> </ListLayout>
<ModulePeekOverview <ModulePeekOverview
projectId={projectId?.toString() ?? ""} projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""} workspaceSlug={workspaceSlug?.toString() ?? ""}

View File

@ -14,7 +14,7 @@ import {
// helpers // helpers
import { calculateTotalFilters } from "@/helpers/filter.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks // hooks
import { useLabel, useMember, useProjectPages } from "@/hooks/store"; import { useMember, useProjectPages } from "@/hooks/store";
type Props = { type Props = {
pageType: TPageNavigationTabs; pageType: TPageNavigationTabs;
@ -29,7 +29,6 @@ export const PagesListHeaderRoot: React.FC<Props> = observer((props) => {
const { const {
workspace: { workspaceMemberIds }, workspace: { workspaceMemberIds },
} = useMember(); } = useMember();
const { projectLabels } = useLabel();
const handleRemoveFilter = useCallback( const handleRemoveFilter = useCallback(
(key: keyof TPageFilterProps, value: string | null) => { (key: keyof TPageFilterProps, value: string | null) => {
@ -48,7 +47,7 @@ export const PagesListHeaderRoot: React.FC<Props> = observer((props) => {
return ( return (
<> <>
<div className="flex-shrink-0 w-full border-b border-custom-border-200 px-3 relative flex items-center gap-4 justify-between"> <div className="flex-shrink-0 h-[50px] w-full border-b border-custom-border-200 px-6 relative flex items-center gap-4 justify-between">
<PageTabNavigation workspaceSlug={workspaceSlug} projectId={projectId} pageType={pageType} /> <PageTabNavigation workspaceSlug={workspaceSlug} projectId={projectId} pageType={pageType} />
<div className="h-full flex items-center gap-2 self-end"> <div className="h-full flex items-center gap-2 self-end">
<PageSearchInput projectId={projectId} /> <PageSearchInput projectId={projectId} />
@ -64,7 +63,6 @@ export const PagesListHeaderRoot: React.FC<Props> = observer((props) => {
<PageFiltersSelection <PageFiltersSelection
filters={filters} filters={filters}
handleFiltersUpdate={updateFilters} handleFiltersUpdate={updateFilters}
labels={projectLabels}
memberIds={workspaceMemberIds ?? undefined} memberIds={workspaceMemberIds ?? undefined}
/> />
</FiltersDropdown> </FiltersDropdown>

View File

@ -0,0 +1,94 @@
import React, { FC } from "react";
import { observer } from "mobx-react";
import { Circle, Earth, Info, Lock, Minus } from "lucide-react";
// ui
import { Avatar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components
import { FavoriteStar } from "@/components/core";
import { PageQuickActions } from "@/components/pages/dropdowns";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useMember, usePage } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
pageId: string;
};
export const BlockItemAction: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, pageId } = props;
// store hooks
const { access, created_at, is_favorite, owned_by, addToFavorites, removeFromFavorites } = usePage(pageId);
const { getUserDetails } = useMember();
// derived values
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
// handlers
const handleFavorites = () => {
if (is_favorite)
removeFromFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page removed from favorites.",
})
);
else
addToFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page added to favorites.",
})
);
};
return (
<>
{/* page details */}
<div className="flex items-center gap-2 text-custom-text-400">
{/* <span className="text-xs">Labels</span>
<Circle className="h-1 w-1 fill-custom-text-300" /> */}
<div className="cursor-default">
<Tooltip tooltipHeading="Owned by" tooltipContent={ownerDetails?.display_name}>
<Avatar src={ownerDetails?.avatar} name={ownerDetails?.display_name} />
</Tooltip>
</div>
<Circle className="h-1 w-1 fill-custom-text-300" />
{/* <span className="text-xs cursor-default">10m read</span>
<Circle className="h-1 w-1 fill-custom-text-300" /> */}
<div className="cursor-default">
<Tooltip tooltipContent={access === 0 ? "Public" : "Private"}>
{access === 0 ? <Earth className="h-3 w-3" /> : <Lock className="h-3 w-3" />}
</Tooltip>
</div>
</div>
{/* vertical divider */}
<Minus className="h-5 w-5 text-custom-text-400 rotate-90 -mx-3" strokeWidth={1} />
{/* page info */}
<Tooltip tooltipContent={`Created on ${renderFormattedDate(created_at)}`}>
<span className="h-4 w-4 grid place-items-center cursor-default">
<Info className="h-4 w-4 text-custom-text-300" />
</span>
</Tooltip>
{/* favorite/unfavorite */}
<FavoriteStar
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleFavorites();
}}
selected={is_favorite}
/>
{/* quick actions dropdown */}
<PageQuickActions pageId={pageId} projectId={projectId} workspaceSlug={workspaceSlug} />
</>
);
});

View File

@ -1,15 +1,11 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link";
import { Circle, Info, Lock, Minus, UsersRound } from "lucide-react";
import { Avatar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components // components
import { FavoriteStar } from "@/components/core"; import { ListItem } from "@/components/core/list";
import { PageQuickActions } from "@/components/pages"; import { BlockItemAction } from "@/components/pages/list";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks // hooks
import { useMember, usePage } from "@/hooks/store"; import { usePage } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TPageListBlock = { type TPageListBlock = {
workspaceSlug: string; workspaceSlug: string;
@ -20,84 +16,15 @@ type TPageListBlock = {
export const PageListBlock: FC<TPageListBlock> = observer((props) => { export const PageListBlock: FC<TPageListBlock> = observer((props) => {
const { workspaceSlug, projectId, pageId } = props; const { workspaceSlug, projectId, pageId } = props;
// hooks // hooks
const { access, created_at, is_favorite, name, owned_by, addToFavorites, removeFromFavorites } = usePage(pageId); const { name } = usePage(pageId);
const { getUserDetails } = useMember(); const { isMobile } = usePlatformOS();
// derived values
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
const handleFavorites = () => {
if (is_favorite)
removeFromFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page removed from favorites.",
})
);
else
addToFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page added to favorites.",
})
);
};
return ( return (
<Link <ListItem
href={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`} title={name ?? ""}
className="flex items-center justify-between gap-5 py-7 px-6 hover:bg-custom-background-90" itemLink={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`}
> actionableItems={<BlockItemAction workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} />}
{/* page title */} isMobile={isMobile}
<Tooltip tooltipHeading="Title" tooltipContent={name}>
<h5 className="text-base font-medium truncate">{name}</h5>
</Tooltip>
{/* page properties */}
<div className="flex items-center gap-5 flex-shrink-0">
{/* page details */}
<div className="flex items-center gap-2 text-custom-text-400">
{/* <span className="text-xs">Labels</span>
<Circle className="h-1 w-1 fill-custom-text-300" /> */}
<div className="cursor-default">
<Tooltip tooltipHeading="Owned by" tooltipContent={ownerDetails?.display_name}>
<Avatar src={ownerDetails?.avatar} name={ownerDetails?.display_name} />
</Tooltip>
</div>
<Circle className="h-1 w-1 fill-custom-text-300" />
{/* <span className="text-xs cursor-default">10m read</span>
<Circle className="h-1 w-1 fill-custom-text-300" /> */}
<div className="cursor-default">
<Tooltip tooltipContent={access === 0 ? "Public" : "Private"}>
{access === 0 ? <UsersRound className="h-3 w-3" /> : <Lock className="h-3 w-3" />}
</Tooltip>
</div>
</div>
{/* vertical divider */}
<Minus className="h-5 w-5 text-custom-text-400 rotate-90 -mx-3" strokeWidth={1} />
{/* page info */}
<Tooltip tooltipContent={`Created on ${renderFormattedDate(created_at)}`}>
<span className="h-4 w-4 grid place-items-center cursor-default">
<Info className="h-4 w-4 text-custom-text-300" />
</span>
</Tooltip>
{/* favorite/unfavorite */}
<FavoriteStar
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleFavorites();
}}
selected={is_favorite}
/> />
{/* quick actions dropdown */}
<PageQuickActions pageId={pageId} projectId={projectId} workspaceSlug={workspaceSlug} />
</div>
</Link>
); );
}); });

View File

@ -1,4 +1,3 @@
export * from "./created-at"; export * from "./created-at";
export * from "./created-by"; export * from "./created-by";
export * from "./labels";
export * from "./root"; export * from "./root";

View File

@ -1,93 +0,0 @@
import React, { useMemo, useState } from "react";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
// types
import { IIssueLabel } from "@plane/types";
// ui
import { Loader } from "@plane/ui";
// components
import { FilterHeader, FilterOption } from "@/components/issues";
const LabelIcons = ({ color }: { color: string }) => (
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
);
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
labels: IIssueLabel[] | undefined;
searchQuery: string;
};
export const FilterLabels: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, labels, searchQuery } = props;
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (labels || []).filter((label) =>
label.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(label) => !(appliedFilters ?? []).includes(label.id),
(label) => label.name.toLowerCase(),
]);
}, [appliedFilters, labels, searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
return (
<>
<FilterHeader
title={`Labels${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((label) => (
<FilterOption
key={label?.id}
isChecked={appliedFilters?.includes(label?.id) ? true : false}
onClick={() => handleUpdate(label?.id)}
icon={<LabelIcons color={label.color} />}
title={label.name}
/>
))}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@ -1,20 +1,19 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import { IIssueLabel, TPageFilterProps, TPageFilters } from "@plane/types"; import { TPageFilterProps, TPageFilters } from "@plane/types";
// components // components
import { FilterOption } from "@/components/issues"; import { FilterOption } from "@/components/issues";
import { FilterCreatedBy, FilterCreatedDate, FilterLabels } from "@/components/pages"; import { FilterCreatedBy, FilterCreatedDate } from "@/components/pages";
type Props = { type Props = {
filters: TPageFilters; filters: TPageFilters;
handleFiltersUpdate: <T extends keyof TPageFilters>(filterKey: T, filterValue: TPageFilters[T]) => void; handleFiltersUpdate: <T extends keyof TPageFilters>(filterKey: T, filterValue: TPageFilters[T]) => void;
labels?: IIssueLabel[] | undefined;
memberIds?: string[] | undefined; memberIds?: string[] | undefined;
}; };
export const PageFiltersSelection: React.FC<Props> = observer((props) => { export const PageFiltersSelection: React.FC<Props> = observer((props) => {
const { filters, handleFiltersUpdate, labels, memberIds } = props; const { filters, handleFiltersUpdate, memberIds } = props;
// states // states
const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
@ -93,16 +92,6 @@ export const PageFiltersSelection: React.FC<Props> = observer((props) => {
memberIds={memberIds} memberIds={memberIds}
/> />
</div> </div>
{/* labels */}
<div className="py-2">
<FilterLabels
appliedFilters={filters.filters?.labels ?? null}
handleUpdate={(val) => handleFilters("labels", val)}
searchQuery={filtersSearchQuery}
labels={labels}
/>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,6 +1,7 @@
export * from "./applied-filters"; export * from "./applied-filters";
export * from "./filters"; export * from "./filters";
export * from "./block"; export * from "./block";
export * from "./block-item-action";
export * from "./order-by"; export * from "./order-by";
export * from "./root"; export * from "./root";
export * from "./search-input"; export * from "./search-input";

View File

@ -2,6 +2,8 @@ import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// types // types
import { TPageNavigationTabs } from "@plane/types"; import { TPageNavigationTabs } from "@plane/types";
// components
import { ListLayout } from "@/components/core/list";
// hooks // hooks
import { useProjectPages } from "@/hooks/store"; import { useProjectPages } from "@/hooks/store";
// components // components
@ -22,10 +24,10 @@ export const PagesListRoot: FC<TPagesListRoot> = observer((props) => {
if (!filteredPageIds) return <></>; if (!filteredPageIds) return <></>;
return ( return (
<div className="relative w-full h-full overflow-hidden overflow-y-auto divide-y-[0.5px] divide-custom-border-200"> <ListLayout>
{filteredPageIds.map((pageId) => ( {filteredPageIds.map((pageId) => (
<PageListBlock key={pageId} workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} /> <PageListBlock key={pageId} workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} />
))} ))}
</div> </ListLayout>
); );
}); });

View File

@ -3,3 +3,4 @@ export * from "./form";
export * from "./modal"; export * from "./modal";
export * from "./view-list-item"; export * from "./view-list-item";
export * from "./views-list"; export * from "./views-list";
export * from "./view-list-item-action";

View File

@ -0,0 +1,134 @@
import React, { FC, useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// icons
import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react";
// types
import { IProjectView } from "@plane/types";
// ui
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { FavoriteStar } from "@/components/core";
import { DeleteProjectViewModal, CreateUpdateProjectViewModal } from "@/components/views";
// constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useProjectView, useUser } from "@/hooks/store";
type Props = {
view: IProjectView;
};
export const ViewListItemAction: FC<Props> = observer((props) => {
const { view } = props;
// states
const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false);
const [deleteViewModal, setDeleteViewModal] = useState(false);
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const {
membership: { currentProjectRole },
} = useUser();
const { addViewToFavorites, removeViewFromFavorites } = useProjectView();
// derived values
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
// @ts-expect-error key types are not compatible
const totalFilters = calculateTotalFilters(view.filters ?? {});
// handlers
const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId) return;
addViewToFavorites(workspaceSlug.toString(), projectId.toString(), view.id);
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !projectId) return;
removeViewFromFavorites(workspaceSlug.toString(), projectId.toString(), view.id);
};
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/views/${view.id}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "View link copied to clipboard.",
});
});
};
return (
<>
{workspaceSlug && projectId && view && (
<CreateUpdateProjectViewModal
isOpen={createUpdateViewModal}
onClose={() => setCreateUpdateViewModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
data={view}
/>
)}
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<p className="hidden rounded bg-custom-background-80 px-2 py-1 text-xs text-custom-text-200 group-hover:block">
{totalFilters} {totalFilters === 1 ? "filter" : "filters"}
</p>
{isEditingAllowed && (
<FavoriteStar
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (view.is_favorite) handleRemoveFromFavorites();
else handleAddToFavorites();
}}
selected={view.is_favorite}
/>
)}
<CustomMenu ellipsis>
{isEditingAllowed && (
<>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCreateUpdateViewModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<PencilIcon size={14} strokeWidth={2} />
<span>Edit View</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDeleteViewModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<TrashIcon size={14} strokeWidth={2} />
<span>Delete View</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy view link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</>
);
});

View File

@ -1,151 +1,32 @@
import React, { useState } from "react"; import { FC } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react";
// types // types
import { IProjectView } from "@plane/types"; import { IProjectView } from "@plane/types";
// ui
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { FavoriteStar } from "@/components/core"; import { ListItem } from "@/components/core/list";
import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "@/components/views"; import { ViewListItemAction } from "@/components/views";
// constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks // hooks
import { useProjectView, useUser } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = { type Props = {
view: IProjectView; view: IProjectView;
}; };
export const ProjectViewListItem: React.FC<Props> = observer((props) => { export const ProjectViewListItem: FC<Props> = observer((props) => {
const { view } = props; const { view } = props;
// states
const [createUpdateViewModal, setCreateUpdateViewModal] = useState(false);
const [deleteViewModal, setDeleteViewModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
// store hooks // store hooks
const { const { isMobile } = usePlatformOS();
membership: { currentProjectRole },
} = useUser();
const { addViewToFavorites, removeViewFromFavorites } = useProjectView();
const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId) return;
addViewToFavorites(workspaceSlug.toString(), projectId.toString(), view.id);
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !projectId) return;
removeViewFromFavorites(workspaceSlug.toString(), projectId.toString(), view.id);
};
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/views/${view.id}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "View link copied to clipboard.",
});
});
};
// @ts-expect-error key types are not compatible
const totalFilters = calculateTotalFilters(view.filters ?? {});
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
return ( return (
<> <ListItem
{workspaceSlug && projectId && view && ( title={view.name}
<CreateUpdateProjectViewModal itemLink={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}
isOpen={createUpdateViewModal} actionableItems={<ViewListItemAction view={view} />}
onClose={() => setCreateUpdateViewModal(false)} isMobile={isMobile}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
data={view}
/> />
)}
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<div className="group border-b border-custom-border-200 hover:bg-custom-background-90">
<Link href={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}>
<div className="relative flex h-[52px] w-full items-center justify-between rounded p-4">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-4 overflow-hidden">
<div className="flex flex-col overflow-hidden ">
<p className="truncate break-all text-sm font-medium leading-4">{view.name}</p>
{view?.description && <p className="break-all text-xs text-custom-text-200">{view.description}</p>}
</div>
</div>
<div className="ml-2 flex flex-shrink-0">
<div className="flex items-center gap-4">
<p className="hidden rounded bg-custom-background-80 px-2 py-1 text-xs text-custom-text-200 group-hover:block">
{totalFilters} {totalFilters === 1 ? "filter" : "filters"}
</p>
{isEditingAllowed && (
<FavoriteStar
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (view.is_favorite) handleRemoveFromFavorites();
else handleAddToFavorites();
}}
selected={view.is_favorite}
/>
)}
<CustomMenu ellipsis>
{isEditingAllowed && (
<>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCreateUpdateViewModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<PencilIcon size={14} strokeWidth={2} />
<span>Edit View</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDeleteViewModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<TrashIcon size={14} strokeWidth={2} />
<span>Delete View</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy view link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</div>
</Link>
</div>
</>
); );
}); });

View File

@ -1,20 +1,28 @@
import { useState } from "react"; import { useRef, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Search } from "lucide-react"; // ui
// hooks import { Search, X } from "lucide-react";
// components // components
import { Input } from "@plane/ui"; import { ListLayout } from "@/components/core/list";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { ViewListLoader } from "@/components/ui"; import { ViewListLoader } from "@/components/ui";
import { ProjectViewListItem } from "@/components/views"; import { ProjectViewListItem } from "@/components/views";
// ui
// constants // constants
import { EmptyStateType } from "@/constants/empty-state"; import { EmptyStateType } from "@/constants/empty-state";
// helper
import { cn } from "@/helpers/common.helper";
// hooks
import { useApplication, useProjectView } from "@/hooks/store"; import { useApplication, useProjectView } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
export const ProjectViewsList = observer(() => { export const ProjectViewsList = observer(() => {
// states // states
const [query, setQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
// refs
const inputRef = useRef<HTMLInputElement>(null);
// store hooks // store hooks
const { const {
commandPalette: { toggleCreateViewModal }, commandPalette: { toggleCreateViewModal },
@ -23,33 +31,89 @@ export const ProjectViewsList = observer(() => {
if (loader || !projectViewIds) return <ViewListLoader />; if (loader || !projectViewIds) return <ViewListLoader />;
// derived values
const viewsList = projectViewIds.map((viewId) => getViewById(viewId)); const viewsList = projectViewIds.map((viewId) => getViewById(viewId));
const filteredViewsList = viewsList.filter((v) => v?.name.toLowerCase().includes(query.toLowerCase())); const filteredViewsList = viewsList.filter((v) => v?.name.toLowerCase().includes(searchQuery.toLowerCase()));
// handlers
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") setSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});
return ( return (
<> <>
{viewsList.length > 0 ? ( {viewsList.length > 0 ? (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
<div className="flex w-full flex-col flex-shrink-0 overflow-hidden"> <div className="h-[50px] flex-shrink-0 w-full border-b border-custom-border-200 px-6 relative flex items-center gap-4 justify-between">
<div className="flex w-full items-center gap-2.5 border-b border-custom-border-200 px-5 py-3"> <div className="flex items-center">
<Search className="text-custom-text-200" size={14} strokeWidth={2} /> <span className="block text-sm font-medium">View name</span>
<Input </div>
className="w-full bg-transparent !p-0 text-xs leading-5 text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" <div className="h-full flex items-center gap-2">
value={query} <div className="flex items-center">
onChange={(e) => setQuery(e.target.value)} {!isSearchOpen && (
<button
type="button"
className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search" placeholder="Search"
mode="true-transparent" value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/> />
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
setSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div> </div>
</div> </div>
<div className="flex flex-col h-full w-full vertical-scrollbar scrollbar-lg"> </div>
</div>
<ListLayout>
{filteredViewsList.length > 0 ? ( {filteredViewsList.length > 0 ? (
filteredViewsList.map((view) => <ProjectViewListItem key={view.id} view={view} />) filteredViewsList.map((view) => <ProjectViewListItem key={view.id} view={view} />)
) : ( ) : (
<p className="mt-10 text-center text-sm text-custom-text-300">No results found</p> <p className="mt-10 text-center text-sm text-custom-text-300">No results found</p>
)} )}
</div> </ListLayout>
</div> </div>
) : ( ) : (
<EmptyState type={EmptyStateType.PROJECT_VIEW} primaryButtonOnClick={() => toggleCreateViewModal(true)} /> <EmptyState type={EmptyStateType.PROJECT_VIEW} primaryButtonOnClick={() => toggleCreateViewModal(true)} />

View File

@ -7,7 +7,7 @@ import { TModuleFilters } from "@plane/types";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { ModulesListHeader } from "@/components/headers"; import { ModulesListHeader } from "@/components/headers";
import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules"; import { ModuleAppliedFiltersList, ModuleViewHeader, ModulesListView } from "@/components/modules";
// types // types
// hooks // hooks
import ModulesListMobileHeader from "@/components/modules/moduels-list-mobile-header"; import ModulesListMobileHeader from "@/components/modules/moduels-list-mobile-header";
@ -57,6 +57,12 @@ const ProjectModulesPage: NextPageWithLayout = observer(() => {
<> <>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<div className="h-full w-full flex flex-col"> <div className="h-full w-full flex flex-col">
<div className="h-[50px] flex-shrink-0 w-full border-b border-custom-border-200 px-6 relative flex items-center gap-4 justify-between">
<div className="flex items-center">
<span className="block text-sm font-medium">Module name</span>
</div>
<ModuleViewHeader />
</div>
{calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && ( {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && (
<div className="border-b border-custom-border-200 px-5 py-3"> <div className="border-b border-custom-border-200 px-5 py-3">
<ModuleAppliedFiltersList <ModuleAppliedFiltersList