Compare commits

..

1 Commits

Author SHA1 Message Date
pablohashescobar c65e0a6668 chore: add workspace creation validation 2025-05-29 14:51:13 +05:30
406 changed files with 2205 additions and 5304 deletions
-2
View File
@@ -53,8 +53,6 @@ mediafiles
.env
.DS_Store
logs/
htmlcov/
.coverage
node_modules/
assets/dist/
+8 -8
View File
@@ -26,16 +26,16 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
formState: { errors, isSubmitting },
} = useForm<AIFormValues>({
defaultValues: {
LLM_API_KEY: config["LLM_API_KEY"],
LLM_MODEL: config["LLM_MODEL"],
OPENAI_API_KEY: config["OPENAI_API_KEY"],
GPT_ENGINE: config["GPT_ENGINE"],
},
});
const aiFormFields: TControllerInputFormField[] = [
{
key: "LLM_MODEL",
key: "GPT_ENGINE",
type: "text",
label: "LLM Model",
label: "GPT_ENGINE",
description: (
<>
Choose an OpenAI engine.{" "}
@@ -49,12 +49,12 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
</a>
</>
),
placeholder: "gpt-4o-mini",
error: Boolean(errors.LLM_MODEL),
placeholder: "gpt-3.5-turbo",
error: Boolean(errors.GPT_ENGINE),
required: false,
},
{
key: "LLM_API_KEY",
key: "OPENAI_API_KEY",
type: "password",
label: "API key",
description: (
@@ -71,7 +71,7 @@ export const InstanceAIForm: FC<IInstanceAIForm> = (props) => {
</>
),
placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd",
error: Boolean(errors.LLM_API_KEY),
error: Boolean(errors.OPENAI_API_KEY),
required: false,
},
];
-25
View File
@@ -1,25 +0,0 @@
[run]
source = plane
omit =
*/tests/*
*/migrations/*
*/settings/*
*/wsgi.py
*/asgi.py
*/urls.py
manage.py
*/admin.py
*/apps.py
[report]
exclude_lines =
pragma: no cover
def __repr__
if self.debug:
raise NotImplementedError
if __name__ == .__main__.
pass
raise ImportError
[html]
directory = htmlcov
+8 -1
View File
@@ -3,11 +3,18 @@ from rest_framework import serializers
# Module import
from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite
from plane.utils.url import clean_value
from .base import BaseSerializer
class UserSerializer(BaseSerializer):
def validate_first_name(self, value):
return clean_value(value)
def validate_last_name(self, value):
return clean_value(value)
class Meta:
model = User
# Exclude password field from the serializer
+31 -7
View File
@@ -1,7 +1,11 @@
# Django imports
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from django.template.defaultfilters import slugify
# Third party imports
from rest_framework import serializers
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
@@ -25,10 +29,7 @@ from plane.db.models import (
WorkspaceUserPreference,
)
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
# Django imports
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from plane.utils.url import clean_value
class WorkSpaceSerializer(DynamicBaseSerializer):
@@ -40,7 +41,30 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
# Check if the slug is restricted
if value in RESTRICTED_WORKSPACE_SLUGS:
raise serializers.ValidationError("Slug is not valid")
return value
# Clean the slug
slug = clean_value(value)
# If the slug is empty, return an error
if not slug:
raise serializers.ValidationError("Slug cannot be empty")
# Slugify the slug
slug = slugify(slug)
# Return the slug
return slug
def validate_name(self, value):
# Clean the name
name = clean_value(value)
# If the name is empty, return an error
if not name:
raise serializers.ValidationError("Name cannot be empty")
# Return the name
return name
class Meta:
model = Workspace
-18
View File
@@ -11,9 +11,6 @@ from plane.app.views import (
AdvanceAnalyticsChartEndpoint,
DefaultAnalyticsEndpoint,
ProjectStatsEndpoint,
ProjectAdvanceAnalyticsEndpoint,
ProjectAdvanceAnalyticsStatsEndpoint,
ProjectAdvanceAnalyticsChartEndpoint,
)
@@ -70,19 +67,4 @@ urlpatterns = [
AdvanceAnalyticsChartEndpoint.as_view(),
name="advance-analytics-chart",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/advance-analytics/",
ProjectAdvanceAnalyticsEndpoint.as_view(),
name="project-advance-analytics",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/advance-analytics-stats/",
ProjectAdvanceAnalyticsStatsEndpoint.as_view(),
name="project-advance-analytics-stats",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/advance-analytics-charts/",
ProjectAdvanceAnalyticsChartEndpoint.as_view(),
name="project-advance-analytics-chart",
),
]
-6
View File
@@ -205,12 +205,6 @@ from .analytic.advance import (
AdvanceAnalyticsChartEndpoint,
)
from .analytic.project_analytics import (
ProjectAdvanceAnalyticsEndpoint,
ProjectAdvanceAnalyticsStatsEndpoint,
ProjectAdvanceAnalyticsChartEndpoint,
)
from .notification.base import (
NotificationViewSet,
UnreadNotificationEndpoint,
+297 -93
View File
@@ -5,6 +5,7 @@ from django.db.models import QuerySet, Q, Count
from django.http import HttpRequest
from django.db.models.functions import TruncMonth
from django.utils import timezone
from datetime import timedelta
from plane.app.views.base import BaseAPIView
from plane.app.permissions import ROLE, allow_permission
from plane.db.models import (
@@ -18,8 +19,10 @@ from plane.db.models import (
Workspace,
CycleIssue,
ModuleIssue,
ProjectMember,
)
from django.db import models
from django.db.models import F, Case, When, Value
from django.db.models.functions import Concat
from plane.utils.build_chart import build_analytics_chart
from plane.utils.date_utils import (
get_analytics_filters,
@@ -72,27 +75,32 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
}
def get_overview_data(self) -> Dict[str, Dict[str, int]]:
members_query = WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug, is_active=True
)
if self.request.GET.get("project_ids", None):
project_ids = self.request.GET.get("project_ids", None)
project_ids = [str(project_id) for project_id in project_ids.split(",")]
members_query = ProjectMember.objects.filter(
project_id__in=project_ids, is_active=True
)
return {
"total_users": self.get_filtered_counts(members_query),
"total_users": self.get_filtered_counts(
WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug, is_active=True
)
),
"total_admins": self.get_filtered_counts(
members_query.filter(role=ROLE.ADMIN.value)
WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug,
role=ROLE.ADMIN.value,
is_active=True,
)
),
"total_members": self.get_filtered_counts(
members_query.filter(role=ROLE.MEMBER.value)
WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug,
role=ROLE.MEMBER.value,
is_active=True,
)
),
"total_guests": self.get_filtered_counts(
members_query.filter(role=ROLE.GUEST.value)
WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug,
role=ROLE.GUEST.value,
is_active=True,
)
),
"total_projects": self.get_filtered_counts(
Project.objects.filter(**self.filters["project_filters"])
@@ -105,13 +113,30 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
),
"total_intake": self.get_filtered_counts(
Issue.objects.filter(**self.filters["base_filters"]).filter(
issue_intake__status__in=["-2", "0"]
issue_intake__isnull=False
)
),
}
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
def get_work_items_stats(
self, cycle_id=None, module_id=None
) -> Dict[str, Dict[str, int]]:
"""
Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
"""
base_queryset = None
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
else:
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
return {
"total_work_items": self.get_filtered_counts(base_queryset),
@@ -140,8 +165,11 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
status=status.HTTP_200_OK,
)
elif tab == "work-items":
# Optionally accept cycle_id or module_id as query params
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
return Response(
self.get_work_items_stats(),
self.get_work_items_stats(cycle_id=cycle_id, module_id=module_id),
status=status.HTTP_200_OK,
)
return Response({"message": "Invalid tab"}, status=status.HTTP_400_BAD_REQUEST)
@@ -160,21 +188,7 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
)
return (
base_queryset.values("project_id", "project__name").annotate(
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
completed_work_items=Count("id", filter=Q(state__group="completed")),
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
started_work_items=Count("id", filter=Q(state__group="started")),
)
.order_by("project_id")
)
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
return (
base_queryset
.values("project_id", "project__name")
base_queryset.values("project_id", "project__name")
.annotate(
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
completed_work_items=Count("id", filter=Q(state__group="completed")),
@@ -185,14 +199,100 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
.order_by("project_id")
)
def get_work_items_stats(
self, cycle_id=None, module_id=None, peek_view=False
) -> Dict[str, Dict[str, int]]:
base_queryset = None
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
elif peek_view:
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
else:
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
return (
base_queryset.values("project_id", "project__name")
.annotate(
cancelled_work_items=Count(
"id", filter=Q(state__group="cancelled")
),
completed_work_items=Count(
"id", filter=Q(state__group="completed")
),
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
un_started_work_items=Count(
"id", filter=Q(state__group="unstarted")
),
started_work_items=Count("id", filter=Q(state__group="started")),
)
.order_by("project_id")
)
return (
base_queryset.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True, then="assignees__avatar"
),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(
cancelled_work_items=Count(
"id", filter=Q(state__group="cancelled"), distinct=True
),
completed_work_items=Count(
"id", filter=Q(state__group="completed"), distinct=True
),
backlog_work_items=Count(
"id", filter=Q(state__group="backlog"), distinct=True
),
un_started_work_items=Count(
"id", filter=Q(state__group="unstarted"), distinct=True
),
started_work_items=Count(
"id", filter=Q(state__group="started"), distinct=True
),
)
.order_by("display_name")
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request: HttpRequest, slug: str) -> Response:
self.initialize_workspace(slug, type="chart")
type = request.GET.get("type", "work-items")
if type == "work-items":
# Optionally accept cycle_id or module_id as query params
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
peek_view = request.GET.get("peek_view", False)
return Response(
self.get_work_items_stats(),
self.get_work_items_stats(
cycle_id=cycle_id, module_id=module_id, peek_view=peek_view
),
status=status.HTTP_200_OK,
)
@@ -252,7 +352,9 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
for key, value in data.items()
]
def work_item_completion_chart(self) -> Dict[str, Any]:
def work_item_completion_chart(
self, cycle_id=None, module_id=None, peek_view=False
) -> Dict[str, Any]:
# Get the base queryset
queryset = (
Issue.issue_objects.filter(**self.filters["base_filters"])
@@ -262,62 +364,143 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
)
)
workspace = Workspace.objects.get(slug=self._workspace_slug)
start_date = workspace.created_at.date().replace(day=1)
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
queryset = queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
# Annotate by month and count
monthly_stats = (
queryset.annotate(month=TruncMonth("created_at"))
.values("month")
.annotate(
created_count=Count("id"),
completed_count=Count("id", filter=Q(state__group="completed")),
)
.order_by("month")
)
# Create dictionary of month -> counts
stats_dict = {
stat["month"].strftime("%Y-%m-%d"): {
"created_count": stat["created_count"],
"completed_count": stat["completed_count"],
}
for stat in monthly_stats
}
# Generate monthly data (ensure months with 0 count are included)
data = []
# include the current date at the end
end_date = timezone.now().date()
last_month = end_date.replace(day=1)
current_month = start_date
while current_month <= last_month:
date_str = current_month.strftime("%Y-%m-%d")
stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0})
data.append(
{
"key": date_str,
"name": date_str,
"count": stats["created_count"],
"completed_issues": stats["completed_count"],
"created_issues": stats["created_count"],
}
)
# Move to next month
if current_month.month == 12:
current_month = current_month.replace(
year=current_month.year + 1, month=1
)
if cycle_id is not None and peek_view:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
cycle = Cycle.objects.filter(id=cycle_id).first()
if cycle and cycle.start_date:
start_date = cycle.start_date.date()
end_date = cycle.end_date.date()
else:
current_month = current_month.replace(month=current_month.month + 1)
return {"data": [], "schema": {}}
queryset = cycle_issues
elif module_id is not None and peek_view:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
module = Module.objects.filter(id=module_id).first()
if module and module.start_date:
start_date = module.start_date
end_date = module.target_date
else:
return {"data": [], "schema": {}}
queryset = module_issues
elif peek_view:
project_ids_str = self.request.GET.get("project_ids")
if project_ids_str:
project_id_list = [
pid.strip() for pid in project_ids_str.split(",") if pid.strip()
]
else:
project_id_list = []
return {"data": [], "schema": {}}
project_id = project_id_list[0]
project = Project.objects.filter(id=project_id).first()
if project.created_at:
start_date = project.created_at.date().replace(day=1)
else:
return {"data": [], "schema": {}}
else:
workspace = Workspace.objects.get(slug=self._workspace_slug)
start_date = workspace.created_at.date().replace(day=1)
if cycle_id or module_id:
# Get daily stats with optimized query
daily_stats = (
queryset.values("created_at__date")
.annotate(
created_count=Count("id"),
completed_count=Count(
"id", filter=Q(issue__state__group="completed")
),
)
.order_by("created_at__date")
)
# Create a dictionary of existing stats with summed counts
stats_dict = {
stat["created_at__date"].strftime("%Y-%m-%d"): {
"created_count": stat["created_count"],
"completed_count": stat["completed_count"],
}
for stat in daily_stats
}
# Generate data for all days in the range
data = []
current_date = start_date
while current_date <= end_date:
date_str = current_date.strftime("%Y-%m-%d")
stats = stats_dict.get(
date_str, {"created_count": 0, "completed_count": 0}
)
data.append(
{
"key": date_str,
"name": date_str,
"count": stats["created_count"] + stats["completed_count"],
"completed_issues": stats["completed_count"],
"created_issues": stats["created_count"],
}
)
current_date += timedelta(days=1)
else:
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
queryset = queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
# Annotate by month and count
monthly_stats = (
queryset.annotate(month=TruncMonth("created_at"))
.values("month")
.annotate(
created_count=Count("id"),
completed_count=Count("id", filter=Q(state__group="completed")),
)
.order_by("month")
)
# Create dictionary of month -> counts
stats_dict = {
stat["month"].strftime("%Y-%m-%d"): {
"created_count": stat["created_count"],
"completed_count": stat["completed_count"],
}
for stat in monthly_stats
}
# Generate monthly data (ensure months with 0 count are included)
data = []
# include the current date at the end
end_date = timezone.now().date()
last_month = end_date.replace(day=1)
current_month = start_date
while current_month <= last_month:
date_str = current_month.strftime("%Y-%m-%d")
stats = stats_dict.get(
date_str, {"created_count": 0, "completed_count": 0}
)
data.append(
{
"key": date_str,
"name": date_str,
"count": stats["created_count"],
"completed_issues": stats["completed_count"],
"created_issues": stats["created_count"],
}
)
# Move to next month
if current_month.month == 12:
current_month = current_month.replace(
year=current_month.year + 1, month=1
)
else:
current_month = current_month.replace(month=current_month.month + 1)
schema = {
"completed_issues": "completed_issues",
@@ -332,6 +515,8 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
type = request.GET.get("type", "projects")
group_by = request.GET.get("group_by", None)
x_axis = request.GET.get("x_axis", "PRIORITY")
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
if type == "projects":
return Response(self.project_chart(), status=status.HTTP_200_OK)
@@ -345,6 +530,19 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
)
)
# Apply cycle/module filters if present
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
queryset = queryset.filter(id__in=cycle_issues)
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
queryset = queryset.filter(id__in=module_issues)
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
@@ -358,8 +556,14 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
)
elif type == "work-items":
# Optionally accept cycle_id or module_id as query params
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
peek_view = request.GET.get("peek_view", False)
return Response(
self.work_item_completion_chart(),
self.work_item_completion_chart(
cycle_id=cycle_id, module_id=module_id, peek_view=peek_view
),
status=status.HTTP_200_OK,
)
@@ -1,421 +0,0 @@
from rest_framework.response import Response
from rest_framework import status
from typing import Dict, Any
from django.db.models import QuerySet, Q, Count
from django.http import HttpRequest
from django.db.models.functions import TruncMonth
from django.utils import timezone
from datetime import timedelta
from plane.app.views.base import BaseAPIView
from plane.app.permissions import ROLE, allow_permission
from plane.db.models import (
Project,
Issue,
Cycle,
Module,
CycleIssue,
ModuleIssue,
)
from django.db import models
from django.db.models import F, Case, When, Value
from django.db.models.functions import Concat
from plane.utils.build_chart import build_analytics_chart
from plane.utils.date_utils import (
get_analytics_filters,
)
class ProjectAdvanceAnalyticsBaseView(BaseAPIView):
def initialize_workspace(self, slug: str, type: str) -> None:
self._workspace_slug = slug
self.filters = get_analytics_filters(
slug=slug,
type=type,
user=self.request.user,
date_filter=self.request.GET.get("date_filter", None),
project_ids=self.request.GET.get("project_ids", None),
)
class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView):
def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
def get_filtered_count() -> int:
if self.filters["analytics_date_range"]:
return queryset.filter(
created_at__gte=self.filters["analytics_date_range"]["current"][
"gte"
],
created_at__lte=self.filters["analytics_date_range"]["current"][
"lte"
],
).count()
return queryset.count()
return {
"count": get_filtered_count(),
}
def get_work_items_stats(
self, project_id, cycle_id=None, module_id=None
) -> Dict[str, Dict[str, int]]:
"""
Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
"""
base_queryset = None
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
else:
base_queryset = Issue.issue_objects.filter(
**self.filters["base_filters"], project_id=project_id
)
return {
"total_work_items": self.get_filtered_counts(base_queryset),
"started_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="started")
),
"backlog_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="backlog")
),
"un_started_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="unstarted")
),
"completed_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="completed")
),
}
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
self.initialize_workspace(slug, type="analytics")
# Optionally accept cycle_id or module_id as query params
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
return Response(
self.get_work_items_stats(
cycle_id=cycle_id, module_id=module_id, project_id=project_id
),
status=status.HTTP_200_OK,
)
class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView):
def get_project_issues_stats(self) -> QuerySet:
# Get the base queryset with workspace and project filters
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
base_queryset = base_queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
return (
base_queryset.values("project_id", "project__name")
.annotate(
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
completed_work_items=Count("id", filter=Q(state__group="completed")),
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
started_work_items=Count("id", filter=Q(state__group="started")),
)
.order_by("project_id")
)
def get_work_items_stats(
self, project_id, cycle_id=None, module_id=None
) -> Dict[str, Dict[str, int]]:
base_queryset = None
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
else:
base_queryset = Issue.issue_objects.filter(
**self.filters["base_filters"], project_id=project_id
)
return (
base_queryset.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True, then="assignees__avatar"
),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(
cancelled_work_items=Count(
"id", filter=Q(state__group="cancelled"), distinct=True
),
completed_work_items=Count(
"id", filter=Q(state__group="completed"), distinct=True
),
backlog_work_items=Count(
"id", filter=Q(state__group="backlog"), distinct=True
),
un_started_work_items=Count(
"id", filter=Q(state__group="unstarted"), distinct=True
),
started_work_items=Count(
"id", filter=Q(state__group="started"), distinct=True
),
)
.order_by("display_name")
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
self.initialize_workspace(slug, type="chart")
type = request.GET.get("type", "work-items")
if type == "work-items":
# Optionally accept cycle_id or module_id as query params
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
return Response(
self.get_work_items_stats(
project_id=project_id, cycle_id=cycle_id, module_id=module_id
),
status=status.HTTP_200_OK,
)
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView):
def work_item_completion_chart(
self, project_id, cycle_id=None, module_id=None
) -> Dict[str, Any]:
# Get the base queryset
queryset = (
Issue.issue_objects.filter(**self.filters["base_filters"])
.filter(project_id=project_id)
.select_related("workspace", "state", "parent")
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
)
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
cycle = Cycle.objects.filter(id=cycle_id).first()
if cycle and cycle.start_date:
start_date = cycle.start_date.date()
end_date = cycle.end_date.date()
else:
return {"data": [], "schema": {}}
queryset = cycle_issues
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
module = Module.objects.filter(id=module_id).first()
if module and module.start_date:
start_date = module.start_date
end_date = module.target_date
else:
return {"data": [], "schema": {}}
queryset = module_issues
else:
project = Project.objects.filter(id=project_id).first()
if project.created_at:
start_date = project.created_at.date().replace(day=1)
else:
return {"data": [], "schema": {}}
if cycle_id or module_id:
# Get daily stats with optimized query
daily_stats = (
queryset.values("created_at__date")
.annotate(
created_count=Count("id"),
completed_count=Count(
"id", filter=Q(issue__state__group="completed")
),
)
.order_by("created_at__date")
)
# Create a dictionary of existing stats with summed counts
stats_dict = {
stat["created_at__date"].strftime("%Y-%m-%d"): {
"created_count": stat["created_count"],
"completed_count": stat["completed_count"],
}
for stat in daily_stats
}
# Generate data for all days in the range
data = []
current_date = start_date
while current_date <= end_date:
date_str = current_date.strftime("%Y-%m-%d")
stats = stats_dict.get(
date_str, {"created_count": 0, "completed_count": 0}
)
data.append(
{
"key": date_str,
"name": date_str,
"count": stats["created_count"] + stats["completed_count"],
"completed_issues": stats["completed_count"],
"created_issues": stats["created_count"],
}
)
current_date += timedelta(days=1)
else:
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
queryset = queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
# Annotate by month and count
monthly_stats = (
queryset.annotate(month=TruncMonth("created_at"))
.values("month")
.annotate(
created_count=Count("id"),
completed_count=Count("id", filter=Q(state__group="completed")),
)
.order_by("month")
)
# Create dictionary of month -> counts
stats_dict = {
stat["month"].strftime("%Y-%m-%d"): {
"created_count": stat["created_count"],
"completed_count": stat["completed_count"],
}
for stat in monthly_stats
}
# Generate monthly data (ensure months with 0 count are included)
data = []
# include the current date at the end
end_date = timezone.now().date()
last_month = end_date.replace(day=1)
current_month = start_date
while current_month <= last_month:
date_str = current_month.strftime("%Y-%m-%d")
stats = stats_dict.get(
date_str, {"created_count": 0, "completed_count": 0}
)
data.append(
{
"key": date_str,
"name": date_str,
"count": stats["created_count"],
"completed_issues": stats["completed_count"],
"created_issues": stats["created_count"],
}
)
# Move to next month
if current_month.month == 12:
current_month = current_month.replace(
year=current_month.year + 1, month=1
)
else:
current_month = current_month.replace(month=current_month.month + 1)
schema = {
"completed_issues": "completed_issues",
"created_issues": "created_issues",
}
return {"data": data, "schema": schema}
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request: HttpRequest, slug: str, project_id: str) -> Response:
self.initialize_workspace(slug, type="chart")
type = request.GET.get("type", "projects")
group_by = request.GET.get("group_by", None)
x_axis = request.GET.get("x_axis", "PRIORITY")
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
if type == "custom-work-items":
queryset = (
Issue.issue_objects.filter(**self.filters["base_filters"])
.filter(project_id=project_id)
.select_related("workspace", "state", "parent")
.prefetch_related(
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
)
# Apply cycle/module filters if present
if cycle_id is not None:
cycle_issues = CycleIssue.objects.filter(
**self.filters["base_filters"], cycle_id=cycle_id
).values_list("issue_id", flat=True)
queryset = queryset.filter(id__in=cycle_issues)
elif module_id is not None:
module_issues = ModuleIssue.objects.filter(
**self.filters["base_filters"], module_id=module_id
).values_list("issue_id", flat=True)
queryset = queryset.filter(id__in=module_issues)
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
queryset = queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
return Response(
build_analytics_chart(queryset, x_axis, group_by),
status=status.HTTP_200_OK,
)
elif type == "work-items":
# Optionally accept cycle_id or module_id as query params
cycle_id = request.GET.get("cycle_id", None)
module_id = request.GET.get("module_id", None)
return Response(
self.work_item_completion_chart(
project_id=project_id, cycle_id=cycle_id, module_id=module_id
),
status=status.HTTP_200_OK,
)
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
+1 -1
View File
@@ -445,7 +445,7 @@ class ProjectViewSet(BaseViewSet):
is_active=True,
).exists()
):
project = Project.objects.get(pk=pk, workspace__slug=slug)
project = Project.objects.get(pk=pk)
project.delete()
webhook_activity.delay(
event="project",
+3 -2
View File
@@ -92,8 +92,6 @@ class WorkSpaceViewSet(BaseViewSet):
status=status.HTTP_403_FORBIDDEN,
)
serializer = WorkSpaceSerializer(data=request.data)
slug = request.data.get("slug", False)
name = request.data.get("name", False)
@@ -109,6 +107,9 @@ class WorkSpaceViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
# Validate the serializer
serializer = WorkSpaceSerializer(data=request.data)
if serializer.is_valid(raise_exception=True):
serializer.save(owner=request.user)
# Create Workspace member
+15 -15
View File
@@ -42,11 +42,11 @@ urlpatterns = [
# credentials
path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"),
path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"),
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"),
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="sign-in"),
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="sign-in"),
# signout
path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"),
path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="space-sign-out"),
path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="sign-out"),
# csrf token
path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"),
# Magic sign in
@@ -56,17 +56,17 @@ urlpatterns = [
path(
"spaces/magic-generate/",
MagicGenerateSpaceEndpoint.as_view(),
name="space-magic-generate",
name="magic-generate",
),
path(
"spaces/magic-sign-in/",
MagicSignInSpaceEndpoint.as_view(),
name="space-magic-sign-in",
name="magic-sign-in",
),
path(
"spaces/magic-sign-up/",
MagicSignUpSpaceEndpoint.as_view(),
name="space-magic-sign-up",
name="magic-sign-up",
),
## Google Oauth
path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"),
@@ -74,12 +74,12 @@ urlpatterns = [
path(
"spaces/google/",
GoogleOauthInitiateSpaceEndpoint.as_view(),
name="space-google-initiate",
name="google-initiate",
),
path(
"spaces/google/callback/",
"google/callback/",
GoogleCallbackSpaceEndpoint.as_view(),
name="space-google-callback",
name="google-callback",
),
## Github Oauth
path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"),
@@ -87,12 +87,12 @@ urlpatterns = [
path(
"spaces/github/",
GitHubOauthInitiateSpaceEndpoint.as_view(),
name="space-github-initiate",
name="github-initiate",
),
path(
"spaces/github/callback/",
GitHubCallbackSpaceEndpoint.as_view(),
name="space-github-callback",
name="github-callback",
),
## Gitlab Oauth
path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"),
@@ -100,12 +100,12 @@ urlpatterns = [
path(
"spaces/gitlab/",
GitLabOauthInitiateSpaceEndpoint.as_view(),
name="space-gitlab-initiate",
name="gitlab-initiate",
),
path(
"spaces/gitlab/callback/",
GitLabCallbackSpaceEndpoint.as_view(),
name="space-gitlab-callback",
name="gitlab-callback",
),
# Email Check
path("email-check/", EmailCheckEndpoint.as_view(), name="email-check"),
@@ -120,12 +120,12 @@ urlpatterns = [
path(
"spaces/forgot-password/",
ForgotPasswordSpaceEndpoint.as_view(),
name="space-forgot-password",
name="forgot-password",
),
path(
"spaces/reset-password/<uidb64>/<token>/",
ResetPasswordSpaceEndpoint.as_view(),
name="space-forgot-password",
name="forgot-password",
),
path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"),
path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"),
@@ -1,23 +0,0 @@
# Generated by Django 4.2.20 on 2025-05-21 13:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0095_page_external_id_page_external_source"),
]
operations = [
migrations.AddField(
model_name="user",
name="is_email_valid",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="user",
name="masked_at",
field=models.DateTimeField(null=True),
),
]
-6
View File
@@ -106,12 +106,6 @@ class User(AbstractBaseUser, PermissionsMixin):
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
)
# email validation
is_email_valid = models.BooleanField(default=False)
# masking
masked_at = models.DateTimeField(null=True)
USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["username"]
@@ -57,7 +57,7 @@ class InstanceEndpoint(BaseAPIView):
POSTHOG_API_KEY,
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
LLM_API_KEY,
OPENAI_API_KEY,
IS_INTERCOM_ENABLED,
INTERCOM_APP_ID,
) = get_configuration_value(
@@ -112,8 +112,8 @@ class InstanceEndpoint(BaseAPIView):
"default": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
},
{
"key": "LLM_API_KEY",
"default": os.environ.get("LLM_API_KEY", ""),
"key": "OPENAI_API_KEY",
"default": os.environ.get("OPENAI_API_KEY", ""),
},
# Intercom settings
{
@@ -151,7 +151,7 @@ class InstanceEndpoint(BaseAPIView):
data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY)
# Open AI settings
data["has_llm_configured"] = bool(LLM_API_KEY)
data["has_openai_configured"] = bool(OPENAI_API_KEY)
# File size settings
data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880))
+2 -32
View File
@@ -83,32 +83,6 @@ class APITokenLogMiddleware:
self.process_request(request, response, request_body)
return response
def _safe_decode_body(self, content):
"""
Safely decodes request/response body content, handling binary data.
Returns None if content is None, or a string representation of the content.
"""
# If the content is None, return None
if content is None:
return None
# If the content is an empty bytes object, return None
if content == b"":
return None
# Check if content is binary by looking for common binary file signatures
if (
content.startswith(b"\x89PNG")
or content.startswith(b"\xff\xd8\xff")
or content.startswith(b"%PDF")
):
return "[Binary Content]"
try:
return content.decode("utf-8")
except UnicodeDecodeError:
return "[Could not decode content]"
def process_request(self, request, response, request_body):
api_key_header = "X-Api-Key"
api_key = request.headers.get(api_key_header)
@@ -121,13 +95,9 @@ class APITokenLogMiddleware:
method=request.method,
query_params=request.META.get("QUERY_STRING", ""),
headers=str(request.headers),
body=(
self._safe_decode_body(request_body) if request_body else None
),
body=(request_body.decode("utf-8") if request_body else None),
response_body=(
self._safe_decode_body(response.content)
if response.content
else None
response.content.decode("utf-8") if response.content else None
),
response_code=response.status_code,
ip_address=get_client_ip(request=request),
+2 -2
View File
@@ -179,7 +179,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
Q(issue_intake__status=1)
| Q(issue_intake__status=-1)
| Q(issue_intake__status=2)
| Q(issue_intake__isnull=True),
| Q(issue_intake__status=True),
archived_at__isnull=True,
is_draft=False,
),
@@ -205,7 +205,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
Q(issue_intake__status=1)
| Q(issue_intake__status=-1)
| Q(issue_intake__status=2)
| Q(issue_intake__isnull=True),
| Q(issue_intake__status=True),
archived_at__isnull=True,
is_draft=False,
),
-143
View File
@@ -1,143 +0,0 @@
# Plane Tests
This directory contains tests for the Plane application. The tests are organized using pytest.
## Test Structure
Tests are organized into the following categories:
- **Unit tests**: Test individual functions or classes in isolation.
- **Contract tests**: Test interactions between components and verify API contracts are fulfilled.
- **API tests**: Test the external API endpoints (under `/api/v1/`).
- **App tests**: Test the web application API endpoints (under `/api/`).
- **Smoke tests**: Basic tests to verify that the application runs correctly.
## API vs App Endpoints
Plane has two types of API endpoints:
1. **External API** (`plane.api`):
- Available at `/api/v1/` endpoint
- Uses API key authentication (X-Api-Key header)
- Designed for external API contracts and third-party access
- Tests use the `api_key_client` fixture for authentication
- Test files are in `contract/api/`
2. **Web App API** (`plane.app`):
- Available at `/api/` endpoint
- Uses session-based authentication (CSRF disabled)
- Designed for the web application frontend
- Tests use the `session_client` fixture for authentication
- Test files are in `contract/app/`
## Running Tests
To run all tests:
```bash
python -m pytest
```
To run specific test categories:
```bash
# Run unit tests
python -m pytest plane/tests/unit/
# Run API contract tests
python -m pytest plane/tests/contract/api/
# Run App contract tests
python -m pytest plane/tests/contract/app/
# Run smoke tests
python -m pytest plane/tests/smoke/
```
For convenience, we also provide a helper script:
```bash
# Run all tests
./run_tests.py
# Run only unit tests
./run_tests.py -u
# Run contract tests with coverage report
./run_tests.py -c -o
# Run tests in parallel
./run_tests.py -p
```
## Fixtures
The following fixtures are available for testing:
- `api_client`: Unauthenticated API client
- `create_user`: Creates a test user
- `api_token`: API token for the test user
- `api_key_client`: API client with API key authentication (for external API tests)
- `session_client`: API client with session authentication (for app API tests)
- `plane_server`: Live Django test server for HTTP-based smoke tests
## Writing Tests
When writing tests, follow these guidelines:
1. Place tests in the appropriate directory based on their type.
2. Use the correct client fixture based on the API being tested:
- For external API (`/api/v1/`), use `api_key_client`
- For web app API (`/api/`), use `session_client`
- For smoke tests with real HTTP, use `plane_server`
3. Use the correct URL namespace when reverse-resolving URLs:
- For external API, use `reverse("api:endpoint_name")`
- For web app API, use `reverse("endpoint_name")`
4. Add the `@pytest.mark.django_db` decorator to tests that interact with the database.
5. Add the appropriate markers (`@pytest.mark.contract`, etc.) to categorize tests.
## Test Fixtures
Common fixtures are defined in:
- `conftest.py`: General fixtures for authentication, database access, etc.
- `conftest_external.py`: Fixtures for external services (Redis, Elasticsearch, Celery, MongoDB)
- `factories.py`: Test factories for easy model instance creation
## Best Practices
When writing tests, follow these guidelines:
1. **Use pytest's assert syntax** instead of Django's `self.assert*` methods.
2. **Add markers to categorize tests**:
```python
@pytest.mark.unit
@pytest.mark.contract
@pytest.mark.smoke
```
3. **Use fixtures instead of setUp/tearDown methods** for cleaner, more reusable test code.
4. **Mock external dependencies** with the provided fixtures to avoid external service dependencies.
5. **Write focused tests** that verify one specific behavior or edge case.
6. **Keep test files small and organized** by logical components or endpoints.
7. **Target 90% code coverage** for models, serializers, and business logic.
## External Dependencies
Tests for components that interact with external services should:
1. Use the `mock_redis`, `mock_elasticsearch`, `mock_mongodb`, and `mock_celery` fixtures for unit and most contract tests.
2. For more comprehensive contract tests, use Docker-based test containers (optional).
## Coverage Reports
Generate a coverage report with:
```bash
python -m pytest --cov=plane --cov-report=term --cov-report=html
```
This creates an HTML report in the `htmlcov/` directory.
## Migration from Old Tests
Some tests are still in the old format in the `api/` directory. These need to be migrated to the new contract test structure in the appropriate directories.
-151
View File
@@ -1,151 +0,0 @@
# Testing Guide for Plane
This guide explains how to write tests for Plane using our pytest-based testing strategy.
## Test Categories
We divide tests into three categories:
1. **Unit Tests**: Testing individual components in isolation.
2. **Contract Tests**: Testing API endpoints and verifying contracts between components.
3. **Smoke Tests**: Basic end-to-end tests for critical flows.
## Writing Unit Tests
Unit tests should be placed in the appropriate directory under `tests/unit/` depending on what you're testing:
- `tests/unit/models/` - For model tests
- `tests/unit/serializers/` - For serializer tests
- `tests/unit/utils/` - For utility function tests
### Example Unit Test:
```python
import pytest
from plane.api.serializers import MySerializer
@pytest.mark.unit
class TestMySerializer:
def test_serializer_valid_data(self):
# Create input data
data = {"field1": "value1", "field2": 42}
# Initialize the serializer
serializer = MySerializer(data=data)
# Validate
assert serializer.is_valid()
# Check validated data
assert serializer.validated_data["field1"] == "value1"
assert serializer.validated_data["field2"] == 42
```
## Writing Contract Tests
Contract tests should be placed in `tests/contract/api/` or `tests/contract/app/` directories and should test the API endpoints.
### Example Contract Test:
```python
import pytest
from django.urls import reverse
from rest_framework import status
@pytest.mark.contract
class TestMyEndpoint:
@pytest.mark.django_db
def test_my_endpoint_get(self, auth_client):
# Get the URL
url = reverse("my-endpoint")
# Make request
response = auth_client.get(url)
# Check response
assert response.status_code == status.HTTP_200_OK
assert "data" in response.data
```
## Writing Smoke Tests
Smoke tests should be placed in `tests/smoke/` directory and use the `plane_server` fixture to test against a real HTTP server.
### Example Smoke Test:
```python
import pytest
import requests
@pytest.mark.smoke
class TestCriticalFlow:
@pytest.mark.django_db
def test_login_flow(self, plane_server, create_user, user_data):
# Get login URL
url = f"{plane_server.url}/api/auth/signin/"
# Test login
response = requests.post(
url,
json={
"email": user_data["email"],
"password": user_data["password"]
}
)
# Verify
assert response.status_code == 200
data = response.json()
assert "access_token" in data
```
## Useful Fixtures
Our test setup provides several useful fixtures:
1. `api_client`: An unauthenticated DRF APIClient
2. `api_key_client`: API client with API key authentication (for external API tests)
3. `session_client`: API client with session authentication (for web app API tests)
4. `create_user`: Creates and returns a test user
5. `mock_redis`: Mocks Redis interactions
6. `mock_elasticsearch`: Mocks Elasticsearch interactions
7. `mock_celery`: Mocks Celery task execution
## Using Factory Boy
For more complex test data setup, use the provided factories:
```python
from plane.tests.factories import UserFactory, WorkspaceFactory
# Create a user
user = UserFactory()
# Create a workspace with a specific owner
workspace = WorkspaceFactory(owner=user)
# Create multiple objects
users = UserFactory.create_batch(5)
```
## Running Tests
Use pytest to run tests:
```bash
# Run all tests
python -m pytest
# Run only unit tests with coverage
python -m pytest -m unit --cov=plane
```
## Best Practices
1. **Keep tests small and focused** - Each test should verify one specific behavior.
2. **Use markers** - Always add appropriate markers (`@pytest.mark.unit`, etc.).
3. **Mock external dependencies** - Use the provided mock fixtures.
4. **Use factories** - For complex data setup, use factories.
5. **Don't test the framework** - Focus on testing your business logic, not Django/DRF itself.
6. **Write readable assertions** - Use plain `assert` statements with clear messaging.
7. **Focus on coverage** - Aim for ≥90% code coverage for critical components.
+1 -1
View File
@@ -1 +1 @@
# Test package initialization
from .api import *
+34
View File
@@ -0,0 +1,34 @@
# Third party imports
from rest_framework.test import APITestCase, APIClient
# Module imports
from plane.db.models import User
from plane.app.views.authentication import get_tokens_for_user
class BaseAPITest(APITestCase):
def setUp(self):
self.client = APIClient(HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10")
class AuthenticatedAPITest(BaseAPITest):
def setUp(self):
super().setUp()
## Create Dummy User
self.email = "user@plane.so"
user = User.objects.create(email=self.email)
user.set_password("user@123")
user.save()
# Set user
self.user = user
# Set Up User ID
self.user_id = user.id
access_token, _ = get_tokens_for_user(user)
self.access_token = access_token
# Set Up Authentication Token
self.client.credentials(HTTP_AUTHORIZATION="Bearer " + access_token)
+1
View File
@@ -0,0 +1 @@
# TODO: Tests for File Asset Uploads
@@ -0,0 +1 @@
# TODO: Tests for ChangePassword and other Endpoints
@@ -0,0 +1,183 @@
# Python import
import json
# Django imports
from django.urls import reverse
# Third Party imports
from rest_framework import status
from .base import BaseAPITest
# Module imports
from plane.db.models import User
from plane.settings.redis import redis_instance
class SignInEndpointTests(BaseAPITest):
def setUp(self):
super().setUp()
user = User.objects.create(email="user@plane.so")
user.set_password("user@123")
user.save()
def test_without_data(self):
url = reverse("sign-in")
response = self.client.post(url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_email_validity(self):
url = reverse("sign-in")
response = self.client.post(
url, {"email": "useremail.com", "password": "user@123"}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(
response.data, {"error": "Please provide a valid email address."}
)
def test_password_validity(self):
url = reverse("sign-in")
response = self.client.post(
url, {"email": "user@plane.so", "password": "user123"}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(
response.data,
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
)
def test_user_exists(self):
url = reverse("sign-in")
response = self.client.post(
url, {"email": "user@email.so", "password": "user123"}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(
response.data,
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
)
def test_user_login(self):
url = reverse("sign-in")
response = self.client.post(
url, {"email": "user@plane.so", "password": "user@123"}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data.get("user").get("email"), "user@plane.so")
class MagicLinkGenerateEndpointTests(BaseAPITest):
def setUp(self):
super().setUp()
user = User.objects.create(email="user@plane.so")
user.set_password("user@123")
user.save()
def test_without_data(self):
url = reverse("magic-generate")
response = self.client.post(url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_email_validity(self):
url = reverse("magic-generate")
response = self.client.post(url, {"email": "useremail.com"}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(
response.data, {"error": "Please provide a valid email address."}
)
def test_magic_generate(self):
url = reverse("magic-generate")
ri = redis_instance()
ri.delete("magic_user@plane.so")
response = self.client.post(url, {"email": "user@plane.so"}, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_max_generate_attempt(self):
url = reverse("magic-generate")
ri = redis_instance()
ri.delete("magic_user@plane.so")
for _ in range(4):
response = self.client.post(url, {"email": "user@plane.so"}, format="json")
response = self.client.post(url, {"email": "user@plane.so"}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(
response.data, {"error": "Max attempts exhausted. Please try again later."}
)
class MagicSignInEndpointTests(BaseAPITest):
def setUp(self):
super().setUp()
user = User.objects.create(email="user@plane.so")
user.set_password("user@123")
user.save()
def test_without_data(self):
url = reverse("magic-sign-in")
response = self.client.post(url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data, {"error": "User token and key are required"})
def test_expired_invalid_magic_link(self):
ri = redis_instance()
ri.delete("magic_user@plane.so")
url = reverse("magic-sign-in")
response = self.client.post(
url,
{"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(
response.data, {"error": "The magic code/link has expired please try again"}
)
def test_invalid_magic_code(self):
ri = redis_instance()
ri.delete("magic_user@plane.so")
## Create Token
url = reverse("magic-generate")
self.client.post(url, {"email": "user@plane.so"}, format="json")
url = reverse("magic-sign-in")
response = self.client.post(
url,
{"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(
response.data, {"error": "Your login code was incorrect. Please try again."}
)
def test_magic_code_sign_in(self):
ri = redis_instance()
ri.delete("magic_user@plane.so")
## Create Token
url = reverse("magic-generate")
self.client.post(url, {"email": "user@plane.so"}, format="json")
# Get the token
user_data = json.loads(ri.get("magic_user@plane.so"))
token = user_data["token"]
url = reverse("magic-sign-in")
response = self.client.post(
url, {"key": "magic_user@plane.so", "token": token}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data.get("user").get("email"), "user@plane.so")
+1
View File
@@ -0,0 +1 @@
# TODO: Write Test for Cycle Endpoints
+1
View File
@@ -0,0 +1 @@
# TODO: Write Test for Issue Endpoints
+1
View File
@@ -0,0 +1 @@
# TODO: Tests for OAuth Authentication Endpoint
+1
View File
@@ -0,0 +1 @@
# TODO: Write Test for people Endpoint
@@ -0,0 +1 @@
# TODO: Write Tests for project endpoints
@@ -0,0 +1 @@
# TODO: Write Test for shortcuts
+1
View File
@@ -0,0 +1 @@
# TODO: Wrote test for state endpoints
+1
View File
@@ -0,0 +1 @@
# TODO: Write test for view endpoints
@@ -0,0 +1,44 @@
# Django imports
from django.urls import reverse
# Third party import
from rest_framework import status
# Module imports
from .base import AuthenticatedAPITest
from plane.db.models import Workspace, WorkspaceMember
class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest):
def setUp(self):
super().setUp()
def test_create_workspace(self):
url = reverse("workspace")
# Test with empty data
response = self.client.post(url, {}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
# Test with valid data
response = self.client.post(
url, {"name": "Plane", "slug": "pla-ne"}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Workspace.objects.count(), 1)
# Check if the member is created
self.assertEqual(WorkspaceMember.objects.count(), 1)
# Check other values
workspace = Workspace.objects.get(pk=response.data["id"])
workspace_member = WorkspaceMember.objects.get(
workspace=workspace, member_id=self.user_id
)
self.assertEqual(workspace.owner_id, self.user_id)
self.assertEqual(workspace_member.role, 20)
# Create a already existing workspace
response = self.client.post(
url, {"name": "Plane", "slug": "pla-ne"}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
-78
View File
@@ -1,78 +0,0 @@
import pytest
from django.conf import settings
from rest_framework.test import APIClient
from pytest_django.fixtures import django_db_setup
from unittest.mock import patch, MagicMock
from plane.db.models import User
from plane.db.models.api import APIToken
@pytest.fixture(scope="session")
def django_db_setup(django_db_setup):
"""Set up the Django database for the test session"""
pass
@pytest.fixture
def api_client():
"""Return an unauthenticated API client"""
return APIClient()
@pytest.fixture
def user_data():
"""Return standard user data for tests"""
return {
"email": "test@plane.so",
"password": "test-password",
"first_name": "Test",
"last_name": "User"
}
@pytest.fixture
def create_user(db, user_data):
"""Create and return a user instance"""
user = User.objects.create(
email=user_data["email"],
first_name=user_data["first_name"],
last_name=user_data["last_name"]
)
user.set_password(user_data["password"])
user.save()
return user
@pytest.fixture
def api_token(db, create_user):
"""Create and return an API token for testing the external API"""
token = APIToken.objects.create(
user=create_user,
label="Test API Token",
token="test-api-token-12345",
)
return token
@pytest.fixture
def api_key_client(api_client, api_token):
"""Return an API key authenticated client for external API testing"""
api_client.credentials(HTTP_X_API_KEY=api_token.token)
return api_client
@pytest.fixture
def session_client(api_client, create_user):
"""Return a session authenticated API client for app API testing, which is what plane.app uses"""
api_client.force_authenticate(user=create_user)
return api_client
@pytest.fixture
def plane_server(live_server):
"""
Renamed version of live_server fixture to avoid name clashes.
Returns a live Django server for testing HTTP requests.
"""
return live_server
-117
View File
@@ -1,117 +0,0 @@
import pytest
from unittest.mock import MagicMock, patch
from django.conf import settings
@pytest.fixture
def mock_redis():
"""
Mock Redis for testing without actual Redis connection.
This fixture patches the redis_instance function to return a MagicMock
that behaves like a Redis client.
"""
mock_redis_client = MagicMock()
# Configure the mock to handle common Redis operations
mock_redis_client.get.return_value = None
mock_redis_client.set.return_value = True
mock_redis_client.delete.return_value = True
mock_redis_client.exists.return_value = 0
mock_redis_client.ttl.return_value = -1
# Start the patch
with patch('plane.settings.redis.redis_instance', return_value=mock_redis_client):
yield mock_redis_client
@pytest.fixture
def mock_elasticsearch():
"""
Mock Elasticsearch for testing without actual ES connection.
This fixture patches Elasticsearch to return a MagicMock
that behaves like an Elasticsearch client.
"""
mock_es_client = MagicMock()
# Configure the mock to handle common ES operations
mock_es_client.indices.exists.return_value = True
mock_es_client.indices.create.return_value = {"acknowledged": True}
mock_es_client.search.return_value = {"hits": {"total": {"value": 0}, "hits": []}}
mock_es_client.index.return_value = {"_id": "test_id", "result": "created"}
mock_es_client.update.return_value = {"_id": "test_id", "result": "updated"}
mock_es_client.delete.return_value = {"_id": "test_id", "result": "deleted"}
# Start the patch
with patch('elasticsearch.Elasticsearch', return_value=mock_es_client):
yield mock_es_client
@pytest.fixture
def mock_mongodb():
"""
Mock MongoDB for testing without actual MongoDB connection.
This fixture patches PyMongo to return a MagicMock that behaves like a MongoDB client.
"""
# Create mock MongoDB clients and collections
mock_mongo_client = MagicMock()
mock_mongo_db = MagicMock()
mock_mongo_collection = MagicMock()
# Set up the chain: client -> database -> collection
mock_mongo_client.__getitem__.return_value = mock_mongo_db
mock_mongo_client.get_database.return_value = mock_mongo_db
mock_mongo_db.__getitem__.return_value = mock_mongo_collection
# Configure common MongoDB collection operations
mock_mongo_collection.find_one.return_value = None
mock_mongo_collection.find.return_value = MagicMock(
__iter__=lambda x: iter([]),
count=lambda: 0
)
mock_mongo_collection.insert_one.return_value = MagicMock(
inserted_id="mock_id_123",
acknowledged=True
)
mock_mongo_collection.insert_many.return_value = MagicMock(
inserted_ids=["mock_id_123", "mock_id_456"],
acknowledged=True
)
mock_mongo_collection.update_one.return_value = MagicMock(
modified_count=1,
matched_count=1,
acknowledged=True
)
mock_mongo_collection.update_many.return_value = MagicMock(
modified_count=2,
matched_count=2,
acknowledged=True
)
mock_mongo_collection.delete_one.return_value = MagicMock(
deleted_count=1,
acknowledged=True
)
mock_mongo_collection.delete_many.return_value = MagicMock(
deleted_count=2,
acknowledged=True
)
mock_mongo_collection.count_documents.return_value = 0
# Start the patch
with patch('pymongo.MongoClient', return_value=mock_mongo_client):
yield mock_mongo_client
@pytest.fixture
def mock_celery():
"""
Mock Celery for testing without actual task execution.
This fixture patches Celery's task.delay() to prevent actual task execution.
"""
# Start the patch
with patch('celery.app.task.Task.delay') as mock_delay:
mock_delay.return_value = MagicMock(id="mock-task-id")
yield mock_delay
@@ -1 +0,0 @@
@@ -1,459 +0,0 @@
import json
import uuid
import pytest
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from django.test import Client
from django.core.exceptions import ValidationError
from unittest.mock import patch, MagicMock
from plane.db.models import User
from plane.settings.redis import redis_instance
from plane.license.models import Instance
@pytest.fixture
def setup_instance(db):
"""Create and configure an instance for authentication tests"""
instance_id = uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id
# Create or update instance with all required fields
instance, _ = Instance.objects.update_or_create(
id=instance_id,
defaults={
"instance_name": "Test Instance",
"instance_id": str(uuid.uuid4()),
"current_version": "1.0.0",
"domain": "http://localhost:8000",
"last_checked_at": timezone.now(),
"is_setup_done": True,
}
)
return instance
@pytest.fixture
def django_client():
"""Return a Django test client with User-Agent header for handling redirects"""
client = Client(HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1")
return client
@pytest.mark.contract
class TestMagicLinkGenerate:
"""Test magic link generation functionality"""
@pytest.fixture
def setup_user(self, db):
"""Create a test user for magic link tests"""
user = User.objects.create(email="user@plane.so")
user.set_password("user@123")
user.save()
return user
@pytest.mark.django_db
def test_without_data(self, api_client, setup_user, setup_instance):
"""Test magic link generation with empty data"""
url = reverse("magic-generate")
try:
response = api_client.post(url, {}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
except ValidationError:
# If a ValidationError is raised directly, that's also acceptable
# as it indicates the empty email was rejected
assert True
@pytest.mark.django_db
def test_email_validity(self, api_client, setup_user, setup_instance):
"""Test magic link generation with invalid email format"""
url = reverse("magic-generate")
try:
response = api_client.post(url, {"email": "useremail.com"}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "error_code" in response.data # Check for error code in response
except ValidationError:
# If a ValidationError is raised directly, that's also acceptable
# as it indicates the invalid email was rejected
assert True
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_magic_generate(self, mock_magic_link, api_client, setup_user, setup_instance):
"""Test successful magic link generation"""
url = reverse("magic-generate")
ri = redis_instance()
ri.delete("magic_user@plane.so")
response = api_client.post(url, {"email": "user@plane.so"}, format="json")
assert response.status_code == status.HTTP_200_OK
assert "key" in response.data # Check for key in response
# Verify the mock was called with the expected arguments
mock_magic_link.assert_called_once()
args = mock_magic_link.call_args[0]
assert args[0] == "user@plane.so" # First arg should be the email
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_max_generate_attempt(self, mock_magic_link, api_client, setup_user, setup_instance):
"""Test exceeding maximum magic link generation attempts"""
url = reverse("magic-generate")
ri = redis_instance()
ri.delete("magic_user@plane.so")
for _ in range(4):
api_client.post(url, {"email": "user@plane.so"}, format="json")
response = api_client.post(url, {"email": "user@plane.so"}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "error_code" in response.data # Check for error code in response
@pytest.mark.contract
class TestSignInEndpoint:
"""Test sign-in functionality"""
@pytest.fixture
def setup_user(self, db):
"""Create a test user for authentication tests"""
user = User.objects.create(email="user@plane.so")
user.set_password("user@123")
user.save()
return user
@pytest.mark.django_db
def test_without_data(self, django_client, setup_user, setup_instance):
"""Test sign-in with empty data"""
url = reverse("sign-in")
response = django_client.post(url, {}, follow=True)
# Check redirect contains error code
assert "REQUIRED_EMAIL_PASSWORD_SIGN_IN" in response.redirect_chain[-1][0]
@pytest.mark.django_db
def test_email_validity(self, django_client, setup_user, setup_instance):
"""Test sign-in with invalid email format"""
url = reverse("sign-in")
response = django_client.post(
url, {"email": "useremail.com", "password": "user@123"}, follow=True
)
# Check redirect contains error code
assert "INVALID_EMAIL_SIGN_IN" in response.redirect_chain[-1][0]
@pytest.mark.django_db
def test_user_exists(self, django_client, setup_user, setup_instance):
"""Test sign-in with non-existent user"""
url = reverse("sign-in")
response = django_client.post(
url, {"email": "user@email.so", "password": "user123"}, follow=True
)
# Check redirect contains error code
assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0]
@pytest.mark.django_db
def test_password_validity(self, django_client, setup_user, setup_instance):
"""Test sign-in with incorrect password"""
url = reverse("sign-in")
response = django_client.post(
url, {"email": "user@plane.so", "password": "user123"}, follow=True
)
# Check for the specific authentication error in the URL
redirect_urls = [url for url, _ in response.redirect_chain]
redirect_contents = ' '.join(redirect_urls)
# The actual error code for invalid password is AUTHENTICATION_FAILED_SIGN_IN
assert "AUTHENTICATION_FAILED_SIGN_IN" in redirect_contents
@pytest.mark.django_db
def test_user_login(self, django_client, setup_user, setup_instance):
"""Test successful sign-in"""
url = reverse("sign-in")
# First make the request without following redirects
response = django_client.post(
url, {"email": "user@plane.so", "password": "user@123"}, follow=False
)
# Check that the initial response is a redirect (302) without error code
assert response.status_code == 302
assert "error_code" not in response.url
# Now follow just the first redirect to avoid 404s
response = django_client.get(response.url, follow=False)
# The user should be authenticated regardless of the final page
assert "_auth_user_id" in django_client.session
@pytest.mark.django_db
def test_next_path_redirection(self, django_client, setup_user, setup_instance):
"""Test sign-in with next_path parameter"""
url = reverse("sign-in")
next_path = "workspaces"
# First make the request without following redirects
response = django_client.post(
url,
{"email": "user@plane.so", "password": "user@123", "next_path": next_path},
follow=False
)
# Check that the initial response is a redirect (302) without error code
assert response.status_code == 302
assert "error_code" not in response.url
# In a real browser, the next_path would be used to build the absolute URL
# Since we're just testing the authentication logic, we won't check for the exact URL structure
# Instead, just verify that we're authenticated
assert "_auth_user_id" in django_client.session
@pytest.mark.contract
class TestMagicSignIn:
"""Test magic link sign-in functionality"""
@pytest.fixture
def setup_user(self, db):
"""Create a test user for magic sign-in tests"""
user = User.objects.create(email="user@plane.so")
user.set_password("user@123")
user.save()
return user
@pytest.mark.django_db
def test_without_data(self, django_client, setup_user, setup_instance):
"""Test magic link sign-in with empty data"""
url = reverse("magic-sign-in")
response = django_client.post(url, {}, follow=True)
# Check redirect contains error code
assert "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0]
@pytest.mark.django_db
def test_expired_invalid_magic_link(self, django_client, setup_user, setup_instance):
"""Test magic link sign-in with expired/invalid link"""
ri = redis_instance()
ri.delete("magic_user@plane.so")
url = reverse("magic-sign-in")
response = django_client.post(
url,
{"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"},
follow=False
)
# Check that we get a redirect
assert response.status_code == 302
# The actual error code is EXPIRED_MAGIC_CODE_SIGN_IN (when key doesn't exist)
# or INVALID_MAGIC_CODE_SIGN_IN (when key exists but code doesn't match)
assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url or "INVALID_MAGIC_CODE_SIGN_IN" in response.url
@pytest.mark.django_db
def test_user_does_not_exist(self, django_client, setup_instance):
"""Test magic sign-in with non-existent user"""
url = reverse("magic-sign-in")
response = django_client.post(
url,
{"email": "nonexistent@plane.so", "code": "xxxx-xxxxx-xxxx"},
follow=True
)
# Check redirect contains error code
assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0]
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, setup_user, setup_instance):
"""Test successful magic link sign-in process"""
# First generate a magic link token
gen_url = reverse("magic-generate")
response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json")
# Check that the token generation was successful
assert response.status_code == status.HTTP_200_OK
# Since we're mocking the magic_link task, we need to manually get the token from Redis
ri = redis_instance()
user_data = json.loads(ri.get("magic_user@plane.so"))
token = user_data["token"]
# Use Django client to test the redirect flow without following redirects
url = reverse("magic-sign-in")
response = django_client.post(
url,
{"email": "user@plane.so", "code": token},
follow=False
)
# Check that the initial response is a redirect without error code
assert response.status_code == 302
assert "error_code" not in response.url
# The user should now be authenticated
assert "_auth_user_id" in django_client.session
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_magic_sign_in_with_next_path(self, mock_magic_link, django_client, api_client, setup_user, setup_instance):
"""Test magic sign-in with next_path parameter"""
# First generate a magic link token
gen_url = reverse("magic-generate")
response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json")
# Check that the token generation was successful
assert response.status_code == status.HTTP_200_OK
# Since we're mocking the magic_link task, we need to manually get the token from Redis
ri = redis_instance()
user_data = json.loads(ri.get("magic_user@plane.so"))
token = user_data["token"]
# Use Django client to test the redirect flow without following redirects
url = reverse("magic-sign-in")
next_path = "workspaces"
response = django_client.post(
url,
{"email": "user@plane.so", "code": token, "next_path": next_path},
follow=False
)
# Check that the initial response is a redirect without error code
assert response.status_code == 302
assert "error_code" not in response.url
# Check that the redirect URL contains the next_path
assert next_path in response.url
# The user should now be authenticated
assert "_auth_user_id" in django_client.session
@pytest.mark.contract
class TestMagicSignUp:
"""Test magic link sign-up functionality"""
@pytest.mark.django_db
def test_without_data(self, django_client, setup_instance):
"""Test magic link sign-up with empty data"""
url = reverse("magic-sign-up")
response = django_client.post(url, {}, follow=True)
# Check redirect contains error code
assert "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0]
@pytest.mark.django_db
def test_user_already_exists(self, django_client, db, setup_instance):
"""Test magic sign-up with existing user"""
# Create a user that already exists
User.objects.create(email="existing@plane.so")
url = reverse("magic-sign-up")
response = django_client.post(
url,
{"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"},
follow=True
)
# Check redirect contains error code
assert "USER_ALREADY_EXIST" in response.redirect_chain[-1][0]
@pytest.mark.django_db
def test_expired_invalid_magic_link(self, django_client, setup_instance):
"""Test magic link sign-up with expired/invalid link"""
url = reverse("magic-sign-up")
response = django_client.post(
url,
{"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"},
follow=False
)
# Check that we get a redirect
assert response.status_code == 302
# The actual error code is EXPIRED_MAGIC_CODE_SIGN_UP (when key doesn't exist)
# or INVALID_MAGIC_CODE_SIGN_UP (when key exists but code doesn't match)
assert "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url or "INVALID_MAGIC_CODE_SIGN_UP" in response.url
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, setup_instance):
"""Test successful magic link sign-up process"""
email = "newuser@plane.so"
# First generate a magic link token
gen_url = reverse("magic-generate")
response = api_client.post(gen_url, {"email": email}, format="json")
# Check that the token generation was successful
assert response.status_code == status.HTTP_200_OK
# Since we're mocking the magic_link task, we need to manually get the token from Redis
ri = redis_instance()
user_data = json.loads(ri.get(f"magic_{email}"))
token = user_data["token"]
# Use Django client to test the redirect flow without following redirects
url = reverse("magic-sign-up")
response = django_client.post(
url,
{"email": email, "code": token},
follow=False
)
# Check that the initial response is a redirect without error code
assert response.status_code == 302
assert "error_code" not in response.url
# Check if user was created
assert User.objects.filter(email=email).exists()
# Check if user is authenticated
assert "_auth_user_id" in django_client.session
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_client, setup_instance):
"""Test magic sign-up with next_path parameter"""
email = "newuser2@plane.so"
# First generate a magic link token
gen_url = reverse("magic-generate")
response = api_client.post(gen_url, {"email": email}, format="json")
# Check that the token generation was successful
assert response.status_code == status.HTTP_200_OK
# Since we're mocking the magic_link task, we need to manually get the token from Redis
ri = redis_instance()
user_data = json.loads(ri.get(f"magic_{email}"))
token = user_data["token"]
# Use Django client to test the redirect flow without following redirects
url = reverse("magic-sign-up")
next_path = "onboarding"
response = django_client.post(
url,
{"email": email, "code": token, "next_path": next_path},
follow=False
)
# Check that the initial response is a redirect without error code
assert response.status_code == 302
assert "error_code" not in response.url
# In a real browser, the next_path would be used to build the absolute URL
# Since we're just testing the authentication logic, we won't check for the exact URL structure
# Check if user was created
assert User.objects.filter(email=email).exists()
# Check if user is authenticated
assert "_auth_user_id" in django_client.session
@@ -1,79 +0,0 @@
import pytest
from django.urls import reverse
from rest_framework import status
from unittest.mock import patch
from plane.db.models import Workspace, WorkspaceMember
@pytest.mark.contract
class TestWorkspaceAPI:
"""Test workspace CRUD operations"""
@pytest.mark.django_db
def test_create_workspace_empty_data(self, session_client):
"""Test creating a workspace with empty data"""
url = reverse("workspace")
# Test with empty data
response = session_client.post(url, {}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
@patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay")
def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, create_user):
"""Test creating a workspace with valid data"""
url = reverse("workspace")
user = create_user # Use the create_user fixture directly as it returns a user object
# Test with valid data - include all required fields
workspace_data = {
"name": "Plane",
"slug": "pla-ne-test",
"company_name": "Plane Inc."
}
# Make the request
response = session_client.post(url, workspace_data, format="json")
# Check response status
assert response.status_code == status.HTTP_201_CREATED
# Verify workspace was created
assert Workspace.objects.count() == 1
# Check if the member is created
assert WorkspaceMember.objects.count() == 1
# Check other values
workspace = Workspace.objects.get(slug=workspace_data["slug"])
workspace_member = WorkspaceMember.objects.filter(
workspace=workspace, member=user
).first()
assert workspace.owner == user
assert workspace_member.role == 20
# Verify the workspace_seed task was called
mock_workspace_seed.assert_called_once_with(response.data["id"])
@pytest.mark.django_db
@patch('plane.bgtasks.workspace_seed_task.workspace_seed.delay')
def test_create_duplicate_workspace(self, mock_workspace_seed, session_client):
"""Test creating a duplicate workspace"""
url = reverse("workspace")
# Create first workspace
session_client.post(
url, {"name": "Plane", "slug": "pla-ne"}, format="json"
)
# Try to create a workspace with the same slug
response = session_client.post(
url, {"name": "Plane", "slug": "pla-ne"}, format="json"
)
# The API returns 400 BAD REQUEST for duplicate slugs, not 409 CONFLICT
assert response.status_code == status.HTTP_400_BAD_REQUEST
# Optionally check the error message to confirm it's related to the duplicate slug
assert "slug" in response.data
-82
View File
@@ -1,82 +0,0 @@
import factory
from uuid import uuid4
from django.utils import timezone
from plane.db.models import (
User,
Workspace,
WorkspaceMember,
Project,
ProjectMember
)
class UserFactory(factory.django.DjangoModelFactory):
"""Factory for creating User instances"""
class Meta:
model = User
django_get_or_create = ('email',)
id = factory.LazyFunction(uuid4)
email = factory.Sequence(lambda n: f'user{n}@plane.so')
password = factory.PostGenerationMethodCall('set_password', 'password')
first_name = factory.Sequence(lambda n: f'First{n}')
last_name = factory.Sequence(lambda n: f'Last{n}')
is_active = True
is_superuser = False
is_staff = False
class WorkspaceFactory(factory.django.DjangoModelFactory):
"""Factory for creating Workspace instances"""
class Meta:
model = Workspace
django_get_or_create = ('slug',)
id = factory.LazyFunction(uuid4)
name = factory.Sequence(lambda n: f'Workspace {n}')
slug = factory.Sequence(lambda n: f'workspace-{n}')
owner = factory.SubFactory(UserFactory)
created_at = factory.LazyFunction(timezone.now)
updated_at = factory.LazyFunction(timezone.now)
class WorkspaceMemberFactory(factory.django.DjangoModelFactory):
"""Factory for creating WorkspaceMember instances"""
class Meta:
model = WorkspaceMember
id = factory.LazyFunction(uuid4)
workspace = factory.SubFactory(WorkspaceFactory)
member = factory.SubFactory(UserFactory)
role = 20 # Admin role by default
created_at = factory.LazyFunction(timezone.now)
updated_at = factory.LazyFunction(timezone.now)
class ProjectFactory(factory.django.DjangoModelFactory):
"""Factory for creating Project instances"""
class Meta:
model = Project
django_get_or_create = ('name', 'workspace')
id = factory.LazyFunction(uuid4)
name = factory.Sequence(lambda n: f'Project {n}')
workspace = factory.SubFactory(WorkspaceFactory)
created_by = factory.SelfAttribute('workspace.owner')
updated_by = factory.SelfAttribute('workspace.owner')
created_at = factory.LazyFunction(timezone.now)
updated_at = factory.LazyFunction(timezone.now)
class ProjectMemberFactory(factory.django.DjangoModelFactory):
"""Factory for creating ProjectMember instances"""
class Meta:
model = ProjectMember
id = factory.LazyFunction(uuid4)
project = factory.SubFactory(ProjectFactory)
member = factory.SubFactory(UserFactory)
role = 20 # Admin role by default
created_at = factory.LazyFunction(timezone.now)
updated_at = factory.LazyFunction(timezone.now)
@@ -1,100 +0,0 @@
import pytest
import requests
from django.urls import reverse
@pytest.mark.smoke
class TestAuthSmoke:
"""Smoke tests for authentication endpoints"""
@pytest.mark.django_db
def test_login_endpoint_available(self, plane_server, create_user, user_data):
"""Test that the login endpoint is available and responds correctly"""
# Get the sign-in URL
relative_url = reverse("sign-in")
url = f"{plane_server.url}{relative_url}"
# 1. Test bad login - test with wrong password
response = requests.post(
url,
data={
"email": user_data["email"],
"password": "wrong-password"
}
)
# For bad credentials, any of these status codes would be valid
# The test shouldn't be brittle to minor implementation changes
assert response.status_code != 500, "Authentication should not cause server errors"
assert response.status_code != 404, "Authentication endpoint should exist"
if response.status_code == 200:
# If API returns 200 for failures, check the response body for error indication
if hasattr(response, 'json'):
try:
data = response.json()
# JSON response might indicate error in its structure
assert "error" in data or "error_code" in data or "detail" in data or response.url.endswith("sign-in"), \
"Error response should contain error details"
except ValueError:
# It's ok if response isn't JSON format
pass
elif response.status_code in [302, 303]:
# If it's a redirect, it should redirect to a login page or error page
redirect_url = response.headers.get('Location', '')
assert "error" in redirect_url or "sign-in" in redirect_url, \
"Failed login should redirect to login page or error page"
# 2. Test good login with correct credentials
response = requests.post(
url,
data={
"email": user_data["email"],
"password": user_data["password"]
},
allow_redirects=False # Don't follow redirects
)
# Successful auth should not be a client error or server error
assert response.status_code not in range(400, 600), \
f"Authentication with valid credentials failed with status {response.status_code}"
# Specific validation based on response type
if response.status_code in [302, 303]:
# Redirect-based auth: check that redirect URL doesn't contain error
redirect_url = response.headers.get('Location', '')
assert "error" not in redirect_url and "error_code" not in redirect_url, \
"Successful login redirect should not contain error parameters"
elif response.status_code == 200:
# API token-based auth: check for tokens or user session
if hasattr(response, 'json'):
try:
data = response.json()
# If it's a token response
if "access_token" in data:
assert "refresh_token" in data, "JWT auth should return both access and refresh tokens"
# If it's a user session response
elif "user" in data:
assert "is_authenticated" in data and data["is_authenticated"], \
"User session response should indicate authentication"
# Otherwise it should at least indicate success
else:
assert not any(error_key in data for error_key in ["error", "error_code", "detail"]), \
"Success response should not contain error keys"
except ValueError:
# Non-JSON is acceptable if it's a redirect or HTML response
pass
@pytest.mark.smoke
class TestHealthCheckSmoke:
"""Smoke test for health check endpoint"""
def test_healthcheck_endpoint(self, plane_server):
"""Test that the health check endpoint is available and responds correctly"""
# Make a request to the health check endpoint
response = requests.get(f"{plane_server.url}/")
# Should be OK
assert response.status_code == 200, "Health check endpoint should return 200 OK"
@@ -1,50 +0,0 @@
import pytest
from uuid import uuid4
from plane.db.models import Workspace, WorkspaceMember, User
@pytest.mark.unit
class TestWorkspaceModel:
"""Test the Workspace model"""
@pytest.mark.django_db
def test_workspace_creation(self, create_user):
"""Test creating a workspace"""
# Create a workspace
workspace = Workspace.objects.create(
name="Test Workspace",
slug="test-workspace",
id=uuid4(),
owner=create_user
)
# Verify it was created
assert workspace.id is not None
assert workspace.name == "Test Workspace"
assert workspace.slug == "test-workspace"
assert workspace.owner == create_user
@pytest.mark.django_db
def test_workspace_member_creation(self, create_user):
"""Test creating a workspace member"""
# Create a workspace
workspace = Workspace.objects.create(
name="Test Workspace",
slug="test-workspace",
id=uuid4(),
owner=create_user
)
# Create a workspace member
workspace_member = WorkspaceMember.objects.create(
workspace=workspace,
member=create_user,
role=20 # Admin role
)
# Verify it was created
assert workspace_member.id is not None
assert workspace_member.workspace == workspace
assert workspace_member.member == create_user
assert workspace_member.role == 20
@@ -1,71 +0,0 @@
import pytest
from uuid import uuid4
from plane.api.serializers import WorkspaceLiteSerializer
from plane.db.models import Workspace, User
@pytest.mark.unit
class TestWorkspaceLiteSerializer:
"""Test the WorkspaceLiteSerializer"""
def test_workspace_lite_serializer_fields(self, db):
"""Test that the serializer includes the correct fields"""
# Create a user to be the owner
owner = User.objects.create(
email="test@example.com",
first_name="Test",
last_name="User"
)
# Create a workspace with explicit ID to test serialization
workspace_id = uuid4()
workspace = Workspace.objects.create(
name="Test Workspace",
slug="test-workspace",
id=workspace_id,
owner=owner
)
# Serialize the workspace
serialized_data = WorkspaceLiteSerializer(workspace).data
# Check fields are present and correct
assert "name" in serialized_data
assert "slug" in serialized_data
assert "id" in serialized_data
assert serialized_data["name"] == "Test Workspace"
assert serialized_data["slug"] == "test-workspace"
assert str(serialized_data["id"]) == str(workspace_id)
def test_workspace_lite_serializer_read_only(self, db):
"""Test that the serializer fields are read-only"""
# Create a user to be the owner
owner = User.objects.create(
email="test2@example.com",
first_name="Test",
last_name="User"
)
# Create a workspace
workspace = Workspace.objects.create(
name="Test Workspace",
slug="test-workspace",
id=uuid4(),
owner=owner
)
# Try to update via serializer
serializer = WorkspaceLiteSerializer(
workspace,
data={"name": "Updated Name", "slug": "updated-slug"}
)
# Serializer should be valid (since read-only fields are ignored)
assert serializer.is_valid()
# Save should not update the read-only fields
updated_workspace = serializer.save()
assert updated_workspace.name == "Test Workspace"
assert updated_workspace.slug == "test-workspace"
@@ -1,49 +0,0 @@
import uuid
import pytest
from plane.utils.uuid import is_valid_uuid, convert_uuid_to_integer
@pytest.mark.unit
class TestUUIDUtils:
"""Test the UUID utilities"""
def test_is_valid_uuid_with_valid_uuid(self):
"""Test is_valid_uuid with a valid UUID"""
# Generate a valid UUID
valid_uuid = str(uuid.uuid4())
assert is_valid_uuid(valid_uuid) is True
def test_is_valid_uuid_with_invalid_uuid(self):
"""Test is_valid_uuid with invalid UUID strings"""
# Test with different invalid formats
assert is_valid_uuid("not-a-uuid") is False
assert is_valid_uuid("123456789") is False
assert is_valid_uuid("") is False
assert is_valid_uuid("00000000-0000-0000-0000-000000000000") is False # This is a valid UUID but version 1
def test_convert_uuid_to_integer(self):
"""Test convert_uuid_to_integer function"""
# Create a known UUID
test_uuid = uuid.UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479")
# Convert to integer
result = convert_uuid_to_integer(test_uuid)
# Check that the result is an integer
assert isinstance(result, int)
# Ensure consistent results with the same input
assert convert_uuid_to_integer(test_uuid) == result
# Different UUIDs should produce different integers
different_uuid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000")
assert convert_uuid_to_integer(different_uuid) != result
def test_convert_uuid_to_integer_string_input(self):
"""Test convert_uuid_to_integer handles string UUID"""
# Test with a UUID string
test_uuid_str = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
test_uuid = uuid.UUID(test_uuid_str)
# Should get the same result whether passing UUID or string
assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer(test_uuid_str)
+28
View File
@@ -77,3 +77,31 @@ def normalize_url_path(url: str) -> str:
normalized_path = re.sub(r"/+", "/", parts.path)
# Reconstruct the URL
return urlunparse(parts._replace(path=normalized_path))
def clean_value(value: str) -> str:
"""
Clean the value by removing URLs and domain patterns.
Args:
value (str): The value to clean.
Returns:
str: The cleaned value.
"""
if not value:
return value
url_pattern = r"(https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" # noqa
# Remove URLs and domains
cleaned_value = re.sub(url_pattern, "", value)
# Keep only alphanumeric characters and spaces
cleaned_value = re.sub(r"[^a-zA-Z0-9\s]", "", cleaned_value)
# Remove extra spaces and trim
cleaned_value = " ".join(cleaned_value.split())
return cleaned_value
-17
View File
@@ -1,17 +0,0 @@
[pytest]
DJANGO_SETTINGS_MODULE = plane.settings.test
python_files = test_*.py
python_classes = Test*
python_functions = test_*
markers =
unit: Unit tests for models, serializers, and utility functions
contract: Contract tests for API endpoints
smoke: Smoke tests for critical functionality
slow: Tests that are slow and might be skipped in some contexts
addopts =
--strict-markers
--reuse-db
--nomigrations
-vs
+3 -11
View File
@@ -1,12 +1,4 @@
-r base.txt
# test framework
pytest==7.4.0
pytest-django==4.5.2
pytest-cov==4.1.0
pytest-xdist==3.3.1
pytest-mock==3.11.1
factory-boy==3.3.0
freezegun==1.2.2
coverage==7.2.7
httpx==0.24.1
requests==2.32.2
# test checker
pytest==7.1.2
coverage==6.5.0
-91
View File
@@ -1,91 +0,0 @@
#!/usr/bin/env python
import argparse
import subprocess
import sys
def main():
parser = argparse.ArgumentParser(description="Run Plane tests")
parser.add_argument(
"-u", "--unit",
action="store_true",
help="Run unit tests only"
)
parser.add_argument(
"-c", "--contract",
action="store_true",
help="Run contract tests only"
)
parser.add_argument(
"-s", "--smoke",
action="store_true",
help="Run smoke tests only"
)
parser.add_argument(
"-o", "--coverage",
action="store_true",
help="Generate coverage report"
)
parser.add_argument(
"-p", "--parallel",
action="store_true",
help="Run tests in parallel"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Verbose output"
)
args = parser.parse_args()
# Build command
cmd = ["python", "-m", "pytest"]
markers = []
# Add test markers
if args.unit:
markers.append("unit")
if args.contract:
markers.append("contract")
if args.smoke:
markers.append("smoke")
# Add markers filter
if markers:
cmd.extend(["-m", " or ".join(markers)])
# Add coverage
if args.coverage:
cmd.extend(["--cov=plane", "--cov-report=term", "--cov-report=html"])
# Add parallel
if args.parallel:
cmd.extend(["-n", "auto"])
# Add verbose
if args.verbose:
cmd.append("-v")
# Add common flags
cmd.extend(["--reuse-db", "--nomigrations"])
# Print command
print(f"Running: {' '.join(cmd)}")
# Execute command
result = subprocess.run(cmd)
# Check coverage thresholds if coverage is enabled
if args.coverage:
print("Checking coverage thresholds...")
coverage_cmd = ["python", "-m", "coverage", "report", "--fail-under=90"]
coverage_result = subprocess.run(coverage_cmd)
if coverage_result.returncode != 0:
print("Coverage below threshold (90%)")
sys.exit(coverage_result.returncode)
sys.exit(result.returncode)
if __name__ == "__main__":
main()
-4
View File
@@ -1,4 +0,0 @@
#!/bin/bash
# This is a simple wrapper script that calls the main test runner in the tests directory
exec tests/run_tests.sh "$@"
@@ -209,7 +209,7 @@
{% for actor_comment in comment.actor_comments.new_value %}
<tr>
<td>
<div style=" padding: 6px 10px; margin-left: 10px; background-color: white; font-size: 0.8rem; color: #525252; margin-top: 5px; border-radius: 4px; overflow-x: scroll; max-width: 430px;" >
<div style=" padding: 6px 10px; margin-left: 10px; background-color: white; font-size: 0.8rem; color: #525252; margin-top: 5px; border-radius: 4px; display: flex; align-items: center; " >
<p> {{ actor_comment|safe }} </p>
</div>
</td>
+1 -1
View File
@@ -57,7 +57,7 @@
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsup": "8.4.0",
"tsup": "^8.4.0",
"typescript": "5.3.3"
}
}
+1 -2
View File
@@ -30,8 +30,7 @@
"nanoid": "3.3.8",
"esbuild": "0.25.0",
"@babel/helpers": "7.26.10",
"@babel/runtime": "7.26.10",
"chokidar": "3.6.0"
"@babel/runtime": "7.26.10"
},
"packageManager": "yarn@1.22.22"
}
-13
View File
@@ -1,14 +1 @@
export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
export const ACCEPTED_AVATAR_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE = {
"image/jpeg": [],
"image/jpg": [],
"image/png": [],
"image/webp": [],
};
export const ACCEPTED_COVER_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE = {
"image/jpeg": [],
"image/jpg": [],
"image/png": [],
"image/webp": [],
};
+1 -1
View File
@@ -355,7 +355,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
sub_work_items: {
list: {
display_properties: SUB_ISSUES_DISPLAY_PROPERTIES_KEYS,
filters: ["priority", "state", "issue_type", "assignees", "start_date", "target_date"],
filters: ["priority", "state", "project", "issue_type", "assignees", "start_date", "target_date"],
display_filters: {
order_by: ["-created_at", "-updated_at", "start_date", "-priority"],
group_by: ["state", "priority", "assignees", null],
+1 -1
View File
@@ -81,7 +81,7 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"postcss": "^8.4.38",
"tsup": "8.4.0",
"tsup": "^8.4.0",
"typescript": "5.3.3"
},
"keywords": [
@@ -1,14 +0,0 @@
import { ExtensionFileSetStorageKey } from "@/plane-editor/types/storage";
export const NODE_FILE_MAP: {
[key: string]: {
fileSetName: ExtensionFileSetStorageKey;
};
} = {
image: {
fileSetName: "deletedImageSet",
},
imageComponent: {
fileSetName: "deletedImageSet",
},
};
@@ -1,4 +1,5 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { Extensions } from "@tiptap/core";
import { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
// plane editor types
+10 -17
View File
@@ -1,20 +1,13 @@
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { type HeadingExtensionStorage } from "@/extensions";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image";
import { type CustomLinkStorage } from "@/extensions/custom-link";
import { type ImageExtensionStorage } from "@/extensions/image";
import { type MentionExtensionStorage } from "@/extensions/mentions";
import { type UtilityExtensionStorage } from "@/extensions/utility";
import { HeadingExtensionStorage } from "@/extensions";
import { CustomImageExtensionStorage } from "@/extensions/custom-image";
import { CustomLinkStorage } from "@/extensions/custom-link";
import { MentionExtensionStorage } from "@/extensions/mentions";
import { ImageExtensionStorage } from "@/plugins/image";
export type ExtensionStorageMap = {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: CustomImageExtensionStorage;
[CORE_EXTENSIONS.IMAGE]: ImageExtensionStorage;
[CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage;
[CORE_EXTENSIONS.HEADINGS_LIST]: HeadingExtensionStorage;
[CORE_EXTENSIONS.MENTION]: MentionExtensionStorage;
[CORE_EXTENSIONS.UTILITY]: UtilityExtensionStorage;
imageComponent: CustomImageExtensionStorage;
image: ImageExtensionStorage;
link: CustomLinkStorage;
headingList: HeadingExtensionStorage;
mention: MentionExtensionStorage;
};
export type ExtensionFileSetStorageKey = Extract<keyof ImageExtensionStorage, "deletedImageSet">;
@@ -7,7 +7,7 @@ import { DocumentContentLoader, PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { WorkItemEmbedExtension } from "@/extensions";
import { IssueWidget } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
@@ -39,10 +39,9 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
} = props;
const extensions: Extensions = [];
if (embedHandler?.issue) {
extensions.push(
WorkItemEmbedExtension({
IssueWidget({
widgetCallback: embedHandler.issue.widgetCallback,
})
);
@@ -7,7 +7,7 @@ import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { WorkItemEmbedExtension } from "@/extensions";
import { IssueWidget } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
@@ -53,7 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const extensions: Extensions = [];
if (embedHandler?.issue) {
extensions.push(
WorkItemEmbedExtension({
IssueWidget({
widgetCallback: embedHandler.issue.widgetCallback,
})
);
@@ -4,7 +4,6 @@ import { FC, ReactNode, useRef } from "react";
import { cn } from "@plane/utils";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// types
import { TDisplayConfig } from "@/types";
// components
@@ -37,12 +36,12 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
if (
currentNode.content.size === 0 && // Check if the current node is empty
!(
editor.isActive(CORE_EXTENSIONS.ORDERED_LIST) ||
editor.isActive(CORE_EXTENSIONS.BULLET_LIST) ||
editor.isActive(CORE_EXTENSIONS.TASK_ITEM) ||
editor.isActive(CORE_EXTENSIONS.TABLE) ||
editor.isActive(CORE_EXTENSIONS.BLOCKQUOTE) ||
editor.isActive(CORE_EXTENSIONS.CODE_BLOCK)
editor.isActive("orderedList") ||
editor.isActive("bulletList") ||
editor.isActive("taskItem") ||
editor.isActive("table") ||
editor.isActive("blockquote") ||
editor.isActive("codeBlock")
) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block
) {
return;
@@ -54,10 +53,10 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
const lastNode = lastNodePos.node();
// Check if the last node is a not paragraph
if (lastNode && lastNode.type.name !== CORE_EXTENSIONS.PARAGRAPH) {
if (lastNode && lastNode.type.name !== "paragraph") {
// If last node is not a paragraph, insert a new paragraph at the end
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run();
// Focus the newly added paragraph for immediate editing
editor
@@ -12,7 +12,7 @@ interface LinkViewContainerProps {
export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containerRef }) => {
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false);
const [virtualElement, setVirtualElement] = useState<Element | null>(null);
const [virtualElement, setVirtualElement] = useState<any>(null);
const editorState = useEditorState({
editor,
@@ -51,9 +51,7 @@ export const LinkEditView = ({ viewProps }: LinkEditViewProps) => {
if (!hasSubmitted.current && !linkRemoved && initialUrl === "") {
try {
removeLink();
} catch (e) {
console.error("Error removing link", e);
}
} catch (e) {}
}
},
[linkRemoved, initialUrl]
@@ -1,9 +1,7 @@
import { Editor } from "@tiptap/react";
import { Copy, LucideIcon, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
import { Editor } from "@tiptap/react";
import tippy, { Instance } from "tippy.js";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import { Copy, LucideIcon, Trash2 } from "lucide-react";
interface BlockMenuProps {
editor: Editor;
@@ -104,8 +102,7 @@ export const BlockMenu = (props: BlockMenuProps) => {
key: "duplicate",
label: "Duplicate",
isDisabled:
editor.state.selection.content().content.firstChild?.type.name === CORE_EXTENSIONS.IMAGE ||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE),
editor.state.selection.content().content.firstChild?.type.name === "image" || editor.isActive("imageComponent"),
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
@@ -1,10 +1,8 @@
import { Editor } from "@tiptap/core";
import { Check, Link, Trash2 } from "lucide-react";
import { Dispatch, FC, SetStateAction, useCallback, useRef, useState } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
@@ -45,7 +43,7 @@ export const BubbleMenuLinkSelector: FC<Props> = (props) => {
"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(CORE_EXTENSIONS.CUSTOM_LINK),
"text-custom-text-100": editor.isActive("link"),
}
)}
onClick={(e) => {
@@ -1,6 +1,6 @@
import { Dispatch, FC, SetStateAction } from "react";
import { Editor } from "@tiptap/react";
import { Check, ChevronDown } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
// plane utils
import { cn } from "@plane/utils";
// components
@@ -18,7 +18,6 @@ import {
} from "@/components/menus";
// constants
import { COLORS_LIST } from "@/constants/common";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
// local components
@@ -91,8 +90,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
if (
empty ||
!editor.isEditable ||
editor.isActive(CORE_EXTENSIONS.IMAGE) ||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE) ||
editor.isActive("image") ||
editor.isActive("imageComponent") ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
isSelecting
@@ -23,8 +23,6 @@ import {
Palette,
AlignCenter,
} from "lucide-react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import {
insertHorizontalRule,
@@ -37,7 +35,12 @@ import {
toggleBold,
toggleBulletList,
toggleCodeBlock,
toggleHeading,
toggleHeadingFive,
toggleHeadingFour,
toggleHeadingOne,
toggleHeadingSix,
toggleHeadingThree,
toggleHeadingTwo,
toggleItalic,
toggleOrderedList,
toggleStrike,
@@ -62,49 +65,63 @@ export type EditorMenuItem<T extends TEditorCommands> = {
export const TextItem = (editor: Editor): EditorMenuItem<"text"> => ({
key: "text",
name: "Text",
isActive: () => editor.isActive(CORE_EXTENSIONS.PARAGRAPH),
isActive: () => editor.isActive("paragraph"),
command: () => setText(editor),
icon: CaseSensitive,
});
type SupportedHeadingLevels = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
const HeadingItem = <T extends SupportedHeadingLevels>(
editor: Editor,
level: 1 | 2 | 3 | 4 | 5 | 6,
key: T,
name: string,
icon: LucideIcon
): EditorMenuItem<T> => ({
key,
name,
isActive: () => editor.isActive(CORE_EXTENSIONS.HEADING, { level }),
command: () => toggleHeading(editor, level),
icon,
export const HeadingOneItem = (editor: Editor): EditorMenuItem<"h1"> => ({
key: "h1",
name: "Heading 1",
isActive: () => editor.isActive("heading", { level: 1 }),
command: () => toggleHeadingOne(editor),
icon: Heading1,
});
export const HeadingOneItem = (editor: Editor): EditorMenuItem<"h1"> =>
HeadingItem(editor, 1, "h1", "Heading 1", Heading1);
export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> => ({
key: "h2",
name: "Heading 2",
isActive: () => editor.isActive("heading", { level: 2 }),
command: () => toggleHeadingTwo(editor),
icon: Heading2,
});
export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> =>
HeadingItem(editor, 2, "h2", "Heading 2", Heading2);
export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> => ({
key: "h3",
name: "Heading 3",
isActive: () => editor.isActive("heading", { level: 3 }),
command: () => toggleHeadingThree(editor),
icon: Heading3,
});
export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> =>
HeadingItem(editor, 3, "h3", "Heading 3", Heading3);
export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> => ({
key: "h4",
name: "Heading 4",
isActive: () => editor.isActive("heading", { level: 4 }),
command: () => toggleHeadingFour(editor),
icon: Heading4,
});
export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> =>
HeadingItem(editor, 4, "h4", "Heading 4", Heading4);
export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> => ({
key: "h5",
name: "Heading 5",
isActive: () => editor.isActive("heading", { level: 5 }),
command: () => toggleHeadingFive(editor),
icon: Heading5,
});
export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> =>
HeadingItem(editor, 5, "h5", "Heading 5", Heading5);
export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> =>
HeadingItem(editor, 6, "h6", "Heading 6", Heading6);
export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> => ({
key: "h6",
name: "Heading 6",
isActive: () => editor.isActive("heading", { level: 6 }),
command: () => toggleHeadingSix(editor),
icon: Heading6,
});
export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({
key: "bold",
name: "Bold",
isActive: () => editor?.isActive(CORE_EXTENSIONS.BOLD),
isActive: () => editor?.isActive("bold"),
command: () => toggleBold(editor),
icon: BoldIcon,
});
@@ -112,7 +129,7 @@ export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({
export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({
key: "italic",
name: "Italic",
isActive: () => editor?.isActive(CORE_EXTENSIONS.ITALIC),
isActive: () => editor?.isActive("italic"),
command: () => toggleItalic(editor),
icon: ItalicIcon,
});
@@ -120,7 +137,7 @@ export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({
export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({
key: "underline",
name: "Underline",
isActive: () => editor?.isActive(CORE_EXTENSIONS.UNDERLINE),
isActive: () => editor?.isActive("underline"),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
});
@@ -128,7 +145,7 @@ export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({
export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({
key: "strikethrough",
name: "Strikethrough",
isActive: () => editor?.isActive(CORE_EXTENSIONS.STRIKETHROUGH),
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
icon: StrikethroughIcon,
});
@@ -136,7 +153,7 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough
export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive(CORE_EXTENSIONS.BULLET_LIST),
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
icon: ListIcon,
});
@@ -144,7 +161,7 @@ export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list">
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive(CORE_EXTENSIONS.ORDERED_LIST),
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
});
@@ -152,7 +169,7 @@ export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
key: "to-do-list",
name: "To-do list",
isActive: () => editor.isActive(CORE_EXTENSIONS.TASK_ITEM),
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
});
@@ -160,7 +177,7 @@ export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
key: "quote",
name: "Quote",
isActive: () => editor?.isActive(CORE_EXTENSIONS.BLOCKQUOTE),
isActive: () => editor?.isActive("blockquote"),
command: () => toggleBlockquote(editor),
icon: TextQuote,
});
@@ -168,7 +185,7 @@ export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
export const CodeItem = (editor: Editor): EditorMenuItem<"code"> => ({
key: "code",
name: "Code",
isActive: () => editor?.isActive(CORE_EXTENSIONS.CODE_INLINE) || editor?.isActive(CORE_EXTENSIONS.CODE_BLOCK),
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
command: () => toggleCodeBlock(editor),
icon: CodeIcon,
});
@@ -176,7 +193,7 @@ export const CodeItem = (editor: Editor): EditorMenuItem<"code"> => ({
export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({
key: "table",
name: "Table",
isActive: () => editor?.isActive(CORE_EXTENSIONS.TABLE),
isActive: () => editor?.isActive("table"),
command: () => insertTableCommand(editor),
icon: TableIcon,
});
@@ -184,7 +201,7 @@ export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({
export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({
key: "image",
name: "Image",
isActive: () => editor?.isActive(CORE_EXTENSIONS.IMAGE) || editor?.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE),
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }),
icon: ImageIcon,
});
@@ -193,7 +210,7 @@ export const HorizontalRuleItem = (editor: Editor) =>
({
key: "divider",
name: "Divider",
isActive: () => editor?.isActive(CORE_EXTENSIONS.HORIZONTAL_RULE),
isActive: () => editor?.isActive("horizontalRule"),
command: () => insertHorizontalRule(editor),
icon: MinusSquare,
}) as const;
@@ -201,7 +218,7 @@ export const HorizontalRuleItem = (editor: Editor) =>
export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ({
key: "text-color",
name: "Color",
isActive: (props) => editor.isActive(CORE_EXTENSIONS.CUSTOM_COLOR, { color: props?.color }),
isActive: (props) => editor.isActive("customColor", { color: props?.color }),
command: (props) => {
if (!props) return;
toggleTextColor(props.color, editor);
@@ -212,7 +229,7 @@ export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => (
export const BackgroundColorItem = (editor: Editor): EditorMenuItem<"background-color"> => ({
key: "background-color",
name: "Background color",
isActive: (props) => editor.isActive(CORE_EXTENSIONS.CUSTOM_COLOR, { backgroundColor: props?.color }),
isActive: (props) => editor.isActive("customColor", { backgroundColor: props?.color }),
command: (props) => {
if (!props) return;
toggleBackgroundColor(props.color, editor);
@@ -1,44 +0,0 @@
export enum CORE_EXTENSIONS {
BLOCKQUOTE = "blockquote",
BOLD = "bold",
BULLET_LIST = "bulletList",
CALLOUT = "calloutComponent",
CHARACTER_COUNT = "characterCount",
CODE_BLOCK = "codeBlock",
CODE_INLINE = "code",
CUSTOM_COLOR = "customColor",
CUSTOM_IMAGE = "imageComponent",
CUSTOM_LINK = "link",
DOCUMENT = "doc",
DROP_CURSOR = "dropCursor",
ENTER_KEY = "enterKey",
GAP_CURSOR = "gapCursor",
HARD_BREAK = "hardBreak",
HEADING = "heading",
HEADINGS_LIST = "headingsList",
HISTORY = "history",
HORIZONTAL_RULE = "horizontalRule",
IMAGE = "image",
ITALIC = "italic",
LIST_ITEM = "listItem",
MARKDOWN_CLIPBOARD = "markdownClipboard",
MENTION = "mention",
ORDERED_LIST = "orderedList",
PARAGRAPH = "paragraph",
PLACEHOLDER = "placeholder",
SIDE_MENU = "editorSideMenu",
SLASH_COMMANDS = "slash-command",
STRIKETHROUGH = "strike",
TABLE = "table",
TABLE_CELL = "tableCell",
TABLE_HEADER = "tableHeader",
TABLE_ROW = "tableRow",
TASK_ITEM = "taskItem",
TASK_LIST = "taskList",
TEXT_ALIGN = "textAlign",
TEXT_STYLE = "textStyle",
TYPOGRAPHY = "typography",
UNDERLINE = "underline",
UTILITY = "utility",
WORK_ITEM_EMBED = "issue-embed-component",
}
@@ -1,3 +0,0 @@
export enum CORE_EDITOR_META {
SKIP_FILE_DELETION = "skipFileDeletion",
}
@@ -1,5 +1,5 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import React, { useState } from "react";
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
// constants
import { COLORS_LIST } from "@/constants/common";
// local components
@@ -1,8 +1,6 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import { Node as NodeType } from "@tiptap/pm/model";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
// types
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
@@ -11,14 +9,14 @@ import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils";
// Extend Tiptap's Commands interface
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CALLOUT]: {
calloutComponent: {
insertCallout: () => ReturnType;
};
}
}
export const CustomCalloutExtensionConfig = Node.create({
name: CORE_EXTENSIONS.CALLOUT,
name: "calloutComponent",
group: "block",
content: "block+",
@@ -1,6 +1,9 @@
// plane imports
// plane helpers
import { convertHexEmojiToDecimal } from "@plane/utils";
// plane ui
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
import { cn, convertHexEmojiToDecimal } from "@plane/utils";
// plane utils
import { cn } from "@plane/utils";
// types
import { TCalloutBlockAttributes } from "./types";
// utils
@@ -20,7 +20,7 @@ export type TCalloutBlockEmojiAttributes = {
export type TCalloutBlockAttributes = {
[EAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[EAttributeNames.BACKGROUND]: string | undefined;
[EAttributeNames.BACKGROUND]: string;
[EAttributeNames.BLOCK_TYPE]: "callout-component";
} & TCalloutBlockIconAttributes &
TCalloutBlockEmojiAttributes;
@@ -1,6 +1,7 @@
// plane imports
import { TEmojiLogoProps } from "@plane/ui";
// plane helpers
import { sanitizeHTML } from "@plane/utils";
// plane ui
import { TEmojiLogoProps } from "@plane/ui";
// types
import {
EAttributeNames,
@@ -11,11 +12,11 @@ import {
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
"data-logo-in-use": "emoji",
"data-icon-color": undefined,
"data-icon-name": undefined,
"data-icon-color": null,
"data-icon-name": null,
"data-emoji-unicode": "128161",
"data-emoji-url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png",
"data-background": undefined,
"data-background": null,
"data-block-type": "callout-component",
};
@@ -31,7 +32,7 @@ export const getStoredLogo = (): TStoredLogoValue => {
};
if (typeof window !== "undefined") {
const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo") ?? "");
const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo"));
if (storedData) {
let parsedData: TEmojiLogoProps;
try {
@@ -68,7 +69,7 @@ export const updateStoredLogo = (value: TEmojiLogoProps): void => {
// function to get the stored background color from local storage
export const getStoredBackgroundColor = (): string | null => {
if (typeof window !== "undefined") {
return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background") ?? "");
return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background"));
}
return null;
};
@@ -0,0 +1,89 @@
import { Extension } from "@tiptap/core";
import { Fragment, Node } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export const MarkdownClipboard = Extension.create({
name: "markdownClipboard",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextSerializer: (slice) => {
const markdownSerializer = this.editor.storage.markdown.serializer;
const isTableRow = slice.content.firstChild?.type?.name === "tableRow";
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
if (nodeSelect) {
return markdownSerializer.serialize(slice.content);
}
const processTableContent = (tableNode: Node | Fragment) => {
let result = "";
tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => {
tableRowNode.content?.forEach?.((cell: Node) => {
const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : "";
result += cellContent + "\n";
});
});
return result;
};
if (isTableRow) {
const rowsCount = slice.content?.childCount || 0;
const cellsCount = slice.content?.firstChild?.content?.childCount || 0;
if (rowsCount === 1 || cellsCount === 1) {
return processTableContent(slice.content);
} else {
return markdownSerializer.serialize(slice.content);
}
}
const traverseToParentOfLeaf = (
node: Node | null,
parent: Fragment | Node,
depth: number
): Node | Fragment => {
let currentNode = node;
let currentParent = parent;
let currentDepth = depth;
while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) {
if (currentNode.content?.childCount > 1) {
if (currentNode.content.firstChild?.type?.name === "listItem") {
return currentParent;
} else {
return currentNode.content;
}
}
currentParent = currentNode;
currentNode = currentNode.content?.firstChild || null;
currentDepth--;
}
return currentParent;
};
if (slice.content.childCount > 1) {
return markdownSerializer.serialize(slice.content);
} else {
const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart);
let currentNode = targetNode;
while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) {
currentNode = currentNode.firstChild;
}
if (currentNode instanceof Node && currentNode.isText) {
return currentNode.text;
}
return markdownSerializer.serialize(targetNode);
}
},
},
}),
];
},
});
@@ -1,6 +1,4 @@
import { Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
export interface CodeOptions {
HTMLAttributes: Record<string, any>;
@@ -8,7 +6,7 @@ export interface CodeOptions {
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CODE_INLINE]: {
code: {
/**
* Set a code mark
*/
@@ -29,7 +27,7 @@ export const inputRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))$/;
const pasteRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))/g;
export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
name: CORE_EXTENSIONS.CODE_INLINE,
name: "code",
addOptions() {
return {
@@ -1,11 +1,11 @@
"use client";
import { useState } from "react";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import ts from "highlight.js/lib/languages/typescript";
import { common, createLowlight } from "lowlight";
import { CopyIcon, CheckIcon } from "lucide-react";
import { useState } from "react";
// ui
import { Tooltip } from "@plane/ui";
// plane utils
@@ -27,7 +27,7 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
await navigator.clipboard.writeText(node.textContent);
setCopied(true);
setTimeout(() => setCopied(false), 1000);
} catch {
} catch (error) {
setCopied(false);
}
e.preventDefault();
@@ -1,7 +1,5 @@
import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
export interface CodeBlockOptions {
/**
@@ -27,7 +25,7 @@ export interface CodeBlockOptions {
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CODE_BLOCK]: {
codeBlock: {
/**
* Set a code block
*/
@@ -44,7 +42,7 @@ export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
export const CodeBlock = Node.create<CodeBlockOptions>({
name: CORE_EXTENSIONS.CODE_BLOCK,
name: "codeBlock",
addOptions() {
return {
@@ -120,7 +118,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
toggleCodeBlock:
(attributes) =>
({ commands }) =>
commands.toggleNode(this.name, CORE_EXTENSIONS.PARAGRAPH, attributes),
commands.toggleNode(this.name, "paragraph", attributes),
};
},
@@ -128,7 +126,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return {
"Mod-Alt-c": () => this.editor.commands.toggleCodeBlock(),
// remove codeBlock when at start of document or codeBlock is empty
// remove code block when at start of document or code block is empty
Backspace: () => {
try {
const { empty, $anchor } = this.editor.state.selection;
@@ -261,7 +259,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
if (this.editor.isActive(CORE_EXTENSIONS.CODE_INLINE)) {
if (this.editor.isActive("code")) {
// Check if it's an inline code block
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
@@ -88,7 +88,7 @@ export function LowlightPlugin({
throw Error("You should provide an instance of lowlight to use the code-block-lowlight extension");
}
const lowlightPlugin: Plugin = new Plugin({
const lowlightPlugin: Plugin<any> = new Plugin({
key: new PluginKey("lowlight"),
state: {
@@ -3,24 +3,24 @@ import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
import TiptapUnderline from "@tiptap/extension-underline";
import StarterKit from "@tiptap/starter-kit";
// extensions
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// plane editor imports
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
// extensions
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
import { CustomCodeInlineExtension } from "./code-inline";
import { CustomColorExtension } from "./custom-color";
import { CustomLinkExtension } from "./custom-link";
import { CustomHorizontalRule } from "./horizontal-rule";
import { ImageExtensionWithoutProps } from "./image";
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomTextAlignExtension } from "./text-align";
import { WorkItemEmbedExtensionConfig } from "./work-item-embed/extension-config";
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomColorExtension } from "./custom-color";
// plane editor extensions
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
export const CoreEditorExtensionsWithoutProps = [
StarterKit.configure({
@@ -72,12 +72,12 @@ export const CoreEditorExtensionsWithoutProps = [
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtensionWithoutProps.configure({
ImageExtensionWithoutProps().configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageComponentWithoutProps,
CustomImageComponentWithoutProps(),
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -104,4 +104,4 @@ export const CoreEditorExtensionsWithoutProps = [
...CoreEditorAdditionalExtensionsWithoutProps,
];
export const DocumentEditorExtensionsWithoutProps = [WorkItemEmbedExtensionConfig];
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
@@ -0,0 +1,9 @@
import { Extension } from "@tiptap/core";
import codemark from "prosemirror-codemark";
export const CustomCodeMarkPlugin = Extension.create({
name: "codemarkPlugin",
addProseMirrorPlugins() {
return codemark({ markType: this.editor.schema.marks.code });
},
});
@@ -1,11 +1,10 @@
import { Mark, mergeAttributes } from "@tiptap/core";
// constants
import { COLORS_LIST } from "@/constants/common";
import { CORE_EXTENSIONS } from "@/constants/extension";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_COLOR]: {
color: {
/**
* Set the text color
* @param {string} color The color to set
@@ -35,7 +34,7 @@ declare module "@tiptap/core" {
}
export const CustomColorExtension = Mark.create({
name: CORE_EXTENSIONS.CUSTOM_COLOR,
name: "customColor",
addOptions() {
return {
@@ -3,7 +3,7 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from
// plane utils
import { cn } from "@plane/utils";
// extensions
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
import { CustoBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
import { ImageUploadStatus } from "./upload-status";
const MIN_SIZE = 100;
@@ -38,7 +38,7 @@ const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefin
return value;
};
type CustomImageBlockProps = CustomBaseImageNodeViewProps & {
type CustomImageBlockProps = CustoBaseImageNodeViewProps & {
imageFromFileSystem: string | undefined;
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null;
@@ -1,13 +1,10 @@
import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
export type CustomBaseImageNodeViewProps = {
export type CustoBaseImageNodeViewProps = {
getPos: () => number;
editor: Editor;
node: NodeViewProps["node"] & {
@@ -17,7 +14,7 @@ export type CustomBaseImageNodeViewProps = {
selected: boolean;
};
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps;
export type CustomImageNodeProps = NodeViewProps & CustoBaseImageNodeViewProps;
export const CustomImageNode = (props: CustomImageNodeProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
@@ -80,7 +77,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
maxFileSize={getExtensionStorage(editor, "imageComponent").maxFileSize}
node={node}
setIsUploaded={setIsUploaded}
selected={selected}
@@ -4,16 +4,12 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
import { cn } from "@plane/utils";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
// helpers
import { EFileError } from "@/helpers/file";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
// hooks
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & {
type CustomImageUploaderProps = CustoBaseImageNodeViewProps & {
maxFileSize: number;
loadImageFromFileSystem: (file: string) => void;
failedToLoadImage: boolean;
@@ -61,7 +57,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
// control cursor position after upload
const nextNode = editor.state.doc.nodeAt(pos + 1);
if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) {
if (nextNode && nextNode.type.name === "paragraph") {
// If there is a paragraph node after the image component, move the focus to the next node
editor.commands.setTextSelection(pos + 1);
} else {
@@ -73,39 +69,22 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
},
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
const uploadImageEditorCommand = useCallback(
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file),
[editor, imageEntityId]
);
const handleProgressStatus = useCallback(
(isUploading: boolean) => {
getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).uploadInProgress = isUploading;
},
[editor]
);
// hooks
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
editorCommand: uploadImageEditorCommand,
handleProgressStatus,
editorCommand: async (file) => await editor?.commands.uploadImage(imageEntityId, file),
handleProgressStatus: (isUploading) => {
editor.storage.imageComponent.uploadInProgress = isUploading;
},
loadFileFromFileSystem: loadImageFromFileSystem,
maxFileSize,
onUpload,
});
const handleInvalidFile = useCallback((_error: EFileError, message: string) => {
alert(message);
}, []);
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
editor,
maxFileSize,
onInvalidFile: handleInvalidFile,
pos: getPos(),
type: "image",
uploader: uploadFile,
@@ -144,7 +123,6 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
editor,
filesList,
maxFileSize,
onInvalidFile: (_error, message) => alert(message),
pos: getPos(),
type: "image",
uploader: uploadFile,
@@ -1,10 +1,6 @@
import { Editor } from "@tiptap/core";
import { useEditorState } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
type Props = {
editor: Editor;
@@ -20,7 +16,7 @@ export const ImageUploadStatus: React.FC<Props> = (props) => {
// subscribe to image upload status
const uploadStatus: number | undefined = useEditorState({
editor,
selector: ({ editor }) => getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.assetsUploadStatus?.[nodeId],
selector: ({ editor }) => editor.storage.imageComponent?.assetsUploadStatus[nodeId],
});
useEffect(() => {
@@ -1,16 +1,17 @@
import { Editor, mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { Image } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomImageNode } from "@/extensions/custom-image";
// helpers
import { isFileValid } from "@/helpers/file";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// plugins
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
// types
import { TFileHandler } from "@/types";
@@ -22,21 +23,23 @@ export type InsertImageComponentProps = {
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
imageComponent: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
updateAssetsUploadStatus?: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
getImageSource?: (path: string) => () => Promise<string>;
restoreImage: (src: string) => () => Promise<void>;
};
}
}
export const getImageComponentImageFileMap = (editor: Editor) =>
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
export const getImageComponentImageFileMap = (editor: Editor) => getExtensionStorage(editor, "imageComponent")?.fileMap;
export interface CustomImageExtensionStorage {
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
fileMap: Map<string, UploadEntity>;
deletedImageSet: Map<string, boolean>;
uploadInProgress: boolean;
maxFileSize: number;
}
@@ -44,14 +47,16 @@ export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File })
export const CustomImageExtension = (props: TFileHandler) => {
const {
assetsUploadStatus,
getAssetSrc,
upload,
delete: deleteImageFn,
restore: restoreImageFn,
validation: { maxFileSize },
} = props;
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
return Image.extend<Record<string, unknown>, CustomImageExtensionStorage>({
name: "imageComponent",
selectable: true,
group: "block",
atom: true,
@@ -97,15 +102,41 @@ export const CustomImageExtension = (props: TFileHandler) => {
};
},
addProseMirrorPlugins() {
return [
TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name),
TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name),
];
},
onCreate(this) {
const imageSources = new Set<string>();
this.editor.state.doc.descendants((node) => {
if (node.type.name === this.name) {
if (!node.attrs.src?.startsWith("http")) return;
imageSources.add(node.attrs.src);
}
});
imageSources.forEach(async (src) => {
try {
await restoreImageFn(src);
} catch (error) {
console.error("Error restoring image: ", error);
}
});
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false,
maxFileSize,
// escape markdown for images
markdown: {
serialize() {},
},
assetsUploadStatus,
};
},
@@ -121,7 +152,6 @@ export const CustomImageExtension = (props: TFileHandler) => {
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
file: props.file,
maxFileSize,
onError: (_error, message) => alert(message),
})
) {
return false;
@@ -166,6 +196,9 @@ export const CustomImageExtension = (props: TFileHandler) => {
const fileUrl = await upload(blockId, file);
return fileUrl;
},
updateAssetsUploadStatus: (updatedStatus) => () => {
this.storage.assetsUploadStatus = updatedStatus;
},
getImageSource: (path) => async () => await getAssetSrc(path),
restoreImage: (src) => async () => {
await restoreImageFn(src);
@@ -1,8 +1,6 @@
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { Image } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// components
import { CustomImageNode, CustomImageExtensionStorage } from "@/extensions/custom-image";
// types
@@ -11,8 +9,8 @@ import { TReadOnlyFileHandler } from "@/types";
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
const { getAssetSrc, restore: restoreImageFn } = props;
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
return Image.extend<Record<string, unknown>, CustomImageExtensionStorage>({
name: "imageComponent",
selectable: false,
group: "block",
atom: true,
@@ -55,11 +53,13 @@ export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false,
maxFileSize: 0,
// escape markdown for images
markdown: {
serialize() {},
},
assetsUploadStatus: {},
};
},
@@ -1,9 +1,6 @@
import { Mark, markPasteRule, mergeAttributes, PasteRuleMatch } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
import { find, registerCustomProtocol, reset } from "linkifyjs";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
import { autolink } from "./helpers/autolink";
import { clickHandler } from "./helpers/clickHandler";
import { pasteHandler } from "./helpers/pasteHandler";
@@ -49,7 +46,7 @@ export interface LinkOptions {
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_LINK]: {
link: {
/**
* Set a link mark
*/
@@ -82,7 +79,7 @@ export type CustomLinkStorage = {
};
export const CustomLinkExtension = Mark.create<LinkOptions, CustomLinkStorage>({
name: CORE_EXTENSIONS.CUSTOM_LINK,
name: "link",
priority: 1000,
@@ -16,7 +16,7 @@ export function clickHandler(options: ClickHandlerOptions): Plugin {
}
let a = event.target as HTMLElement;
const els: HTMLElement[] = [];
const els = [];
while (a?.nodeName !== "DIV") {
els.push(a);
@@ -1,14 +1,12 @@
import { Editor, getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core";
import { Node, NodeType } from "@tiptap/pm/model";
import { EditorState } from "@tiptap/pm/state";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
const { $from } = state.selection;
const nodeType = getNodeType(typeOrName, state.schema);
let currentNode: Node | null = null;
let currentNode = null;
let currentDepth = $from.depth;
let currentPos = $from.pos;
let targetDepth: number | null = null;
@@ -74,11 +72,7 @@ const getPrevListDepth = (typeOrName: string, state: EditorState) => {
// Traverse up the document structure from the adjusted position
for (let d = resolvedPos.depth; d > 0; d--) {
const node = resolvedPos.node(d);
if (
[CORE_EXTENSIONS.BULLET_LIST, CORE_EXTENSIONS.ORDERED_LIST, CORE_EXTENSIONS.TASK_LIST].includes(
node.type.name as CORE_EXTENSIONS
)
) {
if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") {
// Increment depth for each list ancestor found
depth++;
}
@@ -315,12 +309,12 @@ const isCurrentParagraphASibling = (state: EditorState): boolean => {
// Ensure we're in a paragraph and the parent is a list item.
if (
currentParagraphNode.type.name === CORE_EXTENSIONS.PARAGRAPH &&
[CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes(listItemNode.type.name as CORE_EXTENSIONS)
currentParagraphNode.type.name === "paragraph" &&
(listItemNode.type.name === "listItem" || listItemNode.type.name === "taskItem")
) {
let paragraphNodesCount = 0;
listItemNode.forEach((child) => {
if (child.type.name === CORE_EXTENSIONS.PARAGRAPH) {
if (child.type.name === "paragraph") {
paragraphNodesCount++;
}
});
@@ -1,6 +1,4 @@
import { Extension } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { handleBackspace, handleDelete } from "@/extensions/custom-list-keymap/list-helpers";
@@ -33,10 +31,10 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || this.editor.isActive(CORE_EXTENSIONS.TASK_ITEM)) {
if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.LIST_ITEM)) {
if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) {
if (this.editor.commands.sinkListItem("listItem")) {
return true;
} else if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.TASK_ITEM)) {
} else if (this.editor.commands.sinkListItem("taskItem")) {
return true;
}
return true;
@@ -48,9 +46,9 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
return true;
},
"Shift-Tab": () => {
if (this.editor.commands.liftListItem(CORE_EXTENSIONS.LIST_ITEM)) {
if (this.editor.commands.liftListItem("listItem")) {
return true;
} else if (this.editor.commands.liftListItem(CORE_EXTENSIONS.TASK_ITEM)) {
} else if (this.editor.commands.liftListItem("taskItem")) {
return true;
}
// if tabIndex is set, we don't want to handle Tab key
+127
View File
@@ -0,0 +1,127 @@
import { Extension, Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
// constants
import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
// types
import { TEditorCommands } from "@/types";
export const DropHandlerExtension = Extension.create({
name: "dropHandler",
priority: 1000,
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view, event) => {
if (
editor.isEditable &&
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files.length > 0
) {
event.preventDefault();
const files = Array.from(event.clipboardData.files);
const acceptedFiles = files.filter(
(f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type)
);
if (acceptedFiles.length) {
const pos = view.state.selection.from;
insertFilesSafely({
editor,
files: acceptedFiles,
initialPos: pos,
event: "drop",
});
}
return true;
}
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (
editor.isEditable &&
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files.length > 0
) {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
const acceptedFiles = files.filter(
(f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type)
);
if (acceptedFiles.length) {
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coordinates) {
const pos = coordinates.pos;
insertFilesSafely({
editor,
files: acceptedFiles,
initialPos: pos,
event: "drop",
});
}
return true;
}
}
return false;
},
},
}),
];
},
});
type InsertFilesSafelyArgs = {
editor: Editor;
event: "insert" | "drop";
files: File[];
initialPos: number;
type?: Extract<TEditorCommands, "attachment" | "image">;
};
export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => {
const { editor, event, files, initialPos, type } = args;
let pos = initialPos;
for (const file of files) {
// safe insertion
const docSize = editor.state.doc.content.size;
pos = Math.min(pos, docSize);
let fileType: "image" | "attachment" | null = null;
try {
if (type) {
if (["image", "attachment"].includes(type)) fileType = type;
else throw new Error("Wrong file type passed");
} else {
if (ACCEPTED_IMAGE_MIME_TYPES.includes(file.type)) fileType = "image";
else if (ACCEPTED_ATTACHMENT_MIME_TYPES.includes(file.type)) fileType = "attachment";
}
// insert file depending on the type at the current position
if (fileType === "image") {
editor.commands.insertImageComponent({
file,
pos,
event,
});
} else if (fileType === "attachment") {
}
} catch (error) {
console.error(`Error while ${event}ing file:`, error);
}
// Move to the next position
pos += 1;
}
};

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