Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b23004e302 | |||
| d0e0cd324b | |||
| fa9c63716c | |||
| d3f1b511ad | |||
| e624a17868 | |||
| 670da9fa03 | |||
| b03844ee2d | |||
| f36d0691fe | |||
| e7d888d817 | |||
| 2f6923fca0 | |||
| 912246c592 | |||
| 4a065e14d0 | |||
| e09aeab5b8 | |||
| 25a6cd49fc | |||
| b5538565c7 | |||
| b8043f92b1 | |||
| 0e91feacc3 | |||
| 40c0922726 | |||
| dee8f00a71 | |||
| 072f2e2cac | |||
| 79c2dfd293 | |||
| 22a9d48ca3 | |||
| fbcc8fc8a0 | |||
| c1fa372c84 | |||
| 7045a1f2af | |||
| f26b4d3d06 | |||
| c3c1aef7a9 | |||
| 24e57009af | |||
| 2b7a17b484 | |||
| 64fd0b2830 | |||
| 8988cf9a85 | |||
| eb5ffebcc6 | |||
| 414010688d | |||
| 171099667e | |||
| d65f0e264e | |||
| c7d17d00b7 | |||
| 9cdfb2224a | |||
| 8129f5f969 | |||
| 89b8cdbe6e | |||
| 53e6a62a12 | |||
| 75f89c4c12 | |||
| 0983e5f44d | |||
| 2014400bed | |||
| dffcc6dc10 | |||
| 640b23fb1b | |||
| e13d8aa4b3 | |||
| cf595de7c7 | |||
| 0fa9c8b015 | |||
| 6fe0415d66 |
@@ -67,9 +67,8 @@ export const InstanceHeader: FC = observer(() => {
|
||||
{breadcrumbItems.length >= 0 && (
|
||||
<div>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
href="/general/"
|
||||
label="Settings"
|
||||
@@ -80,10 +79,9 @@ export const InstanceHeader: FC = observer(() => {
|
||||
{breadcrumbItems.map(
|
||||
(item) =>
|
||||
item.title && (
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
<Breadcrumbs.Item
|
||||
key={item.title}
|
||||
type="text"
|
||||
link={<BreadcrumbLink href={item.href} label={item.title} />}
|
||||
component={<BreadcrumbLink href={item.href} label={item.title} />}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { FC } from "react";
|
||||
import { Info, X } from "lucide-react";
|
||||
// plane constants
|
||||
import { TAuthErrorInfo } from "@plane/constants";
|
||||
import { TAdminAuthErrorInfo } from "@plane/constants";
|
||||
|
||||
type TAuthBanner = {
|
||||
bannerData: TAuthErrorInfo | undefined;
|
||||
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
|
||||
bannerData: TAdminAuthErrorInfo | undefined;
|
||||
handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void;
|
||||
};
|
||||
|
||||
export const AuthBanner: FC<TAuthBanner> = (props) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FC, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
|
||||
import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Button, Input, Spinner } from "@plane/ui";
|
||||
// components
|
||||
@@ -54,7 +54,7 @@ export const InstanceSignInForm: FC = (props) => {
|
||||
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
|
||||
const [formData, setFormData] = useState<TFormData>(defaultFromData);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
|
||||
const [errorInfo, setErrorInfo] = useState<TAdminAuthErrorInfo | undefined>(undefined);
|
||||
|
||||
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { KeyRound, Mails } from "lucide-react";
|
||||
// plane packages
|
||||
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
|
||||
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
// components
|
||||
@@ -89,7 +89,7 @@ const errorCodeMessages: {
|
||||
export const authErrorHandler = (
|
||||
errorCode: EAdminAuthErrorCodes,
|
||||
email?: string | undefined
|
||||
): TAuthErrorInfo | undefined => {
|
||||
): TAdminAuthErrorInfo | undefined => {
|
||||
const bannerAlertErrorCodes = [
|
||||
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
|
||||
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
|
||||
|
||||
+15
-13
@@ -9,19 +9,21 @@ const nextConfig = {
|
||||
unoptimized: true,
|
||||
},
|
||||
basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "",
|
||||
transpilePackages: [
|
||||
"@plane/constants",
|
||||
"@plane/editor",
|
||||
"@plane/hooks",
|
||||
"@plane/i18n",
|
||||
"@plane/logger",
|
||||
"@plane/propel",
|
||||
"@plane/services",
|
||||
"@plane/shared-state",
|
||||
"@plane/types",
|
||||
"@plane/ui",
|
||||
"@plane/utils",
|
||||
],
|
||||
experimental: {
|
||||
optimizePackageImports: [
|
||||
"@plane/constants",
|
||||
"@plane/editor",
|
||||
"@plane/hooks",
|
||||
"@plane/i18n",
|
||||
"@plane/logger",
|
||||
"@plane/propel",
|
||||
"@plane/services",
|
||||
"@plane/shared-state",
|
||||
"@plane/types",
|
||||
"@plane/ui",
|
||||
"@plane/utils",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "0.26.1",
|
||||
"version": "0.27.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -31,9 +31,9 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"next": "^14.2.29",
|
||||
"next": "14.2.30",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.38",
|
||||
"postcss": "^8.4.49",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "7.51.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.26.1",
|
||||
"version": "0.27.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
|
||||
@@ -39,7 +39,7 @@ from .project import (
|
||||
ProjectMemberRoleSerializer,
|
||||
)
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .view import IssueViewSerializer
|
||||
from .view import IssueViewSerializer, ViewIssueListSerializer
|
||||
from .cycle import (
|
||||
CycleSerializer,
|
||||
CycleIssueSerializer,
|
||||
@@ -74,6 +74,7 @@ from .issue import (
|
||||
IssueLinkLiteSerializer,
|
||||
IssueVersionDetailSerializer,
|
||||
IssueDescriptionVersionDetailSerializer,
|
||||
IssueListDetailSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
|
||||
@@ -725,6 +725,110 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IssueListDetailSerializer(serializers.Serializer):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Extract expand parameter and store it as instance variable
|
||||
self.expand = kwargs.pop("expand", []) or []
|
||||
# Extract fields parameter and store it as instance variable
|
||||
self.fields = kwargs.pop("fields", []) or []
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_module_ids(self, obj):
|
||||
return [module.module_id for module in obj.issue_module.all()]
|
||||
|
||||
def get_label_ids(self, obj):
|
||||
return [label.label_id for label in obj.label_issue.all()]
|
||||
|
||||
def get_assignee_ids(self, obj):
|
||||
return [assignee.assignee_id for assignee in obj.issue_assignee.all()]
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = {
|
||||
# Basic fields
|
||||
"id": instance.id,
|
||||
"name": instance.name,
|
||||
"state_id": instance.state_id,
|
||||
"sort_order": instance.sort_order,
|
||||
"completed_at": instance.completed_at,
|
||||
"estimate_point": instance.estimate_point_id,
|
||||
"priority": instance.priority,
|
||||
"start_date": instance.start_date,
|
||||
"target_date": instance.target_date,
|
||||
"sequence_id": instance.sequence_id,
|
||||
"project_id": instance.project_id,
|
||||
"parent_id": instance.parent_id,
|
||||
"created_at": instance.created_at,
|
||||
"updated_at": instance.updated_at,
|
||||
"created_by": instance.created_by_id,
|
||||
"updated_by": instance.updated_by_id,
|
||||
"is_draft": instance.is_draft,
|
||||
"archived_at": instance.archived_at,
|
||||
# Computed fields
|
||||
"cycle_id": instance.cycle_id,
|
||||
"module_ids": self.get_module_ids(instance),
|
||||
"label_ids": self.get_label_ids(instance),
|
||||
"assignee_ids": self.get_assignee_ids(instance),
|
||||
"sub_issues_count": instance.sub_issues_count,
|
||||
"attachment_count": instance.attachment_count,
|
||||
"link_count": instance.link_count,
|
||||
}
|
||||
|
||||
# Handle expanded fields only when requested - using direct field access
|
||||
if self.expand:
|
||||
if "issue_relation" in self.expand:
|
||||
relations = []
|
||||
for relation in instance.issue_relation.all():
|
||||
related_issue = relation.related_issue
|
||||
# If the related issue is deleted, skip it
|
||||
if not related_issue:
|
||||
continue
|
||||
# Add the related issue to the relations list
|
||||
relations.append(
|
||||
{
|
||||
"id": related_issue.id,
|
||||
"project_id": related_issue.project_id,
|
||||
"sequence_id": related_issue.sequence_id,
|
||||
"name": related_issue.name,
|
||||
"relation_type": relation.relation_type,
|
||||
"state_id": related_issue.state_id,
|
||||
"priority": related_issue.priority,
|
||||
"created_by": related_issue.created_by_id,
|
||||
"created_at": related_issue.created_at,
|
||||
"updated_at": related_issue.updated_at,
|
||||
"updated_by": related_issue.updated_by_id,
|
||||
}
|
||||
)
|
||||
data["issue_relation"] = relations
|
||||
|
||||
if "issue_related" in self.expand:
|
||||
related = []
|
||||
for relation in instance.issue_related.all():
|
||||
issue = relation.issue
|
||||
# If the related issue is deleted, skip it
|
||||
if not issue:
|
||||
continue
|
||||
# Add the related issue to the related list
|
||||
related.append(
|
||||
{
|
||||
"id": issue.id,
|
||||
"project_id": issue.project_id,
|
||||
"sequence_id": issue.sequence_id,
|
||||
"name": issue.name,
|
||||
"relation_type": relation.relation_type,
|
||||
"state_id": issue.state_id,
|
||||
"priority": issue.priority,
|
||||
"created_by": issue.created_by_id,
|
||||
"created_at": issue.created_at,
|
||||
"updated_at": issue.updated_at,
|
||||
"updated_by": issue.updated_by_id,
|
||||
}
|
||||
)
|
||||
data["issue_related"] = related
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class IssueLiteSerializer(DynamicBaseSerializer):
|
||||
class Meta:
|
||||
model = Issue
|
||||
|
||||
@@ -7,6 +7,49 @@ from plane.db.models import IssueView
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class ViewIssueListSerializer(serializers.Serializer):
|
||||
|
||||
def get_assignee_ids(self, instance):
|
||||
return [assignee.assignee_id for assignee in instance.issue_assignee.all()]
|
||||
|
||||
def get_label_ids(self, instance):
|
||||
return [label.label_id for label in instance.label_issue.all()]
|
||||
|
||||
def get_module_ids(self, instance):
|
||||
return [module.module_id for module in instance.issue_module.all()]
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = {
|
||||
"id": instance.id,
|
||||
"name": instance.name,
|
||||
"state_id": instance.state_id,
|
||||
"sort_order": instance.sort_order,
|
||||
"completed_at": instance.completed_at,
|
||||
"estimate_point": instance.estimate_point_id,
|
||||
"priority": instance.priority,
|
||||
"start_date": instance.start_date,
|
||||
"target_date": instance.target_date,
|
||||
"sequence_id": instance.sequence_id,
|
||||
"project_id": instance.project_id,
|
||||
"parent_id": instance.parent_id,
|
||||
"cycle_id": instance.cycle_id,
|
||||
"sub_issues_count": instance.sub_issues_count,
|
||||
"created_at": instance.created_at,
|
||||
"updated_at": instance.updated_at,
|
||||
"created_by": instance.created_by_id,
|
||||
"updated_by": instance.updated_by_id,
|
||||
"attachment_count": instance.attachment_count,
|
||||
"link_count": instance.link_count,
|
||||
"is_draft": instance.is_draft,
|
||||
"archived_at": instance.archived_at,
|
||||
"state__group": instance.state.group if instance.state else None,
|
||||
"assignee_ids": self.get_assignee_ids(instance),
|
||||
"label_ids": self.get_label_ids(instance),
|
||||
"module_ids": self.get_module_ids(instance),
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
class IssueViewSerializer(DynamicBaseSerializer):
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
@@ -198,6 +196,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
|
||||
class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_identifier = serializers.SerializerMethodField()
|
||||
assignees = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
@@ -215,9 +214,15 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_project_identifier(self, obj):
|
||||
project = obj.project
|
||||
|
||||
return project.identifier if project else None
|
||||
|
||||
def get_assignees(self, obj):
|
||||
return list(
|
||||
obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_members = serializers.SerializerMethodField()
|
||||
|
||||
@@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
|
||||
urlpatterns = [
|
||||
# API Tokens
|
||||
path(
|
||||
"workspaces/<str:slug>/api-tokens/",
|
||||
"users/api-tokens/",
|
||||
ApiTokenEndpoint.as_view(),
|
||||
name="api-tokens",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
|
||||
"users/api-tokens/<uuid:pk>/",
|
||||
ApiTokenEndpoint.as_view(),
|
||||
name="api-tokens",
|
||||
name="api-tokens-details",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/service-api-tokens/",
|
||||
|
||||
@@ -13,6 +13,8 @@ from plane.app.views import (
|
||||
ProjectAssetEndpoint,
|
||||
ProjectBulkAssetEndpoint,
|
||||
AssetCheckEndpoint,
|
||||
WorkspaceAssetDownloadEndpoint,
|
||||
ProjectAssetDownloadEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -89,4 +91,14 @@ urlpatterns = [
|
||||
AssetCheckEndpoint.as_view(),
|
||||
name="asset-check",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/download/<uuid:asset_id>/",
|
||||
WorkspaceAssetDownloadEndpoint.as_view(),
|
||||
name="workspace-asset-download",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/download/<uuid:asset_id>/",
|
||||
ProjectAssetDownloadEndpoint.as_view(),
|
||||
name="project-asset-download",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -107,6 +107,8 @@ from .asset.v2 import (
|
||||
ProjectAssetEndpoint,
|
||||
ProjectBulkAssetEndpoint,
|
||||
AssetCheckEndpoint,
|
||||
WorkspaceAssetDownloadEndpoint,
|
||||
ProjectAssetDownloadEndpoint,
|
||||
)
|
||||
from .issue.base import (
|
||||
IssueListEndpoint,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Python import
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
|
||||
# Third party
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
from rest_framework import status
|
||||
|
||||
# Module import
|
||||
@@ -13,12 +15,9 @@ from plane.app.permissions import WorkspaceEntityPermission
|
||||
|
||||
|
||||
class ApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
|
||||
def post(self, request, slug):
|
||||
def post(self, request: Request) -> Response:
|
||||
label = request.data.get("label", str(uuid4().hex))
|
||||
description = request.data.get("description", "")
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
expired_at = request.data.get("expired_at", None)
|
||||
|
||||
# Check the user type
|
||||
@@ -28,7 +27,6 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
label=label,
|
||||
description=description,
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
user_type=user_type,
|
||||
expired_at=expired_at,
|
||||
)
|
||||
@@ -37,29 +35,23 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
# Token will be only visible while creating
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def get(self, request, slug, pk=None):
|
||||
def get(self, request: Request, pk: Optional[str] = None) -> Response:
|
||||
if pk is None:
|
||||
api_tokens = APIToken.objects.filter(
|
||||
user=request.user, workspace__slug=slug, is_service=False
|
||||
)
|
||||
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
|
||||
serializer = APITokenReadSerializer(api_tokens, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
api_tokens = APIToken.objects.get(
|
||||
user=request.user, workspace__slug=slug, pk=pk
|
||||
)
|
||||
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
|
||||
serializer = APITokenReadSerializer(api_tokens)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def delete(self, request, slug, pk):
|
||||
api_token = APIToken.objects.get(
|
||||
workspace__slug=slug, user=request.user, pk=pk, is_service=False
|
||||
)
|
||||
def delete(self, request: Request, pk: str) -> Response:
|
||||
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
|
||||
api_token.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def patch(self, request, slug, pk):
|
||||
api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk)
|
||||
def patch(self, request: Request, pk: str) -> Response:
|
||||
api_token = APIToken.objects.get(user=request.user, pk=pk)
|
||||
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
@@ -70,7 +62,7 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
class ServiceApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
|
||||
def post(self, request, slug):
|
||||
def post(self, request: Request, slug: str) -> Response:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
api_token = APIToken.objects.filter(
|
||||
|
||||
@@ -718,3 +718,56 @@ class AssetCheckEndpoint(BaseAPIView):
|
||||
id=asset_id, workspace__slug=slug, deleted_at__isnull=True
|
||||
).exists()
|
||||
return Response({"exists": asset}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkspaceAssetDownloadEndpoint(BaseAPIView):
|
||||
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def get(self, request, slug, asset_id):
|
||||
try:
|
||||
asset = FileAsset.objects.get(
|
||||
id=asset_id,
|
||||
workspace__slug=slug,
|
||||
is_uploaded=True,
|
||||
)
|
||||
except FileAsset.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "The requested asset could not be found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
storage = S3Storage(request=request)
|
||||
signed_url = storage.generate_presigned_url(
|
||||
object_name=asset.asset.name,
|
||||
disposition=f"attachment; filename={asset.asset.name}",
|
||||
)
|
||||
|
||||
return HttpResponseRedirect(signed_url)
|
||||
|
||||
|
||||
class ProjectAssetDownloadEndpoint(BaseAPIView):
|
||||
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
|
||||
def get(self, request, slug, project_id, asset_id):
|
||||
try:
|
||||
asset = FileAsset.objects.get(
|
||||
id=asset_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
is_uploaded=True,
|
||||
)
|
||||
except FileAsset.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "The requested asset could not be found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
storage = S3Storage(request=request)
|
||||
signed_url = storage.generate_presigned_url(
|
||||
object_name=asset.asset.name,
|
||||
disposition=f"attachment; filename={asset.asset.name}",
|
||||
)
|
||||
|
||||
return HttpResponseRedirect(signed_url)
|
||||
|
||||
@@ -32,6 +32,7 @@ from plane.app.serializers import (
|
||||
IssueDetailSerializer,
|
||||
IssueUserPropertySerializer,
|
||||
IssueSerializer,
|
||||
IssueListDetailSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
@@ -46,6 +47,9 @@ from plane.db.models import (
|
||||
CycleIssue,
|
||||
UserRecentVisit,
|
||||
ModuleIssue,
|
||||
IssueRelation,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -944,10 +948,57 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
# check for the project member role, if the role is 5 then check for the guest_view_all_features
|
||||
# if it is true then show all the issues else show only the issues created by the user
|
||||
permission_subquery = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, id=OuterRef("id")
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__role__gt=ROLE.GUEST.value,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__role=ROLE.GUEST.value,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__role=ROLE.GUEST.value,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
)
|
||||
.values("id")
|
||||
)
|
||||
# Main issue query
|
||||
issue = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.filter(Exists(permission_subquery))
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
@@ -955,43 +1006,6 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -1014,6 +1028,24 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
# Add additional prefetch based on expand parameter
|
||||
if self.expand:
|
||||
if "issue_relation" in self.expand:
|
||||
issue = issue.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_relation",
|
||||
queryset=IssueRelation.objects.select_related("related_issue"),
|
||||
)
|
||||
)
|
||||
if "issue_related" in self.expand:
|
||||
issue = issue.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_related",
|
||||
queryset=IssueRelation.objects.select_related("issue"),
|
||||
)
|
||||
)
|
||||
|
||||
issue = issue.filter(**filters)
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
# Issue queryset
|
||||
@@ -1024,7 +1056,7 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=(issue),
|
||||
on_results=lambda issue: IssueSerializer(
|
||||
on_results=lambda issue: IssueListDetailSerializer(
|
||||
issue, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@@ -341,7 +341,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
{
|
||||
"name": "The project name is already taken",
|
||||
"code": "PROJECT_NAME_ALREADY_EXIST",
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
@@ -350,7 +353,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
except serializers.ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
{
|
||||
"identifier": "The project identifier is already taken",
|
||||
"code": "PROJECT_IDENTIFIER_ALREADY_EXIST",
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Exists, F, Func, OuterRef, Q, UUIDField, Value, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import (
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
Prefetch,
|
||||
)
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.db import transaction
|
||||
@@ -13,7 +18,7 @@ from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.app.serializers import IssueViewSerializer
|
||||
from plane.app.serializers import IssueViewSerializer, ViewIssueListSerializer
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
FileAsset,
|
||||
@@ -25,15 +30,12 @@ from plane.db.models import (
|
||||
Project,
|
||||
CycleIssue,
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
ModuleIssue,
|
||||
)
|
||||
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 plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from .. import BaseViewSet
|
||||
from plane.db.models import UserFavorite
|
||||
@@ -143,6 +145,28 @@ class WorkspaceViewViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
def _get_project_permission_filters(self):
|
||||
"""
|
||||
Get common project permission filters for guest users and role-based access control.
|
||||
Returns Q object for filtering issues based on user role and project settings.
|
||||
"""
|
||||
return Q(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role > 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.issue_objects.annotate(
|
||||
@@ -152,12 +176,25 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
.select_related("state")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
@@ -186,43 +223,6 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@@ -233,126 +233,36 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
# Get common project permission filters
|
||||
permission_filters = self._get_project_permission_filters()
|
||||
|
||||
# Base query for the counts
|
||||
total_issue_count = (
|
||||
Issue.issue_objects.filter(**filters)
|
||||
.filter(workspace__slug=slug)
|
||||
.filter(permission_filters)
|
||||
.only("id")
|
||||
)
|
||||
|
||||
# check for the project member role, if the role is 5 then check for the guest_view_all_features if it is true then show all the issues else show only the issues created by the user
|
||||
|
||||
issue_queryset = issue_queryset.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role < 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
# Apply project permission filters to the issue queryset
|
||||
issue_queryset = issue_queryset.filter(permission_filters)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue_queryset, order_by_param=order_by_param
|
||||
)
|
||||
|
||||
# Group by
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
|
||||
# issue queryset
|
||||
issue_queryset = issue_queryset_grouper(
|
||||
queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data,
|
||||
total_count_queryset=total_issue_count,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
# Check group and sub group value paginate
|
||||
if sub_group_by:
|
||||
if group_by == sub_group_by:
|
||||
return Response(
|
||||
{
|
||||
"error": "Group by and sub group by cannot have same parameters"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
# group and sub group pagination
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
paginator_cls=SubGroupedOffsetPaginator,
|
||||
group_by_fields=issue_group_values(
|
||||
field=group_by, slug=slug, project_id=None, filters=filters
|
||||
),
|
||||
sub_group_by_fields=issue_group_values(
|
||||
field=sub_group_by,
|
||||
slug=slug,
|
||||
project_id=None,
|
||||
filters=filters,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
sub_group_by_field_name=sub_group_by,
|
||||
count_filter=Q(
|
||||
Q(issue_intake__status=1)
|
||||
| Q(issue_intake__status=-1)
|
||||
| Q(issue_intake__status=2)
|
||||
| Q(issue_intake__isnull=True),
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
# Group Paginate
|
||||
else:
|
||||
# Group paginate
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
paginator_cls=GroupedOffsetPaginator,
|
||||
group_by_fields=issue_group_values(
|
||||
field=group_by, slug=slug, project_id=None, filters=filters
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
count_filter=Q(
|
||||
Q(issue_intake__status=1)
|
||||
| Q(issue_intake__status=-1)
|
||||
| Q(issue_intake__status=2)
|
||||
| Q(issue_intake__isnull=True),
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
else:
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class IssueViewViewSet(BaseViewSet):
|
||||
serializer_class = IssueViewSerializer
|
||||
|
||||
@@ -271,15 +271,15 @@ class IssueBlocker(ProjectBaseModel):
|
||||
return f"{self.block.name} {self.blocked_by.name}"
|
||||
|
||||
|
||||
class IssueRelation(ProjectBaseModel):
|
||||
RELATION_CHOICES = (
|
||||
("duplicate", "Duplicate"),
|
||||
("relates_to", "Relates To"),
|
||||
("blocked_by", "Blocked By"),
|
||||
("start_before", "Start Before"),
|
||||
("finish_before", "Finish Before"),
|
||||
)
|
||||
class IssueRelationChoices(models.TextChoices):
|
||||
DUPLICATE = "duplicate", "Duplicate"
|
||||
RELATES_TO = "relates_to", "Relates To"
|
||||
BLOCKED_BY = "blocked_by", "Blocked By"
|
||||
START_BEFORE = "start_before", "Start Before"
|
||||
FINISH_BEFORE = "finish_before", "Finish Before"
|
||||
|
||||
|
||||
class IssueRelation(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
Issue, related_name="issue_relation", on_delete=models.CASCADE
|
||||
)
|
||||
@@ -288,9 +288,9 @@ class IssueRelation(ProjectBaseModel):
|
||||
)
|
||||
relation_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=RELATION_CHOICES,
|
||||
choices=IssueRelationChoices.choices,
|
||||
verbose_name="Issue Relation Type",
|
||||
default="blocked_by",
|
||||
default=IssueRelationChoices.BLOCKED_BY,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -27,7 +27,7 @@ def user_data():
|
||||
"email": "test@plane.so",
|
||||
"password": "test-password",
|
||||
"first_name": "Test",
|
||||
"last_name": "User"
|
||||
"last_name": "User",
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def create_user(db, user_data):
|
||||
user = User.objects.create(
|
||||
email=user_data["email"],
|
||||
first_name=user_data["first_name"],
|
||||
last_name=user_data["last_name"]
|
||||
last_name=user_data["last_name"],
|
||||
)
|
||||
user.set_password(user_data["password"])
|
||||
user.save()
|
||||
@@ -69,10 +69,52 @@ def session_client(api_client, create_user):
|
||||
return api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_bot_user(db):
|
||||
"""Create and return a bot user instance"""
|
||||
from uuid import uuid4
|
||||
|
||||
unique_id = uuid4().hex[:8]
|
||||
user = User.objects.create(
|
||||
email=f"bot-{unique_id}@plane.so",
|
||||
username=f"bot_user_{unique_id}",
|
||||
first_name="Bot",
|
||||
last_name="User",
|
||||
is_bot=True,
|
||||
)
|
||||
user.set_password("bot@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_token_data():
|
||||
"""Return sample API token data for testing"""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
return {
|
||||
"label": "Test API Token",
|
||||
"description": "Test description for API token",
|
||||
"expired_at": (timezone.now() + timedelta(days=30)).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_api_token_for_user(db, create_user):
|
||||
"""Create and return an API token for a specific user"""
|
||||
return APIToken.objects.create(
|
||||
label="Test Token",
|
||||
description="Test token description",
|
||||
user=create_user,
|
||||
user_type=0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plane_server(live_server):
|
||||
"""
|
||||
Renamed version of live_server fixture to avoid name clashes.
|
||||
Returns a live Django server for testing HTTP requests.
|
||||
"""
|
||||
return live_server
|
||||
return live_server
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import pytest
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import APIToken, User
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestApiTokenEndpoint:
|
||||
"""Test cases for ApiTokenEndpoint"""
|
||||
|
||||
# POST /user/api-tokens/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_success(
|
||||
self, session_client, create_user, api_token_data
|
||||
):
|
||||
"""Test successful API token creation"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "token" in response.data
|
||||
assert response.data["label"] == api_token_data["label"]
|
||||
assert response.data["description"] == api_token_data["description"]
|
||||
assert response.data["user_type"] == 0 # Human user
|
||||
|
||||
# Verify token was created in database
|
||||
token = APIToken.objects.get(pk=response.data["id"])
|
||||
assert token.user == create_user
|
||||
assert token.label == api_token_data["label"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_for_bot_user(
|
||||
self, session_client, create_bot_user, api_token_data
|
||||
):
|
||||
"""Test API token creation for bot user"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_bot_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["user_type"] == 1 # Bot user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_minimal_data(self, session_client, create_user):
|
||||
"""Test API token creation with minimal data"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, {}, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "token" in response.data
|
||||
assert len(response.data["label"]) == 32 # UUID hex length
|
||||
assert response.data["description"] == ""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_with_expiry(self, session_client, create_user):
|
||||
"""Test API token creation with expiry date"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
future_date = timezone.now() + timedelta(days=30)
|
||||
data = {"label": "Expiring Token", "expired_at": future_date.isoformat()}
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify expiry date was set
|
||||
token = APIToken.objects.get(pk=response.data["id"])
|
||||
assert token.expired_at is not None
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_unauthenticated(self, api_client, api_token_data):
|
||||
"""Test API token creation without authentication"""
|
||||
# Arrange
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = api_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
# GET /user/api-tokens/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_get_all_api_tokens(self, session_client, create_user):
|
||||
"""Test retrieving all API tokens for user"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
|
||||
# Create multiple tokens
|
||||
APIToken.objects.create(label="Token 1", user=create_user, user_type=0)
|
||||
APIToken.objects.create(label="Token 2", user=create_user, user_type=0)
|
||||
# Create a service token (should be excluded)
|
||||
APIToken.objects.create(
|
||||
label="Service Token", user=create_user, user_type=0, is_service=True
|
||||
)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 2 # Only non-service tokens
|
||||
assert all(token["is_service"] is False for token in response.data)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_empty_api_tokens_list(self, session_client, create_user):
|
||||
"""Test retrieving API tokens when none exist"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == []
|
||||
|
||||
# GET /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_get_specific_api_token(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test retrieving a specific API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert str(response.data["id"]) == str(create_api_token_for_user.pk)
|
||||
assert response.data["label"] == create_api_token_for_user.label
|
||||
assert (
|
||||
"token" not in response.data
|
||||
) # Token should not be visible in read serializer
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test retrieving a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test retrieving another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"other-{unique_id}@plane.so"
|
||||
unique_username = f"other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(
|
||||
label="Other Token", user=other_user, user_type=0
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# DELETE /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_delete_api_token_success(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test successful API token deletion"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test deleting a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test deleting another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"delete-other-{unique_id}@plane.so"
|
||||
unique_username = f"delete_other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(
|
||||
label="Other Token", user=other_user, user_type=0
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Verify token still exists
|
||||
assert APIToken.objects.filter(pk=other_token.pk).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_service_api_token_forbidden(self, session_client, create_user):
|
||||
"""Test deleting a service API token (should fail)"""
|
||||
# Arrange
|
||||
service_token = APIToken.objects.create(
|
||||
label="Service Token", user=create_user, user_type=0, is_service=True
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": service_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Verify token still exists
|
||||
assert APIToken.objects.filter(pk=service_token.pk).exists()
|
||||
|
||||
# PATCH /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_patch_api_token_success(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test successful API token update"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
update_data = {
|
||||
"label": "Updated Token Label",
|
||||
"description": "Updated description",
|
||||
}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["label"] == update_data["label"]
|
||||
assert response.data["description"] == update_data["description"]
|
||||
|
||||
# Verify database was updated
|
||||
create_api_token_for_user.refresh_from_db()
|
||||
assert create_api_token_for_user.label == update_data["label"]
|
||||
assert create_api_token_for_user.description == update_data["description"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_api_token_partial_update(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test partial API token update"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
original_description = create_api_token_for_user.description
|
||||
update_data = {"label": "Only Label Updated"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["label"] == update_data["label"]
|
||||
assert response.data["description"] == original_description
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test updating a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
update_data = {"label": "New Label"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test updating another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"patch-other-{unique_id}@plane.so"
|
||||
unique_username = f"patch_other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(
|
||||
label="Other Token", user=other_user, user_type=0
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
||||
update_data = {"label": "Hacked Label"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# Verify token was not updated
|
||||
other_token.refresh_from_db()
|
||||
assert other_token.label == "Other Token"
|
||||
|
||||
# Authentication tests
|
||||
@pytest.mark.django_db
|
||||
def test_all_endpoints_require_authentication(self, api_client):
|
||||
"""Test that all endpoints require authentication"""
|
||||
# Arrange
|
||||
endpoints = [
|
||||
(reverse("api-tokens"), "get"),
|
||||
(reverse("api-tokens"), "post"),
|
||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "get"),
|
||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "patch"),
|
||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "delete"),
|
||||
]
|
||||
|
||||
# Act & Assert
|
||||
for url, method in endpoints:
|
||||
response = getattr(api_client, method)(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -0,0 +1,75 @@
|
||||
import pytest
|
||||
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
Issue,
|
||||
User,
|
||||
IssueAssignee,
|
||||
WorkspaceMember,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.app.serializers.workspace import IssueRecentVisitSerializer
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssueRecentVisitSerializer:
|
||||
"""Test the IssueRecentVisitSerializer"""
|
||||
|
||||
def test_issue_recent_visit_serializer_fields(self, db):
|
||||
"""Test that the serializer includes the correct fields"""
|
||||
|
||||
test_user_1 = User.objects.create(
|
||||
email="test_user_1@example.com", first_name="Test", last_name="User"
|
||||
)
|
||||
|
||||
# To test for deleted issue assignee
|
||||
test_user_2 = User.objects.create(
|
||||
email="test_user_2@example.com",
|
||||
first_name="Other",
|
||||
last_name="User",
|
||||
username="some user name",
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace", slug="test-workspace", owner=test_user_1
|
||||
)
|
||||
|
||||
WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace)
|
||||
|
||||
project = Project.objects.create(
|
||||
name="Test Project", identifier="test-project", workspace=workspace
|
||||
)
|
||||
ProjectMember.objects.create(project=project, member=test_user_2)
|
||||
|
||||
issue = Issue.objects.create(
|
||||
name="Test Issue",
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
)
|
||||
|
||||
IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project)
|
||||
|
||||
# Deleted issue assignee
|
||||
IssueAssignee.objects.create(
|
||||
issue=issue,
|
||||
assignee=test_user_2,
|
||||
project=project,
|
||||
deleted_at=timezone.now(),
|
||||
)
|
||||
|
||||
serialized_data = IssueRecentVisitSerializer(
|
||||
issue,
|
||||
).data
|
||||
|
||||
# Check fields are present and correct
|
||||
assert "name" in serialized_data
|
||||
assert "assignees" in serialized_data
|
||||
assert "project_identifier" in serialized_data
|
||||
|
||||
assert serialized_data["name"] == "Test Issue"
|
||||
assert serialized_data["project_identifier"] == "TEST-PROJECT"
|
||||
|
||||
# Only including non-deleted issue assignees
|
||||
assert serialized_data["assignees"] == [test_user_1.id]
|
||||
@@ -16,7 +16,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
).order_by("priority_order", "-created_at")
|
||||
order_by_param = (
|
||||
"priority_order" if order_by_param.startswith("-") else "-priority_order"
|
||||
)
|
||||
@@ -36,7 +36,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
|
||||
default=Value(len(state_order)),
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("state_order")
|
||||
).order_by("state_order", "-created_at")
|
||||
order_by_param = (
|
||||
"-state_order" if order_by_param.startswith("-") else "state_order"
|
||||
)
|
||||
@@ -55,11 +55,18 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
|
||||
if order_by_param.startswith("-")
|
||||
else order_by_param
|
||||
)
|
||||
).order_by("-min_values" if order_by_param.startswith("-") else "min_values")
|
||||
).order_by(
|
||||
"-min_values" if order_by_param.startswith("-") else "min_values",
|
||||
"-created_at",
|
||||
)
|
||||
order_by_param = (
|
||||
"-min_values" if order_by_param.startswith("-") else "min_values"
|
||||
)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
# If the order_by_param is created_at, then don't add the -created_at
|
||||
if "created_at" in order_by_param:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param, "-created_at")
|
||||
order_by_param = order_by_param
|
||||
return issue_queryset, order_by_param
|
||||
|
||||
@@ -102,6 +102,7 @@ class OffsetPaginator:
|
||||
max_limit=MAX_LIMIT,
|
||||
max_offset=None,
|
||||
on_results=None,
|
||||
total_count_queryset=None,
|
||||
):
|
||||
# Key tuple and remove `-` if descending order by
|
||||
self.key = (
|
||||
@@ -115,6 +116,7 @@ class OffsetPaginator:
|
||||
self.max_limit = max_limit
|
||||
self.max_offset = max_offset
|
||||
self.on_results = on_results
|
||||
self.total_count_queryset = total_count_queryset
|
||||
|
||||
def get_result(self, limit=1000, cursor=None):
|
||||
# offset is page #
|
||||
@@ -138,9 +140,9 @@ class OffsetPaginator:
|
||||
)
|
||||
# The current page
|
||||
page = cursor.offset
|
||||
# The offset
|
||||
offset = cursor.offset * cursor.value
|
||||
stop = offset + (cursor.value or limit) + 1
|
||||
# The offset - use limit instead of cursor.value for consistent pagination
|
||||
offset = cursor.offset * limit
|
||||
stop = offset + limit + 1
|
||||
|
||||
if self.max_offset is not None and offset >= self.max_offset:
|
||||
raise BadPaginationError("Pagination offset too large")
|
||||
@@ -148,11 +150,23 @@ class OffsetPaginator:
|
||||
raise BadPaginationError("Pagination offset cannot be negative")
|
||||
|
||||
results = queryset[offset:stop]
|
||||
if cursor.value != limit:
|
||||
# Duplicate the queryset so it does not evaluate on any python ops
|
||||
page_results = queryset[offset:stop].values("id")
|
||||
|
||||
# Only slice from the end if we're going backwards (previous page)
|
||||
if cursor.value != limit and cursor.is_prev:
|
||||
results = results[-(limit + 1) :]
|
||||
|
||||
total_count = (
|
||||
self.total_count_queryset.count()
|
||||
if self.total_count_queryset
|
||||
else results.count()
|
||||
)
|
||||
|
||||
# Check if there are more results available after the current page
|
||||
|
||||
# Adjust cursors based on the results for pagination
|
||||
next_cursor = Cursor(limit, page + 1, False, results.count() > limit)
|
||||
next_cursor = Cursor(limit, page + 1, False, page_results.count() > limit)
|
||||
# If the page is greater than 0, then set the previous cursor
|
||||
prev_cursor = Cursor(limit, page - 1, True, page > 0)
|
||||
|
||||
@@ -164,7 +178,7 @@ class OffsetPaginator:
|
||||
results = self.on_results(results)
|
||||
|
||||
# Count the queryset
|
||||
count = queryset.count()
|
||||
count = total_count
|
||||
|
||||
# Optionally, calculate the total count and max_hits if needed
|
||||
max_hits = math.ceil(count / limit)
|
||||
@@ -196,6 +210,7 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
group_by_field_name,
|
||||
group_by_fields,
|
||||
count_filter,
|
||||
total_count_queryset=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -404,6 +419,7 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
group_by_fields,
|
||||
sub_group_by_fields,
|
||||
count_filter,
|
||||
total_count_queryset=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -694,6 +710,7 @@ class BasePaginator:
|
||||
sub_group_by_field_name=None,
|
||||
sub_group_by_fields=None,
|
||||
count_filter=None,
|
||||
total_count_queryset=None,
|
||||
**paginator_kwargs,
|
||||
):
|
||||
"""Paginate the request"""
|
||||
@@ -719,6 +736,8 @@ class BasePaginator:
|
||||
)
|
||||
paginator_kwargs["sub_group_by_fields"] = sub_group_by_fields
|
||||
|
||||
paginator_kwargs["total_count_queryset"] = total_count_queryset
|
||||
|
||||
paginator = paginator_cls(**paginator_kwargs)
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"modules": false
|
||||
}
|
||||
],
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
"plugins": [
|
||||
[
|
||||
"module-resolver",
|
||||
{
|
||||
"root": ["./src"],
|
||||
"alias": {
|
||||
"@/core": "./src/core",
|
||||
"@/plane-live": "./src/ce"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
+1
-4
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["@plane/eslint-config/server.js"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": true
|
||||
}
|
||||
"parser": "@typescript-eslint/parser"
|
||||
}
|
||||
|
||||
+8
-9
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "0.26.1",
|
||||
"version": "0.27.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "A realtime collaborative server powers Plane's rich text editor",
|
||||
"main": "./src/server.ts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "PORT=3100 concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
|
||||
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
|
||||
"dev": "nodemon --exec \"node -r ./src/index.ts\" -e .ts",
|
||||
"build": "tsc && tsc-alias",
|
||||
"start": "node dist/server.js",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||
@@ -21,7 +21,9 @@
|
||||
"@hocuspocus/extension-redis": "^2.15.0",
|
||||
"@hocuspocus/server": "^2.15.0",
|
||||
"@plane/constants": "*",
|
||||
"@plane/decorators": "*",
|
||||
"@plane/editor": "*",
|
||||
"@plane/logger": "*",
|
||||
"@plane/types": "*",
|
||||
"@tiptap/core": "2.10.4",
|
||||
"@tiptap/html": "2.11.0",
|
||||
@@ -43,20 +45,17 @@
|
||||
"yjs": "^13.6.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.25.6",
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@plane/typescript-config": "*",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/dotenv": "^8.2.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-ws": "^3.0.4",
|
||||
"@types/node": "^20.14.9",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"@types/pino-http": "^5.8.4",
|
||||
"concurrently": "^9.0.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsup": "8.4.0",
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { pinoHttp } from "pino-http";
|
||||
import { Logger } from "pino";
|
||||
|
||||
const transport = {
|
||||
target: "pino-pretty",
|
||||
@@ -35,4 +36,4 @@ export const logger = pinoHttp({
|
||||
},
|
||||
});
|
||||
|
||||
export const manualLogger = logger.logger;
|
||||
export const manualLogger: Logger = logger.logger;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "../../ce/lib/authentication.js"
|
||||
// export * from "@/plane-live/lib/authentication.js";
|
||||
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
import compression from "compression";
|
||||
import cors from "cors";
|
||||
import expressWs from "express-ws";
|
||||
import express from "express";
|
||||
import express, { Request, Response } from "express";
|
||||
import helmet from "helmet";
|
||||
// hocuspocus server
|
||||
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
|
||||
@@ -38,18 +38,18 @@ app.use(express.urlencoded({ extended: true }));
|
||||
// cors middleware
|
||||
app.use(cors());
|
||||
|
||||
const router = express.Router();
|
||||
const router: any = express.Router();
|
||||
|
||||
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
|
||||
manualLogger.error("Failed to initialize HocusPocusServer:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
router.get("/health", (_req, res) => {
|
||||
router.get("/health", (_req: Request, res: Response) => {
|
||||
res.status(200).json({ status: "OK" });
|
||||
});
|
||||
|
||||
router.ws("/collaboration", (ws, req) => {
|
||||
router.ws("/collaboration", (ws: any, req: any) => {
|
||||
try {
|
||||
HocusPocusServer.handleConnection(ws, req);
|
||||
} catch (err) {
|
||||
@@ -58,7 +58,7 @@ router.ws("/collaboration", (ws, req) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/convert-document", (req, res) => {
|
||||
router.post("/convert-document", (req: Request, res: Response) => {
|
||||
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
|
||||
try {
|
||||
if (description_html === undefined || variant === undefined) {
|
||||
@@ -85,7 +85,7 @@ router.post("/convert-document", (req, res) => {
|
||||
|
||||
app.use(process.env.LIVE_BASE_PATH || "/live", router);
|
||||
|
||||
app.use((_req, res) => {
|
||||
app.use((_req: Request, res: Response) => {
|
||||
res.status(404).send("Not Found");
|
||||
});
|
||||
|
||||
|
||||
+9
-8
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"extends": "@plane/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2015"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"baseUrl": ".",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/plane-live/*": ["./src/ce/*"]
|
||||
"@/*": ["./*"],
|
||||
"@/plane-live/*": ["./ce/*"]
|
||||
},
|
||||
"removeComments": true,
|
||||
"esModuleInterop": true,
|
||||
@@ -18,6 +19,6 @@
|
||||
"inlineSources": true,
|
||||
"sourceRoot": "/"
|
||||
},
|
||||
"include": ["src/**/*.ts", "tsup.config.ts"],
|
||||
"include": ["src"],
|
||||
"exclude": ["./dist", "./build", "./node_modules"]
|
||||
}
|
||||
|
||||
+14
-5
@@ -2,10 +2,19 @@ import { defineConfig, Options } from "tsup";
|
||||
|
||||
export default defineConfig((options: Options) => ({
|
||||
entry: ["src/server.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: false,
|
||||
external: ["react"],
|
||||
injectStyle: true,
|
||||
format: ["esm"],
|
||||
dts: false,
|
||||
clean: true,
|
||||
target: "node18",
|
||||
sourcemap: true,
|
||||
splitting: false,
|
||||
bundle: true,
|
||||
outDir: "dist",
|
||||
esbuildOptions(options) {
|
||||
options.alias = {
|
||||
"@/core": "./src/core",
|
||||
"@/plane-live": "./src/ce"
|
||||
};
|
||||
},
|
||||
...options,
|
||||
}));
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
"name": "plane",
|
||||
"description": "Open-source project management that unlocks customer value",
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.26.1",
|
||||
"version": "0.27.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
@@ -27,6 +27,7 @@
|
||||
"turbo": "^2.5.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"brace-expansion": "2.0.2",
|
||||
"nanoid": "3.3.8",
|
||||
"esbuild": "0.25.0",
|
||||
"@babel/helpers": "7.26.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.26.1",
|
||||
"version": "0.27.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"license": "AGPL-3.0"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TAnalyticsTabsBase } from "@plane/types";
|
||||
import { ChartXAxisProperty, ChartYAxisMetric } from "../chart";
|
||||
import { ChartXAxisProperty, ChartYAxisMetric, TAnalyticsTabsBase } from "@plane/types";
|
||||
|
||||
export interface IInsightField {
|
||||
key: string;
|
||||
@@ -11,7 +10,7 @@ export interface IInsightField {
|
||||
};
|
||||
}
|
||||
|
||||
export const insightsFields: Record<TAnalyticsTabsBase, IInsightField[]> = {
|
||||
export const ANALYTICS_INSIGHTS_FIELDS: Record<TAnalyticsTabsBase, IInsightField[]> = {
|
||||
overview: [
|
||||
{
|
||||
key: "total_users",
|
||||
|
||||
@@ -69,7 +69,7 @@ export enum EErrorAlertType {
|
||||
|
||||
export type TAuthErrorInfo = {
|
||||
type: EErrorAlertType;
|
||||
code: EAdminAuthErrorCodes;
|
||||
code: EAuthErrorCodes;
|
||||
title: string;
|
||||
message: any;
|
||||
};
|
||||
@@ -87,6 +87,13 @@ export enum EAdminAuthErrorCodes {
|
||||
ADMIN_USER_DEACTIVATED = "5190",
|
||||
}
|
||||
|
||||
export type TAdminAuthErrorInfo = {
|
||||
type: EErrorAlertType;
|
||||
code: EAdminAuthErrorCodes;
|
||||
title: string;
|
||||
message: any;
|
||||
};
|
||||
|
||||
export enum EAuthErrorCodes {
|
||||
// Global
|
||||
INSTANCE_NOT_CONFIGURED = "5000",
|
||||
|
||||
@@ -1,40 +1,9 @@
|
||||
import { TChartColorScheme } from "@plane/types";
|
||||
import { ChartXAxisProperty, TChartColorScheme } from "@plane/types";
|
||||
|
||||
export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
|
||||
export const AXIS_LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
|
||||
|
||||
|
||||
export enum ChartXAxisProperty {
|
||||
STATES = "STATES",
|
||||
STATE_GROUPS = "STATE_GROUPS",
|
||||
LABELS = "LABELS",
|
||||
ASSIGNEES = "ASSIGNEES",
|
||||
ESTIMATE_POINTS = "ESTIMATE_POINTS",
|
||||
CYCLES = "CYCLES",
|
||||
MODULES = "MODULES",
|
||||
PRIORITY = "PRIORITY",
|
||||
START_DATE = "START_DATE",
|
||||
TARGET_DATE = "TARGET_DATE",
|
||||
CREATED_AT = "CREATED_AT",
|
||||
COMPLETED_AT = "COMPLETED_AT",
|
||||
CREATED_BY = "CREATED_BY",
|
||||
WORK_ITEM_TYPES = "WORK_ITEM_TYPES",
|
||||
PROJECTS = "PROJECTS",
|
||||
EPICS = "EPICS",
|
||||
}
|
||||
|
||||
export enum ChartYAxisMetric {
|
||||
WORK_ITEM_COUNT = "WORK_ITEM_COUNT",
|
||||
ESTIMATE_POINT_COUNT = "ESTIMATE_POINT_COUNT",
|
||||
PENDING_WORK_ITEM_COUNT = "PENDING_WORK_ITEM_COUNT",
|
||||
COMPLETED_WORK_ITEM_COUNT = "COMPLETED_WORK_ITEM_COUNT",
|
||||
IN_PROGRESS_WORK_ITEM_COUNT = "IN_PROGRESS_WORK_ITEM_COUNT",
|
||||
WORK_ITEM_DUE_THIS_WEEK_COUNT = "WORK_ITEM_DUE_THIS_WEEK_COUNT",
|
||||
WORK_ITEM_DUE_TODAY_COUNT = "WORK_ITEM_DUE_TODAY_COUNT",
|
||||
BLOCKED_WORK_ITEM_COUNT = "BLOCKED_WORK_ITEM_COUNT",
|
||||
}
|
||||
|
||||
|
||||
export enum ChartXAxisDateGrouping {
|
||||
DAY = "DAY",
|
||||
WEEK = "WEEK",
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
|
||||
export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "/";
|
||||
export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "";
|
||||
export const API_URL = encodeURI(`${API_BASE_URL}${API_BASE_PATH}`);
|
||||
// God Mode Admin App Base Url
|
||||
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
|
||||
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "/";
|
||||
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
|
||||
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}`);
|
||||
// Publish App Base Url
|
||||
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
|
||||
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "/";
|
||||
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
|
||||
export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}`);
|
||||
// Live App Base Url
|
||||
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_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
|
||||
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}`);
|
||||
// Web App Base Url
|
||||
export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
|
||||
export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "/";
|
||||
export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "";
|
||||
export const WEB_URL = encodeURI(`${WEB_BASE_URL}${WEB_BASE_PATH}`);
|
||||
// plane website url
|
||||
export const WEBSITE_URL =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
|
||||
export const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
|
||||
// support email
|
||||
export const SUPPORT_EMAIL =
|
||||
process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
|
||||
export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
|
||||
// marketing links
|
||||
export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing";
|
||||
export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// types
|
||||
// plane imports
|
||||
import { TEstimateSystems } from "@plane/types";
|
||||
|
||||
export const MAX_ESTIMATE_POINT_INPUT_LENGTH = 20;
|
||||
@@ -1,238 +0,0 @@
|
||||
export type IssueEventProps = {
|
||||
eventName: string;
|
||||
payload: any;
|
||||
updates?: any;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type EventProps = {
|
||||
eventName: string;
|
||||
payload: any;
|
||||
updates?: any;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export const getWorkspaceEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
organization_size: payload.organization_size,
|
||||
first_time: payload.first_time,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getProjectEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.id,
|
||||
identifier: payload.identifier,
|
||||
project_visibility: payload.network == 2 ? "Public" : "Private",
|
||||
changed_properties: payload.changed_properties,
|
||||
lead_id: payload.project_lead,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getCycleEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
cycle_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
start_date: payload.start_date,
|
||||
target_date: payload.target_date,
|
||||
cycle_status: payload.status,
|
||||
changed_properties: payload.changed_properties,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getModuleEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
module_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
start_date: payload.start_date,
|
||||
target_date: payload.target_date,
|
||||
module_status: payload.status,
|
||||
lead_id: payload.lead,
|
||||
changed_properties: payload.changed_properties,
|
||||
member_ids: payload.members,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getPageEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
access: payload.access === 0 ? "Public" : "Private",
|
||||
is_locked: payload.is_locked,
|
||||
archived_at: payload.archived_at,
|
||||
created_by: payload.created_by,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getIssueEventPayload = (props: IssueEventProps) => {
|
||||
const { eventName, payload, updates, path } = props;
|
||||
let eventPayload: any = {
|
||||
issue_id: payload.id,
|
||||
estimate_point: payload.estimate_point,
|
||||
link_count: payload.link_count,
|
||||
target_date: payload.target_date,
|
||||
is_draft: payload.is_draft,
|
||||
label_ids: payload.label_ids,
|
||||
assignee_ids: payload.assignee_ids,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
sequence_id: payload.sequence_id,
|
||||
module_ids: payload.module_ids,
|
||||
sub_issues_count: payload.sub_issues_count,
|
||||
parent_id: payload.parent_id,
|
||||
project_id: payload.project_id,
|
||||
workspace_id: payload.workspace_id,
|
||||
priority: payload.priority,
|
||||
state_id: payload.state_id,
|
||||
start_date: payload.start_date,
|
||||
attachment_count: payload.attachment_count,
|
||||
cycle_id: payload.cycle_id,
|
||||
module_id: payload.module_id,
|
||||
archived_at: payload.archived_at,
|
||||
state: payload.state,
|
||||
view_id:
|
||||
path?.includes("workspace-views") || path?.includes("views")
|
||||
? path.split("/").pop()
|
||||
: "",
|
||||
};
|
||||
|
||||
if (eventName === ISSUE_UPDATED) {
|
||||
eventPayload = {
|
||||
...eventPayload,
|
||||
...updates,
|
||||
updated_from: props.path?.includes("workspace-views")
|
||||
? "All views"
|
||||
: props.path?.includes("cycles")
|
||||
? "Cycle"
|
||||
: props.path?.includes("modules")
|
||||
? "Module"
|
||||
: props.path?.includes("views")
|
||||
? "Project view"
|
||||
: props.path?.includes("inbox")
|
||||
? "Inbox"
|
||||
: props.path?.includes("draft")
|
||||
? "Draft"
|
||||
: "Project",
|
||||
};
|
||||
}
|
||||
return eventPayload;
|
||||
};
|
||||
|
||||
export const getProjectStateEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.id,
|
||||
state_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
group: payload.group,
|
||||
color: payload.color,
|
||||
default: payload.default,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
// Workspace crud Events
|
||||
export const WORKSPACE_CREATED = "Workspace created";
|
||||
export const WORKSPACE_UPDATED = "Workspace updated";
|
||||
export const WORKSPACE_DELETED = "Workspace deleted";
|
||||
// Project Events
|
||||
export const PROJECT_CREATED = "Project created";
|
||||
export const PROJECT_UPDATED = "Project updated";
|
||||
export const PROJECT_DELETED = "Project deleted";
|
||||
// Cycle Events
|
||||
export const CYCLE_CREATED = "Cycle created";
|
||||
export const CYCLE_UPDATED = "Cycle updated";
|
||||
export const CYCLE_DELETED = "Cycle deleted";
|
||||
export const CYCLE_FAVORITED = "Cycle favorited";
|
||||
export const CYCLE_UNFAVORITED = "Cycle unfavorited";
|
||||
// Module Events
|
||||
export const MODULE_CREATED = "Module created";
|
||||
export const MODULE_UPDATED = "Module updated";
|
||||
export const MODULE_DELETED = "Module deleted";
|
||||
export const MODULE_FAVORITED = "Module favorited";
|
||||
export const MODULE_UNFAVORITED = "Module unfavorited";
|
||||
export const MODULE_LINK_CREATED = "Module link created";
|
||||
export const MODULE_LINK_UPDATED = "Module link updated";
|
||||
export const MODULE_LINK_DELETED = "Module link deleted";
|
||||
// Issue Events
|
||||
export const ISSUE_CREATED = "Work item created";
|
||||
export const ISSUE_UPDATED = "Work item updated";
|
||||
export const ISSUE_DELETED = "Work item deleted";
|
||||
export const ISSUE_ARCHIVED = "Work item archived";
|
||||
export const ISSUE_RESTORED = "Work item restored";
|
||||
export const ISSUE_OPENED = "Work item opened";
|
||||
// Project State Events
|
||||
export const STATE_CREATED = "State created";
|
||||
export const STATE_UPDATED = "State updated";
|
||||
export const STATE_DELETED = "State deleted";
|
||||
// Project Page Events
|
||||
export const PAGE_CREATED = "Page created";
|
||||
export const PAGE_UPDATED = "Page updated";
|
||||
export const PAGE_DELETED = "Page deleted";
|
||||
// Member Events
|
||||
export const MEMBER_INVITED = "Member invited";
|
||||
export const MEMBER_ACCEPTED = "Member accepted";
|
||||
export const PROJECT_MEMBER_ADDED = "Project member added";
|
||||
export const PROJECT_MEMBER_LEAVE = "Project member leave";
|
||||
export const WORKSPACE_MEMBER_LEAVE = "Workspace member leave";
|
||||
// Sign-in & Sign-up Events
|
||||
export const NAVIGATE_TO_SIGNUP = "Navigate to sign-up page";
|
||||
export const NAVIGATE_TO_SIGNIN = "Navigate to sign-in page";
|
||||
export const CODE_VERIFIED = "Code verified";
|
||||
export const SETUP_PASSWORD = "Password setup";
|
||||
export const PASSWORD_CREATE_SELECTED = "Password created";
|
||||
export const PASSWORD_CREATE_SKIPPED = "Skipped to setup";
|
||||
export const SIGN_IN_WITH_PASSWORD = "Sign in with password";
|
||||
export const SIGN_UP_WITH_PASSWORD = "Sign up with password";
|
||||
export const SIGN_IN_WITH_CODE = "Sign in with magic link";
|
||||
export const FORGOT_PASSWORD = "Forgot password clicked";
|
||||
export const FORGOT_PASS_LINK = "Forgot password link generated";
|
||||
export const NEW_PASS_CREATED = "New password created";
|
||||
// Onboarding Events
|
||||
export const USER_DETAILS = "User details added";
|
||||
export const USER_ONBOARDING_COMPLETED = "User onboarding completed";
|
||||
// Product Tour Events
|
||||
export const PRODUCT_TOUR_STARTED = "Product tour started";
|
||||
export const PRODUCT_TOUR_COMPLETED = "Product tour completed";
|
||||
export const PRODUCT_TOUR_SKIPPED = "Product tour skipped";
|
||||
// Dashboard Events
|
||||
export const CHANGELOG_REDIRECTED = "Changelog redirected";
|
||||
export const GITHUB_REDIRECTED = "GitHub redirected";
|
||||
// Sidebar Events
|
||||
export const SIDEBAR_CLICKED = "Sidenav clicked";
|
||||
// Global View Events
|
||||
export const GLOBAL_VIEW_CREATED = "Global view created";
|
||||
export const GLOBAL_VIEW_UPDATED = "Global view updated";
|
||||
export const GLOBAL_VIEW_DELETED = "Global view deleted";
|
||||
export const GLOBAL_VIEW_OPENED = "Global view opened";
|
||||
// Notification Events
|
||||
export const NOTIFICATION_ARCHIVED = "Notification archived";
|
||||
export const NOTIFICATION_SNOOZED = "Notification snoozed";
|
||||
export const NOTIFICATION_READ = "Notification marked read";
|
||||
export const UNREAD_NOTIFICATIONS = "Unread notifications viewed";
|
||||
export const NOTIFICATIONS_READ = "All notifications marked read";
|
||||
export const SNOOZED_NOTIFICATIONS = "Snoozed notifications viewed";
|
||||
export const ARCHIVED_NOTIFICATIONS = "Archived notifications viewed";
|
||||
// Groups
|
||||
export const GROUP_WORKSPACE = "Workspace_metrics";
|
||||
|
||||
//Elements
|
||||
export const E_ONBOARDING = "Onboarding";
|
||||
export const E_ONBOARDING_STEP_1 = "Onboarding step 1";
|
||||
export const E_ONBOARDING_STEP_2 = "Onboarding step 2";
|
||||
// Favorites
|
||||
export const FAVORITE_ADDED = "Favorite added";
|
||||
@@ -0,0 +1,258 @@
|
||||
export type IssueEventProps = {
|
||||
eventName: string;
|
||||
payload: any;
|
||||
updates?: any;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type EventProps = {
|
||||
eventName: string;
|
||||
payload: any;
|
||||
updates?: any;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export const getWorkspaceEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
organization_size: payload.organization_size,
|
||||
first_time: payload.first_time,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getProjectEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.id,
|
||||
identifier: payload.identifier,
|
||||
project_visibility: payload.network == 2 ? "Public" : "Private",
|
||||
changed_properties: payload.changed_properties,
|
||||
lead_id: payload.project_lead,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getCycleEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
cycle_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
start_date: payload.start_date,
|
||||
target_date: payload.target_date,
|
||||
cycle_status: payload.status,
|
||||
changed_properties: payload.changed_properties,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getModuleEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
module_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
start_date: payload.start_date,
|
||||
target_date: payload.target_date,
|
||||
module_status: payload.status,
|
||||
lead_id: payload.lead,
|
||||
changed_properties: payload.changed_properties,
|
||||
member_ids: payload.members,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getPageEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
access: payload.access === 0 ? "Public" : "Private",
|
||||
is_locked: payload.is_locked,
|
||||
archived_at: payload.archived_at,
|
||||
created_by: payload.created_by,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getIssueEventPayload = (props: IssueEventProps) => {
|
||||
const { eventName, payload, updates, path } = props;
|
||||
let eventPayload: any = {
|
||||
issue_id: payload.id,
|
||||
estimate_point: payload.estimate_point,
|
||||
link_count: payload.link_count,
|
||||
target_date: payload.target_date,
|
||||
is_draft: payload.is_draft,
|
||||
label_ids: payload.label_ids,
|
||||
assignee_ids: payload.assignee_ids,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
sequence_id: payload.sequence_id,
|
||||
module_ids: payload.module_ids,
|
||||
sub_issues_count: payload.sub_issues_count,
|
||||
parent_id: payload.parent_id,
|
||||
project_id: payload.project_id,
|
||||
workspace_id: payload.workspace_id,
|
||||
priority: payload.priority,
|
||||
state_id: payload.state_id,
|
||||
start_date: payload.start_date,
|
||||
attachment_count: payload.attachment_count,
|
||||
cycle_id: payload.cycle_id,
|
||||
module_id: payload.module_id,
|
||||
archived_at: payload.archived_at,
|
||||
state: payload.state,
|
||||
view_id: path?.includes("workspace-views") || path?.includes("views") ? path.split("/").pop() : "",
|
||||
};
|
||||
|
||||
if (eventName === WORK_ITEM_TRACKER_EVENTS.update) {
|
||||
eventPayload = {
|
||||
...eventPayload,
|
||||
...updates,
|
||||
updated_from: props.path?.includes("workspace-views")
|
||||
? "All views"
|
||||
: props.path?.includes("cycles")
|
||||
? "Cycle"
|
||||
: props.path?.includes("modules")
|
||||
? "Module"
|
||||
: props.path?.includes("views")
|
||||
? "Project view"
|
||||
: props.path?.includes("inbox")
|
||||
? "Inbox"
|
||||
: props.path?.includes("draft")
|
||||
? "Draft"
|
||||
: "Project",
|
||||
};
|
||||
}
|
||||
return eventPayload;
|
||||
};
|
||||
|
||||
export const getProjectStateEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.id,
|
||||
state_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
group: payload.group,
|
||||
color: payload.color,
|
||||
default: payload.default,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
// Dashboard Events
|
||||
export const GITHUB_REDIRECTED_TRACKER_EVENT = "github_redirected";
|
||||
// Groups
|
||||
export const GROUP_WORKSPACE_TRACKER_EVENT = "workspace_metrics";
|
||||
|
||||
export const WORKSPACE_TRACKER_EVENTS = {
|
||||
create: "workspace_created",
|
||||
update: "workspace_updated",
|
||||
delete: "workspace_deleted",
|
||||
};
|
||||
|
||||
export const PROJECT_TRACKER_EVENTS = {
|
||||
create: "project_created",
|
||||
update: "project_updated",
|
||||
delete: "project_deleted",
|
||||
};
|
||||
|
||||
export const CYCLE_TRACKER_EVENTS = {
|
||||
create: "cycle_created",
|
||||
update: "cycle_updated",
|
||||
delete: "cycle_deleted",
|
||||
favorite: "cycle_favorited",
|
||||
unfavorite: "cycle_unfavorited",
|
||||
};
|
||||
|
||||
export const MODULE_TRACKER_EVENTS = {
|
||||
create: "module_created",
|
||||
update: "module_updated",
|
||||
delete: "module_deleted",
|
||||
favorite: "module_favorited",
|
||||
unfavorite: "module_unfavorited",
|
||||
link: {
|
||||
create: "module_link_created",
|
||||
update: "module_link_updated",
|
||||
delete: "module_link_deleted",
|
||||
},
|
||||
};
|
||||
|
||||
export const WORK_ITEM_TRACKER_EVENTS = {
|
||||
create: "work_item_created",
|
||||
update: "work_item_updated",
|
||||
delete: "work_item_deleted",
|
||||
archive: "work_item_archived",
|
||||
restore: "work_item_restored",
|
||||
};
|
||||
|
||||
export const STATE_TRACKER_EVENTS = {
|
||||
create: "state_created",
|
||||
update: "state_updated",
|
||||
delete: "state_deleted",
|
||||
};
|
||||
|
||||
export const PROJECT_PAGE_TRACKER_EVENTS = {
|
||||
create: "project_page_created",
|
||||
update: "project_page_updated",
|
||||
delete: "project_page_deleted",
|
||||
};
|
||||
|
||||
export const MEMBER_TRACKER_EVENTS = {
|
||||
invite: "member_invited",
|
||||
accept: "member_accepted",
|
||||
project: {
|
||||
add: "project_member_added",
|
||||
leave: "project_member_left",
|
||||
},
|
||||
workspace: {
|
||||
leave: "workspace_member_left",
|
||||
},
|
||||
};
|
||||
|
||||
export const AUTH_TRACKER_EVENTS = {
|
||||
navigate: {
|
||||
sign_up: "navigate_to_sign_up_page",
|
||||
sign_in: "navigate_to_sign_in_page",
|
||||
},
|
||||
code_verify: "code_verified",
|
||||
sign_up_with_password: "sign_up_with_password",
|
||||
sign_in_with_password: "sign_in_with_password",
|
||||
sign_in_with_code: "sign_in_with_magic_link",
|
||||
forgot_password: "forgot_password_clicked",
|
||||
};
|
||||
|
||||
export const PRODUCT_TOUR_TRACKER_EVENTS = {
|
||||
start: "product_tour_started",
|
||||
complete: "product_tour_completed",
|
||||
skip: "product_tour_skipped",
|
||||
};
|
||||
|
||||
export const GLOBAL_VIEW_TOUR_TRACKER_EVENTS = {
|
||||
create: "global_view_created",
|
||||
update: "global_view_updated",
|
||||
delete: "global_view_deleted",
|
||||
open: "global_view_opened",
|
||||
};
|
||||
|
||||
export const NOTIFICATION_TRACKER_EVENTS = {
|
||||
archive: "notification_archived",
|
||||
all_marked_read: "all_notifications_marked_read",
|
||||
};
|
||||
|
||||
export const USER_TRACKER_EVENTS = {
|
||||
add_details: "user_details_added",
|
||||
onboarding_complete: "user_onboarding_completed",
|
||||
};
|
||||
|
||||
export const ONBOARDING_TRACKER_EVENTS = {
|
||||
root: "onboarding",
|
||||
step_1: "onboarding_step_1",
|
||||
step_2: "onboarding_step_2",
|
||||
};
|
||||
|
||||
export const SIDEBAR_TRACKER_EVENTS = {
|
||||
click: "sidenav_clicked",
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./core";
|
||||
@@ -21,7 +21,7 @@ export * from "./module";
|
||||
export * from "./project";
|
||||
export * from "./views";
|
||||
export * from "./themes";
|
||||
export * from "./inbox";
|
||||
export * from "./intake";
|
||||
export * from "./profile";
|
||||
export * from "./workspace-drafts";
|
||||
export * from "./label";
|
||||
@@ -33,4 +33,5 @@ export * from "./emoji";
|
||||
export * from "./subscription";
|
||||
export * from "./settings";
|
||||
export * from "./icon";
|
||||
export * from "./estimates";
|
||||
export * from "./analytics";
|
||||
|
||||
@@ -1,36 +1,4 @@
|
||||
import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
|
||||
|
||||
export enum EInboxIssueCurrentTab {
|
||||
OPEN = "open",
|
||||
CLOSED = "closed",
|
||||
}
|
||||
|
||||
export enum EInboxIssueStatus {
|
||||
PENDING = -2,
|
||||
DECLINED = -1,
|
||||
SNOOZED = 0,
|
||||
ACCEPTED = 1,
|
||||
DUPLICATE = 2,
|
||||
}
|
||||
|
||||
export enum EInboxIssueSource {
|
||||
IN_APP = "IN_APP",
|
||||
FORMS = "FORMS",
|
||||
EMAIL = "EMAIL",
|
||||
}
|
||||
|
||||
export type TInboxIssueCurrentTab = EInboxIssueCurrentTab;
|
||||
export type TInboxIssueStatus = EInboxIssueStatus;
|
||||
export type TInboxIssue = {
|
||||
id: string;
|
||||
status: TInboxIssueStatus;
|
||||
snoozed_till: Date | null;
|
||||
duplicate_to: string | undefined;
|
||||
source: EInboxIssueSource | undefined;
|
||||
issue: TIssue;
|
||||
created_by: string;
|
||||
duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined;
|
||||
};
|
||||
import { EInboxIssueStatus, TInboxIssueStatus } from "@plane/types";
|
||||
|
||||
export const INBOX_STATUS: {
|
||||
key: string;
|
||||
@@ -95,3 +63,32 @@ export const INBOX_ISSUE_SORT_BY_OPTIONS = [
|
||||
i18n_label: "common.sort.desc",
|
||||
},
|
||||
];
|
||||
|
||||
export enum EPastDurationFilters {
|
||||
TODAY = "today",
|
||||
YESTERDAY = "yesterday",
|
||||
LAST_7_DAYS = "last_7_days",
|
||||
LAST_30_DAYS = "last_30_days",
|
||||
}
|
||||
|
||||
export const PAST_DURATION_FILTER_OPTIONS: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[] = [
|
||||
{
|
||||
name: "Today",
|
||||
value: EPastDurationFilters.TODAY,
|
||||
},
|
||||
{
|
||||
name: "Yesterday",
|
||||
value: EPastDurationFilters.YESTERDAY,
|
||||
},
|
||||
{
|
||||
name: "Last 7 days",
|
||||
value: EPastDurationFilters.LAST_7_DAYS,
|
||||
},
|
||||
{
|
||||
name: "Last 30 days",
|
||||
value: EPastDurationFilters.LAST_30_DAYS,
|
||||
},
|
||||
];
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
TIssue,
|
||||
EIssuesStoreType,
|
||||
} from "@plane/types";
|
||||
|
||||
export const ALL_ISSUES = "All Issues";
|
||||
@@ -44,28 +45,6 @@ export enum EIssueGroupBYServerToProperty {
|
||||
"created_by" = "created_by",
|
||||
}
|
||||
|
||||
export enum EIssueServiceType {
|
||||
ISSUES = "issues",
|
||||
EPICS = "epics",
|
||||
WORK_ITEMS = "work-items",
|
||||
}
|
||||
|
||||
export enum EIssuesStoreType {
|
||||
GLOBAL = "GLOBAL",
|
||||
PROFILE = "PROFILE",
|
||||
TEAM = "TEAM",
|
||||
PROJECT = "PROJECT",
|
||||
CYCLE = "CYCLE",
|
||||
MODULE = "MODULE",
|
||||
TEAM_VIEW = "TEAM_VIEW",
|
||||
PROJECT_VIEW = "PROJECT_VIEW",
|
||||
ARCHIVED = "ARCHIVED",
|
||||
DRAFT = "DRAFT",
|
||||
DEFAULT = "DEFAULT",
|
||||
WORKSPACE_DRAFT = "WORKSPACE_DRAFT",
|
||||
EPIC = "EPIC",
|
||||
}
|
||||
|
||||
export enum EIssueCommentAccessSpecifier {
|
||||
EXTERNAL = "EXTERNAL",
|
||||
INTERNAL = "INTERNAL",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ILayoutDisplayFiltersOptions, TIssueActivityComment } from "@plane/types";
|
||||
import { EIssuesStoreType, ILayoutDisplayFiltersOptions, TIssueActivityComment } from "@plane/types";
|
||||
import {
|
||||
TIssueFilterPriorityObject,
|
||||
ISSUE_DISPLAY_PROPERTIES_KEYS,
|
||||
EIssuesStoreType,
|
||||
SUB_ISSUES_DISPLAY_PROPERTIES_KEYS,
|
||||
} from "./common";
|
||||
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
import { IPaymentProduct, TBillingFrequency, TProductBillingFrequency } from "@plane/types";
|
||||
|
||||
/**
|
||||
* Enum representing different product subscription types
|
||||
*/
|
||||
export enum EProductSubscriptionEnum {
|
||||
FREE = "FREE",
|
||||
ONE = "ONE",
|
||||
PRO = "PRO",
|
||||
BUSINESS = "BUSINESS",
|
||||
ENTERPRISE = "ENTERPRISE",
|
||||
}
|
||||
import { EProductSubscriptionEnum, IPaymentProduct, TBillingFrequency, TProductBillingFrequency } from "@plane/types";
|
||||
|
||||
/**
|
||||
* Default billing frequency for each product subscription type
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { EStartOfTheWeek } from "@plane/types";
|
||||
|
||||
export const PROFILE_SETTINGS = {
|
||||
profile: {
|
||||
key: "profile",
|
||||
@@ -103,20 +105,6 @@ export const PREFERENCE_OPTIONS: {
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @description The start of the week for the user
|
||||
* @enum {number}
|
||||
*/
|
||||
export enum EStartOfTheWeek {
|
||||
SUNDAY = 0,
|
||||
MONDAY = 1,
|
||||
TUESDAY = 2,
|
||||
WEDNESDAY = 3,
|
||||
THURSDAY = 4,
|
||||
FRIDAY = 5,
|
||||
SATURDAY = 6,
|
||||
}
|
||||
|
||||
/**
|
||||
* @description The options for the start of the week
|
||||
* @type {Array<{value: EStartOfTheWeek, label: string}>}
|
||||
|
||||
@@ -149,3 +149,12 @@ export const DEFAULT_PROJECT_FORM_VALUES: Partial<IProject> = {
|
||||
network: 2,
|
||||
project_lead: null,
|
||||
};
|
||||
|
||||
export enum EProjectFeatureKey {
|
||||
WORK_ITEMS = "work_items",
|
||||
CYCLES = "cycles",
|
||||
MODULES = "modules",
|
||||
VIEWS = "views",
|
||||
PAGES = "pages",
|
||||
INTAKE = "intake",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client"
|
||||
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
|
||||
import { TStateGroups } from "@plane/types";
|
||||
|
||||
export type TDraggableData = {
|
||||
groupKey: TStateGroups;
|
||||
@@ -55,6 +54,34 @@ export const PENDING_STATE_GROUPS = [
|
||||
STATE_GROUPS.cancelled.key,
|
||||
];
|
||||
|
||||
export const STATE_DISTRIBUTION = {
|
||||
[STATE_GROUPS.backlog.key]: {
|
||||
key: STATE_GROUPS.backlog.key,
|
||||
issues: "backlog_issues",
|
||||
points: "backlog_estimate_points",
|
||||
},
|
||||
[STATE_GROUPS.unstarted.key]: {
|
||||
key: STATE_GROUPS.unstarted.key,
|
||||
issues: "unstarted_issues",
|
||||
points: "unstarted_estimate_points",
|
||||
},
|
||||
[STATE_GROUPS.started.key]: {
|
||||
key: STATE_GROUPS.started.key,
|
||||
issues: "started_issues",
|
||||
points: "started_estimate_points",
|
||||
},
|
||||
[STATE_GROUPS.completed.key]: {
|
||||
key: STATE_GROUPS.completed.key,
|
||||
issues: "completed_issues",
|
||||
points: "completed_estimate_points",
|
||||
},
|
||||
[STATE_GROUPS.cancelled.key]: {
|
||||
key: STATE_GROUPS.cancelled.key,
|
||||
issues: "cancelled_issues",
|
||||
points: "cancelled_estimate_points",
|
||||
},
|
||||
};
|
||||
|
||||
export const PROGRESS_STATE_GROUPS_DETAILS = [
|
||||
{
|
||||
key: "completed_issues",
|
||||
@@ -78,5 +105,4 @@ export const PROGRESS_STATE_GROUPS_DETAILS = [
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
export const DISPLAY_WORKFLOW_PRO_CTA = false;
|
||||
|
||||
@@ -25,18 +25,6 @@ export enum EUserPermissionsLevel {
|
||||
PROJECT = "PROJECT",
|
||||
}
|
||||
|
||||
export enum EUserWorkspaceRoles {
|
||||
ADMIN = 20,
|
||||
MEMBER = 15,
|
||||
GUEST = 5,
|
||||
}
|
||||
|
||||
export enum EUserProjectRoles {
|
||||
ADMIN = 20,
|
||||
MEMBER = 15,
|
||||
GUEST = 5,
|
||||
}
|
||||
|
||||
export type TUserPermissionsLevel = EUserPermissionsLevel;
|
||||
|
||||
export enum EUserPermissions {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
export enum EViewAccess {
|
||||
PRIVATE,
|
||||
PUBLIC,
|
||||
}
|
||||
import { EViewAccess } from "@plane/types";
|
||||
|
||||
export const VIEW_ACCESS_SPECIFIERS: {
|
||||
key: EViewAccess;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TStaticViewTypes, IWorkspaceSearchResults } from "@plane/types";
|
||||
import { EUserWorkspaceRoles } from "./user";
|
||||
import { TStaticViewTypes, IWorkspaceSearchResults, EUserWorkspaceRoles } from "@plane/types";
|
||||
|
||||
export const ORGANIZATION_SIZE = [
|
||||
"Just myself", // TODO: translate
|
||||
|
||||
@@ -17,7 +17,7 @@ This package is part of the Plane workspace and can be used by adding it to your
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@plane/decorators": "*"
|
||||
"@plane/decorators": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "0.26.1",
|
||||
"version": "0.27.0",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
@@ -34,8 +34,11 @@
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.1",
|
||||
"@floating-ui/react": "^0.26.4",
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@hocuspocus/provider": "^2.15.0",
|
||||
"@plane/constants": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import { TExtensions, TFileHandler } from "@/types";
|
||||
import type { IEditorProps } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: TFileHandler;
|
||||
};
|
||||
export type TCoreAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
>;
|
||||
|
||||
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => {
|
||||
const {} = props;
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
import type { IReadOnlyEditorProps } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
export type TCoreReadOnlyEditorAdditionalExtensionsProps = Pick<
|
||||
IReadOnlyEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions"
|
||||
>;
|
||||
|
||||
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
export const CoreReadOnlyEditorAdditionalExtensions = (
|
||||
props: TCoreReadOnlyEditorAdditionalExtensionsProps
|
||||
): Extensions => {
|
||||
const {} = props;
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -1,37 +1,39 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { AnyExtension } from "@tiptap/core";
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { AnyExtension } from "@tiptap/core";
|
||||
import { SlashCommands } from "@/extensions";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
import type { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { TExtensions, TFileHandler, TUserDetails } from "@/types";
|
||||
import type { IEditorProps, TExtensions, TUserDetails } from "@/types";
|
||||
|
||||
export type TDocumentEditorAdditionalExtensionsProps = {
|
||||
disabledExtensions: TExtensions[];
|
||||
export type TDocumentEditorAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
> & {
|
||||
embedConfig: TEmbedConfig | undefined;
|
||||
fileHandler: TFileHandler;
|
||||
provider?: HocuspocusProvider;
|
||||
userDetails: TUserDetails;
|
||||
};
|
||||
|
||||
export type TDocumentEditorAdditionalExtensionsRegistry = {
|
||||
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
|
||||
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
|
||||
getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension;
|
||||
};
|
||||
|
||||
const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [
|
||||
{
|
||||
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
|
||||
getExtension: ({ disabledExtensions }) => SlashCommands({ disabledExtensions }),
|
||||
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
|
||||
SlashCommands({ disabledExtensions, flaggedExtensions }),
|
||||
},
|
||||
];
|
||||
|
||||
export const DocumentEditorAdditionalExtensions = (_props: TDocumentEditorAdditionalExtensionsProps) => {
|
||||
const { disabledExtensions = [] } = _props;
|
||||
export const DocumentEditorAdditionalExtensions = (props: TDocumentEditorAdditionalExtensionsProps) => {
|
||||
const { disabledExtensions, flaggedExtensions } = props;
|
||||
|
||||
const documentExtensions = extensionRegistry
|
||||
.filter((config) => config.isEnabled(disabledExtensions))
|
||||
.map((config) => config.getExtension(_props));
|
||||
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
|
||||
.map((config) => config.getExtension(props));
|
||||
|
||||
return documentExtensions;
|
||||
};
|
||||
|
||||
@@ -2,19 +2,19 @@ import { AnyExtension, Extensions } from "@tiptap/core";
|
||||
// extensions
|
||||
import { SlashCommands } from "@/extensions/slash-commands/root";
|
||||
// types
|
||||
import { TExtensions, TFileHandler } from "@/types";
|
||||
import { IEditorProps, TExtensions } from "@/types";
|
||||
|
||||
export type TRichTextEditorAdditionalExtensionsProps = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: TFileHandler;
|
||||
};
|
||||
export type TRichTextEditorAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Registry entry configuration for extensions
|
||||
*/
|
||||
export type TRichTextEditorAdditionalExtensionsRegistry = {
|
||||
/** Determines if the extension should be enabled based on disabled extensions */
|
||||
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
|
||||
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
|
||||
/** Returns the extension instance(s) when enabled */
|
||||
getExtension: (props: TRichTextEditorAdditionalExtensionsProps) => AnyExtension | undefined;
|
||||
};
|
||||
@@ -22,18 +22,19 @@ export type TRichTextEditorAdditionalExtensionsRegistry = {
|
||||
const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [
|
||||
{
|
||||
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
|
||||
getExtension: ({ disabledExtensions }) =>
|
||||
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
|
||||
SlashCommands({
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => {
|
||||
const { disabledExtensions } = props;
|
||||
const { disabledExtensions, flaggedExtensions } = props;
|
||||
|
||||
const extensions: Extensions = extensionRegistry
|
||||
.filter((config) => config.isEnabled(disabledExtensions))
|
||||
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
|
||||
.map((config) => config.getExtension(props))
|
||||
.filter((extension): extension is AnyExtension => extension !== undefined);
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { AnyExtension, Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import { TExtensions, TReadOnlyFileHandler } from "@/types";
|
||||
import { IReadOnlyEditorProps, TExtensions } from "@/types";
|
||||
|
||||
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
};
|
||||
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = Pick<
|
||||
IReadOnlyEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Registry entry configuration for extensions
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// extensions
|
||||
import { TSlashCommandAdditionalOption } from "@/extensions";
|
||||
import type { TSlashCommandAdditionalOption } from "@/extensions";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
import type { IEditorProps } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions?: TExtensions[];
|
||||
};
|
||||
type Props = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions">;
|
||||
|
||||
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
|
||||
const {} = props;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @description function to extract all additional assets from HTML content
|
||||
* @param htmlContent
|
||||
* @returns {string[]} array of additional asset sources
|
||||
*/
|
||||
export const extractAdditionalAssetsFromHTMLContent = (_htmlContent: string): string[] => [];
|
||||
|
||||
/**
|
||||
* @description function to replace additional assets in HTML content with new IDs
|
||||
* @param props
|
||||
* @returns {string} HTML content with replaced additional assets
|
||||
*/
|
||||
export const replaceAdditionalAssetsInHTMLContent = (props: {
|
||||
htmlContent: string;
|
||||
assetMap: Record<string, string>;
|
||||
}): string => {
|
||||
const { htmlContent } = props;
|
||||
return htmlContent;
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { type HeadingExtensionStorage } from "@/extensions";
|
||||
import { type CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
import { type CustomImageExtensionStorage } from "@/extensions/custom-image/types";
|
||||
import { type CustomLinkStorage } from "@/extensions/custom-link";
|
||||
import { type ImageExtensionStorage } from "@/extensions/image";
|
||||
import { type MentionExtensionStorage } from "@/extensions/mentions";
|
||||
|
||||
@@ -13,10 +13,11 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
|
||||
// types
|
||||
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
|
||||
import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
|
||||
|
||||
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
|
||||
const {
|
||||
onChange,
|
||||
onTransaction,
|
||||
aiHandler,
|
||||
bubbleMenuEnabled = true,
|
||||
@@ -27,6 +28,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
@@ -56,10 +58,12 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
embedHandler,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
@@ -95,7 +99,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
);
|
||||
};
|
||||
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditor>(
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
|
||||
(props, ref) => (
|
||||
<CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus"
|
||||
// types
|
||||
import { TAIHandler, TDisplayConfig } from "@/types";
|
||||
|
||||
type IPageRenderer = {
|
||||
type Props = {
|
||||
aiHandler?: TAIHandler;
|
||||
bubbleMenuEnabled: boolean;
|
||||
displayConfig: TDisplayConfig;
|
||||
@@ -15,7 +15,7 @@ type IPageRenderer = {
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: IPageRenderer) => {
|
||||
export const PageRenderer = (props: Props) => {
|
||||
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { forwardRef, MutableRefObject } from "react";
|
||||
import React, { forwardRef, MutableRefObject } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
@@ -13,30 +13,9 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// types
|
||||
import {
|
||||
EditorReadOnlyRefApi,
|
||||
TDisplayConfig,
|
||||
TExtensions,
|
||||
TReadOnlyFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
} from "@/types";
|
||||
import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
disabledExtensions: TExtensions[];
|
||||
id: string;
|
||||
initialValue: string;
|
||||
containerClassName: string;
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
embedHandler: any;
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
tabIndex?: number;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
}
|
||||
|
||||
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) => {
|
||||
const {
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
@@ -44,6 +23,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
id,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
@@ -64,6 +44,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
initialValue,
|
||||
@@ -87,7 +68,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps>((props, ref) => (
|
||||
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -53,17 +53,14 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2));
|
||||
const lastNode = lastNodePos.node();
|
||||
|
||||
// Check if the last node is a not paragraph
|
||||
if (lastNode && lastNode.type.name !== CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If last node is not a paragraph, insert a new paragraph at the end
|
||||
const endPosition = editor?.state.doc.content.size;
|
||||
editor?.chain().insertContentAt(endPosition, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
|
||||
|
||||
// Focus the newly added paragraph for immediate editing
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(endPosition + 1)
|
||||
.run();
|
||||
// Check if its last node and add new node
|
||||
if (lastNode) {
|
||||
const isLastNodeEmptyParagraph = lastNode.type.name === CORE_EXTENSIONS.PARAGRAPH && lastNode.content.size === 0;
|
||||
// Only insert a new paragraph if the last node is not an empty paragraph and not a doc node
|
||||
if (!isLastNodeEmptyParagraph && lastNode.type.name !== "doc") {
|
||||
const endPosition = editor?.state.doc.content.size;
|
||||
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).focus("end").run();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
|
||||
|
||||
@@ -26,6 +26,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
id,
|
||||
initialValue,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
@@ -44,6 +45,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
enableHistory: true,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
id,
|
||||
initialValue,
|
||||
|
||||
@@ -4,23 +4,25 @@ import { EditorWrapper } from "@/components/editors/editor-wrapper";
|
||||
// extensions
|
||||
import { EnterKeyExtension } from "@/extensions";
|
||||
// types
|
||||
import { EditorRefApi, ILiteTextEditor } from "@/types";
|
||||
import { EditorRefApi, ILiteTextEditorProps } from "@/types";
|
||||
|
||||
const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
const LiteTextEditor: React.FC<ILiteTextEditorProps> = (props) => {
|
||||
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
...externalExtensions,
|
||||
...(disabledExtensions?.includes("enter-key") ? [] : [EnterKeyExtension(onEnterKeyPress)]),
|
||||
],
|
||||
[externalExtensions, disabledExtensions, onEnterKeyPress]
|
||||
);
|
||||
const extensions = useMemo(() => {
|
||||
const resolvedExtensions = [...externalExtensions];
|
||||
|
||||
if (!disabledExtensions?.includes("enter-key")) {
|
||||
resolvedExtensions.push(EnterKeyExtension(onEnterKeyPress));
|
||||
}
|
||||
|
||||
return resolvedExtensions;
|
||||
}, [externalExtensions, disabledExtensions, onEnterKeyPress]);
|
||||
|
||||
return <EditorWrapper {...props} extensions={extensions} />;
|
||||
};
|
||||
|
||||
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
|
||||
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditorProps>((props, ref) => (
|
||||
<LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { forwardRef } from "react";
|
||||
// components
|
||||
import { ReadOnlyEditorWrapper } from "@/components/editors";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor } from "@/types";
|
||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps } from "@/types";
|
||||
|
||||
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditor>((props, ref) => (
|
||||
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps>((props, ref) => (
|
||||
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
editorClassName = "",
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
id,
|
||||
initialValue,
|
||||
@@ -28,6 +29,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
|
||||
@@ -7,15 +7,16 @@ import { SideMenuExtension } from "@/extensions";
|
||||
// plane editor imports
|
||||
import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/extensions";
|
||||
// types
|
||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||
import { EditorRefApi, IRichTextEditorProps } from "@/types";
|
||||
|
||||
const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
|
||||
const {
|
||||
bubbleMenuEnabled = true,
|
||||
disabledExtensions,
|
||||
dragDropEnabled,
|
||||
fileHandler,
|
||||
bubbleMenuEnabled = true,
|
||||
extensions: externalExtensions = [],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
} = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
@@ -28,11 +29,12 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
...RichTextEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
}),
|
||||
];
|
||||
|
||||
return extensions;
|
||||
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler]);
|
||||
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions]);
|
||||
|
||||
return (
|
||||
<EditorWrapper {...props} extensions={getExtensions()}>
|
||||
@@ -41,7 +43,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
);
|
||||
};
|
||||
|
||||
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditor>((props, ref) => (
|
||||
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditorProps>((props, ref) => (
|
||||
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -2,23 +2,22 @@ import { forwardRef, useCallback } from "react";
|
||||
// plane editor extensions
|
||||
import { RichTextReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/read-only-extensions";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types";
|
||||
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps } from "@/types";
|
||||
// local imports
|
||||
import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper";
|
||||
|
||||
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => {
|
||||
const { disabledExtensions, fileHandler } = props;
|
||||
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps>((props, ref) => {
|
||||
const { disabledExtensions, fileHandler, flaggedExtensions } = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
const extensions = [
|
||||
...RichTextReadOnlyEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
}),
|
||||
];
|
||||
const extensions = RichTextReadOnlyEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
});
|
||||
|
||||
return extensions;
|
||||
}, [disabledExtensions, fileHandler]);
|
||||
}, [disabledExtensions, fileHandler, flaggedExtensions]);
|
||||
|
||||
return (
|
||||
<ReadOnlyEditorWrapper
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
BubbleMenuLinkSelector,
|
||||
BubbleMenuNodeSelector,
|
||||
CodeItem,
|
||||
EditorMenuItem,
|
||||
ItalicItem,
|
||||
StrikeThroughItem,
|
||||
TextAlignItem,
|
||||
@@ -23,6 +24,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
// local components
|
||||
import { TextAlignmentSelector } from "./alignment-selector";
|
||||
import { TEditorCommands } from "@/types";
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
||||
|
||||
@@ -31,19 +33,19 @@ export interface EditorStateType {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strike: boolean;
|
||||
strikethrough: boolean;
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
center: boolean;
|
||||
color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined;
|
||||
backgroundColor:
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
| undefined;
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Editor }) => {
|
||||
@@ -58,8 +60,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
bold: BoldItem(props.editor),
|
||||
italic: ItalicItem(props.editor),
|
||||
underline: UnderLineItem(props.editor),
|
||||
strike: StrikeThroughItem(props.editor),
|
||||
textAlign: TextAlignItem(props.editor),
|
||||
strikethrough: StrikeThroughItem(props.editor),
|
||||
"text-align": TextAlignItem(props.editor),
|
||||
} satisfies {
|
||||
[K in TEditorCommands]?: EditorMenuItem<K>;
|
||||
};
|
||||
|
||||
const editorState: EditorStateType = useEditorState({
|
||||
@@ -69,10 +73,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
bold: formattingItems.bold.isActive(),
|
||||
italic: formattingItems.italic.isActive(),
|
||||
underline: formattingItems.underline.isActive(),
|
||||
strike: formattingItems.strike.isActive(),
|
||||
left: formattingItems.textAlign.isActive({ alignment: "left" }),
|
||||
right: formattingItems.textAlign.isActive({ alignment: "right" }),
|
||||
center: formattingItems.textAlign.isActive({ alignment: "center" }),
|
||||
strikethrough: formattingItems.strikethrough.isActive(),
|
||||
left: formattingItems["text-align"].isActive({ alignment: "left" }),
|
||||
right: formattingItems["text-align"].isActive({ alignment: "right" }),
|
||||
center: formattingItems["text-align"].isActive({ alignment: "center" }),
|
||||
color: COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })),
|
||||
backgroundColor: COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })),
|
||||
}),
|
||||
@@ -80,7 +84,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
|
||||
const basicFormattingOptions = editorState.code
|
||||
? [formattingItems.code]
|
||||
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike];
|
||||
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
|
||||
@@ -12,10 +12,10 @@ import { CustomCalloutExtensionConfig } from "./callout/extension-config";
|
||||
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
|
||||
import { CustomCodeInlineExtension } from "./code-inline";
|
||||
import { CustomColorExtension } from "./custom-color";
|
||||
import { CustomImageExtensionConfig } from "./custom-image/extension-config";
|
||||
import { CustomLinkExtension } from "./custom-link";
|
||||
import { CustomHorizontalRule } from "./horizontal-rule";
|
||||
import { ImageExtensionWithoutProps } from "./image";
|
||||
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
|
||||
import { ImageExtensionConfig } from "./image";
|
||||
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
@@ -72,12 +72,8 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
ImageExtensionWithoutProps.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomImageComponentWithoutProps,
|
||||
ImageExtensionConfig,
|
||||
CustomImageExtensionConfig,
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
|
||||
+27
-53
@@ -1,68 +1,42 @@
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// extensions
|
||||
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
|
||||
// local imports
|
||||
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
|
||||
import { ensurePixelString } from "../utils";
|
||||
import type { CustomImageNodeViewProps } from "./node-view";
|
||||
import { ImageToolbarRoot } from "./toolbar";
|
||||
import { ImageUploadStatus } from "./upload-status";
|
||||
|
||||
const MIN_SIZE = 100;
|
||||
|
||||
type Pixel = `${number}px`;
|
||||
|
||||
type PixelAttribute<TDefault> = Pixel | TDefault;
|
||||
|
||||
export type ImageAttributes = {
|
||||
src: string | null;
|
||||
width: PixelAttribute<"35%" | number>;
|
||||
height: PixelAttribute<"auto" | number>;
|
||||
aspectRatio: number | null;
|
||||
id: string | null;
|
||||
};
|
||||
|
||||
type Size = {
|
||||
width: PixelAttribute<"35%">;
|
||||
height: PixelAttribute<"auto">;
|
||||
aspectRatio: number | null;
|
||||
};
|
||||
|
||||
const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => {
|
||||
if (!value || value === defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return `${value}px` satisfies Pixel;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
type CustomImageBlockProps = CustomBaseImageNodeViewProps & {
|
||||
imageFromFileSystem: string | undefined;
|
||||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
type CustomImageBlockProps = CustomImageNodeViewProps & {
|
||||
editorContainer: HTMLDivElement | null;
|
||||
imageFromFileSystem: string | undefined;
|
||||
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
|
||||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
src: string | undefined;
|
||||
};
|
||||
|
||||
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
// props
|
||||
const {
|
||||
node,
|
||||
updateAttributes,
|
||||
setFailedToLoadImage,
|
||||
imageFromFileSystem,
|
||||
selected,
|
||||
getPos,
|
||||
editor,
|
||||
editorContainer,
|
||||
src: resolvedImageSrc,
|
||||
extension,
|
||||
getPos,
|
||||
imageFromFileSystem,
|
||||
node,
|
||||
selected,
|
||||
setEditorContainer,
|
||||
setFailedToLoadImage,
|
||||
src: resolvedImageSrc,
|
||||
updateAttributes,
|
||||
} = props;
|
||||
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
|
||||
// states
|
||||
const [size, setSize] = useState<Size>({
|
||||
const [size, setSize] = useState<TCustomImageSize>({
|
||||
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
|
||||
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
|
||||
aspectRatio: nodeAspectRatio || null,
|
||||
@@ -77,7 +51,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
|
||||
|
||||
const updateAttributesSafely = useCallback(
|
||||
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
|
||||
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
|
||||
try {
|
||||
updateAttributes(attributes);
|
||||
} catch (error) {
|
||||
@@ -114,7 +88,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
|
||||
const initialHeight = initialWidth / aspectRatioCalculated;
|
||||
|
||||
const initialComputedSize = {
|
||||
const initialComputedSize: TCustomImageSize = {
|
||||
width: `${Math.round(initialWidth)}px` satisfies Pixel,
|
||||
height: `${Math.round(initialHeight)}px` satisfies Pixel,
|
||||
aspectRatio: aspectRatioCalculated,
|
||||
@@ -139,7 +113,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
}
|
||||
}
|
||||
setInitialResizeComplete(true);
|
||||
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
|
||||
}, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]);
|
||||
|
||||
// for real time resizing
|
||||
useLayoutEffect(() => {
|
||||
@@ -168,7 +142,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
|
||||
}, [size, updateAttributes]);
|
||||
}, [size, updateAttributesSafely]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -242,7 +216,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
onLoad={handleImageLoad}
|
||||
onError={async (e) => {
|
||||
// for old image extension this command doesn't exist or if the image failed to load for the first time
|
||||
if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) {
|
||||
if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
|
||||
setFailedToLoadImage(true);
|
||||
return;
|
||||
}
|
||||
@@ -253,7 +227,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
if (!imgNodeSrc) {
|
||||
throw new Error("No source image to restore from");
|
||||
}
|
||||
await editor?.commands.restoreImage?.(imgNodeSrc);
|
||||
await extension.options.restoreImage?.(imgNodeSrc);
|
||||
if (!imageRef.current) {
|
||||
throw new Error("Image reference not found");
|
||||
}
|
||||
@@ -289,10 +263,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
|
||||
}
|
||||
image={{
|
||||
src: resolvedImageSrc,
|
||||
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
|
||||
src: resolvedImageSrc,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./toolbar";
|
||||
export * from "./image-block";
|
||||
export * from "./image-node";
|
||||
export * from "./image-uploader";
|
||||
+22
-25
@@ -2,25 +2,26 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// local imports
|
||||
import type { CustomImageExtension, TCustomImageAttributes } from "../types";
|
||||
import { CustomImageBlock } from "./block";
|
||||
import { CustomImageUploader } from "./uploader";
|
||||
|
||||
export type CustomBaseImageNodeViewProps = {
|
||||
export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
|
||||
extension: CustomImageExtension;
|
||||
getPos: () => number;
|
||||
editor: Editor;
|
||||
node: NodeViewProps["node"] & {
|
||||
attrs: ImageAttributes;
|
||||
attrs: TCustomImageAttributes;
|
||||
};
|
||||
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
|
||||
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps;
|
||||
|
||||
export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
const { getPos, editor, node, updateAttributes, selected } = props;
|
||||
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
|
||||
const { editor, extension, node } = props;
|
||||
const { src: imgNodeSrc } = node.attrs;
|
||||
|
||||
const [isUploaded, setIsUploaded] = useState(false);
|
||||
@@ -50,41 +51,37 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
}, [resolvedSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imgNodeSrc) {
|
||||
setResolvedSrc(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const getImageSource = async () => {
|
||||
// @ts-expect-error function not expected here, but will still work and don't remove await
|
||||
const url: string = await editor?.commands?.getImageSource?.(imgNodeSrc);
|
||||
setResolvedSrc(url as string);
|
||||
const url = await extension.options.getImageSource?.(imgNodeSrc);
|
||||
setResolvedSrc(url);
|
||||
};
|
||||
getImageSource();
|
||||
}, [imgNodeSrc]);
|
||||
}, [imgNodeSrc, extension.options]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
|
||||
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
|
||||
<CustomImageBlock
|
||||
imageFromFileSystem={imageFromFileSystem}
|
||||
editorContainer={editorContainer}
|
||||
editor={editor}
|
||||
src={resolvedSrc}
|
||||
getPos={getPos}
|
||||
node={node}
|
||||
imageFromFileSystem={imageFromFileSystem}
|
||||
setEditorContainer={setEditorContainer}
|
||||
setFailedToLoadImage={setFailedToLoadImage}
|
||||
selected={selected}
|
||||
updateAttributes={updateAttributes}
|
||||
src={resolvedSrc}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<CustomImageUploader
|
||||
editor={editor}
|
||||
failedToLoadImage={failedToLoadImage}
|
||||
getPos={getPos}
|
||||
loadImageFromFileSystem={setImageFromFileSystem}
|
||||
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
|
||||
node={node}
|
||||
setIsUploaded={setIsUploaded}
|
||||
selected={selected}
|
||||
updateAttributes={updateAttributes}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
image: {
|
||||
src: string;
|
||||
height: string;
|
||||
width: string;
|
||||
height: string;
|
||||
aspectRatio: number;
|
||||
src: string;
|
||||
};
|
||||
isOpen: boolean;
|
||||
toggleFullScreenMode: (val: boolean) => void;
|
||||
@@ -189,7 +189,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||
<>
|
||||
<div
|
||||
className={cn("fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none transition-opacity", {
|
||||
"opacity-100 pointer-events-auto": isFullScreenEnabled,
|
||||
"opacity-100 pointer-events-auto editor-image-full-screen-modal": isFullScreenEnabled,
|
||||
"cursor-default": !isDragging,
|
||||
"cursor-grabbing": isDragging,
|
||||
})}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
// local imports
|
||||
import { ImageFullScreenAction } from "./full-screen";
|
||||
|
||||
type Props = {
|
||||
containerClassName?: string;
|
||||
image: {
|
||||
src: string;
|
||||
height: string;
|
||||
width: string;
|
||||
height: string;
|
||||
aspectRatio: number;
|
||||
src: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+13
-11
@@ -1,28 +1,30 @@
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { EFileError } from "@/helpers/file";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// hooks
|
||||
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||
// local imports
|
||||
import { getImageComponentImageFileMap } from "../utils";
|
||||
import type { CustomImageNodeViewProps } from "./node-view";
|
||||
|
||||
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & {
|
||||
maxFileSize: number;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
type CustomImageUploaderProps = CustomImageNodeViewProps & {
|
||||
failedToLoadImage: boolean;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
maxFileSize: number;
|
||||
setIsUploaded: (isUploaded: boolean) => void;
|
||||
};
|
||||
|
||||
export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
const {
|
||||
editor,
|
||||
extension,
|
||||
failedToLoadImage,
|
||||
getPos,
|
||||
loadImageFromFileSystem,
|
||||
@@ -71,12 +73,13 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
|
||||
);
|
||||
|
||||
const uploadImageEditorCommand = useCallback(
|
||||
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file),
|
||||
[editor, imageEntityId]
|
||||
async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
|
||||
[extension.options, imageEntityId]
|
||||
);
|
||||
|
||||
const handleProgressStatus = useCallback(
|
||||
@@ -93,7 +96,6 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
// hooks
|
||||
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
|
||||
editorCommand: uploadImageEditorCommand,
|
||||
handleProgressStatus,
|
||||
loadFileFromFileSystem: loadImageFromFileSystem,
|
||||
@@ -128,7 +130,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
|
||||
}
|
||||
}
|
||||
}, [meta, uploadFile, imageComponentImageFileMap]);
|
||||
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);
|
||||
|
||||
const onFileChange = useCallback(
|
||||
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -163,7 +165,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
}
|
||||
|
||||
return "Add an image";
|
||||
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
|
||||
}, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1,180 +0,0 @@
|
||||
import { Editor, mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { isFileValid } from "@/helpers/file";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
export type InsertImageComponentProps = {
|
||||
file?: File;
|
||||
pos?: number;
|
||||
event: "insert" | "drop";
|
||||
};
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
|
||||
getImageSource?: (path: string) => () => Promise<string>;
|
||||
restoreImage: (src: string) => () => Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
|
||||
|
||||
export interface CustomImageExtensionStorage {
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
maxFileSize: number;
|
||||
}
|
||||
|
||||
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
||||
|
||||
export const CustomImageExtension = (props: TFileHandler) => {
|
||||
const {
|
||||
getAssetSrc,
|
||||
upload,
|
||||
restore: restoreImageFn,
|
||||
validation: { maxFileSize },
|
||||
} = props;
|
||||
|
||||
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertImageComponent:
|
||||
(props) =>
|
||||
({ commands }) => {
|
||||
// Early return if there's an invalid file being dropped
|
||||
if (
|
||||
props?.file &&
|
||||
!isFileValid({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
file: props.file,
|
||||
maxFileSize,
|
||||
onError: (_error, message) => alert(message),
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// generate a unique id for the image to keep track of dropped
|
||||
// files' file data
|
||||
const fileId = uuidv4();
|
||||
|
||||
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
|
||||
|
||||
if (imageComponentImageFileMap) {
|
||||
if (props?.event === "drop" && props.file) {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
file: props.file,
|
||||
event: props.event,
|
||||
});
|
||||
} else if (props.event === "insert") {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
event: props.event,
|
||||
hasOpenedFileInputOnce: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
id: fileId,
|
||||
};
|
||||
|
||||
if (props.pos) {
|
||||
return commands.insertContentAt(props.pos, {
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
}
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
uploadImage: (blockId, file) => async () => {
|
||||
const fileUrl = await upload(blockId, file);
|
||||
return fileUrl;
|
||||
},
|
||||
getImageSource: (path) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src) => async () => {
|
||||
await restoreImageFn(src);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { type CustomImageExtension, ECustomImageAttributeNames, type InsertImageComponentProps } from "./types";
|
||||
import { DEFAULT_CUSTOM_IMAGE_ATTRIBUTES } from "./utils";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomImageExtensionConfig: CustomImageExtension = BaseImageExtension.extend({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
const attributes = {
|
||||
...this.parent?.(),
|
||||
...Object.values(ECustomImageAttributeNames).reduce((acc, value) => {
|
||||
acc[value] = {
|
||||
default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value],
|
||||
};
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
return attributes;
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
// helpers
|
||||
import { isFileValid } from "@/helpers/file";
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// types
|
||||
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageNodeView } from "./components/node-view";
|
||||
import { CustomImageExtensionConfig } from "./extension-config";
|
||||
import { getImageComponentImageFileMap } from "./utils";
|
||||
|
||||
type Props = {
|
||||
fileHandler: TFileHandler | TReadOnlyFileHandler;
|
||||
isEditable: boolean;
|
||||
};
|
||||
|
||||
export const CustomImageExtension = (props: Props) => {
|
||||
const { fileHandler, isEditable } = props;
|
||||
// derived values
|
||||
const { getAssetSrc, restore: restoreImageFn } = fileHandler;
|
||||
|
||||
return CustomImageExtensionConfig.extend({
|
||||
selectable: isEditable,
|
||||
draggable: isEditable,
|
||||
|
||||
addOptions() {
|
||||
const upload = "upload" in fileHandler ? fileHandler.upload : undefined;
|
||||
|
||||
return {
|
||||
...this.parent?.(),
|
||||
getImageSource: getAssetSrc,
|
||||
restoreImage: restoreImageFn,
|
||||
uploadImage: upload,
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
|
||||
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertImageComponent:
|
||||
(props) =>
|
||||
({ commands }) => {
|
||||
// Early return if there's an invalid file being dropped
|
||||
if (
|
||||
props?.file &&
|
||||
!isFileValid({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
file: props.file,
|
||||
maxFileSize: this.storage.maxFileSize,
|
||||
onError: (_error, message) => alert(message),
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// generate a unique id for the image to keep track of dropped
|
||||
// files' file data
|
||||
const fileId = uuidv4();
|
||||
|
||||
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
|
||||
|
||||
if (imageComponentImageFileMap) {
|
||||
if (props?.event === "drop" && props.file) {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
file: props.file,
|
||||
event: props.event,
|
||||
});
|
||||
} else if (props.event === "insert") {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
event: props.event,
|
||||
hasOpenedFileInputOnce: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
id: fileId,
|
||||
};
|
||||
|
||||
if (props.pos) {
|
||||
return commands.insertContentAt(props.pos, {
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
}
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNodeView);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./components";
|
||||
export * from "./custom-image";
|
||||
export * from "./read-only-custom-image";
|
||||
@@ -1,79 +0,0 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// components
|
||||
import { CustomImageNode, CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
// types
|
||||
import { TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc, restore: restoreImageFn } = props;
|
||||
|
||||
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
selectable: false,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize: 0,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src) => async () => {
|
||||
await restoreImageFn(src);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Node } from "@tiptap/core";
|
||||
// types
|
||||
import type { TFileHandler } from "@/types";
|
||||
|
||||
export enum ECustomImageAttributeNames {
|
||||
ID = "id",
|
||||
WIDTH = "width",
|
||||
HEIGHT = "height",
|
||||
ASPECT_RATIO = "aspectRatio",
|
||||
SOURCE = "src",
|
||||
}
|
||||
|
||||
export type Pixel = `${number}px`;
|
||||
|
||||
export type PixelAttribute<TDefault> = Pixel | TDefault;
|
||||
|
||||
export type TCustomImageSize = {
|
||||
width: PixelAttribute<"35%">;
|
||||
height: PixelAttribute<"auto">;
|
||||
aspectRatio: number | null;
|
||||
};
|
||||
|
||||
export type TCustomImageAttributes = {
|
||||
[ECustomImageAttributeNames.ID]: string | null;
|
||||
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
|
||||
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
|
||||
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
|
||||
[ECustomImageAttributeNames.SOURCE]: string | null;
|
||||
};
|
||||
|
||||
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
||||
|
||||
export type InsertImageComponentProps = {
|
||||
file?: File;
|
||||
pos?: number;
|
||||
event: "insert" | "drop";
|
||||
};
|
||||
|
||||
export type CustomImageExtensionOptions = {
|
||||
getImageSource: TFileHandler["getAssetSrc"];
|
||||
restoreImage: TFileHandler["restore"];
|
||||
uploadImage?: TFileHandler["upload"];
|
||||
};
|
||||
|
||||
export type CustomImageExtensionStorage = {
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
maxFileSize: number;
|
||||
};
|
||||
|
||||
export type CustomImageExtension = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// local imports
|
||||
import { ECustomImageAttributeNames, type Pixel, type TCustomImageAttributes } from "./types";
|
||||
|
||||
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
|
||||
[ECustomImageAttributeNames.SOURCE]: null,
|
||||
[ECustomImageAttributeNames.ID]: null,
|
||||
[ECustomImageAttributeNames.WIDTH]: "35%",
|
||||
[ECustomImageAttributeNames.HEIGHT]: "auto",
|
||||
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
|
||||
};
|
||||
|
||||
export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
|
||||
|
||||
export const ensurePixelString = <TDefault>(
|
||||
value: Pixel | TDefault | number | undefined | null,
|
||||
defaultValue?: TDefault
|
||||
) => {
|
||||
if (!value || value === defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return `${value}px` satisfies Pixel;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
CustomCodeInlineExtension,
|
||||
CustomColorExtension,
|
||||
CustomHorizontalRule,
|
||||
CustomImageExtension,
|
||||
CustomKeymap,
|
||||
CustomLinkExtension,
|
||||
CustomMentionExtension,
|
||||
@@ -37,20 +36,29 @@ import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
|
||||
import type { IEditorProps } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageExtension } from "./custom-image/extension";
|
||||
|
||||
type TArguments = {
|
||||
disabledExtensions: TExtensions[];
|
||||
type TArguments = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler" | "placeholder" | "tabIndex"
|
||||
> & {
|
||||
enableHistory: boolean;
|
||||
fileHandler: TFileHandler;
|
||||
mentionHandler: TMentionHandler;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex, editable } = args;
|
||||
const {
|
||||
disabledExtensions,
|
||||
enableHistory,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
editable,
|
||||
} = args;
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
@@ -177,18 +185,20 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
CustomColorExtension,
|
||||
...CoreEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
fileHandler,
|
||||
}),
|
||||
];
|
||||
|
||||
if (!disabledExtensions.includes("image")) {
|
||||
extensions.push(
|
||||
ImageExtension(fileHandler).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
ImageExtension({
|
||||
fileHandler,
|
||||
}),
|
||||
CustomImageExtension(fileHandler)
|
||||
CustomImageExtension({
|
||||
fileHandler,
|
||||
isEditable: editable,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -1,6 +1,12 @@
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
// local imports
|
||||
import { CustomImageExtensionOptions } from "../custom-image/types";
|
||||
import { ImageExtensionStorage } from "./extension";
|
||||
|
||||
export const ImageExtensionWithoutProps = BaseImageExtension.extend({
|
||||
export const ImageExtensionConfig = BaseImageExtension.extend<
|
||||
Pick<CustomImageExtensionOptions, "getImageSource">,
|
||||
ImageExtensionStorage
|
||||
>({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
@@ -1,23 +1,33 @@
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
// helpers
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageNodeView } from "../custom-image/components/node-view";
|
||||
import { ImageExtensionConfig } from "./extension-config";
|
||||
|
||||
export type ImageExtensionStorage = {
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
};
|
||||
|
||||
export const ImageExtension = (fileHandler: TFileHandler) => {
|
||||
const {
|
||||
getAssetSrc,
|
||||
validation: { maxFileSize },
|
||||
} = fileHandler;
|
||||
type Props = {
|
||||
fileHandler: TFileHandler | TReadOnlyFileHandler;
|
||||
};
|
||||
|
||||
export const ImageExtension = (props: Props) => {
|
||||
const { fileHandler } = props;
|
||||
// derived values
|
||||
const { getAssetSrc } = fileHandler;
|
||||
|
||||
return ImageExtensionConfig.extend({
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
getImageSource: getAssetSrc,
|
||||
};
|
||||
},
|
||||
|
||||
return BaseImageExtension.extend<unknown, ImageExtensionStorage>({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
@@ -27,36 +37,17 @@ export const ImageExtension = (fileHandler: TFileHandler) => {
|
||||
|
||||
// storage to keep track of image states Map<src, isDeleted>
|
||||
addStorage() {
|
||||
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
|
||||
|
||||
return {
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||
};
|
||||
},
|
||||
|
||||
// render custom image node
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
return ReactNodeViewRenderer(CustomImageNodeView);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
// local imports
|
||||
import { ImageExtensionStorage } from "./extension";
|
||||
|
||||
export const CustomImageComponentWithoutProps = BaseImageExtension.extend<
|
||||
Record<string, unknown>,
|
||||
ImageExtensionStorage
|
||||
>({
|
||||
name: "imageComponent",
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./extension";
|
||||
export * from "./image-extension-without-props";
|
||||
export * from "./read-only-image";
|
||||
export * from "./extension-config";
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
// types
|
||||
import { TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
export const ReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc } = props;
|
||||
|
||||
return BaseImageExtension.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
export * from "./callout";
|
||||
export * from "./code";
|
||||
export * from "./code-inline";
|
||||
export * from "./custom-image";
|
||||
export * from "./custom-link";
|
||||
export * from "./custom-list-keymap";
|
||||
export * from "./image";
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
CustomHorizontalRule,
|
||||
CustomLinkExtension,
|
||||
CustomTypographyExtension,
|
||||
ReadOnlyImageExtension,
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
TableHeader,
|
||||
@@ -20,27 +19,25 @@ import {
|
||||
TableRow,
|
||||
Table,
|
||||
CustomMentionExtension,
|
||||
CustomReadOnlyImageExtension,
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutReadOnlyExtension,
|
||||
CustomColorExtension,
|
||||
UtilityExtension,
|
||||
ImageExtension,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// plane editor extensions
|
||||
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import { TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
import type { IReadOnlyEditorProps } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageExtension } from "./custom-image/extension";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
};
|
||||
type Props = Pick<IReadOnlyEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler">;
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
const { disabledExtensions, fileHandler, mentionHandler } = props;
|
||||
const { disabledExtensions, fileHandler, flaggedExtensions, mentionHandler } = props;
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
@@ -133,17 +130,19 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
}),
|
||||
...CoreReadOnlyEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
}),
|
||||
];
|
||||
|
||||
if (!disabledExtensions.includes("image")) {
|
||||
extensions.push(
|
||||
ReadOnlyImageExtension(fileHandler).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
ImageExtension({
|
||||
fileHandler,
|
||||
}),
|
||||
CustomReadOnlyImageExtension(fileHandler)
|
||||
CustomImageExtension({
|
||||
fileHandler,
|
||||
isEditable: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export type TSlashCommandSection = {
|
||||
export const getSlashCommandFilteredSections =
|
||||
(args: TExtensionProps) =>
|
||||
({ query }: { query: string }): TSlashCommandSection[] => {
|
||||
const { additionalOptions: externalAdditionalOptions, disabledExtensions } = args;
|
||||
const { additionalOptions: externalAdditionalOptions, disabledExtensions, flaggedExtensions } = args;
|
||||
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
|
||||
{
|
||||
key: "general",
|
||||
@@ -290,6 +290,7 @@ export const getSlashCommandFilteredSections =
|
||||
...(externalAdditionalOptions ?? []),
|
||||
...coreEditorAdditionalSlashCommandOptions({
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
}),
|
||||
]?.forEach((item) => {
|
||||
const sectionToPushTo = SLASH_COMMAND_SECTIONS.find((s) => s.key === item.section) ?? SLASH_COMMAND_SECTIONS[0];
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { CommandListInstance } from "@/helpers/tippy";
|
||||
// types
|
||||
import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types";
|
||||
import { IEditorProps, ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types";
|
||||
// components
|
||||
import { getSlashCommandFilteredSections } from "./command-items-list";
|
||||
import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu";
|
||||
@@ -106,9 +106,8 @@ const renderItems = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export type TExtensionProps = {
|
||||
export type TExtensionProps = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions"> & {
|
||||
additionalOptions?: TSlashCommandAdditionalOption[];
|
||||
disabledExtensions?: TExtensions[];
|
||||
};
|
||||
|
||||
export const SlashCommands = (props: TExtensionProps) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DropHandlerPlugin } from "@/plugins/drop";
|
||||
import { FilePlugins } from "@/plugins/file/root";
|
||||
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
|
||||
// types
|
||||
import { TExtensions, TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
import type { IEditorProps, TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands {
|
||||
@@ -23,8 +23,7 @@ export interface UtilityExtensionStorage {
|
||||
uploadInProgress: boolean;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
type Props = Pick<IEditorProps, "disabledExtensions"> & {
|
||||
fileHandler: TFileHandler | TReadOnlyFileHandler;
|
||||
isEditable: boolean;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user