mirror of
https://github.com/makeplane/plane.git
synced 2026-01-14 02:00:02 -06:00
[WEB-5436] feat: work item preview (#8121)
This commit is contained in:
committed by
GitHub
parent
f34ca18a34
commit
2e6225a883
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
51
apps/web/core/components/issues/preview-card/date.tsx
Normal file
51
apps/web/core/components/issues/preview-card/date.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
apps/web/core/components/issues/preview-card/index.ts
Normal file
1
apps/web/core/components/issues/preview-card/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
63
apps/web/core/components/issues/preview-card/root.tsx
Normal file
63
apps/web/core/components/issues/preview-card/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user