From a88a39fb1e6b99d57ade4baf6763293c17a2b2b7 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Mon, 28 Oct 2024 18:03:31 +0530 Subject: [PATCH] [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 --- apiserver/plane/app/serializers/base.py | 80 ++-- apiserver/plane/app/urls/issue.py | 13 + apiserver/plane/app/views/__init__.py | 2 + apiserver/plane/app/views/issue/base.py | 191 ++++++++++ apiserver/plane/app/views/issue/relation.py | 75 +++- .../plane/bgtasks/issue_activities_task.py | 17 +- apiserver/plane/db/models/issue.py | 2 + .../plane/utils/issue_relation_mapper.py | 24 ++ .../tailwind-config-custom/tailwind.config.js | 1 + packages/types/src/dashboard.d.ts | 2 +- packages/types/src/issues/issue.d.ts | 11 + packages/types/src/issues/issue_relation.d.ts | 7 +- packages/types/src/view-props.d.ts | 3 +- .../dependency/blockDraggables/index.ts | 2 + .../blockDraggables/left-draggable.tsx | 9 + .../blockDraggables/right-draggable.tsx | 8 + .../dependency/dependency-paths.tsx | 1 + .../dependency/draggable-dependency-path.tsx | 1 + .../gantt-chart/dependency/index.ts | 3 + web/ce/components/gantt-chart/index.ts | 1 + web/ce/components/relations/index.tsx | 35 ++ web/ce/constants/gantt-chart.ts | 8 + web/ce/constants/index.ts | 7 + web/ce/constants/issues.ts | 2 + web/ce/store/timeline/base-timeline.store.ts | 283 +++++++++++++++ web/ce/types/gantt-chart.ts | 1 + web/ce/types/index.ts | 1 + web/core/components/api-token/modal/form.tsx | 2 +- .../components/core/render-if-visible-HOC.tsx | 4 +- .../components/cycles/gantt-chart/blocks.tsx | 103 ------ .../cycles/gantt-chart/cycles-list-layout.tsx | 74 ---- .../components/cycles/gantt-chart/index.ts | 2 - web/core/components/cycles/index.ts | 1 - .../widgets/issue-panels/issue-list-item.tsx | 1 - .../gantt-chart/blocks/block-row-list.tsx | 49 +++ .../gantt-chart/blocks/block-row.tsx | 122 +++++++ .../components/gantt-chart/blocks/block.tsx | 132 +++---- .../gantt-chart/blocks/blocks-list.tsx | 36 +- .../components/gantt-chart/chart/header.tsx | 19 +- .../gantt-chart/chart/main-content.tsx | 162 +++++---- .../components/gantt-chart/chart/root.tsx | 55 ++- .../chart/timeline-drag-helper.tsx | 18 + .../gantt-chart/chart/views/bi-week.tsx | 54 --- .../gantt-chart/chart/views/day.tsx | 54 --- .../gantt-chart/chart/views/hours.tsx | 54 --- .../gantt-chart/chart/views/index.ts | 4 - .../gantt-chart/chart/views/month.tsx | 118 +++--- .../gantt-chart/chart/views/quarter.tsx | 113 ++++-- .../gantt-chart/chart/views/week.tsx | 119 ++++-- .../gantt-chart/chart/views/year.tsx | 50 --- web/core/components/gantt-chart/constants.ts | 4 +- .../components/gantt-chart/contexts/index.tsx | 29 +- web/core/components/gantt-chart/data/index.ts | 137 +++---- .../gantt-chart/helpers/add-block.tsx | 24 +- .../blockResizables/left-resizable.tsx | 61 ++++ .../blockResizables/right-resizable.tsx | 59 +++ .../blockResizables/use-gantt-resizable.ts | 124 +++++++ .../gantt-chart/helpers/draggable.tsx | 342 ++---------------- .../components/gantt-chart/hooks/index.ts | 1 - .../gantt-chart/hooks/use-gantt-chart.ts | 11 - web/core/components/gantt-chart/index.ts | 1 - web/core/components/gantt-chart/root.tsx | 70 ++-- .../gantt-chart/sidebar/cycles/block.tsx | 56 --- .../gantt-chart/sidebar/cycles/index.ts | 1 - .../gantt-chart/sidebar/cycles/sidebar.tsx | 59 --- .../components/gantt-chart/sidebar/index.ts | 1 - .../gantt-chart/sidebar/issues/block.tsx | 10 +- .../gantt-chart/sidebar/issues/sidebar.tsx | 48 ++- .../gantt-chart/sidebar/modules/block.tsx | 23 +- .../gantt-chart/sidebar/modules/sidebar.tsx | 25 +- .../components/gantt-chart/sidebar/root.tsx | 3 - .../components/gantt-chart/types/index.ts | 16 +- .../gantt-chart/views/bi-week-view.ts | 132 ------- .../components/gantt-chart/views/day-view.ts | 162 --------- .../components/gantt-chart/views/helpers.ts | 143 +++++--- .../gantt-chart/views/hours-view.ts | 162 --------- .../components/gantt-chart/views/index.ts | 7 +- .../gantt-chart/views/month-view.ts | 239 +++++------- .../gantt-chart/views/quarter-view.ts | 141 ++++++++ .../gantt-chart/views/quater-view.ts | 114 ------ .../components/gantt-chart/views/week-view.ts | 228 +++++++----- .../components/gantt-chart/views/year-view.ts | 114 ------ .../issue-detail-widget-collapsibles.tsx | 6 +- .../relations/content.tsx | 65 ++-- .../issue-detail-widgets/relations/helper.tsx | 30 +- .../relations/quick-action-button.tsx | 16 +- .../issue-detail-widgets/relations/title.tsx | 5 +- .../activity/actions/relation.tsx | 11 +- .../issues/issue-detail/relation-select.tsx | 43 +-- .../issue-layouts/gantt/base-gantt-root.tsx | 94 ++--- .../issues/relations/issue-list-item.tsx | 6 +- .../issues/relations/issue-list.tsx | 7 +- .../gantt-chart/modules-list-layout.tsx | 81 ++--- .../ui/loader/layouts/gantt-layout-loader.tsx | 8 + web/core/hooks/use-auto-scroller.tsx | 112 ++++++ web/core/hooks/use-timeline-chart.ts | 26 ++ .../layouts/auth-layout/project-wrapper.tsx | 10 +- web/core/services/issue/issue.service.ts | 22 +- .../services/issue/issue_relation.service.ts | 6 +- .../store/issue/helpers/base-issues.store.ts | 60 ++- .../helpers/issue-filter-helper.store.ts | 5 + .../issue/issue-details/relation.store.ts | 155 +++++++- .../store/issue/issue-details/root.store.ts | 2 +- web/core/store/root.store.ts | 3 + web/core/store/timeline/index.ts | 19 + .../store/timeline/issues-timeline.store.ts | 23 ++ .../store/timeline/modules-timeline.store.ts | 22 ++ web/ee/components/gantt-chart/index.ts | 1 + web/ee/components/relations/index.tsx | 1 + web/ee/store/timeline/base-timeline.store.ts | 1 + web/helpers/date-time.helper.ts | 31 +- web/helpers/issue.helper.ts | 5 +- 112 files changed, 2918 insertions(+), 2641 deletions(-) create mode 100644 apiserver/plane/utils/issue_relation_mapper.py create mode 100644 web/ce/components/gantt-chart/dependency/blockDraggables/index.ts create mode 100644 web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx create mode 100644 web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx create mode 100644 web/ce/components/gantt-chart/dependency/dependency-paths.tsx create mode 100644 web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx create mode 100644 web/ce/components/gantt-chart/dependency/index.ts create mode 100644 web/ce/components/gantt-chart/index.ts create mode 100644 web/ce/components/relations/index.tsx create mode 100644 web/ce/constants/gantt-chart.ts create mode 100644 web/ce/constants/index.ts create mode 100644 web/ce/store/timeline/base-timeline.store.ts create mode 100644 web/ce/types/gantt-chart.ts delete mode 100644 web/core/components/cycles/gantt-chart/blocks.tsx delete mode 100644 web/core/components/cycles/gantt-chart/cycles-list-layout.tsx delete mode 100644 web/core/components/cycles/gantt-chart/index.ts create mode 100644 web/core/components/gantt-chart/blocks/block-row-list.tsx create mode 100644 web/core/components/gantt-chart/blocks/block-row.tsx create mode 100644 web/core/components/gantt-chart/chart/timeline-drag-helper.tsx delete mode 100644 web/core/components/gantt-chart/chart/views/bi-week.tsx delete mode 100644 web/core/components/gantt-chart/chart/views/day.tsx delete mode 100644 web/core/components/gantt-chart/chart/views/hours.tsx delete mode 100644 web/core/components/gantt-chart/chart/views/year.tsx create mode 100644 web/core/components/gantt-chart/helpers/blockResizables/left-resizable.tsx create mode 100644 web/core/components/gantt-chart/helpers/blockResizables/right-resizable.tsx create mode 100644 web/core/components/gantt-chart/helpers/blockResizables/use-gantt-resizable.ts delete mode 100644 web/core/components/gantt-chart/hooks/index.ts delete mode 100644 web/core/components/gantt-chart/hooks/use-gantt-chart.ts delete mode 100644 web/core/components/gantt-chart/sidebar/cycles/block.tsx delete mode 100644 web/core/components/gantt-chart/sidebar/cycles/index.ts delete mode 100644 web/core/components/gantt-chart/sidebar/cycles/sidebar.tsx delete mode 100644 web/core/components/gantt-chart/views/bi-week-view.ts delete mode 100644 web/core/components/gantt-chart/views/day-view.ts delete mode 100644 web/core/components/gantt-chart/views/hours-view.ts create mode 100644 web/core/components/gantt-chart/views/quarter-view.ts delete mode 100644 web/core/components/gantt-chart/views/quater-view.ts delete mode 100644 web/core/components/gantt-chart/views/year-view.ts create mode 100644 web/core/hooks/use-auto-scroller.tsx create mode 100644 web/core/hooks/use-timeline-chart.ts create mode 100644 web/core/store/timeline/index.ts create mode 100644 web/core/store/timeline/issues-timeline.store.ts create mode 100644 web/core/store/timeline/modules-timeline.store.ts create mode 100644 web/ee/components/gantt-chart/index.ts create mode 100644 web/ee/components/relations/index.tsx create mode 100644 web/ee/store/timeline/base-timeline.store.ts diff --git a/apiserver/plane/app/serializers/base.py b/apiserver/plane/app/serializers/base.py index f84d349a61..10260de583 100644 --- a/apiserver/plane/app/serializers/base.py +++ b/apiserver/plane/app/serializers/base.py @@ -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, diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 23330e8e11..e8ad4408df 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -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//projects//issues-detail/", + IssueDetailEndpoint.as_view(), + name="project-issue-detail", + ), + # updated v1 paginated issues # updated v2 paginated issues path( "workspaces//projects//v2/issues/", @@ -307,4 +315,9 @@ urlpatterns = [ DeletedIssuesListViewSet.as_view(), name="deleted-issues", ), + path( + "workspaces//projects//issue-dates/", + IssueBulkUpdateDateEndpoint.as_view(), + name="project-issue-dates", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 606d05e0d4..1f94cb7ac9 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -130,6 +130,8 @@ from .issue.base import ( BulkDeleteIssuesEndpoint, DeletedIssuesListViewSet, IssuePaginatedViewSet, + IssueDetailEndpoint, + IssueBulkUpdateDateEndpoint, ) from .issue.activity import ( diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index 16c96adc5b..d82ae432e9 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -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, + ) diff --git a/apiserver/plane/app/views/issue/relation.py b/apiserver/plane/app/views/issue/relation.py index 1a0400f582..f169a65bd4 100644 --- a/apiserver/plane/app/views/issue/relation.py +++ b/apiserver/plane/app/views/issue/relation.py @@ -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, diff --git a/apiserver/plane/bgtasks/issue_activities_task.py b/apiserver/plane/bgtasks/issue_activities_task.py index 0cee9baef3..371204aa8a 100644 --- a/apiserver/plane/bgtasks/issue_activities_task.py +++ b/apiserver/plane/bgtasks/issue_activities_task.py @@ -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, ) diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 4bc529f55f..c7b234ff3e 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -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( diff --git a/apiserver/plane/utils/issue_relation_mapper.py b/apiserver/plane/utils/issue_relation_mapper.py new file mode 100644 index 0000000000..f3188eb268 --- /dev/null +++ b/apiserver/plane/utils/issue_relation_mapper.py @@ -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) diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 4f57a3a648..4c8563f5f4 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -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", diff --git a/packages/types/src/dashboard.d.ts b/packages/types/src/dashboard.d.ts index 3b1c825a0a..96efea0070 100644 --- a/packages/types/src/dashboard.d.ts +++ b/packages/types/src/dashboard.d.ts @@ -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" diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index aacc28023b..ae4a98d63f 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -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. diff --git a/packages/types/src/issues/issue_relation.d.ts b/packages/types/src/issues/issue_relation.d.ts index 0b1c5f7cde..378470a586 100644 --- a/packages/types/src/issues/issue_relation.d.ts +++ b/packages/types/src/issues/issue_relation.d.ts @@ -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; export type TIssueRelationMap = { diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index c8375ba255..0c543241ee 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -78,7 +78,8 @@ export type TIssueParams = | "cursor" | "per_page" | "issue_type" - | "layout"; + | "layout" + | "expand"; export type TCalendarLayouts = "month" | "week"; diff --git a/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts b/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts new file mode 100644 index 0000000000..c2f4f8aecf --- /dev/null +++ b/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts @@ -0,0 +1,2 @@ +export * from "./left-draggable"; +export * from "./right-draggable"; diff --git a/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx b/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx new file mode 100644 index 0000000000..ccb3780c56 --- /dev/null +++ b/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx @@ -0,0 +1,9 @@ +import { RefObject } from "react"; +import { IGanttBlock } from "@/components/gantt-chart"; + +type LeftDependencyDraggableProps = { + block: IGanttBlock; + ganttContainerRef: RefObject; +}; + +export const LeftDependencyDraggable = (props: LeftDependencyDraggableProps) => <>; diff --git a/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx b/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx new file mode 100644 index 0000000000..3d5ac24e0d --- /dev/null +++ b/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx @@ -0,0 +1,8 @@ +import { RefObject } from "react"; +import { IGanttBlock } from "@/components/gantt-chart"; + +type RightDependencyDraggableProps = { + block: IGanttBlock; + ganttContainerRef: RefObject; +}; +export const RightDependencyDraggable = (props: RightDependencyDraggableProps) => <>; diff --git a/web/ce/components/gantt-chart/dependency/dependency-paths.tsx b/web/ce/components/gantt-chart/dependency/dependency-paths.tsx new file mode 100644 index 0000000000..f049875f11 --- /dev/null +++ b/web/ce/components/gantt-chart/dependency/dependency-paths.tsx @@ -0,0 +1 @@ +export const TimelineDependencyPaths = () => <>; diff --git a/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx b/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx new file mode 100644 index 0000000000..3b4aa350d8 --- /dev/null +++ b/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx @@ -0,0 +1 @@ +export const TimelineDraggablePath = () => <>; diff --git a/web/ce/components/gantt-chart/dependency/index.ts b/web/ce/components/gantt-chart/dependency/index.ts new file mode 100644 index 0000000000..91d0018db5 --- /dev/null +++ b/web/ce/components/gantt-chart/dependency/index.ts @@ -0,0 +1,3 @@ +export * from "./blockDraggables"; +export * from "./dependency-paths"; +export * from "./draggable-dependency-path"; diff --git a/web/ce/components/gantt-chart/index.ts b/web/ce/components/gantt-chart/index.ts new file mode 100644 index 0000000000..d08e0f7d61 --- /dev/null +++ b/web/ce/components/gantt-chart/index.ts @@ -0,0 +1 @@ +export * from "./dependency"; diff --git a/web/ce/components/relations/index.tsx b/web/ce/components/relations/index.tsx new file mode 100644 index 0000000000..5c110b8e8f --- /dev/null +++ b/web/ce/components/relations/index.tsx @@ -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 = { + relates_to: { + key: "relates_to", + label: "Relates to", + className: "bg-custom-background-80 text-custom-text-200", + icon: (size) => , + placeholder: "Add related issues", + }, + duplicate: { + key: "duplicate", + label: "Duplicate of", + className: "bg-custom-background-80 text-custom-text-200", + icon: (size) => , + placeholder: "None", + }, + blocked_by: { + key: "blocked_by", + label: "Blocked by", + className: "bg-red-500/20 text-red-700", + icon: (size) => , + placeholder: "None", + }, + blocking: { + key: "blocking", + label: "Blocking", + className: "bg-yellow-500/20 text-yellow-700", + icon: (size) => , + placeholder: "None", + }, +}; diff --git a/web/ce/constants/gantt-chart.ts b/web/ce/constants/gantt-chart.ts new file mode 100644 index 0000000000..228b94b5bf --- /dev/null +++ b/web/ce/constants/gantt-chart.ts @@ -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", +}; diff --git a/web/ce/constants/index.ts b/web/ce/constants/index.ts new file mode 100644 index 0000000000..123db122c8 --- /dev/null +++ b/web/ce/constants/index.ts @@ -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"; diff --git a/web/ce/constants/issues.ts b/web/ce/constants/issues.ts index a139dc86a1..d30ec492e5 100644 --- a/web/ce/constants/issues.ts +++ b/web/ce/constants/issues.ts @@ -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; diff --git a/web/ce/store/timeline/base-timeline.store.ts b/web/ce/store/timeline/base-timeline.store.ts new file mode 100644 index 0000000000..944a500a84 --- /dev/null +++ b/web/ce/store/timeline/base-timeline.store.ts @@ -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 = {}; + 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); +} diff --git a/web/ce/types/gantt-chart.ts b/web/ce/types/gantt-chart.ts new file mode 100644 index 0000000000..36bb65c60f --- /dev/null +++ b/web/ce/types/gantt-chart.ts @@ -0,0 +1 @@ +export type TIssueRelationTypes = "blocking" | "blocked_by" | "duplicate" | "relates_to"; diff --git a/web/ce/types/index.ts b/web/ce/types/index.ts index 0d4b66523e..105b7e96a4 100644 --- a/web/ce/types/index.ts +++ b/web/ce/types/index.ts @@ -1,2 +1,3 @@ export * from "./projects"; export * from "./issue-types"; +export * from "./gantt-chart"; diff --git a/web/core/components/api-token/modal/form.tsx b/web/core/components/api-token/modal/form.tsx index 61a6fd5671..18ade7f2af 100644 --- a/web/core/components/api-token/modal/form.tsx +++ b/web/core/components/api-token/modal/form.tsx @@ -50,7 +50,7 @@ const defaultValues: Partial = { 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; diff --git a/web/core/components/core/render-if-visible-HOC.tsx b/web/core/components/core/render-if-visible-HOC.tsx index 3e697a53d9..ea3fc9c2f8 100644 --- a/web/core/components/core/render-if-visible-HOC.tsx +++ b/web/core/components/core/render-if-visible-HOC.tsx @@ -13,6 +13,7 @@ type Props = { defaultValue?: boolean; shouldRecordHeights?: boolean; useIdletime?: boolean; + forceRender?: boolean; }; const RenderIfVisible: React.FC = (props) => { @@ -29,12 +30,13 @@ const RenderIfVisible: React.FC = (props) => { placeholderChildren = null, //placeholder children defaultValue = false, useIdletime = false, + forceRender = false, } = props; const [shouldVisible, setShouldVisible] = useState(defaultValue); const placeholderHeight = useRef(defaultHeight); const intersectionRef = useRef(null); - const isVisible = shouldVisible; + const isVisible = shouldVisible || forceRender; // Set visibility with intersection observer useEffect(() => { diff --git a/web/core/components/cycles/gantt-chart/blocks.tsx b/web/core/components/cycles/gantt-chart/blocks.tsx deleted file mode 100644 index 5c2ac815a7..0000000000 --- a/web/core/components/cycles/gantt-chart/blocks.tsx +++ /dev/null @@ -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 = 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 ( -
- router.push(`/${workspaceSlug?.toString()}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`) - } - > -
- -
{cycleDetails?.name}
-
- {renderFormattedDate(cycleDetails?.start_date ?? "")} to{" "} - {renderFormattedDate(cycleDetails?.end_date ?? "")} -
-
- } - position="top-left" - > -
{cycleDetails?.name}
- -
- ); -}); - -export const CycleGanttSidebarBlock: React.FC = 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 ( - - -
{cycleDetails?.name}
- - ); -}); diff --git a/web/core/components/cycles/gantt-chart/cycles-list-layout.tsx b/web/core/components/cycles/gantt-chart/cycles-list-layout.tsx deleted file mode 100644 index 5f147e0bca..0000000000 --- a/web/core/components/cycles/gantt-chart/cycles-list-layout.tsx +++ /dev/null @@ -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 = 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 ( -
- handleCycleUpdate(block, payload)} - sidebarToRender={(props) => } - blockToRender={(data: ICycle) => } - enableBlockLeftResize={false} - enableBlockRightResize={false} - enableBlockMove={false} - enableReorder - /> -
- ); -}); diff --git a/web/core/components/cycles/gantt-chart/index.ts b/web/core/components/cycles/gantt-chart/index.ts deleted file mode 100644 index a0a16086b2..0000000000 --- a/web/core/components/cycles/gantt-chart/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./blocks"; -export * from "./cycles-list-layout"; diff --git a/web/core/components/cycles/index.ts b/web/core/components/cycles/index.ts index 679ab7238a..7013beeaba 100644 --- a/web/core/components/cycles/index.ts +++ b/web/core/components/cycles/index.ts @@ -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"; diff --git a/web/core/components/dashboard/widgets/issue-panels/issue-list-item.tsx b/web/core/components/dashboard/widgets/issue-panels/issue-list-item.tsx index ba311ee565..becea09841 100644 --- a/web/core/components/dashboard/widgets/issue-panels/issue-list-item.tsx +++ b/web/core/components/dashboard/widgets/issue-panels/issue-list-item.tsx @@ -72,7 +72,6 @@ export const AssignedUpcomingIssueListItem: React.FC = 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" /> ) diff --git a/web/core/components/gantt-chart/blocks/block-row-list.tsx b/web/core/components/gantt-chart/blocks/block-row-list.tsx new file mode 100644 index 0000000000..c64dc81dfa --- /dev/null +++ b/web/core/components/gantt-chart/blocks/block-row-list.tsx @@ -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; +}; + +export const GanttChartRowList: FC = (props) => { + const { blockIds, blockUpdateHandler, enableAddBlock, showAllBlocks, selectionHelpers, ganttContainerRef } = props; + + return ( +
+ {blockIds?.map((blockId) => ( + <> + } + shouldRecordHeights={false} + > + + + + ))} +
+ ); +}; diff --git a/web/core/components/gantt-chart/blocks/block-row.tsx b/web/core/components/gantt-chart/blocks/block-row.tsx new file mode 100644 index 0000000000..cc5672c845 --- /dev/null +++ b/web/core/components/gantt-chart/blocks/block-row.tsx @@ -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; +}; + +export const BlockRow: React.FC = 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 ( +
updateActiveBlockId(blockId)} + onMouseLeave={() => updateActiveBlockId(null)} + style={{ + height: `${BLOCK_HEIGHT}px`, + }} + > +
+ {isBlockVisibleOnChart + ? isHidden && ( + + ) + : enableAddBlock && } +
+
+ ); +}); diff --git a/web/core/components/gantt-chart/blocks/block.tsx b/web/core/components/gantt-chart/blocks/block.tsx index 805ea9876c..a1c83c0dae 100644 --- a/web/core/components/gantt-chart/blocks/block.tsx +++ b/web/core/components/gantt-chart/blocks/block.tsx @@ -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; - selectionHelpers: TSelectionHelper; + ganttContainerRef: RefObject; + updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise; }; export const GanttChartBlock: React.FC = 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(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 (
-
updateActiveBlockId(blockId)} - onMouseLeave={() => updateActiveBlockId(null)} - > - {isBlockVisibleOnChart ? ( - handleChartBlockPosition(block, ...args)} - enableBlockLeftResize={enableBlockLeftResize} - enableBlockRightResize={enableBlockRightResize} - enableBlockMove={enableBlockMove} - ganttContainerRef={ganttContainerRef} - /> - ) : ( - enableAddBlock && - )} -
+ {isBlockVisibleOnChart && ( + } + shouldRecordHeights={false} + forceRender={isCurrentDependencyDragging} + > +
updateActiveBlockId(blockId)} + onMouseLeave={() => updateActiveBlockId(null)} + > + +
+
+ )}
); }); diff --git a/web/core/components/gantt-chart/blocks/blocks-list.tsx b/web/core/components/gantt-chart/blocks/blocks-list.tsx index c4ffae1387..8015819d75 100644 --- a/web/core/components/gantt-chart/blocks/blocks-list.tsx +++ b/web/core/components/gantt-chart/blocks/blocks-list.tsx @@ -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; showAllBlocks: boolean; - selectionHelpers: TSelectionHelper; + updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise; }; export const GanttChartBlocksList: FC = (props) => { const { - itemsContainerWidth, blockIds, blockToRender, - blockUpdateHandler, - getBlockById, enableBlockLeftResize, enableBlockRightResize, enableBlockMove, - enableAddBlock, ganttContainerRef, showAllBlocks, - selectionHelpers, + updateBlockDates, } = props; return ( -
+ <> {blockIds?.map((blockId) => ( = (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} /> ))} -
+ ); }; diff --git a/web/core/components/gantt-chart/chart/header.tsx b/web/core/components/gantt-chart/chart/header.tsx index 9159050eab..5a8f4bb65a 100644 --- a/web/core/components/gantt-chart/chart/header.tsx +++ b/web/core/components/gantt-chart/chart/header.tsx @@ -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 = observer((props) => { const { blockIds, fullScreenMode, handleChartView, handleToday, loaderTitle, toggleFullScreenMode, showToday } = props; // chart hook - const { currentView } = useGanttChart(); + const { currentView } = useTimeLineChartStore(); return ( - +
{blockIds ? `${blockIds.length} ${loaderTitle}` : "Loading..."} diff --git a/web/core/components/gantt-chart/chart/main-content.tsx b/web/core/components/gantt-chart/chart/main-content.tsx index bb55fbb347..16d0c69d91 100644 --- a/web/core/components/gantt-chart/chart/main-content.tsx +++ b/web/core/components/gantt-chart/chart/main-content.tsx @@ -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; blockToRender: (data: any) => React.ReactNode; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; bottomSpacing: boolean; @@ -55,7 +53,6 @@ type Props = { export const GanttChartMainContent: React.FC = observer((props) => { const { blockIds, - getBlockById, loadMoreBlocks, blockToRender, blockUpdateHandler, @@ -73,11 +70,12 @@ export const GanttChartMainContent: React.FC = observer((props) => { canLoadMoreBlocks, updateCurrentViewRenderPayload, quickAdd, + updateBlockDates, } = props; // refs const ganttContainerRef = useRef(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 = observer((props) => { autoScrollForElements({ element, getAllowedAxis: () => "vertical", + canScroll: ({ source }) => source.data.dragInstanceId === "GANTT_REORDER", }) ); }, [ganttContainerRef?.current]); + // handling scroll functionality const onScroll = (e: React.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 = 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 ( - - {(helpers) => ( - <> -
- -
- - {currentViewData && ( - + <> + + + {(helpers) => ( + <> +
+ +
+ + {currentViewData && ( +
+ + + + +
+ )} +
-
- - - )} - + + + )} + + ); }); diff --git a/web/core/components/gantt-chart/chart/root.tsx b/web/core/components/gantt-chart/chart/root.tsx index 3b42e81b6a..45a2bfaec7 100644 --- a/web/core/components/gantt-chart/chart/root.tsx +++ b/web/core/components/gantt-chart/chart/root.tsx @@ -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; canLoadMoreBlocks?: boolean; quickAdd?: React.JSX.Element | undefined; showToday: boolean; @@ -43,7 +40,6 @@ export const ChartViewRoot: FC = observer((props) => { border, title, blockIds, - getBlockById, loadMoreBlocks, loaderTitle, blockUpdateHandler, @@ -60,13 +56,14 @@ export const ChartViewRoot: FC = 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 = 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 = 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 = observer((props) => { /> = observer((props) => { title={title} updateCurrentViewRenderPayload={updateCurrentViewRenderPayload} quickAdd={quickAdd} + updateBlockDates={updateBlockDates} />
); diff --git a/web/core/components/gantt-chart/chart/timeline-drag-helper.tsx b/web/core/components/gantt-chart/chart/timeline-drag-helper.tsx new file mode 100644 index 0000000000..e9896d87e5 --- /dev/null +++ b/web/core/components/gantt-chart/chart/timeline-drag-helper.tsx @@ -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; +}; +export const TimelineDragHelper = observer((props: Props) => { + const { ganttContainerRef } = props; + const { isDragging } = useTimeLineChartStore(); + + useAutoScroller(ganttContainerRef, isDragging, SIDEBAR_WIDTH, HEADER_HEIGHT); + return <>; +}); diff --git a/web/core/components/gantt-chart/chart/views/bi-week.tsx b/web/core/components/gantt-chart/chart/views/bi-week.tsx deleted file mode 100644 index 38c4dd386c..0000000000 --- a/web/core/components/gantt-chart/chart/views/bi-week.tsx +++ /dev/null @@ -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 = observer(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentViewData, renderView } = useGanttChart(); - - return ( - <> -
- {renderView && - renderView.length > 0 && - renderView.map((_itemRoot: any, _idxRoot: any) => ( -
-
-
- {_itemRoot?.title} -
-
- -
- {_itemRoot.children && - _itemRoot.children.length > 0 && - _itemRoot.children.map((_item: any, _idx: any) => ( -
-
-
{_item.title}
-
-
- {_item?.today &&
} -
-
- ))} -
-
- ))} -
- - ); -}); diff --git a/web/core/components/gantt-chart/chart/views/day.tsx b/web/core/components/gantt-chart/chart/views/day.tsx deleted file mode 100644 index 165ba81ad4..0000000000 --- a/web/core/components/gantt-chart/chart/views/day.tsx +++ /dev/null @@ -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 = observer(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentViewData, renderView } = useGanttChart(); - - return ( - <> -
- {renderView && - renderView.length > 0 && - renderView.map((_itemRoot: any, _idxRoot: any) => ( -
-
-
- {_itemRoot?.title} -
-
- -
- {_itemRoot.children && - _itemRoot.children.length > 0 && - _itemRoot.children.map((_item: any, _idx: any) => ( -
-
-
{_item.title}
-
-
- {_item?.today &&
} -
-
- ))} -
-
- ))} -
- - ); -}); diff --git a/web/core/components/gantt-chart/chart/views/hours.tsx b/web/core/components/gantt-chart/chart/views/hours.tsx deleted file mode 100644 index a56fde2cac..0000000000 --- a/web/core/components/gantt-chart/chart/views/hours.tsx +++ /dev/null @@ -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 = observer(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentViewData, renderView } = useGanttChart(); - - return ( - <> -
- {renderView && - renderView.length > 0 && - renderView.map((_itemRoot: any, _idxRoot: any) => ( -
-
-
- {_itemRoot?.title} -
-
- -
- {_itemRoot.children && - _itemRoot.children.length > 0 && - _itemRoot.children.map((_item: any, _idx: any) => ( -
-
-
{_item.title}
-
-
- {_item?.today &&
} -
-
- ))} -
-
- ))} -
- - ); -}); diff --git a/web/core/components/gantt-chart/chart/views/index.ts b/web/core/components/gantt-chart/chart/views/index.ts index 8936623c2d..ea7c85e841 100644 --- a/web/core/components/gantt-chart/chart/views/index.ts +++ b/web/core/components/gantt-chart/chart/views/index.ts @@ -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"; diff --git a/web/core/components/gantt-chart/chart/views/month.tsx b/web/core/components/gantt-chart/chart/views/month.tsx index b3a095778a..583d271de6 100644 --- a/web/core/components/gantt-chart/chart/views/month.tsx +++ b/web/core/components/gantt-chart/chart/views/month.tsx @@ -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 = 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 ( -
- {monthBlocks?.map((block, rootIndex) => ( -
+
+ {currentViewData && ( +
+ {/** Header Div */}
-
-
- {block?.title} -
-
-
- {block?.children?.map((monthDay, index) => ( + {/** Main Month Title */} +
+ {months?.map((monthBlock) => (
-
- {monthDay.dayData.shortTitle[0]}{" "} - - {monthDay.day} - +
+ {monthBlock?.title} + {monthBlock.today && ( + + Current + + )}
))}
+ {/** Weeks Sub title */} +
+ {weeks?.map((weekBlock) => ( +
+
+ + {weekBlock.startDate.getDate()}-{weekBlock.endDate.getDate()} + +
+
{weekBlock.weekData.shortTitle}
+
+ ))} +
-
- {block?.children?.map((monthDay, index) => ( + {/** Week Columns */} +
+ {weeks?.map((weekBlock) => (
- {["sat", "sun"].includes(monthDay?.dayData?.shortTitle) && ( -
- )} -
+ 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` }} + /> ))}
- ))} + )}
); }); diff --git a/web/core/components/gantt-chart/chart/views/quarter.tsx b/web/core/components/gantt-chart/chart/views/quarter.tsx index 6b013f02bf..1bd3e18070 100644 --- a/web/core/components/gantt-chart/chart/views/quarter.tsx +++ b/web/core/components/gantt-chart/chart/views/quarter.tsx @@ -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 = 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 ( - <> -
- {renderView && - renderView.length > 0 && - renderView.map((_itemRoot: any, _idxRoot: any) => ( -
-
-
- {_itemRoot?.title} +
+ {currentViewData && + quarterBlocks?.map((quarterBlock, rootIndex) => ( +
+ {/** Header Div */} +
+ {/** Main Quarter Title */} +
+
+ {quarterBlock?.title} + {quarterBlock.today && ( + + Current + + )} +
+
+ {quarterBlock.shortTitle}
- -
- {_itemRoot.children && - _itemRoot.children.length > 0 && - _itemRoot.children.map((_item: any, _idx: any) => ( -
-
+ {quarterBlock?.children?.map((monthBlock, index) => ( +
+
+ -
{_item.title}
-
-
- {_item?.today &&
} -
+ {monthBlock.monthData.shortTitle} +
- ))} +
+ ))}
- ))} -
- + {/** Month Columns */} +
+ {quarterBlock?.children?.map((monthBlock, index) => ( +
+ ))} +
+
+ ))} +
); }); diff --git a/web/core/components/gantt-chart/chart/views/week.tsx b/web/core/components/gantt-chart/chart/views/week.tsx index 1863772387..88cb6b0430 100644 --- a/web/core/components/gantt-chart/chart/views/week.tsx +++ b/web/core/components/gantt-chart/chart/views/week.tsx @@ -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 = observer(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentViewData, renderView } = useGanttChart(); + const { currentViewData, renderView } = useTimeLineChartStore(); + const weekBlocks: IWeekBlock[] = renderView; return ( - <> -
- {renderView && - renderView.length > 0 && - renderView.map((_itemRoot: any, _idxRoot: any) => ( -
-
-
- {_itemRoot?.title} +
+ {currentViewData && + weekBlocks?.map((block, rootIndex) => ( +
+ {/** Header Div */} +
+ {/** Main Months Title */} +
+
+ {block?.title} +
+
+ {block?.weekData?.title}
- -
- {_itemRoot.children && - _itemRoot.children.length > 0 && - _itemRoot.children.map((_item: any, _idx: any) => ( -
-
-
{_item.title}
-
-
- {_item?.today &&
} -
+ {/** Days Sub title */} +
+ {block?.children?.map((weekDay, index) => ( +
+
+ {weekDay.dayData.abbreviation}
- ))} +
+ + {weekDay.date.getDate()} + +
+
+ ))}
- ))} -
- + {/** Day Columns */} +
+ {block?.children?.map((weekDay, index) => ( +
+ {["sat", "sun"].includes(weekDay?.dayData?.shortTitle) && ( +
+ )} +
+ ))} +
+
+ ))} +
); }); diff --git a/web/core/components/gantt-chart/chart/views/year.tsx b/web/core/components/gantt-chart/chart/views/year.tsx deleted file mode 100644 index 1b6efaaed4..0000000000 --- a/web/core/components/gantt-chart/chart/views/year.tsx +++ /dev/null @@ -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 = observer(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentViewData, renderView } = useGanttChart(); - - return ( - <> -
- {renderView && - renderView.length > 0 && - renderView.map((_itemRoot: any, _idxRoot: any) => ( -
-
-
- {_itemRoot?.title} -
-
- -
- {_itemRoot.children && - _itemRoot.children.length > 0 && - _itemRoot.children.map((_item: any, _idx: any) => ( -
-
-
{_item.title}
-
-
- {_item?.today &&
} -
-
- ))} -
-
- ))} -
- - ); -}); diff --git a/web/core/components/gantt-chart/constants.ts b/web/core/components/gantt-chart/constants.ts index 52167a4984..0f11791ea2 100644 --- a/web/core/components/gantt-chart/constants.ts +++ b/web/core/components/gantt-chart/constants.ts @@ -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; diff --git a/web/core/components/gantt-chart/contexts/index.tsx b/web/core/components/gantt-chart/contexts/index.tsx index 1b3f035ed3..99f2708f96 100644 --- a/web/core/components/gantt-chart/contexts/index.tsx +++ b/web/core/components/gantt-chart/contexts/index.tsx @@ -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(ganttViewStore); +export const TimeLineTypeContext = createContext(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 = ({ children }) => { - const store = initializeStore(); - return {children}; +export const useTimeLineType = () => { + const timelineType = useContext(TimeLineTypeContext); + + return timelineType; }; diff --git a/web/core/components/gantt-chart/data/index.ts b/web/core/components/gantt-chart/data/index.ts index cc15c5d9ec..b980a0ec3e 100644 --- a/web/core/components/gantt-chart/data/index.ts +++ b/web/core/components/gantt-chart/data/index.ts @@ -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") => diff --git a/web/core/components/gantt-chart/helpers/add-block.tsx b/web/core/components/gantt-chart/helpers/add-block.tsx index 1ef0492f0c..0c8ccdcb1d 100644 --- a/web/core/components/gantt-chart/helpers/add-block.tsx +++ b/web/core/components/gantt-chart/helpers/add-block.tsx @@ -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 = 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 = 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); diff --git a/web/core/components/gantt-chart/helpers/blockResizables/left-resizable.tsx b/web/core/components/gantt-chart/helpers/blockResizables/left-resizable.tsx new file mode 100644 index 0000000000..280c609e15 --- /dev/null +++ b/web/core/components/gantt-chart/helpers/blockResizables/left-resizable.tsx @@ -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, 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 && ( +
+
{dateString}
+
+ )} +
{ + 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" + /> +
+ + ); +}); diff --git a/web/core/components/gantt-chart/helpers/blockResizables/right-resizable.tsx b/web/core/components/gantt-chart/helpers/blockResizables/right-resizable.tsx new file mode 100644 index 0000000000..2047a90b63 --- /dev/null +++ b/web/core/components/gantt-chart/helpers/blockResizables/right-resizable.tsx @@ -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, 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 && ( +
+
{dateString}
+
+ )} +
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" + /> +
+ + ); +}); diff --git a/web/core/components/gantt-chart/helpers/blockResizables/use-gantt-resizable.ts b/web/core/components/gantt-chart/helpers/blockResizables/use-gantt-resizable.ts new file mode 100644 index 0000000000..06cef69a46 --- /dev/null +++ b/web/core/components/gantt-chart/helpers/blockResizables/use-gantt-resizable.ts @@ -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, + ganttContainerRef: React.RefObject, + updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise +) => { + // refs + const initialPositionRef = useRef<{ marginLeft: number; width: number; offsetX: number }>({ + marginLeft: 0, + width: 0, + offsetX: 0, + }); + const ganttContainerDimensions = useRef(); + const currMouseEvent = useRef(); + // 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, + 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, + }; +}; diff --git a/web/core/components/gantt-chart/helpers/draggable.tsx b/web/core/components/gantt-chart/helpers/draggable.tsx index 7e3390ba81..a19b19f634 100644 --- a/web/core/components/gantt-chart/helpers/draggable.tsx +++ b/web/core/components/gantt-chart/helpers/draggable.tsx @@ -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, dragDirection: "left" | "right" | "move") => void; + isMoving: "left" | "right" | "move" | undefined; enableBlockLeftResize: boolean; enableBlockRightResize: boolean; enableBlockMove: boolean; - ganttContainerRef: React.RefObject; + ganttContainerRef: RefObject; }; export const ChartDraggable: React.FC = 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(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) => { - 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) => { - 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) => { - 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 && ( - - )} +
+ {/* left resize drag handle */} + +
enableBlockMove && handleBlockDrag(e, "move")} > - {/* left resize drag handle */} - {enableBlockLeftResize && ( - <> -
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" - /> -
- - )} -
- {blockToRender(block.data)} -
- {/* right resize drag handle */} - {enableBlockRightResize && ( - <> -
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" - /> -
- - )} + {blockToRender(block.data)}
- + {/* right resize drag handle */} + + +
); }); diff --git a/web/core/components/gantt-chart/hooks/index.ts b/web/core/components/gantt-chart/hooks/index.ts deleted file mode 100644 index 0096506751..0000000000 --- a/web/core/components/gantt-chart/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./use-gantt-chart"; diff --git a/web/core/components/gantt-chart/hooks/use-gantt-chart.ts b/web/core/components/gantt-chart/hooks/use-gantt-chart.ts deleted file mode 100644 index 916b38adcb..0000000000 --- a/web/core/components/gantt-chart/hooks/use-gantt-chart.ts +++ /dev/null @@ -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; -}; diff --git a/web/core/components/gantt-chart/index.ts b/web/core/components/gantt-chart/index.ts index 78297ffcdb..bb2cbc99c7 100644 --- a/web/core/components/gantt-chart/index.ts +++ b/web/core/components/gantt-chart/index.ts @@ -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"; diff --git a/web/core/components/gantt-chart/root.tsx b/web/core/components/gantt-chart/root.tsx index ba879cefa3..81f064e2fb 100644 --- a/web/core/components/gantt-chart/root.tsx +++ b/web/core/components/gantt-chart/root.tsx @@ -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; 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 = (props) => { +export const GanttChartRoot: FC = observer((props) => { const { border = true, title, @@ -36,7 +37,6 @@ export const GanttChartRoot: FC = (props) => { blockUpdateHandler, sidebarToRender, blockToRender, - getBlockById, loadMoreBlocks, canLoadMoreBlocks, enableBlockLeftResize = false, @@ -49,32 +49,38 @@ export const GanttChartRoot: FC = (props) => { showAllBlocks = false, showToday = true, quickAdd, + updateBlockDates, } = props; + const { setBlockIds } = useTimeLineChartStore(); + + // update the timeline store with updated blockIds + useEffect(() => { + setBlockIds(blockIds); + }, [blockIds]); + return ( - - - + ); -}; +}); diff --git a/web/core/components/gantt-chart/sidebar/cycles/block.tsx b/web/core/components/gantt-chart/sidebar/cycles/block.tsx deleted file mode 100644 index 814e3aa997..0000000000 --- a/web/core/components/gantt-chart/sidebar/cycles/block.tsx +++ /dev/null @@ -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 = observer((props) => { - const { block, isDragging } = props; - // store hooks - const { updateActiveBlockId, isBlockActive } = useGanttChart(); - - const duration = findTotalDaysInRange(block.start_date, block.target_date); - - return ( -
updateActiveBlockId(block.id)} - onMouseLeave={() => updateActiveBlockId(null)} - > -
-
-
- -
- {duration && ( -
- {duration} day{duration > 1 ? "s" : ""} -
- )} -
-
-
- ); -}); diff --git a/web/core/components/gantt-chart/sidebar/cycles/index.ts b/web/core/components/gantt-chart/sidebar/cycles/index.ts deleted file mode 100644 index 01acaeffb1..0000000000 --- a/web/core/components/gantt-chart/sidebar/cycles/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sidebar"; diff --git a/web/core/components/gantt-chart/sidebar/cycles/sidebar.tsx b/web/core/components/gantt-chart/sidebar/cycles/sidebar.tsx deleted file mode 100644 index c5a0a28ff4..0000000000 --- a/web/core/components/gantt-chart/sidebar/cycles/sidebar.tsx +++ /dev/null @@ -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) => { - const { blockUpdateHandler, blockIds, getBlockById, enableReorder } = props; - - const handleOnDrop = ( - draggingBlockId: string | undefined, - droppedBlockId: string | undefined, - dropAtEndOfList: boolean - ) => { - handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blockIds, getBlockById, blockUpdateHandler); - }; - - return ( -
- {blockIds ? ( - blockIds.map((blockId, index) => { - const block = getBlockById(blockId); - if (!block.start_date || !block.target_date) return null; - return ( - - {(isDragging: boolean) => } - - ); - }) - ) : ( - - - - - - - )} -
- ); -}; diff --git a/web/core/components/gantt-chart/sidebar/index.ts b/web/core/components/gantt-chart/sidebar/index.ts index ef9bfb5cbf..e0e48c81eb 100644 --- a/web/core/components/gantt-chart/sidebar/index.ts +++ b/web/core/components/gantt-chart/sidebar/index.ts @@ -1,4 +1,3 @@ -export * from "./cycles"; export * from "./issues"; export * from "./modules"; export * from "./root"; diff --git a/web/core/components/gantt-chart/sidebar/issues/block.tsx b/web/core/components/gantt-chart/sidebar/issues/block.tsx index 93b1f7962c..9b0dc270b2 100644 --- a/web/core/components/gantt-chart/sidebar/issues/block.tsx +++ b/web/core/components/gantt-chart/sidebar/issues/block.tsx @@ -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); diff --git a/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx b/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx index b0be6b8351..b2f6b87928 100644 --- a/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx +++ b/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx @@ -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; @@ -32,7 +35,6 @@ export const IssueGanttSidebar: React.FC = observer((props) => { const { blockUpdateHandler, blockIds, - getBlockById, enableReorder, enableSelection, loadMoreBlocks, @@ -42,6 +44,8 @@ export const IssueGanttSidebar: React.FC = observer((props) => { selectionHelpers, } = props; + const { getBlockById } = useTimeLineChart(ETimeLineTypeType.ISSUE); + const { issues: { getIssueLoader }, } = useIssuesStore(); @@ -77,22 +81,30 @@ export const IssueGanttSidebar: React.FC = observer((props) => { if (!block || (!showAllBlocks && !isBlockVisibleOnSidebar)) return; return ( - } > - {(isDragging: boolean) => ( - - )} - + + {(isDragging: boolean) => ( + + )} + + ); })} {canLoadMoreBlocks && ( diff --git a/web/core/components/gantt-chart/sidebar/modules/block.tsx b/web/core/components/gantt-chart/sidebar/modules/block.tsx index e5a1603ef5..2dc85ac20a 100644 --- a/web/core/components/gantt-chart/sidebar/modules/block.tsx +++ b/web/core/components/gantt-chart/sidebar/modules/block.tsx @@ -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 = 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 (
= (props) => { return (
{blockIds ? ( - blockIds.map((blockId, index) => { - const block = getBlockById(blockId); - return ( - - {(isDragging: boolean) => } - - ); - }) + blockIds.map((blockId, index) => ( + + {(isDragging: boolean) => } + + )) ) : ( diff --git a/web/core/components/gantt-chart/sidebar/root.tsx b/web/core/components/gantt-chart/sidebar/root.tsx index 70e5e152fd..31c9137cc5 100644 --- a/web/core/components/gantt-chart/sidebar/root.tsx +++ b/web/core/components/gantt-chart/sidebar/root.tsx @@ -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 = observer((props) => { enableReorder, enableSelection, sidebarToRender, - getBlockById, loadMoreBlocks, canLoadMoreBlocks, ganttContainerRef, @@ -86,7 +84,6 @@ export const GanttChartSidebar: React.FC = observer((props) => { title, blockUpdateHandler, blockIds, - getBlockById, enableReorder, enableSelection, canLoadMoreBlocks, diff --git a/web/core/components/gantt-chart/types/index.ts b/web/core/components/gantt-chart/types/index.ts index cd90758fc3..022e1e6a40 100644 --- a/web/core/components/gantt-chart/types/index.ts +++ b/web/core/components/gantt-chart/types/index.ts @@ -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; } diff --git a/web/core/components/gantt-chart/views/bi-week-view.ts b/web/core/components/gantt-chart/views/bi-week-view.ts deleted file mode 100644 index 6ace4bcc48..0000000000 --- a/web/core/components/gantt-chart/views/bi-week-view.ts +++ /dev/null @@ -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; -}; diff --git a/web/core/components/gantt-chart/views/day-view.ts b/web/core/components/gantt-chart/views/day-view.ts deleted file mode 100644 index e8da6801cc..0000000000 --- a/web/core/components/gantt-chart/views/day-view.ts +++ /dev/null @@ -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 }; -}; diff --git a/web/core/components/gantt-chart/views/helpers.ts b/web/core/components/gantt-chart/views/helpers.ts index 4bd295ce35..a0976c2307 100644 --- a/web/core/components/gantt-chart/views/helpers.ts +++ b/web/core/components/gantt-chart/views/helpers.ts @@ -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, }; diff --git a/web/core/components/gantt-chart/views/hours-view.ts b/web/core/components/gantt-chart/views/hours-view.ts deleted file mode 100644 index e8da6801cc..0000000000 --- a/web/core/components/gantt-chart/views/hours-view.ts +++ /dev/null @@ -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 }; -}; diff --git a/web/core/components/gantt-chart/views/index.ts b/web/core/components/gantt-chart/views/index.ts index 8d4cb9be6b..8a4835739c 100644 --- a/web/core/components/gantt-chart/views/index.ts +++ b/web/core/components/gantt-chart/views/index.ts @@ -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"; diff --git a/web/core/components/gantt-chart/views/month-view.ts b/web/core/components/gantt-chart/views/month-view.ts index 8bb6353243..1bbd6c2885 100644 --- a/web/core/components/gantt-chart/views/month-view.ts +++ b/web/core/components/gantt-chart/views/month-view.ts @@ -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, }; diff --git a/web/core/components/gantt-chart/views/quarter-view.ts b/web/core/components/gantt-chart/views/quarter-view.ts new file mode 100644 index 0000000000..8a1e812a85 --- /dev/null +++ b/web/core/components/gantt-chart/views/quarter-view.ts @@ -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, +}; diff --git a/web/core/components/gantt-chart/views/quater-view.ts b/web/core/components/gantt-chart/views/quater-view.ts deleted file mode 100644 index 9d45a43a13..0000000000 --- a/web/core/components/gantt-chart/views/quater-view.ts +++ /dev/null @@ -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; -}; diff --git a/web/core/components/gantt-chart/views/week-view.ts b/web/core/components/gantt-chart/views/week-view.ts index bd4ae383d6..d47d79d7e1 100644 --- a/web/core/components/gantt-chart/views/week-view.ts +++ b/web/core/components/gantt-chart/views/week-view.ts @@ -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, }; diff --git a/web/core/components/gantt-chart/views/year-view.ts b/web/core/components/gantt-chart/views/year-view.ts deleted file mode 100644 index 69ff9dae89..0000000000 --- a/web/core/components/gantt-chart/views/year-view.ts +++ /dev/null @@ -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; -}; diff --git a/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx b/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx index 58018c13b2..22b1827da7 100644 --- a/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx +++ b/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx @@ -24,17 +24,17 @@ export const IssueDetailWidgetCollapsibles: FC = 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; diff --git a/web/core/components/issues/issue-detail-widgets/relations/content.tsx b/web/core/components/issues/issue-detail-widgets/relations/content.tsx index b078c19d11..672faabb9d 100644 --- a/web/core/components/issues/issue-detail-widgets/relations/content.tsx +++ b/web/core/components/issues/issue-detail-widgets/relations/content.tsx @@ -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) => , - className: "bg-red-500/20 text-red-700", - }, - { - key: "blocking", - label: "Blocking", - icon: (size: number) => , - className: "bg-yellow-500/20 text-yellow-700", - }, - { - key: "relates_to", - label: "Relates to", - icon: (size: number) => , - className: "bg-custom-background-80 text-custom-text-200", - }, - { - key: "duplicate", - label: "Duplicate of", - icon: (size: number) => , - 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 = observer((props) => { const { workspaceSlug, projectId, issueId, disabled = false } = props; // state @@ -96,17 +79,19 @@ export const RelationsCollapsibleContent: FC = 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); diff --git a/web/core/components/issues/issue-detail-widgets/relations/helper.tsx b/web/core/components/issues/issue-detail-widgets/relations/helper.tsx index fe3be8ca4a..ac8b0f6636 100644 --- a/web/core/components/issues/issue-detail-widgets/relations/helper.tsx +++ b/web/core/components/issues/issue-detail-widgets/relations/helper.tsx @@ -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) => , - className: "bg-red-500/20 text-red-700", - }, - { - key: "blocking", - label: "Blocking", - icon: (size: number) => , - className: "bg-yellow-500/20 text-yellow-700", - }, - { - key: "relates_to", - label: "Relates to", - icon: (size: number) => , - className: "bg-custom-background-80 text-custom-text-200", - }, - { - key: "duplicate", - label: "Duplicate of", - icon: (size: number) => , - className: "bg-custom-background-80 text-custom-text-200", - }, -]; diff --git a/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx b/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx index 67161ecbdd..570ec01035 100644 --- a/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx +++ b/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx @@ -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 = observer((props) => { const customButtonElement = customButton ? <>{customButton} : ; return ( - - {ISSUE_RELATION_OPTIONS.map((item, index) => ( + + {Object.values(ISSUE_RELATION_OPTIONS).map((item, index) => ( { diff --git a/web/core/components/issues/issue-detail-widgets/relations/title.tsx b/web/core/components/issues/issue-detail-widgets/relations/title.tsx index 8c6de6df82..a288146d16 100644 --- a/web/core/components/issues/issue-detail-widgets/relations/title.tsx +++ b/web/core/components/issues/issue-detail-widgets/relations/title.tsx @@ -17,12 +17,11 @@ export const RelationsCollapsibleTitle: FC = 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( diff --git a/web/core/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx b/web/core/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx index aea04a5fec..cb6c07c991 100644 --- a/web/core/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx @@ -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 = observer((props if (!activity) return <>; return ( } + icon={activity.field ? ISSUE_RELATION_OPTIONS[activity.field as TIssueRelationTypes].icon(14) : <>} activityId={activityId} ends={ends} > diff --git a/web/core/components/issues/issue-detail/relation-select.tsx b/web/core/components/issues/issue-detail/relation-select.tsx index c787c5c644..a1a1f09d21 100644 --- a/web/core/components/issues/issue-detail/relation-select.tsx +++ b/web/core/components/issues/issue-detail/relation-select.tsx @@ -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 = { - relates_to: { - className: "bg-custom-background-80 text-custom-text-200", - icon: (size) => , - placeholder: "Add related issues", - }, - blocking: { - className: "bg-yellow-500/20 text-yellow-700", - icon: (size) => , - placeholder: "None", - }, - blocked_by: { - className: "bg-red-500/20 text-red-700", - icon: (size) => , - placeholder: "None", - }, - duplicate: { - className: "bg-custom-background-80 text-custom-text-200", - icon: (size) => , - 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 = observer((pro return (
= observer((pro })}
) : ( - {issueRelationObject[relationKey].placeholder} + {ISSUE_RELATION_OPTIONS[relationKey].placeholder} )} {!disabled && ( = 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 = 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 = 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 = 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 = observer((props: IBaseGan return ( -
- } - sidebarToRender={(props) => } - 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 - /> -
+ +
+ } + sidebarToRender={(props) => } + 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 + /> +
+
); }); diff --git a/web/core/components/issues/relations/issue-list-item.tsx b/web/core/components/issues/relations/issue-list-item.tsx index 20bf1e2f06..9ac1253aea 100644 --- a/web/core/components/issues/relations/issue-list-item.tsx +++ b/web/core/components/issues/relations/issue-list-item.tsx @@ -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 = { diff --git a/web/core/components/issues/relations/issue-list.tsx b/web/core/components/issues/relations/issue-list.tsx index 5f63dd4543..1b89788a41 100644 --- a/web/core/components/issues/relations/issue-list.tsx +++ b/web/core/components/issues/relations/issue-list.tsx @@ -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 = { diff --git a/web/core/components/modules/gantt-chart/modules-list-layout.tsx b/web/core/components/modules/gantt-chart/modules-list-layout.tsx index 73f07bbeff..29d1585579 100644 --- a/web/core/components/modules/gantt-chart/modules-list-layout.tsx +++ b/web/core/components/modules/gantt-chart/modules-list-layout.tsx @@ -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 = {}; + + 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 ( - } - blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)} - blockToRender={(data: IModule) => } - enableBlockLeftResize={isAllowed} - enableBlockRightResize={isAllowed} - enableBlockMove={isAllowed} - enableReorder={isAllowed && displayFilters?.order_by === "sort_order"} - enableAddBlock={isAllowed} - showAllBlocks - /> + + } + blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)} + blockToRender={(data: IModule) => } + enableBlockLeftResize={isAllowed} + enableBlockRightResize={isAllowed} + enableBlockMove={isAllowed} + enableReorder={isAllowed && displayFilters?.order_by === "sort_order"} + enableAddBlock={isAllowed} + updateBlockDates={updateBlockDates} + showAllBlocks + /> + ); }); diff --git a/web/core/components/ui/loader/layouts/gantt-layout-loader.tsx b/web/core/components/ui/loader/layouts/gantt-layout-loader.tsx index 9a4aa77742..bb4cd47e27 100644 --- a/web/core/components/ui/loader/layouts/gantt-layout-loader.tsx +++ b/web/core/components/ui/loader/layouts/gantt-layout-loader.tsx @@ -1,6 +1,14 @@ import { Row } from "@plane/ui"; +import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants"; import { getRandomLength } from "../utils"; +export const GanttLayoutLIstItem = () => ( +
+
+
+
+); + export const GanttLayoutLoader = () => (
diff --git a/web/core/hooks/use-auto-scroller.tsx b/web/core/hooks/use-auto-scroller.tsx new file mode 100644 index 0000000000..3c0d51316d --- /dev/null +++ b/web/core/hooks/use-auto-scroller.tsx @@ -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, + shouldScroll = false, + leftOffset = 0, + topOffset = 0 +) => { + const containerDimensions = useRef(); + const intervalId = useRef | 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]); +}; diff --git a/web/core/hooks/use-timeline-chart.ts b/web/core/hooks/use-timeline-chart.ts new file mode 100644 index 0000000000..1384279ddc --- /dev/null +++ b/web/core/hooks/use-timeline-chart.ts @@ -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); +}; diff --git a/web/core/layouts/auth-layout/project-wrapper.tsx b/web/core/layouts/auth-layout/project-wrapper.tsx index 405e6d60bc..c5a812569b 100644 --- a/web/core/layouts/auth-layout/project-wrapper.tsx +++ b/web/core/layouts/auth-layout/project-wrapper.tsx @@ -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 = 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 = 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 diff --git a/web/core/services/issue/issue.service.ts b/web/core/services/issue/issue.service.ts index ba1585e140..97fef0c16b 100644 --- a/web/core/services/issue/issue.service.ts +++ b/web/core/services/issue/issue.service.ts @@ -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 { + 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 { + 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 { + 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 { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/sub-issues/`) .then((response) => response?.data) diff --git a/web/core/services/issue/issue_relation.service.ts b/web/core/services/issue/issue_relation.service.ts index 927d3af42b..2168cdb44b 100644 --- a/web/core/services/issue/issue_relation.service.ts +++ b/web/core/services/issue/issue_relation.service.ts @@ -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() { diff --git a/web/core/store/issue/helpers/base-issues.store.ts b/web/core/store/issue/helpers/base-issues.store.ts index 816072a2e0..462643eb28 100644 --- a/web/core/store/issue/helpers/base-issues.store.ts +++ b/web/core/store/issue/helpers/base-issues.store.ts @@ -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 | "target_date"; @@ -109,6 +113,7 @@ export interface IBaseIssuesStore { addModuleIds: string[], removeModuleIds: string[] ): Promise; + updateIssueDates(workspaceSlug: string, projectId: string, updates: IBlockUpdateDependencyData[]): Promise; } // 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 = {}; + 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 = {}; + 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 diff --git a/web/core/store/issue/helpers/issue-filter-helper.store.ts b/web/core/store/issue/helpers/issue-filter-helper.store.ts index 338b6a4ac8..3db6dcbb37 100644 --- a/web/core/store/issue/helpers/issue-filter-helper.store.ts +++ b/web/core/store/issue/helpers/issue-filter-helper.store.ts @@ -18,6 +18,8 @@ import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType } from "@/constan import { getComputedDisplayFilters, getComputedDisplayProperties } from "@/helpers/issue.helper"; // lib import { storage } from "@/lib/local-storage"; +// plane-web +import { ENABLE_ISSUE_DEPENDENCIES } from "@/plane-web/constants"; interface ILocalStoreIssueFilters { key: EIssuesStoreType; @@ -116,6 +118,9 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { if (displayFilters?.layout) issueFiltersParams.layout = displayFilters?.layout; + if (ENABLE_ISSUE_DEPENDENCIES && displayFilters.layout === EIssueLayoutTypes.GANTT) + issueFiltersParams["expand"] = "issue_relation,issue_related"; + return issueFiltersParams; }; diff --git a/web/core/store/issue/issue-details/relation.store.ts b/web/core/store/issue/issue-details/relation.store.ts index e0c3869a79..1fefa82bdc 100644 --- a/web/core/store/issue/issue-details/relation.store.ts +++ b/web/core/store/issue/issue-details/relation.store.ts @@ -1,11 +1,18 @@ +import get from "lodash/get"; import set from "lodash/set"; +import uniq from "lodash/uniq"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// Plane +import { TIssueRelationIdMap, TIssueRelationMap, TIssueRelation, TIssue } from "@plane/types"; +// Plane-web +import { ISSUE_RELATION_OPTIONS } from "@/plane-web/components/relations"; +import { REVERSE_RELATIONS } from "@/plane-web/constants"; +import { TIssueRelationTypes } from "@/plane-web/types"; // services -import { TIssueRelationIdMap, TIssueRelationMap, TIssueRelationTypes, TIssueRelation, TIssue } from "@plane/types"; import { IssueRelationService } from "@/services/issue"; // types import { IIssueDetail } from "./root.store"; - export interface IIssueRelationStoreActions { // actions fetchRelations: (workspaceSlug: string, projectId: string, issueId: string) => Promise; @@ -32,7 +39,10 @@ export interface IIssueRelationStore extends IIssueRelationStoreActions { issueRelations: TIssueRelationIdMap | undefined; // helper methods getRelationsByIssueId: (issueId: string) => TIssueRelationIdMap | undefined; + getRelationCountByIssueId: (issueId: string) => number; getRelationByIssueIdRelationType: (issueId: string, relationType: TIssueRelationTypes) => string[] | undefined; + extractRelationsFromIssues: (issues: TIssue[]) => void; + createCurrentRelation: (issueId: string, relationType: TIssueRelationTypes, relatedIssueId: string) => Promise; } export class IssueRelationStore implements IIssueRelationStore { @@ -52,7 +62,9 @@ export class IssueRelationStore implements IIssueRelationStore { // actions fetchRelations: action, createRelation: action, + createCurrentRelation: action, removeRelation: action, + extractRelationsFromIssues: action, }); // root store this.rootIssueDetailStore = rootStore; @@ -73,6 +85,16 @@ export class IssueRelationStore implements IIssueRelationStore { return this.relationMap?.[issueId] ?? undefined; }; + getRelationCountByIssueId = computedFn((issueId: string) => { + const issueRelations = this.getRelationsByIssueId(issueId); + + const issueRelationKeys = (Object.keys(issueRelations ?? {}) as TIssueRelationTypes[]).filter( + (relationKey) => !!ISSUE_RELATION_OPTIONS[relationKey] + ); + + return issueRelationKeys.reduce((acc, curr) => acc + (issueRelations?.[curr]?.length ?? 0), 0); + }); + getRelationByIssueIdRelationType = (issueId: string, relationType: TIssueRelationTypes) => { if (!issueId || !relationType) return undefined; return this.relationMap?.[issueId]?.[relationType] ?? undefined; @@ -116,12 +138,24 @@ export class IssueRelationStore implements IIssueRelationStore { issues, }); + const reverseRelatedType = REVERSE_RELATIONS[relationType]; + + const issuesOfRelation = get(this.relationMap, [issueId, relationType]) ?? []; + if (response && response.length > 0) runInAction(() => { response.forEach((issue) => { + const issuesOfRelated = get(this.relationMap, [issue.id, reverseRelatedType]); this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issue]); - this.relationMap[issueId][relationType].push(issue.id); + issuesOfRelation.push(issue.id); + + if (!issuesOfRelated) { + set(this.relationMap, [issue.id, reverseRelatedType], [issueId]); + } else { + set(this.relationMap, [issue.id, reverseRelatedType], uniq([...issuesOfRelated, issueId])); + } }); + set(this.relationMap, [issueId, relationType], uniq(issuesOfRelation)); }); // fetching activity @@ -132,6 +166,61 @@ export class IssueRelationStore implements IIssueRelationStore { } }; + /** + * create Relation in current project optimistically + * @param issueId + * @param relationType + * @param relatedIssueId + * @returns + */ + createCurrentRelation = async (issueId: string, relationType: TIssueRelationTypes, relatedIssueId: string) => { + const workspaceSlug = this.rootIssueDetailStore.rootIssueStore.workspaceSlug; + const projectId = this.rootIssueDetailStore.rootIssueStore.projectId; + + if (!workspaceSlug || !projectId) return; + + const reverseRelatedType = REVERSE_RELATIONS[relationType]; + + const issuesOfRelation = get(this.relationMap, [issueId, relationType]); + const issuesOfRelated = get(this.relationMap, [relatedIssueId, reverseRelatedType]); + + try { + // update relations before API call + runInAction(() => { + if (!issuesOfRelation) { + set(this.relationMap, [issueId, relationType], [relatedIssueId]); + } else { + set(this.relationMap, [issueId, relationType], uniq([...issuesOfRelation, relatedIssueId])); + } + + if (!issuesOfRelated) { + set(this.relationMap, [relatedIssueId, reverseRelatedType], [issueId]); + } else { + set(this.relationMap, [relatedIssueId, reverseRelatedType], uniq([...issuesOfRelated, issueId])); + } + }); + + // perform API call + await this.issueRelationService.createIssueRelations(workspaceSlug, projectId, issueId, { + relation_type: relationType, + issues: [relatedIssueId], + }); + } catch (e) { + // Revert back store changes if API fails + runInAction(() => { + if (issuesOfRelation) { + set(this.relationMap, [issueId, relationType], issuesOfRelation); + } + + if (issuesOfRelated) { + set(this.relationMap, [relatedIssueId, reverseRelatedType], issuesOfRelated); + } + }); + + throw e; + } + }; + removeRelation = async ( workspaceSlug: string, projectId: string, @@ -140,10 +229,12 @@ export class IssueRelationStore implements IIssueRelationStore { related_issue: string ) => { try { - const relationIndex = this.relationMap[issueId][relationType].findIndex((_issueId) => _issueId === related_issue); + const relationIndex = this.relationMap[issueId]?.[relationType]?.findIndex( + (_issueId) => _issueId === related_issue + ); if (relationIndex >= 0) runInAction(() => { - this.relationMap[issueId][relationType].splice(relationIndex, 1); + this.relationMap[issueId]?.[relationType]?.splice(relationIndex, 1); }); const response = await this.issueRelationService.deleteIssueRelation(workspaceSlug, projectId, issueId, { @@ -151,6 +242,16 @@ export class IssueRelationStore implements IIssueRelationStore { related_issue, }); + // While removing one relation, reverse of the relation should also be removed + const reverseRelatedType = REVERSE_RELATIONS[relationType]; + const relatedIndex = this.relationMap[related_issue]?.[reverseRelatedType]?.findIndex( + (_issueId) => _issueId === related_issue + ); + if (relationIndex >= 0) + runInAction(() => { + this.relationMap[related_issue]?.[reverseRelatedType]?.splice(relatedIndex, 1); + }); + // fetching activity this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); return response; @@ -159,4 +260,48 @@ export class IssueRelationStore implements IIssueRelationStore { throw error; } }; + + /** + * Extract Relation from the issue Array objects and store it in this Store + * @param issues + */ + extractRelationsFromIssues = (issues: TIssue[]) => { + try { + runInAction(() => { + for (const issue of issues) { + const { issue_relation, issue_related, id: issueId } = issue; + + const issueRelations: { [key in TIssueRelationTypes]?: string[] } = {}; + + if (issue_relation && Array.isArray(issue_relation) && issue_relation.length) { + for (const relation of issue_relation) { + const { relation_type, id } = relation; + + if (!relation_type) continue; + + if (issueRelations[relation_type]) issueRelations[relation_type]?.push(id); + else issueRelations[relation_type] = [id]; + } + } + + if (issue_related && Array.isArray(issue_related) && issue_related.length) { + for (const relation of issue_related) { + const { relation_type, id } = relation; + + if (!relation_type) continue; + + const reverseRelatedType = REVERSE_RELATIONS[relation_type as TIssueRelationTypes]; + + if (issueRelations[reverseRelatedType]) issueRelations[reverseRelatedType]?.push(id); + else issueRelations[reverseRelatedType] = [id]; + } + } + + set(this.relationMap, [issueId], issueRelations); + } + }); + } catch (e) { + console.error("Error while extracting issue relations from issues"); + } + }; } diff --git a/web/core/store/issue/issue-details/root.store.ts b/web/core/store/issue/issue-details/root.store.ts index 2e92c05d62..120b5dc92c 100644 --- a/web/core/store/issue/issue-details/root.store.ts +++ b/web/core/store/issue/issue-details/root.store.ts @@ -7,7 +7,6 @@ import { TIssueCommentReaction, TIssueLink, TIssueReaction, - TIssueRelationTypes, TIssueDetailWidget, } from "@plane/types"; // plane web store @@ -17,6 +16,7 @@ import { IIssueActivityStoreActions, TActivityLoader, } from "@/plane-web/store/issue/issue-details/activity.store"; +import { TIssueRelationTypes } from "@/plane-web/types"; import { IIssueRootStore } from "../root.store"; import { IIssueAttachmentStore, IssueAttachmentStore, IIssueAttachmentStoreActions } from "./attachment.store"; import { IIssueCommentStore, IssueCommentStore, IIssueCommentStoreActions, TCommentLoader } from "./comment.store"; diff --git a/web/core/store/root.store.ts b/web/core/store/root.store.ts index af38f51b28..3316661262 100644 --- a/web/core/store/root.store.ts +++ b/web/core/store/root.store.ts @@ -23,6 +23,7 @@ import { IProjectViewStore, ProjectViewStore } from "./project-view.store"; import { RouterStore, IRouterStore } from "./router.store"; import { IStateStore, StateStore } from "./state.store"; import { ThemeStore, IThemeStore } from "./theme.store"; +import { ITimelineStore, TimeLineStore } from "./timeline"; import { ITransientStore, TransientStore } from "./transient.store"; import { IUserStore, UserStore } from "./user"; import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace"; @@ -56,6 +57,7 @@ export class CoreRootStore { workspaceNotification: IWorkspaceNotificationStore; favorite: IFavoriteStore; transient: ITransientStore; + timelineStore: ITimelineStore; constructor() { this.router = new RouterStore(); @@ -84,6 +86,7 @@ export class CoreRootStore { this.workspaceNotification = new WorkspaceNotificationStore(this); this.favorite = new FavoriteStore(this); this.transient = new TransientStore(); + this.timelineStore = new TimeLineStore(this); } resetOnSignOut() { diff --git a/web/core/store/timeline/index.ts b/web/core/store/timeline/index.ts new file mode 100644 index 0000000000..7d5dfdc958 --- /dev/null +++ b/web/core/store/timeline/index.ts @@ -0,0 +1,19 @@ +import { CoreRootStore } from "@/store/root.store"; +// +import { IIssuesTimeLineStore, IssuesTimeLineStore } from "./issues-timeline.store"; +import { IModulesTimeLineStore, ModulesTimeLineStore } from "./modules-timeline.store"; + +export interface ITimelineStore { + issuesTimeLineStore: IIssuesTimeLineStore; + modulesTimeLineStore: IModulesTimeLineStore; +} + +export class TimeLineStore implements ITimelineStore { + issuesTimeLineStore: IIssuesTimeLineStore; + modulesTimeLineStore: IModulesTimeLineStore; + + constructor(rootStore: CoreRootStore) { + this.issuesTimeLineStore = new IssuesTimeLineStore(rootStore); + this.modulesTimeLineStore = new ModulesTimeLineStore(rootStore); + } +} diff --git a/web/core/store/timeline/issues-timeline.store.ts b/web/core/store/timeline/issues-timeline.store.ts new file mode 100644 index 0000000000..9e24eec0fb --- /dev/null +++ b/web/core/store/timeline/issues-timeline.store.ts @@ -0,0 +1,23 @@ +import { autorun } from "mobx"; +// Plane-web +import { BaseTimeLineStore, IBaseTimelineStore } from "@/plane-web/store/timeline/base-timeline.store"; +// Store +import { CoreRootStore } from "@/store/root.store"; + +export interface IIssuesTimeLineStore extends IBaseTimelineStore { + isDependencyEnabled: boolean; +} + +export class IssuesTimeLineStore extends BaseTimeLineStore implements IIssuesTimeLineStore { + isDependencyEnabled = true; + + constructor(_rootStore: CoreRootStore) { + super(_rootStore); + + autorun((reaction) => { + reaction.trace(); + const getIssueById = this.rootStore.issue.issues.getIssueById; + this.updateBlocks(getIssueById); + }); + } +} diff --git a/web/core/store/timeline/modules-timeline.store.ts b/web/core/store/timeline/modules-timeline.store.ts new file mode 100644 index 0000000000..e81167e77b --- /dev/null +++ b/web/core/store/timeline/modules-timeline.store.ts @@ -0,0 +1,22 @@ +import { autorun } from "mobx"; +// Store +import { CoreRootStore } from "@/store/root.store"; +import { BaseTimeLineStore, IBaseTimelineStore } from "ce/store/timeline/base-timeline.store"; + +export interface IModulesTimeLineStore extends IBaseTimelineStore { + isDependencyEnabled: boolean; +} + +export class ModulesTimeLineStore extends BaseTimeLineStore implements IModulesTimeLineStore { + isDependencyEnabled = false; + + constructor(_rootStore: CoreRootStore) { + super(_rootStore); + + autorun((reaction) => { + reaction.trace(); + const getModuleById = this.rootStore.module.getModuleById; + this.updateBlocks(getModuleById); + }); + } +} diff --git a/web/ee/components/gantt-chart/index.ts b/web/ee/components/gantt-chart/index.ts new file mode 100644 index 0000000000..5e3c2f3774 --- /dev/null +++ b/web/ee/components/gantt-chart/index.ts @@ -0,0 +1 @@ +export * from "ce/components/gantt-chart"; diff --git a/web/ee/components/relations/index.tsx b/web/ee/components/relations/index.tsx new file mode 100644 index 0000000000..baf76c90b0 --- /dev/null +++ b/web/ee/components/relations/index.tsx @@ -0,0 +1 @@ +export * from "ce/components/relations"; diff --git a/web/ee/store/timeline/base-timeline.store.ts b/web/ee/store/timeline/base-timeline.store.ts new file mode 100644 index 0000000000..ce2bb7df4f --- /dev/null +++ b/web/ee/store/timeline/base-timeline.store.ts @@ -0,0 +1 @@ +export * from "ce/store/timeline/base-timeline.store"; diff --git a/web/helpers/date-time.helper.ts b/web/helpers/date-time.helper.ts index 3121b58e81..c26addd7ca 100644 --- a/web/helpers/date-time.helper.ts +++ b/web/helpers/date-time.helper.ts @@ -13,13 +13,13 @@ import isNumber from "lodash/isNumber"; export const renderFormattedDate = ( date: string | Date | undefined | null, formatToken: string = "MMM dd, yyyy" -): string | null => { +): string | undefined => { // Parse the date to check if it is valid const parsedDate = getDate(date); // return if undefined - if (!parsedDate) return null; + if (!parsedDate) return; // Check if the parsed date is valid before formatting - if (!isValid(parsedDate)) return null; // Return null for invalid dates + if (!isValid(parsedDate)) return; // Return null for invalid dates let formattedDate; try { // Format the date in the format provided or default format (MMM dd, yyyy) @@ -55,13 +55,13 @@ export const renderFormattedDateWithoutYear = (date: string | Date): string => { * @param {Date | string} date * @example renderFormattedPayloadDate("Jan 01, 20224") // "2024-01-01" */ -export const renderFormattedPayloadDate = (date: Date | string | undefined | null): string | null => { +export const renderFormattedPayloadDate = (date: Date | string | undefined | null): string | undefined => { // Parse the date to check if it is valid const parsedDate = getDate(date); // return if undefined - if (!parsedDate) return null; + if (!parsedDate) return; // Check if the parsed date is valid before formatting - if (!isValid(parsedDate)) return null; // Return null for invalid dates + if (!isValid(parsedDate)) return; // Return null for invalid dates // Format the date in payload format (yyyy-mm-dd) const formattedDate = format(parsedDate, "yyyy-MM-dd"); return formattedDate; @@ -120,6 +120,25 @@ export const findTotalDaysInRange = ( return inclusive ? diffInDays + 1 : diffInDays; }; +/** + * Add number of days to the provided date and return a resulting new date + * @param startDate + * @param numberOfDays + * @returns + */ +export const addDaysToDate = (startDate: Date | string | undefined | null, numberOfDays: number) => { + // Parse the dates to check if they are valid + const parsedStartDate = getDate(startDate); + + // return if undefined + if (!parsedStartDate) return; + + const newDate = new Date(parsedStartDate); + newDate.setDate(newDate.getDate() + numberOfDays); + + return newDate; +}; + /** * @returns {number} number of days left from today * @description Returns number of days left from today diff --git a/web/helpers/issue.helper.ts b/web/helpers/issue.helper.ts index 106381a2d8..d1fc970185 100644 --- a/web/helpers/issue.helper.ts +++ b/web/helpers/issue.helper.ts @@ -174,9 +174,10 @@ export const shouldHighlightIssueDueDate = ( export const getIssueBlocksStructure = (block: TIssue): IGanttBlock => ({ data: block, id: block?.id, + name: block?.name, sort_order: block?.sort_order, - start_date: getDate(block?.start_date), - target_date: getDate(block?.target_date), + start_date: block?.start_date ?? undefined, + target_date: block?.target_date ?? undefined, }); export function getChangedIssuefields(formData: Partial, dirtyFields: { [key: string]: boolean | undefined }) {