Compare commits

...
Author SHA1 Message Date
akshat5302 89fc172382 refactor: update build context and remove deprecated proxy files
* Updated build context and Dockerfile paths in GitHub Actions workflow.
* Removed obsolete Caddyfile and Dockerfile for the proxy service.
2025-07-09 15:54:37 +05:30
akshat5302 13551dd32c Merge branch 'preview' of https://github.com/makeplane/plane into add-caddy-proxy 2025-07-09 14:28:08 +05:30
Anmol Singh BhatiaandGitHub 9d88fc999c [WEB-4464] fix: content wrapper overflow (#7367) 2025-07-09 13:52:17 +05:30
M. PalanikannanandGitHub dadd76b3ed [WIKI-534] fix: save title input on closing (#7366)
* fix: save title input on closing

* fix: title input updatioin
2025-07-09 13:37:17 +05:30
sriram veeraghantaandGitHub 6ce700fd5d chore: format files using prettier (#7364)
* chore: format files using prettier

* chore: api server files formatted
2025-07-08 20:41:11 +05:30
Anmol Singh BhatiaandGitHub 0225d806cc [WEB-4328] feat: sidebar revamp (#7217)
* chore: sidebar peek state added to theme store

* chore: extended sidebar wrapper added

* chore: resizeable sidebar component added

* chore: appsidebar root component

* chore: updated sidebar and applied necessary changes across codebase

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: breadcrumb changes

* chore: sidebar improvements and fixes

* chore: enhancements and fixes

* fix: peek sidebar leave

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: icons added

* chore: add dock variable and toggle function to theme store

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: theme and workspace store updated

* chore: workspace content wrapper and apprail context

* chore: workspace and project wrapper updated

* chore: app rail component

* chore: content wrapper

* chore: sidebar component updated

* chore: layout changes and code refactoring

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: appsidebar toggle button

* chore: code refactor

* chore: workspace menu improvements

* chore: sidebar spacing and padding improvements

* chore: settings layout improvement

* chore: enhancements

* chore: extended sidebar code refactor

* chore: code refactor

* fix: merge conflict

* fix: merge conflict

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-07-08 20:18:39 +05:30
NikhilandGitHub fd9da3164e [WEB-4452] fix: enhance URL detection logic in contains_url function (#7352)
* fix: enhance URL detection logic in contains_url function

* fix: improve error handling in URL detection logic

* fix: enhance URL detection logic with length limits and edge case handling

* fix: adjust URL line length limit for improved detection accuracy
2025-07-08 18:28:47 +05:30
akshat5302 a4b4797a32 Update variables.env to clarify CERT_EMAIL configuration
* Adjusted comments in variables.env to specify the format for CERT_EMAIL and removed redundant lines, ensuring clearer instructions for SSL certificate generation.
2025-06-25 12:55:03 +05:30
akshat5302 f68f889e2a Refactor Caddyfile to remove default value for CERT_EMAIL variable
* Updated Caddyfile to directly use the CERT_EMAIL variable without a default value, aligning with recent changes in environment variable handling.
2025-06-25 12:17:15 +05:30
akshat5302 03ec96f27c Update Caddyfile reference in Dockerfile for proxy setup 2025-06-25 12:08:36 +05:30
akshat5302 a301342903 Update environment variable handling and Caddy configuration
* Removed default value for CERT_EMAIL in docker-compose and variables.env to enforce explicit configuration.
* Updated Caddyfile to use the CERT_EMAIL variable without a default value.
* Increased max_header_size in Caddyfile for improved request handling.
* Updated Dockerfile to use specific versions for dependencies, enhancing build stability.
2025-06-25 12:06:37 +05:30
akshat5302 47746a4fd4 Refactor Dockerfile for Caddy proxy setup
* Transitioned to a multi-stage build using caddy:builder for enhanced customization.
* Integrated Cloudflare DNS support and added necessary dependencies.
* Updated Caddyfile and entrypoint configuration for improved deployment.
2025-06-25 11:36:51 +05:30
akshat5302 74926ea46c Merge branch 'preview' of https://github.com/makeplane/plane into add-caddy-proxy 2025-06-25 11:30:01 +05:30
akshat5302 a46b3ba01e Refactor Caddy setup and update build configuration
* Removed Caddy-related files and scripts as part of the transition to a new proxy setup.
* Updated GitHub Actions workflow to use the new Dockerfile and build context for the proxy.
* Added SITE_ADDRESS environment variable to docker-compose and variables.env for improved configuration management.
2025-06-25 11:28:57 +05:30
akshat5302 b1c2987c6d Enhance Caddy configuration and update environment variables
* Added note in caddy.sh regarding the persistence of SITE_ADDRESS in Docker.
* Updated docker-compose.yml to use dynamic CERT_ACME_CA and added CERT_ACME_DNS variable.
* Documented CERT_ACME_DNS usage in variables.env for DNS challenge certificate generation.
2025-06-23 17:19:37 +05:30
akshat5302 25490663f6 update github Docker build context for Caddy
* Changed build context and Dockerfile path from Nginx to Caddy in the GitHub Actions workflow.
* Ensured compatibility with the new Caddy configuration.
2025-06-23 13:30:34 +05:30
akshat5302 432f161ce6 [INFRA-195] feat: update docker-compose and environment variables for proxy configuration
* Added SSL and domain configuration options to the proxy environment.
* Updated port mappings for HTTP and HTTPS in docker-compose.
* Introduced new environment variables for SSL certificate management.
* Refactored existing variables for clarity and consistency.
2025-06-23 11:39:16 +05:30
226 changed files with 4080 additions and 2083 deletions
+2 -2
View File
@@ -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 }}
+1
View File
@@ -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
+1 -1
View File
@@ -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"]
-1
View File
@@ -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 []
+2 -2
View File
@@ -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
+5 -1
View File
@@ -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": (
-1
View File
@@ -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()]
+3 -3
View File
@@ -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: "))
+3 -3
View File
@@ -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
+12 -21
View File
@@ -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
+19 -20
View File
@@ -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)
+33 -26
View File
@@ -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"
+262
View File
@@ -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"
+6 -2
View File
@@ -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
)
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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):
+39 -2
View File
@@ -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
View File
@@ -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()
+2 -2
View File
@@ -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.`);
}
};
+2 -2
View File
@@ -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.`);
}
};
+10 -33
View File
@@ -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)"
);
}
+1 -4
View File
@@ -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 }),
},
});
+16 -25
View File
@@ -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,
};
};
+2 -6
View File
@@ -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);
}
+12 -44
View File
@@ -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;
}
+19 -39
View File
@@ -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
View File
@@ -1 +1 @@
export * from "../../ce/lib/fetch-document.js"
export * from "../../ce/lib/fetch-document.js";
+1 -1
View File
@@ -1 +1 @@
export * from "../../ce/types/common.js"
export * from "../../ce/types/common.js";
+34
View File
@@ -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
}
+14
View File
@@ -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>
@@ -28,4 +28,4 @@ const ProjectArchivedCyclesPage = observer(() => {
);
});
export default ProjectArchivedCyclesPage;
export default ProjectArchivedCyclesPage;
@@ -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>
);
}
+1 -1
View File
@@ -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));
}, []);
};
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -5,7 +5,7 @@ export const metadata: Metadata = {
robots: {
index: true,
follow: false,
}
},
};
export default function SignUpLayout({ children }: { children: React.ReactNode }) {
+1 -3
View File
@@ -15,7 +15,5 @@ export const viewport: Viewport = {
};
export default function HomeLayout({ children }: { children: React.ReactNode }) {
return (
<>{children}</>
);
return <>{children}</>;
}
+1
View File
@@ -0,0 +1 @@
export * from "./root";
+4
View File
@@ -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";
+6 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,2 +1,2 @@
export * from "./estimate.service";
export * from "./view.service";
export * from "./view.service";
+1 -1
View File
@@ -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 (
<>
+9 -3
View File
@@ -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);
};
};
+11 -7
View File
@@ -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";
+6 -1
View File
@@ -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
+8 -7
View File
@@ -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>
);
});
+4 -1
View File
@@ -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