forked from github/plane
[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:
parent
1b79517f07
commit
87a606446f
2
web/components/core/list/index.ts
Normal file
2
web/components/core/list/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./list-item";
|
||||||
|
export * from "./list-root";
|
53
web/components/core/list/list-item.tsx
Normal file
53
web/components/core/list/list-item.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
10
web/components/core/list/list-root.tsx
Normal file
10
web/components/core/list/list-root.tsx
Normal 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>;
|
||||||
|
};
|
@ -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
|
||||||
|
160
web/components/cycles/list/cycle-list-item-action.tsx
Normal file
160
web/components/cycles/list/cycle-list-item-action.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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"
|
||||||
|
@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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}>
|
||||||
|
@ -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">
|
||||||
|
@ -67,12 +67,12 @@ 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={() => {
|
||||||
|
@ -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),
|
||||||
|
@ -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";
|
||||||
|
168
web/components/modules/module-list-item-action.tsx
Normal file
168
web/components/modules/module-list-item-action.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
178
web/components/modules/module-view-header.tsx
Normal file
178
web/components/modules/module-view-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
@ -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() ?? ""}
|
||||||
|
@ -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>
|
||||||
|
94
web/components/pages/list/block-item-action.tsx
Normal file
94
web/components/pages/list/block-item-action.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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";
|
||||||
|
134
web/components/views/view-list-item-action.tsx
Normal file
134
web/components/views/view-list-item-action.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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)} />
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user