Compare commits

..
Author SHA1 Message Date
Aaron Reisman 409b2b78f1 chore: enum to object lookups 2025-06-12 23:00:11 -07:00
862 changed files with 6395 additions and 6481 deletions
+7 -5
View File
@@ -10,11 +10,13 @@ type Props = {
handleClose: () => void;
};
enum ESendEmailSteps {
SEND_EMAIL = "SEND_EMAIL",
SUCCESS = "SUCCESS",
FAILED = "FAILED",
}
const ESendEmailSteps = {
SEND_EMAIL: "SEND_EMAIL",
SUCCESS: "SUCCESS",
FAILED: "FAILED",
} as const;
type ESendEmailSteps = typeof ESendEmailSteps[keyof typeof ESendEmailSteps];
const instanceService = new InstanceService();
+6 -4
View File
@@ -67,8 +67,9 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.length >= 0 && (
<div>
<Breadcrumbs>
<Breadcrumbs.Item
component={
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href="/general/"
label="Settings"
@@ -79,9 +80,10 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.map(
(item) =>
item.title && (
<Breadcrumbs.Item
<Breadcrumbs.BreadcrumbItem
key={item.title}
component={<BreadcrumbLink href={item.href} label={item.title} />}
type="text"
link={<BreadcrumbLink href={item.href} label={item.title} />}
/>
)
)}
@@ -1,11 +1,11 @@
import { FC } from "react";
import { Info, X } from "lucide-react";
// plane constants
import { TAdminAuthErrorInfo } from "@plane/constants";
import { TAuthErrorInfo } from "@plane/constants";
type TAuthBanner = {
bannerData: TAdminAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void;
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
+11 -9
View File
@@ -16,14 +16,16 @@ import { Banner, PasswordStrengthMeter } from "@/components/common";
const authService = new AuthService();
// error codes
enum EErrorCodes {
INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED",
ADMIN_ALREADY_EXIST = "ADMIN_ALREADY_EXIST",
REQUIRED_EMAIL_PASSWORD_FIRST_NAME = "REQUIRED_EMAIL_PASSWORD_FIRST_NAME",
INVALID_EMAIL = "INVALID_EMAIL",
INVALID_PASSWORD = "INVALID_PASSWORD",
USER_ALREADY_EXISTS = "USER_ALREADY_EXISTS",
}
const EErrorCodes = {
INSTANCE_NOT_CONFIGURED: "INSTANCE_NOT_CONFIGURED",
ADMIN_ALREADY_EXIST: "ADMIN_ALREADY_EXIST",
REQUIRED_EMAIL_PASSWORD_FIRST_NAME: "REQUIRED_EMAIL_PASSWORD_FIRST_NAME",
INVALID_EMAIL: "INVALID_EMAIL",
INVALID_PASSWORD: "INVALID_PASSWORD",
USER_ALREADY_EXISTS: "USER_ALREADY_EXISTS",
} as const;
type EErrorCodes = typeof EErrorCodes[keyof typeof EErrorCodes];
type TError = {
type: EErrorCodes | undefined;
@@ -144,7 +146,7 @@ export const InstanceSetupForm: FC = (props) => {
{errorData.type &&
errorData?.message &&
![EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD].includes(errorData.type) && (
!([EErrorCodes.INVALID_EMAIL, EErrorCodes.INVALID_PASSWORD] as EErrorCodes[]).includes(errorData.type) && (
<Banner type="error" message={errorData?.message} />
)}
+11 -9
View File
@@ -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, TAdminAuthErrorInfo } from "@plane/constants";
import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Input, Spinner } from "@plane/ui";
// components
@@ -18,13 +18,15 @@ import { AuthBanner } from "../authentication";
const authService = new AuthService();
// error codes
enum EErrorCodes {
INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED",
REQUIRED_EMAIL_PASSWORD = "REQUIRED_EMAIL_PASSWORD",
INVALID_EMAIL = "INVALID_EMAIL",
USER_DOES_NOT_EXIST = "USER_DOES_NOT_EXIST",
AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED",
}
const EErrorCodes = {
INSTANCE_NOT_CONFIGURED: "INSTANCE_NOT_CONFIGURED",
REQUIRED_EMAIL_PASSWORD: "REQUIRED_EMAIL_PASSWORD",
INVALID_EMAIL: "INVALID_EMAIL",
USER_DOES_NOT_EXIST: "USER_DOES_NOT_EXIST",
AUTHENTICATION_FAILED: "AUTHENTICATION_FAILED",
} as const;
type EErrorCodes = typeof EErrorCodes[keyof typeof EErrorCodes];
type TError = {
type: EErrorCodes | undefined;
@@ -54,7 +56,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<TAdminAuthErrorInfo | undefined>(undefined);
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
+11 -9
View File
@@ -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, TAdminAuthErrorInfo } from "@plane/constants";
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import { resolveGeneralTheme } from "@plane/utils";
// components
@@ -20,13 +20,15 @@ import githubDarkModeImage from "@/public/logos/github-white.png";
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
import GoogleLogo from "@/public/logos/google-logo.svg";
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
INLINE_EMAIL = "INLINE_EMAIL",
INLINE_PASSWORD = "INLINE_PASSWORD",
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
}
export const EErrorAlertType = {
BANNER_ALERT: "BANNER_ALERT",
INLINE_FIRST_NAME: "INLINE_FIRST_NAME",
INLINE_EMAIL: "INLINE_EMAIL",
INLINE_PASSWORD: "INLINE_PASSWORD",
INLINE_EMAIL_CODE: "INLINE_EMAIL_CODE",
} as const;
export type EErrorAlertType = typeof EErrorAlertType[keyof typeof EErrorAlertType];
const errorCodeMessages: {
[key in EAdminAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };
@@ -89,7 +91,7 @@ const errorCodeMessages: {
export const authErrorHandler = (
errorCode: EAdminAuthErrorCodes,
email?: string | undefined
): TAdminAuthErrorInfo | undefined => {
): TAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
+1 -1
View File
@@ -31,7 +31,7 @@
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "14.2.30",
"next": "^14.2.29",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
+3 -8
View File
@@ -1,5 +1,7 @@
# 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
@@ -196,7 +198,6 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField()
assignees = serializers.SerializerMethodField()
class Meta:
model = Issue
@@ -214,14 +215,8 @@ 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
)
)
return project.identifier if project else None
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
+3 -3
View File
@@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
urlpatterns = [
# API Tokens
path(
"users/api-tokens/",
"workspaces/<str:slug>/api-tokens/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
path(
"users/api-tokens/<uuid:pk>/",
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
ApiTokenEndpoint.as_view(),
name="api-tokens-details",
name="api-tokens",
),
path(
"workspaces/<str:slug>/service-api-tokens/",
-12
View File
@@ -13,8 +13,6 @@ from plane.app.views import (
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
@@ -91,14 +89,4 @@ 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",
),
]
-2
View File
@@ -107,8 +107,6 @@ from .asset.v2 import (
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
from .issue.base import (
IssueListEndpoint,
+19 -11
View File
@@ -1,10 +1,8 @@
# 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
@@ -15,9 +13,12 @@ from plane.app.permissions import WorkspaceEntityPermission
class ApiTokenEndpoint(BaseAPIView):
def post(self, request: Request) -> Response:
permission_classes = [WorkspaceEntityPermission]
def post(self, request, slug):
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
@@ -27,6 +28,7 @@ class ApiTokenEndpoint(BaseAPIView):
label=label,
description=description,
user=request.user,
workspace=workspace,
user_type=user_type,
expired_at=expired_at,
)
@@ -35,23 +37,29 @@ class ApiTokenEndpoint(BaseAPIView):
# Token will be only visible while creating
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request: Request, pk: Optional[str] = None) -> Response:
def get(self, request, slug, pk=None):
if pk is None:
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug, 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, pk=pk)
api_tokens = APIToken.objects.get(
user=request.user, workspace__slug=slug, pk=pk
)
serializer = APITokenReadSerializer(api_tokens)
return Response(serializer.data, status=status.HTTP_200_OK)
def delete(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
def delete(self, request, slug, pk):
api_token = APIToken.objects.get(
workspace__slug=slug, user=request.user, pk=pk, is_service=False
)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk)
def patch(self, request, slug, pk):
api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk)
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
@@ -62,7 +70,7 @@ class ApiTokenEndpoint(BaseAPIView):
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def post(self, request: Request, slug: str) -> Response:
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(
-53
View File
@@ -718,56 +718,3 @@ 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)
+1 -26
View File
@@ -944,33 +944,9 @@ 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
project_member_subquery = ProjectMember.objects.filter(
project_id=OuterRef("project_id"),
member=self.request.user,
is_active=True,
).filter(
Q(role__gt=ROLE.GUEST.value)
| Q(
role=ROLE.GUEST.value, project__guest_view_all_features=True
)
)
# Main issue query
issue = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
.filter(
Q(Exists(project_member_subquery))
| 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,
)
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
@@ -1038,7 +1014,6 @@ class IssueDetailEndpoint(BaseAPIView):
.values("count")
)
)
issue = issue.filter(**filters)
order_by_param = request.GET.get("order_by", "-created_at")
# Issue queryset
+2 -8
View File
@@ -341,10 +341,7 @@ class ProjectViewSet(BaseViewSet):
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{
"name": "The project name is already taken",
"code": "PROJECT_NAME_ALREADY_EXIST",
},
{"name": "The project name is already taken"},
status=status.HTTP_409_CONFLICT,
)
except Workspace.DoesNotExist:
@@ -353,10 +350,7 @@ class ProjectViewSet(BaseViewSet):
)
except serializers.ValidationError:
return Response(
{
"identifier": "The project identifier is already taken",
"code": "PROJECT_IDENTIFIER_ALREADY_EXIST",
},
{"identifier": "The project identifier is already taken"},
status=status.HTTP_409_CONFLICT,
)
+3 -45
View File
@@ -27,7 +27,7 @@ def user_data():
"email": "[email protected]",
"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,52 +69,10 @@ 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
@@ -1,372 +0,0 @@
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
@@ -1,75 +0,0 @@
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="[email protected]", first_name="Test", last_name="User"
)
# To test for deleted issue assignee
test_user_2 = User.objects.create(
email="[email protected]",
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]
-1
View File
@@ -27,7 +27,6 @@
"turbo": "^2.5.4"
},
"resolutions": {
"brace-expansion": "2.0.2",
"nanoid": "3.3.8",
"esbuild": "0.25.0",
"@babel/helpers": "7.26.10",
+5 -3
View File
@@ -1,3 +1,5 @@
export enum AI_EDITOR_TASKS {
ASK_ANYTHING = "ASK_ANYTHING",
}
export const AI_EDITOR_TASKS = {
ASK_ANYTHING: "ASK_ANYTHING",
} as const;
export type AI_EDITOR_TASKS = typeof AI_EDITOR_TASKS[keyof typeof AI_EDITOR_TASKS];
+1 -1
View File
@@ -11,7 +11,7 @@ export interface IInsightField {
};
}
export const ANALYTICS_INSIGHTS_FIELDS: Record<TAnalyticsTabsBase, IInsightField[]> = {
export const insightsFields: Record<TAnalyticsTabsBase, IInsightField[]> = {
overview: [
{
key: "total_users",
+127 -118
View File
@@ -1,9 +1,11 @@
export enum E_PASSWORD_STRENGTH {
EMPTY = "empty",
LENGTH_NOT_VALID = "length_not_valid",
STRENGTH_NOT_VALID = "strength_not_valid",
STRENGTH_VALID = "strength_valid",
}
export const E_PASSWORD_STRENGTH = {
EMPTY: "empty",
LENGTH_NOT_VALID: "length_not_valid",
STRENGTH_NOT_VALID: "strength_not_valid",
STRENGTH_VALID: "strength_valid",
} as const;
export type E_PASSWORD_STRENGTH = typeof E_PASSWORD_STRENGTH[keyof typeof E_PASSWORD_STRENGTH];
export const PASSWORD_MIN_LENGTH = 8;
@@ -31,129 +33,136 @@ export const SPACE_PASSWORD_CRITERIA = [
// },
];
export enum EAuthPageTypes {
PUBLIC = "PUBLIC",
NON_AUTHENTICATED = "NON_AUTHENTICATED",
SET_PASSWORD = "SET_PASSWORD",
ONBOARDING = "ONBOARDING",
AUTHENTICATED = "AUTHENTICATED",
}
export const EAuthPageTypes = {
PUBLIC: "PUBLIC",
NON_AUTHENTICATED: "NON_AUTHENTICATED",
SET_PASSWORD: "SET_PASSWORD",
ONBOARDING: "ONBOARDING",
AUTHENTICATED: "AUTHENTICATED",
} as const;
export enum EPageTypes {
INIT = "INIT",
PUBLIC = "PUBLIC",
NON_AUTHENTICATED = "NON_AUTHENTICATED",
ONBOARDING = "ONBOARDING",
AUTHENTICATED = "AUTHENTICATED",
}
export type EAuthPageTypes = typeof EAuthPageTypes[keyof typeof EAuthPageTypes];
export enum EAuthModes {
SIGN_IN = "SIGN_IN",
SIGN_UP = "SIGN_UP",
}
export const EPageTypes = {
INIT: "INIT",
PUBLIC: "PUBLIC",
NON_AUTHENTICATED: "NON_AUTHENTICATED",
ONBOARDING: "ONBOARDING",
AUTHENTICATED: "AUTHENTICATED",
} as const;
export enum EAuthSteps {
EMAIL = "EMAIL",
PASSWORD = "PASSWORD",
UNIQUE_CODE = "UNIQUE_CODE",
}
export type EPageTypes = typeof EPageTypes[keyof typeof EPageTypes];
export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT",
TOAST_ALERT = "TOAST_ALERT",
INLINE_FIRST_NAME = "INLINE_FIRST_NAME",
INLINE_EMAIL = "INLINE_EMAIL",
INLINE_PASSWORD = "INLINE_PASSWORD",
INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE",
}
export const EAuthModes = {
SIGN_IN: "SIGN_IN",
SIGN_UP: "SIGN_UP",
} as const;
export type EAuthModes = typeof EAuthModes[keyof typeof EAuthModes];
export const EAuthSteps = {
EMAIL: "EMAIL",
PASSWORD: "PASSWORD",
UNIQUE_CODE: "UNIQUE_CODE",
} as const;
export type EAuthSteps = typeof EAuthSteps[keyof typeof EAuthSteps];
export const EErrorAlertType = {
BANNER_ALERT: "BANNER_ALERT",
TOAST_ALERT: "TOAST_ALERT",
INLINE_FIRST_NAME: "INLINE_FIRST_NAME",
INLINE_EMAIL: "INLINE_EMAIL",
INLINE_PASSWORD: "INLINE_PASSWORD",
INLINE_EMAIL_CODE: "INLINE_EMAIL_CODE",
} as const;
export type EErrorAlertType = typeof EErrorAlertType[keyof typeof EErrorAlertType];
export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAuthErrorCodes;
title: string;
message: any;
};
export enum EAdminAuthErrorCodes {
// Admin
ADMIN_ALREADY_EXIST = "5150",
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
INVALID_ADMIN_EMAIL = "5160",
INVALID_ADMIN_PASSWORD = "5165",
REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
ADMIN_AUTHENTICATION_FAILED = "5175",
ADMIN_USER_ALREADY_EXIST = "5180",
ADMIN_USER_DOES_NOT_EXIST = "5185",
ADMIN_USER_DEACTIVATED = "5190",
}
export type TAdminAuthErrorInfo = {
type: EErrorAlertType;
code: EAdminAuthErrorCodes;
title: string;
message: any;
};
export enum EAuthErrorCodes {
// Global
INSTANCE_NOT_CONFIGURED = "5000",
INVALID_EMAIL = "5005",
EMAIL_REQUIRED = "5010",
SIGNUP_DISABLED = "5015",
MAGIC_LINK_LOGIN_DISABLED = "5016",
PASSWORD_LOGIN_DISABLED = "5018",
USER_ACCOUNT_DEACTIVATED = "5019",
// Password strength
INVALID_PASSWORD = "5020",
SMTP_NOT_CONFIGURED = "5025",
// Sign Up
USER_ALREADY_EXIST = "5030",
AUTHENTICATION_FAILED_SIGN_UP = "5035",
REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040",
INVALID_EMAIL_SIGN_UP = "5045",
INVALID_EMAIL_MAGIC_SIGN_UP = "5050",
MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055",
// Sign In
USER_DOES_NOT_EXIST = "5060",
AUTHENTICATION_FAILED_SIGN_IN = "5065",
REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070",
INVALID_EMAIL_SIGN_IN = "5075",
INVALID_EMAIL_MAGIC_SIGN_IN = "5080",
MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085",
// Both Sign in and Sign up for magic
INVALID_MAGIC_CODE_SIGN_IN = "5090",
INVALID_MAGIC_CODE_SIGN_UP = "5092",
EXPIRED_MAGIC_CODE_SIGN_IN = "5095",
EXPIRED_MAGIC_CODE_SIGN_UP = "5097",
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN = "5100",
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP = "5102",
// Oauth
OAUTH_NOT_CONFIGURED = "5104",
GOOGLE_NOT_CONFIGURED = "5105",
GITHUB_NOT_CONFIGURED = "5110",
GITLAB_NOT_CONFIGURED = "5111",
GOOGLE_OAUTH_PROVIDER_ERROR = "5115",
GITHUB_OAUTH_PROVIDER_ERROR = "5120",
GITLAB_OAUTH_PROVIDER_ERROR = "5121",
// Reset Password
INVALID_PASSWORD_TOKEN = "5125",
EXPIRED_PASSWORD_TOKEN = "5130",
// Change password
INCORRECT_OLD_PASSWORD = "5135",
MISSING_PASSWORD = "5138",
INVALID_NEW_PASSWORD = "5140",
// set password
PASSWORD_ALREADY_SET = "5145",
export const EAdminAuthErrorCodes = {
// Admin
ADMIN_ALREADY_EXIST = "5150",
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155",
INVALID_ADMIN_EMAIL = "5160",
INVALID_ADMIN_PASSWORD = "5165",
REQUIRED_ADMIN_EMAIL_PASSWORD = "5170",
ADMIN_AUTHENTICATION_FAILED = "5175",
ADMIN_USER_ALREADY_EXIST = "5180",
ADMIN_USER_DOES_NOT_EXIST = "5185",
ADMIN_USER_DEACTIVATED = "5190",
ADMIN_ALREADY_EXIST: "5150",
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME: "5155",
INVALID_ADMIN_EMAIL: "5160",
INVALID_ADMIN_PASSWORD: "5165",
REQUIRED_ADMIN_EMAIL_PASSWORD: "5170",
ADMIN_AUTHENTICATION_FAILED: "5175",
ADMIN_USER_ALREADY_EXIST: "5180",
ADMIN_USER_DOES_NOT_EXIST: "5185",
ADMIN_USER_DEACTIVATED: "5190",
} as const;
export type EAdminAuthErrorCodes = typeof EAdminAuthErrorCodes[keyof typeof EAdminAuthErrorCodes];
export const EAuthErrorCodes = {
// Global
INSTANCE_NOT_CONFIGURED: "5000",
INVALID_EMAIL: "5005",
EMAIL_REQUIRED: "5010",
SIGNUP_DISABLED: "5015",
MAGIC_LINK_LOGIN_DISABLED: "5016",
PASSWORD_LOGIN_DISABLED: "5018",
USER_ACCOUNT_DEACTIVATED: "5019",
// Password strength
INVALID_PASSWORD: "5020",
SMTP_NOT_CONFIGURED: "5025",
// Sign Up
USER_ALREADY_EXIST: "5030",
AUTHENTICATION_FAILED_SIGN_UP: "5035",
REQUIRED_EMAIL_PASSWORD_SIGN_UP: "5040",
INVALID_EMAIL_SIGN_UP: "5045",
INVALID_EMAIL_MAGIC_SIGN_UP: "5050",
MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED: "5055",
// Sign In
USER_DOES_NOT_EXIST: "5060",
AUTHENTICATION_FAILED_SIGN_IN: "5065",
REQUIRED_EMAIL_PASSWORD_SIGN_IN: "5070",
INVALID_EMAIL_SIGN_IN: "5075",
INVALID_EMAIL_MAGIC_SIGN_IN: "5080",
MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED: "5085",
// Both Sign in and Sign up for magic
INVALID_MAGIC_CODE_SIGN_IN: "5090",
INVALID_MAGIC_CODE_SIGN_UP: "5092",
EXPIRED_MAGIC_CODE_SIGN_IN: "5095",
EXPIRED_MAGIC_CODE_SIGN_UP: "5097",
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN: "5100",
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP: "5102",
// Oauth
OAUTH_NOT_CONFIGURED: "5104",
GOOGLE_NOT_CONFIGURED: "5105",
GITHUB_NOT_CONFIGURED: "5110",
GITLAB_NOT_CONFIGURED: "5111",
GOOGLE_OAUTH_PROVIDER_ERROR: "5115",
GITHUB_OAUTH_PROVIDER_ERROR: "5120",
GITLAB_OAUTH_PROVIDER_ERROR: "5121",
// Reset Password
INVALID_PASSWORD_TOKEN: "5125",
EXPIRED_PASSWORD_TOKEN: "5130",
// Change password
INCORRECT_OLD_PASSWORD: "5135",
MISSING_PASSWORD: "5138",
INVALID_NEW_PASSWORD: "5140",
// set password
PASSWORD_ALREADY_SET: "5145",
// Admin
ADMIN_ALREADY_EXIST: "5150",
REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME: "5155",
INVALID_ADMIN_EMAIL: "5160",
INVALID_ADMIN_PASSWORD: "5165",
REQUIRED_ADMIN_EMAIL_PASSWORD: "5170",
ADMIN_AUTHENTICATION_FAILED: "5175",
ADMIN_USER_ALREADY_EXIST: "5180",
ADMIN_USER_DOES_NOT_EXIST: "5185",
ADMIN_USER_DEACTIVATED: "5190",
// Rate limit
RATE_LIMIT_EXCEEDED = "5900",
}
RATE_LIMIT_EXCEEDED: "5900",
} as const;
export type EAuthErrorCodes = typeof EAuthErrorCodes[keyof typeof EAuthErrorCodes];
+50 -42
View File
@@ -4,43 +4,49 @@ export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracki
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 const 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",
} as const;
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 type ChartXAxisProperty = typeof ChartXAxisProperty[keyof typeof ChartXAxisProperty];
export const 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",
} as const;
export type ChartYAxisMetric = typeof ChartYAxisMetric[keyof typeof ChartYAxisMetric];
export enum ChartXAxisDateGrouping {
DAY = "DAY",
WEEK = "WEEK",
MONTH = "MONTH",
YEAR = "YEAR",
}
export const ChartXAxisDateGrouping = {
DAY: "DAY",
WEEK: "WEEK",
MONTH: "MONTH",
YEAR: "YEAR",
} as const;
export type ChartXAxisDateGrouping = typeof ChartXAxisDateGrouping[keyof typeof ChartXAxisDateGrouping];
export const TO_CAPITALIZE_PROPERTIES: ChartXAxisProperty[] = [
ChartXAxisProperty.PRIORITY,
@@ -55,14 +61,16 @@ export const CHART_X_AXIS_DATE_PROPERTIES: ChartXAxisProperty[] = [
];
export enum EChartModels {
BASIC = "BASIC",
STACKED = "STACKED",
GROUPED = "GROUPED",
MULTI_LINE = "MULTI_LINE",
COMPARISON = "COMPARISON",
PROGRESS = "PROGRESS",
}
export const EChartModels = {
BASIC: "BASIC",
STACKED: "STACKED",
GROUPED: "GROUPED",
MULTI_LINE: "MULTI_LINE",
COMPARISON: "COMPARISON",
PROGRESS: "PROGRESS",
} as const;
export type EChartModels = typeof EChartModels[keyof typeof EChartModels];
export const CHART_COLOR_PALETTES: {
key: TChartColorScheme;
+10 -8
View File
@@ -1,14 +1,16 @@
// types
import { TIssuesListTypes } from "@plane/types";
export enum EDurationFilters {
NONE = "none",
TODAY = "today",
THIS_WEEK = "this_week",
THIS_MONTH = "this_month",
THIS_YEAR = "this_year",
CUSTOM = "custom",
}
export const EDurationFilters = {
NONE: "none",
TODAY: "today",
THIS_WEEK: "this_week",
THIS_MONTH: "this_month",
THIS_YEAR: "this_year",
CUSTOM: "custom",
} as const;
export type EDurationFilters = typeof EDurationFilters[keyof typeof EDurationFilters];
// filter duration options
export const DURATION_FILTER_OPTIONS: {
+9 -7
View File
@@ -1,26 +1,28 @@
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 || "[email protected]";
export const SUPPORT_EMAIL =
process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "[email protected]";
// marketing links
export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing";
export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact";
+6 -4
View File
@@ -1,7 +1,9 @@
export enum E_SORT_ORDER {
ASC = "asc",
DESC = "desc",
}
export const E_SORT_ORDER = {
ASC: "asc",
DESC: "desc",
} as const;
export type E_SORT_ORDER = typeof E_SORT_ORDER[keyof typeof E_SORT_ORDER];
export const DATE_AFTER_FILTER_OPTIONS = [
{
name: "1 week from now",
+9 -7
View File
@@ -1,7 +1,9 @@
export enum EIconSize {
XS = "xs",
SM = "sm",
MD = "md",
LG = "lg",
XL = "xl",
}
export const EIconSize = {
XS: "xs",
SM: "sm",
MD: "md",
LG: "lg",
XL: "xl",
} as const;
export type EIconSize = typeof EIconSize[keyof typeof EIconSize];
@@ -1,23 +1,29 @@
import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
export enum EInboxIssueCurrentTab {
OPEN = "open",
CLOSED = "closed",
}
export const EInboxIssueCurrentTab = {
OPEN: "open",
CLOSED: "closed",
} as const;
export enum EInboxIssueStatus {
PENDING = -2,
DECLINED = -1,
SNOOZED = 0,
ACCEPTED = 1,
DUPLICATE = 2,
}
export type EInboxIssueCurrentTab = typeof EInboxIssueCurrentTab[keyof typeof EInboxIssueCurrentTab];
export enum EInboxIssueSource {
IN_APP = "IN_APP",
FORMS = "FORMS",
EMAIL = "EMAIL",
}
export const EInboxIssueStatus = {
PENDING: -2,
DECLINED: -1,
SNOOZED: 0,
ACCEPTED: 1,
DUPLICATE: 2,
} as const;
export type EInboxIssueStatus = typeof EInboxIssueStatus[keyof typeof EInboxIssueStatus];
export const EInboxIssueSource = {
IN_APP: "IN_APP",
FORMS: "FORMS",
EMAIL: "EMAIL",
} as const;
export type EInboxIssueSource = typeof EInboxIssueSource[keyof typeof EInboxIssueSource];
export type TInboxIssueCurrentTab = EInboxIssueCurrentTab;
export type TInboxIssueStatus = EInboxIssueStatus;
@@ -95,32 +101,3 @@ 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,
},
];
+1 -2
View File
@@ -21,7 +21,7 @@ export * from "./module";
export * from "./project";
export * from "./views";
export * from "./themes";
export * from "./intake";
export * from "./inbox";
export * from "./profile";
export * from "./workspace-drafts";
export * from "./label";
@@ -33,5 +33,4 @@ export * from "./emoji";
export * from "./subscription";
export * from "./settings";
export * from "./icon";
export * from "./estimates";
export * from "./analytics";
+6 -4
View File
@@ -1,7 +1,9 @@
export enum EInstanceStatus {
ERROR = "ERROR",
NOT_YET_READY = "NOT_YET_READY",
}
export const EInstanceStatus = {
ERROR: "ERROR",
NOT_YET_READY: "NOT_YET_READY",
} as const;
export type EInstanceStatus = typeof EInstanceStatus[keyof typeof EInstanceStatus];
export type TInstanceStatus = {
status: EInstanceStatus | undefined;
+75 -63
View File
@@ -17,66 +17,78 @@ export type TIssueFilterPriorityObject = {
icon: string;
};
export enum EIssueGroupByToServerOptions {
"state" = "state_id",
"priority" = "priority",
"labels" = "labels__id",
"state_detail.group" = "state__group",
"assignees" = "assignees__id",
"cycle" = "cycle_id",
"module" = "issue_module__module_id",
"target_date" = "target_date",
"project" = "project_id",
"created_by" = "created_by",
"team_project" = "project_id",
}
export const EIssueGroupByToServerOptions = {
"state": "state_id",
"priority": "priority",
"labels": "labels__id",
"state_detail.group": "state__group",
"assignees": "assignees__id",
"cycle": "cycle_id",
"module": "issue_module__module_id",
"target_date": "target_date",
"project": "project_id",
"created_by": "created_by",
"team_project": "project_id",
} as const;
export enum EIssueGroupBYServerToProperty {
"state_id" = "state_id",
"priority" = "priority",
"labels__id" = "label_ids",
"state__group" = "state__group",
"assignees__id" = "assignee_ids",
"cycle_id" = "cycle_id",
"issue_module__module_id" = "module_ids",
"target_date" = "target_date",
"project_id" = "project_id",
"created_by" = "created_by",
}
export type EIssueGroupByToServerOptions = typeof EIssueGroupByToServerOptions[keyof typeof EIssueGroupByToServerOptions];
export enum EIssueServiceType {
ISSUES = "issues",
EPICS = "epics",
WORK_ITEMS = "work-items",
}
export const EIssueGroupBYServerToProperty = {
"state_id": "state_id",
"priority": "priority",
"labels__id": "label_ids",
"state__group": "state__group",
"assignees__id": "assignee_ids",
"cycle_id": "cycle_id",
"issue_module__module_id": "module_ids",
"target_date": "target_date",
"project_id": "project_id",
"created_by": "created_by",
} as const;
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 type EIssueGroupBYServerToProperty = typeof EIssueGroupBYServerToProperty[keyof typeof EIssueGroupBYServerToProperty];
export enum EIssueCommentAccessSpecifier {
EXTERNAL = "EXTERNAL",
INTERNAL = "INTERNAL",
}
export const EIssueServiceType = {
ISSUES: "issues",
EPICS: "epics",
WORK_ITEMS: "work-items",
} as const;
export enum EIssueListRow {
HEADER = "HEADER",
ISSUE = "ISSUE",
NO_ISSUES = "NO_ISSUES",
QUICK_ADD = "QUICK_ADD",
}
export type EIssueServiceType = typeof EIssueServiceType[keyof typeof EIssueServiceType];
export const 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",
} as const;
export type EIssuesStoreType = typeof EIssuesStoreType[keyof typeof EIssuesStoreType];
export const EIssueCommentAccessSpecifier = {
EXTERNAL: "EXTERNAL",
INTERNAL: "INTERNAL",
} as const;
export type EIssueCommentAccessSpecifier = typeof EIssueCommentAccessSpecifier[keyof typeof EIssueCommentAccessSpecifier];
export const EIssueListRow = {
HEADER: "HEADER",
ISSUE: "ISSUE",
NO_ISSUES: "NO_ISSUES",
QUICK_ADD: "QUICK_ADD",
} as const;
export type EIssueListRow = typeof EIssueListRow[keyof typeof EIssueListRow];
export const ISSUE_PRIORITIES: {
key: TIssuePriorities;
@@ -114,14 +126,14 @@ export const DRAG_ALLOWED_GROUPS: TIssueGroupByOptions[] = [
];
export type TCreateModalStoreTypes =
| EIssuesStoreType.TEAM
| EIssuesStoreType.PROJECT
| EIssuesStoreType.TEAM_VIEW
| EIssuesStoreType.PROJECT_VIEW
| EIssuesStoreType.PROFILE
| EIssuesStoreType.CYCLE
| EIssuesStoreType.MODULE
| EIssuesStoreType.EPIC;
| typeof EIssuesStoreType.TEAM
| typeof EIssuesStoreType.PROJECT
| typeof EIssuesStoreType.TEAM_VIEW
| typeof EIssuesStoreType.PROJECT_VIEW
| typeof EIssuesStoreType.PROFILE
| typeof EIssuesStoreType.CYCLE
| typeof EIssuesStoreType.MODULE
| typeof EIssuesStoreType.EPIC;
export const ISSUE_GROUP_BY_OPTIONS: {
key: TIssueGroupByOptions;
+28 -22
View File
@@ -10,25 +10,29 @@ import { TIssueLayout } from "./layout";
export type TIssueFilterKeys = "priority" | "state" | "labels";
export enum EServerGroupByToFilterOptions {
"state_id" = "state",
"priority" = "priority",
"labels__id" = "labels",
"state__group" = "state_group",
"assignees__id" = "assignees",
"cycle_id" = "cycle",
"issue_module__module_id" = "module",
"target_date" = "target_date",
"project_id" = "project",
"created_by" = "created_by",
}
export const EServerGroupByToFilterOptions = {
"state_id": "state",
"priority": "priority",
"labels__id": "labels",
"state__group": "state_group",
"assignees__id": "assignees",
"cycle_id": "cycle",
"issue_module__module_id": "module",
"target_date": "target_date",
"project_id": "project",
"created_by": "created_by",
} as const;
export enum EIssueFilterType {
FILTERS = "filters",
DISPLAY_FILTERS = "display_filters",
DISPLAY_PROPERTIES = "display_properties",
KANBAN_FILTERS = "kanban_filters",
}
export type EServerGroupByToFilterOptions = typeof EServerGroupByToFilterOptions[keyof typeof EServerGroupByToFilterOptions];
export const EIssueFilterType = {
FILTERS: "filters",
DISPLAY_FILTERS: "display_filters",
DISPLAY_PROPERTIES: "display_properties",
KANBAN_FILTERS: "kanban_filters",
} as const;
export type EIssueFilterType = typeof EIssueFilterType[keyof typeof EIssueFilterType];
export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
[key in TIssueLayout]: Record<"filters", TIssueFilterKeys[]>;
@@ -334,10 +338,12 @@ export const ISSUE_STORE_TO_FILTERS_MAP: Partial<Record<EIssuesStoreType, TFilte
[EIssuesStoreType.PROJECT]: ISSUE_DISPLAY_FILTERS_BY_PAGE.issues,
};
export enum EActivityFilterType {
ACTIVITY = "ACTIVITY",
COMMENT = "COMMENT",
}
export const EActivityFilterType = {
ACTIVITY: "ACTIVITY",
COMMENT: "COMMENT",
} as const;
export type EActivityFilterType = typeof EActivityFilterType[keyof typeof EActivityFilterType];
export type TActivityFilters = EActivityFilterType;
+9 -7
View File
@@ -5,13 +5,15 @@ export type TIssueLayout =
| "spreadsheet"
| "gantt";
export enum EIssueLayoutTypes {
LIST = "list",
KANBAN = "kanban",
CALENDAR = "calendar",
GANTT = "gantt_chart",
SPREADSHEET = "spreadsheet",
}
export const EIssueLayoutTypes = {
LIST: "list",
KANBAN: "kanban",
CALENDAR: "calendar",
GANTT: "gantt_chart",
SPREADSHEET: "spreadsheet",
} as const;
export type EIssueLayoutTypes = typeof EIssueLayoutTypes[keyof typeof EIssueLayoutTypes];
export type TIssueLayoutMap = Record<
EIssueLayoutTypes,
+30 -22
View File
@@ -1,31 +1,39 @@
import { TUnreadNotificationsCount } from "@plane/types";
export enum ENotificationTab {
ALL = "all",
MENTIONS = "mentions",
}
export const ENotificationTab = {
ALL: "all",
MENTIONS: "mentions",
} as const;
export enum ENotificationFilterType {
CREATED = "created",
ASSIGNED = "assigned",
SUBSCRIBED = "subscribed",
}
export type ENotificationTab = typeof ENotificationTab[keyof typeof ENotificationTab];
export enum ENotificationLoader {
INIT_LOADER = "init-loader",
MUTATION_LOADER = "mutation-loader",
PAGINATION_LOADER = "pagination-loader",
REFRESH = "refresh",
MARK_ALL_AS_READY = "mark-all-as-read",
}
export const ENotificationFilterType = {
CREATED: "created",
ASSIGNED: "assigned",
SUBSCRIBED: "subscribed",
} as const;
export enum ENotificationQueryParamType {
INIT = "init",
CURRENT = "current",
NEXT = "next",
}
export type ENotificationFilterType = typeof ENotificationFilterType[keyof typeof ENotificationFilterType];
export type TNotificationTab = ENotificationTab.ALL | ENotificationTab.MENTIONS;
export const ENotificationLoader = {
INIT_LOADER: "init-loader",
MUTATION_LOADER: "mutation-loader",
PAGINATION_LOADER: "pagination-loader",
REFRESH: "refresh",
MARK_ALL_AS_READY: "mark-all-as-read",
} as const;
export type ENotificationLoader = typeof ENotificationLoader[keyof typeof ENotificationLoader];
export const ENotificationQueryParamType = {
INIT: "init",
CURRENT: "current",
NEXT: "next",
} as const;
export type ENotificationQueryParamType = typeof ENotificationQueryParamType[keyof typeof ENotificationQueryParamType];
export type TNotificationTab = typeof ENotificationTab.ALL | typeof ENotificationTab.MENTIONS;
export const NOTIFICATION_TABS = [
{
+6 -4
View File
@@ -1,7 +1,9 @@
export enum EPageAccess {
PUBLIC = 0,
PRIVATE = 1,
}
export const EPageAccess = {
PUBLIC: 0,
PRIVATE: 1,
} as const;
export type EPageAccess = typeof EPageAccess[keyof typeof EPageAccess];
export type TCreatePageModal = {
isOpen: boolean;
+10 -8
View File
@@ -3,13 +3,15 @@ import { IPaymentProduct, TBillingFrequency, TProductBillingFrequency } from "@p
/**
* Enum representing different product subscription types
*/
export enum EProductSubscriptionEnum {
FREE = "FREE",
ONE = "ONE",
PRO = "PRO",
BUSINESS = "BUSINESS",
ENTERPRISE = "ENTERPRISE",
}
export const EProductSubscriptionEnum = {
FREE: "FREE",
ONE: "ONE",
PRO: "PRO",
BUSINESS: "BUSINESS",
ENTERPRISE: "ENTERPRISE",
} as const;
export type EProductSubscriptionEnum = typeof EProductSubscriptionEnum[keyof typeof EProductSubscriptionEnum];
/**
* Default billing frequency for each product subscription type
@@ -29,7 +31,7 @@ export const SUBSCRIPTION_WITH_BILLING_FREQUENCY = [
EProductSubscriptionEnum.PRO,
EProductSubscriptionEnum.BUSINESS,
EProductSubscriptionEnum.ENTERPRISE,
];
] as EProductSubscriptionEnum[];
/**
* Mapping of product subscription types to their respective payment product details
+11 -9
View File
@@ -107,15 +107,17 @@ 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,
}
export const EStartOfTheWeek = {
SUNDAY: 0,
MONDAY: 1,
TUESDAY: 2,
WEDNESDAY: 3,
THURSDAY: 4,
FRIDAY: 5,
SATURDAY: 6,
} as const;
export type EStartOfTheWeek = typeof EStartOfTheWeek[keyof typeof EStartOfTheWeek];
/**
* @description The options for the start of the week
-9
View File
@@ -149,12 +149,3 @@ 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",
}
+18 -12
View File
@@ -1,20 +1,26 @@
import { PROFILE_SETTINGS } from ".";
import { WORKSPACE_SETTINGS } from "./workspace";
export enum WORKSPACE_SETTINGS_CATEGORY {
ADMINISTRATION = "administration",
FEATURES = "features",
DEVELOPER = "developer",
}
export const WORKSPACE_SETTINGS_CATEGORY = {
ADMINISTRATION: "administration",
FEATURES: "features",
DEVELOPER: "developer",
} as const;
export enum PROFILE_SETTINGS_CATEGORY {
YOUR_PROFILE = "your profile",
DEVELOPER = "developer",
}
export type WORKSPACE_SETTINGS_CATEGORY = typeof WORKSPACE_SETTINGS_CATEGORY[keyof typeof WORKSPACE_SETTINGS_CATEGORY];
export enum PROJECT_SETTINGS_CATEGORY {
PROJECTS = "projects",
}
export const PROFILE_SETTINGS_CATEGORY = {
YOUR_PROFILE: "your profile",
DEVELOPER: "developer",
} as const;
export type PROFILE_SETTINGS_CATEGORY = typeof PROFILE_SETTINGS_CATEGORY[keyof typeof PROFILE_SETTINGS_CATEGORY];
export const PROJECT_SETTINGS_CATEGORY = {
PROJECTS: "projects",
} as const;
export type PROJECT_SETTINGS_CATEGORY = typeof PROJECT_SETTINGS_CATEGORY[keyof typeof PROJECT_SETTINGS_CATEGORY];
export const WORKSPACE_SETTINGS_CATEGORIES = [
WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION,
-1
View File
@@ -1,5 +1,4 @@
"use client"
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export type TDraggableData = {
+12 -10
View File
@@ -89,16 +89,18 @@ export const PROJECT_PAGE_TAB_INDICES = [
"submit",
];
export enum ETabIndices {
ISSUE_FORM = "issue-form",
INTAKE_ISSUE_FORM = "intake-issue-form",
CREATE_LABEL = "create-label",
PROJECT_CREATE = "project-create",
PROJECT_CYCLE = "project-cycle",
PROJECT_MODULE = "project-module",
PROJECT_VIEW = "project-view",
PROJECT_PAGE = "project-page",
}
export const ETabIndices = {
ISSUE_FORM: "issue-form",
INTAKE_ISSUE_FORM: "intake-issue-form",
CREATE_LABEL: "create-label",
PROJECT_CREATE: "project-create",
PROJECT_CYCLE: "project-cycle",
PROJECT_MODULE: "project-module",
PROJECT_VIEW: "project-view",
PROJECT_PAGE: "project-page",
} as const;
export type ETabIndices = typeof ETabIndices[keyof typeof ETabIndices];
export const TAB_INDEX_MAP: Record<ETabIndices, string[]> = {
[ETabIndices.ISSUE_FORM]: ISSUE_FORM_TAB_INDICES,
+47 -33
View File
@@ -1,49 +1,63 @@
export enum EAuthenticationPageType {
STATIC = "STATIC",
NOT_AUTHENTICATED = "NOT_AUTHENTICATED",
AUTHENTICATED = "AUTHENTICATED",
}
export const EAuthenticationPageType = {
STATIC: "STATIC",
NOT_AUTHENTICATED: "NOT_AUTHENTICATED",
AUTHENTICATED: "AUTHENTICATED",
} as const;
export enum EInstancePageType {
PRE_SETUP = "PRE_SETUP",
POST_SETUP = "POST_SETUP",
}
export type EAuthenticationPageType = typeof EAuthenticationPageType[keyof typeof EAuthenticationPageType];
export enum EUserStatus {
ERROR = "ERROR",
AUTHENTICATION_NOT_DONE = "AUTHENTICATION_NOT_DONE",
NOT_YET_READY = "NOT_YET_READY",
}
export const EInstancePageType = {
PRE_SETUP: "PRE_SETUP",
POST_SETUP: "POST_SETUP",
} as const;
export type EInstancePageType = typeof EInstancePageType[keyof typeof EInstancePageType];
export const EUserStatus = {
ERROR: "ERROR",
AUTHENTICATION_NOT_DONE: "AUTHENTICATION_NOT_DONE",
NOT_YET_READY: "NOT_YET_READY",
} as const;
export type EUserStatus = typeof EUserStatus[keyof typeof EUserStatus];
export type TUserStatus = {
status: EUserStatus | undefined;
message?: string;
};
export enum EUserPermissionsLevel {
WORKSPACE = "WORKSPACE",
PROJECT = "PROJECT",
}
export const EUserPermissionsLevel = {
WORKSPACE: "WORKSPACE",
PROJECT: "PROJECT",
} as const;
export enum EUserWorkspaceRoles {
ADMIN = 20,
MEMBER = 15,
GUEST = 5,
}
export type EUserPermissionsLevel = typeof EUserPermissionsLevel[keyof typeof EUserPermissionsLevel];
export enum EUserProjectRoles {
ADMIN = 20,
MEMBER = 15,
GUEST = 5,
}
export const EUserWorkspaceRoles = {
ADMIN: 20,
MEMBER: 15,
GUEST: 5,
} as const;
export type EUserWorkspaceRoles = typeof EUserWorkspaceRoles[keyof typeof EUserWorkspaceRoles];
export const EUserProjectRoles = {
ADMIN: 20,
MEMBER: 15,
GUEST: 5,
} as const;
export type EUserProjectRoles = typeof EUserProjectRoles[keyof typeof EUserProjectRoles];
export type TUserPermissionsLevel = EUserPermissionsLevel;
export enum EUserPermissions {
ADMIN = 20,
MEMBER = 15,
GUEST = 5,
}
export const EUserPermissions = {
ADMIN: 20,
MEMBER: 15,
GUEST: 5,
} as const;
export type EUserPermissions = typeof EUserPermissions[keyof typeof EUserPermissions];
export type TUserPermissions = EUserPermissions;
export type TUserAllowedPermissionsObject = {
+6 -4
View File
@@ -1,7 +1,9 @@
export enum EViewAccess {
PRIVATE,
PUBLIC,
}
export const EViewAccess = {
PRIVATE: 0,
PUBLIC: 1,
} as const;
export type EViewAccess = typeof EViewAccess[keyof typeof EViewAccess];
export const VIEW_ACCESS_SPECIFIERS: {
key: EViewAccess;
+8 -6
View File
@@ -1,6 +1,8 @@
export enum EDraftIssuePaginationType {
INIT = "INIT",
NEXT = "NEXT",
PREV = "PREV",
CURRENT = "CURRENT",
}
export const EDraftIssuePaginationType = {
INIT: "INIT",
NEXT: "NEXT",
PREV: "PREV",
CURRENT: "CURRENT",
} as const;
export type EDraftIssuePaginationType = typeof EDraftIssuePaginationType[keyof typeof EDraftIssuePaginationType];
+1 -1
View File
@@ -118,7 +118,7 @@ export const WORKSPACE_SETTINGS = {
export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries(
Object.entries(WORKSPACE_SETTINGS).map(([_, { href, access }]) => [href, access])
);
) as Record<string, EUserWorkspaceRoles[]>;
export const WORKSPACE_SETTINGS_LINKS: {
key: string;
@@ -1,13 +1,13 @@
import type { Extensions } from "@tiptap/core";
import { Extensions } from "@tiptap/core";
// types
import type { IEditorProps } from "@/types";
import { TExtensions, TFileHandler } from "@/types";
export type TCoreAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
type Props = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler;
};
export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => {
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
const {} = props;
return [];
};
@@ -1,15 +1,12 @@
import type { Extensions } from "@tiptap/core";
import { Extensions } from "@tiptap/core";
// types
import type { IReadOnlyEditorProps } from "@/types";
import { TExtensions } from "@/types";
export type TCoreReadOnlyEditorAdditionalExtensionsProps = Pick<
IReadOnlyEditorProps,
"disabledExtensions" | "flaggedExtensions"
>;
type Props = {
disabledExtensions: TExtensions[];
};
export const CoreReadOnlyEditorAdditionalExtensions = (
props: TCoreReadOnlyEditorAdditionalExtensionsProps
): Extensions => {
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
const {} = props;
return [];
};
@@ -1,39 +1,37 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { AnyExtension } from "@tiptap/core";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
// plane editor types
import type { TEmbedConfig } from "@/plane-editor/types";
import { TEmbedConfig } from "@/plane-editor/types";
// types
import type { IEditorProps, TExtensions, TUserDetails } from "@/types";
import { TExtensions, TFileHandler, TUserDetails } from "@/types";
export type TDocumentEditorAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
> & {
export type TDocumentEditorAdditionalExtensionsProps = {
disabledExtensions: TExtensions[];
embedConfig: TEmbedConfig | undefined;
fileHandler: TFileHandler;
provider?: HocuspocusProvider;
userDetails: TUserDetails;
};
export type TDocumentEditorAdditionalExtensionsRegistry = {
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension;
};
const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
SlashCommands({ disabledExtensions, flaggedExtensions }),
getExtension: ({ disabledExtensions }) => SlashCommands({ disabledExtensions }),
},
];
export const DocumentEditorAdditionalExtensions = (props: TDocumentEditorAdditionalExtensionsProps) => {
const { disabledExtensions, flaggedExtensions } = props;
export const DocumentEditorAdditionalExtensions = (_props: TDocumentEditorAdditionalExtensionsProps) => {
const { disabledExtensions = [] } = _props;
const documentExtensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
.map((config) => config.getExtension(props));
.filter((config) => config.isEnabled(disabledExtensions))
.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 { IEditorProps, TExtensions } from "@/types";
import { TExtensions, TFileHandler } from "@/types";
export type TRichTextEditorAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
export type TRichTextEditorAdditionalExtensionsProps = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler;
};
/**
* Registry entry configuration for extensions
*/
export type TRichTextEditorAdditionalExtensionsRegistry = {
/** Determines if the extension should be enabled based on disabled extensions */
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
/** Returns the extension instance(s) when enabled */
getExtension: (props: TRichTextEditorAdditionalExtensionsProps) => AnyExtension | undefined;
};
@@ -22,19 +22,18 @@ export type TRichTextEditorAdditionalExtensionsRegistry = {
const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
getExtension: ({ disabledExtensions }) =>
SlashCommands({
disabledExtensions,
flaggedExtensions,
}),
},
];
export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => {
const { disabledExtensions, flaggedExtensions } = props;
const { disabledExtensions } = props;
const extensions: Extensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(props))
.filter((extension): extension is AnyExtension => extension !== undefined);
@@ -1,11 +1,11 @@
import { AnyExtension, Extensions } from "@tiptap/core";
// types
import { IReadOnlyEditorProps, TExtensions } from "@/types";
import { TExtensions, TReadOnlyFileHandler } from "@/types";
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = Pick<
IReadOnlyEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = {
disabledExtensions: TExtensions[];
fileHandler: TReadOnlyFileHandler;
};
/**
* Registry entry configuration for extensions
@@ -1,9 +1,11 @@
// extensions
import type { TSlashCommandAdditionalOption } from "@/extensions";
import { TSlashCommandAdditionalOption } from "@/extensions";
// types
import type { IEditorProps } from "@/types";
import { TExtensions } from "@/types";
type Props = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions">;
type Props = {
disabledExtensions?: TExtensions[];
};
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
const {} = props;
+1 -1
View File
@@ -2,7 +2,7 @@
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { type HeadingExtensionStorage } from "@/extensions";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image/types";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image";
import { type CustomLinkStorage } from "@/extensions/custom-link";
import { type ImageExtensionStorage } from "@/extensions/image";
import { type MentionExtensionStorage } from "@/extensions/mentions";
@@ -13,11 +13,10 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// types
import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
const {
onChange,
onTransaction,
aiHandler,
bubbleMenuEnabled = true,
@@ -28,7 +27,6 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
editorClassName = "",
embedHandler,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
@@ -58,12 +56,10 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
embedHandler,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
onChange,
onTransaction,
placeholder,
realtimeConfig,
@@ -99,7 +95,7 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
);
};
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditor>(
(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 Props = {
type IPageRenderer = {
aiHandler?: TAIHandler;
bubbleMenuEnabled: boolean;
displayConfig: TDisplayConfig;
@@ -15,7 +15,7 @@ type Props = {
tabIndex?: number;
};
export const PageRenderer = (props: Props) => {
export const PageRenderer = (props: IPageRenderer) => {
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
return (
@@ -1,5 +1,5 @@
import { Extensions } from "@tiptap/core";
import React, { forwardRef, MutableRefObject } from "react";
import { forwardRef, MutableRefObject } from "react";
// plane imports
import { cn } from "@plane/utils";
// components
@@ -13,9 +13,30 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from "@/types";
import {
EditorReadOnlyRefApi,
TDisplayConfig,
TExtensions,
TReadOnlyFileHandler,
TReadOnlyMentionHandler,
} from "@/types";
const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) => {
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 {
containerClassName,
disabledExtensions,
@@ -23,7 +44,6 @@ const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) =
editorClassName = "",
embedHandler,
fileHandler,
flaggedExtensions,
id,
forwardedRef,
handleEditorReady,
@@ -44,7 +64,6 @@ const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) =
editorClassName,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
initialValue,
@@ -68,7 +87,7 @@ const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) =
);
};
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps>((props, ref) => (
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
));
@@ -53,14 +53,17 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2));
const lastNode = lastNodePos.node();
// 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();
}
// 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();
}
} catch (error) {
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
@@ -26,7 +26,6 @@ export const EditorWrapper: React.FC<Props> = (props) => {
id,
initialValue,
fileHandler,
flaggedExtensions,
forwardedRef,
mentionHandler,
onChange,
@@ -45,7 +44,6 @@ export const EditorWrapper: React.FC<Props> = (props) => {
enableHistory: true,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
initialValue,
@@ -4,25 +4,23 @@ import { EditorWrapper } from "@/components/editors/editor-wrapper";
// extensions
import { EnterKeyExtension } from "@/extensions";
// types
import { EditorRefApi, ILiteTextEditorProps } from "@/types";
import { EditorRefApi, ILiteTextEditor } from "@/types";
const LiteTextEditor: React.FC<ILiteTextEditorProps> = (props) => {
const LiteTextEditor = (props: ILiteTextEditor) => {
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
const extensions = useMemo(() => {
const resolvedExtensions = [...externalExtensions];
if (!disabledExtensions?.includes("enter-key")) {
resolvedExtensions.push(EnterKeyExtension(onEnterKeyPress));
}
return resolvedExtensions;
}, [externalExtensions, disabledExtensions, onEnterKeyPress]);
const extensions = useMemo(
() => [
...externalExtensions,
...(disabledExtensions?.includes("enter-key") ? [] : [EnterKeyExtension(onEnterKeyPress)]),
],
[externalExtensions, disabledExtensions, onEnterKeyPress]
);
return <EditorWrapper {...props} extensions={extensions} />;
};
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditorProps>((props, ref) => (
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((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, ILiteTextReadOnlyEditorProps } from "@/types";
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor } from "@/types";
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps>((props, ref) => (
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditor>((props, ref) => (
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
));
@@ -17,7 +17,6 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
editorClassName = "",
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
initialValue,
@@ -29,7 +28,6 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
editorClassName,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
initialValue,
mentionHandler,
@@ -7,16 +7,15 @@ import { SideMenuExtension } from "@/extensions";
// plane editor imports
import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/extensions";
// types
import { EditorRefApi, IRichTextEditorProps } from "@/types";
import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
const RichTextEditor = (props: IRichTextEditor) => {
const {
bubbleMenuEnabled = true,
disabledExtensions,
dragDropEnabled,
extensions: externalExtensions = [],
fileHandler,
flaggedExtensions,
bubbleMenuEnabled = true,
extensions: externalExtensions = [],
} = props;
const getExtensions = useCallback(() => {
@@ -29,12 +28,11 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
...RichTextEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
flaggedExtensions,
}),
];
return extensions;
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions]);
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler]);
return (
<EditorWrapper {...props} extensions={getExtensions()}>
@@ -43,7 +41,7 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
);
};
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditorProps>((props, ref) => (
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditor>((props, ref) => (
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
@@ -2,22 +2,23 @@ import { forwardRef, useCallback } from "react";
// plane editor extensions
import { RichTextReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/read-only-extensions";
// types
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps } from "@/types";
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types";
// local imports
import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper";
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps>((props, ref) => {
const { disabledExtensions, fileHandler, flaggedExtensions } = props;
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => {
const { disabledExtensions, fileHandler } = props;
const getExtensions = useCallback(() => {
const extensions = RichTextReadOnlyEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
flaggedExtensions,
});
const extensions = [
...RichTextReadOnlyEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
}),
];
return extensions;
}, [disabledExtensions, fileHandler, flaggedExtensions]);
}, [disabledExtensions, fileHandler]);
return (
<ReadOnlyEditorWrapper
@@ -10,7 +10,6 @@ import {
BubbleMenuLinkSelector,
BubbleMenuNodeSelector,
CodeItem,
EditorMenuItem,
ItalicItem,
StrikeThroughItem,
TextAlignItem,
@@ -24,7 +23,6 @@ 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">;
@@ -33,19 +31,19 @@ export interface EditorStateType {
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
strike: 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 }) => {
@@ -60,10 +58,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
bold: BoldItem(props.editor),
italic: ItalicItem(props.editor),
underline: UnderLineItem(props.editor),
strikethrough: StrikeThroughItem(props.editor),
"text-align": TextAlignItem(props.editor),
} satisfies {
[K in TEditorCommands]?: EditorMenuItem<K>;
strike: StrikeThroughItem(props.editor),
textAlign: TextAlignItem(props.editor),
};
const editorState: EditorStateType = useEditorState({
@@ -73,10 +69,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
bold: formattingItems.bold.isActive(),
italic: formattingItems.italic.isActive(),
underline: formattingItems.underline.isActive(),
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" }),
strike: formattingItems.strike.isActive(),
left: formattingItems.textAlign.isActive({ alignment: "left" }),
right: formattingItems.textAlign.isActive({ alignment: "right" }),
center: formattingItems.textAlign.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 })),
}),
@@ -84,7 +80,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
const basicFormattingOptions = editorState.code
? [formattingItems.code]
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
+46 -44
View File
@@ -1,44 +1,46 @@
export enum CORE_EXTENSIONS {
BLOCKQUOTE = "blockquote",
BOLD = "bold",
BULLET_LIST = "bulletList",
CALLOUT = "calloutComponent",
CHARACTER_COUNT = "characterCount",
CODE_BLOCK = "codeBlock",
CODE_INLINE = "code",
CUSTOM_COLOR = "customColor",
CUSTOM_IMAGE = "imageComponent",
CUSTOM_LINK = "link",
DOCUMENT = "doc",
DROP_CURSOR = "dropCursor",
ENTER_KEY = "enterKey",
GAP_CURSOR = "gapCursor",
HARD_BREAK = "hardBreak",
HEADING = "heading",
HEADINGS_LIST = "headingsList",
HISTORY = "history",
HORIZONTAL_RULE = "horizontalRule",
IMAGE = "image",
ITALIC = "italic",
LIST_ITEM = "listItem",
MARKDOWN_CLIPBOARD = "markdownClipboard",
MENTION = "mention",
ORDERED_LIST = "orderedList",
PARAGRAPH = "paragraph",
PLACEHOLDER = "placeholder",
SIDE_MENU = "editorSideMenu",
SLASH_COMMANDS = "slash-command",
STRIKETHROUGH = "strike",
TABLE = "table",
TABLE_CELL = "tableCell",
TABLE_HEADER = "tableHeader",
TABLE_ROW = "tableRow",
TASK_ITEM = "taskItem",
TASK_LIST = "taskList",
TEXT_ALIGN = "textAlign",
TEXT_STYLE = "textStyle",
TYPOGRAPHY = "typography",
UNDERLINE = "underline",
UTILITY = "utility",
WORK_ITEM_EMBED = "issue-embed-component",
}
export const CORE_EXTENSIONS = {
BLOCKQUOTE: "blockquote",
BOLD: "bold",
BULLET_LIST: "bulletList",
CALLOUT: "calloutComponent",
CHARACTER_COUNT: "characterCount",
CODE_BLOCK: "codeBlock",
CODE_INLINE: "code",
CUSTOM_COLOR: "customColor",
CUSTOM_IMAGE: "imageComponent",
CUSTOM_LINK: "link",
DOCUMENT: "doc",
DROP_CURSOR: "dropCursor",
ENTER_KEY: "enterKey",
GAP_CURSOR: "gapCursor",
HARD_BREAK: "hardBreak",
HEADING: "heading",
HEADINGS_LIST: "headingsList",
HISTORY: "history",
HORIZONTAL_RULE: "horizontalRule",
IMAGE: "image",
ITALIC: "italic",
LIST_ITEM: "listItem",
MARKDOWN_CLIPBOARD: "markdownClipboard",
MENTION: "mention",
ORDERED_LIST: "orderedList",
PARAGRAPH: "paragraph",
PLACEHOLDER: "placeholder",
SIDE_MENU: "editorSideMenu",
SLASH_COMMANDS: "slash-command",
STRIKETHROUGH: "strike",
TABLE: "table",
TABLE_CELL: "tableCell",
TABLE_HEADER: "tableHeader",
TABLE_ROW: "tableRow",
TASK_ITEM: "taskItem",
TASK_LIST: "taskList",
TEXT_ALIGN: "textAlign",
TEXT_STYLE: "textStyle",
TYPOGRAPHY: "typography",
UNDERLINE: "underline",
UTILITY: "utility",
WORK_ITEM_EMBED: "issue-embed-component",
} as const;
export type CORE_EXTENSIONS = typeof CORE_EXTENSIONS[keyof typeof CORE_EXTENSIONS];
+5 -3
View File
@@ -1,3 +1,5 @@
export enum CORE_EDITOR_META {
SKIP_FILE_DELETION = "skipFileDeletion",
}
export const CORE_EDITOR_META = {
SKIP_FILE_DELETION: "skipFileDeletion",
} as const;
export type CORE_EDITOR_META = typeof CORE_EDITOR_META[keyof typeof CORE_EDITOR_META];
@@ -1,12 +1,14 @@
export enum EAttributeNames {
ICON_COLOR = "data-icon-color",
ICON_NAME = "data-icon-name",
EMOJI_UNICODE = "data-emoji-unicode",
EMOJI_URL = "data-emoji-url",
LOGO_IN_USE = "data-logo-in-use",
BACKGROUND = "data-background",
BLOCK_TYPE = "data-block-type",
}
export const EAttributeNames = {
ICON_COLOR: "data-icon-color",
ICON_NAME: "data-icon-name",
EMOJI_UNICODE: "data-emoji-unicode",
EMOJI_URL: "data-emoji-url",
LOGO_IN_USE: "data-logo-in-use",
BACKGROUND: "data-background",
BLOCK_TYPE: "data-block-type",
} as const;
export type EAttributeNames = typeof EAttributeNames[keyof typeof EAttributeNames];
export type TCalloutBlockIconAttributes = {
[EAttributeNames.ICON_COLOR]: string | undefined;
@@ -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 { ImageExtensionConfig } from "./image";
import { ImageExtensionWithoutProps } from "./image";
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
@@ -72,8 +72,12 @@ export const CoreEditorExtensionsWithoutProps = [
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtensionConfig,
CustomImageExtensionConfig,
ImageExtensionWithoutProps.configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageComponentWithoutProps,
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -1,42 +1,68 @@
import { NodeSelection } from "@tiptap/pm/state";
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
// local imports
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
import { ensurePixelString } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
import { ImageToolbarRoot } from "./toolbar";
// extensions
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
import { ImageUploadStatus } from "./upload-status";
const MIN_SIZE = 100;
type CustomImageBlockProps = CustomImageNodeViewProps & {
editorContainer: HTMLDivElement | null;
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;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
src: string | undefined;
};
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// props
const {
node,
updateAttributes,
setFailedToLoadImage,
imageFromFileSystem,
selected,
getPos,
editor,
editorContainer,
extension,
getPos,
imageFromFileSystem,
node,
selected,
setEditorContainer,
setFailedToLoadImage,
src: resolvedImageSrc,
updateAttributes,
setEditorContainer,
} = props;
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
// states
const [size, setSize] = useState<TCustomImageSize>({
const [size, setSize] = useState<Size>({
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
aspectRatio: nodeAspectRatio || null,
@@ -51,7 +77,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
const updateAttributesSafely = useCallback(
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
try {
updateAttributes(attributes);
} catch (error) {
@@ -88,7 +114,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatioCalculated;
const initialComputedSize: TCustomImageSize = {
const initialComputedSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated,
@@ -113,7 +139,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
}
}
setInitialResizeComplete(true);
}, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]);
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
// for real time resizing
useLayoutEffect(() => {
@@ -142,7 +168,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, updateAttributesSafely]);
}, [size, updateAttributes]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
@@ -216,7 +242,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 (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) {
setFailedToLoadImage(true);
return;
}
@@ -227,7 +253,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
if (!imgNodeSrc) {
throw new Error("No source image to restore from");
}
await extension.options.restoreImage?.(imgNodeSrc);
await editor?.commands.restoreImage?.(imgNodeSrc);
if (!imageRef.current) {
throw new Error("Image reference not found");
}
@@ -263,10 +289,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={{
width: size.width,
height: size.height,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
src: resolvedImageSrc,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
height: size.height,
width: size.width,
}}
/>
)}
@@ -2,26 +2,25 @@ 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 CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
extension: CustomImageExtension;
export type CustomBaseImageNodeViewProps = {
getPos: () => number;
editor: Editor;
node: NodeViewProps["node"] & {
attrs: TCustomImageAttributes;
attrs: ImageAttributes;
};
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
selected: boolean;
};
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
const { editor, extension, node } = props;
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps;
export const CustomImageNode = (props: CustomImageNodeProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
const { src: imgNodeSrc } = node.attrs;
const [isUploaded, setIsUploaded] = useState(false);
@@ -51,37 +50,41 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
}, [resolvedSrc]);
useEffect(() => {
if (!imgNodeSrc) {
setResolvedSrc(undefined);
return;
}
const getImageSource = async () => {
const url = await extension.options.getImageSource?.(imgNodeSrc);
setResolvedSrc(url);
// @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);
};
getImageSource();
}, [imgNodeSrc, extension.options]);
}, [imgNodeSrc]);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
<CustomImageBlock
editorContainer={editorContainer}
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer}
editor={editor}
src={resolvedSrc}
getPos={getPos}
node={node}
setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage}
src={resolvedSrc}
{...props}
selected={selected}
updateAttributes={updateAttributes}
/>
) : (
<CustomImageUploader
editor={editor}
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
node={node}
setIsUploaded={setIsUploaded}
{...props}
selected={selected}
updateAttributes={updateAttributes}
/>
)}
</div>
@@ -1,30 +1,28 @@
import { ImageIcon } from "lucide-react";
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
// plane imports
// plane utils
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 = CustomImageNodeViewProps & {
failedToLoadImage: boolean;
loadImageFromFileSystem: (file: string) => void;
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & {
maxFileSize: number;
loadImageFromFileSystem: (file: string) => void;
failedToLoadImage: boolean;
setIsUploaded: (isUploaded: boolean) => void;
};
export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const {
editor,
extension,
failedToLoadImage,
getPos,
loadImageFromFileSystem,
@@ -73,13 +71,12 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
const uploadImageEditorCommand = useCallback(
async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
[extension.options, imageEntityId]
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file),
[editor, imageEntityId]
);
const handleProgressStatus = useCallback(
@@ -96,6 +93,7 @@ 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,
@@ -130,7 +128,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
}
}
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);
}, [meta, uploadFile, imageComponentImageFileMap]);
const onFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
@@ -165,7 +163,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
return "Add an image";
}, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]);
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
return (
<div
@@ -0,0 +1,4 @@
export * from "./toolbar";
export * from "./image-block";
export * from "./image-node";
export * from "./image-uploader";
@@ -1,14 +1,14 @@
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
type Props = {
image: {
width: string;
height: string;
aspectRatio: number;
src: string;
height: string;
width: string;
aspectRatio: number;
};
isOpen: boolean;
toggleFullScreenMode: (val: boolean) => void;
@@ -1,16 +1,16 @@
import { useState } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
// local imports
// components
import { ImageFullScreenAction } from "./full-screen";
type Props = {
containerClassName?: string;
image: {
width: string;
height: string;
aspectRatio: number;
src: string;
height: string;
width: string;
aspectRatio: number;
};
};
@@ -0,0 +1,180 @@
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);
},
});
};
@@ -1,47 +0,0 @@
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)];
},
});
@@ -1,121 +0,0 @@
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);
},
});
};
@@ -0,0 +1,3 @@
export * from "./components";
export * from "./custom-image";
export * from "./read-only-custom-image";
@@ -0,0 +1,79 @@
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);
},
});
};
@@ -1,51 +0,0 @@
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>;
@@ -1,33 +0,0 @@
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,6 +16,7 @@ import {
CustomCodeInlineExtension,
CustomColorExtension,
CustomHorizontalRule,
CustomImageExtension,
CustomKeymap,
CustomLinkExtension,
CustomMentionExtension,
@@ -36,29 +37,20 @@ import { getExtensionStorage } from "@/helpers/get-extension-storage";
// plane editor extensions
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import type { IEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
type TArguments = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler" | "placeholder" | "tabIndex"
> & {
type TArguments = {
disabledExtensions: TExtensions[];
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,
flaggedExtensions,
mentionHandler,
placeholder,
tabIndex,
editable,
} = args;
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex, editable } = args;
const extensions = [
StarterKit.configure({
@@ -185,20 +177,18 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomColorExtension,
...CoreEditorAdditionalExtensions({
disabledExtensions,
flaggedExtensions,
fileHandler,
}),
];
if (!disabledExtensions.includes("image")) {
extensions.push(
ImageExtension({
fileHandler,
ImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageExtension({
fileHandler,
isEditable: editable,
})
CustomImageExtension(fileHandler)
);
}
@@ -1,33 +1,23 @@
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 type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView } from "../custom-image/components/node-view";
import { ImageExtensionConfig } from "./extension-config";
import { TFileHandler } from "@/types";
export type ImageExtensionStorage = {
deletedImageSet: Map<string, boolean>;
};
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,
};
},
export const ImageExtension = (fileHandler: TFileHandler) => {
const {
getAssetSrc,
validation: { maxFileSize },
} = fileHandler;
return BaseImageExtension.extend<unknown, ImageExtensionStorage>({
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
@@ -37,17 +27,36 @@ export const ImageExtension = (props: Props) => {
// 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(CustomImageNodeView);
return ReactNodeViewRenderer(CustomImageNode);
},
});
};
@@ -0,0 +1,56 @@
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,12 +1,6 @@
import { Image as BaseImageExtension } from "@tiptap/extension-image";
// local imports
import { CustomImageExtensionOptions } from "../custom-image/types";
import { ImageExtensionStorage } from "./extension";
export const ImageExtensionConfig = BaseImageExtension.extend<
Pick<CustomImageExtensionOptions, "getImageSource">,
ImageExtensionStorage
>({
export const ImageExtensionWithoutProps = BaseImageExtension.extend({
addAttributes() {
return {
...this.parent?.(),
@@ -1,2 +1,3 @@
export * from "./extension";
export * from "./extension-config";
export * from "./image-extension-without-props";
export * from "./read-only-image";
@@ -0,0 +1,37 @@
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,6 +1,7 @@
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";
@@ -1,11 +1,13 @@
// plane types
import { TSearchEntities } from "@plane/types";
export enum EMentionComponentAttributeNames {
ID = "id",
ENTITY_IDENTIFIER = "entity_identifier",
ENTITY_NAME = "entity_name",
}
export const EMentionComponentAttributeNames = {
ID: "id",
ENTITY_IDENTIFIER: "entity_identifier",
ENTITY_NAME: "entity_name",
} as const;
export type EMentionComponentAttributeNames = typeof EMentionComponentAttributeNames[keyof typeof EMentionComponentAttributeNames];
export type TMentionComponentAttributes = {
[EMentionComponentAttributeNames.ID]: string | null;
@@ -12,6 +12,7 @@ import {
CustomHorizontalRule,
CustomLinkExtension,
CustomTypographyExtension,
ReadOnlyImageExtension,
CustomCodeBlockExtension,
CustomCodeInlineExtension,
TableHeader,
@@ -19,25 +20,27 @@ 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 type { IReadOnlyEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
import { TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
type Props = Pick<IReadOnlyEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler">;
type Props = {
disabledExtensions: TExtensions[];
fileHandler: TReadOnlyFileHandler;
mentionHandler: TReadOnlyMentionHandler;
};
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const { disabledExtensions, fileHandler, flaggedExtensions, mentionHandler } = props;
const { disabledExtensions, fileHandler, mentionHandler } = props;
const extensions = [
StarterKit.configure({
@@ -130,19 +133,17 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
}),
...CoreReadOnlyEditorAdditionalExtensions({
disabledExtensions,
flaggedExtensions,
}),
];
if (!disabledExtensions.includes("image")) {
extensions.push(
ImageExtension({
fileHandler,
ReadOnlyImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageExtension({
fileHandler,
isEditable: false,
})
CustomReadOnlyImageExtension(fileHandler)
);
}
@@ -49,7 +49,7 @@ export type TSlashCommandSection = {
export const getSlashCommandFilteredSections =
(args: TExtensionProps) =>
({ query }: { query: string }): TSlashCommandSection[] => {
const { additionalOptions: externalAdditionalOptions, disabledExtensions, flaggedExtensions } = args;
const { additionalOptions: externalAdditionalOptions, disabledExtensions } = args;
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
{
key: "general",
@@ -290,7 +290,6 @@ 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 { IEditorProps, ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types";
import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types";
// components
import { getSlashCommandFilteredSections } from "./command-items-list";
import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu";
@@ -106,8 +106,9 @@ const renderItems = () => {
};
};
export type TExtensionProps = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions"> & {
export type TExtensionProps = {
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 type { IEditorProps, TFileHandler, TReadOnlyFileHandler } from "@/types";
import { TExtensions, TFileHandler, TReadOnlyFileHandler } from "@/types";
declare module "@tiptap/core" {
interface Commands {
@@ -23,7 +23,8 @@ export interface UtilityExtensionStorage {
uploadInProgress: boolean;
}
type Props = Pick<IEditorProps, "disabledExtensions"> & {
type Props = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean;
};
@@ -2,8 +2,8 @@ import { Editor, Range } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { InsertImageComponentProps } from "@/extensions";
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
import type { InsertImageComponentProps } from "@/extensions/custom-image/types";
// helpers
import { findTableAncestor } from "@/helpers/common";
+7 -5
View File
@@ -1,8 +1,10 @@
export enum EFileError {
INVALID_FILE_TYPE = "INVALID_FILE_TYPE",
FILE_SIZE_TOO_LARGE = "FILE_SIZE_TOO_LARGE",
NO_FILE_SELECTED = "NO_FILE_SELECTED",
}
export const EFileError = {
INVALID_FILE_TYPE: "INVALID_FILE_TYPE",
FILE_SIZE_TOO_LARGE: "FILE_SIZE_TOO_LARGE",
NO_FILE_SELECTED: "NO_FILE_SELECTED",
} as const;
export type EFileError = typeof EFileError[keyof typeof EFileError];
type TArgs = {
acceptedMimeTypes: string[];
@@ -1,89 +0,0 @@
// plane imports
import { TDocumentPayload, TDuplicateAssetData, TDuplicateAssetResponse } from "@plane/types";
import { TEditorAssetType } from "@plane/types/src/enums";
// local imports
import { convertHTMLDocumentToAllFormats } from "./yjs-utils";
/**
* @description function to extract all image assets from HTML content
* @param htmlContent
* @returns {string[]} array of image asset sources
*/
export const extractImageAssetsFromHTMLContent = (htmlContent: string): string[] => {
// create a DOM parser
const parser = new DOMParser();
// parse the HTML string into a DOM document
const doc = parser.parseFromString(htmlContent, "text/html");
// get all image components
const imageComponents = doc.querySelectorAll("image-component");
// collect all unique image sources
const imageSources = new Set<string>();
// extract sources from image components
imageComponents.forEach((component) => {
const src = component.getAttribute("src");
if (src) imageSources.add(src);
});
return Array.from(imageSources);
};
/**
* @description function to replace image assets in HTML content with new IDs
* @param props
* @returns {string} HTML content with replaced image assets
*/
export const replaceImageAssetsInHTMLContent = (props: {
htmlContent: string;
assetMap: Record<string, string>;
}): string => {
const { htmlContent, assetMap } = props;
// create a DOM parser
const parser = new DOMParser();
// parse the HTML string into a DOM document
const doc = parser.parseFromString(htmlContent, "text/html");
// replace sources in image components
const imageComponents = doc.querySelectorAll("image-component");
imageComponents.forEach((component) => {
const oldSrc = component.getAttribute("src");
if (oldSrc && assetMap[oldSrc]) {
component.setAttribute("src", assetMap[oldSrc]);
}
});
// serialize the document back into a string
return doc.body.innerHTML;
};
export const getEditorContentWithReplacedImageAssets = async (props: {
descriptionHTML: string;
entityId: string;
entityType: TEditorAssetType;
projectId: string | undefined;
variant: "rich" | "document";
duplicateAssetService: (params: TDuplicateAssetData) => Promise<TDuplicateAssetResponse>;
}): Promise<TDocumentPayload> => {
const { descriptionHTML, entityId, entityType, projectId, variant, duplicateAssetService } = props;
let replacedDescription = descriptionHTML;
// step 1: extract image assets from the description
const imageAssets = extractImageAssetsFromHTMLContent(descriptionHTML);
if (imageAssets.length !== 0) {
// step 2: duplicate the image assets
const duplicateAssetsResponse = await duplicateAssetService({
entity_id: entityId,
entity_type: entityType,
project_id: projectId,
asset_ids: imageAssets,
});
if (Object.keys(duplicateAssetsResponse ?? {}).length > 0) {
// step 3: replace the image assets in the description
replacedDescription = replaceImageAssetsInHTMLContent({
htmlContent: descriptionHTML,
assetMap: duplicateAssetsResponse,
});
}
}
// step 4: convert the description to the document payload
const documentPayload = convertHTMLDocumentToAllFormats({
document_html: replacedDescription,
variant,
});
return documentPayload;
};
@@ -3,7 +3,6 @@ import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs";
// extensions
import { TDocumentPayload } from "@plane/types";
import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
@@ -141,50 +140,3 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
contentHTML,
};
};
type TConvertHTMLDocumentToAllFormatsArgs = {
document_html: string;
variant: "rich" | "document";
};
/**
* @description Converts HTML content to all supported document formats (JSON, HTML, and binary)
* @param {TConvertHTMLDocumentToAllFormatsArgs} args - Arguments containing HTML content and variant type
* @param {string} args.document_html - The HTML content to convert
* @param {"rich" | "document"} args.variant - The type of editor variant to use for conversion
* @returns {TDocumentPayload} Object containing the document in all supported formats
* @throws {Error} If an invalid variant is provided
*/
export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllFormatsArgs): TDocumentPayload => {
const { document_html, variant } = args;
let allFormats: TDocumentPayload;
if (variant === "rich") {
// Convert HTML to binary format for rich text editor
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
// Generate all document formats from the binary data
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else if (variant === "document") {
// Convert HTML to binary format for document editor
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
// Generate all document formats from the binary data
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else {
throw new Error(`Invalid variant provided: ${variant}`);
}
return allFormats;
};
@@ -9,20 +9,18 @@ import { useEditor } from "@/hooks/use-editor";
// plane editor extensions
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { TCollaborativeEditorHookProps } from "@/types";
import { TCollaborativeEditorProps } from "@/types";
export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => {
export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
const {
onChange,
onTransaction,
disabledExtensions,
editable,
editorClassName = "",
editorClassName,
editorProps = {},
embedHandler,
extensions = [],
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
@@ -91,22 +89,19 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
Collaboration.configure({
document: provider.document,
}),
...extensions,
...(extensions ?? []),
...DocumentEditorAdditionalExtensions({
disabledExtensions,
embedConfig: embedHandler,
fileHandler,
flaggedExtensions,
provider,
userDetails: user,
}),
],
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
mentionHandler,
onChange,
onTransaction,
placeholder,
provider,
+41 -10
View File
@@ -1,12 +1,13 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { DOMSerializer } from "@tiptap/pm/model";
import { useEditor as useTiptapEditor } from "@tiptap/react";
import { useImperativeHandle, useEffect } from "react";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
import * as Y from "yjs";
// components
import { getEditorMenuItems } from "@/components/menus";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import { CORE_EDITOR_META } from "@/constants/meta";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
@@ -17,19 +18,49 @@ import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helper
// props
import { CoreEditorProps } from "@/props";
// types
import type { TDocumentEventsServer, TEditorCommands, TEditorHookProps } from "@/types";
import type {
TDocumentEventsServer,
EditorRefApi,
TEditorCommands,
TFileHandler,
TExtensions,
TMentionHandler,
} from "@/types";
import { CORE_EDITOR_META } from "@/constants/meta";
export const useEditor = (props: TEditorHookProps) => {
export interface CustomEditorProps {
editable: boolean;
editorClassName: string;
editorProps?: EditorProps;
enableHistory: boolean;
disabledExtensions: TExtensions[];
extensions?: Extensions;
fileHandler: TFileHandler;
forwardedRef?: MutableRefObject<EditorRefApi | null>;
handleEditorReady?: (value: boolean) => void;
id?: string;
initialValue?: string;
mentionHandler: TMentionHandler;
onChange?: (json: object, html: string) => void;
onTransaction?: () => void;
autofocus?: boolean;
placeholder?: string | ((isFocused: boolean, value: string) => string);
provider?: HocuspocusProvider;
tabIndex?: number;
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
value?: string | null | undefined;
}
export const useEditor = (props: CustomEditorProps) => {
const {
autofocus = false,
disabledExtensions,
editable = true,
editorClassName = "",
editorClassName,
editorProps = {},
enableHistory,
extensions = [],
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id = "",
@@ -38,9 +69,10 @@ export const useEditor = (props: TEditorHookProps) => {
onChange,
onTransaction,
placeholder,
provider,
tabIndex,
value,
provider,
autofocus = false,
} = props;
const editor = useTiptapEditor(
@@ -62,7 +94,6 @@ export const useEditor = (props: TEditorHookProps) => {
disabledExtensions,
enableHistory,
fileHandler,
flaggedExtensions,
mentionHandler,
placeholder,
tabIndex,
@@ -1,8 +1,8 @@
import { useEditor as useTiptapEditor } from "@tiptap/react";
import { useImperativeHandle, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
import * as Y from "yjs";
// constants
import { CORE_EDITOR_META } from "@/constants/meta";
// extensions
import { CoreReadOnlyEditorExtensions } from "@/extensions";
// helpers
@@ -11,19 +11,32 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
// types
import type { TReadOnlyEditorHookProps } from "@/types";
import type { EditorReadOnlyRefApi, TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
import { CORE_EDITOR_META } from "@/constants/meta";
export const useReadOnlyEditor = (props: TReadOnlyEditorHookProps) => {
interface CustomReadOnlyEditorProps {
disabledExtensions: TExtensions[];
editorClassName: string;
editorProps?: EditorProps;
extensions?: Extensions;
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
initialValue?: string;
fileHandler: TReadOnlyFileHandler;
handleEditorReady?: (value: boolean) => void;
mentionHandler: TReadOnlyMentionHandler;
provider?: HocuspocusProvider;
}
export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
const {
disabledExtensions,
editorClassName = "",
editorProps = {},
extensions = [],
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
initialValue,
editorClassName,
forwardedRef,
extensions = [],
editorProps = {},
fileHandler,
handleEditorReady,
mentionHandler,
provider,
} = props;
@@ -46,9 +59,8 @@ export const useReadOnlyEditor = (props: TReadOnlyEditorHookProps) => {
extensions: [
...CoreReadOnlyEditorExtensions({
disabledExtensions,
fileHandler,
flaggedExtensions,
mentionHandler,
fileHandler,
}),
...extensions,
],
@@ -1,6 +1,7 @@
import { Fragment, Slice, Node, Schema } from "@tiptap/pm/model";
import { NodeSelection } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
// @ts-expect-error __serializeForClipboard's is not exported
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
@@ -416,7 +417,7 @@ const handleNodeSelection = (
}
const slice = view.state.selection.content();
const { dom, text } = view.serializeForClipboard(slice);
const { dom, text } = __serializeForClipboard(view, slice);
if (event instanceof DragEvent && event.dataTransfer) {
event.dataTransfer.clearData();

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