[WEB-5436] feat: work item preview (#8121)

This commit is contained in:
Aaryan Khandelwal
2025-11-18 13:54:26 +05:30
committed by GitHub
parent f34ca18a34
commit 2e6225a883
6 changed files with 247 additions and 107 deletions

View File

@@ -36,7 +36,7 @@ type TIssueTypeIdentifier = {
size?: "xs" | "sm" | "md" | "lg";
};
export const IssueTypeIdentifier: FC<TIssueTypeIdentifier> = observer((props) => <></>);
export const IssueTypeIdentifier: FC<TIssueTypeIdentifier> = observer((_props) => <></>);
type TIdentifierTextProps = {
identifier: string;
@@ -94,7 +94,7 @@ export const IssueIdentifier: React.FC<TIssueIdentifierProps> = observer((props)
if (!shouldRenderIssueID) return null;
return (
<div className="flex items-center space-x-2">
<div className="shrink-0 flex items-center space-x-2">
<IdentifierText
identifier={`${projectIdentifier}-${issueSequenceId}`}
enableClickToCopyIdentifier={enableClickToCopyIdentifier}

View File

@@ -5,15 +5,12 @@ import { useState, useRef, forwardRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { MoreHorizontal } from "lucide-react";
// plane helpers
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
// types
import { Tooltip } from "@plane/propel/tooltip";
import { Popover } from "@plane/propel/popover";
import type { TIssue } from "@plane/types";
// ui
import { ControlLink } from "@plane/ui";
import { cn, generateWorkItemLink } from "@plane/utils";
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
@@ -25,6 +22,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
// local components
import { WorkItemPreviewCard } from "../../preview-card";
import type { TRenderQuickActions } from "../list/list-view-types";
import type { CalendarStoreType } from "./base-calendar-root";
@@ -89,69 +87,87 @@ export const CalendarIssueBlock = observer(
});
return (
<ControlLink
id={`issue-${issue.id}`}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issue)}
className="block w-full text-sm text-custom-text-100 rounded border-b md:border-[1px] border-custom-border-200 hover:border-custom-border-400"
disabled={!!issue?.tempId || isMobile}
ref={ref}
>
<>
{issue?.tempId !== undefined && (
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
)}
<div
ref={blockRef}
className={cn(
"group/calendar-block flex h-10 md:h-8 w-full items-center justify-between gap-1.5 rounded md:px-1 px-4 py-1.5 ",
{
"bg-custom-background-90 shadow-custom-shadow-rg border-custom-primary-100": isDragging,
"bg-custom-background-100 hover:bg-custom-background-90": !isDragging,
"border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id),
}
)}
>
<div className="flex h-full items-center gap-1.5 truncate">
<span
className="h-full w-0.5 flex-shrink-0 rounded"
style={{
backgroundColor: stateColor,
}}
/>
{issue.project_id && (
<IssueIdentifier
issueId={issue.id}
projectId={issue.project_id}
textContainerClassName="text-sm md:text-xs text-custom-text-300"
displayProperties={issuesFilter?.issueFilters?.displayProperties}
/>
)}
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
<div className="truncate text-sm font-medium md:font-normal md:text-xs">{issue.name}</div>
</Tooltip>
</div>
<div
className={cn("flex-shrink-0 size-5", {
"hidden group-hover/calendar-block:block": !isMobile,
block: isMenuActive,
})}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
<Popover delay={100} openOnHover>
<Popover.Button
className="w-full"
render={
<ControlLink
id={`issue-${issue.id}`}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issue)}
className="block w-full text-sm text-custom-text-100 rounded border-b md:border-[1px] border-custom-border-200 hover:border-custom-border-400"
disabled={!!issue?.tempId || isMobile}
ref={ref}
>
{quickActions({
issue,
parentRef: blockRef,
customActionButton,
placement,
})}
</div>
</div>
</>
</ControlLink>
<>
{issue?.tempId !== undefined && (
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
)}
<div
ref={blockRef}
className={cn(
"group/calendar-block flex h-10 md:h-8 w-full items-center justify-between gap-1.5 rounded md:px-1 px-4 py-1.5 ",
{
"bg-custom-background-90 shadow-custom-shadow-rg border-custom-primary-100": isDragging,
"bg-custom-background-100 hover:bg-custom-background-90": !isDragging,
"border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id),
}
)}
>
<div className="flex h-full items-center gap-1.5 truncate">
<span
className="h-full w-0.5 flex-shrink-0 rounded"
style={{
backgroundColor: stateColor,
}}
/>
{issue.project_id && (
<IssueIdentifier
issueId={issue.id}
projectId={issue.project_id}
textContainerClassName="text-sm md:text-xs text-custom-text-300"
displayProperties={issuesFilter?.issueFilters?.displayProperties}
/>
)}
<div className="truncate text-sm font-medium md:font-normal md:text-xs">{issue.name}</div>
</div>
<div
className={cn("flex-shrink-0 size-5", {
"hidden group-hover/calendar-block:block": !isMobile,
block: isMenuActive,
})}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{quickActions({
issue,
parentRef: blockRef,
customActionButton,
placement,
})}
</div>
</div>
</>
</ControlLink>
}
/>
<Popover.Panel side="bottom" align="start">
<>
{issue.project_id && (
<WorkItemPreviewCard
projectId={issue.project_id}
stateDetails={{
id: issue.state_id ?? undefined,
}}
workItem={issue}
/>
)}
</>
</Popover.Panel>
</Popover>
);
})
);

View File

@@ -2,13 +2,13 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
// plane imports
import { Popover } from "@plane/propel/popover";
import { Tooltip } from "@plane/propel/tooltip";
import { ControlLink } from "@plane/ui";
import { findTotalDaysInRange, generateWorkItemLink } from "@plane/utils";
// components
import { SIDEBAR_WIDTH } from "@/components/gantt-chart/constants";
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
@@ -17,10 +17,11 @@ import { useProjectState } from "@/hooks/store/use-project-state";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
// plane web imports
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
//
import { IssueStats } from "@/plane-web/components/issues/issue-layouts/issue-stats";
// local imports
import { WorkItemPreviewCard } from "../../preview-card";
import { getBlockViewDetails } from "../utils";
import type { GanttStoreType } from "./base-gantt-root";
@@ -48,46 +49,54 @@ export const IssueGanttBlock: React.FC<Props> = observer((props) => {
const stateDetails =
issueDetails && getProjectStates(issueDetails?.project_id)?.find((state) => state?.id == issueDetails?.state_id);
const { message, blockStyle } = getBlockViewDetails(issueDetails, stateDetails?.color ?? "");
const { blockStyle } = getBlockViewDetails(issueDetails, stateDetails?.color ?? "");
const handleIssuePeekOverview = () => handleRedirection(workspaceSlug, issueDetails, isMobile);
const duration = findTotalDaysInRange(issueDetails?.start_date, issueDetails?.target_date) || 0;
return (
<Tooltip
isMobile={isMobile}
tooltipContent={
<div className="space-y-1">
<h5>{issueDetails?.name}</h5>
<div>{message}</div>
</div>
}
position="top-start"
disabled={!message}
>
<div
id={`issue-${issueId}`}
className="relative flex h-full w-full cursor-pointer items-center rounded space-between"
style={blockStyle}
onClick={handleIssuePeekOverview}
>
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50 " />
<div
className="sticky w-auto overflow-hidden truncate px-2.5 py-1 text-sm text-custom-text-100 flex-1"
style={{ left: `${SIDEBAR_WIDTH}px` }}
>
{issueDetails?.name}
</div>
{isEpic && (
<IssueStats
issueId={issueId}
className="sticky mx-2 font-medium text-custom-text-100 overflow-hidden truncate w-auto justify-end flex-shrink-0"
showProgressText={duration >= 2}
/>
)}
</div>
</Tooltip>
<Popover delay={100} openOnHover>
<Popover.Button
className="w-full"
render={
<div
id={`issue-${issueId}`}
className="relative flex h-full w-full cursor-pointer items-center rounded space-between"
style={blockStyle}
onClick={handleIssuePeekOverview}
>
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50 " />
<div
className="sticky w-auto overflow-hidden truncate px-2.5 py-1 text-sm text-custom-text-100 flex-1"
style={{ left: `${SIDEBAR_WIDTH}px` }}
>
{issueDetails?.name}
</div>
{isEpic && (
<IssueStats
issueId={issueId}
className="sticky mx-2 font-medium text-custom-text-100 overflow-hidden truncate w-auto justify-end flex-shrink-0"
showProgressText={duration >= 2}
/>
)}
</div>
}
/>
<Popover.Panel side="bottom" align="start">
<>
{issueDetails && issueDetails?.project_id && (
<WorkItemPreviewCard
projectId={issueDetails.project_id}
stateDetails={{
id: issueDetails.state_id ?? undefined,
}}
workItem={issueDetails}
/>
)}
</>
</Popover.Panel>
</Popover>
);
});

View File

@@ -0,0 +1,51 @@
import { CalendarDays } from "lucide-react";
// plane imports
import { DueDatePropertyIcon, StartDatePropertyIcon } from "@plane/propel/icons";
import type { TStateGroups } from "@plane/types";
import { cn, renderFormattedDate, shouldHighlightIssueDueDate } from "@plane/utils";
type Props = {
startDate: string | null;
stateGroup: TStateGroups;
targetDate: string | null;
};
export const WorkItemPreviewCardDate: React.FC<Props> = (props) => {
const { startDate, stateGroup, targetDate } = props;
// derived values
const isDateRangeEnabled = Boolean(startDate && targetDate);
const shouldHighlightDate = shouldHighlightIssueDueDate(targetDate, stateGroup);
if (!startDate && !targetDate) return null;
return (
<div className="text-xs h-full rounded px-1 text-custom-text-200">
{isDateRangeEnabled ? (
<div
className={cn("h-full flex items-center gap-1", {
"text-red-500": shouldHighlightDate,
})}
>
<CalendarDays className="shrink-0 size-3" />
<span>
{renderFormattedDate(startDate)} - {renderFormattedDate(targetDate)}
</span>
</div>
) : startDate ? (
<div className="h-full flex items-center gap-1">
<StartDatePropertyIcon className="shrink-0 size-3" />
<span>{renderFormattedDate(startDate)}</span>
</div>
) : (
<div
className={cn("h-full flex items-center gap-1", {
"text-red-500": shouldHighlightDate,
})}
>
<DueDatePropertyIcon className="shrink-0 size-3" />
<span>{renderFormattedDate(targetDate)}</span>
</div>
)}
</div>
);
};

View File

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

View File

@@ -0,0 +1,63 @@
import { observer } from "mobx-react";
// plane imports
import { PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
import type { TIssue, TStateGroups } from "@plane/types";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
// plane web imports
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
// local imports
import { WorkItemPreviewCardDate } from "./date";
type Props = {
projectId: string;
stateDetails: {
group?: TStateGroups;
id?: string;
name?: string;
};
workItem: Pick<TIssue, "id" | "name" | "sequence_id" | "priority" | "start_date" | "target_date" | "type_id">;
};
export const WorkItemPreviewCard: React.FC<Props> = observer((props) => {
const { projectId, stateDetails, workItem } = props;
// store hooks
const { getProjectIdentifierById } = useProject();
const { getStateById } = useProjectState();
// derived values
const projectIdentifier = getProjectIdentifierById(projectId);
const fallbackStateDetails = stateDetails.id ? getStateById(stateDetails.id) : undefined;
const stateGroup = stateDetails?.group ?? fallbackStateDetails?.group ?? "backlog";
const stateName = stateDetails?.name ?? fallbackStateDetails?.name;
return (
<div className="p-3 space-y-2 w-72 rounded-lg shadow-custom-shadow-rg bg-custom-background-100 border-[0.5px] border-custom-border-300">
<div className="flex items-center justify-between gap-3 text-custom-text-200">
<IssueIdentifier
textContainerClassName="shrink-0 text-xs text-custom-text-200"
projectId={projectId}
projectIdentifier={projectIdentifier}
issueSequenceId={workItem.sequence_id}
issueTypeId={workItem.type_id}
size="xs"
/>
<div className="shrink-0 flex items-center gap-1">
<StateGroupIcon stateGroup={stateGroup} className="shrink-0 size-3" />
<p className="text-xs font-medium">{stateName}</p>
</div>
</div>
<div>
<h6 className="text-sm">{workItem.name}</h6>
</div>
<div className="flex items-center gap-1 h-5">
<PriorityIcon priority={workItem.priority} withContainer />
<WorkItemPreviewCardDate
startDate={workItem.start_date}
stateGroup={stateGroup}
targetDate={workItem.target_date}
/>
</div>
</div>
);
});