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 plane.db.models import Estimate, EstimatePoint
from plane.app.serializers import (
WorkspaceLiteSerializer,
ProjectLiteSerializer,
)
from rest_framework import serializers

View File

@ -19,6 +19,8 @@ from plane.app.views import (
IssueUserDisplayPropertyEndpoint,
IssueViewSet,
LabelViewSet,
BulkIssueOperationsEndpoint,
BulkArchiveIssuesEndpoint,
)
urlpatterns = [
@ -81,6 +83,11 @@ urlpatterns = [
BulkDeleteIssuesEndpoint.as_view(),
name="project-issues-bulk",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-archive-issues/",
BulkArchiveIssuesEndpoint.as_view(),
name="bulk-archive-issues",
),
##
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
@ -298,4 +305,9 @@ urlpatterns = [
),
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,
)
from .issue.archive import (
IssueArchiveViewSet,
)
from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint
from .issue.attachment import (
IssueAttachmentEndpoint,
@ -154,6 +152,8 @@ from .issue.subscriber import (
)
from .issue.bulk_operations import BulkIssueOperationsEndpoint
from .module.base import (
ModuleViewSet,
ModuleLinkViewSet,

View File

@ -1,4 +1,6 @@
# Python imports
import traceback
import zoneinfo
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
@ -76,7 +78,11 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
response = super().handle_exception(exc)
return response
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):
return Response(
{"error": "The payload is not valid"},

View File

@ -2,43 +2,50 @@
import json
# Django imports
from django.db.models import (
Func,
F,
Q,
OuterRef,
Value,
UUIDField,
)
from django.core import serializers
from django.db.models import (
F,
Func,
OuterRef,
Q,
)
from django.utils import timezone
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.fields import ArrayField
from django.db.models.functions import Coalesce
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
ProjectEntityPermission,
)
# Module imports
from .. import BaseViewSet
from plane.app.serializers import (
IssueSerializer,
CycleIssueSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Cycle,
CycleIssue,
Issue,
IssueLink,
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.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):
serializer_class = CycleIssueSerializer
@ -86,14 +93,9 @@ class CycleIssueViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id, cycle_id):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
order_by = request.GET.get("order_by", "created_at")
order_by_param = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET")
queryset = (
issue_queryset = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
@ -105,7 +107,6 @@ class CycleIssueViewSet(BaseViewSet):
"issue_module__module",
"issue_cycle__cycle",
)
.order_by(order_by)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
@ -130,73 +131,112 @@ class CycleIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="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:
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
)
filters = issue_filters(request.query_params, "GET")
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):
issues = request.data.get("issues", [])

View File

@ -1,52 +1,53 @@
# 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.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.utils import timezone
from rest_framework import status
# Third Party imports
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
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):
@ -569,6 +570,7 @@ def dashboard_recent_collaborators(self, request, slug):
)
return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=project_members_with_activities,
controller=lambda qs: self.get_results_controller(qs, slug),

View File

@ -1,14 +1,14 @@
# Third Party imports
from rest_framework.response import Response
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
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):
@ -72,6 +72,7 @@ class ExportIssuesEndpoint(BaseAPIView):
"cursor", False
):
return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=exporter_history,
on_results=lambda exporter_history: ExporterHistorySerializer(

View File

@ -2,52 +2,54 @@
import json
# 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.db.models import (
F,
Func,
OuterRef,
Q,
Prefetch,
Exists,
)
from django.utils import timezone
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.fields import ArrayField
from django.db.models.functions import Coalesce
# Third Party imports
from rest_framework.response import Response
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 (
ProjectEntityPermission,
)
from plane.app.serializers import (
IssueFlatSerializer,
IssueSerializer,
IssueDetailSerializer
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueLink,
IssueAttachment,
IssueLink,
IssueSubscriber,
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.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):
permission_classes = [
@ -92,33 +94,6 @@ class IssueArchiveViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="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)
@ -126,125 +101,116 @@ class IssueArchiveViewSet(BaseViewSet):
filters = issue_filters(request.query_params, "GET")
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")
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
if show_sub_issues == "true"
else issue_queryset.filter(parent__isnull=True)
)
if self.expand or self.fields:
issues = IssueSerializer(
issue_queryset,
many=True,
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
)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
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):
issue = (
@ -351,3 +317,58 @@ class IssueArchiveViewSet(BaseViewSet):
issue.save()
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
import json
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
Case,
CharField,
Exists,
F,
Func,
Max,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
# Django imports
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third Party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
@ -49,11 +45,21 @@ from plane.db.models import (
IssueSubscriber,
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,
)
from .. import BaseAPIView, BaseViewSet
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
from .. import BaseAPIView, BaseViewSet
class IssueListEndpoint(BaseAPIView):
@ -105,110 +111,28 @@ class IssueListEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="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()
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")
issue_queryset = queryset.filter(**filters)
# Issue queryset
issue_queryset, _ = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# 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")
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# 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_grouper(
queryset=issue_queryset,
group_by=group_by,
sub_group_by=sub_group_by,
)
if self.fields or self.expand:
issues = IssueSerializer(
@ -304,33 +228,6 @@ class IssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="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()
@method_decorator(gzip_page)
@ -340,116 +237,104 @@ class IssueViewSet(BaseViewSet):
issue_queryset = self.get_queryset().filter(**filters)
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
# 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")
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# 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(),
# 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:
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:
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:
issue_queryset = issue_queryset.order_by(order_by_param)
# Only use serializer when expand or fields else return by values
if self.expand or self.fields:
issues = IssueSerializer(
issue_queryset,
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",
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
),
)
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):
project = Project.objects.get(pk=project_id)
@ -481,8 +366,13 @@ class IssueViewSet(BaseViewSet):
origin=request.META.get("HTTP_ORIGIN"),
)
issue = (
self.get_queryset()
.filter(pk=serializer.data["id"])
issue_queryset_grouper(
queryset=self.get_queryset().filter(
pk=serializer.data["id"]
),
group_by=None,
sub_group_by=None,
)
.values(
"id",
"name",
@ -523,6 +413,33 @@ class IssueViewSet(BaseViewSet):
issue = (
self.get_queryset()
.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(
"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.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
Case,
CharField,
Exists,
F,
Func,
Max,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
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.response import Response
# Module imports
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
IssueCreateSerializer,
@ -44,10 +41,17 @@ from plane.db.models import (
IssueSubscriber,
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.user_timezone_converter import user_timezone_converter
# Module imports
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from .. import BaseViewSet
@ -88,153 +92,116 @@ class IssueDraftViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="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()
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
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")
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
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")
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# 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(),
# 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,
),
)
).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)
# Only use serializer when expand else return by values
if self.expand or self.fields:
issues = IssueSerializer(
issue_queryset,
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",
# 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
),
)
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):
project = Project.objects.get(pk=project_id)
@ -265,12 +232,45 @@ class IssueDraftViewSet(BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue = (
self.get_queryset().filter(pk=serializer.data["id"]).first()
)
return Response(
IssueSerializer(issue).data, status=status.HTTP_201_CREATED
issue_queryset_grouper(
queryset=self.get_queryset().filter(
pk=serializer.data["id"]
),
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)
def partial_update(self, request, slug, project_id, pk):
@ -309,6 +309,33 @@ class IssueDraftViewSet(BaseViewSet):
issue = (
self.get_queryset()
.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(
"issue_reactions",

View File

@ -1,37 +1,50 @@
# Python imports
import json
from django.db.models import (
F,
Func,
OuterRef,
Q,
)
# Django Imports
from django.utils import timezone
from django.db.models import F, OuterRef, Func, Q
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.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
# Third party imports
from rest_framework.response import Response
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
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):
serializer_class = ModuleIssueSerializer
@ -80,82 +93,115 @@ class ModuleIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="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()
@method_decorator(gzip_page)
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")
issue_queryset = self.get_queryset().filter(**filters)
if self.fields or self.expand:
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
)
order_by_param = request.GET.get("order_by", "created_at")
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
def create_module_issues(self, request, slug, project_id, module_id):

View File

@ -1,26 +1,27 @@
# Django imports
from django.db.models import Q, OuterRef, Exists
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
# Third party imports
from rest_framework import status
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 (
NotificationSerializer,
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):
@ -131,6 +132,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
"cursor", False
):
return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=(notifications),
on_results=lambda notifications: NotificationSerializer(

View File

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

View File

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

View File

@ -1,47 +1,56 @@
# 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.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.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third party imports
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
from .. import BaseViewSet
from plane.app.serializers import (
IssueViewSerializer,
IssueSerializer,
)
from plane.app.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
)
from plane.db.models import (
Workspace,
IssueView,
Issue,
UserFavorite,
IssueLink,
IssueAttachment,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter
class GlobalViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
@ -143,17 +152,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug):
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")
issue_queryset = (
@ -162,103 +160,107 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.annotate(cycle_id=F("issue_cycle__cycle_id"))
)
# 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")
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# 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)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if self.fields:
issues = IssueSerializer(
issue_queryset, many=True, fields=self.fields
).data
# 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=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:
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",
# 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
),
)
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):

View File

@ -1,61 +1,66 @@
# Python imports
from datetime import date
from dateutil.relativedelta import relativedelta
# Django imports
from django.utils import timezone
from django.db.models import (
OuterRef,
Func,
F,
Q,
Count,
Case,
Value,
CharField,
When,
Max,
Count,
F,
Func,
IntegerField,
UUIDField,
OuterRef,
Q,
Value,
When,
)
from django.db.models.functions import ExtractWeek, Cast
from django.db.models.fields import DateField
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Coalesce
from django.db.models.functions import Cast, ExtractWeek
from django.utils import timezone
# Third party modules
from rest_framework import status
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 (
WorkspaceEntityPermission,
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.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
@ -99,22 +104,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
]
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
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
@ -152,100 +143,103 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="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")
).distinct()
# 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")
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset,
order_by_param=order_by_param,
)
# 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(),
# 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:
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:
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:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueSerializer(
issue_queryset, many=True, fields=fields if fields else None
).data
return Response(issues, status=status.HTTP_200_OK)
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
),
)
class WorkspaceUserPropertiesEndpoint(BaseAPIView):
@ -397,6 +391,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
queryset = queryset.filter(project__in=projects)
return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer(

View File

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

View File

@ -1,46 +1,29 @@
# Python imports
import json
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Exists, F, Func, OuterRef, Q, Prefetch
# Django imports
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.permissions import AllowAny, IsAuthenticated
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.app.serializers import (
IssueCommentSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
IssuePublicSerializer,
)
# Third Party imports
from rest_framework.response import Response
from plane.app.serializers import (
CommentReactionSerializer,
IssueCommentSerializer,
IssuePublicSerializer,
IssueReactionSerializer,
IssueVoteSerializer,
)
from plane.db.models import (
Issue,
IssueComment,
Label,
IssueLink,
IssueAttachment,
State,
ProjectMember,
IssueReaction,
CommentReaction,
@ -49,8 +32,20 @@ from plane.db.models import (
ProjectPublicMember,
)
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.order_queryset import order_issue_queryset
from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
# Module imports
from .base import BaseAPIView, BaseViewSet
class IssueCommentPublicViewSet(BaseViewSet):
@ -535,17 +530,10 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
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
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
@ -576,7 +564,6 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@ -591,113 +578,118 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).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
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")
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# 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(),
# 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,
),
)
).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)
issues = IssuePublicSerializer(issue_queryset, many=True).data
state_group_order = [
"backlog",
"unstarted",
"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(),
# 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
),
)
.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
import logging
import traceback
# Django imports
from django.conf import settings
# Third party imports
from sentry_sdk import capture_exception
@ -11,6 +15,10 @@ def log_exception(e):
logger = logging.getLogger("plane")
logger.error(e)
# Log traceback if running in Debug
if settings.DEBUG:
logger.error(traceback.format_exc(e))
# Capture in sentry if configured
capture_exception(e)
return

View File

@ -1,240 +1,191 @@
def resolve_keys(group_keys, value):
"""resolve keys to a key which will be used for
grouping
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Q, UUIDField, Value
from django.db.models.functions import Coalesce
Args:
group_keys (string): key which will be used for grouping
value (obj): data value
Returns:
string: the key which will be used for
"""
keys = group_keys.split(".")
for key in keys:
value = value.get(key, None)
return value
# Module imports
from plane.db.models import (
Cycle,
Issue,
Label,
Module,
Project,
ProjectMember,
State,
WorkspaceMember,
)
def group_results(results_data, group_by, sub_group_by=False):
"""group results data into certain group_by
def issue_queryset_grouper(queryset, group_by, sub_group_by):
Args:
results_data (obj): complete results data
group_by (key): string
FIELD_MAPPER = {
"label_ids": "labels__id",
"assignee_ids": "assignees__id",
"module_ids": "issue_module__module_id",
}
Returns:
obj: grouped results
"""
if sub_group_by:
main_responsive_dict = dict()
annotations_map = {
"assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)),
"label_ids": ("labels__id", ~Q(labels__id__isnull=True)),
"module_ids": (
"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":
main_responsive_dict = {
"urgent": {},
"high": {},
"medium": {},
"low": {},
"none": {},
}
return queryset.annotate(**default_annotations)
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)]:
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)
def issue_on_results(issues, group_by, sub_group_by):
elif isinstance(group_attribute, list) and not isinstance(
main_group_attribute, list
):
if str(main_group_attribute) not in main_responsive_dict:
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)
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
"issue_module__module_id": "module_ids",
}
elif isinstance(group_attribute, list) and isinstance(
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)
original_list = ["assignee_ids", "label_ids", "module_ids"]
if str(main_group_attribute) not in main_responsive_dict:
main_responsive_dict[str(main_group_attribute)] = {}
required_fields = [
"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 (
str(group_attribute)
in main_responsive_dict[str(main_group_attribute)]
):
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)
if group_by in FIELD_MAPPER:
original_list.remove(FIELD_MAPPER[group_by])
original_list.append(group_by)
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:
response_dict = {}
required_fields.extend(original_list)
return issues.values(*required_fields)
if group_by == "priority":
response_dict = {
"urgent": [],
"high": [],
"medium": [],
"low": [],
"none": [],
}
for value in results_data:
group_attribute = resolve_keys(group_by, value)
if isinstance(group_attribute, list):
if len(group_attribute):
for attrib in group_attribute:
if str(attrib) in response_dict:
response_dict[str(attrib)].append(value)
else:
response_dict[str(attrib)] = []
response_dict[str(attrib)].append(value)
else:
if str(None) in response_dict:
response_dict[str(None)].append(value)
else:
response_dict[str(None)] = []
response_dict[str(None)].append(value)
else:
if str(group_attribute) in response_dict:
response_dict[str(group_attribute)].append(value)
else:
response_dict[str(group_attribute)] = []
response_dict[str(group_attribute)].append(value)
return response_dict
def issue_group_values(field, slug, project_id=None, filters=dict):
if field == "state_id":
queryset = State.objects.filter(
~Q(name="Triage"),
workspace__slug=slug,
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
if field == "labels__id":
queryset = Label.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 == "assignees__id":
if project_id:
return ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
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 uuid
from datetime import timedelta
from django.utils import timezone
# The date from pattern
@ -63,24 +64,27 @@ def date_filter(filter, date_term, queries):
"""
for query in queries:
date_query = query.split(";")
if len(date_query) >= 2:
match = pattern.match(date_query[0])
if match:
if len(date_query) == 3:
digit, term = date_query[0].split("_")
string_date_filter(
filter=filter,
duration=int(digit),
subsequent=date_query[1],
term=term,
date_filter=date_term,
offset=date_query[2],
)
else:
if "after" in date_query:
filter[f"{date_term}__gte"] = date_query[0]
if date_query:
if len(date_query) >= 2:
match = pattern.match(date_query[0])
if match:
if len(date_query) == 3:
digit, term = date_query[0].split("_")
string_date_filter(
filter=filter,
duration=int(digit),
subsequent=date_query[1],
term=term,
date_filter=date_term,
offset=date_query[2],
)
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=""):

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
from rest_framework.exceptions import ParseError
from collections.abc import Sequence
# Python imports
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:
# The cursor value
def __init__(self, value, offset=0, is_prev=False, has_results=None):
self.value = value
self.offset = int(offset)
self.is_prev = bool(is_prev)
self.has_results = has_results
# Return the cursor value in string format
def __str__(self):
return f"{self.value}:{self.offset}:{int(self.is_prev)}"
# Return the cursor value
def __eq__(self, other):
return all(
getattr(self, attr) == getattr(other, attr)
for attr in ("value", "offset", "is_prev", "has_results")
)
# Return the representation of the cursor
def __repr__(self):
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):
return bool(self.has_results)
@classmethod
def from_string(cls, value):
"""Return the cursor value from string format"""
try:
bits = value.split(":")
if len(bits) != 3:
@ -50,15 +66,19 @@ class CursorResult(Sequence):
self.max_hits = max_hits
def __len__(self):
# Return the length of the results
return len(self.results)
def __iter__(self):
# Return the iterator of the results
return iter(self.results)
def __getitem__(self, key):
# Return the results based on the key
return self.results[key]
def __repr__(self):
# Return the representation of the results
return f"<{type(self).__name__}: results={len(self.results)}>"
@ -85,11 +105,14 @@ class OffsetPaginator:
max_offset=None,
on_results=None,
):
# Key tuple and remove `-` if descending order by
self.key = (
order_by
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.max_limit = max_limit
self.max_offset = max_offset
@ -101,11 +124,101 @@ class OffsetPaginator:
if cursor is None:
cursor = Cursor(0, 0, 0)
# Get the min from limit and max limit
limit = min(limit, self.max_limit)
# queryset
queryset = self.queryset
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
offset = cursor.offset * cursor.value
@ -116,20 +229,73 @@ class OffsetPaginator:
if offset < 0:
raise BadPaginationError("Pagination offset cannot be negative")
results = list(queryset[offset:stop])
if cursor.value != limit:
results = results[-(limit + 1) :]
# Compute the results
results = {}
# 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)
prev_cursor = Cursor(limit, page - 1, True, page > 0)
results = list(results[:limit])
if self.on_results:
results = self.on_results(results)
# 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()
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(
results=results,
next=next_cursor,
@ -138,6 +304,393 @@ class OffsetPaginator:
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:
"""BasePaginator class can be inherited by any View to return a paginated view"""
@ -171,6 +724,11 @@ class BasePaginator:
cursor_cls=Cursor,
extra_stats=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,
):
"""Paginate the request"""
@ -178,15 +736,27 @@ class BasePaginator:
# Convert the cursor value to integer and float from string
input_cursor = None
if request.GET.get(self.cursor_name):
try:
input_cursor = cursor_cls.from_string(
request.GET.get(self.cursor_name)
)
except ValueError:
raise ParseError(detail="Invalid cursor parameter.")
try:
input_cursor = cursor_cls.from_string(
request.GET.get(self.cursor_name, f"{per_page}:0:0"),
)
except ValueError:
raise ParseError(detail="Invalid cursor parameter.")
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)
try:
@ -196,12 +766,14 @@ class BasePaginator:
except BadPaginationError:
raise ParseError(detail="Error in parsing")
# Serialize result according to the on_result function
if on_results:
results = on_results(cursor_result.results)
else:
results = cursor_result.results
if group_by_field_name:
results = paginator.process_results(results=results)
# Add Manipulation functions to the response
if controller is not None:
results = controller(results)
@ -211,6 +783,9 @@ class BasePaginator:
# Return the 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),
"prev_cursor": str(cursor_result.prev),
"next_page_results": cursor_result.next.has_results,

View File

@ -60,4 +60,5 @@ zxcvbn==4.4.28
# timezone
pytz==2024.1
# 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
export * from "./issue";
export * from "./issue_reaction";
@ -7,16 +10,30 @@ export * from "./issue_relation";
export * from "./issue_sub_issues";
export * from "./activity/base";
export type TLoader = "init-loader" | "mutation" | undefined;
export type TLoader = "init-loader" | "mutation" | "pagination" | undefined;
export type TGroupedIssues = {
[group_id: string]: string[];
};
export type TSubGroupedIssues = {
[sub_grouped_id: string]: {
[group_id: string]: string[];
};
[sub_grouped_id: string]: TGroupedIssues;
};
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";
// new issue structure types
export type TIssue = {
export type TBaseIssue = {
id: string;
sequence_id: number;
name: string;
description_html: string;
sort_order: number;
state_id: string;
priority: TIssuePriorities;
state_id: string | null;
priority: TIssuePriorities | null;
label_ids: string[];
assignee_ids: string[];
estimate_point: string | null;
@ -21,7 +21,7 @@ export type TIssue = {
attachment_count: number;
link_count: number;
project_id: string;
project_id: string | null;
parent_id: string | null;
cycle_id: string | null;
module_ids: string[] | null;
@ -37,9 +37,14 @@ export type TIssue = {
updated_by: string;
is_draft: boolean;
};
export type TIssue = TBaseIssue & {
description_html?: string;
is_subscribed?: boolean;
parent?: partial<TIssue>;
issue_reactions?: TIssueReaction[];
issue_attachment?: TIssueAttachment[];
issue_link?: TIssueLink[];
@ -51,3 +56,47 @@ export type TIssue = {
export type TIssueMap = {
[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;
}
export type TProfileViews = "assigned" | "created" | "subscribed";
// export interface ICurrentUser {
// id: readonly string;
// avatar: string;

View File

@ -1,3 +1,5 @@
import { EIssueLayoutTypes } from "constants/issue";
export type TIssueLayouts =
| "list"
| "kanban"
@ -13,9 +15,9 @@ export type TIssueGroupByOptions =
| "state_detail.group"
| "project"
| "assignees"
| "mentions"
| "cycle"
| "module"
| "target_date"
| null;
export type TIssueOrderByOptions =
@ -32,10 +34,10 @@ export type TIssueOrderByOptions =
| "-assignees__first_name"
| "labels__name"
| "-labels__name"
| "modules__name"
| "-modules__name"
| "cycle__name"
| "-cycle__name"
| "issue_module__module__name"
| "-issue_module__module__name"
| "issue_cycle__cycle__name"
| "-issue_cycle__cycle__name"
| "target_date"
| "-target_date"
| "estimate_point"
@ -72,7 +74,9 @@ export type TIssueParams =
| "order_by"
| "type"
| "sub_issue"
| "show_empty_groups";
| "show_empty_groups"
| "cursor"
| "per_page";
export type TCalendarLayouts = "month" | "week";
@ -82,9 +86,9 @@ export interface IIssueFilterOptions {
created_by?: string[] | null;
labels?: string[] | null;
priority?: string[] | null;
project?: string[] | null;
cycle?: string[] | null;
module?: string[] | null;
project?: string[] | null;
start_date?: string[] | null;
state?: string[] | null;
state_group?: string[] | null;
@ -99,7 +103,7 @@ export interface IIssueDisplayFilterOptions {
};
group_by?: TIssueGroupByOptions;
sub_group_by?: TIssueGroupByOptions;
layout?: TIssueLayouts;
layout?: EIssueLayoutTypes;
order_by?: TIssueOrderByOptions;
show_empty_groups?: boolean;
sub_issue?: boolean;
@ -191,3 +195,11 @@ export interface IWorkspaceGlobalViewProps {
display_filters: IWorkspaceIssueDisplayFilterOptions | undefined;
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 {
className?: string;
containerClassName?: string;
priority: TIssuePriorities;
priority: TIssuePriorities | undefined | null;
size?: number;
withContainer?: boolean;
}
@ -31,7 +31,7 @@ export const PriorityIcon: React.FC<IPriorityIcon> = (props) => {
low: SignalLow,
none: Ban,
};
const Icon = icons[priority];
const Icon = icons[priority ?? "none"];
if (!Icon) return null;
@ -41,7 +41,7 @@ export const PriorityIcon: React.FC<IPriorityIcon> = (props) => {
<div
className={cn(
"flex items-center justify-center border rounded p-0.5 flex-shrink-0",
priorityClasses[priority],
priorityClasses[priority ?? "none"],
containerClassName
)}
>

View File

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

View File

@ -1,11 +1,5 @@
import { TLogoProps } from "@plane/types";
export type TWorkspaceDetails = {
name: string;
slug: string;
id: string;
};
export type TViewDetails = {
list: boolean;
gantt: boolean;
@ -22,21 +16,3 @@ export type TProjectDetails = {
logo_props: TLogoProps;
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
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
// 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
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
@ -42,7 +48,7 @@ const ProfileIssuesMobileHeader = observer(() => {
workspaceSlug.toString(),
undefined,
EIssueFilterType.DISPLAY_FILTERS,
{ layout: layout },
{ layout: layout as EIssueLayoutTypes | undefined },
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
import { ArrowRight, PanelRight } from "lucide-react";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui
import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui";
// components
@ -15,7 +15,7 @@ import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// 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";
// helpers
import { cn } from "@/helpers/common.helper";
@ -70,7 +70,7 @@ const CycleIssuesHeader: React.FC = observer(() => {
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
issues: { issuesCount },
issues: { getGroupIssueCount },
} = useIssues(EIssuesStoreType.CYCLE);
const { currentProjectCycleIds, getCycleById } = useCycle();
const { toggleCreateIssueModal } = useCommandPalette();
@ -96,7 +96,7 @@ const CycleIssuesHeader: React.FC = observer(() => {
};
const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => {
(layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId) return;
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);
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
const issuesCount = getGroupIssueCount(undefined, undefined, false);
return (
<>
@ -231,7 +232,13 @@ const CycleIssuesHeader: React.FC = observer(() => {
</div>
<div className="hidden items-center gap-2 md:flex ">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>

View File

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

View File

@ -4,14 +4,14 @@ import { FC, useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui
import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// 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
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
@ -56,7 +56,7 @@ const ProjectDraftIssueHeader: FC = observer(() => {
);
const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => {
(layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId) return;
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">
<LayoutSelection
layouts={["list", "kanban"]}
layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>

View File

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

View File

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

View File

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

View File

@ -6,14 +6,14 @@ import { useParams } from "next/navigation";
// icons
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
// 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
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
@ -46,7 +46,7 @@ const ModuleIssuesMobileHeader = observer(() => {
} = useMember();
const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => {
(layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId) return;
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 { useParams } from "next/navigation";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui
import { Breadcrumbs, Button, CustomMenu, PhotoFilterIcon } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// 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";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
@ -52,7 +52,7 @@ const ProjectViewIssuesHeader: React.FC = observer(() => {
const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => {
(layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId || !viewId) return;
updateFilters(
workspaceSlug.toString(),
@ -202,7 +202,13 @@ const ProjectViewIssuesHeader: React.FC = observer(() => {
</div>
<div className="flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)}
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 { workspaceSlug, userId } = useParams();
const pathname = usePathname();
const pathname = usePathname();
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
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;
// auth
const canRestoreIssue = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
"use client";
import { FC, Fragment } from "react";
import { FC, Fragment, useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import useSWR from "swr";
@ -8,7 +8,7 @@ import { CalendarCheck } from "lucide-react";
// headless ui
import { Tab } from "@headlessui/react";
// types
import { ICycle, TIssue } from "@plane/types";
import { ICycle } from "@plane/types";
// ui
import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui";
// components
@ -23,7 +23,8 @@ import { EIssuesStoreType } from "@/constants/issue";
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper";
// 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";
export type ActiveCycleStatsProps = {
@ -37,6 +38,9 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
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) => {
switch (tab) {
case "Priority-Issues":
@ -50,17 +54,29 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
}
};
const {
issues: { fetchActiveCycleIssues },
issues: { getActiveCycleById, fetchActiveCycleIssues, fetchNextActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
const {
issue: { getIssueById },
} = useIssueDetail();
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 ? () => 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 (
<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"
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">
{cycleIssues ? (
cycleIssues.length > 0 ? (
cycleIssues.map((issue: TIssue) => (
<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"
>
<div className="flex items-center gap-1.5 flex-grow w-full min-w-24 truncate">
<PriorityIcon priority={issue.priority} withContainer size={12} />
<div
ref={issuesContainerRef}
className="flex flex-col gap-1 h-full w-full overflow-y-auto vertical-scrollbar scrollbar-sm"
>
{cycleIssueDetails && cycleIssueDetails.issueIds ? (
cycleIssueDetails.issueCount > 0 ? (
<>
{cycleIssueDetails.issueIds.map((issueId: string) => {
const issue = getIssueById(issueId);
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`}
if (!issue) return null;
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">
{currentProjectDetails?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span>
</Tooltip>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<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)}
<div className="flex items-center gap-1.5 flex-grow w-full min-w-24 truncate">
<PriorityIcon priority={issue.priority} withContainer size={12} />
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{currentProjectDetails?.identifier}-{issue.sequence_id}
</span>
</div>
</Tooltip>
)}
</div>
</Link>
))
</Tooltip>
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span>
</Tooltip>
</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">
<EmptyState

View File

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

View File

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

View File

@ -37,7 +37,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
const { setPeekIssue } = useIssueDetail();
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);

View File

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

View File

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

View File

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

View File

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

View File

@ -25,9 +25,9 @@ type Props = TDropdownProps & {
dropdownArrowClassName?: string;
onChange: (val: string) => void;
onClose?: () => void;
projectId: string;
projectId: string | undefined;
showDefaultState?: boolean;
value: string | undefined;
value: string | undefined | null;
};
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 onOpen = async () => {
if (!statesList && workspaceSlug) {
if (!statesList && workspaceSlug && projectId) {
setStateLoader(true);
await fetchProjectStates(workspaceSlug, projectId);
setStateLoader(false);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,13 @@
"use client";
import { MutableRefObject } from "react";
import { RefObject, MutableRefObject, useState } from "react";
import { observer } from "mobx-react";
// ui
import { Loader } from "@plane/ui";
// components
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 { GanttDnDHOC } from "../gantt-dnd-HOC";
import { handleOrderChange } from "../utils";
@ -14,54 +16,81 @@ import { IssuesSidebarBlock } from "./block";
type Props = {
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
getBlockById: (id: string) => IGanttBlock;
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
ganttContainerRef: RefObject<HTMLDivElement>;
blockIds: string[];
enableReorder: boolean;
enableSelection: boolean;
showAllBlocks?: boolean;
selectionHelpers?: TSelectionHelper;
};
export const IssueGanttSidebar: React.FC<Props> = (props) => {
const { blockUpdateHandler, blocks, enableReorder, enableSelection, showAllBlocks = false, selectionHelpers } = props;
export const IssueGanttSidebar: React.FC<Props> = observer((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 = (
draggingBlockId: string | undefined,
droppedBlockId: string | undefined,
dropAtEndOfList: boolean
) => {
handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blocks, blockUpdateHandler);
handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blockIds, getBlockById, blockUpdateHandler);
};
return (
<div>
{blocks ? (
blocks.map((block, index) => {
const isBlockVisibleOnSidebar = block.start_date && block.target_date;
{blockIds ? (
<>
{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
if (!showAllBlocks && !isBlockVisibleOnSidebar) return;
// hide the block if it doesn't have start and target dates and showAllBlocks is false
if (!block || (!showAllBlocks && !isBlockVisibleOnSidebar)) return;
return (
<GanttDnDHOC
key={block.id}
id={block.id}
isLastChild={index === blocks.length - 1}
isDragEnabled={enableReorder}
onDrop={handleOnDrop}
>
{(isDragging: boolean, dragHandleRef: MutableRefObject<HTMLButtonElement | null>) => (
<IssuesSidebarBlock
block={block}
enableReorder={enableReorder}
enableSelection={enableSelection}
isDragging={isDragging}
dragHandleRef={dragHandleRef}
selectionHelpers={selectionHelpers}
/>
)}
</GanttDnDHOC>
);
})
return (
<GanttDnDHOC
key={block.id}
id={block.id}
isLastChild={index === blockIds.length - 1}
isDragEnabled={enableReorder}
onDrop={handleOnDrop}
>
{(isDragging: boolean, dragHandleRef: MutableRefObject<HTMLButtonElement | null>) => (
<IssuesSidebarBlock
block={block}
enableReorder={enableReorder}
enableSelection={enableSelection}
isDragging={isDragging}
dragHandleRef={dragHandleRef}
selectionHelpers={selectionHelpers}
/>
)}
</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.Item height="34px" />
@ -72,4 +101,4 @@ export const IssueGanttSidebar: React.FC<Props> = (props) => {
)}
</div>
);
};
});

View File

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

View File

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

View File

@ -1,38 +1,38 @@
import { IBlockUpdateData, IGanttBlock } from "../types";
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types";
export const handleOrderChange = (
draggingBlockId: string | undefined,
droppedBlockId: string | undefined,
dropAtEndOfList: boolean,
blocks: IGanttBlock[] | null,
blockIds: string[] | null,
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock,
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 destinationBlockIndex = dropAtEndOfList
? blocks.length
: blocks.findIndex((block) => block.id === droppedBlockId);
const sourceBlockIndex = blockIds.findIndex((id) => id === draggingBlockId);
const destinationBlockIndex = dropAtEndOfList ? blockIds.length : blockIds.findIndex((id) => id === droppedBlockId);
// return if dropped outside the list
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
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
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
else {
const destinationSortingOrder = blocks[destinationBlockIndex].sort_order;
const relativeDestinationSortingOrder = blocks[destinationBlockIndex - 1].sort_order;
const destinationSortingOrder = getBlockById(blockIds[destinationBlockIndex])?.sort_order;
const relativeDestinationSortingOrder = getBlockById(blockIds[destinationBlockIndex - 1])?.sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// 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: {
destinationIndex: destinationBlockIndex,
newSortOrder: updatedSortOrder,

View File

@ -98,7 +98,7 @@ export const InboxIssueContentProperties: React.FC<Props> = observer((props) =>
<span>Priority</span>
</div>
<PriorityDropdown
value={issue?.priority || "none"}
value={issue?.priority}
onChange={(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 */}
<div className="h-7">
<StateDropdown
value={data?.state_id || ""}
value={data?.state_id}
onChange={(stateId) => handleData("state_id", stateId)}
projectId={projectId}
buttonVariant="border-with-text"
@ -59,7 +59,7 @@ export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props)
{/* priority */}
<div className="h-7">
<PriorityDropdown
value={data?.priority || "none"}
value={data?.priority}
onChange={(priority) => handleData("priority", priority)}
buttonVariant="border-with-text"
/>

View File

@ -1,22 +1,23 @@
"use client";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { Search } from "lucide-react";
import { Combobox, Dialog, Transition } from "@headlessui/react";
// hooks
// icons
// components
// types
import { ISearchIssueResponse } from "@plane/types";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
import { EmptyState } from "@/components/empty-state";
// services
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { PROJECT_ISSUES_LIST } from "@/constants/fetch-keys";
import { useProject, useProjectState } from "@/hooks/store";
import { IssueService } from "@/services/issue";
// hooks
import { useProject } from "@/hooks/store";
import useDebounce from "@/hooks/use-debounce";
// services
import { ProjectService } from "@/services/project";
type Props = {
isOpen: boolean;
@ -25,7 +26,7 @@ type Props = {
onSubmit: (issueId: string) => void;
};
const issueService = new IssueService();
const projectService = new ProjectService();
export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
const { isOpen, onClose, onSubmit, value } = props;
@ -35,18 +36,27 @@ export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId } = useParams();
// hooks
const { getProjectStates } = useProjectState();
const { getProjectById } = useProject();
const { data: issues } = useSWR(
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
workspaceSlug && projectId
? () =>
issueService
.getIssues(workspaceSlug as string, projectId as string)
.then((res) => Object.values(res ?? {}).filter((issue) => issue.id !== issueId))
: null
);
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
const [isSearching, setIsSearching] = useState(false);
const debouncedSearchTerm: string = useDebounce(query, 500);
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 filteredIssues = issues.filter((issue) => issue.id !== issueId);
const handleClose = () => {
onClose();
@ -62,7 +72,52 @@ export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
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 (
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
@ -110,56 +165,15 @@ export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
static
className="max-h-80 scroll-py-2 divide-y divide-custom-border-200 overflow-y-auto"
>
{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 =
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>
{isSearching ? (
<Loader className="space-y-3 p-3">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<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>
<>{issueList}</>
)}
</Combobox.Options>
</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]);
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);

View File

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

View File

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

View File

@ -1,18 +1,19 @@
"use client";
import { FC } from "react";
import { FC, useCallback, useEffect } from "react";
import { observer } from "mobx-react-lite";
import { useParams } from "next/navigation";
import { TGroupedIssues } from "@plane/types";
// components
import { TOAST_TYPE, setToast } from "@plane/ui";
import { CalendarChart } from "@/components/issues";
// hooks
import { EIssuesStoreType } from "@/constants/issue";
//constants
import { EIssuesStoreType, EIssueGroupByToServerOptions } from "@/constants/issue";
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";
// ui
// types
import { IQuickActionProps } from "../list/list-view-types";
import { handleDragDrop } from "./utils";
@ -25,25 +26,36 @@ type CalendarStoreType =
interface IBaseCalendarRoot {
QuickActions: FC<IQuickActionProps>;
storeType: CalendarStoreType;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
viewId?: string;
isCompletedCycle?: boolean;
viewId?: string | undefined;
}
export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
const { QuickActions, storeType, addIssuesToView, viewId, isCompletedCycle = false } = props;
const { QuickActions, addIssuesToView, isCompletedCycle = false, viewId } = props;
// router
const { workspaceSlug, projectId } = useParams();
// hooks
const storeType = useIssueStoreType() as CalendarStoreType;
const {
membership: { currentProjectRole },
} = useUser();
const { issues, issuesFilter, issueMap } = useIssues(storeType);
const { updateIssue, removeIssue, removeIssueFromView, archiveIssue, restoreIssue, updateFilters } =
useIssuesActions(storeType);
const {
fetchIssues,
fetchNextIssues,
quickAddIssue,
updateIssue,
removeIssue,
removeIssueFromView,
archiveIssue,
restoreIssue,
updateFilters,
} = useIssuesActions(storeType);
const issueCalendarView = useCalendarView();
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
@ -51,6 +63,26 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
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 (
issueId: 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 (
<>
<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}
layout={displayFilters?.calendar?.layout}
showWeekends={displayFilters?.calendar?.show_weekends ?? false}
issueCalendarView={issueCalendarView}
quickActions={({ issue, parentRef, customActionButton, placement }) => (
<QuickActions
parentRef={parentRef}
@ -97,14 +147,16 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
placements={placement}
/>
)}
loadMoreIssues={loadMoreIssues}
getPaginationData={getPaginationData}
getGroupIssueCount={getGroupIssueCount}
addIssuesToView={addIssuesToView}
quickAddCallback={issues.quickAddIssue}
viewId={viewId}
quickAddCallback={quickAddIssue}
readOnly={!isEditingAllowed || isCompletedCycle}
updateFilters={updateFilters}
handleDragAndDrop={handleDragAndDrop}
/>
</div>
</div>
</>
);
});

View File

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

View File

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

View File

@ -1,33 +1,26 @@
import { Dispatch, SetStateAction } from "react";
import { observer } from "mobx-react-lite";
// types
import { TIssue, TIssueMap } from "@plane/types";
import { TIssue, TIssueMap, TPaginationData } from "@plane/types";
// components
import { CalendarQuickAddIssueForm, CalendarIssueBlockRoot } from "@/components/issues";
// helpers
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
import { TRenderQuickActions } from "../list/list-view-types";
// types
type Props = {
date: Date;
issues: TIssueMap | undefined;
issueIdList: string[] | null;
showAllIssues: boolean;
setShowAllIssues?: Dispatch<SetStateAction<boolean>>;
isMonthLayout: boolean;
loadMoreIssues: (dateString: string) => void;
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
issueIdList: string[];
quickActions: TRenderQuickActions;
isDragDisabled?: boolean;
enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean;
quickAddCallback?: (
workspaceSlug: string,
projectId: string,
data: TIssue,
viewId?: string
) => Promise<TIssue | undefined>;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
viewId?: string;
readOnly?: boolean;
isMobileView?: boolean;
};
@ -37,28 +30,36 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
date,
issues,
issueIdList,
showAllIssues,
setShowAllIssues,
quickActions,
loadMoreIssues,
isDragDisabled = false,
enableQuickIssueCreate,
disableIssueCreation,
quickAddCallback,
addIssuesToView,
viewId,
readOnly,
isMonthLayout,
isMobileView = false,
} = props;
const formattedDatePayload = renderFormattedPayloadDate(date);
const totalIssues = issueIdList?.length ?? 0;
const {
issues: { getGroupIssueCount, getPaginationData, getIssueLoader },
} = useIssuesStore();
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 (
<>
{issueIdList?.slice(0, showAllIssues || !isMonthLayout ? issueIdList.length : 4).map((issueId) => (
{issueIdList?.map((issueId) => (
<div key={issueId} className="relative cursor-pointer p-1 px-2">
<CalendarIssueBlockRoot
issues={issues}
@ -68,17 +69,13 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
/>
</div>
))}
{totalIssues > 4 && isMonthLayout && (
<div className="hidden items-center px-2.5 py-1 md:flex">
<button
type="button"
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>
{isPaginating && (
<div className="p-1 px-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>
)}
{enableQuickIssueCreate && !disableIssueCreation && !readOnly && (
<div className="border-b border-custom-border-200 px-1 py-1 md:border-none md:px-2">
<CalendarQuickAddIssueForm
@ -89,11 +86,21 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
}}
quickAddCallback={quickAddCallback}
addIssuesToView={addIssuesToView}
viewId={viewId}
onOpen={() => setShowAllIssues && setShowAllIssues(true)}
/>
</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;
subGroupId?: string | null;
prePopulatedData?: Partial<TIssue>;
quickAddCallback?: (
workspaceSlug: string,
projectId: string,
data: TIssue,
viewId?: string
) => Promise<TIssue | undefined>;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
viewId?: string;
onOpen?: () => void;
};
@ -66,7 +60,7 @@ const Inputs = (props: any) => {
};
export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
const { formKey, prePopulatedData, quickAddCallback, addIssuesToView, viewId, onOpen } = props;
const { formKey, prePopulatedData, quickAddCallback, addIssuesToView, onOpen } = props;
// router
const { workspaceSlug, projectId, moduleId } = useParams();
@ -133,14 +127,9 @@ export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
});
if (quickAddCallback) {
const quickAddPromise = quickAddCallback(
workspaceSlug.toString(),
projectId.toString(),
{
...payload,
},
viewId
);
const quickAddPromise = quickAddCallback(projectId.toString(), {
...payload,
});
setPromiseToast<any>(quickAddPromise, {
loading: "Adding issue...",
success: {

View File

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

View File

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

View File

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

View File

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

View File

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

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