[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 (
<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">
{CYCLE_TABS_LIST.map((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 { observer } from "mobx-react";
import Link from "next/link";
import { useRouter } from "next/router";
// icons
import { Check, Info, User2 } from "lucide-react";
import { Check, Info } from "lucide-react";
// types
import type { TCycleGroups } from "@plane/types";
// ui
import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui";
import { CircularProgressIndicator } 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";
import { ListItem } from "@/components/core/list";
import { CycleListItemAction } from "@/components/cycles/list";
// hooks
import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store";
import { useCycle } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TCyclesListItem = {
@ -33,75 +26,36 @@ type TCyclesListItem = {
};
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const { cycleId, workspaceSlug, projectId, isArchived } = props;
const { cycleId, workspaceSlug, projectId, isArchived = false } = props;
// router
const router = useRouter();
// hooks
const { isMobile } = usePlatformOS();
// store hooks
const { captureEvent } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { getUserDetails } = useMember();
const { getCycleById } = useCycle();
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
// derived values
const cycleDetails = getCycleById(cycleId);
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then(
() => {
captureEvent(CYCLE_FAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
}
);
if (!cycleDetails) return null;
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.",
},
});
};
// 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 handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const cycleTotalIssues =
cycleDetails.backlog_issues +
cycleDetails.unstarted_issues +
cycleDetails.started_issues +
cycleDetails.completed_issues +
cycleDetails.cancelled_issues;
const removeFromFavoritePromise = removeCycleFromFavorites(
workspaceSlug?.toString(),
projectId.toString(),
cycleId
).then(() => {
captureEvent(CYCLE_UNFAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
});
const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100;
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.",
},
});
};
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
// handlers
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
const { query } = router;
e.preventDefault();
@ -121,139 +75,48 @@ 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 (
<div className="relative">
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
onClick={(e) => {
if (isArchived) {
openCycleOverview(e);
}
}}
>
<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">
<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 ? (
progress === 100 ? (
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
) : (
<span className="text-sm text-custom-primary-100">{`!`}</span>
)
) : progress === 100 ? (
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
) : (
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
)}
</CircularProgressIndicator>
</div>
<div className="relative flex items-center gap-2.5 overflow-hidden">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
<Tooltip tooltipContent={cycleDetails.name} position="top" isMobile={isMobile}>
<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" />
</button>
</div>
<div className="text-xs text-custom-text-300 flex-shrink-0">
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
</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>
<ListItem
title={cycleDetails?.name ?? ""}
itemLink={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
onItemClick={(e) => {
if (isArchived) {
openCycleOverview(e);
}
}}
prependTitleElement={
<CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
{isCompleted ? (
progress === 100 ? (
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
) : (
<span className="text-sm text-custom-primary-100">{`!`}</span>
)
) : progress === 100 ? (
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
) : (
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
)}
<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}
isArchived={isArchived}
/>
</div>
</div>
</div>
</div>
</CircularProgressIndicator>
}
appendTitleElement={
<button
onClick={openCycleOverview}
className={`z-[5] flex-shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
>
<Info className="h-4 w-4 text-custom-text-400" />
</button>
}
actionableItems={
<CycleListItemAction
workspaceSlug={workspaceSlug}
projectId={projectId}
cycleId={cycleId}
cycleDetails={cycleDetails}
isArchived={isArchived}
/>
}
isMobile={isMobile}
/>
);
});

View File

@ -1,3 +1,4 @@
export * from "./cycles-list-item";
export * from "./cycles-list-map";
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 { Disclosure } from "@headlessui/react";
// components
import { ListLayout } from "@/components/core/list";
import { CyclePeekOverview, CyclesListMap } from "@/components/cycles";
// helpers
import { cn } from "@/helpers/common.helper";
@ -21,7 +22,7 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
return (
<div className="h-full overflow-y-auto">
<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
cycleIds={cycleIds}
projectId={projectId}
@ -52,7 +53,7 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
</Disclosure.Panel>
</Disclosure>
)}
</div>
</ListLayout>
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} isArchived={isArchived} />
</div>
</div>

View File

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

View File

@ -1,15 +1,16 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
// icons
import { Plus } from "lucide-react";
// hooks
// ui
import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui";
// helpers
// 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 CyclesHeader: FC = observer(() => {
@ -30,52 +31,48 @@ export const CyclesHeader: FC = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
return (
<div className="relative z-10 items-center justify-between gap-x-2 gap-y-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>
<Breadcrumbs onBack={router.back}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={currentProjectDetails?.name ?? "Project"}
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />
}
/>
</Breadcrumbs>
</div>
<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 w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div>
<Breadcrumbs onBack={router.back}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={currentProjectDetails?.name ?? "Project"}
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />}
/>
</Breadcrumbs>
</div>
{canUserCreateCycle && (
<div className="flex items-center gap-3">
<Button
variant="primary"
size="sm"
prependIcon={<Plus />}
onClick={() => {
setTrackElement("Cycles page");
toggleCreateCycleModal(true);
}}
>
<div className="hidden sm:block">Add</div> Cycle
</Button>
</div>
)}
</div>
{canUserCreateCycle && (
<div className="flex items-center gap-3">
<Button
variant="primary"
size="sm"
prependIcon={<Plus />}
onClick={() => {
setTrackElement("Cycles page");
toggleCreateCycleModal(true);
}}
>
<div className="hidden sm:block">Add</div> Cycle
</Button>
</div>
)}
</div>
);
});

View File

@ -1,33 +1,21 @@
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { ListFilter, Plus, Search, X } from "lucide-react";
import { TModuleFilters } from "@plane/types";
// 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";
// icons
import { Plus } from "lucide-react";
// ui
// helpers
// types
import { Breadcrumbs, Button, DiceIcon } from "@plane/ui";
// 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(() => {
// refs
const inputRef = useRef<HTMLInputElement>(null);
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug } = router.query;
// store hooks
const { commandPalette: commandPaletteStore } = useApplication();
const { setTrackElement } = useEventTracker();
@ -35,54 +23,6 @@ export const ModulesListHeader: React.FC = observer(() => {
membership: { currentProjectRole },
} = useUser();
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
const canUserCreateModule =
@ -117,97 +57,6 @@ export const ModulesListHeader: React.FC = observer(() => {
</div>
</div>
<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 && (
<Button
variant="primary"

View File

@ -116,124 +116,121 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)}
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="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={() => router.back()}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects`}
label={currentProjectDetails?.name ?? "Project"}
icon={
currentProjectDetails ? (
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
</span>
)
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
<Briefcase className="h-4 w-4" />
<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 items-center gap-2.5">
<Breadcrumbs onBack={() => router.back()}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects`}
label={currentProjectDetails?.name ?? "Project"}
icon={
currentProjectDetails ? (
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />
}
/>
</Breadcrumbs>
{issueCount && issueCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in this project`}
position="bottom"
>
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{issueCount}
</span>
</Tooltip>
) : null}
</div>
{currentProjectDetails?.is_deployed && deployUrl && (
<a
href={`${deployUrl}/${workspaceSlug}/${currentProjectDetails?.id}`}
className="group flex items-center gap-1.5 rounded bg-custom-primary-100/10 px-2.5 py-1 text-xs font-medium text-custom-primary-100"
target="_blank"
rel="noopener noreferrer"
>
<Circle className="h-1.5 w-1.5 fill-custom-primary-100" strokeWidth={2} />
Public
<ExternalLink className="hidden h-3 w-3 group-hover:block" strokeWidth={2} />
</a>
)}
</div>
<div className="items-center gap-2 hidden md:flex">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<FiltersDropdown title="Filters" placement="bottom-end">
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
<Briefcase className="h-4 w-4" />
</span>
)
}
/>
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
<FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
</div>
{canUserCreateIssue && (
<>
<Button
className="hidden md:block"
onClick={() => setAnalyticsModal(true)}
variant="neutral-primary"
size="sm"
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />}
/>
</Breadcrumbs>
{issueCount && issueCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in this project`}
position="bottom"
>
Analytics
</Button>
<Button
onClick={() => {
setTrackElement("Project issues page");
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
}}
size="sm"
prependIcon={<Plus />}
>
<div className="hidden sm:block">Add</div> Issue
</Button>
</>
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{issueCount}
</span>
</Tooltip>
) : null}
</div>
{currentProjectDetails?.is_deployed && deployUrl && (
<a
href={`${deployUrl}/${workspaceSlug}/${currentProjectDetails?.id}`}
className="group flex items-center gap-1.5 rounded bg-custom-primary-100/10 px-2.5 py-1 text-xs font-medium text-custom-primary-100"
target="_blank"
rel="noopener noreferrer"
>
<Circle className="h-1.5 w-1.5 fill-custom-primary-100" strokeWidth={2} />
Public
<ExternalLink className="hidden h-3 w-3 group-hover:block" strokeWidth={2} />
</a>
)}
</div>
<div className="items-center gap-2 hidden md:flex">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<FiltersDropdown title="Filters" placement="bottom-end">
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
<FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
</div>
{canUserCreateIssue && (
<>
<Button
className="hidden md:block"
onClick={() => setAnalyticsModal(true)}
variant="neutral-primary"
size="sm"
>
Analytics
</Button>
<Button
onClick={() => {
setTrackElement("Project issues page");
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
}}
size="sm"
prependIcon={<Plus />}
>
<div className="hidden sm:block">Add</div> Issue
</Button>
</>
)}
</div>
</>
);

View File

@ -136,9 +136,12 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
/>
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} />
<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>
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
</div>
<div className="relative flex items-center gap-3">
<Button variant="neutral-primary" size="sm" type="button" onClick={handleModalClose}>

View File

@ -50,7 +50,7 @@ export const InboxIssueListItem: FC<InboxIssueListItemProps> = observer((props)
<div
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`,
{ "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">

View File

@ -65,14 +65,14 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
});
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="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) => (
<div
key={option?.key}
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`
)}
onClick={() => {

View File

@ -47,7 +47,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
return (
<div
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),
"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-peek-overview";
export * from "./quick-actions";
export * from "./module-list-item-action";
export * from "./module-view-header";
// 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 { observer } from "mobx-react-lite";
import Link from "next/link";
import { useRouter } from "next/router";
import { Check, Info, User2 } from "lucide-react";
// icons
import { Check, Info } from "lucide-react";
// ui
import { Avatar, AvatarGroup, CircularProgressIndicator, Tooltip, setPromiseToast } from "@plane/ui";
import { CircularProgressIndicator } 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";
import { ListItem } from "@/components/core/list";
import { ModuleListItemAction } from "@/components/modules";
// hooks
import { useModule, useUser, useEventTracker, useMember } from "@/hooks/store";
import { useModule } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
@ -27,76 +21,24 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
const { moduleId, isArchived = false } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug } = router.query;
// store hooks
const {
membership: { currentProjectRole },
} = useUser();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember();
const { captureEvent } = useEventTracker();
const { getModuleById } = useModule();
const { isMobile } = usePlatformOS();
// derived values
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(
() => {
captureEvent(MODULE_FAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
});
}
);
if (!moduleDetails) return null;
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 completionPercentage =
((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100;
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
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.",
},
});
};
const completedModuleCheck = moduleDetails.status === "completed";
// handlers
const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.stopPropagation();
e.preventDefault();
@ -116,126 +58,42 @@ 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 (
<div className="relative">
<Link
href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}
onClick={(e) => {
if (isArchived) {
openModuleOverview(e);
}
}}
>
<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">
<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 ? (
progress === 100 ? (
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
) : (
<span className="text-sm text-custom-primary-100">{`!`}</span>
)
) : progress === 100 ? (
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
) : (
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
)}
</CircularProgressIndicator>
</span>
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
</Tooltip>
</div>
<button onClick={openModuleOverview} className="z-[5] hidden flex-shrink-0 group-hover:flex">
<Info className="h-4 w-4 text-custom-text-400" />
</button>
</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="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>
<ListItem
title={moduleDetails?.name ?? ""}
itemLink={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}
onItemClick={(e) => {
if (isArchived) {
openModuleOverview(e);
}
}}
prependTitleElement={
<CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
{completedModuleCheck ? (
progress === 100 ? (
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
) : (
<span className="text-sm text-custom-primary-100">{`!`}</span>
)
) : progress === 100 ? (
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
) : (
<span className="text-xs text-custom-text-300">{`${progress}%`}</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>
</CircularProgressIndicator>
}
appendTitleElement={
<button
onClick={openModuleOverview}
className={`z-[5] flex-shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
>
<Info className="h-4 w-4 text-custom-text-400" />
</button>
}
actionableItems={
<ModuleListItemAction moduleId={moduleId} moduleDetails={moduleDetails} isArchived={isArchived} />
}
isMobile={isMobile}
/>
);
});

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 { useRouter } from "next/router";
// components
import { ListLayout } from "@/components/core/list";
import { EmptyState } from "@/components/empty-state";
import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "@/components/modules";
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "@/components/ui";
@ -69,11 +70,11 @@ export const ModulesListView: React.FC = observer(() => {
{displayFilters?.layout === "list" && (
<div className="h-full overflow-y-auto">
<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) => (
<ModuleListItem key={moduleId} moduleId={moduleId} />
))}
</div>
</ListLayout>
<ModulePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}

View File

@ -14,7 +14,7 @@ import {
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
import { useLabel, useMember, useProjectPages } from "@/hooks/store";
import { useMember, useProjectPages } from "@/hooks/store";
type Props = {
pageType: TPageNavigationTabs;
@ -29,7 +29,6 @@ export const PagesListHeaderRoot: React.FC<Props> = observer((props) => {
const {
workspace: { workspaceMemberIds },
} = useMember();
const { projectLabels } = useLabel();
const handleRemoveFilter = useCallback(
(key: keyof TPageFilterProps, value: string | null) => {
@ -48,7 +47,7 @@ export const PagesListHeaderRoot: React.FC<Props> = observer((props) => {
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} />
<div className="h-full flex items-center gap-2 self-end">
<PageSearchInput projectId={projectId} />
@ -64,7 +63,6 @@ export const PagesListHeaderRoot: React.FC<Props> = observer((props) => {
<PageFiltersSelection
filters={filters}
handleFiltersUpdate={updateFilters}
labels={projectLabels}
memberIds={workspaceMemberIds ?? undefined}
/>
</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 { 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
import { FavoriteStar } from "@/components/core";
import { PageQuickActions } from "@/components/pages";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { ListItem } from "@/components/core/list";
import { BlockItemAction } from "@/components/pages/list";
// hooks
import { useMember, usePage } from "@/hooks/store";
import { usePage } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TPageListBlock = {
workspaceSlug: string;
@ -20,84 +16,15 @@ type TPageListBlock = {
export const PageListBlock: FC<TPageListBlock> = observer((props) => {
const { workspaceSlug, projectId, pageId } = props;
// hooks
const { access, created_at, is_favorite, name, owned_by, addToFavorites, removeFromFavorites } = usePage(pageId);
const { getUserDetails } = useMember();
// 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.",
})
);
};
const { name } = usePage(pageId);
const { isMobile } = usePlatformOS();
return (
<Link
href={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`}
className="flex items-center justify-between gap-5 py-7 px-6 hover:bg-custom-background-90"
>
{/* page title */}
<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>
<ListItem
title={name ?? ""}
itemLink={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`}
actionableItems={<BlockItemAction workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} />}
isMobile={isMobile}
/>
);
});

View File

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

View File

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

View File

@ -2,6 +2,8 @@ import { FC } from "react";
import { observer } from "mobx-react";
// types
import { TPageNavigationTabs } from "@plane/types";
// components
import { ListLayout } from "@/components/core/list";
// hooks
import { useProjectPages } from "@/hooks/store";
// components
@ -22,10 +24,10 @@ export const PagesListRoot: FC<TPagesListRoot> = observer((props) => {
if (!filteredPageIds) 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) => (
<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 "./view-list-item";
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 Link from "next/link";
import { useRouter } from "next/router";
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 { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "@/components/views";
// constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
import { ListItem } from "@/components/core/list";
import { ViewListItemAction } from "@/components/views";
// hooks
import { useProjectView, useUser } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
view: IProjectView;
};
export const ProjectViewListItem: React.FC<Props> = observer((props) => {
export const ProjectViewListItem: 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 hooks
const {
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;
const { isMobile } = usePlatformOS();
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)} />
<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>
</>
<ListItem
title={view.name}
itemLink={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}
actionableItems={<ViewListItemAction view={view} />}
isMobile={isMobile}
/>
);
});

View File

@ -1,20 +1,28 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Search } from "lucide-react";
// hooks
// ui
import { Search, X } from "lucide-react";
// components
import { Input } from "@plane/ui";
import { ListLayout } from "@/components/core/list";
import { EmptyState } from "@/components/empty-state";
import { ViewListLoader } from "@/components/ui";
import { ProjectViewListItem } from "@/components/views";
// ui
// constants
import { EmptyStateType } from "@/constants/empty-state";
// helper
import { cn } from "@/helpers/common.helper";
// hooks
import { useApplication, useProjectView } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
export const ProjectViewsList = observer(() => {
// states
const [query, setQuery] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
// refs
const inputRef = useRef<HTMLInputElement>(null);
// store hooks
const {
commandPalette: { toggleCreateViewModal },
@ -23,33 +31,89 @@ export const ProjectViewsList = observer(() => {
if (loader || !projectViewIds) return <ViewListLoader />;
// derived values
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 (
<>
{viewsList.length > 0 ? (
<div className="flex h-full w-full flex-col">
<div className="flex w-full flex-col flex-shrink-0 overflow-hidden">
<div className="flex w-full items-center gap-2.5 border-b border-custom-border-200 px-5 py-3">
<Search className="text-custom-text-200" size={14} strokeWidth={2} />
<Input
className="w-full bg-transparent !p-0 text-xs leading-5 text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
mode="true-transparent"
/>
<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">View name</span>
</div>
<div className="h-full 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) => 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">
<ListLayout>
{filteredViewsList.length > 0 ? (
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>
)}
</div>
</ListLayout>
</div>
) : (
<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 { EmptyState } from "@/components/empty-state";
import { ModulesListHeader } from "@/components/headers";
import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules";
import { ModuleAppliedFiltersList, ModuleViewHeader, ModulesListView } from "@/components/modules";
// types
// hooks
import ModulesListMobileHeader from "@/components/modules/moduels-list-mobile-header";
@ -57,6 +57,12 @@ const ProjectModulesPage: NextPageWithLayout = observer(() => {
<>
<PageHead title={pageTitle} />
<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 && (
<div className="border-b border-custom-border-200 px-5 py-3">
<ModuleAppliedFiltersList