Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89fc172382 | ||
|
|
13551dd32c | ||
|
|
9d88fc999c | ||
|
|
dadd76b3ed | ||
|
|
6ce700fd5d | ||
|
|
0225d806cc | ||
|
|
fd9da3164e | ||
|
|
a4b4797a32 | ||
|
|
f68f889e2a | ||
|
|
03ec96f27c | ||
|
|
a301342903 | ||
|
|
47746a4fd4 | ||
|
|
74926ea46c | ||
|
|
a46b3ba01e | ||
|
|
b1c2987c6d | ||
|
|
25490663f6 | ||
|
|
432f161ce6 |
@@ -242,8 +242,8 @@ jobs:
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }}
|
||||
build-context: ./nginx
|
||||
dockerfile-path: ./nginx/Dockerfile
|
||||
build-context: ./apps/proxy
|
||||
dockerfile-path: ./apps/proxy/Dockerfile.ce
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
|
||||
@@ -60,6 +60,7 @@ from plane.utils.host import base_host
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
|
||||
|
||||
|
||||
class WorkspaceIssueAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
This viewset provides `retrieveByIssueId` on workspace level
|
||||
|
||||
@@ -102,4 +102,4 @@ class CycleUserPropertiesSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = CycleUserProperties
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "cycle" "user"]
|
||||
read_only_fields = ["workspace", "project", "cycle", "user"]
|
||||
|
||||
@@ -726,7 +726,6 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
|
||||
|
||||
class IssueListDetailSerializer(serializers.Serializer):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Extract expand parameter and store it as instance variable
|
||||
self.expand = kwargs.pop("expand", []) or []
|
||||
|
||||
@@ -148,8 +148,8 @@ class ProjectMemberAdminSerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
|
||||
original_role = serializers.IntegerField(source='role', read_only=True)
|
||||
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
|
||||
original_role = serializers.IntegerField(source="role", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
|
||||
@@ -110,7 +110,11 @@ class UserMeSettingsSerializer(BaseSerializer):
|
||||
workspace_member__member=obj.id,
|
||||
workspace_member__is_active=True,
|
||||
).first()
|
||||
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
|
||||
logo_asset_url = (
|
||||
workspace.logo_asset.asset_url
|
||||
if workspace.logo_asset is not None
|
||||
else ""
|
||||
)
|
||||
return {
|
||||
"last_workspace_id": profile.last_workspace_id,
|
||||
"last_workspace_slug": (
|
||||
|
||||
@@ -8,7 +8,6 @@ from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class ViewIssueListSerializer(serializers.Serializer):
|
||||
|
||||
def get_assignee_ids(self, instance):
|
||||
return [assignee.assignee_id for assignee in instance.issue_assignee.all()]
|
||||
|
||||
|
||||
@@ -160,7 +160,8 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
|
||||
)
|
||||
|
||||
return (
|
||||
base_queryset.values("project_id", "project__name").annotate(
|
||||
base_queryset.values("project_id", "project__name")
|
||||
.annotate(
|
||||
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
|
||||
completed_work_items=Count("id", filter=Q(state__group="completed")),
|
||||
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
|
||||
@@ -173,8 +174,7 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
|
||||
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
return (
|
||||
base_queryset
|
||||
.values("project_id", "project__name")
|
||||
base_queryset.values("project_id", "project__name")
|
||||
.annotate(
|
||||
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
|
||||
completed_work_items=Count("id", filter=Q(state__group="completed")),
|
||||
|
||||
@@ -37,7 +37,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
receiver_id=self.request.user.id,
|
||||
)
|
||||
.select_related("workspace", "project," "triggered_by", "receiver")
|
||||
.select_related("workspace", "project", "triggered_by", "receiver")
|
||||
)
|
||||
|
||||
@allow_permission(
|
||||
|
||||
@@ -50,7 +50,7 @@ class Command(BaseCommand):
|
||||
project_count = int(input("Number of projects to be created: "))
|
||||
|
||||
for i in range(project_count):
|
||||
print(f"Please provide the following details for project {i+1}:")
|
||||
print(f"Please provide the following details for project {i + 1}:")
|
||||
issue_count = int(input("Number of issues to be created: "))
|
||||
cycle_count = int(input("Number of cycles to be created: "))
|
||||
module_count = int(input("Number of modules to be created: "))
|
||||
|
||||
@@ -134,7 +134,7 @@ def workspace(create_user):
|
||||
)
|
||||
|
||||
WorkspaceMember.objects.create(
|
||||
workspace=created_workspace, member=create_user, role=20
|
||||
)
|
||||
|
||||
workspace=created_workspace, member=create_user, role=20
|
||||
)
|
||||
|
||||
return created_workspace
|
||||
|
||||
@@ -21,7 +21,7 @@ def mock_redis():
|
||||
mock_redis_client.ttl.return_value = -1
|
||||
|
||||
# Start the patch
|
||||
with patch('plane.settings.redis.redis_instance', return_value=mock_redis_client):
|
||||
with patch("plane.settings.redis.redis_instance", return_value=mock_redis_client):
|
||||
yield mock_redis_client
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ def mock_elasticsearch():
|
||||
mock_es_client.delete.return_value = {"_id": "test_id", "result": "deleted"}
|
||||
|
||||
# Start the patch
|
||||
with patch('elasticsearch.Elasticsearch', return_value=mock_es_client):
|
||||
with patch("elasticsearch.Elasticsearch", return_value=mock_es_client):
|
||||
yield mock_es_client
|
||||
|
||||
|
||||
@@ -68,39 +68,30 @@ def mock_mongodb():
|
||||
# Configure common MongoDB collection operations
|
||||
mock_mongo_collection.find_one.return_value = None
|
||||
mock_mongo_collection.find.return_value = MagicMock(
|
||||
__iter__=lambda x: iter([]),
|
||||
count=lambda: 0
|
||||
__iter__=lambda x: iter([]), count=lambda: 0
|
||||
)
|
||||
mock_mongo_collection.insert_one.return_value = MagicMock(
|
||||
inserted_id="mock_id_123",
|
||||
acknowledged=True
|
||||
inserted_id="mock_id_123", acknowledged=True
|
||||
)
|
||||
mock_mongo_collection.insert_many.return_value = MagicMock(
|
||||
inserted_ids=["mock_id_123", "mock_id_456"],
|
||||
acknowledged=True
|
||||
inserted_ids=["mock_id_123", "mock_id_456"], acknowledged=True
|
||||
)
|
||||
mock_mongo_collection.update_one.return_value = MagicMock(
|
||||
modified_count=1,
|
||||
matched_count=1,
|
||||
acknowledged=True
|
||||
modified_count=1, matched_count=1, acknowledged=True
|
||||
)
|
||||
mock_mongo_collection.update_many.return_value = MagicMock(
|
||||
modified_count=2,
|
||||
matched_count=2,
|
||||
acknowledged=True
|
||||
modified_count=2, matched_count=2, acknowledged=True
|
||||
)
|
||||
mock_mongo_collection.delete_one.return_value = MagicMock(
|
||||
deleted_count=1,
|
||||
acknowledged=True
|
||||
deleted_count=1, acknowledged=True
|
||||
)
|
||||
mock_mongo_collection.delete_many.return_value = MagicMock(
|
||||
deleted_count=2,
|
||||
acknowledged=True
|
||||
deleted_count=2, acknowledged=True
|
||||
)
|
||||
mock_mongo_collection.count_documents.return_value = 0
|
||||
|
||||
# Start the patch
|
||||
with patch('pymongo.MongoClient', return_value=mock_mongo_client):
|
||||
with patch("pymongo.MongoClient", return_value=mock_mongo_client):
|
||||
yield mock_mongo_client
|
||||
|
||||
|
||||
@@ -112,6 +103,6 @@ def mock_celery():
|
||||
This fixture patches Celery's task.delay() to prevent actual task execution.
|
||||
"""
|
||||
# Start the patch
|
||||
with patch('celery.app.task.Task.delay') as mock_delay:
|
||||
with patch("celery.app.task.Task.delay") as mock_delay:
|
||||
mock_delay.return_value = MagicMock(id="mock-task-id")
|
||||
yield mock_delay
|
||||
yield mock_delay
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -16,7 +16,9 @@ from plane.license.models import Instance
|
||||
@pytest.fixture
|
||||
def setup_instance(db):
|
||||
"""Create and configure an instance for authentication tests"""
|
||||
instance_id = uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id
|
||||
instance_id = (
|
||||
uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id
|
||||
)
|
||||
|
||||
# Create or update instance with all required fields
|
||||
instance, _ = Instance.objects.update_or_create(
|
||||
@@ -28,7 +30,7 @@ def setup_instance(db):
|
||||
"domain": "http://localhost:8000",
|
||||
"last_checked_at": timezone.now(),
|
||||
"is_setup_done": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
return instance
|
||||
|
||||
@@ -36,7 +38,9 @@ def setup_instance(db):
|
||||
@pytest.fixture
|
||||
def django_client():
|
||||
"""Return a Django test client with User-Agent header for handling redirects"""
|
||||
client = Client(HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1")
|
||||
client = Client(
|
||||
HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1"
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
@@ -79,7 +83,9 @@ class TestMagicLinkGenerate:
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_generate(self, mock_magic_link, api_client, setup_user, setup_instance):
|
||||
def test_magic_generate(
|
||||
self, mock_magic_link, api_client, setup_user, setup_instance
|
||||
):
|
||||
"""Test successful magic link generation"""
|
||||
url = reverse("magic-generate")
|
||||
|
||||
@@ -97,7 +103,9 @@ class TestMagicLinkGenerate:
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_max_generate_attempt(self, mock_magic_link, api_client, setup_user, setup_instance):
|
||||
def test_max_generate_attempt(
|
||||
self, mock_magic_link, api_client, setup_user, setup_instance
|
||||
):
|
||||
"""Test exceeding maximum magic link generation attempts"""
|
||||
url = reverse("magic-generate")
|
||||
|
||||
@@ -163,10 +171,9 @@ class TestSignInEndpoint:
|
||||
url, {"email": "user@plane.so", "password": "user123"}, follow=True
|
||||
)
|
||||
|
||||
|
||||
# Check for the specific authentication error in the URL
|
||||
redirect_urls = [url for url, _ in response.redirect_chain]
|
||||
redirect_contents = ' '.join(redirect_urls)
|
||||
redirect_contents = " ".join(redirect_urls)
|
||||
|
||||
# The actual error code for invalid password is AUTHENTICATION_FAILED_SIGN_IN
|
||||
assert "AUTHENTICATION_FAILED_SIGN_IN" in redirect_contents
|
||||
@@ -201,14 +208,13 @@ class TestSignInEndpoint:
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "user@plane.so", "password": "user@123", "next_path": next_path},
|
||||
follow=False
|
||||
follow=False,
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect (302) without error code
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
|
||||
# In a real browser, the next_path would be used to build the absolute URL
|
||||
# Since we're just testing the authentication logic, we won't check for the exact URL structure
|
||||
# Instead, just verify that we're authenticated
|
||||
@@ -237,16 +243,16 @@ class TestMagicSignIn:
|
||||
assert "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_expired_invalid_magic_link(self, django_client, setup_user, setup_instance):
|
||||
def test_expired_invalid_magic_link(
|
||||
self, django_client, setup_user, setup_instance
|
||||
):
|
||||
"""Test magic link sign-in with expired/invalid link"""
|
||||
ri = redis_instance()
|
||||
ri.delete("magic_user@plane.so")
|
||||
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"},
|
||||
follow=False
|
||||
url, {"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False
|
||||
)
|
||||
|
||||
# Check that we get a redirect
|
||||
@@ -254,7 +260,10 @@ class TestMagicSignIn:
|
||||
|
||||
# The actual error code is EXPIRED_MAGIC_CODE_SIGN_IN (when key doesn't exist)
|
||||
# or INVALID_MAGIC_CODE_SIGN_IN (when key exists but code doesn't match)
|
||||
assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url or "INVALID_MAGIC_CODE_SIGN_IN" in response.url
|
||||
assert (
|
||||
"EXPIRED_MAGIC_CODE_SIGN_IN" in response.url
|
||||
or "INVALID_MAGIC_CODE_SIGN_IN" in response.url
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_does_not_exist(self, django_client, setup_instance):
|
||||
@@ -263,7 +272,7 @@ class TestMagicSignIn:
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "nonexistent@plane.so", "code": "xxxx-xxxxx-xxxx"},
|
||||
follow=True
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Check redirect contains error code
|
||||
@@ -271,7 +280,9 @@ class TestMagicSignIn:
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, setup_user, setup_instance):
|
||||
def test_magic_code_sign_in(
|
||||
self, mock_magic_link, django_client, api_client, setup_user, setup_instance
|
||||
):
|
||||
"""Test successful magic link sign-in process"""
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
@@ -288,9 +299,7 @@ class TestMagicSignIn:
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
url = reverse("magic-sign-in")
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "user@plane.so", "code": token},
|
||||
follow=False
|
||||
url, {"email": "user@plane.so", "code": token}, follow=False
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
@@ -302,7 +311,9 @@ class TestMagicSignIn:
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_sign_in_with_next_path(self, mock_magic_link, django_client, api_client, setup_user, setup_instance):
|
||||
def test_magic_sign_in_with_next_path(
|
||||
self, mock_magic_link, django_client, api_client, setup_user, setup_instance
|
||||
):
|
||||
"""Test magic sign-in with next_path parameter"""
|
||||
# First generate a magic link token
|
||||
gen_url = reverse("magic-generate")
|
||||
@@ -322,7 +333,7 @@ class TestMagicSignIn:
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "user@plane.so", "code": token, "next_path": next_path},
|
||||
follow=False
|
||||
follow=False,
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
@@ -357,9 +368,7 @@ class TestMagicSignUp:
|
||||
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"},
|
||||
follow=True
|
||||
url, {"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=True
|
||||
)
|
||||
|
||||
# Check redirect contains error code
|
||||
@@ -370,9 +379,7 @@ class TestMagicSignUp:
|
||||
"""Test magic link sign-up with expired/invalid link"""
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"},
|
||||
follow=False
|
||||
url, {"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False
|
||||
)
|
||||
|
||||
# Check that we get a redirect
|
||||
@@ -380,11 +387,16 @@ class TestMagicSignUp:
|
||||
|
||||
# The actual error code is EXPIRED_MAGIC_CODE_SIGN_UP (when key doesn't exist)
|
||||
# or INVALID_MAGIC_CODE_SIGN_UP (when key exists but code doesn't match)
|
||||
assert "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url or "INVALID_MAGIC_CODE_SIGN_UP" in response.url
|
||||
assert (
|
||||
"EXPIRED_MAGIC_CODE_SIGN_UP" in response.url
|
||||
or "INVALID_MAGIC_CODE_SIGN_UP" in response.url
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, setup_instance):
|
||||
def test_magic_code_sign_up(
|
||||
self, mock_magic_link, django_client, api_client, setup_instance
|
||||
):
|
||||
"""Test successful magic link sign-up process"""
|
||||
email = "newuser@plane.so"
|
||||
|
||||
@@ -403,9 +415,7 @@ class TestMagicSignUp:
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
url = reverse("magic-sign-up")
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": email, "code": token},
|
||||
follow=False
|
||||
url, {"email": email, "code": token}, follow=False
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
@@ -420,7 +430,9 @@ class TestMagicSignUp:
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
|
||||
def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_client, setup_instance):
|
||||
def test_magic_sign_up_with_next_path(
|
||||
self, mock_magic_link, django_client, api_client, setup_instance
|
||||
):
|
||||
"""Test magic sign-up with next_path parameter"""
|
||||
email = "newuser2@plane.so"
|
||||
|
||||
@@ -440,9 +452,7 @@ class TestMagicSignUp:
|
||||
url = reverse("magic-sign-up")
|
||||
next_path = "onboarding"
|
||||
response = django_client.post(
|
||||
url,
|
||||
{"email": email, "code": token, "next_path": next_path},
|
||||
follow=False
|
||||
url, {"email": email, "code": token, "next_path": next_path}, follow=False
|
||||
)
|
||||
|
||||
# Check that the initial response is a redirect without error code
|
||||
@@ -456,4 +466,4 @@ class TestMagicSignUp:
|
||||
assert User.objects.filter(email=email).exists()
|
||||
|
||||
# Check if user is authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
assert "_auth_user_id" in django_client.session
|
||||
|
||||
@@ -21,7 +21,9 @@ class TestWorkspaceAPI:
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay")
|
||||
def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, create_user):
|
||||
def test_create_workspace_valid_data(
|
||||
self, mock_workspace_seed, session_client, create_user
|
||||
):
|
||||
"""Test creating a workspace with valid data"""
|
||||
url = reverse("workspace")
|
||||
user = create_user # Use the create_user fixture directly as it returns a user object
|
||||
@@ -30,7 +32,7 @@ class TestWorkspaceAPI:
|
||||
workspace_data = {
|
||||
"name": "Plane",
|
||||
"slug": "pla-ne-test",
|
||||
"company_name": "Plane Inc."
|
||||
"company_name": "Plane Inc.",
|
||||
}
|
||||
|
||||
# Make the request
|
||||
@@ -57,15 +59,13 @@ class TestWorkspaceAPI:
|
||||
mock_workspace_seed.assert_called_once_with(response.data["id"])
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch('plane.bgtasks.workspace_seed_task.workspace_seed.delay')
|
||||
@patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay")
|
||||
def test_create_duplicate_workspace(self, mock_workspace_seed, session_client):
|
||||
"""Test creating a duplicate workspace"""
|
||||
url = reverse("workspace")
|
||||
|
||||
# Create first workspace
|
||||
session_client.post(
|
||||
url, {"name": "Plane", "slug": "pla-ne"}, format="json"
|
||||
)
|
||||
session_client.post(url, {"name": "Plane", "slug": "pla-ne"}, format="json")
|
||||
|
||||
# Try to create a workspace with the same slug
|
||||
response = session_client.post(
|
||||
@@ -76,4 +76,4 @@ class TestWorkspaceAPI:
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Optionally check the error message to confirm it's related to the duplicate slug
|
||||
assert "slug" in response.data
|
||||
assert "slug" in response.data
|
||||
|
||||
@@ -2,26 +2,21 @@ import factory
|
||||
from uuid import uuid4
|
||||
from django.utils import timezone
|
||||
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
Project,
|
||||
ProjectMember
|
||||
)
|
||||
from plane.db.models import User, Workspace, WorkspaceMember, Project, ProjectMember
|
||||
|
||||
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating User instances"""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
django_get_or_create = ('email',)
|
||||
django_get_or_create = ("email",)
|
||||
|
||||
id = factory.LazyFunction(uuid4)
|
||||
email = factory.Sequence(lambda n: f'user{n}@plane.so')
|
||||
password = factory.PostGenerationMethodCall('set_password', 'password')
|
||||
first_name = factory.Sequence(lambda n: f'First{n}')
|
||||
last_name = factory.Sequence(lambda n: f'Last{n}')
|
||||
email = factory.Sequence(lambda n: f"user{n}@plane.so")
|
||||
password = factory.PostGenerationMethodCall("set_password", "password")
|
||||
first_name = factory.Sequence(lambda n: f"First{n}")
|
||||
last_name = factory.Sequence(lambda n: f"Last{n}")
|
||||
is_active = True
|
||||
is_superuser = False
|
||||
is_staff = False
|
||||
@@ -29,13 +24,14 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class WorkspaceFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating Workspace instances"""
|
||||
|
||||
class Meta:
|
||||
model = Workspace
|
||||
django_get_or_create = ('slug',)
|
||||
django_get_or_create = ("slug",)
|
||||
|
||||
id = factory.LazyFunction(uuid4)
|
||||
name = factory.Sequence(lambda n: f'Workspace {n}')
|
||||
slug = factory.Sequence(lambda n: f'workspace-{n}')
|
||||
name = factory.Sequence(lambda n: f"Workspace {n}")
|
||||
slug = factory.Sequence(lambda n: f"workspace-{n}")
|
||||
owner = factory.SubFactory(UserFactory)
|
||||
created_at = factory.LazyFunction(timezone.now)
|
||||
updated_at = factory.LazyFunction(timezone.now)
|
||||
@@ -43,6 +39,7 @@ class WorkspaceFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class WorkspaceMemberFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating WorkspaceMember instances"""
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceMember
|
||||
|
||||
@@ -56,21 +53,23 @@ class WorkspaceMemberFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class ProjectFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating Project instances"""
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
django_get_or_create = ('name', 'workspace')
|
||||
django_get_or_create = ("name", "workspace")
|
||||
|
||||
id = factory.LazyFunction(uuid4)
|
||||
name = factory.Sequence(lambda n: f'Project {n}')
|
||||
name = factory.Sequence(lambda n: f"Project {n}")
|
||||
workspace = factory.SubFactory(WorkspaceFactory)
|
||||
created_by = factory.SelfAttribute('workspace.owner')
|
||||
updated_by = factory.SelfAttribute('workspace.owner')
|
||||
created_by = factory.SelfAttribute("workspace.owner")
|
||||
updated_by = factory.SelfAttribute("workspace.owner")
|
||||
created_at = factory.LazyFunction(timezone.now)
|
||||
updated_at = factory.LazyFunction(timezone.now)
|
||||
|
||||
|
||||
class ProjectMemberFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory for creating ProjectMember instances"""
|
||||
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
|
||||
@@ -79,4 +78,4 @@ class ProjectMemberFactory(factory.django.DjangoModelFactory):
|
||||
member = factory.SubFactory(UserFactory)
|
||||
role = 20 # Admin role by default
|
||||
created_at = factory.LazyFunction(timezone.now)
|
||||
updated_at = factory.LazyFunction(timezone.now)
|
||||
updated_at = factory.LazyFunction(timezone.now)
|
||||
|
||||
@@ -16,72 +16,79 @@ class TestAuthSmoke:
|
||||
|
||||
# 1. Test bad login - test with wrong password
|
||||
response = requests.post(
|
||||
url,
|
||||
data={
|
||||
"email": user_data["email"],
|
||||
"password": "wrong-password"
|
||||
}
|
||||
url, data={"email": user_data["email"], "password": "wrong-password"}
|
||||
)
|
||||
|
||||
# For bad credentials, any of these status codes would be valid
|
||||
# The test shouldn't be brittle to minor implementation changes
|
||||
assert response.status_code != 500, "Authentication should not cause server errors"
|
||||
assert response.status_code != 500, (
|
||||
"Authentication should not cause server errors"
|
||||
)
|
||||
assert response.status_code != 404, "Authentication endpoint should exist"
|
||||
|
||||
if response.status_code == 200:
|
||||
# If API returns 200 for failures, check the response body for error indication
|
||||
if hasattr(response, 'json'):
|
||||
if hasattr(response, "json"):
|
||||
try:
|
||||
data = response.json()
|
||||
# JSON response might indicate error in its structure
|
||||
assert "error" in data or "error_code" in data or "detail" in data or response.url.endswith("sign-in"), \
|
||||
"Error response should contain error details"
|
||||
assert (
|
||||
"error" in data
|
||||
or "error_code" in data
|
||||
or "detail" in data
|
||||
or response.url.endswith("sign-in")
|
||||
), "Error response should contain error details"
|
||||
except ValueError:
|
||||
# It's ok if response isn't JSON format
|
||||
pass
|
||||
elif response.status_code in [302, 303]:
|
||||
# If it's a redirect, it should redirect to a login page or error page
|
||||
redirect_url = response.headers.get('Location', '')
|
||||
assert "error" in redirect_url or "sign-in" in redirect_url, \
|
||||
redirect_url = response.headers.get("Location", "")
|
||||
assert "error" in redirect_url or "sign-in" in redirect_url, (
|
||||
"Failed login should redirect to login page or error page"
|
||||
)
|
||||
|
||||
# 2. Test good login with correct credentials
|
||||
response = requests.post(
|
||||
url,
|
||||
data={
|
||||
"email": user_data["email"],
|
||||
"password": user_data["password"]
|
||||
},
|
||||
allow_redirects=False # Don't follow redirects
|
||||
data={"email": user_data["email"], "password": user_data["password"]},
|
||||
allow_redirects=False, # Don't follow redirects
|
||||
)
|
||||
|
||||
# Successful auth should not be a client error or server error
|
||||
assert response.status_code not in range(400, 600), \
|
||||
assert response.status_code not in range(400, 600), (
|
||||
f"Authentication with valid credentials failed with status {response.status_code}"
|
||||
)
|
||||
|
||||
# Specific validation based on response type
|
||||
if response.status_code in [302, 303]:
|
||||
# Redirect-based auth: check that redirect URL doesn't contain error
|
||||
redirect_url = response.headers.get('Location', '')
|
||||
assert "error" not in redirect_url and "error_code" not in redirect_url, \
|
||||
redirect_url = response.headers.get("Location", "")
|
||||
assert "error" not in redirect_url and "error_code" not in redirect_url, (
|
||||
"Successful login redirect should not contain error parameters"
|
||||
)
|
||||
|
||||
elif response.status_code == 200:
|
||||
# API token-based auth: check for tokens or user session
|
||||
if hasattr(response, 'json'):
|
||||
if hasattr(response, "json"):
|
||||
try:
|
||||
data = response.json()
|
||||
# If it's a token response
|
||||
if "access_token" in data:
|
||||
assert "refresh_token" in data, "JWT auth should return both access and refresh tokens"
|
||||
assert "refresh_token" in data, (
|
||||
"JWT auth should return both access and refresh tokens"
|
||||
)
|
||||
# If it's a user session response
|
||||
elif "user" in data:
|
||||
assert "is_authenticated" in data and data["is_authenticated"], \
|
||||
"User session response should indicate authentication"
|
||||
assert (
|
||||
"is_authenticated" in data and data["is_authenticated"]
|
||||
), "User session response should indicate authentication"
|
||||
# Otherwise it should at least indicate success
|
||||
else:
|
||||
assert not any(error_key in data for error_key in ["error", "error_code", "detail"]), \
|
||||
"Success response should not contain error keys"
|
||||
assert not any(
|
||||
error_key in data
|
||||
for error_key in ["error", "error_code", "detail"]
|
||||
), "Success response should not contain error keys"
|
||||
except ValueError:
|
||||
# Non-JSON is acceptable if it's a redirect or HTML response
|
||||
pass
|
||||
@@ -97,4 +104,4 @@ class TestHealthCheckSmoke:
|
||||
response = requests.get(f"{plane_server.url}/")
|
||||
|
||||
# Should be OK
|
||||
assert response.status_code == 200, "Health check endpoint should return 200 OK"
|
||||
assert response.status_code == 200, "Health check endpoint should return 200 OK"
|
||||
|
||||
@@ -13,10 +13,7 @@ class TestWorkspaceModel:
|
||||
"""Test creating a workspace"""
|
||||
# Create a workspace
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace",
|
||||
slug="test-workspace",
|
||||
id=uuid4(),
|
||||
owner=create_user
|
||||
name="Test Workspace", slug="test-workspace", id=uuid4(), owner=create_user
|
||||
)
|
||||
|
||||
# Verify it was created
|
||||
@@ -30,21 +27,18 @@ class TestWorkspaceModel:
|
||||
"""Test creating a workspace member"""
|
||||
# Create a workspace
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace",
|
||||
slug="test-workspace",
|
||||
id=uuid4(),
|
||||
owner=create_user
|
||||
name="Test Workspace", slug="test-workspace", id=uuid4(), owner=create_user
|
||||
)
|
||||
|
||||
# Create a workspace member
|
||||
workspace_member = WorkspaceMember.objects.create(
|
||||
workspace=workspace,
|
||||
member=create_user,
|
||||
role=20 # Admin role
|
||||
role=20, # Admin role
|
||||
)
|
||||
|
||||
# Verify it was created
|
||||
assert workspace_member.id is not None
|
||||
assert workspace_member.workspace == workspace
|
||||
assert workspace_member.member == create_user
|
||||
assert workspace_member.role == 20
|
||||
assert workspace_member.role == 20
|
||||
|
||||
@@ -13,18 +13,13 @@ class TestWorkspaceLiteSerializer:
|
||||
"""Test that the serializer includes the correct fields"""
|
||||
# Create a user to be the owner
|
||||
owner = User.objects.create(
|
||||
email="test@example.com",
|
||||
first_name="Test",
|
||||
last_name="User"
|
||||
email="test@example.com", first_name="Test", last_name="User"
|
||||
)
|
||||
|
||||
# Create a workspace with explicit ID to test serialization
|
||||
workspace_id = uuid4()
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace",
|
||||
slug="test-workspace",
|
||||
id=workspace_id,
|
||||
owner=owner
|
||||
name="Test Workspace", slug="test-workspace", id=workspace_id, owner=owner
|
||||
)
|
||||
|
||||
# Serialize the workspace
|
||||
@@ -43,23 +38,17 @@ class TestWorkspaceLiteSerializer:
|
||||
"""Test that the serializer fields are read-only"""
|
||||
# Create a user to be the owner
|
||||
owner = User.objects.create(
|
||||
email="test2@example.com",
|
||||
first_name="Test",
|
||||
last_name="User"
|
||||
email="test2@example.com", first_name="Test", last_name="User"
|
||||
)
|
||||
|
||||
# Create a workspace
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace",
|
||||
slug="test-workspace",
|
||||
id=uuid4(),
|
||||
owner=owner
|
||||
name="Test Workspace", slug="test-workspace", id=uuid4(), owner=owner
|
||||
)
|
||||
|
||||
# Try to update via serializer
|
||||
serializer = WorkspaceLiteSerializer(
|
||||
workspace,
|
||||
data={"name": "Updated Name", "slug": "updated-slug"}
|
||||
workspace, data={"name": "Updated Name", "slug": "updated-slug"}
|
||||
)
|
||||
|
||||
# Serializer should be valid (since read-only fields are ignored)
|
||||
@@ -68,4 +57,4 @@ class TestWorkspaceLiteSerializer:
|
||||
# Save should not update the read-only fields
|
||||
updated_workspace = serializer.save()
|
||||
assert updated_workspace.name == "Test Workspace"
|
||||
assert updated_workspace.slug == "test-workspace"
|
||||
assert updated_workspace.slug == "test-workspace"
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import pytest
|
||||
from plane.utils.url import (
|
||||
contains_url,
|
||||
is_valid_url,
|
||||
get_url_components,
|
||||
normalize_url_path,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestContainsURL:
|
||||
"""Test the contains_url function"""
|
||||
|
||||
def test_contains_url_with_http_protocol(self):
|
||||
"""Test contains_url with HTTP protocol URLs"""
|
||||
assert contains_url("Check out http://example.com") is True
|
||||
assert contains_url("Visit http://google.com/search") is True
|
||||
assert contains_url("http://localhost:8000") is True
|
||||
|
||||
def test_contains_url_with_https_protocol(self):
|
||||
"""Test contains_url with HTTPS protocol URLs"""
|
||||
assert contains_url("Check out https://example.com") is True
|
||||
assert contains_url("Visit https://google.com/search") is True
|
||||
assert contains_url("https://secure.example.com") is True
|
||||
|
||||
def test_contains_url_with_www_prefix(self):
|
||||
"""Test contains_url with www prefix"""
|
||||
assert contains_url("Visit www.example.com") is True
|
||||
assert contains_url("Check www.google.com") is True
|
||||
assert contains_url("Go to www.test-site.org") is True
|
||||
|
||||
def test_contains_url_with_domain_patterns(self):
|
||||
"""Test contains_url with domain patterns"""
|
||||
assert contains_url("Visit example.com") is True
|
||||
assert contains_url("Check google.org") is True
|
||||
assert contains_url("Go to test-site.co.uk") is True
|
||||
assert contains_url("Visit sub.domain.com") is True
|
||||
|
||||
def test_contains_url_with_ip_addresses(self):
|
||||
"""Test contains_url with IP addresses"""
|
||||
assert contains_url("Connect to 192.168.1.1") is True
|
||||
assert contains_url("Visit 10.0.0.1") is True
|
||||
assert contains_url("Check 127.0.0.1") is True
|
||||
assert contains_url("Go to 8.8.8.8") is True
|
||||
|
||||
def test_contains_url_case_insensitive(self):
|
||||
"""Test contains_url is case insensitive"""
|
||||
assert contains_url("Check HTTP://EXAMPLE.COM") is True
|
||||
assert contains_url("Visit WWW.GOOGLE.COM") is True
|
||||
assert contains_url("Go to Https://Test.Com") is True
|
||||
|
||||
def test_contains_url_with_no_urls(self):
|
||||
"""Test contains_url with text that doesn't contain URLs"""
|
||||
assert contains_url("This is just plain text") is False
|
||||
assert contains_url("No URLs here!") is False
|
||||
assert contains_url("com org net") is False # Just TLD words
|
||||
assert contains_url("192.168") is False # Incomplete IP
|
||||
assert contains_url("") is False # Empty string
|
||||
|
||||
def test_contains_url_edge_cases(self):
|
||||
"""Test contains_url with edge cases"""
|
||||
assert contains_url("example.c") is False # TLD too short
|
||||
assert contains_url("999.999.999.999") is False # Invalid IP (octets > 255)
|
||||
assert contains_url("just-a-hyphen") is False # No domain
|
||||
assert (
|
||||
contains_url("www.") is False
|
||||
) # Incomplete www - needs at least one char after dot
|
||||
|
||||
def test_contains_url_length_limit_under_1000(self):
|
||||
"""Test contains_url with input under 1000 characters containing URLs"""
|
||||
# Create a string under 1000 characters with a URL
|
||||
text_with_url = "a" * 970 + " https://example.com" # 970 + 1 + 19 = 990 chars
|
||||
assert len(text_with_url) < 1000
|
||||
assert contains_url(text_with_url) is True
|
||||
|
||||
# Test with exactly 1000 characters
|
||||
text_exact_1000 = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars
|
||||
assert len(text_exact_1000) == 1000
|
||||
assert contains_url(text_exact_1000) is True
|
||||
|
||||
def test_contains_url_length_limit_over_1000(self):
|
||||
"""Test contains_url with input over 1000 characters returns False"""
|
||||
# Create a string over 1000 characters with a URL
|
||||
text_with_url = "a" * 982 + "https://example.com" # 982 + 19 = 1001 chars
|
||||
assert len(text_with_url) > 1000
|
||||
assert contains_url(text_with_url) is False
|
||||
|
||||
# Test with much longer input
|
||||
long_text_with_url = "a" * 5000 + " https://example.com"
|
||||
assert contains_url(long_text_with_url) is False
|
||||
|
||||
def test_contains_url_length_limit_exactly_1000(self):
|
||||
"""Test contains_url with input exactly 1000 characters"""
|
||||
# Test with exactly 1000 characters without URL
|
||||
text_no_url = "a" * 1000
|
||||
assert len(text_no_url) == 1000
|
||||
assert contains_url(text_no_url) is False
|
||||
|
||||
# Test with exactly 1000 characters with URL at the end
|
||||
text_with_url = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars
|
||||
assert len(text_with_url) == 1000
|
||||
assert contains_url(text_with_url) is True
|
||||
|
||||
def test_contains_url_line_length_scenarios(self):
|
||||
"""Test contains_url with realistic line length scenarios"""
|
||||
# Test with multiline input where total is under 1000 but we test line processing
|
||||
# Short lines with URL
|
||||
multiline_short = "Line 1\nLine 2 with https://example.com\nLine 3"
|
||||
assert contains_url(multiline_short) is True
|
||||
|
||||
# Multiple lines under total limit
|
||||
multiline_text = (
|
||||
"a" * 200 + "\n" + "b" * 200 + "https://example.com\n" + "c" * 200
|
||||
)
|
||||
assert len(multiline_text) < 1000
|
||||
assert contains_url(multiline_text) is True
|
||||
|
||||
def test_contains_url_total_length_vs_line_length(self):
|
||||
"""Test the interaction between total length limit and line processing"""
|
||||
# Test that total length limit takes precedence
|
||||
# Even if individual lines would be processed, total > 1000 means immediate False
|
||||
over_limit_text = "a" * 1001 # No URL, but over total limit
|
||||
assert contains_url(over_limit_text) is False
|
||||
|
||||
# Test that under total limit, line processing works normally
|
||||
under_limit_with_url = "a" * 900 + "https://example.com" # 919 chars total
|
||||
assert len(under_limit_with_url) < 1000
|
||||
assert contains_url(under_limit_with_url) is True
|
||||
|
||||
def test_contains_url_multiline_mixed_lengths(self):
|
||||
"""Test contains_url with multiple lines of different lengths"""
|
||||
# Test realistic multiline scenario under 1000 chars total
|
||||
multiline_text = (
|
||||
"Short line\n"
|
||||
+ "a" * 400
|
||||
+ "https://example.com\n" # Line with URL
|
||||
+ "b" * 300 # Another line
|
||||
)
|
||||
assert len(multiline_text) < 1000
|
||||
assert contains_url(multiline_text) is True
|
||||
|
||||
# Test multiline without URLs
|
||||
multiline_no_url = "Short line\n" + "a" * 400 + "\n" + "b" * 300
|
||||
assert len(multiline_no_url) < 1000
|
||||
assert contains_url(multiline_no_url) is False
|
||||
|
||||
def test_contains_url_edge_cases_with_length_limits(self):
|
||||
"""Test contains_url edge cases related to length limits"""
|
||||
# Empty string
|
||||
assert contains_url("") is False
|
||||
|
||||
# Very short string with URL
|
||||
assert contains_url("http://a.co") is True
|
||||
|
||||
# String with newlines and mixed content
|
||||
mixed_content = "Line 1\nLine 2 with https://example.com\nLine 3"
|
||||
assert contains_url(mixed_content) is True
|
||||
|
||||
# String with many newlines under total limit
|
||||
many_newlines = "\n" * 500 + "https://example.com"
|
||||
assert len(many_newlines) < 1000
|
||||
assert contains_url(many_newlines) is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIsValidURL:
|
||||
"""Test the is_valid_url function"""
|
||||
|
||||
def test_is_valid_url_with_valid_urls(self):
|
||||
"""Test is_valid_url with valid URLs"""
|
||||
assert is_valid_url("https://example.com") is True
|
||||
assert is_valid_url("http://google.com") is True
|
||||
assert is_valid_url("https://sub.domain.com/path") is True
|
||||
assert is_valid_url("http://localhost:8000") is True
|
||||
assert is_valid_url("https://example.com/path?query=1") is True
|
||||
assert is_valid_url("ftp://files.example.com") is True
|
||||
|
||||
def test_is_valid_url_with_invalid_urls(self):
|
||||
"""Test is_valid_url with invalid URLs"""
|
||||
assert is_valid_url("not a url") is False
|
||||
assert is_valid_url("example.com") is False # No scheme
|
||||
assert is_valid_url("https://") is False # No netloc
|
||||
assert is_valid_url("") is False # Empty string
|
||||
assert is_valid_url("://example.com") is False # No scheme
|
||||
assert is_valid_url("https:/example.com") is False # Malformed
|
||||
|
||||
def test_is_valid_url_with_non_string_input(self):
|
||||
"""Test is_valid_url with non-string input"""
|
||||
assert is_valid_url(None) is False
|
||||
assert is_valid_url([]) is False
|
||||
assert is_valid_url({}) is False
|
||||
|
||||
def test_is_valid_url_with_special_schemes(self):
|
||||
"""Test is_valid_url with special URL schemes"""
|
||||
assert is_valid_url("ftp://ftp.example.com") is True
|
||||
assert is_valid_url("mailto:user@example.com") is False
|
||||
assert is_valid_url("file:///path/to/file") is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNormalizeURLPath:
|
||||
"""Test the normalize_url_path function"""
|
||||
|
||||
def test_normalize_url_path_with_multiple_slashes(self):
|
||||
"""Test normalize_url_path with multiple consecutive slashes"""
|
||||
result = normalize_url_path("https://example.com//foo///bar//baz")
|
||||
assert result == "https://example.com/foo/bar/baz"
|
||||
|
||||
def test_normalize_url_path_with_query_and_fragment(self):
|
||||
"""Test normalize_url_path preserves query and fragment"""
|
||||
result = normalize_url_path(
|
||||
"https://example.com//foo///bar//baz?x=1&y=2#fragment"
|
||||
)
|
||||
assert result == "https://example.com/foo/bar/baz?x=1&y=2#fragment"
|
||||
|
||||
def test_normalize_url_path_with_no_redundant_slashes(self):
|
||||
"""Test normalize_url_path with already normalized URL"""
|
||||
url = "https://example.com/foo/bar/baz?x=1#fragment"
|
||||
result = normalize_url_path(url)
|
||||
assert result == url
|
||||
|
||||
def test_normalize_url_path_with_root_path(self):
|
||||
"""Test normalize_url_path with root path"""
|
||||
result = normalize_url_path("https://example.com//")
|
||||
assert result == "https://example.com/"
|
||||
|
||||
def test_normalize_url_path_with_empty_path(self):
|
||||
"""Test normalize_url_path with empty path"""
|
||||
result = normalize_url_path("https://example.com")
|
||||
assert result == "https://example.com"
|
||||
|
||||
def test_normalize_url_path_with_complex_path(self):
|
||||
"""Test normalize_url_path with complex path structure"""
|
||||
result = normalize_url_path(
|
||||
"https://example.com///api//v1///users//123//profile"
|
||||
)
|
||||
assert result == "https://example.com/api/v1/users/123/profile"
|
||||
|
||||
def test_normalize_url_path_with_different_schemes(self):
|
||||
"""Test normalize_url_path with different URL schemes"""
|
||||
# HTTP
|
||||
result = normalize_url_path("http://example.com//path")
|
||||
assert result == "http://example.com/path"
|
||||
|
||||
# FTP
|
||||
result = normalize_url_path("ftp://ftp.example.com//files//document.txt")
|
||||
assert result == "ftp://ftp.example.com/files/document.txt"
|
||||
|
||||
def test_normalize_url_path_with_port(self):
|
||||
"""Test normalize_url_path with port number"""
|
||||
result = normalize_url_path("https://example.com:8080//api//v1")
|
||||
assert result == "https://example.com:8080/api/v1"
|
||||
|
||||
def test_normalize_url_path_edge_cases(self):
|
||||
"""Test normalize_url_path with edge cases"""
|
||||
# Many consecutive slashes
|
||||
result = normalize_url_path("https://example.com///////path")
|
||||
assert result == "https://example.com/path"
|
||||
|
||||
# Mixed single and multiple slashes
|
||||
result = normalize_url_path("https://example.com/a//b/c///d")
|
||||
assert result == "https://example.com/a/b/c/d"
|
||||
@@ -19,7 +19,9 @@ class TestUUIDUtils:
|
||||
assert is_valid_uuid("not-a-uuid") is False
|
||||
assert is_valid_uuid("123456789") is False
|
||||
assert is_valid_uuid("") is False
|
||||
assert is_valid_uuid("00000000-0000-0000-0000-000000000000") is False # This is a valid UUID but version 1
|
||||
assert (
|
||||
is_valid_uuid("00000000-0000-0000-0000-000000000000") is False
|
||||
) # This is a valid UUID but version 1
|
||||
|
||||
def test_convert_uuid_to_integer(self):
|
||||
"""Test convert_uuid_to_integer function"""
|
||||
@@ -46,4 +48,6 @@ class TestUUIDUtils:
|
||||
test_uuid = uuid.UUID(test_uuid_str)
|
||||
|
||||
# Should get the same result whether passing UUID or string
|
||||
assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer(test_uuid_str)
|
||||
assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer(
|
||||
test_uuid_str
|
||||
)
|
||||
|
||||
@@ -50,11 +50,11 @@ def paginate(base_queryset, queryset, cursor, on_result):
|
||||
paginated_data = queryset[start_index:end_index]
|
||||
|
||||
# Create the pagination info object
|
||||
prev_cursor = f"{page_size}:{cursor_object.current_page-1}:0"
|
||||
prev_cursor = f"{page_size}:{cursor_object.current_page - 1}:0"
|
||||
cursor = f"{page_size}:{cursor_object.current_page}:0"
|
||||
next_cursor = None
|
||||
if end_index < total_results:
|
||||
next_cursor = f"{page_size}:{cursor_object.current_page+1}:0"
|
||||
next_cursor = f"{page_size}:{cursor_object.current_page + 1}:0"
|
||||
|
||||
prev_page_results = False
|
||||
if cursor_object.current_page > 0:
|
||||
|
||||
@@ -35,7 +35,7 @@ class Cursor:
|
||||
|
||||
# Return the representation of the cursor
|
||||
def __repr__(self):
|
||||
return f"{type(self).__name__,}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}"
|
||||
return f"{(type(self).__name__,)}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}" # noqa: E501
|
||||
|
||||
# Return if the cursor is true
|
||||
def __bool__(self):
|
||||
|
||||
@@ -3,13 +3,50 @@ import re
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
# Compiled regex pattern for better performance and ReDoS protection
|
||||
# Using atomic groups and length limits to prevent excessive backtracking
|
||||
URL_PATTERN = re.compile(
|
||||
r"(?i)" # Case insensitive
|
||||
r"(?:" # Non-capturing group for alternatives
|
||||
r"https?://[^\s]+" # http:// or https:// followed by non-whitespace
|
||||
r"|"
|
||||
r"www\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*" # www.domain with proper length limits
|
||||
r"|"
|
||||
r"(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}" # domain.tld with length limits
|
||||
r"|"
|
||||
r"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)" # IP address with proper validation
|
||||
r")"
|
||||
)
|
||||
|
||||
|
||||
def contains_url(value: str) -> bool:
|
||||
"""
|
||||
Check if the value contains a URL.
|
||||
|
||||
This function is protected against ReDoS attacks by:
|
||||
1. Using a pre-compiled regex pattern
|
||||
2. Limiting input length to prevent excessive processing
|
||||
3. Using atomic groups and specific quantifiers to avoid backtracking
|
||||
|
||||
Args:
|
||||
value (str): The input string to check for URLs
|
||||
|
||||
Returns:
|
||||
bool: True if the string contains a URL, False otherwise
|
||||
"""
|
||||
url_pattern = re.compile(r"https?://|www\\.")
|
||||
return bool(url_pattern.search(value))
|
||||
# Prevent ReDoS by limiting input length
|
||||
if len(value) > 1000: # Reasonable limit for URL detection
|
||||
return False
|
||||
|
||||
# Additional safety: truncate very long lines that might contain URLs
|
||||
lines = value.split("\n")
|
||||
for line in lines:
|
||||
if len(line) > 500: # Process only reasonable length lines
|
||||
line = line[:500]
|
||||
if URL_PATTERN.search(line):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_valid_url(url: str) -> bool:
|
||||
|
||||
+10
-26
@@ -6,36 +6,20 @@ import sys
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run Plane tests")
|
||||
parser.add_argument("-u", "--unit", action="store_true", help="Run unit tests only")
|
||||
parser.add_argument(
|
||||
"-u", "--unit",
|
||||
action="store_true",
|
||||
help="Run unit tests only"
|
||||
"-c", "--contract", action="store_true", help="Run contract tests only"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--contract",
|
||||
action="store_true",
|
||||
help="Run contract tests only"
|
||||
"-s", "--smoke", action="store_true", help="Run smoke tests only"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s", "--smoke",
|
||||
action="store_true",
|
||||
help="Run smoke tests only"
|
||||
"-o", "--coverage", action="store_true", help="Generate coverage report"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--coverage",
|
||||
action="store_true",
|
||||
help="Generate coverage report"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--parallel",
|
||||
action="store_true",
|
||||
help="Run tests in parallel"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose",
|
||||
action="store_true",
|
||||
help="Verbose output"
|
||||
"-p", "--parallel", action="store_true", help="Run tests in parallel"
|
||||
)
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Build command
|
||||
@@ -71,10 +55,10 @@ def main():
|
||||
|
||||
# Print command
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
|
||||
|
||||
# Execute command
|
||||
result = subprocess.run(cmd)
|
||||
|
||||
|
||||
# Check coverage thresholds if coverage is enabled
|
||||
if args.coverage:
|
||||
print("Checking coverage thresholds...")
|
||||
@@ -83,9 +67,9 @@ def main():
|
||||
if coverage_result.returncode != 0:
|
||||
print("Coverage below threshold (90%)")
|
||||
sys.exit(coverage_result.returncode)
|
||||
|
||||
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -6,9 +6,9 @@ type TArgs = {
|
||||
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.`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,9 +7,9 @@ type TArgs = {
|
||||
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.`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,18 +9,12 @@ import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
|
||||
// core libraries
|
||||
import {
|
||||
fetchPageDescriptionBinary,
|
||||
updatePageDescription,
|
||||
} from "@/core/lib/page.js";
|
||||
import { fetchPageDescriptionBinary, updatePageDescription } from "@/core/lib/page.js";
|
||||
// plane live libraries
|
||||
import { fetchDocument } from "@/plane-live/lib/fetch-document.js";
|
||||
import { updateDocument } from "@/plane-live/lib/update-document.js";
|
||||
// types
|
||||
import {
|
||||
type HocusPocusServerContext,
|
||||
type TDocumentTypes,
|
||||
} from "@/core/types/common.js";
|
||||
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common.js";
|
||||
|
||||
export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
const extensions: Extension[] = [
|
||||
@@ -35,20 +29,14 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
const cookie = (context as HocusPocusServerContext).cookie;
|
||||
// query params
|
||||
const params = requestParameters;
|
||||
const documentType = params.get("documentType")?.toString() as
|
||||
| TDocumentTypes
|
||||
| undefined;
|
||||
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,
|
||||
);
|
||||
fetchedData = await fetchPageDescriptionBinary(params, pageId, cookie);
|
||||
} else {
|
||||
fetchedData = await fetchDocument({
|
||||
cookie,
|
||||
@@ -63,18 +51,11 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
}
|
||||
});
|
||||
},
|
||||
store: async ({
|
||||
context,
|
||||
state,
|
||||
documentName: pageId,
|
||||
requestParameters,
|
||||
}) => {
|
||||
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;
|
||||
const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined;
|
||||
|
||||
// TODO: Fix this lint error.
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
@@ -107,16 +88,12 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
redisClient.on("error", (error: any) => {
|
||||
if (
|
||||
error?.code === "ENOTFOUND" ||
|
||||
error.message.includes("WRONGPASS") ||
|
||||
error.message.includes("NOAUTH")
|
||||
) {
|
||||
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,
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
});
|
||||
@@ -130,12 +107,12 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
} 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,
|
||||
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)",
|
||||
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,7 @@ export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
|
||||
// Send the response
|
||||
res.json({
|
||||
error: {
|
||||
message:
|
||||
process.env.NODE_ENV === "production"
|
||||
? "An unexpected error occurred"
|
||||
: err.message,
|
||||
message: process.env.NODE_ENV === "production" ? "An unexpected error occurred" : err.message,
|
||||
...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
||||
import * as Y from "yjs"
|
||||
import * as Y from "yjs";
|
||||
// plane editor
|
||||
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
|
||||
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [
|
||||
...CoreEditorExtensionsWithoutProps,
|
||||
...DocumentEditorExtensionsWithoutProps,
|
||||
];
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
|
||||
export const getAllDocumentFormatsFromBinaryData = (
|
||||
description: Uint8Array
|
||||
): {
|
||||
contentBinaryEncoded: string;
|
||||
contentJSON: object;
|
||||
contentHTML: string;
|
||||
@@ -22,10 +21,7 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
|
||||
Y.applyUpdate(yDoc, description);
|
||||
// convert to JSON
|
||||
const type = yDoc.getXmlFragment("default");
|
||||
const contentJSON = yXmlFragmentToProseMirrorRootNode(
|
||||
type,
|
||||
documentEditorSchema
|
||||
).toJSON();
|
||||
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
|
||||
// convert to HTML
|
||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
@@ -34,26 +30,21 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
|
||||
contentJSON,
|
||||
contentHTML,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
|
||||
contentBinary: Uint8Array
|
||||
export const getBinaryDataFromHTMLString = (
|
||||
descriptionHTML: string
|
||||
): {
|
||||
contentBinary: Uint8Array;
|
||||
} => {
|
||||
// convert HTML to JSON
|
||||
const contentJSON = generateJSON(
|
||||
descriptionHTML ?? "<p></p>",
|
||||
DOCUMENT_EDITOR_EXTENSIONS
|
||||
);
|
||||
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
|
||||
// convert JSON to Y.Doc format
|
||||
const transformedData = prosemirrorJSONToYDoc(
|
||||
documentEditorSchema,
|
||||
contentJSON,
|
||||
"default"
|
||||
);
|
||||
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
|
||||
// convert Y.Doc to Uint8Array format
|
||||
const encodedData = Y.encodeStateAsUpdate(transformedData);
|
||||
|
||||
return {
|
||||
contentBinary: encodedData
|
||||
}
|
||||
}
|
||||
contentBinary: encodedData,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,10 +4,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { handleAuthentication } from "@/core/lib/authentication.js";
|
||||
// extensions
|
||||
import { getExtensions } from "@/core/extensions/index.js";
|
||||
import {
|
||||
DocumentCollaborativeEvents,
|
||||
TDocumentEventsServer,
|
||||
} from "@plane/editor/lib";
|
||||
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
|
||||
// editor types
|
||||
import { TUserDetails } from "@plane/editor";
|
||||
// types
|
||||
@@ -61,8 +58,7 @@ export const getHocusPocusServer = async () => {
|
||||
},
|
||||
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;
|
||||
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
|
||||
if (response) {
|
||||
document.broadcastStateless(response);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// helpers
|
||||
import {
|
||||
getAllDocumentFormatsFromBinaryData,
|
||||
getBinaryDataFromHTMLString,
|
||||
} from "@/core/helpers/page.js";
|
||||
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page.js";
|
||||
// services
|
||||
import { PageService } from "@/core/services/page.service.js";
|
||||
import { manualLogger } from "../helpers/logger.js";
|
||||
@@ -12,20 +9,17 @@ export const updatePageDescription = async (
|
||||
params: URLSearchParams,
|
||||
pageId: string,
|
||||
updatedDescription: Uint8Array,
|
||||
cookie: string | undefined,
|
||||
cookie: string | undefined
|
||||
) => {
|
||||
if (!(updatedDescription instanceof Uint8Array)) {
|
||||
throw new Error(
|
||||
"Invalid updatedDescription: must be an instance of Uint8Array",
|
||||
);
|
||||
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
|
||||
}
|
||||
|
||||
const workspaceSlug = params.get("workspaceSlug")?.toString();
|
||||
const projectId = params.get("projectId")?.toString();
|
||||
if (!workspaceSlug || !projectId || !cookie) return;
|
||||
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromBinaryData(updatedDescription);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
|
||||
try {
|
||||
const payload = {
|
||||
description_binary: contentBinaryEncoded,
|
||||
@@ -33,13 +27,7 @@ export const updatePageDescription = async (
|
||||
description: contentJSON,
|
||||
};
|
||||
|
||||
await pageService.updateDescription(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
payload,
|
||||
cookie,
|
||||
);
|
||||
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
|
||||
} catch (error) {
|
||||
manualLogger.error("Update error:", error);
|
||||
throw error;
|
||||
@@ -50,26 +38,16 @@ const fetchDescriptionHTMLAndTransform = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
cookie: string,
|
||||
cookie: string
|
||||
) => {
|
||||
if (!workspaceSlug || !projectId || !cookie) return;
|
||||
|
||||
try {
|
||||
const pageDetails = await pageService.fetchDetails(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
cookie,
|
||||
);
|
||||
const { contentBinary } = getBinaryDataFromHTMLString(
|
||||
pageDetails.description_html ?? "<p></p>",
|
||||
);
|
||||
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
|
||||
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
|
||||
return contentBinary;
|
||||
} catch (error) {
|
||||
manualLogger.error(
|
||||
"Error while transforming from HTML to Uint8Array",
|
||||
error,
|
||||
);
|
||||
manualLogger.error("Error while transforming from HTML to Uint8Array", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -77,28 +55,18 @@ const fetchDescriptionHTMLAndTransform = async (
|
||||
export const fetchPageDescriptionBinary = async (
|
||||
params: URLSearchParams,
|
||||
pageId: string,
|
||||
cookie: string | undefined,
|
||||
cookie: string | undefined
|
||||
) => {
|
||||
const workspaceSlug = params.get("workspaceSlug")?.toString();
|
||||
const projectId = params.get("projectId")?.toString();
|
||||
if (!workspaceSlug || !projectId || !cookie) return null;
|
||||
|
||||
try {
|
||||
const response = await pageService.fetchDescriptionBinary(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
cookie,
|
||||
);
|
||||
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
|
||||
const binaryData = new Uint8Array(response);
|
||||
|
||||
if (binaryData.byteLength === 0) {
|
||||
const binary = await fetchDescriptionHTMLAndTransform(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
cookie,
|
||||
);
|
||||
const binary = await fetchDescriptionHTMLAndTransform(workspaceSlug, projectId, pageId, cookie);
|
||||
if (binary) {
|
||||
return binary;
|
||||
}
|
||||
|
||||
@@ -8,42 +8,26 @@ export class PageService extends APIService {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async fetchDetails(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
cookie: string
|
||||
): Promise<TPage> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
|
||||
{
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
}
|
||||
)
|
||||
async fetchDetails(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise<TPage> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, {
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async fetchDescriptionBinary(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
cookie: string
|
||||
): Promise<any> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
Cookie: cookie,
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
}
|
||||
)
|
||||
async fetchDescriptionBinary(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise<any> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
Cookie: cookie,
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
@@ -61,15 +45,11 @@ export class PageService extends APIService {
|
||||
},
|
||||
cookie: string
|
||||
): Promise<any> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
}
|
||||
)
|
||||
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data, {
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "../../ce/lib/fetch-document.js"
|
||||
export * from "../../ce/lib/fetch-document.js";
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
export * from "../../ce/types/common.js"
|
||||
export * from "../../ce/types/common.js";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
(plane_proxy) {
|
||||
request_body {
|
||||
max_size {$FILE_SIZE_LIMIT}
|
||||
}
|
||||
|
||||
reverse_proxy /spaces/* space:3000
|
||||
|
||||
reverse_proxy /god-mode/* admin:3000
|
||||
|
||||
reverse_proxy /live/* live:3000
|
||||
|
||||
reverse_proxy /api/* api:8000
|
||||
|
||||
reverse_proxy /auth/* api:8000
|
||||
|
||||
reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000
|
||||
|
||||
reverse_proxy /* web:3000
|
||||
}
|
||||
|
||||
{
|
||||
{$CERT_EMAIL}
|
||||
acme_ca {$CERT_ACME_CA:https://acme-v02.api.letsencrypt.org/directory}
|
||||
{$CERT_ACME_DNS}
|
||||
servers {
|
||||
max_header_size 25MB
|
||||
client_ip_headers X-Forwarded-For X-Real-IP
|
||||
trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0}
|
||||
}
|
||||
}
|
||||
|
||||
{$SITE_ADDRESS} {
|
||||
import plane_proxy
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM caddy:2.10.0-builder-alpine AS caddy-builder
|
||||
|
||||
RUN xcaddy build \
|
||||
--with github.com/caddy-dns/cloudflare@v0.2.1 \
|
||||
--with github.com/caddy-dns/digitalocean@04bde2867106aa1b44c2f9da41a285fa02e629c5 \
|
||||
--with github.com/mholt/caddy-l4@4d3c80e89c5f80438a3e048a410d5543ff5fb9f4
|
||||
|
||||
FROM caddy:2.10.0-builder-alpine
|
||||
|
||||
RUN apk add nss-tools bash curl
|
||||
|
||||
COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy
|
||||
|
||||
COPY Caddyfile.ce /etc/caddy/Caddyfile
|
||||
@@ -35,9 +35,9 @@ export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) =>
|
||||
|
||||
const updateRouteParams = useCallback(
|
||||
(key: keyof TIssueQueryFilters, value: string[]) => {
|
||||
const state = key === "state" ? value : issueFilters?.filters?.state ?? [];
|
||||
const priority = key === "priority" ? value : issueFilters?.filters?.priority ?? [];
|
||||
const labels = key === "labels" ? value : issueFilters?.filters?.labels ?? [];
|
||||
const state = key === "state" ? value : (issueFilters?.filters?.state ?? []);
|
||||
const priority = key === "priority" ? value : (issueFilters?.filters?.priority ?? []);
|
||||
const labels = key === "labels" ? value : (issueFilters?.filters?.labels ?? []);
|
||||
|
||||
let params: any = { board: activeLayout || "list" };
|
||||
if (priority.length > 0) params = { ...params, priority: priority.join(",") };
|
||||
|
||||
@@ -76,8 +76,8 @@ export const KanbanGroup = observer((props: IKanbanGroup) => {
|
||||
const isSubGroup = !!subGroupId && subGroupId !== "null";
|
||||
|
||||
const issueIds = isSubGroup
|
||||
? (groupedIssueIds as TSubGroupedIssues)?.[groupId]?.[subGroupId] ?? []
|
||||
: (groupedIssueIds as TGroupedIssues)?.[groupId] ?? [];
|
||||
? ((groupedIssueIds as TSubGroupedIssues)?.[groupId]?.[subGroupId] ?? [])
|
||||
: ((groupedIssueIds as TGroupedIssues)?.[groupId] ?? []);
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(groupId, subGroupId, false) ?? 0;
|
||||
const nextPageResults = getPaginationData(groupId, subGroupId)?.nextPageResults;
|
||||
|
||||
@@ -133,12 +133,7 @@ const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = observer(
|
||||
if (subGroupByVisibilityToggle === false) return <></>;
|
||||
return (
|
||||
<div key={`${subGroupBy}_${group.id}`} className="flex w-[350px] flex-shrink-0 flex-col">
|
||||
<HeaderGroupByCard
|
||||
groupBy={groupBy}
|
||||
icon={group.icon}
|
||||
title={group.name}
|
||||
count={groupCount}
|
||||
/>
|
||||
<HeaderGroupByCard groupBy={groupBy} icon={group.icon} title={group.name} count={groupCount} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { SIDEBAR_WIDTH } from "@plane/constants";
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
// hooks
|
||||
import { ResizableSidebar } from "@/components/sidebar";
|
||||
import { useAppTheme } from "@/hooks/store";
|
||||
import { useAppRail } from "@/hooks/use-app-rail";
|
||||
// local imports
|
||||
import { ExtendedAppSidebar } from "./extended-sidebar";
|
||||
import { AppSidebar } from "./sidebar";
|
||||
|
||||
export const ProjectAppSidebar: FC = observer(() => {
|
||||
// store hooks
|
||||
const {
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
sidebarPeek,
|
||||
toggleSidebarPeek,
|
||||
isExtendedSidebarOpened,
|
||||
isAnySidebarDropdownOpen,
|
||||
} = useAppTheme();
|
||||
const { storedValue, setValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH);
|
||||
// states
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(storedValue ?? SIDEBAR_WIDTH);
|
||||
// hooks
|
||||
const { shouldRenderAppRail } = useAppRail();
|
||||
// derived values
|
||||
const isAnyExtendedSidebarOpen = isExtendedSidebarOpened;
|
||||
|
||||
// handlers
|
||||
const handleWidthChange = (width: number) => setValue(width);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResizableSidebar
|
||||
showPeek={sidebarPeek}
|
||||
defaultWidth={storedValue ?? 250}
|
||||
width={sidebarWidth}
|
||||
setWidth={setSidebarWidth}
|
||||
defaultCollapsed={sidebarCollapsed}
|
||||
peekDuration={1500}
|
||||
onWidthChange={handleWidthChange}
|
||||
onCollapsedChange={toggleSidebar}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
toggleCollapsed={toggleSidebar}
|
||||
togglePeek={toggleSidebarPeek}
|
||||
extendedSidebar={
|
||||
<>
|
||||
<ExtendedAppSidebar />
|
||||
</>
|
||||
}
|
||||
isAnyExtendedSidebarExpanded={isAnyExtendedSidebarOpen}
|
||||
isAnySidebarDropdownOpen={isAnySidebarDropdownOpen}
|
||||
disablePeekTrigger={shouldRenderAppRail}
|
||||
>
|
||||
<AppSidebar />
|
||||
</ResizableSidebar>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -8,14 +8,14 @@ import { Plus, Search } from "lucide-react";
|
||||
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
|
||||
import { cn, copyUrlToClipboard, orderJoinedProjects } from "@plane/utils";
|
||||
import { copyUrlToClipboard, orderJoinedProjects } from "@plane/utils";
|
||||
// components
|
||||
import { CreateProjectModal } from "@/components/project";
|
||||
import { SidebarProjectsListItem } from "@/components/workspace";
|
||||
// hooks
|
||||
import { useAppTheme, useProject, useUserPermissions } from "@/hooks/store";
|
||||
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
|
||||
import { TProject } from "@/plane-web/types";
|
||||
import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper";
|
||||
|
||||
export const ExtendedProjectSidebar = observer(() => {
|
||||
// refs
|
||||
@@ -27,7 +27,7 @@ export const ExtendedProjectSidebar = observer(() => {
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { sidebarCollapsed, extendedProjectSidebarCollapsed, toggleExtendedProjectSidebar } = useAppTheme();
|
||||
const { isExtendedProjectSidebarOpened, toggleExtendedProjectSidebar } = useAppTheme();
|
||||
const { getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
@@ -74,15 +74,7 @@ export const ExtendedProjectSidebar = observer(() => {
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
useExtendedSidebarOutsideClickDetector(
|
||||
extendedProjectSidebarRef,
|
||||
() => {
|
||||
if (!isProjectModalOpen) {
|
||||
toggleExtendedProjectSidebar(false);
|
||||
}
|
||||
},
|
||||
"extended-project-sidebar-toggle"
|
||||
);
|
||||
const handleClose = () => toggleExtendedProjectSidebar(false);
|
||||
|
||||
const handleCopyText = (projectId: string) => {
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
|
||||
@@ -103,17 +95,11 @@ export const ExtendedProjectSidebar = observer(() => {
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={extendedProjectSidebarRef}
|
||||
className={cn(
|
||||
"absolute top-0 h-full z-[19] flex flex-col gap-2 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 shadow-md",
|
||||
{
|
||||
"translate-x-0 opacity-100 pointer-events-auto": extendedProjectSidebarCollapsed,
|
||||
"-translate-x-full opacity-0 pointer-events-none": !extendedProjectSidebarCollapsed,
|
||||
"left-[70px]": sidebarCollapsed,
|
||||
"left-[250px]": !sidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
<ExtendedSidebarWrapper
|
||||
isExtendedSidebarOpened={!!isExtendedProjectSidebarOpened}
|
||||
extendedSidebarRef={extendedProjectSidebarRef}
|
||||
handleClose={handleClose}
|
||||
excludedElementId="extended-project-sidebar-toggle"
|
||||
>
|
||||
<div className="flex flex-col gap-1 w-full sticky top-4 pt-0 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -159,7 +145,7 @@ export const ExtendedProjectSidebar = observer(() => {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ExtendedSidebarWrapper>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EXTENDED_SIDEBAR_WIDTH, SIDEBAR_WIDTH } from "@plane/constants";
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
extendedSidebarRef: React.RefObject<HTMLDivElement>;
|
||||
isExtendedSidebarOpened: boolean;
|
||||
handleClose: () => void;
|
||||
excludedElementId: string;
|
||||
};
|
||||
|
||||
export const ExtendedSidebarWrapper: FC<Props> = observer((props) => {
|
||||
const { children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props;
|
||||
// store hooks
|
||||
const { storedValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH);
|
||||
|
||||
useExtendedSidebarOutsideClickDetector(extendedSidebarRef, handleClose, excludedElementId);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={excludedElementId}
|
||||
ref={extendedSidebarRef}
|
||||
className={cn(
|
||||
`absolute h-full z-[19] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm`,
|
||||
{
|
||||
"translate-x-0 opacity-100": isExtendedSidebarOpened,
|
||||
[`-translate-x-[${EXTENDED_SIDEBAR_WIDTH}px] opacity-0 hidden`]: !isExtendedSidebarOpened,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
left: `${storedValue ?? SIDEBAR_WIDTH}px`,
|
||||
width: `${isExtendedSidebarOpened ? EXTENDED_SIDEBAR_WIDTH : 0}px`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -6,12 +6,11 @@ import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants";
|
||||
import { EUserWorkspaceRoles } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppTheme, useWorkspace } from "@/hooks/store";
|
||||
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
|
||||
// plane-web imports
|
||||
import { ExtendedSidebarItem } from "@/plane-web/components/workspace/sidebar";
|
||||
import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper";
|
||||
|
||||
export const ExtendedAppSidebar = observer(() => {
|
||||
// refs
|
||||
@@ -19,7 +18,7 @@ export const ExtendedAppSidebar = observer(() => {
|
||||
// routers
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
|
||||
const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
|
||||
const { updateSidebarPreference, getNavigationPreferences } = useWorkspace();
|
||||
|
||||
// derived values
|
||||
@@ -95,24 +94,14 @@ export const ExtendedAppSidebar = observer(() => {
|
||||
});
|
||||
};
|
||||
|
||||
useExtendedSidebarOutsideClickDetector(
|
||||
extendedSidebarRef,
|
||||
() => toggleExtendedSidebar(true),
|
||||
"extended-sidebar-toggle"
|
||||
);
|
||||
const handleClose = () => toggleExtendedSidebar(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={extendedSidebarRef}
|
||||
className={cn(
|
||||
"absolute top-0 h-full z-[19] flex flex-col gap-0.5 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md pb-6",
|
||||
{
|
||||
"-translate-x-full opacity-0 pointer-events-none": extendedSidebarCollapsed,
|
||||
"translate-x-0 opacity-100 pointer-events-auto": !extendedSidebarCollapsed,
|
||||
"left-[70px]": sidebarCollapsed,
|
||||
"left-[250px]": !sidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
<ExtendedSidebarWrapper
|
||||
isExtendedSidebarOpened={!!isExtendedSidebarOpened}
|
||||
extendedSidebarRef={extendedSidebarRef}
|
||||
handleClose={handleClose}
|
||||
excludedElementId="extended-sidebar-toggle"
|
||||
>
|
||||
{sortedNavigationItems.map((item, index) => (
|
||||
<ExtendedSidebarItem
|
||||
@@ -122,6 +111,6 @@ export const ExtendedAppSidebar = observer(() => {
|
||||
handleOnNavigationItemDrop={handleOnNavigationItemDrop}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ExtendedSidebarWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Home } from "lucide-react";
|
||||
@@ -16,16 +17,17 @@ import { BreadcrumbLink } from "@/components/common";
|
||||
// hooks
|
||||
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
|
||||
|
||||
export const WorkspaceDashboardHeader = () => {
|
||||
export const WorkspaceDashboardHeader = observer(() => {
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
@@ -65,4 +67,4 @@ export const WorkspaceDashboardHeader = () => {
|
||||
</Header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers";
|
||||
// plane web components
|
||||
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
|
||||
import { AppSidebar } from "./sidebar";
|
||||
import { ProjectAppSidebar } from "./_sidebar";
|
||||
|
||||
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthenticationWrapper>
|
||||
<CommandPalette />
|
||||
<WorkspaceAuthWrapper>
|
||||
<div className="relative flex h-screen w-full overflow-hidden">
|
||||
<AppSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
{children}
|
||||
</main>
|
||||
<div className="relative flex flex-col h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
|
||||
<div className="relative flex size-full overflow-hidden">
|
||||
<ProjectAppSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</WorkspaceAuthWrapper>
|
||||
</AuthenticationWrapper>
|
||||
|
||||
+1
-1
@@ -28,4 +28,4 @@ const ProjectArchivedCyclesPage = observer(() => {
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectArchivedCyclesPage;
|
||||
export default ProjectArchivedCyclesPage;
|
||||
|
||||
+1
-1
@@ -28,4 +28,4 @@ const ProjectArchivedIssuesPage = observer(() => {
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectArchivedIssuesPage;
|
||||
export default ProjectArchivedIssuesPage;
|
||||
|
||||
@@ -5,25 +5,25 @@ import { observer } from "mobx-react";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import { SidebarDropdown, SidebarHelpSection, SidebarProjectsList, SidebarQuickActions } from "@/components/workspace";
|
||||
import { AppSidebarToggleButton } from "@/components/sidebar";
|
||||
import { SidebarDropdown, SidebarProjectsList, SidebarQuickActions } from "@/components/workspace";
|
||||
import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu";
|
||||
import { HelpMenu } from "@/components/workspace/sidebar/help-menu";
|
||||
import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useAppTheme, useUserPermissions } from "@/hooks/store";
|
||||
import { useFavorite } from "@/hooks/store/use-favorite";
|
||||
import { useAppRail } from "@/hooks/use-app-rail";
|
||||
import useSize from "@/hooks/use-window-size";
|
||||
// plane web components
|
||||
import { SidebarAppSwitcher } from "@/plane-web/components/sidebar";
|
||||
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge";
|
||||
import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list";
|
||||
import { ExtendedProjectSidebar } from "./extended-project-sidebar";
|
||||
import { ExtendedAppSidebar } from "./extended-sidebar";
|
||||
|
||||
export const AppSidebar: FC = observer(() => {
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
|
||||
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
|
||||
const { groupedFavorites } = useFavorite();
|
||||
const windowSize = useSize();
|
||||
// refs
|
||||
@@ -52,60 +52,38 @@ export const AppSidebar: FC = observer(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-custom-sidebar-border-200 bg-custom-sidebar-background-100 duration-300 w-[250px] md:relative md:ml-0",
|
||||
{
|
||||
"w-[70px] -ml-[250px]": sidebarCollapsed,
|
||||
}
|
||||
<div className="flex flex-col gap-3 px-3">
|
||||
{/* Workspace switcher and settings */}
|
||||
{!shouldRenderAppRail && <SidebarDropdown />}
|
||||
|
||||
{isAppRailEnabled && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-md text-custom-text-200 font-medium pt-1">Projects</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<AppSidebarToggleButton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("size-full flex flex-col flex-1 pt-4 pb-0", {
|
||||
"p-2 pt-4": sidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn("px-2", {
|
||||
"px-4": !sidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
{/* Workspace switcher and settings */}
|
||||
<SidebarDropdown />
|
||||
<div className="flex-shrink-0 h-4" />
|
||||
{/* App switcher */}
|
||||
{canPerformWorkspaceMemberActions && <SidebarAppSwitcher />}
|
||||
{/* Quick actions */}
|
||||
<SidebarQuickActions />
|
||||
</div>
|
||||
<hr
|
||||
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
|
||||
"opacity-0": !sidebarCollapsed,
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={cn("overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto px-2 py-0.5", {
|
||||
"vertical-scrollbar px-4": !sidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
<SidebarMenuItems />
|
||||
{sidebarCollapsed && (
|
||||
<hr className="flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1" />
|
||||
)}
|
||||
{/* Favorites Menu */}
|
||||
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
|
||||
{/* Teams List */}
|
||||
<SidebarTeamsList />
|
||||
{/* Projects List */}
|
||||
<SidebarProjectsList />
|
||||
</div>
|
||||
{/* Help Section */}
|
||||
<SidebarHelpSection />
|
||||
{/* Quick actions */}
|
||||
<SidebarQuickActions />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto vertical-scrollbar px-3 pt-3 pb-0.5">
|
||||
<SidebarMenuItems />
|
||||
{/* Favorites Menu */}
|
||||
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
|
||||
{/* Teams List */}
|
||||
<SidebarTeamsList />
|
||||
{/* Projects List */}
|
||||
<SidebarProjectsList />
|
||||
</div>
|
||||
{/* Help Section */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12">
|
||||
<WorkspaceEditionBadge />
|
||||
<div className="flex items-center gap-2">
|
||||
{!shouldRenderAppRail && <HelpMenu />}
|
||||
{!isAppRailEnabled && <AppSidebarToggleButton />}
|
||||
</div>
|
||||
</div>
|
||||
<ExtendedAppSidebar />
|
||||
<ExtendedProjectSidebar />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,14 +11,16 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
||||
<AuthenticationWrapper>
|
||||
<WorkspaceAuthWrapper>
|
||||
<CommandPalette />
|
||||
<main className="relative flex h-screen w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
{/* Header */}
|
||||
<SettingsHeader />
|
||||
{/* Content */}
|
||||
<ContentWrapper className="px-4 md:pl-12 md:flex w-full">
|
||||
<div className="w-full h-full overflow-hidden">{children}</div>
|
||||
</ContentWrapper>
|
||||
</main>
|
||||
<div className="relative flex h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
{/* Header */}
|
||||
<SettingsHeader />
|
||||
{/* Content */}
|
||||
<ContentWrapper className="p-page-x md:flex w-full">
|
||||
<div className="w-full h-full overflow-hidden">{children}</div>
|
||||
</ContentWrapper>
|
||||
</main>
|
||||
</div>
|
||||
</WorkspaceAuthWrapper>
|
||||
</AuthenticationWrapper>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react";
|
||||
import { EUserPermissionsLevel, GROUPED_WORKSPACE_SETTINGS, WORKSPACE_SETTINGS_CATEGORIES, EUserPermissions, WORKSPACE_SETTINGS_CATEGORY } from "@plane/constants";
|
||||
import {
|
||||
EUserPermissionsLevel,
|
||||
GROUPED_WORKSPACE_SETTINGS,
|
||||
WORKSPACE_SETTINGS_CATEGORIES,
|
||||
EUserPermissions,
|
||||
WORKSPACE_SETTINGS_CATEGORY,
|
||||
} from "@plane/constants";
|
||||
import { EUserWorkspaceRoles } from "@plane/types";
|
||||
import { SettingsSidebar } from "@/components/settings";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { AppRailProvider } from "@/hooks/context/app-rail-context";
|
||||
import { WorkspaceContentWrapper } from "@/plane-web/components/workspace";
|
||||
|
||||
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AppRailProvider>
|
||||
<WorkspaceContentWrapper>{children}</WorkspaceContentWrapper>
|
||||
</AppRailProvider>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export const usePreloadResources = () => {
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/workspaces/?v=${Date.now()}`,
|
||||
];
|
||||
|
||||
urls.forEach(url => preloadItem(url));
|
||||
urls.forEach((url) => preloadItem(url));
|
||||
}, []);
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function ProfileSettingsLayout(props: Props) {
|
||||
<>
|
||||
<CommandPalette />
|
||||
<AuthenticationWrapper>
|
||||
<div className="relative flex h-full w-full overflow-hidden">
|
||||
<div className="relative flex h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
|
||||
<ProfileLayoutSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
<div className="h-full w-full overflow-hidden">{children}</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ export const metadata: Metadata = {
|
||||
robots: {
|
||||
index: true,
|
||||
follow: false,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default function SignUpLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -15,7 +15,5 @@ export const viewport: Viewport = {
|
||||
};
|
||||
|
||||
export default function HomeLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>{children}</>
|
||||
);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,4 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
|
||||
export const AppRailRoot = () => <></>;
|
||||
@@ -1 +1 @@
|
||||
export * from "./subscription-pill";
|
||||
export * from "./subscription-pill";
|
||||
|
||||
@@ -14,7 +14,7 @@ export const ProductUpdatesHeader = observer(() => {
|
||||
<div className="flex gap-2 mx-6 my-4 items-center justify-between flex-shrink-0">
|
||||
<div className="flex w-full items-center">
|
||||
<div className="flex gap-2 text-xl font-medium">{t("whats_new")}</div>
|
||||
<div
|
||||
<div
|
||||
className={cn(
|
||||
"px-2 mx-2 py-0.5 text-center text-xs font-medium rounded-full bg-custom-primary-100/20 text-custom-primary-100"
|
||||
)}
|
||||
|
||||
@@ -4,5 +4,9 @@ import packageJson from "package.json";
|
||||
|
||||
export const PlaneVersionNumber: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return <span>{t("version")}: v{packageJson.version}</span>;
|
||||
return (
|
||||
<span>
|
||||
{t("version")}: v{packageJson.version}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./root";
|
||||
export * from "./root";
|
||||
|
||||
@@ -2,4 +2,3 @@ export * from "./provider";
|
||||
export * from "./issue-type-select";
|
||||
export * from "./additional-properties";
|
||||
export * from "./template-select";
|
||||
|
||||
|
||||
@@ -5,13 +5,17 @@ export const getRelationActivityContent = (activity: TIssueActivity | undefined)
|
||||
|
||||
switch (activity.field) {
|
||||
case "blocking":
|
||||
return activity.old_value === "" ? `marked this work item is blocking work item ` : `removed the blocking work item `;
|
||||
return activity.old_value === ""
|
||||
? `marked this work item is blocking work item `
|
||||
: `removed the blocking work item `;
|
||||
case "blocked_by":
|
||||
return activity.old_value === ""
|
||||
? `marked this work item is being blocked by `
|
||||
: `removed this work item being blocked by work item `;
|
||||
case "duplicate":
|
||||
return activity.old_value === "" ? `marked this work item as duplicate of ` : `removed this work item as a duplicate of `;
|
||||
return activity.old_value === ""
|
||||
? `marked this work item as duplicate of `
|
||||
: `removed this work item as a duplicate of `;
|
||||
case "relates_to":
|
||||
return activity.old_value === "" ? `marked that this work item relates to ` : `removed the relation from `;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,9 @@ import { ProjectNavigation } from "@/components/workspace";
|
||||
type TProjectItemsRootProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isSidebarCollapsed: boolean;
|
||||
};
|
||||
|
||||
export const ProjectNavigationRoot: FC<TProjectItemsRootProps> = (props) => {
|
||||
const { workspaceSlug, projectId, isSidebarCollapsed } = props;
|
||||
return (
|
||||
<ProjectNavigation workspaceSlug={workspaceSlug} projectId={projectId} isSidebarCollapsed={isSidebarCollapsed} />
|
||||
);
|
||||
const { workspaceSlug, projectId } = props;
|
||||
return <ProjectNavigation workspaceSlug={workspaceSlug} projectId={projectId} />;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
export const WorkspaceAppSwitcher = () => <></>;
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export const WorkspaceContentWrapper = observer(({ children }: { children: React.ReactNode }) => (
|
||||
<div className="flex relative size-full overflow-hidden bg-custom-background-90 rounded-lg transition-all ease-in-out duration-300">
|
||||
<div className="size-full p-2 flex-grow transition-all ease-in-out duration-300 overflow-hidden">{children}</div>
|
||||
</div>
|
||||
));
|
||||
@@ -4,3 +4,5 @@ export * from "./billing";
|
||||
export * from "./delete-workspace-section";
|
||||
export * from "./sidebar";
|
||||
export * from "./members";
|
||||
export * from "./content-wrapper";
|
||||
export * from "./app-switcher";
|
||||
|
||||
@@ -2,14 +2,11 @@ import { observer } from "mobx-react";
|
||||
import { Search } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppTheme, useCommandPalette } from "@/hooks/store";
|
||||
import { useCommandPalette } from "@/hooks/store";
|
||||
|
||||
export const AppSearch = observer(() => {
|
||||
// store hooks
|
||||
const { sidebarCollapsed } = useAppTheme();
|
||||
const { toggleCommandPaletteModal } = useCommandPalette();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
@@ -17,12 +14,7 @@ export const AppSearch = observer(() => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-shrink-0 size-8 aspect-square grid place-items-center rounded hover:bg-custom-sidebar-background-90 outline-none",
|
||||
{
|
||||
"border-[0.5px] border-custom-sidebar-border-300": !sidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
className="flex-shrink-0 size-8 aspect-square grid place-items-center rounded hover:bg-custom-sidebar-background-90 outline-none border-[0.5px] border-custom-sidebar-border-300"
|
||||
onClick={() => toggleCommandPaletteModal(true)}
|
||||
aria-label={t("aria_labels.projects_sidebar.open_command_palette")}
|
||||
>
|
||||
|
||||
@@ -171,10 +171,7 @@ export const ExtendedSidebarItem: FC<TExtendedSidebarItemProps> = observer((prop
|
||||
className={cn(
|
||||
"flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab",
|
||||
{
|
||||
// "cursor-not-allowed opacity-60": project.sort_order === null,
|
||||
"cursor-grabbing": isDragging,
|
||||
|
||||
// "!hidden": isSidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
ref={dragHandleRef}
|
||||
|
||||
@@ -5,9 +5,7 @@ import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel, IWorkspaceSidebarNavigationItem } from "@plane/constants";
|
||||
import { usePlatformOS } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar";
|
||||
import { NotificationAppSidebarOption } from "@/components/workspace-notifications";
|
||||
@@ -31,14 +29,13 @@ export const SidebarItem: FC<TSidebarItemProps> = observer((props) => {
|
||||
const { data } = useUser();
|
||||
|
||||
// store hooks
|
||||
const { toggleSidebar, sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { toggleSidebar, isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
|
||||
|
||||
const handleLinkClick = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
toggleSidebar();
|
||||
}
|
||||
if (!extendedSidebarCollapsed) toggleExtendedSidebar();
|
||||
if (isExtendedSidebarOpened) toggleExtendedSidebar(false);
|
||||
};
|
||||
|
||||
const staticItems = ["home", "inbox", "pi-chat", "projects"];
|
||||
@@ -61,30 +58,14 @@ export const SidebarItem: FC<TSidebarItemProps> = observer((props) => {
|
||||
const icon = getSidebarNavigationItemIcon(item.key);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltipContent={t(item.labelTranslationKey)}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!sidebarCollapsed}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<Link href={itemHref} onClick={() => handleLinkClick()}>
|
||||
<SidebarNavItem
|
||||
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
|
||||
isActive={isActive}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 py-[1px]">
|
||||
{icon}
|
||||
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>}
|
||||
</div>
|
||||
{item.key === "inbox" && (
|
||||
<NotificationAppSidebarOption
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
isSidebarCollapsed={sidebarCollapsed ?? false}
|
||||
/>
|
||||
)}
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<Link href={itemHref} onClick={() => handleLinkClick()}>
|
||||
<SidebarNavItem isActive={isActive}>
|
||||
<div className="flex items-center gap-1.5 py-[1px]">
|
||||
{icon}
|
||||
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
|
||||
</div>
|
||||
{item.key === "inbox" && <NotificationAppSidebarOption workspaceSlug={workspaceSlug?.toString()} />}
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export type TRenderSettingsLink = (workspaceSlug: string, settingKey: string) => boolean;
|
||||
export const shouldRenderSettingLink: TRenderSettingsLink = (workspaceSlug, settingKey) => true;
|
||||
export const shouldRenderSettingLink: TRenderSettingsLink = (workspaceSlug, settingKey) => true;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./estimate.service";
|
||||
export * from "./view.service";
|
||||
export * from "./view.service";
|
||||
|
||||
@@ -2,4 +2,4 @@ import { IPartialProject, IProject } from "@plane/types";
|
||||
|
||||
export type TPartialProject = IPartialProject;
|
||||
|
||||
export type TProject = TPartialProject & IProject;
|
||||
export type TProject = TPartialProject & IProject;
|
||||
|
||||
@@ -12,7 +12,8 @@ export const OAuthOptions: React.FC<TOAuthOptionProps> = observer(() => {
|
||||
// hooks
|
||||
const { config } = useInstance();
|
||||
|
||||
const isOAuthEnabled = (config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false;
|
||||
const isOAuthEnabled =
|
||||
(config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false;
|
||||
|
||||
if (!isOAuthEnabled) return null;
|
||||
|
||||
|
||||
@@ -47,11 +47,11 @@ export const ProjectSelect: React.FC<Props> = observer((props) => {
|
||||
{value && value.length > 3
|
||||
? `3+ projects`
|
||||
: value && value.length > 0
|
||||
? projectIds
|
||||
?.filter((p) => value.includes(p))
|
||||
.map((p) => getProjectById(p)?.name)
|
||||
.join(", ")
|
||||
: "All projects"}
|
||||
? projectIds
|
||||
?.filter((p) => value.includes(p))
|
||||
.map((p) => getProjectById(p)?.name)
|
||||
.join(", ")
|
||||
: "All projects"}
|
||||
</div>
|
||||
}
|
||||
multiple
|
||||
|
||||
@@ -21,7 +21,6 @@ import { InsightTable } from "../insight-table";
|
||||
|
||||
const analyticsService = new AnalyticsService();
|
||||
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
export: {
|
||||
|
||||
@@ -251,4 +251,4 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -60,4 +60,4 @@ export const ApiTokenListItem: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug?.toString(),
|
||||
currentProjectDetails?.id
|
||||
);
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { getWeekOfMonth, isValid } from "date-fns";
|
||||
import { CHART_X_AXIS_DATE_PROPERTIES, ChartXAxisDateGrouping, TO_CAPITALIZE_PROPERTIES } from "@plane/constants";
|
||||
import { ChartXAxisProperty, TChart, TChartDatum } from "@plane/types";
|
||||
import { capitalizeFirstLetter, hexToHsl, hslToHex, renderFormattedDate, renderFormattedDateWithoutYear } from "@plane/utils";
|
||||
import {
|
||||
capitalizeFirstLetter,
|
||||
hexToHsl,
|
||||
hslToHex,
|
||||
renderFormattedDate,
|
||||
renderFormattedDateWithoutYear,
|
||||
} from "@plane/utils";
|
||||
//
|
||||
|
||||
const getDateGroupingName = (date: string, dateGrouping: ChartXAxisDateGrouping): string => {
|
||||
@@ -61,7 +67,7 @@ export const parseChartData = (
|
||||
const updatedWidgetData: TChartDatum[] = widgetData.map((datum) => {
|
||||
const keys = Object.keys(datum);
|
||||
const missingKeys = allKeys.filter((key) => !keys.includes(key));
|
||||
const missingValues: Record<string, number> = Object.fromEntries(missingKeys.map(key => [key, 0]));
|
||||
const missingValues: Record<string, number> = Object.fromEntries(missingKeys.map((key) => [key, 0]));
|
||||
|
||||
if (xAxisProperty) {
|
||||
// capitalize first letter if xAxisProperty is in TO_CAPITALIZE_PROPERTIES and no groupByProperty is set
|
||||
@@ -163,4 +169,4 @@ export const generateExtendedColors = (baseColorSet: string[], targetCount: numb
|
||||
}
|
||||
|
||||
return colors.slice(0, targetCount);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
// components
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { Row } from "@plane/ui";
|
||||
import { SidebarHamburgerToggle } from "@/components/core";
|
||||
// components
|
||||
import { AppSidebarToggleButton } from "@/components/sidebar";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store";
|
||||
|
||||
export interface AppHeaderProps {
|
||||
header: ReactNode;
|
||||
mobileHeader?: ReactNode;
|
||||
}
|
||||
|
||||
export const AppHeader = (props: AppHeaderProps) => {
|
||||
export const AppHeader = observer((props: AppHeaderProps) => {
|
||||
const { header, mobileHeader } = props;
|
||||
// store hooks
|
||||
const { sidebarCollapsed } = useAppTheme();
|
||||
|
||||
return (
|
||||
<div className="z-[18]">
|
||||
<Row className="h-[3.75rem] flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
|
||||
<div className="block bg-custom-sidebar-background-100 md:hidden">
|
||||
<SidebarHamburgerToggle />
|
||||
</div>
|
||||
{sidebarCollapsed && <AppSidebarToggleButton />}
|
||||
<div className="w-full">{header}</div>
|
||||
</Row>
|
||||
{mobileHeader && mobileHeader}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Menu } from "lucide-react";
|
||||
import { PanelRight } from "lucide-react";
|
||||
import { useAppTheme } from "@/hooks/store";
|
||||
|
||||
export const SidebarHamburgerToggle = observer(() => {
|
||||
// store hooks
|
||||
const { toggleSidebar } = useAppTheme();
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleSidebar();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex-shrink-0 size-7 grid place-items-center rounded bg-custom-background-80 transition-all hover:bg-custom-background-90 md:hidden"
|
||||
onClick={() => toggleSidebar()}
|
||||
className="group flex-shrink-0 size-7 grid place-items-center rounded hover:bg-custom-background-80 transition-all bg-custom-background-90"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Menu className="size-3.5 text-custom-text-200 transition-all group-hover:text-custom-text-100" />
|
||||
<PanelRight className="size-3.5 text-custom-text-200 transition-all group-hover:text-custom-text-100" />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,51 +13,51 @@ export const ProductUpdatesFooter = () => {
|
||||
<div className="flex items-center justify-between flex-shrink-0 gap-4 m-6 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="https://go.plane.so/p-docs"
|
||||
target="_blank"
|
||||
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
|
||||
href="https://go.plane.so/p-docs"
|
||||
target="_blank"
|
||||
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
|
||||
>
|
||||
{t("docs")}
|
||||
</a>
|
||||
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
|
||||
<circle cx={1} cy={1} r={1} />
|
||||
</svg>
|
||||
<a
|
||||
href="https://go.plane.so/p-changelog"
|
||||
target="_blank"
|
||||
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
|
||||
</svg>
|
||||
<a
|
||||
href="https://go.plane.so/p-changelog"
|
||||
target="_blank"
|
||||
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
|
||||
>
|
||||
{t("full_changelog")}
|
||||
</a>
|
||||
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
|
||||
<circle cx={1} cy={1} r={1} />
|
||||
</svg>
|
||||
<a
|
||||
href="mailto:support@plane.so"
|
||||
target="_blank"
|
||||
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
|
||||
</svg>
|
||||
<a
|
||||
href="mailto:support@plane.so"
|
||||
target="_blank"
|
||||
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
|
||||
>
|
||||
{t("support")}
|
||||
</a>
|
||||
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
|
||||
<circle cx={1} cy={1} r={1} />
|
||||
</svg>
|
||||
</svg>
|
||||
<a
|
||||
href="https://go.plane.so/p-discord"
|
||||
target="_blank"
|
||||
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="https://go.plane.so/p-discord"
|
||||
href="https://plane.so/pages"
|
||||
target="_blank"
|
||||
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
|
||||
className={cn(
|
||||
getButtonStyling("accent-primary", "sm"),
|
||||
"flex gap-1.5 items-center text-center font-medium hover:underline underline-offset-2 outline-none"
|
||||
)}
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="https://plane.so/pages"
|
||||
target="_blank"
|
||||
className={cn(
|
||||
getButtonStyling("accent-primary", "sm"),
|
||||
"flex gap-1.5 items-center text-center font-medium hover:underline underline-offset-2 outline-none"
|
||||
)}
|
||||
>
|
||||
<Image src={PlaneLogo} alt="Plane" width={12} height={12} />
|
||||
{t("powered_by_plane_pages")}
|
||||
</a>
|
||||
|
||||
@@ -6,7 +6,12 @@ import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constant
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
} from "@plane/types";
|
||||
// components
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
import { ArchiveTabsList } from "@/components/archives";
|
||||
@@ -74,7 +79,11 @@ export const ArchivedIssuesHeader: FC = observer(() => {
|
||||
</div>
|
||||
{/* filter options */}
|
||||
<div className="flex items-center gap-2 px-8">
|
||||
<FiltersDropdown title={t("common.filters")} placement="bottom-end" isFiltersApplied={isIssueFilterActive(issueFilters)}>
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters || {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
|
||||
@@ -6,7 +6,14 @@ import Link from "next/link";
|
||||
import { AlertCircle, X } from "lucide-react";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate, truncateText } from "@plane/utils";
|
||||
import {
|
||||
convertBytesToSize,
|
||||
getFileExtension,
|
||||
getFileName,
|
||||
getFileURL,
|
||||
renderFormattedDate,
|
||||
truncateText,
|
||||
} from "@plane/utils";
|
||||
// icons
|
||||
//
|
||||
import { getFileIcon } from "@/components/icons";
|
||||
|
||||
@@ -7,7 +7,12 @@ import { EIssueLayoutTypes, EIssueFilterType, ISSUE_STORE_TO_FILTERS_MAP } from
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
} from "@plane/types";
|
||||
import { Button } from "@plane/ui";
|
||||
// components
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
|
||||
@@ -18,14 +18,7 @@ import { ArchiveIssueModal, DeleteIssueModal, IssueSubscription } from "@/compon
|
||||
// helpers
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import {
|
||||
useIssueDetail,
|
||||
useIssues,
|
||||
useProject,
|
||||
useProjectState,
|
||||
useUser,
|
||||
useUserPermissions,
|
||||
} from "@/hooks/store";
|
||||
import { useIssueDetail, useIssues, useProject, useProjectState, useUser, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./blocks";
|
||||
export * from "./base-gantt-root";
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@ import { FC, useCallback, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { ALL_ISSUES, EIssueLayoutTypes, EIssueFilterType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import {
|
||||
ALL_ISSUES,
|
||||
EIssueLayoutTypes,
|
||||
EIssueFilterType,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
} from "@plane/constants";
|
||||
import { EIssuesStoreType, IIssueDisplayFilterOptions } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssues, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
@@ -12,7 +12,14 @@ import { useTranslation } from "@plane/i18n";
|
||||
import { EIssuesStoreType, TIssue, TWorkspaceDraftIssue } from "@plane/types";
|
||||
// hooks
|
||||
import { Button, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { convertWorkItemDataToSearchResponse, getUpdateFormDataForReset, cn, getTextContent, getChangedIssuefields, getTabIndex } from "@plane/utils";
|
||||
import {
|
||||
convertWorkItemDataToSearchResponse,
|
||||
getUpdateFormDataForReset,
|
||||
cn,
|
||||
getTextContent,
|
||||
getChangedIssuefields,
|
||||
getTabIndex,
|
||||
} from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
IssueDefaultProperties,
|
||||
|
||||
@@ -137,7 +137,12 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<div className={cn("h-full flex items-center justify-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1 text-xs hover:bg-custom-background-80", buttonClassName)}>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex items-center justify-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1 text-xs hover:bg-custom-background-80",
|
||||
buttonClassName
|
||||
)}
|
||||
>
|
||||
<Tag className="h-3 w-3 flex-shrink-0" />
|
||||
<span>{t("labels")}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useState, useEffect, useCallback } from "react";
|
||||
import { FC, useState, useEffect, useCallback, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TNameDescriptionLoader } from "@plane/types";
|
||||
@@ -42,11 +42,20 @@ export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
|
||||
// states
|
||||
const [title, setTitle] = useState("");
|
||||
const [isLengthVisible, setIsLengthVisible] = useState(false);
|
||||
// ref to track if there are unsaved changes
|
||||
const hasUnsavedChanges = useRef(false);
|
||||
// ref to store current title value for cleanup function
|
||||
const currentTitleRef = useRef(title);
|
||||
// hooks
|
||||
const debouncedValue = useDebounce(title, 1500);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) setTitle(value);
|
||||
if (value) {
|
||||
setTitle(value);
|
||||
currentTitleRef.current = value;
|
||||
// Reset unsaved changes flag when value is set from props
|
||||
hasUnsavedChanges.current = false;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -55,6 +64,7 @@ export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
|
||||
if (debouncedValue.trim().length > 0) {
|
||||
issueOperations.update(workspaceSlug, projectId, issueId, { name: debouncedValue }).finally(() => {
|
||||
setIsSubmitting("saved");
|
||||
hasUnsavedChanges.current = false;
|
||||
if (textarea && !textarea.matches(":focus")) {
|
||||
const trimmedTitle = debouncedValue.trim();
|
||||
if (trimmedTitle !== title) setTitle(trimmedTitle);
|
||||
@@ -63,6 +73,7 @@ export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
|
||||
} else {
|
||||
setTitle(value || "");
|
||||
setIsSubmitting("saved");
|
||||
hasUnsavedChanges.current = false;
|
||||
}
|
||||
}
|
||||
// DO NOT Add more dependencies here. It will cause multiple requests to be sent.
|
||||
@@ -76,9 +87,11 @@ export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
|
||||
if (trimmedTitle.length > 0) {
|
||||
setTitle(trimmedTitle);
|
||||
setIsSubmitting("submitting");
|
||||
hasUnsavedChanges.current = true;
|
||||
} else {
|
||||
setTitle(value || "");
|
||||
setIsSubmitting("saved");
|
||||
hasUnsavedChanges.current = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -95,10 +108,32 @@ export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
|
||||
};
|
||||
}, [title, isSubmitting, setIsSubmitting]);
|
||||
|
||||
// Save on unmount if there are unsaved changes
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (hasUnsavedChanges.current && currentTitleRef.current.trim().length > 0) {
|
||||
issueOperations
|
||||
.update(workspaceSlug, projectId, issueId, { name: currentTitleRef.current.trim() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to save title on unmount:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSubmitting("saved");
|
||||
hasUnsavedChanges.current = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setIsSubmitting("submitting");
|
||||
setTitle(e.target.value);
|
||||
const titleFromEvent = e.target.value;
|
||||
setTitle(titleFromEvent);
|
||||
currentTitleRef.current = titleFromEvent;
|
||||
hasUnsavedChanges.current = true;
|
||||
},
|
||||
[setIsSubmitting]
|
||||
);
|
||||
|
||||
@@ -43,7 +43,7 @@ export const WorkspaceDraftEmptyState: FC = observer(() => {
|
||||
onClick: () => {
|
||||
setIsDraftIssueModalOpen(true);
|
||||
},
|
||||
disabled: !canPerformEmptyStateActions
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,7 @@ import { useParams } from "next/navigation";
|
||||
// PLane
|
||||
import { IBlockUpdateData, IBlockUpdateDependencyData, IModule } from "@plane/types";
|
||||
// components
|
||||
import {
|
||||
GanttChartRoot,
|
||||
ModuleGanttSidebar,
|
||||
} from "@/components/gantt-chart";
|
||||
import { GanttChartRoot, ModuleGanttSidebar } from "@/components/gantt-chart";
|
||||
import { ETimeLineTypeType, TimeLineTypeContext } from "@/components/gantt-chart/contexts";
|
||||
import { ModuleGanttBlock } from "@/components/modules";
|
||||
// hooks
|
||||
|
||||
@@ -63,7 +63,9 @@ export const ProfileActivity = observer(() => {
|
||||
<div className="-mt-1 w-4/5 break-words">
|
||||
<p className="inline text-sm text-custom-text-200">
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{currentUser?.id === activity.actor_detail?.id ? "You" : activity.actor_detail?.display_name}{" "}
|
||||
{currentUser?.id === activity.actor_detail?.id
|
||||
? "You"
|
||||
: activity.actor_detail?.display_name}{" "}
|
||||
</span>
|
||||
{activity.field ? (
|
||||
<ActivityMessage activity={activity} showIssue />
|
||||
|
||||
@@ -6,7 +6,12 @@ import { EIssueLayoutTypes, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } fr
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
} from "@plane/types";
|
||||
// components
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown, LayoutSelection } from "@/components/issues";
|
||||
@@ -107,7 +112,11 @@ export const ProfileIssuesFilter = observer(() => {
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
|
||||
<FiltersDropdown title={t("common.filters")} placement="bottom-end" isFiltersApplied={isIssueFilterActive(issueFilters)}>
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues[activeLayout] : undefined
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { getButtonStyling } from "@plane/ui/src/button";
|
||||
@@ -15,16 +16,16 @@ export const SettingsHeader = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { isScrolled } = useUserSettings();
|
||||
// resolved theme
|
||||
const { resolvedTheme } = useTheme();
|
||||
// redirect url for normal mode
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-custom-background-90 px-4 py-4 gap-2 md:px-12 md:py-8 transition-all duration-300 ease-in-out relative",
|
||||
{
|
||||
"!pt-4 flex md:flex-col": isScrolled,
|
||||
}
|
||||
)}
|
||||
className={cn("bg-custom-background-90 p-page-x transition-all duration-300 ease-in-out relative", {
|
||||
"!pt-4 flex md:flex-col": isScrolled,
|
||||
"bg-custom-background-90/50": resolvedTheme === "dark",
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
href={`/${currentWorkspace?.slug}`}
|
||||
@@ -41,7 +42,7 @@ export const SettingsHeader = observer(() => {
|
||||
<Link
|
||||
href={`/${currentWorkspace?.slug}`}
|
||||
className={cn(
|
||||
"group flex gap-2 text-custom-text-300 mb-4 border border-transparent w-fit rounded-lg",
|
||||
"group flex gap-2 text-custom-text-300 mb-3 border border-transparent w-fit rounded-lg",
|
||||
!isScrolled ? "hover:bg-custom-background-100 hover:border-custom-border-200 items-center pr-2 " : " h-0 m-0"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ScrollArea } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { SettingsSidebarHeader } from "./header";
|
||||
import SettingsSidebarNavItem, { TSettingItem } from "./nav-item";
|
||||
@@ -45,12 +46,15 @@ export const SettingsSidebar = observer((props: SettingsSidebarProps) => {
|
||||
{/* Header */}
|
||||
<SettingsSidebarHeader customHeader={customHeader} />
|
||||
{/* Navigation */}
|
||||
<div className="divide-y divide-custom-border-100 overflow-x-hidden w-full h-full overflow-y-scroll">
|
||||
<ScrollArea
|
||||
className="divide-y divide-custom-border-100 overflow-x-hidden w-full h-full overflow-y-scroll"
|
||||
type="hover"
|
||||
>
|
||||
{categories.map((category) => {
|
||||
if (groupedSettings[category].length === 0) return null;
|
||||
return (
|
||||
<div key={category} className="py-3">
|
||||
<span className="text-sm font-semibold text-custom-text-350 capitalize mb-2">{t(category)}</span>
|
||||
<span className="text-sm font-semibold text-custom-text-350 capitalize mb-2 px-2">{t(category)}</span>
|
||||
<div className="relative flex flex-col gap-0.5 h-full mt-2">
|
||||
{groupedSettings[category].map(
|
||||
(setting) =>
|
||||
@@ -70,7 +74,7 @@ export const SettingsSidebar = observer((props: SettingsSidebarProps) => {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export * from "./sidebar-navigation";
|
||||
export * from "./sidebar-navigation";
|
||||
export * from "./resizable-sidebar";
|
||||
export * from "./sidebar-item";
|
||||
export * from "./sidebar-toggle-button";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user