Compare commits

...

19 Commits

Author SHA1 Message Date
sharma01ketan
47251c0bbc fix worklog button render logic 2024-10-10 14:24:54 +05:30
sharma01ketan
33a5d0a5d3 Merge branch 'preview' of https://github.com/makeplane/plane into preview 2024-10-10 14:21:16 +05:30
Akshita Goyal
6d78418e79 fix: create cycle function (#5775)
* fix: create cycle function

* chore: draft and cycle version changes

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-10-08 20:01:15 +05:30
Prateek Shourya
6e52f1b434 [WEB-2601] improvement: add click to copy issue identifier on peek-overview and issue detail page. (#5760) 2024-10-08 18:43:13 +05:30
Aaryan Khandelwal
c3c1ea727d [WEB-2494] feat: text color and highlight options for all editors (#5653)
* feat: add text color and highlight options to pages

* style: rich text editor floating toolbar

* chore: remove unused function

* refactor: slash command components

* chore: move default text and background options to the top

* fix: sections filtering logic
2024-10-08 18:42:47 +05:30
Aaryan Khandelwal
5afc576dec refactor: export components (#5773) 2024-10-08 18:41:08 +05:30
Ketan Sharma
50ae32f3e1 [WEB-2555] fix: add "mark all as read" in the notifications header (#5770)
* move mark all as read to header and remove it from dropdown

* made recommended changes
2024-10-08 17:13:35 +05:30
Akshita Goyal
0451593057 fix: spreadsheet flicker issue (#5769) 2024-10-08 17:10:16 +05:30
M. Palanikannan
be092ac99f [WEB-2603] fix: remove validation of roles from the live server (#5761)
* fix: remove validation of roles from the live server

* chore: remove the service

* fix: remove all validation of authorization

* fix: props updated
2024-10-08 16:55:26 +05:30
Anmol Singh Bhatia
f73a603226 [WEB-2380] chore: cycle sidebar refactor (#5759)
* chore: cycle sidebar refactor

* chore: code splitting

* chore: code refactor

* chore: code refactor
2024-10-08 16:54:44 +05:30
Aaryan Khandelwal
b27249486a [PE-45] feat: page export as PDF & Markdown (#5705)
* feat: export page as pdf and markdown

* chore: add image conversion logic
2024-10-08 16:54:02 +05:30
Anmol Singh Bhatia
20c9e232e7 chore: IssueParentDetail added to issue peekoverview (#5751) 2024-10-08 16:53:07 +05:30
Bavisetti Narayan
d168fd4bfa [WEB-2388] fix: workspace draft issues migration (#5749)
* fix: workspace draft issues

* chore: changed the timezone key

* chore: migration changes
2024-10-08 16:51:57 +05:30
M. Palanikannan
7317975b04 fix: show the full screen toolbar in read only instances as well (#5746) 2024-10-08 16:50:32 +05:30
Aaryan Khandelwal
39195d0d89 [WEB-2532] fix: custom theme mutation logic (#5685)
* fix: custom theme mutation logic

* chore: update querySelector element
2024-10-08 16:47:16 +05:30
Mihir
6bf0e27b66 [WEB-2433] chore-Update name of the Layout (#5661)
* Updated layout names

* Corrected character casing for titles
2024-10-08 16:44:50 +05:30
M. Palanikannan
5fb7e98b7c fix: drag handle scrolling fixed (#5619)
* fix: drag handle scrolling fixed

* fix: closest scrollable parent found and scrolled

* fix: removed overflow auto from framerenderer

* fix: make dragging dynamic and smoother
2024-10-08 16:44:05 +05:30
Prateek Shourya
328b6961a2 [WEB-2605] fix: update URL regex pattern to allow complex links. (#5767) 2024-10-08 13:20:27 +05:30
Bavisetti Narayan
39eabc28b5 chore: only admin can changed the project settings (#5766) 2024-10-07 20:07:24 +05:30
102 changed files with 5699 additions and 1356 deletions

View File

@@ -207,8 +207,7 @@ class CycleAPIEndpoint(BaseAPIView):
# Incomplete Cycles
if cycle_view == "incomplete":
queryset = queryset.filter(
Q(end_date__gte=timezone.now().date())
| Q(end_date__isnull=True),
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True),
)
return self.paginate(
request=request,
@@ -309,10 +308,7 @@ class CycleAPIEndpoint(BaseAPIView):
request_data = request.data
if (
cycle.end_date is not None
and cycle.end_date < timezone.now().date()
):
if cycle.end_date is not None and cycle.end_date < timezone.now():
if "sort_order" in request_data:
# Can only change sort order
request_data = {
@@ -537,7 +533,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
if cycle.end_date >= timezone.now().date():
if cycle.end_date >= timezone.now():
return Response(
{"error": "Only completed cycles can be archived"},
status=status.HTTP_400_BAD_REQUEST,
@@ -1146,7 +1142,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
if (
new_cycle.end_date is not None
and new_cycle.end_date < timezone.now().date()
and new_cycle.end_date < timezone.now()
):
return Response(
{

View File

@@ -124,3 +124,9 @@ from .webhook import WebhookSerializer, WebhookLogSerializer
from .dashboard import DashboardSerializer, WidgetSerializer
from .favorite import UserFavoriteSerializer
from .draft import (
DraftIssueCreateSerializer,
DraftIssueSerializer,
DraftIssueDetailSerializer,
)

View 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

View File

@@ -11,7 +11,6 @@ from plane.app.views import (
IssueActivityEndpoint,
IssueArchiveViewSet,
IssueCommentViewSet,
IssueDraftViewSet,
IssueListEndpoint,
IssueReactionViewSet,
IssueRelationViewSet,
@@ -290,28 +289,6 @@ urlpatterns = [
name="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(
"workspaces/<str:slug>/projects/<uuid:project_id>/deleted-issues/",
DeletedIssuesListViewSet.as_view(),

View File

@@ -27,6 +27,7 @@ from plane.app.views import (
WorkspaceCyclesEndpoint,
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
WorkspaceDraftIssueViewSet,
)
@@ -254,4 +255,30 @@ urlpatterns = [
WorkspaceFavoriteGroupEndpoint.as_view(),
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",
),
]

View File

@@ -40,6 +40,8 @@ from .workspace.base import (
ExportWorkspaceUserActivityEndpoint,
)
from .workspace.draft import WorkspaceDraftIssueViewSet
from .workspace.favorite import (
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
@@ -133,8 +135,6 @@ from .issue.comment import (
CommentReactionViewSet,
)
from .issue.draft import IssueDraftViewSet
from .issue.label import (
LabelViewSet,
BulkCreateIssueLabelsEndpoint,

View File

@@ -604,7 +604,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
if cycle.end_date >= timezone.now().date():
if cycle.end_date >= timezone.now():
return Response(
{"error": "Only completed cycles can be archived"},
status=status.HTTP_400_BAD_REQUEST,

View File

@@ -187,6 +187,7 @@ class CycleViewSet(BaseViewSet):
"completed_issues",
"assignee_ids",
"status",
"version",
"created_by",
)
@@ -216,6 +217,7 @@ class CycleViewSet(BaseViewSet):
"completed_issues",
"assignee_ids",
"status",
"version",
"created_by",
)
return Response(data, status=status.HTTP_200_OK)
@@ -255,6 +257,7 @@ class CycleViewSet(BaseViewSet):
"external_id",
"progress_snapshot",
"logo_props",
"version",
# meta fields
"is_favorite",
"total_issues",
@@ -306,10 +309,7 @@ class CycleViewSet(BaseViewSet):
request_data = request.data
if (
cycle.end_date is not None
and cycle.end_date < timezone.now().date()
):
if cycle.end_date is not None and cycle.end_date < timezone.now():
if "sort_order" in request_data:
# Can only change sort order for a completed cycle``
request_data = {
@@ -347,6 +347,7 @@ class CycleViewSet(BaseViewSet):
"external_id",
"progress_snapshot",
"logo_props",
"version",
# meta fields
"is_favorite",
"total_issues",
@@ -412,6 +413,7 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot",
"sub_issues",
"logo_props",
"version",
# meta fields
"is_favorite",
"total_issues",
@@ -925,7 +927,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
if (
new_cycle.end_date is not None
and new_cycle.end_date < timezone.now().date()
and new_cycle.end_date < timezone.now()
):
return Response(
{
@@ -1148,6 +1150,7 @@ class CycleProgressEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
class CycleAnalyticsEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])

View File

@@ -248,7 +248,7 @@ class CycleIssueViewSet(BaseViewSet):
if (
cycle.end_date is not None
and cycle.end_date < timezone.now().date()
and cycle.end_date < timezone.now()
):
return Response(
{

View File

@@ -40,8 +40,6 @@ from plane.db.models import (
IssueLink,
IssueRelation,
Project,
ProjectMember,
User,
Widget,
WorkspaceMember,
)

View File

@@ -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)

View 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)

View File

@@ -504,7 +504,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
upcoming_cycles = CycleIssue.objects.filter(
workspace__slug=slug,
cycle__start_date__gt=timezone.now().date(),
cycle__start_date__gt=timezone.now(),
issue__assignees__in=[
user_id,
],
@@ -512,8 +512,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
present_cycle = CycleIssue.objects.filter(
workspace__slug=slug,
cycle__start_date__lt=timezone.now().date(),
cycle__end_date__gt=timezone.now().date(),
cycle__start_date__lt=timezone.now(),
cycle__end_date__gt=timezone.now(),
issue__assignees__in=[
user_id,
],

View File

@@ -42,14 +42,12 @@ def archive_old_issues():
),
Q(issue_cycle__isnull=True)
| (
Q(issue_cycle__cycle__end_date__lt=timezone.now().date())
Q(issue_cycle__cycle__end_date__lt=timezone.now())
& Q(issue_cycle__isnull=False)
),
Q(issue_module__isnull=True)
| (
Q(
issue_module__module__target_date__lt=timezone.now().date()
)
Q(issue_module__module__target_date__lt=timezone.now())
& Q(issue_module__isnull=False)
),
).filter(
@@ -122,14 +120,12 @@ def close_old_issues():
),
Q(issue_cycle__isnull=True)
| (
Q(issue_cycle__cycle__end_date__lt=timezone.now().date())
Q(issue_cycle__cycle__end_date__lt=timezone.now())
& Q(issue_cycle__isnull=False)
),
Q(issue_module__isnull=True)
| (
Q(
issue_module__module__target_date__lt=timezone.now().date()
)
Q(issue_module__module__target_date__lt=timezone.now())
& Q(issue_module__isnull=False)
),
).filter(

View File

@@ -5,6 +5,7 @@ from .base import BaseModel
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
from .dashboard import Dashboard, DashboardWidget, Widget
from .deploy_board import DeployBoard
from .draft import DraftIssue, DraftIssueAssignee, DraftIssueLabel, DraftIssueModule, DraftIssueCycle
from .estimate import Estimate, EstimatePoint
from .exporter import ExporterHistory
from .importer import Importer

View File

@@ -1,3 +1,6 @@
# Python imports
import pytz
# Django imports
from django.conf import settings
from django.db import models
@@ -55,10 +58,12 @@ class Cycle(ProjectBaseModel):
description = models.TextField(
verbose_name="Cycle Description", blank=True
)
start_date = models.DateField(
start_date = models.DateTimeField(
verbose_name="Start Date", blank=True, null=True
)
end_date = models.DateField(verbose_name="End Date", blank=True, null=True)
end_date = models.DateTimeField(
verbose_name="End Date", blank=True, null=True
)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
@@ -71,6 +76,12 @@ class Cycle(ProjectBaseModel):
progress_snapshot = models.JSONField(default=dict)
archived_at = models.DateTimeField(null=True)
logo_props = models.JSONField(default=dict)
# timezone
TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
timezone = models.CharField(
max_length=255, default="UTC", choices=TIMEZONE_CHOICES
)
version = models.IntegerField(default=1)
class Meta:
verbose_name = "Cycle"

View 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}"

View File

@@ -1,4 +1,5 @@
# Python imports
import pytz
from uuid import uuid4
# Django imports
@@ -7,7 +8,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Q
# Modeule imports
# Module imports
from plane.db.mixins import AuditModel
# Module imports
@@ -119,6 +120,11 @@ class Project(BaseModel):
related_name="default_state",
)
archived_at = models.DateTimeField(null=True)
# timezone
TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
timezone = models.CharField(
max_length=255, default="UTC", choices=TIMEZONE_CHOICES
)
def __str__(self):
"""Return name of the project"""

View File

@@ -163,7 +163,7 @@ def burndown_plot(
if queryset.end_date and queryset.start_date:
# Get all dates between the two dates
date_range = [
queryset.start_date + timedelta(days=x)
(queryset.start_date + timedelta(days=x)).date()
for x in range(
(queryset.end_date - queryset.start_date).days + 1
)
@@ -203,7 +203,7 @@ def burndown_plot(
if module_id:
# Get all dates between the two dates
date_range = [
queryset.start_date + timedelta(days=x)
(queryset.start_date + timedelta(days=x)).date()
for x in range(
(queryset.target_date - queryset.start_date).days + 1
)

View File

@@ -42,13 +42,15 @@
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
"@tiptap/extension-collaboration": "^2.3.2",
"@tiptap/extension-color": "^2.7.1",
"@tiptap/extension-highlight": "^2.7.1",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-list-item": "^2.1.13",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/extension-placeholder": "^2.3.0",
"@tiptap/extension-task-item": "^2.1.13",
"@tiptap/extension-task-list": "^2.1.13",
"@tiptap/extension-text-style": "^2.1.13",
"@tiptap/extension-text-style": "^2.7.1",
"@tiptap/extension-underline": "^2.1.13",
"@tiptap/pm": "^2.1.13",
"@tiptap/react": "^2.1.13",

View File

@@ -1,6 +1,6 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { Extensions } from "@tiptap/core";
import { SlashCommand } from "@/extensions";
import { SlashCommands } from "@/extensions";
// plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types";
// types
@@ -14,7 +14,7 @@ type Props = {
};
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const extensions: Extensions = [SlashCommand()];
const extensions: Extensions = [SlashCommands()];
return extensions;
};

View File

@@ -3,7 +3,7 @@ import { forwardRef, useCallback } from "react";
import { EditorWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus";
// extensions
import { SideMenuExtension, SlashCommand } from "@/extensions";
import { SideMenuExtension, SlashCommands } from "@/extensions";
// types
import { EditorRefApi, IRichTextEditor } from "@/types";
@@ -11,7 +11,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
const { dragDropEnabled } = props;
const getExtensions = useCallback(() => {
const extensions = [SlashCommand()];
const extensions = [SlashCommands()];
extensions.push(
SideMenuExtension({

View File

@@ -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>
);
};

View File

@@ -1,3 +1,4 @@
export * from "./color-selector";
export * from "./link-selector";
export * from "./node-selector";
export * from "./root";

View File

@@ -1,6 +1,6 @@
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
import { Editor } from "@tiptap/core";
import { Check, Trash } from "lucide-react";
import { Check, Link, Trash } from "lucide-react";
// helpers
import { cn, isValidHttpUrl } from "@/helpers/common";
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
@@ -11,7 +11,9 @@ type Props = {
setIsOpen: Dispatch<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 onLinkSubmit = useCallback(() => {
@@ -28,26 +30,23 @@ export const BubbleMenuLinkSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
});
return (
<div className="relative">
<div className="relative h-full">
<button
type="button"
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",
{ "bg-custom-background-100": isOpen }
"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-80": isOpen,
"text-custom-text-100": editor.isActive("link"),
}
)}
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
>
<p className="text-base"></p>
<p
className={cn("underline underline-offset-4", {
"text-custom-text-100": editor.isActive("link"),
})}
>
Link
</p>
<span>Link</span>
<Link className="flex-shrink-0 size-3" />
</button>
{isOpen && (
<div

View File

@@ -15,7 +15,7 @@ import {
HeadingFourItem,
HeadingFiveItem,
HeadingSixItem,
BubbleMenuItem,
EditorMenuItem,
} from "@/components/menus";
// helpers
import { cn } from "@/helpers/common";
@@ -26,8 +26,10 @@ type Props = {
setIsOpen: Dispatch<SetStateAction<boolean>>;
};
export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
const items: BubbleMenuItem[] = [
export const BubbleMenuNodeSelector: FC<Props> = (props) => {
const { editor, isOpen, setIsOpen } = props;
const items: EditorMenuItem[] = [
TextItem(editor),
HeadingOneItem(editor),
HeadingTwoItem(editor),
@@ -42,7 +44,7 @@ export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
CodeItem(editor),
];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
const activeItem = items.filter((item) => item.isActive("")).pop() ?? {
name: "Multiple",
};
@@ -54,12 +56,11 @@ export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
setIsOpen(!isOpen);
e.stopPropagation();
}}
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
>
<span>{activeItem?.name}</span>
<ChevronDown className="h-4 w-4" />
<ChevronDown className="flex-shrink-0 size-3" />
</button>
{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">
{items.map((item) => (

View File

@@ -1,12 +1,13 @@
import { FC, useEffect, useState } from "react";
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react";
import { LucideIcon } from "lucide-react";
// components
import {
BoldItem,
BubbleMenuColorSelector,
BubbleMenuLinkSelector,
BubbleMenuNodeSelector,
CodeItem,
EditorMenuItem,
ItalicItem,
StrikeThroughItem,
UnderLineItem,
@@ -16,34 +17,23 @@ import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
// helpers
import { cn } from "@/helpers/common";
export interface BubbleMenuItem {
key: string;
name: string;
isActive: () => boolean;
command: () => void;
icon: LucideIcon;
}
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
const items: BubbleMenuItem[] = [
...(props.editor.isActive("code")
? []
: [
BoldItem(props.editor),
ItalicItem(props.editor),
UnderLineItem(props.editor),
StrikeThroughItem(props.editor),
]),
CodeItem(props.editor),
];
// states
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
const items: EditorMenuItem[] = props.editor.isActive("code")
? [CodeItem(props.editor)]
: [BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor)];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
shouldShow: ({ state, editor }) => {
const { selection } = state;
const { empty } = selection;
if (
@@ -63,15 +53,11 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
onHidden: () => {
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
},
},
};
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
useEffect(() => {
function handleMouseDown() {
function handleMouseMove() {
@@ -102,51 +88,66 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
return (
<BubbleMenu
{...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") && (
<BubbleMenuNodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsLinkSelectorOpen(false);
}}
/>
)}
{!props.editor.isActive("code") && (
<BubbleMenuLinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen(!isLinkSelectorOpen);
setIsNodeSelectorOpen(false);
}}
/>
)}
<div className="flex">
<div className="px-2">
{!props.editor.isActive("table") && (
<BubbleMenuNodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen((prev) => !prev);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
)}
</div>
<div className="px-2">
{!props.editor.isActive("code") && (
<BubbleMenuLinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen((prev) => !prev);
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) => (
<button
key={item.name}
key={item.key}
type="button"
onClick={(e) => {
item.command();
e.stopPropagation();
}}
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
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
<item.icon className="size-4" />
</button>
))}
</div>

View File

@@ -20,12 +20,14 @@ import {
Heading6,
CaseSensitive,
LucideIcon,
Palette,
} from "lucide-react";
// helpers
import {
insertImage,
insertTableCommand,
setText,
toggleBackgroundColor,
toggleBlockquote,
toggleBold,
toggleBulletList,
@@ -40,18 +42,26 @@ import {
toggleOrderedList,
toggleStrike,
toggleTaskList,
toggleTextColor,
toggleUnderline,
} from "@/helpers/editor-commands";
// types
import { TEditorCommands } from "@/types";
import { TColorEditorCommands, TNonColorEditorCommands } from "@/types";
export interface EditorMenuItem {
key: TEditorCommands;
export type EditorMenuItem = {
name: string;
isActive: () => boolean;
command: () => void;
command: (...args: any) => void;
icon: LucideIcon;
}
} & (
| {
key: TNonColorEditorCommands;
isActive: () => boolean;
}
| {
key: TColorEditorCommands;
isActive: (color: string | undefined) => boolean;
}
);
export const TextItem = (editor: Editor): EditorMenuItem => ({
key: "text",
@@ -198,10 +208,25 @@ export const ImageItem = (editor: Editor) =>
icon: ImageIcon,
}) as const;
export function getEditorMenuItems(editor: Editor | null) {
if (!editor) {
return [];
}
export const TextColorItem = (editor: Editor): EditorMenuItem => ({
key: "text-color",
name: "Color",
isActive: (color) => editor.getAttributes("textStyle").color === color,
command: (color: string) => toggleTextColor(color, editor),
icon: Palette,
});
export const BackgroundColorItem = (editor: Editor): EditorMenuItem => ({
key: "background-color",
name: "Background color",
isActive: (color) => editor.isActive("highlight", { color }),
command: (color: string) => toggleBackgroundColor(color, editor),
icon: Palette,
});
export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => {
if (!editor) return [];
return [
TextItem(editor),
HeadingOneItem(editor),
@@ -221,5 +246,7 @@ export function getEditorMenuItems(editor: Editor | null) {
QuoteItem(editor),
TableItem(editor),
ImageItem(editor),
TextColorItem(editor),
BackgroundColorItem(editor),
];
}
};

View 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",
},
];

View File

@@ -1,3 +1,5 @@
import { Color } from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
@@ -83,6 +85,10 @@ export const CoreEditorExtensionsWithoutProps = [
TableCell,
TableRow,
CustomMentionWithoutProps(),
Color,
Highlight.configure({
multicolor: true,
}),
];
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];

View File

@@ -1,4 +1,6 @@
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 TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
@@ -166,4 +168,8 @@ export const CoreEditorExtensions = ({
includeChildren: true,
}),
CharacterCount,
Color,
Highlight.configure({
multicolor: true,
}),
];

View File

@@ -6,6 +6,7 @@ export * from "./custom-list-keymap";
export * from "./image";
export * from "./issue-embed";
export * from "./mentions";
export * from "./slash-commands";
export * from "./table";
export * from "./typography";
export * from "./core-without-props";

View File

@@ -1,4 +1,6 @@
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 TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
@@ -109,5 +111,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
readonly: true,
}),
CharacterCount,
Color,
Highlight.configure({
multicolor: true,
}),
HeadingListExtension,
];

View File

@@ -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,
},
});

View File

@@ -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);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

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

View 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,
},
});

View File

@@ -154,3 +154,42 @@ export const unsetLinkEditor = (editor: Editor) => {
export const setLinkEditor = (editor: Editor, url: string) => {
editor.chain().focus().setLink({ href: url }).run();
};
export const toggleTextColor = (color: string | undefined, editor: Editor, range?: Range) => {
if (color) {
if (range) editor.chain().focus().deleteRange(range).setColor(color).run();
else editor.chain().focus().setColor(color).run();
} else {
if (range) editor.chain().focus().deleteRange(range).unsetColor().run();
else editor.chain().focus().unsetColor().run();
}
};
export const toggleBackgroundColor = (color: string | undefined, editor: Editor, range?: Range) => {
if (color) {
if (range) {
editor
.chain()
.focus()
.deleteRange(range)
.setHighlight({
color,
})
.run();
} else {
editor
.chain()
.focus()
.setHighlight({
color,
})
.run();
}
} else {
if (range) {
editor.chain().focus().deleteRange(range).unsetHighlight().run();
} else {
editor.chain().focus().unsetHighlight().run();
}
}
};

View File

@@ -136,7 +136,8 @@ export const useEditor = (props: CustomEditorProps) => {
insertContentAtSavedSelection(editorRef, content, savedSelection);
}
},
executeMenuItemCommand: (itemKey: TEditorCommands) => {
executeMenuItemCommand: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editorRef.current);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
@@ -145,6 +146,8 @@ export const useEditor = (props: CustomEditorProps) => {
if (item) {
if (item.key === "image") {
item.command(savedSelectionRef.current);
} else if (itemKey === "text-color" || itemKey === "background-color") {
item.command(props.color);
} else {
item.command();
}
@@ -152,12 +155,19 @@ export const useEditor = (props: CustomEditorProps) => {
console.warn(`No command found for item: ${itemKey}`);
}
},
isMenuItemActive: (itemName: TEditorCommands): boolean => {
isMenuItemActive: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editorRef.current);
const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemName);
return item ? item.isActive() : false;
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const item = getEditorMenuItem(itemKey);
if (!item) return false;
if (itemKey === "text-color" || itemKey === "background-color") {
return item.isActive(props.color);
} else {
return item.isActive("");
}
},
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
// Subscribe to update event emitted from headers extension

View File

@@ -6,14 +6,15 @@ import {
IMentionHighlight,
IMentionSuggestion,
TAIHandler,
TColorEditorCommands,
TDisplayConfig,
TEditorCommands,
TEmbedConfig,
TExtensions,
TFileHandler,
TNonColorEditorCommands,
TServerHandler,
} from "@/types";
// editor refs
export type EditorReadOnlyRefApi = {
getMarkDown: () => string;
@@ -36,8 +37,26 @@ export type EditorReadOnlyRefApi = {
export interface EditorRefApi extends EditorReadOnlyRefApi {
setEditorValueAtCursorPosition: (content: string) => void;
executeMenuItemCommand: (itemKey: TEditorCommands) => void;
isMenuItemActive: (itemKey: TEditorCommands) => boolean;
executeMenuItemCommand: (
props:
| {
itemKey: TNonColorEditorCommands;
}
| {
itemKey: TColorEditorCommands;
color: string | undefined;
}
) => void;
isMenuItemActive: (
props:
| {
itemKey: TNonColorEditorCommands;
}
| {
itemKey: TColorEditorCommands;
color: string | undefined;
}
) => boolean;
onStateChange: (callback: () => void) => () => void;
setFocusAtPosition: (position: number) => void;
isEditorReadyToDiscard: () => boolean;

View File

@@ -1,4 +1,4 @@
import { ReactNode } from "react";
import { CSSProperties } from "react";
import { Editor, Range } from "@tiptap/core";
export type TEditorCommands =
@@ -21,7 +21,12 @@ export type TEditorCommands =
| "table"
| "image"
| "divider"
| "issue-embed";
| "issue-embed"
| "text-color"
| "background-color";
export type TColorEditorCommands = Extract<TEditorCommands, "text-color" | "background-color">;
export type TNonColorEditorCommands = Exclude<TEditorCommands, "text-color" | "background-color">;
export type CommandProps = {
editor: Editor;
@@ -29,10 +34,12 @@ export type CommandProps = {
};
export type ISlashCommandItem = {
key: TEditorCommands;
commandKey: TEditorCommands;
key: string;
title: string;
description: string;
searchTerms: string[];
icon: ReactNode;
icon: React.ReactNode;
iconContainerStyle?: CSSProperties;
command: ({ editor, range }: CommandProps) => void;
};

View File

@@ -18,6 +18,9 @@ export {
export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
// constants
export * from "@/constants/common";
// helpers
export * from "@/helpers/common";
export * from "@/helpers/editor-commands";

View File

@@ -44,7 +44,7 @@
}
&.sans-serif {
--font-style: sans-serif;
--font-style: "Inter", sans-serif;
}
&.serif {

View File

@@ -4,6 +4,9 @@ export enum EModalPosition {
}
export enum EModalWidth {
SM = "sm:max-w-sm",
MD = "sm:max-w-md",
LG = "sm:max-w-lg",
XL = "sm:max-w-xl",
XXL = "sm:max-w-2xl",
XXXL = "sm:max-w-3xl",

View File

@@ -1,6 +1,6 @@
import React from "react";
// editor
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor";
// components
import { IssueCommentToolbar } from "@/components/editor";
// helpers
@@ -56,7 +56,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
<IssueCommentToolbar
executeCommand={(key) => {
if (isMutableRefObject<EditorRefApi>(ref)) {
ref.current?.executeMenuItemCommand(key);
ref.current?.executeMenuItemCommand({
itemKey: key as TNonColorEditorCommands,
});
}
}}
isSubmitting={isSubmitting}

View File

@@ -2,7 +2,7 @@
import React, { useEffect, useState, useCallback } from "react";
// editor
import { EditorRefApi, TEditorCommands } from "@plane/editor";
import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor";
// ui
import { Button, Tooltip } from "@plane/ui";
// constants
@@ -34,7 +34,9 @@ export const IssueCommentToolbar: React.FC<Props> = (props) => {
.flat()
.forEach((item) => {
// Assert that editorRef.current is not null
newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key);
newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive({
itemKey: item.key as TNonColorEditorCommands,
});
});
setActiveStates(newActiveStates);
}

View File

@@ -29,7 +29,7 @@ export const CycleIssuesMobileHeader = () => {
const { getCycleById } = useCycle();
const layouts = [
{ key: "list", title: "List", icon: List },
{ key: "kanban", title: "Kanban", icon: Kanban },
{ key: "kanban", title: "Board", icon: Kanban },
{ key: "calendar", title: "Calendar", icon: Calendar },
];

View File

@@ -28,7 +28,7 @@ import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/h
export const ProjectIssuesMobileHeader = observer(() => {
const layouts = [
{ key: "list", title: "List", icon: List },
{ key: "kanban", title: "Kanban", icon: Kanban },
{ key: "kanban", title: "Board", icon: Kanban },
{ key: "calendar", title: "Calendar", icon: Calendar },
];
const [analyticsModal, setAnalyticsModal] = useState(false);

View File

@@ -31,7 +31,7 @@ export const ModuleIssuesMobileHeader = observer(() => {
const { getModuleById } = useModule();
const layouts = [
{ key: "list", title: "List", icon: List },
{ key: "kanban", title: "Kanban", icon: Kanban },
{ key: "kanban", title: "Board", icon: Kanban },
{ key: "calendar", title: "Calendar", icon: Calendar },
];
const { workspaceSlug, projectId, moduleId } = useParams() as {

View 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>
);
});

View File

@@ -1 +1 @@
export * from "./sidebar-chart";
export * from "./root";

View 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} />;

View File

@@ -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>
);
};

View File

@@ -1,6 +1,6 @@
"use client";
import { FC, Fragment, useCallback, useMemo, useState } from "react";
import { FC, Fragment, useCallback, useMemo } from "react";
import isEmpty from "lodash/isEmpty";
import isEqual from "lodash/isEqual";
import { observer } from "mobx-react";
@@ -16,10 +16,9 @@ import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
// helpers
import { getDate } from "@/helpers/date-time.helper";
// hooks
import { useIssues, useCycle, useProjectEstimates } from "@/hooks/store";
// plane web constants
import { SidebarBaseChart } from "@/plane-web/components/cycles/analytics-sidebar";
import { EEstimateSystem } from "@/plane-web/constants/estimates";
import { useIssues, useCycle } from "@/hooks/store";
// plane web components
import { SidebarChartRoot } from "@/plane-web/components/cycles";
type TCycleAnalyticsProgress = {
workspaceSlug: string;
@@ -27,7 +26,7 @@ type TCycleAnalyticsProgress = {
cycleId: string;
};
const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => {
export const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => {
if (!cycleDetails || cycleDetails === null) return cycleDetails;
const updatedCycleDetails: any = { ...cycleDetails };
@@ -60,12 +59,9 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
// router
const searchParams = useSearchParams();
const peekCycle = searchParams.get("peekCycle") || undefined;
// hooks
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
const {
getPlotTypeByCycleId,
getEstimateTypeByCycleId,
setPlotType,
getCycleById,
fetchCycleDetails,
fetchArchivedCycleDetails,
@@ -74,17 +70,11 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.CYCLE);
// state
const [loader, setLoader] = useState(false);
// derived values
const cycleDetails = validateCycleSnapshot(getCycleById(cycleId));
const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId);
const estimateType = getEstimateTypeByCycleId(cycleId);
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
const estimateDetails =
isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS;
const completedIssues = cycleDetails?.completed_issues || 0;
const totalIssues = cycleDetails?.total_issues || 0;
@@ -132,15 +122,13 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
setEstimateType(cycleId, value);
if (!workspaceSlug || !projectId || !cycleId) return;
try {
setLoader(true);
if (isArchived) {
await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId);
} else {
await fetchCycleDetails(workspaceSlug, projectId, cycleId);
}
setLoader(false);
} catch (error) {
setLoader(false);
} catch (err) {
console.error(err);
setEstimateType(cycleId, estimateType);
}
};
@@ -218,16 +206,15 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
</CustomSelect.Option>
))}
</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 className="py-4">
<SidebarBaseChart
chartDistributionData={chartDistributionData}
cycleStartDate={cycleStartDate}
cycleEndDate={cycleEndDate}
totalEstimatePoints={totalEstimatePoints}
totalIssues={totalIssues}
plotType={plotType}
/>
<SidebarChartRoot workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
</div>
{/* progress detailed view */}
{chartDistributionData && (

View File

@@ -3,10 +3,10 @@ import React, { FC } from "react";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { LayersIcon, SquareUser, Users } from "lucide-react";
// ui
import { ICycle } from "@plane/types";
import { Avatar, AvatarGroup, TextArea } from "@plane/ui";
// types
import { ICycle } from "@plane/types";
// ui
import { Avatar, AvatarGroup, TextArea } from "@plane/ui";
// hooks
import { useMember, useProjectEstimates } from "@/hooks/store";
// plane web

View File

@@ -1,2 +1,3 @@
export * from "./lite-text-editor";
export * from "./pdf";
export * from "./rich-text-editor";

View File

@@ -1,6 +1,6 @@
import React from "react";
// editor
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor";
// types
import { IUserLite } from "@plane/types";
// components
@@ -87,7 +87,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
accessSpecifier={accessSpecifier}
executeCommand={(key) => {
if (isMutableRefObject<EditorRefApi>(ref)) {
ref.current?.executeMenuItemCommand(key);
ref.current?.executeMenuItemCommand({
itemKey: key as TNonColorEditorCommands,
});
}
}}
handleAccessChange={handleAccessChange}

View File

@@ -3,7 +3,7 @@
import React, { useEffect, useState, useCallback } from "react";
import { Globe2, Lock, LucideIcon } from "lucide-react";
// editor
import { EditorRefApi, TEditorCommands } from "@plane/editor";
import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor";
// ui
import { Button, Tooltip } from "@plane/ui";
// constants
@@ -69,7 +69,9 @@ export const IssueCommentToolbar: React.FC<Props> = (props) => {
.flat()
.forEach((item) => {
// Assert that editorRef.current is not null
newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key);
newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive({
itemKey: item.key as TNonColorEditorCommands,
});
});
setActiveStates(newActiveStates);
}

View 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>
);
};

View File

@@ -0,0 +1 @@
export * from "./document";

View File

@@ -10,11 +10,12 @@ import { TOAST_TYPE, setToast } from "@plane/ui";
import { IssueCommentCreate } from "@/components/issues";
import { IssueActivityCommentRoot } from "@/components/issues/issue-detail";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
import { useIssueDetail, useProject, useUserPermissions } from "@/hooks/store";
// plane web components
import { ActivityFilterRoot, IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog";
// plane web constants
import { TActivityFilters, defaultActivityFilters } from "@/plane-web/constants/issues";
import { EUserPermissions } from "@/plane-web/constants/user-permissions";
type TIssueActivity = {
workspaceSlug: string;
@@ -34,7 +35,11 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false, isIntakeIssue = false } = props;
// hooks
const { createComment, updateComment, removeComment } = useIssueDetail();
const { projectPermissionsByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getProjectById } = useProject();
//derived values
const isGuest = projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId) === EUserPermissions.GUEST;
const isWorklogButtonEnabled = !isIntakeIssue && !isGuest;
// state
const [selectedFilters, setSelectedFilters] = useState<TActivityFilters[]>(defaultActivityFilters);
// toggle filter
@@ -115,7 +120,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
<div className="flex items-center justify-between">
<div className="text-lg text-custom-text-100">Activity</div>
<div className="flex items-center gap-2">
{!isIntakeIssue && (
{isWorklogButtonEnabled && (
<IssueActivityWorklogCreateButton
workspaceSlug={workspaceSlug}
projectId={projectId}

View File

@@ -1,7 +1,7 @@
import { FC, useEffect } from "react";
import { observer } from "mobx-react";
// components
import { TIssueOperations } from "@/components/issues";
import { IssueParentDetail, TIssueOperations } from "@/components/issues";
// store hooks
import { useIssueDetail, useUser } from "@/hooks/store";
// hooks
@@ -57,6 +57,15 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
return (
<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} />
<IssueTitleInput
workspaceSlug={workspaceSlug}

View 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>
);
});

View File

@@ -1,3 +1,4 @@
export * from "./color-dropdown";
export * from "./extra-options";
export * from "./info-popover";
export * from "./options-dropdown";

View File

@@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
import { ArchiveRestoreIcon, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react";
import { ArchiveRestoreIcon, ArrowUpToLine, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react";
// document editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// ui
import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { ExportPageModal } from "@/components/pages";
// helpers
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
@@ -27,6 +30,7 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const router = useRouter();
// store values
const {
name,
archived_at,
is_locked,
id,
@@ -38,6 +42,8 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
canCurrentUserLockPage,
restore,
} = page;
// states
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
// store hooks
const { workspaceSlug, projectId } = useParams();
// page filters
@@ -157,26 +163,41 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
icon: History,
shouldRender: true,
},
{
key: "export",
action: () => setIsExportModalOpen(true),
label: "Export",
icon: ArrowUpToLine,
shouldRender: true,
},
];
return (
<CustomMenu maxHeight="lg" placement="bottom-start" verticalEllipsis closeOnSelect>
<CustomMenu.MenuItem
className="hidden md:flex w-full items-center justify-between gap-2"
onClick={() => handleFullWidth(!isFullWidth)}
>
Full width
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
</CustomMenu.MenuItem>
{MENU_ITEMS.map((item) => {
if (!item.shouldRender) return null;
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>
<>
<ExportPageModal
editorRef={editorRef}
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
pageTitle={name ?? ""}
/>
<CustomMenu maxHeight="lg" placement="bottom-start" verticalEllipsis closeOnSelect>
<CustomMenu.MenuItem
className="hidden md:flex w-full items-center justify-between gap-2"
onClick={() => handleFullWidth(!isFullWidth)}
>
Full width
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
</CustomMenu.MenuItem>
{MENU_ITEMS.map((item) => {
if (!item.shouldRender) return null;
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>
</>
);
});

View File

@@ -3,9 +3,11 @@
import React, { useEffect, useState, useCallback } from "react";
import { Check, ChevronDown } from "lucide-react";
// editor
import { EditorRefApi, TEditorCommands } from "@plane/editor";
import { EditorRefApi, TNonColorEditorCommands } from "@plane/editor";
// ui
import { CustomMenu, Tooltip } from "@plane/ui";
// components
import { ColorDropdown } from "@/components/pages";
// constants
import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS, ToolbarMenuItem } from "@/constants/editor";
// helpers
@@ -18,7 +20,7 @@ type Props = {
type ToolbarButtonProps = {
item: ToolbarMenuItem;
isActive: boolean;
executeCommand: (commandKey: TEditorCommands) => void;
executeCommand: EditorRefApi["executeMenuItemCommand"];
};
const ToolbarButton: React.FC<ToolbarButtonProps> = React.memo((props) => {
@@ -36,7 +38,11 @@ const ToolbarButton: React.FC<ToolbarButtonProps> = React.memo((props) => {
<button
key={item.key}
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", {
"bg-custom-background-80 text-custom-text-100": isActive,
})}
@@ -56,6 +62,7 @@ ToolbarButton.displayName = "ToolbarButton";
const toolbarItems = TOOLBAR_ITEMS.document;
export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
// states
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
const updateActiveStates = useCallback(() => {
@@ -63,7 +70,9 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
Object.values(toolbarItems)
.flat()
.forEach((item) => {
newActiveStates[item.key] = editorRef.isMenuItemActive(item.key);
newActiveStates[item.key] = editorRef.isMenuItemActive({
itemKey: item.key as TNonColorEditorCommands,
});
});
setActiveStates(newActiveStates);
}, [editorRef]);
@@ -74,7 +83,11 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
return () => unsubscribe();
}, [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 (
<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
key={item.key}
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">
<item.icon className="size-3" />
@@ -104,6 +121,20 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
</CustomMenu.MenuItem>
))}
</CustomMenu>
<ColorDropdown
handleColorSelect={(key, color) =>
editorRef.executeMenuItemCommand({
itemKey: key,
color,
})
}
isColorActive={(key, color) =>
editorRef.isMenuItemActive({
itemKey: key,
color,
})
}
/>
{Object.keys(toolbarItems).map((key) => (
<div key={key} className="flex items-center gap-0.5 px-2 first:pl-0 last:pr-0">
{toolbarItems[key].map((item) => (

View File

@@ -1,6 +1,6 @@
"use client";
import { CSSProperties, useState } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// editor
import { EditorRefApi } from "@plane/editor";
@@ -23,27 +23,21 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
// states
const [isLengthVisible, setIsLengthVisible] = useState(false);
// page filters
const { fontSize, fontStyle } = usePageFilters();
const { fontSize } = usePageFilters();
// ui
const titleClassName = cn("bg-transparent tracking-[-2%] font-semibold", {
"text-[1.6rem] leading-[1.8rem]": fontSize === "small-font",
"text-[2rem] leading-[2.25rem]": fontSize === "large-font",
});
const titleStyle: CSSProperties = {
fontFamily: fontStyle,
};
return (
<>
{readOnly ? (
<h6 className={cn(titleClassName, "break-words")} style={titleStyle}>
{title}
</h6>
<h6 className={cn(titleClassName, "break-words")}>{title}</h6>
) : (
<>
<TextArea
className={cn(titleClassName, "w-full outline-none p-0 border-none resize-none rounded-none")}
style={titleStyle}
placeholder="Untitled"
onKeyDown={(e) => {
if (e.key === "Enter") {

View 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>
);
};

View File

@@ -1,3 +1,4 @@
export * from "./create-page-modal";
export * from "./delete-page-modal";
export * from "./export-page-modal";
export * from "./page-form";

View File

@@ -2,20 +2,14 @@
import { FC, ReactNode } from "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 { ArchiveIcon, PopoverMenu, Spinner } from "@plane/ui";
import { ArchiveIcon, PopoverMenu } from "@plane/ui";
// components
import { NotificationMenuOptionItem } from "@/components/workspace-notifications";
// constants
import { NOTIFICATIONS_READ } from "@/constants/event-tracker";
import { ENotificationLoader } from "@/constants/notification";
// hooks
import { useEventTracker, useWorkspaceNotifications } from "@/hooks/store";
type TNotificationHeaderMenuOption = {
workspaceSlug: string;
};
import { useWorkspaceNotifications } from "@/hooks/store";
export type TPopoverMenuOptions = {
key: string;
@@ -27,44 +21,16 @@ export type TPopoverMenuOptions = {
onClick?: (() => void) | undefined;
};
export const NotificationHeaderMenuOption: FC<TNotificationHeaderMenuOption> = observer((props) => {
const { workspaceSlug } = props;
export const NotificationHeaderMenuOption = observer(() => {
// hooks
const { captureEvent } = useEventTracker();
const { loader, filters, updateFilters, updateBulkFilters, markAllNotificationsAsRead } = useWorkspaceNotifications();
const { filters, updateFilters, updateBulkFilters } = useWorkspaceNotifications();
const handleFilterChange = (filterType: keyof TNotificationFilter, filterValue: boolean) =>
updateFilters(filterType, filterValue);
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[] = [
{
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",
type: "menu-item",

View File

@@ -1,13 +1,14 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { RefreshCw } from "lucide-react";
import { Tooltip } from "@plane/ui";
import { CheckCheck, RefreshCw } from "lucide-react";
import { Spinner, Tooltip } from "@plane/ui";
// components
import { NotificationFilter, NotificationHeaderMenuOption } from "@/components/workspace-notifications";
// constants
import { NOTIFICATIONS_READ } from "@/constants/event-tracker";
import { ENotificationLoader, ENotificationQueryParamType } from "@/constants/notification";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store";
import { useEventTracker, useWorkspaceNotifications } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TNotificationSidebarHeaderOptions = {
@@ -18,7 +19,8 @@ export const NotificationSidebarHeaderOptions: FC<TNotificationSidebarHeaderOpti
const { workspaceSlug } = props;
// hooks
const { isMobile } = usePlatformOS();
const { loader, getNotifications } = useWorkspaceNotifications();
const { loader, getNotifications, markAllNotificationsAsRead } = useWorkspaceNotifications();
const { captureEvent } = useEventTracker();
const refreshNotifications = async () => {
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 (
<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 */}
<Tooltip tooltipContent="Refresh" isMobile={isMobile} position="bottom">
<div
@@ -45,7 +74,7 @@ export const NotificationSidebarHeaderOptions: FC<TNotificationSidebarHeaderOpti
<NotificationFilter />
{/* notification menu options */}
<NotificationHeaderMenuOption workspaceSlug={workspaceSlug} />
<NotificationHeaderMenuOption />
</div>
);
});

View File

@@ -40,12 +40,12 @@ export const CYCLE_VIEW_LAYOUTS: {
{
key: "board",
icon: LayoutGrid,
title: "Grid layout",
title: "Gallery layout",
},
{
key: "gantt",
icon: GanttChartSquare,
title: "Gantt layout",
title: "Timeline layout",
},
];

View File

@@ -1,3 +1,4 @@
import { Styles, StyleSheet } from "@react-pdf/renderer";
import {
Bold,
CaseSensitive,
@@ -23,6 +24,8 @@ import {
import { TEditorCommands, TEditorFontStyle } from "@plane/editor";
// ui
import { MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/ui";
// helpers
import { convertRemToPixel } from "@/helpers/common.helper";
type TEditorTypes = "lite" | "document";
@@ -131,3 +134,179 @@ export const EDITOR_FONT_STYLES: {
icon: MonospaceIcon,
},
];
const EDITOR_PDF_FONT_FAMILY_STYLES: Styles = {
"*:not(.courier, .courier-bold)": {
fontFamily: "Inter",
},
".courier": {
fontFamily: "Courier",
},
".courier-bold": {
fontFamily: "Courier-Bold",
},
};
const EDITOR_PDF_TYPOGRAPHY_STYLES: Styles = {
// page title
"h1.page-title": {
fontSize: convertRemToPixel(1.6),
fontWeight: "bold",
marginTop: 0,
marginBottom: convertRemToPixel(2),
},
// headings
"h1:not(.page-title)": {
fontSize: convertRemToPixel(1.4),
fontWeight: "semibold",
marginTop: convertRemToPixel(2),
marginBottom: convertRemToPixel(0.25),
},
h2: {
fontSize: convertRemToPixel(1.2),
fontWeight: "semibold",
marginTop: convertRemToPixel(1.4),
marginBottom: convertRemToPixel(0.0625),
},
h3: {
fontSize: convertRemToPixel(1.1),
fontWeight: "semibold",
marginTop: convertRemToPixel(1),
marginBottom: convertRemToPixel(0.0625),
},
h4: {
fontSize: convertRemToPixel(1),
fontWeight: "semibold",
marginTop: convertRemToPixel(1),
marginBottom: convertRemToPixel(0.0625),
},
h5: {
fontSize: convertRemToPixel(0.9),
fontWeight: "semibold",
marginTop: convertRemToPixel(1),
marginBottom: convertRemToPixel(0.0625),
},
h6: {
fontSize: convertRemToPixel(0.8),
fontWeight: "semibold",
marginTop: convertRemToPixel(1),
marginBottom: convertRemToPixel(0.0625),
},
// paragraph
"p:not(table p)": {
fontSize: convertRemToPixel(0.8),
},
"p:not(ol p, ul p)": {
marginTop: convertRemToPixel(0.25),
marginBottom: convertRemToPixel(0.0625),
},
};
const EDITOR_PDF_LIST_STYLES: Styles = {
"ul, ol": {
fontSize: convertRemToPixel(0.8),
marginHorizontal: -20,
},
"ol p, ul p": {
marginVertical: 0,
},
"ol li, ul li": {
marginTop: convertRemToPixel(0.45),
},
"ul ul, ul ol, ol ol, ol ul": {
marginVertical: 0,
},
"ul[data-type='taskList']": {
position: "relative",
},
"div.input-checkbox": {
position: "absolute",
top: convertRemToPixel(0.15),
left: -convertRemToPixel(1.2),
height: convertRemToPixel(0.75),
width: convertRemToPixel(0.75),
borderWidth: "1.5px",
borderStyle: "solid",
borderRadius: convertRemToPixel(0.125),
},
"div.input-checkbox:not(.checked)": {
backgroundColor: "#ffffff",
borderColor: "#171717",
},
"div.input-checkbox.checked": {
backgroundColor: "#3f76ff",
borderColor: "#3f76ff",
},
"ul li[data-checked='true'] p": {
color: "#a3a3a3",
},
};
const EDITOR_PDF_CODE_STYLES: Styles = {
// code block
"[data-node-type='code-block']": {
marginVertical: convertRemToPixel(0.5),
padding: convertRemToPixel(1),
borderRadius: convertRemToPixel(0.5),
backgroundColor: "#f7f7f7",
fontSize: convertRemToPixel(0.7),
},
// inline code block
"[data-node-type='inline-code-block']": {
margin: 0,
paddingVertical: convertRemToPixel(0.25 / 4 + 0.25 / 8),
paddingHorizontal: convertRemToPixel(0.375),
border: "0.5px solid #e5e5e5",
borderRadius: convertRemToPixel(0.25),
backgroundColor: "#e8e8e8",
color: "#f97316",
fontSize: convertRemToPixel(0.7),
},
};
export const EDITOR_PDF_DOCUMENT_STYLESHEET = StyleSheet.create({
...EDITOR_PDF_FONT_FAMILY_STYLES,
...EDITOR_PDF_TYPOGRAPHY_STYLES,
...EDITOR_PDF_LIST_STYLES,
...EDITOR_PDF_CODE_STYLES,
// quote block
blockquote: {
borderLeft: "3px solid gray",
paddingLeft: convertRemToPixel(1),
marginTop: convertRemToPixel(0.625),
marginBottom: 0,
marginHorizontal: 0,
},
// image
img: {
marginVertical: 0,
borderRadius: convertRemToPixel(0.375),
},
// divider
"div[data-type='horizontalRule']": {
marginVertical: convertRemToPixel(1),
height: 1,
width: "100%",
backgroundColor: "gray",
},
// mention block
"[data-node-type='mention-block']": {
margin: 0,
color: "#3f76ff",
backgroundColor: "#3f76ff33",
paddingHorizontal: convertRemToPixel(0.375),
},
// table
table: {
marginTop: convertRemToPixel(0.5),
marginBottom: convertRemToPixel(1),
marginHorizontal: 0,
},
"table td": {
padding: convertRemToPixel(0.625),
border: "1px solid #e5e5e5",
},
"table p": {
fontSize: convertRemToPixel(0.7),
},
});

View File

@@ -156,24 +156,24 @@ export const ISSUE_EXTRA_OPTIONS: {
];
export const ISSUE_LAYOUT_MAP = {
[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.LIST]: { key: EIssueLayoutTypes.LIST, title: "List layout", label: "List", icon: List },
[EIssueLayoutTypes.KANBAN]: { key: EIssueLayoutTypes.KANBAN, title: "Board layout", label: "Board", icon: Kanban },
[EIssueLayoutTypes.CALENDAR]: {
key: EIssueLayoutTypes.CALENDAR,
title: "Calendar Layout",
title: "Calendar layout",
label: "Calendar",
icon: Calendar,
},
[EIssueLayoutTypes.SPREADSHEET]: {
key: EIssueLayoutTypes.SPREADSHEET,
title: "Spreadsheet Layout",
label: "Spreadsheet",
title: "Table layout",
label: "Table",
icon: Sheet,
},
[EIssueLayoutTypes.GANTT]: {
key: EIssueLayoutTypes.GANTT,
title: "Gantt Chart Layout",
label: "Gantt",
title: "Timeline layout",
label: "Timeline",
icon: GanttChartSquare,
},
};

View File

@@ -62,12 +62,12 @@ export const MODULE_VIEW_LAYOUTS: { key: TModuleLayoutOptions; icon: any; title:
{
key: "board",
icon: LayoutGrid,
title: "Grid layout",
title: "Gallery layout",
},
{
key: "gantt",
icon: GanttChartSquare,
title: "Gantt layout",
title: "Timeline layout",
},
];

View File

@@ -144,7 +144,6 @@ export class CycleStore implements ICycleStore {
fetchActiveCycleProgress: action,
fetchActiveCycleAnalytics: action,
fetchCycleDetails: action,
createCycle: action,
updateCycleDetails: action,
deleteCycle: action,
addCycleToFavorites: action,
@@ -617,13 +616,15 @@ export class CycleStore implements ICycleStore {
* @param data
* @returns
*/
createCycle = async (workspaceSlug: string, projectId: string, data: Partial<ICycle>) =>
await this.cycleService.createCycle(workspaceSlug, projectId, data).then((response) => {
runInAction(() => {
set(this.cycleMap, [response.id], response);
});
return response;
});
createCycle = action(
async (workspaceSlug: string, projectId: string, data: Partial<ICycle>) =>
await this.cycleService.createCycle(workspaceSlug, projectId, data).then((response) => {
runInAction(() => {
set(this.cycleMap, [response.id], response);
});
return response;
})
);
/**
* @description updates cycle details

View File

@@ -37,3 +37,5 @@ export const debounce = (func: any, wait: number, immediate: boolean = false) =>
};
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export const convertRemToPixel = (rem: number): number => rem * 0.9 * 16;

View 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;
};

View 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);
});
};

View File

@@ -32,6 +32,7 @@
"@plane/types": "*",
"@plane/ui": "*",
"@popperjs/core": "^2.11.8",
"@react-pdf/renderer": "^3.4.5",
"@sentry/nextjs": "^8.32.0",
"@sqlite.org/sqlite-wasm": "^3.46.0-build2",
"axios": "^1.7.4",
@@ -57,6 +58,7 @@
"react-dropzone": "^14.2.3",
"react-hook-form": "7.51.5",
"react-markdown": "^8.0.7",
"react-pdf-html": "^2.1.2",
"react-popper": "^2.3.0",
"sharp": "^0.32.1",
"smooth-scroll-into-view-if-needed": "^2.0.2",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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