Compare commits

..
Author SHA1 Message Date
sriram veeraghanta 48c34e068b chore: code refactor 2025-09-03 23:41:10 +05:30
sriram veeraghanta fc6701fc4a fix: merge conflicts 2025-09-03 23:13:01 +05:30
Sriram Veeraghanta 88914fec7c fix: adding new logger and docorator changes 2025-08-29 01:23:36 +05:30
Sriram Veeraghanta 0692dd9ef5 chore: merge conflicts 2025-08-29 01:19:55 +05:30
sriramveeraghanta f56d39d7bb fix: lodash type package update 2025-08-26 19:52:49 +05:30
sriramveeraghanta b77da77d58 fix: type and module resolutions 2025-08-26 19:47:49 +05:30
sriramveeraghanta e2fa03d66c fix: logger instance and middleware 2025-08-26 18:56:58 +05:30
sriramveeraghanta 89a94fe056 Merge branch 'preview' of github.com:makeplane/plane into chore/refactor-decorators-pkg 2025-08-26 18:48:55 +05:30
sriramveeraghanta 78526ff852 fix: lock file 2025-08-25 15:00:46 +05:30
sriramveeraghanta a56606e070 chore: restructured without ce folder 2025-08-25 14:58:30 +05:30
sriramveeraghanta e3e5a1f1e7 fix: controllers setup 2025-08-22 21:59:54 +05:30
sriramveeraghanta 7b10918084 fix: async method handled properly 2025-08-22 19:00:19 +05:30
sriramveeraghanta 269d045185 fix: merge conflicts 2025-08-22 18:50:27 +05:30
Surya Prashanth 2f281323a3 [SILO-454] chore: refactor decorator, logger packages
- add registerControllers function abstracting both rest, ws controllers
- update logger to a simple json based logger
2025-08-21 14:58:47 +05:30
sriram veeraghanta e169a0187d fix: package dependecies 2025-08-20 01:44:54 +05:30
sriram veeraghanta e001d6c605 fix: merge conflicts 2025-08-20 01:27:38 +05:30
sriramveeraghanta cdb488f864 fix: error handling 2025-07-30 17:10:08 +05:30
sriramveeraghanta 6a54a07cee chore: cleanup 2025-07-30 15:14:39 +05:30
sriramveeraghanta 78169fec66 fix: redis and hocuspocus as singletons 2025-07-30 14:56:21 +05:30
sriramveeraghanta b2ed07a778 Merge branch 'preview' of github.com:makeplane/plane into fix-live-sync-changes 2025-07-30 13:32:28 +05:30
sriram veeraghanta 1f7ef1865c Merge branch 'preview' of github.com:makeplane/plane into fix-live-sync-changes 2025-07-26 15:29:53 +05:30
sriramveeraghanta 18b206952b chore: moving file strucutre 2025-07-24 16:07:03 +05:30
451 changed files with 2286 additions and 5912 deletions
@@ -7,8 +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 } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { DiscordIcon, GithubIcon } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
@@ -5,8 +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 { WorkspaceIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { WorkspaceIcon } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { PlaneLockup } from "@plane/propel/icons";
import { PlaneLockup } from "@plane/ui";
export const AuthHeader = () => (
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
+2 -20
View File
@@ -39,31 +39,13 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
).exists():
return view_func(instance, request, *args, **kwargs)
else:
is_user_has_allowed_role = ProjectMember.objects.filter(
if ProjectMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
project_id=kwargs["project_id"],
role__in=allowed_role_values,
is_active=True,
).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()
):
).exists():
return view_func(instance, request, *args, **kwargs)
# Return permission denied if no conditions are met
+13 -22
View File
@@ -3,7 +3,11 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
# Module import
from plane.db.models import ProjectMember, WorkspaceMember
from plane.db.models.project import ROLE
# Permission Mappings
Admin = 20
Member = 15
Guest = 5
class ProjectBasePermission(BasePermission):
@@ -22,31 +26,18 @@ class ProjectBasePermission(BasePermission):
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
role__in=[Admin, Member],
is_active=True,
).exists()
project_member_qs = ProjectMember.objects.filter(
## Only Project Admins can update project attributes
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role=Admin,
project_id=view.project_id,
is_active=True,
)
## 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()
)
).exists()
class ProjectMemberPermission(BasePermission):
@@ -64,7 +55,7 @@ class ProjectMemberPermission(BasePermission):
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
role__in=[Admin, Member],
is_active=True,
).exists()
@@ -72,7 +63,7 @@ class ProjectMemberPermission(BasePermission):
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
role__in=[Admin, Member],
project_id=view.project_id,
is_active=True,
).exists()
@@ -106,7 +97,7 @@ class ProjectEntityPermission(BasePermission):
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
role__in=[Admin, Member],
project_id=view.project_id,
is_active=True,
).exists()
+3 -20
View File
@@ -667,33 +667,16 @@ 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", "display_name"]
fields = ["id", "actor", "issue", "reaction"]
class CommentReactionSerializer(BaseSerializer):
display_name = serializers.CharField(source="actor.display_name", read_only=True)
class Meta:
model = CommentReaction
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"]
fields = "__all__"
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
class IssueVoteSerializer(BaseSerializer):
+13 -39
View File
@@ -5,12 +5,13 @@ 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 status
from rest_framework import serializers, status
from rest_framework.permissions import AllowAny
# Module imports
@@ -105,10 +106,7 @@ 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=ROLE.GUEST.value,
member=request.user, workspace__slug=slug, is_active=True, role=5
).exists():
projects = projects.filter(
project_projectmember__member=self.request.user,
@@ -116,10 +114,7 @@ class ProjectViewSet(BaseViewSet):
)
if WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.MEMBER.value,
member=request.user, workspace__slug=slug, is_active=True, role=15
).exists():
projects = projects.filter(
Q(
@@ -194,10 +189,7 @@ class ProjectViewSet(BaseViewSet):
)
if WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.GUEST.value,
member=request.user, workspace__slug=slug, is_active=True, role=5
).exists():
projects = projects.filter(
project_projectmember__member=self.request.user,
@@ -205,10 +197,7 @@ class ProjectViewSet(BaseViewSet):
)
if WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.MEMBER.value,
member=request.user, workspace__slug=slug, is_active=True, role=15
).exists():
projects = projects.filter(
Q(
@@ -261,9 +250,7 @@ class ProjectViewSet(BaseViewSet):
# Add the user as Administrator to the project
_ = ProjectMember.objects.create(
project_id=serializer.data["id"],
member=request.user,
role=ROLE.ADMIN.value,
project_id=serializer.data["id"], member=request.user, role=20
)
# Also create the issue property for the user
_ = IssueUserProperty.objects.create(
@@ -276,7 +263,7 @@ class ProjectViewSet(BaseViewSet):
ProjectMember.objects.create(
project_id=serializer.data["id"],
member_id=serializer.data["project_lead"],
role=ROLE.ADMIN.value,
role=20,
)
# Also create the issue property for the user
IssueUserProperty.objects.create(
@@ -354,23 +341,13 @@ class ProjectViewSet(BaseViewSet):
def partial_update(self, request, slug, pk=None):
# try:
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(
if not ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
project_id=pk,
role=ROLE.ADMIN.value,
role=20,
is_active=True,
).exists()
# Return error for if the user is neither workspace admin nor project admin
if not is_project_admin and not is_workspace_admin:
).exists():
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
@@ -425,16 +402,13 @@ class ProjectViewSet(BaseViewSet):
def destroy(self, request, slug, pk):
if (
WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=ROLE.ADMIN.value,
member=request.user, workspace__slug=slug, is_active=True, role=20
).exists()
or ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
project_id=pk,
role=ROLE.ADMIN.value,
role=20,
is_active=True,
).exists()
):
@@ -107,8 +107,7 @@ 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:
# Redirect to the home page
path = "/"
path = "accounts/set-password"
else:
# Get the redirection path
path = (
@@ -30,8 +30,6 @@ 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
-6
View File
@@ -18,12 +18,6 @@ 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
-205
View File
@@ -1,205 +0,0 @@
# 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)
@@ -1,213 +0,0 @@
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
@@ -1,278 +0,0 @@
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
@@ -1,475 +0,0 @@
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.
+40 -46
View File
@@ -67,9 +67,20 @@ def validate_binary_data(data):
# Combine custom components and editor-specific nodes into a single set of tags
CUSTOM_TAGS = {
# editor node/tag names
"mention-component",
"label",
"input",
"imageComponent",
"image",
"mention",
"link",
"customColor",
"emoji",
"tableHeader",
"tableCell",
"tableRow",
"codeBlock",
"code",
"horizontalRule",
"calloutComponent",
# component-style tag used by editor embeds
"image-component",
}
ALLOWED_TAGS = nh3.ALLOWED_TAGS | CUSTOM_TAGS
@@ -84,8 +95,6 @@ ATTRIBUTES = {
"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)
@@ -93,64 +102,49 @@ ATTRIBUTES = {
"data-node-type",
"data-type",
"data-checked",
"data-background-color",
"data-background",
"data-text-color",
"data-name",
# callout attributes
"data-icon-name",
"data-icon-color",
"data-background",
"data-background-color",
"data-emoji-unicode",
"data-emoji-url",
"data-logo-in-use",
"data-block-type",
"data-name",
"data-entity-id",
"data-entity-group-id",
},
"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": {
"imageComponent": {"id", "width", "height", "aspectRatio", "src", "alignment"},
"image": {"width", "height", "aspectRatio", "alignment", "src", "alt", "title"},
"mention": {"id", "entity_identifier", "entity_name"},
"link": {"href", "target"},
"customColor": {"color", "backgroundColor"},
"emoji": {"name"},
"tableHeader": {"colspan", "rowspan", "colwidth", "background", "hideContent"},
"tableCell": {
"colspan",
"rowspan",
"colwidth",
"background",
"textColor",
"textcolor",
"hideContent",
"hidecontent",
"style",
},
"tr": {"background", "textColor", "textcolor", "style"},
"pre": {"language"},
"code": {"language", "spellcheck"},
"input": {"type", "checked"},
"tableRow": {"background", "textColor"},
"codeBlock": {"language"},
"calloutComponent": {
"data-icon-color",
"data-icon-name",
"data-emoji-unicode",
"data-emoji-url",
"data-logo-in-use",
"data-background",
"data-block-type",
},
# image-component (from editor extension and seeds)
"image-component": {"src", "id", "width", "height", "aspectratio", "alignment"},
}
SAFE_PROTOCOLS = {"http", "https", "mailto", "tel"}
-2
View File
@@ -476,8 +476,6 @@ 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
+5 -7
View File
@@ -3,20 +3,18 @@ from urllib.parse import urlparse
def validate_next_path(next_path: str) -> str:
"""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("\\", "")
"""Validates that next_path is a valid path and extracts only the path component."""
parsed_url = urlparse(next_path)
# Block absolute URLs or anything with scheme/netloc
# Ensure next_path is not an absolute URL
if parsed_url.scheme or parsed_url.netloc:
next_path = parsed_url.path # Extract only the path component
# Must start with a forward slash and not be empty
if not next_path or not next_path.startswith("/"):
# Ensure it starts with a forward slash (indicating a valid relative path)
if not next_path.startswith("/"):
return ""
# Prevent path traversal
# Ensure it does not contain dangerous path traversal sequences
if ".." in next_path:
return ""
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.24
Django==4.2.22
# rest framework
djangorestframework==3.15.2
# postgres
+1 -1
View File
@@ -61,4 +61,4 @@ ENV TURBO_TELEMETRY_DISABLED=1
EXPOSE 3000
CMD ["node", "apps/live/dist/server.js"]
CMD ["node", "apps/live/dist/start.js"]
+2
View File
@@ -24,7 +24,9 @@
"@hocuspocus/extension-logger": "^2.15.0",
"@hocuspocus/extension-redis": "^2.15.0",
"@hocuspocus/server": "^2.15.0",
"@plane/decorators": "workspace:*",
"@plane/editor": "workspace:*",
"@plane/logger": "workspace:*",
"@plane/types": "workspace:*",
"@tiptap/core": "^2.22.3",
"@tiptap/html": "^2.22.3",
-14
View File
@@ -1,14 +0,0 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
pageId: string;
params: URLSearchParams;
};
export const fetchDocument = async (args: TArgs): Promise<Uint8Array | null> => {
const { documentType } = args;
throw Error(`Fetch failed: Invalid document type ${documentType} provided.`);
};
-15
View File
@@ -1,15 +0,0 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
pageId: string;
params: URLSearchParams;
updatedDescription: Uint8Array;
};
export const updateDocument = async (args: TArgs): Promise<void> => {
const { documentType } = args;
throw Error(`Update failed: Invalid document type ${documentType} provided.`);
};
-1
View File
@@ -1 +0,0 @@
export type TAdditionalDocumentTypes = never;
@@ -0,0 +1,30 @@
import type { Request } from "express";
import type { Hocuspocus } from "@hocuspocus/server";
import { logger } from "@plane/logger";
import { Controller, WebSocket } from "@plane/decorators";
@Controller("/collaboration")
export class CollaborationController {
private metrics = {
errors: 0,
};
constructor(private readonly hocusPocusServer: Hocuspocus) {}
@WebSocket("/")
handleConnection(ws: any, req: Request) {
try {
// Initialize the connection with Hocuspocus
this.hocusPocusServer.handleConnection(ws, req);
// Set up error handling for the connection
ws.on("error", (error: any) => {
logger.error("WebSocket connection error:", error);
ws.close();
});
} catch (error) {
logger.error("WebSocket connection error:", error);
ws.close();
}
}
}
@@ -0,0 +1,36 @@
import type { Request, Response } from "express";
import { Controller, Post } from "@plane/decorators";
import { logger } from "@plane/logger";
// types
import { TConvertDocumentRequestBody } from "@/types";
// utils
import { convertHTMLDocumentToAllFormats } from "@/utils";
@Controller("/convert-document")
export class ConvertDocumentController {
@Post("/")
handleConvertDocument(req: Request, res: Response) {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
try {
if (description_html === undefined || variant === undefined) {
res.status(400).send({
message: "Missing required fields",
});
return;
}
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
res.status(200).json({
description,
description_binary,
});
} catch (error) {
logger.error("Error in /convert-document endpoint:", error);
res.status(500).json({
message: `Internal server error.`,
});
}
}
}
@@ -0,0 +1,14 @@
import type { Request, Response } from "express";
import { Controller, Get } from "@plane/decorators";
@Controller("/health")
export class HealthController {
@Get("/")
async healthCheck(_req: Request, res: Response) {
res.status(200).json({
status: "OK",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || "1.0.0",
});
}
}
+5
View File
@@ -0,0 +1,5 @@
import { HealthController } from "./health.controller";
import { CollaborationController } from "./collaboration.controller";
import { ConvertDocumentController } from "./convert-document.controller";
export const CONTROLLERS = [CollaborationController, ConvertDocumentController, HealthController];
-117
View File
@@ -1,117 +0,0 @@
import { Database } from "@hocuspocus/extension-database";
import { Logger } from "@hocuspocus/extension-logger";
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
import { Extension } from "@hocuspocus/server";
import { Redis } from "ioredis";
// core helpers and utilities
import { manualLogger } from "@/core/helpers/logger.js";
// core libraries
import { fetchPageDescriptionBinary, updatePageDescription } from "@/core/lib/page.js";
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common.js";
// plane live libraries
import { fetchDocument } from "@/plane-live/lib/fetch-document.js";
import { updateDocument } from "@/plane-live/lib/update-document.js";
export const getExtensions: () => Promise<Extension[]> = async () => {
const extensions: Extension[] = [
new Logger({
onChange: false,
log: (message) => {
manualLogger.info(message);
},
}),
new Database({
fetch: async ({ context, documentName: pageId, requestParameters }) => {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
try {
let fetchedData = null;
if (documentType === "project_page") {
fetchedData = await fetchPageDescriptionBinary(params, pageId, cookie);
} else {
fetchedData = await fetchDocument({
cookie,
documentType,
pageId,
params,
});
}
resolve(fetchedData);
} catch (error) {
manualLogger.error("Error in fetching document", error);
}
});
},
store: async ({ context, state, documentName: pageId, requestParameters }) => {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async () => {
try {
if (documentType === "project_page") {
await updatePageDescription(params, pageId, state, cookie);
} else {
await updateDocument({
cookie,
documentType,
pageId,
params,
updatedDescription: state,
});
}
} catch (error) {
manualLogger.error("Error in updating document:", error);
}
});
},
}),
];
const redisUrl = getRedisUrl();
if (redisUrl) {
try {
const redisClient = new Redis(redisUrl);
await new Promise<void>((resolve, reject) => {
redisClient.on("error", (error: any) => {
if (error?.code === "ENOTFOUND" || error.message.includes("WRONGPASS") || error.message.includes("NOAUTH")) {
redisClient.disconnect();
}
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error
);
reject(error);
});
redisClient.on("ready", () => {
extensions.push(new HocusPocusRedis({ redis: redisClient }));
manualLogger.info("Redis Client connected ✅");
resolve();
});
});
} catch (error) {
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error
);
}
} else {
manualLogger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)"
);
}
return extensions;
};
@@ -1,44 +0,0 @@
// plane editor
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getAllDocumentFormatsFromRichTextEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
getBinaryDataFromRichTextEditorHTMLString,
} from "@plane/editor";
// plane types
import { TDocumentPayload } from "@plane/types";
type TArgs = {
document_html: string;
variant: "rich" | "document";
};
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
const { document_html, variant } = args;
let allFormats: TDocumentPayload;
if (variant === "rich") {
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else if (variant === "document") {
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else {
throw new Error(`Invalid variant provided: ${variant}`);
}
return allFormats;
};
@@ -1,18 +0,0 @@
import { ErrorRequestHandler } from "express";
import { manualLogger } from "@/core/helpers/logger.js";
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
// Log the error
manualLogger.error(err);
// Set the response status
res.status(err.status || 500);
// Send the response
res.json({
error: {
message: process.env.NODE_ENV === "production" ? "An unexpected error occurred" : err.message,
...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
},
});
};
-39
View File
@@ -1,39 +0,0 @@
import { pinoHttp } from "pino-http";
const transport = {
target: "pino-pretty",
options: {
colorize: true,
},
};
const hooks = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logMethod(inputArgs: any, method: any): any {
if (inputArgs.length >= 2) {
const arg1 = inputArgs.shift();
const arg2 = inputArgs.shift();
return method.apply(this, [arg2, arg1, ...inputArgs]);
}
return method.apply(this, inputArgs);
},
};
export const logger = pinoHttp({
level: "info",
transport: transport,
hooks: hooks,
serializers: {
req(req) {
return `${req.method} ${req.url}`;
},
res(res) {
return `${res.statusCode} ${res?.statusMessage || ""}`;
},
responseTime(time) {
return `${time}ms`;
},
},
});
export const manualLogger: typeof logger.logger = logger.logger;
-69
View File
@@ -1,69 +0,0 @@
import { Server } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
// editor types
import { TUserDetails } from "@plane/editor";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
// lib
import { handleAuthentication } from "@/core/lib/authentication.js";
// types
import { type HocusPocusServerContext } from "@/core/types/common.js";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
const serverName = process.env.HOSTNAME || uuidv4();
return Server.configure({
name: serverName,
onAuthenticate: async ({
requestHeaders,
context,
// user id used as token for authentication
token,
}) => {
let cookie: string | undefined = undefined;
let userId: string | undefined = undefined;
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
// the cookies are not passed in the request headers)
try {
const parsedToken = JSON.parse(token) as TUserDetails;
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
// If token parsing fails, fallback to request headers
console.error("Token parsing failed, using request headers:", error);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
cookie = requestHeaders.cookie?.toString();
}
}
if (!cookie || !userId) {
throw new Error("Credentials not provided");
}
// set cookie in context, so it can be used throughout the ws connection
(context as HocusPocusServerContext).cookie = cookie;
try {
await handleAuthentication({
cookie,
userId,
});
} catch (_error) {
throw Error("Authentication unsuccessful!");
}
},
async onStateless({ payload, document }) {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
},
extensions,
debounce: 10000,
});
};
-33
View File
@@ -1,33 +0,0 @@
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
// services
import { UserService } from "@/core/services/user.service.js";
const userService = new UserService();
type Props = {
cookie: string;
userId: string;
};
export const handleAuthentication = async (props: Props) => {
const { cookie, userId } = props;
// fetch current user info
let response;
try {
response = await userService.currentUser(cookie);
} catch (error) {
manualLogger.error("Failed to fetch current user:", error);
throw error;
}
if (response.id !== userId) {
throw Error("Authentication failed: Token doesn't match the current user.");
}
return {
user: {
id: response.id,
name: response.display_name,
},
};
};
-15
View File
@@ -1,15 +0,0 @@
export function getRedisUrl() {
const redisUrl = process.env.REDIS_URL?.trim();
const redisHost = process.env.REDIS_HOST?.trim();
const redisPort = process.env.REDIS_PORT?.trim();
if (redisUrl) {
return redisUrl;
}
if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) {
return `redis://${redisHost}:${redisPort}`;
}
return "";
}
-1
View File
@@ -1 +0,0 @@
export * from "../../ce/lib/fetch-document.js";
-1
View File
@@ -1 +0,0 @@
export * from "../../ce/lib/update-document.js";
-1
View File
@@ -1 +0,0 @@
export * from "../../ce/types/common.js";
+191
View File
@@ -0,0 +1,191 @@
import { Database } from "@hocuspocus/extension-database";
import { Logger } from "@hocuspocus/extension-logger";
import { Redis } from "@hocuspocus/extension-redis";
import { Server, Hocuspocus } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
// plane imports
import type { TUserDetails } from "@plane/editor";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
import { logger } from "@plane/logger";
// lib
import { fetchPageDescriptionBinary, updatePageDescription } from "@/lib/page";
// redis
import { redisManager } from "@/redis";
// services
import { UserService } from "@/services/user.service";
// types
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
export class HocusPocusServerManager {
private static instance: HocusPocusServerManager | null = null;
private server: Hocuspocus | null = null;
private isInitialized: boolean = false;
// server options
private serverName = process.env.HOSTNAME || uuidv4();
private constructor() {
// Private constructor to prevent direct instantiation
}
/**
* Get the singleton instance of HocusPocusServerManager
*/
public static getInstance(): HocusPocusServerManager {
if (!HocusPocusServerManager.instance) {
HocusPocusServerManager.instance = new HocusPocusServerManager();
}
return HocusPocusServerManager.instance;
}
/**
* Authenticate the user
* @param requestHeaders - The request headers
* @param context - The context
* @param token - The token
* @returns The authenticated user
*/
private onAuthenticate = async ({ requestHeaders, context, token }: any) => {
let cookie: string | undefined = undefined;
let userId: string | undefined = undefined;
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
// the cookies are not passed in the request headers)
try {
const parsedToken = JSON.parse(token) as TUserDetails;
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
// If token parsing fails, fallback to request headers
logger.error("Token parsing failed, using request headers:", error);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
cookie = requestHeaders.cookie?.toString();
}
}
if (!cookie || !userId) {
throw new Error("Credentials not provided");
}
// set cookie in context, so it can be used throughout the ws connection
(context as HocusPocusServerContext).cookie = cookie;
try {
const userService = new UserService();
const user = await userService.currentUser(cookie);
if (user.id !== userId) {
throw new Error("Authentication unsuccessful!");
}
return {
user: {
id: user.id,
name: user.display_name,
},
};
} catch (_error) {
throw Error("Authentication unsuccessful!");
}
};
private onStateless = async ({ payload, document }: any) => {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
};
private onDatabaseFetch = async ({ context, documentName: pageId, requestParameters }: any) => {
try {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined;
// fetch document
if (documentType === "project_page") {
const data = await fetchPageDescriptionBinary(params, pageId, cookie);
return data;
}
throw new Error(`Invalid document type ${documentType} provided.`);
} catch (error) {
logger.error("Error in fetching document", error);
return null;
}
};
private onDatabaseStore = async ({ context, state, documentName: pageId, requestParameters }: any) => {
const cookie = (context as HocusPocusServerContext).cookie;
try {
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined;
if (documentType === "project_page") {
await updatePageDescription(params, pageId, state, cookie);
}
} catch (error) {
logger.error("Error in updating document:", error);
}
};
/**
* Initialize and configure the HocusPocus server
*/
public async initialize(): Promise<Hocuspocus> {
if (this.isInitialized && this.server) {
return this.server;
}
const redisClient = redisManager.getClient();
if (!redisClient) {
throw new Error("Redis client not initialized");
}
this.server = Server.configure({
name: this.serverName,
onAuthenticate: this.onAuthenticate,
onStateless: this.onStateless,
extensions: [
new Logger({
onChange: false,
log: (message) => {
logger.info(message);
},
}),
new Database({
fetch: this.onDatabaseFetch,
store: this.onDatabaseStore,
}),
new Redis({
redis: redisClient,
}),
],
debounce: 10000,
});
this.isInitialized = true;
return this.server;
}
/**
* Get the configured server instance
*/
public getServer(): Hocuspocus | null {
return this.server;
}
/**
* Check if the server has been initialized
*/
public isServerInitialized(): boolean {
return this.isInitialized;
}
/**
* Reset the singleton instance (useful for testing)
*/
public static resetInstance(): void {
HocusPocusServerManager.instance = null;
}
}
@@ -1,8 +1,9 @@
// helpers
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page.js";
import { logger } from "@plane/logger";
// services
import { PageService } from "@/core/services/page.service.js";
import { manualLogger } from "../helpers/logger.js";
import { PageService } from "@/services/page.service";
// utils
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/utils";
const pageService = new PageService();
export const updatePageDescription = async (
@@ -29,7 +30,7 @@ export const updatePageDescription = async (
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
} catch (error) {
manualLogger.error("Update error:", error);
logger.error("Update error:", error);
throw error;
}
};
@@ -47,7 +48,7 @@ const fetchDescriptionHTMLAndTransform = async (
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
} catch (error) {
manualLogger.error("Error while transforming from HTML to Uint8Array", error);
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
};
@@ -74,7 +75,7 @@ export const fetchPageDescriptionBinary = async (
return binaryData;
} catch (error) {
manualLogger.error("Fetch error:", error);
logger.error("Fetch error:", error);
throw error;
}
};
+210
View File
@@ -0,0 +1,210 @@
import Redis from "ioredis";
import { logger } from "@plane/logger";
export class RedisManager {
private static instance: RedisManager;
private redisClient: Redis | null = null;
private isConnected: boolean = false;
private connectionPromise: Promise<void> | null = null;
private constructor() {}
public static getInstance(): RedisManager {
if (!RedisManager.instance) {
RedisManager.instance = new RedisManager();
}
return RedisManager.instance;
}
public async initialize(): Promise<void> {
if (this.redisClient && this.isConnected) {
logger.info("Redis client already initialized and connected");
return;
}
if (this.connectionPromise) {
logger.info("Redis connection already in progress, waiting...");
await this.connectionPromise;
return;
}
this.connectionPromise = this.connect();
await this.connectionPromise;
}
private getRedisUrl(): string {
const redisUrl = process.env.REDIS_URL?.trim();
const redisHost = process.env.REDIS_HOST?.trim();
const redisPort = process.env.REDIS_PORT?.trim();
if (redisUrl) {
return redisUrl;
}
if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) {
return `redis://${redisHost}:${redisPort}`;
}
return "";
}
private async connect(): Promise<void> {
try {
const redisUrl = this.getRedisUrl();
if (!redisUrl) {
logger.warn("No Redis URL provided, Redis functionality will be disabled");
this.isConnected = false;
return;
}
this.redisClient = new Redis(redisUrl, {
lazyConnect: true,
keepAlive: 30000,
connectTimeout: 10000,
commandTimeout: 5000,
enableOfflineQueue: false,
maxRetriesPerRequest: 3,
});
// Set up event listeners
this.redisClient.on("connect", () => {
logger.info("Redis client connected");
this.isConnected = true;
});
this.redisClient.on("ready", () => {
logger.info("Redis client ready");
this.isConnected = true;
});
this.redisClient.on("error", (error) => {
logger.error("Redis client error:", error);
this.isConnected = false;
});
this.redisClient.on("close", () => {
logger.warn("Redis client connection closed");
this.isConnected = false;
});
this.redisClient.on("reconnecting", () => {
logger.info("Redis client reconnecting...");
this.isConnected = false;
});
// Connect to Redis
await this.redisClient.connect();
// Test the connection
await this.redisClient.ping();
logger.info("Redis connection test successful");
} catch (error) {
logger.error("Failed to initialize Redis client:", error);
this.isConnected = false;
throw error;
} finally {
this.connectionPromise = null;
}
}
public getClient(): Redis | null {
if (!this.redisClient || !this.isConnected) {
logger.warn("Redis client not available or not connected");
return null;
}
return this.redisClient;
}
public isClientConnected(): boolean {
return this.isConnected && this.redisClient !== null;
}
public async disconnect(): Promise<void> {
if (this.redisClient) {
try {
await this.redisClient.quit();
logger.info("Redis client disconnected gracefully");
} catch (error) {
logger.error("Error disconnecting Redis client:", error);
// Force disconnect if quit fails
this.redisClient.disconnect();
} finally {
this.redisClient = null;
this.isConnected = false;
}
}
}
// Convenience methods for common Redis operations
public async set(key: string, value: string, ttl?: number): Promise<boolean> {
const client = this.getClient();
if (!client) return false;
try {
if (ttl) {
await client.setex(key, ttl, value);
} else {
await client.set(key, value);
}
return true;
} catch (error) {
logger.error(`Error setting Redis key ${key}:`, error);
return false;
}
}
public async get(key: string): Promise<string | null> {
const client = this.getClient();
if (!client) return null;
try {
return await client.get(key);
} catch (error) {
logger.error(`Error getting Redis key ${key}:`, error);
return null;
}
}
public async del(key: string): Promise<boolean> {
const client = this.getClient();
if (!client) return false;
try {
await client.del(key);
return true;
} catch (error) {
logger.error(`Error deleting Redis key ${key}:`, error);
return false;
}
}
public async exists(key: string): Promise<boolean> {
const client = this.getClient();
if (!client) return false;
try {
const result = await client.exists(key);
return result === 1;
} catch (error) {
logger.error(`Error checking Redis key ${key}:`, error);
return false;
}
}
public async expire(key: string, ttl: number): Promise<boolean> {
const client = this.getClient();
if (!client) return false;
try {
const result = await client.expire(key, ttl);
return result === 1;
} catch (error) {
logger.error(`Error setting expiry for Redis key ${key}:`, error);
return false;
}
}
}
// Export a default instance for convenience
export const redisManager = RedisManager.getInstance();
+39 -80
View File
@@ -3,13 +3,15 @@ import cors from "cors";
import express, { Request, Response } from "express";
import expressWs from "express-ws";
import helmet from "helmet";
// plane imports
import { registerControllers } from "@plane/decorators";
import { logger, loggerMiddleware } from "@plane/logger";
// controllers
import { CONTROLLERS } from "@/controllers";
// hocuspocus server
// helpers
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document.js";
import { logger, manualLogger } from "@/core/helpers/logger.js";
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common.js";
import { HocusPocusServerManager } from "@/hocuspocus";
// redis
import { redisManager } from "@/redis";
export class Server {
private app: any;
@@ -20,74 +22,38 @@ export class Server {
constructor() {
this.app = express();
this.router = express.Router();
expressWs(this.app);
this.app.set("port", process.env.PORT || 3000);
this.app.use(process.env.LIVE_BASE_PATH || "/live", this.router);
expressWs(this.app);
this.setupMiddleware();
this.setupHocusPocus();
this.setupRoutes();
}
public async initialize(): Promise<void> {
try {
redisManager.initialize();
logger.info("Redis setup completed");
const manager = HocusPocusServerManager.getInstance();
this.hocuspocusServer = await manager.initialize();
logger.info("HocusPocus setup completed");
} catch (error) {
logger.error("Failed to setup Redis:", error);
throw error;
}
}
private setupMiddleware() {
// Security middleware
this.app.use(helmet());
// Middleware for response compression
this.app.use(compression({ level: 6, threshold: 5 * 1000 }));
// Logging middleware
this.app.use(logger);
this.app.use(loggerMiddleware);
// Body parsing middleware
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
// cors middleware
this.app.use(cors());
this.app.use(process.env.LIVE_BASE_PATH || "/live", this.router);
}
private async setupHocusPocus() {
this.hocuspocusServer = await getHocusPocusServer().catch((err) => {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
}
private setupRoutes() {
this.router.get("/health", (_req: Request, res: Response) => {
res.status(200).json({ status: "OK" });
});
this.router.ws("/collaboration", (ws: any, req: Request) => {
try {
this.hocuspocusServer.handleConnection(ws, req);
} catch (err) {
manualLogger.error("WebSocket connection error:", err);
ws.close();
}
});
this.router.post("/convert-document", (req: Request, res: Response) => {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
try {
if (description_html === undefined || variant === undefined) {
res.status(400).send({
message: "Missing required fields",
});
return;
}
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
res.status(200).json({
description,
description_binary,
});
} catch (error) {
manualLogger.error("Error in /convert-document endpoint:", error);
res.status(500).json({
message: `Internal server error.`,
});
}
});
this.app.use((_req: Request, res: Response) => {
res.status(404).json({
message: "Not Found",
@@ -95,37 +61,30 @@ export class Server {
});
}
private setupRoutes() {
CONTROLLERS.forEach((controller) => registerControllers(this.router, controller as any)); // TODO: fix this
}
public listen() {
this.serverInstance = this.app.listen(this.app.get("port"), () => {
manualLogger.info(`Plane Live server has started at port ${this.app.get("port")}`);
logger.info(`Plane Live server has started at port ${this.app.get("port")}`);
});
}
public async destroy() {
// Close the HocusPocus server WebSocket connections
await this.hocuspocusServer.destroy();
manualLogger.info("HocusPocus server WebSocket connections closed gracefully.");
if (this.hocuspocusServer) {
await this.hocuspocusServer.destroy();
logger.info("HocusPocus server WebSocket connections closed gracefully.");
}
// Disconnect Redis
await redisManager.disconnect();
logger.info("Redis connection closed gracefully.");
// Close the Express server
this.serverInstance.close(() => {
manualLogger.info("Express server closed gracefully.");
process.exit(1);
logger.info("Express server closed gracefully.");
});
}
}
const server = new Server();
server.listen();
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", async (err: any) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
await server.destroy();
});
// Graceful shutdown on uncaught exception
process.on("uncaughtException", async (err: any) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
await server.destroy();
});
@@ -1,7 +1,7 @@
// types
import { TPage } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
import { API_BASE_URL, APIService } from "@/services/api.service";
export class PageService extends APIService {
constructor() {
@@ -1,7 +1,7 @@
// types
import type { IUser } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
import { API_BASE_URL, APIService } from "@/services/api.service";
export class UserService extends APIService {
constructor() {
+43
View File
@@ -0,0 +1,43 @@
import { logger } from "@plane/logger";
import { Server } from "./server";
let server: Server;
async function startServer() {
server = new Server();
try {
await server.initialize();
server.listen();
} catch (error) {
logger.error("Failed to start server:", error);
process.exit(1);
}
}
startServer();
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", async (err: any) => {
logger.error(`UNHANDLED REJECTION! 💥 Shutting down...`, err);
try {
if (server) {
await server.destroy();
}
} finally {
logger.info("Exiting process...");
process.exit(1);
}
});
// Graceful shutdown on uncaught exception
process.on("uncaughtException", async (err: any) => {
logger.error(`UNCAUGHT EXCEPTION! 💥 Shutting down...`, err);
try {
if (server) {
await server.destroy();
}
} finally {
logger.info("Exiting process...");
process.exit(1);
}
});
@@ -1,7 +1,4 @@
// types
import { TAdditionalDocumentTypes } from "@/plane-live/types/common.js";
export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes;
export type TDocumentTypes = "project_page";
export type HocusPocusServerContext = {
cookie: string;
@@ -3,11 +3,53 @@ import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs";
// plane editor
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getAllDocumentFormatsFromRichTextEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
getBinaryDataFromRichTextEditorHTMLString,
} from "@plane/editor";
// plane types
import { TDocumentPayload } from "@plane/types";
// plane editor
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
type TArgs = {
document_html: string;
variant: "rich" | "document";
};
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
const { document_html, variant } = args;
if (variant === "rich") {
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
return {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
}
if (variant === "document") {
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
return {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
}
throw new Error(`Invalid variant provided: ${variant}`);
};
export const getAllDocumentFormatsFromBinaryData = (
description: Uint8Array
): {
+1
View File
@@ -0,0 +1 @@
export * from "./document";
@@ -13,11 +13,6 @@ 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();
@@ -2,7 +2,7 @@
import { observer } from "mobx-react";
import Image from "next/image";
import { PlaneLockup } from "@plane/propel/icons";
import { PlaneLockup } from "@plane/ui";
// 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/propel/icons";
import { PlaneLogo } from "@plane/ui";
type TPoweredBy = {
disabled?: boolean;
@@ -1,7 +1,7 @@
"use client";
import { X } from "lucide-react";
import { PriorityIcon, type TIssuePriorities } from "@plane/propel/icons";
import { PriorityIcon, type TIssuePriorities } from "@plane/ui";
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/propel/icons";
import { StateGroupIcon } from "@plane/ui";
// 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/propel/icons";
import { PriorityIcon } from "@plane/ui";
// local imports
import { FilterHeader } from "./helpers/filter-header";
import { FilterOption } from "./helpers/filter-option";
@@ -4,8 +4,7 @@ import React, { useState } from "react";
import { observer } from "mobx-react";
// ui
import { EIconSize } from "@plane/constants";
import { StateGroupIcon } from "@plane/propel/icons";
import { Loader } from "@plane/ui";
import { Loader, StateGroupIcon } from "@plane/ui";
// hooks
import { useStates } from "@/hooks/store/use-state";
// local imports
@@ -2,8 +2,8 @@
import { observer } from "mobx-react";
// plane ui
import { ContrastIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { ContrastIcon } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
//hooks
@@ -2,8 +2,8 @@
import { observer } from "mobx-react";
// plane ui
import { DiceIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { DiceIcon } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// hooks
@@ -3,9 +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 } from "@plane/ui";
// constants
import { cn, getIssuePriorityFilters } from "@plane/utils";
@@ -2,8 +2,8 @@
import { observer } from "mobx-react";
// plane ui
import { StateGroupIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { StateGroupIcon } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
//hooks
@@ -4,7 +4,6 @@ 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,
@@ -13,7 +12,7 @@ import {
TGroupedIssues,
} from "@plane/types";
// ui
import { Avatar } from "@plane/ui";
import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/ui";
// components
// constants
// stores
@@ -5,8 +5,7 @@ import { observer } from "mobx-react";
import { Link2, MoveRight } from "lucide-react";
import { Listbox, Transition } from "@headlessui/react";
// ui
import { CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons";
import { setToast, TOAST_TYPE } from "@plane/ui";
import { CenterPanelIcon, FullScreenPanelIcon, setToast, SidePanelIcon, TOAST_TYPE } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
@@ -5,8 +5,7 @@ import { useParams } from "next/navigation";
import { CalendarCheck2, Signal } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { DoubleCircleIcon, StateGroupIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/ui";
import { DoubleCircleIcon, StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui";
import { cn, getIssuePriorityFilters } from "@plane/utils";
// components
import { Icon } from "@/components/ui";
+1 -1
View File
@@ -2,7 +2,7 @@
import React from "react";
import Link from "next/link";
import { PlaneLockup } from "@plane/propel/icons";
import { PlaneLockup } from "@plane/ui";
export const AuthHeader = () => (
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
@@ -2,8 +2,7 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// ui
import { ContrastIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
import { Breadcrumbs, ContrastIcon, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
// plane web components
@@ -8,9 +8,8 @@ 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 } from "@plane/ui";
import { Breadcrumbs, Header, CustomMenu, UserActivityIcon } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
@@ -3,11 +3,10 @@
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 { Breadcrumbs, Header } from "@plane/ui";
import { ArchiveIcon, Breadcrumbs, Header, ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
// hooks
@@ -4,8 +4,7 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// ui
import { ArchiveIcon, LayersIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
import { ArchiveIcon, Breadcrumbs, LayersIcon, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
@@ -16,7 +16,6 @@ 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,
@@ -26,7 +25,7 @@ import {
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { Breadcrumbs, Button, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui";
import { Breadcrumbs, Button, ContrastIcon, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui";
import { cn, isIssueFilterActive } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
@@ -14,7 +14,6 @@ import {
EProjectFeatureKey,
WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants";
import { DiceIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import {
EIssuesStoreType,
@@ -24,7 +23,7 @@ import {
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
import { Breadcrumbs, Button, DiceIcon, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
import { cn, isIssueFilterActive } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
@@ -3,8 +3,7 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { RecentStickyIcon } from "@plane/propel/icons";
import { Breadcrumbs, Button, Header } from "@plane/ui";
import { Breadcrumbs, Button, Header, RecentStickyIcon } 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 className="items-center">
<Header.RightItem>
{!isLocked ? (
<>
<GlobalViewLayoutSelection
+1 -2
View File
@@ -6,9 +6,8 @@ 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 } from "@plane/ui";
import { Button, getButtonStyling, PlaneLogo } from "@plane/ui";
// components
import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form";
// hooks
+1 -2
View File
@@ -10,10 +10,9 @@ 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 } from "@plane/ui";
import { Button, TOAST_TYPE, setToast, PlaneLogo } from "@plane/ui";
import { truncateText } from "@plane/utils";
// components
import { EmptyState } from "@/components/common/empty-state";
+1 -1
View File
@@ -5,7 +5,7 @@ import { PreloadResources } from "./layout.preload";
// styles
import "@/styles/command-pallette.css";
import "@/styles/emoji.css";
import "@plane/propel/styles/react-day-picker";
import "@/styles/react-day-picker.css";
export const metadata: Metadata = {
robots: {
+49 -63
View File
@@ -1,83 +1,69 @@
"use client";
import Image from "next/image";
import { useTheme } from "next-themes";
// layouts
import { Button } from "@plane/ui";
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";
// layouts
import DefaultLayout from "@/layouts/default-layout";
// images
import maintenanceModeDarkModeImage from "@/public/instance/maintenance-mode-dark.svg";
import maintenanceModeLightModeImage from "@/public/instance/maintenance-mode-light.svg";
// services
import { AuthService } from "@/services/auth.service";
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",
},
];
// services
const authService = new AuthService();
export default function CustomErrorComponent() {
// hooks
const { resolvedTheme } = useTheme();
const router = useAppRouter();
// derived values
const maintenanceModeImage = resolvedTheme === "dark" ? maintenanceModeDarkModeImage : maintenanceModeLightModeImage;
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("/"));
};
return (
<DefaultLayout>
<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">
&#x1F6A7; 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}>
<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{" "}
<a
href={link.value}
href="https://discord.com/invite/A92xrEGCge"
target="_blank"
className="text-custom-primary"
rel="noopener noreferrer"
className="text-custom-primary-100 hover:underline text-sm"
>
{link.label}
Discord
</a>
</div>
))}
</div>
<div className="flex items-center justify-start gap-6">
<Button variant="primary" size="md" onClick={() => router.push("/")}>
Go to home
</Button>
.
</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>
@@ -4,8 +4,7 @@ import { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { EProjectFeatureKey } from "@plane/constants";
import { ISvgIcons } from "@plane/propel/icons";
import { BreadcrumbNavigationDropdown, Breadcrumbs } from "@plane/ui";
import { BreadcrumbNavigationDropdown, Breadcrumbs, ISvgIcons } from "@plane/ui";
// components
import { SwitcherLabel } from "@/components/common/switcher-label";
import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation";
@@ -3,8 +3,7 @@ import { observer } from "mobx-react";
import { Check } from "lucide-react";
// plane imports
import { EIconSize } from "@plane/constants";
import { StateGroupIcon } from "@plane/propel/icons";
import { Spinner } from "@plane/ui";
import { Spinner, StateGroupIcon } from "@plane/ui";
// store hooks
import { useProjectState } from "@/hooks/store/use-project-state";
@@ -2,7 +2,6 @@
// types
import { Briefcase, FileText, Layers, LayoutGrid } from "lucide-react";
import { ContrastIcon, DiceIcon } from "@plane/propel/icons";
import {
IWorkspaceDefaultSearchResult,
IWorkspaceIssueSearchResult,
@@ -11,6 +10,7 @@ import {
IWorkspaceSearchResult,
} from "@plane/types";
// ui
import { ContrastIcon, DiceIcon } from "@plane/ui";
// helpers
import { generateWorkItemLink } from "@plane/utils";
// plane web components
@@ -2,8 +2,9 @@ import { FC, ReactNode, useRef } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { EIssueCommentAccessSpecifier, TIssueComment } from "@plane/types";
import { Avatar, Tooltip } from "@plane/ui";
import { Tooltip } from "@plane/propel/tooltip";
import { TIssueComment } from "@plane/types";
import { Avatar } from "@plane/ui";
import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
@@ -26,13 +27,7 @@ export const CommentBlock: FC<TCommentBlock> = observer((props) => {
// translation
const { t } = useTranslation();
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;
if (!comment || !userDetails) return null;
return (
<div
@@ -48,15 +43,20 @@ 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={displayName} src={getFileURL(avatarUrl)} className="flex-shrink-0" />
<Avatar
size="base"
name={userDetails?.display_name}
src={getFileURL(userDetails?.avatar_url)}
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="flex items-center gap-1">
<span className="text-xs font-medium">
{`${displayName}${comment.access === EIssueCommentAccessSpecifier.EXTERNAL ? " (External User)" : ""}`}
</span>
<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>
<div className="text-xs text-custom-text-300">
commented{" "}
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { PlaneLogo } from "@plane/propel/icons";
import { PlaneLogo } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
// package.json
@@ -1,37 +1,17 @@
export const MaintenanceMessage = () => {
const linkMap = [
{
key: "mail_to",
label: "Contact Support",
value: "mailto:support@plane.so",
},
];
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
export const MaintenanceMessage = observer(() => {
// hooks
const { t } = useTranslation();
return (
<>
<div className="flex flex-col gap-2.5">
<h1 className="text-xl font-semibold text-custom-text-100 text-left">
&#x1F6A7; Looks like Plane didn&apos;t start up correctly!
</h1>
<span className="text-base font-medium text-custom-text-200 text-left">
Some services might have failed to start. Please check your container logs to identify and resolve the issue.
If you&apos;re stuck, reach out to our support team for more help.
</span>
</div>
<div className="flex items-center justify-start gap-6 mt-1">
{linkMap.map((link) => (
<div key={link.key}>
<a
href={link.value}
target="_blank"
rel="noopener noreferrer"
className="text-custom-primary-100 hover:underline text-sm"
>
{link.label}
</a>
</div>
))}
</div>
</>
<h1 className="text-xl font-medium text-custom-text-100 text-center md:text-left">
{t(
"self_hosted_maintenance_message.plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start"
)}
<br />
{t("self_hosted_maintenance_message.choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure")}
</h1>
);
};
});
@@ -13,8 +13,8 @@ import {
Users,
} from "lucide-react";
// types
import { DiceIcon, DoubleCircleIcon, ISvgIcons } from "@plane/propel/icons";
import { IGroupByColumn, IIssueDisplayProperties, TGetColumns, TSpreadsheetColumn } from "@plane/types";
import { DiceIcon, DoubleCircleIcon, ISvgIcons } from "@plane/ui";
// components
import {
SpreadsheetAssigneeColumn,
@@ -6,7 +6,7 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export const PageNavigationPaneAssetsTabEmptyState = () => {
// asset resolved path
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/wiki/navigation-pane/assets" });
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/pages/navigation-pane/assets" });
// translation
const { t } = useTranslation();
@@ -6,7 +6,7 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export const PageNavigationPaneOutlineTabEmptyState = () => {
// asset resolved path
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/wiki/navigation-pane/outline" });
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/pages/navigation-pane/outline" });
// translation
const { t } = useTranslation();
@@ -1,7 +1,7 @@
import { FileText, Layers } from "lucide-react";
// plane imports
import { EUserPermissions, EProjectFeatureKey } from "@plane/constants";
import { ContrastIcon, DiceIcon, LayersIcon, Intake } from "@plane/propel/icons";
import { ContrastIcon, DiceIcon, Intake, LayersIcon } from "@plane/ui";
// components
import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation";
+1 -1
View File
@@ -1,5 +1,5 @@
import { CircleDot, CopyPlus, XCircle } from "lucide-react";
import { RelatedIcon } from "@plane/propel/icons";
import { RelatedIcon } from "@plane/ui";
import type { TRelationObject } from "@/components/issues/issue-detail-widgets/relations";
import type { TIssueRelationTypes } from "../../types";
+1 -69
View File
@@ -1,7 +1,4 @@
import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { EIssueLayoutTypes, IProjectView } from "@plane/types";
import { TContextMenuItem } from "@plane/ui";
import { EIssueLayoutTypes } from "@plane/types";
import { TWorkspaceLayoutProps } from "@/components/views/helper";
export type TLayoutSelectionProps = {
@@ -13,68 +10,3 @@ export type TLayoutSelectionProps = {
export const GlobalViewLayoutSelection = (props: TLayoutSelectionProps) => <></>;
export const WorkspaceAdditionalLayouts = (props: TWorkspaceLayoutProps) => <></>;
export type TMenuItemsFactoryProps = {
isOwner: boolean;
isAdmin: boolean;
setDeleteViewModal: (open: boolean) => void;
setCreateUpdateViewModal: (open: boolean) => void;
handleOpenInNewTab: () => void;
handleCopyText: () => void;
isLocked: boolean;
workspaceSlug: string;
projectId?: string;
viewId: string;
};
export const useMenuItemsFactory = (props: TMenuItemsFactoryProps) => {
const { isOwner, isAdmin, setDeleteViewModal, setCreateUpdateViewModal, handleOpenInNewTab, handleCopyText } = props;
const { t } = useTranslation();
const editMenuItem = () => ({
key: "edit",
action: () => setCreateUpdateViewModal(true),
title: t("edit"),
icon: Pencil,
shouldRender: isOwner,
});
const openInNewTabMenuItem = () => ({
key: "open-new-tab",
action: handleOpenInNewTab,
title: t("open_in_new_tab"),
icon: ExternalLink,
});
const copyLinkMenuItem = () => ({
key: "copy-link",
action: handleCopyText,
title: t("copy_link"),
icon: Link,
});
const deleteMenuItem = () => ({
key: "delete",
action: () => setDeleteViewModal(true),
title: t("delete"),
icon: Trash2,
shouldRender: isOwner || isAdmin,
});
return {
editMenuItem,
openInNewTabMenuItem,
copyLinkMenuItem,
deleteMenuItem,
};
};
export const useViewMenuItems = (props: TMenuItemsFactoryProps): TContextMenuItem[] => {
const factory = useMenuItemsFactory(props);
return [factory.editMenuItem(), factory.openInNewTabMenuItem(), factory.copyLinkMenuItem(), factory.deleteMenuItem()];
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const AdditionalHeaderItems = (view: IProjectView) => <></>;
@@ -1,5 +1,5 @@
import { BarChart2, Briefcase, Home, Inbox, Layers, PenSquare } from "lucide-react";
import { ArchiveIcon, ContrastIcon, UserActivityIcon } from "@plane/propel/icons";
import { ArchiveIcon, ContrastIcon, UserActivityIcon } from "@plane/ui";
import { cn } from "@plane/utils";
export const getSidebarNavigationItemIcon = (key: string, className: string = "") => {
@@ -1,8 +1,8 @@
import { ReactNode } from "react";
import { FileText, Layers, Timer } from "lucide-react";
// plane imports
import { ContrastIcon, DiceIcon, Intake } from "@plane/propel/icons";
import { IProject } from "@plane/types";
import { ContrastIcon, DiceIcon, Intake } from "@plane/ui";
export type TProperties = {
key: string;
+1 -1
View File
@@ -1,7 +1,7 @@
import { Briefcase, FileText, Layers, LucideIcon } from "lucide-react";
// plane imports
import { ContrastIcon, DiceIcon, FavoriteFolderIcon, ISvgIcons } from "@plane/propel/icons";
import { IFavorite } from "@plane/types";
import { ContrastIcon, DiceIcon, FavoriteFolderIcon, ISvgIcons } from "@plane/ui";
export const FAVORITE_ITEM_ICONS: Record<string, React.FC<ISvgIcons> | LucideIcon> = {
page: FileText,
+1 -1
View File
@@ -1,2 +1,2 @@
export * from "./project";
export * from "@/services/workspace.service";
export * from "./workspace.service";
+1 -1
View File
@@ -1,2 +1,2 @@
export * from "./estimate.service";
export * from "@/services/view.service";
export * from "./view.service";
@@ -0,0 +1,60 @@
import { API_BASE_URL } from "@plane/constants";
import { EViewAccess, TPublishViewSettings } from "@plane/types";
import { ViewService as CoreViewService } from "@/services/view.service";
export class ViewService extends CoreViewService {
constructor() {
super(API_BASE_URL);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async updateViewAccess(workspaceSlug: string, projectId: string, viewId: string, access: EViewAccess) {
return Promise.resolve();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async lockView(workspaceSlug: string, projectId: string, viewId: string) {
return Promise.resolve();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async unLockView(workspaceSlug: string, projectId: string, viewId: string) {
return Promise.resolve();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getPublishDetails(workspaceSlug: string, projectId: string, viewId: string): Promise<any> {
return Promise.resolve({});
}
async publishView(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
workspaceSlug: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
viewId: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
data: TPublishViewSettings
): Promise<any> {
return Promise.resolve();
}
async updatePublishedView(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
workspaceSlug: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
viewId: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
data: Partial<TPublishViewSettings>
): Promise<void> {
return Promise.resolve();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async unPublishView(workspaceSlug: string, projectId: string, viewId: string): Promise<void> {
return Promise.resolve();
}
}
+24
View File
@@ -0,0 +1,24 @@
import { API_BASE_URL } from "@plane/constants";
import { EViewAccess } from "@plane/types";
import { WorkspaceService as CoreWorkspaceService } from "@/services/workspace.service";
export class WorkspaceService extends CoreWorkspaceService {
constructor() {
super(API_BASE_URL);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async updateViewAccess(workspaceSlug: string, viewId: string, access: EViewAccess) {
return Promise.resolve();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async lockView(workspaceSlug: string, viewId: string) {
return Promise.resolve();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async unLockView(workspaceSlug: string, viewId: string) {
return Promise.resolve();
}
}
-1
View File
@@ -1 +0,0 @@
export * from "@/store/global-view.store";
-1
View File
@@ -1 +0,0 @@
export * from "@/store/project-view.store";
@@ -1,17 +1,28 @@
// plane package imports
import React from "react";
import React, { useMemo } from "react";
import { IAnalyticsResponseFields } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import TrendPiece from "./trend-piece";
export type InsightCardProps = {
data?: IAnalyticsResponseFields;
label: string;
isLoading?: boolean;
versus?: string | null;
};
const InsightCard = (props: InsightCardProps) => {
const { data, label, isLoading = false } = props;
const count = data?.count ?? 0;
const { data, label, isLoading, versus } = props;
const { count, filter_count } = data || {};
const percentage = useMemo(() => {
if (count != null && filter_count != null) {
const result = ((count - filter_count) / count) * 100;
const isFiniteAndNotNaNOrZero = Number.isFinite(result) && !Number.isNaN(result) && result !== 0;
return isFiniteAndNotNaNOrZero ? result : null;
}
return null;
}, [count, filter_count]);
return (
<div className="flex flex-col gap-3">
@@ -19,6 +30,12 @@ const InsightCard = (props: InsightCardProps) => {
{!isLoading ? (
<div className="flex flex-col gap-1">
<div className="text-2xl font-bold text-custom-text-100">{count}</div>
{/* {percentage && (
<div className="flex gap-1 text-xs text-custom-text-300">
<TrendPiece percentage={percentage} size="xs" />
{versus && <div>vs {versus}</div>}
</div>
)} */}
</div>
) : (
<Loader.Item height="50px" width="100%" />
@@ -19,7 +19,7 @@ const getInsightLabel = (
analyticsType: TAnalyticsTabsBase,
item: IInsightField,
isEpic: boolean | undefined,
t: (key: string, params?: Record<string, unknown>) => string
t: (key: string, options?: any) => string
) => {
if (analyticsType === "work-items") {
return isEpic
@@ -50,7 +50,15 @@ const TotalInsights: React.FC<{
const params = useParams();
const workspaceSlug = params.workspaceSlug.toString();
const { t } = useTranslation();
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics();
const {
selectedDuration,
selectedProjects,
selectedDurationLabel,
selectedCycle,
selectedModule,
isPeekView,
isEpic,
} = useAnalytics();
const { data: totalInsightsData, isLoading } = useSWR(
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isEpic}`,
() =>
@@ -84,6 +92,7 @@ const TotalInsights: React.FC<{
isLoading={isLoading}
data={totalInsightsData?.[item.key]}
label={getInsightLabel(analyticsType, item, isEpic, t)}
versus={selectedDurationLabel}
/>
))}
</div>
@@ -1,6 +1,6 @@
"use client";
import React from "react";
import { AccentureLogo, DolbyLogo, SonyLogo, ZerodhaLogo } from "@plane/propel/icons";
import { AccentureLogo, DolbyLogo, SonyLogo, ZerodhaLogo } from "@plane/ui";
const BRAND_LOGOS: {
id: string;
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { AUTH_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { PlaneLockup } from "@plane/propel/icons";
import { PlaneLockup } from "@plane/ui";
import { PageHead } from "@/components/core/page-title";
import { EAuthModes } from "@/helpers/authentication.helper";
import { useInstance } from "@/hooks/store/use-instance";

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