Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d52b7475be | ||
|
|
383fb56361 | ||
|
|
74b2ec03ff | ||
|
|
5908998127 | ||
|
|
df6a80e7ae | ||
|
|
6ff258ceca | ||
|
|
a8140a5f08 | ||
|
|
9234f21f26 | ||
|
|
ab11e83535 | ||
|
|
b4112358ac | ||
|
|
77239ebcd4 | ||
|
|
54f828cbfa | ||
|
|
9ad8b43408 | ||
|
|
38e8a5c807 | ||
|
|
a9bd2e243a | ||
|
|
ca0d50b229 | ||
|
|
7fca7fd86c | ||
|
|
0ac68f2731 | ||
|
|
5a9ae66680 | ||
|
|
134644fdf1 | ||
|
|
d0f3987aeb | ||
|
|
f06b1b8c4a | ||
|
|
6e56ea4c60 | ||
|
|
216a69f991 | ||
|
|
205395e079 | ||
|
|
ff8bbed6f9 | ||
|
|
d04619477b | ||
|
|
547c138084 | ||
|
|
5c907db0e2 | ||
|
|
a85e592ada | ||
|
|
b21d190ce0 | ||
|
|
cba41e0755 | ||
|
|
02308eeb15 | ||
|
|
9ee41ece98 | ||
|
|
666ddf73b6 | ||
|
|
4499a5fa25 | ||
|
|
727dd4002e | ||
|
|
4b5a2bc4e5 | ||
|
|
b1c340b199 | ||
|
|
a612a17d28 | ||
|
|
d55ee6d5b8 | ||
|
|
aa1e192a50 | ||
|
|
6cd8af1092 | ||
|
|
66652a5d71 | ||
|
|
3bccda0c86 | ||
|
|
fb3295f5f4 | ||
|
|
fa3aa362a9 | ||
|
|
b73ea37798 | ||
|
|
d537e560e3 | ||
|
|
1b92a18ef8 | ||
|
|
31b6d52417 | ||
|
|
a153de34d6 | ||
|
|
64a44f4fce | ||
|
|
bb8a156bdd | ||
|
|
f02a2b04a5 | ||
|
|
b6ab853c57 | ||
|
|
fe43300aa7 | ||
|
|
849d9891d2 | ||
|
|
2768f560ad | ||
|
|
3b6892d42a | ||
|
|
8806a67d08 | ||
|
|
9bcbf2466d | ||
|
|
f0a41bdd14 | ||
|
|
84acc608cc | ||
|
|
c060024919 | ||
|
|
436e4aca73 | ||
|
|
8416b48daf | ||
|
|
1314d3dd9c | ||
|
|
835440f3e0 | ||
|
|
bb00042bff | ||
|
|
9ceb91c207 | ||
|
|
d238bac387 |
@@ -0,0 +1,20 @@
|
||||
### 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 -->
|
||||
@@ -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 == 'Build' }}
|
||||
name: Attach Assets to Build
|
||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
|
||||
name: Attach Assets to Release
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
|
||||
@@ -85,5 +85,3 @@ deploy/selfhost/plane-app/
|
||||
## Storybook
|
||||
*storybook.log
|
||||
output.css
|
||||
|
||||
dev-server
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { FC, useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// components
|
||||
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
|
||||
// hooks
|
||||
|
||||
@@ -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":
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
@@ -14,9 +14,10 @@
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@plane/constants": "*",
|
||||
"@plane/helpers": "*",
|
||||
"@plane/hooks": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
"@sentry/nextjs": "^8.32.0",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/lodash": "^4.17.0",
|
||||
@@ -26,7 +27,7 @@
|
||||
"lucide-react": "^0.356.0",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"next": "^14.2.12",
|
||||
"next": "^14.2.20",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -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://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
|
||||
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
|
||||
@@ -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://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
|
||||
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
|
||||
|
||||
RUN apk --no-cache add \
|
||||
"bash~=5.2" \
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.24.0"
|
||||
"version": "0.24.1"
|
||||
}
|
||||
|
||||
@@ -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,6 +24,18 @@ 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:
|
||||
|
||||
@@ -5,6 +5,7 @@ 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):
|
||||
@@ -15,6 +16,17 @@ 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:
|
||||
|
||||
@@ -54,6 +54,8 @@ 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
|
||||
@@ -62,6 +64,8 @@ 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,
|
||||
|
||||
@@ -8,6 +8,7 @@ from plane.app.views import (
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
PageVersionEndpoint,
|
||||
PageDuplicateEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,4 +79,9 @@ 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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -68,9 +68,7 @@ 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(
|
||||
|
||||
@@ -155,6 +155,7 @@ from .page.base import (
|
||||
PageLogEndpoint,
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
PageDuplicateEndpoint,
|
||||
)
|
||||
from .page.version import PageVersionEndpoint
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Python imports
|
||||
import json
|
||||
import pytz
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
@@ -52,6 +54,11 @@ 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):
|
||||
@@ -67,6 +74,19 @@ 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()
|
||||
@@ -119,12 +139,15 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=timezone.now())
|
||||
& Q(end_date__gte=timezone.now()),
|
||||
Q(start_date__lte=current_time_in_utc)
|
||||
& Q(end_date__gte=current_time_in_utc),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
|
||||
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||
When(
|
||||
start_date__gt=current_time_in_utc,
|
||||
then=Value("UPCOMING"),
|
||||
),
|
||||
When(end_date__lt=current_time_in_utc, then=Value("COMPLETED")),
|
||||
When(
|
||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||
then=Value("DRAFT"),
|
||||
@@ -160,10 +183,22 @@ 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=timezone.now(), end_date__gte=timezone.now()
|
||||
start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
|
||||
)
|
||||
|
||||
data = queryset.values(
|
||||
@@ -191,6 +226,8 @@ 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)
|
||||
@@ -221,6 +258,8 @@ 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])
|
||||
@@ -417,6 +456,8 @@ 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,
|
||||
@@ -492,6 +533,9 @@ 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)
|
||||
|
||||
@@ -15,8 +15,6 @@ from django.db.models import (
|
||||
UUIDField,
|
||||
Value,
|
||||
Subquery,
|
||||
Case,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
@@ -56,10 +54,11 @@ 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.user_timezone_converter import user_timezone_converter
|
||||
from plane.utils.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):
|
||||
@@ -430,6 +429,13 @@ 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)
|
||||
|
||||
@@ -445,12 +451,10 @@ class IssueViewSet(BaseViewSet):
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Case(
|
||||
When(
|
||||
issue_cycle__cycle__deleted_at__isnull=True,
|
||||
then=F("issue_cycle__cycle_id"),
|
||||
),
|
||||
default=None,
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
|
||||
:1
|
||||
]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
@@ -653,6 +657,12 @@ 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)
|
||||
|
||||
|
||||
@@ -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.user_timezone_converter import user_timezone_converter
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
|
||||
@@ -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.user_timezone_converter import user_timezone_converter
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
|
||||
|
||||
# Module imports
|
||||
|
||||
@@ -56,7 +56,7 @@ from plane.db.models import (
|
||||
Project,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
from plane.utils.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
|
||||
|
||||
@@ -121,6 +121,8 @@ 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>"),
|
||||
},
|
||||
)
|
||||
@@ -553,3 +555,33 @@ 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)
|
||||
|
||||
@@ -16,12 +16,7 @@ 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
|
||||
@@ -83,10 +78,7 @@ 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"
|
||||
@@ -94,10 +86,7 @@ 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"
|
||||
@@ -135,8 +124,7 @@ 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(
|
||||
@@ -145,9 +133,7 @@ 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
|
||||
@@ -238,9 +224,7 @@ 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,
|
||||
)
|
||||
|
||||
@@ -280,9 +264,7 @@ 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,
|
||||
)
|
||||
|
||||
@@ -303,10 +285,7 @@ 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
|
||||
):
|
||||
@@ -344,7 +323,6 @@ 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)
|
||||
|
||||
@@ -41,6 +41,7 @@ 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
|
||||
@@ -81,12 +82,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"),
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
# 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 (
|
||||
@@ -26,12 +16,7 @@ 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
|
||||
@@ -119,9 +104,7 @@ 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,
|
||||
)
|
||||
|
||||
@@ -148,9 +131,7 @@ 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
|
||||
@@ -164,9 +145,7 @@ 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"
|
||||
)
|
||||
@@ -213,9 +192,7 @@ 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
|
||||
@@ -279,9 +256,7 @@ 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,6 +60,9 @@ 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,6 +60,7 @@ 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)
|
||||
|
||||
@@ -3,7 +3,8 @@ from django.utils import timezone
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models.fields.related import OneToOneRel
|
||||
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -11,31 +12,98 @@ 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)
|
||||
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:
|
||||
# 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 []
|
||||
)
|
||||
|
||||
for obj in related_objects:
|
||||
if obj:
|
||||
obj.deleted_at = timezone.now()
|
||||
obj.save(using=using)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
# 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
|
||||
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
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
# @shared_task
|
||||
|
||||
@@ -162,8 +162,7 @@ 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 ""
|
||||
),
|
||||
(
|
||||
@@ -197,8 +196,7 @@ 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": (
|
||||
@@ -208,17 +206,11 @@ 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"]),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# 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
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
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
|
||||
@@ -0,0 +1,254 @@
|
||||
# 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)
|
||||
@@ -257,7 +257,9 @@ 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,28 +13,14 @@ 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(
|
||||
"--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",
|
||||
"--role", type=int, nargs="?", help="Role of the user in the project"
|
||||
)
|
||||
|
||||
def handle(self, *args: Any, **options: Any):
|
||||
@@ -67,9 +53,7 @@ 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()
|
||||
)
|
||||
@@ -79,22 +63,15 @@ 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
|
||||
@@ -102,9 +79,7 @@ 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:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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")
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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"))
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -41,6 +41,8 @@ from .issue import (
|
||||
IssueSequence,
|
||||
IssueSubscriber,
|
||||
IssueVote,
|
||||
IssueVersion,
|
||||
IssueDescriptionVersion,
|
||||
)
|
||||
from .module import Module, ModuleIssue, ModuleLink, ModuleMember, ModuleUserProperties
|
||||
from .notification import EmailNotificationLog, Notification, UserNotificationPreference
|
||||
@@ -53,7 +55,6 @@ from .project import (
|
||||
ProjectMemberInvite,
|
||||
ProjectPublicMember,
|
||||
)
|
||||
from .deploy_board import DeployBoard
|
||||
from .session import Session
|
||||
from .social_connection import SocialLoginConnection
|
||||
from .state import State
|
||||
@@ -69,24 +70,6 @@ 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
|
||||
@@ -96,3 +79,5 @@ from .recent_visit import UserRecentVisit
|
||||
from .label import Label
|
||||
|
||||
from .device import Device, DeviceSession
|
||||
|
||||
from .sticky import Sticky
|
||||
|
||||
@@ -44,45 +44,25 @@ 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)
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
|
||||
|
||||
@@ -660,11 +661,6 @@ class IssueVote(ProjectBaseModel):
|
||||
|
||||
|
||||
class IssueVersion(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="versions",
|
||||
)
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
("high", "High"),
|
||||
@@ -672,14 +668,11 @@ 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,
|
||||
@@ -688,9 +681,9 @@ class IssueVersion(ProjectBaseModel):
|
||||
)
|
||||
start_date = models.DateField(null=True, blank=True)
|
||||
target_date = models.DateField(null=True, blank=True)
|
||||
sequence_id = models.IntegerField(
|
||||
default=1, verbose_name="Issue Sequence ID"
|
||||
)
|
||||
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)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
completed_at = models.DateTimeField(null=True)
|
||||
archived_at = models.DateField(null=True)
|
||||
@@ -698,29 +691,26 @@ 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)
|
||||
owned_by = models.UUIDField()
|
||||
assignees = ArrayField(
|
||||
models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="versions"
|
||||
)
|
||||
labels = ArrayField(
|
||||
models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
cycle = models.UUIDField(
|
||||
activity = models.ForeignKey(
|
||||
"db.IssueActivity",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="versions",
|
||||
)
|
||||
modules = ArrayField(
|
||||
models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
owned_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="issue_versions",
|
||||
)
|
||||
properties = models.JSONField(default=dict)
|
||||
meta = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Version"
|
||||
@@ -740,43 +730,93 @@ 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,
|
||||
state=issue.state,
|
||||
point=issue.point,
|
||||
estimate_point=issue.estimate_point,
|
||||
parent=issue.parent_id,
|
||||
state=issue.state_id,
|
||||
estimate_point=issue.estimate_point_id,
|
||||
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,
|
||||
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
|
||||
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)
|
||||
),
|
||||
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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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",)
|
||||
@@ -26,6 +26,14 @@ 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
|
||||
@@ -178,6 +186,12 @@ 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"
|
||||
|
||||
@@ -29,9 +29,7 @@ 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
|
||||
|
||||
@@ -102,12 +102,7 @@ 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):
|
||||
@@ -136,9 +131,7 @@ 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"""
|
||||
@@ -167,10 +160,7 @@ 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:
|
||||
@@ -184,9 +174,7 @@ 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,
|
||||
@@ -221,9 +209,7 @@ 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)
|
||||
@@ -283,9 +269,7 @@ 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)
|
||||
|
||||
@@ -320,9 +304,7 @@ 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,6 +1,8 @@
|
||||
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,6 +13,8 @@ from .admin import (
|
||||
InstanceAdminUserSessionEndpoint,
|
||||
)
|
||||
|
||||
from .changelog import ChangeLogEndpoint
|
||||
|
||||
from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint
|
||||
from .workspace import (
|
||||
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||
InstanceWorkSpaceEndpoint,
|
||||
)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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,16 +66,14 @@ 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)
|
||||
|
||||
@@ -11,14 +11,12 @@ 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(
|
||||
@@ -62,9 +60,5 @@ urlpatterns = [
|
||||
InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(),
|
||||
name="instance-workspace-availability",
|
||||
),
|
||||
path(
|
||||
"workspaces/",
|
||||
InstanceWorkSpaceEndpoint.as_view(),
|
||||
name="instance-workspace",
|
||||
),
|
||||
path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
|
||||
]
|
||||
|
||||
@@ -262,6 +262,9 @@ 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
|
||||
|
||||
@@ -10,9 +10,15 @@ 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(),
|
||||
|
||||
@@ -25,3 +25,5 @@ from .state import ProjectStatesEndpoint
|
||||
from .label import ProjectLabelsEndpoint
|
||||
|
||||
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
|
||||
|
||||
from .meta import ProjectMetaDataEndpoint
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,100 @@
|
||||
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
|
||||
@@ -1,26 +0,0 @@
|
||||
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,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.16
|
||||
Django==4.2.17
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"value": ""
|
||||
},
|
||||
"GITHUB_CLIENT_SECRET": {
|
||||
"description": "Github Client Secret",
|
||||
"description": "GitHub Client Secret",
|
||||
"value": ""
|
||||
},
|
||||
"NEXT_PUBLIC_API_BASE_URL": {
|
||||
|
||||
+14
-14
@@ -1,36 +1,36 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"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 . --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"
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@hocuspocus/extension-database": "^2.13.7",
|
||||
"@hocuspocus/extension-logger": "^2.13.7",
|
||||
"@hocuspocus/extension-redis": "^2.13.7",
|
||||
"@hocuspocus/server": "^2.13.7",
|
||||
"@hocuspocus/extension-database": "^2.11.3",
|
||||
"@hocuspocus/extension-logger": "^2.11.3",
|
||||
"@hocuspocus/extension-redis": "^2.13.5",
|
||||
"@hocuspocus/server": "^2.11.3",
|
||||
"@plane/editor": "*",
|
||||
"@plane/types": "*",
|
||||
"@sentry/node": "^8.28.0",
|
||||
"@sentry/profiling-node": "^8.28.0",
|
||||
"@tiptap/core": "^2.9.1",
|
||||
"@tiptap/html": "^2.9.1",
|
||||
"@tiptap/core": "^2.4.0",
|
||||
"@tiptap/html": "^2.3.0",
|
||||
"axios": "^1.7.2",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.20.0",
|
||||
"express": "^4.21.2",
|
||||
"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.12",
|
||||
"y-prosemirror": "^1.2.9",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.20"
|
||||
"yjs": "^13.6.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.25.6",
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.9",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
|
||||
@@ -5,9 +5,8 @@ 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 { logger } from "@/core/helpers/logger.js";
|
||||
// core helpers and utilities
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
|
||||
// core libraries
|
||||
import {
|
||||
@@ -28,7 +27,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
new Logger({
|
||||
onChange: false,
|
||||
log: (message) => {
|
||||
logger.info(message);
|
||||
manualLogger.info(message);
|
||||
},
|
||||
}),
|
||||
new Database({
|
||||
@@ -41,7 +40,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, reject) => {
|
||||
return new Promise(async (resolve) => {
|
||||
try {
|
||||
let fetchedData = null;
|
||||
if (documentType === "project_page") {
|
||||
@@ -58,16 +57,9 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
if (!fetchedData) {
|
||||
reject("Data is null");
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(fetchedData);
|
||||
} catch (error) {
|
||||
logger.error("Error in fetching document", error);
|
||||
reject("Error in fetching document" + JSON.stringify(error));
|
||||
manualLogger.error("Error in fetching document", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -100,7 +92,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error in updating document:", error);
|
||||
manualLogger.error("Error in updating document:", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -122,7 +114,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
) {
|
||||
redisClient.disconnect();
|
||||
}
|
||||
logger.warn(
|
||||
manualLogger.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,
|
||||
);
|
||||
@@ -131,18 +123,18 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
|
||||
redisClient.on("ready", () => {
|
||||
extensions.push(new HocusPocusRedis({ redis: redisClient }));
|
||||
logger.info("Redis Client connected ✅");
|
||||
manualLogger.info("Redis Client connected ✅");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
manualLogger.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 {
|
||||
logger.warn(
|
||||
manualLogger.warn(
|
||||
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ErrorRequestHandler } from "express";
|
||||
import { logger } from "@/core/helpers/logger.js";
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
|
||||
// Log the error
|
||||
logger.error(err);
|
||||
manualLogger.error(err);
|
||||
|
||||
// Set the response status
|
||||
res.status(err.status || 500);
|
||||
|
||||
@@ -18,7 +18,7 @@ const hooks = {
|
||||
},
|
||||
};
|
||||
|
||||
export const coreLogger = pinoHttp({
|
||||
export const logger = pinoHttp({
|
||||
level: "info",
|
||||
transport: transport,
|
||||
hooks: hooks,
|
||||
@@ -35,4 +35,4 @@ export const coreLogger = pinoHttp({
|
||||
},
|
||||
});
|
||||
|
||||
export const logger = coreLogger.logger;
|
||||
export const manualLogger = logger.logger;
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
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,
|
||||
];
|
||||
export const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
export const getAllDocumentFormatsFromBinaryData = (
|
||||
description: Uint8Array,
|
||||
): {
|
||||
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
|
||||
contentBinaryEncoded: string;
|
||||
contentJSON: object;
|
||||
contentHTML: string;
|
||||
@@ -32,7 +24,7 @@ export const getAllDocumentFormatsFromBinaryData = (
|
||||
const type = yDoc.getXmlFragment("default");
|
||||
const contentJSON = yXmlFragmentToProseMirrorRootNode(
|
||||
type,
|
||||
documentEditorSchema,
|
||||
documentEditorSchema
|
||||
).toJSON();
|
||||
// convert to HTML
|
||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||
@@ -42,29 +34,26 @@ export const getAllDocumentFormatsFromBinaryData = (
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// services
|
||||
import { UserService } from "@/core/services/user.service.js";
|
||||
// core helpers
|
||||
import { logger } from "@/core/helpers/logger.js";
|
||||
import { manualLogger } 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) {
|
||||
logger.error("Failed to fetch current user:", error);
|
||||
manualLogger.error("Failed to fetch current user:", error);
|
||||
throw error;
|
||||
}
|
||||
if (response.id !== userId) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "@/core/helpers/page.js";
|
||||
// services
|
||||
import { PageService } from "@/core/services/page.service.js";
|
||||
import { logger } from "../helpers/logger.js";
|
||||
import { manualLogger } from "../helpers/logger.js";
|
||||
const pageService = new PageService();
|
||||
|
||||
export const updatePageDescription = async (
|
||||
@@ -41,7 +41,7 @@ export const updatePageDescription = async (
|
||||
cookie,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Update error:", error);
|
||||
manualLogger.error("Update error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -66,7 +66,7 @@ const fetchDescriptionHTMLAndTransform = async (
|
||||
);
|
||||
return contentBinary;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
manualLogger.error(
|
||||
"Error while transforming from HTML to Uint8Array",
|
||||
error,
|
||||
);
|
||||
@@ -106,7 +106,7 @@ export const fetchPageDescriptionBinary = async (
|
||||
|
||||
return binaryData;
|
||||
} catch (error) {
|
||||
logger.error("Fetch error:", error);
|
||||
manualLogger.error("Fetch error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
+16
-17
@@ -13,10 +13,7 @@ import cors from "cors";
|
||||
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
|
||||
|
||||
// helpers
|
||||
import {
|
||||
coreLogger as LoggerMiddleware,
|
||||
logger,
|
||||
} from "@/core/helpers/logger.js";
|
||||
import { logger, manualLogger } from "@/core/helpers/logger.js";
|
||||
import { errorHandler } from "@/core/helpers/error-handler.js";
|
||||
|
||||
const app = express();
|
||||
@@ -36,7 +33,7 @@ app.use(
|
||||
);
|
||||
|
||||
// Logging middleware
|
||||
app.use(LoggerMiddleware);
|
||||
app.use(logger);
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json());
|
||||
@@ -48,7 +45,7 @@ app.use(cors());
|
||||
const router = express.Router();
|
||||
|
||||
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
|
||||
logger.error("Failed to initialize HocusPocusServer:", err);
|
||||
manualLogger.error("Failed to initialize HocusPocusServer:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -60,7 +57,7 @@ router.ws("/collaboration", (ws, req) => {
|
||||
try {
|
||||
HocusPocusServer.handleConnection(ws, req);
|
||||
} catch (err) {
|
||||
logger.error("WebSocket connection error:", err);
|
||||
manualLogger.error("WebSocket connection error:", err);
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
@@ -76,44 +73,46 @@ Sentry.setupExpressErrorHandler(app);
|
||||
app.use(errorHandler);
|
||||
|
||||
const liveServer = app.listen(app.get("port"), () => {
|
||||
logger.info(`Plane Live server has started at port ${app.get("port")}`);
|
||||
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
|
||||
});
|
||||
|
||||
const gracefulShutdown = async () => {
|
||||
logger.info("Starting graceful shutdown...");
|
||||
manualLogger.info("Starting graceful shutdown...");
|
||||
|
||||
try {
|
||||
// Close the HocusPocus server WebSocket connections
|
||||
await HocusPocusServer.destroy();
|
||||
logger.info("HocusPocus server WebSocket connections closed gracefully.");
|
||||
manualLogger.info(
|
||||
"HocusPocus server WebSocket connections closed gracefully.",
|
||||
);
|
||||
|
||||
// Close the Express server
|
||||
liveServer.close(() => {
|
||||
logger.info("Express server closed gracefully.");
|
||||
manualLogger.info("Express server closed gracefully.");
|
||||
process.exit(1);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Error during shutdown:", err);
|
||||
manualLogger.error("Error during shutdown:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Forcefully shut down after 10 seconds if not closed
|
||||
setTimeout(() => {
|
||||
logger.error("Forcing shutdown...");
|
||||
manualLogger.error("Forcing shutdown...");
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
// Graceful shutdown on unhandled rejection
|
||||
process.on("unhandledRejection", (err: any) => {
|
||||
logger.info("Unhandled Rejection: ", err);
|
||||
logger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
|
||||
manualLogger.info("Unhandled Rejection: ", err);
|
||||
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
|
||||
gracefulShutdown();
|
||||
});
|
||||
|
||||
// Graceful shutdown on uncaught exception
|
||||
process.on("uncaughtException", (err: any) => {
|
||||
logger.info("Uncaught Exception: ", err);
|
||||
logger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
|
||||
manualLogger.info("Uncaught Exception: ", err);
|
||||
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
|
||||
gracefulShutdown();
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"private": true,
|
||||
"main": "./src/index.ts"
|
||||
}
|
||||
|
||||
@@ -13,3 +13,6 @@ 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";
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum EIssueGroupByToServerOptions {
|
||||
"target_date" = "target_date",
|
||||
"project" = "project_id",
|
||||
"created_by" = "created_by",
|
||||
"team_project" = "project_id",
|
||||
}
|
||||
|
||||
export enum EIssueGroupBYServerToProperty {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
@@ -27,6 +27,7 @@
|
||||
"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": {
|
||||
@@ -36,25 +37,25 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.4",
|
||||
"@hocuspocus/provider": "^2.13.5",
|
||||
"@plane/helpers": "*",
|
||||
"@plane/ui": "*",
|
||||
"@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",
|
||||
"@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",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^1.2.1",
|
||||
"highlight.js": "^11.8.0",
|
||||
@@ -63,17 +64,16 @@
|
||||
"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.10",
|
||||
"tiptap-markdown": "^0.8.9",
|
||||
"uuid": "^10.0.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "^1.2.12",
|
||||
"y-prosemirror": "^1.2.5",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.20"
|
||||
"yjs": "^13.6.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
|
||||
@@ -35,10 +35,6 @@ import {
|
||||
toggleBold,
|
||||
toggleBulletList,
|
||||
toggleCodeBlock,
|
||||
toggleFlatBulletList,
|
||||
toggleFlatOrderedList,
|
||||
toggleFlatTaskList,
|
||||
toggleFlatToggleList,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingFour,
|
||||
toggleHeadingOne,
|
||||
@@ -157,35 +153,27 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough
|
||||
export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({
|
||||
key: "bulleted-list",
|
||||
name: "Bulleted list",
|
||||
isActive: () => editor?.isActive("list", { type: "bullet" }),
|
||||
command: () => toggleFlatBulletList(editor),
|
||||
isActive: () => editor?.isActive("bulletList"),
|
||||
command: () => toggleBulletList(editor),
|
||||
icon: ListIcon,
|
||||
});
|
||||
|
||||
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
|
||||
key: "numbered-list",
|
||||
name: "Numbered list",
|
||||
isActive: () => editor?.isActive("list", { type: "ordered" }),
|
||||
command: () => toggleFlatOrderedList(editor),
|
||||
icon: ListIcon,
|
||||
isActive: () => editor?.isActive("orderedList"),
|
||||
command: () => toggleOrderedList(editor),
|
||||
icon: ListOrderedIcon,
|
||||
});
|
||||
|
||||
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
|
||||
key: "to-do-list",
|
||||
name: "To-do list",
|
||||
isActive: () => editor?.isActive("list", { type: "task" }),
|
||||
command: () => toggleFlatTaskList(editor),
|
||||
isActive: () => editor.isActive("taskItem"),
|
||||
command: () => toggleTaskList(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",
|
||||
@@ -277,7 +265,6 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
|
||||
HorizontalRuleItem(editor),
|
||||
TextColorItem(editor),
|
||||
BackgroundColorItem(editor),
|
||||
ToggleListItem(editor),
|
||||
TextAlignItem(editor),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -3,4 +3,6 @@ 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/helpers";
|
||||
import { convertHexEmojiToDecimal } from "@plane/utils";
|
||||
// plane ui
|
||||
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
|
||||
// helpers
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// plane helpers
|
||||
import { sanitizeHTML } from "@plane/helpers";
|
||||
import { sanitizeHTML } from "@plane/utils";
|
||||
// plane ui
|
||||
import { TEmojiLogoProps } from "@plane/ui";
|
||||
// types
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, Selection } from "@tiptap/pm/state";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
export interface CodeBlockOptions {
|
||||
/**
|
||||
@@ -150,7 +150,6 @@ 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;
|
||||
}
|
||||
@@ -184,6 +183,8 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// exit node on arrow down
|
||||
ArrowDown: ({ editor }) => {
|
||||
try {
|
||||
if (!this.options.exitOnArrowDown) {
|
||||
@@ -204,12 +205,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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);
|
||||
const after = $from.after();
|
||||
|
||||
if (after === undefined) {
|
||||
return false;
|
||||
@@ -218,15 +214,12 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
const nodeAfter = doc.nodeAt(after);
|
||||
|
||||
if (nodeAfter) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(Selection.near(doc.resolve(after)));
|
||||
return true;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.commands.exitCode();
|
||||
} catch (error) {
|
||||
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
|
||||
console.error("Error handling ArrowDown in code block:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -91,12 +91,7 @@ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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);
|
||||
const after = $from.after();
|
||||
|
||||
if (after === undefined) {
|
||||
return false;
|
||||
|
||||
@@ -19,7 +19,6 @@ 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";
|
||||
|
||||
@@ -44,6 +43,16 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
paragraph: {
|
||||
HTMLAttributes: {
|
||||
class: "editor-paragraph-block",
|
||||
},
|
||||
},
|
||||
heading: {
|
||||
HTMLAttributes: {
|
||||
class: "editor-heading-block",
|
||||
},
|
||||
},
|
||||
dropcursor: false,
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
@@ -92,7 +101,6 @@ 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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
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,6 +24,7 @@ import {
|
||||
CustomTypographyExtension,
|
||||
DropHandlerExtension,
|
||||
ImageExtension,
|
||||
ListKeymap,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
@@ -33,14 +34,8 @@ 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[];
|
||||
@@ -59,48 +54,40 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
|
||||
return [
|
||||
StarterKit.configure({
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
listItem: false,
|
||||
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",
|
||||
},
|
||||
},
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
// dropcursor: false,
|
||||
paragraph: {
|
||||
HTMLAttributes: {
|
||||
class: "editor-paragraph-block",
|
||||
},
|
||||
},
|
||||
heading: {
|
||||
HTMLAttributes: {
|
||||
class: "editor-heading-block",
|
||||
},
|
||||
},
|
||||
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({
|
||||
@@ -109,6 +96,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
},
|
||||
}),
|
||||
CustomKeymap,
|
||||
ListKeymap({ tabIndex }),
|
||||
CustomLinkExtension.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
@@ -188,9 +176,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutExtension,
|
||||
CustomColorExtension,
|
||||
FlatListExtension,
|
||||
multipleSelectionExtension,
|
||||
// FlatHeadingListExtension,
|
||||
...CoreEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
}),
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,223 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,787 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,153 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/* 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 }
|
||||
@@ -1,208 +0,0 @@
|
||||
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')),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
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(),
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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>
|
||||
`,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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'),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
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'),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
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)
|
||||
@@ -1,516 +0,0 @@
|
||||
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
Reference in New Issue
Block a user