Merge branch 'develop' of github.com:makeplane/plane into preview
This commit is contained in:
commit
c1f881b2d1
101 changed files with 5692 additions and 1354 deletions
|
|
@ -207,8 +207,7 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||||
# Incomplete Cycles
|
# Incomplete Cycles
|
||||||
if cycle_view == "incomplete":
|
if cycle_view == "incomplete":
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(end_date__gte=timezone.now().date())
|
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True),
|
||||||
| Q(end_date__isnull=True),
|
|
||||||
)
|
)
|
||||||
return self.paginate(
|
return self.paginate(
|
||||||
request=request,
|
request=request,
|
||||||
|
|
@ -309,10 +308,7 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||||
|
|
||||||
request_data = request.data
|
request_data = request.data
|
||||||
|
|
||||||
if (
|
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||||
cycle.end_date is not None
|
|
||||||
and cycle.end_date < timezone.now().date()
|
|
||||||
):
|
|
||||||
if "sort_order" in request_data:
|
if "sort_order" in request_data:
|
||||||
# Can only change sort order
|
# Can only change sort order
|
||||||
request_data = {
|
request_data = {
|
||||||
|
|
@ -537,7 +533,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||||
cycle = Cycle.objects.get(
|
cycle = Cycle.objects.get(
|
||||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
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(
|
return Response(
|
||||||
{"error": "Only completed cycles can be archived"},
|
{"error": "Only completed cycles can be archived"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
|
@ -1146,7 +1142,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||||
|
|
||||||
if (
|
if (
|
||||||
new_cycle.end_date is not None
|
new_cycle.end_date is not None
|
||||||
and new_cycle.end_date < timezone.now().date()
|
and new_cycle.end_date < timezone.now()
|
||||||
):
|
):
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -124,3 +124,9 @@ from .webhook import WebhookSerializer, WebhookLogSerializer
|
||||||
from .dashboard import DashboardSerializer, WidgetSerializer
|
from .dashboard import DashboardSerializer, WidgetSerializer
|
||||||
|
|
||||||
from .favorite import UserFavoriteSerializer
|
from .favorite import UserFavoriteSerializer
|
||||||
|
|
||||||
|
from .draft import (
|
||||||
|
DraftIssueCreateSerializer,
|
||||||
|
DraftIssueSerializer,
|
||||||
|
DraftIssueDetailSerializer,
|
||||||
|
)
|
||||||
|
|
|
||||||
290
apiserver/plane/app/serializers/draft.py
Normal file
290
apiserver/plane/app/serializers/draft.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -11,7 +11,6 @@ from plane.app.views import (
|
||||||
IssueActivityEndpoint,
|
IssueActivityEndpoint,
|
||||||
IssueArchiveViewSet,
|
IssueArchiveViewSet,
|
||||||
IssueCommentViewSet,
|
IssueCommentViewSet,
|
||||||
IssueDraftViewSet,
|
|
||||||
IssueListEndpoint,
|
IssueListEndpoint,
|
||||||
IssueReactionViewSet,
|
IssueReactionViewSet,
|
||||||
IssueRelationViewSet,
|
IssueRelationViewSet,
|
||||||
|
|
@ -290,28 +289,6 @@ urlpatterns = [
|
||||||
name="issue-relation",
|
name="issue-relation",
|
||||||
),
|
),
|
||||||
## End Issue Relation
|
## End Issue Relation
|
||||||
## Issue Drafts
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-drafts/",
|
|
||||||
IssueDraftViewSet.as_view(
|
|
||||||
{
|
|
||||||
"get": "list",
|
|
||||||
"post": "create",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
name="project-issue-draft",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-drafts/<uuid:pk>/",
|
|
||||||
IssueDraftViewSet.as_view(
|
|
||||||
{
|
|
||||||
"get": "retrieve",
|
|
||||||
"patch": "partial_update",
|
|
||||||
"delete": "destroy",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
name="project-issue-draft",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/deleted-issues/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/deleted-issues/",
|
||||||
DeletedIssuesListViewSet.as_view(),
|
DeletedIssuesListViewSet.as_view(),
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from plane.app.views import (
|
||||||
WorkspaceCyclesEndpoint,
|
WorkspaceCyclesEndpoint,
|
||||||
WorkspaceFavoriteEndpoint,
|
WorkspaceFavoriteEndpoint,
|
||||||
WorkspaceFavoriteGroupEndpoint,
|
WorkspaceFavoriteGroupEndpoint,
|
||||||
|
WorkspaceDraftIssueViewSet,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -254,4 +255,30 @@ urlpatterns = [
|
||||||
WorkspaceFavoriteGroupEndpoint.as_view(),
|
WorkspaceFavoriteGroupEndpoint.as_view(),
|
||||||
name="workspace-user-favorites-groups",
|
name="workspace-user-favorites-groups",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/draft-issues/",
|
||||||
|
WorkspaceDraftIssueViewSet.as_view(
|
||||||
|
{
|
||||||
|
"get": "list",
|
||||||
|
"post": "create",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="workspace-draft-issues",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/draft-issues/<uuid:pk>/",
|
||||||
|
WorkspaceDraftIssueViewSet.as_view(
|
||||||
|
{
|
||||||
|
"get": "retrieve",
|
||||||
|
"patch": "partial_update",
|
||||||
|
"delete": "destroy",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
name="workspace-drafts-issues",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/draft-to-issue/<uuid:draft_id>/",
|
||||||
|
WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}),
|
||||||
|
name="workspace-drafts-issues",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ from .workspace.base import (
|
||||||
ExportWorkspaceUserActivityEndpoint,
|
ExportWorkspaceUserActivityEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .workspace.draft import WorkspaceDraftIssueViewSet
|
||||||
|
|
||||||
from .workspace.favorite import (
|
from .workspace.favorite import (
|
||||||
WorkspaceFavoriteEndpoint,
|
WorkspaceFavoriteEndpoint,
|
||||||
WorkspaceFavoriteGroupEndpoint,
|
WorkspaceFavoriteGroupEndpoint,
|
||||||
|
|
@ -133,8 +135,6 @@ from .issue.comment import (
|
||||||
CommentReactionViewSet,
|
CommentReactionViewSet,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .issue.draft import IssueDraftViewSet
|
|
||||||
|
|
||||||
from .issue.label import (
|
from .issue.label import (
|
||||||
LabelViewSet,
|
LabelViewSet,
|
||||||
BulkCreateIssueLabelsEndpoint,
|
BulkCreateIssueLabelsEndpoint,
|
||||||
|
|
|
||||||
|
|
@ -604,7 +604,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
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(
|
return Response(
|
||||||
{"error": "Only completed cycles can be archived"},
|
{"error": "Only completed cycles can be archived"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,7 @@ class CycleViewSet(BaseViewSet):
|
||||||
"completed_issues",
|
"completed_issues",
|
||||||
"assignee_ids",
|
"assignee_ids",
|
||||||
"status",
|
"status",
|
||||||
|
"version",
|
||||||
"created_by",
|
"created_by",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -216,6 +217,7 @@ class CycleViewSet(BaseViewSet):
|
||||||
"completed_issues",
|
"completed_issues",
|
||||||
"assignee_ids",
|
"assignee_ids",
|
||||||
"status",
|
"status",
|
||||||
|
"version",
|
||||||
"created_by",
|
"created_by",
|
||||||
)
|
)
|
||||||
return Response(data, status=status.HTTP_200_OK)
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
|
|
@ -255,6 +257,7 @@ class CycleViewSet(BaseViewSet):
|
||||||
"external_id",
|
"external_id",
|
||||||
"progress_snapshot",
|
"progress_snapshot",
|
||||||
"logo_props",
|
"logo_props",
|
||||||
|
"version",
|
||||||
# meta fields
|
# meta fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
"total_issues",
|
||||||
|
|
@ -306,10 +309,7 @@ class CycleViewSet(BaseViewSet):
|
||||||
|
|
||||||
request_data = request.data
|
request_data = request.data
|
||||||
|
|
||||||
if (
|
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||||
cycle.end_date is not None
|
|
||||||
and cycle.end_date < timezone.now().date()
|
|
||||||
):
|
|
||||||
if "sort_order" in request_data:
|
if "sort_order" in request_data:
|
||||||
# Can only change sort order for a completed cycle``
|
# Can only change sort order for a completed cycle``
|
||||||
request_data = {
|
request_data = {
|
||||||
|
|
@ -347,6 +347,7 @@ class CycleViewSet(BaseViewSet):
|
||||||
"external_id",
|
"external_id",
|
||||||
"progress_snapshot",
|
"progress_snapshot",
|
||||||
"logo_props",
|
"logo_props",
|
||||||
|
"version",
|
||||||
# meta fields
|
# meta fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
"total_issues",
|
||||||
|
|
@ -412,6 +413,7 @@ class CycleViewSet(BaseViewSet):
|
||||||
"progress_snapshot",
|
"progress_snapshot",
|
||||||
"sub_issues",
|
"sub_issues",
|
||||||
"logo_props",
|
"logo_props",
|
||||||
|
"version",
|
||||||
# meta fields
|
# meta fields
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"total_issues",
|
"total_issues",
|
||||||
|
|
@ -925,7 +927,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||||
|
|
||||||
if (
|
if (
|
||||||
new_cycle.end_date is not None
|
new_cycle.end_date is not None
|
||||||
and new_cycle.end_date < timezone.now().date()
|
and new_cycle.end_date < timezone.now()
|
||||||
):
|
):
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
|
|
@ -1148,6 +1150,7 @@ class CycleProgressEndpoint(BaseAPIView):
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CycleAnalyticsEndpoint(BaseAPIView):
|
class CycleAnalyticsEndpoint(BaseAPIView):
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cycle.end_date is not None
|
cycle.end_date is not None
|
||||||
and cycle.end_date < timezone.now().date()
|
and cycle.end_date < timezone.now()
|
||||||
):
|
):
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,6 @@ from plane.db.models import (
|
||||||
IssueLink,
|
IssueLink,
|
||||||
IssueRelation,
|
IssueRelation,
|
||||||
Project,
|
Project,
|
||||||
ProjectMember,
|
|
||||||
User,
|
|
||||||
Widget,
|
Widget,
|
||||||
WorkspaceMember,
|
WorkspaceMember,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
412
apiserver/plane/app/views/workspace/draft.py
Normal file
412
apiserver/plane/app/views/workspace/draft.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -504,7 +504,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||||
|
|
||||||
upcoming_cycles = CycleIssue.objects.filter(
|
upcoming_cycles = CycleIssue.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
cycle__start_date__gt=timezone.now().date(),
|
cycle__start_date__gt=timezone.now(),
|
||||||
issue__assignees__in=[
|
issue__assignees__in=[
|
||||||
user_id,
|
user_id,
|
||||||
],
|
],
|
||||||
|
|
@ -512,8 +512,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||||
|
|
||||||
present_cycle = CycleIssue.objects.filter(
|
present_cycle = CycleIssue.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
cycle__start_date__lt=timezone.now().date(),
|
cycle__start_date__lt=timezone.now(),
|
||||||
cycle__end_date__gt=timezone.now().date(),
|
cycle__end_date__gt=timezone.now(),
|
||||||
issue__assignees__in=[
|
issue__assignees__in=[
|
||||||
user_id,
|
user_id,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -42,14 +42,12 @@ def archive_old_issues():
|
||||||
),
|
),
|
||||||
Q(issue_cycle__isnull=True)
|
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_cycle__isnull=False)
|
||||||
),
|
),
|
||||||
Q(issue_module__isnull=True)
|
Q(issue_module__isnull=True)
|
||||||
| (
|
| (
|
||||||
Q(
|
Q(issue_module__module__target_date__lt=timezone.now())
|
||||||
issue_module__module__target_date__lt=timezone.now().date()
|
|
||||||
)
|
|
||||||
& Q(issue_module__isnull=False)
|
& Q(issue_module__isnull=False)
|
||||||
),
|
),
|
||||||
).filter(
|
).filter(
|
||||||
|
|
@ -122,14 +120,12 @@ def close_old_issues():
|
||||||
),
|
),
|
||||||
Q(issue_cycle__isnull=True)
|
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_cycle__isnull=False)
|
||||||
),
|
),
|
||||||
Q(issue_module__isnull=True)
|
Q(issue_module__isnull=True)
|
||||||
| (
|
| (
|
||||||
Q(
|
Q(issue_module__module__target_date__lt=timezone.now())
|
||||||
issue_module__module__target_date__lt=timezone.now().date()
|
|
||||||
)
|
|
||||||
& Q(issue_module__isnull=False)
|
& Q(issue_module__isnull=False)
|
||||||
),
|
),
|
||||||
).filter(
|
).filter(
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@ from .base import BaseModel
|
||||||
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
|
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
|
||||||
from .dashboard import Dashboard, DashboardWidget, Widget
|
from .dashboard import Dashboard, DashboardWidget, Widget
|
||||||
from .deploy_board import DeployBoard
|
from .deploy_board import DeployBoard
|
||||||
|
from .draft import DraftIssue, DraftIssueAssignee, DraftIssueLabel, DraftIssueModule, DraftIssueCycle
|
||||||
from .estimate import Estimate, EstimatePoint
|
from .estimate import Estimate, EstimatePoint
|
||||||
from .exporter import ExporterHistory
|
from .exporter import ExporterHistory
|
||||||
from .importer import Importer
|
from .importer import Importer
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
# Python imports
|
||||||
|
import pytz
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
@ -55,10 +58,12 @@ class Cycle(ProjectBaseModel):
|
||||||
description = models.TextField(
|
description = models.TextField(
|
||||||
verbose_name="Cycle Description", blank=True
|
verbose_name="Cycle Description", blank=True
|
||||||
)
|
)
|
||||||
start_date = models.DateField(
|
start_date = models.DateTimeField(
|
||||||
verbose_name="Start Date", blank=True, null=True
|
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(
|
owned_by = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
|
|
@ -71,6 +76,12 @@ class Cycle(ProjectBaseModel):
|
||||||
progress_snapshot = models.JSONField(default=dict)
|
progress_snapshot = models.JSONField(default=dict)
|
||||||
archived_at = models.DateTimeField(null=True)
|
archived_at = models.DateTimeField(null=True)
|
||||||
logo_props = models.JSONField(default=dict)
|
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:
|
class Meta:
|
||||||
verbose_name = "Cycle"
|
verbose_name = "Cycle"
|
||||||
|
|
|
||||||
253
apiserver/plane/db/models/draft.py
Normal file
253
apiserver/plane/db/models/draft.py
Normal file
|
|
@ -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="<p></p>")
|
||||||
|
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}"
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
# Python imports
|
# Python imports
|
||||||
|
import pytz
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
|
|
@ -7,7 +8,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
# Modeule imports
|
# Module imports
|
||||||
from plane.db.mixins import AuditModel
|
from plane.db.mixins import AuditModel
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
|
|
@ -119,6 +120,11 @@ class Project(BaseModel):
|
||||||
related_name="default_state",
|
related_name="default_state",
|
||||||
)
|
)
|
||||||
archived_at = models.DateTimeField(null=True)
|
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):
|
def __str__(self):
|
||||||
"""Return name of the project"""
|
"""Return name of the project"""
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ def burndown_plot(
|
||||||
if queryset.end_date and queryset.start_date:
|
if queryset.end_date and queryset.start_date:
|
||||||
# Get all dates between the two dates
|
# Get all dates between the two dates
|
||||||
date_range = [
|
date_range = [
|
||||||
queryset.start_date + timedelta(days=x)
|
(queryset.start_date + timedelta(days=x)).date()
|
||||||
for x in range(
|
for x in range(
|
||||||
(queryset.end_date - queryset.start_date).days + 1
|
(queryset.end_date - queryset.start_date).days + 1
|
||||||
)
|
)
|
||||||
|
|
@ -203,7 +203,7 @@ def burndown_plot(
|
||||||
if module_id:
|
if module_id:
|
||||||
# Get all dates between the two dates
|
# Get all dates between the two dates
|
||||||
date_range = [
|
date_range = [
|
||||||
queryset.start_date + timedelta(days=x)
|
(queryset.start_date + timedelta(days=x)).date()
|
||||||
for x in range(
|
for x in range(
|
||||||
(queryset.target_date - queryset.start_date).days + 1
|
(queryset.target_date - queryset.start_date).days + 1
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,15 @@
|
||||||
"@tiptap/extension-blockquote": "^2.1.13",
|
"@tiptap/extension-blockquote": "^2.1.13",
|
||||||
"@tiptap/extension-character-count": "^2.6.5",
|
"@tiptap/extension-character-count": "^2.6.5",
|
||||||
"@tiptap/extension-collaboration": "^2.3.2",
|
"@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-image": "^2.1.13",
|
||||||
"@tiptap/extension-list-item": "^2.1.13",
|
"@tiptap/extension-list-item": "^2.1.13",
|
||||||
"@tiptap/extension-mention": "^2.1.13",
|
"@tiptap/extension-mention": "^2.1.13",
|
||||||
"@tiptap/extension-placeholder": "^2.3.0",
|
"@tiptap/extension-placeholder": "^2.3.0",
|
||||||
"@tiptap/extension-task-item": "^2.1.13",
|
"@tiptap/extension-task-item": "^2.1.13",
|
||||||
"@tiptap/extension-task-list": "^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/extension-underline": "^2.1.13",
|
||||||
"@tiptap/pm": "^2.1.13",
|
"@tiptap/pm": "^2.1.13",
|
||||||
"@tiptap/react": "^2.1.13",
|
"@tiptap/react": "^2.1.13",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import { Extensions } from "@tiptap/core";
|
import { Extensions } from "@tiptap/core";
|
||||||
import { SlashCommand } from "@/extensions";
|
import { SlashCommands } from "@/extensions";
|
||||||
// plane editor types
|
// plane editor types
|
||||||
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
||||||
// types
|
// types
|
||||||
|
|
@ -14,7 +14,7 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||||
const extensions: Extensions = [SlashCommand()];
|
const extensions: Extensions = [SlashCommands()];
|
||||||
|
|
||||||
return extensions;
|
return extensions;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { forwardRef, useCallback } from "react";
|
||||||
import { EditorWrapper } from "@/components/editors";
|
import { EditorWrapper } from "@/components/editors";
|
||||||
import { EditorBubbleMenu } from "@/components/menus";
|
import { EditorBubbleMenu } from "@/components/menus";
|
||||||
// extensions
|
// extensions
|
||||||
import { SideMenuExtension, SlashCommand } from "@/extensions";
|
import { SideMenuExtension, SlashCommands } from "@/extensions";
|
||||||
// types
|
// types
|
||||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||||
|
|
||||||
|
|
@ -11,7 +11,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||||
const { dragDropEnabled } = props;
|
const { dragDropEnabled } = props;
|
||||||
|
|
||||||
const getExtensions = useCallback(() => {
|
const getExtensions = useCallback(() => {
|
||||||
const extensions = [SlashCommand()];
|
const extensions = [SlashCommands()];
|
||||||
|
|
||||||
extensions.push(
|
extensions.push(
|
||||||
SideMenuExtension({
|
SideMenuExtension({
|
||||||
|
|
|
||||||
|
|
@ -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<SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BubbleMenuColorSelector: FC<Props> = (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 (
|
||||||
|
<div className="relative h-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span>Color</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
|
||||||
|
{
|
||||||
|
"bg-custom-background-100": !activeBackgroundColor,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
activeBackgroundColor
|
||||||
|
? {
|
||||||
|
backgroundColor: activeBackgroundColor.backgroundColor,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ALargeSmall
|
||||||
|
className={cn("size-3.5", {
|
||||||
|
"text-custom-text-100": !activeTextColor,
|
||||||
|
})}
|
||||||
|
style={
|
||||||
|
activeTextColor
|
||||||
|
? {
|
||||||
|
color: activeTextColor.textColor,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<section className="fixed top-full z-[99999] mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 space-y-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{COLORS_LIST.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.textColor}
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.textColor,
|
||||||
|
}}
|
||||||
|
onClick={() => TextColorItem(editor).command(color.textColor)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||||
|
onClick={() => TextColorItem(editor).command(undefined)}
|
||||||
|
>
|
||||||
|
<Ban className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{COLORS_LIST.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.backgroundColor}
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.backgroundColor,
|
||||||
|
}}
|
||||||
|
onClick={() => BackgroundColorItem(editor).command(color.backgroundColor)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||||
|
onClick={() => BackgroundColorItem(editor).command(undefined)}
|
||||||
|
>
|
||||||
|
<Ban className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from "./color-selector";
|
||||||
export * from "./link-selector";
|
export * from "./link-selector";
|
||||||
export * from "./node-selector";
|
export * from "./node-selector";
|
||||||
export * from "./root";
|
export * from "./root";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { Check, Trash } from "lucide-react";
|
import { Check, Link, Trash } from "lucide-react";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn, isValidHttpUrl } from "@/helpers/common";
|
import { cn, isValidHttpUrl } from "@/helpers/common";
|
||||||
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
|
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
|
||||||
|
|
@ -11,7 +11,9 @@ type Props = {
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BubbleMenuLinkSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
|
export const BubbleMenuLinkSelector: FC<Props> = (props) => {
|
||||||
|
const { editor, isOpen, setIsOpen } = props;
|
||||||
|
// refs
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const onLinkSubmit = useCallback(() => {
|
const onLinkSubmit = useCallback(() => {
|
||||||
|
|
@ -28,26 +30,23 @@ export const BubbleMenuLinkSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative h-full">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
|
"h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors",
|
||||||
{ "bg-custom-background-100": isOpen }
|
{
|
||||||
|
"bg-custom-background-80": isOpen,
|
||||||
|
"text-custom-text-100": editor.isActive("link"),
|
||||||
|
}
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="text-base">↗</p>
|
<span>Link</span>
|
||||||
<p
|
<Link className="flex-shrink-0 size-3" />
|
||||||
className={cn("underline underline-offset-4", {
|
|
||||||
"text-custom-text-100": editor.isActive("link"),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Link
|
|
||||||
</p>
|
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
HeadingFourItem,
|
HeadingFourItem,
|
||||||
HeadingFiveItem,
|
HeadingFiveItem,
|
||||||
HeadingSixItem,
|
HeadingSixItem,
|
||||||
BubbleMenuItem,
|
EditorMenuItem,
|
||||||
} from "@/components/menus";
|
} from "@/components/menus";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common";
|
import { cn } from "@/helpers/common";
|
||||||
|
|
@ -26,8 +26,10 @@ type Props = {
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
|
export const BubbleMenuNodeSelector: FC<Props> = (props) => {
|
||||||
const items: BubbleMenuItem[] = [
|
const { editor, isOpen, setIsOpen } = props;
|
||||||
|
|
||||||
|
const items: EditorMenuItem[] = [
|
||||||
TextItem(editor),
|
TextItem(editor),
|
||||||
HeadingOneItem(editor),
|
HeadingOneItem(editor),
|
||||||
HeadingTwoItem(editor),
|
HeadingTwoItem(editor),
|
||||||
|
|
@ -42,7 +44,7 @@ export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||||
CodeItem(editor),
|
CodeItem(editor),
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
const activeItem = items.filter((item) => item.isActive("")).pop() ?? {
|
||||||
name: "Multiple",
|
name: "Multiple",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -54,12 +56,11 @@ export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
e.stopPropagation();
|
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"
|
||||||
>
|
>
|
||||||
<span>{activeItem?.name}</span>
|
<span>{activeItem?.name}</span>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="flex-shrink-0 size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
|
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react";
|
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react";
|
||||||
import { LucideIcon } from "lucide-react";
|
|
||||||
// components
|
// components
|
||||||
import {
|
import {
|
||||||
BoldItem,
|
BoldItem,
|
||||||
|
BubbleMenuColorSelector,
|
||||||
BubbleMenuLinkSelector,
|
BubbleMenuLinkSelector,
|
||||||
BubbleMenuNodeSelector,
|
BubbleMenuNodeSelector,
|
||||||
CodeItem,
|
CodeItem,
|
||||||
|
EditorMenuItem,
|
||||||
ItalicItem,
|
ItalicItem,
|
||||||
StrikeThroughItem,
|
StrikeThroughItem,
|
||||||
UnderLineItem,
|
UnderLineItem,
|
||||||
|
|
@ -16,34 +17,23 @@ import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common";
|
import { cn } from "@/helpers/common";
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
isActive: () => boolean;
|
|
||||||
command: () => void;
|
|
||||||
icon: LucideIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||||
const items: BubbleMenuItem[] = [
|
// states
|
||||||
...(props.editor.isActive("code")
|
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||||
? []
|
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||||
: [
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||||
BoldItem(props.editor),
|
const [isSelecting, setIsSelecting] = useState(false);
|
||||||
ItalicItem(props.editor),
|
|
||||||
UnderLineItem(props.editor),
|
const items: EditorMenuItem[] = props.editor.isActive("code")
|
||||||
StrikeThroughItem(props.editor),
|
? [CodeItem(props.editor)]
|
||||||
]),
|
: [BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor)];
|
||||||
CodeItem(props.editor),
|
|
||||||
];
|
|
||||||
|
|
||||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||||
...props,
|
...props,
|
||||||
shouldShow: ({ state, editor }) => {
|
shouldShow: ({ state, editor }) => {
|
||||||
const { selection } = state;
|
const { selection } = state;
|
||||||
|
|
||||||
const { empty } = selection;
|
const { empty } = selection;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -63,15 +53,11 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||||
onHidden: () => {
|
onHidden: () => {
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsLinkSelectorOpen(false);
|
setIsLinkSelectorOpen(false);
|
||||||
|
setIsColorSelectorOpen(false);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
|
||||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
|
||||||
|
|
||||||
const [isSelecting, setIsSelecting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleMouseDown() {
|
function handleMouseDown() {
|
||||||
function handleMouseMove() {
|
function handleMouseMove() {
|
||||||
|
|
@ -102,51 +88,66 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||||
return (
|
return (
|
||||||
<BubbleMenu
|
<BubbleMenu
|
||||||
{...bubbleMenuProps}
|
{...bubbleMenuProps}
|
||||||
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
|
||||||
>
|
>
|
||||||
{isSelecting ? null : (
|
{!isSelecting && (
|
||||||
<>
|
<>
|
||||||
{!props.editor.isActive("table") && (
|
<div className="px-2">
|
||||||
<BubbleMenuNodeSelector
|
{!props.editor.isActive("table") && (
|
||||||
editor={props.editor!}
|
<BubbleMenuNodeSelector
|
||||||
isOpen={isNodeSelectorOpen}
|
editor={props.editor!}
|
||||||
setIsOpen={() => {
|
isOpen={isNodeSelectorOpen}
|
||||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
setIsOpen={() => {
|
||||||
setIsLinkSelectorOpen(false);
|
setIsNodeSelectorOpen((prev) => !prev);
|
||||||
}}
|
setIsLinkSelectorOpen(false);
|
||||||
/>
|
setIsColorSelectorOpen(false);
|
||||||
)}
|
}}
|
||||||
{!props.editor.isActive("code") && (
|
/>
|
||||||
<BubbleMenuLinkSelector
|
)}
|
||||||
editor={props.editor}
|
</div>
|
||||||
isOpen={isLinkSelectorOpen}
|
<div className="px-2">
|
||||||
setIsOpen={() => {
|
{!props.editor.isActive("code") && (
|
||||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
<BubbleMenuLinkSelector
|
||||||
setIsNodeSelectorOpen(false);
|
editor={props.editor}
|
||||||
}}
|
isOpen={isLinkSelectorOpen}
|
||||||
/>
|
setIsOpen={() => {
|
||||||
)}
|
setIsLinkSelectorOpen((prev) => !prev);
|
||||||
<div className="flex">
|
setIsNodeSelectorOpen(false);
|
||||||
|
setIsColorSelectorOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-2">
|
||||||
|
{!props.editor.isActive("code") && (
|
||||||
|
<BubbleMenuColorSelector
|
||||||
|
editor={props.editor}
|
||||||
|
isOpen={isColorSelectorOpen}
|
||||||
|
setIsOpen={() => {
|
||||||
|
setIsColorSelectorOpen((prev) => !prev);
|
||||||
|
setIsNodeSelectorOpen(false);
|
||||||
|
setIsLinkSelectorOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-0.5 px-2">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.name}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
item.command();
|
item.command();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5",
|
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
|
||||||
{
|
{
|
||||||
"bg-custom-primary-100/5 text-custom-text-100": item.isActive(),
|
"bg-custom-background-80 text-custom-text-100": item.isActive(""),
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon className="size-4" />
|
||||||
className={cn("h-4 w-4", {
|
|
||||||
"text-custom-text-100": item.isActive(),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,14 @@ import {
|
||||||
Heading6,
|
Heading6,
|
||||||
CaseSensitive,
|
CaseSensitive,
|
||||||
LucideIcon,
|
LucideIcon,
|
||||||
|
Palette,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
// helpers
|
// helpers
|
||||||
import {
|
import {
|
||||||
insertImage,
|
insertImage,
|
||||||
insertTableCommand,
|
insertTableCommand,
|
||||||
setText,
|
setText,
|
||||||
|
toggleBackgroundColor,
|
||||||
toggleBlockquote,
|
toggleBlockquote,
|
||||||
toggleBold,
|
toggleBold,
|
||||||
toggleBulletList,
|
toggleBulletList,
|
||||||
|
|
@ -40,18 +42,26 @@ import {
|
||||||
toggleOrderedList,
|
toggleOrderedList,
|
||||||
toggleStrike,
|
toggleStrike,
|
||||||
toggleTaskList,
|
toggleTaskList,
|
||||||
|
toggleTextColor,
|
||||||
toggleUnderline,
|
toggleUnderline,
|
||||||
} from "@/helpers/editor-commands";
|
} from "@/helpers/editor-commands";
|
||||||
// types
|
// types
|
||||||
import { TEditorCommands } from "@/types";
|
import { TColorEditorCommands, TNonColorEditorCommands } from "@/types";
|
||||||
|
|
||||||
export interface EditorMenuItem {
|
export type EditorMenuItem = {
|
||||||
key: TEditorCommands;
|
|
||||||
name: string;
|
name: string;
|
||||||
isActive: () => boolean;
|
command: (...args: any) => void;
|
||||||
command: () => void;
|
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}
|
} & (
|
||||||
|
| {
|
||||||
|
key: TNonColorEditorCommands;
|
||||||
|
isActive: () => boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
key: TColorEditorCommands;
|
||||||
|
isActive: (color: string | undefined) => boolean;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const TextItem = (editor: Editor): EditorMenuItem => ({
|
export const TextItem = (editor: Editor): EditorMenuItem => ({
|
||||||
key: "text",
|
key: "text",
|
||||||
|
|
@ -198,10 +208,25 @@ export const ImageItem = (editor: Editor) =>
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
}) as const;
|
}) as const;
|
||||||
|
|
||||||
export function getEditorMenuItems(editor: Editor | null) {
|
export const TextColorItem = (editor: Editor): EditorMenuItem => ({
|
||||||
if (!editor) {
|
key: "text-color",
|
||||||
return [];
|
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 [
|
return [
|
||||||
TextItem(editor),
|
TextItem(editor),
|
||||||
HeadingOneItem(editor),
|
HeadingOneItem(editor),
|
||||||
|
|
@ -221,5 +246,7 @@ export function getEditorMenuItems(editor: Editor | null) {
|
||||||
QuoteItem(editor),
|
QuoteItem(editor),
|
||||||
TableItem(editor),
|
TableItem(editor),
|
||||||
ImageItem(editor),
|
ImageItem(editor),
|
||||||
|
TextColorItem(editor),
|
||||||
|
BackgroundColorItem(editor),
|
||||||
];
|
];
|
||||||
}
|
};
|
||||||
|
|
|
||||||
51
packages/editor/src/core/constants/common.ts
Normal file
51
packages/editor/src/core/constants/common.ts
Normal file
|
|
@ -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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { Color } from "@tiptap/extension-color";
|
||||||
|
import Highlight from "@tiptap/extension-highlight";
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import TextStyle from "@tiptap/extension-text-style";
|
import TextStyle from "@tiptap/extension-text-style";
|
||||||
|
|
@ -83,6 +85,10 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
CustomMentionWithoutProps(),
|
CustomMentionWithoutProps(),
|
||||||
|
Color,
|
||||||
|
Highlight.configure({
|
||||||
|
multicolor: true,
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
|
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import CharacterCount from "@tiptap/extension-character-count";
|
import CharacterCount from "@tiptap/extension-character-count";
|
||||||
|
import { Color } from "@tiptap/extension-color";
|
||||||
|
import Highlight from "@tiptap/extension-highlight";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
|
|
@ -166,4 +168,8 @@ export const CoreEditorExtensions = ({
|
||||||
includeChildren: true,
|
includeChildren: true,
|
||||||
}),
|
}),
|
||||||
CharacterCount,
|
CharacterCount,
|
||||||
|
Color,
|
||||||
|
Highlight.configure({
|
||||||
|
multicolor: true,
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export * from "./custom-list-keymap";
|
||||||
export * from "./image";
|
export * from "./image";
|
||||||
export * from "./issue-embed";
|
export * from "./issue-embed";
|
||||||
export * from "./mentions";
|
export * from "./mentions";
|
||||||
|
export * from "./slash-commands";
|
||||||
export * from "./table";
|
export * from "./table";
|
||||||
export * from "./typography";
|
export * from "./typography";
|
||||||
export * from "./core-without-props";
|
export * from "./core-without-props";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import CharacterCount from "@tiptap/extension-character-count";
|
import CharacterCount from "@tiptap/extension-character-count";
|
||||||
|
import { Color } from "@tiptap/extension-color";
|
||||||
|
import Highlight from "@tiptap/extension-highlight";
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import TextStyle from "@tiptap/extension-text-style";
|
import TextStyle from "@tiptap/extension-text-style";
|
||||||
|
|
@ -109,5 +111,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||||
readonly: true,
|
readonly: true,
|
||||||
}),
|
}),
|
||||||
CharacterCount,
|
CharacterCount,
|
||||||
|
Color,
|
||||||
|
Highlight.configure({
|
||||||
|
multicolor: true,
|
||||||
|
}),
|
||||||
HeadingListExtension,
|
HeadingListExtension,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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<SuggestionOptions, "editor">;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Command = Extension.create<SlashCommandOptions>({
|
|
||||||
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<ISlashCommandItem>) =>
|
|
||||||
({ query }: { query: string }) => {
|
|
||||||
let slashCommands: ISlashCommandItem[] = [
|
|
||||||
{
|
|
||||||
key: "text",
|
|
||||||
title: "Text",
|
|
||||||
description: "Just start typing with plain text.",
|
|
||||||
searchTerms: ["p", "paragraph"],
|
|
||||||
icon: <CaseSensitive className="size-3.5" />,
|
|
||||||
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: <Heading1 className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleHeadingOne(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "h2",
|
|
||||||
title: "Heading 2",
|
|
||||||
description: "Medium section heading.",
|
|
||||||
searchTerms: ["subtitle", "medium"],
|
|
||||||
icon: <Heading2 className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleHeadingTwo(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "h3",
|
|
||||||
title: "Heading 3",
|
|
||||||
description: "Small section heading.",
|
|
||||||
searchTerms: ["subtitle", "small"],
|
|
||||||
icon: <Heading3 className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleHeadingThree(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "h4",
|
|
||||||
title: "Heading 4",
|
|
||||||
description: "Small section heading.",
|
|
||||||
searchTerms: ["subtitle", "small"],
|
|
||||||
icon: <Heading4 className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleHeadingFour(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "h5",
|
|
||||||
title: "Heading 5",
|
|
||||||
description: "Small section heading.",
|
|
||||||
searchTerms: ["subtitle", "small"],
|
|
||||||
icon: <Heading5 className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleHeadingFive(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "h6",
|
|
||||||
title: "Heading 6",
|
|
||||||
description: "Small section heading.",
|
|
||||||
searchTerms: ["subtitle", "small"],
|
|
||||||
icon: <Heading6 className="size-3.5" />,
|
|
||||||
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: <ListTodo className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleTaskList(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "bulleted-list",
|
|
||||||
title: "Bullet list",
|
|
||||||
description: "Create a simple bullet list.",
|
|
||||||
searchTerms: ["unordered", "point"],
|
|
||||||
icon: <List className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleBulletList(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "numbered-list",
|
|
||||||
title: "Numbered list",
|
|
||||||
description: "Create a list with numbering.",
|
|
||||||
searchTerms: ["ordered"],
|
|
||||||
icon: <ListOrdered className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
toggleOrderedList(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "table",
|
|
||||||
title: "Table",
|
|
||||||
description: "Create a table",
|
|
||||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
|
||||||
icon: <Table className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
insertTableCommand(editor, range);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "quote",
|
|
||||||
title: "Quote",
|
|
||||||
description: "Capture a quote.",
|
|
||||||
searchTerms: ["blockquote"],
|
|
||||||
icon: <Quote className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "code",
|
|
||||||
title: "Code",
|
|
||||||
description: "Capture a code snippet.",
|
|
||||||
searchTerms: ["codeblock"],
|
|
||||||
icon: <Code2 className="size-3.5" />,
|
|
||||||
command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "image",
|
|
||||||
title: "Image",
|
|
||||||
icon: <ImageIcon className="size-3.5" />,
|
|
||||||
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: <MinusSquare className="size-3.5" />,
|
|
||||||
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<HTMLDivElement>(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 (
|
|
||||||
<div
|
|
||||||
id="slash-command"
|
|
||||||
ref={commandListContainer}
|
|
||||||
className="z-10 max-h-80 min-w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
|
|
||||||
>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-sm text-left truncate text-custom-text-200 hover:bg-custom-background-80",
|
|
||||||
{
|
|
||||||
"bg-custom-background-80": index === selectedIndex,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
selectItem(index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="grid place-items-center flex-shrink-0">{item.icon}</span>
|
|
||||||
<p className="flex-grow truncate">{item.title}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CommandListInstance {
|
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderItems = () => {
|
|
||||||
let component: ReactRenderer<CommandListInstance, typeof CommandList> | 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<ISlashCommandItem>) =>
|
|
||||||
Command.configure({
|
|
||||||
suggestion: {
|
|
||||||
items: getSuggestionItems(additionalOptions),
|
|
||||||
render: renderItems,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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: <CaseSensitive className="size-3.5" />,
|
||||||
|
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: <Heading1 className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleHeadingOne(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "h2",
|
||||||
|
key: "h2",
|
||||||
|
title: "Heading 2",
|
||||||
|
description: "Medium section heading.",
|
||||||
|
searchTerms: ["subtitle", "medium"],
|
||||||
|
icon: <Heading2 className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleHeadingTwo(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "h3",
|
||||||
|
key: "h3",
|
||||||
|
title: "Heading 3",
|
||||||
|
description: "Small section heading.",
|
||||||
|
searchTerms: ["subtitle", "small"],
|
||||||
|
icon: <Heading3 className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleHeadingThree(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "h4",
|
||||||
|
key: "h4",
|
||||||
|
title: "Heading 4",
|
||||||
|
description: "Small section heading.",
|
||||||
|
searchTerms: ["subtitle", "small"],
|
||||||
|
icon: <Heading4 className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleHeadingFour(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "h5",
|
||||||
|
key: "h5",
|
||||||
|
title: "Heading 5",
|
||||||
|
description: "Small section heading.",
|
||||||
|
searchTerms: ["subtitle", "small"],
|
||||||
|
icon: <Heading5 className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleHeadingFive(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "h6",
|
||||||
|
key: "h6",
|
||||||
|
title: "Heading 6",
|
||||||
|
description: "Small section heading.",
|
||||||
|
searchTerms: ["subtitle", "small"],
|
||||||
|
icon: <Heading6 className="size-3.5" />,
|
||||||
|
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: <ListTodo className="size-3.5" />,
|
||||||
|
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: <List className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleBulletList(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "numbered-list",
|
||||||
|
key: "numbered-list",
|
||||||
|
title: "Numbered list",
|
||||||
|
description: "Create a list with numbering.",
|
||||||
|
searchTerms: ["ordered"],
|
||||||
|
icon: <ListOrdered className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleOrderedList(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "table",
|
||||||
|
key: "table",
|
||||||
|
title: "Table",
|
||||||
|
description: "Create a table",
|
||||||
|
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||||
|
icon: <Table className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => insertTableCommand(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "quote",
|
||||||
|
key: "quote",
|
||||||
|
title: "Quote",
|
||||||
|
description: "Capture a quote.",
|
||||||
|
searchTerms: ["blockquote"],
|
||||||
|
icon: <Quote className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => toggleBlockquote(editor, range),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "code",
|
||||||
|
key: "code",
|
||||||
|
title: "Code",
|
||||||
|
description: "Capture a code snippet.",
|
||||||
|
searchTerms: ["codeblock"],
|
||||||
|
icon: <Code2 className="size-3.5" />,
|
||||||
|
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commandKey: "image",
|
||||||
|
key: "image",
|
||||||
|
title: "Image",
|
||||||
|
icon: <ImageIcon className="size-3.5" />,
|
||||||
|
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: <MinusSquare className="size-3.5" />,
|
||||||
|
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: (
|
||||||
|
<ALargeSmall
|
||||||
|
className="size-3.5"
|
||||||
|
style={{
|
||||||
|
color: "rgba(var(--color-text-100))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
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: (
|
||||||
|
<ALargeSmall
|
||||||
|
className="size-3.5"
|
||||||
|
style={{
|
||||||
|
color: color.textColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
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: <ALargeSmall className="size-3.5" />,
|
||||||
|
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: <ALargeSmall className="size-3.5" />,
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
@ -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<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
onMouseEnter: () => void;
|
||||||
|
sectionIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommandMenuItem: React.FC<Props> = (props) => {
|
||||||
|
const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id={`item-${sectionIndex}-${itemIndex}`}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-sm text-left truncate text-custom-text-200",
|
||||||
|
{
|
||||||
|
"bg-custom-background-80": isSelected,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
>
|
||||||
|
<span className="size-5 grid place-items-center flex-shrink-0" style={item.iconContainerStyle}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
<p className="flex-grow truncate">{item.title}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
id="slash-command"
|
||||||
|
ref={commandListContainer}
|
||||||
|
className="z-10 max-h-80 min-w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2"
|
||||||
|
>
|
||||||
|
{sections.map((section, sectionIndex) => (
|
||||||
|
<div key={section.key} className="space-y-2">
|
||||||
|
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
|
||||||
|
<div>
|
||||||
|
{section.items.map((item, itemIndex) => (
|
||||||
|
<CommandMenuItem
|
||||||
|
key={item.key}
|
||||||
|
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
|
||||||
|
item={item}
|
||||||
|
itemIndex={itemIndex}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
selectItem(sectionIndex, itemIndex);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() =>
|
||||||
|
setSelectedIndex({
|
||||||
|
section: sectionIndex,
|
||||||
|
item: itemIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sectionIndex={sectionIndex}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
113
packages/editor/src/core/extensions/slash-commands/root.tsx
Normal file
113
packages/editor/src/core/extensions/slash-commands/root.tsx
Normal file
|
|
@ -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<SuggestionOptions, "editor">;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Command = Extension.create<SlashCommandOptions>({
|
||||||
|
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<CommandListInstance, typeof SlashCommandsMenu> | 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -154,3 +154,42 @@ export const unsetLinkEditor = (editor: Editor) => {
|
||||||
export const setLinkEditor = (editor: Editor, url: string) => {
|
export const setLinkEditor = (editor: Editor, url: string) => {
|
||||||
editor.chain().focus().setLink({ href: url }).run();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,8 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
insertContentAtSavedSelection(editorRef, content, savedSelection);
|
insertContentAtSavedSelection(editorRef, content, savedSelection);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
executeMenuItemCommand: (itemKey: TEditorCommands) => {
|
executeMenuItemCommand: (props) => {
|
||||||
|
const { itemKey } = props;
|
||||||
const editorItems = getEditorMenuItems(editorRef.current);
|
const editorItems = getEditorMenuItems(editorRef.current);
|
||||||
|
|
||||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||||
|
|
@ -145,6 +146,8 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
if (item) {
|
if (item) {
|
||||||
if (item.key === "image") {
|
if (item.key === "image") {
|
||||||
item.command(savedSelectionRef.current);
|
item.command(savedSelectionRef.current);
|
||||||
|
} else if (itemKey === "text-color" || itemKey === "background-color") {
|
||||||
|
item.command(props.color);
|
||||||
} else {
|
} else {
|
||||||
item.command();
|
item.command();
|
||||||
}
|
}
|
||||||
|
|
@ -152,12 +155,19 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
console.warn(`No command found for item: ${itemKey}`);
|
console.warn(`No command found for item: ${itemKey}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isMenuItemActive: (itemName: TEditorCommands): boolean => {
|
isMenuItemActive: (props) => {
|
||||||
|
const { itemKey } = props;
|
||||||
const editorItems = getEditorMenuItems(editorRef.current);
|
const editorItems = getEditorMenuItems(editorRef.current);
|
||||||
|
|
||||||
const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
|
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||||
const item = getEditorMenuItem(itemName);
|
const item = getEditorMenuItem(itemKey);
|
||||||
return item ? item.isActive() : false;
|
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) => {
|
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
||||||
// Subscribe to update event emitted from headers extension
|
// Subscribe to update event emitted from headers extension
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,15 @@ import {
|
||||||
IMentionHighlight,
|
IMentionHighlight,
|
||||||
IMentionSuggestion,
|
IMentionSuggestion,
|
||||||
TAIHandler,
|
TAIHandler,
|
||||||
|
TColorEditorCommands,
|
||||||
TDisplayConfig,
|
TDisplayConfig,
|
||||||
TEditorCommands,
|
TEditorCommands,
|
||||||
TEmbedConfig,
|
TEmbedConfig,
|
||||||
TExtensions,
|
TExtensions,
|
||||||
TFileHandler,
|
TFileHandler,
|
||||||
|
TNonColorEditorCommands,
|
||||||
TServerHandler,
|
TServerHandler,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
// editor refs
|
// editor refs
|
||||||
export type EditorReadOnlyRefApi = {
|
export type EditorReadOnlyRefApi = {
|
||||||
getMarkDown: () => string;
|
getMarkDown: () => string;
|
||||||
|
|
@ -36,8 +37,26 @@ export type EditorReadOnlyRefApi = {
|
||||||
|
|
||||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||||
setEditorValueAtCursorPosition: (content: string) => void;
|
setEditorValueAtCursorPosition: (content: string) => void;
|
||||||
executeMenuItemCommand: (itemKey: TEditorCommands) => void;
|
executeMenuItemCommand: (
|
||||||
isMenuItemActive: (itemKey: TEditorCommands) => boolean;
|
props:
|
||||||
|
| {
|
||||||
|
itemKey: TNonColorEditorCommands;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
itemKey: TColorEditorCommands;
|
||||||
|
color: string | undefined;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
isMenuItemActive: (
|
||||||
|
props:
|
||||||
|
| {
|
||||||
|
itemKey: TNonColorEditorCommands;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
itemKey: TColorEditorCommands;
|
||||||
|
color: string | undefined;
|
||||||
|
}
|
||||||
|
) => boolean;
|
||||||
onStateChange: (callback: () => void) => () => void;
|
onStateChange: (callback: () => void) => () => void;
|
||||||
setFocusAtPosition: (position: number) => void;
|
setFocusAtPosition: (position: number) => void;
|
||||||
isEditorReadyToDiscard: () => boolean;
|
isEditorReadyToDiscard: () => boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ReactNode } from "react";
|
import { CSSProperties } from "react";
|
||||||
import { Editor, Range } from "@tiptap/core";
|
import { Editor, Range } from "@tiptap/core";
|
||||||
|
|
||||||
export type TEditorCommands =
|
export type TEditorCommands =
|
||||||
|
|
@ -21,7 +21,12 @@ export type TEditorCommands =
|
||||||
| "table"
|
| "table"
|
||||||
| "image"
|
| "image"
|
||||||
| "divider"
|
| "divider"
|
||||||
| "issue-embed";
|
| "issue-embed"
|
||||||
|
| "text-color"
|
||||||
|
| "background-color";
|
||||||
|
|
||||||
|
export type TColorEditorCommands = Extract<TEditorCommands, "text-color" | "background-color">;
|
||||||
|
export type TNonColorEditorCommands = Exclude<TEditorCommands, "text-color" | "background-color">;
|
||||||
|
|
||||||
export type CommandProps = {
|
export type CommandProps = {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
|
|
@ -29,10 +34,12 @@ export type CommandProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ISlashCommandItem = {
|
export type ISlashCommandItem = {
|
||||||
key: TEditorCommands;
|
commandKey: TEditorCommands;
|
||||||
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
searchTerms: string[];
|
searchTerms: string[];
|
||||||
icon: ReactNode;
|
icon: React.ReactNode;
|
||||||
|
iconContainerStyle?: CSSProperties;
|
||||||
command: ({ editor, range }: CommandProps) => void;
|
command: ({ editor, range }: CommandProps) => void;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ export {
|
||||||
|
|
||||||
export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||||
|
|
||||||
|
// constants
|
||||||
|
export * from "@/constants/common";
|
||||||
|
|
||||||
// helpers
|
// helpers
|
||||||
export * from "@/helpers/common";
|
export * from "@/helpers/common";
|
||||||
export * from "@/helpers/editor-commands";
|
export * from "@/helpers/editor-commands";
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&.sans-serif {
|
&.sans-serif {
|
||||||
--font-style: sans-serif;
|
--font-style: "Inter", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.serif {
|
&.serif {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ export enum EModalPosition {
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum EModalWidth {
|
export enum EModalWidth {
|
||||||
|
SM = "sm:max-w-sm",
|
||||||
|
MD = "sm:max-w-md",
|
||||||
|
LG = "sm:max-w-lg",
|
||||||
XL = "sm:max-w-xl",
|
XL = "sm:max-w-xl",
|
||||||
XXL = "sm:max-w-2xl",
|
XXL = "sm:max-w-2xl",
|
||||||
XXXL = "sm:max-w-3xl",
|
XXXL = "sm:max-w-3xl",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
|
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor";
|
||||||
// components
|
// components
|
||||||
import { IssueCommentToolbar } from "@/components/editor";
|
import { IssueCommentToolbar } from "@/components/editor";
|
||||||
// helpers
|
// helpers
|
||||||
|
|
@ -56,7 +56,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||||
<IssueCommentToolbar
|
<IssueCommentToolbar
|
||||||
executeCommand={(key) => {
|
executeCommand={(key) => {
|
||||||
if (isMutableRefObject<EditorRefApi>(ref)) {
|
if (isMutableRefObject<EditorRefApi>(ref)) {
|
||||||
ref.current?.executeMenuItemCommand(key);
|
ref.current?.executeMenuItemCommand({
|
||||||
|
itemKey: key as TNonColorEditorCommands,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi, TEditorCommands } from "@plane/editor";
|
import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Tooltip } from "@plane/ui";
|
import { Button, Tooltip } from "@plane/ui";
|
||||||
// constants
|
// constants
|
||||||
|
|
@ -34,7 +34,9 @@ export const IssueCommentToolbar: React.FC<Props> = (props) => {
|
||||||
.flat()
|
.flat()
|
||||||
.forEach((item) => {
|
.forEach((item) => {
|
||||||
// Assert that editorRef.current is not null
|
// 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);
|
setActiveStates(newActiveStates);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export const CycleIssuesMobileHeader = () => {
|
||||||
const { getCycleById } = useCycle();
|
const { getCycleById } = useCycle();
|
||||||
const layouts = [
|
const layouts = [
|
||||||
{ key: "list", title: "List", icon: List },
|
{ key: "list", title: "List", icon: List },
|
||||||
{ key: "kanban", title: "Kanban", icon: Kanban },
|
{ key: "kanban", title: "Board", icon: Kanban },
|
||||||
{ key: "calendar", title: "Calendar", icon: Calendar },
|
{ key: "calendar", title: "Calendar", icon: Calendar },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/h
|
||||||
export const ProjectIssuesMobileHeader = observer(() => {
|
export const ProjectIssuesMobileHeader = observer(() => {
|
||||||
const layouts = [
|
const layouts = [
|
||||||
{ key: "list", title: "List", icon: List },
|
{ key: "list", title: "List", icon: List },
|
||||||
{ key: "kanban", title: "Kanban", icon: Kanban },
|
{ key: "kanban", title: "Board", icon: Kanban },
|
||||||
{ key: "calendar", title: "Calendar", icon: Calendar },
|
{ key: "calendar", title: "Calendar", icon: Calendar },
|
||||||
];
|
];
|
||||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export const ModuleIssuesMobileHeader = observer(() => {
|
||||||
const { getModuleById } = useModule();
|
const { getModuleById } = useModule();
|
||||||
const layouts = [
|
const layouts = [
|
||||||
{ key: "list", title: "List", icon: List },
|
{ key: "list", title: "List", icon: List },
|
||||||
{ key: "kanban", title: "Kanban", icon: Kanban },
|
{ key: "kanban", title: "Board", icon: Kanban },
|
||||||
{ key: "calendar", title: "Calendar", icon: Calendar },
|
{ key: "calendar", title: "Calendar", icon: Calendar },
|
||||||
];
|
];
|
||||||
const { workspaceSlug, projectId, moduleId } = useParams() as {
|
const { workspaceSlug, projectId, moduleId } = useParams() as {
|
||||||
|
|
|
||||||
69
web/ce/components/cycles/analytics-sidebar/base.tsx
Normal file
69
web/ce/components/cycles/analytics-sidebar/base.tsx
Normal file
|
|
@ -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<ProgressChartProps> = 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 (
|
||||||
|
<div>
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center gap-1 text-xs">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
|
||||||
|
<span>Ideal</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-1 text-xs">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
|
||||||
|
<span>Current</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{cycleStartDate && cycleEndDate && completionChartDistributionData ? (
|
||||||
|
<Fragment>
|
||||||
|
<ProgressChart
|
||||||
|
distribution={completionChartDistributionData}
|
||||||
|
startDate={cycleStartDate}
|
||||||
|
endDate={cycleEndDate}
|
||||||
|
totalIssues={estimateType === "points" ? totalEstimatePoints : totalIssues}
|
||||||
|
plotTitle={estimateType === "points" ? "points" : "issues"}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
) : (
|
||||||
|
<Loader className="w-full h-[160px] mt-4">
|
||||||
|
<Loader.Item width="100%" height="100%" />
|
||||||
|
</Loader>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1 +1 @@
|
||||||
export * from "./sidebar-chart";
|
export * from "./root";
|
||||||
|
|
|
||||||
12
web/ce/components/cycles/analytics-sidebar/root.tsx
Normal file
12
web/ce/components/cycles/analytics-sidebar/root.tsx
Normal file
|
|
@ -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> = (props) => <SidebarChart {...props} />;
|
||||||
|
|
@ -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 (
|
|
||||||
<div>
|
|
||||||
<div className="relative flex items-center gap-2">
|
|
||||||
<div className="flex items-center justify-center gap-1 text-xs">
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
|
|
||||||
<span>Ideal</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center gap-1 text-xs">
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
|
|
||||||
<span>Current</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{cycleStartDate && cycleEndDate && completionChartDistributionData ? (
|
|
||||||
<Fragment>
|
|
||||||
{plotType === "points" ? (
|
|
||||||
<ProgressChart
|
|
||||||
distribution={completionChartDistributionData}
|
|
||||||
startDate={cycleStartDate}
|
|
||||||
endDate={cycleEndDate}
|
|
||||||
totalIssues={totalEstimatePoints}
|
|
||||||
plotTitle={"points"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ProgressChart
|
|
||||||
distribution={completionChartDistributionData}
|
|
||||||
startDate={cycleStartDate}
|
|
||||||
endDate={cycleEndDate}
|
|
||||||
totalIssues={totalIssues}
|
|
||||||
plotTitle={"issues"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
) : (
|
|
||||||
<Loader className="w-full h-[160px] mt-4">
|
|
||||||
<Loader.Item width="100%" height="100%" />
|
|
||||||
</Loader>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FC, Fragment, useCallback, useMemo, useState } from "react";
|
import { FC, Fragment, useCallback, useMemo } from "react";
|
||||||
import isEmpty from "lodash/isEmpty";
|
import isEmpty from "lodash/isEmpty";
|
||||||
import isEqual from "lodash/isEqual";
|
import isEqual from "lodash/isEqual";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
|
@ -16,10 +16,9 @@ import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
||||||
// helpers
|
// helpers
|
||||||
import { getDate } from "@/helpers/date-time.helper";
|
import { getDate } from "@/helpers/date-time.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssues, useCycle, useProjectEstimates } from "@/hooks/store";
|
import { useIssues, useCycle } from "@/hooks/store";
|
||||||
// plane web constants
|
// plane web components
|
||||||
import { SidebarBaseChart } from "@/plane-web/components/cycles/analytics-sidebar";
|
import { SidebarChartRoot } from "@/plane-web/components/cycles";
|
||||||
import { EEstimateSystem } from "@/plane-web/constants/estimates";
|
|
||||||
|
|
||||||
type TCycleAnalyticsProgress = {
|
type TCycleAnalyticsProgress = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
|
|
@ -27,7 +26,7 @@ type TCycleAnalyticsProgress = {
|
||||||
cycleId: string;
|
cycleId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => {
|
export const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => {
|
||||||
if (!cycleDetails || cycleDetails === null) return cycleDetails;
|
if (!cycleDetails || cycleDetails === null) return cycleDetails;
|
||||||
|
|
||||||
const updatedCycleDetails: any = { ...cycleDetails };
|
const updatedCycleDetails: any = { ...cycleDetails };
|
||||||
|
|
@ -60,12 +59,9 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
|
||||||
// router
|
// router
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const peekCycle = searchParams.get("peekCycle") || undefined;
|
const peekCycle = searchParams.get("peekCycle") || undefined;
|
||||||
// hooks
|
|
||||||
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
|
||||||
const {
|
const {
|
||||||
getPlotTypeByCycleId,
|
getPlotTypeByCycleId,
|
||||||
getEstimateTypeByCycleId,
|
getEstimateTypeByCycleId,
|
||||||
setPlotType,
|
|
||||||
getCycleById,
|
getCycleById,
|
||||||
fetchCycleDetails,
|
fetchCycleDetails,
|
||||||
fetchArchivedCycleDetails,
|
fetchArchivedCycleDetails,
|
||||||
|
|
@ -74,17 +70,11 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
|
||||||
const {
|
const {
|
||||||
issuesFilter: { issueFilters, updateFilters },
|
issuesFilter: { issueFilters, updateFilters },
|
||||||
} = useIssues(EIssuesStoreType.CYCLE);
|
} = useIssues(EIssuesStoreType.CYCLE);
|
||||||
// state
|
|
||||||
const [loader, setLoader] = useState(false);
|
|
||||||
|
|
||||||
// derived values
|
// derived values
|
||||||
const cycleDetails = validateCycleSnapshot(getCycleById(cycleId));
|
const cycleDetails = validateCycleSnapshot(getCycleById(cycleId));
|
||||||
const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId);
|
const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId);
|
||||||
const estimateType = getEstimateTypeByCycleId(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 completedIssues = cycleDetails?.completed_issues || 0;
|
||||||
const totalIssues = cycleDetails?.total_issues || 0;
|
const totalIssues = cycleDetails?.total_issues || 0;
|
||||||
|
|
@ -132,15 +122,13 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
|
||||||
setEstimateType(cycleId, value);
|
setEstimateType(cycleId, value);
|
||||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||||
try {
|
try {
|
||||||
setLoader(true);
|
|
||||||
if (isArchived) {
|
if (isArchived) {
|
||||||
await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId);
|
await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId);
|
||||||
} else {
|
} else {
|
||||||
await fetchCycleDetails(workspaceSlug, projectId, cycleId);
|
await fetchCycleDetails(workspaceSlug, projectId, cycleId);
|
||||||
}
|
}
|
||||||
setLoader(false);
|
} catch (err) {
|
||||||
} catch (error) {
|
console.error(err);
|
||||||
setLoader(false);
|
|
||||||
setEstimateType(cycleId, estimateType);
|
setEstimateType(cycleId, estimateType);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -218,16 +206,15 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
|
||||||
</CustomSelect.Option>
|
</CustomSelect.Option>
|
||||||
))}
|
))}
|
||||||
</CustomSelect>
|
</CustomSelect>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<span className="text-custom-text-300">Done</span>
|
||||||
|
<span className="font-semibold text-custom-text-400">{progressHeaderPercentage}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<SidebarBaseChart
|
<SidebarChartRoot workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
|
||||||
chartDistributionData={chartDistributionData}
|
|
||||||
cycleStartDate={cycleStartDate}
|
|
||||||
cycleEndDate={cycleEndDate}
|
|
||||||
totalEstimatePoints={totalEstimatePoints}
|
|
||||||
totalIssues={totalIssues}
|
|
||||||
plotType={plotType}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/* progress detailed view */}
|
{/* progress detailed view */}
|
||||||
{chartDistributionData && (
|
{chartDistributionData && (
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ import React, { FC } from "react";
|
||||||
import isEmpty from "lodash/isEmpty";
|
import isEmpty from "lodash/isEmpty";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { LayersIcon, SquareUser, Users } from "lucide-react";
|
import { LayersIcon, SquareUser, Users } from "lucide-react";
|
||||||
// ui
|
|
||||||
import { ICycle } from "@plane/types";
|
|
||||||
import { Avatar, AvatarGroup, TextArea } from "@plane/ui";
|
|
||||||
// types
|
// types
|
||||||
|
import { ICycle } from "@plane/types";
|
||||||
|
// ui
|
||||||
|
import { Avatar, AvatarGroup, TextArea } from "@plane/ui";
|
||||||
// hooks
|
// hooks
|
||||||
import { useMember, useProjectEstimates } from "@/hooks/store";
|
import { useMember, useProjectEstimates } from "@/hooks/store";
|
||||||
// plane web
|
// plane web
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from "./lite-text-editor";
|
export * from "./lite-text-editor";
|
||||||
|
export * from "./pdf";
|
||||||
export * from "./rich-text-editor";
|
export * from "./rich-text-editor";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
|
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor";
|
||||||
// types
|
// types
|
||||||
import { IUserLite } from "@plane/types";
|
import { IUserLite } from "@plane/types";
|
||||||
// components
|
// components
|
||||||
|
|
@ -87,7 +87,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||||
accessSpecifier={accessSpecifier}
|
accessSpecifier={accessSpecifier}
|
||||||
executeCommand={(key) => {
|
executeCommand={(key) => {
|
||||||
if (isMutableRefObject<EditorRefApi>(ref)) {
|
if (isMutableRefObject<EditorRefApi>(ref)) {
|
||||||
ref.current?.executeMenuItemCommand(key);
|
ref.current?.executeMenuItemCommand({
|
||||||
|
itemKey: key as TNonColorEditorCommands,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
handleAccessChange={handleAccessChange}
|
handleAccessChange={handleAccessChange}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
import { Globe2, Lock, LucideIcon } from "lucide-react";
|
import { Globe2, Lock, LucideIcon } from "lucide-react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi, TEditorCommands } from "@plane/editor";
|
import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Tooltip } from "@plane/ui";
|
import { Button, Tooltip } from "@plane/ui";
|
||||||
// constants
|
// constants
|
||||||
|
|
@ -69,7 +69,9 @@ export const IssueCommentToolbar: React.FC<Props> = (props) => {
|
||||||
.flat()
|
.flat()
|
||||||
.forEach((item) => {
|
.forEach((item) => {
|
||||||
// Assert that editorRef.current is not null
|
// 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);
|
setActiveStates(newActiveStates);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
web/core/components/editor/pdf/document.tsx
Normal file
53
web/core/components/editor/pdf/document.tsx
Normal file
|
|
@ -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> = (props) => {
|
||||||
|
const { content, pageFormat } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page
|
||||||
|
size={pageFormat}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
padding: 64,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Html stylesheet={EDITOR_PDF_DOCUMENT_STYLESHEET}>{content}</Html>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
web/core/components/editor/pdf/index.ts
Normal file
1
web/core/components/editor/pdf/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./document";
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { FC, useEffect } from "react";
|
import { FC, useEffect } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// components
|
// components
|
||||||
import { TIssueOperations } from "@/components/issues";
|
import { IssueParentDetail, TIssueOperations } from "@/components/issues";
|
||||||
// store hooks
|
// store hooks
|
||||||
import { useIssueDetail, useUser } from "@/hooks/store";
|
import { useIssueDetail, useUser } from "@/hooks/store";
|
||||||
// hooks
|
// hooks
|
||||||
|
|
@ -57,6 +57,15 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{issue.parent_id && (
|
||||||
|
<IssueParentDetail
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={issue.project_id}
|
||||||
|
issueId={issueId}
|
||||||
|
issue={issue}
|
||||||
|
issueOperations={issueOperations}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<IssueTypeSwitcher issueId={issueId} disabled={isArchived || disabled} />
|
<IssueTypeSwitcher issueId={issueId} disabled={isArchived || disabled} />
|
||||||
<IssueTitleInput
|
<IssueTitleInput
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
|
|
|
||||||
127
web/core/components/pages/editor/header/color-dropdown.tsx
Normal file
127
web/core/components/pages/editor/header/color-dropdown.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { Popover } from "@headlessui/react";
|
||||||
|
import { ALargeSmall, Ban } from "lucide-react";
|
||||||
|
// plane editor
|
||||||
|
import { COLORS_LIST, TColorEditorCommands } from "@plane/editor";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
handleColorSelect: (key: TColorEditorCommands, color: string | undefined) => void;
|
||||||
|
isColorActive: (key: TColorEditorCommands, color: string | undefined) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ColorDropdown: React.FC<Props> = 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 (
|
||||||
|
<Popover as="div" className="h-7 px-2">
|
||||||
|
<Popover.Button
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
className={({ open }) =>
|
||||||
|
cn("h-full", {
|
||||||
|
"outline-none": open,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ open }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-full px-2 text-custom-text-300 text-sm flex items-center gap-1.5 rounded hover:bg-custom-background-80",
|
||||||
|
{
|
||||||
|
"text-custom-text-100 bg-custom-background-80": open,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Color
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
|
||||||
|
{
|
||||||
|
"bg-custom-background-100": !activeBackgroundColor,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
activeBackgroundColor
|
||||||
|
? {
|
||||||
|
backgroundColor: activeBackgroundColor.backgroundColor,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ALargeSmall
|
||||||
|
className={cn("size-3.5", {
|
||||||
|
"text-custom-text-100": !activeTextColor,
|
||||||
|
})}
|
||||||
|
style={
|
||||||
|
activeTextColor
|
||||||
|
? {
|
||||||
|
color: activeTextColor.textColor,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Popover.Button>
|
||||||
|
<Popover.Panel
|
||||||
|
as="div"
|
||||||
|
className="fixed z-20 mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg p-2 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{COLORS_LIST.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.textColor}
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.textColor,
|
||||||
|
}}
|
||||||
|
onClick={() => handleColorSelect("text-color", color.textColor)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||||
|
onClick={() => handleColorSelect("text-color", undefined)}
|
||||||
|
>
|
||||||
|
<Ban className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{COLORS_LIST.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.backgroundColor}
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.backgroundColor,
|
||||||
|
}}
|
||||||
|
onClick={() => handleColorSelect("background-color", color.backgroundColor)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||||
|
onClick={() => handleColorSelect("background-color", undefined)}
|
||||||
|
>
|
||||||
|
<Ban className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover.Panel>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from "./color-dropdown";
|
||||||
export * from "./extra-options";
|
export * from "./extra-options";
|
||||||
export * from "./info-popover";
|
export * from "./info-popover";
|
||||||
export * from "./options-dropdown";
|
export * from "./options-dropdown";
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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
|
// document editor
|
||||||
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
||||||
// ui
|
// ui
|
||||||
import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { ExportPageModal } from "@/components/pages";
|
||||||
// helpers
|
// helpers
|
||||||
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
|
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
|
||||||
// hooks
|
// hooks
|
||||||
|
|
@ -27,6 +30,7 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// store values
|
// store values
|
||||||
const {
|
const {
|
||||||
|
name,
|
||||||
archived_at,
|
archived_at,
|
||||||
is_locked,
|
is_locked,
|
||||||
id,
|
id,
|
||||||
|
|
@ -38,6 +42,8 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||||
canCurrentUserLockPage,
|
canCurrentUserLockPage,
|
||||||
restore,
|
restore,
|
||||||
} = page;
|
} = page;
|
||||||
|
// states
|
||||||
|
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||||
// store hooks
|
// store hooks
|
||||||
const { workspaceSlug, projectId } = useParams();
|
const { workspaceSlug, projectId } = useParams();
|
||||||
// page filters
|
// page filters
|
||||||
|
|
@ -157,26 +163,41 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||||
icon: History,
|
icon: History,
|
||||||
shouldRender: true,
|
shouldRender: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "export",
|
||||||
|
action: () => setIsExportModalOpen(true),
|
||||||
|
label: "Export",
|
||||||
|
icon: ArrowUpToLine,
|
||||||
|
shouldRender: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomMenu maxHeight="lg" placement="bottom-start" verticalEllipsis closeOnSelect>
|
<>
|
||||||
<CustomMenu.MenuItem
|
<ExportPageModal
|
||||||
className="hidden md:flex w-full items-center justify-between gap-2"
|
editorRef={editorRef}
|
||||||
onClick={() => handleFullWidth(!isFullWidth)}
|
isOpen={isExportModalOpen}
|
||||||
>
|
onClose={() => setIsExportModalOpen(false)}
|
||||||
Full width
|
pageTitle={name ?? ""}
|
||||||
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
|
/>
|
||||||
</CustomMenu.MenuItem>
|
<CustomMenu maxHeight="lg" placement="bottom-start" verticalEllipsis closeOnSelect>
|
||||||
{MENU_ITEMS.map((item) => {
|
<CustomMenu.MenuItem
|
||||||
if (!item.shouldRender) return null;
|
className="hidden md:flex w-full items-center justify-between gap-2"
|
||||||
return (
|
onClick={() => handleFullWidth(!isFullWidth)}
|
||||||
<CustomMenu.MenuItem key={item.key} onClick={item.action} className="flex items-center gap-2">
|
>
|
||||||
<item.icon className="h-3 w-3" />
|
Full width
|
||||||
{item.label}
|
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
);
|
{MENU_ITEMS.map((item) => {
|
||||||
})}
|
if (!item.shouldRender) return null;
|
||||||
</CustomMenu>
|
return (
|
||||||
|
<CustomMenu.MenuItem key={item.key} onClick={item.action} className="flex items-center gap-2">
|
||||||
|
<item.icon className="h-3 w-3" />
|
||||||
|
{item.label}
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CustomMenu>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
import { Check, ChevronDown } from "lucide-react";
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi, TEditorCommands } from "@plane/editor";
|
import { EditorRefApi, TNonColorEditorCommands } from "@plane/editor";
|
||||||
// ui
|
// ui
|
||||||
import { CustomMenu, Tooltip } from "@plane/ui";
|
import { CustomMenu, Tooltip } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { ColorDropdown } from "@/components/pages";
|
||||||
// constants
|
// constants
|
||||||
import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS, ToolbarMenuItem } from "@/constants/editor";
|
import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS, ToolbarMenuItem } from "@/constants/editor";
|
||||||
// helpers
|
// helpers
|
||||||
|
|
@ -18,7 +20,7 @@ type Props = {
|
||||||
type ToolbarButtonProps = {
|
type ToolbarButtonProps = {
|
||||||
item: ToolbarMenuItem;
|
item: ToolbarMenuItem;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
executeCommand: (commandKey: TEditorCommands) => void;
|
executeCommand: EditorRefApi["executeMenuItemCommand"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ToolbarButton: React.FC<ToolbarButtonProps> = React.memo((props) => {
|
const ToolbarButton: React.FC<ToolbarButtonProps> = React.memo((props) => {
|
||||||
|
|
@ -36,7 +38,11 @@ const ToolbarButton: React.FC<ToolbarButtonProps> = React.memo((props) => {
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => executeCommand(item.key)}
|
onClick={() =>
|
||||||
|
executeCommand({
|
||||||
|
itemKey: item.key as TNonColorEditorCommands,
|
||||||
|
})
|
||||||
|
}
|
||||||
className={cn("grid size-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80", {
|
className={cn("grid size-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80", {
|
||||||
"bg-custom-background-80 text-custom-text-100": isActive,
|
"bg-custom-background-80 text-custom-text-100": isActive,
|
||||||
})}
|
})}
|
||||||
|
|
@ -56,6 +62,7 @@ ToolbarButton.displayName = "ToolbarButton";
|
||||||
const toolbarItems = TOOLBAR_ITEMS.document;
|
const toolbarItems = TOOLBAR_ITEMS.document;
|
||||||
|
|
||||||
export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||||
|
// states
|
||||||
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
|
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const updateActiveStates = useCallback(() => {
|
const updateActiveStates = useCallback(() => {
|
||||||
|
|
@ -63,7 +70,9 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||||
Object.values(toolbarItems)
|
Object.values(toolbarItems)
|
||||||
.flat()
|
.flat()
|
||||||
.forEach((item) => {
|
.forEach((item) => {
|
||||||
newActiveStates[item.key] = editorRef.isMenuItemActive(item.key);
|
newActiveStates[item.key] = editorRef.isMenuItemActive({
|
||||||
|
itemKey: item.key as TNonColorEditorCommands,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
setActiveStates(newActiveStates);
|
setActiveStates(newActiveStates);
|
||||||
}, [editorRef]);
|
}, [editorRef]);
|
||||||
|
|
@ -74,7 +83,11 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
}, [editorRef, updateActiveStates]);
|
}, [editorRef, updateActiveStates]);
|
||||||
|
|
||||||
const activeTypography = TYPOGRAPHY_ITEMS.find((item) => editorRef.isMenuItemActive(item.key));
|
const activeTypography = TYPOGRAPHY_ITEMS.find((item) =>
|
||||||
|
editorRef.isMenuItemActive({
|
||||||
|
itemKey: item.key as TNonColorEditorCommands,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
||||||
|
|
@ -94,7 +107,11 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
key={item.key}
|
key={item.key}
|
||||||
className="flex items-center justify-between gap-2"
|
className="flex items-center justify-between gap-2"
|
||||||
onClick={() => editorRef.executeMenuItemCommand(item.key)}
|
onClick={() =>
|
||||||
|
editorRef.executeMenuItemCommand({
|
||||||
|
itemKey: item.key as TNonColorEditorCommands,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<item.icon className="size-3" />
|
<item.icon className="size-3" />
|
||||||
|
|
@ -104,6 +121,20 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
))}
|
))}
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
|
<ColorDropdown
|
||||||
|
handleColorSelect={(key, color) =>
|
||||||
|
editorRef.executeMenuItemCommand({
|
||||||
|
itemKey: key,
|
||||||
|
color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isColorActive={(key, color) =>
|
||||||
|
editorRef.isMenuItemActive({
|
||||||
|
itemKey: key,
|
||||||
|
color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
{Object.keys(toolbarItems).map((key) => (
|
{Object.keys(toolbarItems).map((key) => (
|
||||||
<div key={key} className="flex items-center gap-0.5 px-2 first:pl-0 last:pr-0">
|
<div key={key} className="flex items-center gap-0.5 px-2 first:pl-0 last:pr-0">
|
||||||
{toolbarItems[key].map((item) => (
|
{toolbarItems[key].map((item) => (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CSSProperties, useState } from "react";
|
import { useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi } from "@plane/editor";
|
import { EditorRefApi } from "@plane/editor";
|
||||||
|
|
@ -23,27 +23,21 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
|
||||||
// states
|
// states
|
||||||
const [isLengthVisible, setIsLengthVisible] = useState(false);
|
const [isLengthVisible, setIsLengthVisible] = useState(false);
|
||||||
// page filters
|
// page filters
|
||||||
const { fontSize, fontStyle } = usePageFilters();
|
const { fontSize } = usePageFilters();
|
||||||
// ui
|
// ui
|
||||||
const titleClassName = cn("bg-transparent tracking-[-2%] font-semibold", {
|
const titleClassName = cn("bg-transparent tracking-[-2%] font-semibold", {
|
||||||
"text-[1.6rem] leading-[1.8rem]": fontSize === "small-font",
|
"text-[1.6rem] leading-[1.8rem]": fontSize === "small-font",
|
||||||
"text-[2rem] leading-[2.25rem]": fontSize === "large-font",
|
"text-[2rem] leading-[2.25rem]": fontSize === "large-font",
|
||||||
});
|
});
|
||||||
const titleStyle: CSSProperties = {
|
|
||||||
fontFamily: fontStyle,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<h6 className={cn(titleClassName, "break-words")} style={titleStyle}>
|
<h6 className={cn(titleClassName, "break-words")}>{title}</h6>
|
||||||
{title}
|
|
||||||
</h6>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<TextArea
|
<TextArea
|
||||||
className={cn(titleClassName, "w-full outline-none p-0 border-none resize-none rounded-none")}
|
className={cn(titleClassName, "w-full outline-none p-0 border-none resize-none rounded-none")}
|
||||||
style={titleStyle}
|
|
||||||
placeholder="Untitled"
|
placeholder="Untitled"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
|
|
|
||||||
282
web/core/components/pages/modals/export-page-modal.tsx
Normal file
282
web/core/components/pages/modals/export-page-modal.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { PageProps, pdf } from "@react-pdf/renderer";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
// plane editor
|
||||||
|
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
||||||
|
// plane ui
|
||||||
|
import { Button, CustomSelect, EModalPosition, EModalWidth, ModalCore, setToast, TOAST_TYPE } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { PDFDocument } from "@/components/editor";
|
||||||
|
// helpers
|
||||||
|
import {
|
||||||
|
replaceCustomComponentsFromHTMLContent,
|
||||||
|
replaceCustomComponentsFromMarkdownContent,
|
||||||
|
} from "@/helpers/editor.helper";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
pageTitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TExportFormats = "pdf" | "markdown";
|
||||||
|
type TPageFormats = Exclude<PageProps["size"], undefined>;
|
||||||
|
type TContentVariety = "everything" | "no-assets";
|
||||||
|
|
||||||
|
type TFormValues = {
|
||||||
|
export_format: TExportFormats;
|
||||||
|
page_format: TPageFormats;
|
||||||
|
content_variety: TContentVariety;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXPORT_FORMATS: {
|
||||||
|
key: TExportFormats;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: "pdf",
|
||||||
|
label: "PDF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "markdown",
|
||||||
|
label: "Markdown",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PAGE_FORMATS: {
|
||||||
|
key: TPageFormats;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: "A4",
|
||||||
|
label: "A4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "A3",
|
||||||
|
label: "A3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "A2",
|
||||||
|
label: "A2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "LETTER",
|
||||||
|
label: "Letter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "LEGAL",
|
||||||
|
label: "Legal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "TABLOID",
|
||||||
|
label: "Tabloid",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONTENT_VARIETY: {
|
||||||
|
key: TContentVariety;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: "everything",
|
||||||
|
label: "Everything",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "no-assets",
|
||||||
|
label: "No images",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultValues: TFormValues = {
|
||||||
|
export_format: "pdf",
|
||||||
|
page_format: "A4",
|
||||||
|
content_variety: "everything",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExportPageModal: React.FC<Props> = (props) => {
|
||||||
|
const { editorRef, isOpen, onClose, pageTitle } = props;
|
||||||
|
// states
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
// form info
|
||||||
|
const { control, reset, watch } = useForm<TFormValues>({
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
// derived values
|
||||||
|
const selectedExportFormat = watch("export_format");
|
||||||
|
const selectedPageFormat = watch("page_format");
|
||||||
|
const selectedContentVariety = watch("content_variety");
|
||||||
|
const isPDFSelected = selectedExportFormat === "pdf";
|
||||||
|
const fileName = pageTitle
|
||||||
|
?.toLowerCase()
|
||||||
|
?.replace(/[^a-z0-9-_]/g, "-")
|
||||||
|
.replace(/-+/g, "-");
|
||||||
|
// handle modal close
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
|
setTimeout(() => {
|
||||||
|
reset();
|
||||||
|
}, 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 pageContent = `<h1 class="page-title">${pageTitle}</h1>${editorRef?.getDocument().html ?? "<p></p>"}`;
|
||||||
|
const parsedPageContent = await replaceCustomComponentsFromHTMLContent({
|
||||||
|
htmlContent: pageContent,
|
||||||
|
noAssets: selectedContentVariety === "no-assets",
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await pdf(<PDFDocument content={parsedPageContent} pageFormat={selectedPageFormat} />).toBlob();
|
||||||
|
initiateDownload(blob, `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Error in exporting as a PDF: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// handle export as markdown
|
||||||
|
const handleExportAsMarkdown = async () => {
|
||||||
|
try {
|
||||||
|
const markdownContent = editorRef?.getMarkDown() ?? "";
|
||||||
|
const parsedMarkdownContent = replaceCustomComponentsFromMarkdownContent({
|
||||||
|
markdownContent,
|
||||||
|
noAssets: selectedContentVariety === "no-assets",
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob([parsedMarkdownContent], { type: "text/markdown" });
|
||||||
|
initiateDownload(blob, `${fileName}.md`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Error in exporting as markdown: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// handle export
|
||||||
|
const handleExport = async () => {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
if (selectedExportFormat === "pdf") {
|
||||||
|
await handleExportAsPDF();
|
||||||
|
}
|
||||||
|
if (selectedExportFormat === "markdown") {
|
||||||
|
await handleExportAsMarkdown();
|
||||||
|
}
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Success!",
|
||||||
|
message: "Page exported successfully.",
|
||||||
|
});
|
||||||
|
handleClose();
|
||||||
|
} catch (error) {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: "Error!",
|
||||||
|
message: "Page could not be exported. Please try again later.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.SM}>
|
||||||
|
<div>
|
||||||
|
<div className="p-5 space-y-5">
|
||||||
|
<h3 className="text-xl font-medium text-custom-text-200">Export page</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Export format</h6>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="export_format"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<CustomSelect
|
||||||
|
label={EXPORT_FORMATS.find((format) => format.key === value)?.label}
|
||||||
|
buttonClassName="border-none"
|
||||||
|
value={value}
|
||||||
|
onChange={(val: TExportFormats) => onChange(val)}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
placement="bottom-end"
|
||||||
|
>
|
||||||
|
{EXPORT_FORMATS.map((format) => (
|
||||||
|
<CustomSelect.Option key={format.key} value={format.key}>
|
||||||
|
{format.label}
|
||||||
|
</CustomSelect.Option>
|
||||||
|
))}
|
||||||
|
</CustomSelect>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Include content</h6>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="content_variety"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<CustomSelect
|
||||||
|
label={CONTENT_VARIETY.find((variety) => variety.key === value)?.label}
|
||||||
|
buttonClassName="border-none"
|
||||||
|
value={value}
|
||||||
|
onChange={(val: TContentVariety) => onChange(val)}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
placement="bottom-end"
|
||||||
|
>
|
||||||
|
{CONTENT_VARIETY.map((variety) => (
|
||||||
|
<CustomSelect.Option key={variety.key} value={variety.key}>
|
||||||
|
{variety.label}
|
||||||
|
</CustomSelect.Option>
|
||||||
|
))}
|
||||||
|
</CustomSelect>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isPDFSelected && (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Page format</h6>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="page_format"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<CustomSelect
|
||||||
|
label={PAGE_FORMATS.find((format) => format.key === value)?.label}
|
||||||
|
buttonClassName="border-none"
|
||||||
|
value={value}
|
||||||
|
onChange={(val: TPageFormats) => onChange(val)}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
placement="bottom-end"
|
||||||
|
>
|
||||||
|
{PAGE_FORMATS.map((format) => (
|
||||||
|
<CustomSelect.Option key={format.key.toString()} value={format.key}>
|
||||||
|
{format.label}
|
||||||
|
</CustomSelect.Option>
|
||||||
|
))}
|
||||||
|
</CustomSelect>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||||
|
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" loading={isExporting} onClick={handleExport}>
|
||||||
|
{isExporting ? "Exporting" : "Export"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalCore>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export * from "./create-page-modal";
|
export * from "./create-page-modal";
|
||||||
export * from "./delete-page-modal";
|
export * from "./delete-page-modal";
|
||||||
|
export * from "./export-page-modal";
|
||||||
export * from "./page-form";
|
export * from "./page-form";
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,14 @@
|
||||||
|
|
||||||
import { FC, ReactNode } from "react";
|
import { FC, ReactNode } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Check, CheckCheck, CheckCircle, Clock } from "lucide-react";
|
import { Check, CheckCircle, Clock } from "lucide-react";
|
||||||
import { TNotificationFilter } from "@plane/types";
|
import { TNotificationFilter } from "@plane/types";
|
||||||
import { ArchiveIcon, PopoverMenu, Spinner } from "@plane/ui";
|
import { ArchiveIcon, PopoverMenu } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { NotificationMenuOptionItem } from "@/components/workspace-notifications";
|
import { NotificationMenuOptionItem } from "@/components/workspace-notifications";
|
||||||
// constants
|
// constants
|
||||||
import { NOTIFICATIONS_READ } from "@/constants/event-tracker";
|
|
||||||
import { ENotificationLoader } from "@/constants/notification";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useEventTracker, useWorkspaceNotifications } from "@/hooks/store";
|
import { useWorkspaceNotifications } from "@/hooks/store";
|
||||||
|
|
||||||
type TNotificationHeaderMenuOption = {
|
|
||||||
workspaceSlug: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TPopoverMenuOptions = {
|
export type TPopoverMenuOptions = {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -27,44 +21,16 @@ export type TPopoverMenuOptions = {
|
||||||
onClick?: (() => void) | undefined;
|
onClick?: (() => void) | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NotificationHeaderMenuOption: FC<TNotificationHeaderMenuOption> = observer((props) => {
|
export const NotificationHeaderMenuOption = observer(() => {
|
||||||
const { workspaceSlug } = props;
|
|
||||||
// hooks
|
// hooks
|
||||||
const { captureEvent } = useEventTracker();
|
const { filters, updateFilters, updateBulkFilters } = useWorkspaceNotifications();
|
||||||
const { loader, filters, updateFilters, updateBulkFilters, markAllNotificationsAsRead } = useWorkspaceNotifications();
|
|
||||||
|
|
||||||
const handleFilterChange = (filterType: keyof TNotificationFilter, filterValue: boolean) =>
|
const handleFilterChange = (filterType: keyof TNotificationFilter, filterValue: boolean) =>
|
||||||
updateFilters(filterType, filterValue);
|
updateFilters(filterType, filterValue);
|
||||||
|
|
||||||
const handleBulkFilterChange = (filter: Partial<TNotificationFilter>) => updateBulkFilters(filter);
|
const handleBulkFilterChange = (filter: Partial<TNotificationFilter>) => updateBulkFilters(filter);
|
||||||
|
|
||||||
const handleMarkAllNotificationsAsRead = async () => {
|
|
||||||
// NOTE: We are using loader to prevent continues request when we are making all the notification to read
|
|
||||||
if (loader) return;
|
|
||||||
try {
|
|
||||||
await markAllNotificationsAsRead(workspaceSlug);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const popoverMenuOptions: TPopoverMenuOptions[] = [
|
const popoverMenuOptions: TPopoverMenuOptions[] = [
|
||||||
{
|
|
||||||
key: "menu-mark-all-read",
|
|
||||||
type: "menu-item",
|
|
||||||
label: "Mark all as read",
|
|
||||||
isActive: true,
|
|
||||||
prependIcon: <CheckCheck className="h-3 w-3" />,
|
|
||||||
appendIcon: loader === ENotificationLoader.MARK_ALL_AS_READY ? <Spinner height="14px" width="14px" /> : undefined,
|
|
||||||
onClick: () => {
|
|
||||||
captureEvent(NOTIFICATIONS_READ);
|
|
||||||
handleMarkAllNotificationsAsRead();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "menu-divider",
|
|
||||||
type: "divider",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "menu-unread",
|
key: "menu-unread",
|
||||||
type: "menu-item",
|
type: "menu-item",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { RefreshCw } from "lucide-react";
|
import { CheckCheck, RefreshCw } from "lucide-react";
|
||||||
import { Tooltip } from "@plane/ui";
|
import { Spinner, Tooltip } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { NotificationFilter, NotificationHeaderMenuOption } from "@/components/workspace-notifications";
|
import { NotificationFilter, NotificationHeaderMenuOption } from "@/components/workspace-notifications";
|
||||||
// constants
|
// constants
|
||||||
|
import { NOTIFICATIONS_READ } from "@/constants/event-tracker";
|
||||||
import { ENotificationLoader, ENotificationQueryParamType } from "@/constants/notification";
|
import { ENotificationLoader, ENotificationQueryParamType } from "@/constants/notification";
|
||||||
// hooks
|
// hooks
|
||||||
import { useWorkspaceNotifications } from "@/hooks/store";
|
import { useEventTracker, useWorkspaceNotifications } from "@/hooks/store";
|
||||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||||
|
|
||||||
type TNotificationSidebarHeaderOptions = {
|
type TNotificationSidebarHeaderOptions = {
|
||||||
|
|
@ -18,7 +19,8 @@ export const NotificationSidebarHeaderOptions: FC<TNotificationSidebarHeaderOpti
|
||||||
const { workspaceSlug } = props;
|
const { workspaceSlug } = props;
|
||||||
// hooks
|
// hooks
|
||||||
const { isMobile } = usePlatformOS();
|
const { isMobile } = usePlatformOS();
|
||||||
const { loader, getNotifications } = useWorkspaceNotifications();
|
const { loader, getNotifications, markAllNotificationsAsRead } = useWorkspaceNotifications();
|
||||||
|
const { captureEvent } = useEventTracker();
|
||||||
|
|
||||||
const refreshNotifications = async () => {
|
const refreshNotifications = async () => {
|
||||||
if (loader) return;
|
if (loader) return;
|
||||||
|
|
@ -29,8 +31,35 @@ export const NotificationSidebarHeaderOptions: FC<TNotificationSidebarHeaderOpti
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMarkAllNotificationsAsRead = async () => {
|
||||||
|
// NOTE: We are using loader to prevent continues request when we are making all the notification to read
|
||||||
|
if (loader) return;
|
||||||
|
try {
|
||||||
|
await markAllNotificationsAsRead(workspaceSlug);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex justify-center items-center gap-2 text-sm">
|
<div className="relative flex justify-center items-center gap-2 text-sm">
|
||||||
|
{/* mark all notifications as read*/}
|
||||||
|
<Tooltip tooltipContent="Mark all as read" isMobile={isMobile} position="bottom">
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 w-5 h-5 flex justify-center items-center overflow-hidden cursor-pointer transition-all hover:bg-custom-background-80 rounded-sm"
|
||||||
|
onClick={() => {
|
||||||
|
captureEvent(NOTIFICATIONS_READ);
|
||||||
|
handleMarkAllNotificationsAsRead();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loader === ENotificationLoader.MARK_ALL_AS_READY ? (
|
||||||
|
<Spinner height="14px" width="14px" />
|
||||||
|
) : (
|
||||||
|
<CheckCheck className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* refetch current notifications */}
|
{/* refetch current notifications */}
|
||||||
<Tooltip tooltipContent="Refresh" isMobile={isMobile} position="bottom">
|
<Tooltip tooltipContent="Refresh" isMobile={isMobile} position="bottom">
|
||||||
<div
|
<div
|
||||||
|
|
@ -45,7 +74,7 @@ export const NotificationSidebarHeaderOptions: FC<TNotificationSidebarHeaderOpti
|
||||||
<NotificationFilter />
|
<NotificationFilter />
|
||||||
|
|
||||||
{/* notification menu options */}
|
{/* notification menu options */}
|
||||||
<NotificationHeaderMenuOption workspaceSlug={workspaceSlug} />
|
<NotificationHeaderMenuOption />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,12 @@ export const CYCLE_VIEW_LAYOUTS: {
|
||||||
{
|
{
|
||||||
key: "board",
|
key: "board",
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
title: "Grid layout",
|
title: "Gallery layout",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "gantt",
|
key: "gantt",
|
||||||
icon: GanttChartSquare,
|
icon: GanttChartSquare,
|
||||||
title: "Gantt layout",
|
title: "Timeline layout",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Styles, StyleSheet } from "@react-pdf/renderer";
|
||||||
import {
|
import {
|
||||||
Bold,
|
Bold,
|
||||||
CaseSensitive,
|
CaseSensitive,
|
||||||
|
|
@ -23,6 +24,8 @@ import {
|
||||||
import { TEditorCommands, TEditorFontStyle } from "@plane/editor";
|
import { TEditorCommands, TEditorFontStyle } from "@plane/editor";
|
||||||
// ui
|
// ui
|
||||||
import { MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/ui";
|
import { MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { convertRemToPixel } from "@/helpers/common.helper";
|
||||||
|
|
||||||
type TEditorTypes = "lite" | "document";
|
type TEditorTypes = "lite" | "document";
|
||||||
|
|
||||||
|
|
@ -131,3 +134,179 @@ export const EDITOR_FONT_STYLES: {
|
||||||
icon: MonospaceIcon,
|
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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -156,24 +156,24 @@ export const ISSUE_EXTRA_OPTIONS: {
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ISSUE_LAYOUT_MAP = {
|
export const ISSUE_LAYOUT_MAP = {
|
||||||
[EIssueLayoutTypes.LIST]: { key: EIssueLayoutTypes.LIST, title: "List Layout", label: "List", icon: List },
|
[EIssueLayoutTypes.LIST]: { key: EIssueLayoutTypes.LIST, title: "List layout", label: "List", icon: List },
|
||||||
[EIssueLayoutTypes.KANBAN]: { key: EIssueLayoutTypes.KANBAN, title: "Kanban Layout", label: "Kanban", icon: Kanban },
|
[EIssueLayoutTypes.KANBAN]: { key: EIssueLayoutTypes.KANBAN, title: "Board layout", label: "Board", icon: Kanban },
|
||||||
[EIssueLayoutTypes.CALENDAR]: {
|
[EIssueLayoutTypes.CALENDAR]: {
|
||||||
key: EIssueLayoutTypes.CALENDAR,
|
key: EIssueLayoutTypes.CALENDAR,
|
||||||
title: "Calendar Layout",
|
title: "Calendar layout",
|
||||||
label: "Calendar",
|
label: "Calendar",
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
},
|
},
|
||||||
[EIssueLayoutTypes.SPREADSHEET]: {
|
[EIssueLayoutTypes.SPREADSHEET]: {
|
||||||
key: EIssueLayoutTypes.SPREADSHEET,
|
key: EIssueLayoutTypes.SPREADSHEET,
|
||||||
title: "Spreadsheet Layout",
|
title: "Table layout",
|
||||||
label: "Spreadsheet",
|
label: "Table",
|
||||||
icon: Sheet,
|
icon: Sheet,
|
||||||
},
|
},
|
||||||
[EIssueLayoutTypes.GANTT]: {
|
[EIssueLayoutTypes.GANTT]: {
|
||||||
key: EIssueLayoutTypes.GANTT,
|
key: EIssueLayoutTypes.GANTT,
|
||||||
title: "Gantt Chart Layout",
|
title: "Timeline layout",
|
||||||
label: "Gantt",
|
label: "Timeline",
|
||||||
icon: GanttChartSquare,
|
icon: GanttChartSquare,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -62,12 +62,12 @@ export const MODULE_VIEW_LAYOUTS: { key: TModuleLayoutOptions; icon: any; title:
|
||||||
{
|
{
|
||||||
key: "board",
|
key: "board",
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
title: "Grid layout",
|
title: "Gallery layout",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "gantt",
|
key: "gantt",
|
||||||
icon: GanttChartSquare,
|
icon: GanttChartSquare,
|
||||||
title: "Gantt layout",
|
title: "Timeline layout",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,6 @@ export class CycleStore implements ICycleStore {
|
||||||
fetchActiveCycleProgress: action,
|
fetchActiveCycleProgress: action,
|
||||||
fetchActiveCycleAnalytics: action,
|
fetchActiveCycleAnalytics: action,
|
||||||
fetchCycleDetails: action,
|
fetchCycleDetails: action,
|
||||||
createCycle: action,
|
|
||||||
updateCycleDetails: action,
|
updateCycleDetails: action,
|
||||||
deleteCycle: action,
|
deleteCycle: action,
|
||||||
addCycleToFavorites: action,
|
addCycleToFavorites: action,
|
||||||
|
|
@ -617,13 +616,15 @@ export class CycleStore implements ICycleStore {
|
||||||
* @param data
|
* @param data
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
createCycle = async (workspaceSlug: string, projectId: string, data: Partial<ICycle>) =>
|
createCycle = action(
|
||||||
await this.cycleService.createCycle(workspaceSlug, projectId, data).then((response) => {
|
async (workspaceSlug: string, projectId: string, data: Partial<ICycle>) =>
|
||||||
runInAction(() => {
|
await this.cycleService.createCycle(workspaceSlug, projectId, data).then((response) => {
|
||||||
set(this.cycleMap, [response.id], response);
|
runInAction(() => {
|
||||||
});
|
set(this.cycleMap, [response.id], response);
|
||||||
return response;
|
});
|
||||||
});
|
return response;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description updates cycle details
|
* @description updates cycle details
|
||||||
|
|
|
||||||
|
|
@ -37,3 +37,5 @@ export const debounce = (func: any, wait: number, immediate: boolean = false) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||||
|
|
||||||
|
export const convertRemToPixel = (rem: number): number => rem * 0.9 * 16;
|
||||||
|
|
|
||||||
157
web/helpers/editor.helper.ts
Normal file
157
web/helpers/editor.helper.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
// helpers
|
||||||
|
import { getBase64Image } from "@/helpers/file.helper";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description function to replace all the custom components from the html component to make it pdf compatible
|
||||||
|
* @param props
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export const replaceCustomComponentsFromHTMLContent = async (props: {
|
||||||
|
htmlContent: string;
|
||||||
|
noAssets?: boolean;
|
||||||
|
}): Promise<string> => {
|
||||||
|
const { htmlContent, noAssets = false } = props;
|
||||||
|
// create a DOM parser
|
||||||
|
const parser = new DOMParser();
|
||||||
|
// parse the HTML string into a DOM document
|
||||||
|
const doc = parser.parseFromString(htmlContent, "text/html");
|
||||||
|
// replace all mention-component elements
|
||||||
|
const mentionComponents = doc.querySelectorAll("mention-component");
|
||||||
|
mentionComponents.forEach((component) => {
|
||||||
|
// get the user label from the component (or use any other attribute)
|
||||||
|
const label = component.getAttribute("label") || "user";
|
||||||
|
// create a span element to replace the mention-component
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("data-node-type", "mention-block");
|
||||||
|
span.textContent = `@${label}`;
|
||||||
|
// replace the mention-component with the anchor element
|
||||||
|
component.replaceWith(span);
|
||||||
|
});
|
||||||
|
// handle code inside pre elements
|
||||||
|
const preElements = doc.querySelectorAll("pre");
|
||||||
|
preElements.forEach((preElement) => {
|
||||||
|
const codeElement = preElement.querySelector("code");
|
||||||
|
if (codeElement) {
|
||||||
|
// create a div element with the required attributes for code blocks
|
||||||
|
const div = doc.createElement("div");
|
||||||
|
div.setAttribute("data-node-type", "code-block");
|
||||||
|
div.setAttribute("class", "courier");
|
||||||
|
// transfer the content from the code block
|
||||||
|
div.innerHTML = codeElement.innerHTML.replace(/\n/g, "<br>") || "";
|
||||||
|
// replace the pre element with the new div
|
||||||
|
preElement.replaceWith(div);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// handle inline code elements (not inside pre tags)
|
||||||
|
const inlineCodeElements = doc.querySelectorAll("code");
|
||||||
|
inlineCodeElements.forEach((codeElement) => {
|
||||||
|
// check if the code element is inside a pre element
|
||||||
|
if (!codeElement.closest("pre")) {
|
||||||
|
// create a span element with the required attributes for inline code blocks
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("data-node-type", "inline-code-block");
|
||||||
|
span.setAttribute("class", "courier-bold");
|
||||||
|
// transfer the code content
|
||||||
|
span.textContent = codeElement.textContent || "";
|
||||||
|
// replace the standalone code element with the new span
|
||||||
|
codeElement.replaceWith(span);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// handle image-component elements
|
||||||
|
const imageComponents = doc.querySelectorAll("image-component");
|
||||||
|
if (noAssets) {
|
||||||
|
// if no assets is enabled, remove the image component elements
|
||||||
|
imageComponents.forEach((component) => component.remove());
|
||||||
|
// remove default img elements
|
||||||
|
const imageElements = doc.querySelectorAll("img");
|
||||||
|
imageElements.forEach((img) => img.remove());
|
||||||
|
} else {
|
||||||
|
// if no assets is not enabled, replace the image component elements with img elements
|
||||||
|
imageComponents.forEach((component) => {
|
||||||
|
// get the image src from the component
|
||||||
|
const src = component.getAttribute("src") ?? "";
|
||||||
|
const height = component.getAttribute("height") ?? "";
|
||||||
|
const width = component.getAttribute("width") ?? "";
|
||||||
|
// create an img element to replace the image-component
|
||||||
|
const img = doc.createElement("img");
|
||||||
|
img.src = src;
|
||||||
|
img.style.height = height;
|
||||||
|
img.style.width = width;
|
||||||
|
// replace the image-component with the img element
|
||||||
|
component.replaceWith(img);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// convert all images to base64
|
||||||
|
const imgElements = doc.querySelectorAll("img");
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(imgElements).map(async (img) => {
|
||||||
|
// get the image src from the img element
|
||||||
|
const src = img.getAttribute("src");
|
||||||
|
if (src) {
|
||||||
|
try {
|
||||||
|
const base64Image = await getBase64Image(src);
|
||||||
|
img.src = base64Image;
|
||||||
|
} catch (error) {
|
||||||
|
// log the error if the image conversion fails
|
||||||
|
console.error("Failed to convert image to base64:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// replace all checkbox elements
|
||||||
|
const checkboxComponents = doc.querySelectorAll("input[type='checkbox']");
|
||||||
|
checkboxComponents.forEach((component) => {
|
||||||
|
// get the checked status from the element
|
||||||
|
const checked = component.getAttribute("checked");
|
||||||
|
// create a div element to replace the input element
|
||||||
|
const div = doc.createElement("div");
|
||||||
|
div.classList.value = "input-checkbox";
|
||||||
|
// add the checked class if the checkbox is checked
|
||||||
|
if (checked === "checked" || checked === "true") div.classList.add("checked");
|
||||||
|
// replace the input element with the div element
|
||||||
|
component.replaceWith(div);
|
||||||
|
});
|
||||||
|
// remove all issue-embed-component elements
|
||||||
|
const issueEmbedComponents = doc.querySelectorAll("issue-embed-component");
|
||||||
|
issueEmbedComponents.forEach((component) => component.remove());
|
||||||
|
// serialize the document back into a string
|
||||||
|
let serializedDoc = doc.body.innerHTML;
|
||||||
|
// remove null colors from table elements
|
||||||
|
serializedDoc = serializedDoc.replace(/background-color: null/g, "").replace(/color: null/g, "");
|
||||||
|
return serializedDoc;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description function to replace all the custom components from the markdown content
|
||||||
|
* @param props
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export const replaceCustomComponentsFromMarkdownContent = (props: {
|
||||||
|
markdownContent: string;
|
||||||
|
noAssets?: boolean;
|
||||||
|
}): string => {
|
||||||
|
const { markdownContent, noAssets = false } = props;
|
||||||
|
let parsedMarkdownContent = markdownContent;
|
||||||
|
// replace the matched mention components with [label](redirect_uri)
|
||||||
|
const mentionRegex = /<mention-component[^>]*label="([^"]+)"[^>]*redirect_uri="([^"]+)"[^>]*><\/mention-component>/g;
|
||||||
|
const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||||
|
|
||||||
|
parsedMarkdownContent = parsedMarkdownContent.replace(
|
||||||
|
mentionRegex,
|
||||||
|
(_match, label, redirectUri) => `[${label}](${originUrl}/${redirectUri})`
|
||||||
|
);
|
||||||
|
// replace the matched image components with <img src={src} >
|
||||||
|
const imageComponentRegex = /<image-component[^>]*src="([^"]+)"[^>]*>[^]*<\/image-component>/g;
|
||||||
|
const imgTagRegex = /<img[^>]*src="([^"]+)"[^>]*\/?>/g;
|
||||||
|
if (noAssets) {
|
||||||
|
// remove all image components
|
||||||
|
parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, "").replace(imgTagRegex, "");
|
||||||
|
} else {
|
||||||
|
// replace the matched image components with <img src={src} >
|
||||||
|
parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, (_match, src) => `<img src="${src}" >`);
|
||||||
|
}
|
||||||
|
// remove all issue-embed components
|
||||||
|
const issueEmbedRegex = /<issue-embed-component[^>]*>[^]*<\/issue-embed-component>/g;
|
||||||
|
parsedMarkdownContent = parsedMarkdownContent.replace(issueEmbedRegex, "");
|
||||||
|
return parsedMarkdownContent;
|
||||||
|
};
|
||||||
42
web/helpers/file.helper.ts
Normal file
42
web/helpers/file.helper.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* @description encode image via URL to base64
|
||||||
|
* @param {string} url
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const getBase64Image = async (url: string): Promise<string> => {
|
||||||
|
if (!url || typeof url !== "string") {
|
||||||
|
throw new Error("Invalid URL provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create a URL object to validate the URL
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("Invalid URL format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
// check if the response is OK
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch image: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (reader.result) {
|
||||||
|
resolve(reader.result as string);
|
||||||
|
} else {
|
||||||
|
reject(new Error("Failed to convert image to base64."));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(new Error("Failed to read the image file."));
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"@plane/types": "*",
|
"@plane/types": "*",
|
||||||
"@plane/ui": "*",
|
"@plane/ui": "*",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@react-pdf/renderer": "^3.4.5",
|
||||||
"@sentry/nextjs": "^8.32.0",
|
"@sentry/nextjs": "^8.32.0",
|
||||||
"@sqlite.org/sqlite-wasm": "^3.46.0-build2",
|
"@sqlite.org/sqlite-wasm": "^3.46.0-build2",
|
||||||
"axios": "^1.7.4",
|
"axios": "^1.7.4",
|
||||||
|
|
@ -57,6 +58,7 @@
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-hook-form": "7.51.5",
|
"react-hook-form": "7.51.5",
|
||||||
"react-markdown": "^8.0.7",
|
"react-markdown": "^8.0.7",
|
||||||
|
"react-pdf-html": "^2.1.2",
|
||||||
"react-popper": "^2.3.0",
|
"react-popper": "^2.3.0",
|
||||||
"sharp": "^0.32.1",
|
"sharp": "^0.32.1",
|
||||||
"smooth-scroll-into-view-if-needed": "^2.0.2",
|
"smooth-scroll-into-view-if-needed": "^2.0.2",
|
||||||
|
|
|
||||||
BIN
web/public/fonts/inter/bold-italic.ttf
Normal file
BIN
web/public/fonts/inter/bold-italic.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/bold.ttf
Normal file
BIN
web/public/fonts/inter/bold.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/heavy-italic.ttf
Normal file
BIN
web/public/fonts/inter/heavy-italic.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/heavy.ttf
Normal file
BIN
web/public/fonts/inter/heavy.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/light-italic.ttf
Normal file
BIN
web/public/fonts/inter/light-italic.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/light.ttf
Normal file
BIN
web/public/fonts/inter/light.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/medium-italic.ttf
Normal file
BIN
web/public/fonts/inter/medium-italic.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/medium.ttf
Normal file
BIN
web/public/fonts/inter/medium.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/regular-italic.ttf
Normal file
BIN
web/public/fonts/inter/regular-italic.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/regular.ttf
Normal file
BIN
web/public/fonts/inter/regular.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/semibold-italic.ttf
Normal file
BIN
web/public/fonts/inter/semibold-italic.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/semibold.ttf
Normal file
BIN
web/public/fonts/inter/semibold.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/thin-italic.ttf
Normal file
BIN
web/public/fonts/inter/thin-italic.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/thin.ttf
Normal file
BIN
web/public/fonts/inter/thin.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/ultrabold-italic.ttf
Normal file
BIN
web/public/fonts/inter/ultrabold-italic.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/ultrabold.ttf
Normal file
BIN
web/public/fonts/inter/ultrabold.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/ultralight-italic.ttf
Normal file
BIN
web/public/fonts/inter/ultralight-italic.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/ultralight.ttf
Normal file
BIN
web/public/fonts/inter/ultralight.ttf
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue