[WEB-2442] feat: Revamp Timeline Layout (#5915)

* chore: added issue relations in issue listing

* chore: added pagination for issue detail endpoint

* chore: bulk date update endpoint

* chore: appended the target date

* chore: issue relation new types defined

* fix: order by and issue filters

* fix: passed order by in pagination

* chore: changed the key for issue dates

* Revamp Timeline Layout

* fix block dragging

* minor ui fixes

* improve auto scroll UX

* remove unused import

* fix timeline layout heights

* modify base timeline store

* Segregate issue relation types

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
rahulramesha 2024-10-28 18:03:31 +05:30 committed by GitHub
parent f986bd83fd
commit a88a39fb1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
112 changed files with 2918 additions and 2641 deletions

View file

@ -24,17 +24,17 @@ export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
const {
issue: { getIssueById },
subIssues: { subIssuesByIssueId },
relation: { getRelationsByIssueId },
relation: { getRelationCountByIssueId },
} = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
const subIssues = subIssuesByIssueId(issueId);
const issueRelations = getRelationsByIssueId(issueId);
const issueRelationsCount = getRelationCountByIssueId(issueId);
// render conditions
const shouldRenderSubIssues = !!subIssues && subIssues.length > 0;
const shouldRenderRelations = Object.values(issueRelations ?? {}).some((relation) => relation.length > 0);
const shouldRenderRelations = issueRelationsCount > 0;
const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0;
const shouldRenderAttachments = !!issue?.attachment_count && issue?.attachment_count > 0;

View file

@ -1,15 +1,17 @@
"use client";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { CircleDot, CopyPlus, XCircle } from "lucide-react";
import { TIssue, TIssueRelationIdMap } from "@plane/types";
import { Collapsible, RelatedIcon } from "@plane/ui";
import { Collapsible } from "@plane/ui";
// components
import { RelationIssueList } from "@/components/issues";
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
// hooks
import { useIssueDetail } from "@/hooks/store";
// Plane-web
import { ISSUE_RELATION_OPTIONS } from "@/plane-web/components/relations";
import { TIssueRelationTypes } from "@/plane-web/types";
// helper
import { useRelationOperations } from "./helper";
@ -20,35 +22,16 @@ type Props = {
disabled: boolean;
};
const ISSUE_RELATION_OPTIONS = [
{
key: "blocked_by",
label: "Blocked by",
icon: (size: number) => <CircleDot size={size} />,
className: "bg-red-500/20 text-red-700",
},
{
key: "blocking",
label: "Blocking",
icon: (size: number) => <XCircle size={size} />,
className: "bg-yellow-500/20 text-yellow-700",
},
{
key: "relates_to",
label: "Relates to",
icon: (size: number) => <RelatedIcon height={size} width={size} />,
className: "bg-custom-background-80 text-custom-text-200",
},
{
key: "duplicate",
label: "Duplicate of",
icon: (size: number) => <CopyPlus size={size} />,
className: "bg-custom-background-80 text-custom-text-200",
},
];
type TIssueCrudState = { toggle: boolean; issueId: string | undefined; issue: TIssue | undefined };
export type TRelationObject = {
key: TIssueRelationTypes;
label: string;
className: string;
icon: (size: number) => React.ReactElement;
placeholder: string;
};
export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
@ -96,17 +79,19 @@ export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
if (!relations) return null;
// map relations to array
const relationsArray = Object.keys(relations).map((relationKey) => {
const issueIds = relations[relationKey as keyof TIssueRelationIdMap];
const issueRelationOption = ISSUE_RELATION_OPTIONS.find((option) => option.key === relationKey);
return {
relationKey: relationKey as keyof TIssueRelationIdMap,
issueIds: issueIds,
icon: issueRelationOption?.icon,
label: issueRelationOption?.label,
className: issueRelationOption?.className,
};
});
const relationsArray = (Object.keys(relations) as TIssueRelationTypes[])
.filter((relationKey) => !!ISSUE_RELATION_OPTIONS[relationKey])
.map((relationKey) => {
const issueIds = relations[relationKey];
const issueRelationOption = ISSUE_RELATION_OPTIONS[relationKey];
return {
relationKey: relationKey,
issueIds: issueIds,
icon: issueRelationOption?.icon,
label: issueRelationOption?.label,
className: issueRelationOption?.className,
};
});
// filter out relations with no issues
const filteredRelationsArray = relationsArray.filter((relation) => relation.issueIds.length > 0);

View file

@ -1,9 +1,8 @@
"use client";
import { useMemo } from "react";
import { usePathname } from "next/navigation";
import { CircleDot, CopyPlus, XCircle } from "lucide-react";
import { TIssue } from "@plane/types";
import { RelatedIcon, TOAST_TYPE, setToast } from "@plane/ui";
import { TOAST_TYPE, setToast } from "@plane/ui";
// constants
import { ISSUE_DELETED, ISSUE_UPDATED } from "@/constants/event-tracker";
// helper
@ -91,30 +90,3 @@ export const useRelationOperations = (): TRelationIssueOperations => {
return issueOperations;
};
export const ISSUE_RELATION_OPTIONS = [
{
key: "blocked_by",
label: "Blocked by",
icon: (size: number) => <CircleDot size={size} />,
className: "bg-red-500/20 text-red-700",
},
{
key: "blocking",
label: "Blocking",
icon: (size: number) => <XCircle size={size} />,
className: "bg-yellow-500/20 text-yellow-700",
},
{
key: "relates_to",
label: "Relates to",
icon: (size: number) => <RelatedIcon height={size} width={size} />,
className: "bg-custom-background-80 text-custom-text-200",
},
{
key: "duplicate",
label: "Duplicate of",
icon: (size: number) => <CopyPlus size={size} />,
className: "bg-custom-background-80 text-custom-text-200",
},
];

View file

@ -2,12 +2,12 @@
import React, { FC } from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
import { TIssueRelationTypes } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store";
// helper
import { ISSUE_RELATION_OPTIONS } from "./helper";
// Plane-web
import { ISSUE_RELATION_OPTIONS } from "@/plane-web/components/relations";
import { TIssueRelationTypes } from "@/plane-web/types";
type Props = {
issueId: string;
@ -30,8 +30,14 @@ export const RelationActionButton: FC<Props> = observer((props) => {
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
return (
<CustomMenu customButton={customButtonElement} placement="bottom-start" disabled={disabled} closeOnSelect>
{ISSUE_RELATION_OPTIONS.map((item, index) => (
<CustomMenu
customButton={customButtonElement}
placement="bottom-start"
disabled={disabled}
maxHeight="lg"
closeOnSelect
>
{Object.values(ISSUE_RELATION_OPTIONS).map((item, index) => (
<CustomMenu.MenuItem
key={index}
onClick={(e) => {

View file

@ -17,12 +17,11 @@ export const RelationsCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, issueId, disabled } = props;
// store hook
const {
relation: { getRelationsByIssueId },
relation: { getRelationCountByIssueId },
} = useIssueDetail();
// derived values
const issueRelations = getRelationsByIssueId(issueId);
const relationsCount = Object.values(issueRelations ?? {}).reduce((acc, curr) => acc + curr.length, 0);
const relationsCount = getRelationCountByIssueId(issueId);
// indicator element
const indicatorElement = useMemo(

View file

@ -1,13 +1,12 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { TIssueRelationTypes } from "@plane/types";
// hooks
import { issueRelationObject } from "@/components/issues/issue-detail/relation-select";
import { useIssueDetail } from "@/hooks/store";
// components
// Plane-web
import { ISSUE_RELATION_OPTIONS } from "@/plane-web/components/relations";
import { TIssueRelationTypes } from "@/plane-web/types";
//
import { IssueActivityBlockComponent } from "./";
// component helpers
// types
type TIssueRelationActivity = { activityId: string; ends: "top" | "bottom" | undefined };
@ -23,7 +22,7 @@ export const IssueRelationActivity: FC<TIssueRelationActivity> = observer((props
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={activity.field ? issueRelationObject[activity.field as TIssueRelationTypes].icon(14) : <></>}
icon={activity.field ? ISSUE_RELATION_OPTIONS[activity.field as TIssueRelationTypes].icon(14) : <></>}
activityId={activityId}
ends={ends}
>

View file

@ -4,42 +4,19 @@ import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { CircleDot, CopyPlus, Pencil, X, XCircle } from "lucide-react";
import { TIssueRelationTypes, ISearchIssueResponse } from "@plane/types";
// hooks
// Plane
import { ISearchIssueResponse } from "@plane/types";
import { RelatedIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ExistingIssuesListModal } from "@/components/core";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useIssueDetail, useIssues, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// components
// ui
// helpers
// types
export type TRelationObject = { className: string; icon: (size: number) => React.ReactElement; placeholder: string };
export const issueRelationObject: Record<TIssueRelationTypes, TRelationObject> = {
relates_to: {
className: "bg-custom-background-80 text-custom-text-200",
icon: (size) => <RelatedIcon height={size} width={size} className="text-custom-text-200" />,
placeholder: "Add related issues",
},
blocking: {
className: "bg-yellow-500/20 text-yellow-700",
icon: (size) => <XCircle size={size} className="text-custom-text-200" />,
placeholder: "None",
},
blocked_by: {
className: "bg-red-500/20 text-red-700",
icon: (size) => <CircleDot size={size} className="text-custom-text-200" />,
placeholder: "None",
},
duplicate: {
className: "bg-custom-background-80 text-custom-text-200",
icon: (size) => <CopyPlus size={size} className="text-custom-text-200" />,
placeholder: "None",
},
};
// Plane-web
import { ISSUE_RELATION_OPTIONS } from "@/plane-web/components/relations";
import { TIssueRelationTypes } from "@/plane-web/types";
type TIssueRelationSelect = {
className?: string;
@ -129,7 +106,7 @@ export const IssueRelationSelect: React.FC<TIssueRelationSelect> = observer((pro
return (
<div
key={relationIssueId}
className={`group flex items-center gap-1 rounded px-1.5 pb-1 pt-1 leading-3 hover:bg-custom-background-90 ${issueRelationObject[relationKey].className}`}
className={`group flex items-center gap-1 rounded px-1.5 pb-1 pt-1 leading-3 hover:bg-custom-background-90 ${ISSUE_RELATION_OPTIONS[relationKey].className}`}
>
<Tooltip tooltipHeading="Title" tooltipContent={currentIssue.name} isMobile={isMobile}>
<Link
@ -160,7 +137,7 @@ export const IssueRelationSelect: React.FC<TIssueRelationSelect> = observer((pro
})}
</div>
) : (
<span className="text-sm text-custom-text-400">{issueRelationObject[relationKey].placeholder}</span>
<span className="text-sm text-custom-text-400">{ISSUE_RELATION_OPTIONS[relationKey].placeholder}</span>
)}
{!disabled && (
<span

View file

@ -4,19 +4,20 @@ import { useParams } from "next/navigation";
// plane constants
import { ALL_ISSUES } from "@plane/constants";
import { TIssue } from "@plane/types";
import { setToast, TOAST_TYPE } from "@plane/ui";
// hooks
import { ChartDataType, GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "@/components/gantt-chart";
import { getMonthChartItemPositionWidthInMonth } from "@/components/gantt-chart/views";
import { GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "@/components/gantt-chart";
import { ETimeLineTypeType, TimeLineTypeContext } from "@/components/gantt-chart/contexts";
import { QuickAddIssueRoot, IssueGanttBlock, GanttQuickAddIssueButton } from "@/components/issues";
//constants
import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue";
// helpers
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { getIssueBlocksStructure } from "@/helpers/issue.helper";
//hooks
import { useIssues, useUserPermissions } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import { useIssuesActions } from "@/hooks/use-issues-actions";
import { useTimeLineChart } from "@/hooks/use-timeline-chart";
// plane web hooks
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-status";
@ -37,11 +38,12 @@ export type GanttStoreType =
export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGanttRoot) => {
const { viewId, isCompletedCycle = false } = props;
// router
const { workspaceSlug } = useParams();
const { workspaceSlug, projectId } = useParams();
const storeType = useIssueStoreType() as GanttStoreType;
const { issues, issuesFilter, issueMap } = useIssues(storeType);
const { issues, issuesFilter } = useIssues(storeType);
const { fetchIssues, fetchNextIssues, updateIssue, quickAddIssue } = useIssuesActions(storeType);
const { initGantt } = useTimeLineChart(ETimeLineTypeType.ISSUE);
// store hooks
const { allowPermissions } = useUserPermissions();
@ -56,6 +58,10 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
fetchIssues("init-loader", { canGroup: false, perPageCount: 100 }, viewId);
}, [fetchIssues, storeType, viewId]);
useEffect(() => {
initGantt();
}, []);
const issuesIds = (issues.groupedIssueIds?.[ALL_ISSUES] as string[]) ?? [];
const nextPageResults = issues.getPaginationData(undefined, undefined)?.nextPageResults;
@ -65,21 +71,6 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
fetchNextIssues();
}, [fetchNextIssues]);
const getBlockById = useCallback(
(id: string, currentViewData?: ChartDataType | undefined) => {
const issue = issueMap[id];
const block = getIssueBlocksStructure(issue);
if (currentViewData) {
return {
...block,
position: getMonthChartItemPositionWidthInMonth(currentViewData, block),
};
}
return block;
},
[issueMap]
);
const updateIssueBlockStructure = async (issue: TIssue, data: IBlockUpdateData) => {
if (!workspaceSlug) return;
@ -90,6 +81,23 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
};
const isAllowed = allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT);
const updateBlockDates = useCallback(
(
updates: {
id: string;
start_date?: string;
target_date?: string;
}[]
) =>
issues.updateIssueDates(workspaceSlug.toString(), projectId.toString(), updates).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error while updating Issue Dates, Please try again Later",
});
}),
[issues]
);
const quickAdd =
enableIssueCreation && isAllowed && !isCompletedCycle ? (
@ -107,28 +115,30 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
return (
<IssueLayoutHOC layout={EIssueLayoutTypes.GANTT}>
<div className="h-full w-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blockIds={issuesIds}
getBlockById={getBlockById}
blockUpdateHandler={updateIssueBlockStructure}
blockToRender={(data: TIssue) => <IssueGanttBlock issueId={data.id} />}
sidebarToRender={(props) => <IssueGanttSidebar {...props} showAllBlocks />}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={appliedDisplayFilters?.order_by === "sort_order" && isAllowed}
enableAddBlock={isAllowed}
enableSelection={isBulkOperationsEnabled && isAllowed}
quickAdd={quickAdd}
loadMoreBlocks={loadMoreIssues}
canLoadMoreBlocks={nextPageResults}
showAllBlocks
/>
</div>
<TimeLineTypeContext.Provider value={ETimeLineTypeType.ISSUE}>
<div className="h-full w-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blockIds={issuesIds}
blockUpdateHandler={updateIssueBlockStructure}
blockToRender={(data: TIssue) => <IssueGanttBlock issueId={data.id} />}
sidebarToRender={(props) => <IssueGanttSidebar {...props} showAllBlocks />}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={appliedDisplayFilters?.order_by === "sort_order" && isAllowed}
enableAddBlock={isAllowed}
enableSelection={isBulkOperationsEnabled && isAllowed}
quickAdd={quickAdd}
loadMoreBlocks={loadMoreIssues}
canLoadMoreBlocks={nextPageResults}
updateBlockDates={updateBlockDates}
showAllBlocks
/>
</div>
</TimeLineTypeContext.Provider>
</IssueLayoutHOC>
);
});

View file

@ -3,7 +3,8 @@
import React, { FC } from "react";
import { observer } from "mobx-react";
import { X, Pencil, Trash, Link as LinkIcon } from "lucide-react";
import { TIssue, TIssueRelationTypes } from "@plane/types";
// Plane
import { TIssue } from "@plane/types";
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
// components
import { RelationIssueProperty } from "@/components/issues/relations";
@ -13,7 +14,8 @@ import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-red
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// types
import { TIssueRelationTypes } from "@/plane-web/types";
//
import { TRelationIssueOperations } from "../issue-detail-widgets/relations/helper";
type Props = {

View file

@ -1,10 +1,13 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
import { TIssue, TIssueRelationTypes } from "@plane/types";
// Plane
import { TIssue } from "@plane/types";
// components
import { RelationIssueListItem } from "@/components/issues/relations";
// types
// Plane-web
import { TIssueRelationTypes } from "@/plane-web/types";
//
import { TRelationIssueOperations } from "../issue-detail-widgets/relations/helper";
type Props = {