Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4eab66e3d | |||
| d9d39199ae | |||
| f30e31e294 | |||
| dc57098507 |
+1
-2
@@ -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/
|
||||
@@ -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
@@ -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
|
||||
|
||||
That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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
@@ -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,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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,7 +39,7 @@ from .project import (
|
||||
ProjectMemberRoleSerializer,
|
||||
)
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .view import IssueViewSerializer, ViewIssueListSerializer
|
||||
from .view import IssueViewSerializer
|
||||
from .cycle import (
|
||||
CycleSerializer,
|
||||
CycleIssueSerializer,
|
||||
@@ -74,7 +74,6 @@ from .issue import (
|
||||
IssueLinkLiteSerializer,
|
||||
IssueVersionDetailSerializer,
|
||||
IssueDescriptionVersionDetailSerializer,
|
||||
IssueListDetailSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
|
||||
@@ -725,110 +725,6 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IssueListDetailSerializer(serializers.Serializer):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Extract expand parameter and store it as instance variable
|
||||
self.expand = kwargs.pop("expand", []) or []
|
||||
# Extract fields parameter and store it as instance variable
|
||||
self.fields = kwargs.pop("fields", []) or []
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_module_ids(self, obj):
|
||||
return [module.module_id for module in obj.issue_module.all()]
|
||||
|
||||
def get_label_ids(self, obj):
|
||||
return [label.label_id for label in obj.label_issue.all()]
|
||||
|
||||
def get_assignee_ids(self, obj):
|
||||
return [assignee.assignee_id for assignee in obj.issue_assignee.all()]
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = {
|
||||
# Basic fields
|
||||
"id": instance.id,
|
||||
"name": instance.name,
|
||||
"state_id": instance.state_id,
|
||||
"sort_order": instance.sort_order,
|
||||
"completed_at": instance.completed_at,
|
||||
"estimate_point": instance.estimate_point_id,
|
||||
"priority": instance.priority,
|
||||
"start_date": instance.start_date,
|
||||
"target_date": instance.target_date,
|
||||
"sequence_id": instance.sequence_id,
|
||||
"project_id": instance.project_id,
|
||||
"parent_id": instance.parent_id,
|
||||
"created_at": instance.created_at,
|
||||
"updated_at": instance.updated_at,
|
||||
"created_by": instance.created_by_id,
|
||||
"updated_by": instance.updated_by_id,
|
||||
"is_draft": instance.is_draft,
|
||||
"archived_at": instance.archived_at,
|
||||
# Computed fields
|
||||
"cycle_id": instance.cycle_id,
|
||||
"module_ids": self.get_module_ids(instance),
|
||||
"label_ids": self.get_label_ids(instance),
|
||||
"assignee_ids": self.get_assignee_ids(instance),
|
||||
"sub_issues_count": instance.sub_issues_count,
|
||||
"attachment_count": instance.attachment_count,
|
||||
"link_count": instance.link_count,
|
||||
}
|
||||
|
||||
# Handle expanded fields only when requested - using direct field access
|
||||
if self.expand:
|
||||
if "issue_relation" in self.expand:
|
||||
relations = []
|
||||
for relation in instance.issue_relation.all():
|
||||
related_issue = relation.related_issue
|
||||
# If the related issue is deleted, skip it
|
||||
if not related_issue:
|
||||
continue
|
||||
# Add the related issue to the relations list
|
||||
relations.append(
|
||||
{
|
||||
"id": related_issue.id,
|
||||
"project_id": related_issue.project_id,
|
||||
"sequence_id": related_issue.sequence_id,
|
||||
"name": related_issue.name,
|
||||
"relation_type": relation.relation_type,
|
||||
"state_id": related_issue.state_id,
|
||||
"priority": related_issue.priority,
|
||||
"created_by": related_issue.created_by_id,
|
||||
"created_at": related_issue.created_at,
|
||||
"updated_at": related_issue.updated_at,
|
||||
"updated_by": related_issue.updated_by_id,
|
||||
}
|
||||
)
|
||||
data["issue_relation"] = relations
|
||||
|
||||
if "issue_related" in self.expand:
|
||||
related = []
|
||||
for relation in instance.issue_related.all():
|
||||
issue = relation.issue
|
||||
# If the related issue is deleted, skip it
|
||||
if not issue:
|
||||
continue
|
||||
# Add the related issue to the related list
|
||||
related.append(
|
||||
{
|
||||
"id": issue.id,
|
||||
"project_id": issue.project_id,
|
||||
"sequence_id": issue.sequence_id,
|
||||
"name": issue.name,
|
||||
"relation_type": relation.relation_type,
|
||||
"state_id": issue.state_id,
|
||||
"priority": issue.priority,
|
||||
"created_by": issue.created_by_id,
|
||||
"created_at": issue.created_at,
|
||||
"updated_at": issue.updated_at,
|
||||
"updated_by": issue.updated_by_id,
|
||||
}
|
||||
)
|
||||
data["issue_related"] = related
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class IssueLiteSerializer(DynamicBaseSerializer):
|
||||
class Meta:
|
||||
model = Issue
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -7,49 +7,6 @@ from plane.db.models import IssueView
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class ViewIssueListSerializer(serializers.Serializer):
|
||||
|
||||
def get_assignee_ids(self, instance):
|
||||
return [assignee.assignee_id for assignee in instance.issue_assignee.all()]
|
||||
|
||||
def get_label_ids(self, instance):
|
||||
return [label.label_id for label in instance.label_issue.all()]
|
||||
|
||||
def get_module_ids(self, instance):
|
||||
return [module.module_id for module in instance.issue_module.all()]
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = {
|
||||
"id": instance.id,
|
||||
"name": instance.name,
|
||||
"state_id": instance.state_id,
|
||||
"sort_order": instance.sort_order,
|
||||
"completed_at": instance.completed_at,
|
||||
"estimate_point": instance.estimate_point_id,
|
||||
"priority": instance.priority,
|
||||
"start_date": instance.start_date,
|
||||
"target_date": instance.target_date,
|
||||
"sequence_id": instance.sequence_id,
|
||||
"project_id": instance.project_id,
|
||||
"parent_id": instance.parent_id,
|
||||
"cycle_id": instance.cycle_id,
|
||||
"sub_issues_count": instance.sub_issues_count,
|
||||
"created_at": instance.created_at,
|
||||
"updated_at": instance.updated_at,
|
||||
"created_by": instance.created_by_id,
|
||||
"updated_by": instance.updated_by_id,
|
||||
"attachment_count": instance.attachment_count,
|
||||
"link_count": instance.link_count,
|
||||
"is_draft": instance.is_draft,
|
||||
"archived_at": instance.archived_at,
|
||||
"state__group": instance.state.group if instance.state else None,
|
||||
"assignee_ids": self.get_assignee_ids(instance),
|
||||
"label_ids": self.get_label_ids(instance),
|
||||
"module_ids": self.get_module_ids(instance),
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
class IssueViewSerializer(DynamicBaseSerializer):
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
|
||||
|
||||
@@ -1,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):
|
||||
|
||||
@@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
|
||||
urlpatterns = [
|
||||
# API Tokens
|
||||
path(
|
||||
"users/api-tokens/",
|
||||
"workspaces/<str:slug>/api-tokens/",
|
||||
ApiTokenEndpoint.as_view(),
|
||||
name="api-tokens",
|
||||
),
|
||||
path(
|
||||
"users/api-tokens/<uuid:pk>/",
|
||||
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
|
||||
ApiTokenEndpoint.as_view(),
|
||||
name="api-tokens-details",
|
||||
name="api-tokens",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/service-api-tokens/",
|
||||
|
||||
@@ -12,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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -106,9 +106,6 @@ from .asset.v2 import (
|
||||
AssetRestoreEndpoint,
|
||||
ProjectAssetEndpoint,
|
||||
ProjectBulkAssetEndpoint,
|
||||
AssetCheckEndpoint,
|
||||
WorkspaceAssetDownloadEndpoint,
|
||||
ProjectAssetDownloadEndpoint,
|
||||
)
|
||||
from .issue.base import (
|
||||
IssueListEndpoint,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# Python import
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
|
||||
# Third party
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
from rest_framework import status
|
||||
|
||||
# Module import
|
||||
@@ -15,9 +13,12 @@ from plane.app.permissions import WorkspaceEntityPermission
|
||||
|
||||
|
||||
class ApiTokenEndpoint(BaseAPIView):
|
||||
def post(self, request: Request) -> Response:
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
|
||||
def post(self, request, slug):
|
||||
label = request.data.get("label", str(uuid4().hex))
|
||||
description = request.data.get("description", "")
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
expired_at = request.data.get("expired_at", None)
|
||||
|
||||
# Check the user type
|
||||
@@ -27,6 +28,7 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
label=label,
|
||||
description=description,
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
user_type=user_type,
|
||||
expired_at=expired_at,
|
||||
)
|
||||
@@ -35,23 +37,29 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
# Token will be only visible while creating
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def get(self, request: Request, pk: Optional[str] = None) -> Response:
|
||||
def get(self, request, slug, pk=None):
|
||||
if pk is None:
|
||||
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
|
||||
api_tokens = APIToken.objects.filter(
|
||||
user=request.user, workspace__slug=slug, is_service=False
|
||||
)
|
||||
serializer = APITokenReadSerializer(api_tokens, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
|
||||
api_tokens = APIToken.objects.get(
|
||||
user=request.user, workspace__slug=slug, pk=pk
|
||||
)
|
||||
serializer = APITokenReadSerializer(api_tokens)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def delete(self, request: Request, pk: str) -> Response:
|
||||
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
|
||||
def delete(self, request, slug, pk):
|
||||
api_token = APIToken.objects.get(
|
||||
workspace__slug=slug, user=request.user, pk=pk, is_service=False
|
||||
)
|
||||
api_token.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def patch(self, request: Request, pk: str) -> Response:
|
||||
api_token = APIToken.objects.get(user=request.user, pk=pk)
|
||||
def patch(self, request, slug, pk):
|
||||
api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk)
|
||||
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
@@ -62,7 +70,7 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
class ServiceApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
|
||||
def post(self, request: Request, slug: str) -> Response:
|
||||
def post(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
api_token = APIToken.objects.filter(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -32,7 +32,6 @@ from plane.app.serializers import (
|
||||
IssueDetailSerializer,
|
||||
IssueUserPropertySerializer,
|
||||
IssueSerializer,
|
||||
IssueListDetailSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
@@ -47,9 +46,6 @@ from plane.db.models import (
|
||||
CycleIssue,
|
||||
UserRecentVisit,
|
||||
ModuleIssue,
|
||||
IssueRelation,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -948,57 +944,10 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
# check for the project member role, if the role is 5 then check for the guest_view_all_features
|
||||
# if it is true then show all the issues else show only the issues created by the user
|
||||
permission_subquery = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, id=OuterRef("id")
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__role__gt=ROLE.GUEST.value,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__role=ROLE.GUEST.value,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__role=ROLE.GUEST.value,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
)
|
||||
.values("id")
|
||||
)
|
||||
# Main issue query
|
||||
issue = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
|
||||
.filter(Exists(permission_subquery))
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
@@ -1006,6 +955,43 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -1028,24 +1014,6 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
# Add additional prefetch based on expand parameter
|
||||
if self.expand:
|
||||
if "issue_relation" in self.expand:
|
||||
issue = issue.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_relation",
|
||||
queryset=IssueRelation.objects.select_related("related_issue"),
|
||||
)
|
||||
)
|
||||
if "issue_related" in self.expand:
|
||||
issue = issue.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_related",
|
||||
queryset=IssueRelation.objects.select_related("issue"),
|
||||
)
|
||||
)
|
||||
|
||||
issue = issue.filter(**filters)
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
# Issue queryset
|
||||
@@ -1056,7 +1024,7 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=(issue),
|
||||
on_results=lambda issue: IssueListDetailSerializer(
|
||||
on_results=lambda issue: IssueSerializer(
|
||||
issue, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
Prefetch,
|
||||
)
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Exists, F, Func, OuterRef, Q, UUIDField, Value, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.db import transaction
|
||||
@@ -18,7 +13,7 @@ from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.app.serializers import IssueViewSerializer, ViewIssueListSerializer
|
||||
from plane.app.serializers import IssueViewSerializer
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
FileAsset,
|
||||
@@ -30,12 +25,15 @@ from plane.db.models import (
|
||||
Project,
|
||||
CycleIssue,
|
||||
UserRecentVisit,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
ModuleIssue,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from .. import BaseViewSet
|
||||
from plane.db.models import UserFavorite
|
||||
@@ -145,28 +143,6 @@ class WorkspaceViewViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
def _get_project_permission_filters(self):
|
||||
"""
|
||||
Get common project permission filters for guest users and role-based access control.
|
||||
Returns Q object for filtering issues based on user role and project settings.
|
||||
"""
|
||||
return Q(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role > 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.issue_objects.annotate(
|
||||
@@ -176,25 +152,12 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("state")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
@@ -223,6 +186,43 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@@ -233,36 +233,126 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
# Get common project permission filters
|
||||
permission_filters = self._get_project_permission_filters()
|
||||
|
||||
# Base query for the counts
|
||||
total_issue_count = (
|
||||
Issue.issue_objects.filter(**filters)
|
||||
.filter(workspace__slug=slug)
|
||||
.filter(permission_filters)
|
||||
.only("id")
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Apply project permission filters to the issue queryset
|
||||
issue_queryset = issue_queryset.filter(permission_filters)
|
||||
# check for the project member role, if the role is 5 then check for the guest_view_all_features if it is true then show all the issues else show only the issues created by the user
|
||||
|
||||
issue_queryset = issue_queryset.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role < 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue_queryset, order_by_param=order_by_param
|
||||
)
|
||||
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data,
|
||||
total_count_queryset=total_issue_count,
|
||||
# Group by
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
|
||||
# issue queryset
|
||||
issue_queryset = issue_queryset_grouper(
|
||||
queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by
|
||||
)
|
||||
|
||||
if group_by:
|
||||
# Check group and sub group value paginate
|
||||
if sub_group_by:
|
||||
if group_by == sub_group_by:
|
||||
return Response(
|
||||
{
|
||||
"error": "Group by and sub group by cannot have same parameters"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
# group and sub group pagination
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
paginator_cls=SubGroupedOffsetPaginator,
|
||||
group_by_fields=issue_group_values(
|
||||
field=group_by, slug=slug, project_id=None, filters=filters
|
||||
),
|
||||
sub_group_by_fields=issue_group_values(
|
||||
field=sub_group_by,
|
||||
slug=slug,
|
||||
project_id=None,
|
||||
filters=filters,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
sub_group_by_field_name=sub_group_by,
|
||||
count_filter=Q(
|
||||
Q(issue_intake__status=1)
|
||||
| Q(issue_intake__status=-1)
|
||||
| Q(issue_intake__status=2)
|
||||
| Q(issue_intake__isnull=True),
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
# Group Paginate
|
||||
else:
|
||||
# Group paginate
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
paginator_cls=GroupedOffsetPaginator,
|
||||
group_by_fields=issue_group_values(
|
||||
field=group_by, slug=slug, project_id=None, filters=filters
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
count_filter=Q(
|
||||
Q(issue_intake__status=1)
|
||||
| Q(issue_intake__status=-1)
|
||||
| Q(issue_intake__status=2)
|
||||
| Q(issue_intake__isnull=True),
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
else:
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class IssueViewViewSet(BaseViewSet):
|
||||
serializer_class = IssueViewSerializer
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -27,7 +27,7 @@ def user_data():
|
||||
"email": "test@plane.so",
|
||||
"password": "test-password",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"last_name": "User"
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def create_user(db, user_data):
|
||||
user = User.objects.create(
|
||||
email=user_data["email"],
|
||||
first_name=user_data["first_name"],
|
||||
last_name=user_data["last_name"],
|
||||
last_name=user_data["last_name"]
|
||||
)
|
||||
user.set_password(user_data["password"])
|
||||
user.save()
|
||||
@@ -69,52 +69,10 @@ def session_client(api_client, create_user):
|
||||
return api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_bot_user(db):
|
||||
"""Create and return a bot user instance"""
|
||||
from uuid import uuid4
|
||||
|
||||
unique_id = uuid4().hex[:8]
|
||||
user = User.objects.create(
|
||||
email=f"bot-{unique_id}@plane.so",
|
||||
username=f"bot_user_{unique_id}",
|
||||
first_name="Bot",
|
||||
last_name="User",
|
||||
is_bot=True,
|
||||
)
|
||||
user.set_password("bot@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_token_data():
|
||||
"""Return sample API token data for testing"""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
return {
|
||||
"label": "Test API Token",
|
||||
"description": "Test description for API token",
|
||||
"expired_at": (timezone.now() + timedelta(days=30)).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_api_token_for_user(db, create_user):
|
||||
"""Create and return an API token for a specific user"""
|
||||
return APIToken.objects.create(
|
||||
label="Test Token",
|
||||
description="Test token description",
|
||||
user=create_user,
|
||||
user_type=0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plane_server(live_server):
|
||||
"""
|
||||
Renamed version of live_server fixture to avoid name clashes.
|
||||
Returns a live Django server for testing HTTP requests.
|
||||
"""
|
||||
return live_server
|
||||
return live_server
|
||||
@@ -1,372 +0,0 @@
|
||||
import pytest
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import APIToken, User
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestApiTokenEndpoint:
|
||||
"""Test cases for ApiTokenEndpoint"""
|
||||
|
||||
# POST /user/api-tokens/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_success(
|
||||
self, session_client, create_user, api_token_data
|
||||
):
|
||||
"""Test successful API token creation"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "token" in response.data
|
||||
assert response.data["label"] == api_token_data["label"]
|
||||
assert response.data["description"] == api_token_data["description"]
|
||||
assert response.data["user_type"] == 0 # Human user
|
||||
|
||||
# Verify token was created in database
|
||||
token = APIToken.objects.get(pk=response.data["id"])
|
||||
assert token.user == create_user
|
||||
assert token.label == api_token_data["label"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_for_bot_user(
|
||||
self, session_client, create_bot_user, api_token_data
|
||||
):
|
||||
"""Test API token creation for bot user"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_bot_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["user_type"] == 1 # Bot user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_minimal_data(self, session_client, create_user):
|
||||
"""Test API token creation with minimal data"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, {}, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "token" in response.data
|
||||
assert len(response.data["label"]) == 32 # UUID hex length
|
||||
assert response.data["description"] == ""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_with_expiry(self, session_client, create_user):
|
||||
"""Test API token creation with expiry date"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
future_date = timezone.now() + timedelta(days=30)
|
||||
data = {"label": "Expiring Token", "expired_at": future_date.isoformat()}
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify expiry date was set
|
||||
token = APIToken.objects.get(pk=response.data["id"])
|
||||
assert token.expired_at is not None
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_unauthenticated(self, api_client, api_token_data):
|
||||
"""Test API token creation without authentication"""
|
||||
# Arrange
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = api_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
# GET /user/api-tokens/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_get_all_api_tokens(self, session_client, create_user):
|
||||
"""Test retrieving all API tokens for user"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
|
||||
# Create multiple tokens
|
||||
APIToken.objects.create(label="Token 1", user=create_user, user_type=0)
|
||||
APIToken.objects.create(label="Token 2", user=create_user, user_type=0)
|
||||
# Create a service token (should be excluded)
|
||||
APIToken.objects.create(
|
||||
label="Service Token", user=create_user, user_type=0, is_service=True
|
||||
)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 2 # Only non-service tokens
|
||||
assert all(token["is_service"] is False for token in response.data)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_empty_api_tokens_list(self, session_client, create_user):
|
||||
"""Test retrieving API tokens when none exist"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == []
|
||||
|
||||
# GET /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_get_specific_api_token(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test retrieving a specific API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert str(response.data["id"]) == str(create_api_token_for_user.pk)
|
||||
assert response.data["label"] == create_api_token_for_user.label
|
||||
assert (
|
||||
"token" not in response.data
|
||||
) # Token should not be visible in read serializer
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test retrieving a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test retrieving another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"other-{unique_id}@plane.so"
|
||||
unique_username = f"other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(
|
||||
label="Other Token", user=other_user, user_type=0
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# DELETE /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_delete_api_token_success(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test successful API token deletion"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test deleting a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test deleting another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"delete-other-{unique_id}@plane.so"
|
||||
unique_username = f"delete_other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(
|
||||
label="Other Token", user=other_user, user_type=0
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Verify token still exists
|
||||
assert APIToken.objects.filter(pk=other_token.pk).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_service_api_token_forbidden(self, session_client, create_user):
|
||||
"""Test deleting a service API token (should fail)"""
|
||||
# Arrange
|
||||
service_token = APIToken.objects.create(
|
||||
label="Service Token", user=create_user, user_type=0, is_service=True
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": service_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Verify token still exists
|
||||
assert APIToken.objects.filter(pk=service_token.pk).exists()
|
||||
|
||||
# PATCH /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_patch_api_token_success(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test successful API token update"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
update_data = {
|
||||
"label": "Updated Token Label",
|
||||
"description": "Updated description",
|
||||
}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["label"] == update_data["label"]
|
||||
assert response.data["description"] == update_data["description"]
|
||||
|
||||
# Verify database was updated
|
||||
create_api_token_for_user.refresh_from_db()
|
||||
assert create_api_token_for_user.label == update_data["label"]
|
||||
assert create_api_token_for_user.description == update_data["description"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_api_token_partial_update(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test partial API token update"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
original_description = create_api_token_for_user.description
|
||||
update_data = {"label": "Only Label Updated"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["label"] == update_data["label"]
|
||||
assert response.data["description"] == original_description
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test updating a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
update_data = {"label": "New Label"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test updating another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"patch-other-{unique_id}@plane.so"
|
||||
unique_username = f"patch_other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(
|
||||
label="Other Token", user=other_user, user_type=0
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
||||
update_data = {"label": "Hacked Label"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# Verify token was not updated
|
||||
other_token.refresh_from_db()
|
||||
assert other_token.label == "Other Token"
|
||||
|
||||
# Authentication tests
|
||||
@pytest.mark.django_db
|
||||
def test_all_endpoints_require_authentication(self, api_client):
|
||||
"""Test that all endpoints require authentication"""
|
||||
# Arrange
|
||||
endpoints = [
|
||||
(reverse("api-tokens"), "get"),
|
||||
(reverse("api-tokens"), "post"),
|
||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "get"),
|
||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "patch"),
|
||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "delete"),
|
||||
]
|
||||
|
||||
# Act & Assert
|
||||
for url, method in endpoints:
|
||||
response = getattr(api_client, method)(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -1,75 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
Issue,
|
||||
User,
|
||||
IssueAssignee,
|
||||
WorkspaceMember,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.app.serializers.workspace import IssueRecentVisitSerializer
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssueRecentVisitSerializer:
|
||||
"""Test the IssueRecentVisitSerializer"""
|
||||
|
||||
def test_issue_recent_visit_serializer_fields(self, db):
|
||||
"""Test that the serializer includes the correct fields"""
|
||||
|
||||
test_user_1 = User.objects.create(
|
||||
email="test_user_1@example.com", first_name="Test", last_name="User"
|
||||
)
|
||||
|
||||
# To test for deleted issue assignee
|
||||
test_user_2 = User.objects.create(
|
||||
email="test_user_2@example.com",
|
||||
first_name="Other",
|
||||
last_name="User",
|
||||
username="some user name",
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace", slug="test-workspace", owner=test_user_1
|
||||
)
|
||||
|
||||
WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace)
|
||||
|
||||
project = Project.objects.create(
|
||||
name="Test Project", identifier="test-project", workspace=workspace
|
||||
)
|
||||
ProjectMember.objects.create(project=project, member=test_user_2)
|
||||
|
||||
issue = Issue.objects.create(
|
||||
name="Test Issue",
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
)
|
||||
|
||||
IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project)
|
||||
|
||||
# Deleted issue assignee
|
||||
IssueAssignee.objects.create(
|
||||
issue=issue,
|
||||
assignee=test_user_2,
|
||||
project=project,
|
||||
deleted_at=timezone.now(),
|
||||
)
|
||||
|
||||
serialized_data = IssueRecentVisitSerializer(
|
||||
issue,
|
||||
).data
|
||||
|
||||
# Check fields are present and correct
|
||||
assert "name" in serialized_data
|
||||
assert "assignees" in serialized_data
|
||||
assert "project_identifier" in serialized_data
|
||||
|
||||
assert serialized_data["name"] == "Test Issue"
|
||||
assert serialized_data["project_identifier"] == "TEST-PROJECT"
|
||||
|
||||
# Only including non-deleted issue assignees
|
||||
assert serialized_data["assignees"] == [test_user_1.id]
|
||||
@@ -16,7 +16,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order", "-created_at")
|
||||
).order_by("priority_order")
|
||||
order_by_param = (
|
||||
"priority_order" if order_by_param.startswith("-") else "-priority_order"
|
||||
)
|
||||
@@ -36,7 +36,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
|
||||
default=Value(len(state_order)),
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("state_order", "-created_at")
|
||||
).order_by("state_order")
|
||||
order_by_param = (
|
||||
"-state_order" if order_by_param.startswith("-") else "state_order"
|
||||
)
|
||||
@@ -55,18 +55,11 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
|
||||
if order_by_param.startswith("-")
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-min_values" if order_by_param.startswith("-") else "min_values",
|
||||
"-created_at",
|
||||
)
|
||||
).order_by("-min_values" if order_by_param.startswith("-") else "min_values")
|
||||
order_by_param = (
|
||||
"-min_values" if order_by_param.startswith("-") else "min_values"
|
||||
)
|
||||
else:
|
||||
# If the order_by_param is created_at, then don't add the -created_at
|
||||
if "created_at" in order_by_param:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param, "-created_at")
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
order_by_param = order_by_param
|
||||
return issue_queryset, order_by_param
|
||||
|
||||
@@ -102,7 +102,6 @@ class OffsetPaginator:
|
||||
max_limit=MAX_LIMIT,
|
||||
max_offset=None,
|
||||
on_results=None,
|
||||
total_count_queryset=None,
|
||||
):
|
||||
# Key tuple and remove `-` if descending order by
|
||||
self.key = (
|
||||
@@ -116,7 +115,6 @@ class OffsetPaginator:
|
||||
self.max_limit = max_limit
|
||||
self.max_offset = max_offset
|
||||
self.on_results = on_results
|
||||
self.total_count_queryset = total_count_queryset
|
||||
|
||||
def get_result(self, limit=1000, cursor=None):
|
||||
# offset is page #
|
||||
@@ -140,9 +138,9 @@ class OffsetPaginator:
|
||||
)
|
||||
# The current page
|
||||
page = cursor.offset
|
||||
# The offset - use limit instead of cursor.value for consistent pagination
|
||||
offset = cursor.offset * limit
|
||||
stop = offset + limit + 1
|
||||
# The offset
|
||||
offset = cursor.offset * cursor.value
|
||||
stop = offset + (cursor.value or limit) + 1
|
||||
|
||||
if self.max_offset is not None and offset >= self.max_offset:
|
||||
raise BadPaginationError("Pagination offset too large")
|
||||
@@ -150,21 +148,11 @@ class OffsetPaginator:
|
||||
raise BadPaginationError("Pagination offset cannot be negative")
|
||||
|
||||
results = queryset[offset:stop]
|
||||
|
||||
# Only slice from the end if we're going backwards (previous page)
|
||||
if cursor.value != limit and cursor.is_prev:
|
||||
if cursor.value != limit:
|
||||
results = results[-(limit + 1) :]
|
||||
|
||||
total_count = (
|
||||
self.total_count_queryset.count()
|
||||
if self.total_count_queryset
|
||||
else results.count()
|
||||
)
|
||||
|
||||
# Check if there are more results available after the current page
|
||||
|
||||
# Adjust cursors based on the results for pagination
|
||||
next_cursor = Cursor(limit, page + 1, False, len(results) > limit)
|
||||
next_cursor = Cursor(limit, page + 1, False, results.count() > limit)
|
||||
# If the page is greater than 0, then set the previous cursor
|
||||
prev_cursor = Cursor(limit, page - 1, True, page > 0)
|
||||
|
||||
@@ -176,7 +164,7 @@ class OffsetPaginator:
|
||||
results = self.on_results(results)
|
||||
|
||||
# Count the queryset
|
||||
count = total_count
|
||||
count = queryset.count()
|
||||
|
||||
# Optionally, calculate the total count and max_hits if needed
|
||||
max_hits = math.ceil(count / limit)
|
||||
@@ -208,7 +196,6 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
group_by_field_name,
|
||||
group_by_fields,
|
||||
count_filter,
|
||||
total_count_queryset=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -417,7 +404,6 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
group_by_fields,
|
||||
sub_group_by_fields,
|
||||
count_filter,
|
||||
total_count_queryset=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -708,7 +694,6 @@ class BasePaginator:
|
||||
sub_group_by_field_name=None,
|
||||
sub_group_by_fields=None,
|
||||
count_filter=None,
|
||||
total_count_queryset=None,
|
||||
**paginator_kwargs,
|
||||
):
|
||||
"""Paginate the request"""
|
||||
@@ -734,8 +719,6 @@ class BasePaginator:
|
||||
)
|
||||
paginator_kwargs["sub_group_by_fields"] = sub_group_by_fields
|
||||
|
||||
paginator_kwargs["total_count_queryset"] = total_count_queryset
|
||||
|
||||
paginator = paginator_cls(**paginator_kwargs)
|
||||
|
||||
try:
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.22
|
||||
Django==4.2.21
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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"];
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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"];
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
export type IssueEventProps = {
|
||||
eventName: string;
|
||||
payload: any;
|
||||
updates?: any;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type EventProps = {
|
||||
eventName: string;
|
||||
payload: any;
|
||||
updates?: any;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export const getWorkspaceEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
organization_size: payload.organization_size,
|
||||
first_time: payload.first_time,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getProjectEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.id,
|
||||
identifier: payload.identifier,
|
||||
project_visibility: payload.network == 2 ? "Public" : "Private",
|
||||
changed_properties: payload.changed_properties,
|
||||
lead_id: payload.project_lead,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getCycleEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
cycle_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
start_date: payload.start_date,
|
||||
target_date: payload.target_date,
|
||||
cycle_status: payload.status,
|
||||
changed_properties: payload.changed_properties,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getModuleEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
module_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
start_date: payload.start_date,
|
||||
target_date: payload.target_date,
|
||||
module_status: payload.status,
|
||||
lead_id: payload.lead,
|
||||
changed_properties: payload.changed_properties,
|
||||
member_ids: payload.members,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getPageEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
access: payload.access === 0 ? "Public" : "Private",
|
||||
is_locked: payload.is_locked,
|
||||
archived_at: payload.archived_at,
|
||||
created_by: payload.created_by,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getIssueEventPayload = (props: IssueEventProps) => {
|
||||
const { eventName, payload, updates, path } = props;
|
||||
let eventPayload: any = {
|
||||
issue_id: payload.id,
|
||||
estimate_point: payload.estimate_point,
|
||||
link_count: payload.link_count,
|
||||
target_date: payload.target_date,
|
||||
is_draft: payload.is_draft,
|
||||
label_ids: payload.label_ids,
|
||||
assignee_ids: payload.assignee_ids,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
sequence_id: payload.sequence_id,
|
||||
module_ids: payload.module_ids,
|
||||
sub_issues_count: payload.sub_issues_count,
|
||||
parent_id: payload.parent_id,
|
||||
project_id: payload.project_id,
|
||||
workspace_id: payload.workspace_id,
|
||||
priority: payload.priority,
|
||||
state_id: payload.state_id,
|
||||
start_date: payload.start_date,
|
||||
attachment_count: payload.attachment_count,
|
||||
cycle_id: payload.cycle_id,
|
||||
module_id: payload.module_id,
|
||||
archived_at: payload.archived_at,
|
||||
state: payload.state,
|
||||
view_id:
|
||||
path?.includes("workspace-views") || path?.includes("views")
|
||||
? path.split("/").pop()
|
||||
: "",
|
||||
};
|
||||
|
||||
if (eventName === ISSUE_UPDATED) {
|
||||
eventPayload = {
|
||||
...eventPayload,
|
||||
...updates,
|
||||
updated_from: props.path?.includes("workspace-views")
|
||||
? "All views"
|
||||
: props.path?.includes("cycles")
|
||||
? "Cycle"
|
||||
: props.path?.includes("modules")
|
||||
? "Module"
|
||||
: props.path?.includes("views")
|
||||
? "Project view"
|
||||
: props.path?.includes("inbox")
|
||||
? "Inbox"
|
||||
: props.path?.includes("draft")
|
||||
? "Draft"
|
||||
: "Project",
|
||||
};
|
||||
}
|
||||
return eventPayload;
|
||||
};
|
||||
|
||||
export const getProjectStateEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.id,
|
||||
state_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
group: payload.group,
|
||||
color: payload.color,
|
||||
default: payload.default,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
// Workspace crud Events
|
||||
export const WORKSPACE_CREATED = "Workspace created";
|
||||
export const WORKSPACE_UPDATED = "Workspace updated";
|
||||
export const WORKSPACE_DELETED = "Workspace deleted";
|
||||
// Project Events
|
||||
export const PROJECT_CREATED = "Project created";
|
||||
export const PROJECT_UPDATED = "Project updated";
|
||||
export const PROJECT_DELETED = "Project deleted";
|
||||
// Cycle Events
|
||||
export const CYCLE_CREATED = "Cycle created";
|
||||
export const CYCLE_UPDATED = "Cycle updated";
|
||||
export const CYCLE_DELETED = "Cycle deleted";
|
||||
export const CYCLE_FAVORITED = "Cycle favorited";
|
||||
export const CYCLE_UNFAVORITED = "Cycle unfavorited";
|
||||
// Module Events
|
||||
export const MODULE_CREATED = "Module created";
|
||||
export const MODULE_UPDATED = "Module updated";
|
||||
export const MODULE_DELETED = "Module deleted";
|
||||
export const MODULE_FAVORITED = "Module favorited";
|
||||
export const MODULE_UNFAVORITED = "Module unfavorited";
|
||||
export const MODULE_LINK_CREATED = "Module link created";
|
||||
export const MODULE_LINK_UPDATED = "Module link updated";
|
||||
export const MODULE_LINK_DELETED = "Module link deleted";
|
||||
// Issue Events
|
||||
export const ISSUE_CREATED = "Work item created";
|
||||
export const ISSUE_UPDATED = "Work item updated";
|
||||
export const ISSUE_DELETED = "Work item deleted";
|
||||
export const ISSUE_ARCHIVED = "Work item archived";
|
||||
export const ISSUE_RESTORED = "Work item restored";
|
||||
export const ISSUE_OPENED = "Work item opened";
|
||||
// Project State Events
|
||||
export const STATE_CREATED = "State created";
|
||||
export const STATE_UPDATED = "State updated";
|
||||
export const STATE_DELETED = "State deleted";
|
||||
// Project Page Events
|
||||
export const PAGE_CREATED = "Page created";
|
||||
export const PAGE_UPDATED = "Page updated";
|
||||
export const PAGE_DELETED = "Page deleted";
|
||||
// Member Events
|
||||
export const MEMBER_INVITED = "Member invited";
|
||||
export const MEMBER_ACCEPTED = "Member accepted";
|
||||
export const PROJECT_MEMBER_ADDED = "Project member added";
|
||||
export const PROJECT_MEMBER_LEAVE = "Project member leave";
|
||||
export const WORKSPACE_MEMBER_LEAVE = "Workspace member leave";
|
||||
// Sign-in & Sign-up Events
|
||||
export const NAVIGATE_TO_SIGNUP = "Navigate to sign-up page";
|
||||
export const NAVIGATE_TO_SIGNIN = "Navigate to sign-in page";
|
||||
export const CODE_VERIFIED = "Code verified";
|
||||
export const SETUP_PASSWORD = "Password setup";
|
||||
export const PASSWORD_CREATE_SELECTED = "Password created";
|
||||
export const PASSWORD_CREATE_SKIPPED = "Skipped to setup";
|
||||
export const SIGN_IN_WITH_PASSWORD = "Sign in with password";
|
||||
export const SIGN_UP_WITH_PASSWORD = "Sign up with password";
|
||||
export const SIGN_IN_WITH_CODE = "Sign in with magic link";
|
||||
export const FORGOT_PASSWORD = "Forgot password clicked";
|
||||
export const FORGOT_PASS_LINK = "Forgot password link generated";
|
||||
export const NEW_PASS_CREATED = "New password created";
|
||||
// Onboarding Events
|
||||
export const USER_DETAILS = "User details added";
|
||||
export const USER_ONBOARDING_COMPLETED = "User onboarding completed";
|
||||
// Product Tour Events
|
||||
export const PRODUCT_TOUR_STARTED = "Product tour started";
|
||||
export const PRODUCT_TOUR_COMPLETED = "Product tour completed";
|
||||
export const PRODUCT_TOUR_SKIPPED = "Product tour skipped";
|
||||
// Dashboard Events
|
||||
export const CHANGELOG_REDIRECTED = "Changelog redirected";
|
||||
export const GITHUB_REDIRECTED = "GitHub redirected";
|
||||
// Sidebar Events
|
||||
export const SIDEBAR_CLICKED = "Sidenav clicked";
|
||||
// Global View Events
|
||||
export const GLOBAL_VIEW_CREATED = "Global view created";
|
||||
export const GLOBAL_VIEW_UPDATED = "Global view updated";
|
||||
export const GLOBAL_VIEW_DELETED = "Global view deleted";
|
||||
export const GLOBAL_VIEW_OPENED = "Global view opened";
|
||||
// Notification Events
|
||||
export const NOTIFICATION_ARCHIVED = "Notification archived";
|
||||
export const NOTIFICATION_SNOOZED = "Notification snoozed";
|
||||
export const NOTIFICATION_READ = "Notification marked read";
|
||||
export const UNREAD_NOTIFICATIONS = "Unread notifications viewed";
|
||||
export const NOTIFICATIONS_READ = "All notifications marked read";
|
||||
export const SNOOZED_NOTIFICATIONS = "Snoozed notifications viewed";
|
||||
export const ARCHIVED_NOTIFICATIONS = "Archived notifications viewed";
|
||||
// Groups
|
||||
export const GROUP_WORKSPACE = "Workspace_metrics";
|
||||
|
||||
//Elements
|
||||
export const E_ONBOARDING = "Onboarding";
|
||||
export const E_ONBOARDING_STEP_1 = "Onboarding step 1";
|
||||
export const E_ONBOARDING_STEP_2 = "Onboarding step 2";
|
||||
// Favorites
|
||||
export const FAVORITE_ADDED = "Favorite added";
|
||||
@@ -1,258 +0,0 @@
|
||||
export type IssueEventProps = {
|
||||
eventName: string;
|
||||
payload: any;
|
||||
updates?: any;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type EventProps = {
|
||||
eventName: string;
|
||||
payload: any;
|
||||
updates?: any;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export const getWorkspaceEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
organization_size: payload.organization_size,
|
||||
first_time: payload.first_time,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getProjectEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.id,
|
||||
identifier: payload.identifier,
|
||||
project_visibility: payload.network == 2 ? "Public" : "Private",
|
||||
changed_properties: payload.changed_properties,
|
||||
lead_id: payload.project_lead,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getCycleEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
cycle_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
start_date: payload.start_date,
|
||||
target_date: payload.target_date,
|
||||
cycle_status: payload.status,
|
||||
changed_properties: payload.changed_properties,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getModuleEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
module_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
start_date: payload.start_date,
|
||||
target_date: payload.target_date,
|
||||
module_status: payload.status,
|
||||
lead_id: payload.lead,
|
||||
changed_properties: payload.changed_properties,
|
||||
member_ids: payload.members,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getPageEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
access: payload.access === 0 ? "Public" : "Private",
|
||||
is_locked: payload.is_locked,
|
||||
archived_at: payload.archived_at,
|
||||
created_by: payload.created_by,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getIssueEventPayload = (props: IssueEventProps) => {
|
||||
const { eventName, payload, updates, path } = props;
|
||||
let eventPayload: any = {
|
||||
issue_id: payload.id,
|
||||
estimate_point: payload.estimate_point,
|
||||
link_count: payload.link_count,
|
||||
target_date: payload.target_date,
|
||||
is_draft: payload.is_draft,
|
||||
label_ids: payload.label_ids,
|
||||
assignee_ids: payload.assignee_ids,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
sequence_id: payload.sequence_id,
|
||||
module_ids: payload.module_ids,
|
||||
sub_issues_count: payload.sub_issues_count,
|
||||
parent_id: payload.parent_id,
|
||||
project_id: payload.project_id,
|
||||
workspace_id: payload.workspace_id,
|
||||
priority: payload.priority,
|
||||
state_id: payload.state_id,
|
||||
start_date: payload.start_date,
|
||||
attachment_count: payload.attachment_count,
|
||||
cycle_id: payload.cycle_id,
|
||||
module_id: payload.module_id,
|
||||
archived_at: payload.archived_at,
|
||||
state: payload.state,
|
||||
view_id: path?.includes("workspace-views") || path?.includes("views") ? path.split("/").pop() : "",
|
||||
};
|
||||
|
||||
if (eventName === WORK_ITEM_TRACKER_EVENTS.update) {
|
||||
eventPayload = {
|
||||
...eventPayload,
|
||||
...updates,
|
||||
updated_from: props.path?.includes("workspace-views")
|
||||
? "All views"
|
||||
: props.path?.includes("cycles")
|
||||
? "Cycle"
|
||||
: props.path?.includes("modules")
|
||||
? "Module"
|
||||
: props.path?.includes("views")
|
||||
? "Project view"
|
||||
: props.path?.includes("inbox")
|
||||
? "Inbox"
|
||||
: props.path?.includes("draft")
|
||||
? "Draft"
|
||||
: "Project",
|
||||
};
|
||||
}
|
||||
return eventPayload;
|
||||
};
|
||||
|
||||
export const getProjectStateEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.id,
|
||||
state_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
group: payload.group,
|
||||
color: payload.color,
|
||||
default: payload.default,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
// Dashboard Events
|
||||
export const GITHUB_REDIRECTED_TRACKER_EVENT = "github_redirected";
|
||||
// Groups
|
||||
export const GROUP_WORKSPACE_TRACKER_EVENT = "workspace_metrics";
|
||||
|
||||
export const WORKSPACE_TRACKER_EVENTS = {
|
||||
create: "workspace_created",
|
||||
update: "workspace_updated",
|
||||
delete: "workspace_deleted",
|
||||
};
|
||||
|
||||
export const PROJECT_TRACKER_EVENTS = {
|
||||
create: "project_created",
|
||||
update: "project_updated",
|
||||
delete: "project_deleted",
|
||||
};
|
||||
|
||||
export const CYCLE_TRACKER_EVENTS = {
|
||||
create: "cycle_created",
|
||||
update: "cycle_updated",
|
||||
delete: "cycle_deleted",
|
||||
favorite: "cycle_favorited",
|
||||
unfavorite: "cycle_unfavorited",
|
||||
};
|
||||
|
||||
export const MODULE_TRACKER_EVENTS = {
|
||||
create: "module_created",
|
||||
update: "module_updated",
|
||||
delete: "module_deleted",
|
||||
favorite: "module_favorited",
|
||||
unfavorite: "module_unfavorited",
|
||||
link: {
|
||||
create: "module_link_created",
|
||||
update: "module_link_updated",
|
||||
delete: "module_link_deleted",
|
||||
},
|
||||
};
|
||||
|
||||
export const WORK_ITEM_TRACKER_EVENTS = {
|
||||
create: "work_item_created",
|
||||
update: "work_item_updated",
|
||||
delete: "work_item_deleted",
|
||||
archive: "work_item_archived",
|
||||
restore: "work_item_restored",
|
||||
};
|
||||
|
||||
export const STATE_TRACKER_EVENTS = {
|
||||
create: "state_created",
|
||||
update: "state_updated",
|
||||
delete: "state_deleted",
|
||||
};
|
||||
|
||||
export const PROJECT_PAGE_TRACKER_EVENTS = {
|
||||
create: "project_page_created",
|
||||
update: "project_page_updated",
|
||||
delete: "project_page_deleted",
|
||||
};
|
||||
|
||||
export const MEMBER_TRACKER_EVENTS = {
|
||||
invite: "member_invited",
|
||||
accept: "member_accepted",
|
||||
project: {
|
||||
add: "project_member_added",
|
||||
leave: "project_member_left",
|
||||
},
|
||||
workspace: {
|
||||
leave: "workspace_member_left",
|
||||
},
|
||||
};
|
||||
|
||||
export const AUTH_TRACKER_EVENTS = {
|
||||
navigate: {
|
||||
sign_up: "navigate_to_sign_up_page",
|
||||
sign_in: "navigate_to_sign_in_page",
|
||||
},
|
||||
code_verify: "code_verified",
|
||||
sign_up_with_password: "sign_up_with_password",
|
||||
sign_in_with_password: "sign_in_with_password",
|
||||
sign_in_with_code: "sign_in_with_magic_link",
|
||||
forgot_password: "forgot_password_clicked",
|
||||
};
|
||||
|
||||
export const PRODUCT_TOUR_TRACKER_EVENTS = {
|
||||
start: "product_tour_started",
|
||||
complete: "product_tour_completed",
|
||||
skip: "product_tour_skipped",
|
||||
};
|
||||
|
||||
export const GLOBAL_VIEW_TOUR_TRACKER_EVENTS = {
|
||||
create: "global_view_created",
|
||||
update: "global_view_updated",
|
||||
delete: "global_view_deleted",
|
||||
open: "global_view_opened",
|
||||
};
|
||||
|
||||
export const NOTIFICATION_TRACKER_EVENTS = {
|
||||
archive: "notification_archived",
|
||||
all_marked_read: "all_notifications_marked_read",
|
||||
};
|
||||
|
||||
export const USER_TRACKER_EVENTS = {
|
||||
add_details: "user_details_added",
|
||||
onboarding_complete: "user_onboarding_completed",
|
||||
};
|
||||
|
||||
export const ONBOARDING_TRACKER_EVENTS = {
|
||||
root: "onboarding",
|
||||
step_1: "onboarding_step_1",
|
||||
step_2: "onboarding_step_2",
|
||||
};
|
||||
|
||||
export const SIDEBAR_TRACKER_EVENTS = {
|
||||
click: "sidenav_clicked",
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./core";
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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";
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
"use client"
|
||||
|
||||
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
|
||||
|
||||
export type TDraggableData = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
import { Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import type { IEditorProps } from "@/types";
|
||||
import { TExtensions, TFileHandler } from "@/types";
|
||||
|
||||
export type TCoreAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
>;
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: TFileHandler;
|
||||
};
|
||||
|
||||
export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => {
|
||||
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
const {} = props;
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
import { Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import type { IReadOnlyEditorProps } from "@/types";
|
||||
import { TExtensions } from "@/types";
|
||||
|
||||
export type TCoreReadOnlyEditorAdditionalExtensionsProps = Pick<
|
||||
IReadOnlyEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions"
|
||||
>;
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
|
||||
export const CoreReadOnlyEditorAdditionalExtensions = (
|
||||
props: TCoreReadOnlyEditorAdditionalExtensionsProps
|
||||
): Extensions => {
|
||||
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
const {} = props;
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { AnyExtension } from "@tiptap/core";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { AnyExtension } from "@tiptap/core";
|
||||
import { SlashCommands } from "@/extensions";
|
||||
// plane editor types
|
||||
import type { TEmbedConfig } from "@/plane-editor/types";
|
||||
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import type { IEditorProps, TExtensions, TUserDetails } from "@/types";
|
||||
import { TExtensions, TUserDetails } from "@/types";
|
||||
|
||||
export type TDocumentEditorAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
> & {
|
||||
embedConfig: TEmbedConfig | undefined;
|
||||
provider?: HocuspocusProvider;
|
||||
type Props = {
|
||||
disabledExtensions?: TExtensions[];
|
||||
issueEmbedConfig: TIssueEmbedConfig | undefined;
|
||||
provider: HocuspocusProvider;
|
||||
userDetails: TUserDetails;
|
||||
};
|
||||
|
||||
export type TDocumentEditorAdditionalExtensionsRegistry = {
|
||||
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
|
||||
getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension;
|
||||
type ExtensionConfig = {
|
||||
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
|
||||
getExtension: (props: Props) => AnyExtension;
|
||||
};
|
||||
|
||||
const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [
|
||||
const extensionRegistry: ExtensionConfig[] = [
|
||||
{
|
||||
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
|
||||
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
|
||||
SlashCommands({ disabledExtensions, flaggedExtensions }),
|
||||
getExtension: () => SlashCommands({}),
|
||||
},
|
||||
];
|
||||
|
||||
export const DocumentEditorAdditionalExtensions = (props: TDocumentEditorAdditionalExtensionsProps) => {
|
||||
const { disabledExtensions, flaggedExtensions } = props;
|
||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||
const { disabledExtensions = [] } = _props;
|
||||
|
||||
const documentExtensions = extensionRegistry
|
||||
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
|
||||
.map((config) => config.getExtension(props));
|
||||
.filter((config) => config.isEnabled(disabledExtensions))
|
||||
.map((config) => config.getExtension(_props));
|
||||
|
||||
return documentExtensions;
|
||||
};
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { AnyExtension, Extensions } from "@tiptap/core";
|
||||
// extensions
|
||||
import { SlashCommands } from "@/extensions/slash-commands/root";
|
||||
// types
|
||||
import { IEditorProps, TExtensions } from "@/types";
|
||||
|
||||
export type TRichTextEditorAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Registry entry configuration for extensions
|
||||
*/
|
||||
export type TRichTextEditorAdditionalExtensionsRegistry = {
|
||||
/** Determines if the extension should be enabled based on disabled extensions */
|
||||
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: 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, flaggedExtensions }) =>
|
||||
SlashCommands({
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => {
|
||||
const { disabledExtensions, flaggedExtensions } = props;
|
||||
|
||||
const extensions: Extensions = extensionRegistry
|
||||
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
|
||||
.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 { IReadOnlyEditorProps, TExtensions } from "@/types";
|
||||
|
||||
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = Pick<
|
||||
IReadOnlyEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
// extensions
|
||||
import type { TSlashCommandAdditionalOption } from "@/extensions";
|
||||
import { TSlashCommandAdditionalOption } from "@/extensions";
|
||||
// types
|
||||
import type { IEditorProps } from "@/types";
|
||||
import { TExtensions } from "@/types";
|
||||
|
||||
type Props = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions">;
|
||||
type Props = {
|
||||
disabledExtensions?: TExtensions[];
|
||||
};
|
||||
|
||||
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
|
||||
const {} = props;
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* @description function to extract all additional assets from HTML content
|
||||
* @param htmlContent
|
||||
* @returns {string[]} array of additional asset sources
|
||||
*/
|
||||
export const extractAdditionalAssetsFromHTMLContent = (_htmlContent: string): string[] => [];
|
||||
|
||||
/**
|
||||
* @description function to replace additional assets in HTML content with new IDs
|
||||
* @param props
|
||||
* @returns {string} HTML content with replaced additional assets
|
||||
*/
|
||||
export const replaceAdditionalAssetsInHTMLContent = (props: {
|
||||
htmlContent: string;
|
||||
assetMap: Record<string, string>;
|
||||
}): string => {
|
||||
const { htmlContent } = props;
|
||||
return htmlContent;
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { type HeadingExtensionStorage } from "@/extensions";
|
||||
import { type CustomImageExtensionStorage } from "@/extensions/custom-image/types";
|
||||
import { type CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
import { type CustomLinkStorage } from "@/extensions/custom-link";
|
||||
import { type ImageExtensionStorage } from "@/extensions/image";
|
||||
import { type MentionExtensionStorage } from "@/extensions/mentions";
|
||||
|
||||
@@ -13,11 +13,10 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
|
||||
// types
|
||||
import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
|
||||
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
|
||||
|
||||
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
|
||||
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
const {
|
||||
onChange,
|
||||
onTransaction,
|
||||
aiHandler,
|
||||
bubbleMenuEnabled = true,
|
||||
@@ -28,7 +27,6 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
@@ -58,12 +56,10 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
||||
embedHandler,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
@@ -99,7 +95,7 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
||||
);
|
||||
};
|
||||
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditor>(
|
||||
(props, ref) => (
|
||||
<CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus"
|
||||
// types
|
||||
import { TAIHandler, TDisplayConfig } from "@/types";
|
||||
|
||||
type Props = {
|
||||
type IPageRenderer = {
|
||||
aiHandler?: TAIHandler;
|
||||
bubbleMenuEnabled: boolean;
|
||||
displayConfig: TDisplayConfig;
|
||||
@@ -15,7 +15,7 @@ type Props = {
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: Props) => {
|
||||
export const PageRenderer = (props: IPageRenderer) => {
|
||||
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import React, { forwardRef, MutableRefObject } from "react";
|
||||
import { forwardRef, MutableRefObject } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
@@ -13,9 +13,30 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from "@/types";
|
||||
import {
|
||||
EditorReadOnlyRefApi,
|
||||
TDisplayConfig,
|
||||
TExtensions,
|
||||
TReadOnlyFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
} from "@/types";
|
||||
|
||||
const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) => {
|
||||
interface IDocumentReadOnlyEditor {
|
||||
disabledExtensions: TExtensions[];
|
||||
id: string;
|
||||
initialValue: string;
|
||||
containerClassName: string;
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
embedHandler: any;
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
tabIndex?: number;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
}
|
||||
|
||||
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
const {
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
@@ -23,7 +44,6 @@ const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) =
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
id,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
@@ -44,7 +64,6 @@ const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) =
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
initialValue,
|
||||
@@ -68,7 +87,7 @@ const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) =
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps>((props, ref) => (
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
|
||||
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -53,14 +53,17 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2));
|
||||
const lastNode = lastNodePos.node();
|
||||
|
||||
// Check if its last node and add new node
|
||||
if (lastNode) {
|
||||
const isLastNodeEmptyParagraph = lastNode.type.name === CORE_EXTENSIONS.PARAGRAPH && lastNode.content.size === 0;
|
||||
// Only insert a new paragraph if the last node is not an empty paragraph and not a doc node
|
||||
if (!isLastNodeEmptyParagraph && lastNode.type.name !== "doc") {
|
||||
const endPosition = editor?.state.doc.content.size;
|
||||
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).focus("end").run();
|
||||
}
|
||||
// Check if the last node is a not paragraph
|
||||
if (lastNode && lastNode.type.name !== CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If last node is not a paragraph, insert a new paragraph at the end
|
||||
const endPosition = editor?.state.doc.content.size;
|
||||
editor?.chain().insertContentAt(endPosition, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
|
||||
|
||||
// Focus the newly added paragraph for immediate editing
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(endPosition + 1)
|
||||
.run();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
|
||||
|
||||
@@ -26,7 +26,6 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
id,
|
||||
initialValue,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
@@ -45,7 +44,6 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
enableHistory: true,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
id,
|
||||
initialValue,
|
||||
|
||||
@@ -4,25 +4,23 @@ import { EditorWrapper } from "@/components/editors/editor-wrapper";
|
||||
// extensions
|
||||
import { EnterKeyExtension } from "@/extensions";
|
||||
// types
|
||||
import { EditorRefApi, ILiteTextEditorProps } from "@/types";
|
||||
import { EditorRefApi, ILiteTextEditor } from "@/types";
|
||||
|
||||
const LiteTextEditor: React.FC<ILiteTextEditorProps> = (props) => {
|
||||
const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const resolvedExtensions = [...externalExtensions];
|
||||
|
||||
if (!disabledExtensions?.includes("enter-key")) {
|
||||
resolvedExtensions.push(EnterKeyExtension(onEnterKeyPress));
|
||||
}
|
||||
|
||||
return resolvedExtensions;
|
||||
}, [externalExtensions, disabledExtensions, onEnterKeyPress]);
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
...externalExtensions,
|
||||
...(disabledExtensions?.includes("enter-key") ? [] : [EnterKeyExtension(onEnterKeyPress)]),
|
||||
],
|
||||
[externalExtensions, disabledExtensions, onEnterKeyPress]
|
||||
);
|
||||
|
||||
return <EditorWrapper {...props} extensions={extensions} />;
|
||||
};
|
||||
|
||||
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditorProps>((props, ref) => (
|
||||
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
|
||||
<LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { forwardRef } from "react";
|
||||
// components
|
||||
import { ReadOnlyEditorWrapper } from "@/components/editors";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps } from "@/types";
|
||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor } from "@/types";
|
||||
|
||||
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps>((props, ref) => (
|
||||
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditor>((props, ref) => (
|
||||
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -15,9 +15,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
id,
|
||||
initialValue,
|
||||
@@ -27,9 +25,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
const editor = useReadOnlyEditor({
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
|
||||
@@ -3,21 +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, IRichTextEditorProps } from "@/types";
|
||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||
|
||||
const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
|
||||
const {
|
||||
bubbleMenuEnabled = true,
|
||||
disabledExtensions,
|
||||
dragDropEnabled,
|
||||
extensions: externalExtensions = [],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
} = props;
|
||||
const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const { disabledExtensions, dragDropEnabled, bubbleMenuEnabled = true, extensions: externalExtensions = [] } = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
const extensions = [
|
||||
@@ -26,15 +17,17 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
|
||||
aiEnabled: false,
|
||||
dragDropEnabled: !!dragDropEnabled,
|
||||
}),
|
||||
...RichTextEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
}),
|
||||
];
|
||||
if (!disabledExtensions?.includes("slash-commands")) {
|
||||
extensions.push(
|
||||
SlashCommands({
|
||||
disabledExtensions,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions]);
|
||||
}, [dragDropEnabled, disabledExtensions, externalExtensions]);
|
||||
|
||||
return (
|
||||
<EditorWrapper {...props} extensions={getExtensions()}>
|
||||
@@ -43,7 +36,7 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditorProps>((props, ref) => (
|
||||
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditor>((props, ref) => (
|
||||
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -1,32 +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, IRichTextReadOnlyEditorProps } from "@/types";
|
||||
// local imports
|
||||
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types";
|
||||
import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper";
|
||||
|
||||
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps>((props, ref) => {
|
||||
const { disabledExtensions, fileHandler, flaggedExtensions } = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
const extensions = RichTextReadOnlyEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
});
|
||||
|
||||
return extensions;
|
||||
}, [disabledExtensions, fileHandler, flaggedExtensions]);
|
||||
|
||||
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";
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
BubbleMenuLinkSelector,
|
||||
BubbleMenuNodeSelector,
|
||||
CodeItem,
|
||||
EditorMenuItem,
|
||||
ItalicItem,
|
||||
StrikeThroughItem,
|
||||
TextAlignItem,
|
||||
@@ -24,7 +23,6 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
// local components
|
||||
import { TextAlignmentSelector } from "./alignment-selector";
|
||||
import { TEditorCommands } from "@/types";
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
||||
|
||||
@@ -33,19 +31,19 @@ export interface EditorStateType {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strikethrough: boolean;
|
||||
strike: boolean;
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
center: boolean;
|
||||
color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined;
|
||||
backgroundColor:
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
| undefined;
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Editor }) => {
|
||||
@@ -60,10 +58,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
bold: BoldItem(props.editor),
|
||||
italic: ItalicItem(props.editor),
|
||||
underline: UnderLineItem(props.editor),
|
||||
strikethrough: StrikeThroughItem(props.editor),
|
||||
"text-align": TextAlignItem(props.editor),
|
||||
} satisfies {
|
||||
[K in TEditorCommands]?: EditorMenuItem<K>;
|
||||
strike: StrikeThroughItem(props.editor),
|
||||
textAlign: TextAlignItem(props.editor),
|
||||
};
|
||||
|
||||
const editorState: EditorStateType = useEditorState({
|
||||
@@ -73,10 +69,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
bold: formattingItems.bold.isActive(),
|
||||
italic: formattingItems.italic.isActive(),
|
||||
underline: formattingItems.underline.isActive(),
|
||||
strikethrough: formattingItems.strikethrough.isActive(),
|
||||
left: formattingItems["text-align"].isActive({ alignment: "left" }),
|
||||
right: formattingItems["text-align"].isActive({ alignment: "right" }),
|
||||
center: formattingItems["text-align"].isActive({ alignment: "center" }),
|
||||
strike: formattingItems.strike.isActive(),
|
||||
left: formattingItems.textAlign.isActive({ alignment: "left" }),
|
||||
right: formattingItems.textAlign.isActive({ alignment: "right" }),
|
||||
center: formattingItems.textAlign.isActive({ alignment: "center" }),
|
||||
color: COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })),
|
||||
backgroundColor: COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })),
|
||||
}),
|
||||
@@ -84,7 +80,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
|
||||
const basicFormattingOptions = editorState.code
|
||||
? [formattingItems.code]
|
||||
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
|
||||
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike];
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
|
||||
@@ -12,10 +12,10 @@ import { CustomCalloutExtensionConfig } from "./callout/extension-config";
|
||||
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
|
||||
import { CustomCodeInlineExtension } from "./code-inline";
|
||||
import { CustomColorExtension } from "./custom-color";
|
||||
import { CustomImageExtensionConfig } from "./custom-image/extension-config";
|
||||
import { CustomLinkExtension } from "./custom-link";
|
||||
import { CustomHorizontalRule } from "./horizontal-rule";
|
||||
import { ImageExtensionConfig } from "./image";
|
||||
import { ImageExtensionWithoutProps } from "./image";
|
||||
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
|
||||
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
@@ -72,8 +72,12 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
ImageExtensionConfig,
|
||||
CustomImageExtensionConfig,
|
||||
ImageExtensionWithoutProps.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomImageComponentWithoutProps,
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
|
||||
+53
-27
@@ -1,42 +1,68 @@
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||
// plane imports
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
|
||||
import { ensurePixelString } from "../utils";
|
||||
import type { CustomImageNodeViewProps } from "./node-view";
|
||||
import { ImageToolbarRoot } from "./toolbar";
|
||||
// extensions
|
||||
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
|
||||
import { ImageUploadStatus } from "./upload-status";
|
||||
|
||||
const MIN_SIZE = 100;
|
||||
|
||||
type CustomImageBlockProps = CustomImageNodeViewProps & {
|
||||
editorContainer: HTMLDivElement | null;
|
||||
type Pixel = `${number}px`;
|
||||
|
||||
type PixelAttribute<TDefault> = Pixel | TDefault;
|
||||
|
||||
export type ImageAttributes = {
|
||||
src: string | null;
|
||||
width: PixelAttribute<"35%" | number>;
|
||||
height: PixelAttribute<"auto" | number>;
|
||||
aspectRatio: number | null;
|
||||
id: string | null;
|
||||
};
|
||||
|
||||
type Size = {
|
||||
width: PixelAttribute<"35%">;
|
||||
height: PixelAttribute<"auto">;
|
||||
aspectRatio: number | null;
|
||||
};
|
||||
|
||||
const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => {
|
||||
if (!value || value === defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return `${value}px` satisfies Pixel;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
type CustomImageBlockProps = CustomBaseImageNodeViewProps & {
|
||||
imageFromFileSystem: string | undefined;
|
||||
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
|
||||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
editorContainer: HTMLDivElement | null;
|
||||
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
|
||||
src: string | undefined;
|
||||
};
|
||||
|
||||
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
// props
|
||||
const {
|
||||
node,
|
||||
updateAttributes,
|
||||
setFailedToLoadImage,
|
||||
imageFromFileSystem,
|
||||
selected,
|
||||
getPos,
|
||||
editor,
|
||||
editorContainer,
|
||||
extension,
|
||||
getPos,
|
||||
imageFromFileSystem,
|
||||
node,
|
||||
selected,
|
||||
setEditorContainer,
|
||||
setFailedToLoadImage,
|
||||
src: resolvedImageSrc,
|
||||
updateAttributes,
|
||||
setEditorContainer,
|
||||
} = props;
|
||||
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
|
||||
// states
|
||||
const [size, setSize] = useState<TCustomImageSize>({
|
||||
const [size, setSize] = useState<Size>({
|
||||
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
|
||||
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
|
||||
aspectRatio: nodeAspectRatio || null,
|
||||
@@ -51,7 +77,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
|
||||
|
||||
const updateAttributesSafely = useCallback(
|
||||
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
|
||||
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
|
||||
try {
|
||||
updateAttributes(attributes);
|
||||
} catch (error) {
|
||||
@@ -88,7 +114,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
|
||||
const initialHeight = initialWidth / aspectRatioCalculated;
|
||||
|
||||
const initialComputedSize: TCustomImageSize = {
|
||||
const initialComputedSize = {
|
||||
width: `${Math.round(initialWidth)}px` satisfies Pixel,
|
||||
height: `${Math.round(initialHeight)}px` satisfies Pixel,
|
||||
aspectRatio: aspectRatioCalculated,
|
||||
@@ -113,7 +139,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
}
|
||||
}
|
||||
setInitialResizeComplete(true);
|
||||
}, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]);
|
||||
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
|
||||
|
||||
// for real time resizing
|
||||
useLayoutEffect(() => {
|
||||
@@ -142,7 +168,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
|
||||
}, [size, updateAttributesSafely]);
|
||||
}, [size, updateAttributes]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -216,7 +242,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
onLoad={handleImageLoad}
|
||||
onError={async (e) => {
|
||||
// for old image extension this command doesn't exist or if the image failed to load for the first time
|
||||
if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
|
||||
if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) {
|
||||
setFailedToLoadImage(true);
|
||||
return;
|
||||
}
|
||||
@@ -227,7 +253,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
if (!imgNodeSrc) {
|
||||
throw new Error("No source image to restore from");
|
||||
}
|
||||
await extension.options.restoreImage?.(imgNodeSrc);
|
||||
await editor?.commands.restoreImage?.(imgNodeSrc);
|
||||
if (!imageRef.current) {
|
||||
throw new Error("Image reference not found");
|
||||
}
|
||||
@@ -263,10 +289,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
|
||||
}
|
||||
image={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
|
||||
src: resolvedImageSrc,
|
||||
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
+25
-22
@@ -2,26 +2,25 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// local imports
|
||||
import type { CustomImageExtension, TCustomImageAttributes } from "../types";
|
||||
import { CustomImageBlock } from "./block";
|
||||
import { CustomImageUploader } from "./uploader";
|
||||
|
||||
export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
|
||||
extension: CustomImageExtension;
|
||||
export type CustomBaseImageNodeViewProps = {
|
||||
getPos: () => number;
|
||||
editor: Editor;
|
||||
node: NodeViewProps["node"] & {
|
||||
attrs: TCustomImageAttributes;
|
||||
attrs: ImageAttributes;
|
||||
};
|
||||
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
|
||||
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
|
||||
const { editor, extension, node } = props;
|
||||
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps;
|
||||
|
||||
export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
const { getPos, editor, node, updateAttributes, selected } = props;
|
||||
const { src: imgNodeSrc } = node.attrs;
|
||||
|
||||
const [isUploaded, setIsUploaded] = useState(false);
|
||||
@@ -51,37 +50,41 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
|
||||
}, [resolvedSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imgNodeSrc) {
|
||||
setResolvedSrc(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const getImageSource = async () => {
|
||||
const url = await extension.options.getImageSource?.(imgNodeSrc);
|
||||
setResolvedSrc(url);
|
||||
// @ts-expect-error function not expected here, but will still work and don't remove await
|
||||
const url: string = await editor?.commands?.getImageSource?.(imgNodeSrc);
|
||||
setResolvedSrc(url as string);
|
||||
};
|
||||
getImageSource();
|
||||
}, [imgNodeSrc, extension.options]);
|
||||
}, [imgNodeSrc]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
|
||||
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
|
||||
<CustomImageBlock
|
||||
editorContainer={editorContainer}
|
||||
imageFromFileSystem={imageFromFileSystem}
|
||||
editorContainer={editorContainer}
|
||||
editor={editor}
|
||||
src={resolvedSrc}
|
||||
getPos={getPos}
|
||||
node={node}
|
||||
setEditorContainer={setEditorContainer}
|
||||
setFailedToLoadImage={setFailedToLoadImage}
|
||||
src={resolvedSrc}
|
||||
{...props}
|
||||
selected={selected}
|
||||
updateAttributes={updateAttributes}
|
||||
/>
|
||||
) : (
|
||||
<CustomImageUploader
|
||||
editor={editor}
|
||||
failedToLoadImage={failedToLoadImage}
|
||||
getPos={getPos}
|
||||
loadImageFromFileSystem={setImageFromFileSystem}
|
||||
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
|
||||
node={node}
|
||||
setIsUploaded={setIsUploaded}
|
||||
{...props}
|
||||
selected={selected}
|
||||
updateAttributes={updateAttributes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
+21
-18
@@ -1,30 +1,28 @@
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
// plane imports
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { EFileError } from "@/helpers/file";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// hooks
|
||||
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||
// local imports
|
||||
import { getImageComponentImageFileMap } from "../utils";
|
||||
import type { CustomImageNodeViewProps } from "./node-view";
|
||||
|
||||
type CustomImageUploaderProps = CustomImageNodeViewProps & {
|
||||
failedToLoadImage: boolean;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & {
|
||||
maxFileSize: number;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
failedToLoadImage: boolean;
|
||||
setIsUploaded: (isUploaded: boolean) => void;
|
||||
};
|
||||
|
||||
export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
const {
|
||||
editor,
|
||||
extension,
|
||||
failedToLoadImage,
|
||||
getPos,
|
||||
loadImageFromFileSystem,
|
||||
@@ -73,13 +71,12 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
|
||||
);
|
||||
|
||||
const uploadImageEditorCommand = useCallback(
|
||||
async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
|
||||
[extension.options, imageEntityId]
|
||||
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file),
|
||||
[editor, imageEntityId]
|
||||
);
|
||||
|
||||
const handleProgressStatus = useCallback(
|
||||
@@ -89,23 +86,26 @@ 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,
|
||||
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
|
||||
editorCommand: uploadImageEditorCommand,
|
||||
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,
|
||||
@@ -130,7 +130,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
|
||||
}
|
||||
}
|
||||
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);
|
||||
}, [meta, uploadFile, imageComponentImageFileMap]);
|
||||
|
||||
const onFileChange = useCallback(
|
||||
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -140,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,
|
||||
@@ -165,7 +168,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
}
|
||||
|
||||
return "Add an image";
|
||||
}, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]);
|
||||
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./toolbar";
|
||||
export * from "./image-block";
|
||||
export * from "./image-node";
|
||||
export * from "./image-uploader";
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
// plane imports
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
image: {
|
||||
width: string;
|
||||
height: string;
|
||||
aspectRatio: number;
|
||||
src: string;
|
||||
height: string;
|
||||
width: string;
|
||||
aspectRatio: number;
|
||||
};
|
||||
isOpen: boolean;
|
||||
toggleFullScreenMode: (val: boolean) => void;
|
||||
@@ -189,7 +189,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||
<>
|
||||
<div
|
||||
className={cn("fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none transition-opacity", {
|
||||
"opacity-100 pointer-events-auto editor-image-full-screen-modal": isFullScreenEnabled,
|
||||
"opacity-100 pointer-events-auto": isFullScreenEnabled,
|
||||
"cursor-default": !isDragging,
|
||||
"cursor-grabbing": isDragging,
|
||||
})}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState } from "react";
|
||||
// plane imports
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
// components
|
||||
import { ImageFullScreenAction } from "./full-screen";
|
||||
|
||||
type Props = {
|
||||
containerClassName?: string;
|
||||
image: {
|
||||
width: string;
|
||||
height: string;
|
||||
aspectRatio: number;
|
||||
src: string;
|
||||
height: string;
|
||||
width: string;
|
||||
aspectRatio: number;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Editor, mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { isFileValid } from "@/helpers/file";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
export type InsertImageComponentProps = {
|
||||
file?: File;
|
||||
pos?: number;
|
||||
event: "insert" | "drop";
|
||||
};
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
|
||||
getImageSource?: (path: string) => () => Promise<string>;
|
||||
restoreImage: (src: string) => () => Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
|
||||
|
||||
export interface CustomImageExtensionStorage {
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
maxFileSize: number;
|
||||
}
|
||||
|
||||
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
||||
|
||||
export const CustomImageExtension = (props: TFileHandler) => {
|
||||
const {
|
||||
getAssetSrc,
|
||||
upload,
|
||||
restore: restoreImageFn,
|
||||
validation: { maxFileSize },
|
||||
} = props;
|
||||
|
||||
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertImageComponent:
|
||||
(props) =>
|
||||
({ commands }) => {
|
||||
// Early return if there's an invalid file being dropped
|
||||
if (
|
||||
props?.file &&
|
||||
!isFileValid({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
file: props.file,
|
||||
maxFileSize,
|
||||
onError: (_error, message) => alert(message),
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// generate a unique id for the image to keep track of dropped
|
||||
// files' file data
|
||||
const fileId = uuidv4();
|
||||
|
||||
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
|
||||
|
||||
if (imageComponentImageFileMap) {
|
||||
if (props?.event === "drop" && props.file) {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
file: props.file,
|
||||
event: props.event,
|
||||
});
|
||||
} else if (props.event === "insert") {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
event: props.event,
|
||||
hasOpenedFileInputOnce: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
id: fileId,
|
||||
};
|
||||
|
||||
if (props.pos) {
|
||||
return commands.insertContentAt(props.pos, {
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
}
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
uploadImage: (blockId, file) => async () => {
|
||||
const fileUrl = await upload(blockId, file);
|
||||
return fileUrl;
|
||||
},
|
||||
getImageSource: (path) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src) => async () => {
|
||||
await restoreImageFn(src);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { type CustomImageExtension, ECustomImageAttributeNames, type InsertImageComponentProps } from "./types";
|
||||
import { DEFAULT_CUSTOM_IMAGE_ATTRIBUTES } from "./utils";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomImageExtensionConfig: CustomImageExtension = BaseImageExtension.extend({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
const attributes = {
|
||||
...this.parent?.(),
|
||||
...Object.values(ECustomImageAttributeNames).reduce((acc, value) => {
|
||||
acc[value] = {
|
||||
default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value],
|
||||
};
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
return attributes;
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
// helpers
|
||||
import { isFileValid } from "@/helpers/file";
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// types
|
||||
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageNodeView } from "./components/node-view";
|
||||
import { CustomImageExtensionConfig } from "./extension-config";
|
||||
import { getImageComponentImageFileMap } from "./utils";
|
||||
|
||||
type Props = {
|
||||
fileHandler: TFileHandler | TReadOnlyFileHandler;
|
||||
isEditable: boolean;
|
||||
};
|
||||
|
||||
export const CustomImageExtension = (props: Props) => {
|
||||
const { fileHandler, isEditable } = props;
|
||||
// derived values
|
||||
const { getAssetSrc, restore: restoreImageFn } = fileHandler;
|
||||
|
||||
return CustomImageExtensionConfig.extend({
|
||||
selectable: isEditable,
|
||||
draggable: isEditable,
|
||||
|
||||
addOptions() {
|
||||
const upload = "upload" in fileHandler ? fileHandler.upload : undefined;
|
||||
|
||||
return {
|
||||
...this.parent?.(),
|
||||
getImageSource: getAssetSrc,
|
||||
restoreImage: restoreImageFn,
|
||||
uploadImage: upload,
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
|
||||
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertImageComponent:
|
||||
(props) =>
|
||||
({ commands }) => {
|
||||
// Early return if there's an invalid file being dropped
|
||||
if (
|
||||
props?.file &&
|
||||
!isFileValid({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
file: props.file,
|
||||
maxFileSize: this.storage.maxFileSize,
|
||||
onError: (_error, message) => alert(message),
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// generate a unique id for the image to keep track of dropped
|
||||
// files' file data
|
||||
const fileId = uuidv4();
|
||||
|
||||
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
|
||||
|
||||
if (imageComponentImageFileMap) {
|
||||
if (props?.event === "drop" && props.file) {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
file: props.file,
|
||||
event: props.event,
|
||||
});
|
||||
} else if (props.event === "insert") {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
event: props.event,
|
||||
hasOpenedFileInputOnce: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
id: fileId,
|
||||
};
|
||||
|
||||
if (props.pos) {
|
||||
return commands.insertContentAt(props.pos, {
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
}
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNodeView);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./components";
|
||||
export * from "./custom-image";
|
||||
export * from "./read-only-custom-image";
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// components
|
||||
import { CustomImageNode, CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
// types
|
||||
import { TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc, restore: restoreImageFn } = props;
|
||||
|
||||
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
selectable: false,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize: 0,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src) => async () => {
|
||||
await restoreImageFn(src);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { Node } from "@tiptap/core";
|
||||
// types
|
||||
import type { TFileHandler } from "@/types";
|
||||
|
||||
export enum ECustomImageAttributeNames {
|
||||
ID = "id",
|
||||
WIDTH = "width",
|
||||
HEIGHT = "height",
|
||||
ASPECT_RATIO = "aspectRatio",
|
||||
SOURCE = "src",
|
||||
}
|
||||
|
||||
export type Pixel = `${number}px`;
|
||||
|
||||
export type PixelAttribute<TDefault> = Pixel | TDefault;
|
||||
|
||||
export type TCustomImageSize = {
|
||||
width: PixelAttribute<"35%">;
|
||||
height: PixelAttribute<"auto">;
|
||||
aspectRatio: number | null;
|
||||
};
|
||||
|
||||
export type TCustomImageAttributes = {
|
||||
[ECustomImageAttributeNames.ID]: string | null;
|
||||
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
|
||||
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
|
||||
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
|
||||
[ECustomImageAttributeNames.SOURCE]: string | null;
|
||||
};
|
||||
|
||||
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
||||
|
||||
export type InsertImageComponentProps = {
|
||||
file?: File;
|
||||
pos?: number;
|
||||
event: "insert" | "drop";
|
||||
};
|
||||
|
||||
export type CustomImageExtensionOptions = {
|
||||
getImageSource: TFileHandler["getAssetSrc"];
|
||||
restoreImage: TFileHandler["restore"];
|
||||
uploadImage?: TFileHandler["upload"];
|
||||
};
|
||||
|
||||
export type CustomImageExtensionStorage = {
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
maxFileSize: number;
|
||||
};
|
||||
|
||||
export type CustomImageExtension = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// local imports
|
||||
import { ECustomImageAttributeNames, type Pixel, type TCustomImageAttributes } from "./types";
|
||||
|
||||
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
|
||||
[ECustomImageAttributeNames.SOURCE]: null,
|
||||
[ECustomImageAttributeNames.ID]: null,
|
||||
[ECustomImageAttributeNames.WIDTH]: "35%",
|
||||
[ECustomImageAttributeNames.HEIGHT]: "auto",
|
||||
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
|
||||
};
|
||||
|
||||
export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
|
||||
|
||||
export const ensurePixelString = <TDefault>(
|
||||
value: Pixel | TDefault | number | undefined | null,
|
||||
defaultValue?: TDefault
|
||||
) => {
|
||||
if (!value || value === defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return `${value}px` satisfies Pixel;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user