[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
112 changed files with 2918 additions and 2641 deletions

View File

@@ -49,44 +49,47 @@ class DynamicBaseSerializer(BaseSerializer):
allowed.append(list(item.keys())[0])
for field in allowed:
from . import (
WorkspaceLiteSerializer,
ProjectLiteSerializer,
UserLiteSerializer,
StateLiteSerializer,
IssueSerializer,
LabelSerializer,
CycleIssueSerializer,
IssueLiteSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer,
IssueReactionLiteSerializer,
IssueLinkLiteSerializer,
)
if field not in self.fields:
from . import (
WorkspaceLiteSerializer,
ProjectLiteSerializer,
UserLiteSerializer,
StateLiteSerializer,
IssueSerializer,
LabelSerializer,
CycleIssueSerializer,
IssueLiteSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer,
IssueReactionLiteSerializer,
IssueLinkLiteSerializer,
RelatedIssueSerializer,
)
# Expansion mapper
expansion = {
"user": UserLiteSerializer,
"workspace": WorkspaceLiteSerializer,
"project": ProjectLiteSerializer,
"default_assignee": UserLiteSerializer,
"project_lead": UserLiteSerializer,
"state": StateLiteSerializer,
"created_by": UserLiteSerializer,
"issue": IssueSerializer,
"actor": UserLiteSerializer,
"owned_by": UserLiteSerializer,
"members": UserLiteSerializer,
"assignees": UserLiteSerializer,
"labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer,
"parent": IssueLiteSerializer,
"issue_relation": IssueRelationSerializer,
"issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_link": IssueLinkLiteSerializer,
"sub_issues": IssueLiteSerializer,
}
# Expansion mapper
expansion = {
"user": UserLiteSerializer,
"workspace": WorkspaceLiteSerializer,
"project": ProjectLiteSerializer,
"default_assignee": UserLiteSerializer,
"project_lead": UserLiteSerializer,
"state": StateLiteSerializer,
"created_by": UserLiteSerializer,
"issue": IssueSerializer,
"actor": UserLiteSerializer,
"owned_by": UserLiteSerializer,
"members": UserLiteSerializer,
"assignees": UserLiteSerializer,
"labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer,
"parent": IssueLiteSerializer,
"issue_relation": IssueRelationSerializer,
"issue_related": RelatedIssueSerializer,
"issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_link": IssueLinkLiteSerializer,
"sub_issues": IssueLiteSerializer,
}
if field not in self.fields and field in expansion:
self.fields[field] = expansion[field](
@@ -104,6 +107,7 @@ class DynamicBaseSerializer(BaseSerializer):
"issue_attachment",
"issue_link",
"sub_issues",
"issue_related",
]
else False
)
@@ -133,6 +137,7 @@ class DynamicBaseSerializer(BaseSerializer):
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
RelatedIssueSerializer,
)
# Expansion mapper
@@ -153,6 +158,7 @@ class DynamicBaseSerializer(BaseSerializer):
"issue_cycle": CycleIssueSerializer,
"parent": IssueLiteSerializer,
"issue_relation": IssueRelationSerializer,
"issue_related": RelatedIssueSerializer,
"issue_inbox": InboxIssueLiteSerializer,
"issue_reactions": IssueReactionLiteSerializer,
"issue_attachment": IssueAttachmentLiteSerializer,

View File

@@ -21,7 +21,9 @@ from plane.app.views import (
BulkArchiveIssuesEndpoint,
DeletedIssuesListViewSet,
IssuePaginatedViewSet,
IssueDetailEndpoint,
IssueAttachmentV2Endpoint,
IssueBulkUpdateDateEndpoint,
)
urlpatterns = [
@@ -40,6 +42,12 @@ urlpatterns = [
),
name="project-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues-detail/",
IssueDetailEndpoint.as_view(),
name="project-issue-detail",
),
# updated v1 paginated issues
# updated v2 paginated issues
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/v2/issues/",
@@ -307,4 +315,9 @@ urlpatterns = [
DeletedIssuesListViewSet.as_view(),
name="deleted-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-dates/",
IssueBulkUpdateDateEndpoint.as_view(),
name="project-issue-dates",
),
]

View File

@@ -130,6 +130,8 @@ from .issue.base import (
BulkDeleteIssuesEndpoint,
DeletedIssuesListViewSet,
IssuePaginatedViewSet,
IssueDetailEndpoint,
IssueBulkUpdateDateEndpoint,
)
from .issue.activity import (

View File

@@ -976,3 +976,194 @@ class IssuePaginatedViewSet(BaseViewSet):
)
return Response(paginated_data, status=status.HTTP_200_OK)
class IssueDetailEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
issue = (
Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("cycle_id")[:1]
)
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True),
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
issue = issue.filter(**filters)
order_by_param = request.GET.get("order_by", "-created_at")
# Issue queryset
issue, order_by_param = order_issue_queryset(
issue_queryset=issue,
order_by_param=order_by_param,
)
return self.paginate(
request=request,
order_by=order_by_param,
queryset=(issue),
on_results=lambda issue: IssueSerializer(
issue,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
class IssueBulkUpdateDateEndpoint(BaseAPIView):
def validate_dates(
self, current_start, current_target, new_start, new_target
):
"""
Validate that start date is before target date.
"""
start = new_start or current_start
target = new_target or current_target
if start and target and start > target:
return False
return True
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id):
updates = request.data.get("updates", [])
issue_ids = [update["id"] for update in updates]
epoch = int(timezone.now().timestamp())
# Fetch all relevant issues in a single query
issues = list(Issue.objects.filter(id__in=issue_ids))
issues_dict = {str(issue.id): issue for issue in issues}
issues_to_update = []
for update in updates:
issue_id = update["id"]
issue = issues_dict.get(issue_id)
if not issue:
continue
start_date = update.get("start_date")
target_date = update.get("target_date")
validate_dates = self.validate_dates(
issue.start_date, issue.target_date, start_date, target_date
)
if not validate_dates:
return Response(
{
"message": "Start date cannot exceed target date",
},
status=status.HTTP_400_BAD_REQUEST,
)
if start_date:
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps(
{"start_date": update.get("start_date")}
),
current_instance=json.dumps(
{"start_date": str(issue.start_date)}
),
issue_id=str(issue_id),
actor_id=str(request.user.id),
project_id=str(project_id),
epoch=epoch,
)
issue.start_date = start_date
issues_to_update.append(issue)
if target_date:
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps(
{"target_date": update.get("target_date")}
),
current_instance=json.dumps(
{"target_date": str(issue.target_date)}
),
issue_id=str(issue_id),
actor_id=str(request.user.id),
project_id=str(project_id),
epoch=epoch,
)
issue.target_date = target_date
issues_to_update.append(issue)
# Bulk update issues
Issue.objects.bulk_update(
issues_to_update, ["start_date", "target_date"]
)
return Response(
{"message": "Issues updated successfully"},
status=status.HTTP_200_OK,
)

View File

@@ -38,6 +38,7 @@ from plane.db.models import (
CycleIssue,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_relation_mapper import get_actual_relation
class IssueRelationViewSet(BaseViewSet):
@@ -89,6 +90,26 @@ class IssueRelationViewSet(BaseViewSet):
related_issue_id=issue_id, relation_type="relates_to"
).values_list("issue_id", flat=True)
# get all start after issues
start_after_issues = issue_relations.filter(
relation_type="start_before", related_issue_id=issue_id
).values_list("issue_id", flat=True)
# get all start_before issues
start_before_issues = issue_relations.filter(
relation_type="start_before", issue_id=issue_id
).values_list("related_issue_id", flat=True)
# get all finish after issues
finish_after_issues = issue_relations.filter(
relation_type="finish_before", related_issue_id=issue_id
).values_list("issue_id", flat=True)
# get all finish before issues
finish_before_issues = issue_relations.filter(
relation_type="finish_before", issue_id=issue_id
).values_list("related_issue_id", flat=True)
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
@@ -211,12 +232,50 @@ class IssueRelationViewSet(BaseViewSet):
)
)
.values(*fields),
"start_after": queryset.filter(pk__in=start_after_issues)
.annotate(
relation_type=Value(
"start_after",
output_field=CharField(),
)
)
.values(*fields),
"start_before": queryset.filter(pk__in=start_before_issues)
.annotate(
relation_type=Value(
"start_before",
output_field=CharField(),
)
)
.values(*fields),
"finish_after": queryset.filter(pk__in=finish_after_issues)
.annotate(
relation_type=Value(
"finish_after",
output_field=CharField(),
)
)
.values(*fields),
"finish_before": queryset.filter(pk__in=finish_before_issues)
.annotate(
relation_type=Value(
"finish_before",
output_field=CharField(),
)
)
.values(*fields),
}
return Response(response_data, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, issue_id):
relation_type = request.data.get("relation_type", None)
if relation_type is None:
return Response(
{"message": "Issue relation type is required"},
status=status.HTTP_400_BAD_REQUEST,
)
issues = request.data.get("issues", [])
project = Project.objects.get(pk=project_id)
@@ -224,16 +283,18 @@ class IssueRelationViewSet(BaseViewSet):
[
IssueRelation(
issue_id=(
issue if relation_type == "blocking" else issue_id
issue
if relation_type
in ["blocking", "start_after", "finish_after"]
else issue_id
),
related_issue_id=(
issue_id if relation_type == "blocking" else issue
),
relation_type=(
"blocked_by"
if relation_type == "blocking"
else relation_type
issue_id
if relation_type
in ["blocking", "start_after", "finish_after"]
else issue
),
relation_type=(get_actual_relation(relation_type)),
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,

View File

@@ -31,6 +31,7 @@ from plane.db.models import (
from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
from plane.bgtasks.webhook_task import webhook_activity
from plane.utils.issue_relation_mapper import get_inverse_relation
# Track Changes in name
@@ -1394,6 +1395,9 @@ def create_issue_relation_activity(
epoch=epoch,
)
)
inverse_relation = get_inverse_relation(
requested_data.get("relation_type")
)
issue = Issue.objects.get(pk=issue_id)
issue_activities.append(
IssueActivity(
@@ -1402,19 +1406,10 @@ def create_issue_relation_activity(
verb="updated",
old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field=(
"blocking"
if requested_data.get("relation_type") == "blocked_by"
else (
"blocked_by"
if requested_data.get("relation_type")
== "blocking"
else requested_data.get("relation_type")
)
),
field=inverse_relation,
project_id=project_id,
workspace_id=workspace_id,
comment=f'added {"blocking" if requested_data.get("relation_type") == "blocked_by" else ("blocked_by" if requested_data.get("relation_type") == "blocking" else requested_data.get("relation_type")),} relation',
comment=f"added {inverse_relation} relation",
old_identifier=issue_id,
epoch=epoch,
)

View File

@@ -279,6 +279,8 @@ class IssueRelation(ProjectBaseModel):
("duplicate", "Duplicate"),
("relates_to", "Relates To"),
("blocked_by", "Blocked By"),
("start_before", "Start Before"),
("finish_before", "Finish Before"),
)
issue = models.ForeignKey(

View File

@@ -0,0 +1,24 @@
def get_inverse_relation(relation_type):
relation_mapping = {
"start_after": "start_before",
"finish_after": "finish_before",
"blocked_by": "blocking",
"blocking": "blocked_by",
"start_before": "start_after",
"finish_before": "finish_after",
}
return relation_mapping.get(relation_type, relation_type)
def get_actual_relation(relation_type):
# This function is used to get the actual relation type which is store in database
actual_relation = {
"start_after": "start_before",
"finish_after": "finish_before",
"blocking": "blocked_by",
"blocked_by": "blocked_by",
"start_before": "start_before",
"finish_before": "finish_before",
}
return actual_relation.get(relation_type, relation_type)

View File

@@ -289,6 +289,7 @@ module.exports = {
},
// scale down font sizes to 90% of default
fontSize: {
"2xs": "0.5625rem",
xs: "0.675rem",
sm: "0.7875rem",
base: "0.9rem",

View File

@@ -1,8 +1,8 @@
import { EDurationFilters } from "./enums";
import { IIssueActivity, TIssuePriorities } from "./issues";
import { TIssue } from "./issues/issue";
import { TIssueRelationTypes } from "./issues/issue_relation";
import { TStateGroups } from "./state";
import { TIssueRelationTypes } from "@/plane-web/types";
export type TWidgetKeys =
| "overview_stats"

View File

@@ -2,6 +2,7 @@ import { TIssuePriorities } from "../issues";
import { TIssueAttachment } from "./issue_attachment";
import { TIssueLink } from "./issue_link";
import { TIssueReaction } from "./issue_reaction";
import { TIssueRelationTypes } from "@/plane-web/types";
// new issue structure types
@@ -40,6 +41,14 @@ export type TBaseIssue = {
is_draft: boolean;
};
export type IssueRelation = {
id: string;
name: string;
project_id: string;
relation_type: TIssueRelationTypes;
sequence_id: number;
};
export type TIssue = TBaseIssue & {
description_html?: string;
is_subscribed?: boolean;
@@ -47,6 +56,8 @@ export type TIssue = TBaseIssue & {
issue_reactions?: TIssueReaction[];
issue_attachments?: TIssueAttachment[];
issue_link?: TIssueLink[];
issue_relation?: IssueRelation[];
issue_related?: IssueRelation[];
// tempId is used for optimistic updates. It is not a part of the API response.
tempId?: string;
// sourceIssueId is used to store the original issue id when creating a copy of an issue. Used in cloning property values. It is not a part of the API response.

View File

@@ -1,11 +1,6 @@
import { TIssueRelationTypes } from "@/plane-web/types";
import { TIssue } from "./issues";
export type TIssueRelationTypes =
| "blocking"
| "blocked_by"
| "duplicate"
| "relates_to";
export type TIssueRelation = Record<TIssueRelationTypes, TIssue[]>;
export type TIssueRelationMap = {

View File

@@ -78,7 +78,8 @@ export type TIssueParams =
| "cursor"
| "per_page"
| "issue_type"
| "layout";
| "layout"
| "expand";
export type TCalendarLayouts = "month" | "week";

View File

@@ -0,0 +1,2 @@
export * from "./left-draggable";
export * from "./right-draggable";

View File

@@ -0,0 +1,9 @@
import { RefObject } from "react";
import { IGanttBlock } from "@/components/gantt-chart";
type LeftDependencyDraggableProps = {
block: IGanttBlock;
ganttContainerRef: RefObject<HTMLDivElement>;
};
export const LeftDependencyDraggable = (props: LeftDependencyDraggableProps) => <></>;

View File

@@ -0,0 +1,8 @@
import { RefObject } from "react";
import { IGanttBlock } from "@/components/gantt-chart";
type RightDependencyDraggableProps = {
block: IGanttBlock;
ganttContainerRef: RefObject<HTMLDivElement>;
};
export const RightDependencyDraggable = (props: RightDependencyDraggableProps) => <></>;

View File

@@ -0,0 +1 @@
export const TimelineDependencyPaths = () => <></>;

View File

@@ -0,0 +1 @@
export const TimelineDraggablePath = () => <></>;

View File

@@ -0,0 +1,3 @@
export * from "./blockDraggables";
export * from "./dependency-paths";
export * from "./draggable-dependency-path";

View File

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

View File

@@ -0,0 +1,35 @@
import { CircleDot, CopyPlus, XCircle } from "lucide-react";
import { RelatedIcon } from "@plane/ui";
import { TRelationObject } from "@/components/issues";
import { TIssueRelationTypes } from "../../types";
export const ISSUE_RELATION_OPTIONS: Record<TIssueRelationTypes, TRelationObject> = {
relates_to: {
key: "relates_to",
label: "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",
},
duplicate: {
key: "duplicate",
label: "Duplicate of",
className: "bg-custom-background-80 text-custom-text-200",
icon: (size) => <CopyPlus size={size} className="text-custom-text-200" />,
placeholder: "None",
},
blocked_by: {
key: "blocked_by",
label: "Blocked by",
className: "bg-red-500/20 text-red-700",
icon: (size) => <CircleDot size={size} className="text-custom-text-200" />,
placeholder: "None",
},
blocking: {
key: "blocking",
label: "Blocking",
className: "bg-yellow-500/20 text-yellow-700",
icon: (size) => <XCircle size={size} className="text-custom-text-200" />,
placeholder: "None",
},
};

View File

@@ -0,0 +1,8 @@
import { TIssueRelationTypes } from "../types";
export const REVERSE_RELATIONS: { [key in TIssueRelationTypes]: TIssueRelationTypes } = {
blocked_by: "blocking",
blocking: "blocked_by",
relates_to: "relates_to",
duplicate: "duplicate",
};

View File

@@ -0,0 +1,7 @@
export * from "./ai";
export * from "./estimates";
export * from "./gantt-chart";
export * from "./issues";
export * from "./project";
export * from "./user-permissions";
export * from "./workspace";

View File

@@ -33,3 +33,5 @@ export const filterActivityOnSelectedFilters = (
// boolean to decide if the local db cache is enabled
export const ENABLE_LOCAL_DB_CACHE = false;
export const ENABLE_ISSUE_DEPENDENCIES = false;

View File

@@ -0,0 +1,283 @@
import isEqual from "lodash/isEqual";
import set from "lodash/set";
import { action, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// components
import { ChartDataType, IBlockUpdateDependencyData, IGanttBlock, TGanttViews } from "@/components/gantt-chart";
import { currentViewDataWithView } from "@/components/gantt-chart/data";
import { getDateFromPositionOnGantt, getItemPositionWidth } from "@/components/gantt-chart/views/helpers";
// helpers
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// store
import { CoreRootStore } from "@/store/root.store";
// types
type BlockData = {
id: string;
name: string;
sort_order: number;
start_date: string | undefined | null;
target_date: string | undefined | null;
};
export interface IBaseTimelineStore {
// observables
currentView: TGanttViews;
currentViewData: ChartDataType | undefined;
activeBlockId: string | null;
renderView: any;
isDragging: boolean;
//
setBlockIds: (ids: string[]) => void;
getBlockById: (blockId: string) => IGanttBlock;
// computed functions
getIsCurrentDependencyDragging: (blockId: string) => boolean;
isBlockActive: (blockId: string) => boolean;
// actions
updateCurrentView: (view: TGanttViews) => void;
updateCurrentViewData: (data: ChartDataType | undefined) => void;
updateActiveBlockId: (blockId: string | null) => void;
updateRenderView: (data: any) => void;
getUpdatedPositionAfterDrag: (id: string, ignoreDependencies?: boolean) => IBlockUpdateDependencyData[];
updateBlockPosition: (id: string, deltaLeft: number, deltaWidth: number, ignoreDependencies?: boolean) => void;
getNumberOfDaysFromPosition: (position: number | undefined) => number | undefined;
setIsDragging: (isDragging: boolean) => void;
initGantt: () => void;
getDateFromPositionOnGantt: (position: number, offsetDays: number) => Date | undefined;
}
export class BaseTimeLineStore implements IBaseTimelineStore {
blocksMap: Record<string, IGanttBlock> = {};
blockIds: string[] | undefined = undefined;
isDragging: boolean = false;
currentView: TGanttViews = "week";
currentViewData: ChartDataType | undefined = undefined;
activeBlockId: string | null = null;
renderView: any = [];
rootStore: CoreRootStore;
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observables
blocksMap: observable,
blockIds: observable,
isDragging: observable.ref,
currentView: observable.ref,
currentViewData: observable,
activeBlockId: observable.ref,
renderView: observable,
// actions
setIsDragging: action,
setBlockIds: action.bound,
initGantt: action.bound,
updateCurrentView: action.bound,
updateCurrentViewData: action.bound,
updateActiveBlockId: action.bound,
updateRenderView: action.bound,
});
this.initGantt();
this.rootStore = _rootStore;
}
/**
* Update Block Ids to derive blocks from
* @param ids
*/
setBlockIds = (ids: string[]) => {
this.blockIds = ids;
};
/**
* setIsDragging
* @param isDragging
*/
setIsDragging = (isDragging: boolean) => {
runInAction(() => {
this.isDragging = isDragging;
});
};
/**
* @description check if block is active
* @param {string} blockId
*/
isBlockActive = computedFn((blockId: string): boolean => this.activeBlockId === blockId);
/**
* @description update current view
* @param {TGanttViews} view
*/
updateCurrentView = (view: TGanttViews) => {
this.currentView = view;
};
/**
* @description update current view data
* @param {ChartDataType | undefined} data
*/
updateCurrentViewData = (data: ChartDataType | undefined) => {
runInAction(() => {
this.currentViewData = data;
});
};
/**
* @description update active block
* @param {string | null} block
*/
updateActiveBlockId = (blockId: string | null) => {
this.activeBlockId = blockId;
};
/**
* @description update render view
* @param {any[]} data
*/
updateRenderView = (data: any[]) => {
this.renderView = data;
};
/**
* @description initialize gantt chart with month view
*/
initGantt = () => {
const newCurrentViewData = currentViewDataWithView(this.currentView);
runInAction(() => {
this.currentViewData = newCurrentViewData;
this.blocksMap = {};
this.blockIds = undefined;
});
};
/** Gets Block from Id */
getBlockById = computedFn((blockId: string) => this.blocksMap[blockId]);
/**
* updates the BlocksMap from blockIds
* @param getDataById
* @returns
*/
updateBlocks(getDataById: (id: string) => BlockData | undefined | null) {
if (!this.blockIds || !Array.isArray(this.blockIds) || this.isDragging) return true;
const updatedBlockMaps: { path: string[]; value: any }[] = [];
const newBlocks: IGanttBlock[] = [];
// Loop through blockIds to generate blocks Data
for (const blockId of this.blockIds) {
const blockData = getDataById(blockId);
if (!blockData) continue;
const block: IGanttBlock = {
data: blockData,
id: blockData?.id,
name: blockData.name,
sort_order: blockData?.sort_order,
start_date: blockData?.start_date ?? undefined,
target_date: blockData?.target_date ?? undefined,
};
if (this.currentViewData && this.currentViewData?.data?.startDate && this.currentViewData?.data?.dayWidth) {
block.position = getItemPositionWidth(this.currentViewData, block);
}
// create block updates if the block already exists, or push them to newBlocks
if (this.blocksMap[blockId]) {
for (const key of Object.keys(block)) {
const currValue = this.blocksMap[blockId][key as keyof IGanttBlock];
const nextValue = block[key as keyof IGanttBlock];
if (!isEqual(currValue, nextValue)) {
updatedBlockMaps.push({ path: [blockId, key], value: nextValue });
}
}
} else {
newBlocks.push(block);
}
}
// update the store with the block updates
runInAction(() => {
for (const updatedBlock of updatedBlockMaps) {
set(this.blocksMap, updatedBlock.path, updatedBlock.value);
}
for (const newBlock of newBlocks) {
set(this.blocksMap, [newBlock.id], newBlock);
}
});
}
/**
* returns number of days that the position pixels span across the timeline chart
* @param position
* @returns
*/
getNumberOfDaysFromPosition = (position: number | undefined) => {
if (!this.currentViewData || !position) return;
return Math.round(position / this.currentViewData.data.dayWidth);
};
/**
* returns the date at which the position corresponds to on the timeline chart
*/
getDateFromPositionOnGantt = computedFn((position: number, offsetDays: number) => {
if (!this.currentViewData) return;
return getDateFromPositionOnGantt(position, this.currentViewData, offsetDays);
});
/**
* returns updates dates of blocks post drag.
* @param id
* @returns
*/
getUpdatedPositionAfterDrag = action((id: string) => {
const currBlock = this.blocksMap[id];
if (!currBlock?.position || !this.currentViewData) return [];
return [
{
id,
start_date: renderFormattedPayloadDate(
getDateFromPositionOnGantt(currBlock.position.marginLeft, this.currentViewData)
),
target_date: renderFormattedPayloadDate(
getDateFromPositionOnGantt(currBlock.position.marginLeft + currBlock.position.width, this.currentViewData, -1)
),
},
] as IBlockUpdateDependencyData[];
});
/**
* updates the block's position such as marginLeft and width wile dragging
* @param id
* @param deltaLeft
* @param deltaWidth
* @returns
*/
updateBlockPosition = action((id: string, deltaLeft: number, deltaWidth: number) => {
const currBlock = this.blocksMap[id];
if (!currBlock?.position) return;
const newMarginLeft = currBlock.position.marginLeft + deltaLeft;
const newWidth = currBlock.position.width + deltaWidth;
runInAction(() => {
set(this.blocksMap, [id, "position"], {
marginLeft: newMarginLeft ?? currBlock.position?.marginLeft,
width: newWidth ?? currBlock.position?.width,
});
});
});
// Dummy method to return if the current Block's dependency is being dragged
getIsCurrentDependencyDragging = computedFn((blockId: string) => false);
}

View File

@@ -0,0 +1 @@
export type TIssueRelationTypes = "blocking" | "blocked_by" | "duplicate" | "relates_to";

View File

@@ -1,2 +1,3 @@
export * from "./projects";
export * from "./issue-types";
export * from "./gantt-chart";

View File

@@ -50,7 +50,7 @@ const defaultValues: Partial<IApiToken> = {
expired_at: null,
};
const getExpiryDate = (val: string): string | null => {
const getExpiryDate = (val: string): string | null | undefined => {
const today = new Date();
const dateToAdd = EXPIRY_DATE_OPTIONS.find((option) => option.key === val)?.value;

View File

@@ -13,6 +13,7 @@ type Props = {
defaultValue?: boolean;
shouldRecordHeights?: boolean;
useIdletime?: boolean;
forceRender?: boolean;
};
const RenderIfVisible: React.FC<Props> = (props) => {
@@ -29,12 +30,13 @@ const RenderIfVisible: React.FC<Props> = (props) => {
placeholderChildren = null, //placeholder children
defaultValue = false,
useIdletime = false,
forceRender = false,
} = props;
const [shouldVisible, setShouldVisible] = useState<boolean>(defaultValue);
const placeholderHeight = useRef<string>(defaultHeight);
const intersectionRef = useRef<HTMLElement | null>(null);
const isVisible = shouldVisible;
const isVisible = shouldVisible || forceRender;
// Set visibility with intersection observer
useEffect(() => {

View File

@@ -1,103 +0,0 @@
"use client";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
// ui
import { Tooltip, ContrastIcon } from "@plane/ui";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useCycle } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
cycleId: string;
};
export const CycleGanttBlock: React.FC<Props> = observer((props) => {
const { cycleId } = props;
// router
const router = useAppRouter();
// store hooks
const { workspaceSlug } = useParams();
const { getCycleById } = useCycle();
// derived values
const cycleDetails = getCycleById(cycleId);
const { isMobile } = usePlatformOS();
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase();
return (
<div
className="relative flex h-full w-full items-center rounded"
style={{
backgroundColor:
cycleStatus === "current"
? "#09a953"
: cycleStatus === "upcoming"
? "#f7ae59"
: cycleStatus === "completed"
? "#3f76ff"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: "",
}}
onClick={() =>
router.push(`/${workspaceSlug?.toString()}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)
}
>
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" />
<Tooltip
isMobile={isMobile}
tooltipContent={
<div className="space-y-1">
<h5>{cycleDetails?.name}</h5>
<div>
{renderFormattedDate(cycleDetails?.start_date ?? "")} to{" "}
{renderFormattedDate(cycleDetails?.end_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="relative w-full truncate px-2.5 py-1 text-sm text-custom-text-100">{cycleDetails?.name}</div>
</Tooltip>
</div>
);
});
export const CycleGanttSidebarBlock: React.FC<Props> = observer((props) => {
const { cycleId } = props;
// store hooks
const { workspaceSlug } = useParams();
const { getCycleById } = useCycle();
// derived values
const cycleDetails = getCycleById(cycleId);
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase();
return (
<Link
className="relative flex h-full w-full items-center gap-2"
href={`/${workspaceSlug?.toString()}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`}
draggable={false}
>
<ContrastIcon
className="h-5 w-5 flex-shrink-0"
color={`${
cycleStatus === "current"
? "#09a953"
: cycleStatus === "upcoming"
? "#f7ae59"
: cycleStatus === "completed"
? "#3f76ff"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
<h6 className="flex-grow truncate text-sm font-medium">{cycleDetails?.name}</h6>
</Link>
);
});

View File

@@ -1,74 +0,0 @@
import { FC, useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ICycle } from "@plane/types";
// components
import { CycleGanttBlock } from "@/components/cycles";
import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar, ChartDataType } from "@/components/gantt-chart";
import { getMonthChartItemPositionWidthInMonth } from "@/components/gantt-chart/views";
// helpers
import { getDate } from "@/helpers/date-time.helper";
// hooks
import { useCycle } from "@/hooks/store";
type Props = {
workspaceSlug: string;
cycleIds: string[];
};
export const CyclesListGanttChartView: FC<Props> = observer((props) => {
const { cycleIds } = props;
// router
const { workspaceSlug } = useParams();
// store hooks
const { getCycleById, updateCycleDetails } = useCycle();
const getBlockById = useCallback(
(id: string, currentViewData?: ChartDataType | undefined) => {
const cycle = getCycleById(id);
const block = {
data: cycle,
id: cycle?.id ?? "",
sort_order: cycle?.sort_order ?? 0,
start_date: getDate(cycle?.start_date),
target_date: getDate(cycle?.end_date),
};
if (currentViewData) {
return {
...block,
position: getMonthChartItemPositionWidthInMonth(currentViewData, block),
};
}
return block;
},
[getCycleById]
);
const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => {
if (!workspaceSlug || !cycle) return;
const payload: any = { ...data };
if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder;
await updateCycleDetails(workspaceSlug.toString(), cycle.project_id, cycle.id, payload);
};
return (
<div className="h-full w-full overflow-y-auto">
<GanttChartRoot
title="Cycles"
loaderTitle="Cycles"
blockIds={cycleIds}
getBlockById={getBlockById}
blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)}
sidebarToRender={(props) => <CycleGanttSidebar {...props} />}
blockToRender={(data: ICycle) => <CycleGanttBlock cycleId={data.id} />}
enableBlockLeftResize={false}
enableBlockRightResize={false}
enableBlockMove={false}
enableReorder
/>
</div>
);
});

View File

@@ -1,2 +0,0 @@
export * from "./blocks";
export * from "./cycles-list-layout";

View File

@@ -1,7 +1,6 @@
export * from "./active-cycle";
export * from "./applied-filters";
export * from "./dropdowns";
export * from "./gantt-chart";
export * from "./list";
export * from "./cycle-peek-overview";
export * from "./cycles-view-header";

View File

@@ -72,7 +72,6 @@ export const AssignedUpcomingIssueListItem: React.FC<IssueListItemProps> = obser
projectIdentifier={blockedByIssueProjectDetails?.identifier}
projectId={blockedByIssueProjectDetails?.id}
issueSequenceId={blockedByIssues[0]?.sequence_id}
issueTypeId={blockedByIssues[0]?.type_id}
textContainerClassName="text-xs text-custom-text-200 font-medium"
/>
)

View File

@@ -0,0 +1,49 @@
import { FC } from "react";
// components
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
// hooks
import { TSelectionHelper } from "@/hooks/use-multiple-select";
// types
import { BLOCK_HEIGHT } from "../constants";
import { IBlockUpdateData } from "../types";
import { BlockRow } from "./block-row";
export type GanttChartBlocksProps = {
blockIds: string[];
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableAddBlock: boolean | ((blockId: string) => boolean);
showAllBlocks: boolean;
selectionHelpers: TSelectionHelper;
ganttContainerRef: React.RefObject<HTMLDivElement>;
};
export const GanttChartRowList: FC<GanttChartBlocksProps> = (props) => {
const { blockIds, blockUpdateHandler, enableAddBlock, showAllBlocks, selectionHelpers, ganttContainerRef } = props;
return (
<div className="absolute top-0 left-0 min-w-full w-max">
{blockIds?.map((blockId) => (
<>
<RenderIfVisible
root={ganttContainerRef}
horizontalOffset={100}
verticalOffset={200}
classNames="relative min-w-full w-max"
placeholderChildren={<div className="w-full pointer-events-none" style={{ height: `${BLOCK_HEIGHT}px` }} />}
shouldRecordHeights={false}
>
<BlockRow
key={blockId}
blockId={blockId}
showAllBlocks={showAllBlocks}
blockUpdateHandler={blockUpdateHandler}
enableAddBlock={typeof enableAddBlock === "function" ? enableAddBlock(blockId) : enableAddBlock}
selectionHelpers={selectionHelpers}
ganttContainerRef={ganttContainerRef}
/>
</RenderIfVisible>
</>
))}
</div>
);
};

View File

@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { ArrowRight } from "lucide-react";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useIssueDetail } from "@/hooks/store";
import { TSelectionHelper } from "@/hooks/use-multiple-select";
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { BLOCK_HEIGHT, SIDEBAR_WIDTH } from "../constants";
import { ChartAddBlock } from "../helpers";
import { IBlockUpdateData } from "../types";
type Props = {
blockId: string;
showAllBlocks: boolean;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableAddBlock: boolean;
selectionHelpers: TSelectionHelper;
ganttContainerRef: React.RefObject<HTMLDivElement>;
};
export const BlockRow: React.FC<Props> = observer((props) => {
const { blockId, showAllBlocks, blockUpdateHandler, enableAddBlock, selectionHelpers } = props;
// states
const [isHidden, setIsHidden] = useState(false);
const [isBlockHiddenOnLeft, setIsBlockHiddenOnLeft] = useState(false);
// store hooks
const { getBlockById, updateActiveBlockId, isBlockActive } = useTimeLineChartStore();
const { getIsIssuePeeked } = useIssueDetail();
const block = getBlockById(blockId);
useEffect(() => {
const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement;
const timelineBlock = document.getElementById(`gantt-block-${block?.id}`);
if (!timelineBlock || !intersectionRoot) return;
setIsBlockHiddenOnLeft(
!!block.position?.marginLeft &&
!!block.position?.width &&
intersectionRoot.scrollLeft > block.position.marginLeft + block.position.width
);
// Observe if the block is visible on the chart
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
setIsHidden(!entry.isIntersecting);
setIsBlockHiddenOnLeft(entry.boundingClientRect.left < 0);
});
},
{
root: intersectionRoot,
rootMargin: `0px 0px 0px -${SIDEBAR_WIDTH}px`,
}
);
observer.observe(timelineBlock);
return () => {
observer.unobserve(timelineBlock);
};
}, [block]);
// hide the block if it doesn't have start and target dates and showAllBlocks is false
if (!block || !block.data || (!showAllBlocks && !(block.start_date && block.target_date))) return null;
// scroll to a hidden block
const handleScrollToBlock = () => {
const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement;
if (!scrollContainer || !block.position) return;
// update container's scroll position to the block's position
scrollContainer.scrollLeft = block.position.marginLeft - 4;
};
const isBlockVisibleOnChart = block.start_date && block.target_date;
const isBlockSelected = selectionHelpers.getIsEntitySelected(block.id);
const isBlockFocused = selectionHelpers.getIsEntityActive(block.id);
const isBlockHoveredOn = isBlockActive(block.id);
return (
<div
className="relative min-w-full w-max"
onMouseEnter={() => updateActiveBlockId(blockId)}
onMouseLeave={() => updateActiveBlockId(null)}
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
<div
className={cn("relative h-full", {
"rounded-l border border-r-0 border-custom-primary-70": getIsIssuePeeked(block.data.id),
"bg-custom-background-90": isBlockHoveredOn,
"bg-custom-primary-100/5 hover:bg-custom-primary-100/10": isBlockSelected,
"bg-custom-primary-100/10": isBlockSelected && isBlockHoveredOn,
"border border-r-0 border-custom-border-400": isBlockFocused,
})}
>
{isBlockVisibleOnChart
? isHidden && (
<button
type="button"
className="sticky z-[5] grid h-8 w-8 translate-y-1.5 cursor-pointer place-items-center rounded border border-custom-border-300 bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100"
style={{
left: `${SIDEBAR_WIDTH + 4}px`,
}}
onClick={handleScrollToBlock}
>
<ArrowRight
className={cn("h-3.5 w-3.5", {
"rotate-180": isBlockHiddenOnLeft,
})}
/>
</button>
)
: enableAddBlock && <ChartAddBlock block={block} blockUpdateHandler={blockUpdateHandler} />}
</div>
</div>
);
});

View File

@@ -1,124 +1,100 @@
import { RefObject, useRef } from "react";
import { observer } from "mobx-react";
// components
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
// helpers
import { cn } from "@/helpers/common.helper";
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// hooks
import { useIssueDetail } from "@/hooks/store";
import { TSelectionHelper } from "@/hooks/use-multiple-select";
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
// constants
import { BLOCK_HEIGHT } from "../constants";
// components
import { ChartAddBlock, ChartDraggable } from "../helpers";
import { useGanttChart } from "../hooks";
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types";
import { ChartDraggable } from "../helpers";
import { useGanttResizable } from "../helpers/blockResizables/use-gantt-resizable";
import { IBlockUpdateDependencyData } from "../types";
type Props = {
blockId: string;
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
showAllBlocks: boolean;
blockToRender: (data: any) => React.ReactNode;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableBlockLeftResize: boolean;
enableBlockRightResize: boolean;
enableBlockMove: boolean;
enableAddBlock: boolean;
ganttContainerRef: React.RefObject<HTMLDivElement>;
selectionHelpers: TSelectionHelper;
ganttContainerRef: RefObject<HTMLDivElement>;
updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise<void>;
};
export const GanttChartBlock: React.FC<Props> = observer((props) => {
const {
blockId,
getBlockById,
showAllBlocks,
blockToRender,
blockUpdateHandler,
enableBlockLeftResize,
enableBlockRightResize,
enableBlockMove,
enableAddBlock,
ganttContainerRef,
selectionHelpers,
updateBlockDates,
} = props;
// store hooks
const { currentViewData, updateActiveBlockId, isBlockActive } = useGanttChart();
const { getIsIssuePeeked } = useIssueDetail();
const { updateActiveBlockId, getBlockById, getIsCurrentDependencyDragging, currentView } = useTimeLineChartStore();
// refs
const resizableRef = useRef<HTMLDivElement>(null);
const block = getBlockById(blockId, currentViewData);
const block = getBlockById(blockId);
const isCurrentDependencyDragging = getIsCurrentDependencyDragging(blockId);
const { isMoving, handleBlockDrag } = useGanttResizable(block, resizableRef, ganttContainerRef, updateBlockDates);
// hide the block if it doesn't have start and target dates and showAllBlocks is false
if (!block || (!showAllBlocks && !(block.start_date && block.target_date))) return null;
const isBlockVisibleOnChart = block.start_date && block.target_date;
const handleChartBlockPosition = (
block: IGanttBlock,
totalBlockShifts: number,
dragDirection: "left" | "right" | "move"
) => {
const originalStartDate = getDate(block.start_date);
const originalTargetDate = getDate(block.target_date);
if (!originalStartDate || !originalTargetDate) return;
const updatedStartDate = new Date(originalStartDate);
const updatedTargetDate = new Date(originalTargetDate);
// update the start date on left resize
if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts);
// update the target date on right resize
else if (dragDirection === "right") updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
// update both the dates on x-axis move
else if (dragDirection === "move") {
updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts);
updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
}
// call the block update handler with the updated dates
blockUpdateHandler(block.data, {
start_date: renderFormattedPayloadDate(updatedStartDate) ?? undefined,
target_date: renderFormattedPayloadDate(updatedTargetDate) ?? undefined,
});
};
if (!block.data) return null;
const isBlockSelected = selectionHelpers.getIsEntitySelected(block.id);
const isBlockFocused = selectionHelpers.getIsEntityActive(block.id);
const isBlockHoveredOn = isBlockActive(block.id);
return (
<div
className="relative min-w-full w-max"
className={cn("relative z-[5]", {
"transition-all": !!isMoving && currentView === "week",
"pointer-events-none": !isBlockVisibleOnChart,
})}
id={`gantt-block-${block.id}`}
ref={resizableRef}
style={{
height: `${BLOCK_HEIGHT}px`,
transform: `translateX(${block.position?.marginLeft}px)`,
width: `${block.position?.width}px`,
}}
>
<div
className={cn("relative h-full", {
"rounded-l border border-r-0 border-custom-primary-70": getIsIssuePeeked(block.data.id),
"bg-custom-background-90": isBlockHoveredOn,
"bg-custom-primary-100/5 hover:bg-custom-primary-100/10": isBlockSelected,
"bg-custom-primary-100/10": isBlockSelected && isBlockHoveredOn,
"border border-r-0 border-custom-border-400": isBlockFocused,
})}
onMouseEnter={() => updateActiveBlockId(blockId)}
onMouseLeave={() => updateActiveBlockId(null)}
>
{isBlockVisibleOnChart ? (
<ChartDraggable
block={block}
blockToRender={blockToRender}
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
ganttContainerRef={ganttContainerRef}
/>
) : (
enableAddBlock && <ChartAddBlock block={block} blockUpdateHandler={blockUpdateHandler} />
)}
</div>
{isBlockVisibleOnChart && (
<RenderIfVisible
root={ganttContainerRef}
horizontalOffset={100}
verticalOffset={200}
classNames="flex h-full w-full items-center"
placeholderChildren={<div className="h-8 w-full bg-custom-background-80 rounded" />}
shouldRecordHeights={false}
forceRender={isCurrentDependencyDragging}
>
<div
className={cn("relative h-full w-full")}
onMouseEnter={() => updateActiveBlockId(blockId)}
onMouseLeave={() => updateActiveBlockId(null)}
>
<ChartDraggable
block={block}
blockToRender={blockToRender}
handleBlockDrag={handleBlockDrag}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
isMoving={isMoving}
ganttContainerRef={ganttContainerRef}
/>
</div>
</RenderIfVisible>
)}
</div>
);
});

View File

@@ -1,60 +1,39 @@
import { FC } from "react";
// hooks
import { TSelectionHelper } from "@/hooks/use-multiple-select";
// constants
import { HEADER_HEIGHT } from "../constants";
// types
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types";
// components
//
import { IBlockUpdateDependencyData } from "../types";
import { GanttChartBlock } from "./block";
export type GanttChartBlocksProps = {
itemsContainerWidth: number;
blockIds: string[];
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
blockToRender: (data: any) => React.ReactNode;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableBlockLeftResize: boolean | ((blockId: string) => boolean);
enableBlockRightResize: boolean | ((blockId: string) => boolean);
enableBlockMove: boolean | ((blockId: string) => boolean);
enableAddBlock: boolean | ((blockId: string) => boolean);
ganttContainerRef: React.RefObject<HTMLDivElement>;
showAllBlocks: boolean;
selectionHelpers: TSelectionHelper;
updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise<void>;
};
export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
const {
itemsContainerWidth,
blockIds,
blockToRender,
blockUpdateHandler,
getBlockById,
enableBlockLeftResize,
enableBlockRightResize,
enableBlockMove,
enableAddBlock,
ganttContainerRef,
showAllBlocks,
selectionHelpers,
updateBlockDates,
} = props;
return (
<div
className="h-full"
style={{
width: `${itemsContainerWidth}px`,
transform: `translateY(${HEADER_HEIGHT}px)`,
}}
>
<>
{blockIds?.map((blockId) => (
<GanttChartBlock
key={blockId}
blockId={blockId}
getBlockById={getBlockById}
showAllBlocks={showAllBlocks}
blockToRender={blockToRender}
blockUpdateHandler={blockUpdateHandler}
enableBlockLeftResize={
typeof enableBlockLeftResize === "function" ? enableBlockLeftResize(blockId) : enableBlockLeftResize
}
@@ -62,11 +41,10 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
typeof enableBlockRightResize === "function" ? enableBlockRightResize(blockId) : enableBlockRightResize
}
enableBlockMove={typeof enableBlockMove === "function" ? enableBlockMove(blockId) : enableBlockMove}
enableAddBlock={typeof enableAddBlock === "function" ? enableAddBlock(blockId) : enableAddBlock}
ganttContainerRef={ganttContainerRef}
selectionHelpers={selectionHelpers}
updateBlockDates={updateBlockDates}
/>
))}
</div>
</>
);
};

View File

@@ -1,14 +1,16 @@
import { observer } from "mobx-react";
import { Expand, Shrink } from "lucide-react";
// hooks
// helpers
// plane
import { Row } from "@plane/ui";
// components
import { VIEWS_LIST } from "@/components/gantt-chart/data";
// helpers
import { cn } from "@/helpers/common.helper";
// types
import { useGanttChart } from "../hooks/use-gantt-chart";
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { GANTT_BREADCRUMBS_HEIGHT } from "../constants";
import { TGanttViews } from "../types";
// constants
type Props = {
blockIds: string[];
@@ -24,10 +26,13 @@ export const GanttChartHeader: React.FC<Props> = observer((props) => {
const { blockIds, fullScreenMode, handleChartView, handleToday, loaderTitle, toggleFullScreenMode, showToday } =
props;
// chart hook
const { currentView } = useGanttChart();
const { currentView } = useTimeLineChartStore();
return (
<Row className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap py-2">
<Row
className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap py-2"
style={{ height: `${GANTT_BREADCRUMBS_HEIGHT}px` }}
>
<div className="ml-auto">
<div className="ml-auto text-sm font-medium">
{blockIds ? `${blockIds.length} ${loaderTitle}` : "Loading..."}

View File

@@ -5,36 +5,34 @@ import { observer } from "mobx-react";
// components
import { MultipleSelectGroup } from "@/components/core";
import {
BiWeekChartView,
ChartDataType,
DayChartView,
GanttChartBlocksList,
GanttChartSidebar,
HourChartView,
IBlockUpdateData,
IGanttBlock,
IBlockUpdateDependencyData,
MonthChartView,
QuarterChartView,
TGanttViews,
WeekChartView,
YearChartView,
} from "@/components/gantt-chart";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
// plane web components
import { TimelineDependencyPaths, TimelineDraggablePath } from "@/plane-web/components/gantt-chart";
import { IssueBulkOperationsRoot } from "@/plane-web/components/issues";
// plane web hooks
import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-status";
// constants
import { GANTT_SELECT_GROUP } from "../constants";
// hooks
import { useGanttChart } from "../hooks/use-gantt-chart";
//
import { GanttChartRowList } from "../blocks/block-row-list";
import { GANTT_SELECT_GROUP, HEADER_HEIGHT } from "../constants";
import { TimelineDragHelper } from "./timeline-drag-helper";
type Props = {
blockIds: string[];
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise<void>;
blockToRender: (data: any) => React.ReactNode;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
bottomSpacing: boolean;
@@ -55,7 +53,6 @@ type Props = {
export const GanttChartMainContent: React.FC<Props> = observer((props) => {
const {
blockIds,
getBlockById,
loadMoreBlocks,
blockToRender,
blockUpdateHandler,
@@ -73,11 +70,12 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
canLoadMoreBlocks,
updateCurrentViewRenderPayload,
quickAdd,
updateBlockDates,
} = props;
// refs
const ganttContainerRef = useRef<HTMLDivElement>(null);
// chart hook
const { currentView, currentViewData } = useGanttChart();
const { currentView, currentViewData } = useTimeLineChartStore();
// plane web hooks
const isBulkOperationsEnabled = useBulkOperationStatus();
@@ -91,15 +89,15 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
autoScrollForElements({
element,
getAllowedAxis: () => "vertical",
canScroll: ({ source }) => source.data.dragInstanceId === "GANTT_REORDER",
})
);
}, [ganttContainerRef?.current]);
// handling scroll functionality
const onScroll = (e: React.UIEvent<HTMLDivElement, UIEvent>) => {
const { clientWidth, scrollLeft, scrollWidth } = e.currentTarget;
// updateScrollLeft(scrollLeft);
const approxRangeLeft = scrollLeft >= clientWidth + 1000 ? 1000 : scrollLeft - clientWidth;
const approxRangeRight = scrollWidth - (scrollLeft + clientWidth);
@@ -110,77 +108,89 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
const CHART_VIEW_COMPONENTS: {
[key in TGanttViews]: React.FC;
} = {
hours: HourChartView,
day: DayChartView,
week: WeekChartView,
bi_week: BiWeekChartView,
month: MonthChartView,
quarter: QuarterChartView,
year: YearChartView,
};
if (!currentView) return null;
const ActiveChartView = CHART_VIEW_COMPONENTS[currentView];
return (
<MultipleSelectGroup
containerRef={ganttContainerRef}
entities={{
[GANTT_SELECT_GROUP]: blockIds ?? [],
}}
disabled={!isBulkOperationsEnabled}
>
{(helpers) => (
<>
<div
// DO NOT REMOVE THE ID
id="gantt-container"
className={cn(
"h-full w-full overflow-auto vertical-scrollbar horizontal-scrollbar scrollbar-lg flex border-t-[0.5px] border-custom-border-200",
{
"mb-8": bottomSpacing,
}
)}
ref={ganttContainerRef}
onScroll={onScroll}
>
<GanttChartSidebar
blockIds={blockIds}
getBlockById={getBlockById}
loadMoreBlocks={loadMoreBlocks}
canLoadMoreBlocks={canLoadMoreBlocks}
ganttContainerRef={ganttContainerRef}
blockUpdateHandler={blockUpdateHandler}
enableReorder={enableReorder}
enableSelection={enableSelection}
sidebarToRender={sidebarToRender}
title={title}
quickAdd={quickAdd}
selectionHelpers={helpers}
/>
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
<ActiveChartView />
{currentViewData && (
<GanttChartBlocksList
itemsContainerWidth={itemsContainerWidth}
blockIds={blockIds}
getBlockById={getBlockById}
blockToRender={blockToRender}
blockUpdateHandler={blockUpdateHandler}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableAddBlock={enableAddBlock}
ganttContainerRef={ganttContainerRef}
showAllBlocks={showAllBlocks}
selectionHelpers={helpers}
/>
<>
<TimelineDragHelper ganttContainerRef={ganttContainerRef} />
<MultipleSelectGroup
containerRef={ganttContainerRef}
entities={{
[GANTT_SELECT_GROUP]: blockIds ?? [],
}}
disabled={!isBulkOperationsEnabled}
>
{(helpers) => (
<>
<div
// DO NOT REMOVE THE ID
id="gantt-container"
className={cn(
"h-full w-full overflow-auto vertical-scrollbar horizontal-scrollbar scrollbar-lg flex border-t-[0.5px] border-custom-border-200",
{
"mb-8": bottomSpacing,
}
)}
ref={ganttContainerRef}
onScroll={onScroll}
>
<GanttChartSidebar
blockIds={blockIds}
loadMoreBlocks={loadMoreBlocks}
canLoadMoreBlocks={canLoadMoreBlocks}
ganttContainerRef={ganttContainerRef}
blockUpdateHandler={blockUpdateHandler}
enableReorder={enableReorder}
enableSelection={enableSelection}
sidebarToRender={sidebarToRender}
title={title}
quickAdd={quickAdd}
selectionHelpers={helpers}
/>
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
<ActiveChartView />
{currentViewData && (
<div
className="relative h-full"
style={{
width: `${itemsContainerWidth}px`,
transform: `translateY(${HEADER_HEIGHT}px)`,
}}
>
<GanttChartRowList
blockIds={blockIds}
blockUpdateHandler={blockUpdateHandler}
enableAddBlock={enableAddBlock}
showAllBlocks={showAllBlocks}
selectionHelpers={helpers}
ganttContainerRef={ganttContainerRef}
/>
<TimelineDependencyPaths />
<TimelineDraggablePath />
<GanttChartBlocksList
blockIds={blockIds}
blockToRender={blockToRender}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
ganttContainerRef={ganttContainerRef}
showAllBlocks={showAllBlocks}
updateBlockDates={updateBlockDates}
/>
</div>
)}
</div>
</div>
</div>
<IssueBulkOperationsRoot selectionHelpers={helpers} />
</>
)}
</MultipleSelectGroup>
<IssueBulkOperationsRoot selectionHelpers={helpers} />
</>
)}
</MultipleSelectGroup>
</>
);
});

View File

@@ -1,19 +1,16 @@
import { FC, useEffect, useState } from "react";
import { observer } from "mobx-react";
// hooks
// components
import { GanttChartHeader, GanttChartMainContent } from "@/components/gantt-chart";
// views
// helpers
import { cn } from "@/helpers/common.helper";
// types
// data
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { SIDEBAR_WIDTH } from "../constants";
import { currentViewDataWithView } from "../data";
// constants
import { useGanttChart } from "../hooks/use-gantt-chart";
import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types";
import { generateMonthChart, getNumberOfDaysBetweenTwoDatesInMonth } from "../views";
import { ChartDataType, IBlockUpdateData, IBlockUpdateDependencyData, TGanttViews } from "../types";
import { getNumberOfDaysBetweenTwoDates, IMonthBlock, IMonthView, IWeekBlock, timelineViewHelpers } from "../views";
type ChartViewRootProps = {
border: boolean;
@@ -31,8 +28,8 @@ type ChartViewRootProps = {
enableSelection: boolean | ((blockId: string) => boolean);
bottomSpacing: boolean;
showAllBlocks: boolean;
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
loadMoreBlocks?: () => void;
updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise<void>;
canLoadMoreBlocks?: boolean;
quickAdd?: React.JSX.Element | undefined;
showToday: boolean;
@@ -43,7 +40,6 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
border,
title,
blockIds,
getBlockById,
loadMoreBlocks,
loaderTitle,
blockUpdateHandler,
@@ -60,13 +56,14 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
showAllBlocks,
quickAdd,
showToday,
updateBlockDates,
} = props;
// states
const [itemsContainerWidth, setItemsContainerWidth] = useState(0);
const [fullScreenMode, setFullScreenMode] = useState(false);
// hooks
const { currentView, currentViewData, renderView, updateCurrentView, updateCurrentViewData, updateRenderView } =
useGanttChart();
useTimeLineChartStore();
const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews) => {
const selectedCurrentView: TGanttViews = view;
@@ -77,21 +74,25 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
if (selectedCurrentViewData === undefined) return;
let currentRender: any;
if (selectedCurrentView === "month") currentRender = generateMonthChart(selectedCurrentViewData, side);
const currentViewHelpers = timelineViewHelpers[selectedCurrentView];
const currentRender = currentViewHelpers.generateChart(selectedCurrentViewData, side);
const mergeRenderPayloads = currentViewHelpers.mergeRenderPayloads as (
a: IWeekBlock[] | IMonthView | IMonthBlock[],
b: IWeekBlock[] | IMonthView | IMonthBlock[]
) => IWeekBlock[] | IMonthView | IMonthBlock[];
// updating the prevData, currentData and nextData
if (currentRender.payload.length > 0) {
if (currentRender.payload) {
updateCurrentViewData(currentRender.state);
if (side === "left") {
updateCurrentView(selectedCurrentView);
updateRenderView([...currentRender.payload, ...renderView]);
updateRenderView(mergeRenderPayloads(currentRender.payload, renderView));
updatingCurrentLeftScrollPosition(currentRender.scrollWidth);
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
} else if (side === "right") {
updateCurrentView(view);
updateRenderView([...renderView, ...currentRender.payload]);
updateRenderView(mergeRenderPayloads(renderView, currentRender.payload));
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
} else {
updateCurrentView(view);
@@ -127,24 +128,12 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
const clientVisibleWidth: number = scrollContainer?.clientWidth;
let scrollWidth: number = 0;
let daysDifference: number = 0;
// if (currentView === "hours")
// daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
// if (currentView === "day")
// daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
// if (currentView === "week")
// daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
// if (currentView === "bi_week")
// daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
if (currentView === "month")
daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date);
// if (currentView === "quarter")
// daysDifference = getNumberOfDaysBetweenTwoDatesInQuarter(currentState.data.startDate, date);
// if (currentView === "year")
// daysDifference = getNumberOfDaysBetweenTwoDatesInYear(currentState.data.startDate, date);
daysDifference = getNumberOfDaysBetweenTwoDates(currentState.data.startDate, date);
scrollWidth =
daysDifference * currentState.data.width - (clientVisibleWidth / 2 - currentState.data.width) + SIDEBAR_WIDTH / 2;
Math.abs(daysDifference) * currentState.data.dayWidth -
(clientVisibleWidth / 2 - currentState.data.dayWidth) +
SIDEBAR_WIDTH / 2;
scrollContainer.scrollLeft = scrollWidth;
};
@@ -167,7 +156,6 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
/>
<GanttChartMainContent
blockIds={blockIds}
getBlockById={getBlockById}
loadMoreBlocks={loadMoreBlocks}
canLoadMoreBlocks={canLoadMoreBlocks}
blockToRender={blockToRender}
@@ -185,6 +173,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
title={title}
updateCurrentViewRenderPayload={updateCurrentViewRenderPayload}
quickAdd={quickAdd}
updateBlockDates={updateBlockDates}
/>
</div>
);

View File

@@ -0,0 +1,18 @@
import { RefObject } from "react";
import { observer } from "mobx-react";
// hooks
import { useAutoScroller } from "@/hooks/use-auto-scroller";
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../constants";
type Props = {
ganttContainerRef: RefObject<HTMLDivElement>;
};
export const TimelineDragHelper = observer((props: Props) => {
const { ganttContainerRef } = props;
const { isDragging } = useTimeLineChartStore();
useAutoScroller(ganttContainerRef, isDragging, SIDEBAR_WIDTH, HEADER_HEIGHT);
return <></>;
});

View File

@@ -1,54 +0,0 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart";
export const BiWeekChartView: FC<any> = observer(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { currentViewData, renderView } = useGanttChart();
return (
<>
<div className="absolute flex h-full flex-grow divide-x divide-custom-border-200">
{renderView &&
renderView.length > 0 &&
renderView.map((_itemRoot: any, _idxRoot: any) => (
<div key={`title-${_idxRoot}`} className="relative flex flex-col">
<div className="relative border-b border-custom-border-200">
<div className="sticky left-0 inline-flex whitespace-nowrap px-2 py-1 text-sm font-medium capitalize">
{_itemRoot?.title}
</div>
</div>
<div className="flex h-full w-full divide-x divide-custom-border-200">
{_itemRoot.children &&
_itemRoot.children.length > 0 &&
_itemRoot.children.map((_item: any, _idx: any) => (
<div
key={`sub-title-${_idxRoot}-${_idx}`}
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
style={{ width: `${currentViewData?.data.width}px` }}
>
<div
className={`flex-shrink-0 border-b py-1 text-center text-sm font-medium capitalize ${
_item?.today ? `border-red-500 text-red-500` : `border-custom-border-200`
}`}
>
<div>{_item.title}</div>
</div>
<div
className={`relative flex h-full w-full flex-1 justify-center ${
["sat", "sun"].includes(_item?.dayData?.shortTitle || "") ? `bg-custom-background-80` : ``
}`}
>
{_item?.today && <div className="absolute bottom-0 top-0 border border-red-500"> </div>}
</div>
</div>
))}
</div>
</div>
))}
</div>
</>
);
});

View File

@@ -1,54 +0,0 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart";
export const DayChartView: FC<any> = observer(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { currentViewData, renderView } = useGanttChart();
return (
<>
<div className="absolute flex h-full flex-grow divide-x divide-custom-border-200">
{renderView &&
renderView.length > 0 &&
renderView.map((_itemRoot: any, _idxRoot: any) => (
<div key={`title-${_idxRoot}`} className="relative flex flex-col">
<div className="relative border-b border-custom-border-200">
<div className="sticky left-0 inline-flex whitespace-nowrap px-2 py-1 text-sm font-medium capitalize">
{_itemRoot?.title}
</div>
</div>
<div className="flex h-full w-full divide-x divide-custom-border-200">
{_itemRoot.children &&
_itemRoot.children.length > 0 &&
_itemRoot.children.map((_item: any, _idx: any) => (
<div
key={`sub-title-${_idxRoot}-${_idx}`}
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
style={{ width: `${currentViewData?.data.width}px` }}
>
<div
className={`flex-shrink-0 border-b py-1 text-center text-sm font-medium capitalize ${
_item?.today ? `border-red-500 text-red-500` : `border-custom-border-200`
}`}
>
<div>{_item.title}</div>
</div>
<div
className={`relative flex h-full w-full flex-1 justify-center ${
["sat", "sun"].includes(_item?.dayData?.shortTitle || "") ? `bg-gray-100` : ``
}`}
>
{_item?.today && <div className="absolute bottom-0 top-0 border border-red-500"> </div>}
</div>
</div>
))}
</div>
</div>
))}
</div>
</>
);
});

View File

@@ -1,54 +0,0 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart";
export const HourChartView: FC<any> = observer(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { currentViewData, renderView } = useGanttChart();
return (
<>
<div className="absolute flex h-full flex-grow divide-x divide-custom-border-200">
{renderView &&
renderView.length > 0 &&
renderView.map((_itemRoot: any, _idxRoot: any) => (
<div key={`title-${_idxRoot}`} className="relative flex flex-col">
<div className="relative border-b border-custom-border-200">
<div className="sticky left-0 inline-flex whitespace-nowrap px-2 py-1 text-sm font-medium capitalize">
{_itemRoot?.title}
</div>
</div>
<div className="flex h-full w-full divide-x divide-custom-border-200">
{_itemRoot.children &&
_itemRoot.children.length > 0 &&
_itemRoot.children.map((_item: any, _idx: any) => (
<div
key={`sub-title-${_idxRoot}-${_idx}`}
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
style={{ width: `${currentViewData?.data.width}px` }}
>
<div
className={`flex-shrink-0 border-b py-1 text-center text-sm font-medium capitalize ${
_item?.today ? `border-red-500 text-red-500` : `border-custom-border-200`
}`}
>
<div>{_item.title}</div>
</div>
<div
className={`relative flex h-full w-full flex-1 justify-center ${
["sat", "sun"].includes(_item?.dayData?.shortTitle || "") ? `bg-gray-100` : ``
}`}
>
{_item?.today && <div className="absolute bottom-0 top-0 border border-red-500"> </div>}
</div>
</div>
))}
</div>
</div>
))}
</div>
</>
);
});

View File

@@ -1,7 +1,3 @@
export * from "./bi-week";
export * from "./day";
export * from "./hours";
export * from "./month";
export * from "./quarter";
export * from "./week";
export * from "./year";

View File

@@ -1,75 +1,105 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
// components
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "@/components/gantt-chart/constants";
import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
// types
import { IMonthBlock } from "../../views";
// constants
import { IMonthView } from "../../views";
import { getNumberOfDaysBetweenTwoDates } from "../../views/helpers";
export const MonthChartView: FC<any> = observer(() => {
// chart hook
const { currentViewData, renderView } = useGanttChart();
const monthBlocks: IMonthBlock[] = renderView;
const { currentViewData, renderView } = useTimeLineChartStore();
const monthView: IMonthView = renderView;
if (!monthView) return <></>;
const { months, weeks } = monthView;
const monthsStartDate = new Date(months[0].year, months[0].month, 1);
const weeksStartDate = weeks[0].startDate;
const marginLeftDays = getNumberOfDaysBetweenTwoDates(monthsStartDate, weeksStartDate);
return (
<div className="absolute top-0 left-0 min-h-full h-max w-max flex divide-x divide-custom-border-100/50">
{monthBlocks?.map((block, rootIndex) => (
<div key={`month-${block?.month}-${block?.year}`} className="relative flex flex-col">
<div className={`absolute top-0 left-0 h-max w-max flex`} style={{ minHeight: `calc(100% + ${HEADER_HEIGHT}px` }}>
{currentViewData && (
<div className="relative flex flex-col outline-[0.25px] outline outline-custom-border-200">
{/** Header Div */}
<div
className="w-full sticky top-0 z-[5] bg-custom-background-100 flex-shrink-0"
style={{
height: `${HEADER_HEIGHT}px`,
}}
>
<div className="h-1/2">
<div
className="sticky inline-flex whitespace-nowrap px-3 py-2 text-xs font-medium capitalize"
style={{
left: `${SIDEBAR_WIDTH}px`,
}}
>
{block?.title}
</div>
</div>
<div className="h-1/2 w-full flex">
{block?.children?.map((monthDay, index) => (
{/** Main Month Title */}
<div className="flex h-7" style={{ marginLeft: `${marginLeftDays * currentViewData.data.dayWidth}px` }}>
{months?.map((monthBlock) => (
<div
key={`sub-title-${rootIndex}-${index}`}
className="flex-shrink-0 border-b-[0.5px] border-custom-border-200 py-1 text-center capitalize"
style={{ width: `${currentViewData?.data.width}px` }}
key={`month-${monthBlock?.month}-${monthBlock?.year}`}
className="flex outline-[0.5px] outline outline-custom-border-200"
style={{ width: `${monthBlock.days * currentViewData?.data.dayWidth}px` }}
>
<div className="space-x-1 text-xs">
<span className="text-custom-text-200">{monthDay.dayData.shortTitle[0]}</span>{" "}
<span
className={cn({
"rounded-full bg-custom-primary-100 px-1 text-white": monthDay.today,
})}
>
{monthDay.day}
</span>
<div
className="sticky flex items-center font-normal z-[1] m-1 whitespace-nowrap px-3 py-1 text-base capitalize bg-custom-background-100 text-custom-text-200"
style={{
left: `${SIDEBAR_WIDTH}px`,
}}
>
{monthBlock?.title}
{monthBlock.today && (
<span className={cn("rounded ml-2 font-medium bg-custom-primary-100 px-1 text-2xs text-white")}>
Current
</span>
)}
</div>
</div>
))}
</div>
{/** Weeks Sub title */}
<div className="h-5 w-full flex">
{weeks?.map((weekBlock) => (
<div
key={`sub-title-${weekBlock.startDate}-${weekBlock.endDate}`}
className={cn(
"flex flex-shrink-0 py-1 px-2 text-center capitalize justify-between outline-[0.25px] outline outline-custom-border-200",
{
"bg-custom-primary-10": weekBlock.today,
}
)}
style={{ width: `${currentViewData?.data.dayWidth * 7}px` }}
>
<div className="space-x-1 text-xs font-medium text-custom-text-400">
<span
className={cn({
"rounded bg-custom-primary-100 px-1 text-white": weekBlock.today,
})}
>
{weekBlock.startDate.getDate()}-{weekBlock.endDate.getDate()}
</span>
</div>
<div className="space-x-1 text-xs font-medium">{weekBlock.weekData.shortTitle}</div>
</div>
))}
</div>
</div>
<div className="h-full w-full flex-grow flex divide-x divide-custom-border-100/50">
{block?.children?.map((monthDay, index) => (
{/** Week Columns */}
<div className="h-full w-full flex-grow flex">
{weeks?.map((weekBlock) => (
<div
key={`column-${rootIndex}-${index}`}
className="h-full overflow-hidden"
style={{ width: `${currentViewData?.data.width}px` }}
>
{["sat", "sun"].includes(monthDay?.dayData?.shortTitle) && (
<div className="h-full bg-custom-background-90" />
)}
</div>
key={`column-${weekBlock.startDate}-${weekBlock.endDate}`}
className={cn("h-full overflow-hidden outline-[0.25px] outline outline-custom-border-100", {
"bg-custom-primary-10": weekBlock.today,
})}
style={{ width: `${currentViewData?.data.dayWidth * 7}px` }}
/>
))}
</div>
</div>
))}
)}
</div>
);
});

View File

@@ -1,50 +1,93 @@
import { FC } from "react";
import { observer } from "mobx-react";
// Plane
import { cn } from "@plane/editor";
// hooks
import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart";
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../../constants";
import { groupMonthsToQuarters, IMonthBlock, IQuarterMonthBlock } from "../../views";
export const QuarterChartView: FC<any> = observer(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { currentViewData, renderView } = useGanttChart();
const { currentViewData, renderView } = useTimeLineChartStore();
const monthBlocks: IMonthBlock[] = renderView;
const quarterBlocks: IQuarterMonthBlock[] = groupMonthsToQuarters(monthBlocks);
return (
<>
<div className="absolute flex h-full flex-grow divide-x divide-custom-border-200">
{renderView &&
renderView.length > 0 &&
renderView.map((_itemRoot: any, _idxRoot: any) => (
<div key={`title-${_idxRoot}`} className="relative flex flex-col">
<div className="relative border-b border-custom-border-200">
<div className="sticky left-0 inline-flex whitespace-nowrap px-2 py-1 text-sm font-medium capitalize">
{_itemRoot?.title}
<div className={`absolute top-0 left-0 h-max w-max flex`} style={{ minHeight: `calc(100% + ${HEADER_HEIGHT}px` }}>
{currentViewData &&
quarterBlocks?.map((quarterBlock, rootIndex) => (
<div
key={`month-${quarterBlock.quarterNumber}-${quarterBlock.year}`}
className="relative flex flex-col outline-[0.25px] outline outline-custom-border-200"
>
{/** Header Div */}
<div
className="w-full sticky top-0 z-[5] bg-custom-background-100 flex-shrink-0 outline-[1px] outline outline-custom-border-200"
style={{
height: `${HEADER_HEIGHT}px`,
}}
>
{/** Main Quarter Title */}
<div className="w-full inline-flex h-7 justify-between">
<div
className="sticky flex items-center font-normal z-[1] my-1 whitespace-nowrap px-3 py-1 text-base capitalize bg-custom-background-100 text-custom-text-200"
style={{
left: `${SIDEBAR_WIDTH}px`,
}}
>
{quarterBlock?.title}
{quarterBlock.today && (
<span className={cn("rounded ml-2 font-medium bg-custom-primary-100 px-1 text-2xs text-white")}>
Current
</span>
)}
</div>
<div className="sticky whitespace-nowrap px-3 py-2 text-xs capitalize text-custom-text-400">
{quarterBlock.shortTitle}
</div>
</div>
<div className="flex h-full w-full divide-x divide-custom-border-200">
{_itemRoot.children &&
_itemRoot.children.length > 0 &&
_itemRoot.children.map((_item: any, _idx: any) => (
<div
key={`sub-title-${_idxRoot}-${_idx}`}
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
style={{ width: `${currentViewData?.data.width}px` }}
>
<div
className={`flex-shrink-0 border-b py-1 text-center text-sm font-medium capitalize ${
_item?.today ? `border-red-500 text-red-500` : `border-custom-border-200`
}`}
{/** Months Sub title */}
<div className="h-5 w-full flex">
{quarterBlock?.children?.map((monthBlock, index) => (
<div
key={`sub-title-${rootIndex}-${index}`}
className={cn(
"flex flex-shrink-0 text-center capitalize justify-center outline-[0.25px] outline outline-custom-border-200",
{
"bg-custom-primary-10": monthBlock.today,
}
)}
style={{ width: `${currentViewData?.data.dayWidth * monthBlock.days}px` }}
>
<div className="space-x-1 flex items-center justify-center text-xs font-medium h-full">
<span
className={cn({
"rounded-lg bg-custom-primary-100 px-2 text-white": monthBlock.today,
})}
>
<div>{_item.title}</div>
</div>
<div className={`relative flex h-full w-full flex-1 justify-center`}>
{_item?.today && <div className="absolute bottom-0 top-0 border border-red-500"> </div>}
</div>
{monthBlock.monthData.shortTitle}
</span>
</div>
))}
</div>
))}
</div>
</div>
))}
</div>
</>
{/** Month Columns */}
<div className="h-full w-full flex-grow flex">
{quarterBlock?.children?.map((monthBlock, index) => (
<div
key={`column-${rootIndex}-${index}`}
className={cn("h-full overflow-hidden outline-[0.25px] outline outline-custom-border-100", {
"bg-custom-primary-10": monthBlock.today,
})}
style={{ width: `${currentViewData?.data.dayWidth * monthBlock.days}px` }}
/>
))}
</div>
</div>
))}
</div>
);
});

View File

@@ -1,54 +1,93 @@
import { FC } from "react";
import { observer } from "mobx-react";
// Plane
import { cn } from "@plane/editor";
// hooks
import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart";
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../../constants";
import { IWeekBlock } from "../../views";
export const WeekChartView: FC<any> = observer(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { currentViewData, renderView } = useGanttChart();
const { currentViewData, renderView } = useTimeLineChartStore();
const weekBlocks: IWeekBlock[] = renderView;
return (
<>
<div className="absolute flex h-full flex-grow divide-x divide-custom-border-200">
{renderView &&
renderView.length > 0 &&
renderView.map((_itemRoot: any, _idxRoot: any) => (
<div key={`title-${_idxRoot}`} className="relative flex flex-col">
<div className="relative border-b border-custom-border-200">
<div className="sticky left-0 inline-flex whitespace-nowrap px-2 py-1 text-sm font-medium capitalize">
{_itemRoot?.title}
<div className={`absolute top-0 left-0 h-max w-max flex`} style={{ minHeight: `calc(100% + ${HEADER_HEIGHT}px` }}>
{currentViewData &&
weekBlocks?.map((block, rootIndex) => (
<div
key={`month-${block?.startDate}-${block?.endDate}`}
className="relative flex flex-col outline-[0.25px] outline outline-custom-border-200"
>
{/** Header Div */}
<div
className="w-full sticky top-0 z-[5] bg-custom-background-100 flex-shrink-0 outline-[1px] outline outline-custom-border-200"
style={{
height: `${HEADER_HEIGHT}px`,
}}
>
{/** Main Months Title */}
<div className="w-full inline-flex h-7 justify-between">
<div
className="sticky flex items-center font-normal z-[1] m-1 whitespace-nowrap px-3 py-1 text-sm capitalize bg-custom-background-100 text-custom-text-200"
style={{
left: `${SIDEBAR_WIDTH}px`,
}}
>
{block?.title}
</div>
<div className="sticky whitespace-nowrap px-3 py-2 text-xs capitalize text-custom-text-400">
{block?.weekData?.title}
</div>
</div>
<div className="flex h-full w-full divide-x divide-custom-border-200">
{_itemRoot.children &&
_itemRoot.children.length > 0 &&
_itemRoot.children.map((_item: any, _idx: any) => (
<div
key={`sub-title-${_idxRoot}-${_idx}`}
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
style={{ width: `${currentViewData?.data.width}px` }}
>
<div
className={`flex-shrink-0 border-b py-1 text-center text-sm font-medium capitalize ${
_item?.today ? `border-red-500 text-red-500` : `border-custom-border-200`
}`}
>
<div>{_item.title}</div>
</div>
<div
className={`relative flex h-full w-full flex-1 justify-center ${
["sat", "sun"].includes(_item?.dayData?.shortTitle || "") ? `bg-custom-background-80` : ``
}`}
>
{_item?.today && <div className="absolute bottom-0 top-0 border border-red-500"> </div>}
</div>
{/** Days Sub title */}
<div className="h-5 w-full flex">
{block?.children?.map((weekDay, index) => (
<div
key={`sub-title-${rootIndex}-${index}`}
className={cn(
"flex flex-shrink-0 p-1 text-center capitalize justify-between outline-[0.25px] outline outline-custom-border-200",
{
"bg-custom-primary-10": weekDay.today,
}
)}
style={{ width: `${currentViewData?.data.dayWidth}px` }}
>
<div className="space-x-1 text-xs font-medium text-custom-text-400">
{weekDay.dayData.abbreviation}
</div>
))}
<div className="space-x-1 text-xs font-medium">
<span
className={cn({
"rounded bg-custom-primary-100 px-1 text-white": weekDay.today,
})}
>
{weekDay.date.getDate()}
</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
</>
{/** Day Columns */}
<div className="h-full w-full flex-grow flex">
{block?.children?.map((weekDay, index) => (
<div
key={`column-${rootIndex}-${index}`}
className={cn("h-full overflow-hidden outline-[0.25px] outline outline-custom-border-100", {
"bg-custom-primary-10": weekDay.today,
})}
style={{ width: `${currentViewData?.data.dayWidth}px` }}
>
{["sat", "sun"].includes(weekDay?.dayData?.shortTitle) && (
<div className="h-full bg-custom-background-90 outline-[0.25px] outline outline-custom-border-300" />
)}
</div>
))}
</div>
</div>
))}
</div>
);
});

View File

@@ -1,50 +0,0 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "@/components/gantt-chart/hooks/use-gantt-chart";
export const YearChartView: FC<any> = observer(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { currentViewData, renderView } = useGanttChart();
return (
<>
<div className="absolute flex h-full flex-grow divide-x divide-custom-border-200">
{renderView &&
renderView.length > 0 &&
renderView.map((_itemRoot: any, _idxRoot: any) => (
<div key={`title-${_idxRoot}`} className="relative flex flex-col">
<div className="relative border-b border-custom-border-200">
<div className="sticky left-0 inline-flex whitespace-nowrap px-2 py-1 text-sm font-medium capitalize">
{_itemRoot?.title}
</div>
</div>
<div className="flex h-full w-full divide-x divide-custom-border-200">
{_itemRoot.children &&
_itemRoot.children.length > 0 &&
_itemRoot.children.map((_item: any, _idx: any) => (
<div
key={`sub-title-${_idxRoot}-${_idx}`}
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
style={{ width: `${currentViewData?.data.width}px` }}
>
<div
className={`flex-shrink-0 border-b py-1 text-center text-sm font-medium capitalize ${
_item?.today ? `border-red-500 text-red-500` : `border-custom-border-200`
}`}
>
<div>{_item.title}</div>
</div>
<div className={`relative flex h-full w-full flex-1 justify-center`}>
{_item?.today && <div className="absolute bottom-0 top-0 border border-red-500"> </div>}
</div>
</div>
))}
</div>
</div>
))}
</div>
</>
);
});

View File

@@ -1,6 +1,8 @@
export const BLOCK_HEIGHT = 44;
export const HEADER_HEIGHT = 60;
export const HEADER_HEIGHT = 48;
export const GANTT_BREADCRUMBS_HEIGHT = 40;
export const SIDEBAR_WIDTH = 360;

View File

@@ -1,23 +1,14 @@
import React, { FC, createContext } from "react";
// mobx store
import { GanttStore } from "@/store/issue/issue_gantt_view.store";
import { createContext, useContext } from "react";
let ganttViewStore = new GanttStore();
export enum ETimeLineTypeType {
ISSUE = "ISSUE",
MODULE = "MODULE",
}
export const GanttStoreContext = createContext<GanttStore>(ganttViewStore);
export const TimeLineTypeContext = createContext<ETimeLineTypeType | undefined>(undefined);
const initializeStore = () => {
const newGanttViewStore = ganttViewStore ?? new GanttStore();
if (typeof window === "undefined") return newGanttViewStore;
if (!ganttViewStore) ganttViewStore = newGanttViewStore;
return newGanttViewStore;
};
type GanttStoreProviderProps = {
children: React.ReactNode;
};
export const GanttStoreProvider: FC<GanttStoreProviderProps> = ({ children }) => {
const store = initializeStore();
return <GanttStoreContext.Provider value={store}>{children}</GanttStoreContext.Provider>;
export const useTimeLineType = () => {
const timelineType = useContext(TimeLineTypeContext);
return timelineType;
};

View File

@@ -3,28 +3,35 @@ import { WeekMonthDataType, ChartDataType, TGanttViews } from "../types";
// constants
export const weeks: WeekMonthDataType[] = [
{ key: 0, shortTitle: "sun", title: "sunday" },
{ key: 1, shortTitle: "mon", title: "monday" },
{ key: 2, shortTitle: "tue", title: "tuesday" },
{ key: 3, shortTitle: "wed", title: "wednesday" },
{ key: 4, shortTitle: "thurs", title: "thursday" },
{ key: 5, shortTitle: "fri", title: "friday" },
{ key: 6, shortTitle: "sat", title: "saturday" },
{ key: 0, shortTitle: "sun", title: "sunday", abbreviation: "Su" },
{ key: 1, shortTitle: "mon", title: "monday", abbreviation: "M" },
{ key: 2, shortTitle: "tue", title: "tuesday", abbreviation: "T" },
{ key: 3, shortTitle: "wed", title: "wednesday", abbreviation: "W" },
{ key: 4, shortTitle: "thurs", title: "thursday", abbreviation: "Th" },
{ key: 5, shortTitle: "fri", title: "friday", abbreviation: "F" },
{ key: 6, shortTitle: "sat", title: "saturday", abbreviation: "Sa" },
];
export const months: WeekMonthDataType[] = [
{ key: 0, shortTitle: "jan", title: "january" },
{ key: 1, shortTitle: "feb", title: "february" },
{ key: 2, shortTitle: "mar", title: "march" },
{ key: 3, shortTitle: "apr", title: "april" },
{ key: 4, shortTitle: "may", title: "may" },
{ key: 5, shortTitle: "jun", title: "june" },
{ key: 6, shortTitle: "jul", title: "july" },
{ key: 7, shortTitle: "aug", title: "august" },
{ key: 8, shortTitle: "sept", title: "september" },
{ key: 9, shortTitle: "oct", title: "october" },
{ key: 10, shortTitle: "nov", title: "november" },
{ key: 11, shortTitle: "dec", title: "december" },
{ key: 0, shortTitle: "jan", title: "january", abbreviation: "Jan" },
{ key: 1, shortTitle: "feb", title: "february", abbreviation: "Feb" },
{ key: 2, shortTitle: "mar", title: "march", abbreviation: "Mar" },
{ key: 3, shortTitle: "apr", title: "april", abbreviation: "Apr" },
{ key: 4, shortTitle: "may", title: "may", abbreviation: "May" },
{ key: 5, shortTitle: "jun", title: "june", abbreviation: "Jun" },
{ key: 6, shortTitle: "jul", title: "july", abbreviation: "Jul" },
{ key: 7, shortTitle: "aug", title: "august", abbreviation: "Aug" },
{ key: 8, shortTitle: "sept", title: "september", abbreviation: "Sept" },
{ key: 9, shortTitle: "oct", title: "october", abbreviation: "Oct" },
{ key: 10, shortTitle: "nov", title: "november", abbreviation: "Nov" },
{ key: 11, shortTitle: "dec", title: "december", abbreviation: "Dec" },
];
export const quarters: WeekMonthDataType[] = [
{ key: 0, shortTitle: "Q1", title: "Jan - Mar", abbreviation: "Q1" },
{ key: 1, shortTitle: "Q2", title: "Apr - Jun", abbreviation: "Q2" },
{ key: 2, shortTitle: "Q3", title: "Jul - Sept", abbreviation: "Q3" },
{ key: 3, shortTitle: "Q4", title: "Oct - Dec", abbreviation: "Q4" },
];
export const charCapitalize = (word: string) => `${word.charAt(0).toUpperCase()}${word.substring(1)}`;
@@ -54,50 +61,17 @@ export const datePreview = (date: Date, includeTime: boolean = false) => {
// context data
export const VIEWS_LIST: ChartDataType[] = [
// {
// key: "hours",
// title: "Hours",
// data: {
// startDate: new Date(),
// currentDate: new Date(),
// endDate: new Date(),
// approxFilterRange: 4,
// width: 40,
// },
// },
// {
// key: "days",
// title: "Days",
// data: {
// startDate: new Date(),
// currentDate: new Date(),
// endDate: new Date(),
// approxFilterRange: 4,
// width: 40,
// },
// },
// {
// key: "week",
// title: "Week",
// data: {
// startDate: new Date(),
// currentDate: new Date(),
// endDate: new Date(),
// approxFilterRange: 4,
// width: 180, // it will preview week dates with weekends highlighted with 1 week limitations ex: title (Wed 1, Thu 2, Fri 3)
// },
// },
// {
// key: "bi_week",
// title: "Bi-Week",
// data: {
// startDate: new Date(),
// currentDate: new Date(),
// endDate: new Date(),
// approxFilterRange: 4,
// width: 100, // it will preview monthly all dates with weekends highlighted with 3 week limitations ex: title (Wed 1, Thu 2, Fri 3)
// },
// },
{
key: "week",
title: "Week",
data: {
startDate: new Date(),
currentDate: new Date(),
endDate: new Date(),
approxFilterRange: 4, // it will preview week dates with weekends highlighted with 1 week limitations ex: title (Wed 1, Thu 2, Fri 3)
dayWidth: 60,
},
},
{
key: "month",
title: "Month",
@@ -105,32 +79,21 @@ export const VIEWS_LIST: ChartDataType[] = [
startDate: new Date(),
currentDate: new Date(),
endDate: new Date(),
approxFilterRange: 6,
width: 55, // it will preview monthly all dates with weekends highlighted with no limitations ex: title (1, 2, 3)
approxFilterRange: 6, // it will preview monthly all dates with weekends highlighted with no limitations ex: title (1, 2, 3)
dayWidth: 20,
},
},
{
key: "quarter",
title: "Quarter",
data: {
startDate: new Date(),
currentDate: new Date(),
endDate: new Date(),
approxFilterRange: 18, // it will preview week starting dates all months data and there is 3 months limitation for preview ex: title (2, 9, 16, 23, 30)
dayWidth: 5,
},
},
// {
// key: "quarter",
// title: "Quarter",
// data: {
// startDate: new Date(),
// currentDate: new Date(),
// endDate: new Date(),
// approxFilterRange: 12,
// width: 100, // it will preview week starting dates all months data and there is 3 months limitation for preview ex: title (2, 9, 16, 23, 30)
// },
// },
// {
// key: "year",
// title: "Year",
// data: {
// startDate: new Date(),
// currentDate: new Date(),
// endDate: new Date(),
// approxFilterRange: 10,
// width: 80, // it will preview week starting dates all months data and there is no limitation for preview ex: title (2, 9, 16, 23, 30)
// },
// },
];
export const currentViewDataWithView = (view: TGanttViews = "month") =>

View File

@@ -8,11 +8,11 @@ import { Plus } from "lucide-react";
import { Tooltip } from "@plane/ui";
// helpers
import { renderFormattedDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// types
import { usePlatformOS } from "@/hooks/use-platform-os";
import { useGanttChart } from "../hooks/use-gantt-chart";
import { IBlockUpdateData, IGanttBlock } from "../types";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { IBlockUpdateData, IGanttBlock } from "../types";
type Props = {
block: IGanttBlock;
@@ -30,16 +30,20 @@ export const ChartAddBlock: React.FC<Props> = observer((props) => {
// hooks
const { isMobile } = usePlatformOS();
// chart hook
const { currentViewData } = useGanttChart();
const { currentViewData, currentView } = useTimeLineChartStore();
const handleButtonClick = () => {
if (!currentViewData) return;
const { startDate: chartStartDate, width } = currentViewData.data;
const columnNumber = buttonXPosition / width;
const { startDate: chartStartDate, dayWidth } = currentViewData.data;
const columnNumber = buttonXPosition / dayWidth;
let numberOfDays = 1;
if (currentView === "quarter") numberOfDays = 7;
const startDate = addDays(chartStartDate, columnNumber);
const endDate = addDays(startDate, 1);
const endDate = addDays(startDate, numberOfDays);
blockUpdateHandler(block.data, {
start_date: renderFormattedPayloadDate(startDate) ?? undefined,
@@ -57,8 +61,8 @@ export const ChartAddBlock: React.FC<Props> = observer((props) => {
setButtonXPosition(e.offsetX);
const { startDate: chartStartDate, width } = currentViewData.data;
const columnNumber = buttonXPosition / width;
const { startDate: chartStartDate, dayWidth } = currentViewData.data;
const columnNumber = buttonXPosition / dayWidth;
const startDate = addDays(chartStartDate, columnNumber);
setButtonStartDate(startDate);

View File

@@ -0,0 +1,61 @@
import { useState } from "react";
import { observer } from "mobx-react";
// Plane
import { cn } from "@plane/editor";
//helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
//hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
type LeftResizableProps = {
enableBlockLeftResize: boolean;
handleBlockDrag: (e: React.MouseEvent<HTMLDivElement, MouseEvent>, dragDirection: "left" | "right" | "move") => void;
isMoving: "left" | "right" | "move" | undefined;
position?: {
marginLeft: number;
width: number;
};
};
export const LeftResizable = observer((props: LeftResizableProps) => {
const { enableBlockLeftResize, isMoving, handleBlockDrag, position } = props;
const [isHovering, setIsHovering] = useState(false);
const { getDateFromPositionOnGantt } = useTimeLineChartStore();
const date = position ? getDateFromPositionOnGantt(position.marginLeft, 0) : undefined;
const dateString = date ? renderFormattedDate(date) : undefined;
const isLeftResizing = isMoving === "left" || isMoving === "move";
if (!enableBlockLeftResize) return null;
return (
<>
{(isHovering || isLeftResizing) && dateString && (
<div className="absolute flex text-xs font-normal text-custom-text-300 h-full w-32 -left-36 justify-end items-center">
<div className="px-2 py-1 bg-custom-primary-20 rounded">{dateString}</div>
</div>
)}
<div
onMouseDown={(e) => {
handleBlockDrag(e, "left");
}}
onMouseOver={() => {
setIsHovering(true);
}}
onMouseOut={() => {
setIsHovering(false);
}}
className="absolute -left-1.5 top-1/2 -translate-y-1/2 z-[6] h-full w-3 cursor-col-resize rounded-md"
/>
<div
className={cn(
"absolute left-1 top-1/2 -translate-y-1/2 h-7 w-1 z-[5] rounded-sm bg-custom-background-100 transition-all duration-300",
{
"-left-1.5": isLeftResizing,
}
)}
/>
</>
);
});

View File

@@ -0,0 +1,59 @@
import { useState } from "react";
import { observer } from "mobx-react";
// Plane
import { cn } from "@plane/editor";
//helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
//hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
type RightResizableProps = {
enableBlockRightResize: boolean;
handleBlockDrag: (e: React.MouseEvent<HTMLDivElement, MouseEvent>, dragDirection: "left" | "right" | "move") => void;
isMoving: "left" | "right" | "move" | undefined;
position?: {
marginLeft: number;
width: number;
};
};
export const RightResizable = observer((props: RightResizableProps) => {
const { enableBlockRightResize, handleBlockDrag, isMoving, position } = props;
const [isHovering, setIsHovering] = useState(false);
const { getDateFromPositionOnGantt } = useTimeLineChartStore();
const date = position ? getDateFromPositionOnGantt(position.marginLeft + position.width, -1) : undefined;
const dateString = date ? renderFormattedDate(date) : undefined;
const isRightResizing = isMoving === "right" || isMoving === "move";
if (!enableBlockRightResize) return null;
return (
<>
{(isHovering || isRightResizing) && dateString && (
<div className="z-[10] absolute flex text-xs font-normal text-custom-text-300 h-full w-32 -right-36 justify-start items-center">
<div className="px-2 py-1 bg-custom-primary-20 rounded">{dateString}</div>
</div>
)}
<div
onMouseDown={(e) => handleBlockDrag(e, "right")}
onMouseOver={() => {
setIsHovering(true);
}}
onMouseOut={() => {
setIsHovering(false);
}}
className="absolute -right-1.5 top-1/2 -translate-y-1/2 z-[6] h-full w-3 cursor-col-resize rounded-md"
/>
<div
className={cn(
"absolute right-1 top-1/2 -translate-y-1/2 h-7 w-1 z-[5] rounded-sm bg-custom-background-100 transition-all duration-300",
{
"-right-1.5": isRightResizing,
}
)}
/>
</>
);
});

View File

@@ -0,0 +1,124 @@
import { useRef, useState } from "react";
// Plane
import { setToast } from "@plane/ui";
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { SIDEBAR_WIDTH } from "../../constants";
import { IBlockUpdateDependencyData, IGanttBlock } from "../../types";
export const useGanttResizable = (
block: IGanttBlock,
resizableRef: React.RefObject<HTMLDivElement>,
ganttContainerRef: React.RefObject<HTMLDivElement>,
updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise<void>
) => {
// refs
const initialPositionRef = useRef<{ marginLeft: number; width: number; offsetX: number }>({
marginLeft: 0,
width: 0,
offsetX: 0,
});
const ganttContainerDimensions = useRef<DOMRect | undefined>();
const currMouseEvent = useRef<MouseEvent | undefined>();
// states
const { currentViewData, updateBlockPosition, setIsDragging, getUpdatedPositionAfterDrag } = useTimeLineChartStore();
const [isMoving, setIsMoving] = useState<"left" | "right" | "move" | undefined>();
// handle block resize from the left end
const handleBlockDrag = (
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
dragDirection: "left" | "right" | "move"
) => {
const ganttContainerElement = ganttContainerRef.current;
if (!currentViewData || !resizableRef.current || !block.position || !ganttContainerElement) return;
if (e.button !== 0) return;
const resizableDiv = resizableRef.current;
ganttContainerDimensions.current = ganttContainerElement.getBoundingClientRect();
const dayWidth = currentViewData.data.dayWidth;
const mouseX = e.clientX - ganttContainerDimensions.current.left - SIDEBAR_WIDTH + ganttContainerElement.scrollLeft;
// record position on drag start
initialPositionRef.current = {
width: block.position.width ?? 0,
marginLeft: block.position.marginLeft ?? 0,
offsetX: mouseX - block.position.marginLeft,
};
const handleOnScroll = () => {
if (currMouseEvent.current) handleMouseMove(currMouseEvent.current);
};
const handleMouseMove = (e: MouseEvent) => {
currMouseEvent.current = e;
setIsMoving(dragDirection);
setIsDragging(true);
if (!ganttContainerDimensions.current) return;
const { left: containerLeft } = ganttContainerDimensions.current;
const mouseX = e.clientX - containerLeft - SIDEBAR_WIDTH + ganttContainerElement.scrollLeft;
let width = initialPositionRef.current.width;
let marginLeft = initialPositionRef.current.marginLeft;
const blockRight = initialPositionRef.current.width + initialPositionRef.current.marginLeft;
if (dragDirection === "left") {
// calculate new marginLeft and update the initial marginLeft to the newly calculated one
marginLeft = Math.round(mouseX / dayWidth) * dayWidth;
// calculate new width
width = blockRight - marginLeft;
} else if (dragDirection === "right") {
// calculate new width and update the initialMarginLeft using +=
width = Math.round(mouseX / dayWidth) * dayWidth - marginLeft;
} else if (dragDirection === "move") {
// calculate new marginLeft and update the initial marginLeft using -=
marginLeft = Math.round((mouseX - initialPositionRef.current.offsetX) / dayWidth) * dayWidth;
}
// block needs to be at least 1 dayWidth Wide
if (width < dayWidth) return;
resizableDiv.style.width = `${width}px`;
resizableDiv.style.transform = `translateX(${marginLeft}px)`;
const deltaLeft = Math.round((marginLeft - (block.position?.marginLeft ?? 0)) / dayWidth) * dayWidth;
const deltaWidth = Math.round((width - (block.position?.width ?? 0)) / dayWidth) * dayWidth;
// call update blockPosition
if (deltaWidth || deltaLeft) updateBlockPosition(block.id, deltaLeft, deltaWidth, dragDirection !== "move");
};
// remove event listeners and call updateBlockDates
const handleMouseUp = () => {
setIsMoving(undefined);
document.removeEventListener("mousemove", handleMouseMove);
ganttContainerElement.removeEventListener("scroll", handleOnScroll);
document.removeEventListener("mouseup", handleMouseUp);
try {
const blockUpdates = getUpdatedPositionAfterDrag(block.id, dragDirection !== "move");
updateBlockDates && updateBlockDates(blockUpdates);
} catch (e) {
setToast;
}
setIsDragging(false);
};
document.addEventListener("mousemove", handleMouseMove);
ganttContainerElement.addEventListener("scroll", handleOnScroll);
document.addEventListener("mouseup", handleMouseUp);
};
return {
isMoving,
handleBlockDrag,
};
};

View File

@@ -1,338 +1,64 @@
import React, { useEffect, useRef, useState } from "react";
import React, { RefObject } from "react";
import { observer } from "mobx-react";
import { ArrowRight } from "lucide-react";
// hooks
import { IGanttBlock } from "@/components/gantt-chart";
// helpers
import { cn } from "@/helpers/common.helper";
// constants
import { SIDEBAR_WIDTH } from "../constants";
import { useGanttChart } from "../hooks/use-gantt-chart";
// Plane-web
import { LeftDependencyDraggable, RightDependencyDraggable } from "@/plane-web/components/gantt-chart";
//
import { LeftResizable } from "./blockResizables/left-resizable";
import { RightResizable } from "./blockResizables/right-resizable";
type Props = {
block: IGanttBlock;
blockToRender: (data: any) => React.ReactNode;
handleBlock: (totalBlockShifts: number, dragDirection: "left" | "right" | "move") => void;
handleBlockDrag: (e: React.MouseEvent<HTMLDivElement, MouseEvent>, dragDirection: "left" | "right" | "move") => void;
isMoving: "left" | "right" | "move" | undefined;
enableBlockLeftResize: boolean;
enableBlockRightResize: boolean;
enableBlockMove: boolean;
ganttContainerRef: React.RefObject<HTMLDivElement>;
ganttContainerRef: RefObject<HTMLDivElement>;
};
export const ChartDraggable: React.FC<Props> = observer((props) => {
const {
block,
blockToRender,
handleBlock,
handleBlockDrag,
enableBlockLeftResize,
enableBlockRightResize,
enableBlockMove,
isMoving,
ganttContainerRef,
} = props;
// states
const [isLeftResizing, setIsLeftResizing] = useState(false);
const [isRightResizing, setIsRightResizing] = useState(false);
const [isMoving, setIsMoving] = useState(false);
const [isHidden, setIsHidden] = useState(true);
const [scrollLeft, setScrollLeft] = useState(0);
// refs
const resizableRef = useRef<HTMLDivElement>(null);
// chart hook
const { currentViewData } = useGanttChart();
// check if cursor reaches either end while resizing/dragging
const checkScrollEnd = (e: MouseEvent): number => {
const SCROLL_THRESHOLD = 70;
let delWidth = 0;
const ganttContainer = document.querySelector("#gantt-container") as HTMLDivElement;
const ganttSidebar = document.querySelector("#gantt-sidebar") as HTMLDivElement;
if (!ganttContainer || !ganttSidebar) return 0;
const posFromLeft = e.clientX;
// manually scroll to left if reached the left end while dragging
if (posFromLeft - (ganttContainer.getBoundingClientRect().left + ganttSidebar.clientWidth) <= SCROLL_THRESHOLD) {
if (e.movementX > 0) return 0;
delWidth = -5;
ganttContainer.scrollBy(delWidth, 0);
} else delWidth = e.movementX;
// manually scroll to right if reached the right end while dragging
const posFromRight = ganttContainer.getBoundingClientRect().right - e.clientX;
if (posFromRight <= SCROLL_THRESHOLD) {
if (e.movementX < 0) return 0;
delWidth = 5;
ganttContainer.scrollBy(delWidth, 0);
} else delWidth = e.movementX;
return delWidth;
};
// handle block resize from the left end
const handleBlockLeftResize = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (!currentViewData || !resizableRef.current || !block.position) return;
if (e.button !== 0) return;
const resizableDiv = resizableRef.current;
const columnWidth = currentViewData.data.width;
const blockInitialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
let initialMarginLeft = parseInt(resizableDiv.style.marginLeft);
const handleMouseMove = (e: MouseEvent) => {
let delWidth = 0;
delWidth = checkScrollEnd(e);
// calculate new width and update the initialMarginLeft using -=
const newWidth = Math.round((initialWidth -= delWidth) / columnWidth) * columnWidth;
// calculate new marginLeft and update the initial marginLeft to the newly calculated one
const newMarginLeft = initialMarginLeft - (newWidth - (block.position?.width ?? 0));
initialMarginLeft = newMarginLeft;
// block needs to be at least 1 column wide
if (newWidth < columnWidth) return;
resizableDiv.style.width = `${newWidth}px`;
resizableDiv.style.marginLeft = `${newMarginLeft}px`;
if (block.position) {
block.position.width = newWidth;
block.position.marginLeft = newMarginLeft;
}
};
// remove event listeners and call block handler with the updated start date
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
const totalBlockShifts = Math.ceil((resizableDiv.clientWidth - blockInitialWidth) / columnWidth);
handleBlock(totalBlockShifts, "left");
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
// handle block resize from the right end
const handleBlockRightResize = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (!currentViewData || !resizableRef.current || !block.position) return;
if (e.button !== 0) return;
const resizableDiv = resizableRef.current;
const columnWidth = currentViewData.data.width;
const blockInitialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
const handleMouseMove = (e: MouseEvent) => {
let delWidth = 0;
delWidth = checkScrollEnd(e);
// calculate new width and update the initialMarginLeft using +=
const newWidth = Math.round((initialWidth += delWidth) / columnWidth) * columnWidth;
// block needs to be at least 1 column wide
if (newWidth < columnWidth) return;
resizableDiv.style.width = `${Math.max(newWidth, 80)}px`;
if (block.position) block.position.width = Math.max(newWidth, 80);
};
// remove event listeners and call block handler with the updated target date
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
const totalBlockShifts = Math.ceil((resizableDiv.clientWidth - blockInitialWidth) / columnWidth);
handleBlock(totalBlockShifts, "right");
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
// handle block x-axis move
const handleBlockMove = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (!enableBlockMove || !currentViewData || !resizableRef.current || !block.position) return;
if (e.button !== 0) return;
const resizableDiv = resizableRef.current;
const columnWidth = currentViewData.data.width;
const blockInitialMarginLeft = parseInt(resizableDiv.style.marginLeft);
let initialMarginLeft = parseInt(resizableDiv.style.marginLeft);
const handleMouseMove = (e: MouseEvent) => {
setIsMoving(true);
let delWidth = 0;
delWidth = checkScrollEnd(e);
// calculate new marginLeft and update the initial marginLeft using -=
const newMarginLeft = Math.round((initialMarginLeft += delWidth) / columnWidth) * columnWidth;
resizableDiv.style.marginLeft = `${newMarginLeft}px`;
if (block.position) block.position.marginLeft = newMarginLeft;
};
// remove event listeners and call block handler with the updated dates
const handleMouseUp = () => {
setIsMoving(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
const totalBlockShifts = Math.ceil(
(parseInt(resizableDiv.style.marginLeft) - blockInitialMarginLeft) / columnWidth
);
handleBlock(totalBlockShifts, "move");
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
// scroll to a hidden block
const handleScrollToBlock = () => {
const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement;
if (!scrollContainer || !block.position) return;
// update container's scroll position to the block's position
scrollContainer.scrollLeft = block.position.marginLeft - 4;
};
// check if block is hidden on either side
const isBlockHiddenOnLeft =
block.position?.marginLeft &&
block.position?.width &&
scrollLeft > block.position.marginLeft + block.position.width;
useEffect(() => {
const ganttContainer = ganttContainerRef.current;
if (!ganttContainer) return;
const handleScroll = () => setScrollLeft(ganttContainer.scrollLeft);
ganttContainer.addEventListener("scroll", handleScroll);
return () => {
ganttContainer.removeEventListener("scroll", handleScroll);
};
}, [ganttContainerRef]);
useEffect(() => {
const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement;
const resizableBlock = resizableRef.current;
if (!resizableBlock || !intersectionRoot) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
setIsHidden(!entry.isIntersecting);
});
},
{
root: intersectionRoot,
rootMargin: `0px 0px 0px -${SIDEBAR_WIDTH}px`,
}
);
observer.observe(resizableBlock);
return () => {
observer.unobserve(resizableBlock);
};
}, []);
return (
<>
{/* move to the hidden block */}
{isHidden && (
<button
type="button"
className="sticky z-[1] grid h-8 w-8 translate-y-1.5 cursor-pointer place-items-center rounded border border-custom-border-300 bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100"
style={{
left: `${SIDEBAR_WIDTH + 4}px`,
}}
onClick={handleScrollToBlock}
>
<ArrowRight
className={cn("h-3.5 w-3.5", {
"rotate-180": isBlockHiddenOnLeft,
})}
/>
</button>
)}
<div className="group w-full z-[5] relative inline-flex h-full cursor-pointer items-center font-medium transition-all">
{/* left resize drag handle */}
<LeftDependencyDraggable block={block} ganttContainerRef={ganttContainerRef} />
<LeftResizable
enableBlockLeftResize={enableBlockLeftResize}
handleBlockDrag={handleBlockDrag}
isMoving={isMoving}
position={block.position}
/>
<div
ref={resizableRef}
className="group relative inline-flex h-full cursor-pointer items-center font-medium transition-all"
style={{
marginLeft: `${block.position?.marginLeft}px`,
width: `${block.position?.width}px`,
}}
className={cn("relative z-[6] flex h-8 w-full items-center rounded", {
"pointer-events-none": isMoving,
})}
onMouseDown={(e) => enableBlockMove && handleBlockDrag(e, "move")}
>
{/* left resize drag handle */}
{enableBlockLeftResize && (
<>
<div
onMouseDown={handleBlockLeftResize}
onMouseEnter={() => setIsLeftResizing(true)}
onMouseLeave={() => setIsLeftResizing(false)}
className="absolute -left-2.5 top-1/2 -translate-y-1/2 z-[3] h-full w-6 cursor-col-resize rounded-md"
/>
<div
className={cn(
"absolute left-1 top-1/2 -translate-y-1/2 h-7 w-1 rounded-sm bg-custom-background-100 transition-all duration-300",
{
"-left-2.5": isLeftResizing,
}
)}
/>
</>
)}
<div
className={cn("relative z-[2] flex h-8 w-full items-center rounded", {
"pointer-events-none": isMoving,
})}
onMouseDown={handleBlockMove}
>
{blockToRender(block.data)}
</div>
{/* right resize drag handle */}
{enableBlockRightResize && (
<>
<div
onMouseDown={handleBlockRightResize}
onMouseEnter={() => setIsRightResizing(true)}
onMouseLeave={() => setIsRightResizing(false)}
className="absolute -right-2.5 top-1/2 -translate-y-1/2 z-[2] h-full w-6 cursor-col-resize rounded-md"
/>
<div
className={cn(
"absolute right-1 top-1/2 -translate-y-1/2 h-7 w-1 rounded-sm bg-custom-background-100 transition-all duration-300",
{
"-right-2.5": isRightResizing,
}
)}
/>
</>
)}
{blockToRender(block.data)}
</div>
</>
{/* right resize drag handle */}
<RightResizable
enableBlockRightResize={enableBlockRightResize}
handleBlockDrag={handleBlockDrag}
isMoving={isMoving}
position={block.position}
/>
<RightDependencyDraggable block={block} ganttContainerRef={ganttContainerRef} />
</div>
);
});

View File

@@ -1 +0,0 @@
export * from "./use-gantt-chart";

View File

@@ -1,11 +0,0 @@
import { useContext } from "react";
// mobx store
import { GanttStoreContext } from "@/components/gantt-chart/contexts";
// types
import { IGanttStore } from "@/store/issue/issue_gantt_view.store";
export const useGanttChart = (): IGanttStore => {
const context = useContext(GanttStoreContext);
if (context === undefined) throw new Error("useGanttChart must be used within GanttStoreProvider");
return context;
};

View File

@@ -1,7 +1,6 @@
export * from "./blocks";
export * from "./chart";
export * from "./helpers";
export * from "./hooks";
export * from "./root";
export * from "./sidebar";
export * from "./types";

View File

@@ -1,8 +1,9 @@
import { FC } from "react";
import { FC, useEffect } from "react";
import { observer } from "mobx-react";
// components
import { ChartDataType, ChartViewRoot, IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart";
// context
import { GanttStoreProvider } from "@/components/gantt-chart/contexts";
import { ChartViewRoot, IBlockUpdateData, IBlockUpdateDependencyData } from "@/components/gantt-chart";
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
type GanttChartRootProps = {
border?: boolean;
@@ -13,9 +14,9 @@ type GanttChartRootProps = {
blockToRender: (data: any) => React.ReactNode;
sidebarToRender: (props: any) => React.ReactNode;
quickAdd?: React.JSX.Element | undefined;
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise<void>;
enableBlockLeftResize?: boolean | ((blockId: string) => boolean);
enableBlockRightResize?: boolean | ((blockId: string) => boolean);
enableBlockMove?: boolean | ((blockId: string) => boolean);
@@ -27,7 +28,7 @@ type GanttChartRootProps = {
showToday?: boolean;
};
export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
export const GanttChartRoot: FC<GanttChartRootProps> = observer((props) => {
const {
border = true,
title,
@@ -36,7 +37,6 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
blockUpdateHandler,
sidebarToRender,
blockToRender,
getBlockById,
loadMoreBlocks,
canLoadMoreBlocks,
enableBlockLeftResize = false,
@@ -49,32 +49,38 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
showAllBlocks = false,
showToday = true,
quickAdd,
updateBlockDates,
} = props;
const { setBlockIds } = useTimeLineChartStore();
// update the timeline store with updated blockIds
useEffect(() => {
setBlockIds(blockIds);
}, [blockIds]);
return (
<GanttStoreProvider>
<ChartViewRoot
border={border}
title={title}
blockIds={blockIds}
getBlockById={getBlockById}
loadMoreBlocks={loadMoreBlocks}
canLoadMoreBlocks={canLoadMoreBlocks}
loaderTitle={loaderTitle}
blockUpdateHandler={blockUpdateHandler}
sidebarToRender={sidebarToRender}
blockToRender={blockToRender}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableReorder={enableReorder}
enableAddBlock={enableAddBlock}
enableSelection={enableSelection}
bottomSpacing={bottomSpacing}
showAllBlocks={showAllBlocks}
quickAdd={quickAdd}
showToday={showToday}
/>
</GanttStoreProvider>
<ChartViewRoot
border={border}
title={title}
blockIds={blockIds}
loadMoreBlocks={loadMoreBlocks}
canLoadMoreBlocks={canLoadMoreBlocks}
loaderTitle={loaderTitle}
blockUpdateHandler={blockUpdateHandler}
sidebarToRender={sidebarToRender}
blockToRender={blockToRender}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableReorder={enableReorder}
enableAddBlock={enableAddBlock}
enableSelection={enableSelection}
bottomSpacing={bottomSpacing}
showAllBlocks={showAllBlocks}
quickAdd={quickAdd}
showToday={showToday}
updateBlockDates={updateBlockDates}
/>
);
};
});

View File

@@ -1,56 +0,0 @@
import { observer } from "mobx-react";
// hooks
import { CycleGanttSidebarBlock } from "@/components/cycles";
import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants";
import { useGanttChart } from "@/components/gantt-chart/hooks";
// components
// helpers
import { IGanttBlock } from "@/components/gantt-chart/types";
import { cn } from "@/helpers/common.helper";
import { findTotalDaysInRange } from "@/helpers/date-time.helper";
// types
// constants
type Props = {
block: IGanttBlock;
isDragging: boolean;
};
export const CyclesSidebarBlock: React.FC<Props> = observer((props) => {
const { block, isDragging } = props;
// store hooks
const { updateActiveBlockId, isBlockActive } = useGanttChart();
const duration = findTotalDaysInRange(block.start_date, block.target_date);
return (
<div
className={cn({
"rounded bg-custom-background-80": isDragging,
})}
onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)}
>
<div
id={`sidebar-block-${block.id}`}
className={cn("group w-full flex items-center gap-2 pr-4", {
"bg-custom-background-90": isBlockActive(block.id),
})}
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
<div className="flex-grow truncate">
<CycleGanttSidebarBlock cycleId={block.data.id} />
</div>
{duration && (
<div className="flex-shrink-0 text-sm text-custom-text-200">
{duration} day{duration > 1 ? "s" : ""}
</div>
)}
</div>
</div>
</div>
);
});

View File

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

View File

@@ -1,59 +0,0 @@
"use client";
// ui
import { Loader } from "@plane/ui";
// components
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart/types";
import { GanttDnDHOC } from "../gantt-dnd-HOC";
import { handleOrderChange } from "../utils";
import { CyclesSidebarBlock } from "./block";
// types
type Props = {
title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
blockIds: string[];
enableReorder: boolean;
};
export const CycleGanttSidebar: React.FC<Props> = (props) => {
const { blockUpdateHandler, blockIds, getBlockById, enableReorder } = props;
const handleOnDrop = (
draggingBlockId: string | undefined,
droppedBlockId: string | undefined,
dropAtEndOfList: boolean
) => {
handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blockIds, getBlockById, blockUpdateHandler);
};
return (
<div className="h-full">
{blockIds ? (
blockIds.map((blockId, index) => {
const block = getBlockById(blockId);
if (!block.start_date || !block.target_date) return null;
return (
<GanttDnDHOC
key={block.id}
id={block.id}
isLastChild={index === blockIds.length - 1}
isDragEnabled={enableReorder}
onDrop={handleOnDrop}
>
{(isDragging: boolean) => <CyclesSidebarBlock block={block} isDragging={isDragging} />}
</GanttDnDHOC>
);
})
) : (
<Loader className="space-y-3 pr-2">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
</div>
);
};

View File

@@ -1,4 +1,3 @@
export * from "./cycles";
export * from "./issues";
export * from "./modules";
export * from "./root";

View File

@@ -2,14 +2,13 @@ import { observer } from "mobx-react";
// components
import { Row } from "@plane/ui";
import { MultipleSelectEntityAction } from "@/components/core";
import { useGanttChart } from "@/components/gantt-chart/hooks";
import { IssueGanttSidebarBlock } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { findTotalDaysInRange } from "@/helpers/date-time.helper";
// hooks
import { useIssueDetail } from "@/hooks/store";
import { TSelectionHelper } from "@/hooks/use-multiple-select";
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
// constants
import { BLOCK_HEIGHT, GANTT_SELECT_GROUP } from "../../constants";
// types
@@ -25,12 +24,13 @@ type Props = {
export const IssuesSidebarBlock = observer((props: Props) => {
const { block, enableSelection, isDragging, selectionHelpers } = props;
// store hooks
const { updateActiveBlockId, isBlockActive } = useGanttChart();
const { updateActiveBlockId, isBlockActive, getNumberOfDaysFromPosition } = useTimeLineChartStore();
const { getIsIssuePeeked } = useIssueDetail();
const duration = findTotalDaysInRange(block.start_date, block.target_date);
const isBlockVisibleOnChart = !!block?.start_date && !!block?.target_date;
const duration = isBlockVisibleOnChart ? getNumberOfDaysFromPosition(block?.position?.width) : undefined;
if (!block.data) return null;
if (!block?.data) return null;
const isIssueSelected = selectionHelpers?.getIsEntitySelected(block.id);
const isIssueFocused = selectionHelpers?.getIsEntityActive(block.id);

View File

@@ -5,19 +5,22 @@ import { observer } from "mobx-react";
// ui
import { Loader } from "@plane/ui";
// components
import { IGanttBlock, IBlockUpdateData } from "@/components/gantt-chart/types";
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
import { IBlockUpdateData } from "@/components/gantt-chart/types";
import { GanttLayoutLIstItem } from "@/components/ui";
//hooks
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
import { TSelectionHelper } from "@/hooks/use-multiple-select";
//
import { useTimeLineChart } from "../../../../hooks/use-timeline-chart";
import { ETimeLineTypeType } from "../../contexts";
import { GanttDnDHOC } from "../gantt-dnd-HOC";
import { handleOrderChange } from "../utils";
// types
import { IssuesSidebarBlock } from "./block";
type Props = {
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
getBlockById: (id: string) => IGanttBlock;
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
ganttContainerRef: RefObject<HTMLDivElement>;
@@ -32,7 +35,6 @@ export const IssueGanttSidebar: React.FC<Props> = observer((props) => {
const {
blockUpdateHandler,
blockIds,
getBlockById,
enableReorder,
enableSelection,
loadMoreBlocks,
@@ -42,6 +44,8 @@ export const IssueGanttSidebar: React.FC<Props> = observer((props) => {
selectionHelpers,
} = props;
const { getBlockById } = useTimeLineChart(ETimeLineTypeType.ISSUE);
const {
issues: { getIssueLoader },
} = useIssuesStore();
@@ -77,22 +81,30 @@ export const IssueGanttSidebar: React.FC<Props> = observer((props) => {
if (!block || (!showAllBlocks && !isBlockVisibleOnSidebar)) return;
return (
<GanttDnDHOC
<RenderIfVisible
key={block.id}
id={block.id}
isLastChild={index === blockIds.length - 1}
isDragEnabled={enableReorder}
onDrop={handleOnDrop}
root={ganttContainerRef}
horizontalOffset={100}
verticalOffset={200}
shouldRecordHeights={false}
placeholderChildren={<GanttLayoutLIstItem />}
>
{(isDragging: boolean) => (
<IssuesSidebarBlock
block={block}
enableSelection={enableSelection}
isDragging={isDragging}
selectionHelpers={selectionHelpers}
/>
)}
</GanttDnDHOC>
<GanttDnDHOC
id={block.id}
isLastChild={index === blockIds.length - 1}
isDragEnabled={enableReorder}
onDrop={handleOnDrop}
>
{(isDragging: boolean) => (
<IssuesSidebarBlock
block={block}
enableSelection={enableSelection}
isDragging={isDragging}
selectionHelpers={selectionHelpers}
/>
)}
</GanttDnDHOC>
</RenderIfVisible>
);
})}
{canLoadMoreBlocks && (

View File

@@ -1,28 +1,29 @@
import { observer } from "mobx-react";
// hooks
// Plane
import { Row } from "@plane/ui";
import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants";
import { useGanttChart } from "@/components/gantt-chart/hooks";
// components
import { IGanttBlock } from "@/components/gantt-chart/types";
import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants";
import { ModuleGanttSidebarBlock } from "@/components/modules";
// helpers
import { cn } from "@/helpers/common.helper";
import { findTotalDaysInRange } from "@/helpers/date-time.helper";
// types
// constants
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
type Props = {
block: IGanttBlock;
blockId: string;
isDragging: boolean;
};
export const ModulesSidebarBlock: React.FC<Props> = observer((props) => {
const { block, isDragging } = props;
const { blockId, isDragging } = props;
// store hooks
const { updateActiveBlockId, isBlockActive } = useGanttChart();
const { getBlockById, updateActiveBlockId, isBlockActive, getNumberOfDaysFromPosition } = useTimeLineChartStore();
const block = getBlockById(blockId);
const duration = findTotalDaysInRange(block.start_date, block.target_date);
if (!block) return <></>;
const isBlockVisibleOnChart = !!block.start_date && !!block.target_date;
const duration = isBlockVisibleOnChart ? getNumberOfDaysFromPosition(block?.position?.width) : undefined;
return (
<div

View File

@@ -31,20 +31,17 @@ export const ModuleGanttSidebar: React.FC<Props> = (props) => {
return (
<div className="h-full">
{blockIds ? (
blockIds.map((blockId, index) => {
const block = getBlockById(blockId);
return (
<GanttDnDHOC
key={block.id}
id={block.id}
isLastChild={index === blockIds.length - 1}
isDragEnabled={enableReorder}
onDrop={handleOnDrop}
>
{(isDragging: boolean) => <ModulesSidebarBlock block={block} isDragging={isDragging} />}
</GanttDnDHOC>
);
})
blockIds.map((blockId, index) => (
<GanttDnDHOC
key={blockId}
id={blockId}
isLastChild={index === blockIds.length - 1}
isDragEnabled={enableReorder}
onDrop={handleOnDrop}
>
{(isDragging: boolean) => <ModulesSidebarBlock blockId={blockId} isDragging={isDragging} />}
</GanttDnDHOC>
))
) : (
<Loader className="space-y-3 pr-2">
<Loader.Item height="34px" />

View File

@@ -21,7 +21,6 @@ type Props = {
enableSelection: boolean | ((blockId: string) => boolean);
sidebarToRender: (props: any) => React.ReactNode;
title: string;
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
quickAdd?: React.JSX.Element | undefined;
selectionHelpers: TSelectionHelper;
};
@@ -33,7 +32,6 @@ export const GanttChartSidebar: React.FC<Props> = observer((props) => {
enableReorder,
enableSelection,
sidebarToRender,
getBlockById,
loadMoreBlocks,
canLoadMoreBlocks,
ganttContainerRef,
@@ -86,7 +84,6 @@ export const GanttChartSidebar: React.FC<Props> = observer((props) => {
title,
blockUpdateHandler,
blockIds,
getBlockById,
enableReorder,
enableSelection,
canLoadMoreBlocks,

View File

@@ -1,13 +1,14 @@
export interface IGanttBlock {
data: any;
id: string;
name: string;
position?: {
marginLeft: number;
width: number;
};
sort_order: number;
start_date: Date | undefined;
target_date: Date | undefined;
start_date: string | undefined;
target_date: string | undefined;
}
export interface IBlockUpdateData {
@@ -20,13 +21,20 @@ export interface IBlockUpdateData {
target_date?: string;
}
export type TGanttViews = "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year";
export interface IBlockUpdateDependencyData {
id: string;
start_date?: string;
target_date?: string;
}
export type TGanttViews = "week" | "month" | "quarter";
// chart render types
export interface WeekMonthDataType {
key: number;
shortTitle: string;
title: string;
abbreviation: string;
}
export interface ChartDataType {
@@ -40,5 +48,5 @@ export interface ChartDataTypeData {
currentDate: Date;
endDate: Date;
approxFilterRange: number;
width: number;
dayWidth: number;
}

View File

@@ -1,132 +0,0 @@
// types
import { weeks, months } from "../data";
import { ChartDataType } from "../types";
// data
// helpers
import { generateDate, getWeekNumberByDate, getNumberOfDaysInMonth, getDatesBetweenTwoDates } from "./helpers";
type GetAllDaysInMonthInMonthViewType = {
date: any;
day: any;
dayData: any;
weekNumber: number;
title: string;
active: boolean;
today: boolean;
};
const getAllDaysInMonthInMonthView = (month: number, year: number) => {
const day: GetAllDaysInMonthInMonthViewType[] = [];
const numberOfDaysInMonth = getNumberOfDaysInMonth(month, year);
const currentDate = new Date();
Array.from(Array(numberOfDaysInMonth).keys()).map((_day: number) => {
const date: Date = generateDate(_day + 1, month, year);
day.push({
date: date,
day: _day + 1,
dayData: weeks[date.getDay()],
weekNumber: getWeekNumberByDate(date),
title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`,
active: false,
today:
currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1
? true
: false,
});
});
return day;
};
const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number) => {
const currentMonth: number = month;
const currentYear: number = year;
const monthPayload = {
year: currentYear,
month: currentMonth,
monthData: months[currentMonth],
children: getAllDaysInMonthInMonthView(currentMonth, currentYear),
title: `${months[currentMonth].title} ${currentYear}`,
};
return monthPayload;
};
export const generateBiWeekChart = (monthPayload: ChartDataType, side: null | "left" | "right") => {
let renderState = monthPayload;
const renderPayload: any = [];
const range: number = renderState.data.approxFilterRange || 6;
let filteredDates: Date[] = [];
let minusDate: Date = new Date();
let plusDate: Date = new Date();
if (side === null) {
const currentDate = renderState.data.currentDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: {
...renderState.data,
startDate: filteredDates[0],
endDate: filteredDates[filteredDates.length - 1],
},
};
} else if (side === "left") {
const currentDate = renderState.data.startDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, startDate: filteredDates[0] },
};
} else if (side === "right") {
const currentDate = renderState.data.endDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] },
};
}
if (filteredDates && filteredDates.length > 0)
for (const currentDate in filteredDates) {
const date = filteredDates[parseInt(currentDate)];
const currentYear = date.getFullYear();
const currentMonth = date.getMonth();
renderPayload.push(generateMonthDataByMonthAndYearInMonthView(currentMonth, currentYear));
}
const scrollWidth =
renderPayload
.map((monthData: any) => monthData.children.length)
.reduce((partialSum: number, a: number) => partialSum + a, 0) * monthPayload.data.width;
return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth };
};
export const getNumberOfDaysBetweenTwoDatesInBiWeek = (startDate: Date, endDate: Date) => {
let daysDifference: number = 0;
startDate.setHours(0, 0, 0, 0);
endDate.setHours(0, 0, 0, 0);
const timeDifference: number = startDate.getTime() - endDate.getTime();
daysDifference = Math.abs(Math.floor(timeDifference / (1000 * 60 * 60 * 24)));
return daysDifference;
};

View File

@@ -1,162 +0,0 @@
// types
import { weeks, months } from "../data";
import { ChartDataType } from "../types";
// data
export const getWeekNumberByDate = (date: Date) => {
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
const daysOffset = firstDayOfYear.getDay();
const firstWeekStart = firstDayOfYear.getTime() - daysOffset * 24 * 60 * 60 * 1000;
const weekStart = new Date(firstWeekStart);
const weekNumber = Math.floor((date.getTime() - weekStart.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1;
return weekNumber;
};
export const getNumberOfDaysInMonth = (month: number, year: number) => {
const date = new Date(year, month, 1);
date.setMonth(date.getMonth() + 1);
date.setDate(date.getDate() - 1);
return date.getDate();
};
export const generateDate = (day: number, month: number, year: number) => new Date(year, month, day);
export const getDatesBetweenTwoDates = (startDate: Date, endDate: Date) => {
const months = [];
const startYear = startDate.getFullYear();
const startMonth = startDate.getMonth();
const endYear = endDate.getFullYear();
const endMonth = endDate.getMonth();
const currentDate = new Date(startYear, startMonth);
while (currentDate <= endDate) {
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
months.push(new Date(currentYear, currentMonth));
currentDate.setMonth(currentDate.getMonth() + 1);
}
if (endYear === currentDate.getFullYear() && endMonth === currentDate.getMonth()) months.push(endDate);
return months;
};
export type GetAllDaysInMonthType = {
date: any;
day: any;
dayData: any;
weekNumber: number;
title: string;
today: boolean;
};
export const getAllDaysInMonth = (month: number, year: number) => {
const day: GetAllDaysInMonthType[] = [];
const numberOfDaysInMonth = getNumberOfDaysInMonth(month, year);
const currentDate = new Date();
Array.from(Array(numberOfDaysInMonth).keys()).map((_day: number) => {
const date: Date = generateDate(_day + 1, month, year);
day.push({
date: date,
day: _day + 1,
dayData: weeks[date.getDay()],
weekNumber: getWeekNumberByDate(date),
title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`,
today:
currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1
? true
: false,
});
});
return day;
};
export const generateMonthDataByMonth = (month: number, year: number) => {
const currentMonth: number = month;
const currentYear: number = year;
const monthPayload = {
year: currentYear,
month: currentMonth,
monthData: months[currentMonth],
children: getAllDaysInMonth(currentMonth, currentYear),
title: `${months[currentMonth].title} ${currentYear}`,
};
return monthPayload;
};
export const generateMonthDataByYear = (monthPayload: ChartDataType, side: null | "left" | "right") => {
let renderState = monthPayload;
const renderPayload: any = [];
const range: number = renderState.data.approxFilterRange || 6;
let filteredDates: Date[] = [];
let minusDate: Date = new Date();
let plusDate: Date = new Date();
if (side === null) {
const currentDate = renderState.data.currentDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: {
...renderState.data,
startDate: filteredDates[0],
endDate: filteredDates[filteredDates.length - 1],
},
};
} else if (side === "left") {
const currentDate = renderState.data.startDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, startDate: filteredDates[0] },
};
} else if (side === "right") {
const currentDate = renderState.data.endDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] },
};
}
if (filteredDates && filteredDates.length > 0)
for (const currentDate in filteredDates) {
const date = filteredDates[parseInt(currentDate)];
const currentYear = date.getFullYear();
const currentMonth = date.getMonth();
renderPayload.push(generateMonthDataByMonth(currentMonth, currentYear));
}
const scrollWidth =
renderPayload
.map((monthData: any) => monthData.children.length)
.reduce((partialSum: number, a: number) => partialSum + a, 0) * monthPayload.data.width;
return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth };
};

View File

@@ -1,17 +1,35 @@
// Generating the date by using the year, month, and day
import { addDaysToDate, findTotalDaysInRange, getDate } from "@/helpers/date-time.helper";
import { ChartDataType, IGanttBlock } from "../types";
import { IMonthBlock, IMonthView, monthView } from "./month-view";
import { quarterView } from "./quarter-view";
import { IWeekBlock, weekView } from "./week-view";
/**
* Generates Date by using Day, month and Year
* @param day
* @param month
* @param year
* @returns
*/
export const generateDate = (day: number, month: number, year: number) => new Date(year, month, day);
// Getting the number of days in a month
/**
* Returns number of days in month
* @param month
* @param year
* @returns
*/
export const getNumberOfDaysInMonth = (month: number, year: number) => {
const date = new Date(year, month, 1);
date.setMonth(date.getMonth() + 1);
date.setDate(date.getDate() - 1);
const date = new Date(year, month + 1, 0);
return date.getDate();
};
// Getting the week number by date
/**
* Returns week number from date
* @param date
* @returns
*/
export const getWeekNumberByDate = (date: Date) => {
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
const daysOffset = firstDayOfYear.getDay();
@@ -24,67 +42,80 @@ export const getWeekNumberByDate = (date: Date) => {
return weekNumber;
};
// Getting all weeks between two dates
export const getWeeksByMonthAndYear = (month: number, year: number) => {
const weeks = [];
const startDate = new Date(year, month, 1);
const endDate = new Date(year, month + 1, 0);
const currentDate = new Date(startDate.getTime());
/**
* Returns number of days between two dates
* @param startDate
* @param endDate
* @returns
*/
export const getNumberOfDaysBetweenTwoDates = (startDate: Date, endDate: Date) => {
let daysDifference: number = 0;
startDate.setHours(0, 0, 0, 0);
endDate.setHours(0, 0, 0, 0);
currentDate.setDate(currentDate.getDate() + ((7 - currentDate.getDay()) % 7));
const timeDifference: number = startDate.getTime() - endDate.getTime();
daysDifference = Math.round(timeDifference / (1000 * 60 * 60 * 24));
while (currentDate <= endDate) {
weeks.push({
year: year,
month: month,
weekNumber: getWeekNumberByDate(currentDate),
startDate: new Date(currentDate.getTime()),
endDate: new Date(currentDate.getTime() + 6 * 24 * 60 * 60 * 1000),
});
currentDate.setDate(currentDate.getDate() + 7);
}
return weeks;
return daysDifference;
};
// Getting all dates in a week by week number and year
export const getAllDatesInWeekByWeekNumber = (weekNumber: number, year: number) => {
const januaryFirst = new Date(year, 0, 1);
const firstDayOfYear =
januaryFirst.getDay() === 0 ? januaryFirst : new Date(year, 0, 1 + (7 - januaryFirst.getDay()));
/**
* returns a date corresponding to the position on the timeline chart
* @param position
* @param chartData
* @param offsetDays
* @returns
*/
export const getDateFromPositionOnGantt = (position: number, chartData: ChartDataType, offsetDays = 0) => {
const numberOfDaysSinceStart = Math.round(position / chartData.data.dayWidth) + offsetDays;
const startDate = new Date(firstDayOfYear.getTime());
startDate.setDate(startDate.getDate() + 7 * (weekNumber - 1));
const newDate = addDaysToDate(chartData.data.startDate, numberOfDaysSinceStart);
const datesInWeek = [];
for (let i = 0; i < 7; i++) {
const currentDate = new Date(startDate.getTime());
currentDate.setDate(currentDate.getDate() + i);
datesInWeek.push(currentDate);
}
if (!newDate) undefined;
return datesInWeek;
return newDate;
};
// Getting the dates between two dates
export const getDatesBetweenTwoDates = (startDate: Date, endDate: Date) => {
const dates = [];
/**
* returns the position and width of the block on the timeline chart from startDate and EndDate
* @param chartData
* @param itemData
* @returns
*/
export const getItemPositionWidth = (chartData: ChartDataType, itemData: IGanttBlock) => {
let scrollPosition: number = 0;
let scrollWidth: number = 0;
const startYear = startDate.getFullYear();
const startMonth = startDate.getMonth();
const { startDate: chartStartDate } = chartData.data;
const { start_date, target_date } = itemData;
const endYear = endDate.getFullYear();
const endMonth = endDate.getMonth();
const itemStartDate = getDate(start_date);
const itemTargetDate = getDate(target_date);
const currentDate = new Date(startYear, startMonth);
if (!itemStartDate || !itemTargetDate) return;
while (currentDate <= endDate) {
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
dates.push(new Date(currentYear, currentMonth));
currentDate.setMonth(currentDate.getMonth() + 1);
}
if (endYear === currentDate.getFullYear() && endMonth === currentDate.getMonth()) dates.push(endDate);
chartStartDate.setHours(0, 0, 0, 0);
itemStartDate.setHours(0, 0, 0, 0);
itemTargetDate.setHours(0, 0, 0, 0);
return dates;
// get number of days from chart start date to block's start date
const positionDaysDifference = Math.round(findTotalDaysInRange(chartStartDate, itemStartDate, false) ?? 0);
if (!positionDaysDifference) return;
// get scroll position from the number of days and width of each day
scrollPosition = positionDaysDifference * chartData.data.dayWidth;
// get width of block
const widthTimeDifference: number = itemStartDate.getTime() - itemTargetDate.getTime();
const widthDaysDifference: number = Math.abs(Math.floor(widthTimeDifference / (1000 * 60 * 60 * 24)));
scrollWidth = (widthDaysDifference + 1) * chartData.data.dayWidth;
return { marginLeft: scrollPosition, width: scrollWidth };
};
export const timelineViewHelpers = {
week: weekView,
month: monthView,
quarter: quarterView,
};

View File

@@ -1,162 +0,0 @@
// types
import { weeks, months } from "../data";
import { ChartDataType } from "../types";
// data
export const getWeekNumberByDate = (date: Date) => {
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
const daysOffset = firstDayOfYear.getDay();
const firstWeekStart = firstDayOfYear.getTime() - daysOffset * 24 * 60 * 60 * 1000;
const weekStart = new Date(firstWeekStart);
const weekNumber = Math.floor((date.getTime() - weekStart.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1;
return weekNumber;
};
export const getNumberOfDaysInMonth = (month: number, year: number) => {
const date = new Date(year, month, 1);
date.setMonth(date.getMonth() + 1);
date.setDate(date.getDate() - 1);
return date.getDate();
};
export const generateDate = (day: number, month: number, year: number) => new Date(year, month, day);
export const getDatesBetweenTwoDates = (startDate: Date, endDate: Date) => {
const months = [];
const startYear = startDate.getFullYear();
const startMonth = startDate.getMonth();
const endYear = endDate.getFullYear();
const endMonth = endDate.getMonth();
const currentDate = new Date(startYear, startMonth);
while (currentDate <= endDate) {
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
months.push(new Date(currentYear, currentMonth));
currentDate.setMonth(currentDate.getMonth() + 1);
}
if (endYear === currentDate.getFullYear() && endMonth === currentDate.getMonth()) months.push(endDate);
return months;
};
export type GetAllDaysInMonthType = {
date: any;
day: any;
dayData: any;
weekNumber: number;
title: string;
today: boolean;
};
export const getAllDaysInMonth = (month: number, year: number) => {
const day: GetAllDaysInMonthType[] = [];
const numberOfDaysInMonth = getNumberOfDaysInMonth(month, year);
const currentDate = new Date();
Array.from(Array(numberOfDaysInMonth).keys()).map((_day: number) => {
const date: Date = generateDate(_day + 1, month, year);
day.push({
date: date,
day: _day + 1,
dayData: weeks[date.getDay()],
weekNumber: getWeekNumberByDate(date),
title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`,
today:
currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1
? true
: false,
});
});
return day;
};
export const generateMonthDataByMonth = (month: number, year: number) => {
const currentMonth: number = month;
const currentYear: number = year;
const monthPayload = {
year: currentYear,
month: currentMonth,
monthData: months[currentMonth],
children: getAllDaysInMonth(currentMonth, currentYear),
title: `${months[currentMonth].title} ${currentYear}`,
};
return monthPayload;
};
export const generateMonthDataByYear = (monthPayload: ChartDataType, side: null | "left" | "right") => {
let renderState = monthPayload;
const renderPayload: any = [];
const range: number = renderState.data.approxFilterRange || 6;
let filteredDates: Date[] = [];
let minusDate: Date = new Date();
let plusDate: Date = new Date();
if (side === null) {
const currentDate = renderState.data.currentDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: {
...renderState.data,
startDate: filteredDates[0],
endDate: filteredDates[filteredDates.length - 1],
},
};
} else if (side === "left") {
const currentDate = renderState.data.startDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, startDate: filteredDates[0] },
};
} else if (side === "right") {
const currentDate = renderState.data.endDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] },
};
}
if (filteredDates && filteredDates.length > 0)
for (const currentDate in filteredDates) {
const date = filteredDates[parseInt(currentDate)];
const currentYear = date.getFullYear();
const currentMonth = date.getMonth();
renderPayload.push(generateMonthDataByMonth(currentMonth, currentYear));
}
const scrollWidth =
renderPayload
.map((monthData: any) => monthData.children.length)
.reduce((partialSum: number, a: number) => partialSum + a, 0) * monthPayload.data.width;
return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth };
};

View File

@@ -1,7 +1,4 @@
// export * from "./hours-view";
// export * from "./day-view";
export * from "./week-view";
export * from "./bi-week-view";
export * from "./month-view";
export * from "./quater-view";
export * from "./year-view";
export * from "./quarter-view";
export * from "./helpers";

View File

@@ -1,38 +1,15 @@
// types
import { findTotalDaysInRange } from "@/helpers/date-time.helper";
import { weeks, months } from "../data";
import { ChartDataType, IGanttBlock } from "../types";
// data
// helpers
import { generateDate, getWeekNumberByDate, getNumberOfDaysInMonth, getDatesBetweenTwoDates } from "./helpers";
type GetAllDaysInMonthInMonthViewType = {
date: any;
day: any;
dayData: any;
weekNumber: number;
title: string;
active: boolean;
today: boolean;
};
interface IMonthChild {
active: boolean;
date: Date;
day: number;
dayData: {
key: number;
shortTitle: string;
title: string;
};
title: string;
today: boolean;
weekNumber: number;
}
import cloneDeep from "lodash/cloneDeep";
import uniqBy from "lodash/uniqBy";
//
import { months } from "../data";
import { ChartDataType } from "../types";
import { getNumberOfDaysInMonth } from "./helpers";
import { getWeeksBetweenTwoDates, IWeekBlock } from "./week-view";
export interface IMonthBlock {
children: IMonthChild[];
today: boolean;
month: number;
days: number;
monthData: {
key: number;
shortTitle: string;
@@ -41,157 +18,141 @@ export interface IMonthBlock {
title: string;
year: number;
}
[];
const getAllDaysInMonthInMonthView = (month: number, year: number): IMonthChild[] => {
const day: GetAllDaysInMonthInMonthViewType[] = [];
const numberOfDaysInMonth = getNumberOfDaysInMonth(month, year);
const currentDate = new Date();
export interface IMonthView {
months: IMonthBlock[];
weeks: IWeekBlock[];
}
Array.from(Array(numberOfDaysInMonth).keys()).map((_day: number) => {
const date: Date = generateDate(_day + 1, month, year);
day.push({
date: date,
day: _day + 1,
dayData: weeks[date.getDay()],
weekNumber: getWeekNumberByDate(date),
title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`,
active: false,
today:
currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1
? true
: false,
});
});
return day;
};
const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number): IMonthBlock => {
const currentMonth: number = month;
const currentYear: number = year;
const monthPayload = {
year: currentYear,
month: currentMonth,
monthData: months[currentMonth],
children: getAllDaysInMonthInMonthView(currentMonth, currentYear),
title: `${months[currentMonth].title} ${currentYear}`,
};
return monthPayload;
};
export const generateMonthChart = (monthPayload: ChartDataType, side: null | "left" | "right") => {
let renderState = monthPayload;
const renderPayload: any = [];
/**
* Generate Month Chart data
* @param monthPayload
* @param side
* @returns
*/
const generateMonthChart = (monthPayload: ChartDataType, side: null | "left" | "right") => {
let renderState = cloneDeep(monthPayload);
const range: number = renderState.data.approxFilterRange || 6;
let filteredDates: Date[] = [];
let filteredDates: IMonthView = { months: [], weeks: [] };
let minusDate: Date = new Date();
let plusDate: Date = new Date();
// if side is null generate months on both side of current date
if (side === null) {
const currentDate = renderState.data.currentDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
if (minusDate && plusDate) filteredDates = getMonthsViewBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: {
...renderState.data,
startDate: filteredDates[0],
endDate: filteredDates[filteredDates.length - 1],
startDate: filteredDates.weeks[0]?.startDate,
endDate: filteredDates.weeks[filteredDates.weeks.length - 1]?.endDate,
},
};
} else if (side === "left") {
}
// When side is left, generate more months on the left side of the start date
else if (side === "left") {
const currentDate = renderState.data.startDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate() - 1);
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
if (minusDate && plusDate) filteredDates = getMonthsViewBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, startDate: filteredDates[0] },
data: { ...renderState.data, startDate: filteredDates.weeks[0].startDate },
};
} else if (side === "right") {
}
// When side is right, generate more months on the right side of the end date
else if (side === "right") {
const currentDate = renderState.data.endDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate());
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate() + 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
if (minusDate && plusDate) filteredDates = getMonthsViewBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] },
data: { ...renderState.data, endDate: filteredDates.weeks[filteredDates.weeks.length - 1]?.endDate },
};
}
if (filteredDates && filteredDates.length > 0)
for (const currentDate in filteredDates) {
const date = filteredDates[parseInt(currentDate)];
const currentYear = date.getFullYear();
const currentMonth = date.getMonth();
renderPayload.push(generateMonthDataByMonthAndYearInMonthView(currentMonth, currentYear));
}
const scrollWidth = filteredDates.weeks.length * monthPayload.data.dayWidth * 7;
const scrollWidth =
renderPayload
.map((monthData: any) => monthData.children.length)
.reduce((partialSum: number, a: number) => partialSum + a, 0) * monthPayload.data.width;
return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth };
return { state: renderState, payload: filteredDates, scrollWidth: scrollWidth };
};
export const getNumberOfDaysBetweenTwoDatesInMonth = (startDate: Date, endDate: Date) => {
let daysDifference: number = 0;
startDate.setHours(0, 0, 0, 0);
endDate.setHours(0, 0, 0, 0);
/**
* Get Month View data between two dates, i.e., Months and Weeks between two dates
* @param startDate
* @param endDate
* @returns
*/
const getMonthsViewBetweenTwoDates = (startDate: Date, endDate: Date): IMonthView => ({
months: getMonthsBetweenTwoDates(startDate, endDate),
weeks: getWeeksBetweenTwoDates(startDate, endDate, false),
});
const timeDifference: number = startDate.getTime() - endDate.getTime();
daysDifference = Math.abs(Math.floor(timeDifference / (1000 * 60 * 60 * 24)));
/**
* generate array of months between two dates
* @param startDate
* @param endDate
* @returns
*/
export const getMonthsBetweenTwoDates = (startDate: Date, endDate: Date): IMonthBlock[] => {
const monthBlocks = [];
return daysDifference;
const startYear = startDate.getFullYear();
const startMonth = startDate.getMonth();
const today = new Date();
const todayMonth = today.getMonth();
const todayYear = today.getFullYear();
const currentDate = new Date(startYear, startMonth);
while (currentDate <= endDate) {
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
monthBlocks.push({
year: currentYear,
month: currentMonth,
monthData: months[currentMonth],
title: `${months[currentMonth].title} ${currentYear}`,
days: getNumberOfDaysInMonth(currentMonth, currentYear),
today: todayMonth === currentMonth && todayYear === currentYear,
});
currentDate.setMonth(currentDate.getMonth() + 1);
}
return monthBlocks;
};
// calc item scroll position and width
export const getMonthChartItemPositionWidthInMonth = (chartData: ChartDataType, itemData: IGanttBlock) => {
let scrollPosition: number = 0;
let scrollWidth: number = 0;
/**
* Merge two MonthView data payloads
* @param a
* @param b
* @returns
*/
const mergeMonthRenderPayloads = (a: IMonthView, b: IMonthView): IMonthView => ({
months: uniqBy([...a.months, ...b.months], (monthBlock) => `${monthBlock.month}_${monthBlock.year}`),
weeks: uniqBy(
[...a.weeks, ...b.weeks],
(weekBlock) => `${weekBlock.startDate.getTime()}_${weekBlock.endDate.getTime()}`
),
});
const { startDate } = chartData.data;
const { start_date: itemStartDate, target_date: itemTargetDate } = itemData;
if (!itemStartDate || !itemTargetDate) return;
startDate.setHours(0, 0, 0, 0);
itemStartDate.setHours(0, 0, 0, 0);
itemTargetDate.setHours(0, 0, 0, 0);
const positionDaysDifference = findTotalDaysInRange(startDate, itemStartDate, false);
if (!positionDaysDifference) return;
scrollPosition = positionDaysDifference * chartData.data.width;
let diffMonths = (itemStartDate.getFullYear() - startDate.getFullYear()) * 12;
diffMonths -= startDate.getMonth();
diffMonths += itemStartDate.getMonth();
scrollPosition = scrollPosition + diffMonths;
// position code ends
// width code starts
const widthTimeDifference: number = itemStartDate.getTime() - itemTargetDate.getTime();
const widthDaysDifference: number = Math.abs(Math.floor(widthTimeDifference / (1000 * 60 * 60 * 24)));
scrollWidth = (widthDaysDifference + 1) * chartData.data.width + 1;
// width code ends
return { marginLeft: scrollPosition, width: scrollWidth };
export const monthView = {
generateChart: generateMonthChart,
mergeRenderPayloads: mergeMonthRenderPayloads,
};

View File

@@ -0,0 +1,141 @@
//
import { quarters } from "../data";
import { ChartDataType } from "../types";
import { getNumberOfDaysBetweenTwoDates } from "./helpers";
import { getMonthsBetweenTwoDates, IMonthBlock } from "./month-view";
export interface IQuarterMonthBlock {
children: IMonthBlock[];
quarterNumber: number;
shortTitle: string;
title: string;
year: number;
today: boolean;
}
/**
* Generate Quarter Chart data, which in turn are months in an array
* @param quarterPayload
* @param side
* @returns
*/
const generateQuarterChart = (quarterPayload: ChartDataType, side: null | "left" | "right") => {
let renderState = quarterPayload;
const range: number = renderState.data.approxFilterRange || 12;
let filteredDates: IMonthBlock[] = [];
let minusDate: Date = new Date();
let plusDate: Date = new Date();
// if side is null generate months on both side of current date
if (side === null) {
const currentDate = renderState.data.currentDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 0);
if (minusDate && plusDate) filteredDates = getMonthsBetweenTwoDates(minusDate, plusDate);
const startMonthBlock = filteredDates[0];
const endMonthBlock = filteredDates[filteredDates.length - 1];
renderState = {
...renderState,
data: {
...renderState.data,
startDate: new Date(startMonthBlock.year, startMonthBlock.month, 1),
endDate: new Date(endMonthBlock.year, endMonthBlock.month + 1, 0),
},
};
}
// When side is left, generate more months on the left side of the start date
else if (side === "left") {
const currentDate = renderState.data.startDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1);
if (minusDate && plusDate) filteredDates = getMonthsBetweenTwoDates(minusDate, plusDate);
const startMonthBlock = filteredDates[0];
renderState = {
...renderState,
data: { ...renderState.data, startDate: new Date(startMonthBlock.year, startMonthBlock.month, 1) },
};
}
// When side is right, generate more months on the right side of the end date
else if (side === "right") {
const currentDate = renderState.data.endDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 1);
if (minusDate && plusDate) filteredDates = getMonthsBetweenTwoDates(minusDate, plusDate);
const endMonthBlock = filteredDates[filteredDates.length - 1];
renderState = {
...renderState,
data: { ...renderState.data, endDate: new Date(endMonthBlock.year, endMonthBlock.month + 1, 0) },
};
}
const startMonthBlock = filteredDates[0];
const endMonthBlock = filteredDates[filteredDates.length - 1];
const startDate = new Date(startMonthBlock.year, startMonthBlock.month, 1);
const endDate = new Date(endMonthBlock.year, endMonthBlock.month + 1, 0);
const days = Math.abs(getNumberOfDaysBetweenTwoDates(startDate, endDate));
const scrollWidth = days * quarterPayload.data.dayWidth;
return { state: renderState, payload: filteredDates, scrollWidth: scrollWidth };
};
/**
* Merge two Quarter data payloads
* @param a
* @param b
* @returns
*/
const mergeQuarterRenderPayloads = (a: IMonthBlock[], b: IMonthBlock[]) => [...a, ...b];
/**
* Group array of Months into Quarters, returns an array og Quarters and it's children Months
* @param monthBlocks
* @returns
*/
export const groupMonthsToQuarters = (monthBlocks: IMonthBlock[]): IQuarterMonthBlock[] => {
const quartersMap: { [key: string]: IQuarterMonthBlock } = {};
const today = new Date();
const todayQuarterNumber = Math.floor(today.getMonth() / 3);
const todayYear = today.getFullYear();
for (const monthBlock of monthBlocks) {
const { month, year } = monthBlock;
const quarterNumber = Math.floor(month / 3);
const quarterKey = `Q${quarterNumber}-${year}`;
if (quartersMap[quarterKey]) {
quartersMap[quarterKey].children.push(monthBlock);
} else {
const quarterData = quarters[quarterNumber];
quartersMap[quarterKey] = {
children: [monthBlock],
quarterNumber,
shortTitle: quarterData.shortTitle,
title: `${quarterData.title} ${year}`,
year,
today: todayQuarterNumber === quarterNumber && todayYear === year,
};
}
}
return Object.values(quartersMap);
};
export const quarterView = {
generateChart: generateQuarterChart,
mergeRenderPayloads: mergeQuarterRenderPayloads,
};

View File

@@ -1,114 +0,0 @@
// types
import { weeks, months } from "../data";
import { ChartDataType } from "../types";
// data
// helpers
import { getDatesBetweenTwoDates, getWeeksByMonthAndYear } from "./helpers";
const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number) => {
const currentMonth: number = month;
const currentYear: number = year;
const today = new Date();
const weeksBetweenTwoDates = getWeeksByMonthAndYear(month, year);
const weekPayload = {
year: currentYear,
month: currentMonth,
monthData: months[currentMonth],
children: weeksBetweenTwoDates.map((weekData: any) => {
const date: Date = weekData.startDate;
return {
date: date,
startDate: weekData.startDate,
endDate: weekData.endDate,
day: date.getDay(),
dayData: weeks[date.getDay()],
weekNumber: weekData.weekNumber,
title: `W${weekData.weekNumber} (${date.getDate()})`,
active: false,
today: today >= weekData.startDate && today <= weekData.endDate ? true : false,
};
}),
title: `${months[currentMonth].title} ${currentYear}`,
};
return weekPayload;
};
export const generateQuarterChart = (quarterPayload: ChartDataType, side: null | "left" | "right") => {
let renderState = quarterPayload;
const renderPayload: any = [];
const range: number = renderState.data.approxFilterRange || 6;
let filteredDates: Date[] = [];
let minusDate: Date = new Date();
let plusDate: Date = new Date();
if (side === null) {
const currentDate = renderState.data.currentDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 0);
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: {
...renderState.data,
startDate: filteredDates[0],
endDate: filteredDates[filteredDates.length - 1],
},
};
} else if (side === "left") {
const currentDate = renderState.data.startDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 0);
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, startDate: filteredDates[0] },
};
} else if (side === "right") {
const currentDate = renderState.data.endDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 0);
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] },
};
}
if (filteredDates && filteredDates.length > 0)
for (const currentDate in filteredDates) {
const date = filteredDates[parseInt(currentDate)];
const currentYear = date.getFullYear();
const currentMonth = date.getMonth();
renderPayload.push(generateMonthDataByMonthAndYearInMonthView(currentMonth, currentYear));
}
const scrollWidth =
renderPayload
.map((monthData: any) => monthData.children.length)
.reduce((partialSum: number, a: number) => partialSum + a, 0) * quarterPayload.data.width;
return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth };
};
export const getNumberOfDaysBetweenTwoDatesInQuarter = (startDate: Date, endDate: Date) => {
let weeksDifference: number = 0;
const timeDiff = Math.abs(endDate.getTime() - startDate.getTime());
const diffDays = Math.ceil(timeDiff / (1000 * 3600 * 24));
weeksDifference = Math.floor(diffDays / 7);
return weeksDifference;
};

View File

@@ -1,132 +1,194 @@
// types
//
import { weeks, months } from "../data";
import { ChartDataType } from "../types";
// data
// helpers
import { generateDate, getWeekNumberByDate, getNumberOfDaysInMonth, getDatesBetweenTwoDates } from "./helpers";
type GetAllDaysInMonthInMonthViewType = {
date: any;
day: any;
dayData: any;
weekNumber: number;
title: string;
active: boolean;
today: boolean;
};
const getAllDaysInMonthInMonthView = (month: number, year: number) => {
const day: GetAllDaysInMonthInMonthViewType[] = [];
const numberOfDaysInMonth = getNumberOfDaysInMonth(month, year);
const currentDate = new Date();
Array.from(Array(numberOfDaysInMonth).keys()).map((_day: number) => {
const date: Date = generateDate(_day + 1, month, year);
day.push({
date: date,
day: _day + 1,
dayData: weeks[date.getDay()],
weekNumber: getWeekNumberByDate(date),
title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`,
active: false,
today:
currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1
? true
: false,
});
});
return day;
};
const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number) => {
const currentMonth: number = month;
const currentYear: number = year;
const monthPayload = {
year: currentYear,
month: currentMonth,
monthData: months[currentMonth],
children: getAllDaysInMonthInMonthView(currentMonth, currentYear),
title: `${months[currentMonth].title} ${currentYear}`,
import { getWeekNumberByDate } from "./helpers";
export interface IDayBlock {
date: Date;
day: number;
dayData: {
key: number;
shortTitle: string;
title: string;
abbreviation: string;
};
title: string;
today: boolean;
}
return monthPayload;
};
export interface IWeekBlock {
children?: IDayBlock[];
weekNumber: number;
weekData: {
shortTitle: string;
title: string;
};
title: string;
startDate: Date;
endDate: Date;
startMonth: number;
startYear: number;
endMonth: number;
endYear: number;
today: boolean;
}
export const generateWeekChart = (monthPayload: ChartDataType, side: null | "left" | "right") => {
let renderState = monthPayload;
const renderPayload: any = [];
/**
* Generate Week Chart data
* @param weekPayload
* @param side
* @returns
*/
const generateWeekChart = (weekPayload: ChartDataType, side: null | "left" | "right") => {
let renderState = weekPayload;
const range: number = renderState.data.approxFilterRange || 6;
let filteredDates: Date[] = [];
let filteredDates: IWeekBlock[] = [];
let minusDate: Date = new Date();
let plusDate: Date = new Date();
// if side is null generate weeks on both side of current date
if (side === null) {
const currentDate = renderState.data.currentDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: {
...renderState.data,
startDate: filteredDates[0],
endDate: filteredDates[filteredDates.length - 1],
startDate: filteredDates[0].startDate,
endDate: filteredDates[filteredDates.length - 1].endDate,
},
};
} else if (side === "left") {
}
// When side is left, generate more weeks on the left side of the start date
else if (side === "left") {
const currentDate = renderState.data.startDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate() - 1);
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, startDate: filteredDates[0] },
data: { ...renderState.data, startDate: filteredDates[0].startDate },
};
} else if (side === "right") {
}
// When side is right, generate more weeks on the right side of the end date
else if (side === "right") {
const currentDate = renderState.data.endDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate());
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate() + 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] },
data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1].endDate },
};
}
if (filteredDates && filteredDates.length > 0)
for (const currentDate in filteredDates) {
const date = filteredDates[parseInt(currentDate)];
const currentYear = date.getFullYear();
const currentMonth = date.getMonth();
renderPayload.push(generateMonthDataByMonthAndYearInMonthView(currentMonth, currentYear));
}
const scrollWidth =
renderPayload
filteredDates
.map((monthData: any) => monthData.children.length)
.reduce((partialSum: number, a: number) => partialSum + a, 0) * monthPayload.data.width;
.reduce((partialSum: number, a: number) => partialSum + a, 0) * weekPayload.data.dayWidth;
return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth };
return { state: renderState, payload: filteredDates, scrollWidth: scrollWidth };
};
export const getNumberOfDaysBetweenTwoDatesInWeek = (startDate: Date, endDate: Date) => {
let daysDifference: number = 0;
startDate.setHours(0, 0, 0, 0);
endDate.setHours(0, 0, 0, 0);
/**
* Generate weeks array between two dates
* @param startDate
* @param endDate
* @param shouldPopulateDaysForWeek
* @returns
*/
export const getWeeksBetweenTwoDates = (
startDate: Date,
endDate: Date,
shouldPopulateDaysForWeek: boolean = true
): IWeekBlock[] => {
const weeks: IWeekBlock[] = [];
const timeDifference: number = startDate.getTime() - endDate.getTime();
daysDifference = Math.abs(Math.floor(timeDifference / (1000 * 60 * 60 * 24)));
const currentDate = new Date(startDate.getTime());
const today = new Date();
return daysDifference;
currentDate.setDate(currentDate.getDate() - currentDate.getDay());
while (currentDate <= endDate) {
const weekStartDate = new Date(currentDate.getTime());
const weekEndDate = new Date(currentDate.getTime() + 6 * 24 * 60 * 60 * 1000);
const monthAtStartOfTheWeek = weekStartDate.getMonth();
const yearAtStartOfTheWeek = weekStartDate.getFullYear();
const monthAtEndOfTheWeek = weekEndDate.getMonth();
const yearAtEndOfTheWeek = weekEndDate.getFullYear();
const weekNumber = getWeekNumberByDate(currentDate);
weeks.push({
children: shouldPopulateDaysForWeek ? populateDaysForWeek(weekStartDate) : undefined,
weekNumber,
weekData: {
shortTitle: `w${weekNumber}`,
title: `Week ${weekNumber}`,
},
title:
monthAtStartOfTheWeek === monthAtEndOfTheWeek
? `${months[monthAtStartOfTheWeek].abbreviation} ${yearAtStartOfTheWeek}`
: `${months[monthAtStartOfTheWeek].abbreviation} ${yearAtStartOfTheWeek} - ${months[monthAtEndOfTheWeek].abbreviation} ${yearAtEndOfTheWeek}`,
startMonth: monthAtStartOfTheWeek,
startYear: yearAtStartOfTheWeek,
endMonth: monthAtEndOfTheWeek,
endYear: yearAtEndOfTheWeek,
startDate: weekStartDate,
endDate: weekEndDate,
today: today >= weekStartDate && today <= weekEndDate ? true : false,
});
currentDate.setDate(currentDate.getDate() + 7);
}
return weeks;
};
/**
* return back array of 7 days from the date provided
* @param startDate
* @returns
*/
const populateDaysForWeek = (startDate: Date): IDayBlock[] => {
const currentDate = new Date(startDate);
const days: IDayBlock[] = [];
const today = new Date();
for (let i = 0; i < 7; i++) {
days.push({
date: new Date(currentDate),
day: currentDate.getDay(),
dayData: weeks[currentDate.getDay()],
title: `${weeks[currentDate.getDay()].abbreviation} ${currentDate.getDate()}`,
today: today.setHours(0, 0, 0, 0) == currentDate.setHours(0, 0, 0, 0),
});
currentDate.setDate(currentDate.getDate() + 1);
}
return days;
};
/**
* Merge two Week data payloads
* @param a
* @param b
* @returns
*/
const mergeWeekRenderPayloads = (a: IWeekBlock[], b: IWeekBlock[]) => [...a, ...b];
export const weekView = {
generateChart: generateWeekChart,
mergeRenderPayloads: mergeWeekRenderPayloads,
};

View File

@@ -1,114 +0,0 @@
// types
import { weeks, months } from "../data";
import { ChartDataType } from "../types";
// data
// helpers
import { getDatesBetweenTwoDates, getWeeksByMonthAndYear } from "./helpers";
const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number) => {
const currentMonth: number = month;
const currentYear: number = year;
const today = new Date();
const weeksBetweenTwoDates = getWeeksByMonthAndYear(month, year);
const weekPayload = {
year: currentYear,
month: currentMonth,
monthData: months[currentMonth],
children: weeksBetweenTwoDates.map((weekData: any) => {
const date: Date = weekData.startDate;
return {
date: date,
startDate: weekData.startDate,
endDate: weekData.endDate,
day: date.getDay(),
dayData: weeks[date.getDay()],
weekNumber: weekData.weekNumber,
title: `W${weekData.weekNumber} (${date.getDate()})`,
active: false,
today: today >= weekData.startDate && today <= weekData.endDate ? true : false,
};
}),
title: `${months[currentMonth].title} ${currentYear}`,
};
return weekPayload;
};
export const generateYearChart = (yearPayload: ChartDataType, side: null | "left" | "right") => {
let renderState = yearPayload;
const renderPayload: any = [];
const range: number = renderState.data.approxFilterRange || 6;
let filteredDates: Date[] = [];
let minusDate: Date = new Date();
let plusDate: Date = new Date();
if (side === null) {
const currentDate = renderState.data.currentDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 0);
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: {
...renderState.data,
startDate: filteredDates[0],
endDate: filteredDates[filteredDates.length - 1],
},
};
} else if (side === "left") {
const currentDate = renderState.data.startDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 0);
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, startDate: filteredDates[0] },
};
} else if (side === "right") {
const currentDate = renderState.data.endDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 0);
if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate);
renderState = {
...renderState,
data: { ...renderState.data, endDate: filteredDates[filteredDates.length - 1] },
};
}
if (filteredDates && filteredDates.length > 0)
for (const currentDate in filteredDates) {
const date = filteredDates[parseInt(currentDate)];
const currentYear = date.getFullYear();
const currentMonth = date.getMonth();
renderPayload.push(generateMonthDataByMonthAndYearInMonthView(currentMonth, currentYear));
}
const scrollWidth =
renderPayload
.map((monthData: any) => monthData.children.length)
.reduce((partialSum: number, a: number) => partialSum + a, 0) * yearPayload.data.width;
return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth };
};
export const getNumberOfDaysBetweenTwoDatesInYear = (startDate: Date, endDate: Date) => {
let weeksDifference: number = 0;
const timeDiff = Math.abs(endDate.getTime() - startDate.getTime());
const diffDays = Math.ceil(timeDiff / (1000 * 3600 * 24));
weeksDifference = Math.floor(diffDays / 7);
return weeksDifference;
};

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 = {

View File

@@ -1,23 +1,27 @@
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// PLane
import { IModule } from "@plane/types";
// mobx store
// components
import { ChartDataType, GanttChartRoot, IBlockUpdateData, ModuleGanttSidebar } from "@/components/gantt-chart";
import { getMonthChartItemPositionWidthInMonth } from "@/components/gantt-chart/views";
import {
GanttChartRoot,
IBlockUpdateData,
IBlockUpdateDependencyData,
ModuleGanttSidebar,
} from "@/components/gantt-chart";
import { ETimeLineTypeType, TimeLineTypeContext } from "@/components/gantt-chart/contexts";
import { ModuleGanttBlock } from "@/components/modules";
import { getDate } from "@/helpers/date-time.helper";
// hooks
import { useModule, useModuleFilter, useProject } from "@/hooks/store";
// types
export const ModulesListGanttChartView: React.FC = observer(() => {
// router
const { workspaceSlug, projectId } = useParams();
// store
const { currentProjectDetails } = useProject();
const { getFilteredModuleIds, getModuleById, updateModuleDetails } = useModule();
const { getFilteredModuleIds, updateModuleDetails, getModuleById } = useModule();
const { currentProjectDisplayFilters: displayFilters } = useModuleFilter();
// derived values
const filteredModuleIds = projectId ? getFilteredModuleIds(projectId.toString()) : undefined;
@@ -30,47 +34,40 @@ export const ModulesListGanttChartView: React.FC = observer(() => {
await updateModuleDetails(workspaceSlug.toString(), module.project_id, module.id, payload);
};
const getBlockById = useCallback(
(id: string, currentViewData?: ChartDataType | undefined) => {
const projectModule = getModuleById(id);
const updateBlockDates = async (blockUpdates: IBlockUpdateDependencyData[]) => {
const blockUpdate = blockUpdates[0];
const block = {
data: projectModule,
id: projectModule?.id ?? "",
sort_order: projectModule?.sort_order ?? 0,
start_date: getDate(projectModule?.start_date),
target_date: getDate(projectModule?.target_date),
};
if (currentViewData) {
return {
...block,
position: getMonthChartItemPositionWidthInMonth(currentViewData, block),
};
}
return block;
},
[getModuleById]
);
if (!blockUpdate) return;
const payload: Partial<IModule> = {};
if (blockUpdate.start_date) payload.start_date = blockUpdate.start_date;
if (blockUpdate.target_date) payload.target_date = blockUpdate.target_date;
await updateModuleDetails(workspaceSlug.toString(), projectId.toString(), blockUpdate.id, payload);
};
const isAllowed = currentProjectDetails?.member_role === 20 || currentProjectDetails?.member_role === 15;
if (!filteredModuleIds) return null;
return (
<GanttChartRoot
title="Modules"
loaderTitle="Modules"
blockIds={filteredModuleIds}
getBlockById={getBlockById}
sidebarToRender={(props) => <ModuleGanttSidebar {...props} />}
blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)}
blockToRender={(data: IModule) => <ModuleGanttBlock moduleId={data.id} />}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={isAllowed && displayFilters?.order_by === "sort_order"}
enableAddBlock={isAllowed}
showAllBlocks
/>
<TimeLineTypeContext.Provider value={ETimeLineTypeType.MODULE}>
<GanttChartRoot
title="Modules"
loaderTitle="Modules"
blockIds={filteredModuleIds}
sidebarToRender={(props) => <ModuleGanttSidebar {...props} />}
blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)}
blockToRender={(data: IModule) => <ModuleGanttBlock moduleId={data.id} />}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableReorder={isAllowed && displayFilters?.order_by === "sort_order"}
enableAddBlock={isAllowed}
updateBlockDates={updateBlockDates}
showAllBlocks
/>
</TimeLineTypeContext.Provider>
);
});

View File

@@ -1,6 +1,14 @@
import { Row } from "@plane/ui";
import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants";
import { getRandomLength } from "../utils";
export const GanttLayoutLIstItem = () => (
<div className="flex flex w-full items-center gap-4 px-6 " style={{ height: `${BLOCK_HEIGHT}px` }}>
<div className="px-3 h-6 w-8 bg-custom-background-80 rounded" />
<div className={`px-3 h-6 w-${getRandomLength(["32", "52", "72"])} bg-custom-background-80 rounded`} />
</div>
);
export const GanttLayoutLoader = () => (
<div className="flex flex-col h-full overflow-x-auto animate-pulse">
<div className="min-h-10 w-full border-b border-custom-border-200 ">

View File

@@ -0,0 +1,112 @@
import { RefObject, useEffect, useRef } from "react";
const SCROLL_BY = 1;
const AUTO_SCROLL_THRESHOLD = 15;
const MAX_SPEED_THRESHOLD = 5;
export const useAutoScroller = (
containerRef: RefObject<HTMLDivElement>,
shouldScroll = false,
leftOffset = 0,
topOffset = 0
) => {
const containerDimensions = useRef<DOMRect | undefined>();
const intervalId = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
const clearRegisteredTimeout = () => {
clearInterval(intervalId.current);
};
const onDragEnd = () => clearRegisteredTimeout();
const handleAutoScroll = (e: MouseEvent) => {
const rect = containerDimensions.current;
clearInterval(intervalId.current);
if (!rect || !shouldScroll || (e.clientX === 0 && e.clientY === 0)) return;
const { left, top, width, height } = rect;
const mouseX = e.clientX - left - leftOffset;
const mouseY = e.clientY - top - topOffset;
const currWidth = width - leftOffset;
const currHeight = height - topOffset;
// Get Threshold in percentages
const thresholdX = (currWidth / 100) * AUTO_SCROLL_THRESHOLD;
const thresholdY = (currHeight / 100) * AUTO_SCROLL_THRESHOLD;
const maxSpeedX = (currWidth / 100) * MAX_SPEED_THRESHOLD;
const maxSpeedY = (currHeight / 100) * MAX_SPEED_THRESHOLD;
let scrollByX = 0,
scrollByY = 0;
// Check mouse positions against thresholds
if (mouseX < thresholdX) {
scrollByX = -1 * SCROLL_BY;
if (mouseX < maxSpeedX) {
scrollByX *= 2;
}
}
if (mouseX > currWidth - thresholdX) {
scrollByX = SCROLL_BY;
if (mouseX > currWidth - maxSpeedX) {
scrollByX *= 2;
}
}
if (mouseY < thresholdY) {
scrollByY = -1 * SCROLL_BY;
if (mouseX < maxSpeedY) {
scrollByY *= 2;
}
}
if (mouseY > currHeight - thresholdY) {
scrollByY = SCROLL_BY;
if (mouseY > currHeight - maxSpeedY) {
scrollByY *= 2;
}
}
// if mouse position breaches threshold, then start to scroll
if (scrollByX || scrollByY) {
containerRef.current?.scrollBy(scrollByX, scrollByY);
intervalId.current = setInterval(() => {
containerRef.current?.scrollBy(scrollByX, scrollByY);
}, 16);
}
};
useEffect(() => {
const containerElement = containerRef.current;
if (!containerElement || !shouldScroll) return;
containerElement.addEventListener("drag", handleAutoScroll);
containerElement.addEventListener("mousemove", handleAutoScroll);
document.addEventListener("mouseup", onDragEnd);
document.addEventListener("dragend", onDragEnd);
return () => {
containerElement?.removeEventListener("drag", handleAutoScroll);
containerElement?.removeEventListener("mousemove", handleAutoScroll);
document.removeEventListener("mouseup", onDragEnd);
document.removeEventListener("dragend", onDragEnd);
};
}, [shouldScroll, intervalId]);
useEffect(() => {
const containerElement = containerRef.current;
if (!containerElement || !shouldScroll) {
clearRegisteredTimeout();
containerDimensions.current = undefined;
}
containerDimensions.current = containerElement?.getBoundingClientRect();
}, [shouldScroll]);
};

View File

@@ -0,0 +1,26 @@
import { useContext } from "react";
// mobx store
// types
import { StoreContext } from "@/lib/store-context";
import { IBaseTimelineStore } from "ee/store/timeline/base-timeline.store";
import { ETimeLineTypeType, useTimeLineType } from "../components/gantt-chart/contexts";
export const useTimeLineChart = (timeLineType: ETimeLineTypeType): IBaseTimelineStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useTimeLineChart must be used within StoreProvider");
switch (timeLineType) {
case ETimeLineTypeType.ISSUE:
return context.timelineStore.issuesTimeLineStore;
case ETimeLineTypeType.MODULE:
return context.timelineStore.modulesTimeLineStore;
}
};
export const useTimeLineChartStore = () => {
const timelineType = useTimeLineType();
if (!timelineType) throw new Error("useTimeLineChartStore must be used within TimeLineTypeContext");
return useTimeLineChart(timelineType);
};

View File

@@ -1,6 +1,6 @@
"use client";
import { FC, ReactNode } from "react";
import { FC, ReactNode, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
@@ -8,6 +8,7 @@ import useSWR from "swr";
// components
import { JoinProject } from "@/components/auth-screens";
import { EmptyState, LogoSpinner } from "@/components/common";
import { ETimeLineTypeType } from "@/components/gantt-chart/contexts";
// hooks
import {
useCommandPalette,
@@ -22,6 +23,7 @@ import {
useProjectView,
useUserPermissions,
} from "@/hooks/store";
import { useTimeLineChart } from "@/hooks/use-timeline-chart";
// local
import { persistence } from "@/local-db/storage.sqlite";
// plane web constants
@@ -43,6 +45,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
const { loader, getProjectById, fetchProjectDetails } = useProject();
const { fetchAllCycles } = useCycle();
const { fetchModulesSlim, fetchModules } = useModule();
const { initGantt } = useTimeLineChart(ETimeLineTypeType.MODULE);
const { fetchViews } = useProjectView();
const {
project: { fetchProjectMembers },
@@ -55,6 +58,11 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
const projectMemberInfo = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()];
// Initialize module timeline chart
useEffect(() => {
initGantt();
}, []);
useSWR(
workspaceSlug && projectId ? `PROJECT_SYNC_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
workspaceSlug && projectId

View File

@@ -1,4 +1,5 @@
import { startSpan } from "@sentry/nextjs";
import isEmpty from "lodash/isEmpty";
// types
import type {
IIssueDisplayProperties,
@@ -36,8 +37,12 @@ export class IssueService extends APIService {
queries?: any,
config = {}
): Promise<TIssuesResponse> {
const path =
(queries.expand as string)?.includes("issue_relation") && !queries.group_by
? `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues-detail/`
: `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/`;
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/`,
path,
{
params: queries,
},
@@ -63,6 +68,9 @@ export class IssueService extends APIService {
}
async getIssues(workspaceSlug: string, projectId: string, queries?: any, config = {}): Promise<TIssuesResponse> {
if (!isEmpty(queries.expand as string) && !queries.group_by)
return await this.getIssuesFromServer(workspaceSlug, projectId, queries, config);
const response = await startSpan({ name: "GET_ISSUES" }, async () => {
const res = await persistence.getIssues(workspaceSlug, projectId, queries, config);
return res;
@@ -225,6 +233,18 @@ export class IssueService extends APIService {
});
}
async updateIssueDates(
workspaceSlug: string,
projectId: string,
updates: { id: string; start_date?: string; target_date?: string }[]
): Promise<void> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-dates/`, { updates })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async subIssues(workspaceSlug: string, projectId: string, issueId: string): Promise<TIssueSubIssues> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/sub-issues/`)
.then((response) => response?.data)

View File

@@ -1,8 +1,10 @@
import type { TIssueRelation, TIssue, TIssueRelationTypes } from "@plane/types";
import type { TIssueRelation, TIssue } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// Plane-web
import { TIssueRelationTypes } from "@/plane-web/types";
// services
import { APIService } from "@/services/api.service";
// types
export class IssueRelationService extends APIService {
constructor() {

View File

@@ -29,12 +29,19 @@ import {
TPaginationData,
TBulkOperationsPayload,
} from "@plane/types";
// components
import { IBlockUpdateDependencyData } from "@/components/gantt-chart";
// constants
import { EIssueLayoutTypes, ISSUE_PRIORITIES } from "@/constants/issue";
// helpers
import { convertToISODateString } from "@/helpers/date-time.helper";
// local-db
import { updatePersistentLayer } from "@/local-db/utils/utils";
// services
import { CycleService } from "@/services/cycle.service";
import { IssueArchiveService, IssueDraftService, IssueService } from "@/services/issue";
import { ModuleService } from "@/services/module.service";
//
import { IIssueRootStore } from "../root.store";
import {
getDifference,
@@ -45,9 +52,6 @@ import {
getSubGroupIssueKeyActions,
} from "./base-issues-utils";
import { IBaseIssueFilterStore } from "./issue-filter-helper.store";
// constants
// helpers
// services
export type TIssueDisplayFilterOptions = Exclude<TIssueGroupByOptions, null> | "target_date";
@@ -109,6 +113,7 @@ export interface IBaseIssuesStore {
addModuleIds: string[],
removeModuleIds: string[]
): Promise<void>;
updateIssueDates(workspaceSlug: string, projectId: string, updates: IBlockUpdateDependencyData[]): Promise<void>;
}
// This constant maps the group by keys to the respective issue property that the key relies on
@@ -226,6 +231,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore {
issueUpdate: action,
createDraftIssue: action,
updateDraftIssue: action,
updateIssueDates: action,
issueQuickAdd: action.bound,
removeIssue: action.bound,
issueArchive: action.bound,
@@ -476,6 +482,8 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore {
// fetch parent stats if required, to be handled in the Implemented class
this.fetchParentStats(workspaceSlug, projectId, id);
this.rootIssueStore.issueDetail.relation.extractRelationsFromIssues(issueList);
// store Pagination options for next subsequent calls and data like next cursor etc
this.storePreviousPaginationValues(issuesResponse, options);
}
@@ -501,6 +509,8 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore {
this.loader[getGroupKey(groupId, subGroupId)] = undefined;
});
this.rootIssueStore.issueDetail.relation.extractRelationsFromIssues(issueList);
// store Pagination data like next cursor etc
this.storePreviousPaginationValues(issuesResponse, undefined, groupId, subGroupId);
}
@@ -792,6 +802,50 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore {
});
};
async updateIssueDates(
workspaceSlug: string,
projectId: string,
updates: { id: string; start_date?: string; target_date?: string }[]
) {
const issueDatesBeforeChange: { id: string; start_date?: string; target_date?: string }[] = [];
try {
const getIssueById = this.rootIssueStore.issues.getIssueById;
runInAction(() => {
for (const update of updates) {
const dates: Partial<TIssue> = {};
if (update.start_date) dates.start_date = update.start_date;
if (update.target_date) dates.target_date = update.target_date;
const currIssue = getIssueById(update.id);
if (currIssue) {
issueDatesBeforeChange.push({
id: update.id,
start_date: currIssue.start_date ?? undefined,
target_date: currIssue.target_date ?? undefined,
});
}
this.issueUpdate(workspaceSlug, projectId, update.id, dates, false);
}
});
await this.issueService.updateIssueDates(workspaceSlug, projectId, updates);
} catch (e) {
runInAction(() => {
for (const update of issueDatesBeforeChange) {
const dates: Partial<TIssue> = {};
if (update.start_date) dates.start_date = update.start_date;
if (update.target_date) dates.target_date = update.target_date;
this.issueUpdate(workspaceSlug, projectId, update.id, dates, false);
}
});
console.error("error while updating Timeline dependencies");
throw e;
}
}
/**
* This method is used to add issues to a particular Cycle
* @param workspaceSlug

Some files were not shown because too many files have changed in this diff Show More