Compare commits

..
Author SHA1 Message Date
Palanikannan M a5752adb42 chore: remove dev editor 2024-12-03 15:04:31 +05:30
Palanikannan M 7231b943be Merge branch 'preview' into feat/flat-list 2024-12-02 20:58:04 +05:30
Palanikannan M 17b53c888f fix: removed input rules from lists 2024-11-28 08:42:18 +05:30
Palanikannan M f4061c4ad6 Merge branch 'preview' into feat/flat-list 2024-11-27 16:38:55 +05:30
Palanikannan M 3b7003815b Merge branch 'preview' into feat/flat-list 2024-11-26 19:58:04 +05:30
Palanikannan M 2c499cf436 fix: parse html 2024-11-13 20:36:38 +05:30
Palanikannan M 24f46bd098 fix: flat-list 2024-11-13 18:01:07 +05:30
Palanikannan M 5e6e12d5e2 feat: trying out heading lists, nested styles and diving deeper 2024-11-13 09:34:55 +05:30
Palanikannan M 2d9a2ba9c7 Merge branch 'preview' into chore/editor-updates 2024-11-09 15:25:49 +05:30
Palanikannan M c81f02d879 revert: old fetch logic 2024-10-29 13:11:55 +05:30
Palanikannan M 9773a1feed fix: exiting code blocks inside lists 2024-10-28 16:56:57 +05:30
Palanikannan M 88b8e764e3 fix: insert table command for nesting it in lists 2024-10-28 16:56:35 +05:30
Palanikannan M 523b0a3eaf fix: image attr type 2024-10-28 16:56:18 +05:30
Palanikannan M daf14f17c7 fix: table insertion 2024-10-28 16:56:02 +05:30
Palanikannan M 6ce6533de5 fix: styles of ordered lists 2024-10-25 15:39:10 +05:30
Palanikannan M 95a063d564 Merge branch 'preview' into feat/flat-list 2024-10-25 05:41:36 +05:30
Palanikannan M 2a1c08f203 fix: migration script added 2024-10-25 04:58:26 +05:30
Palanikannan M cefc1e99b1 fix: flat lists 2024-10-25 01:49:51 +05:30
Palanikannan M 15668299b4 feat: migration (but with duplication of content and persistence of old lists due to indexed db) 2024-10-22 17:30:49 +05:30
Palanikannan M 594ae81c31 feat: old and new lists coexist 2024-10-22 15:45:06 +05:30
Palanikannan M 8a41b523d6 chore: renamed manual logger and logger middleware across the live server 2024-10-22 15:44:36 +05:30
Palanikannan M fb6cdb7f86 Merge branch 'preview' into feat/flat-list 2024-10-22 13:06:39 +05:30
Palanikannan M ce99563a0d fix: types to insert list 2024-10-22 13:06:37 +05:30
Palanikannan M 1b9b59799e fix: able to render prosemirror flat lists 2024-10-21 20:52:20 +05:30
Palanikannan M a83903280c wip: added flat list 2024-10-21 15:32:34 +05:30
501 changed files with 14750 additions and 9875 deletions
-20
View File
@@ -1,20 +0,0 @@
### Description
<!-- Provide a detailed description of the changes in this PR -->
### Type of Change
<!-- Put an 'x' in the boxes that apply -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Feature (non-breaking change which adds functionality)
- [ ] Improvement (change that would cause existing functionality to not work as expected)
- [ ] Code refactoring
- [ ] Performance improvements
- [ ] Documentation update
### Screenshots and Media (if applicable)
<!-- Add screenshots to help explain your changes, ideally showcasing before and after -->
### Test Scenarios
<!-- Please describe the tests that you ran to verify your changes -->
### References
<!-- Link related issues if there are any -->
+2 -2
View File
@@ -314,8 +314,8 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
attach_assets_to_build:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Attach Assets to Release
if: ${{ needs.branch_build_setup.outputs.build_type == 'Build' }}
name: Attach Assets to Build
runs-on: ubuntu-20.04
needs: [branch_build_setup]
steps:
+2
View File
@@ -85,3 +85,5 @@ deploy/selfhost/plane-app/
## Storybook
*storybook.log
output.css
dev-server
+3 -3
View File
@@ -44,7 +44,7 @@ const InstanceGithubAuthenticationPage = observer(() => {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `GitHub authentication is now ${value ? "active" : "disabled"}.`,
message: () => `Github authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
@@ -67,8 +67,8 @@ const InstanceGithubAuthenticationPage = observer(() => {
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<AuthenticationMethodCard
name="GitHub"
description="Allow members to login or sign up to plane with their GitHub accounts."
name="Github"
description="Allow members to login or sign up to plane with their Github accounts."
icon={
<Image
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
+1 -1
View File
@@ -3,7 +3,7 @@
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
import { useOutsideClickDetector } from "@plane/helpers";
// components
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
// hooks
+1 -1
View File
@@ -30,7 +30,7 @@ export const InstanceHeader: FC = observer(() => {
case "google":
return "Google";
case "github":
return "GitHub";
return "Github";
case "gitlab":
return "GitLab";
case "workspace":
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "0.24.1",
"version": "0.24.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -14,10 +14,9 @@
"dependencies": {
"@headlessui/react": "^1.7.19",
"@plane/constants": "*",
"@plane/hooks": "*",
"@plane/helpers": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@sentry/nextjs": "^8.32.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
@@ -27,7 +26,7 @@
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.20",
"next": "^14.2.12",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
+1 -1
View File
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
WORKDIR /code
+1 -1
View File
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
RUN apk --no-cache add \
"bash~=5.2" \
Binary file not shown.
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.24.1"
"version": "0.24.0"
}
+1 -13
View File
@@ -4,7 +4,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.db.models import Cycle, CycleIssue
from plane.utils.timezone_converter import convert_to_utc
class CycleSerializer(BaseSerializer):
total_issues = serializers.IntegerField(read_only=True)
@@ -24,18 +24,6 @@ class CycleSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError("Start date cannot exceed end date")
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
):
project_id = self.initial_data.get("project_id") or self.instance.project_id
data["start_date"] = convert_to_utc(
str(data.get("start_date").date()), project_id, is_start_date=True
)
data["end_date"] = convert_to_utc(
str(data.get("end_date", None).date()), project_id
)
return data
class Meta:
-12
View File
@@ -5,7 +5,6 @@ from rest_framework import serializers
from .base import BaseSerializer
from .issue import IssueStateSerializer
from plane.db.models import Cycle, CycleIssue, CycleUserProperties
from plane.utils.timezone_converter import convert_to_utc
class CycleWriteSerializer(BaseSerializer):
@@ -16,17 +15,6 @@ class CycleWriteSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError("Start date cannot exceed end date")
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
):
project_id = self.initial_data.get("project_id") or self.instance.project_id
data["start_date"] = convert_to_utc(
str(data.get("start_date").date()), project_id, is_start_date=True
)
data["end_date"] = convert_to_utc(
str(data.get("end_date", None).date()), project_id
)
return data
class Meta:
-4
View File
@@ -54,8 +54,6 @@ class PageSerializer(BaseSerializer):
labels = validated_data.pop("labels", None)
project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"]
description = self.context["description"]
description_binary = self.context["description_binary"]
description_html = self.context["description_html"]
# Get the workspace id from the project
@@ -64,8 +62,6 @@ class PageSerializer(BaseSerializer):
# Create the page
page = Page.objects.create(
**validated_data,
description=description,
description_binary=description_binary,
description_html=description_html,
owned_by_id=owned_by_id,
workspace_id=project.workspace_id,
-6
View File
@@ -8,7 +8,6 @@ from plane.app.views import (
SubPagesEndpoint,
PagesDescriptionViewSet,
PageVersionEndpoint,
PageDuplicateEndpoint,
)
@@ -79,9 +78,4 @@ urlpatterns = [
PageVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/duplicate/",
PageDuplicateEndpoint.as_view(),
name="page-duplicate",
),
]
+3 -1
View File
@@ -68,7 +68,9 @@ urlpatterns = [
# user workspace invitations
path(
"users/me/workspaces/invitations/",
UserWorkspaceInvitationsViewSet.as_view({"get": "list", "post": "create"}),
UserWorkspaceInvitationsViewSet.as_view(
{"get": "list", "post": "create"}
),
name="user-workspace-invitations",
),
path(
-1
View File
@@ -155,7 +155,6 @@ from .page.base import (
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageDuplicateEndpoint,
)
from .page.version import PageVersionEndpoint
+5 -49
View File
@@ -1,7 +1,5 @@
# Python imports
import json
import pytz
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
@@ -54,11 +52,6 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
# Module imports
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
from plane.utils.timezone_converter import (
convert_utc_to_project_timezone,
convert_to_utc,
user_timezone_converter,
)
class CycleViewSet(BaseViewSet):
@@ -74,19 +67,6 @@ class CycleViewSet(BaseViewSet):
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
project = Project.objects.get(id=self.kwargs.get("project_id"))
# Fetch project for the specific record or pass project_id dynamically
project_timezone = project.timezone
# Convert the current time (timezone.now()) to the project's timezone
local_tz = pytz.timezone(project_timezone)
current_time_in_project_tz = timezone.now().astimezone(local_tz)
# Convert project local time back to UTC for comparison (start_date is stored in UTC)
current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc)
return self.filter_queryset(
super()
.get_queryset()
@@ -139,15 +119,12 @@ class CycleViewSet(BaseViewSet):
.annotate(
status=Case(
When(
Q(start_date__lte=current_time_in_utc)
& Q(end_date__gte=current_time_in_utc),
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=current_time_in_utc,
then=Value("UPCOMING"),
),
When(end_date__lt=current_time_in_utc, then=Value("COMPLETED")),
When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
@@ -183,22 +160,10 @@ class CycleViewSet(BaseViewSet):
# Update the order by
queryset = queryset.order_by("-is_favorite", "-created_at")
project = Project.objects.get(id=self.kwargs.get("project_id"))
# Fetch project for the specific record or pass project_id dynamically
project_timezone = project.timezone
# Convert the current time (timezone.now()) to the project's timezone
local_tz = pytz.timezone(project_timezone)
current_time_in_project_tz = timezone.now().astimezone(local_tz)
# Convert project local time back to UTC for comparison (start_date is stored in UTC)
current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc)
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(
start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
start_date__lte=timezone.now(), end_date__gte=timezone.now()
)
data = queryset.values(
@@ -226,8 +191,6 @@ class CycleViewSet(BaseViewSet):
"version",
"created_by",
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, project_timezone)
if data:
return Response(data, status=status.HTTP_200_OK)
@@ -258,8 +221,6 @@ class CycleViewSet(BaseViewSet):
"version",
"created_by",
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@@ -456,8 +417,6 @@ class CycleViewSet(BaseViewSet):
)
queryset = queryset.first()
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
recent_visited_task.delay(
slug=slug,
@@ -533,9 +492,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
start_date = convert_to_utc(str(start_date), project_id, is_start_date=True)
end_date = convert_to_utc(str(end_date), project_id)
# Check if any cycle intersects in the given interval
cycles = Cycle.objects.filter(
Q(workspace__slug=slug)
+9 -19
View File
@@ -15,6 +15,8 @@ from django.db.models import (
UUIDField,
Value,
Subquery,
Case,
When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -54,11 +56,10 @@ from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from .. import BaseAPIView, BaseViewSet
from plane.utils.timezone_converter import user_timezone_converter
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.global_paginator import paginate
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
class IssueListEndpoint(BaseAPIView):
@@ -429,13 +430,6 @@ class IssueViewSet(BaseViewSet):
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=json.dumps(request.data, cls=DjangoJSONEncoder),
issue_id=str(serializer.data["id"]),
user_id=request.user.id,
is_creating=True,
)
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -451,10 +445,12 @@ class IssueViewSet(BaseViewSet):
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
:1
]
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(
@@ -657,12 +653,6 @@ class IssueViewSet(BaseViewSet):
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=current_instance,
issue_id=str(serializer.data.get("id", None)),
user_id=request.user.id,
)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+1 -1
View File
@@ -20,7 +20,7 @@ from plane.app.serializers import IssueSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.timezone_converter import user_timezone_converter
from plane.utils.user_timezone_converter import user_timezone_converter
from collections import defaultdict
+1 -1
View File
@@ -28,7 +28,7 @@ from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import ModuleDetailSerializer
from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project
from plane.utils.analytics_plot import burndown_plot
from plane.utils.timezone_converter import user_timezone_converter
from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports
+1 -1
View File
@@ -56,7 +56,7 @@ from plane.db.models import (
Project,
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.timezone_converter import user_timezone_converter
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.webhook_task import model_activity
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
-32
View File
@@ -121,8 +121,6 @@ class PageViewSet(BaseViewSet):
context={
"project_id": project_id,
"owned_by_id": request.user.id,
"description": request.data.get("description", {}),
"description_binary": request.data.get("description_binary", None),
"description_html": request.data.get("description_html", "<p></p>"),
},
)
@@ -555,33 +553,3 @@ class PagesDescriptionViewSet(BaseViewSet):
return Response({"message": "Updated successfully"})
else:
return Response({"error": "No binary data provided"})
class PageDuplicateEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, page_id):
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
).values()
new_page_data = list(page)[0]
new_page_data.name = f"{new_page_data.name} (Copy)"
serializer = PageSerializer(
data=new_page_data,
context={
"project_id": project_id,
"owned_by_id": request.user.id,
"description": new_page_data.description,
"description_binary": new_page_data.description_binary,
"description_html": new_page_data.description_html,
},
)
if serializer.is_valid():
serializer.save()
# capture the page transaction
page_transaction.delay(request.data, None, serializer.data["id"])
page = Page.objects.get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+31 -9
View File
@@ -16,7 +16,12 @@ from plane.app.permissions import (
WorkspaceUserPermission,
)
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
from plane.db.models import (
Project,
ProjectMember,
IssueUserProperty,
WorkspaceMember,
)
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
from plane.app.permissions.base import allow_permission, ROLE
@@ -78,7 +83,10 @@ class ProjectMemberViewSet(BaseViewSet):
workspace_member_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=member, is_active=True
).role
if workspace_member_role in [20] and member_roles.get(member) in [5, 15]:
if workspace_member_role in [20] and member_roles.get(member) in [
5,
15,
]:
return Response(
{
"error": "You cannot add a user with role lower than the workspace role"
@@ -86,7 +94,10 @@ class ProjectMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
if workspace_member_role in [5] and member_roles.get(member) in [15, 20]:
if workspace_member_role in [5] and member_roles.get(member) in [
15,
20,
]:
return Response(
{
"error": "You cannot add a user with role higher than the workspace role"
@@ -124,7 +135,8 @@ class ProjectMemberViewSet(BaseViewSet):
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id")) == str(member.get("member_id"))
if str(project_member.get("member_id"))
== str(member.get("member_id"))
]
# Create a new project member
bulk_project_members.append(
@@ -133,7 +145,9 @@ class ProjectMemberViewSet(BaseViewSet):
role=member.get("role", 5),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=(sort_order[0] - 10000 if len(sort_order) else 65535),
sort_order=(
sort_order[0] - 10000 if len(sort_order) else 65535
),
)
)
# Create a new issue property
@@ -224,7 +238,9 @@ class ProjectMemberViewSet(BaseViewSet):
> requested_project_member.role
):
return Response(
{"error": "You cannot update a role that is higher than your own role"},
{
"error": "You cannot update a role that is higher than your own role"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -264,7 +280,9 @@ class ProjectMemberViewSet(BaseViewSet):
# User cannot deactivate higher role
if requesting_project_member.role < project_member.role:
return Response(
{"error": "You cannot remove a user having role higher than you"},
{
"error": "You cannot remove a user having role higher than you"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -285,7 +303,10 @@ class ProjectMemberViewSet(BaseViewSet):
if (
project_member.role == 20
and not ProjectMember.objects.filter(
workspace__slug=slug, project_id=project_id, role=20, is_active=True
workspace__slug=slug,
project_id=project_id,
role=20,
is_active=True,
).count()
> 1
):
@@ -323,6 +344,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
).values("project_id", "role")
project_members = {
str(member["project_id"]): member["role"] for member in project_members
str(member["project_id"]): member["role"]
for member in project_members
}
return Response(project_members, status=status.HTTP_200_OK)
+2 -3
View File
@@ -41,7 +41,6 @@ from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.license.utils.instance_value import get_configuration_value
class WorkSpaceViewSet(BaseViewSet):
model = Workspace
serializer_class = WorkSpaceSerializer
@@ -82,12 +81,12 @@ class WorkSpaceViewSet(BaseViewSet):
def create(self, request):
try:
(DISABLE_WORKSPACE_CREATION,) = get_configuration_value(
DISABLE_WORKSPACE_CREATION, = get_configuration_value(
[
{
"key": "DISABLE_WORKSPACE_CREATION",
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
}
},
]
)
+1 -1
View File
@@ -10,7 +10,7 @@ from plane.app.views.base import BaseAPIView
from plane.db.models import Cycle
from plane.app.permissions import WorkspaceViewerPermission
from plane.app.serializers.cycle import CycleSerializer
from plane.utils.timezone_converter import user_timezone_converter
class WorkspaceCyclesEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission]
+33 -8
View File
@@ -1,12 +1,22 @@
# Django imports
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
from django.db.models import (
Count,
Q,
OuterRef,
Subquery,
IntegerField,
)
from django.db.models.functions import Coalesce
# Third party modules
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
from plane.app.permissions import (
WorkspaceEntityPermission,
allow_permission,
ROLE,
)
# Module imports
from plane.app.serializers import (
@@ -16,7 +26,12 @@ from plane.app.serializers import (
WorkSpaceMemberSerializer,
)
from plane.app.views.base import BaseAPIView
from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue
from plane.db.models import (
Project,
ProjectMember,
WorkspaceMember,
DraftIssue,
)
from plane.utils.cache import invalidate_cache
from .. import BaseViewSet
@@ -104,7 +119,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
if requesting_workspace_member.role < workspace_member.role:
return Response(
{"error": "You cannot remove a user having role higher than you"},
{
"error": "You cannot remove a user having role higher than you"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -131,7 +148,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
workspace__slug=slug,
member_id=workspace_member.member_id,
is_active=True,
).update(is_active=False)
workspace_member.is_active = False
@@ -145,7 +164,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
multiple=True,
)
@invalidate_cache(path="/api/users/me/settings/")
@invalidate_cache(path="api/users/me/workspaces/", user=False, multiple=True)
@invalidate_cache(
path="api/users/me/workspaces/", user=False, multiple=True
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
@@ -192,7 +213,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
workspace__slug=slug,
member_id=workspace_member.member_id,
is_active=True,
).update(is_active=False)
# # Deactivate the user
@@ -256,7 +279,9 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
project_members = ProjectMember.objects.filter(
workspace__slug=slug, project_id__in=project_ids, is_active=True
).select_related("project", "member", "workspace")
project_members = ProjectMemberRoleSerializer(project_members, many=True).data
project_members = ProjectMemberRoleSerializer(
project_members, many=True
).data
project_members_dict = dict()
@@ -60,9 +60,6 @@ class EmailCheckEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
# Lower the email
email = str(email).lower().strip()
# Validate email
try:
validate_email(email)
@@ -60,7 +60,6 @@ class EmailCheckSpaceEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
email = str(email).lower().strip()
# Validate email
try:
validate_email(email)
+23 -91
View File
@@ -3,8 +3,7 @@ from django.utils import timezone
from django.apps import apps
from django.conf import settings
from django.db import models
from django.db.models.fields.related import OneToOneRel
from django.core.exceptions import ObjectDoesNotExist
# Third party imports
from celery import shared_task
@@ -12,98 +11,31 @@ from celery import shared_task
@shared_task
def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
"""
Soft delete related objects for a given model instance
"""
# Get the model class using app registry
model_class = apps.get_model(app_label, model_name)
# Get the instance using all_objects to ensure we can get even if it's already soft deleted
try:
instance = model_class.all_objects.get(pk=instance_pk)
except model_class.DoesNotExist:
return
# Get all related fields that are reverse relationships
all_related = [
f
for f in instance._meta.get_fields()
if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete
]
# Handle each related field
for relation in all_related:
related_name = relation.get_accessor_name()
# Skip if the relation doesn't exist
if not hasattr(instance, related_name):
continue
# Get the on_delete behavior name
on_delete_name = (
relation.on_delete.__name__
if hasattr(relation.on_delete, "__name__")
else ""
)
if on_delete_name == "DO_NOTHING":
continue
elif on_delete_name == "SET_NULL":
# Handle SET_NULL relationships
if isinstance(relation, OneToOneRel):
# For OneToOne relationships
related_obj = getattr(instance, related_name, None)
if related_obj and isinstance(related_obj, models.Model):
setattr(related_obj, relation.remote_field.name, None)
related_obj.save(update_fields=[relation.remote_field.name])
else:
# For other relationships
related_queryset = getattr(instance, related_name).all()
related_queryset.update(**{relation.remote_field.name: None})
else:
# Handle CASCADE and other delete behaviors
instance = model_class.all_objects.get(pk=instance_pk)
related_fields = instance._meta.get_fields()
for field in related_fields:
if field.one_to_many or field.one_to_one:
try:
if relation.one_to_one:
# Handle OneToOne relationships
related_obj = getattr(instance, related_name, None)
if related_obj:
if hasattr(related_obj, "deleted_at"):
if not related_obj.deleted_at:
related_obj.deleted_at = timezone.now()
related_obj.save()
# Recursively handle related objects
soft_delete_related_objects(
related_obj._meta.app_label,
related_obj._meta.model_name,
related_obj.pk,
using,
)
else:
# Handle other relationships
related_queryset = getattr(instance, related_name).all()
for related_obj in related_queryset:
if hasattr(related_obj, "deleted_at"):
if not related_obj.deleted_at:
related_obj.deleted_at = timezone.now()
related_obj.save()
# Recursively handle related objects
soft_delete_related_objects(
related_obj._meta.app_label,
related_obj._meta.model_name,
related_obj.pk,
using,
)
except Exception as e:
# Log the error or handle as needed
print(f"Error handling relation {related_name}: {str(e)}")
continue
# Check if the field has CASCADE on delete
if (
not hasattr(field.remote_field, "on_delete")
or field.remote_field.on_delete == models.CASCADE
):
if field.one_to_many:
related_objects = getattr(instance, field.name).all()
elif field.one_to_one:
related_object = getattr(instance, field.name)
related_objects = (
[related_object] if related_object is not None else []
)
# Finally, soft delete the instance itself if it hasn't been deleted yet
if hasattr(instance, "deleted_at") and not instance.deleted_at:
instance.deleted_at = timezone.now()
instance.save()
for obj in related_objects:
if obj:
obj.deleted_at = timezone.now()
obj.save(using=using)
except ObjectDoesNotExist:
pass
# @shared_task
+13 -5
View File
@@ -162,7 +162,8 @@ def generate_table_row(issue):
issue["priority"],
(
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
if issue["created_by__first_name"]
and issue["created_by__last_name"]
else ""
),
(
@@ -196,7 +197,8 @@ def generate_json_row(issue):
"Priority": issue["priority"],
"Created By": (
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
if issue["created_by__first_name"]
and issue["created_by__last_name"]
else ""
),
"Assignee": (
@@ -206,11 +208,17 @@ def generate_json_row(issue):
),
"Labels": issue["labels__name"] if issue["labels__name"] else "",
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle Start Date": dateConverter(
issue["issue_cycle__cycle__start_date"]
),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Module Start Date": dateConverter(
issue["issue_module__module__start_date"]
),
"Module Target Date": dateConverter(
issue["issue_module__module__target_date"]
),
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
@@ -1,125 +0,0 @@
# Python imports
from typing import Optional
import logging
# Django imports
from django.utils import timezone
from django.db import transaction
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import Issue, IssueDescriptionVersion, ProjectMember
from plane.utils.exception_logger import log_exception
def get_owner_id(issue: Issue) -> Optional[int]:
"""Get the owner ID of the issue"""
if issue.updated_by_id:
return issue.updated_by_id
if issue.created_by_id:
return issue.created_by_id
# Find project admin as fallback
project_member = ProjectMember.objects.filter(
project_id=issue.project_id,
role=20, # Admin role
).first()
return project_member.member_id if project_member else None
@shared_task
def sync_issue_description_version(batch_size=5000, offset=0, countdown=300):
"""Task to create IssueDescriptionVersion records for existing Issues in batches"""
try:
with transaction.atomic():
base_query = Issue.objects
total_issues_count = base_query.count()
if total_issues_count == 0:
return
# Calculate batch range
end_offset = min(offset + batch_size, total_issues_count)
# Fetch issues with related data
issues_batch = (
base_query.order_by("created_at")
.select_related("workspace", "project")
.only(
"id",
"workspace_id",
"project_id",
"created_by_id",
"updated_by_id",
"description_binary",
"description_html",
"description_stripped",
"description",
)[offset:end_offset]
)
if not issues_batch:
return
version_objects = []
for issue in issues_batch:
# Validate required fields
if not issue.workspace_id or not issue.project_id:
logging.warning(
f"Skipping {issue.id} - missing workspace_id or project_id"
)
continue
# Determine owned_by_id
owned_by_id = get_owner_id(issue)
if owned_by_id is None:
logging.warning(f"Skipping issue {issue.id} - missing owned_by")
continue
# Create version object
version_objects.append(
IssueDescriptionVersion(
workspace_id=issue.workspace_id,
project_id=issue.project_id,
created_by_id=issue.created_by_id,
updated_by_id=issue.updated_by_id,
owned_by_id=owned_by_id,
last_saved_at=timezone.now(),
issue_id=issue.id,
description_binary=issue.description_binary,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_json=issue.description,
)
)
# Bulk create version objects
if version_objects:
IssueDescriptionVersion.objects.bulk_create(version_objects)
# Schedule next batch if needed
if end_offset < total_issues_count:
sync_issue_description_version.apply_async(
kwargs={
"batch_size": batch_size,
"offset": end_offset,
"countdown": countdown,
},
countdown=countdown,
)
return
except Exception as e:
log_exception(e)
return
@shared_task
def schedule_issue_description_version(batch_size=5000, countdown=300):
sync_issue_description_version.delay(
batch_size=int(batch_size), countdown=countdown
)
@@ -1,84 +0,0 @@
from celery import shared_task
from django.db import transaction
from django.utils import timezone
from typing import Optional, Dict
import json
from plane.db.models import Issue, IssueDescriptionVersion
from plane.utils.exception_logger import log_exception
def should_update_existing_version(
version: IssueDescriptionVersion, user_id: str, max_time_difference: int = 600
) -> bool:
if not version:
return
time_difference = (timezone.now() - version.last_saved_at).total_seconds()
return (
str(version.owned_by_id) == str(user_id)
and time_difference <= max_time_difference
)
def update_existing_version(version: IssueDescriptionVersion, issue) -> None:
version.description_json = issue.description
version.description_html = issue.description_html
version.description_binary = issue.description_binary
version.description_stripped = issue.description_stripped
version.last_saved_at = timezone.now()
version.save(
update_fields=[
"description_json",
"description_html",
"description_binary",
"description_stripped",
"last_saved_at",
]
)
@shared_task
def issue_description_version_task(
updated_issue, issue_id, user_id, is_creating=False
) -> Optional[bool]:
try:
# Parse updated issue data
current_issue: Dict = json.loads(updated_issue) if updated_issue else {}
# Get current issue
issue = Issue.objects.get(id=issue_id)
# Check if description has changed
if (
current_issue.get("description_html") == issue.description_html
and not is_creating
):
return
with transaction.atomic():
# Get latest version
latest_version = (
IssueDescriptionVersion.objects.filter(issue_id=issue_id)
.order_by("-last_saved_at")
.first()
)
# Determine whether to update existing or create new version
if should_update_existing_version(version=latest_version, user_id=user_id):
update_existing_version(latest_version, issue)
else:
IssueDescriptionVersion.log_issue_description_version(issue, user_id)
return
except Issue.DoesNotExist:
# Issue no longer exists, skip processing
return
except json.JSONDecodeError as e:
log_exception(f"Invalid JSON for updated_issue: {e}")
return
except Exception as e:
log_exception(f"Error processing issue description version: {e}")
return
@@ -1,254 +0,0 @@
# Python imports
import json
from typing import Optional, List, Dict
from uuid import UUID
from itertools import groupby
import logging
# Django imports
from django.utils import timezone
from django.db import transaction
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import (
Issue,
IssueVersion,
ProjectMember,
CycleIssue,
ModuleIssue,
IssueActivity,
IssueAssignee,
IssueLabel,
)
from plane.utils.exception_logger import log_exception
@shared_task
def issue_task(updated_issue, issue_id, user_id):
try:
current_issue = json.loads(updated_issue) if updated_issue else {}
issue = Issue.objects.get(id=issue_id)
updated_current_issue = {}
for key, value in current_issue.items():
if getattr(issue, key) != value:
updated_current_issue[key] = value
if updated_current_issue:
issue_version = (
IssueVersion.objects.filter(issue_id=issue_id)
.order_by("-last_saved_at")
.first()
)
if (
issue_version
and str(issue_version.owned_by) == str(user_id)
and (timezone.now() - issue_version.last_saved_at).total_seconds()
<= 600
):
for key, value in updated_current_issue.items():
setattr(issue_version, key, value)
issue_version.last_saved_at = timezone.now()
issue_version.save(
update_fields=list(updated_current_issue.keys()) + ["last_saved_at"]
)
else:
IssueVersion.log_issue_version(issue, user_id)
return
except Issue.DoesNotExist:
return
except Exception as e:
log_exception(e)
return
def get_owner_id(issue: Issue) -> Optional[int]:
"""Get the owner ID of the issue"""
if issue.updated_by_id:
return issue.updated_by_id
if issue.created_by_id:
return issue.created_by_id
# Find project admin as fallback
project_member = ProjectMember.objects.filter(
project_id=issue.project_id,
role=20, # Admin role
).first()
return project_member.member_id if project_member else None
def get_related_data(issue_ids: List[UUID]) -> Dict:
"""Get related data for the given issue IDs"""
cycle_issues = {
ci.issue_id: ci.cycle_id
for ci in CycleIssue.objects.filter(issue_id__in=issue_ids)
}
# Get assignees with proper grouping
assignee_records = list(
IssueAssignee.objects.filter(issue_id__in=issue_ids)
.values_list("issue_id", "assignee_id")
.order_by("issue_id")
)
assignees = {}
for issue_id, group in groupby(assignee_records, key=lambda x: x[0]):
assignees[issue_id] = [str(g[1]) for g in group]
# Get labels with proper grouping
label_records = list(
IssueLabel.objects.filter(issue_id__in=issue_ids)
.values_list("issue_id", "label_id")
.order_by("issue_id")
)
labels = {}
for issue_id, group in groupby(label_records, key=lambda x: x[0]):
labels[issue_id] = [str(g[1]) for g in group]
# Get modules with proper grouping
module_records = list(
ModuleIssue.objects.filter(issue_id__in=issue_ids)
.values_list("issue_id", "module_id")
.order_by("issue_id")
)
modules = {}
for issue_id, group in groupby(module_records, key=lambda x: x[0]):
modules[issue_id] = [str(g[1]) for g in group]
# Get latest activities
latest_activities = {}
activities = IssueActivity.objects.filter(issue_id__in=issue_ids).order_by(
"issue_id", "-created_at"
)
for issue_id, activities_group in groupby(activities, key=lambda x: x.issue_id):
first_activity = next(activities_group, None)
if first_activity:
latest_activities[issue_id] = first_activity.id
return {
"cycle_issues": cycle_issues,
"assignees": assignees,
"labels": labels,
"modules": modules,
"activities": latest_activities,
}
def create_issue_version(issue: Issue, related_data: Dict) -> Optional[IssueVersion]:
"""Create IssueVersion object from the given issue and related data"""
try:
if not issue.workspace_id or not issue.project_id:
logging.warning(
f"Skipping issue {issue.id} - missing workspace_id or project_id"
)
return None
owned_by_id = get_owner_id(issue)
if owned_by_id is None:
logging.warning(f"Skipping issue {issue.id} - missing owned_by")
return None
return IssueVersion(
workspace_id=issue.workspace_id,
project_id=issue.project_id,
created_by_id=issue.created_by_id,
updated_by_id=issue.updated_by_id,
owned_by_id=owned_by_id,
last_saved_at=timezone.now(),
activity_id=related_data["activities"].get(issue.id),
properties=getattr(issue, "properties", {}),
meta=getattr(issue, "meta", {}),
issue_id=issue.id,
parent=issue.parent_id,
state=issue.state_id,
estimate_point=issue.estimate_point_id,
name=issue.name,
priority=issue.priority,
start_date=issue.start_date,
target_date=issue.target_date,
assignees=related_data["assignees"].get(issue.id, []),
sequence_id=issue.sequence_id,
labels=related_data["labels"].get(issue.id, []),
sort_order=issue.sort_order,
completed_at=issue.completed_at,
archived_at=issue.archived_at,
is_draft=issue.is_draft,
external_source=issue.external_source,
external_id=issue.external_id,
type=issue.type_id,
cycle=related_data["cycle_issues"].get(issue.id),
modules=related_data["modules"].get(issue.id, []),
)
except Exception as e:
log_exception(e)
return None
@shared_task
def sync_issue_version(batch_size=5000, offset=0, countdown=300):
"""Task to create IssueVersion records for existing Issues in batches"""
try:
with transaction.atomic():
base_query = Issue.objects
total_issues_count = base_query.count()
if total_issues_count == 0:
return
end_offset = min(offset + batch_size, total_issues_count)
# Get issues batch with optimized queries
issues_batch = list(
base_query.order_by("created_at")
.select_related("workspace", "project")
.all()[offset:end_offset]
)
if not issues_batch:
return
# Get all related data in bulk
issue_ids = [issue.id for issue in issues_batch]
related_data = get_related_data(issue_ids)
issue_versions = []
for issue in issues_batch:
version = create_issue_version(issue, related_data)
if version:
issue_versions.append(version)
# Bulk create versions
if issue_versions:
IssueVersion.objects.bulk_create(issue_versions, batch_size=1000)
# Schedule the next batch if there are more workspaces to process
if end_offset < total_issues_count:
sync_issue_version.apply_async(
kwargs={
"batch_size": batch_size,
"offset": end_offset,
"countdown": countdown,
},
countdown=countdown,
)
logging.info(f"Processed Issues: {end_offset}")
return
except Exception as e:
log_exception(e)
return
@shared_task
def schedule_issue_version(batch_size=5000, countdown=300):
sync_issue_version.delay(batch_size=int(batch_size), countdown=countdown)
+1 -3
View File
@@ -257,9 +257,7 @@ def notifications(
)
new_mentions = [
str(mention)
for mention in new_mentions
if mention in set(project_members)
str(mention) for mention in new_mentions if mention in set(project_members)
]
removed_mention = get_removed_mentions(
requested_instance=requested_data, current_instance=current_instance
@@ -13,14 +13,28 @@ from plane.db.models import (
class Command(BaseCommand):
help = "Add a member to a project. If present in the workspace"
def add_arguments(self, parser):
# Positional argument
parser.add_argument("--project_id", type=str, nargs="?", help="Project ID")
parser.add_argument("--user_email", type=str, nargs="?", help="User Email")
parser.add_argument(
"--role", type=int, nargs="?", help="Role of the user in the project"
"--project_id",
type=str,
nargs="?",
help="Project ID",
)
parser.add_argument(
"--user_email",
type=str,
nargs="?",
help="User Email",
)
parser.add_argument(
"--role",
type=int,
nargs="?",
help="Role of the user in the project",
)
def handle(self, *args: Any, **options: Any):
@@ -53,7 +67,9 @@ class Command(BaseCommand):
# Get the smallest sort order
smallest_sort_order = (
ProjectMember.objects.filter(workspace_id=project.workspace_id)
ProjectMember.objects.filter(
workspace_id=project.workspace_id,
)
.order_by("sort_order")
.first()
)
@@ -63,15 +79,22 @@ class Command(BaseCommand):
else:
sort_order = 65535
if ProjectMember.objects.filter(project=project, member=user).exists():
if ProjectMember.objects.filter(
project=project,
member=user,
).exists():
# Update the project member
ProjectMember.objects.filter(project=project, member=user).update(
is_active=True, sort_order=sort_order, role=role
)
ProjectMember.objects.filter(
project=project,
member=user,
).update(is_active=True, sort_order=sort_order, role=role)
else:
# Create the project member
ProjectMember.objects.create(
project=project, member=user, role=role, sort_order=sort_order
project=project,
member=user,
role=role,
sort_order=sort_order,
)
# Issue Property
@@ -79,7 +102,9 @@ class Command(BaseCommand):
# Success message
self.stdout.write(
self.style.SUCCESS(f"User {user_email} added to project {project_id}")
self.style.SUCCESS(
f"User {user_email} added to project {project_id}"
)
)
return
except CommandError as e:
@@ -1,23 +0,0 @@
# Django imports
from django.core.management.base import BaseCommand
# Module imports
from plane.bgtasks.issue_description_version_sync import (
schedule_issue_description_version,
)
class Command(BaseCommand):
help = "Creates IssueDescriptionVersion records for existing Issues in batches"
def handle(self, *args, **options):
batch_size = input("Enter the batch size: ")
batch_countdown = input("Enter the batch countdown: ")
schedule_issue_description_version.delay(
batch_size=batch_size, countdown=int(batch_countdown)
)
self.stdout.write(
self.style.SUCCESS("Successfully created issue description version task")
)
@@ -1,19 +0,0 @@
# Django imports
from django.core.management.base import BaseCommand
# Module imports
from plane.bgtasks.issue_version_sync import schedule_issue_version
class Command(BaseCommand):
help = "Creates IssueVersion records for existing Issues in batches"
def handle(self, *args, **options):
batch_size = input("Enter the batch size: ")
batch_countdown = input("Enter the batch countdown: ")
schedule_issue_version.delay(
batch_size=batch_size, countdown=int(batch_countdown)
)
self.stdout.write(self.style.SUCCESS("Successfully created issue version task"))
@@ -1,117 +0,0 @@
# Generated by Django 4.2.17 on 2024-12-13 10:09
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import plane.db.models.user
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0086_issueversion_alter_teampage_unique_together_and_more'),
]
operations = [
migrations.RemoveField(
model_name='issueversion',
name='description',
),
migrations.RemoveField(
model_name='issueversion',
name='description_binary',
),
migrations.RemoveField(
model_name='issueversion',
name='description_html',
),
migrations.RemoveField(
model_name='issueversion',
name='description_stripped',
),
migrations.AddField(
model_name='issueversion',
name='activity',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='versions', to='db.issueactivity'),
),
migrations.AddField(
model_name='profile',
name='is_mobile_onboarded',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='profile',
name='mobile_onboarding_step',
field=models.JSONField(default=plane.db.models.user.get_mobile_default_onboarding),
),
migrations.AddField(
model_name='profile',
name='mobile_timezone_auto_set',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='profile',
name='language',
field=models.CharField(default='en', max_length=255),
),
migrations.AlterField(
model_name='issueversion',
name='owned_by',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_versions', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='Sticky',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.TextField()),
('description', models.JSONField(blank=True, default=dict)),
('description_html', models.TextField(blank=True, default='<p></p>')),
('description_stripped', models.TextField(blank=True, null=True)),
('description_binary', models.BinaryField(null=True)),
('logo_props', models.JSONField(default=dict)),
('color', models.CharField(blank=True, max_length=255, null=True)),
('background_color', models.CharField(blank=True, max_length=255, null=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stickies', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stickies', to='db.workspace')),
],
options={
'verbose_name': 'Sticky',
'verbose_name_plural': 'Stickies',
'db_table': 'stickies',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='IssueDescriptionVersion',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('description_binary', models.BinaryField(null=True)),
('description_html', models.TextField(blank=True, default='<p></p>')),
('description_stripped', models.TextField(blank=True, null=True)),
('description_json', models.JSONField(blank=True, default=dict)),
('last_saved_at', models.DateTimeField(default=django.utils.timezone.now)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='description_versions', to='db.issue')),
('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_description_versions', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Description Version',
'verbose_name_plural': 'Issue Description Versions',
'db_table': 'issue_description_versions',
},
),
]
+19 -4
View File
@@ -41,8 +41,6 @@ from .issue import (
IssueSequence,
IssueSubscriber,
IssueVote,
IssueVersion,
IssueDescriptionVersion,
)
from .module import Module, ModuleIssue, ModuleLink, ModuleMember, ModuleUserProperties
from .notification import EmailNotificationLog, Notification, UserNotificationPreference
@@ -55,6 +53,7 @@ from .project import (
ProjectMemberInvite,
ProjectPublicMember,
)
from .deploy_board import DeployBoard
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State
@@ -70,6 +69,24 @@ from .workspace import (
WorkspaceUserProperties,
)
from .importer import Importer
from .page import Page, PageLog, PageLabel
from .estimate import Estimate, EstimatePoint
from .intake import Intake, IntakeIssue
from .analytic import AnalyticView
from .notification import Notification, UserNotificationPreference, EmailNotificationLog
from .exporter import ExporterHistory
from .webhook import Webhook, WebhookLog
from .dashboard import Dashboard, DashboardWidget, Widget
from .favorite import UserFavorite
from .issue_type import IssueType
@@ -79,5 +96,3 @@ from .recent_visit import UserRecentVisit
from .label import Label
from .device import Device, DeviceSession
from .sticky import Sticky
+26 -6
View File
@@ -44,25 +44,45 @@ class FileAsset(BaseModel):
"db.User", on_delete=models.CASCADE, null=True, related_name="assets"
)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets"
"db.Workspace",
on_delete=models.CASCADE,
null=True,
related_name="assets",
)
draft_issue = models.ForeignKey(
"db.DraftIssue", on_delete=models.CASCADE, null=True, related_name="assets"
"db.DraftIssue",
on_delete=models.CASCADE,
null=True,
related_name="assets",
)
project = models.ForeignKey(
"db.Project", on_delete=models.CASCADE, null=True, related_name="assets"
"db.Project",
on_delete=models.CASCADE,
null=True,
related_name="assets",
)
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, null=True, related_name="assets"
)
comment = models.ForeignKey(
"db.IssueComment", on_delete=models.CASCADE, null=True, related_name="assets"
"db.IssueComment",
on_delete=models.CASCADE,
null=True,
related_name="assets",
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, null=True, related_name="assets"
)
entity_type = models.CharField(max_length=255, null=True, blank=True)
entity_identifier = models.CharField(max_length=255, null=True, blank=True)
entity_type = models.CharField(
max_length=255,
null=True,
blank=True,
)
entity_identifier = models.CharField(
max_length=255,
null=True,
blank=True,
)
is_deleted = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
external_id = models.CharField(max_length=255, null=True, blank=True)
+48 -88
View File
@@ -15,7 +15,6 @@ from django import apps
from plane.utils.html_processor import strip_tags
from plane.db.mixins import SoftDeletionManager
from plane.utils.exception_logger import log_exception
from .base import BaseModel
from .project import ProjectBaseModel
@@ -661,6 +660,11 @@ class IssueVote(ProjectBaseModel):
class IssueVersion(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue",
on_delete=models.CASCADE,
related_name="versions",
)
PRIORITY_CHOICES = (
("urgent", "Urgent"),
("high", "High"),
@@ -668,11 +672,14 @@ class IssueVersion(ProjectBaseModel):
("low", "Low"),
("none", "None"),
)
parent = models.UUIDField(blank=True, null=True)
state = models.UUIDField(blank=True, null=True)
estimate_point = models.UUIDField(blank=True, null=True)
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
priority = models.CharField(
max_length=30,
choices=PRIORITY_CHOICES,
@@ -681,9 +688,9 @@ class IssueVersion(ProjectBaseModel):
)
start_date = models.DateField(null=True, blank=True)
target_date = models.DateField(null=True, blank=True)
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
labels = ArrayField(models.UUIDField(), blank=True, default=list)
sequence_id = models.IntegerField(
default=1, verbose_name="Issue Sequence ID"
)
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
@@ -691,26 +698,29 @@ class IssueVersion(ProjectBaseModel):
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
type = models.UUIDField(blank=True, null=True)
cycle = models.UUIDField(null=True, blank=True)
modules = ArrayField(models.UUIDField(), blank=True, default=list)
properties = models.JSONField(default=dict) # issue properties
meta = models.JSONField(default=dict) # issue meta
last_saved_at = models.DateTimeField(default=timezone.now)
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="versions"
owned_by = models.UUIDField()
assignees = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
activity = models.ForeignKey(
"db.IssueActivity",
on_delete=models.SET_NULL,
labels = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
cycle = models.UUIDField(
null=True,
related_name="versions",
blank=True,
)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_versions",
modules = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
properties = models.JSONField(default=dict)
meta = models.JSONField(default=dict)
class Meta:
verbose_name = "Issue Version"
@@ -730,93 +740,43 @@ class IssueVersion(ProjectBaseModel):
Module = apps.get_model("db.Module")
CycleIssue = apps.get_model("db.CycleIssue")
IssueAssignee = apps.get_model("db.IssueAssignee")
IssueLabel = apps.get_model("db.IssueLabel")
cycle_issue = CycleIssue.objects.filter(issue=issue).first()
cycle_issue = CycleIssue.objects.filter(
issue=issue,
).first()
cls.objects.create(
issue=issue,
parent=issue.parent_id,
state=issue.state_id,
estimate_point=issue.estimate_point_id,
parent=issue.parent,
state=issue.state,
point=issue.point,
estimate_point=issue.estimate_point,
name=issue.name,
description=issue.description,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_binary=issue.description_binary,
priority=issue.priority,
start_date=issue.start_date,
target_date=issue.target_date,
assignees=list(
IssueAssignee.objects.filter(issue=issue).values_list(
"assignee_id", flat=True
)
),
sequence_id=issue.sequence_id,
labels=list(
IssueLabel.objects.filter(issue=issue).values_list(
"label_id", flat=True
)
),
sort_order=issue.sort_order,
completed_at=issue.completed_at,
archived_at=issue.archived_at,
is_draft=issue.is_draft,
external_source=issue.external_source,
external_id=issue.external_id,
type=issue.type_id,
cycle=cycle_issue.cycle_id if cycle_issue else None,
modules=list(
Module.objects.filter(issue=issue).values_list("id", flat=True)
type=issue.type,
last_saved_at=issue.last_saved_at,
assignees=issue.assignees,
labels=issue.labels,
cycle=cycle_issue.cycle if cycle_issue else None,
modules=Module.objects.filter(issue=issue).values_list(
"id", flat=True
),
properties={},
meta={},
last_saved_at=timezone.now(),
owned_by=user,
)
return True
except Exception as e:
log_exception(e)
return False
class IssueDescriptionVersion(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="description_versions"
)
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_json = models.JSONField(default=dict, blank=True)
last_saved_at = models.DateTimeField(default=timezone.now)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_description_versions",
)
class Meta:
verbose_name = "Issue Description Version"
verbose_name_plural = "Issue Description Versions"
db_table = "issue_description_versions"
@classmethod
def log_issue_description_version(cls, issue, user):
try:
"""
Log the issue description version
"""
cls.objects.create(
workspace_id=issue.workspace_id,
project_id=issue.project_id,
created_by_id=issue.created_by_id,
updated_by_id=issue.updated_by_id,
owned_by_id=user,
last_saved_at=timezone.now(),
issue_id=issue.id,
description_binary=issue.description_binary,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_json=issue.description,
)
return True
except Exception as e:
log_exception(e)
return False
-32
View File
@@ -1,32 +0,0 @@
# Django imports
from django.conf import settings
from django.db import models
# Module imports
from .base import BaseModel
class Sticky(BaseModel):
name = models.TextField()
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
logo_props = models.JSONField(default=dict)
color = models.CharField(max_length=255, blank=True, null=True)
background_color = models.CharField(max_length=255, blank=True, null=True)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="stickies"
)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stickies"
)
class Meta:
verbose_name = "Sticky"
verbose_name_plural = "Stickies"
db_table = "stickies"
ordering = ("-created_at",)
-14
View File
@@ -26,14 +26,6 @@ def get_default_onboarding():
}
def get_mobile_default_onboarding():
return {
"profile_complete": False,
"workspace_create": False,
"workspace_join": False,
}
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
@@ -186,12 +178,6 @@ class Profile(TimeAuditModel):
billing_address = models.JSONField(null=True)
has_billing_address = models.BooleanField(default=False)
company_name = models.CharField(max_length=255, blank=True)
# mobile
is_mobile_onboarded = models.BooleanField(default=False)
mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding)
mobile_timezone_auto_set = models.BooleanField(default=False)
# language
language = models.CharField(max_length=255, default="en")
class Meta:
verbose_name = "Profile"
+3 -1
View File
@@ -29,7 +29,9 @@ def validate_domain(value):
class Webhook(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks"
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_webhooks",
)
url = models.URLField(
validators=[validate_schema, validate_domain], max_length=1024
+25 -7
View File
@@ -102,7 +102,12 @@ def get_default_display_properties():
def get_issue_props():
return {"subscribed": True, "assigned": True, "created": True, "all_issues": True}
return {
"subscribed": True,
"assigned": True,
"created": True,
"all_issues": True,
}
def slug_validator(value):
@@ -131,7 +136,9 @@ class Workspace(BaseModel):
max_length=48, db_index=True, unique=True, validators=[slug_validator]
)
organization_size = models.CharField(max_length=20, blank=True, null=True)
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
timezone = models.CharField(
max_length=255, default="UTC", choices=TIMEZONE_CHOICES
)
def __str__(self):
"""Return name of the Workspace"""
@@ -160,7 +167,10 @@ class WorkspaceBaseModel(BaseModel):
"db.Workspace", models.CASCADE, related_name="workspace_%(class)s"
)
project = models.ForeignKey(
"db.Project", models.CASCADE, related_name="project_%(class)s", null=True
"db.Project",
models.CASCADE,
related_name="project_%(class)s",
null=True,
)
class Meta:
@@ -174,7 +184,9 @@ class WorkspaceBaseModel(BaseModel):
class WorkspaceMember(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member"
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_member",
)
member = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -209,7 +221,9 @@ class WorkspaceMember(BaseModel):
class WorkspaceMemberInvite(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite"
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_member_invite",
)
email = models.CharField(max_length=255)
accepted = models.BooleanField(default=False)
@@ -269,7 +283,9 @@ class WorkspaceTheme(BaseModel):
)
name = models.CharField(max_length=300)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes"
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="themes",
)
colors = models.JSONField(default=dict)
@@ -304,7 +320,9 @@ class WorkspaceUserProperties(BaseModel):
)
filters = models.JSONField(default=get_default_filters)
display_filters = models.JSONField(default=get_default_display_filters)
display_properties = models.JSONField(default=get_default_display_properties)
display_properties = models.JSONField(
default=get_default_display_properties
)
class Meta:
unique_together = ["workspace", "user", "deleted_at"]
@@ -2,4 +2,4 @@ from .instance import InstanceSerializer
from .configuration import InstanceConfigurationSerializer
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
from .workspace import WorkspaceSerializer
from .workspace import WorkspaceSerializer
@@ -1,8 +1,6 @@
from .base import BaseSerializer
from plane.db.models import User
class UserLiteSerializer(BaseSerializer):
class Meta:
model = User
fields = ["id", "email", "first_name", "last_name"]
fields = ["id", "email", "first_name", "last_name",]
@@ -13,8 +13,6 @@ from .admin import (
InstanceAdminUserSessionEndpoint,
)
from .changelog import ChangeLogEndpoint
from .workspace import (
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint
@@ -0,0 +1,33 @@
# Python imports
import requests
# Django imports
from django.conf import settings
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
# plane imports
from .base import BaseAPIView
class ChangeLogEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def fetch_change_logs(self):
response = requests.get(settings.INSTANCE_CHANGELOG_URL)
response.raise_for_status()
return response.json()
def get(self, request):
# Fetch the changelog
if settings.INSTANCE_CHANGELOG_URL:
data = self.fetch_change_logs()
return Response(data, status=status.HTTP_200_OK)
else:
return Response(
{"error": "could not fetch changelog please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -43,19 +43,19 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
member_count = (
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False, is_active=True
)
.select_related("owner")
).select_related("owner")
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
workspaces = Workspace.objects.annotate(
total_projects=project_count, total_members=member_count
total_projects=project_count,
total_members=member_count,
)
# Add search functionality
@@ -66,14 +66,16 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=workspaces,
on_results=lambda results: WorkspaceSerializer(results, many=True).data,
on_results=lambda results: WorkspaceSerializer(
results, many=True,
).data,
max_per_page=10,
default_per_page=10,
)
def post(self, request):
try:
serializer = WorkspaceSerializer(data=request.data)
serializer = WorkspaceSerializer (data=request.data)
slug = request.data.get("slug", False)
name = request.data.get("name", False)
+7 -1
View File
@@ -11,12 +11,14 @@ from plane.license.api.views import (
InstanceAdminUserMeEndpoint,
InstanceAdminSignOutEndpoint,
InstanceAdminUserSessionEndpoint,
ChangeLogEndpoint,
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
urlpatterns = [
path("", InstanceEndpoint.as_view(), name="instance"),
path("changelog/", ChangeLogEndpoint.as_view(), name="instance-changelog"),
path("admins/", InstanceAdminEndpoint.as_view(), name="instance-admins"),
path("admins/me/", InstanceAdminUserMeEndpoint.as_view(), name="instance-admins"),
path(
@@ -60,5 +62,9 @@ urlpatterns = [
InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(),
name="instance-workspace-availability",
),
path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
path(
"workspaces/",
InstanceWorkSpaceEndpoint.as_view(),
name="instance-workspace",
),
]
-3
View File
@@ -262,9 +262,6 @@ CELERY_IMPORTS = (
"plane.license.bgtasks.tracer",
# management tasks
"plane.bgtasks.dummy_data_task",
# issue version tasks
"plane.bgtasks.issue_version_sync",
"plane.bgtasks.issue_description_version_sync",
)
# Sentry Settings
-6
View File
@@ -10,15 +10,9 @@ from plane.space.views import (
ProjectStatesEndpoint,
ProjectLabelsEndpoint,
ProjectMembersEndpoint,
ProjectMetaDataEndpoint,
)
urlpatterns = [
path(
"anchor/<str:anchor>/meta/",
ProjectMetaDataEndpoint.as_view(),
name="project-meta",
),
path(
"anchor/<str:anchor>/settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
-2
View File
@@ -25,5 +25,3 @@ from .state import ProjectStatesEndpoint
from .label import ProjectLabelsEndpoint
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
from .meta import ProjectMetaDataEndpoint
-34
View File
@@ -1,34 +0,0 @@
# third party
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework.response import Response
from plane.db.models import DeployBoard, Project
from .base import BaseAPIView
from plane.space.serializer.project import ProjectLiteSerializer
class ProjectMetaDataEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
try:
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
except DeployBoard.DoesNotExist:
return Response(
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
)
try:
project_id = deploy_board.entity_identifier
project = Project.objects.get(id=project_id)
except Project.DoesNotExist:
return Response(
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
)
serializer = ProjectLiteSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
-100
View File
@@ -1,100 +0,0 @@
import pytz
from plane.db.models import Project
from datetime import datetime, time
from datetime import timedelta
def user_timezone_converter(queryset, datetime_fields, user_timezone):
# Create a timezone object for the user's timezone
user_tz = pytz.timezone(user_timezone)
# Check if queryset is a dictionary (single item) or a list of dictionaries
if isinstance(queryset, dict):
queryset_values = [queryset]
else:
queryset_values = list(queryset)
# Iterate over the dictionaries in the list
for item in queryset_values:
# Iterate over the datetime fields
for field in datetime_fields:
# Convert the datetime field to the user's timezone
if field in item and item[field]:
item[field] = item[field].astimezone(user_tz)
# If queryset was a single item, return a single item
if isinstance(queryset, dict):
return queryset_values[0]
else:
return queryset_values
def convert_to_utc(date, project_id, is_start_date=False):
"""
Converts a start date string to the project's local timezone at 12:00 AM
and then converts it to UTC for storage.
Args:
date (str): The date string in "YYYY-MM-DD" format.
project_id (int): The project's ID to fetch the associated timezone.
Returns:
datetime: The UTC datetime.
"""
# Retrieve the project's timezone using the project ID
project = Project.objects.get(id=project_id)
project_timezone = project.timezone
if not date or not project_timezone:
raise ValueError("Both date and timezone must be provided.")
# Parse the string into a date object
start_date = datetime.strptime(date, "%Y-%m-%d").date()
# Get the project's timezone
local_tz = pytz.timezone(project_timezone)
# Combine the date with 12:00 AM time
local_datetime = datetime.combine(start_date, time.min)
# Localize the datetime to the project's timezone
localized_datetime = local_tz.localize(local_datetime)
# If it's an start date, add one minute
if is_start_date:
localized_datetime += timedelta(minutes=1)
# Convert the localized datetime to UTC
utc_datetime = localized_datetime.astimezone(pytz.utc)
# Return the UTC datetime for storage
return utc_datetime
def convert_utc_to_project_timezone(utc_datetime, project_id):
"""
Converts a UTC datetime (stored in the database) to the project's local timezone.
Args:
utc_datetime (datetime): The UTC datetime to be converted.
project_id (int): The project's ID to fetch the associated timezone.
Returns:
datetime: The datetime in the project's local timezone.
"""
# Retrieve the project's timezone using the project ID
project = Project.objects.get(id=project_id)
project_timezone = project.timezone
if not project_timezone:
raise ValueError("Project timezone must be provided.")
# Get the timezone object for the project's timezone
local_tz = pytz.timezone(project_timezone)
# Convert the UTC datetime to the project's local timezone
if utc_datetime.tzinfo is None:
# Localize UTC datetime if it's naive (i.e., without timezone info)
utc_datetime = pytz.utc.localize(utc_datetime)
# Convert to the project's local timezone
local_datetime = utc_datetime.astimezone(local_tz)
return local_datetime
@@ -0,0 +1,26 @@
import pytz
def user_timezone_converter(queryset, datetime_fields, user_timezone):
# Create a timezone object for the user's timezone
user_tz = pytz.timezone(user_timezone)
# Check if queryset is a dictionary (single item) or a list of dictionaries
if isinstance(queryset, dict):
queryset_values = [queryset]
else:
queryset_values = list(queryset)
# Iterate over the dictionaries in the list
for item in queryset_values:
# Iterate over the datetime fields
for field in datetime_fields:
# Convert the datetime field to the user's timezone
if field in item and item[field]:
item[field] = item[field].astimezone(user_tz)
# If queryset was a single item, return a single item
if isinstance(queryset, dict):
return queryset_values[0]
else:
return queryset_values
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.17
Django==4.2.16
# rest framework
djangorestframework==3.15.2
# postgres
+1 -1
View File
@@ -70,7 +70,7 @@
"value": ""
},
"GITHUB_CLIENT_SECRET": {
"description": "GitHub Client Secret",
"description": "Github Client Secret",
"value": ""
},
"NEXT_PUBLIC_API_BASE_URL": {
+14 -14
View File
@@ -1,36 +1,36 @@
{
"name": "live",
"version": "0.24.1",
"version": "0.24.0",
"description": "",
"main": "./src/server.ts",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
"lint": "eslint . --ext .ts,.tsx",
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@hocuspocus/extension-database": "^2.11.3",
"@hocuspocus/extension-logger": "^2.11.3",
"@hocuspocus/extension-redis": "^2.13.5",
"@hocuspocus/server": "^2.11.3",
"@hocuspocus/extension-database": "^2.13.7",
"@hocuspocus/extension-logger": "^2.13.7",
"@hocuspocus/extension-redis": "^2.13.7",
"@hocuspocus/server": "^2.13.7",
"@plane/editor": "*",
"@plane/types": "*",
"@sentry/node": "^8.28.0",
"@sentry/profiling-node": "^8.28.0",
"@tiptap/core": "^2.4.0",
"@tiptap/html": "^2.3.0",
"@tiptap/core": "^2.9.1",
"@tiptap/html": "^2.9.1",
"axios": "^1.7.2",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express": "^4.20.0",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
@@ -39,14 +39,14 @@
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.9",
"y-prosemirror": "^1.2.12",
"y-protocols": "^1.0.6",
"yjs": "^13.6.14"
"yjs": "^13.6.20"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-env": "^7.25.9",
"@babel/preset-typescript": "^7.24.7",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
+18 -10
View File
@@ -5,8 +5,9 @@ import { Database } from "@hocuspocus/extension-database";
import { Extension } from "@hocuspocus/server";
import { Logger } from "@hocuspocus/extension-logger";
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
// core helpers and utilities
import { manualLogger } from "@/core/helpers/logger.js";
// Core helpers and utilities
import { logger } from "@/core/helpers/logger.js";
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
// core libraries
import {
@@ -27,7 +28,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
new Logger({
onChange: false,
log: (message) => {
manualLogger.info(message);
logger.info(message);
},
}),
new Database({
@@ -40,7 +41,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
return new Promise(async (resolve, reject) => {
try {
let fetchedData = null;
if (documentType === "project_page") {
@@ -57,9 +58,16 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
params,
});
}
if (!fetchedData) {
reject("Data is null");
return;
}
resolve(fetchedData);
} catch (error) {
manualLogger.error("Error in fetching document", error);
logger.error("Error in fetching document", error);
reject("Error in fetching document" + JSON.stringify(error));
}
});
},
@@ -92,7 +100,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
});
}
} catch (error) {
manualLogger.error("Error in updating document:", error);
logger.error("Error in updating document:", error);
}
});
},
@@ -114,7 +122,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
) {
redisClient.disconnect();
}
manualLogger.warn(
logger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
@@ -123,18 +131,18 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
redisClient.on("ready", () => {
extensions.push(new HocusPocusRedis({ redis: redisClient }));
manualLogger.info("Redis Client connected ✅");
logger.info("Redis Client connected ✅");
resolve();
});
});
} catch (error) {
manualLogger.warn(
logger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
}
} else {
manualLogger.warn(
logger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
);
}
+2 -2
View File
@@ -1,9 +1,9 @@
import { ErrorRequestHandler } from "express";
import { manualLogger } from "@/core/helpers/logger.js";
import { logger } from "@/core/helpers/logger.js";
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
// Log the error
manualLogger.error(err);
logger.error(err);
// Set the response status
res.status(err.status || 500);
+2 -2
View File
@@ -18,7 +18,7 @@ const hooks = {
},
};
export const logger = pinoHttp({
export const coreLogger = pinoHttp({
level: "info",
transport: transport,
hooks: hooks,
@@ -35,4 +35,4 @@ export const logger = pinoHttp({
},
});
export const manualLogger = logger.logger;
export const logger = coreLogger.logger;
+25 -14
View File
@@ -1,17 +1,25 @@
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs"
import {
prosemirrorJSONToYDoc,
yXmlFragmentToProseMirrorRootNode,
} from "y-prosemirror";
import * as Y from "yjs";
// plane editor
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
} from "@plane/editor/lib";
const DOCUMENT_EDITOR_EXTENSIONS = [
...CoreEditorExtensionsWithoutProps,
...DocumentEditorExtensionsWithoutProps,
];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
export const getAllDocumentFormatsFromBinaryData = (
description: Uint8Array,
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
@@ -24,7 +32,7 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(
type,
documentEditorSchema
documentEditorSchema,
).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
@@ -34,26 +42,29 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
contentJSON,
contentHTML,
};
}
};
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
contentBinary: Uint8Array
export const getBinaryDataFromHTMLString = (
descriptionHTML: string,
): {
contentBinary: Uint8Array;
} => {
// convert HTML to JSON
const contentJSON = generateJSON(
descriptionHTML ?? "<p></p>",
DOCUMENT_EDITOR_EXTENSIONS
DOCUMENT_EDITOR_EXTENSIONS,
);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(
documentEditorSchema,
contentJSON,
"default"
"default",
);
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return {
contentBinary: encodedData
}
}
contentBinary: encodedData,
};
};
+2 -2
View File
@@ -1,7 +1,7 @@
// services
import { UserService } from "@/core/services/user.service.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
import { logger } from "@/core/helpers/logger.js";
const userService = new UserService();
@@ -17,7 +17,7 @@ export const handleAuthentication = async (props: Props) => {
try {
response = await userService.currentUser(cookie);
} catch (error) {
manualLogger.error("Failed to fetch current user:", error);
logger.error("Failed to fetch current user:", error);
throw error;
}
if (response.id !== userId) {
+4 -4
View File
@@ -5,7 +5,7 @@ import {
} from "@/core/helpers/page.js";
// services
import { PageService } from "@/core/services/page.service.js";
import { manualLogger } from "../helpers/logger.js";
import { logger } from "../helpers/logger.js";
const pageService = new PageService();
export const updatePageDescription = async (
@@ -41,7 +41,7 @@ export const updatePageDescription = async (
cookie,
);
} catch (error) {
manualLogger.error("Update error:", error);
logger.error("Update error:", error);
throw error;
}
};
@@ -66,7 +66,7 @@ const fetchDescriptionHTMLAndTransform = async (
);
return contentBinary;
} catch (error) {
manualLogger.error(
logger.error(
"Error while transforming from HTML to Uint8Array",
error,
);
@@ -106,7 +106,7 @@ export const fetchPageDescriptionBinary = async (
return binaryData;
} catch (error) {
manualLogger.error("Fetch error:", error);
logger.error("Fetch error:", error);
throw error;
}
};
+17 -16
View File
@@ -13,7 +13,10 @@ import cors from "cors";
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
import { logger, manualLogger } from "@/core/helpers/logger.js";
import {
coreLogger as LoggerMiddleware,
logger,
} from "@/core/helpers/logger.js";
import { errorHandler } from "@/core/helpers/error-handler.js";
const app = express();
@@ -33,7 +36,7 @@ app.use(
);
// Logging middleware
app.use(logger);
app.use(LoggerMiddleware);
// Body parsing middleware
app.use(express.json());
@@ -45,7 +48,7 @@ app.use(cors());
const router = express.Router();
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
logger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
@@ -57,7 +60,7 @@ router.ws("/collaboration", (ws, req) => {
try {
HocusPocusServer.handleConnection(ws, req);
} catch (err) {
manualLogger.error("WebSocket connection error:", err);
logger.error("WebSocket connection error:", err);
ws.close();
}
});
@@ -73,46 +76,44 @@ Sentry.setupExpressErrorHandler(app);
app.use(errorHandler);
const liveServer = app.listen(app.get("port"), () => {
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
logger.info(`Plane Live server has started at port ${app.get("port")}`);
});
const gracefulShutdown = async () => {
manualLogger.info("Starting graceful shutdown...");
logger.info("Starting graceful shutdown...");
try {
// Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy();
manualLogger.info(
"HocusPocus server WebSocket connections closed gracefully.",
);
logger.info("HocusPocus server WebSocket connections closed gracefully.");
// Close the Express server
liveServer.close(() => {
manualLogger.info("Express server closed gracefully.");
logger.info("Express server closed gracefully.");
process.exit(1);
});
} catch (err) {
manualLogger.error("Error during shutdown:", err);
logger.error("Error during shutdown:", err);
process.exit(1);
}
// Forcefully shut down after 10 seconds if not closed
setTimeout(() => {
manualLogger.error("Forcing shutdown...");
logger.error("Forcing shutdown...");
process.exit(1);
}, 10000);
};
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", (err: any) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
logger.info("Unhandled Rejection: ", err);
logger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
gracefulShutdown();
});
// Graceful shutdown on uncaught exception
process.on("uncaughtException", (err: any) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
logger.info("Uncaught Exception: ", err);
logger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
gracefulShutdown();
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.24.1",
"version": "0.24.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.24.1",
"version": "0.24.0",
"private": true,
"main": "./src/index.ts"
}
-3
View File
@@ -13,6 +13,3 @@ export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}/`);
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}/`);
// plane website url
export const WEBSITE_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
-1
View File
@@ -11,7 +11,6 @@ export enum EIssueGroupByToServerOptions {
"target_date" = "target_date",
"project" = "project_id",
"created_by" = "created_by",
"team_project" = "project_id",
}
export enum EIssueGroupBYServerToProperty {
+23 -23
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.24.1",
"version": "0.24.0",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -27,7 +27,6 @@
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"peerDependencies": {
@@ -37,25 +36,25 @@
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.13.5",
"@plane/helpers": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
"@tiptap/extension-collaboration": "^2.3.2",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-list-item": "^2.1.13",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/extension-placeholder": "^2.3.0",
"@tiptap/extension-task-item": "^2.1.13",
"@tiptap/extension-task-list": "^2.1.13",
"@tiptap/extension-text-align": "^2.8.0",
"@tiptap/extension-text-style": "^2.7.1",
"@tiptap/extension-underline": "^2.1.13",
"@tiptap/pm": "^2.1.13",
"@tiptap/react": "^2.1.13",
"@tiptap/starter-kit": "^2.1.13",
"@tiptap/suggestion": "^2.0.13",
"@tiptap/core": "^2.9.1",
"@tiptap/extension-blockquote": "^2.9.1",
"@tiptap/extension-character-count": "^2.9.1",
"@tiptap/extension-collaboration": "^2.9.1",
"@tiptap/extension-image": "^2.9.1",
"@tiptap/extension-list-item": "^2.9.1",
"@tiptap/extension-mention": "^2.9.1",
"@tiptap/extension-placeholder": "^2.9.1",
"@tiptap/extension-task-item": "^2.9.1",
"@tiptap/extension-task-list": "^2.9.1",
"@tiptap/extension-text-style": "^2.9.1",
"@tiptap/extension-underline": "^2.9.1",
"@tiptap/extension-text-align": "^2.9.1",
"@tiptap/pm": "^2.9.1",
"@tiptap/react": "^2.9.1",
"@tiptap/starter-kit": "^2.9.1",
"@tiptap/suggestion": "^2.9.1",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"highlight.js": "^11.8.0",
@@ -64,16 +63,17 @@
"lowlight": "^3.0.0",
"lucide-react": "^0.378.0",
"prosemirror-codemark": "^0.4.2",
"prosemirror-flat-list": "^0.5.4",
"prosemirror-utils": "^1.2.2",
"react-moveable": "^0.54.2",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.9",
"tiptap-markdown": "^0.8.10",
"uuid": "^10.0.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.5",
"y-prosemirror": "^1.2.12",
"y-protocols": "^1.0.6",
"yjs": "^13.6.15"
"yjs": "^13.6.20"
},
"devDependencies": {
"@plane/eslint-config": "*",
@@ -35,6 +35,10 @@ import {
toggleBold,
toggleBulletList,
toggleCodeBlock,
toggleFlatBulletList,
toggleFlatOrderedList,
toggleFlatTaskList,
toggleFlatToggleList,
toggleHeadingFive,
toggleHeadingFour,
toggleHeadingOne,
@@ -153,27 +157,35 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough
export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
isActive: () => editor?.isActive("list", { type: "bullet" }),
command: () => toggleFlatBulletList(editor),
icon: ListIcon,
});
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
isActive: () => editor?.isActive("list", { type: "ordered" }),
command: () => toggleFlatOrderedList(editor),
icon: ListIcon,
});
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
key: "to-do-list",
name: "To-do list",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
isActive: () => editor?.isActive("list", { type: "task" }),
command: () => toggleFlatTaskList(editor),
icon: CheckSquare,
});
export const ToggleListItem = (editor: Editor): EditorMenuItem<"toggle-list"> => ({
key: "toggle-list",
name: "Toggle list",
isActive: () => editor?.isActive("list", { type: "toggle" }),
command: () => toggleFlatToggleList(editor),
icon: ListIcon,
});
export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
key: "quote",
name: "Quote",
@@ -265,6 +277,7 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
HorizontalRuleItem(editor),
TextColorItem(editor),
BackgroundColorItem(editor),
ToggleListItem(editor),
TextAlignItem(editor),
];
};
@@ -3,6 +3,4 @@ export const DocumentCollaborativeEvents = {
unlock: { client: "unlocked", server: "unlock" },
archive: { client: "archived", server: "archive" },
unarchive: { client: "unarchived", server: "unarchive" },
"make-public": { client: "made-public", server: "make-public" },
"make-private": { client: "made-private", server: "make-private" },
} as const;
@@ -1,5 +1,5 @@
// plane helpers
import { convertHexEmojiToDecimal } from "@plane/utils";
import { convertHexEmojiToDecimal } from "@plane/helpers";
// plane ui
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
// helpers
@@ -1,5 +1,5 @@
// plane helpers
import { sanitizeHTML } from "@plane/utils";
import { sanitizeHTML } from "@plane/helpers";
// plane ui
import { TEmojiLogoProps } from "@plane/ui";
// types
@@ -1,5 +1,5 @@
import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Plugin, PluginKey, Selection } from "@tiptap/pm/state";
export interface CodeBlockOptions {
/**
@@ -150,6 +150,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
// exit node on triple enter
Enter: ({ editor }) => {
try {
console.log("ran in code block");
if (!this.options.exitOnTripleEnter) {
return false;
}
@@ -183,8 +184,6 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
},
// exit node on arrow down
ArrowDown: ({ editor }) => {
try {
if (!this.options.exitOnArrowDown) {
@@ -205,7 +204,12 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
const after = $from.after();
// if the code block is directly on the root level, then just
// find the next node at same depth (basically the root and code block are at the same level)
// else it's always to be found at $from.depth - 1 to set the cursor at the next node
const parentDepth = $from.depth === 1 ? $from.depth : $from.depth - 1;
const after = $from.after(parentDepth);
if (after === undefined) {
return false;
@@ -214,12 +218,15 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
const nodeAfter = doc.nodeAt(after);
if (nodeAfter) {
return false;
return editor.commands.command(({ tr }) => {
tr.setSelection(Selection.near(doc.resolve(after)));
return true;
});
}
return editor.commands.exitCode();
} catch (error) {
console.error("Error handling ArrowDown in code block:", error);
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
return false;
}
},
@@ -91,7 +91,12 @@ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
return false;
}
const after = $from.after();
// if the code block is directly on the root level, then just
// find the next node at same depth (basically the root and code block are at the same level)
// else it's always to be found at $from.depth - 1 to set the cursor at the next node
const parentDepth = $from.depth === 1 ? $from.depth : $from.depth - 1;
const after = $from.after(parentDepth);
if (after === undefined) {
return false;
@@ -19,6 +19,7 @@ import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomTextAlignExtension } from "./text-align";
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomColorExtension } from "./custom-color";
import { FlatListExtension } from "./flat-list/flat-list";
// plane editor extensions
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
@@ -43,16 +44,6 @@ export const CoreEditorExtensionsWithoutProps = [
codeBlock: false,
horizontalRule: false,
blockquote: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
dropcursor: false,
}),
CustomQuoteExtension,
@@ -101,6 +92,7 @@ export const CoreEditorExtensionsWithoutProps = [
CustomTextAlignExtension,
CustomCalloutExtensionConfig,
CustomColorExtension,
FlatListExtension,
...CoreEditorAdditionalExtensionsWithoutProps,
];
@@ -88,41 +88,41 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
return handled;
},
Backspace: ({ editor }) => {
try {
let handled = false;
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true;
}
});
return handled;
} catch (e) {
console.log("Error in handling Backspace:", e);
return false;
}
},
"Mod-Backspace": ({ editor }) => {
let handled = false;
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true;
}
});
return handled;
},
// Backspace: ({ editor }) => {
// try {
// let handled = false;
//
// this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
// if (editor.state.schema.nodes[itemName] === undefined) {
// return;
// }
//
// if (handleBackspace(editor, itemName, wrapperNames)) {
// handled = true;
// }
// });
//
// return handled;
// } catch (e) {
// console.log("Error in handling Backspace:", e);
// return false;
// }
// },
// "Mod-Backspace": ({ editor }) => {
// let handled = false;
//
// this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
// if (editor.state.schema.nodes[itemName] === undefined) {
// return;
// }
//
// if (handleBackspace(editor, itemName, wrapperNames)) {
// handled = true;
// }
// });
//
// return handled;
// },
};
},
});
@@ -0,0 +1,234 @@
import { Plugin, EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { dropPoint } from "prosemirror-transform";
import { Extension } from "@tiptap/core";
export function dropCursor(options: DropCursorOptions = {}): Plugin {
return new Plugin({
view(editorView) {
return new DropCursorView(editorView, {
...options,
// Add custom behavior for list nodes
disableDropCursor: (view: EditorView, pos: { pos: number; inside: number }, event: DragEvent) => {
console.log("adf");
if (!pos) return true;
const $pos = view.state.doc.resolve(pos.pos);
const parentNode = $pos.parent;
// If we're between two list items, only show cursor at the list boundary
if (parentNode.type.name === "list" || parentNode.type.name.includes("list")) {
const nodeBefore = $pos.nodeBefore;
const nodeAfter = $pos.nodeAfter;
// Only show cursor at list boundaries
if (nodeBefore?.type.name.includes("list") && nodeAfter?.type.name.includes("list")) {
// Only allow cursor at the exact position between lists
return $pos.pos !== pos.pos;
}
}
return false;
},
});
},
});
}
interface DropCursorOptions {
/// The color of the cursor. Defaults to `black`. Use `false` to apply no color and rely only on class.
color?: string | false;
/// The precise width of the cursor in pixels. Defaults to 1.
width?: number;
/// A CSS class name to add to the cursor element.
class?: string;
}
/// Create a plugin that, when added to a ProseMirror instance,
/// causes a decoration to show up at the drop position when something
/// is dragged over the editor.
///
/// Nodes may add a `disableDropCursor` property to their spec to
/// control the showing of a drop cursor inside them. This may be a
/// boolean or a function, which will be called with a view and a
/// position, and should return a boolean.
// export function dropCursor(options: DropCursorOptions = {}): Plugin {
// return new Plugin({
// view(editorView) {
// return new DropCursorView(editorView, options);
// },
// });
// }
class DropCursorView {
width: number;
color: string | undefined;
class: string | undefined;
cursorPos: number | null = null;
element: HTMLElement | null = null;
timeout: number = -1;
handlers: { name: string; handler: (event: Event) => void }[];
constructor(
readonly editorView: EditorView,
options: DropCursorOptions
) {
this.width = options.width ?? 1;
this.color = options.color === false ? undefined : options.color || "black";
this.class = options.class;
this.handlers = ["dragover", "dragend", "drop", "dragleave"].map((name) => {
let handler = (e: Event) => {
(this as any)[name](e);
};
editorView.dom.addEventListener(name, handler);
return { name, handler };
});
}
destroy() {
this.handlers.forEach(({ name, handler }) => this.editorView.dom.removeEventListener(name, handler));
}
update(editorView: EditorView, prevState: EditorState) {
if (this.cursorPos != null && prevState.doc != editorView.state.doc) {
if (this.cursorPos > editorView.state.doc.content.size) this.setCursor(null);
else this.updateOverlay();
}
}
setCursor(pos: number | null) {
if (pos == this.cursorPos) return;
this.cursorPos = pos;
if (pos == null) {
this.element!.parentNode!.removeChild(this.element!);
this.element = null;
} else {
this.updateOverlay();
}
}
updateOverlay() {
let $pos = this.editorView.state.doc.resolve(this.cursorPos!);
let isBlock = !$pos.parent.inlineContent,
rect;
let editorDOM = this.editorView.dom,
editorRect = editorDOM.getBoundingClientRect();
let scaleX = editorRect.width / editorDOM.offsetWidth,
scaleY = editorRect.height / editorDOM.offsetHeight;
if (isBlock) {
let before = $pos.nodeBefore,
after = $pos.nodeAfter;
if (before || after) {
let node = this.editorView.nodeDOM(this.cursorPos! - (before ? before.nodeSize : 0));
if (node) {
let nodeRect = (node as HTMLElement).getBoundingClientRect();
let top = before ? nodeRect.bottom : nodeRect.top;
if (before && after)
top = (top + (this.editorView.nodeDOM(this.cursorPos!) as HTMLElement).getBoundingClientRect().top) / 2;
let halfWidth = (this.width / 2) * scaleY;
rect = { left: nodeRect.left, right: nodeRect.right, top: top - halfWidth, bottom: top + halfWidth };
}
}
}
if (!rect) {
let coords = this.editorView.coordsAtPos(this.cursorPos!);
let halfWidth = (this.width / 2) * scaleX;
rect = { left: coords.left - halfWidth, right: coords.left + halfWidth, top: coords.top, bottom: coords.bottom };
}
let parent = this.editorView.dom.offsetParent as HTMLElement;
if (!this.element) {
this.element = parent.appendChild(document.createElement("div"));
if (this.class) this.element.className = this.class;
this.element.style.cssText = "position: absolute; z-index: 50; pointer-events: none;";
if (this.color) {
this.element.style.backgroundColor = this.color;
}
}
this.element.classList.toggle("prosemirror-dropcursor-block", isBlock);
this.element.classList.toggle("prosemirror-dropcursor-inline", !isBlock);
let parentLeft, parentTop;
if (!parent || (parent == document.body && getComputedStyle(parent).position == "static")) {
parentLeft = -pageXOffset;
parentTop = -pageYOffset;
} else {
let rect = parent.getBoundingClientRect();
let parentScaleX = rect.width / parent.offsetWidth,
parentScaleY = rect.height / parent.offsetHeight;
parentLeft = rect.left - parent.scrollLeft * parentScaleX;
parentTop = rect.top - parent.scrollTop * parentScaleY;
}
this.element.style.left = (rect.left - parentLeft) / scaleX + "px";
this.element.style.top = (rect.top - parentTop) / scaleY + "px";
this.element.style.width = (rect.right - rect.left) / scaleX + "px";
this.element.style.height = (rect.bottom - rect.top) / scaleY + "px";
}
scheduleRemoval(timeout: number) {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => this.setCursor(null), timeout);
}
dragover(event: DragEvent) {
if (!this.editorView.editable) return;
let pos = this.editorView.posAtCoords({ left: event.clientX, top: event.clientY });
let node = pos && pos.inside >= 0 && this.editorView.state.doc.nodeAt(pos.inside);
let disableDropCursor = node && node.type.spec.disableDropCursor;
let disabled =
typeof disableDropCursor == "function" ? disableDropCursor(this.editorView, pos, event) : disableDropCursor;
if (!pos) return true;
const $pos = this.editorView.state.doc.resolve(pos.pos);
const parentNode = $pos.parent;
// If we're between two list items, only show cursor at the list boundary
if (parentNode.type.name === "list" || parentNode.type.name.includes("list")) {
const nodeBefore = $pos.nodeBefore;
const nodeAfter = $pos.nodeAfter;
console.log(nodeBefore.type.name, nodeAfter.type.name);
// Only show cursor at list boundaries
if (nodeBefore?.type.name.includes("list") && nodeAfter?.type.name.includes("list")) {
// Only allow cursor at the exact position between lists
return $pos.pos !== pos.pos;
}
}
// return false;
if (pos && !disabled) {
let target: number | null = pos.pos;
if (this.editorView.dragging && this.editorView.dragging.slice) {
let point = dropPoint(this.editorView.state.doc, target, this.editorView.dragging.slice);
if (point != null) target = point;
}
this.setCursor(target);
this.scheduleRemoval(5000);
}
}
dragend() {
this.scheduleRemoval(20);
}
drop() {
this.scheduleRemoval(20);
}
dragleave(event: DragEvent) {
if (event.target == this.editorView.dom || !this.editorView.dom.contains((event as any).relatedTarget))
this.setCursor(null);
}
}
export const dropCursorExtension = (options: DropCursorOptions) =>
Extension.create({
addProseMirrorPlugins() {
return [dropCursor(options)];
},
});
@@ -1,11 +1,11 @@
import { Extensions } from "@tiptap/core";
import CharacterCount from "@tiptap/extension-character-count";
import Placeholder from "@tiptap/extension-placeholder";
import TaskItem from "@tiptap/extension-task-item";
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";
import BulletList from "@tiptap/extension-bullet-list";
import OrderedList from "@tiptap/extension-ordered-list";
import { Markdown } from "tiptap-markdown";
// extensions
import {
@@ -24,7 +24,6 @@ import {
CustomTypographyExtension,
DropHandlerExtension,
ImageExtension,
ListKeymap,
Table,
TableCell,
TableHeader,
@@ -34,8 +33,14 @@ import {
import { isValidHttpUrl } from "@/helpers/common";
// types
import { IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
import { FlatListExtension } from "./flat-list/flat-list";
import { multipleSelectionExtension } from "./selections/multipleSelections";
// plane editor extensions
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import ListItem from "@tiptap/extension-list-item";
type TArguments = {
disabledExtensions: TExtensions[];
@@ -54,40 +59,48 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
return [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
},
},
listItem: {
HTMLAttributes: {
class: "not-prose space-y-2",
},
},
bulletList: false,
orderedList: false,
listItem: false,
code: false,
codeBlock: false,
horizontalRule: false,
blockquote: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
// dropcursor: false,
dropcursor: {
class: "text-custom-text-300",
},
...(enableHistory ? {} : { history: false }),
}),
BulletList.extend({
addInputRules() {
return [];
},
addKeyboardShortcuts() {
return {};
},
}).configure({
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
},
}),
OrderedList.extend({
addInputRules() {
return [];
},
addKeyboardShortcuts() {
return {};
},
}).configure({
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
},
}),
ListItem.configure({
HTMLAttributes: {
class: "not-prose space-y-2",
},
}),
CustomQuoteExtension,
DropHandlerExtension(),
CustomHorizontalRule.configure({
@@ -96,7 +109,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
},
}),
CustomKeymap,
ListKeymap({ tabIndex }),
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
@@ -176,6 +188,9 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomTextAlignExtension,
CustomCalloutExtension,
CustomColorExtension,
FlatListExtension,
multipleSelectionExtension,
// FlatHeadingListExtension,
...CoreEditorAdditionalExtensions({
disabledExtensions,
}),
@@ -0,0 +1,575 @@
import { type TaggedProsemirrorNode } from 'jest-remirror'
import { type Node as ProsemirrorNode } from 'prosemirror-model'
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { createDedentListCommand } from './dedent-list'
describe('dedentList', () => {
const t = setupTestingEditor()
const markdown = t.markdown
it('can dedent a list node to outer list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B<cursor>1
`,
markdown`
- A1
- B<cursor>1
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- - <cursor>B1
- A1
`,
markdown`
- B1
- A1
`,
)
})
it('can dedent a paragraph node to outer list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1a
B1b<cursor>
`,
markdown`
- A1
- B1a
B1b<cursor>
`,
)
})
it('can unwrap a list node', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1<cursor>
paragraph
`,
markdown`
A1<cursor>
paragraph
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1<cursor>
- A2
`,
markdown`
A1<cursor>
- A2
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- A2<cursor>
`,
markdown`
- A1
A2
`,
)
})
it('can unwrap multiple list nodes', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1<start>
- A2<end>
`,
markdown`
A1
A2
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- A2<start>
- A3<end>
- A4
`,
markdown`
- A1
A2
A3
- A4
`,
)
})
it('can keep siblings after the lifted items at the same position', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2<start>
- B3
- C1<end>
B3
- B4
`,
markdown`
- A1
- B1
- B2<start>
- B3
- C1<end>
B3
- B4
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2<cursor>
A1
`,
markdown`
- A1
- B1
- B2<cursor>
A1
`,
)
})
it('can only dedent selected part when the selection across multiple depth of a nested lists', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2
- C1<start>
- B3<end>
`,
markdown`
- A1
- B1
- B2
- C1<start>
- B3<end>
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2
- C1<start>
- B3<end>
- C2
`,
markdown`
- A1
- B1
- B2
- C1<start>
- B3<end>
- - C2
`,
)
})
it('can wrap unselected paragraphs with a list node if necessary', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2<start>
- B3<end>
B3
B3
- B4
`,
markdown`
- A1
- B1
- B2<start>
- B3<end>
- B3
B3
- B4
`,
)
})
it('can keep the indentation of sub list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1<cursor>
- C1
`,
markdown`
- A1
- B1<cursor>
- - C1
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1<cursor>
- B1
`,
markdown`
A1<cursor>
- - B1
`,
)
})
it('do nothing when not inside a list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
Hello<cursor>
paragraph
`,
markdown`
Hello<cursor>
paragraph
`,
)
})
it('can dedent a nested list item', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- - B1<cursor>
B1
A1
`,
markdown`
- B1
- B1
A1
`,
)
})
it('can dedent a blockquote inside a list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- > A1
>
> A2<cursor>
`,
markdown`
- > A1
A2<cursor>
`,
)
})
it('can accept custom positions', () => {
t.applyCommand(
createDedentListCommand({ from: 13, to: 17 }),
t.doc(
/*0*/
t.bulletList(/*1*/ t.p('A1') /*5*/),
/*6*/
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
/*12*/
t.bulletList(/*13*/ t.p('A3') /*17*/),
/*18*/
),
t.doc(
//
t.bulletList(t.p('A1')),
t.bulletList(t.p('A2')),
t.p('A3'),
),
)
t.applyCommand(
createDedentListCommand({ from: 10, to: 14 }),
t.doc(
/*0*/
t.bulletList(/*1*/ t.p('A1') /*5*/),
/*6*/
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
/*12*/
t.bulletList(/*13*/ t.p('A3') /*17*/),
/*18*/
),
t.doc(
//
t.bulletList(t.p('A1')),
t.p('A2'),
t.p('A3'),
),
)
})
it('can handle some complex nested lists', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- <start>B2
- A2
- B3
- C1<end>
- D1
- B4
`,
markdown`
- A1
- B1
- <start>B2
A2
- B3
- C1<end>
- - D1
- B4
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2
- C1
- D1
D1<start>
- A2
- B3
- C2
C2
- D2<end>
C2
- C3
`,
markdown`
- A1
- B1
- B2
- C1
- D1
D1<start>
A2
- B3
- C2
C2
- D2<end>
C2
- C3
`,
)
})
it('only needs one step for some of the most comment indent action', () => {
const countSteps = (
doc: TaggedProsemirrorNode,
expected: TaggedProsemirrorNode,
) => {
t.add(doc)
const state = t.view.state
const command = createDedentListCommand()
let count = -1
let actual: ProsemirrorNode | null = null
command(state, (tr) => {
count = tr.steps.length
actual = tr.doc
})
expect(actual).not.equal(null)
expect(actual).toEqualRemirrorDocument(expected)
return count
}
expect(
countSteps(
markdown`
- A1
- B1<cursor>
`,
markdown`
- A1
- B1<cursor>
`,
),
).toBe(1)
expect(
countSteps(
markdown`
- A1
- A2<cursor>
`,
markdown`
- A1
A2<cursor>
`,
),
).toBe(1)
expect(
countSteps(
markdown`
# heading
- A1<cursor>
`,
markdown`
# heading
A1<cursor>
`,
),
).toBe(1)
expect(
countSteps(
markdown`
# heading
- - A1<cursor>
`,
markdown`
# heading
- A1<cursor>
`,
),
).toBe(1)
})
})
@@ -0,0 +1,223 @@
import { Fragment, NodeRange, Slice } from "prosemirror-model";
import { type Command, type Transaction } from "prosemirror-state";
import { ReplaceAroundStep } from "prosemirror-transform";
import { withVisibleSelection } from "./set-safe-selection";
import { findListsRange, isListNode, isListsRange, getListType } from "prosemirror-flat-list";
import { atStartBlockBoundary, atEndBlockBoundary } from "../utils/block-boundary";
import { mapPos } from "../utils/map-pos";
import { safeLift } from "../utils/safe-lift";
import { zoomInRange } from "../utils/zoom-in-range";
/**
* @public
*/
export interface DedentListOptions {
/**
* A optional from position to indent.
*
* @defaultValue `state.selection.from`
*/
from?: number;
/**
* A optional to position to indent.
*
* @defaultValue `state.selection.to`
*/
to?: number;
}
/**
* Returns a command function that decreases the indentation of selected list nodes.
*
* @public @group Commands
*/
export function createDedentListCommand(options?: DedentListOptions): Command {
const dedentListCommand: Command = (state, dispatch): boolean => {
const tr = state.tr;
const $from = options?.from == null ? tr.selection.$from : tr.doc.resolve(options.from);
const $to = options?.to == null ? tr.selection.$to : tr.doc.resolve(options.to);
const range = findListsRange($from, $to);
if (!range) return false;
if (dedentRange(range, tr)) {
dispatch?.(tr);
return true;
}
return false;
};
return withVisibleSelection(dedentListCommand);
}
function dedentRange(range: NodeRange, tr: Transaction, startBoundary?: boolean, endBoundary?: boolean): boolean {
const { depth, $from, $to } = range;
startBoundary = startBoundary || atStartBlockBoundary($from, depth + 1);
if (!startBoundary) {
const { startIndex, endIndex } = range;
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range);
return contentRange ? dedentRange(contentRange, tr) : false;
} else {
return splitAndDedentRange(range, tr, startIndex + 1);
}
}
endBoundary = endBoundary || atEndBlockBoundary($to, depth + 1);
if (!endBoundary) {
fixEndBoundary(range, tr);
const endOfParent = $to.end(depth);
range = new NodeRange(tr.doc.resolve($from.pos), tr.doc.resolve(endOfParent), depth);
return dedentRange(range, tr, undefined, true);
}
if (range.startIndex === 0 && range.endIndex === range.parent.childCount && isListNode(range.parent)) {
return dedentNodeRange(new NodeRange($from, $to, depth - 1), tr);
}
return dedentNodeRange(range, tr);
}
/**
* Split a range into two parts, and dedent them separately.
*/
function splitAndDedentRange(range: NodeRange, tr: Transaction, splitIndex: number): boolean {
const { $from, $to, depth } = range;
const splitPos = $from.posAtIndex(splitIndex, depth);
const range1 = $from.blockRange(tr.doc.resolve(splitPos - 1));
if (!range1) return false;
const getRange2From = mapPos(tr, splitPos + 1);
const getRange2To = mapPos(tr, $to.pos);
dedentRange(range1, tr, undefined, true);
let range2 = tr.doc.resolve(getRange2From()).blockRange(tr.doc.resolve(getRange2To()));
if (range2 && range2.depth >= depth) {
range2 = new NodeRange(range2.$from, range2.$to, depth);
dedentRange(range2, tr, true, undefined);
}
return true;
}
export function dedentNodeRange(range: NodeRange, tr: Transaction) {
if (isListNode(range.parent)) {
return safeLiftRange(tr, range);
} else if (isListsRange(range)) {
return dedentOutOfList(tr, range);
} else {
return safeLiftRange(tr, range);
}
}
function safeLiftRange(tr: Transaction, range: NodeRange): boolean {
if (moveRangeSiblings(tr, range)) {
const $from = tr.doc.resolve(range.$from.pos);
const $to = tr.doc.resolve(range.$to.pos);
range = new NodeRange($from, $to, range.depth);
}
return safeLift(tr, range);
}
function moveRangeSiblings(tr: Transaction, range: NodeRange): boolean {
const listType = getListType(tr.doc.type.schema);
const { $to, depth, end, parent, endIndex } = range;
const endOfParent = $to.end(depth);
if (end < endOfParent) {
// There are siblings after the lifted items, which must become
// children of the last item
const lastChild = parent.maybeChild(endIndex - 1);
if (!lastChild) return false;
const canAppend =
endIndex < parent.childCount &&
lastChild.canReplace(lastChild.childCount, lastChild.childCount, parent.content, endIndex, parent.childCount);
if (canAppend) {
tr.step(
new ReplaceAroundStep(
end - 1,
endOfParent,
end,
endOfParent,
new Slice(Fragment.from(listType.create(null)), 1, 0),
0,
true
)
);
return true;
} else {
tr.step(
new ReplaceAroundStep(
end,
endOfParent,
end,
endOfParent,
new Slice(Fragment.from(listType.create(null)), 0, 0),
1,
true
)
);
return true;
}
}
return false;
}
function fixEndBoundary(range: NodeRange, tr: Transaction): void {
if (range.endIndex - range.startIndex >= 2) {
range = new NodeRange(
range.$to.doc.resolve(range.$to.posAtIndex(range.endIndex - 1, range.depth)),
range.$to,
range.depth
);
}
const contentRange = zoomInRange(range);
if (contentRange) {
fixEndBoundary(contentRange, tr);
range = new NodeRange(tr.doc.resolve(range.$from.pos), tr.doc.resolve(range.$to.pos), range.depth);
}
moveRangeSiblings(tr, range);
}
export function dedentOutOfList(tr: Transaction, range: NodeRange): boolean {
const { startIndex, endIndex, parent } = range;
const getRangeStart = mapPos(tr, range.start);
const getRangeEnd = mapPos(tr, range.end);
// Merge the list nodes into a single big list node
for (let end = getRangeEnd(), i = endIndex - 1; i > startIndex; i--) {
end -= parent.child(i).nodeSize;
tr.delete(end - 1, end + 1);
}
const $start = tr.doc.resolve(getRangeStart());
const listNode = $start.nodeAfter;
if (!listNode) return false;
const start = range.start;
const end = start + listNode.nodeSize;
if (getRangeEnd() !== end) return false;
if (!$start.parent.canReplace(startIndex, startIndex + 1, Fragment.from(listNode))) {
return false;
}
tr.step(new ReplaceAroundStep(start, end, start + 1, end - 1, new Slice(Fragment.empty, 0, 0), 0, true));
return true;
}
@@ -0,0 +1,19 @@
import {
chainCommands,
createParagraphNear,
newlineInCode,
splitBlock,
} from 'prosemirror-commands'
import { type Command } from 'prosemirror-state'
/**
* This command has the same behavior as the `Enter` keybinding from
* `prosemirror-commands`, but without the `liftEmptyBlock` command.
*
* @internal
*/
export const enterWithoutLift: Command = chainCommands(
newlineInCode,
createParagraphNear,
splitBlock,
)
@@ -0,0 +1,787 @@
import { type TaggedProsemirrorNode } from 'jest-remirror'
import { type Node as ProsemirrorNode } from 'prosemirror-model'
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { createIndentListCommand } from './indent-list'
describe('indentList', () => {
const t = setupTestingEditor()
const markdown = t.markdown
const indentList = createIndentListCommand()
it('can indent a list node and append it to the previous list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A<cursor>2
`,
markdown`
- A1
- A<cursor>2
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- ## A<cursor>2
`,
markdown`
- A1
- ## A<cursor>2
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- > ## A<cursor>2
`,
markdown`
- A1
- > ## A<cursor>2
`,
)
})
it('can indent multiple list nodes and append them to the previous list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2<start>
- A3<end>
`,
markdown`
- A1
- A2<start>
- A3<end>
`,
)
})
it('should not wrap a paragraph with a new list node when it will bring a new visual bullet', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2a
A2b<cursor>
`,
markdown`
- A1
- A2a
A2b<cursor>
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- A2a
A2b<cursor>
A2c
`,
markdown`
- A1
- A2a
A2b<cursor>
A2c
`,
)
})
it('can indent a paragraph and append it to the previous sibling list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2a
- B1
A2b<cursor>
`,
markdown`
- A1
- A2a
- B1
A2b<cursor>
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- A2a
- B1
A2b<cursor>
- B2
`,
markdown`
- A1
- A2a
- B1
A2b<cursor>
- B2
`,
)
})
it('can only indent selected part when the selection across multiple depth of a nested lists', () => {
t.applyCommand(
indentList,
markdown`
- A1a
- B1a
- C1
B1b<start>
A1b<end>
`,
markdown`
- A1a
- B1a
- C1
B1b<start>
A1b<end>
`,
)
t.applyCommand(
indentList,
markdown`
- A1a
- B1a
- C1
B1b<start>
- B2
- B3
- B4
A1b<end>
`,
markdown`
- A1a
- B1a
- C1
B1b<start>
- B2
- B3
- B4
A1b<end>
`,
)
})
it('can indent multiple list nodes and append them to the previous list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A<start>2
- A<end>3
`,
markdown`
- A1
- A<start>2
- A<end>3
`,
)
})
it('can add ambitious indentations', () => {
t.applyCommand(
indentList,
markdown`
- A1
- B<cursor>2
`,
markdown`
- A1
- - B<cursor>2
`,
)
})
it('can split the list when necessary', () => {
t.applyCommand(
indentList,
markdown`
- A1
- B<cursor>2a
B2b
B2c
`,
markdown`
- A1
- - B<cursor>2a
- B2b
B2c
`,
)
})
it('can keep attributes', () => {
t.applyCommand(
indentList,
markdown`
- [ ] A1
- [x] A<cursor>2
`,
markdown`
- [ ] A1
- [x] A<cursor>2
`,
)
t.applyCommand(
indentList,
markdown`
1. A1
2. A<cursor>2
- B1
`,
markdown`
1. A1
1. A<cursor>2
- B1
`,
)
t.applyCommand(
indentList,
markdown`
- [x] A1
- B1
- [x] A<cursor>2
1. B2
`,
markdown`
- [x] A1
- B1
- [x] A<cursor>2
1. B2
`,
)
})
it('can keep the indentation of sub list nodes', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2
- A3<cursor>
- B1
- B2
- B3
`,
markdown`
- A1
- A2
- A3<cursor>
- B1
- B2
- B3
`,
)
})
it('can move all collapsed content', () => {
t.applyCommand(
indentList,
t.doc(
t.bulletList(t.p('A1')),
t.bulletList(t.p('A2')),
t.collapsedToggleList(
t.p('A3<cursor>'),
t.bulletList(t.p('B1')),
t.bulletList(t.p('B2')),
t.bulletList(t.p('B3')),
),
),
t.doc(
t.bulletList(t.p('A1')),
t.bulletList(
t.p('A2'),
t.collapsedToggleList(
t.p('A3<cursor>'),
t.bulletList(t.p('B1')),
t.bulletList(t.p('B2')),
t.bulletList(t.p('B3')),
),
),
),
)
})
it('can expand a collapsed list node if something is indent into it', () => {
t.applyCommand(
indentList,
t.doc(
t.collapsedToggleList(
t.p('A1'),
t.bulletList(t.p('B1')),
t.bulletList(t.p('B2')),
t.bulletList(t.p('B3')),
),
t.p('<cursor>'),
),
t.doc(
t.expandedToggleList(
t.p('A1'),
t.bulletList(t.p('B1')),
t.bulletList(t.p('B2')),
t.bulletList(t.p('B3')),
t.p('<cursor>'),
),
),
)
})
it('can keep the indentation of sub list nodes when moving multiple list', () => {
t.applyCommand(
indentList,
markdown`
- A1
- <start>A2
- A3<end>
- B1
- B2
- B3
`,
markdown`
- A1
- <start>A2
- A3<end>
- B1
- B2
- B3
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- B1
- A2<start>
- B2<end>
- C1
`,
markdown`
- A1
- B1
- A2<start>
- B2<end>
- C1
`,
)
})
it('can keep the indentation of siblings around the indented item', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2<cursor>
A2
`,
markdown`
- A1
- A2<cursor>
A2
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- A2<cursor>
A2
- B1
`,
markdown`
- A1
- A2<cursor>
A2
- B1
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- A2
- B1
A2<cursor>
A2
- B1
`,
markdown`
- A1
- A2
- B1
A2<cursor>
A2
- B1
`,
)
t.applyCommand(
indentList,
markdown`
- A1
A1
- <cursor>A2
`,
markdown`
- A1
A1
- <cursor>A2
`,
)
})
it('can indent a paragraph that not inside a list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
P1<cursor>
`,
markdown`
- A1
P1<cursor>
`,
)
t.applyCommand(
indentList,
markdown`
- A1
P1<start>
P2<end>
P3
`,
markdown`
- A1
P1<start>
P2<end>
P3
`,
)
})
it('can accept custom positions', () => {
t.applyCommand(
createIndentListCommand({ from: 13, to: 17 }),
t.doc(
/*0*/
t.bulletList(/*1*/ t.p('A1') /*5*/),
/*6*/
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
/*12*/
t.bulletList(/*13*/ t.p('A3') /*17*/),
/*18*/
),
t.doc(
t.bulletList(t.p('A1')),
t.bulletList(t.p('A2'), t.bulletList(t.p('A3'))),
),
)
t.applyCommand(
createIndentListCommand({ from: 10, to: 17 }),
t.doc(
/*0*/
t.bulletList(/*1*/ t.p('A1') /*5*/),
/*6*/
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
/*12*/
t.bulletList(/*13*/ t.p('A3') /*17*/),
/*18*/
),
t.doc(
t.bulletList(
t.p('A1'),
t.bulletList(t.p('A2')),
t.bulletList(t.p('A3')),
),
),
)
})
it('can handle some complex nested lists', () => {
t.applyCommand(
indentList,
markdown`
- A1
- B1
- <start>B2
- A2
- B3
- C1<end>
- D1
- B4
`,
markdown`
- A1
- B1
- <start>B2
- A2
- B3
- C1<end>
- D1
- B4
`,
)
})
it('only needs one step for some of the most comment indent action', () => {
const countSteps = (
doc: TaggedProsemirrorNode,
expected: TaggedProsemirrorNode,
) => {
t.add(doc)
const state = t.view.state
const command = createIndentListCommand()
let count = -1
let actual: ProsemirrorNode | null = null
command(state, (tr) => {
count = tr.steps.length
actual = tr.doc
})
expect(actual).not.equal(null)
expect(actual).toEqualRemirrorDocument(expected)
return count
}
expect(
countSteps(
markdown`
- A1
- A2<cursor>
`,
markdown`
- A1
- A2<cursor>
`,
),
).toBe(1)
expect(
countSteps(
markdown`
- A1
- [ ] A2<cursor>
- [x] A3
`,
markdown`
- A1
- [ ] A2<cursor>
- [x] A3
`,
),
).toBe(1)
expect(
countSteps(
markdown`
1. A1
2. <start>A2
3. A3<end>
4. A4
`,
markdown`
1. A1
1. <start>A2
2. A3<end>
2. A4
`,
),
).toBe(1)
expect(
countSteps(
markdown`
1. A1
- B1
- <start>B2
- B3
- B4<end>
`,
markdown`
1. A1
- B1
- <start>B2
- B3
- B4<end>
`,
),
).toBe(1)
// For more complex (and less common) cases, more steps is acceptable
expect(
countSteps(
markdown`
- A1
- B1
- C1
- D1
D1b
- <start>D2
C1b
C1c
- B2
- C2
- A2
- A3
- B3
B3b
- B4<end>
A3b
`,
markdown`
- A1
- B1
- C1
- D1
D1b
- <start>D2
C1b
C1c
- B2
- C2
- A2
- A3
- B3
B3b
- B4<end>
A3b
`,
),
).toBeGreaterThan(1)
})
})
@@ -0,0 +1,153 @@
import { Fragment, type NodeRange, Slice } from "prosemirror-model";
import { type Command, type Transaction } from "prosemirror-state";
import { ReplaceAroundStep } from "prosemirror-transform";
import { withAutoFixList } from "../utils/auto-fix-list";
import { atEndBlockBoundary, atStartBlockBoundary } from "../utils/block-boundary";
import { getListType } from "../utils/get-list-type";
import { inCollapsedList } from "../utils/in-collapsed-list";
import { isListNode } from "../utils/is-list-node";
import { findListsRange } from "../utils/list-range";
import { mapPos } from "../utils/map-pos";
import { zoomInRange } from "../utils/zoom-in-range";
import { withVisibleSelection } from "./set-safe-selection";
import { ListAttributes } from "prosemirror-flat-list";
/**
* @public
*/
export interface IndentListOptions {
/**
* A optional from position to indent.
*
* @defaultValue `state.selection.from`
*/
from?: number;
/**
* A optional to position to indent.
*
* @defaultValue `state.selection.to`
*/
to?: number;
}
/**
* Returns a command function that increases the indentation of selected list
* nodes.
*
* @public @group Commands
*/
export function createIndentListCommand(options?: IndentListOptions): Command {
const indentListCommand: Command = (state, dispatch): boolean => {
const tr = state.tr;
const $from = options?.from == null ? tr.selection.$from : tr.doc.resolve(options.from);
const $to = options?.to == null ? tr.selection.$to : tr.doc.resolve(options.to);
const range = findListsRange($from, $to) || $from.blockRange($to);
if (!range) return false;
if (indentRange(range, tr)) {
dispatch?.(tr);
return true;
}
return false;
};
return withVisibleSelection(withAutoFixList(indentListCommand));
}
function indentRange(range: NodeRange, tr: Transaction, startBoundary?: boolean, endBoundary?: boolean): boolean {
const { depth, $from, $to } = range;
startBoundary = startBoundary || atStartBlockBoundary($from, depth + 1);
if (!startBoundary) {
const { startIndex, endIndex } = range;
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range);
return contentRange ? indentRange(contentRange, tr) : false;
} else {
return splitAndIndentRange(range, tr, startIndex + 1);
}
}
endBoundary = endBoundary || atEndBlockBoundary($to, depth + 1);
if (!endBoundary && !inCollapsedList($to)) {
const { startIndex, endIndex } = range;
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range);
return contentRange ? indentRange(contentRange, tr) : false;
} else {
return splitAndIndentRange(range, tr, endIndex - 1);
}
}
return indentNodeRange(range, tr);
}
/**
* Split a range into two parts, and indent them separately.
*/
function splitAndIndentRange(range: NodeRange, tr: Transaction, splitIndex: number): boolean {
const { $from, $to, depth } = range;
const splitPos = $from.posAtIndex(splitIndex, depth);
const range1 = $from.blockRange(tr.doc.resolve(splitPos - 1));
if (!range1) return false;
const getRange2From = mapPos(tr, splitPos + 1);
const getRange2To = mapPos(tr, $to.pos);
indentRange(range1, tr, undefined, true);
const range2 = tr.doc.resolve(getRange2From()).blockRange(tr.doc.resolve(getRange2To()));
if (range2) {
indentRange(range2, tr, true, undefined);
}
return true;
}
/**
* Increase the indentation of a block range.
*/
function indentNodeRange(range: NodeRange, tr: Transaction): boolean {
const listType = getListType(tr.doc.type.schema);
const { parent, startIndex } = range;
const prevChild = startIndex >= 1 && parent.child(startIndex - 1);
// If the previous node before the range is a list node, move the range into
// the previous list node as its children
if (prevChild && isListNode(prevChild)) {
const { start, end } = range;
tr.step(
new ReplaceAroundStep(start - 1, end, start, end, new Slice(Fragment.from(listType.create(null)), 1, 0), 0, true)
);
return true;
}
// If we can avoid to add a new bullet visually, we can wrap the range with a
// new list node.
const isParentListNode = isListNode(parent);
const isFirstChildListNode = isListNode(parent.maybeChild(startIndex));
if ((startIndex === 0 && isParentListNode) || isFirstChildListNode) {
const { start, end } = range;
const listAttrs: ListAttributes | null = isFirstChildListNode
? parent.child(startIndex).attrs
: isParentListNode
? parent.attrs
: null;
tr.step(
new ReplaceAroundStep(start, end, start, end, new Slice(Fragment.from(listType.create(listAttrs)), 0, 0), 1, true)
);
return true;
}
// Otherwise we cannot indent
return false;
}
@@ -0,0 +1,56 @@
import { type ResolvedPos } from "prosemirror-model";
import { type Command, TextSelection } from "prosemirror-state";
import { atTextblockStart } from "../utils/at-textblock-start";
import { isListNode } from "../utils/is-list-node";
import { joinTextblocksAround } from "./join-textblocks-around";
import { ListAttributes } from "prosemirror-flat-list";
/**
* If the selection is empty and at the start of a block, and there is a
* collapsed list node right before the cursor, move current block and append it
* to the first child of the collapsed list node (i.e. skip the hidden content).
*
* @public @group Commands
*/
export const joinCollapsedListBackward: Command = (state, dispatch, view) => {
const $cursor = atTextblockStart(state, view);
if (!$cursor) return false;
const $cut = findCutBefore($cursor);
if (!$cut) return false;
const { nodeBefore, nodeAfter } = $cut;
if (
nodeBefore &&
nodeAfter &&
isListNode(nodeBefore) &&
(nodeBefore.attrs as ListAttributes).collapsed &&
nodeAfter.isBlock
) {
const tr = state.tr;
const listPos = $cut.pos - nodeBefore.nodeSize;
tr.delete($cut.pos, $cut.pos + nodeAfter.nodeSize);
const insert = listPos + 1 + nodeBefore.child(0).nodeSize;
tr.insert(insert, nodeAfter);
const $insert = tr.doc.resolve(insert);
tr.setSelection(TextSelection.near($insert));
if (joinTextblocksAround(tr, $insert, dispatch)) {
return true;
}
}
return false;
};
// https://github.com/prosemirror/prosemirror-commands/blob/e607d5abda0fcc399462e6452a82450f4118702d/src/commands.ts#L150
function findCutBefore($pos: ResolvedPos): ResolvedPos | null {
if (!$pos.parent.type.spec.isolating)
for (let i = $pos.depth - 1; i >= 0; i--) {
if ($pos.index(i) > 0) return $pos.doc.resolve($pos.before(i + 1));
if ($pos.node(i).type.spec.isolating) break;
}
return null;
}
@@ -0,0 +1,76 @@
import { NodeRange, type ResolvedPos } from 'prosemirror-model'
import {
type Command,
type EditorState,
type Transaction,
} from 'prosemirror-state'
import { atTextblockStart } from '../utils/at-textblock-start'
import { isListNode } from '../utils/is-list-node'
import { safeLift } from '../utils/safe-lift'
/**
* If the text cursor is at the start of the first child of a list node, lift
* all content inside the list. If the text cursor is at the start of the last
* child of a list node, lift this child.
*
* @public @group Commands
*/
export const joinListUp: Command = (state, dispatch, view) => {
const $cursor = atTextblockStart(state, view)
if (!$cursor) return false
const { depth } = $cursor
if (depth < 2) return false
const listDepth = depth - 1
const listNode = $cursor.node(listDepth)
if (!isListNode(listNode)) return false
const indexInList = $cursor.index(listDepth)
if (indexInList === 0) {
if (dispatch) {
liftListContent(state, dispatch, $cursor)
}
return true
}
if (indexInList === listNode.childCount - 1) {
if (dispatch) {
liftParent(state, dispatch, $cursor)
}
return true
}
return false
}
function liftListContent(
state: EditorState,
dispatch: (tr: Transaction) => void,
$cursor: ResolvedPos,
) {
const tr = state.tr
const listDepth = $cursor.depth - 1
const range = new NodeRange(
$cursor,
tr.doc.resolve($cursor.end(listDepth)),
listDepth,
)
if (safeLift(tr, range)) {
dispatch(tr)
}
}
function liftParent(
state: EditorState,
dispatch: (tr: Transaction) => void,
$cursor: ResolvedPos,
) {
const tr = state.tr
const range = $cursor.blockRange()
if (range && safeLift(tr, range)) {
dispatch(tr)
}
}
@@ -0,0 +1,36 @@
/* eslint-disable prefer-const */
import { type ResolvedPos, Slice } from 'prosemirror-model'
import { TextSelection, type Transaction } from 'prosemirror-state'
import { replaceStep, ReplaceStep } from 'prosemirror-transform'
// prettier-ignore
// https://github.com/prosemirror/prosemirror-commands/blob/e607d5abda0fcc399462e6452a82450f4118702d/src/commands.ts#L94
function joinTextblocksAround(tr: Transaction, $cut: ResolvedPos, dispatch?: (tr: Transaction) => void) {
let before = $cut.nodeBefore!, beforeText = before, beforePos = $cut.pos - 1
for (; !beforeText.isTextblock; beforePos--) {
if (beforeText.type.spec.isolating) return false
let child = beforeText.lastChild
if (!child) return false
beforeText = child
}
let after = $cut.nodeAfter!, afterText = after, afterPos = $cut.pos + 1
for (; !afterText.isTextblock; afterPos++) {
if (afterText.type.spec.isolating) return false
let child = afterText.firstChild
if (!child) return false
afterText = child
}
let step = replaceStep(tr.doc, beforePos, afterPos, Slice.empty) as ReplaceStep | null
if (!step || step.from != beforePos ||
step instanceof ReplaceStep && step.slice.size >= afterPos - beforePos) return false
if (dispatch) {
tr.step(step)
tr.setSelection(TextSelection.create(tr.doc, beforePos))
dispatch(tr.scrollIntoView())
}
return true
}
export { joinTextblocksAround }
@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { backspaceCommand } from './keymap'
describe('Keymap', () => {
const t = setupTestingEditor()
const markdown = t.markdown
describe('Backspace', () => {
it('should delete the empty paragraph between two list nodes', () => {
t.applyCommand(
backspaceCommand,
t.doc(
t.bulletList(t.p('A1')),
t.p('<cursor>'),
t.bulletList(t.p('A2')),
),
t.doc(t.bulletList(t.p('A1')), t.bulletList(t.p('A2'))),
)
})
it('can handle nested list', () => {
const doc1 = markdown`
- A1
- B1
- <cursor>B2
`
const doc2 = markdown`
- A1
- B1
<cursor>B2
`
const doc3 = markdown`
- A1
- B1
<cursor>B2
`
const doc4 = markdown`
- A1
- B1<cursor>B2
`
t.add(doc1)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc2)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc3)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc4)
})
it('can handle nested list with multiple children', () => {
const doc1 = markdown`
- A1
- B1
- <cursor>B2a
B2b
B2c
`
const doc2 = markdown`
- A1
- B1
<cursor>B2a
B2b
B2c
`
const doc3 = markdown`
- A1
- B1<cursor>B2a
B2b
B2c
`
t.add(doc1)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc2)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc3)
})
it('can handle cursor in the middle child', () => {
const doc1 = markdown`
- A1
- B1
- B2a
<cursor>B2b
B2c
`
const doc2 = markdown`
- A1
- B1
- B2a<cursor>B2b
B2c
`
t.add(doc1)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc2)
})
it('can handle cursor in the last child', () => {
const doc1 = markdown`
- A1
- B1
- B2a
B2b
<cursor>B2c
`
const doc2 = markdown`
- A1
- B1
- B2a
B2b
<cursor>B2c
`
const doc3 = markdown`
- A1
- B1
- B2a
B2b
<cursor>B2c
`
t.add(doc1)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc2)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc3)
})
it('can skip collapsed content', () => {
t.applyCommand(
backspaceCommand,
t.doc(
t.collapsedToggleList(
//
t.p('A1'),
t.bulletList(t.p('B1')),
),
t.p('<cursor>A2'),
),
t.doc(
t.collapsedToggleList(
//
t.p('A1<cursor>A2'),
t.bulletList(t.p('B1')),
),
),
)
t.applyCommand(
backspaceCommand,
t.doc(
t.collapsedToggleList(
//
t.p('A1'),
t.bulletList(t.p('B1')),
),
t.blockquote(t.p('<cursor>A2')),
),
t.doc(
t.collapsedToggleList(
//
t.p('A1<cursor>A2'),
t.bulletList(t.p('B1')),
),
),
)
})
})
})
@@ -0,0 +1,91 @@
import {
chainCommands,
deleteSelection,
joinTextblockBackward,
joinTextblockForward,
selectNodeBackward,
selectNodeForward,
} from 'prosemirror-commands'
import { createDedentListCommand } from './dedent-list'
import { createIndentListCommand } from './indent-list'
import { joinCollapsedListBackward } from './join-collapsed-backward'
import { joinListUp } from './join-list-up'
import { protectCollapsed } from './protect-collapsed'
import { createSplitListCommand } from './split-list'
/**
* Keybinding for `Enter`. It's chained with following commands:
*
* - {@link protectCollapsed}
* - {@link createSplitListCommand}
*
* @public @group Commands
*/
export const enterCommand = chainCommands(
protectCollapsed,
createSplitListCommand(),
)
/**
* Keybinding for `Backspace`. It's chained with following commands:
*
* - {@link protectCollapsed}
* - [deleteSelection](https://prosemirror.net/docs/ref/#commands.deleteSelection)
* - {@link joinListUp}
* - {@link joinCollapsedListBackward}
* - [joinTextblockBackward](https://prosemirror.net/docs/ref/#commands.joinTextblockBackward)
* - [selectNodeBackward](https://prosemirror.net/docs/ref/#commands.selectNodeBackward)
*
* @public @group Commands
*
*/
export const backspaceCommand = chainCommands(
protectCollapsed,
deleteSelection,
joinListUp,
joinCollapsedListBackward,
joinTextblockBackward,
selectNodeBackward,
)
/**
* Keybinding for `Delete`. It's chained with following commands:
*
* - {@link protectCollapsed}
* - [deleteSelection](https://prosemirror.net/docs/ref/#commands.deleteSelection)
* - [joinTextblockForward](https://prosemirror.net/docs/ref/#commands.joinTextblockForward)
* - [selectNodeForward](https://prosemirror.net/docs/ref/#commands.selectNodeForward)
*
* @public @group Commands
*
*/
export const deleteCommand = chainCommands(
protectCollapsed,
deleteSelection,
joinTextblockForward,
selectNodeForward,
)
/**
* Returns an object containing the keymap for the list commands.
*
* - `Enter`: See {@link enterCommand}.
* - `Backspace`: See {@link backspaceCommand}.
* - `Delete`: See {@link deleteCommand}.
* - `Mod-[`: Decrease indentation. See {@link createDedentListCommand}.
* - `Mod-]`: Increase indentation. See {@link createIndentListCommand}.
*
* @public @group Commands
*/
export const listKeymap = {
Enter: enterCommand,
Backspace: backspaceCommand,
Delete: deleteCommand,
'Mod-[': createDedentListCommand(),
'Mod-]': createIndentListCommand(),
}
@@ -0,0 +1,84 @@
import { describe, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { createMoveListCommand } from './move-list'
describe('moveList', () => {
const t = setupTestingEditor()
const markdown = t.markdown
const moveUp = createMoveListCommand('up')
const moveDown = createMoveListCommand('down')
it('can move up list nodes', () => {
t.applyCommand(
moveUp,
markdown`
- A1
- A2<start>
- A3<end>
`,
markdown`
- A2<start>
- A3<end>
- A1
`,
)
})
it('can move up and dedent list nodes to parent list', () => {
t.applyCommand(
moveUp,
markdown`
- A1
- A2
- B1<start>
- B2<end>
- B3
`,
markdown`
- A1
- B1<start>
- B2<end>
- A2
- B3
`,
)
})
it('can move down list nodes', () => {
t.applyCommand(
moveDown,
markdown`
- A1<start>
- A2<end>
- A3
`,
markdown`
- A3
- A1<start>
- A2<end>
`,
)
})
it('can move down and dedent list nodes to parent list', () => {
t.applyCommand(
moveDown,
markdown`
- A1
- A2
- B1<start>
- B2<end>
- A3
`,
markdown`
- A1
- A2
- A3
- B1<start>
- B2<end>
`,
)
})
})
@@ -0,0 +1,86 @@
import { type Command, type Transaction } from 'prosemirror-state'
import { withAutoFixList } from '../utils/auto-fix-list'
import { cutByIndex } from '../utils/cut-by-index'
import { isListNode } from '../utils/is-list-node'
import { findListsRange } from '../utils/list-range'
import { safeLift } from '../utils/safe-lift'
/**
* Returns a command function that moves up or down selected list nodes.
*
* @public @group Commands
*
*/
export function createMoveListCommand(direction: 'up' | 'down'): Command {
const moveList: Command = (state, dispatch): boolean => {
const tr = state.tr
if (doMoveList(tr, direction, true, !!dispatch)) {
dispatch?.(tr)
return true
}
return false
}
return withAutoFixList(moveList)
}
/** @internal */
export function doMoveList(
tr: Transaction,
direction: 'up' | 'down',
canDedent: boolean,
dispatch: boolean,
): boolean {
const { $from, $to } = tr.selection
const range = findListsRange($from, $to)
if (!range) return false
const { parent, depth, startIndex, endIndex } = range
if (direction === 'up') {
if (startIndex >= 2 || (startIndex === 1 && isListNode(parent.child(0)))) {
const before = cutByIndex(parent.content, startIndex - 1, startIndex)
const selected = cutByIndex(parent.content, startIndex, endIndex)
if (
parent.canReplace(startIndex - 1, endIndex, selected.append(before))
) {
if (dispatch) {
tr.insert($from.posAtIndex(endIndex, depth), before)
tr.delete(
$from.posAtIndex(startIndex - 1, depth),
$from.posAtIndex(startIndex, depth),
)
}
return true
} else {
return false
}
} else if (canDedent && isListNode(parent)) {
return safeLift(tr, range) && doMoveList(tr, direction, false, dispatch)
} else {
return false
}
} else {
if (endIndex < parent.childCount) {
const selected = cutByIndex(parent.content, startIndex, endIndex)
const after = cutByIndex(parent.content, endIndex, endIndex + 1)
if (parent.canReplace(startIndex, endIndex + 1, after.append(selected))) {
if (dispatch) {
tr.delete(
$from.posAtIndex(endIndex, depth),
$from.posAtIndex(endIndex + 1, depth),
)
tr.insert($from.posAtIndex(startIndex, depth), after)
}
return true
} else {
return false
}
} else if (canDedent && isListNode(parent)) {
return safeLift(tr, range) && doMoveList(tr, direction, false, dispatch)
} else {
return false
}
}
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
describe('protectCollapsed', () => {
const { add, doc, p, editor, collapsedToggleList, expandedToggleList } =
setupTestingEditor()
it('can skip collapsed content', () => {
// Cursor in the last paragraph of the item
add(
doc(
collapsedToggleList(
//
p('1<start>23'),
p('456'),
),
collapsedToggleList(
//
p('123'),
p('4<end>56'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
expandedToggleList(
//
p('1<start>23'),
p('456'),
),
expandedToggleList(
//
p('123'),
p('4<end>56'),
),
),
)
})
})
@@ -0,0 +1,43 @@
import type { Command } from 'prosemirror-state'
import { isCollapsedListNode } from '../utils/is-collapsed-list-node'
/**
* This command will protect the collapsed items from being deleted.
*
* If current selection contains a collapsed item, we don't want the user to
* delete this selection by pressing Backspace or Delete, because this could
* be unintentional.
*
* In such case, we will stop the delete action and expand the collapsed items
* instead. Therefore the user can clearly know what content he is trying to
* delete.
*
* @public @group Commands
*
*/
export const protectCollapsed: Command = (state, dispatch): boolean => {
const tr = state.tr
let found = false
const { from, to } = state.selection
state.doc.nodesBetween(from, to, (node, pos, parent, index) => {
if (found && !dispatch) {
return false
}
if (parent && isCollapsedListNode(parent) && index >= 1) {
found = true
if (!dispatch) {
return false
}
const $pos = state.doc.resolve(pos)
tr.setNodeAttribute($pos.before($pos.depth), 'collapsed', false)
}
})
if (found) {
dispatch?.(tr)
}
return found
}
@@ -0,0 +1,154 @@
import { type Command } from 'prosemirror-state'
import { describe, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { setSafeSelection } from './set-safe-selection'
describe('setSafeSelection', () => {
const {
doc,
p,
collapsedToggleList,
expandedToggleList,
bulletList,
applyCommand,
} = setupTestingEditor()
const command: Command = (state, dispatch) => {
dispatch?.(setSafeSelection(state.tr))
return true
}
it('can move cursor outside of collapsed content', () => {
applyCommand(
command,
doc(
collapsedToggleList(
//
p('123'),
p('45<cursor>6'),
),
),
doc(
collapsedToggleList(
//
p('123<cursor>'),
p('456'),
),
),
)
})
it('can move cursor outside of collapsed and deep sub list', () => {
applyCommand(
command,
doc(
bulletList(
bulletList(
bulletList(
collapsedToggleList(
//
p('123'),
p('45<cursor>6'),
),
),
),
),
),
doc(
bulletList(
bulletList(
bulletList(
collapsedToggleList(
//
p('123<cursor>'),
p('456'),
),
),
),
),
),
)
})
it('does not change if the cursor is visible ', () => {
applyCommand(
command,
doc(
collapsedToggleList(
//
p('12<cursor>3'),
p('456'),
),
),
doc(
collapsedToggleList(
//
p('12<cursor>3'),
p('456'),
),
),
)
})
it('can handle from position', () => {
applyCommand(
command,
doc(
collapsedToggleList(
//
p('123'),
p('45<start>6'),
),
expandedToggleList(
//
p('12<end>3'),
p('456'),
),
),
doc(
collapsedToggleList(
//
p('123<cursor>'),
p('456'),
),
expandedToggleList(
//
p('123'),
p('456'),
),
),
)
})
it('can handle to position', () => {
applyCommand(
command,
doc(
expandedToggleList(
//
p('1<start>23'),
p('456'),
),
collapsedToggleList(
//
p('123'),
p('4<end>56'),
),
),
doc(
expandedToggleList(
//
p('123'),
p('456'),
),
collapsedToggleList(
//
p('123<cursor>'),
p('456'),
),
),
)
})
})
@@ -0,0 +1,70 @@
import { type ResolvedPos } from 'prosemirror-model'
import {
type Selection,
TextSelection,
type Transaction,
} from 'prosemirror-state'
import { isCollapsedListNode } from '../utils/is-collapsed-list-node'
import { patchCommand } from '../utils/patch-command'
import { setListAttributes } from '../utils/set-list-attributes'
function moveOutOfCollapsed(
$pos: ResolvedPos,
minDepth: number,
): Selection | null {
for (let depth = minDepth; depth <= $pos.depth; depth++) {
if (isCollapsedListNode($pos.node(depth)) && $pos.index(depth) >= 1) {
const before = $pos.posAtIndex(1, depth)
const $before = $pos.doc.resolve(before)
return TextSelection.near($before, -1)
}
}
return null
}
/**
* If one of the selection's end points is inside a collapsed node, move the selection outside of it
*
* @internal
*/
export function setSafeSelection(tr: Transaction): Transaction {
const { $from, $to, to } = tr.selection
const selection =
moveOutOfCollapsed($from, 0) ||
moveOutOfCollapsed($to, $from.sharedDepth(to))
if (selection) {
tr.setSelection(selection)
}
return tr
}
export const withSafeSelection = patchCommand(setSafeSelection)
function getCollapsedPosition($pos: ResolvedPos, minDepth: number) {
for (let depth = minDepth; depth <= $pos.depth; depth++) {
if (isCollapsedListNode($pos.node(depth)) && $pos.index(depth) >= 1) {
return $pos.before(depth)
}
}
return null
}
/**
* If one of the selection's end points is inside a collapsed node, expand it
*
* @internal
*/
export function setVisibleSelection(tr: Transaction): Transaction {
const { $from, $to, to } = tr.selection
const pos =
getCollapsedPosition($from, 0) ??
getCollapsedPosition($to, $from.sharedDepth(to))
if (pos != null) {
tr.doc.resolve(pos)
setListAttributes(tr, pos, { collapsed: false })
}
return tr
}
export const withVisibleSelection = patchCommand(setVisibleSelection)
@@ -0,0 +1,516 @@
import { NodeSelection } from 'prosemirror-state'
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { enterCommand } from './keymap'
describe('splitList', () => {
const {
add,
doc,
p,
bulletList,
blockquote,
editor,
markdown,
applyCommand,
collapsedToggleList,
expandedToggleList,
checkedTaskList,
uncheckedTaskList,
} = setupTestingEditor()
it('can split non-empty item', () => {
applyCommand(
enterCommand,
markdown`
- 123
- 234<cursor>
paragraph
`,
markdown`
- 123
- 234
- <cursor>
paragraph
`,
)
applyCommand(
enterCommand,
markdown`
- 123
- 23<cursor>4
`,
markdown`
- 123
- 23
- <cursor>4
`,
)
applyCommand(
enterCommand,
markdown`
- 1<cursor>23
- 234
`,
markdown`
- 1
- <cursor>23
- 234
`,
)
})
it('can split non-empty sub item', () => {
applyCommand(
enterCommand,
markdown`
- 123
- 456<cursor>
paragraph
`,
markdown`
- 123
- 456
- <cursor>
paragraph
`,
)
})
it('can delete empty item', () => {
applyCommand(
enterCommand,
markdown`
- 123
- <cursor>
paragraph
`,
markdown`
- 123
<cursor>
paragraph
`,
)
applyCommand(
enterCommand,
markdown`
- 123
- <cursor>
- 456
`,
markdown`
- 123
<cursor>
- 456
`,
)
applyCommand(
enterCommand,
markdown`
- <cursor>
- 123
`,
markdown`
<cursor>
- 123
`,
)
})
it('can dedent the last empty sub item', () => {
applyCommand(
enterCommand,
markdown`
- A1
- <cursor>
paragraph
`,
markdown`
- A1
- <cursor>
paragraph
`,
)
applyCommand(
enterCommand,
markdown`
- A1
- B1
- <cursor>
paragraph
`,
markdown`
- A1
- B1
- <cursor>
paragraph
`,
)
})
it('can delete selected text', () => {
applyCommand(
enterCommand,
markdown`
- <start>123<end>
- 456
`,
markdown`
-
- <cusror>
- 456
`,
)
})
it('can set attributes correctly', () => {
applyCommand(
enterCommand,
doc(
checkedTaskList(p('<cursor>A1')),
uncheckedTaskList(p('A2')),
uncheckedTaskList(p('A3')),
),
doc(
uncheckedTaskList(p('')),
checkedTaskList(p('<cursor>A1')),
uncheckedTaskList(p('A2')),
uncheckedTaskList(p('A3')),
),
)
applyCommand(
enterCommand,
doc(
uncheckedTaskList(p('A1')),
checkedTaskList(p('A2<cursor>')),
uncheckedTaskList(p('A3')),
),
doc(
uncheckedTaskList(p('A1')),
checkedTaskList(p('A2')),
uncheckedTaskList(p('<cursor>')),
uncheckedTaskList(p('A3')),
),
)
applyCommand(
enterCommand,
doc(
uncheckedTaskList(p('A1')),
checkedTaskList(p('A<cursor>2')),
uncheckedTaskList(p('A3')),
),
doc(
uncheckedTaskList(p('A1')),
checkedTaskList(p('A')),
uncheckedTaskList(p('<cursor>2')),
uncheckedTaskList(p('A3')),
),
)
})
it('escapes the item when the cursor is in the first paragraph of the item', () => {
applyCommand(
enterCommand,
markdown`
- 123<cursor>
456
789
`,
markdown`
- 123
- <cursor>
456
789
`,
)
// Nested list item
applyCommand(
enterCommand,
markdown`
- Parent
- 123<cursor>
456
789
`,
markdown`
- Parent
- 123
- <cursor>
456
789
`,
)
})
it('can create new paragraph when the caret is not inside the first child of the list', () => {
// Cursor in the last paragraph of the item
add(
doc(
bulletList(
//
p('123'),
p('456<cursor>'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
//
p('123'),
p('456'),
p('<cursor>'),
),
),
)
// Cursor in the middle paragraph of the item
add(
doc(
bulletList(
//
p('123'),
p('456<cursor>'),
p('789'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
//
p('123'),
p('456'),
p('<cursor>'),
p('789'),
),
),
)
// Cursor in the last paragraph of the item (nested list item)
add(
doc(
bulletList(
p('parent'),
bulletList(
//
p('123'),
p('<cursor>456'),
),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
p('parent'),
bulletList(
//
p('123'),
p(''),
p('<cursor>456'),
),
),
),
)
add(
doc(
bulletList(
//
p('123'),
p('<cursor>'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
//
p('123'),
p(''),
p('<cursor>'),
),
),
)
add(
doc(
bulletList(
//
p('123'),
p('<cursor>'),
p('456'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
//
p('123'),
p(''),
p('<cursor>'),
p('456'),
),
),
)
})
it('can skip collapsed content', () => {
// Cursor in the last paragraph of the item
add(
doc(
collapsedToggleList(
//
p('1<start>23<end>'),
p('456'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
collapsedToggleList(
//
p('1'),
p('456'),
),
expandedToggleList(
//
p('<cursor>'),
),
),
)
})
it("won't effect non-list document", () => {
applyCommand(
enterCommand,
markdown`
# h1
1<cursor>23
`,
null,
)
applyCommand(
enterCommand,
markdown`
# h1
123
> 4<cursor>56
`,
null,
)
add(
doc(
blockquote(
p('123'),
blockquote(
//
p('4<cursor>56'),
),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
blockquote(
p('123'),
blockquote(
//
p('4'),
p('<cursor>56'),
),
),
),
)
})
it('can split list node for a block node selection', () => {
add(markdown`
# h1
1. ***
`)
let hrPos = -1
editor.doc.descendants((node, pos) => {
if (node.type.name === 'horizontalRule') {
hrPos = pos
}
})
expect(hrPos > -1).toBe(true)
const nodeSelection = NodeSelection.create(editor.state.doc, hrPos)
editor.view.dispatch(editor.view.state.tr.setSelection(nodeSelection))
expect(editor.view.state.selection.toJSON()).toMatchInlineSnapshot(`
{
"anchor": 5,
"type": "node",
}
`)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(markdown`
# h1
1. ***
2. <cursor>\n
`)
})
})

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