Compare commits

..
Author SHA1 Message Date
Aaron Reisman b4eab66e3d refactor: revert unintentional layout changes 2025-05-28 20:21:03 -07:00
Aaron Reisman d9d39199ae refactor: standardize loading spinner implementation in dynamic graph components
- Replaced inline loading divs with a shared LoadingSpinner component across all dynamic graph imports.
- Ensured consistent loading behavior for BarGraph, PieGraph, LineGraph, CalendarGraph, and ScatterPlotGraph components.
2025-05-28 20:14:25 -07:00
Aaron Reisman f30e31e294 refactor: enhance webpack configuration for client-side optimizations
- Updated webpack settings to improve tree shaking and chunk splitting strategies for client-side production builds.
- Increased maximum chunk size to reduce fragmentation and improve loading performance.
- Adjusted cache groups for better management of framework and library chunks.
2025-05-28 20:11:31 -07:00
Aaron Reisman dc57098507 chore: update dependencies and optimize dynamic imports in layout components
- Updated various dependencies in package.json and yarn.lock.
- Refactored layout components to dynamically import heavy components for improved performance.
- Enhanced webpack configuration for better chunk splitting and optimization.
2025-05-28 20:02:40 -07:00
1108 changed files with 14645 additions and 12425 deletions
+1 -2
View File
@@ -2,7 +2,6 @@
*.pyc
.env
venv
.venv
node_modules/
**/node_modules/
npm-debug.log
@@ -15,4 +14,4 @@ build/
out/
**/out/
dist/
**/dist/
**/dist/
-1
View File
@@ -290,6 +290,5 @@ jobs:
${{ github.workspace }}/deploy/selfhost/setup.sh
${{ github.workspace }}/deploy/selfhost/swarm.sh
${{ github.workspace }}/deploy/selfhost/restore.sh
${{ github.workspace }}/deploy/selfhost/restore-airgapped.sh
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
${{ github.workspace }}/deploy/selfhost/variables.env
+3 -3
View File
@@ -69,14 +69,14 @@ chmod +x setup.sh
docker compose -f docker-compose-local.yml up
```
4. Start web apps:
5. Start web apps:
```bash
yarn dev
```
5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
6. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
6. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
7. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
Thats it! Youre all set to begin coding. Remember to refresh your browser if changes dont auto-reload. Happy contributing! 🎉
+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) => {
+2 -2
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
@@ -54,7 +54,7 @@ export const InstanceSignInForm: FC = (props) => {
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorInfo, setErrorInfo] = useState<TAdminAuthErrorInfo | undefined>(undefined);
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
+2 -2
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
@@ -89,7 +89,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,
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@@ -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.28",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
@@ -50,6 +50,6 @@
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"typescript": "5.8.3"
"typescript": "5.3.3"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+1 -7
View File
@@ -58,7 +58,7 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
class WorkspaceIssueAPIEndpoint(BaseAPIView):
"""
@@ -692,9 +692,6 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
link = IssueLink.objects.get(pk=serializer.data["id"])
link.created_by_id = request.data.get("created_by", request.user.id)
@@ -722,9 +719,6 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
+2 -5
View File
@@ -148,13 +148,10 @@ class ProjectMemberAdminSerializer(BaseSerializer):
fields = "__all__"
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
original_role = serializers.IntegerField(source='role', read_only=True)
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
class Meta:
model = ProjectMember
fields = ("id", "role", "member", "project", "original_role", "created_at")
read_only_fields = ["original_role", "created_at"]
fields = ("id", "role", "member", "project")
class ProjectMemberInviteSerializer(BaseSerializer):
-16
View File
@@ -3,22 +3,11 @@ from rest_framework import serializers
# Module import
from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite
from plane.utils.url import contains_url
from .base import BaseSerializer
class UserSerializer(BaseSerializer):
def validate_first_name(self, value):
if contains_url(value):
raise serializers.ValidationError("First name cannot contain a URL.")
return value
def validate_last_name(self, value):
if contains_url(value):
raise serializers.ValidationError("Last name cannot contain a URL.")
return value
class Meta:
model = User
# Exclude password field from the serializer
@@ -110,16 +99,11 @@ class UserMeSettingsSerializer(BaseSerializer):
workspace_member__member=obj.id,
workspace_member__is_active=True,
).first()
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
return {
"last_workspace_id": profile.last_workspace_id,
"last_workspace_slug": (
workspace.slug if workspace is not None else ""
),
"last_workspace_name": (
workspace.name if workspace is not None else ""
),
"last_workspace_logo": (logo_asset_url),
"fallback_workspace_id": profile.last_workspace_id,
"fallback_workspace_slug": (
workspace.slug if workspace is not None else ""
+3 -21
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
@@ -23,12 +25,10 @@ from plane.db.models import (
WorkspaceUserPreference,
)
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.utils.url import contains_url
# Django imports
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
import re
class WorkSpaceSerializer(DynamicBaseSerializer):
@@ -36,21 +36,10 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
logo_url = serializers.CharField(read_only=True)
role = serializers.IntegerField(read_only=True)
def validate_name(self, value):
# Check if the name contains a URL
if contains_url(value):
raise serializers.ValidationError("Name must not contain URLs")
return value
def validate_slug(self, value):
# Check if the slug is restricted
if value in RESTRICTED_WORKSPACE_SLUGS:
raise serializers.ValidationError("Slug is not valid")
# Slug should only contain alphanumeric characters, hyphens, and underscores
if not re.match(r"^[a-zA-Z0-9_-]+$", value):
raise serializers.ValidationError(
"Slug can only contain letters, numbers, hyphens (-), and underscores (_)"
)
return value
class Meta:
@@ -196,7 +185,6 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField()
assignees = serializers.SerializerMethodField()
class Meta:
model = Issue
@@ -214,14 +202,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):
-19
View File
@@ -12,9 +12,6 @@ from plane.app.views import (
AssetRestoreEndpoint,
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
@@ -84,21 +81,5 @@ urlpatterns = [
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/<uuid:entity_id>/bulk/",
ProjectBulkAssetEndpoint.as_view(),
name="bulk-asset-update",
),
path(
"assets/v2/workspaces/<str:slug>/check/<uuid:asset_id>/",
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",
),
]
-3
View File
@@ -106,9 +106,6 @@ from .asset.v2 import (
AssetRestoreEndpoint,
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
from .issue.base import (
IssueListEndpoint,
-64
View File
@@ -707,67 +707,3 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
pass
return Response(status=status.HTTP_204_NO_CONTENT)
class AssetCheckEndpoint(BaseAPIView):
"""Endpoint to check if an asset exists."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
asset = FileAsset.all_objects.filter(
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)
-16
View File
@@ -15,7 +15,6 @@ from plane.app.serializers import IssueLinkSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueLink
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
from plane.utils.host import base_host
@@ -45,9 +44,6 @@ class IssueLinkViewSet(BaseViewSet):
serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
@@ -59,10 +55,6 @@ class IssueLinkViewSet(BaseViewSet):
notification=True,
origin=base_host(request=request, is_app=True),
)
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
serializer = IssueLinkSerializer(issue_link)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -74,14 +66,9 @@ class IssueLinkViewSet(BaseViewSet):
current_instance = json.dumps(
IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder
)
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
@@ -93,9 +80,6 @@ class IssueLinkViewSet(BaseViewSet):
notification=True,
origin=base_host(request=request, is_app=True),
)
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
serializer = IssueLinkSerializer(issue_link)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+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,
)
+1 -7
View File
@@ -168,8 +168,6 @@ class ProjectMemberViewSet(BaseViewSet):
workspace__slug=slug,
member__is_bot=False,
is_active=True,
member__member_workspace__workspace__slug=slug,
member__member_workspace__is_active=True,
).select_related("project", "member", "workspace")
serializer = ProjectMemberRoleSerializer(
@@ -315,11 +313,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
def get(self, request, slug):
project_members = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=request.user.id,
is_active=True,
member__member_workspace__workspace__slug=slug,
member__member_workspace__is_active=True,
workspace__slug=slug, member_id=request.user.id, is_active=True
).values("project_id", "role")
project_members = {
+2 -19
View File
@@ -3,7 +3,6 @@ import csv
import io
import os
from datetime import date
import uuid
from dateutil.relativedelta import relativedelta
from django.db import IntegrityError
@@ -36,7 +35,6 @@ from plane.db.models import (
Workspace,
WorkspaceMember,
WorkspaceTheme,
Profile,
)
from plane.app.permissions import ROLE, allow_permission
from django.utils.decorators import method_decorator
@@ -45,7 +43,6 @@ from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.workspace_seed_task import workspace_seed
from plane.utils.url import contains_url
class WorkSpaceViewSet(BaseViewSet):
@@ -112,12 +109,6 @@ class WorkSpaceViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
if contains_url(name):
return Response(
{"error": "Name cannot contain a URL"},
status=status.HTTP_400_BAD_REQUEST,
)
if serializer.is_valid(raise_exception=True):
serializer.save(owner=request.user)
# Create Workspace member
@@ -159,18 +150,8 @@ class WorkSpaceViewSet(BaseViewSet):
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
def remove_last_workspace_ids_from_user_settings(self, id: uuid.UUID) -> None:
"""
Remove the last workspace id from the user settings
"""
Profile.objects.filter(last_workspace_id=id).update(last_workspace_id=None)
return
@allow_permission([ROLE.ADMIN], level="WORKSPACE")
def destroy(self, request, *args, **kwargs):
# Get the workspace
workspace = self.get_object()
self.remove_last_workspace_ids_from_user_settings(workspace.id)
return super().destroy(request, *args, **kwargs)
@@ -178,6 +159,8 @@ class UserWorkSpacesEndpoint(BaseAPIView):
search_fields = ["name"]
filterset_fields = ["owner"]
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def get(self, request):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
member_count = (
@@ -1,6 +1,5 @@
# Django imports
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
from django.utils import timezone
from django.db.models.functions import Coalesce
# Third party modules
@@ -134,7 +133,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False, updated_at=timezone.now())
).update(is_active=False)
workspace_member.is_active = False
workspace_member.save()
@@ -195,7 +194,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False, updated_at=timezone.now())
).update(is_active=False)
# # Deactivate the user
workspace_member.is_active = False
@@ -284,7 +284,6 @@ def send_email_notification(
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
"entity_type": "issue",
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
@@ -1,177 +0,0 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlparse, urljoin
import base64
import ipaddress
from typing import Dict, Any
from typing import Optional
from plane.db.models import IssueLink
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
"""
Crawls a URL to extract the title and favicon.
Args:
url (str): The URL to crawl
Returns:
str: JSON string containing title and base64-encoded favicon
"""
try:
# Prevent access to private IP ranges
parsed = urlparse(url)
try:
ip = ipaddress.ip_address(parsed.hostname)
if ip.is_private or ip.is_loopback or ip.is_reserved:
raise ValueError("Access to private/internal networks is not allowed")
except ValueError:
# Not an IP address, continue with domain validation
pass
# Set up headers to mimic a real browser
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501
}
soup = None
title = None
try:
response = requests.get(url, headers=headers, timeout=1)
soup = BeautifulSoup(response.content, "html.parser")
title_tag = soup.find("title")
title = title_tag.get_text().strip() if title_tag else None
except requests.RequestException as e:
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
# Fetch and encode favicon
favicon_base64 = fetch_and_encode_favicon(headers, soup, url)
# Prepare result
result = {
"title": title,
"favicon": favicon_base64["favicon_base64"],
"url": url,
"favicon_url": favicon_base64["favicon_url"],
}
return result
except Exception as e:
log_exception(e)
return {
"error": f"Unexpected error: {str(e)}",
"title": None,
"favicon": None,
"url": url,
}
def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[str]:
"""
Find the favicon URL from HTML soup.
Args:
soup: BeautifulSoup object
base_url: Base URL for resolving relative paths
Returns:
str: Absolute URL to favicon or None
"""
if soup is not None:
# Look for various favicon link tags
favicon_selectors = [
'link[rel="icon"]',
'link[rel="shortcut icon"]',
'link[rel="apple-touch-icon"]',
'link[rel="apple-touch-icon-precomposed"]',
]
for selector in favicon_selectors:
favicon_tag = soup.select_one(selector)
if favicon_tag and favicon_tag.get("href"):
return urljoin(base_url, favicon_tag["href"])
# Fallback to /favicon.ico
parsed_url = urlparse(base_url)
fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico"
# Check if fallback exists
try:
response = requests.head(fallback_url, timeout=2)
if response.status_code == 200:
return fallback_url
except requests.RequestException as e:
log_exception(e)
return None
return None
def fetch_and_encode_favicon(
headers: Dict[str, str], soup: Optional[BeautifulSoup], url: str
) -> Dict[str, Optional[str]]:
"""
Fetch favicon and encode it as base64.
Args:
favicon_url: URL to the favicon
headers: Request headers
Returns:
str: Base64 encoded favicon with data URI prefix or None
"""
try:
favicon_url = find_favicon_url(soup, url)
if favicon_url is None:
return {
"favicon_url": None,
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
response = requests.get(favicon_url, headers=headers, timeout=1)
# Get content type
content_type = response.headers.get("content-type", "image/x-icon")
# Convert to base64
favicon_base64 = base64.b64encode(response.content).decode("utf-8")
# Return as data URI
return {
"favicon_url": favicon_url,
"favicon_base64": f"data:{content_type};base64,{favicon_base64}",
}
except Exception as e:
logger.warning(f"Failed to fetch favicon: {e}")
return {
"favicon_url": None,
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
@shared_task
def crawl_work_item_link_title(id: str, url: str) -> None:
meta_data = crawl_work_item_link_title_and_favicon(url)
issue_link = IssueLink.objects.get(id=id)
issue_link.metadata = meta_data
issue_link.save()
@@ -1,23 +0,0 @@
# Generated by Django 4.2.21 on 2025-06-06 12:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0096_user_is_email_valid_user_masked_at'),
]
operations = [
migrations.AddField(
model_name='project',
name='external_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='project',
name='external_source',
field=models.CharField(blank=True, max_length=255, null=True),
),
]
-3
View File
@@ -122,9 +122,6 @@ class Project(BaseModel):
# timezone
TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
# external_id for imports
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
@property
def cover_image_url(self):
@@ -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="test_user_1@example.com", first_name="Test", last_name="User"
)
# To test for deleted issue assignee
test_user_2 = User.objects.create(
email="test_user_2@example.com",
first_name="Other",
last_name="User",
username="some user name",
)
workspace = Workspace.objects.create(
name="Test Workspace", slug="test-workspace", owner=test_user_1
)
WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace)
project = Project.objects.create(
name="Test Project", identifier="test-project", workspace=workspace
)
ProjectMember.objects.create(project=project, member=test_user_2)
issue = Issue.objects.create(
name="Test Issue",
workspace=workspace,
project=project,
)
IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project)
# Deleted issue assignee
IssueAssignee.objects.create(
issue=issue,
assignee=test_user_2,
project=project,
deleted_at=timezone.now(),
)
serialized_data = IssueRecentVisitSerializer(
issue,
).data
# Check fields are present and correct
assert "name" in serialized_data
assert "assignees" in serialized_data
assert "project_identifier" in serialized_data
assert serialized_data["name"] == "Test Issue"
assert serialized_data["project_identifier"] == "TEST-PROJECT"
# Only including non-deleted issue assignees
assert serialized_data["assignees"] == [test_user_1.id]
-8
View File
@@ -4,14 +4,6 @@ from typing import Optional
from urllib.parse import urlparse, urlunparse
def contains_url(value: str) -> bool:
"""
Check if the value contains a URL.
"""
url_pattern = re.compile(r"https?://|www\\.")
return bool(url_pattern.search(value))
def is_valid_url(url: str) -> bool:
"""
Validates whether the given string is a well-formed URL.
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.22
Django==4.2.21
# rest framework
djangorestframework==3.15.2
# postgres
+1 -1
View File
@@ -9,4 +9,4 @@ factory-boy==3.3.0
freezegun==1.2.2
coverage==7.2.7
httpx==0.24.1
requests==2.32.4
requests==2.32.2
@@ -3,7 +3,7 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Updates on {{entity_type}}</title>
<title>Updates on issue</title>
<style type="text/css" emogrify="no"> html { font-family: system-ui; } p, h1, h2, h3, h4, ol, ul { margin: 0; } h-full { height: 100%; } a:hover { color: #3358d4 !important; } </style>
<style> *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } } </style>
@@ -37,7 +37,7 @@
{% else %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> {{summary}} <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %} </span>and others. </p>
{% endif %} <!-- {% if actors_involved == 1 %} {% if data|length > 0 and comments|length == 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} </span> made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} to the issue. </p> {% elif data|length == 0 and comments|length > 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name }} </span> added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %}. </p> {% elif data|length > 0 and comments|length > 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} </span> made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} and added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %} on the issue. </p> {% endif %} {% else %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> There are {{ total_updates }} new updates and {{total_comments}} new comments on the issue. </p> {% endif %} --> {% for update in data %} {% if update.changes.name %} <!-- Issue title updated -->
<p style="font-size: 1rem; line-height: 28px; color: #1f2d5c"> The {{entity_type}} title has been updated to {{ issue.name}} </p>
<p style="font-size: 1rem; line-height: 28px; color: #1f2d5c"> The issue title has been updated to {{ issue.name}} </p>
{% endif %} <!-- Outer update Box start --> {% if data %}
<div style=" background-color: #f7f9ff; border-radius: 8px; border-style: solid; border-width: 1px; border-color: #c1d0ff; padding: 20px; margin-top: 15px; max-width: 100%; " >
<!-- Block Heading -->
@@ -224,7 +224,7 @@
{% endif %}
</div>
<a href="{{ issue_url }}" style="text-decoration: none;">
<div style=" max-width: min-content; white-space: nowrap; background-color: #3e63dd; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: white; " > View {{entity_type}} </div>
<div style=" max-width: min-content; white-space: nowrap; background-color: #3e63dd; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: white; " > View issue </div>
</a>
</div>
<!-- Footer -->
@@ -232,7 +232,7 @@
<tr>
<td>
<div style="font-size: 0.8rem; color: #1c2024">
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the {{entity_type}}</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the issue</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://twitter.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
</div>
</td>
+1 -26
View File
@@ -486,7 +486,7 @@ When you want to restore the previously backed-up data, follow the instructions
1. Download the restore script using the command below. We suggest downloading it in the same folder as `setup.sh`.
```bash
curl -fsSL -o restore.sh https://github.com/makeplane/plane/releases/latest/download/restore.sh
curl -fsSL -o restore.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/restore.sh
chmod +x restore.sh
```
@@ -529,31 +529,6 @@ When you want to restore the previously backed-up data, follow the instructions
---
### Restore for Commercial Air-Gapped (Docker Compose)
When you want to restore the previously backed-up data on Plane Commercial Air-Gapped version, follow the instructions below.
1. Download the restore script using the command below
```bash
curl -fsSL -o restore-airgapped.sh https://github.com/makeplane/plane/releases/latest/download/restore-airgapped.sh
chmod +x restore-airgapped.sh
```
1. Copy the backup folder and the `restore-airgapped.sh` to `Commercial Airgapped Edition` server
1. Make sure that Plane Commercial (Airgapped) is extracted and ready to get started. In case it is running, you would need to stop that.
1. Execute the command below to restore your data.
```bash
./restore-airgapped.sh <path to backup folder containing *.tar.gz files>
```
1. After restoration, you are ready to start Plane Commercial (Airgapped) will all your previously saved data.
---
<details>
<summary><h2>Upgrading from v0.13.2 to v0.14.x</h2></summary>
-144
View File
@@ -1,144 +0,0 @@
#!/bin/bash
+set -euo pipefail
function print_header() {
clear
cat <<"EOF"
--------------------------------------------
____ _ /////////
| _ \| | __ _ _ __ ___ /////////
| |_) | |/ _` | '_ \ / _ \ ///// /////
| __/| | (_| | | | | __/ ///// /////
|_| |_|\__,_|_| |_|\___| ////
////
--------------------------------------------
Project management tool from the future
--------------------------------------------
EOF
}
function restoreData() {
echo ""
echo "****************************************************"
echo "We are about to restore your data from the backup files."
echo "****************************************************"
echo ""
# set the backup folder path
BACKUP_FOLDER=${1}
if [ -z "$BACKUP_FOLDER" ]; then
BACKUP_FOLDER="$PWD/backup"
read -p "Enter the backup folder path [$BACKUP_FOLDER]: " BACKUP_FOLDER
if [ -z "$BACKUP_FOLDER" ]; then
BACKUP_FOLDER="$PWD/backup"
fi
fi
# check if the backup folder exists
if [ ! -d "$BACKUP_FOLDER" ]; then
echo "Error: Backup folder not found at $BACKUP_FOLDER"
exit 1
fi
# check if there are any .tar.gz files in the backup folder
if ! ls "$BACKUP_FOLDER"/*.tar.gz 1> /dev/null 2>&1; then
echo "Error: Backup folder does not contain .tar.gz files"
exit 1
fi
echo ""
echo "Using backup folder: $BACKUP_FOLDER"
echo ""
# ask for current install path
AIRGAPPED_INSTALL_PATH="$HOME/planeairgapped"
read -p "Enter the airgapped instance install path [$AIRGAPPED_INSTALL_PATH]: " AIRGAPPED_INSTALL_PATH
if [ -z "$AIRGAPPED_INSTALL_PATH" ]; then
AIRGAPPED_INSTALL_PATH="$HOME/planeairgapped"
fi
# check if the airgapped instance install path exists
if [ ! -d "$AIRGAPPED_INSTALL_PATH" ]; then
echo "Error: Airgapped instance install path not found at $AIRGAPPED_INSTALL_PATH"
exit 1
fi
echo ""
echo "Using airgapped instance install path: $AIRGAPPED_INSTALL_PATH"
echo ""
# check if the docker-compose.yaml exists
if [ ! -f "$AIRGAPPED_INSTALL_PATH/docker-compose.yml" ]; then
echo "Error: docker-compose.yml not found at $AIRGAPPED_INSTALL_PATH/docker-compose.yml"
exit 1
fi
local dockerServiceStatus
if command -v jq &> /dev/null; then
dockerServiceStatus=$($COMPOSE_CMD ls --filter name=plane-airgapped --format=json | jq -r .[0].Status)
else
dockerServiceStatus=$($COMPOSE_CMD ls --filter name=plane-airgapped | grep -o "running" | head -n 1)
fi
if [[ $dockerServiceStatus == "running" ]]; then
echo "Plane Airgapped is running. Please STOP the Plane Airgapped before restoring data."
exit 1
fi
CURRENT_USER_ID=$(id -u)
CURRENT_GROUP_ID=$(id -g)
# if the data folder not exists, create it
if [ ! -d "$AIRGAPPED_INSTALL_PATH/data" ]; then
mkdir -p "$AIRGAPPED_INSTALL_PATH/data"
chown -R $CURRENT_USER_ID:$CURRENT_GROUP_ID "$AIRGAPPED_INSTALL_PATH/data"
fi
for BACKUP_FILE in "$BACKUP_FOLDER/*.tar.gz"; do
if [ -e "$BACKUP_FILE" ]; then
# get the basefilename without the extension
BASE_FILE_NAME=$(basename "$BACKUP_FILE" ".tar.gz")
# extract the restoreFile to the airgapped instance install path
echo "Restoring $BASE_FILE_NAME"
rm -rf "$AIRGAPPED_INSTALL_PATH/data/$BASE_FILE_NAME" || true
tar -xvzf "$BACKUP_FILE" -C "$AIRGAPPED_INSTALL_PATH/data/"
if [ $? -ne 0 ]; then
echo "Error: Failed to extract $BACKUP_FILE"
exit 1
fi
chown -R $CURRENT_USER_ID:$CURRENT_GROUP_ID "$AIRGAPPED_INSTALL_PATH/data/$BASE_FILE_NAME"
if [ $? -ne 0 ]; then
echo "Error: Failed to change ownership of $AIRGAPPED_INSTALL_PATH/data/$BASE_FILE_NAME"
exit 1
fi
else
echo "No .tar.gz files found in the current directory."
echo ""
echo "Please provide the path to the backup file."
echo ""
echo "Usage: $0 /path/to/backup"
exit 1
fi
done
echo ""
echo "Restore completed successfully."
echo ""
}
# if docker-compose is installed
if command -v docker-compose &> /dev/null
then
COMPOSE_CMD="docker-compose"
else
COMPOSE_CMD="docker compose"
fi
print_header
restoreData "$@"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./src/server.ts",
@@ -58,6 +58,6 @@
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsup": "8.4.0",
"typescript": "5.8.3"
"typescript": "5.3.3"
}
}
+3 -5
View File
@@ -2,7 +2,7 @@
"name": "plane",
"description": "Open-source project management that unlocks customer value",
"repository": "https://github.com/makeplane/plane.git",
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -24,16 +24,14 @@
"devDependencies": {
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"turbo": "^2.5.4"
"turbo": "^2.5.3"
},
"resolutions": {
"brace-expansion": "2.0.2",
"nanoid": "3.3.8",
"esbuild": "0.25.0",
"@babel/helpers": "7.26.10",
"@babel/runtime": "7.26.10",
"chokidar": "3.6.0",
"tar-fs": "3.0.9"
"chokidar": "3.6.0"
},
"packageManager": "yarn@1.22.22"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.26.1",
"version": "0.26.0",
"private": true,
"main": "./src/index.ts",
"license": "AGPL-3.0"
@@ -0,0 +1,105 @@
import { TAnalyticsTabsV2Base } from "@plane/types";
import { ChartXAxisProperty, ChartYAxisMetric } from "../chart";
export const insightsFields: Record<TAnalyticsTabsV2Base, string[]> = {
overview: [
"total_users",
"total_admins",
"total_members",
"total_guests",
"total_projects",
"total_work_items",
"total_cycles",
"total_intake",
],
"work-items": [
"total_work_items",
"started_work_items",
"backlog_work_items",
"un_started_work_items",
"completed_work_items",
],
};
export const ANALYTICS_V2_DURATION_FILTER_OPTIONS = [
{
name: "Yesterday",
value: "yesterday",
},
{
name: "Last 7 days",
value: "last_7_days",
},
{
name: "Last 30 days",
value: "last_30_days",
},
{
name: "Last 3 months",
value: "last_3_months",
},
];
export const ANALYTICS_V2_X_AXIS_VALUES: { value: ChartXAxisProperty; label: string }[] = [
{
value: ChartXAxisProperty.STATES,
label: "State name",
},
{
value: ChartXAxisProperty.STATE_GROUPS,
label: "State group",
},
{
value: ChartXAxisProperty.PRIORITY,
label: "Priority",
},
{
value: ChartXAxisProperty.LABELS,
label: "Label",
},
{
value: ChartXAxisProperty.ASSIGNEES,
label: "Assignee",
},
{
value: ChartXAxisProperty.ESTIMATE_POINTS,
label: "Estimate point",
},
{
value: ChartXAxisProperty.CYCLES,
label: "Cycle",
},
{
value: ChartXAxisProperty.MODULES,
label: "Module",
},
{
value: ChartXAxisProperty.COMPLETED_AT,
label: "Completed date",
},
{
value: ChartXAxisProperty.TARGET_DATE,
label: "Due date",
},
{
value: ChartXAxisProperty.START_DATE,
label: "Start date",
},
{
value: ChartXAxisProperty.CREATED_AT,
label: "Created date",
},
];
export const ANALYTICS_V2_Y_AXIS_VALUES: { value: ChartYAxisMetric; label: string }[] = [
{
value: ChartYAxisMetric.WORK_ITEM_COUNT,
label: "Work item",
},
{
value: ChartYAxisMetric.ESTIMATE_POINT_COUNT,
label: "Estimate",
},
];
export const ANALYTICS_V2_DATE_KEYS = ["completed_at", "target_date", "start_date", "created_at"];
+81
View File
@@ -0,0 +1,81 @@
// types
import { TXAxisValues, TYAxisValues } from "@plane/types";
export const ANALYTICS_TABS = [
{
key: "scope_and_demand",
i18n_title: "workspace_analytics.tabs.scope_and_demand",
},
{ key: "custom", i18n_title: "workspace_analytics.tabs.custom" },
];
export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] =
[
{
value: "state_id",
label: "State name",
},
{
value: "state__group",
label: "State group",
},
{
value: "priority",
label: "Priority",
},
{
value: "labels__id",
label: "Label",
},
{
value: "assignees__id",
label: "Assignee",
},
{
value: "estimate_point__value",
label: "Estimate point",
},
{
value: "issue_cycle__cycle_id",
label: "Cycle",
},
{
value: "issue_module__module_id",
label: "Module",
},
{
value: "completed_at",
label: "Completed date",
},
{
value: "target_date",
label: "Due date",
},
{
value: "start_date",
label: "Start date",
},
{
value: "created_at",
label: "Created date",
},
];
export const ANALYTICS_Y_AXIS_VALUES: { value: TYAxisValues; label: string }[] =
[
{
value: "issue_count",
label: "Work item Count",
},
{
value: "estimate",
label: "Estimate",
},
];
export const ANALYTICS_DATE_KEYS = [
"completed_at",
"target_date",
"start_date",
"created_at",
];
-178
View File
@@ -1,178 +0,0 @@
import { TAnalyticsTabsBase } from "@plane/types";
import { ChartXAxisProperty, ChartYAxisMetric } from "../chart";
export interface IInsightField {
key: string;
i18nKey: string;
i18nProps?: {
entity?: string;
entityPlural?: string;
[key: string]: any;
};
}
export const ANALYTICS_INSIGHTS_FIELDS: Record<TAnalyticsTabsBase, IInsightField[]> = {
overview: [
{
key: "total_users",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.users",
},
},
{
key: "total_admins",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.admins",
},
},
{
key: "total_members",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.members",
},
},
{
key: "total_guests",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.guests",
},
},
{
key: "total_projects",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.projects",
},
},
{
key: "total_work_items",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.work_items",
},
},
{
key: "total_cycles",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.cycles",
},
},
{
key: "total_intake",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "sidebar.intake",
},
},
],
"work-items": [
{
key: "total_work_items",
i18nKey: "workspace_analytics.total",
},
{
key: "started_work_items",
i18nKey: "workspace_analytics.started_work_items",
},
{
key: "backlog_work_items",
i18nKey: "workspace_analytics.backlog_work_items",
},
{
key: "un_started_work_items",
i18nKey: "workspace_analytics.un_started_work_items",
},
{
key: "completed_work_items",
i18nKey: "workspace_analytics.completed_work_items",
},
],
};
export const ANALYTICS_DURATION_FILTER_OPTIONS = [
{
name: "Yesterday",
value: "yesterday",
},
{
name: "Last 7 days",
value: "last_7_days",
},
{
name: "Last 30 days",
value: "last_30_days",
},
{
name: "Last 3 months",
value: "last_3_months",
},
];
export const ANALYTICS_X_AXIS_VALUES: { value: ChartXAxisProperty; label: string }[] = [
{
value: ChartXAxisProperty.STATES,
label: "State name",
},
{
value: ChartXAxisProperty.STATE_GROUPS,
label: "State group",
},
{
value: ChartXAxisProperty.PRIORITY,
label: "Priority",
},
{
value: ChartXAxisProperty.LABELS,
label: "Label",
},
{
value: ChartXAxisProperty.ASSIGNEES,
label: "Assignee",
},
{
value: ChartXAxisProperty.ESTIMATE_POINTS,
label: "Estimate point",
},
{
value: ChartXAxisProperty.CYCLES,
label: "Cycle",
},
{
value: ChartXAxisProperty.MODULES,
label: "Module",
},
{
value: ChartXAxisProperty.COMPLETED_AT,
label: "Completed date",
},
{
value: ChartXAxisProperty.TARGET_DATE,
label: "Due date",
},
{
value: ChartXAxisProperty.START_DATE,
label: "Start date",
},
{
value: ChartXAxisProperty.CREATED_AT,
label: "Created date",
},
];
export const ANALYTICS_Y_AXIS_VALUES: { value: ChartYAxisMetric; label: string }[] = [
{
value: ChartYAxisMetric.WORK_ITEM_COUNT,
label: "Work item",
},
{
value: ChartYAxisMetric.ESTIMATE_POINT_COUNT,
label: "Estimate",
},
];
export const ANALYTICS_V2_DATE_KEYS = ["completed_at", "target_date", "start_date", "created_at"];
+1 -8
View File
@@ -69,7 +69,7 @@ export enum EErrorAlertType {
export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAuthErrorCodes;
code: EAdminAuthErrorCodes;
title: string;
message: any;
};
@@ -87,13 +87,6 @@ export enum EAdminAuthErrorCodes {
ADMIN_USER_DEACTIVATED = "5190",
}
export type TAdminAuthErrorInfo = {
type: EErrorAlertType;
code: EAdminAuthErrorCodes;
title: string;
message: any;
};
export enum EAuthErrorCodes {
// Global
INSTANCE_NOT_CONFIGURED = "5000",
+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 || "support@plane.so";
export const SUPPORT_EMAIL =
process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
// marketing links
export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing";
export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact";
@@ -95,32 +95,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,
},
];
+3 -4
View File
@@ -1,4 +1,5 @@
export * from "./ai";
export * from "./analytics";
export * from "./auth";
export * from "./chart";
export * from "./endpoints";
@@ -21,7 +22,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";
@@ -31,7 +32,5 @@ export * from "./dashboard";
export * from "./page";
export * from "./emoji";
export * from "./subscription";
export * from "./settings";
export * from "./icon";
export * from "./estimates";
export * from "./analytics";
export * from "./analytics-v2";
+39 -1
View File
@@ -136,7 +136,45 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state", "cycle", "module", "priority", "labels", "assignees", "created_by", null],
group_by: [
"state",
"cycle",
"module",
"state_detail.group",
"priority",
"labels",
"assignees",
"created_by",
null,
],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
extra_options: {
access: true,
values: ["show_empty_groups"],
},
},
},
draft_issues: {
list: {
filters: ["priority", "state_group", "cycle", "module", "labels", "start_date", "target_date", "issue_type"],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state_detail.group", "cycle", "module", "priority", "project", "labels", null],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
extra_options: {
access: true,
values: ["show_empty_groups"],
},
},
kanban: {
filters: ["priority", "state_group", "cycle", "module", "labels", "start_date", "target_date", "issue_type"],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state_detail.group", "cycle", "module", "priority", "project", "labels"],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
+11 -18
View File
@@ -1,16 +1,9 @@
// types
import { TModuleLayoutOptions, TModuleOrderByOptions, TModuleStatus } from "@plane/types";
export const MODULE_STATUS_COLORS: {
[key in TModuleStatus]: string;
} = {
backlog: "#a3a3a2",
planned: "#3f76ff",
paused: "#525252",
completed: "#16a34a",
cancelled: "#ef4444",
"in-progress": "#f39e1f",
};
import {
TModuleLayoutOptions,
TModuleOrderByOptions,
TModuleStatus,
} from "@plane/types";
export const MODULE_STATUS: {
i18n_label: string;
@@ -22,42 +15,42 @@ export const MODULE_STATUS: {
{
i18n_label: "project_modules.status.backlog",
value: "backlog",
color: MODULE_STATUS_COLORS.backlog,
color: "#a3a3a2",
textColor: "text-custom-text-400",
bgColor: "bg-custom-background-80",
},
{
i18n_label: "project_modules.status.planned",
value: "planned",
color: MODULE_STATUS_COLORS.planned,
color: "#3f76ff",
textColor: "text-blue-500",
bgColor: "bg-indigo-50",
},
{
i18n_label: "project_modules.status.in_progress",
value: "in-progress",
color: MODULE_STATUS_COLORS["in-progress"],
color: "#f39e1f",
textColor: "text-amber-500",
bgColor: "bg-amber-50",
},
{
i18n_label: "project_modules.status.paused",
value: "paused",
color: MODULE_STATUS_COLORS.paused,
color: "#525252",
textColor: "text-custom-text-300",
bgColor: "bg-custom-background-90",
},
{
i18n_label: "project_modules.status.completed",
value: "completed",
color: MODULE_STATUS_COLORS.completed,
color: "#16a34a",
textColor: "text-green-600",
bgColor: "bg-green-100",
},
{
i18n_label: "project_modules.status.cancelled",
value: "cancelled",
color: MODULE_STATUS_COLORS.cancelled,
color: "#ef4444",
textColor: "text-red-500",
bgColor: "bg-red-50",
},
+7 -7
View File
@@ -72,23 +72,23 @@ export const PLANE_COMMUNITY_PRODUCTS: Record<string, IPaymentProduct> = {
prices: [
{
id: `price_yearly_${EProductSubscriptionEnum.BUSINESS}`,
unit_amount: 15600,
unit_amount: 0,
recurring: "year",
currency: "usd",
workspace_amount: 15600,
workspace_amount: 0,
product: EProductSubscriptionEnum.BUSINESS,
},
{
id: `price_monthly_${EProductSubscriptionEnum.BUSINESS}`,
unit_amount: 1500,
unit_amount: 0,
recurring: "month",
currency: "usd",
workspace_amount: 1500,
workspace_amount: 0,
product: EProductSubscriptionEnum.BUSINESS,
},
],
payment_quantity: 1,
is_active: true,
is_active: false,
},
[EProductSubscriptionEnum.ENTERPRISE]: {
id: EProductSubscriptionEnum.ENTERPRISE,
@@ -141,8 +141,8 @@ export const SUBSCRIPTION_REDIRECTION_URLS: Record<EProductSubscriptionEnum, Rec
year: "https://app.plane.so/upgrade/pro/self-hosted?plan=year",
},
[EProductSubscriptionEnum.BUSINESS]: {
month: "https://app.plane.so/upgrade/business/self-hosted?plan=month",
year: "https://app.plane.so/upgrade/business/self-hosted?plan=year",
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
[EProductSubscriptionEnum.ENTERPRISE]: {
month: TALK_TO_SALES_URL,
+30 -61
View File
@@ -1,53 +1,39 @@
export const PROFILE_SETTINGS = {
profile: {
key: "profile",
i18n_label: "profile.actions.profile",
href: `/settings/account`,
highlight: (pathname: string) => pathname === "/settings/account/",
},
security: {
key: "security",
i18n_label: "profile.actions.security",
href: `/settings/account/security`,
highlight: (pathname: string) => pathname === "/settings/account/security/",
},
activity: {
key: "activity",
i18n_label: "profile.actions.activity",
href: `/settings/account/activity`,
highlight: (pathname: string) => pathname === "/settings/account/activity/",
},
preferences: {
key: "preferences",
i18n_label: "profile.actions.preferences",
href: `/settings/account/preferences`,
highlight: (pathname: string) => pathname === "/settings/account/preferences",
},
notifications: {
key: "notifications",
i18n_label: "profile.actions.notifications",
href: `/settings/account/notifications`,
highlight: (pathname: string) => pathname === "/settings/account/notifications/",
},
"api-tokens": {
key: "api-tokens",
i18n_label: "profile.actions.api-tokens",
href: `/settings/account/api-tokens`,
highlight: (pathname: string) => pathname === "/settings/account/api-tokens/",
},
};
export const PROFILE_ACTION_LINKS: {
key: string;
i18n_label: string;
href: string;
highlight: (pathname: string) => boolean;
}[] = [
PROFILE_SETTINGS["profile"],
PROFILE_SETTINGS["security"],
PROFILE_SETTINGS["activity"],
PROFILE_SETTINGS["preferences"],
PROFILE_SETTINGS["notifications"],
PROFILE_SETTINGS["api-tokens"],
{
key: "profile",
i18n_label: "profile.actions.profile",
href: `/profile`,
highlight: (pathname: string) => pathname === "/profile/",
},
{
key: "security",
i18n_label: "profile.actions.security",
href: `/profile/security`,
highlight: (pathname: string) => pathname === "/profile/security/",
},
{
key: "activity",
i18n_label: "profile.actions.activity",
href: `/profile/activity`,
highlight: (pathname: string) => pathname === "/profile/activity/",
},
{
key: "appearance",
i18n_label: "profile.actions.appearance",
href: `/profile/appearance`,
highlight: (pathname: string) => pathname.includes("/profile/appearance"),
},
{
key: "notifications",
i18n_label: "profile.actions.notifications",
href: `/profile/notifications`,
highlight: (pathname: string) => pathname === "/profile/notifications/",
},
];
export const PROFILE_VIEWER_TAB = [
@@ -86,23 +72,6 @@ export const PROFILE_ADMINS_TAB = [
},
];
export const PREFERENCE_OPTIONS: {
id: string;
title: string;
description: string;
}[] = [
{
id: "theme",
title: "theme",
description: "select_or_customize_your_interface_color_scheme",
},
{
id: "start_of_week",
title: "First day of the week",
description: "This will change how all calendars in your app look.",
},
];
/**
* @description The start of the week for the user
* @enum {number}
-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",
}
-52
View File
@@ -1,52 +0,0 @@
import { PROFILE_SETTINGS } from ".";
import { WORKSPACE_SETTINGS } from "./workspace";
export enum WORKSPACE_SETTINGS_CATEGORY {
ADMINISTRATION = "administration",
FEATURES = "features",
DEVELOPER = "developer",
}
export enum PROFILE_SETTINGS_CATEGORY {
YOUR_PROFILE = "your profile",
DEVELOPER = "developer",
}
export enum PROJECT_SETTINGS_CATEGORY {
PROJECTS = "projects",
}
export const WORKSPACE_SETTINGS_CATEGORIES = [
WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION,
WORKSPACE_SETTINGS_CATEGORY.FEATURES,
WORKSPACE_SETTINGS_CATEGORY.DEVELOPER,
];
export const PROFILE_SETTINGS_CATEGORIES = [
PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE,
PROFILE_SETTINGS_CATEGORY.DEVELOPER,
];
export const PROJECT_SETTINGS_CATEGORIES = [PROJECT_SETTINGS_CATEGORY.PROJECTS];
export const GROUPED_WORKSPACE_SETTINGS = {
[WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION]: [
WORKSPACE_SETTINGS["general"],
WORKSPACE_SETTINGS["members"],
WORKSPACE_SETTINGS["billing-and-plans"],
WORKSPACE_SETTINGS["export"],
],
[WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [],
[WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]],
};
export const GROUPED_PROFILE_SETTINGS = {
[PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [
PROFILE_SETTINGS["profile"],
PROFILE_SETTINGS["preferences"],
PROFILE_SETTINGS["notifications"],
PROFILE_SETTINGS["security"],
PROFILE_SETTINGS["activity"],
],
[PROFILE_SETTINGS_CATEGORY.DEVELOPER]: [PROFILE_SETTINGS["api-tokens"]],
};
-1
View File
@@ -1,5 +1,4 @@
"use client"
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export type TDraggableData = {
+8
View File
@@ -114,6 +114,13 @@ export const WORKSPACE_SETTINGS = {
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`,
},
"api-tokens": {
key: "api-tokens",
i18n_label: "workspace_settings.settings.api_tokens.title",
href: `/settings/api-tokens`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`,
},
};
export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries(
@@ -132,6 +139,7 @@ export const WORKSPACE_SETTINGS_LINKS: {
WORKSPACE_SETTINGS["billing-and-plans"],
WORKSPACE_SETTINGS["export"],
WORKSPACE_SETTINGS["webhooks"],
WORKSPACE_SETTINGS["api-tokens"],
];
export const ROLE = {
+1 -1
View File
@@ -28,7 +28,7 @@
"@types/reflect-metadata": "^0.1.0",
"@types/ws": "^8.5.10",
"tsup": "8.4.0",
"typescript": "5.8.3"
"typescript": "^5.3.3"
},
"peerDependencies": {
"express": ">=4.21.2",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.26.1",
"version": "0.26.0",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,
@@ -82,7 +82,7 @@
"@types/react-dom": "^18.2.18",
"postcss": "^8.4.38",
"tsup": "8.4.0",
"typescript": "5.8.3"
"typescript": "5.3.3"
},
"keywords": [
"editor",
@@ -2,31 +2,30 @@ import { HocuspocusProvider } from "@hocuspocus/provider";
import { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
// plane editor types
import { TEmbedConfig } from "@/plane-editor/types";
import { TIssueEmbedConfig } from "@/plane-editor/types";
// types
import { TExtensions, TFileHandler, TUserDetails } from "@/types";
import { TExtensions, TUserDetails } from "@/types";
export type TDocumentEditorAdditionalExtensionsProps = {
disabledExtensions: TExtensions[];
embedConfig: TEmbedConfig | undefined;
fileHandler: TFileHandler;
provider?: HocuspocusProvider;
type Props = {
disabledExtensions?: TExtensions[];
issueEmbedConfig: TIssueEmbedConfig | undefined;
provider: HocuspocusProvider;
userDetails: TUserDetails;
};
export type TDocumentEditorAdditionalExtensionsRegistry = {
type ExtensionConfig = {
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension;
getExtension: (props: Props) => AnyExtension;
};
const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [
const extensionRegistry: ExtensionConfig[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions }) => SlashCommands({ disabledExtensions }),
getExtension: () => SlashCommands({}),
},
];
export const DocumentEditorAdditionalExtensions = (_props: TDocumentEditorAdditionalExtensionsProps) => {
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const { disabledExtensions = [] } = _props;
const documentExtensions = extensionRegistry
@@ -1,41 +0,0 @@
import { AnyExtension, Extensions } from "@tiptap/core";
// extensions
import { SlashCommands } from "@/extensions/slash-commands/root";
// types
import { TExtensions, TFileHandler } from "@/types";
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[]) => boolean;
/** Returns the extension instance(s) when enabled */
getExtension: (props: TRichTextEditorAdditionalExtensionsProps) => AnyExtension | undefined;
};
const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions }) =>
SlashCommands({
disabledExtensions,
}),
},
];
export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => {
const { disabledExtensions } = props;
const extensions: Extensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(props))
.filter((extension): extension is AnyExtension => extension !== undefined);
return extensions;
};
@@ -1,31 +0,0 @@
import { AnyExtension, Extensions } from "@tiptap/core";
// types
import { TExtensions, TReadOnlyFileHandler } from "@/types";
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = {
disabledExtensions: TExtensions[];
fileHandler: TReadOnlyFileHandler;
};
/**
* Registry entry configuration for extensions
*/
export type TRichTextReadOnlyEditorAdditionalExtensionsRegistry = {
/** Determines if the extension should be enabled based on disabled extensions */
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
/** Returns the extension instance(s) when enabled */
getExtension: (props: TRichTextReadOnlyEditorAdditionalExtensionsProps) => AnyExtension | undefined;
};
const extensionRegistry: TRichTextReadOnlyEditorAdditionalExtensionsRegistry[] = [];
export const RichTextReadOnlyEditorAdditionalExtensions = (props: TRichTextReadOnlyEditorAdditionalExtensionsProps) => {
const { disabledExtensions } = props;
const extensions: Extensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(props))
.filter((extension): extension is AnyExtension => extension !== undefined);
return extensions;
};
@@ -15,7 +15,6 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
extensions,
fileHandler,
forwardedRef,
id,
@@ -26,7 +25,6 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
const editor = useReadOnlyEditor({
disabledExtensions,
editorClassName,
extensions,
fileHandler,
forwardedRef,
initialValue,
@@ -3,20 +3,12 @@ import { forwardRef, useCallback } from "react";
import { EditorWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus";
// extensions
import { SideMenuExtension } from "@/extensions";
// plane editor imports
import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/extensions";
import { SideMenuExtension, SlashCommands } from "@/extensions";
// types
import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => {
const {
disabledExtensions,
dragDropEnabled,
fileHandler,
bubbleMenuEnabled = true,
extensions: externalExtensions = [],
} = props;
const { disabledExtensions, dragDropEnabled, bubbleMenuEnabled = true, extensions: externalExtensions = [] } = props;
const getExtensions = useCallback(() => {
const extensions = [
@@ -25,14 +17,17 @@ const RichTextEditor = (props: IRichTextEditor) => {
aiEnabled: false,
dragDropEnabled: !!dragDropEnabled,
}),
...RichTextEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
}),
];
if (!disabledExtensions?.includes("slash-commands")) {
extensions.push(
SlashCommands({
disabledExtensions,
})
);
}
return extensions;
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler]);
}, [dragDropEnabled, disabledExtensions, externalExtensions]);
return (
<EditorWrapper {...props} extensions={getExtensions()}>
@@ -1,33 +1,11 @@
import { forwardRef, useCallback } from "react";
// plane editor extensions
import { RichTextReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/read-only-extensions";
import { forwardRef } from "react";
// types
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types";
// local imports
import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper";
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => {
const { disabledExtensions, fileHandler } = props;
const getExtensions = useCallback(() => {
const extensions = [
...RichTextReadOnlyEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
}),
];
return extensions;
}, [disabledExtensions, fileHandler]);
return (
<ReadOnlyEditorWrapper
{...props}
extensions={getExtensions()}
forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>}
/>
);
});
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => (
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
));
RichTextReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef";
@@ -86,10 +86,6 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
[editor]
);
const handleInvalidFile = useCallback((_error: EFileError, _file: File, message: string) => {
alert(message);
}, []);
// hooks
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
@@ -98,12 +94,18 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
handleProgressStatus,
loadFileFromFileSystem: loadImageFromFileSystem,
maxFileSize,
onInvalidFile: handleInvalidFile,
onUpload,
});
const handleInvalidFile = useCallback((_error: EFileError, message: string) => {
alert(message);
}, []);
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
editor,
maxFileSize,
onInvalidFile: handleInvalidFile,
pos: getPos(),
type: "image",
uploader: uploadFile,
@@ -138,8 +140,11 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
return;
}
await uploadFirstFileAndInsertRemaining({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
editor,
filesList,
maxFileSize,
onInvalidFile: (_error, message) => alert(message),
pos: getPos(),
type: "image",
uploader: uploadFile,
@@ -170,9 +170,8 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomTextAlignExtension,
CustomCalloutExtension,
UtilityExtension({
disabledExtensions,
fileHandler,
isEditable: editable,
fileHandler,
}),
CustomColorExtension,
...CoreEditorAdditionalExtensions({
@@ -127,9 +127,8 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
UtilityExtension({
disabledExtensions,
fileHandler,
isEditable: false,
fileHandler,
}),
...CoreReadOnlyEditorAdditionalExtensions({
disabledExtensions,
@@ -8,7 +8,7 @@ import { DropHandlerPlugin } from "@/plugins/drop";
import { FilePlugins } from "@/plugins/file/root";
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
// types
import { TExtensions, TFileHandler, TReadOnlyFileHandler } from "@/types";
import { TFileHandler, TReadOnlyFileHandler } from "@/types";
declare module "@tiptap/core" {
interface Commands {
@@ -24,14 +24,13 @@ export interface UtilityExtensionStorage {
}
type Props = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean;
};
export const UtilityExtension = (props: Props) => {
const { disabledExtensions, fileHandler, isEditable } = props;
const { restore } = fileHandler;
const { fileHandler, isEditable } = props;
const { restore: restoreImageFn } = fileHandler;
return Extension.create<Record<string, unknown>, UtilityExtensionStorage>({
name: "utility",
@@ -46,15 +45,12 @@ export const UtilityExtension = (props: Props) => {
}),
...codemark({ markType: this.editor.schema.marks.code }),
MarkdownClipboardPlugin(this.editor),
DropHandlerPlugin({
disabledExtensions,
editor: this.editor,
}),
DropHandlerPlugin(this.editor),
];
},
onCreate() {
restorePublicImages(this.editor, restore);
restorePublicImages(this.editor, restoreImageFn);
},
addStorage() {
@@ -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;
};
@@ -92,8 +92,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
...(extensions ?? []),
...DocumentEditorAdditionalExtensions({
disabledExtensions,
embedConfig: embedHandler,
fileHandler,
issueEmbedConfig: embedHandler?.issue,
provider,
userDetails: user,
}),
+2 -3
View File
@@ -81,7 +81,6 @@ export const useEditor = (props: CustomEditorProps) => {
immediatelyRender: false,
shouldRerenderOnTransaction: false,
autofocus,
parseOptions: { preserveWhitespace: true },
editorProps: {
...CoreEditorProps({
editorClassName,
@@ -120,7 +119,7 @@ export const useEditor = (props: CustomEditorProps) => {
const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress;
if (!editor.isDestroyed && !isUploadInProgress) {
try {
editor.commands.setContent(value, false, { preserveWhitespace: true });
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
if (editor.state.selection) {
const docLength = editor.state.doc.content.size;
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
@@ -154,7 +153,7 @@ export const useEditor = (props: CustomEditorProps) => {
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string, emitUpdate = false) => {
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
},
setEditorValueAtCursorPosition: (content: string) => {
if (editor?.state.selection) {
@@ -9,11 +9,11 @@ import { TEditorCommands } from "@/types";
type TUploaderArgs = {
acceptedMimeTypes: string[];
editorCommand: (file: File) => Promise<string | undefined>;
editorCommand: (file: File) => Promise<string>;
handleProgressStatus?: (isUploading: boolean) => void;
loadFileFromFileSystem?: (file: string) => void;
maxFileSize: number;
onInvalidFile: (error: EFileError, file: File, message: string) => void;
onInvalidFile: (error: EFileError, message: string) => void;
onUpload: (url: string, file: File) => void;
};
@@ -38,7 +38,7 @@ export const useUploader = (args: TUploaderArgs) => {
acceptedMimeTypes,
file,
maxFileSize,
onError: (error, message) => onInvalidFile(error, file, message),
onError: onInvalidFile,
});
if (!isValid) {
handleProgressStatus?.(false);
@@ -60,7 +60,7 @@ export const useUploader = (args: TUploaderArgs) => {
};
reader.readAsDataURL(file);
}
const url = await editorCommand(file);
const url: string = await editorCommand(file);
if (!url) {
throw new Error("Something went wrong while uploading the file.");
@@ -89,14 +89,17 @@ export const useUploader = (args: TUploaderArgs) => {
};
type TDropzoneArgs = {
acceptedMimeTypes: string[];
editor: Editor;
maxFileSize: number;
onInvalidFile: (error: EFileError, message: string) => void;
pos: number;
type: Extract<TEditorCommands, "attachment" | "image">;
uploader: (file: File) => Promise<void>;
};
export const useDropZone = (args: TDropzoneArgs) => {
const { editor, pos, type, uploader } = args;
const { acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader } = args;
// states
const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggedInside, setDraggedInside] = useState<boolean>(false);
@@ -123,21 +126,22 @@ export const useDropZone = (args: TDropzoneArgs) => {
async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDraggedInside(false);
const filesList = e.dataTransfer.files;
if (filesList.length === 0 || !editor.isEditable) {
if (e.dataTransfer.files.length === 0 || !editor.isEditable) {
return;
}
const filesList = e.dataTransfer.files;
await uploadFirstFileAndInsertRemaining({
acceptedMimeTypes,
editor,
filesList,
maxFileSize,
onInvalidFile,
pos,
type,
uploader,
});
},
[editor, pos, type, uploader]
[acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader]
);
const onDragEnter = useCallback(() => setDraggedInside(true), []);
const onDragLeave = useCallback(() => setDraggedInside(false), []);
@@ -152,8 +156,11 @@ export const useDropZone = (args: TDropzoneArgs) => {
};
type TMultipleFileArgs = {
acceptedMimeTypes: string[];
editor: Editor;
filesList: FileList;
maxFileSize: number;
onInvalidFile: (error: EFileError, message: string) => void;
pos: number;
type: Extract<TEditorCommands, "attachment" | "image">;
uploader: (file: File) => Promise<void>;
@@ -161,18 +168,35 @@ type TMultipleFileArgs = {
// Upload the first file and insert the remaining ones for uploading multiple files
export const uploadFirstFileAndInsertRemaining = async (args: TMultipleFileArgs) => {
const { editor, filesList, pos, type, uploader } = args;
const filesArray = Array.from(filesList);
if (filesArray.length === 0) {
const { acceptedMimeTypes, editor, filesList, maxFileSize, onInvalidFile, pos, type, uploader } = args;
const filteredFiles: File[] = [];
for (let i = 0; i < filesList.length; i += 1) {
const file = filesList.item(i);
if (
file &&
isFileValid({
acceptedMimeTypes,
file,
maxFileSize,
onError: onInvalidFile,
})
) {
filteredFiles.push(file);
}
}
if (filteredFiles.length !== filesList.length) {
console.warn("Some files were invalid and have been ignored.");
}
if (filteredFiles.length === 0) {
console.error("No files found to upload.");
return;
}
// Upload the first file
const firstFile = filesArray[0];
const firstFile = filteredFiles[0];
uploader(firstFile);
// Insert the remaining files
const remainingFiles = filesArray.slice(1);
const remainingFiles = filteredFiles.slice(1);
if (remainingFiles.length > 0) {
const docSize = editor.state.doc.content.size;
const posOfNextFileToBeInserted = Math.min(pos + 1, docSize);
@@ -46,7 +46,6 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
immediatelyRender: true,
shouldRerenderOnTransaction: false,
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
parseOptions: { preserveWhitespace: true },
editorProps: {
...CoreReadOnlyEditorProps({
editorClassName,
@@ -72,7 +71,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
// for syncing swr data on tab refocus etc
useEffect(() => {
if (initialValue === null || initialValue === undefined) return;
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: true });
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
}, [editor, initialValue]);
useImperativeHandle(forwardedRef, () => ({
@@ -80,7 +79,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string, emitUpdate = false) => {
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
},
getMarkDown: (): string => {
const markdownOutput = editor?.storage.markdown.getMarkdown();
@@ -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();
+5 -16
View File
@@ -3,17 +3,10 @@ import { Plugin, PluginKey } from "@tiptap/pm/state";
// constants
import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
// types
import { TEditorCommands, TExtensions } from "@/types";
import { TEditorCommands } from "@/types";
type Props = {
disabledExtensions?: TExtensions[];
editor: Editor;
};
export const DropHandlerPlugin = (props: Props): Plugin => {
const { disabledExtensions, editor } = props;
return new Plugin({
export const DropHandlerPlugin = (editor: Editor): Plugin =>
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view, event) => {
@@ -32,7 +25,6 @@ export const DropHandlerPlugin = (props: Props): Plugin => {
if (acceptedFiles.length) {
const pos = view.state.selection.from;
insertFilesSafely({
disabledExtensions,
editor,
files: acceptedFiles,
initialPos: pos,
@@ -66,7 +58,6 @@ export const DropHandlerPlugin = (props: Props): Plugin => {
if (coordinates) {
const pos = coordinates.pos;
insertFilesSafely({
disabledExtensions,
editor,
files: acceptedFiles,
initialPos: pos,
@@ -80,10 +71,8 @@ export const DropHandlerPlugin = (props: Props): Plugin => {
},
},
});
};
type InsertFilesSafelyArgs = {
disabledExtensions?: TExtensions[];
editor: Editor;
event: "insert" | "drop";
files: File[];
@@ -92,7 +81,7 @@ type InsertFilesSafelyArgs = {
};
export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => {
const { disabledExtensions, editor, event, files, initialPos, type } = args;
const { editor, event, files, initialPos, type } = args;
let pos = initialPos;
for (const file of files) {
@@ -111,7 +100,7 @@ export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => {
else if (ACCEPTED_ATTACHMENT_MIME_TYPES.includes(file.type)) fileType = "attachment";
}
// insert file depending on the type at the current position
if (fileType === "image" && !disabledExtensions?.includes("image")) {
if (fileType === "image") {
editor.commands.insertImageComponent({
file,
pos,
@@ -1,7 +1,5 @@
import { Editor } from "@tiptap/core";
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
// constants
import { CORE_EDITOR_META } from "@/constants/meta";
// plane editor imports
import { NODE_FILE_MAP } from "@/plane-editor/constants/utility";
// types
@@ -34,7 +32,7 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
transactions.forEach((transaction) => {
// if the transaction has meta of skipFileDeletion set to true, then return (like while clearing the editor content programmatically)
if (transaction.getMeta(CORE_EDITOR_META.SKIP_FILE_DELETION)) return;
if (transaction.getMeta("skipFileDeletion")) return;
const removedFiles: TFileNode[] = [];
-1
View File
@@ -1,5 +1,4 @@
export type TReadOnlyFileHandler = {
checkIfAssetExists: (assetId: string) => Promise<boolean>;
getAssetSrc: (path: string) => Promise<string>;
restore: (assetSrc: string) => Promise<void>;
};
-1
View File
@@ -160,7 +160,6 @@ export interface IReadOnlyEditorProps {
disabledExtensions: TExtensions[];
displayConfig?: TDisplayConfig;
editorClassName?: string;
extensions?: Extensions;
fileHandler: TReadOnlyFileHandler;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
id: string;
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"files": [
"library.js",
@@ -18,6 +18,6 @@
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^5.2.0",
"typescript": "5.8.3"
"typescript": "5.3.3"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/hooks",
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"description": "React hooks that are shared across multiple apps internally",
"private": true,
@@ -23,6 +23,6 @@
"@types/node": "^22.5.4",
"@types/react": "^18.3.11",
"tsup": "8.4.0",
"typescript": "5.8.3"
"typescript": "^5.3.3"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/i18n",
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"description": "I18n shared across multiple apps internally",
"private": true,
@@ -17,6 +17,6 @@
"devDependencies": {
"@plane/eslint-config": "*",
"@types/node": "^22.5.4",
"typescript": "5.8.3"
"typescript": "^5.3.3"
}
}
-1
View File
@@ -31,7 +31,6 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
export enum ETranslationFiles {
TRANSLATIONS = "translations",
ACCESSIBILITY = "accessibility",
EDITOR = "editor",
}
export const LANGUAGE_STORAGE_KEY = "userLanguage";
@@ -22,13 +22,6 @@
"collapse_sidebar": "Sbalit postranní panel",
"expand_sidebar": "Rozbalit postranní panel",
"edition_badge": "Otevřít modal placených plánů"
},
"auth_forms": {
"clear_email": "Vymazat e-mail",
"show_password": "Zobrazit heslo",
"hide_password": "Skrýt heslo",
"close_alert": "Zavřít upozornění",
"close_popover": "Zavřít vyskakovací okno"
}
}
}
}
-1
View File
@@ -1 +0,0 @@
{}
+20 -31
View File
@@ -316,8 +316,6 @@
"failed_to_remove_project_from_favorites": "Nepodařilo se odstranit projekt z oblíbených. Zkuste to prosím znovu.",
"project_created_successfully": "Projekt úspěšně vytvořen",
"project_created_successfully_description": "Projekt byl úspěšně vytvořen. Nyní můžete začít přidávat pracovní položky.",
"project_name_already_taken": "Název projektu už je zabraný.",
"project_identifier_already_taken": "Identifikátor projektu už je zabraný.",
"project_cover_image_alt": "Úvodní obrázek projektu",
"name_is_required": "Název je povinný",
"title_should_be_less_than_255_characters": "Název by měl být kratší než 255 znaků",
@@ -850,7 +848,6 @@
"live": "Živě",
"change_history": "Historie změn",
"coming_soon": "Již brzy",
"member": "Člen",
"members": "Členové",
"you": "Vy",
"upgrade_cta": {
@@ -868,21 +865,7 @@
"view": "Pohled",
"deactivated_user": "Deaktivovaný uživatel",
"apply": "Použít",
"applying": "Používání",
"users": "Uživatelé",
"admins": "Administrátoři",
"guests": "Hosté",
"on_track": "Na správné cestě",
"off_track": "Mimo plán",
"at_risk": "V ohrožení",
"timeline": "Časová osa",
"completion": "Dokončení",
"upcoming": "Nadcházející",
"completed": "Dokončeno",
"in_progress": "Probíhá",
"planned": "Plánováno",
"paused": "Pozastaveno",
"no_of": "Počet {entity}"
"applying": "Používání"
},
"chart": {
"x_axis": "Osa X",
@@ -1097,9 +1080,7 @@
"select": {
"error": "Vyberte alespoň jednu pracovní položku",
"empty": "Nevybrány žádné pracovní položky",
"add_selected": "Přidat vybrané pracovní položky",
"select_all": "Vybrat vše",
"deselect_all": "Zrušit výběr všeho"
"add_selected": "Přidat vybrané pracovní položky"
},
"open_in_full_screen": "Otevřít pracovní položku na celou obrazovku"
},
@@ -1332,6 +1313,19 @@
"custom": "Vlastní analytika"
},
"empty_state": {
"general": {
"title": "Sledujte pokrok, vytížení a alokace. Identifikujte trendy, odstraňte překážky a zrychlete práci",
"description": "Sledujte rozsah vs. poptávku, odhady a rozsah. Zjistěte výkonnost členů a týmů, zajistěte včasné dokončení projektů.",
"primary_button": {
"text": "Začněte první projekt",
"comic": {
"title": "Analytika funguje nejlépe s Cykly + Moduly",
"description": "Nejprve časově ohraničte práci do Cyklů a seskupte položky přesahující cyklus do Modulů. Najdete je v levém menu."
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Pracovní položky přiřazené vám, rozdělené podle stavu, se zde zobrazí.",
"title": "Zatím žádná data"
@@ -1347,22 +1341,21 @@
},
"created_vs_resolved": "Vytvořeno vs Vyřešeno",
"customized_insights": "Přizpůsobené přehledy",
"backlog_work_items": "Backlog {entity}",
"backlog_work_items": "Pracovní položky v backlogu",
"active_projects": "Aktivní projekty",
"trend_on_charts": "Trend na grafech",
"all_projects": "Všechny projekty",
"summary_of_projects": "Souhrn projektů",
"project_insights": "Přehled projektu",
"started_work_items": "Zahájené {entity}",
"total_work_items": "Celkový počet {entity}",
"started_work_items": "Zahájené pracovní položky",
"total_work_items": "Celkový počet pracovních položek",
"total_projects": "Celkový počet projektů",
"total_admins": "Celkový počet administrátorů",
"total_users": "Celkový počet uživatelů",
"total_intake": "Celkový příjem",
"un_started_work_items": "Nezahájené {entity}",
"un_started_work_items": "Nezahájené pracovní položky",
"total_guests": "Celkový počet hostů",
"completed_work_items": "Dokončené {entity}",
"total": "Celkový počet {entity}"
"completed_work_items": "Dokončené pracovní položky"
},
"workspace_projects": {
"label": "{count, plural, one {Projekt} few {Projekty} other {Projektů}}",
@@ -2466,9 +2459,5 @@
"last_edited_by": "Naposledy upraveno uživatelem",
"previously_edited_by": "Dříve upraveno uživatelem",
"edited_by": "Upraveno uživatelem"
},
"self_hosted_maintenance_message": {
"plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane se nespustil. To může být způsobeno tím, že se jeden nebo více služeb Plane nepodařilo spustit.",
"choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Vyberte View Logs z setup.sh a Docker logů, abyste si byli jisti."
}
}
@@ -22,13 +22,6 @@
"collapse_sidebar": "Seitenleiste einklappen",
"expand_sidebar": "Seitenleiste ausklappen",
"edition_badge": "Modal für kostenpflichtige Pläne öffnen"
},
"auth_forms": {
"clear_email": "E-Mail löschen",
"show_password": "Passwort anzeigen",
"hide_password": "Passwort verbergen",
"close_alert": "Warnung schließen",
"close_popover": "Popover schließen"
}
}
}
}
-1
View File
@@ -1 +0,0 @@
{}
+21 -32
View File
@@ -316,8 +316,6 @@
"failed_to_remove_project_from_favorites": "Projekt konnte nicht aus den Favoriten entfernt werden. Bitte versuchen Sie es erneut.",
"project_created_successfully": "Projekt erfolgreich erstellt",
"project_created_successfully_description": "Das Projekt wurde erfolgreich erstellt. Sie können nun Arbeitselemente hinzufügen.",
"project_name_already_taken": "Der Projektname ist bereits vergeben.",
"project_identifier_already_taken": "Der Projekt-Identifier ist bereits vergeben.",
"project_cover_image_alt": "Titelbild des Projekts",
"name_is_required": "Name ist erforderlich",
"title_should_be_less_than_255_characters": "Der Titel sollte weniger als 255 Zeichen enthalten",
@@ -850,7 +848,6 @@
"live": "Live",
"change_history": "Änderungsverlauf",
"coming_soon": "Demnächst verfügbar",
"member": "Mitglied",
"members": "Mitglieder",
"you": "Sie",
"upgrade_cta": {
@@ -868,21 +865,7 @@
"view": "Ansicht",
"deactivated_user": "Deaktivierter Benutzer",
"apply": "Anwenden",
"applying": "Wird angewendet",
"users": "Benutzer",
"admins": "Administratoren",
"guests": "Gäste",
"on_track": "Im Plan",
"off_track": "Außer Plan",
"at_risk": "Gefährdet",
"timeline": "Zeitleiste",
"completion": "Fertigstellung",
"upcoming": "Bevorstehend",
"completed": "Abgeschlossen",
"in_progress": "In Bearbeitung",
"planned": "Geplant",
"paused": "Pausiert",
"no_of": "Anzahl {entity}"
"applying": "Wird angewendet"
},
"chart": {
"x_axis": "X-Achse",
@@ -1097,9 +1080,7 @@
"select": {
"error": "Wählen Sie mindestens ein Arbeitselement aus",
"empty": "Keine Arbeitselemente ausgewählt",
"add_selected": "Ausgewählte Arbeitselemente hinzufügen",
"select_all": "Alle auswählen",
"deselect_all": "Alle abwählen"
"add_selected": "Ausgewählte Arbeitselemente hinzufügen"
},
"open_in_full_screen": "Arbeitselement im Vollbild öffnen"
},
@@ -1332,6 +1313,19 @@
"custom": "Benutzerdefinierte Analysen"
},
"empty_state": {
"general": {
"title": "Verfolgen Sie Fortschritt, Auslastung und Zuordnungen. Erkennen Sie Trends, entfernen Sie Blocker und beschleunigen Sie die Arbeit",
"description": "Behalten Sie Umfang vs. Nachfrage, Schätzungen und Umfang im Blick. Verfolgen Sie die Leistung von Mitgliedern und Teams, um sicherzustellen, dass Projekte pünktlich abgeschlossen werden.",
"primary_button": {
"text": "Erstes Projekt starten",
"comic": {
"title": "Analysen funktionieren am besten mit Zyklen + Modulen",
"description": "Begrenzen Sie zuerst Arbeit zeitlich in Zyklen und gruppieren Sie die übergreifenden Elemente in Module. Sie finden sie im linken Menü."
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Ihnen zugewiesene Arbeitselemente, aufgeschlüsselt nach Status, werden hier angezeigt.",
"title": "Noch keine Daten"
@@ -1347,22 +1341,21 @@
},
"created_vs_resolved": "Erstellt vs Gelöst",
"customized_insights": "Individuelle Einblicke",
"backlog_work_items": "Backlog-{entity}",
"backlog_work_items": "Backlog-Arbeitselemente",
"active_projects": "Aktive Projekte",
"trend_on_charts": "Trend in Diagrammen",
"all_projects": "Alle Projekte",
"summary_of_projects": "Projektübersicht",
"project_insights": "Projekteinblicke",
"started_work_items": "Begonnene {entity}",
"total_work_items": "Gesamte {entity}",
"started_work_items": "Begonnene Arbeitselemente",
"total_work_items": "Gesamte Arbeitselemente",
"total_projects": "Gesamtprojekte",
"total_admins": "Gesamtanzahl der Admins",
"total_users": "Gesamtanzahl der Benutzer",
"total_intake": "Gesamteinnahmen",
"un_started_work_items": "Nicht begonnene {entity}",
"un_started_work_items": "Nicht begonnene Arbeitselemente",
"total_guests": "Gesamtanzahl der Gäste",
"completed_work_items": "Abgeschlossene {entity}",
"total": "Gesamte {entity}"
"completed_work_items": "Abgeschlossene Arbeitselemente"
},
"workspace_projects": {
"label": "{count, plural, one {Projekt} few {Projekte} other {Projekte}}",
@@ -2465,9 +2458,5 @@
"last_edited_by": "Zuletzt bearbeitet von",
"previously_edited_by": "Zuvor bearbeitet von",
"edited_by": "Bearbeitet von"
},
"self_hosted_maintenance_message": {
"plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane ist nicht gestartet. Dies könnte daran liegen, dass einer oder mehrere Plane-Services nicht starten konnten.",
"choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Wählen Sie View Logs aus setup.sh und Docker-Logs, um sicherzugehen."
}
}
}
@@ -22,13 +22,6 @@
"collapse_sidebar": "Collapse sidebar",
"expand_sidebar": "Expand sidebar",
"edition_badge": "Open paid plans' modal"
},
"auth_forms": {
"clear_email": "Clear email",
"show_password": "Show password",
"hide_password": "Hide password",
"close_alert": "Close alert",
"close_popover": "Close popover"
}
}
}
}
-1
View File
@@ -1 +0,0 @@
{}
+36 -87
View File
@@ -43,8 +43,7 @@
"your_account": "Your account",
"security": "Security",
"activity": "Activity",
"preferences": "Preferences",
"language_and_time": "Language & Time",
"appearance": "Appearance",
"notifications": "Notifications",
"workspaces": "Workspaces",
"create_workspace": "Create workspace",
@@ -57,10 +56,6 @@
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
"load_more": "Load more",
"select_or_customize_your_interface_color_scheme": "Select or customize your interface color scheme.",
"timezone_setting": "Current timezone setting.",
"language_setting": "Choose the language used in the user interface.",
"settings_moved_to_preferences": "Timezone & Language settings have been moved to preferences.",
"go_to_preferences": "Go to preferences",
"theme": "Theme",
"system_preference": "System preference",
"light": "Light",
@@ -153,8 +148,6 @@
"failed_to_remove_project_from_favorites": "Couldn't remove the project from favorites. Please try again.",
"project_created_successfully": "Project created successfully",
"project_created_successfully_description": "Project created successfully. You can now start adding work items to it.",
"project_name_already_taken": "The project name is already taken.",
"project_identifier_already_taken": "The project identifier is already taken.",
"project_cover_image_alt": "Project cover image",
"name_is_required": "Name is required",
"title_should_be_less_than_255_characters": "Title should be less than 255 characters",
@@ -341,8 +334,6 @@
"new_password_must_be_different_from_old_password": "New password must be different from old password",
"edited": "edited",
"bot": "Bot",
"settings_description": "Manage your account, workspace, and project preferences all in one place. Switch between tabs to easily configure.",
"back_to_workspace": "Back to workspace",
"project_view": {
"sort_by": {
"created_at": "Created at",
@@ -478,9 +469,6 @@
"modules": "Modules",
"labels": "Labels",
"label": "Label",
"admins": "Admins",
"users": "Users",
"guests": "Guests",
"assignees": "Assignees",
"assignee": "Assignee",
"created_by": "Created by",
@@ -617,16 +605,6 @@
"quarter": "Quarter",
"press_for_commands": "Press '/' for commands",
"click_to_add_description": "Click to add description",
"on_track": "On-Track",
"off_track": "Off-Track",
"at_risk": "At risk",
"timeline": "Timeline",
"completion": "Completion",
"upcoming": "Upcoming",
"completed": "Completed",
"in_progress": "In progress",
"planned": "Planned",
"paused": "Paused",
"search": {
"label": "Search",
"placeholder": "Type to search",
@@ -705,7 +683,6 @@
"live": "Live",
"change_history": "Change History",
"coming_soon": "Coming soon",
"member": "Member",
"members": "Members",
"you": "You",
"upgrade_cta": {
@@ -724,8 +701,7 @@
"deactivated_user": "Deactivated user",
"apply": "Apply",
"applying": "Applying",
"overview": "Overview",
"no_of": "No. of {entity}"
"overview": "Overview"
},
"chart": {
"x_axis": "X-axis",
@@ -940,9 +916,7 @@
"select": {
"error": "Please select at least one work item",
"empty": "No work items selected",
"add_selected": "Add selected work items",
"select_all": "Select all",
"deselect_all": "Deselect all"
"add_selected": "Add selected work items"
},
"open_in_full_screen": "Open work item in full screen"
},
@@ -1174,11 +1148,29 @@
"scope_and_demand": "Scope and Demand",
"custom": "Custom Analytics"
},
"total": "Total {entity}",
"started_work_items": "Started {entity}",
"backlog_work_items": "Backlog {entity}",
"un_started_work_items": "Unstarted {entity}",
"completed_work_items": "Completed {entity}",
"empty_state": {
"general": {
"title": "Track progress, workloads, and allocations. Spot trends, remove blockers, and move work faster",
"description": "See scope versus demand, estimates, and scope creep. Get performance by team members and teams, and make sure your project runs on time.",
"primary_button": {
"text": "Start your first project",
"comic": {
"title": "Analytics works best with Cycles + Modules",
"description": "First, timebox your work items into Cycles and, if you can, group work items that span more than a cycle into Modules. Check out both on the left nav."
}
}
}
},
"total_work_items": "Total work items",
"started_work_items": "Started work items",
"backlog_work_items": "Backlog work items",
"un_started_work_items": "Unstarted work items",
"completed_work_items": "Completed work items",
"total_guests": "Total Guests",
"total_intake": "Total Intake",
"total_users": "Total Users",
"total_admins": "Total Admins",
"total_projects": "Total Projects",
"project_insights": "Project Insights",
"summary_of_projects": "Summary of Projects",
"all_projects": "All Projects",
@@ -1186,7 +1178,7 @@
"active_projects": "Active Projects",
"customized_insights": "Customized Insights",
"created_vs_resolved": "Created vs Resolved",
"empty_state": {
"empty_state_v2": {
"project_insights": {
"title": "No data yet",
"description": "Work items assigned to you, broken down by state, will show up here."
@@ -1309,28 +1301,6 @@
}
}
},
"account_settings": {
"profile": {},
"preferences": {
"heading": "Preferences",
"description": "Customize your app experience the way you work"
},
"notifications": {
"heading": "Email notifications",
"description": "Stay in the loop on Work items you are subscribed to. Enable this to get notified."
},
"security": {
"heading": "Security"
},
"api_tokens": {
"heading": "Personal Access Tokens",
"description": "Generate secure API tokens to integrate your data with external systems and applications."
},
"activity": {
"heading": "Activity",
"description": "Track your recent actions and changes across all projects and work items."
}
},
"workspace_settings": {
"label": "Workspace settings",
"page_label": "{workspace} - General settings",
@@ -1397,22 +1367,16 @@
}
},
"billing_and_plans": {
"heading": "Billing & Plans",
"description": "Choose your plan, manage subscriptions, and easily upgrade as your needs grow.",
"title": "Billing & Plans",
"current_plan": "Current plan",
"free_plan": "You are currently using the free plan",
"view_plans": "View plans"
},
"exports": {
"heading": "Exports",
"description": "Export your project data in various formats and access your export history with download links.",
"title": "Exports",
"exporting": "Exporting",
"previous_exports": "Previous exports",
"export_separate_files": "Export the data into separate files",
"exporting_projects": "Exporting project",
"format": "Format",
"modal": {
"title": "Export to",
"toasts": {
@@ -1428,8 +1392,6 @@
}
},
"webhooks": {
"heading": "Webhooks",
"description": "Automate notifications to external services when project events occur.",
"title": "Webhooks",
"add_webhook": "Add webhook",
"modal": {
@@ -1481,29 +1443,29 @@
}
},
"api_tokens": {
"title": "Personal Access Tokens",
"add_token": "Add personal access token",
"title": "API Tokens",
"add_token": "Add API token",
"create_token": "Create token",
"never_expires": "Never expires",
"generate_token": "Generate token",
"generating": "Generating",
"delete": {
"title": "Delete personal access token",
"title": "Delete API token",
"description": "Any application using this token will no longer have the access to Plane data. This action cannot be undone.",
"success": {
"title": "Success!",
"message": "The token has been successfully deleted"
"message": "The API token has been successfully deleted"
},
"error": {
"title": "Error!",
"message": "The token could not be deleted"
"message": "The API token could not be deleted"
}
}
}
},
"empty_state": {
"api_tokens": {
"title": "No personal access tokens created",
"title": "No API tokens created",
"description": "Plane APIs can be used to integrate your data in Plane with any external system. Create a token to get started."
},
"webhooks": {
@@ -1553,9 +1515,8 @@
"profile": "Profile",
"security": "Security",
"activity": "Activity",
"preferences": "Preferences",
"notifications": "Notifications",
"api-tokens": "Personal Access Tokens"
"appearance": "Appearance",
"notifications": "Notifications"
},
"tabs": {
"summary": "Summary",
@@ -1617,8 +1578,6 @@
}
},
"states": {
"heading": "States",
"description": "Define and customize workflow states to track the progress of your work items.",
"describe_this_state_for_your_members": "Describe this state for your members.",
"empty_state": {
"title": "No states available for the {groupKey} group",
@@ -1626,8 +1585,6 @@
}
},
"labels": {
"heading": "Labels",
"description": "Create custom labels to categorize and organize your work items",
"label_title": "Label title",
"label_title_is_required": "Label title is required",
"label_max_char": "Label name should not exceed 255 characters",
@@ -1636,11 +1593,9 @@
}
},
"estimates": {
"heading": "Estimates",
"description": "Set up estimation systems to track and communicate the effort required for each work item.",
"label": "Estimates",
"title": "Enable estimates for my project",
"enable_description": "They help you in communicating complexity and workload of the team.",
"description": "They help you in communicating complexity and workload of the team.",
"no_estimate": "No estimate",
"new": "New estimate system",
"create": {
@@ -1722,8 +1677,6 @@
},
"automations": {
"label": "Automations",
"heading": "Automations",
"description": "Configure automated actions to streamline your project management workflow and reduce manual tasks.",
"auto-archive": {
"title": "Auto-archive closed work items",
"description": "Plane will auto archive work items that have been completed or canceled.",
@@ -2342,9 +2295,5 @@
"last_edited_by": "Last edited by",
"previously_edited_by": "Previously edited by",
"edited_by": "Edited by"
},
"self_hosted_maintenance_message": {
"plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane didn't start up. This could be because one or more Plane services failed to start.",
"choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Choose View Logs from setup.sh and Docker logs to be sure."
}
}
@@ -22,13 +22,6 @@
"collapse_sidebar": "Colapsar barra lateral",
"expand_sidebar": "Expandir barra lateral",
"edition_badge": "Abrir modal de planes de pago"
},
"auth_forms": {
"clear_email": "Limpiar correo electrónico",
"show_password": "Mostrar contraseña",
"hide_password": "Ocultar contraseña",
"close_alert": "Cerrar alerta",
"close_popover": "Cerrar ventana emergente"
}
}
}
}
-1
View File
@@ -1 +0,0 @@
{}
+20 -31
View File
@@ -318,8 +318,6 @@
"failed_to_remove_project_from_favorites": "No se pudo eliminar el proyecto de favoritos. Por favor, inténtalo de nuevo.",
"project_created_successfully": "Proyecto creado exitosamente",
"project_created_successfully_description": "Proyecto creado exitosamente. Ahora puedes comenzar a agregar elementos de trabajo.",
"project_name_already_taken": "El nombre del proyecto ya está en uso.",
"project_identifier_already_taken": "El identificador del proyecto ya está en uso.",
"project_cover_image_alt": "Imagen de portada del proyecto",
"name_is_required": "El nombre es requerido",
"title_should_be_less_than_255_characters": "El título debe tener menos de 255 caracteres",
@@ -853,7 +851,6 @@
"live": "En vivo",
"change_history": "Historial de cambios",
"coming_soon": "Próximamente",
"member": "Miembro",
"members": "Miembros",
"you": "Tú",
"upgrade_cta": {
@@ -871,21 +868,7 @@
"view": "Ver",
"deactivated_user": "Usuario desactivado",
"apply": "Aplicar",
"applying": "Aplicando",
"users": "Usuarios",
"admins": "Administradores",
"guests": "Invitados",
"on_track": "En camino",
"off_track": "Fuera de camino",
"at_risk": "En riesgo",
"timeline": "Cronograma",
"completion": "Finalización",
"upcoming": "Próximo",
"completed": "Completado",
"in_progress": "En progreso",
"planned": "Planificado",
"paused": "Pausado",
"no_of": "N.º de {entity}"
"applying": "Aplicando"
},
"chart": {
"x_axis": "Eje X",
@@ -1100,9 +1083,7 @@
"select": {
"error": "Por favor selecciona al menos un elemento de trabajo",
"empty": "No hay elementos de trabajo seleccionados",
"add_selected": "Agregar elementos seleccionados",
"select_all": "Seleccionar todo",
"deselect_all": "Deseleccionar todo"
"add_selected": "Agregar elementos seleccionados"
},
"open_in_full_screen": "Abrir elemento de trabajo en pantalla completa"
},
@@ -1335,6 +1316,19 @@
"custom": "Análisis Personalizado"
},
"empty_state": {
"general": {
"title": "Rastrea el progreso, cargas de trabajo y asignaciones. Identifica tendencias, elimina bloqueos y mueve el trabajo más rápido",
"description": "Observa el alcance versus la demanda, estimaciones y el aumento del alcance. Obtén el rendimiento por miembros del equipo y equipos, y asegúrate de que tu proyecto se ejecute a tiempo.",
"primary_button": {
"text": "Inicia tu primer proyecto",
"comic": {
"title": "El análisis funciona mejor con Ciclos + Módulos",
"description": "Primero, organiza tus elementos de trabajo en Ciclos y, si puedes, agrupa los elementos de trabajo que abarcan más de un ciclo en Módulos. Revisa ambos en la navegación izquierda."
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Los elementos de trabajo asignados a ti, desglosados por estado, aparecerán aquí.",
"title": "Aún no hay datos"
@@ -1350,22 +1344,21 @@
},
"created_vs_resolved": "Creado vs Resuelto",
"customized_insights": "Información personalizada",
"backlog_work_items": "{entity} en backlog",
"backlog_work_items": "Elementos de trabajo en backlog",
"active_projects": "Proyectos activos",
"trend_on_charts": "Tendencia en gráficos",
"all_projects": "Todos los proyectos",
"summary_of_projects": "Resumen de proyectos",
"project_insights": "Información del proyecto",
"started_work_items": "{entity} iniciados",
"total_work_items": "Total de {entity}",
"started_work_items": "Elementos de trabajo iniciados",
"total_work_items": "Total de elementos de trabajo",
"total_projects": "Total de proyectos",
"total_admins": "Total de administradores",
"total_users": "Total de usuarios",
"total_intake": "Ingreso total",
"un_started_work_items": "{entity} no iniciados",
"un_started_work_items": "Elementos de trabajo no iniciados",
"total_guests": "Total de invitados",
"completed_work_items": "{entity} completados",
"total": "Total de {entity}"
"completed_work_items": "Elementos de trabajo completados"
},
"workspace_projects": {
"label": "{count, plural, one {Proyecto} other {Proyectos}}",
@@ -2468,9 +2461,5 @@
"last_edited_by": "Última edición por",
"previously_edited_by": "Editado anteriormente por",
"edited_by": "Editado por"
},
"self_hosted_maintenance_message": {
"plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane no se inició. Esto podría deberse a que uno o más servicios de Plane fallaron al iniciar.",
"choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Selecciona View Logs desde setup.sh y los logs de Docker para estar seguro."
}
}
@@ -22,13 +22,6 @@
"collapse_sidebar": "Réduire la barre latérale",
"expand_sidebar": "Étendre la barre latérale",
"edition_badge": "Ouvrir le modal des plans payants"
},
"auth_forms": {
"clear_email": "Effacer l'e-mail",
"show_password": "Afficher le mot de passe",
"hide_password": "Masquer le mot de passe",
"close_alert": "Fermer l'alerte",
"close_popover": "Fermer la fenêtre contextuelle"
}
}
}
}
-1
View File
@@ -1 +0,0 @@
{}
+20 -31
View File
@@ -316,8 +316,6 @@
"failed_to_remove_project_from_favorites": "Impossible de supprimer le projet des favoris. Veuillez réessayer.",
"project_created_successfully": "Projet créé avec succès",
"project_created_successfully_description": "Projet créé avec succès. Vous pouvez maintenant commencer à ajouter des éléments de travail.",
"project_name_already_taken": "Le nom du projet est déjà pris.",
"project_identifier_already_taken": "Lidentifiant du projet est déjà pris.",
"project_cover_image_alt": "Image de couverture du projet",
"name_is_required": "Le nom est requis",
"title_should_be_less_than_255_characters": "Le titre doit faire moins de 255 caractères",
@@ -851,7 +849,6 @@
"live": "En direct",
"change_history": "Historique des modifications",
"coming_soon": "À venir",
"member": "Membre",
"members": "Membres",
"you": "Vous",
"upgrade_cta": {
@@ -869,21 +866,7 @@
"view": "Afficher",
"deactivated_user": "Utilisateur désactivé",
"apply": "Appliquer",
"applying": "Application",
"users": "Utilisateurs",
"admins": "Administrateurs",
"guests": "Invités",
"on_track": "Sur la bonne voie",
"off_track": "Hors de la bonne voie",
"at_risk": "À risque",
"timeline": "Chronologie",
"completion": "Achèvement",
"upcoming": "À venir",
"completed": "Terminé",
"in_progress": "En cours",
"planned": "Planifié",
"paused": "En pause",
"no_of": "Nº de {entity}"
"applying": "Application"
},
"chart": {
"x_axis": "Axe X",
@@ -1098,9 +1081,7 @@
"select": {
"error": "Veuillez sélectionner au moins un élément de travail",
"empty": "Aucun élément de travail sélectionné",
"add_selected": "Ajouter les éléments de travail sélectionnés",
"select_all": "Sélectionner tout",
"deselect_all": "Tout désélectionner"
"add_selected": "Ajouter les éléments de travail sélectionnés"
},
"open_in_full_screen": "Ouvrir l'élément de travail en plein écran"
},
@@ -1333,6 +1314,19 @@
"custom": "Analytique Personnalisée"
},
"empty_state": {
"general": {
"title": "Suivez les progrès, les charges de travail et les allocations. Repérez les tendances, supprimez les blocages et accélérez le travail",
"description": "Visualisez la portée par rapport à la demande, les estimations et l'augmentation de la portée. Obtenez les performances par membres de l'équipe et équipes, et assurez-vous que votre projet se déroule dans les délais.",
"primary_button": {
"text": "Commencez votre premier projet",
"comic": {
"title": "L'analytique fonctionne mieux avec les Cycles + Modules",
"description": "D'abord, planifiez vos éléments de travail dans des Cycles et, si possible, regroupez les éléments de travail qui s'étendent sur plus d'un cycle dans des Modules. Consultez les deux dans la navigation de gauche."
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Les éléments de travail qui vous sont assignés, répartis par état, s'afficheront ici.",
"title": "Pas encore de données"
@@ -1348,22 +1342,21 @@
},
"created_vs_resolved": "Créé vs Résolu",
"customized_insights": "Informations personnalisées",
"backlog_work_items": "{entity} en backlog",
"backlog_work_items": "Éléments de travail en backlog",
"active_projects": "Projets actifs",
"trend_on_charts": "Tendance sur les graphiques",
"all_projects": "Tous les projets",
"summary_of_projects": "Résumé des projets",
"project_insights": "Aperçus du projet",
"started_work_items": "{entity} commencés",
"total_work_items": "Total des {entity}",
"started_work_items": "Éléments de travail commencés",
"total_work_items": "Total des éléments de travail",
"total_projects": "Total des projets",
"total_admins": "Total des administrateurs",
"total_users": "Nombre total d'utilisateurs",
"total_intake": "Revenu total",
"un_started_work_items": "{entity} non commencés",
"un_started_work_items": "Éléments de travail non commencés",
"total_guests": "Nombre total d'invités",
"completed_work_items": "{entity} terminés",
"total": "Total des {entity}"
"completed_work_items": "Éléments de travail terminés"
},
"workspace_projects": {
"label": "{count, plural, one {Projet} other {Projets}}",
@@ -2466,9 +2459,5 @@
"last_edited_by": "Dernière modification par",
"previously_edited_by": "Précédemment modifié par",
"edited_by": "Modifié par"
},
"self_hosted_maintenance_message": {
"plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane n&apos;a pas démarré. Cela pourrait être dû au fait qu&apos;un ou plusieurs services Plane ont échoué à démarrer.",
"choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Choisissez View Logs depuis setup.sh et les logs Docker pour en être sûr."
}
}
@@ -22,13 +22,6 @@
"collapse_sidebar": "Tutup sidebar",
"expand_sidebar": "Perluas sidebar",
"edition_badge": "Buka modal paket berbayar"
},
"auth_forms": {
"clear_email": "Hapus email",
"show_password": "Tampilkan kata sandi",
"hide_password": "Sembunyikan kata sandi",
"close_alert": "Tutup peringatan",
"close_popover": "Tutup popover"
}
}
}
}
-1
View File
@@ -1 +0,0 @@
{}
+21 -33
View File
@@ -316,8 +316,6 @@
"failed_to_remove_project_from_favorites": "Tidak dapat menghapus proyek dari favorit. Silakan coba lagi.",
"project_created_successfully": "Proyek berhasil dibuat",
"project_created_successfully_description": "Proyek berhasil dibuat. Anda sekarang dapat mulai menambahkan item kerja ke dalamnya.",
"project_name_already_taken": "Nama proyek sudah digunakan",
"project_identifier_already_taken": "ID proyek sudah digunakan",
"project_cover_image_alt": "Gambar sampul proyek",
"name_is_required": "Nama diperlukan",
"title_should_be_less_than_255_characters": "Judul harus kurang dari 255 karakter",
@@ -850,7 +848,6 @@
"live": "Langsung",
"change_history": "Riwayat Perubahan",
"coming_soon": "Segera hadir",
"member": "Anggota",
"members": "Anggota",
"you": "Anda",
"upgrade_cta": {
@@ -868,21 +865,7 @@
"view": "Lihat",
"deactivated_user": "Pengguna dinonaktifkan",
"apply": "Terapkan",
"applying": "Terapkan",
"users": "Pengguna",
"admins": "Admin",
"guests": "Tamu",
"on_track": "Sesuai Jalur",
"off_track": "Menyimpang",
"at_risk": "Dalam risiko",
"timeline": "Linimasa",
"completion": "Penyelesaian",
"upcoming": "Mendatang",
"completed": "Selesai",
"in_progress": "Sedang berlangsung",
"planned": "Direncanakan",
"paused": "Dijedaikan",
"no_of": "Jumlah {entity}"
"applying": "Terapkan"
},
"chart": {
"x_axis": "Sumbu-X",
@@ -1097,9 +1080,7 @@
"select": {
"error": "Silakan pilih setidaknya satu item kerja",
"empty": "Tidak ada item kerja yang dipilih",
"add_selected": "Tambah item kerja yang dipilih",
"select_all": "Pilih semua item kerja",
"deselect_all": "Batalkan pilihan semua item kerja"
"add_selected": "Tambah item kerja yang dipilih"
},
"open_in_full_screen": "Buka item kerja dalam layar penuh"
},
@@ -1332,6 +1313,19 @@
"custom": "Analitik Kustom"
},
"empty_state": {
"general": {
"title": "Lacak kemajuan, beban kerja, dan alokasi. Temukan tren, hilangkan penghalang, dan percepat pekerjaan",
"description": "Lihat lingkup dibandingkan permintaan, perkiraan, dan lingkup cree. Dapatkan kinerja oleh anggota tim dan tim, dan pastikan proyek Anda berjalan tepat waktu.",
"primary_button": {
"text": "Mulai proyek pertama Anda",
"comic": {
"title": "Analitik bekerja terbaik dengan Siklus + Modul",
"description": "Pertama, bagi item kerja Anda ke dalam Siklus dan, jika memungkinkan, kelompokkan item kerja yang menjangkau lebih dari satu siklus ke dalam Modul. Lihat kedua fungsi pada navigasi kiri."
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Item pekerjaan yang ditugaskan kepada Anda, dipecah berdasarkan status, akan muncul di sini.",
"title": "Belum ada data"
@@ -1347,22 +1341,21 @@
},
"created_vs_resolved": "Dibuat vs Diselesaikan",
"customized_insights": "Wawasan yang Disesuaikan",
"backlog_work_items": "{entity} backlog",
"backlog_work_items": "Item pekerjaan backlog",
"active_projects": "Proyek Aktif",
"trend_on_charts": "Tren pada grafik",
"all_projects": "Semua Proyek",
"summary_of_projects": "Ringkasan Proyek",
"project_insights": "Wawasan Proyek",
"started_work_items": "{entity} yang telah dimulai",
"total_work_items": "Total {entity}",
"started_work_items": "Item pekerjaan yang telah dimulai",
"total_work_items": "Total item pekerjaan",
"total_projects": "Total Proyek",
"total_admins": "Total Admin",
"total_users": "Total Pengguna",
"total_intake": "Total Pemasukan",
"un_started_work_items": "{entity} yang belum dimulai",
"un_started_work_items": "Item pekerjaan yang belum dimulai",
"total_guests": "Total Tamu",
"completed_work_items": "{entity} yang telah selesai",
"total": "Total {entity}"
"completed_work_items": "Item pekerjaan yang telah selesai"
},
"workspace_projects": {
"label": "{count, plural, one {Proyek} other {Proyek}}",
@@ -2460,10 +2453,5 @@
"last_edited_by": "Terakhir disunting oleh",
"previously_edited_by": "Sebelumnya disunting oleh",
"edited_by": "Disunting oleh"
},
"self_hosted_maintenance_message": {
"plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane tidak berhasil dimulai. Ini bisa karena satu atau lebih layanan Plane gagal untuk dimulai.",
"choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Pilih View Logs dari setup.sh dan log Docker untuk memastikan."
},
"no_of": "Jumlah {entity}"
}
}
@@ -22,13 +22,6 @@
"collapse_sidebar": "Comprimi barra laterale",
"expand_sidebar": "Espandi barra laterale",
"edition_badge": "Apri modal piani a pagamento"
},
"auth_forms": {
"clear_email": "Cancella email",
"show_password": "Mostra password",
"hide_password": "Nascondi password",
"close_alert": "Chiudi avviso",
"close_popover": "Chiudi popover"
}
}
}
}
-1
View File
@@ -1 +0,0 @@
{}
+20 -31
View File
@@ -316,8 +316,6 @@
"failed_to_remove_project_from_favorites": "Impossibile rimuovere il progetto dai preferiti. Per favore, riprova.",
"project_created_successfully": "Progetto creato con successo",
"project_created_successfully_description": "Progetto creato con successo. Ora puoi iniziare ad aggiungere elementi di lavoro.",
"project_name_already_taken": "Il nome del progetto è già stato utilizzato.",
"project_identifier_already_taken": "L'identificatore del progetto è già stato utilizzato.",
"project_cover_image_alt": "Immagine di copertina del progetto",
"name_is_required": "Il nome è obbligatorio",
"title_should_be_less_than_255_characters": "Il titolo deve contenere meno di 255 caratteri",
@@ -849,7 +847,6 @@
"live": "Live",
"change_history": "Cronologia modifiche",
"coming_soon": "Prossimamente",
"member": "Membro",
"members": "Membri",
"you": "Tu",
"upgrade_cta": {
@@ -867,21 +864,7 @@
"view": "Visualizza",
"deactivated_user": "Utente disattivato",
"apply": "Applica",
"applying": "Applicazione",
"users": "Utenti",
"admins": "Amministratori",
"guests": "Ospiti",
"on_track": "In linea",
"off_track": "Fuori rotta",
"at_risk": "A rischio",
"timeline": "Cronologia",
"completion": "Completamento",
"upcoming": "In arrivo",
"completed": "Completato",
"in_progress": "In corso",
"planned": "Pianificato",
"paused": "In pausa",
"no_of": "N. di {entity}"
"applying": "Applicazione"
},
"chart": {
"x_axis": "Asse X",
@@ -1096,9 +1079,7 @@
"select": {
"error": "Seleziona almeno un elemento di lavoro",
"empty": "Nessun elemento di lavoro selezionato",
"add_selected": "Aggiungi gli elementi di lavoro selezionati",
"select_all": "Seleziona tutto",
"deselect_all": "Deseleziona tutto"
"add_selected": "Aggiungi gli elementi di lavoro selezionati"
},
"open_in_full_screen": "Apri l'elemento di lavoro a schermo intero"
},
@@ -1331,6 +1312,19 @@
"custom": "Analisi personalizzata"
},
"empty_state": {
"general": {
"title": "Traccia il progresso, i carichi di lavoro e le assegnazioni. Individua tendenze, rimuovi gli ostacoli e accelera il lavoro",
"description": "Visualizza l'ambito rispetto alla domanda, le stime e il fenomeno del scope creep. Ottieni le prestazioni dei membri del team e dei team, e assicurati che il tuo progetto rispetti le scadenze.",
"primary_button": {
"text": "Inizia il tuo primo progetto",
"comic": {
"title": "Le analisi funzionano meglio con Cicli + Moduli",
"description": "Prima, definisci i tuoi elementi di lavoro in cicli e, se puoi, raggruppa quelli che si estendono per più di un ciclo in moduli. Dai un'occhiata ad entrambi nel menu di sinistra."
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Gli elementi di lavoro assegnati a te, suddivisi per stato, verranno visualizzati qui.",
"title": "Nessun dato disponibile"
@@ -1346,22 +1340,21 @@
},
"created_vs_resolved": "Creato vs Risolto",
"customized_insights": "Approfondimenti personalizzati",
"backlog_work_items": "{entity} nel backlog",
"backlog_work_items": "Elementi di lavoro nel backlog",
"active_projects": "Progetti attivi",
"trend_on_charts": "Tendenza nei grafici",
"all_projects": "Tutti i progetti",
"summary_of_projects": "Riepilogo dei progetti",
"project_insights": "Approfondimenti sul progetto",
"started_work_items": "{entity} iniziati",
"total_work_items": "Totale {entity}",
"started_work_items": "Elementi di lavoro iniziati",
"total_work_items": "Totale elementi di lavoro",
"total_projects": "Progetti totali",
"total_admins": "Totale amministratori",
"total_users": "Totale utenti",
"total_intake": "Entrate totali",
"un_started_work_items": "{entity} non avviati",
"un_started_work_items": "Elementi di lavoro non avviati",
"total_guests": "Totale ospiti",
"completed_work_items": "{entity} completati",
"total": "Totale {entity}"
"completed_work_items": "Elementi di lavoro completati"
},
"workspace_projects": {
"label": "{count, plural, one {Progetto} other {Progetti}}",
@@ -2465,9 +2458,5 @@
"last_edited_by": "Ultima modifica di",
"previously_edited_by": "Precedentemente modificato da",
"edited_by": "Modificato da"
},
"self_hosted_maintenance_message": {
"plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane non si è avviato. Questo potrebbe essere dovuto al fatto che uno o più servizi Plane non sono riusciti ad avviarsi.",
"choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Scegli View Logs da setup.sh e dai log Docker per essere sicuro."
}
}
@@ -22,13 +22,6 @@
"collapse_sidebar": "サイドバーを折りたたむ",
"expand_sidebar": "サイドバーを展開",
"edition_badge": "有料プランのモーダルを開く"
},
"auth_forms": {
"clear_email": "メールをクリア",
"show_password": "パスワードを表示",
"hide_password": "パスワードを非表示",
"close_alert": "アラートを閉じる",
"close_popover": "ポップオーバーを閉じる"
}
}
}
}

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