Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92564e219 | ||
|
|
b60f12a88e | ||
|
|
76a0b38dd1 | ||
|
|
8ee665f491 | ||
|
|
85f23b450d | ||
|
|
8bf059535a | ||
|
|
4cfea87108 | ||
|
|
4fe2ef706b | ||
|
|
8d354b3eb2 | ||
|
|
ec541c2557 | ||
|
|
11cd8d11e4 | ||
|
|
0f7bfdde91 | ||
|
|
ac835bf287 | ||
|
|
db18c3555c | ||
|
|
b696ae91ed | ||
|
|
61e91bd09c | ||
|
|
20d773042b | ||
|
|
30b175108b | ||
|
|
6d116beea3 | ||
|
|
b0db4fcf10 | ||
|
|
7e03264758 | ||
|
|
1c8ac3d247 | ||
|
|
3b8bb1effc | ||
|
|
5a63e6dad2 | ||
|
|
45688bdc72 | ||
|
|
43b7a6ad0a | ||
|
|
498613284e | ||
|
|
9ab3143a73 | ||
|
|
56cd0fc445 | ||
|
|
260d9a053d | ||
|
|
34bdc2ad76 | ||
|
|
99bc4262c5 | ||
|
|
49127ebeea | ||
|
|
a8a6536379 | ||
|
|
11b83cf4f2 | ||
|
|
fbd48c33f5 | ||
|
|
37ce8a9fe6 | ||
|
|
291101a8e5 | ||
|
|
0cb4976e38 | ||
|
|
20d139cc9e | ||
|
|
ff181e566f | ||
|
|
a696b6039c | ||
|
|
af1dcd335e | ||
|
|
a1500c2206 | ||
|
|
88f194ca8e | ||
|
|
b68d2ca921 | ||
|
|
78c6aede52 | ||
|
|
c6c46f9aab | ||
|
|
7d7e37439d | ||
|
|
d258080ee7 | ||
|
|
c40c7804e7 | ||
|
|
36c735bede | ||
|
|
cab5d20217 | ||
|
|
9965fc75a6 | ||
|
|
cb1e93f83d | ||
|
|
2e673026d4 | ||
|
|
49a40031a4 | ||
|
|
54e4a0f6dd | ||
|
|
473535fcfa | ||
|
|
59ace4f411 | ||
|
|
b99ddc24e7 | ||
|
|
91f0228b5f | ||
|
|
4b74751e4e | ||
|
|
1c28bd5887 | ||
|
|
292264ba96 | ||
|
|
28d368d502 | ||
|
|
652a6cc885 | ||
|
|
26b48bfcf0 | ||
|
|
569b2fba83 | ||
|
|
779a96acb8 | ||
|
|
2d31b562d8 | ||
|
|
50b4aca726 | ||
|
|
d960d7ce88 | ||
|
|
fd5ba6c7b8 |
@@ -7,7 +7,8 @@ import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
// plane internal packages
|
||||
import { WEB_BASE_URL } from "@plane/constants";
|
||||
import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui";
|
||||
import { DiscordIcon, GithubIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
|
||||
@@ -5,7 +5,8 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { Tooltip, WorkspaceIcon } from "@plane/ui";
|
||||
import { WorkspaceIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { PlaneLockup } from "@plane/ui";
|
||||
import { PlaneLockup } from "@plane/propel/icons";
|
||||
|
||||
export const AuthHeader = () => (
|
||||
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
|
||||
type Props = {
|
||||
label?: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
// plane internal packages
|
||||
import { WEB_BASE_URL } from "@plane/constants";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
+10
-10
@@ -26,26 +26,26 @@
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"autoprefixer": "10.4.14",
|
||||
"axios": "1.11.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.469.0",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"next": "14.2.32",
|
||||
"axios": "catalog:",
|
||||
"lodash": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.49",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "7.51.5",
|
||||
"sharp": "^0.33.5",
|
||||
"swr": "^2.2.4",
|
||||
"uuid": "^9.0.1"
|
||||
"sharp": "catalog:",
|
||||
"swr": "catalog:",
|
||||
"uuid": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/node": "18.16.1",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
|
||||
@@ -39,13 +39,31 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
|
||||
).exists():
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
else:
|
||||
if ProjectMember.objects.filter(
|
||||
is_user_has_allowed_role = ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
role__in=allowed_role_values,
|
||||
is_active=True,
|
||||
).exists():
|
||||
).exists()
|
||||
|
||||
# Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role
|
||||
if is_user_has_allowed_role:
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
elif (
|
||||
ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
is_active=True,
|
||||
).exists()
|
||||
and WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
# Return permission denied if no conditions are met
|
||||
|
||||
@@ -3,11 +3,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
# Module import
|
||||
from plane.db.models import ProjectMember, WorkspaceMember
|
||||
|
||||
# Permission Mappings
|
||||
Admin = 20
|
||||
Member = 15
|
||||
Guest = 5
|
||||
from plane.db.models.project import ROLE
|
||||
|
||||
|
||||
class ProjectBasePermission(BasePermission):
|
||||
@@ -26,18 +22,31 @@ class ProjectBasePermission(BasePermission):
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Only Project Admins can update project attributes
|
||||
return ProjectMember.objects.filter(
|
||||
project_member_qs = ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role=Admin,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
)
|
||||
|
||||
## Only project admins or workspace admin who is part of the project can access
|
||||
|
||||
if project_member_qs.filter(role=ROLE.ADMIN.value).exists():
|
||||
return True
|
||||
else:
|
||||
return (
|
||||
project_member_qs.exists()
|
||||
and WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class ProjectMemberPermission(BasePermission):
|
||||
@@ -55,7 +64,7 @@ class ProjectMemberPermission(BasePermission):
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
@@ -63,7 +72,7 @@ class ProjectMemberPermission(BasePermission):
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
@@ -97,7 +106,7 @@ class ProjectEntityPermission(BasePermission):
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
@@ -667,16 +667,33 @@ class IssueReactionSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueReactionLiteSerializer(DynamicBaseSerializer):
|
||||
display_name = serializers.CharField(source="actor.display_name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueReaction
|
||||
fields = ["id", "actor", "issue", "reaction"]
|
||||
fields = ["id", "actor", "issue", "reaction", "display_name"]
|
||||
|
||||
|
||||
class CommentReactionSerializer(BaseSerializer):
|
||||
display_name = serializers.CharField(source="actor.display_name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CommentReaction
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
|
||||
fields = [
|
||||
"id",
|
||||
"actor",
|
||||
"comment",
|
||||
"reaction",
|
||||
"display_name",
|
||||
"deleted_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at", "created_by", "updated_by"]
|
||||
|
||||
|
||||
class IssueVoteSerializer(BaseSerializer):
|
||||
|
||||
@@ -5,13 +5,12 @@ from django.utils import timezone
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# Module imports
|
||||
@@ -106,7 +105,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
fields = [field for field in request.GET.get("fields", "").split(",") if field]
|
||||
projects = self.get_queryset().order_by("sort_order", "name")
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=5
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.GUEST.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
project_projectmember__member=self.request.user,
|
||||
@@ -114,7 +116,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=15
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.MEMBER.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
Q(
|
||||
@@ -189,7 +194,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=5
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.GUEST.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
project_projectmember__member=self.request.user,
|
||||
@@ -197,7 +205,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=15
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.MEMBER.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
Q(
|
||||
@@ -250,7 +261,9 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
# Add the user as Administrator to the project
|
||||
_ = ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"], member=request.user, role=20
|
||||
project_id=serializer.data["id"],
|
||||
member=request.user,
|
||||
role=ROLE.ADMIN.value,
|
||||
)
|
||||
# Also create the issue property for the user
|
||||
_ = IssueUserProperty.objects.create(
|
||||
@@ -263,7 +276,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"],
|
||||
member_id=serializer.data["project_lead"],
|
||||
role=20,
|
||||
role=ROLE.ADMIN.value,
|
||||
)
|
||||
# Also create the issue property for the user
|
||||
IssueUserProperty.objects.create(
|
||||
@@ -341,13 +354,23 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
def partial_update(self, request, slug, pk=None):
|
||||
# try:
|
||||
if not ProjectMember.objects.filter(
|
||||
is_workspace_admin = WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.ADMIN.value,
|
||||
).exists()
|
||||
|
||||
is_project_admin = ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=pk,
|
||||
role=20,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists():
|
||||
).exists()
|
||||
|
||||
# Return error for if the user is neither workspace admin nor project admin
|
||||
if not is_project_admin and not is_workspace_admin:
|
||||
return Response(
|
||||
{"error": "You don't have the required permissions."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
@@ -402,13 +425,16 @@ class ProjectViewSet(BaseViewSet):
|
||||
def destroy(self, request, slug, pk):
|
||||
if (
|
||||
WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=20
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.ADMIN.value,
|
||||
).exists()
|
||||
or ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=pk,
|
||||
role=20,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
|
||||
@@ -7,7 +7,6 @@ from plane.app.serializers import StateSerializer
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import State
|
||||
from plane.app.permissions import WorkspaceEntityPermission
|
||||
from plane.utils.cache import cache_response
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
@@ -15,7 +14,6 @@ class WorkspaceStatesEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
use_read_replica = True
|
||||
|
||||
@cache_response(60 * 60 * 2)
|
||||
def get(self, request, slug):
|
||||
states = State.objects.filter(
|
||||
workspace__slug=slug,
|
||||
|
||||
@@ -107,7 +107,8 @@ class MagicSignInEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
if user.is_password_autoset and profile.is_onboarded:
|
||||
path = "accounts/set-password"
|
||||
# Redirect to the home page
|
||||
path = "/"
|
||||
else:
|
||||
# Get the redirection path
|
||||
path = (
|
||||
|
||||
@@ -30,6 +30,8 @@ def page_version(page_id, existing_instance, user_id):
|
||||
description_binary=page.description_binary,
|
||||
owned_by_id=user_id,
|
||||
last_saved_at=page.updated_at,
|
||||
description_json=page.description,
|
||||
description_stripped=page.description_stripped,
|
||||
)
|
||||
|
||||
# If page versions are greater than 20 delete the oldest one
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-01 14:33
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('db', '0102_page_sort_order_pagelog_entity_type_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name='fileasset',
|
||||
index=models.Index(fields=['entity_type'], name='asset_entity_type_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='fileasset',
|
||||
index=models.Index(fields=['entity_identifier'], name='asset_entity_identifier_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='fileasset',
|
||||
index=models.Index(fields=['entity_type', 'entity_identifier'], name='asset_entity_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['entity_identifier'], name='notif_entity_identifier_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['entity_name'], name='notif_entity_name_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['read_at'], name='notif_read_at_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['receiver', 'read_at'], name='notif_entity_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='pagelog',
|
||||
index=models.Index(fields=['entity_type'], name='pagelog_entity_type_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='pagelog',
|
||||
index=models.Index(fields=['entity_identifier'], name='pagelog_entity_id_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='pagelog',
|
||||
index=models.Index(fields=['entity_name'], name='pagelog_entity_name_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='pagelog',
|
||||
index=models.Index(fields=['entity_type', 'entity_identifier'], name='pagelog_type_id_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='pagelog',
|
||||
index=models.Index(fields=['entity_name', 'entity_identifier'], name='pagelog_name_id_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='userfavorite',
|
||||
index=models.Index(fields=['entity_type'], name='fav_entity_type_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='userfavorite',
|
||||
index=models.Index(fields=['entity_identifier'], name='fav_entity_identifier_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='userfavorite',
|
||||
index=models.Index(fields=['entity_type', 'entity_identifier'], name='fav_entity_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-03 05:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0103_fileasset_asset_entity_type_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cycleuserproperties',
|
||||
name='rich_filters',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exporterhistory',
|
||||
name='rich_filters',
|
||||
field=models.JSONField(blank=True, default=dict, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issueuserproperty',
|
||||
name='rich_filters',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issueview',
|
||||
name='rich_filters',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='moduleuserproperties',
|
||||
name='rich_filters',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspaceuserproperties',
|
||||
name='rich_filters',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
]
|
||||
@@ -76,6 +76,15 @@ class FileAsset(BaseModel):
|
||||
verbose_name_plural = "File Assets"
|
||||
db_table = "file_assets"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["entity_type"], name="asset_entity_type_idx"),
|
||||
models.Index(
|
||||
fields=["entity_identifier"], name="asset_entity_identifier_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["entity_type", "entity_identifier"], name="asset_entity_idx"
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return str(self.asset)
|
||||
|
||||
@@ -139,6 +139,7 @@ class CycleUserProperties(ProjectBaseModel):
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["cycle", "user", "deleted_at"]
|
||||
|
||||
@@ -56,6 +56,7 @@ class ExporterHistory(BaseModel):
|
||||
related_name="workspace_exporters",
|
||||
)
|
||||
filters = models.JSONField(blank=True, null=True)
|
||||
rich_filters = models.JSONField(default=dict, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Exporter"
|
||||
|
||||
@@ -41,6 +41,15 @@ class UserFavorite(WorkspaceBaseModel):
|
||||
verbose_name_plural = "User Favorites"
|
||||
db_table = "user_favorites"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["entity_type"], name="fav_entity_type_idx"),
|
||||
models.Index(
|
||||
fields=["entity_identifier"], name="fav_entity_identifier_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["entity_type", "entity_identifier"], name="fav_entity_idx"
|
||||
),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
|
||||
@@ -509,6 +509,7 @@ class IssueUserProperty(ProjectBaseModel):
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue User Property"
|
||||
|
||||
@@ -207,6 +207,7 @@ class ModuleUserProperties(ProjectBaseModel):
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["module", "user", "deleted_at"]
|
||||
|
||||
@@ -39,6 +39,14 @@ class Notification(BaseModel):
|
||||
verbose_name_plural = "Notifications"
|
||||
db_table = "notifications"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["entity_identifier"], name="notif_entity_identifier_idx"
|
||||
),
|
||||
models.Index(fields=["entity_name"], name="notif_entity_name_idx"),
|
||||
models.Index(fields=["read_at"], name="notif_read_at_idx"),
|
||||
models.Index(fields=["receiver", "read_at"], name="notif_entity_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the notifications"""
|
||||
|
||||
@@ -101,7 +101,9 @@ class PageLog(BaseModel):
|
||||
page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE)
|
||||
entity_identifier = models.UUIDField(null=True, blank=True)
|
||||
entity_name = models.CharField(max_length=30, verbose_name="Transaction Type")
|
||||
entity_type = models.CharField(max_length=30, verbose_name="Entity Type", null=True, blank=True)
|
||||
entity_type = models.CharField(
|
||||
max_length=30, verbose_name="Entity Type", null=True, blank=True
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log"
|
||||
)
|
||||
@@ -112,6 +114,17 @@ class PageLog(BaseModel):
|
||||
verbose_name_plural = "Page Logs"
|
||||
db_table = "page_logs"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["entity_type"], name="pagelog_entity_type_idx"),
|
||||
models.Index(fields=["entity_identifier"], name="pagelog_entity_id_idx"),
|
||||
models.Index(fields=["entity_name"], name="pagelog_entity_name_idx"),
|
||||
models.Index(
|
||||
fields=["entity_type", "entity_identifier"], name="pagelog_type_id_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["entity_name", "entity_identifier"], name="pagelog_name_id_idx"
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.page.name} {self.entity_name}"
|
||||
|
||||
@@ -18,6 +18,12 @@ from .base import BaseModel
|
||||
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
|
||||
|
||||
|
||||
class ROLE(Enum):
|
||||
ADMIN = 20
|
||||
MEMBER = 15
|
||||
GUEST = 5
|
||||
|
||||
|
||||
class ProjectNetwork(Enum):
|
||||
SECRET = 0
|
||||
PUBLIC = 2
|
||||
|
||||
@@ -58,6 +58,7 @@ class IssueView(WorkspaceBaseModel):
|
||||
filters = models.JSONField(default=dict)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
access = models.PositiveSmallIntegerField(
|
||||
default=1, choices=((0, "Private"), (1, "Public"))
|
||||
)
|
||||
|
||||
@@ -332,6 +332,7 @@ class WorkspaceUserProperties(BaseModel):
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "user", "deleted_at"]
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Testing library/framework: pytest + pytest-django + factory_boy
|
||||
import pytest
|
||||
import pytz
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.utils import timezone
|
||||
|
||||
# Prefer existing FactoryBoy factories defined in the repo
|
||||
from plane.tests.factories import WorkspaceFactory, UserFactory, ProjectFactory
|
||||
|
||||
# Import models and helpers under test
|
||||
from plane.db.models.project import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectMemberInvite,
|
||||
ProjectIdentifier,
|
||||
ProjectDeployBoard,
|
||||
ProjectPublicMember,
|
||||
ROLE,
|
||||
ROLE_CHOICES,
|
||||
ProjectNetwork,
|
||||
get_default_props,
|
||||
get_default_preferences,
|
||||
get_default_views,
|
||||
)
|
||||
|
||||
# FileAsset is optional in some environments; guard import
|
||||
try:
|
||||
from plane.db.models.asset import FileAsset
|
||||
except Exception:
|
||||
FileAsset = None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProjectModel:
|
||||
def test_identifier_is_stripped_and_uppercased_on_save(self):
|
||||
ws = WorkspaceFactory()
|
||||
proj = Project.objects.create(
|
||||
name="Alpha",
|
||||
identifier=" al-1 ",
|
||||
workspace=ws,
|
||||
)
|
||||
proj.refresh_from_db()
|
||||
assert proj.identifier == "AL-1"
|
||||
|
||||
def test_cover_image_url_resolution_and_precedence(self):
|
||||
ws = WorkspaceFactory()
|
||||
proj = ProjectFactory(workspace=ws)
|
||||
|
||||
# No cover data
|
||||
proj.cover_image = None
|
||||
proj.cover_image_asset = None
|
||||
assert proj.cover_image_url is None
|
||||
|
||||
# Text cover image
|
||||
proj.cover_image = "https://cdn.example.com/cover.jpg"
|
||||
assert proj.cover_image_url == "https://cdn.example.com/cover.jpg"
|
||||
|
||||
# FileAsset should take precedence when present
|
||||
if FileAsset:
|
||||
asset = FileAsset.objects.create(asset_url="https://assets.example.com/a.png")
|
||||
proj.cover_image_asset = asset
|
||||
assert proj.cover_image_url == "https://assets.example.com/a.png"
|
||||
|
||||
def test___str___format(self):
|
||||
ws = WorkspaceFactory(name="Team Rocket")
|
||||
proj = ProjectFactory(name="Gamma", identifier="GMM", workspace=ws)
|
||||
assert str(proj) == "Gamma <Team Rocket>"
|
||||
|
||||
def test_timezone_default_and_choices_include_utc(self):
|
||||
ws = WorkspaceFactory()
|
||||
proj = ProjectFactory(workspace=ws, timezone="UTC")
|
||||
assert proj.timezone == "UTC"
|
||||
assert ("UTC", "UTC") in Project.TIMEZONE_CHOICES
|
||||
assert "UTC" in pytz.common_timezones
|
||||
|
||||
def test_archive_close_in_validators_bounds(self):
|
||||
ws = WorkspaceFactory()
|
||||
proj = Project(name="Delta", identifier="DLT", workspace=ws, archive_in=0, close_in=12)
|
||||
# Valid
|
||||
proj.full_clean()
|
||||
|
||||
# Invalid negative
|
||||
proj.archive_in = -1
|
||||
with pytest.raises(ValidationError):
|
||||
proj.full_clean()
|
||||
|
||||
# Invalid > 12
|
||||
proj.archive_in = 0
|
||||
proj.close_in = 13
|
||||
with pytest.raises(ValidationError):
|
||||
proj.full_clean()
|
||||
|
||||
def test_unique_identifier_and_name_per_workspace_with_soft_delete(self):
|
||||
ws = WorkspaceFactory()
|
||||
p1 = Project.objects.create(name="Echo", identifier="ECH", workspace=ws)
|
||||
|
||||
# Duplicates while not soft-deleted -> IntegrityError
|
||||
with pytest.raises(IntegrityError):
|
||||
Project.objects.create(name="Echo", identifier="ECH2", workspace=ws)
|
||||
with pytest.raises(IntegrityError):
|
||||
Project.objects.create(name="Foxtrot", identifier="ECH", workspace=ws)
|
||||
|
||||
# Soft-delete first, then allow duplicates for the constrained fields
|
||||
p1.deleted_at = timezone.now()
|
||||
p1.save(update_fields=["deleted_at"])
|
||||
|
||||
Project.objects.create(name="Echo", identifier="ECHX", workspace=ws)
|
||||
Project.objects.create(name="Zulu", identifier="ECH", workspace=ws)
|
||||
|
||||
def test_network_choices_and_enum(self):
|
||||
assert Project.NETWORK_CHOICES == ((0, "Secret"), (2, "Public"))
|
||||
assert ProjectNetwork.choices() == [(0, "Secret"), (2, "Public")]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProjectBaseAndMembers:
|
||||
def test_projectbasemodel_sets_workspace_on_save_for_invite(self):
|
||||
ws = WorkspaceFactory()
|
||||
proj = ProjectFactory(workspace=ws)
|
||||
inv = ProjectMemberInvite.objects.create(project=proj, email="invitee@example.com", token="tok")
|
||||
inv.refresh_from_db()
|
||||
assert inv.workspace_id == proj.workspace_id
|
||||
assert str(inv) == f"{proj.name} invitee@example.com {inv.accepted}"
|
||||
|
||||
def test_projectmember_sort_order_initialization(self):
|
||||
ws = WorkspaceFactory()
|
||||
user = UserFactory()
|
||||
proj_a = ProjectFactory(workspace=ws)
|
||||
proj_b = ProjectFactory(workspace=ws)
|
||||
|
||||
# First membership => default 65535
|
||||
m1 = ProjectMember.objects.create(project=proj_a, member=user)
|
||||
assert pytest.approx(m1.sort_order) == 65535
|
||||
|
||||
# Second membership for same user in same workspace => smallest - 10000
|
||||
m2 = ProjectMember.objects.create(project=proj_b, member=user)
|
||||
assert m2.sort_order == m1.sort_order - 10000
|
||||
|
||||
def test_projectmember_defaults_role_props_and_str(self):
|
||||
ws = WorkspaceFactory()
|
||||
user = UserFactory()
|
||||
proj = ProjectFactory(workspace=ws)
|
||||
|
||||
member = ProjectMember.objects.create(project=proj, member=user)
|
||||
|
||||
# Role defaults
|
||||
assert member.role == ROLE.GUEST.value == 5
|
||||
assert ROLE_CHOICES == ((20, "Admin"), (15, "Member"), (5, "Guest"))
|
||||
|
||||
# Default props/preferences
|
||||
assert member.view_props == get_default_props()
|
||||
assert member.default_props == get_default_props()
|
||||
assert member.preferences == get_default_preferences()
|
||||
|
||||
assert str(member) == f"{user.email} <{proj.name}>"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProjectIdentifierAndDeployBoard:
|
||||
def test_project_identifier_unique_with_soft_delete(self):
|
||||
ws = WorkspaceFactory()
|
||||
proj = ProjectFactory(workspace=ws, identifier="MU", name="Mu")
|
||||
pid1 = ProjectIdentifier.objects.create(project=proj, workspace=ws, name="MU")
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
ProjectIdentifier.objects.create(project=proj, workspace=ws, name="MU")
|
||||
|
||||
pid1.deleted_at = timezone.now()
|
||||
pid1.save(update_fields=["deleted_at"])
|
||||
|
||||
# Now allowed after soft-delete
|
||||
ProjectIdentifier.objects.create(project=proj, workspace=ws, name="MU")
|
||||
|
||||
def test_deploy_board_defaults_and_str(self):
|
||||
ws = WorkspaceFactory()
|
||||
proj = ProjectFactory(workspace=ws)
|
||||
board = ProjectDeployBoard.objects.create(project=proj)
|
||||
|
||||
assert isinstance(board.anchor, str) and len(board.anchor) >= 32
|
||||
assert board.views == get_default_views()
|
||||
assert str(board) == f"{board.anchor} <{proj.name}>"
|
||||
|
||||
def test_get_default_views_contents(self):
|
||||
assert get_default_views() == {
|
||||
"list": True,
|
||||
"kanban": True,
|
||||
"calendar": True,
|
||||
"gantt": True,
|
||||
"spreadsheet": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProjectPublicMember:
|
||||
def test_unique_constraint_on_public_member(self):
|
||||
ws = WorkspaceFactory()
|
||||
user = UserFactory()
|
||||
proj = ProjectFactory(workspace=ws)
|
||||
|
||||
pm1 = ProjectPublicMember.objects.create(project=proj, member=user)
|
||||
assert pm1.pk is not None
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
ProjectPublicMember.objects.create(project=proj, member=user)
|
||||
@@ -0,0 +1,213 @@
|
||||
import builtins
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Import the decorator and ROLE enum from the provided module
|
||||
from apps.api.plane.tests.test_permissions_base import allow_permission, ROLE
|
||||
|
||||
# Helper: minimal request/user and dummy view
|
||||
class DummyUser(SimpleNamespace):
|
||||
pass
|
||||
|
||||
class DummyRequest(SimpleNamespace):
|
||||
pass
|
||||
|
||||
def dummy_view(instance, request, *args, **kwargs):
|
||||
return Response({"ok": True}, status=status.HTTP_200_OK)
|
||||
|
||||
@pytest.fixture
|
||||
def user():
|
||||
return DummyUser(id=1, username="u1")
|
||||
|
||||
@pytest.fixture
|
||||
def request(user):
|
||||
return DummyRequest(user=user)
|
||||
|
||||
@pytest.fixture
|
||||
def view_instance():
|
||||
# For function-based views 'instance' is unused; for method-decorated views could be 'self'
|
||||
return object()
|
||||
|
||||
def _wrap_and_call(deco, request, view_instance=None, **kwargs):
|
||||
wrapped = deco(dummy_view)
|
||||
return wrapped(view_instance or object(), request, **kwargs)
|
||||
|
||||
def make_exists_mock(result: bool):
|
||||
# Returns a mock for model.objects.filter(...).exists() chain
|
||||
exists_mock = MagicMock(return_value=result)
|
||||
filter_mock = MagicMock(return_value=SimpleNamespace(exists=exists_mock))
|
||||
objects_mock = SimpleNamespace(filter=filter_mock)
|
||||
return objects_mock, filter_mock, exists_mock
|
||||
|
||||
# --- Tests ---
|
||||
|
||||
def test_allows_when_creator_matches_model_pk_and_created_by(request, view_instance):
|
||||
# If creator=True and model is supplied, creator match should short-circuit and allow regardless of roles
|
||||
class DummyModel:
|
||||
pass
|
||||
|
||||
objects_mock, filter_mock, exists_mock = make_exists_mock(True)
|
||||
with patch.object(DummyModel, "objects", objects_mock):
|
||||
deco = allow_permission(allowed_roles=[ROLE.GUEST], level="PROJECT", creator=True, model=DummyModel)
|
||||
resp = _wrap_and_call(deco, request, view_instance, pk=123, slug="ws", project_id=456)
|
||||
|
||||
filter_mock.assert_called_once()
|
||||
# Ensure query filters by id and created_by
|
||||
assert filter_mock.call_args.kwargs.get("id") == 123
|
||||
assert filter_mock.call_args.kwargs.get("created_by") is request.user
|
||||
exists_mock.assert_called_once()
|
||||
assert isinstance(resp, Response) and resp.status_code == 200 and resp.data == {"ok": True}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allowed_roles_input",
|
||||
[
|
||||
[ROLE.ADMIN, ROLE.MEMBER], # enum members
|
||||
[20, 15], # numeric roles
|
||||
[ROLE.ADMIN.value, ROLE.MEMBER.value], # explicit ints
|
||||
],
|
||||
)
|
||||
def test_workspace_level_allows_when_workspace_member_has_allowed_role(request, view_instance, allowed_roles_input):
|
||||
# WorkspaceMember.exists() -> True should allow
|
||||
WM_objects, WM_filter, WM_exists = make_exists_mock(True)
|
||||
with patch("apps.api.plane.tests.test_permissions_base.WorkspaceMember") as WM, \
|
||||
patch("apps.api.plane.tests.test_permissions_base.ProjectMember") as PM:
|
||||
WM.objects = WM_objects
|
||||
# ProjectMember shouldn't be called at WORKSPACE level when allowed
|
||||
PM.objects = SimpleNamespace(filter=MagicMock())
|
||||
|
||||
deco = allow_permission(allowed_roles=allowed_roles_input, level="WORKSPACE")
|
||||
resp = _wrap_and_call(deco, request, view_instance, slug="acme", project_id=999, pk=1)
|
||||
|
||||
WM_filter.assert_called_once()
|
||||
assert WM_exists.called
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_workspace_level_denies_when_no_allowed_role_or_inactive(request, view_instance):
|
||||
# WorkspaceMember.exists() -> False should deny (no project fallback at WORKSPACE level)
|
||||
WM_objects, WM_filter, WM_exists = make_exists_mock(False)
|
||||
with patch("apps.api.plane.tests.test_permissions_base.WorkspaceMember") as WM:
|
||||
WM.objects = WM_objects
|
||||
deco = allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
resp = _wrap_and_call(deco, request, view_instance, slug="acme", project_id=1, pk=1)
|
||||
|
||||
assert resp.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert resp.data == {"error": "You don't have the required permissions."}
|
||||
|
||||
def test_project_level_allows_when_project_member_has_allowed_role(request, view_instance):
|
||||
PM_objects, PM_filter, PM_exists = make_exists_mock(True)
|
||||
with patch("apps.api.plane.tests.test_permissions_base.ProjectMember") as PM, \
|
||||
patch("apps.api.plane.tests.test_permissions_base.WorkspaceMember") as WM:
|
||||
PM.objects = PM_objects
|
||||
WM.objects = SimpleNamespace(filter=MagicMock()) # not used
|
||||
deco = allow_permission(allowed_roles=[ROLE.MEMBER], level="PROJECT")
|
||||
resp = _wrap_and_call(deco, request, view_instance, slug="acme", project_id=42, pk=7)
|
||||
|
||||
PM_filter.assert_called_once()
|
||||
assert PM_exists.called
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_project_level_allows_when_workspace_admin_even_if_project_role_not_allowed(request, view_instance):
|
||||
# First project role check -> False
|
||||
PM_allowed_objects, PM_allowed_filter, PM_allowed_exists = make_exists_mock(False)
|
||||
# Then project membership check (any role) -> True
|
||||
PM_member_objects, PM_member_filter, PM_member_exists = make_exists_mock(True)
|
||||
# Workspace admin check -> True
|
||||
WM_admin_objects, WM_admin_filter, WM_admin_exists = make_exists_mock(True)
|
||||
|
||||
def pm_filter_side_effect(*args, **kwargs):
|
||||
# For role__in present, use allowed False; otherwise for membership True
|
||||
if "role__in" in kwargs:
|
||||
return PM_allowed_objects.filter(**kwargs)
|
||||
return PM_member_objects.filter(**kwargs)
|
||||
|
||||
with patch("apps.api.plane.tests.test_permissions_base.ProjectMember") as PM, \
|
||||
patch("apps.api.plane.tests.test_permissions_base.WorkspaceMember") as WM:
|
||||
PM.objects = SimpleNamespace(filter=MagicMock(side_effect=pm_filter_side_effect))
|
||||
WM.objects = WM_admin_objects
|
||||
|
||||
deco = allow_permission(allowed_roles=[ROLE.GUEST], level="PROJECT")
|
||||
resp = _wrap_and_call(deco, request, view_instance, slug="acme", project_id=123, pk=9)
|
||||
|
||||
# Confirm both the membership and admin checks participated
|
||||
assert resp.status_code == 200
|
||||
assert WM_admin_filter.called
|
||||
assert PM_allowed_filter.called or PM.objects.filter.called
|
||||
|
||||
def test_project_level_denies_when_not_allowed_role_not_member_or_not_workspace_admin(request, view_instance):
|
||||
# All checks -> False
|
||||
PM_allowed_objects, PM_allowed_filter, PM_allowed_exists = make_exists_mock(False)
|
||||
PM_member_objects, PM_member_filter, PM_member_exists = make_exists_mock(False)
|
||||
WM_admin_objects, WM_admin_filter, WM_admin_exists = make_exists_mock(False)
|
||||
|
||||
def pm_filter_side_effect(*args, **kwargs):
|
||||
if "role__in" in kwargs:
|
||||
return PM_allowed_objects.filter(**kwargs)
|
||||
return PM_member_objects.filter(**kwargs)
|
||||
|
||||
with patch("apps.api.plane.tests.test_permissions_base.ProjectMember") as PM, \
|
||||
patch("apps.api.plane.tests.test_permissions_base.WorkspaceMember") as WM:
|
||||
PM.objects = SimpleNamespace(filter=MagicMock(side_effect=pm_filter_side_effect))
|
||||
WM.objects = WM_admin_objects
|
||||
|
||||
deco = allow_permission(allowed_roles=[ROLE.ADMIN], level="PROJECT")
|
||||
resp = _wrap_and_call(deco, request, view_instance, slug="acme", project_id=99, pk=3)
|
||||
|
||||
assert resp.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert resp.data == {"error": "You don't have the required permissions."}
|
||||
|
||||
def test_roles_accepts_mixed_enum_and_int_inputs(request, view_instance):
|
||||
# Ensure conversion to values works: mix enum and ints
|
||||
PM_objects, PM_filter, PM_exists = make_exists_mock(True)
|
||||
with patch("apps.api.plane.tests.test_permissions_base.ProjectMember") as PM, \
|
||||
patch("apps.api.plane.tests.test_permissions_base.WorkspaceMember") as WM:
|
||||
PM.objects = PM_objects
|
||||
WM.objects = SimpleNamespace(filter=MagicMock())
|
||||
deco = allow_permission(allowed_roles=[ROLE.MEMBER, 5], level="PROJECT")
|
||||
resp = _wrap_and_call(deco, request, view_instance, slug="acme", project_id=1, pk=1)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# Verify role__in passed as ints
|
||||
passed_roles = PM_filter.call_args.kwargs.get("role__in")
|
||||
assert isinstance(passed_roles, list)
|
||||
assert all(isinstance(r, int) for r in passed_roles)
|
||||
|
||||
def test_creator_flag_without_model_does_not_error_and_falls_back_to_role_checks(request, view_instance):
|
||||
# creator=True but no model should not crash; proceed to role checks which we set to allow
|
||||
PM_objects, PM_filter, PM_exists = make_exists_mock(True)
|
||||
with patch("apps.api.plane.tests.test_permissions_base.ProjectMember") as PM, \
|
||||
patch("apps.api.plane.tests.test_permissions_base.WorkspaceMember") as WM:
|
||||
PM.objects = PM_objects
|
||||
WM.objects = SimpleNamespace(filter=MagicMock())
|
||||
deco = allow_permission(allowed_roles=[ROLE.ADMIN], level="PROJECT", creator=True, model=None)
|
||||
resp = _wrap_and_call(deco, request, view_instance, slug="acme", project_id=11, pk=22)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_missing_kwargs_results_in_403_in_project_level(request, view_instance):
|
||||
# If required kwargs like slug or project_id are missing, filter will get KeyError before queries;
|
||||
# The decorator accesses kwargs[...] so simulate missing and expect KeyError -> treat as failure handled by pytest
|
||||
PM_objects, PM_filter, PM_exists = make_exists_mock(False)
|
||||
with patch("apps.api.plane.tests.test_permissions_base.ProjectMember") as PM, \
|
||||
patch("apps.api.plane.tests.test_permissions_base.WorkspaceMember") as WM:
|
||||
PM.objects = PM_objects
|
||||
WM.objects = SimpleNamespace(filter=MagicMock())
|
||||
|
||||
deco = allow_permission(allowed_roles=[ROLE.ADMIN], level="PROJECT")
|
||||
with pytest.raises(KeyError):
|
||||
_ = _wrap_and_call(deco, request, view_instance, pk=1) # slug/project_id missing
|
||||
|
||||
def test_inactive_members_are_not_considered(request, view_instance):
|
||||
# Ensure is_active=True is part of filters; we validate by inspecting call kwargs
|
||||
PM_objects, PM_filter, PM_exists = make_exists_mock(True)
|
||||
with patch("apps.api.plane.tests.test_permissions_base.ProjectMember") as PM, \
|
||||
patch("apps.api.plane.tests.test_permissions_base.WorkspaceMember") as WM:
|
||||
PM.objects = PM_objects
|
||||
WM.objects = SimpleNamespace(filter=MagicMock())
|
||||
deco = allow_permission(allowed_roles=[ROLE.MEMBER], level="PROJECT")
|
||||
_ = _wrap_and_call(deco, request, view_instance, slug="acme", project_id=1, pk=1)
|
||||
|
||||
assert PM_filter.call_args.kwargs.get("is_active") is True
|
||||
@@ -0,0 +1,278 @@
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Subject under test: permission classes defined in the provided file
|
||||
from apps.api.plane.tests import test_permissions_project as perms
|
||||
|
||||
|
||||
def _req(user_is_anonymous: bool, method: str = "GET"):
|
||||
return types.SimpleNamespace(
|
||||
user=types.SimpleNamespace(is_anonymous=user_is_anonymous),
|
||||
method=method,
|
||||
)
|
||||
|
||||
|
||||
def _view(workspace_slug="ws-1", project_id=1, project_identifier=None):
|
||||
v = types.SimpleNamespace(
|
||||
workspace_slug=workspace_slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
if project_identifier is not None:
|
||||
v.project_identifier = project_identifier
|
||||
return v
|
||||
|
||||
|
||||
def _qs_with_exists(result: bool):
|
||||
qs = MagicMock()
|
||||
qs.exists.return_value = result
|
||||
# Any nested filter(...) should return the same qs unless overridden in a test
|
||||
qs.filter.return_value = qs
|
||||
return qs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS"])
|
||||
def test_project_base_permission_safe_methods_allowed_for_active_workspace_member(method):
|
||||
request = _req(False, method)
|
||||
view = _view()
|
||||
|
||||
with patch.object(perms.WorkspaceMember.objects, "filter") as wm_filter:
|
||||
wm_filter.return_value = _qs_with_exists(True)
|
||||
|
||||
assert perms.ProjectBasePermission().has_permission(request, view) is True
|
||||
|
||||
wm_filter.assert_called_with(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS"])
|
||||
def test_project_base_permission_safe_methods_denied_for_non_member(method):
|
||||
request = _req(False, method)
|
||||
view = _view()
|
||||
|
||||
with patch.object(perms.WorkspaceMember.objects, "filter") as wm_filter:
|
||||
wm_filter.return_value = _qs_with_exists(False)
|
||||
|
||||
assert perms.ProjectBasePermission().has_permission(request, view) is False
|
||||
|
||||
|
||||
def test_project_base_permission_post_requires_workspace_admin_or_member():
|
||||
request = _req(False, "POST")
|
||||
view = _view()
|
||||
|
||||
with patch.object(perms.WorkspaceMember.objects, "filter") as wm_filter:
|
||||
qs = _qs_with_exists(True)
|
||||
wm_filter.return_value = qs
|
||||
|
||||
assert perms.ProjectBasePermission().has_permission(request, view) is True
|
||||
|
||||
# Validate role__in and other filters passed
|
||||
called_kwargs = wm_filter.call_args.kwargs
|
||||
assert called_kwargs["workspace__slug"] == view.workspace_slug
|
||||
assert called_kwargs["member"] == request.user
|
||||
assert called_kwargs["is_active"] is True
|
||||
assert "role__in" in called_kwargs
|
||||
assert isinstance(called_kwargs["role__in"], (list, tuple))
|
||||
assert len(called_kwargs["role__in"]) == 2 # [ROLE.ADMIN.value, ROLE.MEMBER.value]
|
||||
|
||||
|
||||
def test_project_base_permission_post_denied_when_not_in_workspace():
|
||||
request = _req(False, "POST")
|
||||
view = _view()
|
||||
|
||||
with patch.object(perms.WorkspaceMember.objects, "filter") as wm_filter:
|
||||
wm_filter.return_value = _qs_with_exists(False)
|
||||
|
||||
assert perms.ProjectBasePermission().has_permission(request, view) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["PUT", "PATCH", "DELETE"])
|
||||
def test_project_base_permission_non_safe_granted_if_project_admin(method):
|
||||
request = _req(False, method)
|
||||
view = _view()
|
||||
|
||||
with patch.object(perms.ProjectMember.objects, "filter") as pm_filter:
|
||||
base_qs = _qs_with_exists(False)
|
||||
admin_qs = _qs_with_exists(True) # admin exists
|
||||
base_qs.filter.return_value = admin_qs
|
||||
pm_filter.return_value = base_qs
|
||||
|
||||
assert perms.ProjectBasePermission().has_permission(request, view) is True
|
||||
|
||||
# Ensure admin role filter applied
|
||||
admin_call = base_qs.filter.call_args
|
||||
assert admin_call.kwargs == {"role": perms.ROLE.ADMIN.value}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["PUT", "PATCH", "DELETE"])
|
||||
def test_project_base_permission_non_safe_granted_if_project_member_and_workspace_admin(method):
|
||||
request = _req(False, method)
|
||||
view = _view()
|
||||
|
||||
with patch.object(perms.ProjectMember.objects, "filter") as pm_filter, \
|
||||
patch.object(perms.WorkspaceMember.objects, "filter") as wm_filter:
|
||||
|
||||
base_qs = _qs_with_exists(True) # user is a project member
|
||||
admin_qs = _qs_with_exists(False) # not a project admin
|
||||
base_qs.filter.return_value = admin_qs
|
||||
pm_filter.return_value = base_qs
|
||||
|
||||
wm_filter.return_value = _qs_with_exists(True) # workspace admin
|
||||
|
||||
assert perms.ProjectBasePermission().has_permission(request, view) is True
|
||||
|
||||
# Validate workspace admin filter call includes role=ROLE.ADMIN.value
|
||||
called_kwargs = wm_filter.call_args.kwargs
|
||||
assert called_kwargs["workspace__slug"] == view.workspace_slug
|
||||
assert called_kwargs["member"] == request.user
|
||||
assert called_kwargs["role"] == perms.ROLE.ADMIN.value
|
||||
assert called_kwargs["is_active"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["PUT", "PATCH", "DELETE"])
|
||||
def test_project_base_permission_non_safe_denied_if_not_admin_and_not_workspace_admin(method):
|
||||
request = _req(False, method)
|
||||
view = _view()
|
||||
|
||||
with patch.object(perms.ProjectMember.objects, "filter") as pm_filter, \
|
||||
patch.object(perms.WorkspaceMember.objects, "filter") as wm_filter:
|
||||
|
||||
base_qs = _qs_with_exists(False) # not a project member (or inactive)
|
||||
admin_qs = _qs_with_exists(False)
|
||||
base_qs.filter.return_value = admin_qs
|
||||
pm_filter.return_value = base_qs
|
||||
|
||||
wm_filter.return_value = _qs_with_exists(False)
|
||||
|
||||
assert perms.ProjectBasePermission().has_permission(request, view) is False
|
||||
|
||||
|
||||
def test_project_base_permission_denies_anonymous_for_all_methods():
|
||||
for method in ["GET", "POST", "PATCH"]:
|
||||
assert perms.ProjectBasePermission().has_permission(_req(True, method), _view()) is False
|
||||
|
||||
|
||||
# ---- ProjectMemberPermission tests ----
|
||||
|
||||
@pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS"])
|
||||
def test_project_member_permission_safe_methods_require_project_membership(method):
|
||||
request = _req(False, method)
|
||||
view = _view()
|
||||
|
||||
with patch.object(perms.ProjectMember.objects, "filter") as pm_filter:
|
||||
pm_filter.return_value = _qs_with_exists(True)
|
||||
assert perms.ProjectMemberPermission().has_permission(request, view) is True
|
||||
|
||||
pm_filter.return_value = _qs_with_exists(False)
|
||||
assert perms.ProjectMemberPermission().has_permission(request, view) is False
|
||||
|
||||
|
||||
def test_project_member_permission_post_requires_workspace_admin_or_member():
|
||||
request = _req(False, "POST")
|
||||
view = _view()
|
||||
|
||||
with patch.object(perms.WorkspaceMember.objects, "filter") as wm_filter:
|
||||
wm_filter.return_value = _qs_with_exists(True)
|
||||
assert perms.ProjectMemberPermission().has_permission(request, view) is True
|
||||
|
||||
called_kwargs = wm_filter.call_args.kwargs
|
||||
assert "role__in" in called_kwargs
|
||||
assert len(called_kwargs["role__in"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_active", [True, False])
|
||||
def test_project_member_permission_write_requires_admin_or_member_and_active(is_active):
|
||||
request = _req(False, "PATCH")
|
||||
view = _view()
|
||||
|
||||
with patch.object(perms.ProjectMember.objects, "filter") as pm_filter:
|
||||
qs = _qs_with_exists(is_active)
|
||||
pm_filter.return_value = qs
|
||||
|
||||
result = perms.ProjectMemberPermission().has_permission(request, view)
|
||||
assert result is is_active
|
||||
|
||||
# Validate role__in and scoping filters
|
||||
called_kwargs = pm_filter.call_args.kwargs
|
||||
assert called_kwargs["workspace__slug"] == view.workspace_slug
|
||||
assert called_kwargs["member"] == request.user
|
||||
assert called_kwargs["project_id"] == view.project_id
|
||||
assert "role__in" in called_kwargs
|
||||
assert called_kwargs["is_active"] is True
|
||||
|
||||
|
||||
def test_project_member_permission_denies_anonymous():
|
||||
assert perms.ProjectMemberPermission().has_permission(_req(True, "GET"), _view()) is False
|
||||
|
||||
|
||||
# ---- ProjectEntityPermission tests ----
|
||||
|
||||
def test_project_entity_permission_safe_with_project_identifier_scopes_by_identifier():
|
||||
request = _req(False, "GET")
|
||||
view = _view(project_identifier="PRJ-123")
|
||||
|
||||
with patch.object(perms.ProjectMember.objects, "filter") as pm_filter:
|
||||
pm_filter.return_value = _qs_with_exists(True)
|
||||
assert perms.ProjectEntityPermission().has_permission(request, view) is True
|
||||
|
||||
# Ensure identifier used (not project_id)
|
||||
called_kwargs = pm_filter.call_args.kwargs
|
||||
assert called_kwargs["project__identifier"] == "PRJ-123"
|
||||
assert "project_id" not in called_kwargs
|
||||
|
||||
|
||||
def test_project_entity_permission_safe_without_identifier_scopes_by_project_id():
|
||||
request = _req(False, "GET")
|
||||
view = _view(project_identifier=None)
|
||||
|
||||
with patch.object(perms.ProjectMember.objects, "filter") as pm_filter:
|
||||
pm_filter.return_value = _qs_with_exists(True)
|
||||
assert perms.ProjectEntityPermission().has_permission(request, view) is True
|
||||
|
||||
called_kwargs = pm_filter.call_args.kwargs
|
||||
assert called_kwargs["project_id"] == view.project_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"])
|
||||
def test_project_entity_permission_write_requires_admin_or_member(method):
|
||||
request = _req(False, method)
|
||||
view = _view()
|
||||
|
||||
with patch.object(perms.ProjectMember.objects, "filter") as pm_filter:
|
||||
pm_filter.return_value = _qs_with_exists(True)
|
||||
assert perms.ProjectEntityPermission().has_permission(request, view) is True
|
||||
|
||||
called_kwargs = pm_filter.call_args.kwargs
|
||||
assert "role__in" in called_kwargs
|
||||
assert len(called_kwargs["role__in"]) == 2
|
||||
assert called_kwargs["is_active"] is True
|
||||
|
||||
|
||||
def test_project_entity_permission_denies_anonymous():
|
||||
assert perms.ProjectEntityPermission().has_permission(_req(True, "GET"), _view()) is False
|
||||
|
||||
|
||||
# ---- ProjectLitePermission tests ----
|
||||
|
||||
def test_project_lite_permission_denies_anonymous():
|
||||
assert perms.ProjectLitePermission().has_permission(_req(True, "GET"), _view()) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exists", [True, False])
|
||||
def test_project_lite_permission_requires_project_membership(exists):
|
||||
request = _req(False, "GET")
|
||||
view = _view()
|
||||
|
||||
with patch.object(perms.ProjectMember.objects, "filter") as pm_filter:
|
||||
pm_filter.return_value = _qs_with_exists(exists)
|
||||
assert perms.ProjectLitePermission().has_permission(request, view) is exists
|
||||
|
||||
called_kwargs = pm_filter.call_args.kwargs
|
||||
assert called_kwargs["workspace__slug"] == view.workspace_slug
|
||||
assert called_kwargs["member"] == request.user
|
||||
assert called_kwargs["project_id"] == view.project_id
|
||||
assert called_kwargs["is_active"] is True
|
||||
@@ -0,0 +1,475 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient, APIRequestFactory, force_authenticate
|
||||
|
||||
# NOTE: Import paths may need adjustment based on the repo's layout.
|
||||
# We bias for action by importing via common app paths; adapt as needed if tests fail.
|
||||
from plane.app.models import (
|
||||
Project,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
ProjectMember,
|
||||
DeployBoard,
|
||||
UserFavorite,
|
||||
Intake,
|
||||
State,
|
||||
ProjectIdentifier,
|
||||
User,
|
||||
)
|
||||
from plane.app.views import (
|
||||
ProjectViewSet,
|
||||
ProjectArchiveUnarchiveEndpoint,
|
||||
ProjectIdentifierEndpoint,
|
||||
ProjectUserViewsEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
DeployBoardViewSet,
|
||||
)
|
||||
from plane.app.permissions import ROLE
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProjectViewSetListDetail:
|
||||
def setup_method(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.client = APIClient()
|
||||
# Users
|
||||
self.owner = User.objects.create_user(email="owner@example.com", password="x")
|
||||
self.member = User.objects.create_user(email="member@example.com", password="x")
|
||||
self.guest = User.objects.create_user(email="guest@example.com", password="x")
|
||||
# Workspace and memberships
|
||||
self.ws = Workspace.objects.create(name="Acme", slug="acme")
|
||||
WorkspaceMember.objects.create(
|
||||
workspace=self.ws, member=self.owner, role=ROLE.ADMIN.value, is_active=True
|
||||
)
|
||||
WorkspaceMember.objects.create(
|
||||
workspace=self.ws, member=self.member, role=ROLE.MEMBER.value, is_active=True
|
||||
)
|
||||
WorkspaceMember.objects.create(
|
||||
workspace=self.ws, member=self.guest, role=ROLE.GUEST.value, is_active=True
|
||||
)
|
||||
# Projects
|
||||
self.p1 = Project.objects.create(name="P1", identifier="P1", workspace=self.ws)
|
||||
self.p2 = Project.objects.create(name="P2", identifier="P2", workspace=self.ws, network=2)
|
||||
self.p3 = Project.objects.create(name="P3", identifier="P3", workspace=self.ws, archived_at=None)
|
||||
# Project memberships
|
||||
ProjectMember.objects.create(project=self.p1, workspace=self.ws, member=self.owner, role=ROLE.ADMIN.value, is_active=True)
|
||||
ProjectMember.objects.create(project=self.p1, workspace=self.ws, member=self.member, role=ROLE.MEMBER.value, is_active=True)
|
||||
ProjectMember.objects.create(project=self.p2, workspace=self.ws, member=self.owner, role=ROLE.ADMIN.value, is_active=True)
|
||||
ProjectMember.objects.create(project=self.p3, workspace=self.ws, member=self.owner, role=ROLE.ADMIN.value, is_active=True)
|
||||
# Favorites and DeployBoard anchor annotations coverage
|
||||
UserFavorite.objects.create(user=self.owner, entity_type="project", entity_identifier=self.p1.id, project=self.p1, workspace=self.ws)
|
||||
DeployBoard.objects.create(entity_name="project", entity_identifier=self.p1.id, project=self.p1, workspace=self.ws, anchor="p1-anchor")
|
||||
|
||||
def _view(self, action: str):
|
||||
return ProjectViewSet.as_view({ "get": action })
|
||||
|
||||
def test_list_detail_admin_sees_all_sorted(self):
|
||||
request = self.factory.get(f"/workspaces/{self.ws.slug}/projects/list-detail")
|
||||
force_authenticate(request, user=self.owner)
|
||||
response = self._view("list_detail")(request, slug=self.ws.slug)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
# Should include at least p1, p2, p3
|
||||
ids = [p["id"] for p in response.data]
|
||||
assert self.p1.id in ids and self.p2.id in ids and self.p3.id in ids
|
||||
|
||||
def test_list_detail_guest_only_memberships(self):
|
||||
request = self.factory.get(f"/workspaces/{self.ws.slug}/projects/list-detail")
|
||||
force_authenticate(request, user=self.guest)
|
||||
response = self._view("list_detail")(request, slug=self.ws.slug)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
# guest not a member of any project -> sees none
|
||||
assert response.data == []
|
||||
|
||||
# Add guest to p1, ensure visibility
|
||||
ProjectMember.objects.create(project=self.p1, workspace=self.ws, member=self.guest, role=ROLE.GUEST.value, is_active=True)
|
||||
request = self.factory.get(f"/workspaces/{self.ws.slug}/projects/list-detail")
|
||||
force_authenticate(request, user=self.guest)
|
||||
response = self._view("list_detail")(request, slug=self.ws.slug)
|
||||
ids = [p["id"] for p in response.data]
|
||||
assert self.p1.id in ids
|
||||
assert self.p2.id not in ids # guest not member of p2
|
||||
|
||||
def test_list_detail_member_sees_memberships_plus_network_2(self):
|
||||
request = self.factory.get(f"/workspaces/{self.ws.slug}/projects/list-detail")
|
||||
force_authenticate(request, user=self.member)
|
||||
response = self._view("list_detail")(request, slug=self.ws.slug)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
ids = [p["id"] for p in response.data]
|
||||
# Member of p1, should also see p2 because network=2
|
||||
assert self.p1.id in ids and self.p2.id in ids
|
||||
|
||||
def test_list_detail_fields_filtering(self):
|
||||
request = self.factory.get(f"/workspaces/{self.ws.slug}/projects/list-detail?fields=id,name")
|
||||
force_authenticate(request, user=self.owner)
|
||||
response = self._view("list_detail")(request, slug=self.ws.slug)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert set(response.data[0].keys()).issubset({"id","name"})
|
||||
|
||||
def test_list_detail_cursor_pagination_path(self, monkeypatch):
|
||||
# Force paginate branch by providing per_page and cursor
|
||||
request = self.factory.get(f"/workspaces/{self.ws.slug}/projects/list-detail?per_page=2&cursor=abc&order_by=-created_at")
|
||||
force_authenticate(request, user=self.owner)
|
||||
# Mock paginate to ensure it is invoked
|
||||
with mock.patch.object(ProjectViewSet, "paginate", return_value=mock.sentinel.PAGED) as m:
|
||||
response = self._view("list_detail")(request, slug=self.ws.slug)
|
||||
assert response == mock.sentinel.PAGED
|
||||
assert m.called
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProjectViewSetList:
|
||||
def setup_method(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.ws = Workspace.objects.create(name="Acme", slug="acme")
|
||||
self.admin = User.objects.create_user(email="admin@example.com", password="x")
|
||||
WorkspaceMember.objects.create(workspace=self.ws, member=self.admin, role=ROLE.ADMIN.value, is_active=True)
|
||||
self.p1 = Project.objects.create(name="P1", identifier="P1", workspace=self.ws)
|
||||
self.p2 = Project.objects.create(name="P2", identifier="P2", workspace=self.ws, network=2)
|
||||
ProjectMember.objects.create(project=self.p1, workspace=self.ws, member=self.admin, role=ROLE.ADMIN.value, is_active=True)
|
||||
|
||||
def _view(self, action):
|
||||
return ProjectViewSet.as_view({ "get": action })
|
||||
|
||||
def test_list_values_shape_and_inbox_view(self):
|
||||
request = self.factory.get(f"/workspaces/{self.ws.slug}/projects")
|
||||
force_authenticate(request, user=self.admin)
|
||||
response = self._view("list")(request, slug=self.ws.slug)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert isinstance(response.data, list)
|
||||
item = response.data[0]
|
||||
# Expect selected fields only
|
||||
expected_subset = {"id","name","identifier","sort_order","member_role","inbox_view","network","created_at","updated_at"}
|
||||
assert expected_subset.issubset(set(item.keys()))
|
||||
|
||||
def test_list_member_filtering_includes_network_two(self):
|
||||
member = User.objects.create_user(email="member@example.com", password="x")
|
||||
WorkspaceMember.objects.create(workspace=self.ws, member=member, role=ROLE.MEMBER.value, is_active=True)
|
||||
ProjectMember.objects.create(project=self.p1, workspace=self.ws, member=member, role=ROLE.MEMBER.value, is_active=True)
|
||||
|
||||
request = self.factory.get(f"/workspaces/{self.ws.slug}/projects")
|
||||
force_authenticate(request, user=member)
|
||||
response = self._view("list")(request, slug=self.ws.slug)
|
||||
ids = [p["id"] for p in response.data]
|
||||
assert self.p1.id in ids
|
||||
assert self.p2.id in ids # network=2 visible to members
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProjectViewSetRetrieveCreateUpdateDestroy:
|
||||
def setup_method(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.ws = Workspace.objects.create(name="Acme", slug="acme")
|
||||
self.admin = User.objects.create_user(email="admin@example.com", password="x")
|
||||
self.member = User.objects.create_user(email="user@example.com", password="x")
|
||||
WorkspaceMember.objects.create(workspace=self.ws, member=self.admin, role=ROLE.ADMIN.value, is_active=True)
|
||||
WorkspaceMember.objects.create(workspace=self.ws, member=self.member, role=ROLE.MEMBER.value, is_active=True)
|
||||
self.project = Project.objects.create(name="Proj", identifier="PROJ", workspace=self.ws)
|
||||
ProjectMember.objects.create(project=self.project, workspace=self.ws, member=self.admin, role=ROLE.ADMIN.value, is_active=True)
|
||||
|
||||
def _view(self, method_name, http_method="get"):
|
||||
return ProjectViewSet.as_view({ http_method: method_name })
|
||||
|
||||
@mock.patch("plane.app.views.recent_visited_task.delay")
|
||||
def test_retrieve_happy_path_and_404(self, mock_task):
|
||||
# make member of project
|
||||
ProjectMember.objects.create(project=self.project, workspace=self.ws, member=self.member, role=ROLE.MEMBER.value, is_active=True)
|
||||
# OK
|
||||
req = self.factory.get("/")
|
||||
force_authenticate(req, user=self.member)
|
||||
resp = self._view("retrieve")(req, slug=self.ws.slug, pk=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
mock_task.assert_called_once()
|
||||
# 404 when not a member
|
||||
outsider = User.objects.create_user(email="out@example.com", password="x")
|
||||
WorkspaceMember.objects.create(workspace=self.ws, member=outsider, role=ROLE.MEMBER.value, is_active=True)
|
||||
req = self.factory.get("/")
|
||||
force_authenticate(req, user=outsider)
|
||||
resp = self._view("retrieve")(req, slug=self.ws.slug, pk=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@mock.patch("plane.app.views.model_activity.delay")
|
||||
def test_create_creates_default_states_and_members(self, mock_activity):
|
||||
req = self.factory.post("/", data={"name": "NewProj", "identifier": "NP"}, format="json")
|
||||
force_authenticate(req, user=self.admin)
|
||||
resp = self._view("create", http_method="post")(req, slug=self.ws.slug)
|
||||
assert resp.status_code == status.HTTP_201_CREATED
|
||||
created_id = resp.data["id"]
|
||||
# Defaults: states created
|
||||
assert State.objects.filter(project_id=created_id, workspace=self.ws).count() >= 5
|
||||
# Memberships: admin added
|
||||
assert ProjectMember.objects.filter(project_id=created_id, member=self.admin, role=ROLE.ADMIN.value).exists()
|
||||
mock_activity.assert_called_once()
|
||||
|
||||
@mock.patch("plane.app.views.model_activity.delay")
|
||||
def test_partial_update_requires_permission_and_handles_archived(self, mock_activity):
|
||||
# Non-admin project member should be forbidden
|
||||
non_admin = self.member
|
||||
ProjectMember.objects.create(project=self.project, workspace=self.ws, member=non_admin, role=ROLE.MEMBER.value, is_active=True)
|
||||
req = self.factory.patch("/", data={"name": "X"}, format="json")
|
||||
force_authenticate(req, user=non_admin)
|
||||
resp = self._view("partial_update", http_method="patch")(req, slug=self.ws.slug, pk=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
# Archive project then attempt update -> 400
|
||||
self.project.archived_at = timezone.now()
|
||||
self.project.save()
|
||||
req = self.factory.patch("/", data={"name": "Y"}, format="json")
|
||||
force_authenticate(req, user=self.admin)
|
||||
resp = self._view("partial_update", http_method="patch")(req, slug=self.ws.slug, pk=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Unarchive and enable inbox_view -> ensure Intake default created
|
||||
self.project.archived_at = None
|
||||
self.project.save()
|
||||
req = self.factory.patch("/", data={"inbox_view": True}, format="json")
|
||||
force_authenticate(req, user=self.admin)
|
||||
resp = self._view("partial_update", http_method="patch")(req, slug=self.ws.slug, pk=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert Intake.objects.filter(project=self.project, is_default=True).exists()
|
||||
assert mock_activity.called
|
||||
|
||||
@mock.patch("plane.app.views.webhook_activity.delay")
|
||||
def test_destroy_deletes_related_and_respects_permission(self, mock_webhook):
|
||||
# Non-admin cannot delete
|
||||
req = self.factory.delete("/")
|
||||
force_authenticate(req, user=self.member)
|
||||
resp = self._view("destroy", http_method="delete")(req, slug=self.ws.slug, pk=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
# Admin delete removes deploy board and favorites
|
||||
DeployBoard.objects.create(entity_name="project", entity_identifier=self.project.id, project=self.project, workspace=self.ws)
|
||||
UserFavorite.objects.create(user=self.admin, entity_type="project", entity_identifier=self.project.id, project=self.project, workspace=self.ws)
|
||||
req = self.factory.delete("/")
|
||||
force_authenticate(req, user=self.admin)
|
||||
resp = self._view("destroy", http_method="delete")(req, slug=self.ws.slug, pk=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not DeployBoard.objects.filter(project_id=self.project.id, workspace=self.ws).exists()
|
||||
assert not UserFavorite.objects.filter(project_id=self.project.id, workspace=self.ws).exists()
|
||||
mock_webhook.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProjectArchiveUnarchiveEndpoint:
|
||||
def setup_method(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.ws = Workspace.objects.create(name="Acme", slug="acme")
|
||||
self.admin = User.objects.create_user(email="admin@example.com", password="x")
|
||||
WorkspaceMember.objects.create(workspace=self.ws, member=self.admin, role=ROLE.ADMIN.value, is_active=True)
|
||||
self.project = Project.objects.create(name="Proj", identifier="PROJ", workspace=self.ws)
|
||||
|
||||
def test_archive_sets_archived_at_and_removes_favorites(self):
|
||||
fav = UserFavorite.objects.create(user=self.admin, entity_type="project", entity_identifier=self.project.id, project=self.project, workspace=self.ws)
|
||||
view = ProjectArchiveUnarchiveEndpoint.as_view({"post": "post"})
|
||||
req = self.factory.post("/")
|
||||
force_authenticate(req, user=self.admin)
|
||||
resp = view(req, slug=self.ws.slug, project_id=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
self.project.refresh_from_db()
|
||||
assert self.project.archived_at is not None
|
||||
assert not UserFavorite.objects.filter(pk=fav.pk).exists()
|
||||
|
||||
def test_unarchive_clears_archived_at(self):
|
||||
self.project.archived_at = timezone.now()
|
||||
self.project.save()
|
||||
view = ProjectArchiveUnarchiveEndpoint.as_view({"delete": "delete"})
|
||||
req = self.factory.delete("/")
|
||||
force_authenticate(req, user=self.admin)
|
||||
resp = view(req, slug=self.ws.slug, project_id=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_204_NO_CONTENT
|
||||
self.project.refresh_from_db()
|
||||
assert self.project.archived_at is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProjectIdentifierEndpoint:
|
||||
def setup_method(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.ws = Workspace.objects.create(name="Acme", slug="acme")
|
||||
self.admin = User.objects.create_user(email="admin@example.com", password="x")
|
||||
WorkspaceMember.objects.create(workspace=self.ws, member=self.admin, role=ROLE.ADMIN.value, is_active=True)
|
||||
|
||||
def test_get_requires_name(self):
|
||||
view = ProjectIdentifierEndpoint.as_view({"get": "get"})
|
||||
req = self.factory.get("/", data={"name": ""})
|
||||
force_authenticate(req, user=self.admin)
|
||||
resp = view(req, slug=self.ws.slug)
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_get_returns_existing_identifiers(self):
|
||||
ProjectIdentifier.objects.create(name="API", workspace=self.ws)
|
||||
view = ProjectIdentifierEndpoint.as_view({"get": "get"})
|
||||
req = self.factory.get("/", data={"name": "api"})
|
||||
force_authenticate(req, user=self.admin)
|
||||
resp = view(req, slug=self.ws.slug)
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.data["exists"] >= 1
|
||||
assert any(i["name"] == "API" for i in resp.data["identifiers"])
|
||||
|
||||
def test_delete_validation_and_success(self):
|
||||
view = ProjectIdentifierEndpoint.as_view({"delete": "delete"})
|
||||
# Missing name
|
||||
req = self.factory.delete("/", data={"name": ""}, format="json")
|
||||
force_authenticate(req, user=self.admin)
|
||||
resp = view(req, slug=self.ws.slug)
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Cannot delete if used by a project
|
||||
proj = Project.objects.create(name="Proj", identifier="USED", workspace=self.ws)
|
||||
req = self.factory.delete("/", data={"name": "used"}, format="json")
|
||||
force_authenticate(req, user=self.admin)
|
||||
resp = view(req, slug=self.ws.slug)
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Can delete free identifier
|
||||
ProjectIdentifier.objects.create(name="FREE", workspace=self.ws)
|
||||
req = self.factory.delete("/", data={"name": "free"}, format="json")
|
||||
force_authenticate(req, user=self.admin)
|
||||
resp = view(req, slug=self.ws.slug)
|
||||
assert resp.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not ProjectIdentifier.objects.filter(name="FREE", workspace=self.ws).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProjectUserViewsEndpoint:
|
||||
def setup_method(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.ws = Workspace.objects.create(name="Acme", slug="acme")
|
||||
self.user = User.objects.create_user(email="u@example.com", password="x")
|
||||
self.project = Project.objects.create(name="Proj", identifier="PROJ", workspace=self.ws)
|
||||
|
||||
def test_post_forbidden_if_not_project_member(self):
|
||||
view = ProjectUserViewsEndpoint.as_view({"post": "post"})
|
||||
req = self.factory.post("/", data={"view_props": {"x": 1}}, format="json")
|
||||
force_authenticate(req, user=self.user)
|
||||
resp = view(req, slug=self.ws.slug, project_id=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_post_updates_member_props(self):
|
||||
# Make user a member
|
||||
ProjectMember.objects.create(project=self.project, workspace=self.ws, member=self.user, role=ROLE.MEMBER.value, is_active=True, view_props={"a": 1}, default_props={"b": 2}, preferences={"c": 3}, sort_order=5)
|
||||
view = ProjectUserViewsEndpoint.as_view({"post": "post"})
|
||||
payload = {
|
||||
"view_props": {"a": 9},
|
||||
"default_props": {"b": 8},
|
||||
"preferences": {"c": 7},
|
||||
"sort_order": 42,
|
||||
}
|
||||
req = self.factory.post("/", data=payload, format="json")
|
||||
force_authenticate(req, user=self.user)
|
||||
resp = view(req, slug=self.ws.slug, project_id=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_204_NO_CONTENT
|
||||
pm = ProjectMember.objects.get(project=self.project, member=self.user)
|
||||
assert pm.view_props == payload["view_props"]
|
||||
assert pm.default_props == payload["default_props"]
|
||||
assert pm.preferences == payload["preferences"]
|
||||
assert pm.sort_order == 42
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProjectFavoritesViewSet:
|
||||
def setup_method(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.ws = Workspace.objects.create(name="Acme", slug="acme")
|
||||
self.user = User.objects.create_user(email="u@example.com", password="x")
|
||||
WorkspaceMember.objects.create(workspace=self.ws, member=self.user, role=ROLE.MEMBER.value, is_active=True)
|
||||
self.project = Project.objects.create(name="Proj", identifier="PROJ", workspace=self.ws)
|
||||
|
||||
def _view(self, method, http="post"):
|
||||
return ProjectFavoritesViewSet.as_view({ http: method })
|
||||
|
||||
def test_create_and_destroy_favorite(self):
|
||||
# Create
|
||||
req = self.factory.post("/", data={"project": self.project.id}, format="json")
|
||||
force_authenticate(req, user=self.user)
|
||||
resp = self._view("create", "post")(req, slug=self.ws.slug)
|
||||
assert resp.status_code == status.HTTP_204_NO_CONTENT
|
||||
fav = UserFavorite.objects.get(user=self.user, project=self.project, entity_type="project")
|
||||
|
||||
# Destroy
|
||||
req = self.factory.delete("/")
|
||||
force_authenticate(req, user=self.user)
|
||||
resp = self._view("destroy", "delete")(req, slug=self.ws.slug, project_id=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not UserFavorite.objects.filter(pk=fav.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProjectPublicCoverImagesEndpoint:
|
||||
@mock.patch("plane.app.views.boto3.client")
|
||||
def test_get_lists_public_images_and_handles_errors(self, mock_client):
|
||||
# Mock S3 response
|
||||
s3 = mock_client.return_value
|
||||
s3.list_objects_v2.return_value = {
|
||||
"Contents": [
|
||||
{"Key": "static/project-cover/img1.png"},
|
||||
{"Key": "static/project-cover/nested/"},
|
||||
{"Key": "static/project-cover/img2.jpg"},
|
||||
]
|
||||
}
|
||||
req = APIRequestFactory().get("/public/project-covers")
|
||||
resp = ProjectPublicCoverImagesEndpoint.as_view()(req)
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
# Only files, ignore folders
|
||||
assert len(resp.data) == 2
|
||||
assert all(k.endswith(("img1.png","img2.jpg")) for k in resp.data[0:2] or [])
|
||||
|
||||
# Error path returns empty list
|
||||
s3.list_objects_v2.side_effect = Exception("boom")
|
||||
req = APIRequestFactory().get("/public/project-covers")
|
||||
resp = ProjectPublicCoverImagesEndpoint.as_view()(req)
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.data == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestDeployBoardViewSet:
|
||||
def setup_method(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.client = APIClient()
|
||||
self.ws = Workspace.objects.create(name="Acme", slug="acme")
|
||||
self.user = User.objects.create_user(email="u@example.com", password="x")
|
||||
WorkspaceMember.objects.create(workspace=self.ws, member=self.user, role=ROLE.MEMBER.value, is_active=True)
|
||||
self.project = Project.objects.create(name="Proj", identifier="PROJ", workspace=self.ws)
|
||||
ProjectMember.objects.create(project=self.project, workspace=self.ws, member=self.user, role=ROLE.MEMBER.value, is_active=True)
|
||||
|
||||
def _view(self, method, http="get"):
|
||||
return DeployBoardViewSet.as_view({ http: method })
|
||||
|
||||
def test_list_returns_existing_board(self):
|
||||
db = DeployBoard.objects.create(entity_name="project", entity_identifier=self.project.id, project=self.project, workspace=self.ws, view_props={"list": True})
|
||||
req = self.factory.get("/")
|
||||
force_authenticate(req, user=self.user)
|
||||
resp = self._view("list", "get")(req, slug=self.ws.slug, project_id=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.data["id"] == str(db.id) or resp.data["id"] == db.id
|
||||
|
||||
def test_create_upserts_and_sets_flags(self):
|
||||
payload = {
|
||||
"is_comments_enabled": True,
|
||||
"is_reactions_enabled": True,
|
||||
"is_votes_enabled": True,
|
||||
"intake": {"enabled": True},
|
||||
"views": {"list": True, "kanban": False, "calendar": True, "gantt": False, "spreadsheet": True},
|
||||
}
|
||||
req = self.factory.post("/", data=payload, format="json")
|
||||
force_authenticate(req, user=self.user)
|
||||
resp = self._view("create", "post")(req, slug=self.ws.slug, project_id=str(self.project.id))
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
|
||||
db = DeployBoard.objects.get(entity_name="project", entity_identifier=self.project.id)
|
||||
assert db.is_comments_enabled is True
|
||||
assert db.is_reactions_enabled is True
|
||||
assert db.is_votes_enabled is True
|
||||
assert db.view_props == payload["views"]
|
||||
assert db.intake == payload["intake"]
|
||||
|
||||
|
||||
# Testing framework note:
|
||||
# These tests are written for pytest with pytest-django and Django REST Framework's testing utilities (APIRequestFactory/APIClient).
|
||||
# They follow repository conventions by mocking external dependencies (boto3, celery tasks) and exercising public interfaces of view classes.
|
||||
@@ -2,6 +2,9 @@
|
||||
import base64
|
||||
import nh3
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from bs4 import BeautifulSoup
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
# Maximum allowed size for binary data (10MB)
|
||||
MAX_SIZE = 10 * 1024 * 1024
|
||||
@@ -19,7 +22,8 @@ SUSPICIOUS_BINARY_PATTERNS = [
|
||||
|
||||
def validate_binary_data(data):
|
||||
"""
|
||||
Validate that binary data appears to be valid document format and doesn't contain malicious content.
|
||||
Validate that binary data appears to be a valid document format
|
||||
and doesn't contain malicious content.
|
||||
|
||||
Args:
|
||||
data (bytes or str): The binary data to validate, or base64-encoded string
|
||||
@@ -60,6 +64,147 @@ def validate_binary_data(data):
|
||||
return True, None
|
||||
|
||||
|
||||
# Combine custom components and editor-specific nodes into a single set of tags
|
||||
CUSTOM_TAGS = {
|
||||
# editor node/tag names
|
||||
"mention-component",
|
||||
"label",
|
||||
"input",
|
||||
"image-component",
|
||||
}
|
||||
ALLOWED_TAGS = nh3.ALLOWED_TAGS | CUSTOM_TAGS
|
||||
|
||||
# Merge nh3 defaults with all attributes used across our custom components
|
||||
ATTRIBUTES = {
|
||||
"*": {
|
||||
"class",
|
||||
"id",
|
||||
"title",
|
||||
"role",
|
||||
"aria-label",
|
||||
"aria-hidden",
|
||||
"style",
|
||||
"start",
|
||||
"type",
|
||||
# common editor data-* attributes seen in stored HTML
|
||||
# (wildcards like data-* are NOT supported by nh3; we add known keys
|
||||
# here and dynamically include all data-* seen in the input below)
|
||||
"data-tight",
|
||||
"data-node-type",
|
||||
"data-type",
|
||||
"data-checked",
|
||||
"data-background-color",
|
||||
"data-text-color",
|
||||
"data-name",
|
||||
# callout attributes
|
||||
"data-icon-name",
|
||||
"data-icon-color",
|
||||
"data-background",
|
||||
"data-emoji-unicode",
|
||||
"data-emoji-url",
|
||||
"data-logo-in-use",
|
||||
"data-block-type",
|
||||
},
|
||||
"a": {"href", "target"},
|
||||
# editor node/tag attributes
|
||||
"image-component": {
|
||||
"id",
|
||||
"width",
|
||||
"height",
|
||||
"aspectRatio",
|
||||
"aspectratio",
|
||||
"src",
|
||||
"alignment",
|
||||
},
|
||||
"img": {
|
||||
"width",
|
||||
"height",
|
||||
"aspectRatio",
|
||||
"aspectratio",
|
||||
"alignment",
|
||||
"src",
|
||||
"alt",
|
||||
"title",
|
||||
},
|
||||
"mention-component": {"id", "entity_identifier", "entity_name"},
|
||||
"th": {
|
||||
"colspan",
|
||||
"rowspan",
|
||||
"colwidth",
|
||||
"background",
|
||||
"hideContent",
|
||||
"hidecontent",
|
||||
"style",
|
||||
},
|
||||
"td": {
|
||||
"colspan",
|
||||
"rowspan",
|
||||
"colwidth",
|
||||
"background",
|
||||
"textColor",
|
||||
"textcolor",
|
||||
"hideContent",
|
||||
"hidecontent",
|
||||
"style",
|
||||
},
|
||||
"tr": {"background", "textColor", "textcolor", "style"},
|
||||
"pre": {"language"},
|
||||
"code": {"language", "spellcheck"},
|
||||
"input": {"type", "checked"},
|
||||
}
|
||||
|
||||
SAFE_PROTOCOLS = {"http", "https", "mailto", "tel"}
|
||||
|
||||
|
||||
def _compute_html_sanitization_diff(before_html: str, after_html: str):
|
||||
"""
|
||||
Compute a coarse diff between original and sanitized HTML.
|
||||
|
||||
Returns a dict with:
|
||||
- removed_tags: mapping[tag] -> removed_count
|
||||
- removed_attributes: mapping[tag] -> sorted list of attribute names removed
|
||||
"""
|
||||
try:
|
||||
|
||||
def collect(soup):
|
||||
tag_counts = defaultdict(int)
|
||||
attrs_by_tag = defaultdict(set)
|
||||
for el in soup.find_all(True):
|
||||
tag_name = (el.name or "").lower()
|
||||
if not tag_name:
|
||||
continue
|
||||
tag_counts[tag_name] += 1
|
||||
for attr_name in list(el.attrs.keys()):
|
||||
if isinstance(attr_name, str) and attr_name:
|
||||
attrs_by_tag[tag_name].add(attr_name.lower())
|
||||
return tag_counts, attrs_by_tag
|
||||
|
||||
soup_before = BeautifulSoup(before_html or "", "html.parser")
|
||||
soup_after = BeautifulSoup(after_html or "", "html.parser")
|
||||
|
||||
counts_before, attrs_before = collect(soup_before)
|
||||
counts_after, attrs_after = collect(soup_after)
|
||||
|
||||
removed_tags = {}
|
||||
for tag, cnt_before in counts_before.items():
|
||||
cnt_after = counts_after.get(tag, 0)
|
||||
if cnt_after < cnt_before:
|
||||
removed = cnt_before - cnt_after
|
||||
removed_tags[tag] = removed
|
||||
|
||||
removed_attributes = {}
|
||||
for tag, before_set in attrs_before.items():
|
||||
after_set = attrs_after.get(tag, set())
|
||||
removed = before_set - after_set
|
||||
if removed:
|
||||
removed_attributes[tag] = sorted(list(removed))
|
||||
|
||||
return {"removed_tags": removed_tags, "removed_attributes": removed_attributes}
|
||||
except Exception:
|
||||
# Best-effort only; if diffing fails we don't block the request
|
||||
return {"removed_tags": {}, "removed_attributes": {}}
|
||||
|
||||
|
||||
def validate_html_content(html_content: str):
|
||||
"""
|
||||
Sanitize HTML content using nh3.
|
||||
@@ -73,7 +218,25 @@ def validate_html_content(html_content: str):
|
||||
return False, "HTML content exceeds maximum size limit (10MB)", None
|
||||
|
||||
try:
|
||||
clean_html = nh3.clean(html_content)
|
||||
clean_html = nh3.clean(
|
||||
html_content,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ATTRIBUTES,
|
||||
url_schemes=SAFE_PROTOCOLS,
|
||||
)
|
||||
# Report removals to logger (Sentry) if anything was stripped
|
||||
diff = _compute_html_sanitization_diff(html_content, clean_html)
|
||||
if diff.get("removed_tags") or diff.get("removed_attributes"):
|
||||
try:
|
||||
import json
|
||||
|
||||
summary = json.dumps(diff)
|
||||
except Exception:
|
||||
summary = str(diff)
|
||||
log_exception(
|
||||
f"HTML sanitization removals: {summary}",
|
||||
warning=True,
|
||||
)
|
||||
return True, None, clean_html
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Q, UUIDField, Value, QuerySet
|
||||
from django.db.models import Q, UUIDField, Value, QuerySet, OuterRef, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Module imports
|
||||
@@ -14,6 +14,9 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
State,
|
||||
WorkspaceMember,
|
||||
IssueAssignee,
|
||||
ModuleIssue,
|
||||
IssueLabel,
|
||||
)
|
||||
from typing import Optional, Dict, Tuple, Any, Union, List
|
||||
|
||||
@@ -39,33 +42,52 @@ def issue_queryset_grouper(
|
||||
if group_key in GROUP_FILTER_MAPPER:
|
||||
queryset = queryset.filter(GROUP_FILTER_MAPPER[group_key])
|
||||
|
||||
issue_assignee_subquery = Subquery(
|
||||
IssueAssignee.objects.filter(
|
||||
issue_id=OuterRef("pk"),
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("assignee_id", distinct=True))
|
||||
.values("arr")
|
||||
)
|
||||
|
||||
issue_module_subquery = Subquery(
|
||||
ModuleIssue.objects.filter(
|
||||
issue_id=OuterRef("pk"),
|
||||
deleted_at__isnull=True,
|
||||
module__archived_at__isnull=True,
|
||||
)
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("module_id", distinct=True))
|
||||
.values("arr")
|
||||
)
|
||||
|
||||
issue_label_subquery = Subquery(
|
||||
IssueLabel.objects.filter(issue_id=OuterRef("pk"), deleted_at__isnull=True)
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("label_id", distinct=True))
|
||||
.values("arr")
|
||||
)
|
||||
|
||||
annotations_map: Dict[str, Tuple[str, Q]] = {
|
||||
"assignee_ids": (
|
||||
"assignees__id",
|
||||
~Q(assignees__id__isnull=True) & Q(issue_assignee__deleted_at__isnull=True),
|
||||
"assignee_ids": Coalesce(
|
||||
issue_assignee_subquery, Value([], output_field=ArrayField(UUIDField()))
|
||||
),
|
||||
"label_ids": (
|
||||
"labels__id",
|
||||
~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True),
|
||||
"label_ids": Coalesce(
|
||||
issue_label_subquery, Value([], output_field=ArrayField(UUIDField()))
|
||||
),
|
||||
"module_ids": (
|
||||
"issue_module__module_id",
|
||||
(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
"module_ids": Coalesce(
|
||||
issue_module_subquery, Value([], output_field=ArrayField(UUIDField()))
|
||||
),
|
||||
}
|
||||
|
||||
default_annotations: Dict[str, Any] = {
|
||||
key: Coalesce(
|
||||
ArrayAgg(field, distinct=True, filter=condition),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
for key, (field, condition) in annotations_map.items()
|
||||
if FIELD_MAPPER.get(key) != group_by or FIELD_MAPPER.get(key) != sub_group_by
|
||||
}
|
||||
default_annotations: Dict[str, Any] = {}
|
||||
|
||||
for key, expression in annotations_map.items():
|
||||
if FIELD_MAPPER.get(key) in {group_by, sub_group_by}:
|
||||
continue
|
||||
default_annotations[key] = expression
|
||||
|
||||
return queryset.annotate(**default_annotations)
|
||||
|
||||
|
||||
@@ -476,6 +476,8 @@ def filter_subscribed_issues(params, issue_filter, method, prefix=""):
|
||||
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = params.get(
|
||||
"subscriber"
|
||||
)
|
||||
issue_filter[f"{prefix}issue_subscribers__deleted_at__isnull"] = True
|
||||
|
||||
return issue_filter
|
||||
|
||||
|
||||
|
||||
@@ -3,18 +3,20 @@ from urllib.parse import urlparse
|
||||
|
||||
|
||||
def validate_next_path(next_path: str) -> str:
|
||||
"""Validates that next_path is a valid path and extracts only the path component."""
|
||||
"""Validates that next_path is a safe relative path for redirection."""
|
||||
# Browsers interpret backslashes as forward slashes. Remove all backslashes.
|
||||
next_path = next_path.replace("\\", "")
|
||||
parsed_url = urlparse(next_path)
|
||||
|
||||
# Ensure next_path is not an absolute URL
|
||||
# Block absolute URLs or anything with scheme/netloc
|
||||
if parsed_url.scheme or parsed_url.netloc:
|
||||
next_path = parsed_url.path # Extract only the path component
|
||||
|
||||
# Ensure it starts with a forward slash (indicating a valid relative path)
|
||||
if not next_path.startswith("/"):
|
||||
# Must start with a forward slash and not be empty
|
||||
if not next_path or not next_path.startswith("/"):
|
||||
return ""
|
||||
|
||||
# Ensure it does not contain dangerous path traversal sequences
|
||||
# Prevent path traversal
|
||||
if ".." in next_path:
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.22
|
||||
Django==4.2.24
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsup --watch --onSuccess 'node --env-file=.env dist/server.js'",
|
||||
"build": "tsc --noEmit && tsup",
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"start": "node --env-file=.env dist/server.js",
|
||||
"check:lint": "eslint . --max-warnings 10",
|
||||
"check:types": "tsc --noEmit",
|
||||
@@ -28,7 +28,7 @@
|
||||
"@plane/types": "workspace:*",
|
||||
"@tiptap/core": "^2.22.3",
|
||||
"@tiptap/html": "^2.22.3",
|
||||
"axios": "1.11.0",
|
||||
"axios": "catalog:",
|
||||
"compression": "1.8.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -36,11 +36,11 @@
|
||||
"express-ws": "^5.0.2",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "catalog:",
|
||||
"morgan": "1.10.1",
|
||||
"pino-http": "^10.3.0",
|
||||
"pino-pretty": "^11.2.2",
|
||||
"uuid": "^10.0.0",
|
||||
"uuid": "catalog:",
|
||||
"y-prosemirror": "^1.2.15",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.20"
|
||||
@@ -58,7 +58,7 @@
|
||||
"concurrently": "^9.0.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsup": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"emitDecoratorMetadata": true,
|
||||
"sourceRoot": "/"
|
||||
},
|
||||
"include": ["src/**/*.ts", "tsup.config.ts"],
|
||||
"include": ["src/**/*.ts", "tsdown.config.ts"],
|
||||
"exclude": ["./dist", "./build", "./node_modules"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/server.ts"],
|
||||
outDir: "dist",
|
||||
format: ["esm", "cjs"],
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/server.ts"],
|
||||
format: ["esm", "cjs"],
|
||||
dts: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
target: "node18",
|
||||
outDir: "dist",
|
||||
env: {
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
},
|
||||
});
|
||||
@@ -13,6 +13,11 @@ export async function generateMetadata({ params }: Props) {
|
||||
const { anchor } = params;
|
||||
const DEFAULT_TITLE = "Plane";
|
||||
const DEFAULT_DESCRIPTION = "Made with Plane, an AI-powered work management platform with publishing capabilities.";
|
||||
// Validate anchor before using in request (only allow alphanumeric, -, _)
|
||||
const ANCHOR_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
if (!ANCHOR_REGEX.test(anchor)) {
|
||||
return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION };
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/public/anchor/${anchor}/meta/`);
|
||||
const data = await response.json();
|
||||
|
||||
@@ -172,7 +172,7 @@ export const AuthRoot: FC = observer(() => {
|
||||
text: `${content} with GitHub`,
|
||||
icon: (
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? GithubDarkLogo : GithubLightLogo}
|
||||
src={resolvedTheme === "dark" ? GithubLightLogo : GithubDarkLogo}
|
||||
height={18}
|
||||
width={18}
|
||||
alt="GitHub Logo"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { PlaneLockup } from "@plane/ui";
|
||||
import { PlaneLockup } from "@plane/propel/icons";
|
||||
// components
|
||||
import { PoweredBy } from "@/components/common/powered-by";
|
||||
import { UserAvatar } from "@/components/issues/navbar/user-avatar";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { FC } from "react";
|
||||
import { WEBSITE_URL } from "@plane/constants";
|
||||
// assets
|
||||
import { PlaneLogo } from "@plane/ui";
|
||||
import { PlaneLogo } from "@plane/propel/icons";
|
||||
|
||||
type TPoweredBy = {
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -2,17 +2,16 @@ import React from "react";
|
||||
// plane imports
|
||||
import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, type TFileHandler } from "@plane/editor";
|
||||
import type { MakeOptional } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
import { cn, isCommentEmpty } from "@plane/utils";
|
||||
// helpers
|
||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
import { isCommentEmpty } from "@/helpers/string.helper";
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
// local imports
|
||||
import { EditorMentionsRoot } from "./embeds/mentions";
|
||||
import { IssueCommentToolbar } from "./toolbar";
|
||||
|
||||
type LiteTextEditorWrapperProps = MakeOptional<
|
||||
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler">,
|
||||
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
|
||||
"disabledExtensions" | "flaggedExtensions"
|
||||
> & {
|
||||
anchor: string;
|
||||
@@ -63,6 +62,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||
mentionHandler={{
|
||||
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||
}}
|
||||
extendedEditorProps={{}}
|
||||
{...rest}
|
||||
// overriding the containerClassName to add relative class passed
|
||||
containerClassName={cn(containerClassName, "relative")}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
import { EditorMentionsRoot } from "./embeds/mentions";
|
||||
|
||||
type RichTextEditorWrapperProps = MakeOptional<
|
||||
Omit<IRichTextEditorProps, "editable" | "fileHandler" | "mentionHandler">,
|
||||
Omit<IRichTextEditorProps, "editable" | "fileHandler" | "mentionHandler" | "extendedEditorProps">,
|
||||
"disabledExtensions" | "flaggedExtensions"
|
||||
> & {
|
||||
anchor: string;
|
||||
@@ -56,6 +56,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
||||
workspaceId,
|
||||
})}
|
||||
flaggedExtensions={richTextEditorExtensions.flagged}
|
||||
extendedEditorProps={{}}
|
||||
{...rest}
|
||||
containerClassName={containerClassName}
|
||||
editorClassName="min-h-[100px] py-2 overflow-hidden"
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
// plane imports
|
||||
import { TOOLBAR_ITEMS, type ToolbarMenuItem, type EditorRefApi } from "@plane/editor";
|
||||
import { Button, Tooltip } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { Button } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { PriorityIcon, type TIssuePriorities } from "@plane/ui";
|
||||
import { PriorityIcon, type TIssuePriorities } from "@plane/propel/icons";
|
||||
|
||||
type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIconSize } from "@plane/constants";
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
// hooks
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { ISSUE_PRIORITY_FILTERS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PriorityIcon } from "@plane/ui";
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
// local imports
|
||||
import { FilterHeader } from "./helpers/filter-header";
|
||||
import { FilterOption } from "./helpers/filter-option";
|
||||
|
||||
@@ -4,7 +4,8 @@ import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { EIconSize } from "@plane/constants";
|
||||
import { Loader, StateGroupIcon } from "@plane/ui";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import { Loader } from "@plane/ui";
|
||||
// hooks
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
// local imports
|
||||
|
||||
@@ -5,9 +5,9 @@ import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
// plane types
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
// plane ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
|
||||
@@ -5,9 +5,9 @@ import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
// plane types
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
// plane ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
@@ -75,7 +75,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
|
||||
onClick={handleIssuePeekOverview}
|
||||
className="w-full truncate cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<Tooltip tooltipContent={issue.name} position="top-left">
|
||||
<Tooltip tooltipContent={issue.name} position="top-start">
|
||||
<p className="truncate">{issue.name}</p>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Layers, Link, Paperclip } from "lucide-react";
|
||||
// plane imports
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IIssueDisplayProperties } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC";
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane ui
|
||||
import { ContrastIcon, Tooltip } from "@plane/ui";
|
||||
import { ContrastIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
//hooks
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarCheck2 } from "lucide-react";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Tags } from "lucide-react";
|
||||
// plane imports
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// hooks
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane ui
|
||||
import { DiceIcon, Tooltip } from "@plane/ui";
|
||||
import { DiceIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { SignalHigh } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { TIssuePriorities } from "@plane/types";
|
||||
import { PriorityIcon, Tooltip } from "@plane/ui";
|
||||
// constants
|
||||
import { cn, getIssuePriorityFilters } from "@plane/utils";
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane ui
|
||||
import { StateGroupIcon, Tooltip } from "@plane/ui";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
//hooks
|
||||
|
||||
@@ -4,6 +4,7 @@ import isNil from "lodash/isNil";
|
||||
import { ContrastIcon } from "lucide-react";
|
||||
// types
|
||||
import { EIconSize, ISSUE_PRIORITIES } from "@plane/constants";
|
||||
import { CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
|
||||
import {
|
||||
GroupByColumnTypes,
|
||||
IGroupByColumn,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
TGroupedIssues,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/ui";
|
||||
import { Avatar } from "@plane/ui";
|
||||
// components
|
||||
// constants
|
||||
// stores
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { SITES_ISSUE_LAYOUTS } from "@plane/constants";
|
||||
// plane i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// ui
|
||||
|
||||
@@ -5,7 +5,8 @@ import { observer } from "mobx-react";
|
||||
import { Link2, MoveRight } from "lucide-react";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { CenterPanelIcon, FullScreenPanelIcon, setToast, SidePanelIcon, TOAST_TYPE } from "@plane/ui";
|
||||
import { CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons";
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useParams } from "next/navigation";
|
||||
import { CalendarCheck2, Signal } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { DoubleCircleIcon, StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { DoubleCircleIcon, StateGroupIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { cn, getIssuePriorityFilters } from "@plane/utils";
|
||||
// components
|
||||
import { Icon } from "@/components/ui";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
// lib
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { ReactionSelector } from "@/components/ui";
|
||||
// helpers
|
||||
import { groupReactions, renderEmoji } from "@/helpers/emoji.helper";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { PlaneLockup } from "@plane/ui";
|
||||
import { PlaneLockup } from "@plane/propel/icons";
|
||||
|
||||
export const AuthHeader = () => (
|
||||
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
|
||||
|
||||
@@ -57,24 +57,6 @@ export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[]
|
||||
return cleanText.trim() === "";
|
||||
};
|
||||
|
||||
/**
|
||||
* @description this function returns whether a comment is empty or not by checking for the following conditions-
|
||||
* 1. If comment is undefined
|
||||
* 2. If comment is an empty string
|
||||
* 3. If comment is "<p></p>"
|
||||
* @param {string | undefined} comment
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isCommentEmpty = (comment: string | undefined): boolean => {
|
||||
// return true if comment is undefined
|
||||
if (!comment) return true;
|
||||
return (
|
||||
comment?.trim() === "" ||
|
||||
comment === "<p></p>" ||
|
||||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component", "embed-component"])
|
||||
);
|
||||
};
|
||||
|
||||
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
|
||||
|
||||
export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
+11
-13
@@ -15,8 +15,6 @@
|
||||
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "^4.16.3",
|
||||
"@blueprintjs/popover2": "^1.13.3",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@headlessui/react": "^1.7.13",
|
||||
@@ -30,18 +28,18 @@
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"axios": "1.11.0",
|
||||
"axios": "catalog:",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.0.11",
|
||||
"dotenv": "^16.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "catalog:",
|
||||
"lowlight": "^2.9.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"mobx": "^6.10.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"mobx-utils": "^6.0.8",
|
||||
"next": "14.2.32",
|
||||
"lucide-react": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
"mobx-utils": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-themes": "^0.2.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "catalog:",
|
||||
@@ -49,16 +47,16 @@
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "7.51.5",
|
||||
"react-popper": "^2.3.0",
|
||||
"sharp": "^0.33.5",
|
||||
"swr": "^2.2.2",
|
||||
"sharp": "catalog:",
|
||||
"swr": "catalog:",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"uuid": "^9.0.0"
|
||||
"uuid": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/node": "18.14.1",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/react": "catalog:",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "@plane/propel/styles/fonts";
|
||||
@import "@plane/editor/styles";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Breadcrumbs, ContrastIcon, Header } from "@plane/ui";
|
||||
import { ContrastIcon } from "@plane/propel/icons";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
// plane web components
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useParams } from "next/navigation";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
import { copyUrlToClipboard, orderJoinedProjects } from "@plane/utils";
|
||||
// components
|
||||
import { CreateProjectModal } from "@/components/project/create-project-modal";
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Home, Shapes } from "lucide-react";
|
||||
// images
|
||||
import githubBlackImage from "/public/logos/github-black.png";
|
||||
import githubWhiteImage from "/public/logos/github-white.png";
|
||||
// ui
|
||||
import { GITHUB_REDIRECTED_TRACKER_EVENT, HEADER_GITHUB_ICON } from "@plane/constants";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs, Button, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
// constants
|
||||
// hooks
|
||||
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
// local imports
|
||||
import { StarUsOnGitHubLink } from "./star-us-link";
|
||||
|
||||
export const WorkspaceDashboardHeader = observer(() => {
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { toggleWidgetSettings } = useHome();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { toggleWidgetSettings } = useHome();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -48,31 +42,7 @@ export const WorkspaceDashboardHeader = observer(() => {
|
||||
<Shapes size={16} />
|
||||
<div className="hidden text-xs font-medium sm:hidden md:block">{t("home.manage_widgets")}</div>
|
||||
</Button>
|
||||
<a
|
||||
onClick={() =>
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: HEADER_GITHUB_ICON,
|
||||
},
|
||||
event: {
|
||||
eventName: GITHUB_REDIRECTED_TRACKER_EVENT,
|
||||
state: "SUCCESS",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5"
|
||||
href="https://github.com/makeplane/plane"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? githubWhiteImage : githubBlackImage}
|
||||
height={16}
|
||||
width={16}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
<span className="hidden text-xs font-medium sm:hidden md:block">{t("home.star_us_on_github")}</span>
|
||||
</a>
|
||||
<StarUsOnGitHubLink />
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
</>
|
||||
|
||||
@@ -8,8 +8,9 @@ import { useParams } from "next/navigation";
|
||||
import { ChevronDown, PanelRight } from "lucide-react";
|
||||
import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { UserActivityIcon } from "@plane/propel/icons";
|
||||
import { IUserProfileProjectSegregation } from "@plane/types";
|
||||
import { Breadcrumbs, Header, CustomMenu, UserActivityIcon } from "@plane/ui";
|
||||
import { Breadcrumbs, Header, CustomMenu } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
|
||||
+3
-1
@@ -3,9 +3,11 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArchiveIcon, ContrastIcon, DiceIcon, LayersIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
// ui
|
||||
import { ArchiveIcon, Breadcrumbs, Tooltip, Header, ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
// hooks
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { ArchiveIcon, Breadcrumbs, LayersIcon, Header } from "@plane/ui";
|
||||
import { ArchiveIcon, LayersIcon } from "@plane/propel/icons";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
|
||||
|
||||
+3
-1
@@ -16,6 +16,8 @@ import {
|
||||
} from "@plane/constants";
|
||||
import { usePlatformOS } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ContrastIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
ICustomSearchSelectOption,
|
||||
@@ -24,7 +26,7 @@ import {
|
||||
IIssueFilterOptions,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import { Breadcrumbs, Button, ContrastIcon, BreadcrumbNavigationSearchDropdown, Header, Tooltip } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui";
|
||||
import { cn, isIssueFilterActive } from "@plane/utils";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane constants
|
||||
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import {
|
||||
DisplayFiltersSelection,
|
||||
FiltersDropdown,
|
||||
FilterSelection,
|
||||
LayoutSelection,
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs/project";
|
||||
|
||||
// FIXME: Deprecated. Remove it
|
||||
export const ProjectDraftIssueHeader: FC = observer(() => {
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.DRAFT);
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues });
|
||||
},
|
||||
[workspaceSlug, projectId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter);
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property);
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const issueCount = undefined;
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label="Draft work items"
|
||||
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
{issueCount && issueCount > 0 ? (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "work items" : "work item"} in project's draft`}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
|
||||
{issueCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<LayoutSelection
|
||||
layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title={t("common.display")} placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader } from "@/components/core/app-header";
|
||||
import { ContentWrapper } from "@/components/core/content-wrapper";
|
||||
import { ProjectDraftIssueHeader } from "./header";
|
||||
|
||||
export default function ProjectDraftIssuesLayou({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectDraftIssueHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { X, PenSquare } from "lucide-react";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { DraftIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/draft-issue-layout-root";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
const ProjectDraftIssuesPage = observer(() => {
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name ? `${project?.name} - Draft work items` : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="gap-1 flex items-center border-b border-custom-border-200 px-4 py-2.5 shadow-sm bg-custom-background-100 z-[12]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/issues/`)}
|
||||
className="flex items-center gap-1.5 rounded-full border border-custom-border-200 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<PenSquare className="h-4 w-4" />
|
||||
<span>Draft work items</span>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<DraftIssueLayoutRoot />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectDraftIssuesPage;
|
||||
+3
-1
@@ -14,6 +14,8 @@ import {
|
||||
EProjectFeatureKey,
|
||||
WORK_ITEM_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { DiceIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
ICustomSearchSelectOption,
|
||||
@@ -22,7 +24,7 @@ import {
|
||||
IIssueFilterOptions,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import { Breadcrumbs, Button, DiceIcon, Header, BreadcrumbNavigationSearchDropdown, Tooltip } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
import { cn, isIssueFilterActive } from "@plane/utils";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
|
||||
+36
-8
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -20,6 +20,7 @@ import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages
|
||||
import { useEditorConfig } from "@/hooks/editor";
|
||||
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
|
||||
// plane web services
|
||||
@@ -30,13 +31,17 @@ const workspaceService = new WorkspaceService();
|
||||
const projectPageService = new ProjectPageService();
|
||||
const projectPageVersionService = new ProjectPageVersionService();
|
||||
|
||||
const storeType = EPageStoreType.PROJECT;
|
||||
|
||||
const PageDetailsPage = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId, pageId } = useParams();
|
||||
// store hooks
|
||||
const { createPage, fetchPageDetails } = usePageStore(EPageStoreType.PROJECT);
|
||||
const { createPage, fetchPageDetails } = usePageStore(storeType);
|
||||
const page = usePage({
|
||||
pageId: pageId?.toString() ?? "",
|
||||
storeType: EPageStoreType.PROJECT,
|
||||
storeType,
|
||||
});
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
const { uploadEditorAsset } = useEditorAsset();
|
||||
@@ -88,10 +93,25 @@ const PageDetailsPage = observer(() => {
|
||||
versionId
|
||||
);
|
||||
},
|
||||
getRedirectionLink: (pageId) => `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`,
|
||||
restoreVersion: async (pageId, versionId) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
await projectPageVersionService.restoreVersion(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
pageId,
|
||||
versionId
|
||||
);
|
||||
},
|
||||
getRedirectionLink: (pageId) => {
|
||||
if (pageId) {
|
||||
return `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`;
|
||||
} else {
|
||||
return `/${workspaceSlug}/projects/${projectId}/pages`;
|
||||
}
|
||||
},
|
||||
updateDescription: updateDescription ?? (async () => {}),
|
||||
}),
|
||||
[createPage, fetchEntityCallback, id, projectId, updateDescription, workspaceSlug]
|
||||
[createPage, fetchEntityCallback, id, updateDescription, workspaceSlug, projectId]
|
||||
);
|
||||
// page root config
|
||||
const pageRootConfig: TPageRootConfig = useMemo(
|
||||
@@ -115,7 +135,7 @@ const PageDetailsPage = observer(() => {
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
}),
|
||||
}),
|
||||
[getEditorFileHandlers, id, projectId, uploadEditorAsset, workspaceId, workspaceSlug]
|
||||
[getEditorFileHandlers, id, uploadEditorAsset, projectId, workspaceId, workspaceSlug]
|
||||
);
|
||||
|
||||
const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo(
|
||||
@@ -127,6 +147,12 @@ const PageDetailsPage = observer(() => {
|
||||
[projectId, workspaceSlug]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (page?.deleted_at && page?.id) {
|
||||
router.push(pageRootHandlers.getRedirectionLink());
|
||||
}
|
||||
}, [page?.deleted_at, page?.id, router, pageRootHandlers]);
|
||||
|
||||
if ((!page || !id) && !pageDetailsError)
|
||||
return (
|
||||
<div className="size-full grid place-items-center">
|
||||
@@ -150,7 +176,7 @@ const PageDetailsPage = observer(() => {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!page) return null;
|
||||
if (!page || !workspaceSlug || !projectId) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -160,9 +186,11 @@ const PageDetailsPage = observer(() => {
|
||||
<PageRoot
|
||||
config={pageRootConfig}
|
||||
handlers={pageRootHandlers}
|
||||
storeType={storeType}
|
||||
page={page}
|
||||
webhookConnectionParams={webhookConnectionParams}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
/>
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@ import {
|
||||
WORK_ITEM_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
// types
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
EViewAccess,
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, Tooltip, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
// components
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { HEADER_GITHUB_ICON, GITHUB_REDIRECTED_TRACKER_EVENT } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// helpers
|
||||
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
|
||||
// public imports
|
||||
import githubBlackImage from "@/public/logos/github-black.png";
|
||||
import githubWhiteImage from "@/public/logos/github-white.png";
|
||||
|
||||
export const StarUsOnGitHubLink = () => {
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
const imageSrc = resolvedTheme === "dark" ? githubWhiteImage : githubBlackImage;
|
||||
|
||||
return (
|
||||
<a
|
||||
aria-label={t("home.star_us_on_github")}
|
||||
onClick={() =>
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: HEADER_GITHUB_ICON,
|
||||
},
|
||||
event: {
|
||||
eventName: GITHUB_REDIRECTED_TRACKER_EVENT,
|
||||
state: "SUCCESS",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5"
|
||||
href="https://github.com/makeplane/plane"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image src={imageSrc} height={16} width={16} alt="GitHub Logo" aria-hidden="true" />
|
||||
<span className="hidden text-xs font-medium sm:hidden md:block">{t("home.star_us_on_github")}</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { Breadcrumbs, Button, Header, RecentStickyIcon } from "@plane/ui";
|
||||
import { RecentStickyIcon } from "@plane/propel/icons";
|
||||
import { Breadcrumbs, Button, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { StickySearch } from "@/components/stickies/modal/search";
|
||||
|
||||
@@ -191,7 +191,7 @@ export const GlobalIssuesHeader = observer(() => {
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
|
||||
<Header.RightItem>
|
||||
<Header.RightItem className="items-center">
|
||||
{!isLocked ? (
|
||||
<>
|
||||
<GlobalViewLayoutSelection
|
||||
|
||||
@@ -6,8 +6,9 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PlaneLogo } from "@plane/propel/icons";
|
||||
import { IWorkspace } from "@plane/types";
|
||||
import { Button, getButtonStyling, PlaneLogo } from "@plane/ui";
|
||||
import { Button, getButtonStyling } from "@plane/ui";
|
||||
// components
|
||||
import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form";
|
||||
// hooks
|
||||
|
||||
@@ -10,9 +10,10 @@ import { CheckCircle2 } from "lucide-react";
|
||||
import { ROLE, MEMBER_TRACKER_EVENTS, MEMBER_TRACKER_ELEMENTS, GROUP_WORKSPACE_TRACKER_EVENT } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { PlaneLogo } from "@plane/propel/icons";
|
||||
import type { IWorkspaceMemberInvitation } from "@plane/types";
|
||||
// ui
|
||||
import { Button, TOAST_TYPE, setToast, PlaneLogo } from "@plane/ui";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { truncateText } from "@plane/utils";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PreloadResources } from "./layout.preload";
|
||||
// styles
|
||||
import "@/styles/command-pallette.css";
|
||||
import "@/styles/emoji.css";
|
||||
import "@/styles/react-day-picker.css";
|
||||
import "@plane/propel/styles/react-day-picker";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: {
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
import { PROFILE_ACTION_LINKS } from "@plane/constants";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { cn, getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
|
||||
|
||||
+63
-49
@@ -1,69 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// layouts
|
||||
import { Button } from "@plane/ui";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import DefaultLayout from "@/layouts/default-layout";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
// images
|
||||
import maintenanceModeDarkModeImage from "@/public/instance/maintenance-mode-dark.svg";
|
||||
import maintenanceModeLightModeImage from "@/public/instance/maintenance-mode-light.svg";
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
const linkMap = [
|
||||
{
|
||||
key: "mail_to",
|
||||
label: "Contact Support",
|
||||
value: "mailto:support@plane.so",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status Page",
|
||||
value: "https://status.plane.so/",
|
||||
},
|
||||
{
|
||||
key: "twitter_handle",
|
||||
label: "@planepowers",
|
||||
value: "https://x.com/planepowers",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CustomErrorComponent() {
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
const router = useAppRouter();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authService
|
||||
.signOut(API_BASE_URL)
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to sign out. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => router.push("/"));
|
||||
};
|
||||
// derived values
|
||||
const maintenanceModeImage = resolvedTheme === "dark" ? maintenanceModeDarkModeImage : maintenanceModeLightModeImage;
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>
|
||||
<div className="grid h-full place-items-center p-4">
|
||||
<div className="space-y-8 text-center">
|
||||
<div className="space-y-2 relative flex flex-col justify-center items-center">
|
||||
<h3 className="text-lg font-semibold">Yikes! That doesn{"'"}t look good.</h3>
|
||||
<p className="mx-auto md:w-1/2 text-sm text-custom-text-200">
|
||||
That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more
|
||||
details, please write to{" "}
|
||||
<a href="mailto:support@plane.so" className="text-custom-primary">
|
||||
support@plane.so
|
||||
</a>{" "}
|
||||
or on our{" "}
|
||||
<div className="relative container mx-auto h-full w-full max-w-xl flex flex-col gap-2 items-center justify-center gap-y-6 bg-custom-background-100 text-center px-6">
|
||||
<div className="relative w-full">
|
||||
<Image
|
||||
src={maintenanceModeImage}
|
||||
height="176"
|
||||
width="288"
|
||||
alt="ProjectSettingImg"
|
||||
className="w-full h-full object-fill object-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full relative flex flex-col gap-4 mt-4">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h1 className="text-xl font-semibold text-custom-text-100 text-left">
|
||||
🚧 Looks like something went wrong!
|
||||
</h1>
|
||||
<span className="text-base font-medium text-custom-text-200 text-left">
|
||||
We track these errors automatically and working on getting things back up and running. If the problem
|
||||
persists feel free to contact us. In the meantime, try refreshing.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start gap-6 mt-1">
|
||||
{linkMap.map((link) => (
|
||||
<div key={link.key}>
|
||||
<a
|
||||
href="https://discord.com/invite/A92xrEGCge"
|
||||
href={link.value}
|
||||
target="_blank"
|
||||
className="text-custom-primary"
|
||||
rel="noopener noreferrer"
|
||||
className="text-custom-primary-100 hover:underline text-sm"
|
||||
>
|
||||
Discord
|
||||
{link.label}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Link href="/" className={cn(getButtonStyling("primary", "md"))}>
|
||||
Go to home
|
||||
</Link>
|
||||
<Button variant="neutral-primary" size="md" onClick={handleSignOut}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start gap-6">
|
||||
<Button variant="primary" size="md" onClick={() => router.push("/")}>
|
||||
Go to home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EProjectFeatureKey } from "@plane/constants";
|
||||
import { BreadcrumbNavigationDropdown, Breadcrumbs, ISvgIcons } from "@plane/ui";
|
||||
import { ISvgIcons } from "@plane/propel/icons";
|
||||
import { BreadcrumbNavigationDropdown, Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { SwitcherLabel } from "@/components/common/switcher-label";
|
||||
import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation";
|
||||
|
||||
@@ -4,8 +4,9 @@ import { observer } from "mobx-react";
|
||||
import { Briefcase } from "lucide-react";
|
||||
// plane imports
|
||||
import { ICustomSearchSelectOption } from "@plane/types";
|
||||
import { BreadcrumbNavigationSearchDropdown, Breadcrumbs, Logo } from "@plane/ui";
|
||||
import { BreadcrumbNavigationSearchDropdown, Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { Logo } from "@/components/common/logo";
|
||||
import { SwitcherLabel } from "@/components/common/switcher-label";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@ import { observer } from "mobx-react";
|
||||
import { Check } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIconSize } from "@plane/constants";
|
||||
import { Spinner, StateGroupIcon } from "@plane/ui";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// store hooks
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
// types
|
||||
import { Briefcase, FileText, Layers, LayoutGrid } from "lucide-react";
|
||||
import { ContrastIcon, DiceIcon } from "@plane/propel/icons";
|
||||
import {
|
||||
IWorkspaceDefaultSearchResult,
|
||||
IWorkspaceIssueSearchResult,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
IWorkspaceSearchResult,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { ContrastIcon, DiceIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { generateWorkItemLink } from "@plane/utils";
|
||||
// plane web components
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EIssueServiceType, EIssuesStoreType, TIssue } from "@plane/types";
|
||||
// components
|
||||
@@ -22,7 +22,6 @@ export type TIssueLevelModalsProps = {
|
||||
export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) => {
|
||||
const { projectId, issueId } = props;
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug, cycleId, moduleId } = useParams();
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
@@ -45,7 +44,6 @@ export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) =>
|
||||
} = useCommandPalette();
|
||||
// derived values
|
||||
const issueDetails = issueId ? getIssueById(issueId) : undefined;
|
||||
const isDraftIssue = pathname?.includes("draft-issues") || false;
|
||||
const { fetchSubIssues: fetchSubWorkItems } = useIssueDetail();
|
||||
const { fetchSubIssues: fetchEpicSubWorkItems } = useIssueDetail(EIssueServiceType.EPICS);
|
||||
|
||||
@@ -81,7 +79,6 @@ export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) =>
|
||||
isOpen={isCreateIssueModalOpen}
|
||||
onClose={() => toggleCreateIssueModal(false)}
|
||||
data={getCreateIssueModalData()}
|
||||
isDraft={isDraftIssue}
|
||||
onSubmit={handleCreateIssueSubmit}
|
||||
allowedProjectIds={createWorkItemAllowedProjectIds}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FC, ReactNode, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TIssueComment } from "@plane/types";
|
||||
import { EIssueCommentAccessSpecifier, TIssueComment } from "@plane/types";
|
||||
import { Avatar, Tooltip } from "@plane/ui";
|
||||
import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils";
|
||||
// hooks
|
||||
@@ -26,7 +26,13 @@ export const CommentBlock: FC<TCommentBlock> = observer((props) => {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!comment || !userDetails) return null;
|
||||
const displayName = comment?.actor_detail?.is_bot
|
||||
? comment?.actor_detail?.first_name + ` ${t("bot")}`
|
||||
: (userDetails?.display_name ?? comment?.actor_detail?.display_name);
|
||||
|
||||
const avatarUrl = userDetails?.avatar_url ?? comment?.actor_detail?.avatar_url;
|
||||
|
||||
if (!comment) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -42,20 +48,15 @@ export const CommentBlock: FC<TCommentBlock> = observer((props) => {
|
||||
"flex-shrink-0 relative w-7 h-6 rounded-full transition-border duration-1000 flex justify-center items-center z-[3] uppercase font-medium"
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
size="base"
|
||||
name={userDetails?.display_name}
|
||||
src={getFileURL(userDetails?.avatar_url)}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<Avatar size="base" name={displayName} src={getFileURL(avatarUrl)} className="flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 truncate flex-grow">
|
||||
<div className="flex w-full gap-2">
|
||||
<div className="flex-1 flex flex-wrap items-center gap-1">
|
||||
<div className="text-xs font-medium">
|
||||
{comment?.actor_detail?.is_bot
|
||||
? comment?.actor_detail?.first_name + ` ${t("bot")}`
|
||||
: comment?.actor_detail?.display_name || userDetails.display_name}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium">
|
||||
{`${displayName}${comment.access === EIssueCommentAccessSpecifier.EXTERNAL ? " (External User)" : ""}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-custom-text-300">
|
||||
commented{" "}
|
||||
|
||||
@@ -18,15 +18,6 @@ type TDeDupeIssuePopoverRootProps = {
|
||||
};
|
||||
|
||||
export const DeDupeIssuePopoverRoot: FC<TDeDupeIssuePopoverRootProps> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
rootIssueId,
|
||||
issues,
|
||||
issueOperations,
|
||||
disabled = false,
|
||||
renderDeDupeActionModals = true,
|
||||
isIntakeIssue = false,
|
||||
} = props;
|
||||
const {} = props;
|
||||
return <></>;
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Pen, Trash } from "lucide-react";
|
||||
import { PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// components
|
||||
import { ProIcon } from "@/components/common/pro-icon";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PlaneLogo } from "@plane/ui";
|
||||
import { PlaneLogo } from "@plane/propel/icons";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
// package.json
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user