feat: Issue pagination (#4109)

* dev: separate order by of issue queryset to separate utilty function

* dev: pagination for spreadhseet and gantt

* dev: group pagination

* dev: paginate single entities

* dev: refactor pagination

* dev: paginating issue apis

* dev: grouped pagination for empty groups

* dev: ungrouped list

* dev: fix paginator for single groups

* dev: fix paginating true list

* dev: state__group pagination

* fix: imports

* dev: fix grouping on taget date and project_id

* dev: remove unused imports

* dev: add ruff in dependencies

* make store changes for pagination

* fix some build errors due to type changes

* dev: add total pages key

* chore: paginator changes

* implement pagination for spreadsheet, list, kanban and calendar

* fix: order by grouped pagination

* dev: sub group paginator

* dev: grouped paginator

* dev: sub grouping paginator

* restructure gantt layout charts

* dev: fix pagination count

* dev: date filtering for issues

* dev: group by counts

* implement new logic for pagination layouts

* fix: label id and assignee id interchange

* dev: fix priority ordering

* fix group by bugs

* dev: grouping for priority

* fix reeordering while update

* dev: fix order by for pagination

* fix: total results for sub group pagination

* dev: add comments and fix ordering

* fix orderby priority for spreadsheet

* fix subGroupCount

* Fix logic for load more in Kanban

* fix issue quick add

* dev: fix issue creation

* dev: add sorting

* fix order by for modules and cycles

* fix non render of Issues

* fix subGroupKey generation when subGroupId is null

* dev: fix cycle and module issue

* dev: fix sub grouping

* fix: imports

* fix minor build errors

* fix major build errors

* fix priority order by

* grouped pagination cursor logic changes

* fix calendar pagination

* active cycle issues pagination

* dev: fix lint errors

* fix Kanban subgroup dnd

* fix empty subgroup kanbans

* fix updation from an empty field with groupBy

* fix issue count of groups

* fix issue sorting on first page fetch

* dev: remove pagination from list endpoint add ordering for sub grouping and handle error for empty issues

* refactor module and cycle issues

* fix quick add refactor

* refactor gantt roots

* fix empty states

* fix filter params

* fix group by module

* minor UX changes

* fix sub grouping in Kanban

* remove unnecessary sorting logic in backend (Nikhil's changes)

* dev: add error handling when using without on results

* calendar layout loader improvement

* list per page count logic change

* spreadsheet loader improvement

* Added loader for issues load more pagination

* fix quick add in gantt

* dev: add profile issue pagination

* fix all issue and profile issues logic

* remove empty state from calendar layout

* use useEffect instead of swr to fetch issues to have quick switching between views cycles etc

* dev: add aggregation for multi fields

* fix priority sorting for workspace issues

* fix move from draft for draft issues

* fix pagination loader for spreadsheet

* fetch project, module and cycle stats on update, create and delete of issues

* increase horizontal margin

* change load more pagination to on scroll pagination for active cycle issues

* fix linting error

* dev: fix ordering when order by m2m

* dev: fix null paginations

* dev: commenting

* 0add comments to the issue stores methods

* fix order by for array properties

* fix: priority ordering

* perform optimistic updates while adding or removing cycles or modules

* fix build errors

* dev: add default values when iterating through sub group

* Move code from EE to CE repo

* chore: folder structure updates

* Move sortabla and radio input to packages/ui

* chore: updated empty and loading screens

* chore: delete an estimate point

* chore: estimate point response change

* chore: updated create estimate and handled the build error

* chore: migration fixes

* chore: updated create estimate

* [WEB-1322] dev: conflict free pages collaboration (#4463)

* chore: pages realtime

* chore: empty binary response

* chore: added a ypy package

* feat: pages collaboration

* chore: update fetching logic

* chore: degrade ypy version

* chore: replace useEffect fetch logic with useSWR

* chore: move all the update logic to the page store

* refactor: remove react-hook-form

* chore: save description_html as well

* chore: migrate old data logic

* fix: added description_binary as field name

* fix: code cleanup

* refactor: create separate hook to handle page description

* fix: build errors

* chore: combine updates instead of using the whole document

* chore: removed ypy package

* chore: added conflict resolving logic to the client side

* chore: add a save changes button

* chore: add read-only validation

* chore: remove saving state information

* chore: added permission class

* chore: removed the migration file

* chore: corrected the model field

* chore: rename pageStore to page

* chore: update collaboration provider

* chore: add try catch to handle error

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* chore: create estimate workflow update

* chore: editing and deleting the existing estimate updates

* chore: updating the new estinates in update modal

* chore: ui changed

* chore: response changes of get and post

* chore: new field added in estimates

* chore: individual endpoint for estimate points

* chore: typo changes

* chore: create estimate point

* chore: integrated new endpoints

* chore: update key value pair

* chore: update sorting in the estimates

* Add custom option in the estimate templates

* chore: handled current project active estimate

* chore: handle estimate update worklfow

* chore: AIO docker images for preview deployments (#4605)

* fix: adding single docker base file

* action added

* fix action

* dockerfile.base modified

* action fix

* dockerfile

* fix: base aio dockerfile

* fix: dockerfile.base

* fix: dockerfile base

* fix: modified folder structure

* fix: action

* fix: dockerfile

* fix: dockerfile.base

* fix: supervisor file name changed

* fix: base dockerfile updated

* fix dockerfile base

* fix: base dockerfile

* fix: docker files

* fix: base dockerfile

* update base image

* modified docker aio base

* aio base modified to debian-12-slim

* fixes

* finalize the dockerfiles with volume exposure

* modified the aio build and dockerfile

* fix: codacy suggestions implemented

* fix: codacy fix

* update aio build action

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* chore: handled estimates switch

* chore: handled estimate edit

* chore: handled close button in estimate edit

* chore: updated ceate estimare workflow

* chore: updated switch estimate

* fix minor bugs in base issues store

* single column scroll pagination

* UI changes for load more button

* chore: UI and typos

* chore: resolved build error

* [WEB-1184] feat: issue bulk operations (#4530)

* chore: bulk operations

* chore: archive bulk issues

* chore: bulk ops keys changed

* chore: bulk delete and archive confirmation modals

* style: list layout spacing

* chore: create hoc for multi-select groups

* chore: update multiple select components

* chore: archive, target and start date error messsage

* chore: edge case handling

* chore: bulk ops in spreadsheet layout

* chore: update UI

* chore: scroll element into view

* fix: shift + arrow navigation

* chore: implement bulk ops in the gantt layout

* fix: ui bugs

* chore: move selection logic to store

* fix: group selection

* refactor: multiple select store

* style: dropdowns UI

* fix: bulk assignee and label update mutation

* chore: removed migrations

* refactor: entities grouping logic

* fix performance issue is selection of bulk ops

* fix: shift keyboard navigation

* fix: group click action

* chore: start and target date validation

* chore: remove optimistic updates, check archivability in frontend

* chore: code optimisation

* chore: add store comments

* refactor: component fragmentation

* style: issue active state

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: rahulramesha <rahulramesham@gmail.com>

* fix a performance issue when there are too many groups

* chore: updated delete dropdown and handled the repeated values while creating and updating the estimate point

* [WEB-1424] chore: page and view logo implementation, and emoji/icon picker improvement (#4583)

* chore: added logo_props

* chore: logo props in cycles, views and modules

* chore: emoji icon picker types updated

* chore: info icon added to plane ui package

* chore: icon color adjust helper function added

* style: icon picker ui improvement and default color options updated

* chore: update page logo action added in store

* chore: emoji code to unicode helper function added

* chore: common logo renderer component added

* chore: app header project logo updated

* chore: project logo updated across platform

* chore: page logo picker added

* chore: control link component improvement

* chore: list item improvement

* chore: emoji picker component updated

* chore: space app and package logo prop type updated

* chore: migration

* chore: logo added to project view

* chore: page logo picker added in create modal and breadcrumbs

* chore: view logo picker added in create modal and updated breadcrumbs

* fix: build error

* chore: AIO docker images for preview deployments (#4605)

* fix: adding single docker base file

* action added

* fix action

* dockerfile.base modified

* action fix

* dockerfile

* fix: base aio dockerfile

* fix: dockerfile.base

* fix: dockerfile base

* fix: modified folder structure

* fix: action

* fix: dockerfile

* fix: dockerfile.base

* fix: supervisor file name changed

* fix: base dockerfile updated

* fix dockerfile base

* fix: base dockerfile

* fix: docker files

* fix: base dockerfile

* update base image

* modified docker aio base

* aio base modified to debian-12-slim

* fixes

* finalize the dockerfiles with volume exposure

* modified the aio build and dockerfile

* fix: codacy suggestions implemented

* fix: codacy fix

* update aio build action

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* fix: merge conflict

* chore: lucide react added to planu ui package

* chore: new emoji picker component added with lucid icon and code refactor

* chore: logo component updated

* chore: emoji picker updated for pages and views

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* chore: handled inline errors in the estimate switch

* fix module and cycle drag and drop

* Fix issue count bug for accumulated actions

* chore: handled active and availability vadilation

* chore: handled create and update components in projecr estimates

* chore: added migration

* Add category specific values for custom template

* chore: estimate dropdown handled in issues

* chore: estimate alerts

* fix bulk updates

* chore: updated alerts

* add optional chaining

* Extract the list row actions

* change color of load more to match new Issues

* list group collapsible

* fix: updated and handled the estimate points

* fix: upgrader ee banner

* Fix issues with sortable

* Fix sortable spacing issue in create estimate modal

* fix: updated the issue create sorting

* chore: removed radio button from ui and updated in the estimates

* chore: resolved import error in packaged ui

* chore: handled props in create modal

* chore: removed ee files

* chore: changed default analytics

* fix: pagination ordering for grouped and subgrouped

* chore: removed the migration file

* chore: estimate point value in graph

* chore: estimate point key change

* chore: squashed migration (#4634)

* chore: squashed migration

* chore: removed instance migraion

* chore: key changes

* chore: issue activity back migration

* dev: replaced estimate key with estimate id and replaced estimate type from number to string in issue

* chore: estimate point value field

* chore: estimate point activity

* chore: removed the unused function

* chore: resolved merge conflicts

* chore: deploy board keys changed

* chore: yarn lock file change

* chore: resolved frontend build

---------

Co-authored-by: guru_sainath <gurusainath007@gmail.com>

* [WEB-1516] refactor: space app routing and layouts (#4705)

* dev: change layout

* chore: replace workspace slug and project id with anchor

* chore: migration fixes

* chore: update filtering logic

* chore: endpoint changes

* chore: update endpoint

* chore: changed url pratterns

* chore: use client side for layout and page

* chore: issue vote changes

* chore: project deploy board response change

* refactor: publish project store and components

* fix: update layout options after fetching settings

* chore: remove unnecessary types

* style: peek overview

* refactor: components folder structure

* fix: redirect from old path

* chore: make the whole issue block clickable

* chore: removed the migration file

* chore: add server side redirection for old routes

* chore: is enabled key change

* chore: update types

* chore: removed the migration file

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* Merge develop into revamp-estimates-ce

* chore: removed migration file and updated the estimate system order and removed ee banner

* chore: initial radio select in create estimate

* chore: space key changes

* Fix sortable component as the sort order was broken.

* fix: formatting and linting errors

* fix Alignment for load more

* add logic to approuter

* fix approuter changes and fix build

* chore: removed the linting issue

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
Co-authored-by: guru_sainath <gurusainath007@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Co-authored-by: pushya22 <130810100+pushya22@users.noreply.github.com>
This commit is contained in:
rahulramesha 2024-06-10 20:15:03 +05:30 committed by GitHub
parent 7ac07b7b73
commit 666d35afb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
234 changed files with 9056 additions and 6188 deletions

View File

@ -2,10 +2,6 @@
from .base import BaseSerializer from .base import BaseSerializer
from plane.db.models import Estimate, EstimatePoint from plane.db.models import Estimate, EstimatePoint
from plane.app.serializers import (
WorkspaceLiteSerializer,
ProjectLiteSerializer,
)
from rest_framework import serializers from rest_framework import serializers

View File

@ -19,6 +19,8 @@ from plane.app.views import (
IssueUserDisplayPropertyEndpoint, IssueUserDisplayPropertyEndpoint,
IssueViewSet, IssueViewSet,
LabelViewSet, LabelViewSet,
BulkIssueOperationsEndpoint,
BulkArchiveIssuesEndpoint,
) )
urlpatterns = [ urlpatterns = [
@ -81,6 +83,11 @@ urlpatterns = [
BulkDeleteIssuesEndpoint.as_view(), BulkDeleteIssuesEndpoint.as_view(),
name="project-issues-bulk", name="project-issues-bulk",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-archive-issues/",
BulkArchiveIssuesEndpoint.as_view(),
name="bulk-archive-issues",
),
## ##
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
@ -298,4 +305,9 @@ urlpatterns = [
), ),
name="project-issue-draft", name="project-issue-draft",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-operation-issues/",
BulkIssueOperationsEndpoint.as_view(),
name="bulk-operations-issues",
),
] ]

View File

@ -113,9 +113,7 @@ from .issue.activity import (
IssueActivityEndpoint, IssueActivityEndpoint,
) )
from .issue.archive import ( from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint
IssueArchiveViewSet,
)
from .issue.attachment import ( from .issue.attachment import (
IssueAttachmentEndpoint, IssueAttachmentEndpoint,
@ -154,6 +152,8 @@ from .issue.subscriber import (
) )
from .issue.bulk_operations import BulkIssueOperationsEndpoint
from .module.base import ( from .module.base import (
ModuleViewSet, ModuleViewSet,
ModuleLinkViewSet, ModuleLinkViewSet,

View File

@ -1,4 +1,6 @@
# Python imports # Python imports
import traceback
import zoneinfo import zoneinfo
from django.conf import settings from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
@ -76,7 +78,11 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
response = super().handle_exception(exc) response = super().handle_exception(exc)
return response return response
except Exception as e: except Exception as e:
print(e) if settings.DEBUG else print("Server Error") (
print(e, traceback.format_exc())
if settings.DEBUG
else print("Server Error")
)
if isinstance(e, IntegrityError): if isinstance(e, IntegrityError):
return Response( return Response(
{"error": "The payload is not valid"}, {"error": "The payload is not valid"},

View File

@ -2,43 +2,50 @@
import json import json
# Django imports # Django imports
from django.db.models import (
Func,
F,
Q,
OuterRef,
Value,
UUIDField,
)
from django.core import serializers from django.core import serializers
from django.db.models import (
F,
Func,
OuterRef,
Q,
)
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce
# Third party imports # Third party imports
from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
)
# Module imports # Module imports
from .. import BaseViewSet from .. import BaseViewSet
from plane.app.serializers import ( from plane.app.serializers import (
IssueSerializer,
CycleIssueSerializer, CycleIssueSerializer,
) )
from plane.app.permissions import ProjectEntityPermission from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import ( from plane.db.models import (
Cycle, Cycle,
CycleIssue, CycleIssue,
Issue, Issue,
IssueLink,
IssueAttachment, IssueAttachment,
IssueLink,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
) )
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
# Module imports
class CycleIssueViewSet(BaseViewSet): class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer serializer_class = CycleIssueSerializer
@ -86,14 +93,9 @@ class CycleIssueViewSet(BaseViewSet):
@method_decorator(gzip_page) @method_decorator(gzip_page)
def list(self, request, slug, project_id, cycle_id): def list(self, request, slug, project_id, cycle_id):
fields = [ order_by_param = request.GET.get("order_by", "created_at")
field
for field in request.GET.get("fields", "").split(",")
if field
]
order_by = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
queryset = ( issue_queryset = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.filter(project_id=project_id) .filter(project_id=project_id)
.filter(workspace__slug=slug) .filter(workspace__slug=slug)
@ -105,7 +107,6 @@ class CycleIssueViewSet(BaseViewSet):
"issue_module__module", "issue_module__module",
"issue_cycle__cycle", "issue_cycle__cycle",
) )
.order_by(order_by)
.filter(**filters) .filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate( .annotate(
@ -130,73 +131,112 @@ class CycleIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by(order_by)
) )
if self.fields: filters = issue_filters(request.query_params, "GET")
issues = IssueSerializer(
queryset, many=True, fields=fields if fields else None
).data
else:
issues = queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK) order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = issue_queryset.filter(**filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if group_by:
# Check group and sub group value paginate
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# group and sub group pagination
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)
def create(self, request, slug, project_id, cycle_id): def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", []) issues = request.data.get("issues", [])

View File

@ -1,52 +1,53 @@
# Django imports # Django imports
from django.db.models import (
Q,
Case,
When,
Value,
CharField,
Count,
F,
Exists,
OuterRef,
Subquery,
JSONField,
Func,
Prefetch,
IntegerField,
)
from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.db.models import UUIDField from django.db.models import (
Case,
CharField,
Count,
Exists,
F,
Func,
IntegerField,
JSONField,
OuterRef,
Prefetch,
Q,
Subquery,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
from rest_framework import status
# Third Party imports # Third Party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status
from plane.app.serializers import (
DashboardSerializer,
IssueActivitySerializer,
IssueSerializer,
WidgetSerializer,
)
from plane.db.models import (
Dashboard,
DashboardWidget,
Issue,
IssueActivity,
IssueAttachment,
IssueLink,
IssueRelation,
Project,
ProjectMember,
User,
Widget,
)
from plane.utils.issue_filters import issue_filters
# Module imports # Module imports
from .. import BaseAPIView from .. import BaseAPIView
from plane.db.models import (
Issue,
IssueActivity,
ProjectMember,
Widget,
DashboardWidget,
Dashboard,
Project,
IssueLink,
IssueAttachment,
IssueRelation,
User,
)
from plane.app.serializers import (
IssueActivitySerializer,
IssueSerializer,
DashboardSerializer,
WidgetSerializer,
)
from plane.utils.issue_filters import issue_filters
def dashboard_overview_stats(self, request, slug): def dashboard_overview_stats(self, request, slug):
@ -569,6 +570,7 @@ def dashboard_recent_collaborators(self, request, slug):
) )
return self.paginate( return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request, request=request,
queryset=project_members_with_activities, queryset=project_members_with_activities,
controller=lambda qs: self.get_results_controller(qs, slug), controller=lambda qs: self.get_results_controller(qs, slug),

View File

@ -1,14 +1,14 @@
# Third Party imports # Third Party imports
from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import WorkSpaceAdminPermission
from plane.app.serializers import ExporterHistorySerializer
from plane.bgtasks.export_task import issue_export_task
from plane.db.models import ExporterHistory, Project, Workspace
# Module imports # Module imports
from .. import BaseAPIView from .. import BaseAPIView
from plane.app.permissions import WorkSpaceAdminPermission
from plane.bgtasks.export_task import issue_export_task
from plane.db.models import Project, ExporterHistory, Workspace
from plane.app.serializers import ExporterHistorySerializer
class ExportIssuesEndpoint(BaseAPIView): class ExportIssuesEndpoint(BaseAPIView):
@ -72,6 +72,7 @@ class ExportIssuesEndpoint(BaseAPIView):
"cursor", False "cursor", False
): ):
return self.paginate( return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request, request=request,
queryset=exporter_history, queryset=exporter_history,
on_results=lambda exporter_history: ExporterHistorySerializer( on_results=lambda exporter_history: ExporterHistorySerializer(

View File

@ -2,52 +2,54 @@
import json import json
# Django imports # Django imports
from django.utils import timezone
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Q,
Case,
Value,
CharField,
When,
Exists,
Max,
UUIDField,
)
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
F,
Func,
OuterRef,
Q,
Prefetch,
Exists,
)
from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce
# Third Party imports # Third Party imports
from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.response import Response
# Module imports
from .. import BaseViewSet
from plane.app.serializers import (
IssueSerializer,
IssueFlatSerializer,
IssueDetailSerializer,
)
from plane.app.permissions import ( from plane.app.permissions import (
ProjectEntityPermission, ProjectEntityPermission,
) )
from plane.app.serializers import (
IssueFlatSerializer,
IssueSerializer,
IssueDetailSerializer
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import ( from plane.db.models import (
Issue, Issue,
IssueLink,
IssueAttachment, IssueAttachment,
IssueLink,
IssueSubscriber, IssueSubscriber,
IssueReaction, IssueReaction,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
# Module imports
from .. import BaseViewSet, BaseAPIView
class IssueArchiveViewSet(BaseViewSet): class IssueArchiveViewSet(BaseViewSet):
permission_classes = [ permission_classes = [
@ -92,33 +94,6 @@ class IssueArchiveViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
) )
@method_decorator(gzip_page) @method_decorator(gzip_page)
@ -126,125 +101,116 @@ class IssueArchiveViewSet(BaseViewSet):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true") show_sub_issues = request.GET.get("show_sub_issues", "true")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters) issue_queryset = self.get_queryset().filter(**filters)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issue_queryset = ( issue_queryset = (
issue_queryset issue_queryset
if show_sub_issues == "true" if show_sub_issues == "true"
else issue_queryset.filter(parent__isnull=True) else issue_queryset.filter(parent__isnull=True)
) )
if self.expand or self.fields: # Issue queryset
issues = IssueSerializer( issue_queryset, order_by_param = order_issue_queryset(
issue_queryset, issue_queryset=issue_queryset,
many=True, order_by_param=order_by_param,
fields=self.fields, )
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK) # Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if group_by:
# Check group and sub group value paginate
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# group and sub group pagination
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)
def retrieve(self, request, slug, project_id, pk=None): def retrieve(self, request, slug, project_id, pk=None):
issue = ( issue = (
@ -351,3 +317,58 @@ class IssueArchiveViewSet(BaseViewSet):
issue.save() issue.save()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class BulkArchiveIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):
return Response(
{"error": "Issue IDs are required"},
status=status.HTTP_400_BAD_REQUEST,
)
issues = Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
).select_related("state")
bulk_archive_issues = []
for issue in issues:
if issue.state.group not in ["completed", "cancelled"]:
return Response(
{
"error_code": 4091,
"error_message": "INVALID_ARCHIVE_STATE_GROUP"
},
status=status.HTTP_400_BAD_REQUEST,
)
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps(
{
"archived_at": str(timezone.now().date()),
"automation": False,
}
),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue.archived_at = timezone.now().date()
bulk_archive_issues.append(issue)
Issue.objects.bulk_update(bulk_archive_issues, ["archived_at"])
return Response(
{"archived_at": str(timezone.now().date())},
status=status.HTTP_200_OK,
)

View File

@ -1,34 +1,30 @@
# Python imports # Python imports
import json import json
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import ( from django.db.models import (
Case,
CharField,
Exists, Exists,
F, F,
Func, Func,
Max,
OuterRef, OuterRef,
Prefetch, Prefetch,
Q, Q,
UUIDField, UUIDField,
Value, Value,
When,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
# Django imports
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third Party imports # Third Party imports
from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
# Module imports
from plane.app.permissions import ( from plane.app.permissions import (
ProjectEntityPermission, ProjectEntityPermission,
ProjectLitePermission, ProjectLitePermission,
@ -49,11 +45,21 @@ from plane.db.models import (
IssueSubscriber, IssueSubscriber,
Project, Project,
) )
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from .. import BaseAPIView, BaseViewSet
from plane.utils.user_timezone_converter import user_timezone_converter from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports # Module imports
from .. import BaseAPIView, BaseViewSet
class IssueListEndpoint(BaseAPIView): class IssueListEndpoint(BaseAPIView):
@ -105,110 +111,28 @@ class IssueListEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct() ).distinct()
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = queryset.filter(**filters) issue_queryset = queryset.filter(**filters)
# Issue queryset
issue_queryset, _ = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# Priority Ordering # Group by
if order_by_param == "priority" or order_by_param == "-priority": group_by = request.GET.get("group_by", False)
priority_order = ( sub_group_by = request.GET.get("sub_group_by", False)
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering # issue queryset
elif order_by_param in [ issue_queryset = issue_queryset_grouper(
"state__name", queryset=issue_queryset,
"state__group", group_by=group_by,
"-state__name", sub_group_by=sub_group_by,
"-state__group", )
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
if self.fields or self.expand: if self.fields or self.expand:
issues = IssueSerializer( issues = IssueSerializer(
@ -304,33 +228,6 @@ class IssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct() ).distinct()
@method_decorator(gzip_page) @method_decorator(gzip_page)
@ -340,116 +237,104 @@ class IssueViewSet(BaseViewSet):
issue_queryset = self.get_queryset().filter(**filters) issue_queryset = self.get_queryset().filter(**filters)
# Custom ordering for priority and state # Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
# Priority Ordering # Issue queryset
if order_by_param == "priority" or order_by_param == "-priority": issue_queryset, order_by_param = order_issue_queryset(
priority_order = ( issue_queryset=issue_queryset,
priority_order order_by_param=order_by_param,
if order_by_param == "priority" )
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering # Group by
elif order_by_param in [ group_by = request.GET.get("group_by", False)
"state__name", sub_group_by = request.GET.get("sub_group_by", False)
"state__group",
"-state__name", # issue queryset
"-state__group", issue_queryset = issue_queryset_grouper(
]: queryset=issue_queryset,
state_order = ( group_by=group_by,
state_order sub_group_by=sub_group_by,
if order_by_param in ["state__name", "state__group"] )
else state_order[::-1]
) if group_by:
issue_queryset = issue_queryset.annotate( if sub_group_by:
state_order=Case( if group_by == sub_group_by:
*[ return Response(
When(state__group=state_group, then=Value(i)) {
for i, state_group in enumerate(state_order) "error": "Group by and sub group by cannot have same parameters"
], },
default=Value(len(state_order)), status=status.HTTP_400_BAD_REQUEST,
output_field=CharField(), )
else:
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
) )
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) return self.paginate(
order_by=order_by_param,
# Only use serializer when expand or fields else return by values request=request,
if self.expand or self.fields: queryset=issue_queryset,
issues = IssueSerializer( on_results=lambda issues: issue_on_results(
issue_queryset, group_by=group_by, issues=issues, sub_group_by=sub_group_by
many=True, ),
fields=self.fields,
expand=self.expand,
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
) )
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id) project = Project.objects.get(pk=project_id)
@ -481,8 +366,13 @@ class IssueViewSet(BaseViewSet):
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
) )
issue = ( issue = (
self.get_queryset() issue_queryset_grouper(
.filter(pk=serializer.data["id"]) queryset=self.get_queryset().filter(
pk=serializer.data["id"]
),
group_by=None,
sub_group_by=None,
)
.values( .values(
"id", "id",
"name", "name",
@ -523,6 +413,33 @@ class IssueViewSet(BaseViewSet):
issue = ( issue = (
self.get_queryset() self.get_queryset()
.filter(pk=pk) .filter(pk=pk)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.prefetch_related( .prefetch_related(
Prefetch( Prefetch(
"issue_reactions", "issue_reactions",

View File

@ -0,0 +1,288 @@
# Python imports
import json
from datetime import datetime
# Django imports
from django.utils import timezone
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseAPIView
from plane.app.permissions import (
ProjectEntityPermission,
)
from plane.db.models import (
Project,
Issue,
IssueLabel,
IssueAssignee,
)
from plane.bgtasks.issue_activites_task import issue_activity
class BulkIssueOperationsEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):
return Response(
{"error": "Issue IDs are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get all the issues
issues = (
Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
.select_related("state")
.prefetch_related("labels", "assignees")
)
# Current epoch
epoch = int(timezone.now().timestamp())
# Project details
project = Project.objects.get(workspace__slug=slug, pk=project_id)
workspace_id = project.workspace_id
# Initialize arrays
bulk_update_issues = []
bulk_issue_activities = []
bulk_update_issue_labels = []
bulk_update_issue_assignees = []
properties = request.data.get("properties", {})
if properties.get("start_date", False) and properties.get("target_date", False):
if (
datetime.strptime(properties.get("start_date"), "%Y-%m-%d").date()
> datetime.strptime(properties.get("target_date"), "%Y-%m-%d").date()
):
return Response(
{
"error_code": 4100,
"error_message": "INVALID_ISSUE_DATES",
},
status=status.HTTP_400_BAD_REQUEST,
)
for issue in issues:
# Priority
if properties.get("priority", False):
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"priority": properties.get("priority")}
),
"current_instance": json.dumps(
{"priority": (issue.priority)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.priority = properties.get("priority")
# State
if properties.get("state_id", False):
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"state": properties.get("state")}
),
"current_instance": json.dumps(
{"state": str(issue.state_id)}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
issue.state_id = properties.get("state_id")
# Start date
if properties.get("start_date", False):
if (
issue.target_date
and not properties.get("target_date", False)
and issue.target_date
<= datetime.strptime(
properties.get("start_date"), "%Y-%m-%d"
).date()
):
return Response(
{
"error_code": 4101,
"error_message": "INVALID_ISSUE_START_DATE",
},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"start_date": properties.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 = properties.get("start_date")
# Target date
if properties.get("target_date", False):
if (
issue.start_date
and not properties.get("start_date", False)
and issue.start_date
>= datetime.strptime(
properties.get("target_date"), "%Y-%m-%d"
).date()
):
return Response(
{
"error_code": 4102,
"error_message": "INVALID_ISSUE_TARGET_DATE",
},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"target_date": properties.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 = properties.get("target_date")
bulk_update_issues.append(issue)
# Labels
if properties.get("label_ids", []):
for label_id in properties.get("label_ids", []):
bulk_update_issue_labels.append(
IssueLabel(
issue=issue,
label_id=label_id,
created_by=request.user,
project_id=project_id,
workspace_id=workspace_id,
)
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{"label_ids": properties.get("label_ids", [])}
),
"current_instance": json.dumps(
{
"label_ids": [
str(label.id)
for label in issue.labels.all()
]
}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
# Assignees
if properties.get("assignee_ids", []):
for assignee_id in properties.get(
"assignee_ids", issue.assignees
):
bulk_update_issue_assignees.append(
IssueAssignee(
issue=issue,
assignee_id=assignee_id,
created_by=request.user,
project_id=project_id,
workspace_id=workspace_id,
)
)
bulk_issue_activities.append(
{
"type": "issue.activity.updated",
"requested_data": json.dumps(
{
"assignee_ids": properties.get(
"assignee_ids", []
)
}
),
"current_instance": json.dumps(
{
"assignee_ids": [
str(assignee.id)
for assignee in issue.assignees.all()
]
}
),
"issue_id": str(issue.id),
"actor_id": str(request.user.id),
"project_id": str(project_id),
"epoch": epoch,
}
)
# Bulk update all the objects
Issue.objects.bulk_update(
bulk_update_issues,
[
"priority",
"start_date",
"target_date",
"state",
],
batch_size=100,
)
# Create new labels
IssueLabel.objects.bulk_create(
bulk_update_issue_labels,
ignore_conflicts=True,
batch_size=100,
)
# Create new assignees
IssueAssignee.objects.bulk_create(
bulk_update_issue_assignees,
ignore_conflicts=True,
batch_size=100,
)
# update the issue activity
[
issue_activity.delay(**activity)
for activity in bulk_issue_activities
]
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -6,18 +6,14 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import ( from django.db.models import (
Case,
CharField,
Exists, Exists,
F, F,
Func, Func,
Max,
OuterRef, OuterRef,
Prefetch, Prefetch,
Q, Q,
UUIDField, UUIDField,
Value, Value,
When,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
@ -28,6 +24,7 @@ from django.views.decorators.gzip import gzip_page
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
# Module imports
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import ( from plane.app.serializers import (
IssueCreateSerializer, IssueCreateSerializer,
@ -44,10 +41,17 @@ from plane.db.models import (
IssueSubscriber, IssueSubscriber,
Project, Project,
) )
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
# Module imports GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from .. import BaseViewSet from .. import BaseViewSet
@ -88,153 +92,116 @@ class IssueDraftViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct() ).distinct()
@method_decorator(gzip_page) @method_decorator(gzip_page)
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters) issue_queryset = self.get_queryset().filter(**filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# Priority Ordering # Group by
if order_by_param == "priority" or order_by_param == "-priority": group_by = request.GET.get("group_by", False)
priority_order = ( sub_group_by = request.GET.get("sub_group_by", False)
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering # issue queryset
elif order_by_param in [ issue_queryset = issue_queryset_grouper(
"state__name", queryset=issue_queryset,
"state__group", group_by=group_by,
"-state__name", sub_group_by=sub_group_by,
"-state__group", )
]:
state_order = ( if group_by:
state_order # Check group and sub group value paginate
if order_by_param in ["state__name", "state__group"] if sub_group_by:
else state_order[::-1] if group_by == sub_group_by:
) return Response(
issue_queryset = issue_queryset.annotate( {
state_order=Case( "error": "Group by and sub group by cannot have same parameters"
*[ },
When(state__group=state_group, then=Value(i)) status=status.HTTP_400_BAD_REQUEST,
for i, state_group in enumerate(state_order) )
], else:
default=Value(len(state_order)), # group and sub group pagination
output_field=CharField(), return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
) )
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) # List Paginate
return self.paginate(
# Only use serializer when expand else return by values order_by=order_by_param,
if self.expand or self.fields: request=request,
issues = IssueSerializer( queryset=issue_queryset,
issue_queryset, on_results=lambda issues: issue_on_results(
many=True, group_by=group_by, issues=issues, sub_group_by=sub_group_by
fields=self.fields, ),
expand=self.expand,
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
) )
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id) project = Project.objects.get(pk=project_id)
@ -265,12 +232,45 @@ class IssueDraftViewSet(BaseViewSet):
notification=True, notification=True,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
) )
issue = ( issue = (
self.get_queryset().filter(pk=serializer.data["id"]).first() issue_queryset_grouper(
) queryset=self.get_queryset().filter(
return Response( pk=serializer.data["id"]
IssueSerializer(issue).data, status=status.HTTP_201_CREATED ),
group_by=None,
sub_group_by=None,
)
.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
.first()
) )
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
@ -309,6 +309,33 @@ class IssueDraftViewSet(BaseViewSet):
issue = ( issue = (
self.get_queryset() self.get_queryset()
.filter(pk=pk) .filter(pk=pk)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.prefetch_related( .prefetch_related(
Prefetch( Prefetch(
"issue_reactions", "issue_reactions",

View File

@ -1,37 +1,50 @@
# Python imports # Python imports
import json import json
from django.db.models import (
F,
Func,
OuterRef,
Q,
)
# Django Imports # Django Imports
from django.utils import timezone from django.utils import timezone
from django.db.models import F, OuterRef, Func, Q
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third party imports # Third party imports
from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
)
from plane.app.serializers import (
ModuleIssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
ModuleIssue,
Project,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
# Module imports # Module imports
from .. import BaseViewSet from .. import BaseViewSet
from plane.app.serializers import (
ModuleIssueSerializer,
IssueSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
ModuleIssue,
Project,
Issue,
IssueLink,
IssueAttachment,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class ModuleIssueViewSet(BaseViewSet): class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer serializer_class = ModuleIssueSerializer
@ -80,82 +93,115 @@ class ModuleIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct() ).distinct()
@method_decorator(gzip_page) @method_decorator(gzip_page)
def list(self, request, slug, project_id, module_id): def list(self, request, slug, project_id, module_id):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters) issue_queryset = self.get_queryset().filter(**filters)
if self.fields or self.expand: order_by_param = request.GET.get("order_by", "created_at")
issues = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
).data
else:
issues = issue_queryset.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK) # Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if group_by:
# Check group and sub group value paginate
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# group and sub group pagination
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)
# create multiple issues inside a module # create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id): def create_module_issues(self, request, slug, project_id, module_id):

View File

@ -1,26 +1,27 @@
# Django imports # Django imports
from django.db.models import Q, OuterRef, Exists from django.db.models import Exists, OuterRef, Q
from django.utils import timezone from django.utils import timezone
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.utils.paginator import BasePaginator
# Module imports
from ..base import BaseViewSet, BaseAPIView
from plane.db.models import (
Notification,
IssueAssignee,
IssueSubscriber,
Issue,
WorkspaceMember,
UserNotificationPreference,
)
from plane.app.serializers import ( from plane.app.serializers import (
NotificationSerializer, NotificationSerializer,
UserNotificationPreferenceSerializer, UserNotificationPreferenceSerializer,
) )
from plane.db.models import (
Issue,
IssueAssignee,
IssueSubscriber,
Notification,
UserNotificationPreference,
WorkspaceMember,
)
from plane.utils.paginator import BasePaginator
# Module imports
from ..base import BaseAPIView, BaseViewSet
class NotificationViewSet(BaseViewSet, BasePaginator): class NotificationViewSet(BaseViewSet, BasePaginator):
@ -131,6 +132,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
"cursor", False "cursor", False
): ):
return self.paginate( return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request, request=request,
queryset=(notifications), queryset=(notifications),
on_results=lambda notifications: NotificationSerializer( on_results=lambda notifications: NotificationSerializer(

View File

@ -1,26 +1,25 @@
# Python imports # Python imports
import boto3 import boto3
from django.conf import settings
from django.utils import timezone
import json import json
# Django imports # Django imports
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import ( from django.db.models import (
Prefetch,
Q,
Exists, Exists,
OuterRef,
F, F,
Func, Func,
OuterRef,
Prefetch,
Q,
Subquery, Subquery,
) )
from django.conf import settings
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports # Third Party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import serializers, status
from rest_framework import serializers
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
# Module imports # Module imports
@ -35,20 +34,19 @@ from plane.app.permissions import (
ProjectBasePermission, ProjectBasePermission,
ProjectMemberPermission, ProjectMemberPermission,
) )
from plane.db.models import ( from plane.db.models import (
Project,
ProjectMember,
Workspace,
State,
UserFavorite, UserFavorite,
ProjectIdentifier,
Module,
Cycle, Cycle,
Inbox, Inbox,
DeployBoard, DeployBoard,
IssueProperty, IssueProperty,
Issue, Issue,
Module,
Project,
ProjectIdentifier,
ProjectMember,
State,
Workspace,
) )
from plane.utils.cache import cache_response from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity from plane.bgtasks.webhook_task import model_activity
@ -168,6 +166,7 @@ class ProjectViewSet(BaseViewSet):
"cursor", False "cursor", False
): ):
return self.paginate( return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request, request=request,
queryset=(projects), queryset=(projects),
on_results=lambda projects: ProjectListSerializer( on_results=lambda projects: ProjectListSerializer(

View File

@ -250,6 +250,7 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
).select_related("actor", "workspace", "issue", "project") ).select_related("actor", "workspace", "issue", "project")
return self.paginate( return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request, request=request,
queryset=queryset, queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer( on_results=lambda issue_activities: IssueActivitySerializer(

View File

@ -1,47 +1,56 @@
# Django imports # Django imports
from django.db.models import (
Q,
OuterRef,
Func,
F,
Case,
Value,
CharField,
When,
Exists,
Max,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.db.models import UUIDField from django.db.models import (
Exists,
F,
Func,
OuterRef,
Q,
UUIDField,
Value,
)
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status
from plane.app.permissions import (
ProjectEntityPermission,
WorkspaceEntityPermission,
)
from plane.app.serializers import (
IssueViewSerializer,
)
from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
IssueView,
Workspace,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
# Module imports # Module imports
from .. import BaseViewSet from .. import BaseViewSet
from plane.app.serializers import (
IssueViewSerializer,
IssueSerializer,
)
from plane.app.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
)
from plane.db.models import ( from plane.db.models import (
Workspace,
IssueView,
Issue,
UserFavorite, UserFavorite,
IssueLink,
IssueAttachment,
) )
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class GlobalViewViewSet(BaseViewSet): class GlobalViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer serializer_class = IssueViewSerializer
@ -143,17 +152,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
@method_decorator(gzip_page) @method_decorator(gzip_page)
def list(self, request, slug): def list(self, request, slug):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = ( issue_queryset = (
@ -162,103 +160,107 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
) )
# Priority Ordering # Issue queryset
if order_by_param == "priority" or order_by_param == "-priority": issue_queryset, order_by_param = order_issue_queryset(
priority_order = ( issue_queryset=issue_queryset,
priority_order order_by_param=order_by_param,
if order_by_param == "priority" )
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering # Group by
elif order_by_param in [ group_by = request.GET.get("group_by", False)
"state__name", sub_group_by = request.GET.get("sub_group_by", False)
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
if self.fields: # issue queryset
issues = IssueSerializer( issue_queryset = issue_queryset_grouper(
issue_queryset, many=True, fields=self.fields queryset=issue_queryset,
).data group_by=group_by,
sub_group_by=sub_group_by,
)
if group_by:
# Check group and sub group value paginate
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# group and sub group pagination
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=None,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=None,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=None,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else: else:
issues = issue_queryset.values( # List Paginate
"id", return self.paginate(
"name", order_by=order_by_param,
"state_id", request=request,
"sort_order", queryset=issue_queryset,
"completed_at", on_results=lambda issues: issue_on_results(
"estimate_point", group_by=group_by, issues=issues, sub_group_by=sub_group_by
"priority", ),
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
) )
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(
issues, datetime_fields, request.user.user_timezone
)
return Response(issues, status=status.HTTP_200_OK)
class IssueViewViewSet(BaseViewSet): class IssueViewViewSet(BaseViewSet):

View File

@ -1,61 +1,66 @@
# Python imports # Python imports
from datetime import date from datetime import date
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
# Django imports # Django imports
from django.utils import timezone
from django.db.models import ( from django.db.models import (
OuterRef,
Func,
F,
Q,
Count,
Case, Case,
Value, Count,
CharField, F,
When, Func,
Max,
IntegerField, IntegerField,
UUIDField, OuterRef,
Q,
Value,
When,
) )
from django.db.models.functions import ExtractWeek, Cast
from django.db.models.fields import DateField from django.db.models.fields import DateField
from django.contrib.postgres.aggregates import ArrayAgg from django.db.models.functions import Cast, ExtractWeek
from django.contrib.postgres.fields import ArrayField from django.utils import timezone
from django.db.models.functions import Coalesce
# Third party modules # Third party modules
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
# Module imports
from plane.app.serializers import (
WorkSpaceSerializer,
ProjectMemberSerializer,
IssueActivitySerializer,
IssueSerializer,
WorkspaceUserPropertiesSerializer,
)
from plane.app.views.base import BaseAPIView
from plane.db.models import (
User,
Workspace,
ProjectMember,
IssueActivity,
Issue,
IssueLink,
IssueAttachment,
IssueSubscriber,
Project,
WorkspaceMember,
CycleIssue,
WorkspaceUserProperties,
)
from plane.app.permissions import ( from plane.app.permissions import (
WorkspaceEntityPermission, WorkspaceEntityPermission,
WorkspaceViewerPermission, WorkspaceViewerPermission,
) )
# Module imports
from plane.app.serializers import (
IssueActivitySerializer,
ProjectMemberSerializer,
WorkSpaceSerializer,
WorkspaceUserPropertiesSerializer,
)
from plane.app.views.base import BaseAPIView
from plane.db.models import (
CycleIssue,
Issue,
IssueActivity,
IssueAttachment,
IssueLink,
IssueSubscriber,
Project,
ProjectMember,
User,
Workspace,
WorkspaceMember,
WorkspaceUserProperties,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
@ -99,22 +104,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
] ]
def get(self, request, slug, user_id): def get(self, request, slug, user_id):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state filters = issue_filters(request.query_params, "GET")
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = ( issue_queryset = (
@ -152,100 +143,103 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by("created_at") .order_by("created_at")
).distinct() ).distinct()
# Priority Ordering # Issue queryset
if order_by_param == "priority" or order_by_param == "-priority": issue_queryset, order_by_param = order_issue_queryset(
priority_order = ( issue_queryset=issue_queryset,
priority_order order_by_param=order_by_param,
if order_by_param == "priority" )
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering # Group by
elif order_by_param in [ group_by = request.GET.get("group_by", False)
"state__name", sub_group_by = request.GET.get("sub_group_by", False)
"state__group",
"-state__name", # issue queryset
"-state__group", issue_queryset = issue_queryset_grouper(
]: queryset=issue_queryset,
state_order = ( group_by=group_by,
state_order sub_group_by=sub_group_by,
if order_by_param in ["state__name", "state__group"] )
else state_order[::-1]
) if group_by:
issue_queryset = issue_queryset.annotate( if sub_group_by:
state_order=Case( if group_by == sub_group_by:
*[ return Response(
When(state__group=state_group, then=Value(i)) {
for i, state_group in enumerate(state_order) "error": "Group by and sub group by cannot have same parameters"
], },
default=Value(len(state_order)), status=status.HTTP_400_BAD_REQUEST,
output_field=CharField(), )
else:
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
) )
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) return self.paginate(
order_by=order_by_param,
issues = IssueSerializer( request=request,
issue_queryset, many=True, fields=fields if fields else None queryset=issue_queryset,
).data on_results=lambda issues: issue_on_results(
return Response(issues, status=status.HTTP_200_OK) group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)
class WorkspaceUserPropertiesEndpoint(BaseAPIView): class WorkspaceUserPropertiesEndpoint(BaseAPIView):
@ -397,6 +391,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
queryset = queryset.filter(project__in=projects) queryset = queryset.filter(project__in=projects)
return self.paginate( return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request, request=request,
queryset=queryset, queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer( on_results=lambda issue_activities: IssueActivitySerializer(

View File

@ -5,6 +5,7 @@ import logging
from celery import shared_task from celery import shared_task
# Django imports # Django imports
# Third party imports
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.html import strip_tags from django.utils.html import strip_tags

View File

@ -1,46 +1,29 @@
# Python imports # Python imports
import json import json
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Exists, F, Func, OuterRef, Q, Prefetch
# Django imports # Django imports
from django.utils import timezone from django.utils import timezone
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Q,
Case,
Value,
CharField,
When,
Exists,
Max,
IntegerField,
)
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
# Module imports # Third Party imports
from .base import BaseViewSet, BaseAPIView from rest_framework.response import Response
from plane.app.serializers import (
IssueCommentSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
IssuePublicSerializer,
)
from plane.app.serializers import (
CommentReactionSerializer,
IssueCommentSerializer,
IssuePublicSerializer,
IssueReactionSerializer,
IssueVoteSerializer,
)
from plane.db.models import ( from plane.db.models import (
Issue, Issue,
IssueComment, IssueComment,
Label,
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
State,
ProjectMember, ProjectMember,
IssueReaction, IssueReaction,
CommentReaction, CommentReaction,
@ -49,8 +32,20 @@ from plane.db.models import (
ProjectPublicMember, ProjectPublicMember,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
# Module imports
from .base import BaseAPIView, BaseViewSet
class IssueCommentPublicViewSet(BaseViewSet): class IssueCommentPublicViewSet(BaseViewSet):
@ -535,17 +530,10 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
anchor=anchor, entity_name="project" anchor=anchor, entity_name="project"
) )
filters = issue_filters(request.query_params, "GET") project_id = project_deploy_board.entity_identifier
slug = project_deploy_board.workspace.slug
# Custom ordering for priority and state filters = issue_filters(request.query_params, "GET")
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
@ -576,7 +564,6 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
) )
.filter(**filters) .filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by() .order_by()
@ -591,113 +578,118 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("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")
)
).distinct()
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
) )
# Priority Ordering # Group by
if order_by_param == "priority" or order_by_param == "-priority": group_by = request.GET.get("group_by", False)
priority_order = ( sub_group_by = request.GET.get("sub_group_by", False)
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering # issue queryset
elif order_by_param in [ issue_queryset = issue_queryset_grouper(
"state__name", queryset=issue_queryset,
"state__group", group_by=group_by,
"-state__name", sub_group_by=sub_group_by,
"-state__group", )
]:
state_order = ( if group_by:
state_order # Check group and sub group value paginate
if order_by_param in ["state__name", "state__group"] if sub_group_by:
else state_order[::-1] if group_by == sub_group_by:
) return Response(
issue_queryset = issue_queryset.annotate( {
state_order=Case( "error": "Group by and sub group by cannot have same parameters"
*[ },
When(state__group=state_group, then=Value(i)) status=status.HTTP_400_BAD_REQUEST,
for i, state_group in enumerate(state_order) )
], else:
default=Value(len(state_order)), # group and sub group pagination
output_field=CharField(), return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by,
issues=issues,
sub_group_by=sub_group_by,
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
) )
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values"
if order_by_param.startswith("-")
else "max_values"
)
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) # List Paginate
return self.paginate(
issues = IssuePublicSerializer(issue_queryset, many=True).data order_by=order_by_param,
request=request,
state_group_order = [ queryset=issue_queryset,
"backlog", on_results=lambda issues: issue_on_results(
"unstarted", group_by=group_by, issues=issues, sub_group_by=sub_group_by
"started",
"completed",
"cancelled",
]
states = (
State.objects.filter(
~Q(name="Triage"),
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
)
.annotate(
custom_order=Case(
*[
When(group=value, then=Value(index))
for index, value in enumerate(state_group_order)
],
default=Value(len(state_group_order)),
output_field=IntegerField(),
), ),
) )
.values("name", "group", "color", "id")
.order_by("custom_order", "sequence")
)
labels = Label.objects.filter(
workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
).values("id", "name", "color", "parent")
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
issues = group_results(issues, group_by)
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)

View File

@ -1,5 +1,9 @@
# Python imports # Python imports
import logging import logging
import traceback
# Django imports
from django.conf import settings
# Third party imports # Third party imports
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
@ -11,6 +15,10 @@ def log_exception(e):
logger = logging.getLogger("plane") logger = logging.getLogger("plane")
logger.error(e) logger.error(e)
# Log traceback if running in Debug
if settings.DEBUG:
logger.error(traceback.format_exc(e))
# Capture in sentry if configured # Capture in sentry if configured
capture_exception(e) capture_exception(e)
return return

View File

@ -1,240 +1,191 @@
def resolve_keys(group_keys, value): # Django imports
"""resolve keys to a key which will be used for from django.contrib.postgres.aggregates import ArrayAgg
grouping from django.contrib.postgres.fields import ArrayField
from django.db.models import Q, UUIDField, Value
from django.db.models.functions import Coalesce
Args: # Module imports
group_keys (string): key which will be used for grouping from plane.db.models import (
value (obj): data value Cycle,
Issue,
Returns: Label,
string: the key which will be used for Module,
""" Project,
keys = group_keys.split(".") ProjectMember,
for key in keys: State,
value = value.get(key, None) WorkspaceMember,
return value )
def group_results(results_data, group_by, sub_group_by=False): def issue_queryset_grouper(queryset, group_by, sub_group_by):
"""group results data into certain group_by
Args: FIELD_MAPPER = {
results_data (obj): complete results data "label_ids": "labels__id",
group_by (key): string "assignee_ids": "assignees__id",
"module_ids": "issue_module__module_id",
}
Returns: annotations_map = {
obj: grouped results "assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)),
""" "label_ids": ("labels__id", ~Q(labels__id__isnull=True)),
if sub_group_by: "module_ids": (
main_responsive_dict = dict() "issue_module__module_id",
~Q(issue_module__module_id__isnull=True),
),
}
default_annotations = {
key: Coalesce(
ArrayAgg(
field,
distinct=True,
filter=condition,
),
Value([], output_field=ArrayField(UUIDField())),
)
for key, (field, condition) in annotations_map.items()
if FIELD_MAPPER.get(key) != group_by
or FIELD_MAPPER.get(key) != sub_group_by
}
if sub_group_by == "priority": return queryset.annotate(**default_annotations)
main_responsive_dict = {
"urgent": {},
"high": {},
"medium": {},
"low": {},
"none": {},
}
for value in results_data:
main_group_attribute = resolve_keys(sub_group_by, value)
group_attribute = resolve_keys(group_by, value)
if isinstance(main_group_attribute, list) and not isinstance(
group_attribute, list
):
if len(main_group_attribute):
for attrib in main_group_attribute:
if str(attrib) not in main_responsive_dict:
main_responsive_dict[str(attrib)] = {}
if (
str(group_attribute)
in main_responsive_dict[str(attrib)]
):
main_responsive_dict[str(attrib)][
str(group_attribute)
].append(value)
else:
main_responsive_dict[str(attrib)][
str(group_attribute)
] = []
main_responsive_dict[str(attrib)][
str(group_attribute)
].append(value)
else:
if str(None) not in main_responsive_dict:
main_responsive_dict[str(None)] = {}
if str(group_attribute) in main_responsive_dict[str(None)]: def issue_on_results(issues, group_by, sub_group_by):
main_responsive_dict[str(None)][
str(group_attribute)
].append(value)
else:
main_responsive_dict[str(None)][
str(group_attribute)
] = []
main_responsive_dict[str(None)][
str(group_attribute)
].append(value)
elif isinstance(group_attribute, list) and not isinstance( FIELD_MAPPER = {
main_group_attribute, list "labels__id": "label_ids",
): "assignees__id": "assignee_ids",
if str(main_group_attribute) not in main_responsive_dict: "issue_module__module_id": "module_ids",
main_responsive_dict[str(main_group_attribute)] = {} }
if len(group_attribute):
for attrib in group_attribute:
if (
str(attrib)
in main_responsive_dict[str(main_group_attribute)]
):
main_responsive_dict[str(main_group_attribute)][
str(attrib)
].append(value)
else:
main_responsive_dict[str(main_group_attribute)][
str(attrib)
] = []
main_responsive_dict[str(main_group_attribute)][
str(attrib)
].append(value)
else:
if (
str(None)
in main_responsive_dict[str(main_group_attribute)]
):
main_responsive_dict[str(main_group_attribute)][
str(None)
].append(value)
else:
main_responsive_dict[str(main_group_attribute)][
str(None)
] = []
main_responsive_dict[str(main_group_attribute)][
str(None)
].append(value)
elif isinstance(group_attribute, list) and isinstance( original_list = ["assignee_ids", "label_ids", "module_ids"]
main_group_attribute, list
):
if len(main_group_attribute):
for main_attrib in main_group_attribute:
if str(main_attrib) not in main_responsive_dict:
main_responsive_dict[str(main_attrib)] = {}
if len(group_attribute):
for attrib in group_attribute:
if (
str(attrib)
in main_responsive_dict[str(main_attrib)]
):
main_responsive_dict[str(main_attrib)][
str(attrib)
].append(value)
else:
main_responsive_dict[str(main_attrib)][
str(attrib)
] = []
main_responsive_dict[str(main_attrib)][
str(attrib)
].append(value)
else:
if (
str(None)
in main_responsive_dict[str(main_attrib)]
):
main_responsive_dict[str(main_attrib)][
str(None)
].append(value)
else:
main_responsive_dict[str(main_attrib)][
str(None)
] = []
main_responsive_dict[str(main_attrib)][
str(None)
].append(value)
else:
if str(None) not in main_responsive_dict:
main_responsive_dict[str(None)] = {}
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][
str(attrib)
].append(value)
else:
main_responsive_dict[str(None)][
str(attrib)
] = []
main_responsive_dict[str(None)][
str(attrib)
].append(value)
else:
if str(None) in main_responsive_dict[str(None)]:
main_responsive_dict[str(None)][str(None)].append(
value
)
else:
main_responsive_dict[str(None)][str(None)] = []
main_responsive_dict[str(None)][str(None)].append(
value
)
else:
main_group_attribute = resolve_keys(sub_group_by, value)
group_attribute = resolve_keys(group_by, value)
if str(main_group_attribute) not in main_responsive_dict: required_fields = [
main_responsive_dict[str(main_group_attribute)] = {} "id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
"state__group",
]
if ( if group_by in FIELD_MAPPER:
str(group_attribute) original_list.remove(FIELD_MAPPER[group_by])
in main_responsive_dict[str(main_group_attribute)] original_list.append(group_by)
):
main_responsive_dict[str(main_group_attribute)][
str(group_attribute)
].append(value)
else:
main_responsive_dict[str(main_group_attribute)][
str(group_attribute)
] = []
main_responsive_dict[str(main_group_attribute)][
str(group_attribute)
].append(value)
return main_responsive_dict if sub_group_by in FIELD_MAPPER:
original_list.remove(FIELD_MAPPER[sub_group_by])
original_list.append(sub_group_by)
else: required_fields.extend(original_list)
response_dict = {} return issues.values(*required_fields)
if group_by == "priority":
response_dict = {
"urgent": [],
"high": [],
"medium": [],
"low": [],
"none": [],
}
for value in results_data: def issue_group_values(field, slug, project_id=None, filters=dict):
group_attribute = resolve_keys(group_by, value) if field == "state_id":
if isinstance(group_attribute, list): queryset = State.objects.filter(
if len(group_attribute): ~Q(name="Triage"),
for attrib in group_attribute: workspace__slug=slug,
if str(attrib) in response_dict: ).values_list("id", flat=True)
response_dict[str(attrib)].append(value) if project_id:
else: return list(queryset.filter(project_id=project_id))
response_dict[str(attrib)] = [] else:
response_dict[str(attrib)].append(value) return list(queryset)
else: if field == "labels__id":
if str(None) in response_dict: queryset = Label.objects.filter(workspace__slug=slug).values_list(
response_dict[str(None)].append(value) "id", flat=True
else: )
response_dict[str(None)] = [] if project_id:
response_dict[str(None)].append(value) return list(queryset.filter(project_id=project_id)) + ["None"]
else: else:
if str(group_attribute) in response_dict: return list(queryset) + ["None"]
response_dict[str(group_attribute)].append(value) if field == "assignees__id":
else: if project_id:
response_dict[str(group_attribute)] = [] return ProjectMember.objects.filter(
response_dict[str(group_attribute)].append(value) workspace__slug=slug,
project_id=project_id,
return response_dict is_active=True,
).values_list("member_id", flat=True)
else:
return list(
WorkspaceMember.objects.filter(
workspace__slug=slug, is_active=True
).values_list("member_id", flat=True)
)
if field == "issue_module__module_id":
queryset = Module.objects.filter(
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "cycle_id":
queryset = Cycle.objects.filter(
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
if field == "project_id":
queryset = Project.objects.filter(workspace__slug=slug).values_list(
"id", flat=True
)
return list(queryset)
if field == "priority":
return [
"low",
"medium",
"high",
"urgent",
"none",
]
if field == "state__group":
return [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
if field == "target_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("target_date", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "start_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
.filter(**filters)
.values_list("start_date", flat=True)
.distinct()
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
return []

View File

@ -1,6 +1,7 @@
import re import re
import uuid import uuid
from datetime import timedelta from datetime import timedelta
from django.utils import timezone from django.utils import timezone
# The date from pattern # The date from pattern
@ -63,24 +64,27 @@ def date_filter(filter, date_term, queries):
""" """
for query in queries: for query in queries:
date_query = query.split(";") date_query = query.split(";")
if len(date_query) >= 2: if date_query:
match = pattern.match(date_query[0]) if len(date_query) >= 2:
if match: match = pattern.match(date_query[0])
if len(date_query) == 3: if match:
digit, term = date_query[0].split("_") if len(date_query) == 3:
string_date_filter( digit, term = date_query[0].split("_")
filter=filter, string_date_filter(
duration=int(digit), filter=filter,
subsequent=date_query[1], duration=int(digit),
term=term, subsequent=date_query[1],
date_filter=date_term, term=term,
offset=date_query[2], date_filter=date_term,
) offset=date_query[2],
else: )
if "after" in date_query:
filter[f"{date_term}__gte"] = date_query[0]
else: else:
filter[f"{date_term}__lte"] = date_query[0] if "after" in date_query:
filter[f"{date_term}__gte"] = date_query[0]
else:
filter[f"{date_term}__lte"] = date_query[0]
else:
filter[f"{date_term}__contains"] = date_query[0]
def filter_state(params, filter, method, prefix=""): def filter_state(params, filter, method, prefix=""):

View File

@ -0,0 +1,84 @@
from django.db.models import (
Case,
CharField,
Min,
Value,
When,
)
# Custom ordering for priority and state
PRIORITY_ORDER = ["urgent", "high", "medium", "low", "none"]
STATE_ORDER = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(PRIORITY_ORDER)
],
output_field=CharField(),
)
).order_by("priority_order")
order_by_param = (
"-priority_order"
if order_by_param.startswith("-")
else "priority_order"
)
# State Ordering
elif order_by_param in [
"state__group",
"-state__group",
]:
state_order = (
STATE_ORDER
if order_by_param in ["state__name", "state__group"]
else STATE_ORDER[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
order_by_param = (
"-state_order" if order_by_param.startswith("-") else "state_order"
)
# assignee and label ordering
elif order_by_param in [
"labels__name",
"assignees__first_name",
"issue_module__module__name",
"-labels__name",
"-assignees__first_name",
"-issue_module__module__name",
]:
issue_queryset = issue_queryset.annotate(
min_values=Min(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-min_values" if order_by_param.startswith("-") else "min_values"
)
order_by_param = (
"-min_values" if order_by_param.startswith("-") else "min_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
order_by_param = order_by_param
return issue_queryset, order_by_param

View File

@ -1,33 +1,49 @@
from rest_framework.response import Response # Python imports
from rest_framework.exceptions import ParseError
from collections.abc import Sequence
import math import math
from collections import defaultdict
from collections.abc import Sequence
# Django imports
from django.db.models import Count, F, Window
from django.db.models.functions import RowNumber
# Third party imports
from rest_framework.exceptions import ParseError
from rest_framework.response import Response
# Module imports
class Cursor: class Cursor:
# The cursor value
def __init__(self, value, offset=0, is_prev=False, has_results=None): def __init__(self, value, offset=0, is_prev=False, has_results=None):
self.value = value self.value = value
self.offset = int(offset) self.offset = int(offset)
self.is_prev = bool(is_prev) self.is_prev = bool(is_prev)
self.has_results = has_results self.has_results = has_results
# Return the cursor value in string format
def __str__(self): def __str__(self):
return f"{self.value}:{self.offset}:{int(self.is_prev)}" return f"{self.value}:{self.offset}:{int(self.is_prev)}"
# Return the cursor value
def __eq__(self, other): def __eq__(self, other):
return all( return all(
getattr(self, attr) == getattr(other, attr) getattr(self, attr) == getattr(other, attr)
for attr in ("value", "offset", "is_prev", "has_results") for attr in ("value", "offset", "is_prev", "has_results")
) )
# Return the representation of the cursor
def __repr__(self): def __repr__(self):
return f"{type(self).__name__,}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}" return f"{type(self).__name__,}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}"
# Return if the cursor is true
def __bool__(self): def __bool__(self):
return bool(self.has_results) return bool(self.has_results)
@classmethod @classmethod
def from_string(cls, value): def from_string(cls, value):
"""Return the cursor value from string format"""
try: try:
bits = value.split(":") bits = value.split(":")
if len(bits) != 3: if len(bits) != 3:
@ -50,15 +66,19 @@ class CursorResult(Sequence):
self.max_hits = max_hits self.max_hits = max_hits
def __len__(self): def __len__(self):
# Return the length of the results
return len(self.results) return len(self.results)
def __iter__(self): def __iter__(self):
# Return the iterator of the results
return iter(self.results) return iter(self.results)
def __getitem__(self, key): def __getitem__(self, key):
# Return the results based on the key
return self.results[key] return self.results[key]
def __repr__(self): def __repr__(self):
# Return the representation of the results
return f"<{type(self).__name__}: results={len(self.results)}>" return f"<{type(self).__name__}: results={len(self.results)}>"
@ -85,11 +105,14 @@ class OffsetPaginator:
max_offset=None, max_offset=None,
on_results=None, on_results=None,
): ):
# Key tuple and remove `-` if descending order by
self.key = ( self.key = (
order_by order_by
if order_by is None or isinstance(order_by, (list, tuple, set)) if order_by is None or isinstance(order_by, (list, tuple, set))
else (order_by,) else (order_by[1::] if order_by.startswith("-") else order_by,)
) )
# Set desc to true when `-` exists in the order by
self.desc = True if order_by.startswith("-") else False
self.queryset = queryset self.queryset = queryset
self.max_limit = max_limit self.max_limit = max_limit
self.max_offset = max_offset self.max_offset = max_offset
@ -101,11 +124,101 @@ class OffsetPaginator:
if cursor is None: if cursor is None:
cursor = Cursor(0, 0, 0) cursor = Cursor(0, 0, 0)
# Get the min from limit and max limit
limit = min(limit, self.max_limit) limit = min(limit, self.max_limit)
# queryset
queryset = self.queryset queryset = self.queryset
if self.key: if self.key:
queryset = queryset.order_by(*self.key) queryset = queryset.order_by(
(
F(*self.key).desc(nulls_last=True)
if self.desc
else F(*self.key).asc(nulls_last=True)
),
"-created_at",
)
# The current page
page = cursor.offset
# The offset
offset = cursor.offset * cursor.value
stop = offset + (cursor.value or limit) + 1
if self.max_offset is not None and offset >= self.max_offset:
raise BadPaginationError("Pagination offset too large")
if offset < 0:
raise BadPaginationError("Pagination offset cannot be negative")
results = queryset[offset:stop]
if cursor.value != limit:
results = results[-(limit + 1) :]
# Adjust cursors based on the results for pagination
next_cursor = Cursor(limit, page + 1, False, results.count() > limit)
# If the page is greater than 0, then set the previous cursor
prev_cursor = Cursor(limit, page - 1, True, page > 0)
# Process the results
results = results[:limit]
# Process the results
if self.on_results:
results = self.on_results(results)
# Count the queryset
count = queryset.count()
# Optionally, calculate the total count and max_hits if needed
max_hits = math.ceil(count / limit)
# Return the cursor results
return CursorResult(
results=results,
next=next_cursor,
prev=prev_cursor,
hits=count,
max_hits=max_hits,
)
def process_results(self, results):
raise NotImplementedError
class GroupedOffsetPaginator(OffsetPaginator):
# Field mappers
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
"modules__id": "module_ids",
}
def __init__(
self,
queryset,
group_by_field_name,
group_by_fields,
count_filter,
*args,
**kwargs,
):
# Initiate the parent class for all the parameters
super().__init__(queryset, *args, **kwargs)
self.group_by_field_name = group_by_field_name
self.group_by_fields = group_by_fields
self.count_filter = count_filter
def get_result(self, limit=50, cursor=None):
# offset is page #
# value is page limit
if cursor is None:
cursor = Cursor(0, 0, 0)
limit = min(limit, self.max_limit)
# Adjust the initial offset and stop based on the cursor and limit
queryset = self.queryset
page = cursor.offset page = cursor.offset
offset = cursor.offset * cursor.value offset = cursor.offset * cursor.value
@ -116,20 +229,73 @@ class OffsetPaginator:
if offset < 0: if offset < 0:
raise BadPaginationError("Pagination offset cannot be negative") raise BadPaginationError("Pagination offset cannot be negative")
results = list(queryset[offset:stop]) # Compute the results
if cursor.value != limit: results = {}
results = results[-(limit + 1) :] # Create window for all the groups
queryset = queryset.annotate(
row_number=Window(
expression=RowNumber(),
partition_by=[F(self.group_by_field_name)],
order_by=(
(
F(*self.key).desc(
nulls_last=True
) # order by desc if desc is set
if self.desc
else F(*self.key).asc(
nulls_last=True
) # Order by asc if set
),
F("created_at").desc(),
),
)
)
# Filter the results by row number
results = queryset.filter(
row_number__gt=offset, row_number__lt=stop
).order_by(
(
F(*self.key).desc(nulls_last=True)
if self.desc
else F(*self.key).asc(nulls_last=True)
),
F("created_at").desc(),
)
next_cursor = Cursor(limit, page + 1, False, len(results) > limit) # Adjust cursors based on the grouped results for pagination
prev_cursor = Cursor(limit, page - 1, True, page > 0) next_cursor = Cursor(
limit,
results = list(results[:limit]) page + 1,
if self.on_results: False,
results = self.on_results(results) queryset.filter(row_number__gte=stop).exists(),
)
prev_cursor = Cursor(
limit,
page - 1,
True,
page > 0,
)
# Count the queryset
count = queryset.count() count = queryset.count()
max_hits = math.ceil(count / limit)
# Optionally, calculate the total count and max_hits if needed
# This might require adjustments based on specific use cases
if results:
max_hits = math.ceil(
queryset.values(self.group_by_field_name)
.annotate(
count=Count(
"id",
filter=self.count_filter,
distinct=True,
)
)
.order_by("-count")[0]["count"]
/ limit
)
else:
max_hits = 0
return CursorResult( return CursorResult(
results=results, results=results,
next=next_cursor, next=next_cursor,
@ -138,6 +304,393 @@ class OffsetPaginator:
max_hits=max_hits, max_hits=max_hits,
) )
def __get_total_queryset(self):
# Get total queryset
return (
self.queryset.values(self.group_by_field_name)
.annotate(
count=Count(
"id",
filter=self.count_filter,
distinct=True,
)
)
.order_by()
)
def __get_total_dict(self):
# Convert the total into dictionary of keys as group name and value as the total
total_group_dict = {}
for group in self.__get_total_queryset():
total_group_dict[str(group.get(self.group_by_field_name))] = (
total_group_dict.get(
str(group.get(self.group_by_field_name)), 0
)
+ (1 if group.get("count") == 0 else group.get("count"))
)
return total_group_dict
def __get_field_dict(self):
# Create a field dictionary
total_group_dict = self.__get_total_dict()
return {
str(field): {
"results": [],
"total_results": total_group_dict.get(str(field), 0),
}
for field in self.group_by_fields
}
def __result_already_added(self, result, group):
# Check if the result is already added then add it
for existing_issue in group:
if existing_issue["id"] == result["id"]:
return True
return False
def __query_multi_grouper(self, results):
# Grouping for m2m values
total_group_dict = self.__get_total_dict()
# Preparing a dict to keep track of group IDs associated with each label ID
result_group_mapping = defaultdict(set)
# Preparing a dict to group result by group ID
grouped_by_field_name = defaultdict(list)
# Iterate over results to fill the above dictionaries
for result in results:
result_id = result["id"]
group_id = result[self.group_by_field_name]
result_group_mapping[str(result_id)].add(str(group_id))
# Adding group_ids key to each issue and grouping by group_name
for result in results:
result_id = result["id"]
group_ids = list(result_group_mapping[str(result_id)])
result[self.FIELD_MAPPER.get(self.group_by_field_name)] = (
[] if "None" in group_ids else group_ids
)
# If a result belongs to multiple groups, add it to each group
for group_id in group_ids:
if not self.__result_already_added(
result, grouped_by_field_name[group_id]
):
grouped_by_field_name[group_id].append(result)
# Convert grouped_by_field_name back to a list for each group
processed_results = {
str(group_id): {
"results": issues,
"total_results": total_group_dict.get(str(group_id)),
}
for group_id, issues in grouped_by_field_name.items()
}
return processed_results
def __query_grouper(self, results):
# Grouping for single values
processed_results = self.__get_field_dict()
for result in results:
(
print(result["created_at"].date(), result["priority"])
if str(result[self.group_by_field_name])
== "c88dfd3b-e97e-4948-851b-a5fe1e36ffd0"
else None
)
group_value = str(result.get(self.group_by_field_name))
if group_value in processed_results:
processed_results[str(group_value)]["results"].append(result)
return processed_results
def process_results(self, results):
# Process results
if results:
if self.group_by_field_name in self.FIELD_MAPPER:
processed_results = self.__query_multi_grouper(results=results)
else:
processed_results = self.__query_grouper(results=results)
else:
processed_results = {}
return processed_results
class SubGroupedOffsetPaginator(OffsetPaginator):
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
"modules__id": "module_ids",
}
def __init__(
self,
queryset,
group_by_field_name,
sub_group_by_field_name,
group_by_fields,
sub_group_by_fields,
count_filter,
*args,
**kwargs,
):
super().__init__(queryset, *args, **kwargs)
self.group_by_field_name = group_by_field_name
self.group_by_fields = group_by_fields
self.sub_group_by_field_name = sub_group_by_field_name
self.sub_group_by_fields = sub_group_by_fields
self.count_filter = count_filter
def get_result(self, limit=30, cursor=None):
# offset is page #
# value is page limit
if cursor is None:
cursor = Cursor(0, 0, 0)
limit = min(limit, self.max_limit)
# Adjust the initial offset and stop based on the cursor and limit
queryset = self.queryset
page = cursor.offset
offset = cursor.offset * cursor.value
stop = offset + (cursor.value or limit) + 1
if self.max_offset is not None and offset >= self.max_offset:
raise BadPaginationError("Pagination offset too large")
if offset < 0:
raise BadPaginationError("Pagination offset cannot be negative")
# Compute the results
results = {}
# Create windows for group and sub group field name
queryset = queryset.annotate(
row_number=Window(
expression=RowNumber(),
partition_by=[
F(self.group_by_field_name),
F(self.sub_group_by_field_name),
],
order_by=(
(
F(*self.key).desc(nulls_last=True)
if self.desc
else F(*self.key).asc(nulls_last=True)
),
"-created_at",
),
)
)
# Filter the results
results = queryset.filter(
row_number__gt=offset, row_number__lt=stop
).order_by(
(
F(*self.key).desc(nulls_last=True)
if self.desc
else F(*self.key).asc(nulls_last=True)
),
F("created_at").desc(),
)
# Adjust cursors based on the grouped results for pagination
next_cursor = Cursor(
limit,
page + 1,
False,
queryset.filter(row_number__gte=stop).exists(),
)
prev_cursor = Cursor(
limit,
page - 1,
True,
page > 0,
)
# Count the queryset
count = queryset.count()
# Optionally, calculate the total count and max_hits if needed
# This might require adjustments based on specific use cases
if results:
max_hits = math.ceil(
queryset.values(self.group_by_field_name)
.annotate(
count=Count(
"id",
filter=self.count_filter,
distinct=True,
)
)
.order_by("-count")[0]["count"]
/ limit
)
else:
max_hits = 0
return CursorResult(
results=results,
next=next_cursor,
prev=prev_cursor,
hits=count,
max_hits=max_hits,
)
def __get_group_total_queryset(self):
# Get group totals
return (
self.queryset.order_by(self.group_by_field_name)
.values(self.group_by_field_name)
.annotate(
count=Count(
"id",
filter=self.count_filter,
distinct=True,
)
)
.distinct()
)
def __get_subgroup_total_queryset(self):
# Get subgroup totals
return (
self.queryset.values(
self.group_by_field_name, self.sub_group_by_field_name
)
.annotate(
count=Count("id", filter=self.count_filter, distinct=True)
)
.order_by()
.values(
self.group_by_field_name, self.sub_group_by_field_name, "count"
)
)
def __get_total_dict(self):
# Use the above to convert to dictionary of 2D objects
total_group_dict = {}
total_sub_group_dict = {}
for group in self.__get_group_total_queryset():
total_group_dict[str(group.get(self.group_by_field_name))] = (
total_group_dict.get(
str(group.get(self.group_by_field_name)), 0
)
+ (1 if group.get("count") == 0 else group.get("count"))
)
# Sub group total values
for item in self.__get_subgroup_total_queryset():
group = str(item[self.group_by_field_name])
subgroup = str(item[self.sub_group_by_field_name])
count = item["count"]
if group not in total_sub_group_dict:
total_sub_group_dict[str(group)] = {}
if subgroup not in total_sub_group_dict[group]:
total_sub_group_dict[str(group)][str(subgroup)] = {}
total_sub_group_dict[group][subgroup] = count
return total_group_dict, total_sub_group_dict
def __get_field_dict(self):
total_group_dict, total_sub_group_dict = self.__get_total_dict()
return {
str(group): {
"results": {
str(sub_group): {
"results": [],
"total_results": total_sub_group_dict.get(
str(group)
).get(str(sub_group), 0),
}
for sub_group in total_sub_group_dict.get(str(group), [])
},
"total_results": total_group_dict.get(str(group), 0),
}
for group in self.group_by_fields
}
def __query_multi_grouper(self, results):
# Multi grouper
processed_results = self.__get_field_dict()
# Preparing a dict to keep track of group IDs associated with each label ID
result_group_mapping = defaultdict(set)
result_sub_group_mapping = defaultdict(set)
# Iterate over results to fill the above dictionaries
if self.group_by_field_name in self.FIELD_MAPPER:
for result in results:
result_id = result["id"]
group_id = result[self.group_by_field_name]
result_group_mapping[str(result_id)].add(str(group_id))
# Use the same calculation for the sub group
if self.sub_group_by_field_name in self.FIELD_MAPPER:
for result in results:
result_id = result["id"]
sub_group_id = result[self.sub_group_by_field_name]
result_sub_group_mapping[str(result_id)].add(str(sub_group_id))
# Iterate over results
for result in results:
# Get the group value
group_value = str(result.get(self.group_by_field_name))
# Get the sub group value
sub_group_value = str(result.get(self.sub_group_by_field_name))
if (
group_value in processed_results
and sub_group_value
in processed_results[str(group_value)]["results"]
):
if self.group_by_field_name in self.FIELD_MAPPER:
# for multi grouper
group_ids = list(result_group_mapping[str(result_id)])
result[self.FIELD_MAPPER.get(self.group_by_field_name)] = (
[] if "None" in group_ids else group_ids
)
if self.sub_group_by_field_name in self.FIELD_MAPPER:
sub_group_ids = list(result_group_mapping[str(result_id)])
# for multi groups
result[self.FIELD_MAPPER.get(self.group_by_field_name)] = (
[] if "None" in sub_group_ids else sub_group_ids
)
processed_results[str(group_value)]["results"][
str(sub_group_value)
]["results"].append(result)
return processed_results
def __query_grouper(self, results):
# Single grouper
processed_results = self.__get_field_dict()
for result in results:
group_value = str(result.get(self.group_by_field_name))
sub_group_value = str(result.get(self.sub_group_by_field_name))
processed_results[group_value]["results"][sub_group_value][
"results"
].append(result)
return processed_results
def process_results(self, results):
if results:
if (
self.group_by_field_name in self.FIELD_MAPPER
or self.sub_group_by_field_name in self.FIELD_MAPPER
):
processed_results = self.__query_multi_grouper(results=results)
else:
processed_results = self.__query_grouper(results=results)
else:
processed_results = {}
return processed_results
class BasePaginator: class BasePaginator:
"""BasePaginator class can be inherited by any View to return a paginated view""" """BasePaginator class can be inherited by any View to return a paginated view"""
@ -171,6 +724,11 @@ class BasePaginator:
cursor_cls=Cursor, cursor_cls=Cursor,
extra_stats=None, extra_stats=None,
controller=None, controller=None,
group_by_field_name=None,
group_by_fields=None,
sub_group_by_field_name=None,
sub_group_by_fields=None,
count_filter=None,
**paginator_kwargs, **paginator_kwargs,
): ):
"""Paginate the request""" """Paginate the request"""
@ -178,15 +736,27 @@ class BasePaginator:
# Convert the cursor value to integer and float from string # Convert the cursor value to integer and float from string
input_cursor = None input_cursor = None
if request.GET.get(self.cursor_name): try:
try: input_cursor = cursor_cls.from_string(
input_cursor = cursor_cls.from_string( request.GET.get(self.cursor_name, f"{per_page}:0:0"),
request.GET.get(self.cursor_name) )
) except ValueError:
except ValueError: raise ParseError(detail="Invalid cursor parameter.")
raise ParseError(detail="Invalid cursor parameter.")
if not paginator: if not paginator:
if group_by_field_name:
paginator_kwargs["group_by_field_name"] = group_by_field_name
paginator_kwargs["group_by_fields"] = group_by_fields
paginator_kwargs["count_filter"] = count_filter
if sub_group_by_field_name:
paginator_kwargs["sub_group_by_field_name"] = (
sub_group_by_field_name
)
paginator_kwargs["sub_group_by_fields"] = (
sub_group_by_fields
)
paginator = paginator_cls(**paginator_kwargs) paginator = paginator_cls(**paginator_kwargs)
try: try:
@ -196,12 +766,14 @@ class BasePaginator:
except BadPaginationError: except BadPaginationError:
raise ParseError(detail="Error in parsing") raise ParseError(detail="Error in parsing")
# Serialize result according to the on_result function
if on_results: if on_results:
results = on_results(cursor_result.results) results = on_results(cursor_result.results)
else: else:
results = cursor_result.results results = cursor_result.results
if group_by_field_name:
results = paginator.process_results(results=results)
# Add Manipulation functions to the response # Add Manipulation functions to the response
if controller is not None: if controller is not None:
results = controller(results) results = controller(results)
@ -211,6 +783,9 @@ class BasePaginator:
# Return the response # Return the response
response = Response( response = Response(
{ {
"grouped_by": group_by_field_name,
"sub_grouped_by": sub_group_by_field_name,
"total_count": (cursor_result.hits),
"next_cursor": str(cursor_result.next), "next_cursor": str(cursor_result.next),
"prev_cursor": str(cursor_result.prev), "prev_cursor": str(cursor_result.prev),
"next_page_results": cursor_result.next.has_results, "next_page_results": cursor_result.next.has_results,

View File

@ -60,4 +60,5 @@ zxcvbn==4.4.28
# timezone # timezone
pytz==2024.1 pytz==2024.1
# jwt # jwt
PyJWT==2.8.0 PyJWT==2.8.0

View File

@ -1,3 +1,6 @@
import { StateGroup } from "components/states";
import { TIssuePriorities } from "../issues";
// issues // issues
export * from "./issue"; export * from "./issue";
export * from "./issue_reaction"; export * from "./issue_reaction";
@ -7,16 +10,30 @@ export * from "./issue_relation";
export * from "./issue_sub_issues"; export * from "./issue_sub_issues";
export * from "./activity/base"; export * from "./activity/base";
export type TLoader = "init-loader" | "mutation" | undefined; export type TLoader = "init-loader" | "mutation" | "pagination" | undefined;
export type TGroupedIssues = { export type TGroupedIssues = {
[group_id: string]: string[]; [group_id: string]: string[];
}; };
export type TSubGroupedIssues = { export type TSubGroupedIssues = {
[sub_grouped_id: string]: { [sub_grouped_id: string]: TGroupedIssues;
[group_id: string]: string[];
};
}; };
export type TUnGroupedIssues = string[]; export type TIssues = TGroupedIssues | TSubGroupedIssues;
export type TPaginationData = {
nextCursor: string;
prevCursor: string;
nextPageResults: boolean;
};
export type TIssuePaginationData = {
[group_id: string]: TPaginationData;
};
export type TGroupedIssueCount = {
[group_id: string]: number;
};
export type TUnGroupedIssues = string[];

View File

@ -4,15 +4,15 @@ import { TIssueLink } from "./issue_link";
import { TIssueReaction } from "./issue_reaction"; import { TIssueReaction } from "./issue_reaction";
// new issue structure types // new issue structure types
export type TIssue = {
export type TBaseIssue = {
id: string; id: string;
sequence_id: number; sequence_id: number;
name: string; name: string;
description_html: string;
sort_order: number; sort_order: number;
state_id: string; state_id: string | null;
priority: TIssuePriorities; priority: TIssuePriorities | null;
label_ids: string[]; label_ids: string[];
assignee_ids: string[]; assignee_ids: string[];
estimate_point: string | null; estimate_point: string | null;
@ -21,7 +21,7 @@ export type TIssue = {
attachment_count: number; attachment_count: number;
link_count: number; link_count: number;
project_id: string; project_id: string | null;
parent_id: string | null; parent_id: string | null;
cycle_id: string | null; cycle_id: string | null;
module_ids: string[] | null; module_ids: string[] | null;
@ -37,9 +37,14 @@ export type TIssue = {
updated_by: string; updated_by: string;
is_draft: boolean; is_draft: boolean;
};
export type TIssue = TBaseIssue & {
description_html?: string;
is_subscribed?: boolean; is_subscribed?: boolean;
parent?: partial<TIssue>; parent?: partial<TIssue>;
issue_reactions?: TIssueReaction[]; issue_reactions?: TIssueReaction[];
issue_attachment?: TIssueAttachment[]; issue_attachment?: TIssueAttachment[];
issue_link?: TIssueLink[]; issue_link?: TIssueLink[];
@ -51,3 +56,47 @@ export type TIssue = {
export type TIssueMap = { export type TIssueMap = {
[issue_id: string]: TIssue; [issue_id: string]: TIssue;
}; };
type TIssueResponseResults =
| TBaseIssue[]
| {
[key: string]: {
results:
| TBaseIssue[]
| {
[key: string]: {
results: TBaseIssue[];
total_results: number;
};
};
total_results: number;
};
};
export type TIssuesResponse = {
grouped_by: string;
next_cursor: string;
prev_cursor: string;
next_page_results: boolean;
prev_page_results: boolean;
total_count: number;
count: number;
total_pages: number;
extra_stats: null;
results: TIssueResponseResults;
}
export type TBulkIssueProperties = Pick<
TIssue,
| "state_id"
| "priority"
| "label_ids"
| "assignee_ids"
| "start_date"
| "target_date"
>;
export type TBulkOperationsPayload = {
issue_ids: string[];
properties: Partial<TBulkIssueProperties>;
};

View File

@ -186,6 +186,8 @@ export interface IUserEmailNotificationSettings {
issue_completed: boolean; issue_completed: boolean;
} }
export type TProfileViews = "assigned" | "created" | "subscribed";
// export interface ICurrentUser { // export interface ICurrentUser {
// id: readonly string; // id: readonly string;
// avatar: string; // avatar: string;

View File

@ -1,3 +1,5 @@
import { EIssueLayoutTypes } from "constants/issue";
export type TIssueLayouts = export type TIssueLayouts =
| "list" | "list"
| "kanban" | "kanban"
@ -13,9 +15,9 @@ export type TIssueGroupByOptions =
| "state_detail.group" | "state_detail.group"
| "project" | "project"
| "assignees" | "assignees"
| "mentions"
| "cycle" | "cycle"
| "module" | "module"
| "target_date"
| null; | null;
export type TIssueOrderByOptions = export type TIssueOrderByOptions =
@ -32,10 +34,10 @@ export type TIssueOrderByOptions =
| "-assignees__first_name" | "-assignees__first_name"
| "labels__name" | "labels__name"
| "-labels__name" | "-labels__name"
| "modules__name" | "issue_module__module__name"
| "-modules__name" | "-issue_module__module__name"
| "cycle__name" | "issue_cycle__cycle__name"
| "-cycle__name" | "-issue_cycle__cycle__name"
| "target_date" | "target_date"
| "-target_date" | "-target_date"
| "estimate_point" | "estimate_point"
@ -72,7 +74,9 @@ export type TIssueParams =
| "order_by" | "order_by"
| "type" | "type"
| "sub_issue" | "sub_issue"
| "show_empty_groups"; | "show_empty_groups"
| "cursor"
| "per_page";
export type TCalendarLayouts = "month" | "week"; export type TCalendarLayouts = "month" | "week";
@ -82,9 +86,9 @@ export interface IIssueFilterOptions {
created_by?: string[] | null; created_by?: string[] | null;
labels?: string[] | null; labels?: string[] | null;
priority?: string[] | null; priority?: string[] | null;
project?: string[] | null;
cycle?: string[] | null; cycle?: string[] | null;
module?: string[] | null; module?: string[] | null;
project?: string[] | null;
start_date?: string[] | null; start_date?: string[] | null;
state?: string[] | null; state?: string[] | null;
state_group?: string[] | null; state_group?: string[] | null;
@ -99,7 +103,7 @@ export interface IIssueDisplayFilterOptions {
}; };
group_by?: TIssueGroupByOptions; group_by?: TIssueGroupByOptions;
sub_group_by?: TIssueGroupByOptions; sub_group_by?: TIssueGroupByOptions;
layout?: TIssueLayouts; layout?: EIssueLayoutTypes;
order_by?: TIssueOrderByOptions; order_by?: TIssueOrderByOptions;
show_empty_groups?: boolean; show_empty_groups?: boolean;
sub_issue?: boolean; sub_issue?: boolean;
@ -191,3 +195,11 @@ export interface IWorkspaceGlobalViewProps {
display_filters: IWorkspaceIssueDisplayFilterOptions | undefined; display_filters: IWorkspaceIssueDisplayFilterOptions | undefined;
display_properties: IIssueDisplayProperties; display_properties: IIssueDisplayProperties;
} }
export interface IssuePaginationOptions {
canGroup: boolean;
perPageCount: number;
before?: string;
after?: string;
groupedBy?: TIssueGroupByOptions;
}

View File

@ -7,7 +7,7 @@ type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none";
interface IPriorityIcon { interface IPriorityIcon {
className?: string; className?: string;
containerClassName?: string; containerClassName?: string;
priority: TIssuePriorities; priority: TIssuePriorities | undefined | null;
size?: number; size?: number;
withContainer?: boolean; withContainer?: boolean;
} }
@ -31,7 +31,7 @@ export const PriorityIcon: React.FC<IPriorityIcon> = (props) => {
low: SignalLow, low: SignalLow,
none: Ban, none: Ban,
}; };
const Icon = icons[priority]; const Icon = icons[priority ?? "none"];
if (!Icon) return null; if (!Icon) return null;
@ -41,7 +41,7 @@ export const PriorityIcon: React.FC<IPriorityIcon> = (props) => {
<div <div
className={cn( className={cn(
"flex items-center justify-center border rounded p-0.5 flex-shrink-0", "flex items-center justify-center border rounded p-0.5 flex-shrink-0",
priorityClasses[priority], priorityClasses[priority ?? "none"],
containerClassName containerClassName
)} )}
> >

View File

@ -12,7 +12,7 @@ type Story = StoryObj<typeof Sortable>;
const data = [ const data = [
{ id: "1", name: "John Doe" }, { id: "1", name: "John Doe" },
{ id: "2", name: "Jane Doe 2" }, { id: "2", name: "Satish" },
{ id: "3", name: "Alice" }, { id: "3", name: "Alice" },
{ id: "4", name: "Bob" }, { id: "4", name: "Bob" },
{ id: "5", name: "Charlie" }, { id: "5", name: "Charlie" },

View File

@ -1,11 +1,5 @@
import { TLogoProps } from "@plane/types"; import { TLogoProps } from "@plane/types";
export type TWorkspaceDetails = {
name: string;
slug: string;
id: string;
};
export type TViewDetails = { export type TViewDetails = {
list: boolean; list: boolean;
gantt: boolean; gantt: boolean;
@ -22,21 +16,3 @@ export type TProjectDetails = {
logo_props: TLogoProps; logo_props: TLogoProps;
description: string; description: string;
}; };
export type TProjectSettings = {
id: string;
anchor: string;
comments: boolean;
reactions: boolean;
votes: boolean;
inbox: unknown;
workspace: string;
workspace_detail: TWorkspaceDetails;
project: string;
project_details: TProjectDetails;
views: TViewDetails;
created_by: string;
updated_by: string;
created_at: string;
updated_at: string;
};

24
space/types/publish.d.ts vendored Normal file
View File

@ -0,0 +1,24 @@
import { IWorkspaceLite } from "@plane/types";
import { TProjectDetails, TViewDetails } from "@/types/project";
export type TPublishEntityType = "project";
export type TPublishSettings = {
anchor: string | undefined;
is_comments_enabled: boolean;
created_at: string | undefined;
created_by: string | undefined;
entity_identifier: string | undefined;
entity_name: TPublishEntityType | undefined;
id: string | undefined;
inbox: unknown;
project: string | undefined;
project_details: TProjectDetails | undefined;
is_reactions_enabled: boolean;
updated_at: string | undefined;
updated_by: string | undefined;
view_props: TViewDetails | undefined;
is_votes_enabled: boolean;
workspace: string | undefined;
workspace_detail: IWorkspaceLite | undefined;
};

View File

@ -0,0 +1,20 @@
"use client";
import { useParams } from "next/navigation";
// components
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
import UserProfileHeader from "../header";
import ProfileIssuesMobileHeader from "../mobile-header";
const ProfileHeader = () => {
const { profileViewId } = useParams();
return (
<AppHeaderWrapper
header={<UserProfileHeader type={profileViewId?.toString()} />}
mobileHeader={<ProfileIssuesMobileHeader />}
/>
);
};
export default ProfileHeader;

View File

@ -1,12 +0,0 @@
"use client";
// components
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
import UserProfileHeader from "../header";
import ProfileIssuesMobileHeader from "../mobile-header";
const ProfileAssignedHeader = () => (
<AppHeaderWrapper header={<UserProfileHeader type="Assigned" />} mobileHeader={<ProfileIssuesMobileHeader />} />
);
export default ProfileAssignedHeader;

View File

@ -1,12 +0,0 @@
"use client";
// components
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
import UserProfileHeader from "../header";
import ProfileIssuesMobileHeader from "../mobile-header";
const ProfileCreatedHeader = () => (
<AppHeaderWrapper header={<UserProfileHeader type="Created" />} mobileHeader={<ProfileIssuesMobileHeader />} />
);
export default ProfileCreatedHeader;

View File

@ -12,7 +12,13 @@ import { CustomMenu } from "@plane/ui";
// components // components
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
// constants // constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; import {
EIssueFilterType,
EIssueLayoutTypes,
EIssuesStoreType,
ISSUE_DISPLAY_FILTERS_BY_LAYOUT,
ISSUE_LAYOUTS,
} from "@/constants/issue";
// helpers // helpers
import { calculateTotalFilters } from "@/helpers/filter.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks // hooks
@ -42,7 +48,7 @@ const ProfileIssuesMobileHeader = observer(() => {
workspaceSlug.toString(), workspaceSlug.toString(),
undefined, undefined,
EIssueFilterType.DISPLAY_FILTERS, EIssueFilterType.DISPLAY_FILTERS,
{ layout: layout }, { layout: layout as EIssueLayoutTypes | undefined },
userId.toString() userId.toString()
); );
}, },

View File

@ -1,12 +0,0 @@
"use client";
// components
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
import UserProfileHeader from "../header";
import ProfileIssuesMobileHeader from "../mobile-header";
const ProfileSubscribedHeader = () => (
<AppHeaderWrapper header={<UserProfileHeader type="Subscribed" />} mobileHeader={<ProfileIssuesMobileHeader />} />
);
export default ProfileSubscribedHeader;

View File

@ -7,7 +7,7 @@ import { useParams, useRouter } from "next/navigation";
// icons // icons
import { ArrowRight, PanelRight } from "lucide-react"; import { ArrowRight, PanelRight } from "lucide-react";
// types // types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui // ui
import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui"; import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui";
// components // components
@ -15,7 +15,7 @@ import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink, Logo } from "@/components/common"; import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// constants // constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
@ -70,7 +70,7 @@ const CycleIssuesHeader: React.FC = observer(() => {
// store hooks // store hooks
const { const {
issuesFilter: { issueFilters, updateFilters }, issuesFilter: { issueFilters, updateFilters },
issues: { issuesCount }, issues: { getGroupIssueCount },
} = useIssues(EIssuesStoreType.CYCLE); } = useIssues(EIssuesStoreType.CYCLE);
const { currentProjectCycleIds, getCycleById } = useCycle(); const { currentProjectCycleIds, getCycleById } = useCycle();
const { toggleCreateIssueModal } = useCommandPalette(); const { toggleCreateIssueModal } = useCommandPalette();
@ -96,7 +96,7 @@ const CycleIssuesHeader: React.FC = observer(() => {
}; };
const handleLayoutChange = useCallback( const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => { (layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId); updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId);
}, },
@ -147,6 +147,7 @@ const CycleIssuesHeader: React.FC = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
const issuesCount = getGroupIssueCount(undefined, undefined, false);
return ( return (
<> <>
@ -231,7 +232,13 @@ const CycleIssuesHeader: React.FC = observer(() => {
</div> </div>
<div className="hidden items-center gap-2 md:flex "> <div className="hidden items-center gap-2 md:flex ">
<LayoutSelection <LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]} layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)} onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout} selectedLayout={activeLayout}
/> />

View File

@ -5,14 +5,14 @@ import { useParams } from "next/navigation";
// icons // icons
import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
// types // types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui // ui
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
// components // components
import { ProjectAnalyticsModal } from "@/components/analytics"; import { ProjectAnalyticsModal } from "@/components/analytics";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
// constants // constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue";
// helpers // helpers
import { calculateTotalFilters } from "@/helpers/filter.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks // hooks
@ -37,7 +37,7 @@ const CycleIssuesMobileHeader = () => {
const activeLayout = issueFilters?.displayFilters?.layout; const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback( const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => { (layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId || !cycleId) return; if (!workspaceSlug || !projectId || !cycleId) return;
updateFilters( updateFilters(
workspaceSlug.toString(), workspaceSlug.toString(),

View File

@ -4,14 +4,14 @@ import { FC, useCallback } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
// types // types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui // ui
import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
// components // components
import { BreadcrumbLink, Logo } from "@/components/common"; import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// constants // constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EIssueFilterType, EIssuesStoreType, EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
// helpers // helpers
import { calculateTotalFilters } from "@/helpers/filter.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks // hooks
@ -56,7 +56,7 @@ const ProjectDraftIssueHeader: FC = observer(() => {
); );
const handleLayoutChange = useCallback( const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => { (layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
}, },
@ -131,7 +131,7 @@ const ProjectDraftIssueHeader: FC = observer(() => {
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<LayoutSelection <LayoutSelection
layouts={["list", "kanban"]} layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN]}
onChange={(layout) => handleLayoutChange(layout)} onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout} selectedLayout={activeLayout}
/> />

View File

@ -6,7 +6,7 @@ import { useParams, useRouter } from "next/navigation";
// icons // icons
import { Briefcase, Circle, ExternalLink } from "lucide-react"; import { Briefcase, Circle, ExternalLink } from "lucide-react";
// types // types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui // ui
import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui"; import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui";
// components // components
@ -14,7 +14,7 @@ import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink, Logo } from "@/components/common"; import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// constants // constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EIssueFilterType, EIssuesStoreType, EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers // helpers
import { SPACE_BASE_URL } from "@/helpers/common.helper"; import { SPACE_BASE_URL } from "@/helpers/common.helper";
@ -44,7 +44,7 @@ const ProjectIssuesHeader: React.FC = observer(() => {
} = useMember(); } = useMember();
const { const {
issuesFilter: { issueFilters, updateFilters }, issuesFilter: { issueFilters, updateFilters },
issues: { issuesCount }, issues: { getGroupIssueCount },
} = useIssues(EIssuesStoreType.PROJECT); } = useIssues(EIssuesStoreType.PROJECT);
const { toggleCreateIssueModal } = useCommandPalette(); const { toggleCreateIssueModal } = useCommandPalette();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
@ -79,7 +79,7 @@ const ProjectIssuesHeader: React.FC = observer(() => {
); );
const handleLayoutChange = useCallback( const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => { (layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
}, },
@ -108,6 +108,7 @@ const ProjectIssuesHeader: React.FC = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
const issuesCount = getGroupIssueCount(undefined, undefined, false);
return ( return (
<> <>
@ -176,7 +177,12 @@ const ProjectIssuesHeader: React.FC = observer(() => {
</div> </div>
<div className="items-center gap-2 hidden md:flex"> <div className="items-center gap-2 hidden md:flex">
<LayoutSelection <LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]} layouts={[EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)} onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout} selectedLayout={activeLayout}
/> />

View File

@ -6,14 +6,14 @@ import { useParams } from "next/navigation";
// icons // icons
import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
// types // types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui // ui
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
// components // components
import { ProjectAnalyticsModal } from "@/components/analytics"; import { ProjectAnalyticsModal } from "@/components/analytics";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts"; import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts";
// constants // constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue";
// helpers // helpers
import { calculateTotalFilters } from "@/helpers/filter.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks // hooks
@ -44,7 +44,7 @@ const ProjectIssuesMobileHeader = observer(() => {
const activeLayout = issueFilters?.displayFilters?.layout; const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback( const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => { (layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
}, },

View File

@ -7,7 +7,7 @@ import { useParams, useRouter } from "next/navigation";
// icons // icons
import { ArrowRight, PanelRight } from "lucide-react"; import { ArrowRight, PanelRight } from "lucide-react";
// types // types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui // ui
import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip } from "@plane/ui"; import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip } from "@plane/ui";
// components // components
@ -15,7 +15,7 @@ import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink, Logo } from "@/components/common"; import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// constants // constants
import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EIssuesStoreType, EIssueFilterType, EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
@ -71,7 +71,7 @@ const ModuleIssuesHeader: React.FC = observer(() => {
// store hooks // store hooks
const { const {
issuesFilter: { issueFilters }, issuesFilter: { issueFilters },
issues: { issuesCount }, issues: { getGroupIssueCount },
} = useIssues(EIssuesStoreType.MODULE); } = useIssues(EIssuesStoreType.MODULE);
const { updateFilters } = useIssuesActions(EIssuesStoreType.MODULE); const { updateFilters } = useIssuesActions(EIssuesStoreType.MODULE);
const { projectModuleIds, getModuleById } = useModule(); const { projectModuleIds, getModuleById } = useModule();
@ -97,11 +97,11 @@ const ModuleIssuesHeader: React.FC = observer(() => {
const activeLayout = issueFilters?.displayFilters?.layout; const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback( const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => { (layout: EIssueLayoutTypes) => {
if (!projectId) return; if (!projectId) return;
updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
}, },
[projectId, moduleId, updateFilters] [projectId, updateFilters]
); );
const handleFiltersUpdate = useCallback( const handleFiltersUpdate = useCallback(
@ -122,7 +122,7 @@ const ModuleIssuesHeader: React.FC = observer(() => {
updateFilters(projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues }); updateFilters(projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues });
}, },
[projectId, moduleId, issueFilters, updateFilters] [projectId, issueFilters, updateFilters]
); );
const handleDisplayFilters = useCallback( const handleDisplayFilters = useCallback(
@ -130,7 +130,7 @@ const ModuleIssuesHeader: React.FC = observer(() => {
if (!projectId) return; if (!projectId) return;
updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter);
}, },
[projectId, moduleId, updateFilters] [projectId, updateFilters]
); );
const handleDisplayProperties = useCallback( const handleDisplayProperties = useCallback(
@ -138,7 +138,7 @@ const ModuleIssuesHeader: React.FC = observer(() => {
if (!projectId) return; if (!projectId) return;
updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property); updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property);
}, },
[projectId, moduleId, updateFilters] [projectId, updateFilters]
); );
// derived values // derived values
@ -147,6 +147,7 @@ const ModuleIssuesHeader: React.FC = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
const issuesCount = getGroupIssueCount(undefined, undefined, false);
return ( return (
<> <>
@ -231,7 +232,13 @@ const ModuleIssuesHeader: React.FC = observer(() => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="hidden gap-2 md:flex"> <div className="hidden gap-2 md:flex">
<LayoutSelection <LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]} layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)} onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout} selectedLayout={activeLayout}
/> />

View File

@ -6,14 +6,14 @@ import { useParams } from "next/navigation";
// icons // icons
import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
// types // types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui // ui
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
// components // components
import { ProjectAnalyticsModal } from "@/components/analytics"; import { ProjectAnalyticsModal } from "@/components/analytics";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
// constants // constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue";
// helpers // helpers
import { calculateTotalFilters } from "@/helpers/filter.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks // hooks
@ -46,7 +46,7 @@ const ModuleIssuesMobileHeader = observer(() => {
} = useMember(); } = useMember();
const handleLayoutChange = useCallback( const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => { (layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId); updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId);
}, },

View File

@ -4,14 +4,14 @@ import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
// types // types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui // ui
import { Breadcrumbs, Button, CustomMenu, PhotoFilterIcon } from "@plane/ui"; import { Breadcrumbs, Button, CustomMenu, PhotoFilterIcon } from "@plane/ui";
// components // components
import { BreadcrumbLink, Logo } from "@/components/common"; import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// constants // constants
import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EIssuesStoreType, EIssueFilterType, EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers // helpers
import { calculateTotalFilters } from "@/helpers/filter.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper";
@ -52,7 +52,7 @@ const ProjectViewIssuesHeader: React.FC = observer(() => {
const activeLayout = issueFilters?.displayFilters?.layout; const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback( const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => { (layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId || !viewId) return; if (!workspaceSlug || !projectId || !viewId) return;
updateFilters( updateFilters(
workspaceSlug.toString(), workspaceSlug.toString(),
@ -202,7 +202,13 @@ const ProjectViewIssuesHeader: React.FC = observer(() => {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LayoutSelection <LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]} layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)} onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout} selectedLayout={activeLayout}
/> />

View File

@ -0,0 +1,30 @@
"use client";
import React from "react";
import { useParams } from "next/navigation";
// components
import { PageHead } from "@/components/core";
import { ProfileIssuesPage } from "@/components/profile/profile-issues";
const ProfilePageHeader = {
assigned: "Profile - Assigned",
created: "Profile - Created",
subscribed: "Profile - Subscribed",
};
const ProfileIssuesTypePage = () => {
const { profileViewId } = useParams() as { profileViewId: "assigned" | "subscribed" | "created" | undefined };
if (!profileViewId) return null;
const header = ProfilePageHeader[profileViewId];
return (
<>
<PageHead title={header} />
<ProfileIssuesPage type={profileViewId} />
</>
);
};
export default ProfileIssuesTypePage;

View File

@ -1,15 +0,0 @@
"use client";
import React from "react";
// components
import { PageHead } from "@/components/core";
import { ProfileIssuesPage } from "@/components/profile/profile-issues";
const ProfileAssignedIssuesPage = () => (
<>
<PageHead title="Profile - Assigned" />
<ProfileIssuesPage type="assigned" />
</>
);
export default ProfileAssignedIssuesPage;

View File

@ -1,16 +0,0 @@
"use client";
// store
import { observer } from "mobx-react-lite";
// components
import { PageHead } from "@/components/core";
import { ProfileIssuesPage } from "@/components/profile/profile-issues";
const ProfileCreatedIssuesPage = () => (
<>
<PageHead title="Profile - Created" />
<ProfileIssuesPage type="created" />
</>
);
export default observer(ProfileCreatedIssuesPage);

View File

@ -17,7 +17,7 @@ export const ProfileNavbar: React.FC<Props> = (props) => {
const { isAuthorized, showProfileIssuesFilter } = props; const { isAuthorized, showProfileIssuesFilter } = props;
const { workspaceSlug, userId } = useParams(); const { workspaceSlug, userId } = useParams();
const pathname = usePathname(); const pathname = usePathname();
const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB;

View File

@ -1,16 +0,0 @@
"use client";
// store
import { observer } from "mobx-react-lite";
// components
import { PageHead } from "@/components/core";
import { ProfileIssuesPage } from "@/components/profile/profile-issues";
const ProfileSubscribedIssuesPage = () => (
<>
<PageHead title="Profile - Subscribed" />
<ProfileIssuesPage type="subscribed" />
</>
);
export default observer(ProfileSubscribedIssuesPage);

View File

@ -47,7 +47,7 @@ const ArchivedIssueDetailsPage = observer(() => {
// derived values // derived values
const issue = archivedIssueId ? getIssueById(archivedIssueId.toString()) : undefined; const issue = archivedIssueId ? getIssueById(archivedIssueId.toString()) : undefined;
const project = issue ? getProjectById(issue?.project_id) : undefined; const project = issue ? getProjectById(issue?.project_id ?? "") : undefined;
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
// auth // auth
const canRestoreIssue = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const canRestoreIssue = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;

View File

@ -1,7 +1,6 @@
"use client"; "use client";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components // components
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { AllIssueLayoutRoot } from "@/components/issues"; import { AllIssueLayoutRoot } from "@/components/issues";
@ -13,7 +12,8 @@ import { useGlobalView, useWorkspace } from "@/hooks/store";
const GlobalViewIssuesPage = observer(() => { const GlobalViewIssuesPage = observer(() => {
// router // router
const { globalViewId } = useParams(); //const { globalViewId } = useParams();
const globalViewId = "assigned";
// store hooks // store hooks
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { getViewDetailsById } = useGlobalView(); const { getViewDetailsById } = useGlobalView();

View File

@ -1,13 +1,18 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
// hooks // hooks
import { useProjectState } from "@/hooks/store"; import { ISearchIssueResponse } from "@plane/types";
export const BulkDeleteIssuesModalItem: React.FC<any> = observer((props) => { interface Props {
const { issue, delete_issue_ids, identifier } = props; issue: ISearchIssueResponse;
const { getStateById } = useProjectState(); canDeleteIssueIds: boolean;
identifier: string | undefined;
}
const color = getStateById(issue.state_id)?.color; export const BulkDeleteIssuesModalItem: React.FC<Props> = observer((props: Props) => {
const { issue, canDeleteIssueIds, identifier } = props;
const color = issue.state__color;
return ( return (
<Combobox.Option <Combobox.Option
@ -21,7 +26,7 @@ export const BulkDeleteIssuesModalItem: React.FC<any> = observer((props) => {
} }
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input type="checkbox" checked={delete_issue_ids} readOnly /> <input type="checkbox" checked={canDeleteIssueIds} readOnly />
<span <span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full" className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{ style={{

View File

@ -1,28 +1,28 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import useSWR from "swr";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { Combobox, Dialog, Transition } from "@headlessui/react"; import { Combobox, Dialog, Transition } from "@headlessui/react";
// services //plane
import { IUser, TIssue } from "@plane/types"; import { ISearchIssueResponse, IUser } from "@plane/types";
import { Button, TOAST_TYPE, setToast } from "@plane/ui"; import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui";
//components
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
//constants
import { EmptyStateType } from "@/constants/empty-state"; import { EmptyStateType } from "@/constants/empty-state";
import { PROJECT_ISSUES_LIST } from "@/constants/fetch-keys";
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
//hooks
import { useIssues, useProject } from "@/hooks/store"; import { useIssues, useProject } from "@/hooks/store";
import { IssueService } from "@/services/issue"; import useDebounce from "@/hooks/use-debounce";
// services
import { ProjectService } from "@/services/project";
// ui // ui
// icons // icons
// types
// store hooks
// components // components
import { BulkDeleteIssuesModalItem } from "./bulk-delete-issues-modal-item"; import { BulkDeleteIssuesModalItem } from "./bulk-delete-issues-modal-item";
// constants
type FormInput = { type FormInput = {
delete_issue_ids: string[]; delete_issue_ids: string[];
@ -34,7 +34,7 @@ type Props = {
user: IUser | undefined; user: IUser | undefined;
}; };
const issueService = new IssueService(); const projectService = new ProjectService();
export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => { export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose } = props; const { isOpen, onClose } = props;
@ -47,13 +47,23 @@ export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
} = useIssues(EIssuesStoreType.PROJECT); } = useIssues(EIssuesStoreType.PROJECT);
// states // states
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
// fetching project issues. const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
const { data: issues } = useSWR( const [isSearching, setIsSearching] = useState(false);
workspaceSlug && projectId && isOpen ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
workspaceSlug && projectId && isOpen const debouncedSearchTerm: string = useDebounce(query, 500);
? () => issueService.getIssues(workspaceSlug as string, projectId as string)
: null useEffect(() => {
); if (!isOpen || !workspaceSlug || !projectId) return;
setIsSearching(true);
projectService
.projectIssuesSearch(workspaceSlug.toString(), projectId.toString(), {
search: debouncedSearchTerm,
workspace_search: false,
})
.then((res: ISearchIssueResponse[]) => setIssues(res))
.finally(() => setIsSearching(false));
}, [debouncedSearchTerm, isOpen, projectId, workspaceSlug]);
const { const {
handleSubmit, handleSubmit,
@ -107,14 +117,33 @@ export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
const projectDetails = getProjectById(projectId as string); const projectDetails = getProjectById(projectId as string);
const filteredIssues: TIssue[] = const issueList =
query === "" issues.length > 0 ? (
? Object.values(issues ?? {}) <li className="p-2">
: Object.values(issues ?? {})?.filter( {query === "" && (
(issue) => <h2 className="mb-2 mt-4 px-3 text-xs font-semibold text-custom-text-100">Select issues to delete</h2>
issue.name.toLowerCase().includes(query.toLowerCase()) || )}
`${projectDetails?.identifier}-${issue.sequence_id}`.toLowerCase().includes(query.toLowerCase()) <ul className="text-sm text-custom-text-200">
) ?? []; {issues.map((issue) => (
<BulkDeleteIssuesModalItem
issue={issue}
identifier={projectDetails?.identifier}
canDeleteIssueIds={watch("delete_issue_ids").includes(issue.id)}
key={issue.id}
/>
))}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<EmptyState
type={
query === "" ? EmptyStateType.ISSUE_RELATION_EMPTY_STATE : EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE
}
layout="screen-simple"
/>
</div>
);
return ( return (
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear> <Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
@ -160,40 +189,20 @@ export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
static static
className="max-h-80 scroll-py-2 divide-y divide-custom-border-200 overflow-y-auto" className="max-h-80 scroll-py-2 divide-y divide-custom-border-200 overflow-y-auto"
> >
{filteredIssues.length > 0 ? ( {isSearching ? (
<li className="p-2"> <Loader className="space-y-3 p-3">
{query === "" && ( <Loader.Item height="40px" />
<h2 className="mb-2 mt-4 px-3 text-xs font-semibold text-custom-text-100"> <Loader.Item height="40px" />
Select issues to delete <Loader.Item height="40px" />
</h2> <Loader.Item height="40px" />
)} </Loader>
<ul className="text-sm text-custom-text-200">
{filteredIssues.map((issue) => (
<BulkDeleteIssuesModalItem
issue={issue}
identifier={projectDetails?.identifier}
delete_issue_ids={watch("delete_issue_ids").includes(issue.id)}
key={issue.id}
/>
))}
</ul>
</li>
) : ( ) : (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center"> <>{issueList}</>
<EmptyState
type={
query === ""
? EmptyStateType.ISSUE_RELATION_EMPTY_STATE
: EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE
}
layout="screen-simple"
/>
</div>
)} )}
</Combobox.Options> </Combobox.Options>
</Combobox> </Combobox>
{filteredIssues.length > 0 && ( {issues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3"> <div className="flex items-center justify-end gap-2 p-3">
<Button variant="neutral-primary" size="sm" onClick={handleClose}> <Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel Cancel

View File

@ -9,9 +9,7 @@ type Props = {
children: ReactNode; children: ReactNode;
as?: keyof JSX.IntrinsicElements; as?: keyof JSX.IntrinsicElements;
classNames?: string; classNames?: string;
alwaysRender?: boolean;
placeholderChildren?: ReactNode; placeholderChildren?: ReactNode;
pauseHeightUpdateWhileRendering?: boolean;
}; };
const RenderIfVisible: React.FC<Props> = (props) => { const RenderIfVisible: React.FC<Props> = (props) => {
@ -23,15 +21,13 @@ const RenderIfVisible: React.FC<Props> = (props) => {
as = "div", as = "div",
children, children,
classNames = "", classNames = "",
alwaysRender = false, //render the children even if it is not visible in root
placeholderChildren = null, //placeholder children placeholderChildren = null, //placeholder children
pauseHeightUpdateWhileRendering = false, //while this is true the height of the blocks are maintained
} = props; } = props;
const [shouldVisible, setShouldVisible] = useState<boolean>(alwaysRender); const [shouldVisible, setShouldVisible] = useState<boolean>();
const placeholderHeight = useRef<string>(defaultHeight); const placeholderHeight = useRef<string>(defaultHeight);
const intersectionRef = useRef<HTMLElement | null>(null); const intersectionRef = useRef<HTMLElement | null>(null);
const isVisible = alwaysRender || shouldVisible; const isVisible = shouldVisible;
// Set visibility with intersection observer // Set visibility with intersection observer
useEffect(() => { useEffect(() => {
@ -68,11 +64,10 @@ const RenderIfVisible: React.FC<Props> = (props) => {
if (intersectionRef.current && isVisible) { if (intersectionRef.current && isVisible) {
placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`; placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`;
} }
}, [isVisible, intersectionRef, alwaysRender, pauseHeightUpdateWhileRendering]); }, [isVisible, intersectionRef]);
const child = isVisible ? <>{children}</> : placeholderChildren; const child = isVisible ? <>{children}</> : placeholderChildren;
const style = const style = isVisible ? {} : { height: placeholderHeight.current, width: "100%" };
isVisible && !pauseHeightUpdateWhileRendering ? {} : { height: placeholderHeight.current, width: "100%" };
const className = isVisible ? classNames : cn(classNames, "bg-custom-background-80"); const className = isVisible ? classNames : cn(classNames, "bg-custom-background-80");
return React.createElement(as, { ref: intersectionRef, style, className }, child); return React.createElement(as, { ref: intersectionRef, style, className }, child);

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { FC, Fragment } from "react"; import { FC, Fragment, useCallback, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import useSWR from "swr"; import useSWR from "swr";
@ -8,7 +8,7 @@ import { CalendarCheck } from "lucide-react";
// headless ui // headless ui
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
// types // types
import { ICycle, TIssue } from "@plane/types"; import { ICycle } from "@plane/types";
// ui // ui
import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui"; import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui";
// components // components
@ -23,7 +23,8 @@ import { EIssuesStoreType } from "@/constants/issue";
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper"; import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper";
// hooks // hooks
import { useIssues, useProject } from "@/hooks/store"; import { useIssueDetail, useIssues, useProject } from "@/hooks/store";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import useLocalStorage from "@/hooks/use-local-storage"; import useLocalStorage from "@/hooks/use-local-storage";
export type ActiveCycleStatsProps = { export type ActiveCycleStatsProps = {
@ -37,6 +38,9 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees"); const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
const issuesContainerRef = useRef<HTMLDivElement | null>(null);
const [issuesLoaderElement, setIssueLoaderElement] = useState<HTMLDivElement | null>(null);
const currentValue = (tab: string | null) => { const currentValue = (tab: string | null) => {
switch (tab) { switch (tab) {
case "Priority-Issues": case "Priority-Issues":
@ -50,17 +54,29 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
} }
}; };
const { const {
issues: { fetchActiveCycleIssues }, issues: { getActiveCycleById, fetchActiveCycleIssues, fetchNextActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE); } = useIssues(EIssuesStoreType.CYCLE);
const {
issue: { getIssueById },
} = useIssueDetail();
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const { data: activeCycleIssues } = useSWR( useSWR(
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES_WITH_PARAMS(cycle.id, { priority: "urgent,high" }) : null, workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES_WITH_PARAMS(cycle.id, { priority: "urgent,high" }) : null,
workspaceSlug && projectId && cycle.id ? () => fetchActiveCycleIssues(workspaceSlug, projectId, cycle.id) : null workspaceSlug && projectId && cycle.id
? () => fetchActiveCycleIssues(workspaceSlug, projectId, 30, cycle.id)
: null,
{ revalidateIfStale: false, revalidateOnFocus: false }
); );
const cycleIssues = activeCycleIssues ?? []; const cycleIssueDetails = getActiveCycleById(cycle.id);
const loadMoreIssues = useCallback(() => {
fetchNextActiveCycleIssues(workspaceSlug, projectId, cycle.id);
}, [workspaceSlug, projectId, cycle.id, issuesLoaderElement, cycleIssueDetails?.nextPageResults]);
useIntersectionObserver(issuesContainerRef, issuesLoaderElement, loadMoreIssues, `0% 0% 100% 0%`);
return ( return (
<div className="flex flex-col gap-4 p-4 min-h-[17rem] overflow-hidden bg-custom-background-100 col-span-1 lg:col-span-2 xl:col-span-1 border border-custom-border-200 rounded-lg"> <div className="flex flex-col gap-4 p-4 min-h-[17rem] overflow-hidden bg-custom-background-100 col-span-1 lg:col-span-2 xl:col-span-1 border border-custom-border-200 rounded-lg">
@ -134,53 +150,75 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
as="div" as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm" className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
> >
<div className="flex flex-col gap-1 h-full w-full overflow-y-auto vertical-scrollbar scrollbar-sm"> <div
{cycleIssues ? ( ref={issuesContainerRef}
cycleIssues.length > 0 ? ( className="flex flex-col gap-1 h-full w-full overflow-y-auto vertical-scrollbar scrollbar-sm"
cycleIssues.map((issue: TIssue) => ( >
<Link {cycleIssueDetails && cycleIssueDetails.issueIds ? (
key={issue.id} cycleIssueDetails.issueCount > 0 ? (
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`} <>
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md hover:bg-custom-background-90 p-1" {cycleIssueDetails.issueIds.map((issueId: string) => {
> const issue = getIssueById(issueId);
<div className="flex items-center gap-1.5 flex-grow w-full min-w-24 truncate">
<PriorityIcon priority={issue.priority} withContainer size={12} />
<Tooltip if (!issue) return null;
tooltipHeading="Issue ID"
tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`} return (
<Link
key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md hover:bg-custom-background-90 p-1"
> >
<span className="flex-shrink-0 text-xs text-custom-text-200"> <div className="flex items-center gap-1.5 flex-grow w-full min-w-24 truncate">
{currentProjectDetails?.identifier}-{issue.sequence_id} <PriorityIcon priority={issue.priority} withContainer size={12} />
</span>
</Tooltip> <Tooltip
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}> tooltipHeading="Issue ID"
<span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span> tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`}
</Tooltip> >
</div> <span className="flex-shrink-0 text-xs text-custom-text-200">
<div className="flex items-center gap-1.5 flex-shrink-0"> {currentProjectDetails?.identifier}-{issue.sequence_id}
<StateDropdown
value={issue.state_id ?? undefined}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
disabled
buttonVariant="background-with-text"
buttonContainerClassName="cursor-pointer max-w-24"
showTooltip
/>
{issue.target_date && (
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
<div className="h-full flex truncate items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 group-hover:bg-custom-background-100 cursor-pointer">
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
<span className="text-xs truncate">
{renderFormattedDateWithoutYear(issue.target_date)}
</span> </span>
</div> </Tooltip>
</Tooltip> <Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
)} <span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span>
</div> </Tooltip>
</Link> </div>
)) <div className="flex items-center gap-1.5 flex-shrink-0">
<StateDropdown
value={issue.state_id}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
disabled
buttonVariant="background-with-text"
buttonContainerClassName="cursor-pointer max-w-24"
showTooltip
/>
{issue.target_date && (
<Tooltip
tooltipHeading="Target Date"
tooltipContent={renderFormattedDate(issue.target_date)}
>
<div className="h-full flex truncate items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 group-hover:bg-custom-background-100 cursor-pointer">
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
<span className="text-xs truncate">
{renderFormattedDateWithoutYear(issue.target_date)}
</span>
</div>
</Tooltip>
)}
</div>
</Link>
);
})}
{(cycleIssueDetails.nextPageResults === undefined || cycleIssueDetails.nextPageResults) && (
<div
ref={setIssueLoaderElement}
className={
"h-11 relative flex items-center gap-3 bg-custom-background-80 p-3 text-sm cursor-pointer animate-pulse"
}
/>
)}
</>
) : ( ) : (
<div className="flex items-center justify-center h-full w-full"> <div className="flex items-center justify-center h-full w-full">
<EmptyState <EmptyState

View File

@ -1,15 +1,15 @@
import { FC } from "react"; import { FC, useCallback } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { ICycle } from "@plane/types"; import { ICycle } from "@plane/types";
// hooks
import { CycleGanttBlock } from "@/components/cycles";
import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "@/components/gantt-chart";
import { getDate } from "@/helpers/date-time.helper";
import { useCycle } from "@/hooks/store";
// components // components
// types import { CycleGanttBlock } from "@/components/cycles";
// constants 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 = { type Props = {
workspaceSlug: string; workspaceSlug: string;
@ -23,6 +23,28 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
// store hooks // store hooks
const { getCycleById, updateCycleDetails } = useCycle(); 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) => { const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => {
if (!workspaceSlug || !cycle) return; if (!workspaceSlug || !cycle) return;
@ -32,28 +54,13 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
await updateCycleDetails(workspaceSlug.toString(), cycle.project_id, cycle.id, payload); await updateCycleDetails(workspaceSlug.toString(), cycle.project_id, cycle.id, payload);
}; };
const blockFormat = (blocks: (ICycle | null)[]) => {
if (!blocks) return [];
const filteredBlocks = blocks.filter((b) => b !== null && b.start_date && b.end_date);
const structuredBlocks = filteredBlocks.map((block) => ({
data: block,
id: block?.id ?? "",
sort_order: block?.sort_order ?? 0,
start_date: getDate(block?.start_date),
target_date: getDate(block?.end_date),
}));
return structuredBlocks;
};
return ( return (
<div className="h-full w-full overflow-y-auto"> <div className="h-full w-full overflow-y-auto">
<GanttChartRoot <GanttChartRoot
title="Cycles" title="Cycles"
loaderTitle="Cycles" loaderTitle="Cycles"
blocks={cycleIds ? blockFormat(cycleIds.map((c) => getCycleById(c))) : null} blockIds={cycleIds}
getBlockById={getBlockById}
blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)} blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)}
sidebarToRender={(props) => <CycleGanttSidebar {...props} />} sidebarToRender={(props) => <CycleGanttSidebar {...props} />}
blockToRender={(data: ICycle) => <CycleGanttBlock cycleId={data.id} />} blockToRender={(data: ICycle) => <CycleGanttBlock cycleId={data.id} />}

View File

@ -27,7 +27,7 @@ export const AssignedUpcomingIssueListItem: React.FC<IssueListItemProps> = obser
// derived values // derived values
const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined; const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined;
if (!issueDetails) return null; if (!issueDetails || !issueDetails.project_id) return null;
const projectDetails = getProjectById(issueDetails.project_id); const projectDetails = getProjectById(issueDetails.project_id);
@ -75,7 +75,7 @@ export const AssignedOverdueIssueListItem: React.FC<IssueListItemProps> = observ
// derived values // derived values
const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined; const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined;
if (!issueDetails) return null; if (!issueDetails || !issueDetails.project_id) return null;
const projectDetails = getProjectById(issueDetails.project_id); const projectDetails = getProjectById(issueDetails.project_id);
const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? []; const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? [];
@ -122,7 +122,7 @@ export const AssignedCompletedIssueListItem: React.FC<IssueListItemProps> = obse
// derived values // derived values
const issueDetails = getIssueById(issueId); const issueDetails = getIssueById(issueId);
if (!issueDetails) return null; if (!issueDetails || !issueDetails.project_id) return null;
const projectDetails = getProjectById(issueDetails.project_id); const projectDetails = getProjectById(issueDetails.project_id);
@ -154,7 +154,7 @@ export const CreatedUpcomingIssueListItem: React.FC<IssueListItemProps> = observ
// derived values // derived values
const issue = getIssueById(issueId); const issue = getIssueById(issueId);
if (!issue) return null; if (!issue || !issue.project_id) return null;
const projectDetails = getProjectById(issue.project_id); const projectDetails = getProjectById(issue.project_id);
const targetDate = getDate(issue.target_date); const targetDate = getDate(issue.target_date);
@ -205,7 +205,7 @@ export const CreatedOverdueIssueListItem: React.FC<IssueListItemProps> = observe
// derived values // derived values
const issue = getIssueById(issueId); const issue = getIssueById(issueId);
if (!issue) return null; if (!issue || !issue.project_id) return null;
const projectDetails = getProjectById(issue.project_id); const projectDetails = getProjectById(issue.project_id);
@ -257,7 +257,7 @@ export const CreatedCompletedIssueListItem: React.FC<IssueListItemProps> = obser
// derived values // derived values
const issue = getIssueById(issueId); const issue = getIssueById(issueId);
if (!issue) return null; if (!issue || !issue.project_id) return null;
const projectDetails = getProjectById(issue.project_id); const projectDetails = getProjectById(issue.project_id);

View File

@ -37,7 +37,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
const { setPeekIssue } = useIssueDetail(); const { setPeekIssue } = useIssueDetail();
const handleIssuePeekOverview = (issue: TIssue) => const handleIssuePeekOverview = (issue: TIssue) =>
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); issue.project_id && setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
const filterParams = getRedirectionFilters(tab); const filterParams = getRedirectionFilters(tab);

View File

@ -23,7 +23,7 @@ type Props = TDropdownProps & {
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
onChange: (val: string | null) => void; onChange: (val: string | null) => void;
onClose?: () => void; onClose?: () => void;
projectId: string; projectId: string | undefined;
value: string | null; value: string | null;
}; };
@ -127,7 +127,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
</button> </button>
)} )}
</Combobox.Button> </Combobox.Button>
{isOpen && ( {isOpen && projectId && (
<CycleOptions isOpen={isOpen} projectId={projectId} placement={placement} referenceElement={referenceElement} /> <CycleOptions isOpen={isOpen} projectId={projectId} placement={placement} referenceElement={referenceElement} />
)} )}
</Combobox> </Combobox>

View File

@ -25,8 +25,8 @@ type Props = TDropdownProps & {
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
onChange: (val: string | undefined) => void; onChange: (val: string | undefined) => void;
onClose?: () => void; onClose?: () => void;
projectId: string; projectId: string | undefined;
value: string | undefined; value: string | undefined | null;
}; };
type DropdownOptions = type DropdownOptions =
@ -120,7 +120,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
const selectedEstimate = value && estimatePointById ? estimatePointById(value) : undefined; const selectedEstimate = value && estimatePointById ? estimatePointById(value) : undefined;
const onOpen = async () => { const onOpen = async () => {
if (!currentActiveEstimateId && workspaceSlug) await getProjectEstimates(workspaceSlug, projectId); if (!currentActiveEstimateId && workspaceSlug && projectId) await getProjectEstimates(workspaceSlug, projectId);
}; };
const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({ const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({

View File

@ -24,7 +24,7 @@ type Props = TDropdownProps & {
button?: ReactNode; button?: ReactNode;
dropdownArrow?: boolean; dropdownArrow?: boolean;
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
projectId: string; projectId: string | undefined;
showCount?: boolean; showCount?: boolean;
onClose?: () => void; onClose?: () => void;
} & ( } & (
@ -272,7 +272,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
</button> </button>
)} )}
</Combobox.Button> </Combobox.Button>
{isOpen && ( {isOpen && projectId && (
<ModuleOptions <ModuleOptions
isOpen={isOpen} isOpen={isOpen}
projectId={projectId} projectId={projectId}

View File

@ -28,7 +28,7 @@ type Props = TDropdownProps & {
highlightUrgent?: boolean; highlightUrgent?: boolean;
onChange: (val: TIssuePriorities) => void; onChange: (val: TIssuePriorities) => void;
onClose?: () => void; onClose?: () => void;
value: TIssuePriorities | undefined; value: TIssuePriorities | undefined | null;
}; };
type ButtonProps = { type ButtonProps = {
@ -304,7 +304,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
placement, placement,
showTooltip = false, showTooltip = false,
tabIndex, tabIndex,
value, value = "none",
} = props; } = props;
// states // states
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@ -363,8 +363,8 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant) const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant)
? BorderButton ? BorderButton
: BACKGROUND_BUTTON_VARIANTS.includes(buttonVariant) : BACKGROUND_BUTTON_VARIANTS.includes(buttonVariant)
? BackgroundButton ? BackgroundButton
: TransparentButton; : TransparentButton;
return ( return (
<Combobox <Combobox
@ -408,7 +408,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
onClick={handleOnClick} onClick={handleOnClick}
> >
<ButtonToRender <ButtonToRender
priority={value} priority={value ?? undefined}
className={cn(buttonClassName, { className={cn(buttonClassName, {
"text-custom-text-200": resolvedTheme?.includes("dark") || resolvedTheme === "custom", "text-custom-text-200": resolvedTheme?.includes("dark") || resolvedTheme === "custom",
})} })}

View File

@ -25,9 +25,9 @@ type Props = TDropdownProps & {
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
onChange: (val: string) => void; onChange: (val: string) => void;
onClose?: () => void; onClose?: () => void;
projectId: string; projectId: string | undefined;
showDefaultState?: boolean; showDefaultState?: boolean;
value: string | undefined; value: string | undefined | null;
}; };
export const StateDropdown: React.FC<Props> = observer((props) => { export const StateDropdown: React.FC<Props> = observer((props) => {
@ -96,7 +96,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
const selectedState = stateValue ? getStateById(stateValue) : undefined; const selectedState = stateValue ? getStateById(stateValue) : undefined;
const onOpen = async () => { const onOpen = async () => {
if (!statesList && workspaceSlug) { if (!statesList && workspaceSlug && projectId) {
setStateLoader(true); setStateLoader(true);
await fetchProjectStates(workspaceSlug, projectId); await fetchProjectStates(workspaceSlug, projectId);
setStateLoader(false); setStateLoader(false);

View File

@ -10,11 +10,12 @@ import { BLOCK_HEIGHT } from "../constants";
// components // components
import { ChartAddBlock, ChartDraggable } from "../helpers"; import { ChartAddBlock, ChartDraggable } from "../helpers";
import { useGanttChart } from "../hooks"; import { useGanttChart } from "../hooks";
// types import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types";
import { IBlockUpdateData, IGanttBlock } from "../types";
type Props = { type Props = {
block: IGanttBlock; blockId: string;
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
showAllBlocks: boolean;
blockToRender: (data: any) => React.ReactNode; blockToRender: (data: any) => React.ReactNode;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableBlockLeftResize: boolean; enableBlockLeftResize: boolean;
@ -27,7 +28,9 @@ type Props = {
export const GanttChartBlock: React.FC<Props> = observer((props) => { export const GanttChartBlock: React.FC<Props> = observer((props) => {
const { const {
block, blockId,
getBlockById,
showAllBlocks,
blockToRender, blockToRender,
blockUpdateHandler, blockUpdateHandler,
enableBlockLeftResize, enableBlockLeftResize,
@ -38,9 +41,14 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
selectionHelpers, selectionHelpers,
} = props; } = props;
// store hooks // store hooks
const { updateActiveBlockId, isBlockActive } = useGanttChart(); const { currentViewData, updateActiveBlockId, isBlockActive } = useGanttChart();
const { getIsIssuePeeked } = useIssueDetail(); const { getIsIssuePeeked } = useIssueDetail();
const block = getBlockById(blockId, currentViewData);
// 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 isBlockVisibleOnChart = block.start_date && block.target_date;
const handleChartBlockPosition = ( const handleChartBlockPosition = (
@ -73,13 +81,14 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
}); });
}; };
if (!block.data) return null;
const isBlockSelected = selectionHelpers.getIsEntitySelected(block.id); const isBlockSelected = selectionHelpers.getIsEntitySelected(block.id);
const isBlockFocused = selectionHelpers.getIsEntityActive(block.id); const isBlockFocused = selectionHelpers.getIsEntityActive(block.id);
const isBlockHoveredOn = isBlockActive(block.id); const isBlockHoveredOn = isBlockActive(block.id);
return ( return (
<div <div
key={`block-${block.id}`}
className="relative min-w-full w-max" className="relative min-w-full w-max"
style={{ style={{
height: `${BLOCK_HEIGHT}px`, height: `${BLOCK_HEIGHT}px`,
@ -93,7 +102,7 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
"bg-custom-primary-100/10": isBlockSelected && isBlockHoveredOn, "bg-custom-primary-100/10": isBlockSelected && isBlockHoveredOn,
"border border-r-0 border-custom-border-400": isBlockFocused, "border border-r-0 border-custom-border-400": isBlockFocused,
})} })}
onMouseEnter={() => updateActiveBlockId(block.id)} onMouseEnter={() => updateActiveBlockId(blockId)}
onMouseLeave={() => updateActiveBlockId(null)} onMouseLeave={() => updateActiveBlockId(null)}
> >
{isBlockVisibleOnChart ? ( {isBlockVisibleOnChart ? (

View File

@ -4,13 +4,14 @@ import { TSelectionHelper } from "@/hooks/use-multiple-select";
// constants // constants
import { HEADER_HEIGHT } from "../constants"; import { HEADER_HEIGHT } from "../constants";
// types // types
import { IBlockUpdateData, IGanttBlock } from "../types"; import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types";
// components // components
import { GanttChartBlock } from "./block"; import { GanttChartBlock } from "./block";
export type GanttChartBlocksProps = { export type GanttChartBlocksProps = {
itemsContainerWidth: number; itemsContainerWidth: number;
blocks: IGanttBlock[] | null; blockIds: string[];
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
blockToRender: (data: any) => React.ReactNode; blockToRender: (data: any) => React.ReactNode;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableBlockLeftResize: boolean; enableBlockLeftResize: boolean;
@ -25,9 +26,10 @@ export type GanttChartBlocksProps = {
export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => { export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
const { const {
itemsContainerWidth, itemsContainerWidth,
blocks, blockIds,
blockToRender, blockToRender,
blockUpdateHandler, blockUpdateHandler,
getBlockById,
enableBlockLeftResize, enableBlockLeftResize,
enableBlockRightResize, enableBlockRightResize,
enableBlockMove, enableBlockMove,
@ -45,25 +47,22 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
transform: `translateY(${HEADER_HEIGHT}px)`, transform: `translateY(${HEADER_HEIGHT}px)`,
}} }}
> >
{blocks?.map((block) => { {blockIds?.map((blockId) => (
// hide the block if it doesn't have start and target dates and showAllBlocks is false <GanttChartBlock
if (!showAllBlocks && !(block.start_date && block.target_date)) return; key={blockId}
blockId={blockId}
return ( getBlockById={getBlockById}
<GanttChartBlock showAllBlocks={showAllBlocks}
key={block.id} blockToRender={blockToRender}
block={block} blockUpdateHandler={blockUpdateHandler}
blockToRender={blockToRender} enableBlockLeftResize={enableBlockLeftResize}
blockUpdateHandler={blockUpdateHandler} enableBlockRightResize={enableBlockRightResize}
enableBlockLeftResize={enableBlockLeftResize} enableBlockMove={enableBlockMove}
enableBlockRightResize={enableBlockRightResize} enableAddBlock={enableAddBlock}
enableBlockMove={enableBlockMove} ganttContainerRef={ganttContainerRef}
enableAddBlock={enableAddBlock} selectionHelpers={selectionHelpers}
ganttContainerRef={ganttContainerRef} />
selectionHelpers={selectionHelpers} ))}
/>
);
})}
</div> </div>
); );
}; };

View File

@ -6,11 +6,11 @@ import { VIEWS_LIST } from "@/components/gantt-chart/data";
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// types // types
import { useGanttChart } from "../hooks/use-gantt-chart"; import { useGanttChart } from "../hooks/use-gantt-chart";
import { IGanttBlock, TGanttViews } from "../types"; import { TGanttViews } from "../types";
// constants // constants
type Props = { type Props = {
blocks: IGanttBlock[] | null; blockIds: string[];
fullScreenMode: boolean; fullScreenMode: boolean;
handleChartView: (view: TGanttViews) => void; handleChartView: (view: TGanttViews) => void;
handleToday: () => void; handleToday: () => void;
@ -19,14 +19,16 @@ type Props = {
}; };
export const GanttChartHeader: React.FC<Props> = observer((props) => { export const GanttChartHeader: React.FC<Props> = observer((props) => {
const { blocks, fullScreenMode, handleChartView, handleToday, loaderTitle, toggleFullScreenMode } = props; const { blockIds, fullScreenMode, handleChartView, handleToday, loaderTitle, toggleFullScreenMode } = props;
// chart hook // chart hook
const { currentView } = useGanttChart(); const { currentView } = useGanttChart();
return ( return (
<div className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2"> <div className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2">
<div className="ml-auto"> <div className="ml-auto">
<div className="ml-auto text-sm font-medium">{blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}</div> <div className="ml-auto text-sm font-medium">
{blockIds ? `${blockIds.length} ${loaderTitle}` : "Loading..."}
</div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">

View File

@ -6,6 +6,7 @@ import { observer } from "mobx-react";
import { MultipleSelectGroup } from "@/components/core"; import { MultipleSelectGroup } from "@/components/core";
import { import {
BiWeekChartView, BiWeekChartView,
ChartDataType,
DayChartView, DayChartView,
GanttChartBlocksList, GanttChartBlocksList,
GanttChartSidebar, GanttChartSidebar,
@ -27,17 +28,19 @@ import { GANTT_SELECT_GROUP } from "../constants";
import { useGanttChart } from "../hooks/use-gantt-chart"; import { useGanttChart } from "../hooks/use-gantt-chart";
type Props = { type Props = {
blocks: IGanttBlock[] | null; blockIds: string[];
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
blockToRender: (data: any) => React.ReactNode; blockToRender: (data: any) => React.ReactNode;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
bottomSpacing: boolean; bottomSpacing: boolean;
chartBlocks: IGanttBlock[] | null;
enableBlockLeftResize: boolean; enableBlockLeftResize: boolean;
enableBlockMove: boolean; enableBlockMove: boolean;
enableBlockRightResize: boolean; enableBlockRightResize: boolean;
enableReorder: boolean; enableReorder: boolean;
enableAddBlock: boolean;
enableSelection: boolean; enableSelection: boolean;
enableAddBlock: boolean;
itemsContainerWidth: number; itemsContainerWidth: number;
showAllBlocks: boolean; showAllBlocks: boolean;
sidebarToRender: (props: any) => React.ReactNode; sidebarToRender: (props: any) => React.ReactNode;
@ -48,11 +51,12 @@ type Props = {
export const GanttChartMainContent: React.FC<Props> = observer((props) => { export const GanttChartMainContent: React.FC<Props> = observer((props) => {
const { const {
blocks, blockIds,
getBlockById,
loadMoreBlocks,
blockToRender, blockToRender,
blockUpdateHandler, blockUpdateHandler,
bottomSpacing, bottomSpacing,
chartBlocks,
enableBlockLeftResize, enableBlockLeftResize,
enableBlockMove, enableBlockMove,
enableBlockRightResize, enableBlockRightResize,
@ -63,6 +67,7 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
showAllBlocks, showAllBlocks,
sidebarToRender, sidebarToRender,
title, title,
canLoadMoreBlocks,
updateCurrentViewRenderPayload, updateCurrentViewRenderPayload,
quickAdd, quickAdd,
} = props; } = props;
@ -116,7 +121,7 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
<MultipleSelectGroup <MultipleSelectGroup
containerRef={ganttContainerRef} containerRef={ganttContainerRef}
entities={{ entities={{
[GANTT_SELECT_GROUP]: chartBlocks?.map((block) => block.id) ?? [], [GANTT_SELECT_GROUP]: blockIds ?? [],
}} }}
disabled disabled
> >
@ -135,33 +140,38 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
onScroll={onScroll} onScroll={onScroll}
> >
<GanttChartSidebar <GanttChartSidebar
blocks={blocks} blockIds={blockIds}
blockUpdateHandler={blockUpdateHandler} getBlockById={getBlockById}
enableReorder={enableReorder} loadMoreBlocks={loadMoreBlocks}
enableSelection={enableSelection} canLoadMoreBlocks={canLoadMoreBlocks}
sidebarToRender={sidebarToRender} ganttContainerRef={ganttContainerRef}
title={title} blockUpdateHandler={blockUpdateHandler}
quickAdd={quickAdd} enableReorder={enableReorder}
selectionHelpers={helpers} enableSelection={enableSelection}
/> sidebarToRender={sidebarToRender}
<div className="relative min-h-full h-max flex-shrink-0 flex-grow"> title={title}
<ActiveChartView /> quickAdd={quickAdd}
{currentViewData && ( selectionHelpers={helpers}
<GanttChartBlocksList />
itemsContainerWidth={itemsContainerWidth} <div className="relative min-h-full h-max flex-shrink-0 flex-grow">
blocks={chartBlocks} <ActiveChartView />
blockToRender={blockToRender} {currentViewData && (
blockUpdateHandler={blockUpdateHandler} <GanttChartBlocksList
enableBlockLeftResize={enableBlockLeftResize} itemsContainerWidth={itemsContainerWidth}
enableBlockRightResize={enableBlockRightResize} blockIds={blockIds}
enableBlockMove={enableBlockMove} getBlockById={getBlockById}
enableAddBlock={enableAddBlock} blockToRender={blockToRender}
ganttContainerRef={ganttContainerRef} blockUpdateHandler={blockUpdateHandler}
showAllBlocks={showAllBlocks} enableBlockLeftResize={enableBlockLeftResize}
selectionHelpers={helpers} enableBlockRightResize={enableBlockRightResize}
/> enableBlockMove={enableBlockMove}
)} enableAddBlock={enableAddBlock}
</div> ganttContainerRef={ganttContainerRef}
showAllBlocks={showAllBlocks}
selectionHelpers={helpers}
/>
)}
</div>
</div> </div>
<IssueBulkOperationsRoot selectionHelpers={helpers} /> <IssueBulkOperationsRoot selectionHelpers={helpers} />
</> </>

View File

@ -13,17 +13,13 @@ import { currentViewDataWithView } from "../data";
// constants // constants
import { useGanttChart } from "../hooks/use-gantt-chart"; import { useGanttChart } from "../hooks/use-gantt-chart";
import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types"; import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types";
import { import { generateMonthChart, getNumberOfDaysBetweenTwoDatesInMonth } from "../views";
generateMonthChart,
getNumberOfDaysBetweenTwoDatesInMonth,
getMonthChartItemPositionWidthInMonth,
} from "../views";
type ChartViewRootProps = { type ChartViewRootProps = {
border: boolean; border: boolean;
title: string; title: string;
loaderTitle: string; loaderTitle: string;
blocks: IGanttBlock[] | null; blockIds: string[];
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blockToRender: (data: any) => React.ReactNode; blockToRender: (data: any) => React.ReactNode;
sidebarToRender: (props: any) => React.ReactNode; sidebarToRender: (props: any) => React.ReactNode;
@ -35,6 +31,9 @@ type ChartViewRootProps = {
enableSelection: boolean; enableSelection: boolean;
bottomSpacing: boolean; bottomSpacing: boolean;
showAllBlocks: boolean; showAllBlocks: boolean;
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
loadMoreBlocks?: () => void;
canLoadMoreBlocks?: boolean;
quickAdd?: React.JSX.Element | undefined; quickAdd?: React.JSX.Element | undefined;
}; };
@ -42,11 +41,14 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
const { const {
border, border,
title, title,
blocks = null, blockIds,
getBlockById,
loadMoreBlocks,
loaderTitle, loaderTitle,
blockUpdateHandler, blockUpdateHandler,
sidebarToRender, sidebarToRender,
blockToRender, blockToRender,
canLoadMoreBlocks,
enableBlockLeftResize, enableBlockLeftResize,
enableBlockRightResize, enableBlockRightResize,
enableBlockMove, enableBlockMove,
@ -60,25 +62,10 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
// states // states
const [itemsContainerWidth, setItemsContainerWidth] = useState(0); const [itemsContainerWidth, setItemsContainerWidth] = useState(0);
const [fullScreenMode, setFullScreenMode] = useState(false); const [fullScreenMode, setFullScreenMode] = useState(false);
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null);
// hooks // hooks
const { currentView, currentViewData, renderView, updateCurrentView, updateCurrentViewData, updateRenderView } = const { currentView, currentViewData, renderView, updateCurrentView, updateCurrentViewData, updateRenderView } =
useGanttChart(); useGanttChart();
// rendering the block structure
const renderBlockStructure = (view: ChartDataType, blocks: IGanttBlock[] | null) =>
blocks
? blocks.map((block: IGanttBlock) => ({
...block,
position: getMonthChartItemPositionWidthInMonth(view, block),
}))
: [];
useEffect(() => {
if (!currentViewData || !blocks) return;
setChartBlocks(() => renderBlockStructure(currentViewData, blocks));
}, [currentViewData, blocks]);
const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews) => { const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews) => {
const selectedCurrentView: TGanttViews = view; const selectedCurrentView: TGanttViews = view;
const selectedCurrentViewData: ChartDataType | undefined = const selectedCurrentViewData: ChartDataType | undefined =
@ -168,7 +155,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
})} })}
> >
<GanttChartHeader <GanttChartHeader
blocks={blocks} blockIds={blockIds}
fullScreenMode={fullScreenMode} fullScreenMode={fullScreenMode}
toggleFullScreenMode={() => setFullScreenMode((prevData) => !prevData)} toggleFullScreenMode={() => setFullScreenMode((prevData) => !prevData)}
handleChartView={(key) => updateCurrentViewRenderPayload(null, key)} handleChartView={(key) => updateCurrentViewRenderPayload(null, key)}
@ -176,17 +163,19 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
loaderTitle={loaderTitle} loaderTitle={loaderTitle}
/> />
<GanttChartMainContent <GanttChartMainContent
blocks={blocks} blockIds={blockIds}
getBlockById={getBlockById}
loadMoreBlocks={loadMoreBlocks}
canLoadMoreBlocks={canLoadMoreBlocks}
blockToRender={blockToRender} blockToRender={blockToRender}
blockUpdateHandler={blockUpdateHandler} blockUpdateHandler={blockUpdateHandler}
bottomSpacing={bottomSpacing} bottomSpacing={bottomSpacing}
chartBlocks={chartBlocks}
enableBlockLeftResize={enableBlockLeftResize} enableBlockLeftResize={enableBlockLeftResize}
enableBlockMove={enableBlockMove} enableBlockMove={enableBlockMove}
enableBlockRightResize={enableBlockRightResize} enableBlockRightResize={enableBlockRightResize}
enableReorder={enableReorder} enableReorder={enableReorder}
enableAddBlock={enableAddBlock}
enableSelection={enableSelection} enableSelection={enableSelection}
enableAddBlock={enableAddBlock}
itemsContainerWidth={itemsContainerWidth} itemsContainerWidth={itemsContainerWidth}
showAllBlocks={showAllBlocks} showAllBlocks={showAllBlocks}
sidebarToRender={sidebarToRender} sidebarToRender={sidebarToRender}

View File

@ -1,6 +1,6 @@
import { FC } from "react"; import { FC } from "react";
// components // components
import { ChartViewRoot, IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart"; import { ChartDataType, ChartViewRoot, IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart";
// context // context
import { GanttStoreProvider } from "@/components/gantt-chart/contexts"; import { GanttStoreProvider } from "@/components/gantt-chart/contexts";
@ -8,11 +8,14 @@ type GanttChartRootProps = {
border?: boolean; border?: boolean;
title: string; title: string;
loaderTitle: string; loaderTitle: string;
blocks: IGanttBlock[] | null; blockIds: string[];
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blockToRender: (data: any) => React.ReactNode; blockToRender: (data: any) => React.ReactNode;
sidebarToRender: (props: any) => React.ReactNode; sidebarToRender: (props: any) => React.ReactNode;
quickAdd?: React.JSX.Element | undefined; quickAdd?: React.JSX.Element | undefined;
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
enableBlockLeftResize?: boolean; enableBlockLeftResize?: boolean;
enableBlockRightResize?: boolean; enableBlockRightResize?: boolean;
enableBlockMove?: boolean; enableBlockMove?: boolean;
@ -27,11 +30,14 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
const { const {
border = true, border = true,
title, title,
blocks, blockIds,
loaderTitle = "blocks", loaderTitle = "blocks",
blockUpdateHandler, blockUpdateHandler,
sidebarToRender, sidebarToRender,
blockToRender, blockToRender,
getBlockById,
loadMoreBlocks,
canLoadMoreBlocks,
enableBlockLeftResize = false, enableBlockLeftResize = false,
enableBlockRightResize = false, enableBlockRightResize = false,
enableBlockMove = false, enableBlockMove = false,
@ -48,7 +54,10 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
<ChartViewRoot <ChartViewRoot
border={border} border={border}
title={title} title={title}
blocks={blocks} blockIds={blockIds}
getBlockById={getBlockById}
loadMoreBlocks={loadMoreBlocks}
canLoadMoreBlocks={canLoadMoreBlocks}
loaderTitle={loaderTitle} loaderTitle={loaderTitle}
blockUpdateHandler={blockUpdateHandler} blockUpdateHandler={blockUpdateHandler}
sidebarToRender={sidebarToRender} sidebarToRender={sidebarToRender}

View File

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

View File

@ -33,6 +33,8 @@ export const IssuesSidebarBlock = observer((props: Props) => {
const duration = findTotalDaysInRange(block.start_date, block.target_date); const duration = findTotalDaysInRange(block.start_date, block.target_date);
if (!block.data) return null;
const isIssueSelected = selectionHelpers?.getIsEntitySelected(block.id); const isIssueSelected = selectionHelpers?.getIsEntitySelected(block.id);
const isIssueFocused = selectionHelpers?.getIsEntityActive(block.id); const isIssueFocused = selectionHelpers?.getIsEntityActive(block.id);
const isBlockHoveredOn = isBlockActive(block.id); const isBlockHoveredOn = isBlockActive(block.id);

View File

@ -1,11 +1,13 @@
"use client"; "use client";
import { MutableRefObject } from "react"; import { RefObject, MutableRefObject, useState } from "react";
import { observer } from "mobx-react";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
// components // components
import { IGanttBlock, IBlockUpdateData } from "@/components/gantt-chart/types"; import { IGanttBlock, IBlockUpdateData } from "@/components/gantt-chart/types";
// hooks //hooks
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { TSelectionHelper } from "@/hooks/use-multiple-select"; import { TSelectionHelper } from "@/hooks/use-multiple-select";
import { GanttDnDHOC } from "../gantt-dnd-HOC"; import { GanttDnDHOC } from "../gantt-dnd-HOC";
import { handleOrderChange } from "../utils"; import { handleOrderChange } from "../utils";
@ -14,54 +16,81 @@ import { IssuesSidebarBlock } from "./block";
type Props = { type Props = {
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null; getBlockById: (id: string) => IGanttBlock;
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
ganttContainerRef: RefObject<HTMLDivElement>;
blockIds: string[];
enableReorder: boolean; enableReorder: boolean;
enableSelection: boolean; enableSelection: boolean;
showAllBlocks?: boolean; showAllBlocks?: boolean;
selectionHelpers?: TSelectionHelper; selectionHelpers?: TSelectionHelper;
}; };
export const IssueGanttSidebar: React.FC<Props> = (props) => { export const IssueGanttSidebar: React.FC<Props> = observer((props) => {
const { blockUpdateHandler, blocks, enableReorder, enableSelection, showAllBlocks = false, selectionHelpers } = props; const {
blockUpdateHandler,
blockIds,
getBlockById,
enableReorder,
enableSelection,
loadMoreBlocks,
canLoadMoreBlocks,
ganttContainerRef,
showAllBlocks = false,
selectionHelpers
} = props;
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
useIntersectionObserver(ganttContainerRef, intersectionElement, loadMoreBlocks, "50% 0% 50% 0%");
const handleOnDrop = ( const handleOnDrop = (
draggingBlockId: string | undefined, draggingBlockId: string | undefined,
droppedBlockId: string | undefined, droppedBlockId: string | undefined,
dropAtEndOfList: boolean dropAtEndOfList: boolean
) => { ) => {
handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blocks, blockUpdateHandler); handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blockIds, getBlockById, blockUpdateHandler);
}; };
return ( return (
<div> <div>
{blocks ? ( {blockIds ? (
blocks.map((block, index) => { <>
const isBlockVisibleOnSidebar = block.start_date && block.target_date; {blockIds.map((blockId, index) => {
const block = getBlockById(blockId);
const isBlockVisibleOnSidebar = block?.start_date && block?.target_date;
// hide the block if it doesn't have start and target dates and showAllBlocks is false // hide the block if it doesn't have start and target dates and showAllBlocks is false
if (!showAllBlocks && !isBlockVisibleOnSidebar) return; if (!block || (!showAllBlocks && !isBlockVisibleOnSidebar)) return;
return ( return (
<GanttDnDHOC <GanttDnDHOC
key={block.id} key={block.id}
id={block.id} id={block.id}
isLastChild={index === blocks.length - 1} isLastChild={index === blockIds.length - 1}
isDragEnabled={enableReorder} isDragEnabled={enableReorder}
onDrop={handleOnDrop} onDrop={handleOnDrop}
> >
{(isDragging: boolean, dragHandleRef: MutableRefObject<HTMLButtonElement | null>) => ( {(isDragging: boolean, dragHandleRef: MutableRefObject<HTMLButtonElement | null>) => (
<IssuesSidebarBlock <IssuesSidebarBlock
block={block} block={block}
enableReorder={enableReorder} enableReorder={enableReorder}
enableSelection={enableSelection} enableSelection={enableSelection}
isDragging={isDragging} isDragging={isDragging}
dragHandleRef={dragHandleRef} dragHandleRef={dragHandleRef}
selectionHelpers={selectionHelpers} selectionHelpers={selectionHelpers}
/> />
)} )}
</GanttDnDHOC> </GanttDnDHOC>
); );
}) })}
{canLoadMoreBlocks && (
<div ref={setIntersectionElement} className="p-2">
<div className="flex h-10 md:h-8 w-full items-center justify-between gap-1.5 rounded md:px-1 px-4 py-1.5 bg-custom-background-80 animate-pulse" />
</div>
)}
</>
) : ( ) : (
<Loader className="space-y-3 pr-2"> <Loader className="space-y-3 pr-2">
<Loader.Item height="34px" /> <Loader.Item height="34px" />
@ -72,4 +101,4 @@ export const IssueGanttSidebar: React.FC<Props> = (props) => {
)} )}
</div> </div>
); );
}; });

View File

@ -4,7 +4,7 @@ import { MutableRefObject } from "react";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
// components // components
import { IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart"; import { ChartDataType, IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
import { GanttDnDHOC } from "../gantt-dnd-HOC"; import { GanttDnDHOC } from "../gantt-dnd-HOC";
import { handleOrderChange } from "../utils"; import { handleOrderChange } from "../utils";
import { ModulesSidebarBlock } from "./block"; import { ModulesSidebarBlock } from "./block";
@ -13,29 +13,32 @@ import { ModulesSidebarBlock } from "./block";
type Props = { type Props = {
title: string; title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null; getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
blockIds: string[];
enableReorder: boolean; enableReorder: boolean;
}; };
export const ModuleGanttSidebar: React.FC<Props> = (props) => { export const ModuleGanttSidebar: React.FC<Props> = (props) => {
const { blockUpdateHandler, blocks, enableReorder } = props; const { blockUpdateHandler, blockIds, getBlockById, enableReorder } = props;
const handleOnDrop = ( const handleOnDrop = (
draggingBlockId: string | undefined, draggingBlockId: string | undefined,
droppedBlockId: string | undefined, droppedBlockId: string | undefined,
dropAtEndOfList: boolean dropAtEndOfList: boolean
) => { ) => {
handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blocks, blockUpdateHandler); handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blockIds, getBlockById, blockUpdateHandler);
}; };
return ( return (
<div className="h-full"> <div className="h-full">
{blocks ? ( {blockIds ? (
blocks.map((block, index) => ( blockIds.map((blockId, index) => {
const block = getBlockById(blockId);
return (
<GanttDnDHOC <GanttDnDHOC
key={block.id} key={block.id}
id={block.id} id={block.id}
isLastChild={index === blocks.length - 1} isLastChild={index === blockIds.length - 1}
isDragEnabled={enableReorder} isDragEnabled={enableReorder}
onDrop={handleOnDrop} onDrop={handleOnDrop}
> >
@ -48,7 +51,7 @@ export const ModuleGanttSidebar: React.FC<Props> = (props) => {
/> />
)} )}
</GanttDnDHOC> </GanttDnDHOC>
)) )})
) : ( ) : (
<Loader className="space-y-3 pr-2"> <Loader className="space-y-3 pr-2">
<Loader.Item height="34px" /> <Loader.Item height="34px" />

View File

@ -1,7 +1,8 @@
import { RefObject } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// components // components
import { MultipleSelectGroupAction } from "@/components/core"; import { MultipleSelectGroupAction } from "@/components/core";
import { IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart"; import { ChartDataType, IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks // hooks
@ -10,23 +11,31 @@ import { TSelectionHelper } from "@/hooks/use-multiple-select";
import { GANTT_SELECT_GROUP, HEADER_HEIGHT, SIDEBAR_WIDTH } from "../constants"; import { GANTT_SELECT_GROUP, HEADER_HEIGHT, SIDEBAR_WIDTH } from "../constants";
type Props = { type Props = {
blocks: IGanttBlock[] | null; blockIds: string[];
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
ganttContainerRef: RefObject<HTMLDivElement>;
enableReorder: boolean; enableReorder: boolean;
enableSelection: boolean; enableSelection: boolean;
sidebarToRender: (props: any) => React.ReactNode; sidebarToRender: (props: any) => React.ReactNode;
title: string; title: string;
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
quickAdd?: React.JSX.Element | undefined; quickAdd?: React.JSX.Element | undefined;
selectionHelpers: TSelectionHelper; selectionHelpers: TSelectionHelper;
}; };
export const GanttChartSidebar: React.FC<Props> = observer((props) => { export const GanttChartSidebar: React.FC<Props> = observer((props) => {
const { const {
blocks, blockIds,
blockUpdateHandler, blockUpdateHandler,
enableReorder, enableReorder,
enableSelection, enableSelection,
sidebarToRender, sidebarToRender,
getBlockById,
loadMoreBlocks,
canLoadMoreBlocks,
ganttContainerRef,
title, title,
quickAdd, quickAdd,
selectionHelpers, selectionHelpers,
@ -74,7 +83,19 @@ export const GanttChartSidebar: React.FC<Props> = observer((props) => {
</div> </div>
<div className="min-h-full h-max bg-custom-background-100 overflow-hidden"> <div className="min-h-full h-max bg-custom-background-100 overflow-hidden">
{sidebarToRender?.({ title, blockUpdateHandler, blocks, enableReorder, enableSelection, selectionHelpers })} {sidebarToRender &&
sidebarToRender({
title,
blockUpdateHandler,
blockIds,
getBlockById,
enableReorder,
enableSelection,
canLoadMoreBlocks,
ganttContainerRef,
loadMoreBlocks,
selectionHelpers
})}
</div> </div>
{quickAdd ? quickAdd : null} {quickAdd ? quickAdd : null}
</div> </div>

View File

@ -1,38 +1,38 @@
import { IBlockUpdateData, IGanttBlock } from "../types"; import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types";
export const handleOrderChange = ( export const handleOrderChange = (
draggingBlockId: string | undefined, draggingBlockId: string | undefined,
droppedBlockId: string | undefined, droppedBlockId: string | undefined,
dropAtEndOfList: boolean, dropAtEndOfList: boolean,
blocks: IGanttBlock[] | null, blockIds: string[] | null,
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock,
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void
) => { ) => {
if (!blocks || !draggingBlockId || !droppedBlockId) return; if (!blockIds || !draggingBlockId || !droppedBlockId) return;
const sourceBlockIndex = blocks.findIndex((block) => block.id === draggingBlockId); const sourceBlockIndex = blockIds.findIndex((id) => id === draggingBlockId);
const destinationBlockIndex = dropAtEndOfList const destinationBlockIndex = dropAtEndOfList ? blockIds.length : blockIds.findIndex((id) => id === droppedBlockId);
? blocks.length
: blocks.findIndex((block) => block.id === droppedBlockId);
// return if dropped outside the list // return if dropped outside the list
if (sourceBlockIndex === -1 || destinationBlockIndex === -1 || sourceBlockIndex === destinationBlockIndex) return; if (sourceBlockIndex === -1 || destinationBlockIndex === -1 || sourceBlockIndex === destinationBlockIndex) return;
let updatedSortOrder = blocks[sourceBlockIndex].sort_order; let updatedSortOrder = getBlockById(blockIds[sourceBlockIndex])?.sort_order;
// update the sort order to the lowest if dropped at the top // update the sort order to the lowest if dropped at the top
if (destinationBlockIndex === 0) updatedSortOrder = blocks[0].sort_order - 1000; if (destinationBlockIndex === 0) updatedSortOrder = getBlockById(blockIds[0])?.sort_order - 1000;
// update the sort order to the highest if dropped at the bottom // update the sort order to the highest if dropped at the bottom
else if (destinationBlockIndex === blocks.length) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; else if (destinationBlockIndex === blockIds.length)
updatedSortOrder = getBlockById(blockIds[blockIds.length - 1])?.sort_order + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between // update the sort order to the average of the two adjacent blocks if dropped in between
else { else {
const destinationSortingOrder = blocks[destinationBlockIndex].sort_order; const destinationSortingOrder = getBlockById(blockIds[destinationBlockIndex])?.sort_order;
const relativeDestinationSortingOrder = blocks[destinationBlockIndex - 1].sort_order; const relativeDestinationSortingOrder = getBlockById(blockIds[destinationBlockIndex - 1])?.sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
} }
// call the block update handler with the updated sort order, new and old index // call the block update handler with the updated sort order, new and old index
blockUpdateHandler(blocks[sourceBlockIndex].data, { blockUpdateHandler(getBlockById(blockIds[sourceBlockIndex])?.data, {
sort_order: { sort_order: {
destinationIndex: destinationBlockIndex, destinationIndex: destinationBlockIndex,
newSortOrder: updatedSortOrder, newSortOrder: updatedSortOrder,

View File

@ -98,7 +98,7 @@ export const InboxIssueContentProperties: React.FC<Props> = observer((props) =>
<span>Priority</span> <span>Priority</span>
</div> </div>
<PriorityDropdown <PriorityDropdown
value={issue?.priority || "none"} value={issue?.priority}
onChange={(val) => onChange={(val) =>
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { priority: val }) issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { priority: val })
} }

View File

@ -49,7 +49,7 @@ export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props)
{/* state */} {/* state */}
<div className="h-7"> <div className="h-7">
<StateDropdown <StateDropdown
value={data?.state_id || ""} value={data?.state_id}
onChange={(stateId) => handleData("state_id", stateId)} onChange={(stateId) => handleData("state_id", stateId)}
projectId={projectId} projectId={projectId}
buttonVariant="border-with-text" buttonVariant="border-with-text"
@ -59,7 +59,7 @@ export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props)
{/* priority */} {/* priority */}
<div className="h-7"> <div className="h-7">
<PriorityDropdown <PriorityDropdown
value={data?.priority || "none"} value={data?.priority}
onChange={(priority) => handleData("priority", priority)} onChange={(priority) => handleData("priority", priority)}
buttonVariant="border-with-text" buttonVariant="border-with-text"
/> />

View File

@ -1,22 +1,23 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import useSWR from "swr";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { Combobox, Dialog, Transition } from "@headlessui/react"; import { Combobox, Dialog, Transition } from "@headlessui/react";
// hooks
// icons // icons
// components // components
// types
import { ISearchIssueResponse } from "@plane/types";
// ui // ui
import { TOAST_TYPE, setToast } from "@plane/ui"; import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
// services
// constants // constants
import { EmptyStateType } from "@/constants/empty-state"; import { EmptyStateType } from "@/constants/empty-state";
import { PROJECT_ISSUES_LIST } from "@/constants/fetch-keys"; // hooks
import { useProject, useProjectState } from "@/hooks/store"; import { useProject } from "@/hooks/store";
import { IssueService } from "@/services/issue"; import useDebounce from "@/hooks/use-debounce";
// services
import { ProjectService } from "@/services/project";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -25,7 +26,7 @@ type Props = {
onSubmit: (issueId: string) => void; onSubmit: (issueId: string) => void;
}; };
const issueService = new IssueService(); const projectService = new ProjectService();
export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => { export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
const { isOpen, onClose, onSubmit, value } = props; const { isOpen, onClose, onSubmit, value } = props;
@ -35,18 +36,27 @@ export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId } = useParams(); const { workspaceSlug, projectId, issueId } = useParams();
// hooks // hooks
const { getProjectStates } = useProjectState();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { data: issues } = useSWR( const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null, const [isSearching, setIsSearching] = useState(false);
workspaceSlug && projectId
? () => const debouncedSearchTerm: string = useDebounce(query, 500);
issueService
.getIssues(workspaceSlug as string, projectId as string) useEffect(() => {
.then((res) => Object.values(res ?? {}).filter((issue) => issue.id !== issueId)) if (!isOpen || !workspaceSlug || !projectId) return;
: null
); setIsSearching(true);
projectService
.projectIssuesSearch(workspaceSlug.toString(), projectId.toString(), {
search: debouncedSearchTerm,
workspace_search: false,
})
.then((res: ISearchIssueResponse[]) => setIssues(res))
.finally(() => setIsSearching(false));
}, [debouncedSearchTerm, isOpen, projectId, workspaceSlug]);
const filteredIssues = issues.filter((issue) => issue.id !== issueId);
const handleClose = () => { const handleClose = () => {
onClose(); onClose();
@ -62,7 +72,52 @@ export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
handleClose(); handleClose();
}; };
const filteredIssues = (query === "" ? issues : issues?.filter((issue) => issue.name.includes(query))) ?? []; const issueList =
filteredIssues.length > 0 ? (
<li className="p-2">
{query === "" && <h2 className="mb-2 mt-4 px-3 text-xs font-semibold text-custom-text-100">Select issue</h2>}
<ul className="text-sm text-custom-text-100">
{filteredIssues.map((issue) => {
const stateColor = issue.state__color || "";
return (
<Combobox.Option
key={issue.id}
as="div"
value={issue.id}
className={({ active, selected }) =>
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
active || selected ? "bg-custom-background-80 text-custom-text-100" : ""
} `
}
>
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: stateColor,
}}
/>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id}
</span>
<span className="text-custom-text-200">{issue.name}</span>
</div>
</Combobox.Option>
);
})}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
<EmptyState
type={
query === "" ? EmptyStateType.ISSUE_RELATION_EMPTY_STATE : EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE
}
layout="screen-simple"
/>
</div>
);
return ( return (
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear> <Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
@ -110,56 +165,15 @@ export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
static static
className="max-h-80 scroll-py-2 divide-y divide-custom-border-200 overflow-y-auto" className="max-h-80 scroll-py-2 divide-y divide-custom-border-200 overflow-y-auto"
> >
{filteredIssues.length > 0 ? ( {isSearching ? (
<li className="p-2"> <Loader className="space-y-3 p-3">
{query === "" && ( <Loader.Item height="40px" />
<h2 className="mb-2 mt-4 px-3 text-xs font-semibold text-custom-text-100">Select issue</h2> <Loader.Item height="40px" />
)} <Loader.Item height="40px" />
<ul className="text-sm text-custom-text-100"> <Loader.Item height="40px" />
{filteredIssues.map((issue) => { </Loader>
const stateColor =
getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)
?.color || "";
return (
<Combobox.Option
key={issue.id}
as="div"
value={issue.id}
className={({ active, selected }) =>
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
active || selected ? "bg-custom-background-80 text-custom-text-100" : ""
} `
}
>
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: stateColor,
}}
/>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id}
</span>
<span className="text-custom-text-200">{issue.name}</span>
</div>
</Combobox.Option>
);
})}
</ul>
</li>
) : ( ) : (
<div className="flex flex-col items-center justify-center px-3 py-8 text-center"> <>{issueList}</>
<EmptyState
type={
query === ""
? EmptyStateType.ISSUE_RELATION_EMPTY_STATE
: EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE
}
layout="screen-simple"
/>
</div>
)} )}
</Combobox.Options> </Combobox.Options>
</Combobox> </Combobox>

View File

@ -0,0 +1,67 @@
import { useState } from "react";
import { observer } from "mobx-react";
// ui
import { ArchiveIcon, Tooltip } from "@plane/ui";
// components
// constants
import { ARCHIVABLE_STATE_GROUPS } from "@/constants/state";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppRouter, useIssueDetail, useProjectState } from "@/hooks/store";
import { BulkArchiveConfirmationModal } from "../bulk-archive-modal";
type Props = {
handleClearSelection: () => void;
selectedEntityIds: string[];
};
export const BulkArchiveIssues: React.FC<Props> = observer((props) => {
const { handleClearSelection, selectedEntityIds } = props;
// states
const [isBulkArchiveModalOpen, setIsBulkArchiveModalOpen] = useState(false);
// store hooks
const { projectId, workspaceSlug } = useAppRouter();
const {
issue: { getIssueById },
} = useIssueDetail();
const { getStateById } = useProjectState();
const canAllIssuesBeArchived = selectedEntityIds.every((issueId) => {
const issueDetails = getIssueById(issueId);
if (!issueDetails) return false;
const stateDetails = getStateById(issueDetails.state_id);
if (!stateDetails) return false;
return ARCHIVABLE_STATE_GROUPS.includes(stateDetails.group);
});
return (
<>
{projectId && workspaceSlug && (
<BulkArchiveConfirmationModal
isOpen={isBulkArchiveModalOpen}
handleClose={() => setIsBulkArchiveModalOpen(false)}
issueIds={selectedEntityIds}
onSubmit={handleClearSelection}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
)}
<Tooltip
tooltipHeading="Archive"
tooltipContent={canAllIssuesBeArchived ? "" : "The selected issues are not in the right state group to archive"}
>
<button
type="button"
className={cn("outline-none grid place-items-center", {
"cursor-not-allowed text-custom-text-400": !canAllIssuesBeArchived,
})}
onClick={() => {
if (canAllIssuesBeArchived) setIsBulkArchiveModalOpen(true);
}}
>
<ArchiveIcon className="size-4" />
</button>
</Tooltip>
</>
);
});

View File

@ -0,0 +1,45 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { Trash2 } from "lucide-react";
// ui
import { Tooltip } from "@plane/ui";
// hooks
import { useAppRouter } from "@/hooks/store";
import { BulkDeleteConfirmationModal } from "../bulk-delete-modal";
type Props = {
handleClearSelection: () => void;
selectedEntityIds: string[];
};
export const BulkDeleteIssues: React.FC<Props> = observer((props) => {
const { handleClearSelection, selectedEntityIds } = props;
// states
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
// store hooks
const { projectId, workspaceSlug } = useAppRouter();
return (
<>
{projectId && workspaceSlug && (
<BulkDeleteConfirmationModal
isOpen={isBulkDeleteModalOpen}
handleClose={() => setIsBulkDeleteModalOpen(false)}
issueIds={selectedEntityIds}
onSubmit={handleClearSelection}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
)}
<Tooltip tooltipHeading="Delete" tooltipContent="">
<button
type="button"
className="outline-none grid place-items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 className="size-4" />
</button>
</Tooltip>
</>
);
});

View File

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

View File

@ -0,0 +1,18 @@
import { BulkArchiveIssues } from "./archive";
import { BulkDeleteIssues } from "./delete";
type Props = {
handleClearSelection: () => void;
selectedEntityIds: string[];
};
export const BulkOperationsActionsRoot: React.FC<Props> = (props) => {
const { handleClearSelection, selectedEntityIds } = props;
return (
<div className="h-7 px-3 flex items-center gap-3 flex-shrink-0">
<BulkArchiveIssues handleClearSelection={handleClearSelection} selectedEntityIds={selectedEntityIds} />
<BulkDeleteIssues handleClearSelection={handleClearSelection} selectedEntityIds={selectedEntityIds} />
</div>
);
};

View File

@ -0,0 +1,83 @@
import { useState } from "react";
import { observer } from "mobx-react";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { AlertModalCore, EModalPosition, EModalWidth } from "@/components/core";
// constants
import { EErrorCodes, ERROR_DETAILS } from "@/constants/errors";
// hooks
import { useIssues } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
type Props = {
handleClose: () => void;
isOpen: boolean;
issueIds: string[];
onSubmit?: () => void;
projectId: string;
workspaceSlug: string;
};
export const BulkArchiveConfirmationModal: React.FC<Props> = observer((props) => {
const { handleClose, isOpen, issueIds, onSubmit, projectId, workspaceSlug } = props;
// states
const [isArchiving, setIsDeleting] = useState(false);
// store hooks
const storeType = useIssueStoreType();
const {
issues: { archiveBulkIssues },
} = useIssues(storeType);
const handleSubmit = async () => {
setIsDeleting(true);
archiveBulkIssues &&
(await archiveBulkIssues(workspaceSlug, projectId, issueIds)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Issues archived successfully.",
});
onSubmit?.();
handleClose();
})
.catch((error) => {
const errorInfo = ERROR_DETAILS[error?.error_code as EErrorCodes] ?? undefined;
setToast({
type: TOAST_TYPE.ERROR,
title: errorInfo?.title ?? "Error!",
message: errorInfo?.message ?? "Something went wrong. Please try again.",
});
})
.finally(() => setIsDeleting(false)));
};
const issueVariant = issueIds.length > 1 ? "issues" : "issue";
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleSubmit}
isSubmitting={isArchiving}
isOpen={isOpen}
variant="primary"
position={EModalPosition.CENTER}
width={EModalWidth.XL}
title={`Archive ${issueVariant}`}
content={
<>
Are you sure you want to archive {issueIds.length} {issueVariant}? Sub issues of selected {issueVariant} will
also be archived. Once archived {issueIds.length > 1 ? "they" : "it"} can be restored later via the archives
section.
</>
}
primaryButtonText={{
loading: "Archiving",
default: `Archive ${issueVariant}`,
}}
hideIcon
/>
);
});

View File

@ -0,0 +1,79 @@
import { useState } from "react";
import { observer } from "mobx-react";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { AlertModalCore, EModalPosition, EModalWidth } from "@/components/core";
// constants
// hooks
import { useIssues } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
type Props = {
handleClose: () => void;
isOpen: boolean;
issueIds: string[];
onSubmit?: () => void;
projectId: string;
workspaceSlug: string;
};
export const BulkDeleteConfirmationModal: React.FC<Props> = observer((props) => {
const { handleClose, isOpen, issueIds, onSubmit, projectId, workspaceSlug } = props;
// states
const [isDeleting, setIsDeleting] = useState(false);
// store hooks
const storeType = useIssueStoreType();
const {
issues: { removeBulkIssues },
} = useIssues(storeType);
const handleSubmit = async () => {
setIsDeleting(true);
await removeBulkIssues(workspaceSlug, projectId, issueIds)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Issues deleted successfully.",
});
onSubmit?.();
handleClose();
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
})
)
.finally(() => setIsDeleting(false));
};
const issueVariant = issueIds.length > 1 ? "issues" : "issue";
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleSubmit}
isSubmitting={isDeleting}
isOpen={isOpen}
variant="danger"
position={EModalPosition.CENTER}
width={EModalWidth.XL}
title={`Delete ${issueVariant}`}
content={
<>
Are you sure you want to delete {issueIds.length} {issueVariant}? Sub issues of selected {issueVariant} will
also be deleted. All of the data related to the {issueVariant} will be permanently removed. This action cannot
be undone.
</>
}
primaryButtonText={{
loading: "Deleting",
default: `Delete ${issueVariant}`,
}}
/>
);
});

View File

@ -0,0 +1 @@
export const BulkOperationsExtraProperties = () => null;

View File

@ -50,7 +50,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
}, [isSubmitting, setShowAlert, setIsSubmitting]); }, [isSubmitting, setShowAlert, setIsSubmitting]);
const issue = issueId ? getIssueById(issueId) : undefined; const issue = issueId ? getIssueById(issueId) : undefined;
if (!issue) return <></>; if (!issue || !issue.project_id) return <></>;
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);

View File

@ -27,7 +27,7 @@ export const IssueParentSiblings: FC<TIssueParentSiblings> = observer((props) =>
? `ISSUE_PARENT_CHILD_ISSUES_${workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}` ? `ISSUE_PARENT_CHILD_ISSUES_${workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}`
: null, : null,
parentIssue && parentIssue.project_id parentIssue && parentIssue.project_id
? () => fetchSubIssues(workspaceSlug, parentIssue.project_id, parentIssue.id) ? () => fetchSubIssues(workspaceSlug, parentIssue.project_id!, parentIssue.id)
: null : null
); );

View File

@ -205,7 +205,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<span>State</span> <span>State</span>
</div> </div>
<StateDropdown <StateDropdown
value={issue?.state_id ?? undefined} value={issue?.state_id}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })} onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })}
projectId={projectId?.toString() ?? ""} projectId={projectId?.toString() ?? ""}
disabled={!isEditable} disabled={!isEditable}
@ -234,7 +234,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
className="group w-3/5 flex-grow" className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left" buttonContainerClassName="w-full text-left"
buttonClassName={`text-sm justify-between ${ buttonClassName={`text-sm justify-between ${
issue?.assignee_ids.length > 0 ? "" : "text-custom-text-400" issue?.assignee_ids?.length > 0 ? "" : "text-custom-text-400"
}`} }`}
hideIcon={issue.assignee_ids?.length === 0} hideIcon={issue.assignee_ids?.length === 0}
dropdownArrow dropdownArrow
@ -248,7 +248,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<span>Priority</span> <span>Priority</span>
</div> </div>
<PriorityDropdown <PriorityDropdown
value={issue?.priority || undefined} value={issue?.priority}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })} onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })}
disabled={!isEditable} disabled={!isEditable}
buttonVariant="border-with-text" buttonVariant="border-with-text"

View File

@ -1,18 +1,19 @@
"use client"; "use client";
import { FC } from "react"; import { FC, useCallback, useEffect } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { TGroupedIssues } from "@plane/types"; import { TGroupedIssues } from "@plane/types";
// components // components
import { TOAST_TYPE, setToast } from "@plane/ui"; import { TOAST_TYPE, setToast } from "@plane/ui";
import { CalendarChart } from "@/components/issues"; import { CalendarChart } from "@/components/issues";
// hooks //constants
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType, EIssueGroupByToServerOptions } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
import { useIssues, useUser } from "@/hooks/store"; // hooks
import { useIssues, useUser, useCalendarView } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import { useIssuesActions } from "@/hooks/use-issues-actions"; import { useIssuesActions } from "@/hooks/use-issues-actions";
// ui
// types // types
import { IQuickActionProps } from "../list/list-view-types"; import { IQuickActionProps } from "../list/list-view-types";
import { handleDragDrop } from "./utils"; import { handleDragDrop } from "./utils";
@ -25,25 +26,36 @@ type CalendarStoreType =
interface IBaseCalendarRoot { interface IBaseCalendarRoot {
QuickActions: FC<IQuickActionProps>; QuickActions: FC<IQuickActionProps>;
storeType: CalendarStoreType;
addIssuesToView?: (issueIds: string[]) => Promise<any>; addIssuesToView?: (issueIds: string[]) => Promise<any>;
viewId?: string;
isCompletedCycle?: boolean; isCompletedCycle?: boolean;
viewId?: string | undefined;
} }
export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
const { QuickActions, storeType, addIssuesToView, viewId, isCompletedCycle = false } = props; const { QuickActions, addIssuesToView, isCompletedCycle = false, viewId } = props;
// router // router
const { workspaceSlug, projectId } = useParams(); const { workspaceSlug, projectId } = useParams();
// hooks // hooks
const storeType = useIssueStoreType() as CalendarStoreType;
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { issues, issuesFilter, issueMap } = useIssues(storeType); const { issues, issuesFilter, issueMap } = useIssues(storeType);
const { updateIssue, removeIssue, removeIssueFromView, archiveIssue, restoreIssue, updateFilters } = const {
useIssuesActions(storeType); fetchIssues,
fetchNextIssues,
quickAddIssue,
updateIssue,
removeIssue,
removeIssueFromView,
archiveIssue,
restoreIssue,
updateFilters,
} = useIssuesActions(storeType);
const issueCalendarView = useCalendarView();
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
@ -51,6 +63,26 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
const groupedIssueIds = (issues.groupedIssueIds ?? {}) as TGroupedIssues; const groupedIssueIds = (issues.groupedIssueIds ?? {}) as TGroupedIssues;
const layout = displayFilters?.calendar?.layout ?? "month";
const { startDate, endDate } = issueCalendarView.getStartAndEndDate(layout) ?? {};
useEffect(() => {
startDate &&
endDate &&
layout &&
fetchIssues(
"init-loader",
{
canGroup: true,
perPageCount: layout === "month" ? 4 : 30,
before: endDate,
after: startDate,
groupedBy: EIssueGroupByToServerOptions["target_date"],
},
viewId
);
}, [fetchIssues, storeType, startDate, endDate, layout, viewId]);
const handleDragAndDrop = async ( const handleDragAndDrop = async (
issueId: string | undefined, issueId: string | undefined,
sourceDate: string | undefined, sourceDate: string | undefined,
@ -74,6 +106,23 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
}); });
}; };
const loadMoreIssues = useCallback(
(dateString: string) => {
fetchNextIssues(dateString);
},
[fetchNextIssues]
);
const getPaginationData = useCallback(
(groupId: string | undefined) => issues?.getPaginationData(groupId, undefined),
[issues?.getPaginationData]
);
const getGroupIssueCount = useCallback(
(groupId: string | undefined) => issues?.getGroupIssueCount(groupId, undefined, false),
[issues?.getGroupIssueCount]
);
return ( return (
<> <>
<div className="h-full w-full overflow-hidden bg-custom-background-100 pt-4"> <div className="h-full w-full overflow-hidden bg-custom-background-100 pt-4">
@ -83,6 +132,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
groupedIssueIds={groupedIssueIds} groupedIssueIds={groupedIssueIds}
layout={displayFilters?.calendar?.layout} layout={displayFilters?.calendar?.layout}
showWeekends={displayFilters?.calendar?.show_weekends ?? false} showWeekends={displayFilters?.calendar?.show_weekends ?? false}
issueCalendarView={issueCalendarView}
quickActions={({ issue, parentRef, customActionButton, placement }) => ( quickActions={({ issue, parentRef, customActionButton, placement }) => (
<QuickActions <QuickActions
parentRef={parentRef} parentRef={parentRef}
@ -97,14 +147,16 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
placements={placement} placements={placement}
/> />
)} )}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}
quickAddCallback={issues.quickAddIssue} quickAddCallback={quickAddIssue}
viewId={viewId}
readOnly={!isEditingAllowed || isCompletedCycle} readOnly={!isEditingAllowed || isCompletedCycle}
updateFilters={updateFilters} updateFilters={updateFilters}
handleDragAndDrop={handleDragAndDrop} handleDragAndDrop={handleDragAndDrop}
/> />
</div> </div>
</> </>
); );
}); });

View File

@ -13,6 +13,7 @@ import type {
TIssue, TIssue,
TIssueKanbanFilters, TIssueKanbanFilters,
TIssueMap, TIssueMap,
TPaginationData,
} from "@plane/types"; } from "@plane/types";
// ui // ui
import { Spinner } from "@plane/ui"; import { Spinner } from "@plane/ui";
@ -20,20 +21,21 @@ import { Spinner } from "@plane/ui";
import { CalendarHeader, CalendarIssueBlocks, CalendarWeekDays, CalendarWeekHeader } from "@/components/issues"; import { CalendarHeader, CalendarIssueBlocks, CalendarWeekDays, CalendarWeekHeader } from "@/components/issues";
// constants // constants
import { MONTHS_LIST } from "@/constants/calendar"; import { MONTHS_LIST } from "@/constants/calendar";
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// hooks // hooks
import { useIssues, useUser } from "@/hooks/store"; import { useIssues, useUser } from "@/hooks/store";
import { useCalendarView } from "@/hooks/store/use-calendar-view";
import useSize from "@/hooks/use-window-size"; import useSize from "@/hooks/use-window-size";
// store // store
import { ICycleIssuesFilter } from "@/store/issue/cycle"; import { ICycleIssuesFilter } from "@/store/issue/cycle";
import { ICalendarStore } from "@/store/issue/issue_calendar_view.store";
import { IModuleIssuesFilter } from "@/store/issue/module"; import { IModuleIssuesFilter } from "@/store/issue/module";
import { IProjectIssuesFilter } from "@/store/issue/project"; import { IProjectIssuesFilter } from "@/store/issue/project";
import { IProjectViewIssuesFilter } from "@/store/issue/project-views"; import { IProjectViewIssuesFilter } from "@/store/issue/project-views";
import { IssueLayoutHOC } from "../issue-layout-HOC";
import { TRenderQuickActions } from "../list/list-view-types"; import { TRenderQuickActions } from "../list/list-view-types";
import type { ICalendarWeek } from "./types"; import type { ICalendarWeek } from "./types";
@ -43,20 +45,18 @@ type Props = {
groupedIssueIds: TGroupedIssues; groupedIssueIds: TGroupedIssues;
layout: "month" | "week" | undefined; layout: "month" | "week" | undefined;
showWeekends: boolean; showWeekends: boolean;
issueCalendarView: ICalendarStore;
loadMoreIssues: (dateString: string) => void;
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
quickActions: TRenderQuickActions; quickActions: TRenderQuickActions;
handleDragAndDrop: ( handleDragAndDrop: (
issueId: string | undefined, issueId: string | undefined,
sourceDate: string | undefined, sourceDate: string | undefined,
destinationDate: string | undefined destinationDate: string | undefined
) => Promise<void>; ) => Promise<void>;
quickAddCallback?: (
workspaceSlug: string,
projectId: string,
data: TIssue,
viewId?: string
) => Promise<TIssue | undefined>;
addIssuesToView?: (issueIds: string[]) => Promise<any>; addIssuesToView?: (issueIds: string[]) => Promise<any>;
viewId?: string;
readOnly?: boolean; readOnly?: boolean;
updateFilters?: ( updateFilters?: (
projectId: string, projectId: string,
@ -72,11 +72,14 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
groupedIssueIds, groupedIssueIds,
layout, layout,
showWeekends, showWeekends,
issueCalendarView,
loadMoreIssues,
handleDragAndDrop, handleDragAndDrop,
quickActions, quickActions,
quickAddCallback, quickAddCallback,
addIssuesToView, addIssuesToView,
viewId, getPaginationData,
getGroupIssueCount,
updateFilters, updateFilters,
readOnly = false, readOnly = false,
} = props; } = props;
@ -88,7 +91,7 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
const { const {
issues: { viewFlags }, issues: { viewFlags },
} = useIssues(EIssuesStoreType.PROJECT); } = useIssues(EIssuesStoreType.PROJECT);
const issueCalendarView = useCalendarView();
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
@ -123,7 +126,7 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
</div> </div>
); );
const issueIdList = groupedIssueIds ? groupedIssueIds[formattedDatePayload] : null; const issueIdList = groupedIssueIds ? groupedIssueIds[formattedDatePayload] : [];
return ( return (
<> <>
@ -133,57 +136,90 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
issuesFilterStore={issuesFilterStore} issuesFilterStore={issuesFilterStore}
updateFilters={updateFilters} updateFilters={updateFilters}
/> />
<div
className={cn("flex w-full flex-col overflow-y-auto md:h-full", { <IssueLayoutHOC layout={EIssueLayoutTypes.CALENDAR}>
"vertical-scrollbar scrollbar-lg": windowWidth > 768, <div
})} className={cn("flex md:h-full w-full flex-col overflow-y-auto", {
ref={scrollableContainerRef} "vertical-scrollbar scrollbar-lg": windowWidth > 768,
> })}
<CalendarWeekHeader isLoading={!issues} showWeekends={showWeekends} /> ref={scrollableContainerRef}
<div className="h-full w-full"> >
{layout === "month" && ( <CalendarWeekHeader isLoading={!issues} showWeekends={showWeekends} />
<div className="grid h-full w-full grid-cols-1 divide-y-[0.5px] divide-custom-border-200"> <div className="h-full w-full">
{allWeeksOfActiveMonth && {layout === "month" && (
Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => ( <div className="grid h-full w-full grid-cols-1 divide-y-[0.5px] divide-custom-border-200">
<CalendarWeekDays {allWeeksOfActiveMonth &&
selectedDate={selectedDate} Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => (
setSelectedDate={setSelectedDate} <CalendarWeekDays
issuesFilterStore={issuesFilterStore} selectedDate={selectedDate}
handleDragAndDrop={handleDragAndDrop} setSelectedDate={setSelectedDate}
key={weekIndex} handleDragAndDrop={handleDragAndDrop}
week={week} issuesFilterStore={issuesFilterStore}
issues={issues} key={weekIndex}
groupedIssueIds={groupedIssueIds} week={week}
enableQuickIssueCreate issues={issues}
disableIssueCreation={!enableIssueCreation || !isEditingAllowed} groupedIssueIds={groupedIssueIds}
quickActions={quickActions} loadMoreIssues={loadMoreIssues}
quickAddCallback={quickAddCallback} getPaginationData={getPaginationData}
addIssuesToView={addIssuesToView} getGroupIssueCount={getGroupIssueCount}
viewId={viewId} enableQuickIssueCreate
readOnly={readOnly} disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
/> quickActions={quickActions}
))} quickAddCallback={quickAddCallback}
</div> addIssuesToView={addIssuesToView}
)} readOnly={readOnly}
{layout === "week" && ( />
<CalendarWeekDays ))}
selectedDate={selectedDate} </div>
setSelectedDate={setSelectedDate} )}
issuesFilterStore={issuesFilterStore} {layout === "week" && (
handleDragAndDrop={handleDragAndDrop} <CalendarWeekDays
week={issueCalendarView.allDaysOfActiveWeek} selectedDate={selectedDate}
setSelectedDate={setSelectedDate}
handleDragAndDrop={handleDragAndDrop}
issuesFilterStore={issuesFilterStore}
week={issueCalendarView.allDaysOfActiveWeek}
issues={issues}
groupedIssueIds={groupedIssueIds}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
enableQuickIssueCreate
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
quickActions={quickActions}
quickAddCallback={quickAddCallback}
addIssuesToView={addIssuesToView}
readOnly={readOnly}
/>
)}
</div>
{/* mobile view */}
<div className="md:hidden">
<p className="p-4 text-xl font-semibold">
{`${selectedDate.getDate()} ${
MONTHS_LIST[selectedDate.getMonth() + 1].title
}, ${selectedDate.getFullYear()}`}
</p>
<CalendarIssueBlocks
date={selectedDate}
issues={issues} issues={issues}
groupedIssueIds={groupedIssueIds} issueIdList={issueIdList}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
quickActions={quickActions}
enableQuickIssueCreate enableQuickIssueCreate
disableIssueCreation={!enableIssueCreation || !isEditingAllowed} disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
quickActions={quickActions}
quickAddCallback={quickAddCallback} quickAddCallback={quickAddCallback}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}
viewId={viewId}
readOnly={readOnly} readOnly={readOnly}
isDragDisabled
isMobileView
/> />
)} </div>
</div> </div>
</IssueLayoutHOC>
{/* mobile view */} {/* mobile view */}
<div className="md:hidden"> <div className="md:hidden">
@ -197,20 +233,19 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
issues={issues} issues={issues}
issueIdList={issueIdList} issueIdList={issueIdList}
quickActions={quickActions} quickActions={quickActions}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
enableQuickIssueCreate enableQuickIssueCreate
disableIssueCreation={!enableIssueCreation || !isEditingAllowed} disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
quickAddCallback={quickAddCallback} quickAddCallback={quickAddCallback}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}
viewId={viewId}
readOnly={readOnly} readOnly={readOnly}
isMonthLayout={false}
showAllIssues
isDragDisabled isDragDisabled
isMobileView isMobileView
/> />
</div> </div>
</div> </div>
</div>
</> </>
); );
}); });

View File

@ -6,7 +6,7 @@ import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element
import { differenceInCalendarDays } from "date-fns"; import { differenceInCalendarDays } from "date-fns";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// types // types
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; import { TGroupedIssues, TIssue, TIssueMap, TPaginationData } from "@plane/types";
// ui // ui
import { TOAST_TYPE, setToast } from "@plane/ui"; import { TOAST_TYPE, setToast } from "@plane/ui";
// components // components
@ -29,22 +29,19 @@ type Props = {
date: ICalendarDate; date: ICalendarDate;
issues: TIssueMap | undefined; issues: TIssueMap | undefined;
groupedIssueIds: TGroupedIssues; groupedIssueIds: TGroupedIssues;
quickActions: TRenderQuickActions; loadMoreIssues: (dateString: string) => void;
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
enableQuickIssueCreate?: boolean; enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean; disableIssueCreation?: boolean;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
quickActions: TRenderQuickActions;
handleDragAndDrop: ( handleDragAndDrop: (
issueId: string | undefined, issueId: string | undefined,
sourceDate: string | undefined, sourceDate: string | undefined,
destinationDate: string | undefined destinationDate: string | undefined
) => Promise<void>; ) => Promise<void>;
quickAddCallback?: (
workspaceSlug: string,
projectId: string,
data: TIssue,
viewId?: string
) => Promise<TIssue | undefined>;
addIssuesToView?: (issueIds: string[]) => Promise<any>; addIssuesToView?: (issueIds: string[]) => Promise<any>;
viewId?: string;
readOnly?: boolean; readOnly?: boolean;
selectedDate: Date; selectedDate: Date;
setSelectedDate: (date: Date) => void; setSelectedDate: (date: Date) => void;
@ -56,12 +53,14 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
date, date,
issues, issues,
groupedIssueIds, groupedIssueIds,
loadMoreIssues,
getPaginationData,
getGroupIssueCount,
quickActions, quickActions,
enableQuickIssueCreate, enableQuickIssueCreate,
disableIssueCreation, disableIssueCreation,
quickAddCallback, quickAddCallback,
addIssuesToView, addIssuesToView,
viewId,
readOnly = false, readOnly = false,
selectedDate, selectedDate,
handleDragAndDrop, handleDragAndDrop,
@ -69,7 +68,6 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
} = props; } = props;
const [isDraggingOver, setIsDraggingOver] = useState(false); const [isDraggingOver, setIsDraggingOver] = useState(false);
const [showAllIssues, setShowAllIssues] = useState(false);
const calendarLayout = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.layout ?? "month"; const calendarLayout = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.layout ?? "month";
@ -114,7 +112,6 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
} }
handleDragAndDrop(sourceData?.id, sourceData?.date, destinationData?.date); handleDragAndDrop(sourceData?.id, sourceData?.date, destinationData?.date);
setShowAllIssues(true);
highlightIssueOnDrop(source?.element?.id, false); highlightIssueOnDrop(source?.element?.id, false);
}, },
}) })
@ -122,9 +119,7 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
}, [dayTileRef?.current, formattedDatePayload]); }, [dayTileRef?.current, formattedDatePayload]);
if (!formattedDatePayload) return null; if (!formattedDatePayload) return null;
const issueIdList = groupedIssueIds ? groupedIssueIds[formattedDatePayload] : null; const issueIds = groupedIssueIds?.[formattedDatePayload];
const totalIssues = issueIdList?.length ?? 0;
const isToday = date.date.toDateString() === new Date().toDateString(); const isToday = date.date.toDateString() === new Date().toDateString();
const isSelectedDate = date.date.toDateString() == selectedDate.toDateString(); const isSelectedDate = date.date.toDateString() == selectedDate.toDateString();
@ -171,18 +166,17 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
<CalendarIssueBlocks <CalendarIssueBlocks
date={date.date} date={date.date}
issues={issues} issues={issues}
issueIdList={issueIdList} issueIdList={issueIds}
showAllIssues={showAllIssues}
setShowAllIssues={setShowAllIssues}
quickActions={quickActions} quickActions={quickActions}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
isDragDisabled={readOnly} isDragDisabled={readOnly}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}
disableIssueCreation={disableIssueCreation} disableIssueCreation={disableIssueCreation}
enableQuickIssueCreate={enableQuickIssueCreate} enableQuickIssueCreate={enableQuickIssueCreate}
quickAddCallback={quickAddCallback} quickAddCallback={quickAddCallback}
viewId={viewId}
readOnly={readOnly} readOnly={readOnly}
isMonthLayout={isMonthLayout}
/> />
</div> </div>
</div> </div>
@ -205,8 +199,6 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
> >
{date.date.getDate()} {date.date.getDate()}
</div> </div>
{totalIssues > 0 && <div className="mt-1 size-1.5 flex flex-shrink-0 rounded bg-custom-primary-100" />}
</div> </div>
</div> </div>
</> </>

View File

@ -1,33 +1,26 @@
import { Dispatch, SetStateAction } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// types import { TIssue, TIssueMap, TPaginationData } from "@plane/types";
import { TIssue, TIssueMap } from "@plane/types";
// components // components
import { CalendarQuickAddIssueForm, CalendarIssueBlockRoot } from "@/components/issues"; import { CalendarQuickAddIssueForm, CalendarIssueBlockRoot } from "@/components/issues";
// helpers // helpers
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
import { TRenderQuickActions } from "../list/list-view-types"; import { TRenderQuickActions } from "../list/list-view-types";
// types // types
type Props = { type Props = {
date: Date; date: Date;
issues: TIssueMap | undefined; issues: TIssueMap | undefined;
issueIdList: string[] | null; loadMoreIssues: (dateString: string) => void;
showAllIssues: boolean; getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
setShowAllIssues?: Dispatch<SetStateAction<boolean>>; getGroupIssueCount: (groupId: string | undefined) => number | undefined;
isMonthLayout: boolean; issueIdList: string[];
quickActions: TRenderQuickActions; quickActions: TRenderQuickActions;
isDragDisabled?: boolean; isDragDisabled?: boolean;
enableQuickIssueCreate?: boolean; enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean; disableIssueCreation?: boolean;
quickAddCallback?: ( quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
workspaceSlug: string,
projectId: string,
data: TIssue,
viewId?: string
) => Promise<TIssue | undefined>;
addIssuesToView?: (issueIds: string[]) => Promise<any>; addIssuesToView?: (issueIds: string[]) => Promise<any>;
viewId?: string;
readOnly?: boolean; readOnly?: boolean;
isMobileView?: boolean; isMobileView?: boolean;
}; };
@ -37,28 +30,36 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
date, date,
issues, issues,
issueIdList, issueIdList,
showAllIssues,
setShowAllIssues,
quickActions, quickActions,
loadMoreIssues,
isDragDisabled = false, isDragDisabled = false,
enableQuickIssueCreate, enableQuickIssueCreate,
disableIssueCreation, disableIssueCreation,
quickAddCallback, quickAddCallback,
addIssuesToView, addIssuesToView,
viewId,
readOnly, readOnly,
isMonthLayout,
isMobileView = false, isMobileView = false,
} = props; } = props;
const formattedDatePayload = renderFormattedPayloadDate(date); const formattedDatePayload = renderFormattedPayloadDate(date);
const totalIssues = issueIdList?.length ?? 0;
const {
issues: { getGroupIssueCount, getPaginationData, getIssueLoader },
} = useIssuesStore();
if (!formattedDatePayload) return null; if (!formattedDatePayload) return null;
const dayIssueCount = getGroupIssueCount(formattedDatePayload, undefined, false);
const nextPageResults = getPaginationData(formattedDatePayload, undefined)?.nextPageResults;
const isPaginating = !!getIssueLoader(formattedDatePayload);
const shouldLoadMore =
nextPageResults === undefined && dayIssueCount !== undefined
? issueIdList?.length < dayIssueCount
: !!nextPageResults;
return ( return (
<> <>
{issueIdList?.slice(0, showAllIssues || !isMonthLayout ? issueIdList.length : 4).map((issueId) => ( {issueIdList?.map((issueId) => (
<div key={issueId} className="relative cursor-pointer p-1 px-2"> <div key={issueId} className="relative cursor-pointer p-1 px-2">
<CalendarIssueBlockRoot <CalendarIssueBlockRoot
issues={issues} issues={issues}
@ -68,17 +69,13 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
/> />
</div> </div>
))} ))}
{totalIssues > 4 && isMonthLayout && (
<div className="hidden items-center px-2.5 py-1 md:flex"> {isPaginating && (
<button <div className="p-1 px-2">
type="button" <div className="flex h-10 md:h-8 w-full items-center justify-between gap-1.5 rounded md:px-1 px-4 py-1.5 bg-custom-background-80 animate-pulse" />
className="w-min whitespace-nowrap rounded px-1.5 py-1 text-xs font-medium text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-300"
onClick={() => setShowAllIssues && setShowAllIssues(!showAllIssues)}
>
{showAllIssues ? "Hide" : totalIssues - 4 + " more"}
</button>
</div> </div>
)} )}
{enableQuickIssueCreate && !disableIssueCreation && !readOnly && ( {enableQuickIssueCreate && !disableIssueCreation && !readOnly && (
<div className="border-b border-custom-border-200 px-1 py-1 md:border-none md:px-2"> <div className="border-b border-custom-border-200 px-1 py-1 md:border-none md:px-2">
<CalendarQuickAddIssueForm <CalendarQuickAddIssueForm
@ -89,11 +86,21 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
}} }}
quickAddCallback={quickAddCallback} quickAddCallback={quickAddCallback}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}
viewId={viewId}
onOpen={() => setShowAllIssues && setShowAllIssues(true)}
/> />
</div> </div>
)} )}
{shouldLoadMore && !isPaginating && (
<div className="flex items-center px-2.5 py-1">
<button
type="button"
className="w-min whitespace-nowrap rounded text-xs px-1.5 py-1 text-custom-text-400 font-medium hover:bg-custom-background-80 hover:text-custom-text-300"
onClick={() => loadMoreIssues(formattedDatePayload)}
>
Load More
</button>
</div>
)}
</> </>
); );
}); });

View File

@ -27,14 +27,8 @@ type Props = {
groupId?: string; groupId?: string;
subGroupId?: string | null; subGroupId?: string | null;
prePopulatedData?: Partial<TIssue>; prePopulatedData?: Partial<TIssue>;
quickAddCallback?: ( quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
workspaceSlug: string,
projectId: string,
data: TIssue,
viewId?: string
) => Promise<TIssue | undefined>;
addIssuesToView?: (issueIds: string[]) => Promise<any>; addIssuesToView?: (issueIds: string[]) => Promise<any>;
viewId?: string;
onOpen?: () => void; onOpen?: () => void;
}; };
@ -66,7 +60,7 @@ const Inputs = (props: any) => {
}; };
export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => { export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
const { formKey, prePopulatedData, quickAddCallback, addIssuesToView, viewId, onOpen } = props; const { formKey, prePopulatedData, quickAddCallback, addIssuesToView, onOpen } = props;
// router // router
const { workspaceSlug, projectId, moduleId } = useParams(); const { workspaceSlug, projectId, moduleId } = useParams();
@ -133,14 +127,9 @@ export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
}); });
if (quickAddCallback) { if (quickAddCallback) {
const quickAddPromise = quickAddCallback( const quickAddPromise = quickAddCallback(projectId.toString(), {
workspaceSlug.toString(), ...payload,
projectId.toString(), });
{
...payload,
},
viewId
);
setPromiseToast<any>(quickAddPromise, { setPromiseToast<any>(quickAddPromise, {
loading: "Adding issue...", loading: "Adding issue...",
success: { success: {

View File

@ -33,9 +33,8 @@ export const CycleCalendarLayout: React.FC = observer(() => {
<BaseCalendarRoot <BaseCalendarRoot
QuickActions={CycleIssueQuickActions} QuickActions={CycleIssueQuickActions}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}
viewId={cycleId.toString()}
isCompletedCycle={isCompletedCycle} isCompletedCycle={isCompletedCycle}
storeType={EIssuesStoreType.CYCLE} viewId={cycleId?.toString()}
/> />
); );
}); });

View File

@ -28,9 +28,8 @@ export const ModuleCalendarLayout: React.FC = observer(() => {
return ( return (
<BaseCalendarRoot <BaseCalendarRoot
QuickActions={ModuleIssueQuickActions} QuickActions={ModuleIssueQuickActions}
storeType={EIssuesStoreType.MODULE}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}
viewId={moduleId.toString()} viewId={moduleId?.toString()}
/> />
); );
}); });

View File

@ -1,10 +1,7 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// hooks // hooks
import { ProjectIssueQuickActions } from "@/components/issues"; import { ProjectIssueQuickActions } from "@/components/issues";
import { EIssuesStoreType } from "@/constants/issue";
// components // components
import { BaseCalendarRoot } from "../base-calendar-root"; import { BaseCalendarRoot } from "../base-calendar-root";
export const CalendarLayout: React.FC = observer(() => ( export const CalendarLayout: React.FC = observer(() => <BaseCalendarRoot QuickActions={ProjectIssueQuickActions} />);
<BaseCalendarRoot QuickActions={ProjectIssueQuickActions} storeType={EIssuesStoreType.PROJECT} />
));

View File

@ -1,22 +1,11 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks // hooks
import { ProjectIssueQuickActions } from "@/components/issues"; import { ProjectIssueQuickActions } from "@/components/issues";
import { EIssuesStoreType } from "@/constants/issue";
// components // components
// types // types
import { BaseCalendarRoot } from "../base-calendar-root"; import { BaseCalendarRoot } from "../base-calendar-root";
// constants // constants
export const ProjectViewCalendarLayout: React.FC = observer(() => { export const ProjectViewCalendarLayout: React.FC = observer(() => (
// router <BaseCalendarRoot QuickActions={ProjectIssueQuickActions} />
const { viewId } = useParams(); ));
return (
<BaseCalendarRoot
QuickActions={ProjectIssueQuickActions}
viewId={viewId?.toString()}
storeType={EIssuesStoreType.PROJECT_VIEW}
/>
);
});

View File

@ -1,5 +1,5 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; import { TGroupedIssues, TIssue, TIssueMap, TPaginationData } from "@plane/types";
// components // components
import { CalendarDayTile } from "@/components/issues"; import { CalendarDayTile } from "@/components/issues";
// helpers // helpers
@ -17,22 +17,19 @@ type Props = {
issues: TIssueMap | undefined; issues: TIssueMap | undefined;
groupedIssueIds: TGroupedIssues; groupedIssueIds: TGroupedIssues;
week: ICalendarWeek | undefined; week: ICalendarWeek | undefined;
quickActions: TRenderQuickActions; quickActions: TRenderQuickActions
loadMoreIssues: (dateString: string) => void;
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
enableQuickIssueCreate?: boolean; enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean; disableIssueCreation?: boolean;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
handleDragAndDrop: ( handleDragAndDrop: (
issueId: string | undefined, issueId: string | undefined,
sourceDate: string | undefined, sourceDate: string | undefined,
destinationDate: string | undefined destinationDate: string | undefined
) => Promise<void>; ) => Promise<void>;
quickAddCallback?: (
workspaceSlug: string,
projectId: string,
data: TIssue,
viewId?: string
) => Promise<TIssue | undefined>;
addIssuesToView?: (issueIds: string[]) => Promise<any>; addIssuesToView?: (issueIds: string[]) => Promise<any>;
viewId?: string;
readOnly?: boolean; readOnly?: boolean;
selectedDate: Date; selectedDate: Date;
setSelectedDate: (date: Date) => void; setSelectedDate: (date: Date) => void;
@ -45,12 +42,14 @@ export const CalendarWeekDays: React.FC<Props> = observer((props) => {
groupedIssueIds, groupedIssueIds,
handleDragAndDrop, handleDragAndDrop,
week, week,
loadMoreIssues,
getPaginationData,
getGroupIssueCount,
quickActions, quickActions,
enableQuickIssueCreate, enableQuickIssueCreate,
disableIssueCreation, disableIssueCreation,
quickAddCallback, quickAddCallback,
addIssuesToView, addIssuesToView,
viewId,
readOnly = false, readOnly = false,
selectedDate, selectedDate,
setSelectedDate, setSelectedDate,
@ -79,12 +78,14 @@ export const CalendarWeekDays: React.FC<Props> = observer((props) => {
date={date} date={date}
issues={issues} issues={issues}
groupedIssueIds={groupedIssueIds} groupedIssueIds={groupedIssueIds}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
quickActions={quickActions} quickActions={quickActions}
enableQuickIssueCreate={enableQuickIssueCreate} enableQuickIssueCreate={enableQuickIssueCreate}
disableIssueCreation={disableIssueCreation} disableIssueCreation={disableIssueCreation}
quickAddCallback={quickAddCallback} quickAddCallback={quickAddCallback}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}
viewId={viewId}
readOnly={readOnly} readOnly={readOnly}
handleDragAndDrop={handleDragAndDrop} handleDragAndDrop={handleDragAndDrop}
/> />

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