diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 3814466322e..48e7f6d1f67 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -207,8 +207,7 @@ def get(self, request, slug, project_id, pk=None): # Incomplete Cycles if cycle_view == "incomplete": queryset = queryset.filter( - Q(end_date__gte=timezone.now().date()) - | Q(end_date__isnull=True), + Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True), ) return self.paginate( request=request, @@ -309,10 +308,7 @@ def patch(self, request, slug, project_id, pk): request_data = request.data - if ( - cycle.end_date is not None - and cycle.end_date < timezone.now().date() - ): + if cycle.end_date is not None and cycle.end_date < timezone.now(): if "sort_order" in request_data: # Can only change sort order request_data = { @@ -537,7 +533,7 @@ def post(self, request, slug, project_id, cycle_id): cycle = Cycle.objects.get( pk=cycle_id, project_id=project_id, workspace__slug=slug ) - if cycle.end_date >= timezone.now().date(): + if cycle.end_date >= timezone.now(): return Response( {"error": "Only completed cycles can be archived"}, status=status.HTTP_400_BAD_REQUEST, @@ -1146,7 +1142,7 @@ def post(self, request, slug, project_id, cycle_id): if ( new_cycle.end_date is not None - and new_cycle.end_date < timezone.now().date() + and new_cycle.end_date < timezone.now() ): return Response( { diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 618a9ec20f1..b3c3d794994 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -124,3 +124,9 @@ from .dashboard import DashboardSerializer, WidgetSerializer from .favorite import UserFavoriteSerializer + +from .draft import ( + DraftIssueCreateSerializer, + DraftIssueSerializer, + DraftIssueDetailSerializer, +) diff --git a/apiserver/plane/app/serializers/draft.py b/apiserver/plane/app/serializers/draft.py new file mode 100644 index 00000000000..2128b927d22 --- /dev/null +++ b/apiserver/plane/app/serializers/draft.py @@ -0,0 +1,290 @@ +# Django imports +from django.utils import timezone + +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from plane.db.models import ( + User, + Issue, + Label, + State, + DraftIssue, + DraftIssueAssignee, + DraftIssueLabel, + DraftIssueCycle, + DraftIssueModule, +) + + +class DraftIssueCreateSerializer(BaseSerializer): + # ids + state_id = serializers.PrimaryKeyRelatedField( + source="state", + queryset=State.objects.all(), + required=False, + allow_null=True, + ) + parent_id = serializers.PrimaryKeyRelatedField( + source="parent", + queryset=Issue.objects.all(), + required=False, + allow_null=True, + ) + label_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + write_only=True, + required=False, + ) + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = DraftIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + assignee_ids = self.initial_data.get("assignee_ids") + data["assignee_ids"] = assignee_ids if assignee_ids else [] + label_ids = self.initial_data.get("label_ids") + data["label_ids"] = label_ids if label_ids else [] + return data + + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("target_date", None) is not None + and data.get("start_date", None) > data.get("target_date", None) + ): + raise serializers.ValidationError( + "Start date cannot exceed target date" + ) + return data + + def create(self, validated_data): + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) + modules = validated_data.pop("module_ids", None) + cycle_id = self.initial_data.get("cycle_id", None) + modules = self.initial_data.get("module_ids", None) + + workspace_id = self.context["workspace_id"] + project_id = self.context["project_id"] + + # Create Issue + issue = DraftIssue.objects.create( + **validated_data, + workspace_id=workspace_id, + project_id=project_id, + ) + + # Issue Audit Users + created_by_id = issue.created_by_id + updated_by_id = issue.updated_by_id + + if assignees is not None and len(assignees): + DraftIssueAssignee.objects.bulk_create( + [ + DraftIssueAssignee( + assignee=user, + draft_issue=issue, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for user in assignees + ], + batch_size=10, + ) + + if labels is not None and len(labels): + DraftIssueLabel.objects.bulk_create( + [ + DraftIssueLabel( + label=label, + draft_issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + if cycle_id is not None: + DraftIssueCycle.objects.create( + cycle_id=cycle_id, + draft_issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + + if modules is not None and len(modules): + DraftIssueModule.objects.bulk_create( + [ + DraftIssueModule( + module_id=module_id, + draft_issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for module_id in modules + ], + batch_size=10, + ) + + return issue + + def update(self, instance, validated_data): + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) + cycle_id = self.context.get("cycle_id", None) + modules = self.initial_data.get("module_ids", None) + + # Related models + workspace_id = instance.workspace_id + project_id = instance.project_id + + created_by_id = instance.created_by_id + updated_by_id = instance.updated_by_id + + if assignees is not None: + DraftIssueAssignee.objects.filter(draft_issue=instance).delete() + DraftIssueAssignee.objects.bulk_create( + [ + DraftIssueAssignee( + assignee=user, + draft_issue=instance, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for user in assignees + ], + batch_size=10, + ) + + if labels is not None: + DraftIssueLabel.objects.filter(draft_issue=instance).delete() + DraftIssueLabel.objects.bulk_create( + [ + DraftIssueLabel( + label=label, + draft_issue=instance, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + if cycle_id != "not_provided": + DraftIssueCycle.objects.filter(draft_issue=instance).delete() + if cycle_id is not None: + DraftIssueCycle.objects.create( + cycle_id=cycle_id, + draft_issue=instance, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + + if modules is not None: + DraftIssueModule.objects.filter(draft_issue=instance).delete() + DraftIssueModule.objects.bulk_create( + [ + DraftIssueModule( + module_id=module_id, + draft_issue=instance, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for module_id in modules + ], + batch_size=10, + ) + + # Time updation occurs even when other related models are updated + instance.updated_at = timezone.now() + return super().update(instance, validated_data) + + +class DraftIssueSerializer(BaseSerializer): + # ids + cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) + module_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + ) + + # Many to many + label_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + ) + assignee_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + ) + + class Meta: + model = DraftIssue + fields = [ + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = fields + + +class DraftIssueDetailSerializer(DraftIssueSerializer): + description_html = serializers.CharField() + + class Meta(DraftIssueSerializer.Meta): + fields = DraftIssueSerializer.Meta.fields + [ + "description_html", + ] + read_only_fields = fields diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 564725e8391..e6007862fda 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -11,7 +11,6 @@ IssueActivityEndpoint, IssueArchiveViewSet, IssueCommentViewSet, - IssueDraftViewSet, IssueListEndpoint, IssueReactionViewSet, IssueRelationViewSet, @@ -290,28 +289,6 @@ name="issue-relation", ), ## End Issue Relation - ## Issue Drafts - path( - "workspaces//projects//issue-drafts/", - IssueDraftViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-draft", - ), - path( - "workspaces//projects//issue-drafts//", - IssueDraftViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-draft", - ), path( "workspaces//projects//deleted-issues/", DeletedIssuesListViewSet.as_view(), diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index 3f1e000e473..fb6f4c13acc 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -27,6 +27,7 @@ WorkspaceCyclesEndpoint, WorkspaceFavoriteEndpoint, WorkspaceFavoriteGroupEndpoint, + WorkspaceDraftIssueViewSet, ) @@ -254,4 +255,30 @@ WorkspaceFavoriteGroupEndpoint.as_view(), name="workspace-user-favorites-groups", ), + path( + "workspaces//draft-issues/", + WorkspaceDraftIssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="workspace-draft-issues", + ), + path( + "workspaces//draft-issues//", + WorkspaceDraftIssueViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="workspace-drafts-issues", + ), + path( + "workspaces//draft-to-issue//", + WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}), + name="workspace-drafts-issues", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 6c4cc12c894..872b511a049 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -40,6 +40,8 @@ ExportWorkspaceUserActivityEndpoint, ) +from .workspace.draft import WorkspaceDraftIssueViewSet + from .workspace.favorite import ( WorkspaceFavoriteEndpoint, WorkspaceFavoriteGroupEndpoint, @@ -133,8 +135,6 @@ CommentReactionViewSet, ) -from .issue.draft import IssueDraftViewSet - from .issue.label import ( LabelViewSet, BulkCreateIssueLabelsEndpoint, diff --git a/apiserver/plane/app/views/cycle/archive.py b/apiserver/plane/app/views/cycle/archive.py index 25ad8a2eb6e..9d7f79b0ec1 100644 --- a/apiserver/plane/app/views/cycle/archive.py +++ b/apiserver/plane/app/views/cycle/archive.py @@ -604,7 +604,7 @@ def post(self, request, slug, project_id, cycle_id): pk=cycle_id, project_id=project_id, workspace__slug=slug ) - if cycle.end_date >= timezone.now().date(): + if cycle.end_date >= timezone.now(): return Response( {"error": "Only completed cycles can be archived"}, status=status.HTTP_400_BAD_REQUEST, diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index fc04abe35d2..3a372d36ded 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -187,6 +187,7 @@ def list(self, request, slug, project_id): "completed_issues", "assignee_ids", "status", + "version", "created_by", ) @@ -216,6 +217,7 @@ def list(self, request, slug, project_id): "completed_issues", "assignee_ids", "status", + "version", "created_by", ) return Response(data, status=status.HTTP_200_OK) @@ -255,6 +257,7 @@ def create(self, request, slug, project_id): "external_id", "progress_snapshot", "logo_props", + "version", # meta fields "is_favorite", "total_issues", @@ -306,10 +309,7 @@ def partial_update(self, request, slug, project_id, pk): request_data = request.data - if ( - cycle.end_date is not None - and cycle.end_date < timezone.now().date() - ): + if cycle.end_date is not None and cycle.end_date < timezone.now(): if "sort_order" in request_data: # Can only change sort order for a completed cycle`` request_data = { @@ -347,6 +347,7 @@ def partial_update(self, request, slug, project_id, pk): "external_id", "progress_snapshot", "logo_props", + "version", # meta fields "is_favorite", "total_issues", @@ -412,6 +413,7 @@ def retrieve(self, request, slug, project_id, pk): "progress_snapshot", "sub_issues", "logo_props", + "version", # meta fields "is_favorite", "total_issues", @@ -925,7 +927,7 @@ def post(self, request, slug, project_id, cycle_id): if ( new_cycle.end_date is not None - and new_cycle.end_date < timezone.now().date() + and new_cycle.end_date < timezone.now() ): return Response( { @@ -1148,6 +1150,7 @@ def get(self, request, slug, project_id, cycle_id): status=status.HTTP_200_OK, ) + class CycleAnalyticsEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index 211f5a88acf..6be2c9ea943 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -248,7 +248,7 @@ def create(self, request, slug, project_id, cycle_id): if ( cycle.end_date is not None - and cycle.end_date < timezone.now().date() + and cycle.end_date < timezone.now() ): return Response( { diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py index 4a760ca3b14..6a72fed2898 100644 --- a/apiserver/plane/app/views/dashboard/base.py +++ b/apiserver/plane/app/views/dashboard/base.py @@ -40,8 +40,6 @@ IssueLink, IssueRelation, Project, - ProjectMember, - User, Widget, WorkspaceMember, ) diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py deleted file mode 100644 index c5899d9725d..00000000000 --- a/apiserver/plane/app/views/issue/draft.py +++ /dev/null @@ -1,410 +0,0 @@ -# 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 ( - Exists, - F, - Func, - OuterRef, - Prefetch, - Q, - UUIDField, - Value, -) -from django.db.models.functions import Coalesce -from django.utils import timezone -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page - -# Third Party imports -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, - IssueDetailSerializer, - IssueFlatSerializer, - IssueSerializer, -) -from plane.bgtasks.issue_activities_task import issue_activity -from plane.db.models import ( - Issue, - IssueAttachment, - IssueLink, - IssueReaction, - IssueSubscriber, - Project, - ProjectMember, -) -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 BaseViewSet - - -class IssueDraftViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] - serializer_class = IssueFlatSerializer - model = Issue - - def get_queryset(self): - return ( - Issue.objects.filter(project_id=self.kwargs.get("project_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(is_draft=True) - .filter(deleted_at__isnull=True) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ).distinct() - - @method_decorator(gzip_page) - def list(self, request, slug, project_id): - filters = issue_filters(request.query_params, "GET") - - 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, - ) - - # 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): - project = Project.objects.get(pk=project_id) - - serializer = IssueCreateSerializer( - data=request.data, - context={ - "project_id": project_id, - "workspace_id": project.workspace_id, - "default_assignee_id": project.default_assignee_id, - }, - ) - - if serializer.is_valid(): - serializer.save(is_draft=True) - - # Track the issue - issue_activity.delay( - type="issue_draft.activity.created", - requested_data=json.dumps( - self.request.data, cls=DjangoJSONEncoder - ), - actor_id=str(request.user.id), - issue_id=str(serializer.data.get("id", None)), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) - - issue = ( - 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): - issue = self.get_queryset().filter(pk=pk).first() - - if not issue: - return Response( - {"error": "Issue does not exist"}, - status=status.HTTP_404_NOT_FOUND, - ) - - serializer = IssueCreateSerializer( - issue, data=request.data, partial=True - ) - - if serializer.is_valid(): - serializer.save() - issue_activity.delay( - type="issue_draft.activity.updated", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("pk", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - IssueSerializer(issue).data, - cls=DjangoJSONEncoder, - ), - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) - return Response(status=status.HTTP_204_NO_CONTENT) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def retrieve(self, request, slug, project_id, pk=None): - 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", - queryset=IssueReaction.objects.select_related( - "issue", "actor" - ), - ) - ) - .prefetch_related( - Prefetch( - "issue_attachment", - queryset=IssueAttachment.objects.select_related("issue"), - ) - ) - .prefetch_related( - Prefetch( - "issue_link", - queryset=IssueLink.objects.select_related("created_by"), - ) - ) - .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_id=OuterRef("pk"), - subscriber=request.user, - ) - ) - ) - ).first() - - if not issue: - return Response( - {"error": "The required object does not exist."}, - status=status.HTTP_404_NOT_FOUND, - ) - serializer = IssueDetailSerializer(issue, expand=self.expand) - return Response(serializer.data, status=status.HTTP_200_OK) - - def destroy(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) - if issue.created_by_id != request.user.id and ( - not ProjectMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=20, - project_id=project_id, - is_active=True, - ).exists() - ): - return Response( - {"error": "Only admin or creator can delete the issue"}, - status=status.HTTP_403_FORBIDDEN, - ) - issue.delete() - issue_activity.delay( - type="issue_draft.activity.deleted", - requested_data=json.dumps({"issue_id": str(pk)}), - actor_id=str(request.user.id), - issue_id=str(pk), - project_id=str(project_id), - current_instance={}, - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 6a9afb65237..f5ddb224587 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -413,9 +413,20 @@ def create(self, request, slug): status=status.HTTP_410_GONE, ) - @allow_permission([ROLE.ADMIN]) def partial_update(self, request, slug, pk=None): try: + if not ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + project_id=pk, + role=20, + is_active=True, + ).exists(): + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) + workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(pk=pk) diff --git a/apiserver/plane/app/views/workspace/draft.py b/apiserver/plane/app/views/workspace/draft.py new file mode 100644 index 00000000000..ad543a75635 --- /dev/null +++ b/apiserver/plane/app/views/workspace/draft.py @@ -0,0 +1,412 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +from django.core import serializers +from django.core.serializers.json import DjangoJSONEncoder +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import ( + F, + 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 + +# Third Party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import allow_permission, ROLE +from plane.app.serializers import ( + IssueCreateSerializer, + DraftIssueCreateSerializer, + DraftIssueSerializer, + DraftIssueDetailSerializer, +) +from plane.db.models import ( + Issue, + DraftIssue, + CycleIssue, + ModuleIssue, + DraftIssueModule, + DraftIssueCycle, + Workspace, +) +from .. import BaseViewSet +from plane.bgtasks.issue_activities_task import issue_activity +from plane.utils.issue_filters import issue_filters + + +class WorkspaceDraftIssueViewSet(BaseViewSet): + + model = DraftIssue + + @method_decorator(gzip_page) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def list(self, request, slug): + filters = issue_filters(request.query_params, "GET") + issues = ( + DraftIssue.objects.filter(workspace__slug=slug) + .filter(created_by=request.user) + .select_related("workspace", "project", "state", "parent") + .prefetch_related( + "assignees", "labels", "draft_issue_module__module" + ) + .annotate(cycle_id=F("draft_issue_cycle__cycle_id")) + .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( + "draft_issue_module__module_id", + distinct=True, + filter=~Q(draft_issue_module__module_id__isnull=True) + & Q( + draft_issue_module__module__archived_at__isnull=True + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .order_by("-created_at") + ) + + issues = issues.filter(**filters) + # List Paginate + return self.paginate( + request=request, + queryset=(issues), + on_results=lambda issues: DraftIssueSerializer( + issues, + many=True, + ).data, + ) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + + serializer = DraftIssueCreateSerializer( + data=request.data, + context={ + "workspace_id": workspace.id, + "project_id": request.data.get("project_id", None), + }, + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], + creator=True, + model=Issue, + level="WORKSPACE", + ) + def partial_update(self, request, slug, pk): + issue = ( + DraftIssue.objects.filter(workspace__slug=slug) + .filter(pk=pk) + .filter(created_by=request.user) + .select_related("workspace", "project", "state", "parent") + .prefetch_related( + "assignees", "labels", "draft_issue_module__module" + ) + .annotate(cycle_id=F("draft_issue_cycle__cycle_id")) + .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( + "draft_issue_module__module_id", + distinct=True, + filter=~Q(draft_issue_module__module_id__isnull=True) + & Q( + draft_issue_module__module__archived_at__isnull=True + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .first() + ) + + if not issue: + return Response( + {"error": "Issue not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = DraftIssueCreateSerializer( + issue, + data=request.data, + partial=True, + context={ + "project_id": request.data.get("project_id", None), + "cycle_id": request.data.get("cycle_id", "not_provided"), + }, + ) + + if serializer.is_valid(): + serializer.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission( + allowed_roles=[ROLE.ADMIN], + creator=True, + model=Issue, + level="WORKSPACE", + ) + def retrieve(self, request, slug, pk=None): + issue = ( + DraftIssue.objects.filter(workspace__slug=slug) + .filter(pk=pk) + .filter(created_by=request.user) + .select_related("workspace", "project", "state", "parent") + .prefetch_related( + "assignees", "labels", "draft_issue_module__module" + ) + .annotate(cycle_id=F("draft_issue_cycle__cycle_id")) + .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( + "draft_issue_module__module_id", + distinct=True, + filter=~Q(draft_issue_module__module_id__isnull=True) + & Q( + draft_issue_module__module__archived_at__isnull=True + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ).first() + + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = DraftIssueDetailSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission( + allowed_roles=[ROLE.ADMIN], + creator=True, + model=Issue, + level="WORKSPACE", + ) + def destroy(self, request, slug, pk=None): + draft_issue = DraftIssue.objects.get(workspace__slug=slug, pk=pk) + draft_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], + level="WORKSPACE", + ) + def create_draft_to_issue(self, request, slug, draft_id): + draft_issue = ( + DraftIssue.objects.filter(workspace__slug=slug, pk=draft_id) + .annotate(cycle_id=F("draft_issue_cycle__cycle_id")) + .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( + "draft_issue_module__module_id", + distinct=True, + filter=~Q(draft_issue_module__module_id__isnull=True) + & Q( + draft_issue_module__module__archived_at__isnull=True + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .select_related("project", "workspace") + .first() + ) + + if not draft_issue.project_id: + return Response( + {"error": "Project is required to create an issue."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IssueCreateSerializer( + data=request.data, + context={ + "project_id": draft_issue.project_id, + "workspace_id": draft_issue.project.workspace_id, + "default_assignee_id": draft_issue.project.default_assignee_id, + }, + ) + + if serializer.is_valid(): + serializer.save() + + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), + actor_id=str(request.user.id), + issue_id=str(serializer.data.get("id", None)), + project_id=str(draft_issue.project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + if draft_issue.cycle_id: + created_records = CycleIssue.objects.create( + cycle_id=draft_issue.cycle_id, + issue_id=serializer.data.get("id", None), + project_id=draft_issue.project_id, + workspace_id=draft_issue.workspace_id, + created_by_id=draft_issue.created_by_id, + updated_by_id=draft_issue.updated_by_id, + ) + # Capture Issue Activity + issue_activity.delay( + type="cycle.activity.created", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "updated_cycle_issues": None, + "created_cycle_issues": serializers.serialize( + "json", created_records + ), + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + if draft_issue.module_ids: + # bulk create the module + ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + module_id=module, + issue_id=serializer.data.get("id", None), + workspace_id=draft_issue.workspace_id, + project_id=draft_issue.project_id, + created_by_id=draft_issue.created_by_id, + updated_by_id=draft_issue.updated_by_id, + ) + for module in draft_issue.module_ids + ], + batch_size=10, + ) + # Bulk Update the activity + _ = [ + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"module_id": str(module)}), + actor_id=str(request.user.id), + issue_id=serializer.data.get("id", None), + project_id=draft_issue.project_id, + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + for module in draft_issue.module_ids + ] + + # delete the draft issue + draft_issue.delete() + + # delete the draft issue module + DraftIssueModule.objects.filter(draft_issue=draft_issue).delete() + + # delete the draft issue cycle + DraftIssueCycle.objects.filter(draft_issue=draft_issue).delete() + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/workspace/user.py b/apiserver/plane/app/views/workspace/user.py index 5c173f2021f..15e2d01da03 100644 --- a/apiserver/plane/app/views/workspace/user.py +++ b/apiserver/plane/app/views/workspace/user.py @@ -504,7 +504,7 @@ def get(self, request, slug, user_id): upcoming_cycles = CycleIssue.objects.filter( workspace__slug=slug, - cycle__start_date__gt=timezone.now().date(), + cycle__start_date__gt=timezone.now(), issue__assignees__in=[ user_id, ], @@ -512,8 +512,8 @@ def get(self, request, slug, user_id): present_cycle = CycleIssue.objects.filter( workspace__slug=slug, - cycle__start_date__lt=timezone.now().date(), - cycle__end_date__gt=timezone.now().date(), + cycle__start_date__lt=timezone.now(), + cycle__end_date__gt=timezone.now(), issue__assignees__in=[ user_id, ], diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 8e648c16b0b..e7ca16a984b 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -42,14 +42,12 @@ def archive_old_issues(): ), Q(issue_cycle__isnull=True) | ( - Q(issue_cycle__cycle__end_date__lt=timezone.now().date()) + Q(issue_cycle__cycle__end_date__lt=timezone.now()) & Q(issue_cycle__isnull=False) ), Q(issue_module__isnull=True) | ( - Q( - issue_module__module__target_date__lt=timezone.now().date() - ) + Q(issue_module__module__target_date__lt=timezone.now()) & Q(issue_module__isnull=False) ), ).filter( @@ -122,14 +120,12 @@ def close_old_issues(): ), Q(issue_cycle__isnull=True) | ( - Q(issue_cycle__cycle__end_date__lt=timezone.now().date()) + Q(issue_cycle__cycle__end_date__lt=timezone.now()) & Q(issue_cycle__isnull=False) ), Q(issue_module__isnull=True) | ( - Q( - issue_module__module__target_date__lt=timezone.now().date() - ) + Q(issue_module__module__target_date__lt=timezone.now()) & Q(issue_module__isnull=False) ), ).filter( diff --git a/apiserver/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py b/apiserver/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py new file mode 100644 index 00000000000..ee70f661528 --- /dev/null +++ b/apiserver/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py @@ -0,0 +1,2036 @@ +# Generated by Django 4.2.15 on 2024-09-24 08:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid +from django.db.models import Prefetch + + +def migrate_draft_issues(apps, schema_editor): + Issue = apps.get_model("db", "Issue") + DraftIssue = apps.get_model("db", "DraftIssue") + IssueAssignee = apps.get_model("db", "IssueAssignee") + DraftIssueAssignee = apps.get_model("db", "DraftIssueAssignee") + IssueLabel = apps.get_model("db", "IssueLabel") + DraftIssueLabel = apps.get_model("db", "DraftIssueLabel") + ModuleIssue = apps.get_model("db", "ModuleIssue") + DraftIssueModule = apps.get_model("db", "DraftIssueModule") + DraftIssueCycle = apps.get_model("db", "DraftIssueCycle") + + # Fetch all draft issues with their related assignees and labels + issues = ( + Issue.objects.filter(is_draft=True) + .select_related("issue_cycle__cycle") + .prefetch_related( + Prefetch( + "issue_assignee", + queryset=IssueAssignee.objects.select_related("assignee"), + ), + Prefetch( + "label_issue", + queryset=IssueLabel.objects.select_related("label"), + ), + Prefetch( + "issue_module", + queryset=ModuleIssue.objects.select_related("module"), + ), + ) + ) + + draft_issues = [] + draft_issue_cycle = [] + draft_issue_labels = [] + draft_issue_modules = [] + draft_issue_assignees = [] + # issue_ids_to_delete = [] + + for issue in issues: + draft_issue = DraftIssue( + parent_id=issue.parent_id, + state_id=issue.state_id, + estimate_point_id=issue.estimate_point_id, + name=issue.name, + description=issue.description, + description_html=issue.description_html, + description_stripped=issue.description_stripped, + description_binary=issue.description_binary, + priority=issue.priority, + start_date=issue.start_date, + target_date=issue.target_date, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + created_by_id=issue.created_by_id, + updated_by_id=issue.updated_by_id, + ) + draft_issues.append(draft_issue) + + for assignee in issue.issue_assignee.all(): + draft_issue_assignees.append( + DraftIssueAssignee( + draft_issue=draft_issue, + assignee=assignee.assignee, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + ) + ) + + # Prepare labels for bulk insert + for label in issue.label_issue.all(): + draft_issue_labels.append( + DraftIssueLabel( + draft_issue=draft_issue, + label=label.label, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + ) + ) + + for module_issue in issue.issue_module.all(): + draft_issue_modules.append( + DraftIssueModule( + draft_issue=draft_issue, + module=module_issue.module, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + ) + ) + + if hasattr(issue, "issue_cycle") and issue.issue_cycle: + draft_issue_cycle.append( + DraftIssueCycle( + draft_issue=draft_issue, + cycle=issue.issue_cycle.cycle, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + ) + ) + + # issue_ids_to_delete.append(issue.id) + + # Bulk create draft issues + DraftIssue.objects.bulk_create(draft_issues) + + # Bulk create draft assignees and labels + DraftIssueLabel.objects.bulk_create(draft_issue_labels) + DraftIssueAssignee.objects.bulk_create(draft_issue_assignees) + + # Bulk create draft modules + DraftIssueCycle.objects.bulk_create(draft_issue_cycle) + DraftIssueModule.objects.bulk_create(draft_issue_modules) + + # Delete original issues + # Issue.objects.filter(id__in=issue_ids_to_delete).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0076_alter_projectmember_role_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="DraftIssue", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Issue Name", + ), + ), + ("description", models.JSONField(blank=True, default=dict)), + ( + "description_html", + models.TextField(blank=True, default="

"), + ), + ( + "description_stripped", + models.TextField(blank=True, null=True), + ), + ("description_binary", models.BinaryField(null=True)), + ( + "priority", + models.CharField( + choices=[ + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ], + default="none", + max_length=30, + verbose_name="Issue Priority", + ), + ), + ("start_date", models.DateField(blank=True, null=True)), + ("target_date", models.DateField(blank=True, null=True)), + ("sort_order", models.FloatField(default=65535)), + ("completed_at", models.DateTimeField(null=True)), + ( + "external_source", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "external_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ], + options={ + "verbose_name": "DraftIssue", + "verbose_name_plural": "DraftIssues", + "db_table": "draft_issues", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="cycle", + name="timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ( + "America/Argentina/Buenos_Aires", + "America/Argentina/Buenos_Aires", + ), + ( + "America/Argentina/Catamarca", + "America/Argentina/Catamarca", + ), + ( + "America/Argentina/ComodRivadavia", + "America/Argentina/ComodRivadavia", + ), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ( + "America/Argentina/La_Rioja", + "America/Argentina/La_Rioja", + ), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ( + "America/Argentina/Rio_Gallegos", + "America/Argentina/Rio_Gallegos", + ), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ( + "America/Argentina/San_Juan", + "America/Argentina/San_Juan", + ), + ( + "America/Argentina/San_Luis", + "America/Argentina/San_Luis", + ), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ( + "America/Indiana/Indianapolis", + "America/Indiana/Indianapolis", + ), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ( + "America/Indiana/Petersburg", + "America/Indiana/Petersburg", + ), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ( + "America/Kentucky/Louisville", + "America/Kentucky/Louisville", + ), + ( + "America/Kentucky/Monticello", + "America/Kentucky/Monticello", + ), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ( + "America/North_Dakota/Beulah", + "America/North_Dakota/Beulah", + ), + ( + "America/North_Dakota/Center", + "America/North_Dakota/Center", + ), + ( + "America/North_Dakota/New_Salem", + "America/North_Dakota/New_Salem", + ), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default="UTC", + max_length=255, + ), + ), + migrations.AddField( + model_name="project", + name="timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ( + "America/Argentina/Buenos_Aires", + "America/Argentina/Buenos_Aires", + ), + ( + "America/Argentina/Catamarca", + "America/Argentina/Catamarca", + ), + ( + "America/Argentina/ComodRivadavia", + "America/Argentina/ComodRivadavia", + ), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ( + "America/Argentina/La_Rioja", + "America/Argentina/La_Rioja", + ), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ( + "America/Argentina/Rio_Gallegos", + "America/Argentina/Rio_Gallegos", + ), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ( + "America/Argentina/San_Juan", + "America/Argentina/San_Juan", + ), + ( + "America/Argentina/San_Luis", + "America/Argentina/San_Luis", + ), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ( + "America/Indiana/Indianapolis", + "America/Indiana/Indianapolis", + ), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ( + "America/Indiana/Petersburg", + "America/Indiana/Petersburg", + ), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ( + "America/Kentucky/Louisville", + "America/Kentucky/Louisville", + ), + ( + "America/Kentucky/Monticello", + "America/Kentucky/Monticello", + ), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ( + "America/North_Dakota/Beulah", + "America/North_Dakota/Beulah", + ), + ( + "America/North_Dakota/Center", + "America/North_Dakota/Center", + ), + ( + "America/North_Dakota/New_Salem", + "America/North_Dakota/New_Salem", + ), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default="UTC", + max_length=255, + ), + ), + migrations.AlterField( + model_name="cycle", + name="end_date", + field=models.DateTimeField( + blank=True, null=True, verbose_name="End Date" + ), + ), + migrations.AlterField( + model_name="cycle", + name="start_date", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Start Date" + ), + ), + migrations.CreateModel( + name="DraftIssueModule", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "draft_issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_module", + to="db.draftissue", + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_module", + to="db.module", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Draft Issue Module", + "verbose_name_plural": "Draft Issue Modules", + "db_table": "draft_issue_modules", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="DraftIssueLabel", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "draft_issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_label_issue", + to="db.draftissue", + ), + ), + ( + "label", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_label_issue", + to="db.label", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Draft Issue Label", + "verbose_name_plural": "Draft Issue Labels", + "db_table": "draft_issue_labels", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="DraftIssueCycle", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "cycle", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_cycle", + to="db.cycle", + ), + ), + ( + "draft_issue", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_cycle", + to="db.draftissue", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Draft Issue Cycle", + "verbose_name_plural": "Draft Issue Cycles", + "db_table": "draft_issue_cycles", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="DraftIssueAssignee", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "assignee", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_assignee", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "draft_issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_assignee", + to="db.draftissue", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Draft Issue Assignee", + "verbose_name_plural": "Draft Issue Assignees", + "db_table": "draft_issue_assignees", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="draftissue", + name="assignees", + field=models.ManyToManyField( + blank=True, + related_name="draft_assignee", + through="db.DraftIssueAssignee", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="draftissue", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AddField( + model_name="draftissue", + name="estimate_point", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="draft_issue_estimates", + to="db.estimatepoint", + ), + ), + migrations.AddField( + model_name="draftissue", + name="labels", + field=models.ManyToManyField( + blank=True, + related_name="draft_labels", + through="db.DraftIssueLabel", + to="db.label", + ), + ), + migrations.AddField( + model_name="draftissue", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_parent_issue", + to="db.issue", + ), + ), + migrations.AddField( + model_name="draftissue", + name="project", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AddField( + model_name="draftissue", + name="state", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="state_draft_issue", + to="db.state", + ), + ), + migrations.AddField( + model_name="draftissue", + name="type", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="draft_issue_type", + to="db.issuetype", + ), + ), + migrations.AddField( + model_name="draftissue", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AddField( + model_name="draftissue", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AddConstraint( + model_name="draftissuemodule", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("draft_issue", "module"), + name="module_draft_issue_unique_issue_module_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="draftissuemodule", + unique_together={("draft_issue", "module", "deleted_at")}, + ), + migrations.AddConstraint( + model_name="draftissueassignee", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("draft_issue", "assignee"), + name="draft_issue_assignee_unique_issue_assignee_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="draftissueassignee", + unique_together={("draft_issue", "assignee", "deleted_at")}, + ), + migrations.AddField( + model_name="cycle", + name="version", + field=models.IntegerField(default=1), + ), + migrations.RunPython(migrate_draft_issues), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index e7def641d5c..a6fa6dddb76 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -5,6 +5,7 @@ from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties from .dashboard import Dashboard, DashboardWidget, Widget from .deploy_board import DeployBoard +from .draft import DraftIssue, DraftIssueAssignee, DraftIssueLabel, DraftIssueModule, DraftIssueCycle from .estimate import Estimate, EstimatePoint from .exporter import ExporterHistory from .importer import Importer diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index b3ce49e01a9..7c6ac8e3935 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -1,3 +1,6 @@ +# Python imports +import pytz + # Django imports from django.conf import settings from django.db import models @@ -55,10 +58,12 @@ class Cycle(ProjectBaseModel): description = models.TextField( verbose_name="Cycle Description", blank=True ) - start_date = models.DateField( + start_date = models.DateTimeField( verbose_name="Start Date", blank=True, null=True ) - end_date = models.DateField(verbose_name="End Date", blank=True, null=True) + end_date = models.DateTimeField( + verbose_name="End Date", blank=True, null=True + ) owned_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -71,6 +76,12 @@ class Cycle(ProjectBaseModel): progress_snapshot = models.JSONField(default=dict) archived_at = models.DateTimeField(null=True) logo_props = models.JSONField(default=dict) + # timezone + TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + timezone = models.CharField( + max_length=255, default="UTC", choices=TIMEZONE_CHOICES + ) + version = models.IntegerField(default=1) class Meta: verbose_name = "Cycle" diff --git a/apiserver/plane/db/models/draft.py b/apiserver/plane/db/models/draft.py new file mode 100644 index 00000000000..671b89ff1fa --- /dev/null +++ b/apiserver/plane/db/models/draft.py @@ -0,0 +1,253 @@ +# Django imports +from django.conf import settings +from django.db import models +from django.utils import timezone + +# Module imports +from plane.utils.html_processor import strip_tags + +from .workspace import WorkspaceBaseModel + + +class DraftIssue(WorkspaceBaseModel): + PRIORITY_CHOICES = ( + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ) + parent = models.ForeignKey( + "db.Issue", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="draft_parent_issue", + ) + state = models.ForeignKey( + "db.State", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="state_draft_issue", + ) + estimate_point = models.ForeignKey( + "db.EstimatePoint", + on_delete=models.SET_NULL, + related_name="draft_issue_estimates", + null=True, + blank=True, + ) + name = models.CharField( + max_length=255, verbose_name="Issue Name", blank=True, null=True + ) + description = models.JSONField(blank=True, default=dict) + description_html = models.TextField(blank=True, default="

") + description_stripped = models.TextField(blank=True, null=True) + description_binary = models.BinaryField(null=True) + priority = models.CharField( + max_length=30, + choices=PRIORITY_CHOICES, + verbose_name="Issue Priority", + default="none", + ) + start_date = models.DateField(null=True, blank=True) + target_date = models.DateField(null=True, blank=True) + assignees = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="draft_assignee", + through="DraftIssueAssignee", + through_fields=("draft_issue", "assignee"), + ) + labels = models.ManyToManyField( + "db.Label", + blank=True, + related_name="draft_labels", + through="DraftIssueLabel", + ) + sort_order = models.FloatField(default=65535) + completed_at = models.DateTimeField(null=True) + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) + type = models.ForeignKey( + "db.IssueType", + on_delete=models.SET_NULL, + related_name="draft_issue_type", + null=True, + blank=True, + ) + + class Meta: + verbose_name = "DraftIssue" + verbose_name_plural = "DraftIssues" + db_table = "draft_issues" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + if self.state is None: + try: + from plane.db.models import State + + default_state = State.objects.filter( + ~models.Q(is_triage=True), + project=self.project, + default=True, + ).first() + if default_state is None: + random_state = State.objects.filter( + ~models.Q(is_triage=True), project=self.project + ).first() + self.state = random_state + else: + self.state = default_state + except ImportError: + pass + else: + try: + from plane.db.models import State + + if self.state.group == "completed": + self.completed_at = timezone.now() + else: + self.completed_at = None + except ImportError: + pass + + if self._state.adding: + # Strip the html tags using html parser + self.description_stripped = ( + None + if ( + self.description_html == "" + or self.description_html is None + ) + else strip_tags(self.description_html) + ) + largest_sort_order = DraftIssue.objects.filter( + project=self.project, state=self.state + ).aggregate(largest=models.Max("sort_order"))["largest"] + if largest_sort_order is not None: + self.sort_order = largest_sort_order + 10000 + + super(DraftIssue, self).save(*args, **kwargs) + + else: + # Strip the html tags using html parser + self.description_stripped = ( + None + if ( + self.description_html == "" + or self.description_html is None + ) + else strip_tags(self.description_html) + ) + super(DraftIssue, self).save(*args, **kwargs) + + def __str__(self): + """Return name of the draft issue""" + return f"{self.name} <{self.project.name}>" + + +class DraftIssueAssignee(WorkspaceBaseModel): + draft_issue = models.ForeignKey( + DraftIssue, + on_delete=models.CASCADE, + related_name="draft_issue_assignee", + ) + assignee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="draft_issue_assignee", + ) + + class Meta: + unique_together = ["draft_issue", "assignee", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["draft_issue", "assignee"], + condition=models.Q(deleted_at__isnull=True), + name="draft_issue_assignee_unique_issue_assignee_when_deleted_at_null", + ) + ] + verbose_name = "Draft Issue Assignee" + verbose_name_plural = "Draft Issue Assignees" + db_table = "draft_issue_assignees" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.draft_issue.name} {self.assignee.email}" + + +class DraftIssueLabel(WorkspaceBaseModel): + draft_issue = models.ForeignKey( + "db.DraftIssue", + on_delete=models.CASCADE, + related_name="draft_label_issue", + ) + label = models.ForeignKey( + "db.Label", on_delete=models.CASCADE, related_name="draft_label_issue" + ) + + class Meta: + verbose_name = "Draft Issue Label" + verbose_name_plural = "Draft Issue Labels" + db_table = "draft_issue_labels" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.draft_issue.name} {self.label.name}" + + +class DraftIssueModule(WorkspaceBaseModel): + module = models.ForeignKey( + "db.Module", + on_delete=models.CASCADE, + related_name="draft_issue_module", + ) + draft_issue = models.ForeignKey( + "db.DraftIssue", + on_delete=models.CASCADE, + related_name="draft_issue_module", + ) + + class Meta: + unique_together = ["draft_issue", "module", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["draft_issue", "module"], + condition=models.Q(deleted_at__isnull=True), + name="module_draft_issue_unique_issue_module_when_deleted_at_null", + ) + ] + verbose_name = "Draft Issue Module" + verbose_name_plural = "Draft Issue Modules" + db_table = "draft_issue_modules" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.draft_issue.name}" + + +class DraftIssueCycle(WorkspaceBaseModel): + """ + Draft Issue Cycles + """ + + draft_issue = models.OneToOneField( + "db.DraftIssue", + on_delete=models.CASCADE, + related_name="draft_issue_cycle", + ) + cycle = models.ForeignKey( + "db.Cycle", on_delete=models.CASCADE, related_name="draft_issue_cycle" + ) + + class Meta: + verbose_name = "Draft Issue Cycle" + verbose_name_plural = "Draft Issue Cycles" + db_table = "draft_issue_cycles" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.cycle}" diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index bcc16822739..3f784b399ec 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -1,4 +1,5 @@ # Python imports +import pytz from uuid import uuid4 # Django imports @@ -7,7 +8,7 @@ from django.db import models from django.db.models import Q -# Modeule imports +# Module imports from plane.db.mixins import AuditModel # Module imports @@ -119,6 +120,11 @@ class Project(BaseModel): related_name="default_state", ) archived_at = models.DateTimeField(null=True) + # timezone + TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + timezone = models.CharField( + max_length=255, default="UTC", choices=TIMEZONE_CHOICES + ) def __str__(self): """Return name of the project""" diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index eda3b30ac9c..fd9f6405850 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -163,7 +163,7 @@ def burndown_plot( if queryset.end_date and queryset.start_date: # Get all dates between the two dates date_range = [ - queryset.start_date + timedelta(days=x) + (queryset.start_date + timedelta(days=x)).date() for x in range( (queryset.end_date - queryset.start_date).days + 1 ) @@ -203,7 +203,7 @@ def burndown_plot( if module_id: # Get all dates between the two dates date_range = [ - queryset.start_date + timedelta(days=x) + (queryset.start_date + timedelta(days=x)).date() for x in range( (queryset.target_date - queryset.start_date).days + 1 ) diff --git a/live/src/ce/lib/authentication.ts b/live/src/ce/lib/authentication.ts deleted file mode 100644 index 3d5a1ea48e5..00000000000 --- a/live/src/ce/lib/authentication.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ConnectionConfiguration } from "@hocuspocus/server"; -// types -import { TDocumentTypes } from "@/core/types/common.js"; - -type TArgs = { - connection: ConnectionConfiguration - cookie: string; - documentType: TDocumentTypes | undefined; - params: URLSearchParams; -} - -export const authenticateUser = async (args: TArgs): Promise => { - const { documentType } = args; - throw Error(`Authentication failed: Invalid document type ${documentType} provided.`); -} \ No newline at end of file diff --git a/live/src/core/hocuspocus-server.ts b/live/src/core/hocuspocus-server.ts index fb30c8f8281..0aa411b9334 100644 --- a/live/src/core/hocuspocus-server.ts +++ b/live/src/core/hocuspocus-server.ts @@ -12,15 +12,11 @@ export const getHocusPocusServer = async () => { name: serverName, onAuthenticate: async ({ requestHeaders, - requestParameters, - connection, // user id used as token for authentication token, }) => { // request headers const cookie = requestHeaders.cookie?.toString(); - // params - const params = requestParameters; if (!cookie) { throw Error("Credentials not provided"); @@ -28,9 +24,7 @@ export const getHocusPocusServer = async () => { try { await handleAuthentication({ - connection, cookie, - params, token, }); } catch (error) { @@ -38,6 +32,6 @@ export const getHocusPocusServer = async () => { } }, extensions, - debounce: 10000 + debounce: 10000, }); }; diff --git a/live/src/core/lib/authentication.ts b/live/src/core/lib/authentication.ts index dbde17959ad..ee01b020908 100644 --- a/live/src/core/lib/authentication.ts +++ b/live/src/core/lib/authentication.ts @@ -1,28 +1,17 @@ -import { ConnectionConfiguration } from "@hocuspocus/server"; // services import { UserService } from "@/core/services/user.service.js"; -// types -import { TDocumentTypes } from "@/core/types/common.js"; -// plane live lib -import { authenticateUser } from "@/plane-live/lib/authentication.js"; // core helpers import { manualLogger } from "@/core/helpers/logger.js"; const userService = new UserService(); type Props = { - connection: ConnectionConfiguration; cookie: string; - params: URLSearchParams; token: string; }; export const handleAuthentication = async (props: Props) => { - const { connection, cookie, params, token } = props; - // params - const documentType = params.get("documentType")?.toString() as - | TDocumentTypes - | undefined; + const { cookie, token } = props; // fetch current user info let response; try { @@ -35,40 +24,6 @@ export const handleAuthentication = async (props: Props) => { throw Error("Authentication failed: Token doesn't match the current user."); } - if (documentType === "project_page") { - // params - const workspaceSlug = params.get("workspaceSlug")?.toString(); - const projectId = params.get("projectId")?.toString(); - if (!workspaceSlug || !projectId) { - throw Error( - "Authentication failed: Incomplete query params. Either workspaceSlug or projectId is missing." - ); - } - // fetch current user's project membership info - try { - const projectMembershipInfo = await userService.getUserProjectMembership( - workspaceSlug, - projectId, - cookie - ); - const projectRole = projectMembershipInfo.role; - // make the connection read only for roles lower than a member - if (projectRole < 15) { - connection.readOnly = true; - } - } catch (error) { - manualLogger.error("Failed to fetch project membership info:", error); - throw error; - } - } else { - await authenticateUser({ - connection, - cookie, - documentType, - params, - }); - } - return { user: { id: response.id, diff --git a/live/src/core/services/user.service.ts b/live/src/core/services/user.service.ts index 09412aa532c..39d200919ac 100644 --- a/live/src/core/services/user.service.ts +++ b/live/src/core/services/user.service.ts @@ -1,5 +1,5 @@ // types -import type { IProjectMember, IUser } from "@plane/types"; +import type { IUser } from "@plane/types"; // services import { API_BASE_URL, APIService } from "@/core/services/api.service.js"; @@ -25,37 +25,4 @@ export class UserService extends APIService { throw error; }); } - - async getUserWorkspaceMembership( - workspaceSlug: string, - cookie: string - ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/workspace-members/me/`, - { - headers: { - Cookie: cookie, - }, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async getUserProjectMembership( - workspaceSlug: string, - projectId: string, - cookie: string - ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`, - { - headers: { - Cookie: cookie, - }, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } } diff --git a/packages/editor/package.json b/packages/editor/package.json index bfb379f8b60..01d03db1fd4 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -42,13 +42,15 @@ "@tiptap/extension-blockquote": "^2.1.13", "@tiptap/extension-character-count": "^2.6.5", "@tiptap/extension-collaboration": "^2.3.2", + "@tiptap/extension-color": "^2.7.1", + "@tiptap/extension-highlight": "^2.7.1", "@tiptap/extension-image": "^2.1.13", "@tiptap/extension-list-item": "^2.1.13", "@tiptap/extension-mention": "^2.1.13", "@tiptap/extension-placeholder": "^2.3.0", "@tiptap/extension-task-item": "^2.1.13", "@tiptap/extension-task-list": "^2.1.13", - "@tiptap/extension-text-style": "^2.1.13", + "@tiptap/extension-text-style": "^2.7.1", "@tiptap/extension-underline": "^2.1.13", "@tiptap/pm": "^2.1.13", "@tiptap/react": "^2.1.13", diff --git a/packages/editor/src/ce/extensions/document-extensions.tsx b/packages/editor/src/ce/extensions/document-extensions.tsx index 93900700b27..2809fcee4ef 100644 --- a/packages/editor/src/ce/extensions/document-extensions.tsx +++ b/packages/editor/src/ce/extensions/document-extensions.tsx @@ -1,6 +1,6 @@ import { HocuspocusProvider } from "@hocuspocus/provider"; import { Extensions } from "@tiptap/core"; -import { SlashCommand } from "@/extensions"; +import { SlashCommands } from "@/extensions"; // plane editor types import { TIssueEmbedConfig } from "@/plane-editor/types"; // types @@ -14,7 +14,7 @@ type Props = { }; export const DocumentEditorAdditionalExtensions = (_props: Props) => { - const extensions: Extensions = [SlashCommand()]; + const extensions: Extensions = [SlashCommands()]; return extensions; }; diff --git a/packages/editor/src/core/components/editors/rich-text/editor.tsx b/packages/editor/src/core/components/editors/rich-text/editor.tsx index fe4d2d51373..53f766ee21a 100644 --- a/packages/editor/src/core/components/editors/rich-text/editor.tsx +++ b/packages/editor/src/core/components/editors/rich-text/editor.tsx @@ -3,7 +3,7 @@ import { forwardRef, useCallback } from "react"; import { EditorWrapper } from "@/components/editors"; import { EditorBubbleMenu } from "@/components/menus"; // extensions -import { SideMenuExtension, SlashCommand } from "@/extensions"; +import { SideMenuExtension, SlashCommands } from "@/extensions"; // types import { EditorRefApi, IRichTextEditor } from "@/types"; @@ -11,7 +11,7 @@ const RichTextEditor = (props: IRichTextEditor) => { const { dragDropEnabled } = props; const getExtensions = useCallback(() => { - const extensions = [SlashCommand()]; + const extensions = [SlashCommands()]; extensions.push( SideMenuExtension({ diff --git a/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx new file mode 100644 index 00000000000..cc3eb5412e2 --- /dev/null +++ b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx @@ -0,0 +1,118 @@ +import { Dispatch, FC, SetStateAction } from "react"; +import { Editor } from "@tiptap/react"; +import { ALargeSmall, Ban } from "lucide-react"; +// constants +import { COLORS_LIST } from "@/constants/common"; +// helpers +import { cn } from "@/helpers/common"; +import { BackgroundColorItem, TextColorItem } from "../menu-items"; + +type Props = { + editor: Editor; + isOpen: boolean; + setIsOpen: Dispatch>; +}; + +export const BubbleMenuColorSelector: FC = (props) => { + const { editor, isOpen, setIsOpen } = props; + + const activeTextColor = COLORS_LIST.find((c) => editor.getAttributes("textStyle").color === c.textColor); + const activeBackgroundColor = COLORS_LIST.find((c) => + editor.isActive("highlight", { + color: c.backgroundColor, + }) + ); + + return ( +
+ + {isOpen && ( +
+
+

Text colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+
+

Background colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+
+ )} +
+ ); +}; diff --git a/packages/editor/src/core/components/menus/bubble-menu/index.ts b/packages/editor/src/core/components/menus/bubble-menu/index.ts index 71a98bada08..526feed3d1e 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/index.ts +++ b/packages/editor/src/core/components/menus/bubble-menu/index.ts @@ -1,3 +1,4 @@ +export * from "./color-selector"; export * from "./link-selector"; export * from "./node-selector"; export * from "./root"; diff --git a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx index 20335e8abb6..eaa20ed26bb 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx @@ -1,6 +1,6 @@ import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react"; import { Editor } from "@tiptap/core"; -import { Check, Trash } from "lucide-react"; +import { Check, Link, Trash } from "lucide-react"; // helpers import { cn, isValidHttpUrl } from "@/helpers/common"; import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands"; @@ -11,7 +11,9 @@ type Props = { setIsOpen: Dispatch>; }; -export const BubbleMenuLinkSelector: FC = ({ editor, isOpen, setIsOpen }) => { +export const BubbleMenuLinkSelector: FC = (props) => { + const { editor, isOpen, setIsOpen } = props; + // refs const inputRef = useRef(null); const onLinkSubmit = useCallback(() => { @@ -28,26 +30,23 @@ export const BubbleMenuLinkSelector: FC = ({ editor, isOpen, setIsOpen }) }); return ( -
+
{isOpen && (
>; }; -export const BubbleMenuNodeSelector: FC = ({ editor, isOpen, setIsOpen }) => { - const items: BubbleMenuItem[] = [ +export const BubbleMenuNodeSelector: FC = (props) => { + const { editor, isOpen, setIsOpen } = props; + + const items: EditorMenuItem[] = [ TextItem(editor), HeadingOneItem(editor), HeadingTwoItem(editor), @@ -42,7 +44,7 @@ export const BubbleMenuNodeSelector: FC = ({ editor, isOpen, setIsOpen }) CodeItem(editor), ]; - const activeItem = items.filter((item) => item.isActive()).pop() ?? { + const activeItem = items.filter((item) => item.isActive("")).pop() ?? { name: "Multiple", }; @@ -54,12 +56,11 @@ export const BubbleMenuNodeSelector: FC = ({ editor, isOpen, setIsOpen }) setIsOpen(!isOpen); e.stopPropagation(); }} - className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5" + className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors" > {activeItem?.name} - + - {isOpen && (
{items.map((item) => ( diff --git a/packages/editor/src/core/components/menus/bubble-menu/root.tsx b/packages/editor/src/core/components/menus/bubble-menu/root.tsx index ec72f154086..0f789dd8a1b 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -1,12 +1,13 @@ import { FC, useEffect, useState } from "react"; import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react"; -import { LucideIcon } from "lucide-react"; // components import { BoldItem, + BubbleMenuColorSelector, BubbleMenuLinkSelector, BubbleMenuNodeSelector, CodeItem, + EditorMenuItem, ItalicItem, StrikeThroughItem, UnderLineItem, @@ -16,34 +17,23 @@ import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele // helpers import { cn } from "@/helpers/common"; -export interface BubbleMenuItem { - key: string; - name: string; - isActive: () => boolean; - command: () => void; - icon: LucideIcon; -} - type EditorBubbleMenuProps = Omit; export const EditorBubbleMenu: FC = (props: any) => { - const items: BubbleMenuItem[] = [ - ...(props.editor.isActive("code") - ? [] - : [ - BoldItem(props.editor), - ItalicItem(props.editor), - UnderLineItem(props.editor), - StrikeThroughItem(props.editor), - ]), - CodeItem(props.editor), - ]; + // states + const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); + const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); + const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); + const [isSelecting, setIsSelecting] = useState(false); + + const items: EditorMenuItem[] = props.editor.isActive("code") + ? [CodeItem(props.editor)] + : [BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor)]; const bubbleMenuProps: EditorBubbleMenuProps = { ...props, shouldShow: ({ state, editor }) => { const { selection } = state; - const { empty } = selection; if ( @@ -63,15 +53,11 @@ export const EditorBubbleMenu: FC = (props: any) => { onHidden: () => { setIsNodeSelectorOpen(false); setIsLinkSelectorOpen(false); + setIsColorSelectorOpen(false); }, }, }; - const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); - const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); - - const [isSelecting, setIsSelecting] = useState(false); - useEffect(() => { function handleMouseDown() { function handleMouseMove() { @@ -102,51 +88,66 @@ export const EditorBubbleMenu: FC = (props: any) => { return ( - {isSelecting ? null : ( + {!isSelecting && ( <> - {!props.editor.isActive("table") && ( - { - setIsNodeSelectorOpen(!isNodeSelectorOpen); - setIsLinkSelectorOpen(false); - }} - /> - )} - {!props.editor.isActive("code") && ( - { - setIsLinkSelectorOpen(!isLinkSelectorOpen); - setIsNodeSelectorOpen(false); - }} - /> - )} -
+
+ {!props.editor.isActive("table") && ( + { + setIsNodeSelectorOpen((prev) => !prev); + setIsLinkSelectorOpen(false); + setIsColorSelectorOpen(false); + }} + /> + )} +
+
+ {!props.editor.isActive("code") && ( + { + setIsLinkSelectorOpen((prev) => !prev); + setIsNodeSelectorOpen(false); + setIsColorSelectorOpen(false); + }} + /> + )} +
+
+ {!props.editor.isActive("code") && ( + { + setIsColorSelectorOpen((prev) => !prev); + setIsNodeSelectorOpen(false); + setIsLinkSelectorOpen(false); + }} + /> + )} +
+
{items.map((item) => ( ))}
diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index cf10081f1e5..f7082c12de0 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -20,12 +20,14 @@ import { Heading6, CaseSensitive, LucideIcon, + Palette, } from "lucide-react"; // helpers import { insertImage, insertTableCommand, setText, + toggleBackgroundColor, toggleBlockquote, toggleBold, toggleBulletList, @@ -40,18 +42,26 @@ import { toggleOrderedList, toggleStrike, toggleTaskList, + toggleTextColor, toggleUnderline, } from "@/helpers/editor-commands"; // types -import { TEditorCommands } from "@/types"; +import { TColorEditorCommands, TNonColorEditorCommands } from "@/types"; -export interface EditorMenuItem { - key: TEditorCommands; +export type EditorMenuItem = { name: string; - isActive: () => boolean; - command: () => void; + command: (...args: any) => void; icon: LucideIcon; -} +} & ( + | { + key: TNonColorEditorCommands; + isActive: () => boolean; + } + | { + key: TColorEditorCommands; + isActive: (color: string | undefined) => boolean; + } +); export const TextItem = (editor: Editor): EditorMenuItem => ({ key: "text", @@ -198,10 +208,25 @@ export const ImageItem = (editor: Editor) => icon: ImageIcon, }) as const; -export function getEditorMenuItems(editor: Editor | null) { - if (!editor) { - return []; - } +export const TextColorItem = (editor: Editor): EditorMenuItem => ({ + key: "text-color", + name: "Color", + isActive: (color) => editor.getAttributes("textStyle").color === color, + command: (color: string) => toggleTextColor(color, editor), + icon: Palette, +}); + +export const BackgroundColorItem = (editor: Editor): EditorMenuItem => ({ + key: "background-color", + name: "Background color", + isActive: (color) => editor.isActive("highlight", { color }), + command: (color: string) => toggleBackgroundColor(color, editor), + icon: Palette, +}); + +export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => { + if (!editor) return []; + return [ TextItem(editor), HeadingOneItem(editor), @@ -221,5 +246,7 @@ export function getEditorMenuItems(editor: Editor | null) { QuoteItem(editor), TableItem(editor), ImageItem(editor), + TextColorItem(editor), + BackgroundColorItem(editor), ]; -} +}; diff --git a/packages/editor/src/core/constants/common.ts b/packages/editor/src/core/constants/common.ts new file mode 100644 index 00000000000..4e46fb83769 --- /dev/null +++ b/packages/editor/src/core/constants/common.ts @@ -0,0 +1,51 @@ +export const COLORS_LIST: { + backgroundColor: string; + textColor: string; + label: string; +}[] = [ + // { + // backgroundColor: "#1c202426", + // textColor: "#1c2024", + // label: "Black", + // }, + { + backgroundColor: "#5c5e6326", + textColor: "#5c5e63", + label: "Gray", + }, + { + backgroundColor: "#ff5b5926", + textColor: "#ff5b59", + label: "Peach", + }, + { + backgroundColor: "#f6538526", + textColor: "#f65385", + label: "Pink", + }, + { + backgroundColor: "#fd903826", + textColor: "#fd9038", + label: "Orange", + }, + { + backgroundColor: "#0fc27b26", + textColor: "#0fc27b", + label: "Green", + }, + { + backgroundColor: "#17bee926", + textColor: "#17bee9", + label: "Light blue", + }, + { + backgroundColor: "#266df026", + textColor: "#266df0", + label: "Dark blue", + }, + { + backgroundColor: "#9162f926", + textColor: "#9162f9", + label: "Purple", + }, +]; diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts index 1cedd513966..7fc0ae6d052 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -1,3 +1,5 @@ +import { Color } from "@tiptap/extension-color"; +import Highlight from "@tiptap/extension-highlight"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import TextStyle from "@tiptap/extension-text-style"; @@ -83,6 +85,10 @@ export const CoreEditorExtensionsWithoutProps = [ TableCell, TableRow, CustomMentionWithoutProps(), + Color, + Highlight.configure({ + multicolor: true, + }), ]; export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()]; diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx index f067ee94780..ed60f3dab08 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -201,8 +201,10 @@ export const CustomImageBlock: React.FC = (props) => { // show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or) // if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete; - // show the image utils only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) - const showImageUtils = editor.isEditable && remoteImageSrc && initialResizeComplete; + // show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) + const showImageUtils = remoteImageSrc && initialResizeComplete; + // show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) + const showImageResizer = editor.isEditable && remoteImageSrc && initialResizeComplete; // show the preview image from the file system if the remote image's src is not set const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem; @@ -258,7 +260,7 @@ export const CustomImageBlock: React.FC = (props) => { {selected && displayedImageSrc === remoteImageSrc && (
)} - {showImageUtils && ( + {showImageResizer && ( <>
{ ai: aiEnabled, dragDrop: dragDropEnabled, }, - scrollThreshold: { up: 300, down: 100 }, + scrollThreshold: { up: 200, down: 100 }, }), ]; }, diff --git a/packages/editor/src/core/extensions/slash-commands.tsx b/packages/editor/src/core/extensions/slash-commands.tsx deleted file mode 100644 index 2be8d89d96b..00000000000 --- a/packages/editor/src/core/extensions/slash-commands.tsx +++ /dev/null @@ -1,422 +0,0 @@ -import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react"; -import { Editor, Range, Extension } from "@tiptap/core"; -import { ReactRenderer } from "@tiptap/react"; -import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; -import tippy from "tippy.js"; -import { - CaseSensitive, - Code2, - Heading1, - Heading2, - Heading3, - Heading4, - Heading5, - Heading6, - ImageIcon, - List, - ListOrdered, - ListTodo, - MinusSquare, - Quote, - Table, -} from "lucide-react"; -// helpers -import { cn } from "@/helpers/common"; -import { - insertTableCommand, - toggleBlockquote, - toggleBulletList, - toggleOrderedList, - toggleTaskList, - toggleHeadingOne, - toggleHeadingTwo, - toggleHeadingThree, - toggleHeadingFour, - toggleHeadingFive, - toggleHeadingSix, - insertImage, -} from "@/helpers/editor-commands"; -// types -import { CommandProps, ISlashCommandItem } from "@/types"; - -interface CommandItemProps { - key: string; - title: string; - description: string; - icon: ReactNode; -} - -export type SlashCommandOptions = { - suggestion: Omit; -}; - -const Command = Extension.create({ - name: "slash-command", - addOptions() { - return { - suggestion: { - char: "/", - command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { - props.command({ editor, range }); - }, - allow({ editor }: { editor: Editor }) { - const { selection } = editor.state; - - const parentNode = selection.$from.node(selection.$from.depth); - const blockType = parentNode.type.name; - - if (blockType === "codeBlock") { - return false; - } - - if (editor.isActive("table")) { - return false; - } - - return true; - }, - }, - }; - }, - addProseMirrorPlugins() { - return [ - Suggestion({ - editor: this.editor, - ...this.options.suggestion, - }), - ]; - }, -}); - -const getSuggestionItems = - (additionalOptions?: Array) => - ({ query }: { query: string }) => { - let slashCommands: ISlashCommandItem[] = [ - { - key: "text", - title: "Text", - description: "Just start typing with plain text.", - searchTerms: ["p", "paragraph"], - icon: , - command: ({ editor, range }: CommandProps) => { - if (range) { - editor.chain().focus().deleteRange(range).clearNodes().run(); - } - editor.chain().focus().clearNodes().run(); - }, - }, - { - key: "h1", - title: "Heading 1", - description: "Big section heading.", - searchTerms: ["title", "big", "large"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingOne(editor, range); - }, - }, - { - key: "h2", - title: "Heading 2", - description: "Medium section heading.", - searchTerms: ["subtitle", "medium"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingTwo(editor, range); - }, - }, - { - key: "h3", - title: "Heading 3", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingThree(editor, range); - }, - }, - { - key: "h4", - title: "Heading 4", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingFour(editor, range); - }, - }, - { - key: "h5", - title: "Heading 5", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingFive(editor, range); - }, - }, - { - key: "h6", - title: "Heading 6", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingSix(editor, range); - }, - }, - { - key: "to-do-list", - title: "To do", - description: "Track tasks with a to-do list.", - searchTerms: ["todo", "task", "list", "check", "checkbox"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleTaskList(editor, range); - }, - }, - { - key: "bulleted-list", - title: "Bullet list", - description: "Create a simple bullet list.", - searchTerms: ["unordered", "point"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleBulletList(editor, range); - }, - }, - { - key: "numbered-list", - title: "Numbered list", - description: "Create a list with numbering.", - searchTerms: ["ordered"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleOrderedList(editor, range); - }, - }, - { - key: "table", - title: "Table", - description: "Create a table", - searchTerms: ["table", "cell", "db", "data", "tabular"], - icon: , - command: ({ editor, range }: CommandProps) => { - insertTableCommand(editor, range); - }, - }, - { - key: "quote", - title: "Quote", - description: "Capture a quote.", - searchTerms: ["blockquote"], - icon: , - command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range), - }, - { - key: "code", - title: "Code", - description: "Capture a code snippet.", - searchTerms: ["codeblock"], - icon: , - command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), - }, - { - key: "image", - title: "Image", - icon: , - description: "Insert an image", - searchTerms: ["img", "photo", "picture", "media", "upload"], - command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }), - }, - { - key: "divider", - title: "Divider", - description: "Visually divide blocks.", - searchTerms: ["line", "divider", "horizontal", "rule", "separate"], - icon: , - command: ({ editor, range }: CommandProps) => { - editor.chain().focus().deleteRange(range).setHorizontalRule().run(); - }, - }, - ]; - - if (additionalOptions) { - additionalOptions.map((item) => { - slashCommands.push(item); - }); - } - - slashCommands = slashCommands.filter((item) => { - if (typeof query === "string" && query.length > 0) { - const search = query.toLowerCase(); - return ( - item.title.toLowerCase().includes(search) || - item.description.toLowerCase().includes(search) || - (item.searchTerms && item.searchTerms.some((term: string) => term.includes(search))) - ); - } - return true; - }); - - return slashCommands; - }; - -export const updateScrollView = (container: HTMLElement, item: HTMLElement) => { - const containerHeight = container.offsetHeight; - const itemHeight = item ? item.offsetHeight : 0; - - const top = item.offsetTop; - const bottom = top + itemHeight; - - if (top < container.scrollTop) { - container.scrollTop -= container.scrollTop - top + 5; - } else if (bottom > containerHeight + container.scrollTop) { - container.scrollTop += bottom - containerHeight - container.scrollTop + 5; - } -}; - -const CommandList = ({ items, command }: { items: CommandItemProps[]; command: any; editor: any; range: any }) => { - // states - const [selectedIndex, setSelectedIndex] = useState(0); - // refs - const commandListContainer = useRef(null); - - const selectItem = useCallback( - (index: number) => { - const item = items[index]; - if (item) command(item); - }, - [command, items] - ); - - useEffect(() => { - const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; - const onKeyDown = (e: KeyboardEvent) => { - if (navigationKeys.includes(e.key)) { - e.preventDefault(); - if (e.key === "ArrowUp") { - setSelectedIndex((selectedIndex + items.length - 1) % items.length); - return true; - } - if (e.key === "ArrowDown") { - setSelectedIndex((selectedIndex + 1) % items.length); - return true; - } - if (e.key === "Enter") { - selectItem(selectedIndex); - return true; - } - return false; - } - }; - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("keydown", onKeyDown); - }; - }, [items, selectedIndex, setSelectedIndex, selectItem]); - - useEffect(() => { - setSelectedIndex(0); - }, [items]); - - useLayoutEffect(() => { - const container = commandListContainer?.current; - - const item = container?.children[selectedIndex] as HTMLElement; - - if (item && container) updateScrollView(container, item); - }, [selectedIndex]); - - if (items.length <= 0) return null; - - return ( -
- {items.map((item, index) => ( - - ))} -
- ); -}; - -interface CommandListInstance { - onKeyDown: (props: { event: KeyboardEvent }) => boolean; -} - -const renderItems = () => { - let component: ReactRenderer | null = null; - let popup: any | null = null; - return { - onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { - component = new ReactRenderer(CommandList, { - props, - editor: props.editor, - }); - - const tippyContainer = - document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'); - - // @ts-expect-error Tippy overloads are messed up - popup = tippy("body", { - getReferenceClientRect: props.clientRect, - appendTo: tippyContainer, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - }); - }, - onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { - component?.updateProps(props); - - popup && - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); - }, - onKeyDown: (props: { event: KeyboardEvent }) => { - if (props.event.key === "Escape") { - popup?.[0].hide(); - - return true; - } - - if (component?.ref?.onKeyDown(props)) { - return true; - } - return false; - }, - onExit: () => { - popup?.[0].destroy(); - component?.destroy(); - }, - }; -}; - -export const SlashCommand = (additionalOptions?: Array) => - Command.configure({ - suggestion: { - items: getSuggestionItems(additionalOptions), - render: renderItems, - }, - }); diff --git a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx new file mode 100644 index 00000000000..5f443ee332c --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx @@ -0,0 +1,294 @@ +import { + ALargeSmall, + CaseSensitive, + Code2, + Heading1, + Heading2, + Heading3, + Heading4, + Heading5, + Heading6, + ImageIcon, + List, + ListOrdered, + ListTodo, + MinusSquare, + Quote, + Table, +} from "lucide-react"; +// constants +import { COLORS_LIST } from "@/constants/common"; +// helpers +import { + insertTableCommand, + toggleBlockquote, + toggleBulletList, + toggleOrderedList, + toggleTaskList, + toggleHeadingOne, + toggleHeadingTwo, + toggleHeadingThree, + toggleHeadingFour, + toggleHeadingFive, + toggleHeadingSix, + toggleTextColor, + toggleBackgroundColor, + insertImage, +} from "@/helpers/editor-commands"; +// types +import { CommandProps, ISlashCommandItem } from "@/types"; + +export type TSlashCommandSection = { + key: string; + title?: string; + items: ISlashCommandItem[]; +}; + +const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [ + { + key: "general", + items: [ + { + commandKey: "text", + key: "text", + title: "Text", + description: "Just start typing with plain text.", + searchTerms: ["p", "paragraph"], + icon: , + command: ({ editor, range }: CommandProps) => { + if (range) { + editor.chain().focus().deleteRange(range).clearNodes().run(); + } + editor.chain().focus().clearNodes().run(); + }, + }, + { + commandKey: "h1", + key: "h1", + title: "Heading 1", + description: "Big section heading.", + searchTerms: ["title", "big", "large"], + icon: , + command: ({ editor, range }) => toggleHeadingOne(editor, range), + }, + { + commandKey: "h2", + key: "h2", + title: "Heading 2", + description: "Medium section heading.", + searchTerms: ["subtitle", "medium"], + icon: , + command: ({ editor, range }) => toggleHeadingTwo(editor, range), + }, + { + commandKey: "h3", + key: "h3", + title: "Heading 3", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingThree(editor, range), + }, + { + commandKey: "h4", + key: "h4", + title: "Heading 4", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingFour(editor, range), + }, + { + commandKey: "h5", + key: "h5", + title: "Heading 5", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingFive(editor, range), + }, + { + commandKey: "h6", + key: "h6", + title: "Heading 6", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingSix(editor, range), + }, + { + commandKey: "to-do-list", + key: "to-do-list", + title: "To do", + description: "Track tasks with a to-do list.", + searchTerms: ["todo", "task", "list", "check", "checkbox"], + icon: , + command: ({ editor, range }) => toggleTaskList(editor, range), + }, + { + commandKey: "bulleted-list", + key: "bulleted-list", + title: "Bullet list", + description: "Create a simple bullet list.", + searchTerms: ["unordered", "point"], + icon: , + command: ({ editor, range }) => toggleBulletList(editor, range), + }, + { + commandKey: "numbered-list", + key: "numbered-list", + title: "Numbered list", + description: "Create a list with numbering.", + searchTerms: ["ordered"], + icon: , + command: ({ editor, range }) => toggleOrderedList(editor, range), + }, + { + commandKey: "table", + key: "table", + title: "Table", + description: "Create a table", + searchTerms: ["table", "cell", "db", "data", "tabular"], + icon:
, + command: ({ editor, range }) => insertTableCommand(editor, range), + }, + { + commandKey: "quote", + key: "quote", + title: "Quote", + description: "Capture a quote.", + searchTerms: ["blockquote"], + icon: , + command: ({ editor, range }) => toggleBlockquote(editor, range), + }, + { + commandKey: "code", + key: "code", + title: "Code", + description: "Capture a code snippet.", + searchTerms: ["codeblock"], + icon: , + command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + { + commandKey: "image", + key: "image", + title: "Image", + icon: , + description: "Insert an image", + searchTerms: ["img", "photo", "picture", "media", "upload"], + command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }), + }, + { + commandKey: "divider", + key: "divider", + title: "Divider", + description: "Visually divide blocks.", + searchTerms: ["line", "divider", "horizontal", "rule", "separate"], + icon: , + command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(), + }, + ], + }, + { + key: "text-color", + title: "Colors", + items: [ + { + commandKey: "text-color", + key: "text-color-default", + title: "Default", + description: "Change text color", + searchTerms: ["color", "text", "default"], + icon: ( + + ), + command: ({ editor, range }) => toggleTextColor(undefined, editor, range), + }, + ...COLORS_LIST.map( + (color) => + ({ + commandKey: "text-color", + key: `text-color-${color.textColor}`, + title: color.label, + description: "Change text color", + searchTerms: ["color", "text", color.label], + icon: ( + + ), + command: ({ editor, range }) => toggleTextColor(color.textColor, editor, range), + }) as ISlashCommandItem + ), + ], + }, + { + key: "background-color", + title: "Background colors", + items: [ + { + commandKey: "background-color", + key: "background-color-default", + title: "Default background", + description: "Change background color", + searchTerms: ["color", "bg", "background", "default"], + icon: , + iconContainerStyle: { + borderRadius: "4px", + backgroundColor: "rgba(var(--color-background-100))", + border: "1px solid rgba(var(--color-border-300))", + }, + command: ({ editor, range }) => toggleTextColor(undefined, editor, range), + }, + ...COLORS_LIST.map( + (color) => + ({ + commandKey: "background-color", + key: `background-color-${color.backgroundColor}`, + title: `${color.label} background`, + description: "Change background color", + searchTerms: ["color", "bg", "background", color.label], + icon: , + iconContainerStyle: { + borderRadius: "4px", + backgroundColor: color.backgroundColor, + }, + command: ({ editor, range }) => toggleBackgroundColor(color.backgroundColor, editor, range), + }) as ISlashCommandItem + ), + ], + }, +]; + +export const getSlashCommandFilteredSections = + (additionalOptions?: ISlashCommandItem[]) => + ({ query }: { query: string }): TSlashCommandSection[] => { + if (additionalOptions) { + additionalOptions.map((item) => SLASH_COMMAND_SECTIONS?.[0]?.items.push(item)); + } + + const filteredSlashSections = SLASH_COMMAND_SECTIONS.map((section) => ({ + ...section, + items: section.items.filter((item) => { + if (typeof query !== "string") return; + + const lowercaseQuery = query.toLowerCase(); + return ( + item.title.toLowerCase().includes(lowercaseQuery) || + item.description.toLowerCase().includes(lowercaseQuery) || + item.searchTerms.some((t) => t.includes(lowercaseQuery)) + ); + }), + })); + + return filteredSlashSections.filter((s) => s.items.length !== 0); + }; diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx new file mode 100644 index 00000000000..3a03c3b6a70 --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx @@ -0,0 +1,37 @@ +// helpers +import { cn } from "@/helpers/common"; +// types +import { ISlashCommandItem } from "@/types"; + +type Props = { + isSelected: boolean; + item: ISlashCommandItem; + itemIndex: number; + onClick: (e: React.MouseEvent) => void; + onMouseEnter: () => void; + sectionIndex: number; +}; + +export const CommandMenuItem: React.FC = (props) => { + const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex } = props; + + return ( + + ); +}; diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx new file mode 100644 index 00000000000..977f688286e --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +// components +import { TSlashCommandSection } from "./command-items-list"; +import { CommandMenuItem } from "./command-menu-item"; + +type Props = { + items: TSlashCommandSection[]; + command: any; + editor: any; + range: any; +}; + +export const SlashCommandsMenu = (props: Props) => { + const { items: sections, command } = props; + // states + const [selectedIndex, setSelectedIndex] = useState({ + section: 0, + item: 0, + }); + // refs + const commandListContainer = useRef(null); + + const selectItem = useCallback( + (sectionIndex: number, itemIndex: number) => { + const item = sections[sectionIndex].items[itemIndex]; + if (item) command(item); + }, + [command, sections] + ); + // handle arrow key navigation + useEffect(() => { + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + const onKeyDown = (e: KeyboardEvent) => { + if (navigationKeys.includes(e.key)) { + e.preventDefault(); + const currentSection = selectedIndex.section; + const currentItem = selectedIndex.item; + let nextSection = currentSection; + let nextItem = currentItem; + + if (e.key === "ArrowUp") { + nextItem = currentItem - 1; + if (nextItem < 0) { + nextSection = currentSection - 1; + if (nextSection < 0) nextSection = sections.length - 1; + nextItem = sections[nextSection].items.length - 1; + } + } + if (e.key === "ArrowDown") { + nextItem = currentItem + 1; + if (nextItem >= sections[currentSection].items.length) { + nextSection = currentSection + 1; + if (nextSection >= sections.length) nextSection = 0; + nextItem = 0; + } + } + if (e.key === "Enter") { + selectItem(currentSection, currentItem); + } + setSelectedIndex({ + section: nextSection, + item: nextItem, + }); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [sections, selectedIndex, setSelectedIndex, selectItem]); + // initialize the select index to 0 by default + useEffect(() => { + setSelectedIndex({ + section: 0, + item: 0, + }); + }, [sections]); + // scroll to the dropdown item when navigating via keyboard + useLayoutEffect(() => { + const container = commandListContainer?.current; + if (!container) return; + + const item = container.querySelector(`#item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement; + + // use scroll into view to bring the item in view if it is not in view + item?.scrollIntoView({ block: "nearest" }); + }, [sections, selectedIndex]); + + const areSearchResultsEmpty = sections.map((s) => s.items.length).reduce((acc, curr) => acc + curr, 0) === 0; + + if (areSearchResultsEmpty) return null; + + return ( +
+ {sections.map((section, sectionIndex) => ( +
+ {section.title &&
{section.title}
} +
+ {section.items.map((item, itemIndex) => ( + { + e.stopPropagation(); + selectItem(sectionIndex, itemIndex); + }} + onMouseEnter={() => + setSelectedIndex({ + section: sectionIndex, + item: itemIndex, + }) + } + sectionIndex={sectionIndex} + /> + ))} +
+
+ ))} +
+ ); +}; diff --git a/packages/editor/src/core/extensions/slash-commands/index.ts b/packages/editor/src/core/extensions/slash-commands/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/packages/editor/src/core/extensions/slash-commands/root.tsx b/packages/editor/src/core/extensions/slash-commands/root.tsx new file mode 100644 index 00000000000..ac88f20d3ac --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/root.tsx @@ -0,0 +1,113 @@ +import { Editor, Range, Extension } from "@tiptap/core"; +import { ReactRenderer } from "@tiptap/react"; +import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; +import tippy from "tippy.js"; +// types +import { ISlashCommandItem } from "@/types"; +// components +import { getSlashCommandFilteredSections } from "./command-items-list"; +import { SlashCommandsMenu } from "./command-menu"; + +export type SlashCommandOptions = { + suggestion: Omit; +}; + +const Command = Extension.create({ + name: "slash-command", + addOptions() { + return { + suggestion: { + char: "/", + command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { + props.command({ editor, range }); + }, + allow({ editor }: { editor: Editor }) { + const { selection } = editor.state; + + const parentNode = selection.$from.node(selection.$from.depth); + const blockType = parentNode.type.name; + + if (blockType === "codeBlock") { + return false; + } + + if (editor.isActive("table")) { + return false; + } + + return true; + }, + }, + }; + }, + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ]; + }, +}); + +interface CommandListInstance { + onKeyDown: (props: { event: KeyboardEvent }) => boolean; +} + +const renderItems = () => { + let component: ReactRenderer | null = null; + let popup: any | null = null; + return { + onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { + component = new ReactRenderer(SlashCommandsMenu, { + props, + editor: props.editor, + }); + + const tippyContainer = + document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'); + + // @ts-expect-error Tippy overloads are messed up + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: tippyContainer, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { + component?.updateProps(props); + + popup?.[0]?.setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0].hide(); + + return true; + } + + if (component?.ref?.onKeyDown(props)) { + return true; + } + return false; + }, + onExit: () => { + popup?.[0].destroy(); + component?.destroy(); + }, + }; +}; + +export const SlashCommands = (additionalOptions?: ISlashCommandItem[]) => + Command.configure({ + suggestion: { + items: getSlashCommandFilteredSections(additionalOptions), + render: renderItems, + }, + }); diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index 66be05bb261..fb63a6fbf23 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -154,3 +154,42 @@ export const unsetLinkEditor = (editor: Editor) => { export const setLinkEditor = (editor: Editor, url: string) => { editor.chain().focus().setLink({ href: url }).run(); }; + +export const toggleTextColor = (color: string | undefined, editor: Editor, range?: Range) => { + if (color) { + if (range) editor.chain().focus().deleteRange(range).setColor(color).run(); + else editor.chain().focus().setColor(color).run(); + } else { + if (range) editor.chain().focus().deleteRange(range).unsetColor().run(); + else editor.chain().focus().unsetColor().run(); + } +}; + +export const toggleBackgroundColor = (color: string | undefined, editor: Editor, range?: Range) => { + if (color) { + if (range) { + editor + .chain() + .focus() + .deleteRange(range) + .setHighlight({ + color, + }) + .run(); + } else { + editor + .chain() + .focus() + .setHighlight({ + color, + }) + .run(); + } + } else { + if (range) { + editor.chain().focus().deleteRange(range).unsetHighlight().run(); + } else { + editor.chain().focus().unsetHighlight().run(); + } + } +}; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 65e36c01ae6..0edb6ca50bd 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -136,7 +136,8 @@ export const useEditor = (props: CustomEditorProps) => { insertContentAtSavedSelection(editorRef, content, savedSelection); } }, - executeMenuItemCommand: (itemKey: TEditorCommands) => { + executeMenuItemCommand: (props) => { + const { itemKey } = props; const editorItems = getEditorMenuItems(editorRef.current); const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey); @@ -145,6 +146,8 @@ export const useEditor = (props: CustomEditorProps) => { if (item) { if (item.key === "image") { item.command(savedSelectionRef.current); + } else if (itemKey === "text-color" || itemKey === "background-color") { + item.command(props.color); } else { item.command(); } @@ -152,12 +155,19 @@ export const useEditor = (props: CustomEditorProps) => { console.warn(`No command found for item: ${itemKey}`); } }, - isMenuItemActive: (itemName: TEditorCommands): boolean => { + isMenuItemActive: (props) => { + const { itemKey } = props; const editorItems = getEditorMenuItems(editorRef.current); - const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName); - const item = getEditorMenuItem(itemName); - return item ? item.isActive() : false; + const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey); + const item = getEditorMenuItem(itemKey); + if (!item) return false; + + if (itemKey === "text-color" || itemKey === "background-color") { + return item.isActive(props.color); + } else { + return item.isActive(""); + } }, onHeadingChange: (callback: (headings: IMarking[]) => void) => { // Subscribe to update event emitted from headers extension diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index ba390180bbe..809802b4f92 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -233,14 +233,46 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); + const isScrollable = (node: HTMLElement | SVGElement) => { + if (!(node instanceof HTMLElement || node instanceof SVGElement)) { + return false; + } + const style = getComputedStyle(node); + return ["overflow", "overflow-y"].some((propertyName) => { + const value = style.getPropertyValue(propertyName); + return value === "auto" || value === "scroll"; + }); + }; + + const getScrollParent = (node: HTMLElement | SVGElement) => { + let currentParent = node.parentElement; + while (currentParent) { + if (isScrollable(currentParent)) { + return currentParent; + } + currentParent = currentParent.parentElement; + } + return document.scrollingElement || document.documentElement; + }; + + const maxScrollSpeed = 100; + dragHandleElement.addEventListener("drag", (e) => { hideDragHandle(); - const frameRenderer = document.querySelector(".frame-renderer"); - if (!frameRenderer) return; - if (e.clientY < options.scrollThreshold.up) { - frameRenderer.scrollBy({ top: -70, behavior: "smooth" }); - } else if (window.innerHeight - e.clientY < options.scrollThreshold.down) { - frameRenderer.scrollBy({ top: 70, behavior: "smooth" }); + const scrollableParent = getScrollParent(dragHandleElement); + if (!scrollableParent) return; + const scrollThreshold = options.scrollThreshold; + + if (e.clientY < scrollThreshold.up) { + const overflow = scrollThreshold.up - e.clientY; + const ratio = Math.min(overflow / scrollThreshold.up, 1); + const scrollAmount = -maxScrollSpeed * ratio; + scrollableParent.scrollBy({ top: scrollAmount }); + } else if (window.innerHeight - e.clientY < scrollThreshold.down) { + const overflow = e.clientY - (window.innerHeight - scrollThreshold.down); + const ratio = Math.min(overflow / scrollThreshold.down, 1); + const scrollAmount = maxScrollSpeed * ratio; + scrollableParent.scrollBy({ top: scrollAmount }); } }); diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 3624fa046ce..c833cb74920 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -6,14 +6,15 @@ import { IMentionHighlight, IMentionSuggestion, TAIHandler, + TColorEditorCommands, TDisplayConfig, TEditorCommands, TEmbedConfig, TExtensions, TFileHandler, + TNonColorEditorCommands, TServerHandler, } from "@/types"; - // editor refs export type EditorReadOnlyRefApi = { getMarkDown: () => string; @@ -36,8 +37,26 @@ export type EditorReadOnlyRefApi = { export interface EditorRefApi extends EditorReadOnlyRefApi { setEditorValueAtCursorPosition: (content: string) => void; - executeMenuItemCommand: (itemKey: TEditorCommands) => void; - isMenuItemActive: (itemKey: TEditorCommands) => boolean; + executeMenuItemCommand: ( + props: + | { + itemKey: TNonColorEditorCommands; + } + | { + itemKey: TColorEditorCommands; + color: string | undefined; + } + ) => void; + isMenuItemActive: ( + props: + | { + itemKey: TNonColorEditorCommands; + } + | { + itemKey: TColorEditorCommands; + color: string | undefined; + } + ) => boolean; onStateChange: (callback: () => void) => () => void; setFocusAtPosition: (position: number) => void; isEditorReadyToDiscard: () => boolean; diff --git a/packages/editor/src/core/types/slash-commands-suggestion.ts b/packages/editor/src/core/types/slash-commands-suggestion.ts index 3cb9d76b0ea..ce3408a34f0 100644 --- a/packages/editor/src/core/types/slash-commands-suggestion.ts +++ b/packages/editor/src/core/types/slash-commands-suggestion.ts @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import { CSSProperties } from "react"; import { Editor, Range } from "@tiptap/core"; export type TEditorCommands = @@ -21,7 +21,12 @@ export type TEditorCommands = | "table" | "image" | "divider" - | "issue-embed"; + | "issue-embed" + | "text-color" + | "background-color"; + +export type TColorEditorCommands = Extract; +export type TNonColorEditorCommands = Exclude; export type CommandProps = { editor: Editor; @@ -29,10 +34,12 @@ export type CommandProps = { }; export type ISlashCommandItem = { - key: TEditorCommands; + commandKey: TEditorCommands; + key: string; title: string; description: string; searchTerms: string[]; - icon: ReactNode; + icon: React.ReactNode; + iconContainerStyle?: CSSProperties; command: ({ editor, range }: CommandProps) => void; }; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index fc9fe1ac603..c65f43dd4fb 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -18,6 +18,9 @@ export { export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; +// constants +export * from "@/constants/common"; + // helpers export * from "@/helpers/common"; export * from "@/helpers/editor-commands"; diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index e5047fb0c48..caffc0534a5 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -44,7 +44,7 @@ } &.sans-serif { - --font-style: sans-serif; + --font-style: "Inter", sans-serif; } &.serif { diff --git a/packages/ui/src/modals/constants.ts b/packages/ui/src/modals/constants.ts index 0cb268fc882..fe72ef7aea1 100644 --- a/packages/ui/src/modals/constants.ts +++ b/packages/ui/src/modals/constants.ts @@ -4,6 +4,9 @@ export enum EModalPosition { } export enum EModalWidth { + SM = "sm:max-w-sm", + MD = "sm:max-w-md", + LG = "sm:max-w-lg", XL = "sm:max-w-xl", XXL = "sm:max-w-2xl", XXXL = "sm:max-w-3xl", diff --git a/space/core/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx index 186f44a1011..86b143f9ded 100644 --- a/space/core/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -1,6 +1,6 @@ import React from "react"; // editor -import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor"; // components import { IssueCommentToolbar } from "@/components/editor"; // helpers @@ -56,7 +56,9 @@ export const LiteTextEditor = React.forwardRef { if (isMutableRefObject(ref)) { - ref.current?.executeMenuItemCommand(key); + ref.current?.executeMenuItemCommand({ + itemKey: key as TNonColorEditorCommands, + }); } }} isSubmitting={isSubmitting} diff --git a/space/core/components/editor/toolbar.tsx b/space/core/components/editor/toolbar.tsx index 45e94c2d9a5..beccc8cb763 100644 --- a/space/core/components/editor/toolbar.tsx +++ b/space/core/components/editor/toolbar.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from "react"; // editor -import { EditorRefApi, TEditorCommands } from "@plane/editor"; +import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor"; // ui import { Button, Tooltip } from "@plane/ui"; // constants @@ -34,7 +34,9 @@ export const IssueCommentToolbar: React.FC = (props) => { .flat() .forEach((item) => { // Assert that editorRef.current is not null - newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key); + newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive({ + itemKey: item.key as TNonColorEditorCommands, + }); }); setActiveStates(newActiveStates); } diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx index 27e33e2c2c5..aa81ae5816a 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx @@ -29,7 +29,7 @@ export const CycleIssuesMobileHeader = () => { const { getCycleById } = useCycle(); const layouts = [ { key: "list", title: "List", icon: List }, - { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "kanban", title: "Board", icon: Kanban }, { key: "calendar", title: "Calendar", icon: Calendar }, ]; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx index a1255b206c2..025c9fab04f 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx @@ -28,7 +28,7 @@ import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/h export const ProjectIssuesMobileHeader = observer(() => { const layouts = [ { key: "list", title: "List", icon: List }, - { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "kanban", title: "Board", icon: Kanban }, { key: "calendar", title: "Calendar", icon: Calendar }, ]; const [analyticsModal, setAnalyticsModal] = useState(false); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx index f67df642c76..0edfa2b5a2b 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx @@ -31,7 +31,7 @@ export const ModuleIssuesMobileHeader = observer(() => { const { getModuleById } = useModule(); const layouts = [ { key: "list", title: "List", icon: List }, - { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "kanban", title: "Board", icon: Kanban }, { key: "calendar", title: "Calendar", icon: Calendar }, ]; const { workspaceSlug, projectId, moduleId } = useParams() as { diff --git a/web/app/profile/appearance/page.tsx b/web/app/profile/appearance/page.tsx index ef19a3342cf..775ff637b04 100644 --- a/web/app/profile/appearance/page.tsx +++ b/web/app/profile/appearance/page.tsx @@ -52,13 +52,8 @@ const ProfileAppearancePage = observer(() => { const applyThemeChange = (theme: Partial) => { setTheme(theme?.theme || "system"); - const customThemeElement = window.document?.querySelector("[data-theme='custom']"); - if (theme?.theme === "custom" && theme?.palette && customThemeElement) { - applyTheme( - theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", - false, - customThemeElement - ); + if (theme?.theme === "custom" && theme?.palette) { + applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false); } else unsetCustomCssVariables(); }; diff --git a/web/ce/components/cycles/analytics-sidebar/base.tsx b/web/ce/components/cycles/analytics-sidebar/base.tsx new file mode 100644 index 00000000000..94609bc1f63 --- /dev/null +++ b/web/ce/components/cycles/analytics-sidebar/base.tsx @@ -0,0 +1,69 @@ +"use client"; +import { FC, Fragment } from "react"; +import { observer } from "mobx-react"; +// plane ui +import { Loader } from "@plane/ui"; +// components +import ProgressChart from "@/components/core/sidebar/progress-chart"; +import { validateCycleSnapshot } from "@/components/cycles"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; +// hooks +import { useCycle } from "@/hooks/store"; + +type ProgressChartProps = { + workspaceSlug: string; + projectId: string; + cycleId: string; +}; +export const SidebarChart: FC = observer((props) => { + const { workspaceSlug, projectId, cycleId } = props; + + // hooks + const { getEstimateTypeByCycleId, getCycleById } = useCycle(); + + // derived data + const cycleDetails = validateCycleSnapshot(getCycleById(cycleId)); + const cycleStartDate = getDate(cycleDetails?.start_date); + const cycleEndDate = getDate(cycleDetails?.end_date); + const totalEstimatePoints = cycleDetails?.total_estimate_points || 0; + const totalIssues = cycleDetails?.total_issues || 0; + const estimateType = getEstimateTypeByCycleId(cycleId); + + const chartDistributionData = + estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined; + + const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; + + if (!workspaceSlug || !projectId || !cycleId) return null; + + return ( +
+
+
+ + Ideal +
+
+ + Current +
+
+ {cycleStartDate && cycleEndDate && completionChartDistributionData ? ( + + + + ) : ( + + + + )} +
+ ); +}); diff --git a/web/ce/components/cycles/analytics-sidebar/index.ts b/web/ce/components/cycles/analytics-sidebar/index.ts index 3ba38c61be5..1efe34c51ec 100644 --- a/web/ce/components/cycles/analytics-sidebar/index.ts +++ b/web/ce/components/cycles/analytics-sidebar/index.ts @@ -1 +1 @@ -export * from "./sidebar-chart"; +export * from "./root"; diff --git a/web/ce/components/cycles/analytics-sidebar/root.tsx b/web/ce/components/cycles/analytics-sidebar/root.tsx new file mode 100644 index 00000000000..d18f9168dcf --- /dev/null +++ b/web/ce/components/cycles/analytics-sidebar/root.tsx @@ -0,0 +1,12 @@ +"use client"; +import React, { FC } from "react"; +// components +import { SidebarChart } from "./base"; + +type Props = { + workspaceSlug: string; + projectId: string; + cycleId: string; +}; + +export const SidebarChartRoot: FC = (props) => ; diff --git a/web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx b/web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx deleted file mode 100644 index e5b69ef24b1..00000000000 --- a/web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Fragment } from "react"; -import { TCycleDistribution, TCycleEstimateDistribution } from "@plane/types"; -import { Loader } from "@plane/ui"; -import ProgressChart from "@/components/core/sidebar/progress-chart"; - -type ProgressChartProps = { - chartDistributionData: TCycleEstimateDistribution | TCycleDistribution | undefined; - cycleStartDate: Date | undefined; - cycleEndDate: Date | undefined; - totalEstimatePoints: number; - totalIssues: number; - plotType: string; -}; -export const SidebarBaseChart = (props: ProgressChartProps) => { - const { chartDistributionData, cycleStartDate, cycleEndDate, totalEstimatePoints, totalIssues, plotType } = props; - const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; - - return ( -
-
-
- - Ideal -
-
- - Current -
-
- {cycleStartDate && cycleEndDate && completionChartDistributionData ? ( - - {plotType === "points" ? ( - - ) : ( - - )} - - ) : ( - - - - )} -
- ); -}; diff --git a/web/ce/components/issues/issue-details/issue-identifier.tsx b/web/ce/components/issues/issue-details/issue-identifier.tsx index b12cc6de71c..c461e88fa31 100644 --- a/web/ce/components/issues/issue-details/issue-identifier.tsx +++ b/web/ce/components/issues/issue-details/issue-identifier.tsx @@ -1,6 +1,8 @@ import { observer } from "mobx-react"; // types import { IIssueDisplayProperties } from "@plane/types"; +// ui +import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; // helpers import { cn } from "@/helpers/common.helper"; // hooks @@ -11,6 +13,7 @@ type TIssueIdentifierBaseProps = { size?: "xs" | "sm" | "md" | "lg"; textContainerClassName?: string; displayProperties?: IIssueDisplayProperties | undefined; + enableClickToCopyIdentifier?: boolean; }; type TIssueIdentifierFromStore = TIssueIdentifierBaseProps & { @@ -23,9 +26,48 @@ type TIssueIdentifierWithDetails = TIssueIdentifierBaseProps & { issueSequenceId: string | number; }; -type TIssueIdentifierProps = TIssueIdentifierFromStore | TIssueIdentifierWithDetails; +export type TIssueIdentifierProps = TIssueIdentifierFromStore | TIssueIdentifierWithDetails; + +type TIdentifierTextProps = { + identifier: string; + enableClickToCopyIdentifier?: boolean; + textContainerClassName?: string; +}; + +export const IdentifierText: React.FC = (props) => { + const { identifier, enableClickToCopyIdentifier = false, textContainerClassName } = props; + // handlers + const handleCopyIssueIdentifier = () => { + if (enableClickToCopyIdentifier) { + navigator.clipboard.writeText(identifier).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Issue ID copied to clipboard", + }); + }); + } + }; + + return ( + + + {identifier} + + + ); +}; + export const IssueIdentifier: React.FC = observer((props) => { - const { projectId, textContainerClassName, displayProperties } = props; + const { projectId, textContainerClassName, displayProperties, enableClickToCopyIdentifier = false } = props; // store hooks const { getProjectIdentifierById } = useProject(); const { @@ -43,9 +85,11 @@ export const IssueIdentifier: React.FC = observer((props) return (
- - {projectIdentifier}-{issueSequenceId} - +
); }); diff --git a/web/ce/components/issues/issue-details/issue-type-switcher.tsx b/web/ce/components/issues/issue-details/issue-type-switcher.tsx index 5cbd8e6d67f..5d4adeb9558 100644 --- a/web/ce/components/issues/issue-details/issue-type-switcher.tsx +++ b/web/ce/components/issues/issue-details/issue-type-switcher.tsx @@ -20,5 +20,5 @@ export const IssueTypeSwitcher: React.FC = observer((pr if (!issue || !issue.project_id) return <>; - return ; + return ; }); diff --git a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx index 2f9b4b79e9d..55178ed2171 100644 --- a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx +++ b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC, Fragment, useCallback, useMemo, useState } from "react"; +import { FC, Fragment, useCallback, useMemo } from "react"; import isEmpty from "lodash/isEmpty"; import isEqual from "lodash/isEqual"; import { observer } from "mobx-react"; @@ -16,10 +16,9 @@ import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; // helpers import { getDate } from "@/helpers/date-time.helper"; // hooks -import { useIssues, useCycle, useProjectEstimates } from "@/hooks/store"; -// plane web constants -import { SidebarBaseChart } from "@/plane-web/components/cycles/analytics-sidebar"; -import { EEstimateSystem } from "@/plane-web/constants/estimates"; +import { useIssues, useCycle } from "@/hooks/store"; +// plane web components +import { SidebarChartRoot } from "@/plane-web/components/cycles"; type TCycleAnalyticsProgress = { workspaceSlug: string; @@ -27,7 +26,7 @@ type TCycleAnalyticsProgress = { cycleId: string; }; -const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => { +export const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => { if (!cycleDetails || cycleDetails === null) return cycleDetails; const updatedCycleDetails: any = { ...cycleDetails }; @@ -60,12 +59,9 @@ export const CycleAnalyticsProgress: FC = observer((pro // router const searchParams = useSearchParams(); const peekCycle = searchParams.get("peekCycle") || undefined; - // hooks - const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates(); const { getPlotTypeByCycleId, getEstimateTypeByCycleId, - setPlotType, getCycleById, fetchCycleDetails, fetchArchivedCycleDetails, @@ -74,17 +70,11 @@ export const CycleAnalyticsProgress: FC = observer((pro const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.CYCLE); - // state - const [loader, setLoader] = useState(false); // derived values const cycleDetails = validateCycleSnapshot(getCycleById(cycleId)); const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId); const estimateType = getEstimateTypeByCycleId(cycleId); - const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; - const estimateDetails = - isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId); - const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS; const completedIssues = cycleDetails?.completed_issues || 0; const totalIssues = cycleDetails?.total_issues || 0; @@ -132,15 +122,13 @@ export const CycleAnalyticsProgress: FC = observer((pro setEstimateType(cycleId, value); if (!workspaceSlug || !projectId || !cycleId) return; try { - setLoader(true); if (isArchived) { await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId); } else { await fetchCycleDetails(workspaceSlug, projectId, cycleId); } - setLoader(false); - } catch (error) { - setLoader(false); + } catch (err) { + console.error(err); setEstimateType(cycleId, estimateType); } }; @@ -218,16 +206,15 @@ export const CycleAnalyticsProgress: FC = observer((pro ))} +
+
+ Done + {progressHeaderPercentage}% +
+
- +
{/* progress detailed view */} {chartDistributionData && ( diff --git a/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx b/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx index 0c6ad1fd81f..708b9c217b3 100644 --- a/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx +++ b/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx @@ -3,10 +3,10 @@ import React, { FC } from "react"; import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; import { LayersIcon, SquareUser, Users } from "lucide-react"; -// ui +// types import { ICycle } from "@plane/types"; +// ui import { Avatar, AvatarGroup, TextArea } from "@plane/ui"; -// types // hooks import { useMember, useProjectEstimates } from "@/hooks/store"; // plane web diff --git a/web/core/components/editor/index.ts b/web/core/components/editor/index.ts index 72e92a6a8ac..0b14bd13570 100644 --- a/web/core/components/editor/index.ts +++ b/web/core/components/editor/index.ts @@ -1,2 +1,3 @@ export * from "./lite-text-editor"; +export * from "./pdf"; export * from "./rich-text-editor"; diff --git a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx index 8036e4c8d4e..f8e1f3bde22 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx @@ -1,6 +1,6 @@ import React from "react"; // editor -import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor"; // types import { IUserLite } from "@plane/types"; // components @@ -87,7 +87,9 @@ export const LiteTextEditor = React.forwardRef { if (isMutableRefObject(ref)) { - ref.current?.executeMenuItemCommand(key); + ref.current?.executeMenuItemCommand({ + itemKey: key as TNonColorEditorCommands, + }); } }} handleAccessChange={handleAccessChange} diff --git a/web/core/components/editor/lite-text-editor/toolbar.tsx b/web/core/components/editor/lite-text-editor/toolbar.tsx index 58eef6f13b5..ecf8c3283c8 100644 --- a/web/core/components/editor/lite-text-editor/toolbar.tsx +++ b/web/core/components/editor/lite-text-editor/toolbar.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState, useCallback } from "react"; import { Globe2, Lock, LucideIcon } from "lucide-react"; // editor -import { EditorRefApi, TEditorCommands } from "@plane/editor"; +import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor"; // ui import { Button, Tooltip } from "@plane/ui"; // constants @@ -69,7 +69,9 @@ export const IssueCommentToolbar: React.FC = (props) => { .flat() .forEach((item) => { // Assert that editorRef.current is not null - newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key); + newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive({ + itemKey: item.key as TNonColorEditorCommands, + }); }); setActiveStates(newActiveStates); } diff --git a/web/core/components/editor/pdf/document.tsx b/web/core/components/editor/pdf/document.tsx new file mode 100644 index 00000000000..4dca9e6d53c --- /dev/null +++ b/web/core/components/editor/pdf/document.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Document, Font, Page, PageProps } from "@react-pdf/renderer"; +import { Html } from "react-pdf-html"; +// constants +import { EDITOR_PDF_DOCUMENT_STYLESHEET } from "@/constants/editor"; + +Font.register({ + family: "Inter", + fonts: [ + { src: "/fonts/inter/thin.ttf", fontWeight: "thin" }, + { src: "/fonts/inter/thin.ttf", fontWeight: "thin", fontStyle: "italic" }, + { src: "/fonts/inter/ultralight.ttf", fontWeight: "ultralight" }, + { src: "/fonts/inter/ultralight.ttf", fontWeight: "ultralight", fontStyle: "italic" }, + { src: "/fonts/inter/light.ttf", fontWeight: "light" }, + { src: "/fonts/inter/light.ttf", fontWeight: "light", fontStyle: "italic" }, + { src: "/fonts/inter/regular.ttf", fontWeight: "normal" }, + { src: "/fonts/inter/regular.ttf", fontWeight: "normal", fontStyle: "italic" }, + { src: "/fonts/inter/medium.ttf", fontWeight: "medium" }, + { src: "/fonts/inter/medium.ttf", fontWeight: "medium", fontStyle: "italic" }, + { src: "/fonts/inter/semibold.ttf", fontWeight: "semibold" }, + { src: "/fonts/inter/semibold.ttf", fontWeight: "semibold", fontStyle: "italic" }, + { src: "/fonts/inter/bold.ttf", fontWeight: "bold" }, + { src: "/fonts/inter/bold.ttf", fontWeight: "bold", fontStyle: "italic" }, + { src: "/fonts/inter/extrabold.ttf", fontWeight: "ultrabold" }, + { src: "/fonts/inter/extrabold.ttf", fontWeight: "ultrabold", fontStyle: "italic" }, + { src: "/fonts/inter/heavy.ttf", fontWeight: "heavy" }, + { src: "/fonts/inter/heavy.ttf", fontWeight: "heavy", fontStyle: "italic" }, + ], +}); + +type Props = { + content: string; + pageFormat: PageProps["size"]; +}; + +export const PDFDocument: React.FC = (props) => { + const { content, pageFormat } = props; + + return ( + + + {content} + + + ); +}; diff --git a/web/core/components/editor/pdf/index.ts b/web/core/components/editor/pdf/index.ts new file mode 100644 index 00000000000..fe6d89c0eb9 --- /dev/null +++ b/web/core/components/editor/pdf/index.ts @@ -0,0 +1 @@ +export * from "./document"; diff --git a/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 0ee9d96fd44..ab4b73df4d2 100644 --- a/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -18,7 +18,7 @@ import { SPREADSHEET_SELECT_GROUP } from "@/constants/spreadsheet"; // helper import { cn } from "@/helpers/common.helper"; // hooks -import { useIssueDetail, useProject } from "@/hooks/store"; +import { useIssueDetail, useIssues, useProject } from "@/hooks/store"; import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection"; import { TSelectionHelper } from "@/hooks/use-multiple-select"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -26,6 +26,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; import { IssueIdentifier } from "@/plane-web/components/issues"; // local components import { TRenderQuickActions } from "../list/list-view-types"; +import { isIssueNew } from "../utils"; import { IssueColumn } from "./issue-column"; interface Props { @@ -42,6 +43,7 @@ interface Props { spreadsheetColumnsList: (keyof IIssueDisplayProperties)[]; spacingLeft?: number; selectionHelpers: TSelectionHelper; + shouldRenderByDefault?: boolean; } export const SpreadsheetIssueRow = observer((props: Props) => { @@ -59,11 +61,14 @@ export const SpreadsheetIssueRow = observer((props: Props) => { spreadsheetColumnsList, spacingLeft = 6, selectionHelpers, + shouldRenderByDefault, } = props; // states const [isExpanded, setExpanded] = useState(false); // store hooks const { subIssues: subIssuesStore } = useIssueDetail(); + const { issueMap } = useIssues(); + // derived values const subIssues = subIssuesStore.subIssuesByIssueId(issueId); const isIssueSelected = selectionHelpers.getIsEntitySelected(issueId); @@ -88,6 +93,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => { })} verticalOffset={100} shouldRecordHeights={false} + defaultValue={shouldRenderByDefault || isIssueNew(issueMap[issueId])} > { containerRef={containerRef} spreadsheetColumnsList={spreadsheetColumnsList} selectionHelpers={selectionHelpers} + shouldRenderByDefault={isExpanded} /> ))} diff --git a/web/core/components/issues/peek-overview/issue-detail.tsx b/web/core/components/issues/peek-overview/issue-detail.tsx index 20117d10f51..242ebfd0cee 100644 --- a/web/core/components/issues/peek-overview/issue-detail.tsx +++ b/web/core/components/issues/peek-overview/issue-detail.tsx @@ -1,7 +1,7 @@ import { FC, useEffect } from "react"; import { observer } from "mobx-react"; // components -import { TIssueOperations } from "@/components/issues"; +import { IssueParentDetail, TIssueOperations } from "@/components/issues"; // store hooks import { useIssueDetail, useUser } from "@/hooks/store"; // hooks @@ -57,6 +57,15 @@ export const PeekOverviewIssueDetails: FC = observer( return (
+ {issue.parent_id && ( + + )} void; + isColorActive: (key: TColorEditorCommands, color: string | undefined) => boolean; +}; + +export const ColorDropdown: React.FC = memo((props) => { + const { handleColorSelect, isColorActive } = props; + + const activeTextColor = COLORS_LIST.find((c) => isColorActive("text-color", c.textColor)); + const activeBackgroundColor = COLORS_LIST.find((c) => isColorActive("background-color", c.backgroundColor)); + + return ( + + + cn("h-full", { + "outline-none": open, + }) + } + > + {({ open }) => ( + + Color + + + + + )} + + +
+

Text colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+
+

Background colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+
+
+ ); +}); diff --git a/web/core/components/pages/editor/header/index.ts b/web/core/components/pages/editor/header/index.ts index 219ed44d87f..d87f5d11946 100644 --- a/web/core/components/pages/editor/header/index.ts +++ b/web/core/components/pages/editor/header/index.ts @@ -1,3 +1,4 @@ +export * from "./color-dropdown"; export * from "./extra-options"; export * from "./info-popover"; export * from "./options-dropdown"; diff --git a/web/core/components/pages/editor/header/options-dropdown.tsx b/web/core/components/pages/editor/header/options-dropdown.tsx index 0560002d840..c7cf53a5f50 100644 --- a/web/core/components/pages/editor/header/options-dropdown.tsx +++ b/web/core/components/pages/editor/header/options-dropdown.tsx @@ -1,12 +1,15 @@ "use client"; +import { useState } from "react"; import { observer } from "mobx-react"; import { useParams, useRouter } from "next/navigation"; -import { ArchiveRestoreIcon, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react"; +import { ArchiveRestoreIcon, ArrowUpToLine, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react"; // document editor import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; // ui import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; +// components +import { ExportPageModal } from "@/components/pages"; // helpers import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper"; // hooks @@ -27,6 +30,7 @@ export const PageOptionsDropdown: React.FC = observer((props) => { const router = useRouter(); // store values const { + name, archived_at, is_locked, id, @@ -38,6 +42,8 @@ export const PageOptionsDropdown: React.FC = observer((props) => { canCurrentUserLockPage, restore, } = page; + // states + const [isExportModalOpen, setIsExportModalOpen] = useState(false); // store hooks const { workspaceSlug, projectId } = useParams(); // page filters @@ -157,26 +163,41 @@ export const PageOptionsDropdown: React.FC = observer((props) => { icon: History, shouldRender: true, }, + { + key: "export", + action: () => setIsExportModalOpen(true), + label: "Export", + icon: ArrowUpToLine, + shouldRender: true, + }, ]; return ( - - handleFullWidth(!isFullWidth)} - > - Full width - {}} /> - - {MENU_ITEMS.map((item) => { - if (!item.shouldRender) return null; - return ( - - - {item.label} - - ); - })} - + <> + setIsExportModalOpen(false)} + pageTitle={name ?? ""} + /> + + handleFullWidth(!isFullWidth)} + > + Full width + {}} /> + + {MENU_ITEMS.map((item) => { + if (!item.shouldRender) return null; + return ( + + + {item.label} + + ); + })} + + ); }); diff --git a/web/core/components/pages/editor/header/toolbar.tsx b/web/core/components/pages/editor/header/toolbar.tsx index 65d484ef152..447616b532f 100644 --- a/web/core/components/pages/editor/header/toolbar.tsx +++ b/web/core/components/pages/editor/header/toolbar.tsx @@ -3,9 +3,11 @@ import React, { useEffect, useState, useCallback } from "react"; import { Check, ChevronDown } from "lucide-react"; // editor -import { EditorRefApi, TEditorCommands } from "@plane/editor"; +import { EditorRefApi, TNonColorEditorCommands } from "@plane/editor"; // ui import { CustomMenu, Tooltip } from "@plane/ui"; +// components +import { ColorDropdown } from "@/components/pages"; // constants import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS, ToolbarMenuItem } from "@/constants/editor"; // helpers @@ -18,7 +20,7 @@ type Props = { type ToolbarButtonProps = { item: ToolbarMenuItem; isActive: boolean; - executeCommand: (commandKey: TEditorCommands) => void; + executeCommand: EditorRefApi["executeMenuItemCommand"]; }; const ToolbarButton: React.FC = React.memo((props) => { @@ -36,7 +38,11 @@ const ToolbarButton: React.FC = React.memo((props) => {