diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index ea300602723..a126c2ab65b 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -12,8 +12,6 @@ from django.db.models import ( Count, Exists, - F, - Func, OuterRef, Prefetch, Q, @@ -50,7 +48,6 @@ Issue, IssueAssignee, IssueLabel, - IssueLink, IssueReaction, IssueRelation, IssueSubscriber, @@ -76,6 +73,23 @@ from .. import BaseAPIView, BaseViewSet +def annotate_issue_cycle_and_counts(queryset): + return ( + queryset.annotate( + cycle_id=Subquery(CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1]) + ) + .annotate(link_count=Count("issue_link", distinct=True)) + .annotate( + attachment_count=Count( + "assets", + filter=Q(assets__entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT), + distinct=True, + ) + ) + .annotate(sub_issues_count=Count("parent_issue", distinct=True)) + ) + + class IssueListEndpoint(BaseAPIView): filter_backends = (ComplexFilterBackend,) filterset_class = IssueFilterSet @@ -106,35 +120,7 @@ def get(self, request, slug, project_id): ) # Add annotations - issue_queryset = ( - issue_queryset.annotate( - cycle_id=Subquery( - CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] - ) - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .distinct() - ) + issue_queryset = annotate_issue_cycle_and_counts(issue_queryset).distinct() order_by_param = request.GET.get("order_by", "-created_at") # Issue queryset @@ -210,42 +196,7 @@ def get_queryset(self): return issues def apply_annotations(self, issues): - issues = ( - issues.annotate( - cycle_id=Subquery( - CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] - ) - ) - .annotate( - link_count=Subquery( - IssueLink.objects.filter(issue=OuterRef("id")) - .values("issue") - .annotate(count=Count("id")) - .values("count") - ) - ) - .annotate( - attachment_count=Subquery( - FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .values("issue_id") - .annotate(count=Count("id")) - .values("count") - ) - ) - .annotate( - sub_issues_count=Subquery( - Issue.issue_objects.filter(parent=OuterRef("id")) - .values("parent") - .annotate(count=Count("id")) - .values("count") - ) - ) - ) - - return issues + return annotate_issue_cycle_and_counts(issues) @method_decorator(gzip_page) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @@ -482,40 +433,14 @@ def retrieve(self, request, slug, project_id, pk=None): project = Project.objects.get(pk=project_id, workspace__slug=slug) issue = ( - Issue.objects.filter( - project_id=self.kwargs.get("project_id"), - workspace__slug=self.kwargs.get("slug"), - pk=pk, - ) - .select_related("state") - .annotate(cycle_id=Subquery(CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[:1])) - .annotate( - link_count=Subquery( - IssueLink.objects.filter(issue=OuterRef("id")) - .values("issue") - .annotate(count=Count("id")) - .values("count") - ) - ) - .annotate( - attachment_count=Subquery( - FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .values("issue_id") - .annotate(count=Count("id")) - .values("count") - ) - ) - .annotate( - sub_issues_count=Subquery( - Issue.issue_objects.filter(parent=OuterRef("id")) - .values("parent") - .annotate(count=Count("id")) - .values("count") + annotate_issue_cycle_and_counts( + Issue.objects.filter( + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + pk=pk, ) ) + .select_related("state") .annotate( label_ids=Coalesce( Subquery( @@ -808,37 +733,7 @@ def get_queryset(self): issue_queryset = Issue.issue_objects.filter(workspace__slug=workspace_slug, project_id=project_id) - return ( - issue_queryset.select_related("state") - .annotate(cycle_id=Subquery(CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[:1])) - .annotate( - link_count=Subquery( - IssueLink.objects.filter(issue=OuterRef("id")) - .values("issue") - .annotate(count=Count("id")) - .values("count") - ) - ) - .annotate( - attachment_count=Subquery( - FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .values("issue_id") - .annotate(count=Count("id")) - .values("count") - ) - ) - .annotate( - sub_issues_count=Subquery( - Issue.issue_objects.filter(parent=OuterRef("id")) - .values("parent") - .annotate(count=Count("id")) - .values("count") - ) - ) - ) + return annotate_issue_cycle_and_counts(issue_queryset.select_related("state")) def process_paginated_result(self, fields, results, timezone): paginated_data = results.values(*fields) @@ -966,32 +861,7 @@ class IssueDetailEndpoint(BaseAPIView): def apply_annotations(self, issues): return ( - issues.annotate( - cycle_id=Subquery( - CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] - ) - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) + annotate_issue_cycle_and_counts(issues) .prefetch_related( Prefetch( "issue_assignee", @@ -1220,32 +1090,9 @@ def get(self, request, slug, project_identifier, issue_identifier): # Fetch the issue issue = ( - Issue.objects.filter(project_id=project.id) - .filter(workspace__slug=slug) + annotate_issue_cycle_and_counts(Issue.objects.filter(project_id=project.id).filter(workspace__slug=slug)) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=Subquery(CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[:1])) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) .filter(sequence_id=issue_identifier) .annotate( label_ids=Coalesce( diff --git a/apps/api/plane/app/views/issue/relation.py b/apps/api/plane/app/views/issue/relation.py index e91ddffec81..877788ff3f3 100644 --- a/apps/api/plane/app/views/issue/relation.py +++ b/apps/api/plane/app/views/issue/relation.py @@ -7,7 +7,7 @@ # Django imports from django.utils import timezone -from django.db.models import Q, OuterRef, F, Func, UUIDField, Value, CharField, Subquery +from django.db.models import Q, UUIDField, Value, CharField from django.core.serializers.json import DjangoJSONEncoder from django.db.models.functions import Coalesce from django.contrib.postgres.aggregates import ArrayAgg @@ -25,13 +25,11 @@ Project, IssueRelation, Issue, - FileAsset, - IssueLink, - CycleIssue, ) from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.issue_relation_mapper import get_actual_relation from plane.utils.host import base_host +from .base import annotate_issue_cycle_and_counts class IssueRelationViewSet(BaseViewSet): @@ -100,35 +98,9 @@ def list(self, request, slug, project_id, issue_id): ) queryset = ( - Issue.issue_objects.filter(workspace__slug=slug) + annotate_issue_cycle_and_counts(Issue.issue_objects.filter(workspace__slug=slug)) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate( - cycle_id=Subquery( - CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] - ) - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) .annotate( label_ids=Coalesce( ArrayAgg( diff --git a/apps/api/plane/app/views/issue/sub_issue.py b/apps/api/plane/app/views/issue/sub_issue.py index b52e07564f6..d80c685e1c2 100644 --- a/apps/api/plane/app/views/issue/sub_issue.py +++ b/apps/api/plane/app/views/issue/sub_issue.py @@ -7,7 +7,7 @@ # Django imports from django.utils import timezone -from django.db.models import OuterRef, Func, F, Q, Value, UUIDField, Subquery +from django.db.models import F, Q, Value, UUIDField from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.contrib.postgres.aggregates import ArrayAgg @@ -22,12 +22,13 @@ from .. import BaseAPIView from plane.app.serializers import IssueSerializer from plane.app.permissions import ProjectEntityPermission -from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue +from plane.db.models import Issue from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.timezone_converter import user_timezone_converter from collections import defaultdict from plane.utils.host import base_host from plane.utils.order_queryset import order_issue_queryset +from .base import annotate_issue_cycle_and_counts class SubIssuesEndpoint(BaseAPIView): @@ -36,35 +37,9 @@ class SubIssuesEndpoint(BaseAPIView): @method_decorator(gzip_page) def get(self, request, slug, project_id, issue_id): sub_issues = ( - Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug) + annotate_issue_cycle_and_counts(Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug)) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate( - cycle_id=Subquery( - CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] - ) - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) .annotate( label_ids=Coalesce( ArrayAgg( diff --git a/apps/api/plane/app/views/module/issue.py b/apps/api/plane/app/views/module/issue.py index 088468dcd35..aef68cd5439 100644 --- a/apps/api/plane/app/views/module/issue.py +++ b/apps/api/plane/app/views/module/issue.py @@ -5,7 +5,7 @@ # Python imports import json -from django.db.models import F, Func, OuterRef, Q, Subquery +from django.db.models import Q # Django Imports from django.utils import timezone @@ -19,14 +19,7 @@ from plane.app.permissions import allow_permission, ROLE from plane.app.serializers import ModuleIssueSerializer from plane.bgtasks.issue_activities_task import issue_activity -from plane.db.models import ( - Issue, - FileAsset, - IssueLink, - ModuleIssue, - Project, - CycleIssue, -) +from plane.db.models import Issue, ModuleIssue, Project from plane.utils.grouper import ( issue_group_values, issue_on_results, @@ -37,6 +30,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from plane.utils.filters import ComplexFilterBackend from plane.utils.filters import IssueFilterSet +from ..issue.base import annotate_issue_cycle_and_counts from .. import BaseViewSet from plane.utils.host import base_host @@ -50,35 +44,7 @@ class ModuleIssueViewSet(BaseViewSet): filterset_class = IssueFilterSet def apply_annotations(self, issues): - return ( - issues.annotate( - cycle_id=Subquery( - CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] - ) - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .prefetch_related("assignees", "labels", "issue_module__module") - ) + return annotate_issue_cycle_and_counts(issues).prefetch_related("assignees", "labels", "issue_module__module") def get_queryset(self): return ( diff --git a/apps/api/plane/app/views/view/base.py b/apps/api/plane/app/views/view/base.py index 3e082eb98b8..94963189393 100644 --- a/apps/api/plane/app/views/view/base.py +++ b/apps/api/plane/app/views/view/base.py @@ -6,7 +6,6 @@ from django.db.models import ( Exists, F, - Func, OuterRef, Q, Subquery, @@ -25,14 +24,11 @@ from plane.app.serializers import IssueViewSerializer, ViewIssueListSerializer from plane.db.models import ( Issue, - FileAsset, - IssueLink, IssueView, Workspace, WorkspaceMember, ProjectMember, Project, - CycleIssue, UserRecentVisit, IssueAssignee, IssueLabel, @@ -45,6 +41,7 @@ from plane.db.models import UserFavorite from plane.utils.filters import ComplexFilterBackend from plane.utils.filters import IssueFilterSet +from ..issue.base import annotate_issue_cycle_and_counts class WorkspaceViewViewSet(BaseViewSet): @@ -161,32 +158,7 @@ def _get_project_permission_filters(self): def apply_annotations(self, issues): return ( - issues.annotate( - cycle_id=Subquery( - CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] - ) - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) + annotate_issue_cycle_and_counts(issues) .prefetch_related( Prefetch( "issue_assignee", diff --git a/apps/api/plane/space/views/issue.py b/apps/api/plane/space/views/issue.py index 9e2187466aa..2ebeef7aae3 100644 --- a/apps/api/plane/space/views/issue.py +++ b/apps/api/plane/space/views/issue.py @@ -22,7 +22,6 @@ JSONField, Value, OuterRef, - Func, CharField, Subquery, ) @@ -56,18 +55,17 @@ from plane.db.models import ( Issue, IssueComment, - IssueLink, IssueReaction, ProjectMember, CommentReaction, DeployBoard, IssueVote, ProjectPublicMember, - FileAsset, CycleIssue, ) from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.issue_filters import issue_filters +from plane.app.views.issue.base import annotate_issue_cycle_and_counts class ProjectIssuesPublicEndpoint(BaseAPIView): @@ -85,7 +83,7 @@ def get(self, request, anchor): slug = deploy_board.workspace.slug issue_queryset = ( - Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id) + annotate_issue_cycle_and_counts(Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( @@ -95,32 +93,6 @@ def get(self, request, anchor): ) ) .prefetch_related(Prefetch("votes", queryset=IssueVote.objects.select_related("actor"))) - .annotate( - cycle_id=Subquery( - CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] - ) - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) ).distinct() issue_queryset = issue_queryset.filter(**filters) diff --git a/apps/api/plane/tests/unit/views/test_issue_counts.py b/apps/api/plane/tests/unit/views/test_issue_counts.py new file mode 100644 index 00000000000..4b5c38990e6 --- /dev/null +++ b/apps/api/plane/tests/unit/views/test_issue_counts.py @@ -0,0 +1,108 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import pytest + +from plane.app.views.issue.base import annotate_issue_cycle_and_counts +from plane.db.models import FileAsset, Issue, IssueLink, Project, ProjectMember, State + + +@pytest.fixture +def project(db, workspace, create_user): + project = Project.objects.create( + name="Test Project", + identifier="TP", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create( + project=project, + member=create_user, + role=20, + is_active=True, + ) + return project + + +@pytest.fixture +def state(project): + return State.objects.create( + name="Todo", + project=project, + group="backlog", + default=True, + ) + + +@pytest.fixture +def issue_with_relations(workspace, project, state, create_user): + issue = Issue.objects.create( + name="Parent issue", + workspace=workspace, + project=project, + state=state, + created_by=create_user, + updated_by=create_user, + ) + Issue.objects.create( + name="Child issue", + workspace=workspace, + project=project, + state=state, + parent=issue, + created_by=create_user, + updated_by=create_user, + ) + IssueLink.objects.create( + issue=issue, + workspace=workspace, + project=project, + title="Reference", + url="https://plane.so", + created_by=create_user, + updated_by=create_user, + ) + FileAsset.objects.create( + issue=issue, + workspace=workspace, + project=project, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + asset="attachment.txt", + size=10, + is_uploaded=True, + created_by=create_user, + updated_by=create_user, + ) + return issue + + +@pytest.fixture +def issue_without_relations(workspace, project, state, create_user): + return Issue.objects.create( + name="Standalone issue", + workspace=workspace, + project=project, + state=state, + created_by=create_user, + updated_by=create_user, + ) + + +@pytest.mark.unit +class TestIssueCountAnnotations: + @pytest.mark.django_db + def test_annotate_issue_cycle_and_counts_returns_expected_counts(self, issue_with_relations): + issue = annotate_issue_cycle_and_counts(Issue.objects.filter(pk=issue_with_relations.pk)).get() + + assert issue.link_count == 1 + assert issue.attachment_count == 1 + assert issue.sub_issues_count == 1 + + @pytest.mark.django_db + def test_annotate_issue_cycle_and_counts_returns_zeroes_without_relations(self, issue_without_relations): + issue = annotate_issue_cycle_and_counts(Issue.objects.filter(pk=issue_without_relations.pk)).get() + + assert issue.link_count == 0 + assert issue.attachment_count == 0 + assert issue.sub_issues_count == 0 diff --git a/apps/web/core/components/editor/pdf/document.tsx b/apps/web/core/components/editor/pdf/document.tsx index 1c439bfae14..e254333a884 100644 --- a/apps/web/core/components/editor/pdf/document.tsx +++ b/apps/web/core/components/editor/pdf/document.tsx @@ -18,7 +18,7 @@ import interThin from "@/app/assets/fonts/inter/thin.ttf?url"; import interUltraBold from "@/app/assets/fonts/inter/ultrabold.ttf?url"; import interUltraLight from "@/app/assets/fonts/inter/ultralight.ttf?url"; // constants -import { EDITOR_PDF_DOCUMENT_STYLESHEET } from "@/constants/editor"; +import { EDITOR_PDF_DOCUMENT_STYLESHEET } from "@/constants/editor-pdf"; Font.register({ family: "Inter", diff --git a/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx b/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx index 291f3cb47c1..544adbaf198 100644 --- a/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx +++ b/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx @@ -4,13 +4,12 @@ * See the LICENSE file for details. */ -import { useMemo, useState } from "react"; +import { lazy, Suspense, useMemo, useState } from "react"; import { observer } from "mobx-react"; import { ArrowUpToLine, Clipboard, History } from "lucide-react"; // plane imports import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { ToggleSwitch } from "@plane/ui"; -import { copyTextToClipboard } from "@plane/utils"; // hooks import { useAppRouter } from "@/hooks/use-app-router"; import { usePageFilters } from "@/hooks/use-page-filters"; @@ -22,9 +21,14 @@ import type { EPageStoreType } from "@/plane-web/hooks/store"; import type { TPageInstance } from "@/store/pages/base-page"; // local imports import { PageActions } from "../../dropdowns"; -import { ExportPageModal } from "../../modals/export-page-modal"; import { PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM } from "../../navigation-pane"; +const ExportPageModal = lazy(() => + import("../../modals/export-page-modal").then((module) => ({ + default: module.ExportPageModal, + })) +); + type Props = { page: TPageInstance; storeType: EPageStoreType; @@ -127,12 +131,16 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props: return ( <> - setIsExportModalOpen(false)} - pageTitle={name ?? ""} - /> + {isExportModalOpen && ( + + setIsExportModalOpen(false)} + pageTitle={name ?? ""} + /> + + )} ; +type TPageFormats = "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID"; type TContentVariety = "everything" | "no-assets"; type TFormValues = { @@ -101,6 +97,17 @@ const defaultValues: TFormValues = { content_variety: "everything", }; +const initiateDownload = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + setTimeout(() => { + URL.revokeObjectURL(url); + }, 1000); +}; + export function ExportPageModal(props: Props) { const { editorRef, isOpen, onClose, pageTitle } = props; // states @@ -133,20 +140,13 @@ export function ExportPageModal(props: Props) { }, 300); }; - const initiateDownload = (blob: Blob, filename: string) => { - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - link.click(); - setTimeout(() => { - URL.revokeObjectURL(url); - }, 1000); - }; - // handle export as a PDF const handleExportAsPDF = async () => { try { + const [{ pdf }, { PDFDocument }] = await Promise.all([ + import("@react-pdf/renderer"), + import("@/components/editor/pdf"), + ]); const pageContent = `

${pageTitle}

${editorRef?.getDocument().html ?? "

"}`; const parsedPageContent = await replaceCustomComponentsFromHTMLContent({ htmlContent: pageContent, @@ -156,7 +156,7 @@ export function ExportPageModal(props: Props) { const blob = await pdf().toBlob(); initiateDownload(blob, `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`); } catch (error) { - throw new Error(`Error in exporting as a PDF: ${error}`); + throw new Error(`Error in exporting as a PDF: ${error}`, { cause: error }); } }; // handle export as markdown @@ -171,7 +171,7 @@ export function ExportPageModal(props: Props) { const blob = new Blob([parsedMarkdownContent], { type: "text/markdown" }); initiateDownload(blob, `${fileName}.md`); } catch (error) { - throw new Error(`Error in exporting as markdown: ${error}`); + throw new Error(`Error in exporting as markdown: ${error}`, { cause: error }); } }; // handle export diff --git a/apps/web/core/constants/editor-pdf.ts b/apps/web/core/constants/editor-pdf.ts new file mode 100644 index 00000000000..c03f4e4930e --- /dev/null +++ b/apps/web/core/constants/editor-pdf.ts @@ -0,0 +1,175 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Styles } from "@react-pdf/renderer"; +import { StyleSheet } from "@react-pdf/renderer"; +import { convertRemToPixel } from "@plane/utils"; + +const EDITOR_PDF_FONT_FAMILY_STYLES: Styles = { + "*:not(.courier, .courier-bold)": { + fontFamily: "Inter", + }, + ".courier": { + fontFamily: "Courier", + }, + ".courier-bold": { + fontFamily: "Courier-Bold", + }, +}; + +const EDITOR_PDF_TYPOGRAPHY_STYLES: Styles = { + "h1.page-title": { + fontSize: convertRemToPixel(1.6), + fontWeight: "bold", + marginTop: 0, + marginBottom: convertRemToPixel(2), + }, + "h1:not(.page-title)": { + fontSize: convertRemToPixel(1.4), + fontWeight: "semibold", + marginTop: convertRemToPixel(2), + marginBottom: convertRemToPixel(0.25), + }, + h2: { + fontSize: convertRemToPixel(1.2), + fontWeight: "semibold", + marginTop: convertRemToPixel(1.4), + marginBottom: convertRemToPixel(0.0625), + }, + h3: { + fontSize: convertRemToPixel(1.1), + fontWeight: "semibold", + marginTop: convertRemToPixel(1), + marginBottom: convertRemToPixel(0.0625), + }, + h4: { + fontSize: convertRemToPixel(1), + fontWeight: "semibold", + marginTop: convertRemToPixel(1), + marginBottom: convertRemToPixel(0.0625), + }, + h5: { + fontSize: convertRemToPixel(0.9), + fontWeight: "semibold", + marginTop: convertRemToPixel(1), + marginBottom: convertRemToPixel(0.0625), + }, + h6: { + fontSize: convertRemToPixel(0.8), + fontWeight: "semibold", + marginTop: convertRemToPixel(1), + marginBottom: convertRemToPixel(0.0625), + }, + "p:not(table p)": { + fontSize: convertRemToPixel(0.8), + }, + "p:not(ol p, ul p)": { + marginTop: convertRemToPixel(0.25), + marginBottom: convertRemToPixel(0.0625), + }, +}; + +const EDITOR_PDF_LIST_STYLES: Styles = { + "ul, ol": { + fontSize: convertRemToPixel(0.8), + marginHorizontal: -20, + }, + "ol p, ul p": { + marginVertical: 0, + }, + "ol li, ul li": { + marginTop: convertRemToPixel(0.45), + }, + "ul ul, ul ol, ol ol, ol ul": { + marginVertical: 0, + }, + "ul[data-type='taskList']": { + position: "relative", + }, + "div.input-checkbox": { + position: "absolute", + top: convertRemToPixel(0.15), + left: -convertRemToPixel(1.2), + height: convertRemToPixel(0.75), + width: convertRemToPixel(0.75), + borderWidth: "1.5px", + borderStyle: "solid", + borderRadius: convertRemToPixel(0.125), + }, + "div.input-checkbox:not(.checked)": { + backgroundColor: "#ffffff", + borderColor: "#171717", + }, + "div.input-checkbox.checked": { + backgroundColor: "#3f76ff", + borderColor: "#3f76ff", + }, + "ul li[data-checked='true'] p": { + color: "#a3a3a3", + }, +}; + +const EDITOR_PDF_CODE_STYLES: Styles = { + "[data-node-type='code-block']": { + marginVertical: convertRemToPixel(0.5), + padding: convertRemToPixel(1), + borderRadius: convertRemToPixel(0.5), + backgroundColor: "#f7f7f7", + fontSize: convertRemToPixel(0.7), + }, + "[data-node-type='inline-code-block']": { + margin: 0, + paddingVertical: convertRemToPixel(0.25 / 4 + 0.25 / 8), + paddingHorizontal: convertRemToPixel(0.375), + border: "0.5px solid #e5e5e5", + borderRadius: convertRemToPixel(0.25), + backgroundColor: "#e8e8e8", + color: "#f97316", + fontSize: convertRemToPixel(0.7), + }, +}; + +export const EDITOR_PDF_DOCUMENT_STYLESHEET = StyleSheet.create({ + ...EDITOR_PDF_FONT_FAMILY_STYLES, + ...EDITOR_PDF_TYPOGRAPHY_STYLES, + ...EDITOR_PDF_LIST_STYLES, + ...EDITOR_PDF_CODE_STYLES, + blockquote: { + borderLeft: "3px solid gray", + paddingLeft: convertRemToPixel(1), + marginTop: convertRemToPixel(0.625), + marginBottom: 0, + marginHorizontal: 0, + }, + img: { + marginVertical: 0, + borderRadius: convertRemToPixel(0.375), + }, + "div[data-type='horizontalRule']": { + marginVertical: convertRemToPixel(1), + height: 1, + width: "100%", + backgroundColor: "gray", + }, + "[data-node-type='mention-block']": { + margin: 0, + color: "#3f76ff", + backgroundColor: "#3f76ff33", + paddingHorizontal: convertRemToPixel(0.375), + }, + table: { + marginTop: convertRemToPixel(0.5), + marginBottom: convertRemToPixel(1), + marginHorizontal: 0, + }, + "table td": { + padding: convertRemToPixel(0.625), + border: "1px solid #e5e5e5", + }, + "table p": { + fontSize: convertRemToPixel(0.7), + }, +}); diff --git a/apps/web/core/constants/editor.ts b/apps/web/core/constants/editor.ts index 5cd8b929cda..0da0f28407b 100644 --- a/apps/web/core/constants/editor.ts +++ b/apps/web/core/constants/editor.ts @@ -4,8 +4,6 @@ * See the LICENSE file for details. */ -import type { Styles } from "@react-pdf/renderer"; -import { StyleSheet } from "@react-pdf/renderer"; import type { LucideIcon } from "lucide-react"; import { AlignCenter, @@ -33,7 +31,6 @@ import { // plane imports import type { TCommandExtraProps, TEditorCommands, TEditorFontStyle } from "@plane/editor"; import { MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/propel/icons"; -import { convertRemToPixel } from "@plane/utils"; type TEditorTypes = "lite" | "document" | "sticky"; @@ -225,179 +222,3 @@ export const EDITOR_FONT_STYLES: { icon: MonospaceIcon, }, ]; - -const EDITOR_PDF_FONT_FAMILY_STYLES: Styles = { - "*:not(.courier, .courier-bold)": { - fontFamily: "Inter", - }, - ".courier": { - fontFamily: "Courier", - }, - ".courier-bold": { - fontFamily: "Courier-Bold", - }, -}; - -const EDITOR_PDF_TYPOGRAPHY_STYLES: Styles = { - // page title - "h1.page-title": { - fontSize: convertRemToPixel(1.6), - fontWeight: "bold", - marginTop: 0, - marginBottom: convertRemToPixel(2), - }, - // headings - "h1:not(.page-title)": { - fontSize: convertRemToPixel(1.4), - fontWeight: "semibold", - marginTop: convertRemToPixel(2), - marginBottom: convertRemToPixel(0.25), - }, - h2: { - fontSize: convertRemToPixel(1.2), - fontWeight: "semibold", - marginTop: convertRemToPixel(1.4), - marginBottom: convertRemToPixel(0.0625), - }, - h3: { - fontSize: convertRemToPixel(1.1), - fontWeight: "semibold", - marginTop: convertRemToPixel(1), - marginBottom: convertRemToPixel(0.0625), - }, - h4: { - fontSize: convertRemToPixel(1), - fontWeight: "semibold", - marginTop: convertRemToPixel(1), - marginBottom: convertRemToPixel(0.0625), - }, - h5: { - fontSize: convertRemToPixel(0.9), - fontWeight: "semibold", - marginTop: convertRemToPixel(1), - marginBottom: convertRemToPixel(0.0625), - }, - h6: { - fontSize: convertRemToPixel(0.8), - fontWeight: "semibold", - marginTop: convertRemToPixel(1), - marginBottom: convertRemToPixel(0.0625), - }, - // paragraph - "p:not(table p)": { - fontSize: convertRemToPixel(0.8), - }, - "p:not(ol p, ul p)": { - marginTop: convertRemToPixel(0.25), - marginBottom: convertRemToPixel(0.0625), - }, -}; - -const EDITOR_PDF_LIST_STYLES: Styles = { - "ul, ol": { - fontSize: convertRemToPixel(0.8), - marginHorizontal: -20, - }, - "ol p, ul p": { - marginVertical: 0, - }, - "ol li, ul li": { - marginTop: convertRemToPixel(0.45), - }, - "ul ul, ul ol, ol ol, ol ul": { - marginVertical: 0, - }, - "ul[data-type='taskList']": { - position: "relative", - }, - "div.input-checkbox": { - position: "absolute", - top: convertRemToPixel(0.15), - left: -convertRemToPixel(1.2), - height: convertRemToPixel(0.75), - width: convertRemToPixel(0.75), - borderWidth: "1.5px", - borderStyle: "solid", - borderRadius: convertRemToPixel(0.125), - }, - "div.input-checkbox:not(.checked)": { - backgroundColor: "#ffffff", - borderColor: "#171717", - }, - "div.input-checkbox.checked": { - backgroundColor: "#3f76ff", - borderColor: "#3f76ff", - }, - "ul li[data-checked='true'] p": { - color: "#a3a3a3", - }, -}; - -const EDITOR_PDF_CODE_STYLES: Styles = { - // code block - "[data-node-type='code-block']": { - marginVertical: convertRemToPixel(0.5), - padding: convertRemToPixel(1), - borderRadius: convertRemToPixel(0.5), - backgroundColor: "#f7f7f7", - fontSize: convertRemToPixel(0.7), - }, - // inline code block - "[data-node-type='inline-code-block']": { - margin: 0, - paddingVertical: convertRemToPixel(0.25 / 4 + 0.25 / 8), - paddingHorizontal: convertRemToPixel(0.375), - border: "0.5px solid #e5e5e5", - borderRadius: convertRemToPixel(0.25), - backgroundColor: "#e8e8e8", - color: "#f97316", - fontSize: convertRemToPixel(0.7), - }, -}; - -export const EDITOR_PDF_DOCUMENT_STYLESHEET = StyleSheet.create({ - ...EDITOR_PDF_FONT_FAMILY_STYLES, - ...EDITOR_PDF_TYPOGRAPHY_STYLES, - ...EDITOR_PDF_LIST_STYLES, - ...EDITOR_PDF_CODE_STYLES, - // quote block - blockquote: { - borderLeft: "3px solid gray", - paddingLeft: convertRemToPixel(1), - marginTop: convertRemToPixel(0.625), - marginBottom: 0, - marginHorizontal: 0, - }, - // image - img: { - marginVertical: 0, - borderRadius: convertRemToPixel(0.375), - }, - // divider - "div[data-type='horizontalRule']": { - marginVertical: convertRemToPixel(1), - height: 1, - width: "100%", - backgroundColor: "gray", - }, - // mention block - "[data-node-type='mention-block']": { - margin: 0, - color: "#3f76ff", - backgroundColor: "#3f76ff33", - paddingHorizontal: convertRemToPixel(0.375), - }, - // table - table: { - marginTop: convertRemToPixel(0.5), - marginBottom: convertRemToPixel(1), - marginHorizontal: 0, - }, - "table td": { - padding: convertRemToPixel(0.625), - border: "1px solid #e5e5e5", - }, - "table p": { - fontSize: convertRemToPixel(0.7), - }, -});