fix: merge conflicts resolved from preview

This commit is contained in:
Aaryan Khandelwal
2025-12-04 14:18:52 +05:30
180 changed files with 2592 additions and 1477 deletions

1
.gitignore vendored
View File

@@ -111,3 +111,4 @@ build/
.react-router/
AGENTS.md
temp/
scripts/

View File

@@ -48,6 +48,7 @@
"uuid": "catalog:"
},
"devDependencies": {
"@dotenvx/dotenvx": "catalog:",
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
@@ -57,7 +58,6 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"dotenv": "^16.4.5",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "^5.1.4"

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import * as dotenv from "@dotenvx/dotenvx";
import { reactRouter } from "@react-router/dev/vite";
import dotenv from "dotenv";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { joinUrlPath } from "@plane/utils";

View File

@@ -32,6 +32,9 @@ AWS_S3_ENDPOINT_URL="http://localhost:9000"
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# Signed URL expiration time in seconds (default: 3600 = 1 hour)
# Set to 30 for 30 seconds, 300 for 5 minutes, etc.
SIGNED_URL_EXPIRATION=3600
# Settings related to Docker
DOCKERIZED=1 # deprecated

View File

@@ -54,4 +54,5 @@ from .asset import (
FileAssetSerializer,
)
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .member import ProjectMemberSerializer
from .sticky import StickySerializer

View File

@@ -4,7 +4,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.db.models import Cycle, CycleIssue, User
from plane.db.models import Cycle, CycleIssue, User, Project
from plane.utils.timezone_converter import convert_to_utc
@@ -55,6 +55,18 @@ class CycleCreateSerializer(BaseSerializer):
]
def validate(self, data):
project_id = self.initial_data.get("project_id") or (
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
)
if not project_id:
raise serializers.ValidationError("Project ID is required")
project = Project.objects.filter(id=project_id).first()
if not project:
raise serializers.ValidationError("Project not found")
if not project.cycle_view:
raise serializers.ValidationError("Cycles are not enabled for this project")
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
@@ -63,13 +75,6 @@ class CycleCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed end date")
if data.get("start_date", None) is not None and data.get("end_date", None) is not None:
project_id = self.initial_data.get("project_id") or (
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
)
if not project_id:
raise serializers.ValidationError("Project ID is required")
data["start_date"] = convert_to_utc(
date=str(data.get("start_date").date()),
project_id=project_id,

View File

@@ -10,6 +10,7 @@ from plane.db.models import (
ModuleMember,
ModuleIssue,
ProjectMember,
Project,
)
@@ -53,6 +54,14 @@ class ModuleCreateSerializer(BaseSerializer):
]
def validate(self, data):
project_id = self.context.get("project_id")
if not project_id:
raise serializers.ValidationError("Project ID is required")
project = Project.objects.get(id=project_id)
if not project:
raise serializers.ValidationError("Project not found")
if not project.module_view:
raise serializers.ValidationError("Modules are not enabled for this project")
if (
data.get("start_date", None) is not None
and data.get("target_date", None) is not None

View File

@@ -17,7 +17,7 @@ from plane.utils.content_validator import (
from .base import BaseSerializer
class ProjectCreateSerializer(BaseSerializer):
class ProjectCreateSerializer(BaseSerializer):
"""
Serializer for creating projects with workspace validation.
@@ -171,7 +171,7 @@ class ProjectUpdateSerializer(ProjectCreateSerializer):
if (
validated_data.get("estimate", None) is not None
and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate")).exists()
and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate").id).exists()
):
# Check if the estimate is a estimate in the project
raise serializers.ValidationError("Estimate should be a estimate in the project")

View File

@@ -0,0 +1,30 @@
from rest_framework import serializers
from .base import BaseSerializer
from plane.db.models import Sticky
from plane.utils.content_validator import validate_html_content, validate_binary_data
class StickySerializer(BaseSerializer):
class Meta:
model = Sticky
fields = "__all__"
read_only_fields = ["workspace", "owner"]
extra_kwargs = {"name": {"required": False}}
def validate(self, data):
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"error": "html content is not valid"})
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
return data

View File

@@ -9,6 +9,7 @@ from .state import urlpatterns as state_patterns
from .user import urlpatterns as user_patterns
from .work_item import urlpatterns as work_item_patterns
from .invite import urlpatterns as invite_patterns
from .sticky import urlpatterns as sticky_patterns
urlpatterns = [
*asset_patterns,
@@ -22,4 +23,5 @@ urlpatterns = [
*user_patterns,
*work_item_patterns,
*invite_patterns,
*sticky_patterns,
]

View File

@@ -0,0 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from plane.api.views import StickyViewSet
router = DefaultRouter()
router.register(r"stickies", StickyViewSet, basename="workspace-stickies")
urlpatterns = [
path("workspaces/<str:slug>/", include(router.urls)),
]

View File

@@ -54,4 +54,6 @@ from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpo
from .user import UserEndpoint
from .invite import WorkspaceInvitationsViewset
from .invite import WorkspaceInvitationsViewset
from .sticky import StickyViewSet

View File

@@ -0,0 +1,109 @@
from rest_framework.response import Response
from rest_framework import status
from plane.api.views.base import BaseViewSet
from plane.app.permissions import WorkspaceUserPermission
from plane.db.models import Sticky, Workspace
from plane.api.serializers import StickySerializer
# OpenAPI imports
from plane.utils.openapi.decorators import sticky_docs
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
from plane.utils.openapi import (
STICKY_EXAMPLE,
create_paginated_response,
DELETED_RESPONSE,
)
class StickyViewSet(BaseViewSet):
serializer_class = StickySerializer
model = Sticky
use_read_replica = True
permission_classes = [WorkspaceUserPermission]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(owner_id=self.request.user.id)
.distinct()
)
@sticky_docs(
operation_id="create_sticky",
summary="Create a new sticky",
description="Create a new sticky in the workspace",
request=OpenApiRequest(request=StickySerializer),
responses={
201: OpenApiResponse(description="Sticky created", response=StickySerializer, examples=[STICKY_EXAMPLE])
},
)
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = StickySerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@sticky_docs(
operation_id="list_stickies",
summary="List stickies",
description="List all stickies in the workspace",
responses={
200: create_paginated_response(
StickySerializer, "Sticky", "List of stickies", example_name="List of stickies"
)
},
)
def list(self, request, slug):
query = request.query_params.get("query", False)
stickies = self.get_queryset().order_by("-created_at")
if query:
stickies = stickies.filter(description_stripped__icontains=query)
return self.paginate(
request=request,
queryset=(stickies),
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
default_per_page=20,
)
@sticky_docs(
operation_id="retrieve_sticky",
summary="Retrieve a sticky",
description="Retrieve a sticky by its ID",
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
)
def retrieve(self, request, slug, pk):
sticky = self.get_object()
return Response(StickySerializer(sticky).data)
@sticky_docs(
operation_id="update_sticky",
summary="Update a sticky",
description="Update a sticky by its ID",
request=OpenApiRequest(request=StickySerializer),
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
)
def partial_update(self, request, slug, pk):
sticky = self.get_object()
serializer = StickySerializer(sticky, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@sticky_docs(
operation_id="delete_sticky",
summary="Delete a sticky",
description="Delete a sticky by its ID",
responses={204: DELETED_RESPONSE},
)
def destroy(self, request, slug, pk):
sticky = self.get_object()
sticky.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -11,7 +11,6 @@ from plane.app.views import (
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
UserProjectInvitationsViewset,
ProjectPublicCoverImagesEndpoint,
UserProjectRolesEndpoint,
ProjectArchiveUnarchiveEndpoint,
ProjectMemberPreferenceEndpoint,
@@ -106,11 +105,6 @@ urlpatterns = [
ProjectFavoritesViewSet.as_view({"delete": "destroy"}),
name="project-favorite",
),
path(
"project-covers/",
ProjectPublicCoverImagesEndpoint.as_view(),
name="project-covers",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/",
DeployBoardViewSet.as_view({"get": "list", "post": "create"}),

View File

@@ -3,7 +3,6 @@ from .project.base import (
ProjectIdentifierEndpoint,
ProjectUserViewsEndpoint,
ProjectFavoritesViewSet,
ProjectPublicCoverImagesEndpoint,
DeployBoardViewSet,
ProjectArchiveUnarchiveEndpoint,
)

View File

@@ -1,43 +1,44 @@
# Python imports
import boto3
from django.conf import settings
from django.utils import timezone
import json
import boto3
# Django imports
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
from django.utils import timezone
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.app.permissions import ROLE, ProjectMemberPermission, allow_permission
from plane.app.serializers import (
ProjectSerializer,
ProjectListSerializer,
DeployBoardSerializer,
ProjectListSerializer,
ProjectSerializer,
)
from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE
from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.db.models import (
UserFavorite,
Intake,
DeployBoard,
Intake,
IssueUserProperty,
Project,
ProjectIdentifier,
ProjectMember,
ProjectNetwork,
State,
DEFAULT_STATES,
Workspace,
WorkspaceMember,
)
from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.exception_logger import log_exception
from plane.utils.host import base_host
@@ -210,19 +211,25 @@ class ProjectViewSet(BaseViewSet):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def retrieve(self, request, slug, pk):
project = (
self.get_queryset()
.filter(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
.filter(archived_at__isnull=True)
.filter(pk=pk)
).first()
project = self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk).first()
if project is None:
return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND)
member_ids = [str(project_member.member_id) for project_member in project.members_list]
if str(request.user.id) not in member_ids:
if project.network == ProjectNetwork.SECRET.value:
return Response(
{"error": "You do not have permission"},
status=status.HTTP_403_FORBIDDEN,
)
else:
return Response(
{"error": "You are not a member of this project"},
status=status.HTTP_409_CONFLICT,
)
recent_visited_task.delay(
slug=slug,
project_id=pk,
@@ -519,49 +526,6 @@ class ProjectFavoritesViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
permission_classes = [AllowAny]
# Cache the below api for 24 hours
@cache_response(60 * 60 * 24, user=False)
def get(self, request):
files = []
if settings.USE_MINIO:
s3 = boto3.client(
"s3",
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
else:
s3 = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
params = {
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
"Prefix": "static/project-cover/",
}
try:
response = s3.list_objects_v2(**params)
# Extracting file keys from the response
if "Contents" in response:
for content in response["Contents"]:
if not content["Key"].endswith(
"/"
): # This line ensures we're only getting files, not "sub-folders"
files.append(
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
)
return Response(files, status=status.HTTP_200_OK)
except Exception as e:
log_exception(e)
return Response([], status=status.HTTP_200_OK)
class DeployBoardViewSet(BaseViewSet):
permission_classes = [ProjectMemberPermission]
serializer_class = DeployBoardSerializer

View File

@@ -164,6 +164,40 @@ class ProjectMemberViewSet(BaseViewSet):
serializer = ProjectMemberRoleSerializer(project_members, fields=("id", "member", "role"), many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve(self, request, slug, project_id, pk):
requesting_project_member = ProjectMember.objects.get(
project_id=project_id,
workspace__slug=slug,
member=request.user,
is_active=True,
)
project_member = (
ProjectMember.objects.filter(
pk=pk,
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
is_active=True,
)
.select_related("project", "member", "workspace")
.first()
)
if not project_member:
return Response(
{"error": "Project member not found"},
status=status.HTTP_404_NOT_FOUND,
)
if requesting_project_member.role > ROLE.GUEST.value:
serializer = ProjectMemberAdminSerializer(project_member)
else:
serializer = ProjectMemberRoleSerializer(project_member, fields=("id", "member", "role"))
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id, is_active=True)

View File

@@ -2,8 +2,6 @@
import uuid
import json
import logging
import random
import string
import secrets
# Django imports
@@ -151,13 +149,7 @@ class UserEndpoint(BaseViewSet):
# Include user ID to bind the code to the specific user
cache_key = f"magic_email_update_{user.id}_{new_email}"
## Generate a random token
token = (
"".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
+ "-"
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
+ "-"
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
)
token = str(secrets.randbelow(900000) + 100000)
# Store in cache with 10 minute expiration
cache_data = json.dumps({"token": token})
cache.set(cache_key, cache_data, timeout=600)

View File

@@ -50,6 +50,25 @@ class WorkSpaceMemberViewSet(BaseViewSet):
serializer = WorkSpaceMemberSerializer(workspace_members, fields=("id", "member", "role"), many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def retrieve(self, request, slug, pk):
workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True)
try:
# Get the specific workspace member by pk
member = self.get_queryset().get(pk=pk)
except WorkspaceMember.DoesNotExist:
return Response(
{"error": "Workspace member not found"},
status=status.HTTP_404_NOT_FOUND,
)
if workspace_member.role > ROLE.GUEST.value:
serializer = WorkspaceMemberAdminSerializer(member, fields=("id", "member", "role"))
else:
serializer = WorkSpaceMemberSerializer(member, fields=("id", "member", "role"))
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def partial_update(self, request, slug, pk):
workspace_member = WorkspaceMember.objects.get(

View File

@@ -1,8 +1,7 @@
# Python imports
import json
import os
import random
import string
import secrets
# Module imports
@@ -50,13 +49,7 @@ class MagicCodeProvider(CredentialAdapter):
def initiate(self):
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
token = str(secrets.randbelow(900000) + 100000)
ri = redis_instance()

View File

@@ -52,6 +52,7 @@ from .project import (
ProjectIdentifier,
ProjectMember,
ProjectMemberInvite,
ProjectNetwork,
ProjectPublicMember,
)
from .session import Session

View File

@@ -175,6 +175,7 @@ class InstanceEndpoint(BaseAPIView):
data["app_base_url"] = settings.APP_BASE_URL
data["instance_changelog_url"] = settings.INSTANCE_CHANGELOG_URL
data["is_self_managed"] = settings.IS_SELF_MANAGED
instance_data = serializer.data
instance_data["workspaces_exist"] = Workspace.objects.count() >= 1

View File

@@ -25,6 +25,9 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", "0"))
# Self-hosted mode
IS_SELF_MANAGED = True
# Allowed Hosts
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
@@ -69,9 +72,7 @@ MIDDLEWARE = [
# Rest Framework settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",),
"DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.AnonRateThrottle",),
"DEFAULT_THROTTLE_RATES": {
"anon": "30/minute",

View File

@@ -29,6 +29,8 @@ class S3Storage(S3Boto3Storage):
self.aws_region = os.environ.get("AWS_REGION")
# Use the AWS_S3_ENDPOINT_URL environment variable for the endpoint URL
self.aws_s3_endpoint_url = os.environ.get("AWS_S3_ENDPOINT_URL") or os.environ.get("MINIO_ENDPOINT_URL")
# Use the SIGNED_URL_EXPIRATION environment variable for the expiration time (default: 3600 seconds)
self.signed_url_expiration = int(os.environ.get("SIGNED_URL_EXPIRATION", "3600"))
if os.environ.get("USE_MINIO") == "1":
# Determine protocol based on environment variable
@@ -56,8 +58,10 @@ class S3Storage(S3Boto3Storage):
config=boto3.session.Config(signature_version="s3v4"),
)
def generate_presigned_post(self, object_name, file_type, file_size, expiration=3600):
def generate_presigned_post(self, object_name, file_type, file_size, expiration=None):
"""Generate a presigned URL to upload an S3 object"""
if expiration is None:
expiration = self.signed_url_expiration
fields = {"Content-Type": file_type}
conditions = [
@@ -104,13 +108,15 @@ class S3Storage(S3Boto3Storage):
def generate_presigned_url(
self,
object_name,
expiration=3600,
expiration=None,
http_method="GET",
disposition="inline",
filename=None,
):
content_disposition = self._get_content_disposition(disposition, filename)
"""Generate a presigned URL to share an S3 object"""
if expiration is None:
expiration = self.signed_url_expiration
content_disposition = self._get_content_disposition(disposition, filename)
try:
response = self.s3_client.generate_presigned_url(
"get_object",

View File

@@ -0,0 +1,202 @@
import os
from unittest.mock import Mock, patch
import pytest
from plane.settings.storage import S3Storage
@pytest.mark.unit
class TestS3StorageSignedURLExpiration:
"""Test the configurable signed URL expiration in S3Storage"""
@patch.dict(os.environ, {}, clear=True)
@patch("plane.settings.storage.boto3")
def test_default_expiration_without_env_variable(self, mock_boto3):
"""Test that default expiration is 3600 seconds when env variable is not set"""
# Mock the boto3 client
mock_boto3.client.return_value = Mock()
# Create S3Storage instance without SIGNED_URL_EXPIRATION env variable
storage = S3Storage()
# Assert default expiration is 3600
assert storage.signed_url_expiration == 3600
@patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "30"}, clear=True)
@patch("plane.settings.storage.boto3")
def test_custom_expiration_with_env_variable(self, mock_boto3):
"""Test that expiration is read from SIGNED_URL_EXPIRATION env variable"""
# Mock the boto3 client
mock_boto3.client.return_value = Mock()
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
storage = S3Storage()
# Assert expiration is 30
assert storage.signed_url_expiration == 30
@patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "300"}, clear=True)
@patch("plane.settings.storage.boto3")
def test_custom_expiration_multiple_values(self, mock_boto3):
"""Test that expiration works with different custom values"""
# Mock the boto3 client
mock_boto3.client.return_value = Mock()
# Create S3Storage instance with SIGNED_URL_EXPIRATION=300
storage = S3Storage()
# Assert expiration is 300
assert storage.signed_url_expiration == 300
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_post_uses_default_expiration(self, mock_boto3):
"""Test that generate_presigned_post uses the configured default expiration"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_post.return_value = {
"url": "https://test-url.com",
"fields": {},
}
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance
storage = S3Storage()
# Call generate_presigned_post without explicit expiration
storage.generate_presigned_post("test-object", "image/png", 1024)
# Assert that the boto3 method was called with the default expiration (3600)
mock_s3_client.generate_presigned_post.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
assert call_kwargs["ExpiresIn"] == 3600
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
"SIGNED_URL_EXPIRATION": "60",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_post_uses_custom_expiration(self, mock_boto3):
"""Test that generate_presigned_post uses custom expiration from env variable"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_post.return_value = {
"url": "https://test-url.com",
"fields": {},
}
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance with SIGNED_URL_EXPIRATION=60
storage = S3Storage()
# Call generate_presigned_post without explicit expiration
storage.generate_presigned_post("test-object", "image/png", 1024)
# Assert that the boto3 method was called with custom expiration (60)
mock_s3_client.generate_presigned_post.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
assert call_kwargs["ExpiresIn"] == 60
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_url_uses_default_expiration(self, mock_boto3):
"""Test that generate_presigned_url uses the configured default expiration"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance
storage = S3Storage()
# Call generate_presigned_url without explicit expiration
storage.generate_presigned_url("test-object")
# Assert that the boto3 method was called with the default expiration (3600)
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
assert call_kwargs["ExpiresIn"] == 3600
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
"SIGNED_URL_EXPIRATION": "30",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_url_uses_custom_expiration(self, mock_boto3):
"""Test that generate_presigned_url uses custom expiration from env variable"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
storage = S3Storage()
# Call generate_presigned_url without explicit expiration
storage.generate_presigned_url("test-object")
# Assert that the boto3 method was called with custom expiration (30)
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
assert call_kwargs["ExpiresIn"] == 30
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
"SIGNED_URL_EXPIRATION": "30",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_explicit_expiration_overrides_default(self, mock_boto3):
"""Test that explicit expiration parameter overrides the default"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
storage = S3Storage()
# Call generate_presigned_url with explicit expiration=120
storage.generate_presigned_url("test-object", expiration=120)
# Assert that the boto3 method was called with explicit expiration (120)
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
assert call_kwargs["ExpiresIn"] == 120

View File

@@ -140,6 +140,7 @@ from .examples import (
WORKSPACE_MEMBER_EXAMPLE,
PROJECT_MEMBER_EXAMPLE,
CYCLE_ISSUE_EXAMPLE,
STICKY_EXAMPLE,
)
# Helper decorators
@@ -292,6 +293,7 @@ __all__ = [
"WORKSPACE_MEMBER_EXAMPLE",
"PROJECT_MEMBER_EXAMPLE",
"CYCLE_ISSUE_EXAMPLE",
"STICKY_EXAMPLE",
# Decorators
"workspace_docs",
"project_docs",

View File

@@ -262,3 +262,18 @@ def state_docs(**kwargs):
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
def sticky_docs(**kwargs):
"""Decorator for sticky management endpoints"""
defaults = {
"tags": ["Stickies"],
"summary": "Endpoints for sticky create/update/delete and fetch sticky details",
"parameters": [WORKSPACE_SLUG_PARAMETER],
"responses": {
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
}
return extend_schema(**_merge_schema_options(defaults, kwargs))

View File

@@ -672,6 +672,15 @@ CYCLE_ISSUE_EXAMPLE = OpenApiExample(
},
)
STICKY_EXAMPLE = OpenApiExample(
name="Sticky",
value={
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Sticky 1",
"description_html": "<p>Sticky 1 description</p>",
"created_at": "2024-01-01T10:30:00Z",
},
)
# Sample data for different entity types
SAMPLE_ISSUE = {
@@ -781,6 +790,13 @@ SAMPLE_CYCLE_ISSUE = {
"created_at": "2024-01-01T10:30:00Z",
}
SAMPLE_STICKY = {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Sticky 1",
"description_html": "<p>Sticky 1 description</p>",
"created_at": "2024-01-01T10:30:00Z",
}
# Mapping of schema types to sample data
SCHEMA_EXAMPLES = {
"Issue": SAMPLE_ISSUE,
@@ -795,6 +811,7 @@ SCHEMA_EXAMPLES = {
"Activity": SAMPLE_ACTIVITY,
"Intake": SAMPLE_INTAKE,
"CycleIssue": SAMPLE_CYCLE_ISSUE,
"Sticky": SAMPLE_STICKY,
}

View File

@@ -24,7 +24,7 @@
},
"author": "Plane Software Inc.",
"dependencies": {
"@dotenvx/dotenvx": "^1.49.0",
"@dotenvx/dotenvx": "catalog:",
"@hocuspocus/extension-database": "2.15.2",
"@hocuspocus/extension-logger": "2.15.2",
"@hocuspocus/extension-redis": "2.15.2",
@@ -41,8 +41,7 @@
"axios": "catalog:",
"compression": "1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express": "catalog:",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "5.7.0",

View File

@@ -111,7 +111,7 @@ export function AuthUniqueCodeForm(props: TAuthUniqueCodeForm) {
name="code"
value={uniqueCodeFormData.code}
onChange={(e) => handleFormChange("code", e.target.value)}
placeholder="gets-sets-flys"
placeholder="123456"
className="disable-autofill-style h-10 w-full border border-subtle !bg-surface-1 pr-12 placeholder:text-placeholder"
autoFocus
/>

View File

@@ -52,6 +52,7 @@
"uuid": "catalog:"
},
"devDependencies": {
"@dotenvx/dotenvx": "catalog:",
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
@@ -61,7 +62,6 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"dotenv": "^16.4.5",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "^5.1.4"

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import * as dotenv from "@dotenvx/dotenvx";
import { reactRouter } from "@react-router/dev/vite";
import dotenv from "dotenv";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { joinUrlPath } from "@plane/utils";

View File

@@ -82,7 +82,7 @@ function IssueDetailsPage({ params }: Route.ComponentProps) {
return (
<>
<PageHead title={pageTitle} />
{error ? (
{error && !issueLoader ? (
<EmptyState
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
title={t("issue.empty_state.issue_detail.title")}

View File

@@ -11,6 +11,7 @@ import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-butt
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
// local imports
import type { Route } from "./+types/layout";
@@ -44,7 +45,9 @@ function ProjectLayout({ params }: Route.ComponentProps) {
</Row>
</div>
)}
<Outlet />
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<Outlet />
</ProjectAuthWrapper>
</>
);
}

View File

@@ -1,14 +0,0 @@
import { Outlet } from "react-router";
// plane web layouts
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
import type { Route } from "./+types/layout";
export default function ProjectDetailLayout({ params }: Route.ComponentProps) {
// router
const { workspaceSlug, projectId } = params;
return (
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<Outlet />
</ProjectAuthWrapper>
);
}

View File

@@ -1,7 +1,6 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { PageHead } from "@/components/core/page-title";
import { ProfileForm } from "@/components/profile/form";
// hooks
@@ -12,13 +11,7 @@ function ProfileSettingsPage() {
// store hooks
const { data: currentUser, userProfile } = useUser();
if (!currentUser)
return (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
);
if (!currentUser) return <></>;
return (
<>
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />

View File

@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { PageHead } from "@/components/core/page-title";
import { PreferencesList } from "@/components/preferences/list";
import { LanguageTimezone } from "@/components/profile/preferences/language-timezone";
@@ -16,30 +15,23 @@ function ProfileAppearancePage() {
// hooks
const { data: userProfile } = useUserProfile();
if (!userProfile) return <></>;
return (
<>
<PageHead title={`${t("profile.label")} - ${t("preferences")}`} />
{userProfile ? (
<>
<div className="flex flex-col gap-4 w-full">
<div>
<SettingsHeading
title={t("account_settings.preferences.heading")}
description={t("account_settings.preferences.description")}
/>
<PreferencesList />
</div>
<div>
<ProfileSettingContentHeader title={t("language_and_time")} />
<LanguageTimezone />
</div>
</div>
</>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
<div className="flex flex-col gap-4 w-full">
<div>
<SettingsHeading
title={t("account_settings.preferences.heading")}
description={t("account_settings.preferences.description")}
/>
<PreferencesList />
</div>
)}
<div>
<ProfileSettingContentHeader title={t("language_and_time")} />
<LanguageTimezone />
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,33 @@
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Outlet } from "react-router";
// components
import { getProjectActivePath } from "@/components/settings/helper";
import { SettingsMobileNav } from "@/components/settings/mobile";
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
// plane web imports
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
// types
import type { Route } from "./+types/layout";
function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
// router
const pathname = usePathname();
return (
<>
<SettingsMobileNav hamburgerContent={ProjectSettingsSidebar} activePath={getProjectActivePath(pathname) || ""} />
<div className="relative flex h-full w-full">
<div className="hidden md:block">{projectId && <ProjectSettingsSidebar />}</div>
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
<Outlet />
</div>
</ProjectAuthWrapper>
</div>
</>
);
}
export default observer(ProjectDetailSettingsLayout);

View File

@@ -1,6 +1,5 @@
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
// components
@@ -24,12 +23,8 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) {
// router
const { workspaceSlug, projectId } = params;
// store hooks
const { currentProjectDetails, fetchProjectDetails } = useProject();
const { currentProjectDetails } = useProject();
const { allowPermissions } = useUserPermissions();
// api call to fetch project details
// TODO: removed this API if not necessary
const { isLoading } = useSWR(`PROJECT_DETAILS_${projectId}`, () => fetchProjectDetails(workspaceSlug, projectId));
// derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
@@ -56,7 +51,7 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) {
)}
<div className={`w-full ${isAdmin ? "" : "opacity-60"}`}>
{currentProjectDetails && !isLoading ? (
{currentProjectDetails ? (
<ProjectDetailsForm
project={currentProjectDetails}
workspaceSlug={workspaceSlug}

View File

@@ -1,21 +1,17 @@
import { useEffect } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Outlet } from "react-router";
// components
import { getProjectActivePath } from "@/components/settings/helper";
import { SettingsMobileNav } from "@/components/settings/mobile";
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
// types
import type { Route } from "./+types/layout";
function ProjectSettingsLayout({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
// router
const router = useAppRouter();
const pathname = usePathname();
const { workspaceSlug, projectId } = params;
// store hooks
const { joinedProjectIds } = useProject();
useEffect(() => {
@@ -25,19 +21,7 @@ function ProjectSettingsLayout({ params }: Route.ComponentProps) {
}
}, [joinedProjectIds, router, workspaceSlug, projectId]);
return (
<>
<SettingsMobileNav hamburgerContent={ProjectSettingsSidebar} activePath={getProjectActivePath(pathname) || ""} />
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<div className="relative flex h-full w-full">
<div className="hidden md:block">{projectId && <ProjectSettingsSidebar />}</div>
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
<Outlet />
</div>
</div>
</ProjectAuthWrapper>
</>
);
return <Outlet />;
}
export default observer(ProjectSettingsLayout);

View File

@@ -1,8 +1,14 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useTheme } from "next-themes";
// plane imports
import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { cn } from "@plane/utils";
// assets
import ProjectDarkEmptyState from "@/app/assets/empty-state/project-settings/no-projects-dark.png?url";
import ProjectLightEmptyState from "@/app/assets/empty-state/project-settings/no-projects-light.png?url";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
function ProjectSettingsPage() {
@@ -10,13 +16,10 @@ function ProjectSettingsPage() {
const { resolvedTheme } = useTheme();
const { toggleCreateProjectModal } = useCommandPalette();
// derived values
const resolvedPath =
resolvedTheme === "dark"
? "/empty-state/project-settings/no-projects-dark.png"
: "/empty-state/project-settings/no-projects-light.png";
const resolvedPath = resolvedTheme === "dark" ? ProjectDarkEmptyState : ProjectLightEmptyState;
return (
<div className="flex flex-col gap-4 items-center justify-center h-full max-w-[480px] mx-auto">
<img src={resolvedPath} className="w-full h-full object-contain" alt="No projects yet" />
<img src={resolvedPath} alt="No projects yet" />
<div className="text-16 font-semibold text-custom-text-350">No projects yet</div>
<div className="text-13 text-custom-text-350 text-center">
Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you
@@ -38,4 +41,4 @@ function ProjectSettingsPage() {
);
}
export default ProjectSettingsPage;
export default observer(ProjectSettingsPage);

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View File

@@ -25,8 +25,8 @@ const PostHogProvider = lazy(function PostHogProvider() {
return import("@/lib/posthog-provider");
});
const IntercomProvider = lazy(function IntercomProvider() {
return import("@/lib/intercom-provider");
const ChatSupportModal = lazy(function ChatSupportModal() {
return import("@/components/global/chat-support-modal");
});
export interface IAppProvider {
@@ -50,11 +50,10 @@ export function AppProvider(props: IAppProvider) {
<StoreWrapper>
<InstanceWrapper>
<Suspense>
<IntercomProvider>
<PostHogProvider>
<SWRConfig value={WEB_SWR_CONFIG}>{children}</SWRConfig>
</PostHogProvider>
</IntercomProvider>
<ChatSupportModal />
<PostHogProvider>
<SWRConfig value={WEB_SWR_CONFIG}>{children}</SWRConfig>
</PostHogProvider>
</Suspense>
</InstanceWrapper>
</StoreWrapper>

View File

@@ -108,9 +108,13 @@ export const coreRoutes: RouteConfigEntry[] = [
),
]),
// ====================================================================
// PROJECT LEVEL ROUTES
// ====================================================================
// Archived Projects
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [
route(
":workspaceSlug/projects/archives",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx"
),
]),
// --------------------------------------------------------------------
// PROJECT LEVEL ROUTES
@@ -122,136 +126,123 @@ export const coreRoutes: RouteConfigEntry[] = [
]),
// Project Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx", [
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx", [
// Project Issues List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/issues",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
),
]),
// Issue Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx", [
// Project Issues List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/issues/:issueId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
),
// Cycle Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles/:cycleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
),
]),
// Cycles List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx"
),
]),
// Module Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules/:moduleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx"
),
]),
// Modules List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx"
),
]),
// View Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views/:viewId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx"
),
]),
// Views List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx"
),
]),
// Page Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages/:pageId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx"
),
]),
// Pages List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx"
),
]),
// Intake list
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/intake",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx"
),
]),
]),
// Archived Projects
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [
route(
":workspaceSlug/projects/archives",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx"
":workspaceSlug/projects/:projectId/issues",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
),
]),
// Project Archives - Issues, Cycles, Modules
// Project Archives - Issues - List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/archives/issues",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx"
),
]),
// Project Archives - Issues - Detail
layout(
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx",
[
route(
":workspaceSlug/projects/:projectId/archives/issues/:archivedIssueId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx"
),
]
// Issue Detail
route(
":workspaceSlug/projects/:projectId/issues/:issueId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
),
// Project Archives - Cycles
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx", [
// Cycle Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/archives/cycles",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx"
":workspaceSlug/projects/:projectId/cycles/:cycleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
),
]),
// Project Archives - Modules
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx", [
// Cycles List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/archives/modules",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx"
":workspaceSlug/projects/:projectId/cycles",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx"
),
]),
// Module Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules/:moduleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx"
),
]),
// Modules List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx"
),
]),
// View Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views/:viewId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx"
),
]),
// Views List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx"
),
]),
// Page Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages/:pageId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx"
),
]),
// Pages List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx"
),
]),
// Intake list
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/intake",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx"
),
]),
]),
// Project Archives - Issues, Cycles, Modules
// Project Archives - Issues - List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/archives/issues",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx"
),
]),
// Project Archives - Issues - Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/archives/issues/:archivedIssueId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx"
),
]),
// Project Archives - Cycles
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/archives/cycles",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx"
),
]),
// Project Archives - Modules
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/archives/modules",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx"
),
]),
]),
@@ -320,44 +311,46 @@ export const coreRoutes: RouteConfigEntry[] = [
// --------------------------------------------------------------------
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx", [
// CORE Routes
// Project Settings
// No Projects available page
route(":workspaceSlug/settings/projects", "./(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx"),
route(
":workspaceSlug/settings/projects/:projectId",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx"
),
// Project Members
route(
":workspaceSlug/settings/projects/:projectId/members",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx"
),
// Project Features
route(
":workspaceSlug/settings/projects/:projectId/features",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx"
),
// Project States
route(
":workspaceSlug/settings/projects/:projectId/states",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx"
),
// Project Labels
route(
":workspaceSlug/settings/projects/:projectId/labels",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx"
),
// Project Estimates
route(
":workspaceSlug/settings/projects/:projectId/estimates",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx"
),
// Project Automations
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx", [
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx", [
// Project Settings
route(
":workspaceSlug/settings/projects/:projectId/automations",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx"
":workspaceSlug/settings/projects/:projectId",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx"
),
// Project Members
route(
":workspaceSlug/settings/projects/:projectId/members",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx"
),
// Project Features
route(
":workspaceSlug/settings/projects/:projectId/features",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx"
),
// Project States
route(
":workspaceSlug/settings/projects/:projectId/states",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx"
),
// Project Labels
route(
":workspaceSlug/settings/projects/:projectId/labels",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx"
),
// Project Estimates
route(
":workspaceSlug/settings/projects/:projectId/estimates",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx"
),
// Project Automations
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx", [
route(
":workspaceSlug/settings/projects/:projectId/automations",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx"
),
]),
]),
]),
]),

View File

@@ -1,2 +1 @@
export * from "./version-number";
export * from "./product-updates-header";

View File

@@ -0,0 +1,83 @@
import { useState, useEffect, useRef } from "react";
import { observer } from "mobx-react";
// hooks
import { Loader } from "@plane/ui";
import { ProductUpdatesFallback } from "@/components/global/product-updates/fallback";
import { useInstance } from "@/hooks/store/use-instance";
export const ProductUpdatesChangelog = observer(function ProductUpdatesChangelog() {
// refs
const isLoadingRef = useRef(true);
// states
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
// store hooks
const { config } = useInstance();
// derived values
const changeLogUrl = config?.instance_changelog_url;
const shouldShowFallback = !changeLogUrl || changeLogUrl === "" || hasError;
// timeout fallback - if iframe doesn't load within 15 seconds, show error
useEffect(() => {
if (!changeLogUrl || changeLogUrl === "") {
setIsLoading(false);
isLoadingRef.current = false;
return;
}
setIsLoading(true);
setHasError(false);
isLoadingRef.current = true;
const timeoutId = setTimeout(() => {
if (isLoadingRef.current) {
setHasError(true);
setIsLoading(false);
isLoadingRef.current = false;
}
}, 15000); // 15 second timeout
return () => {
clearTimeout(timeoutId);
};
}, [changeLogUrl]);
const handleIframeLoad = () => {
setTimeout(() => {
isLoadingRef.current = false;
setIsLoading(false);
}, 1000);
};
const handleIframeError = () => {
isLoadingRef.current = false;
setHasError(true);
setIsLoading(false);
};
// Show fallback if URL is missing, empty, or iframe failed to load
if (shouldShowFallback) {
return (
<ProductUpdatesFallback
description="We're having trouble fetching the updates. Please visit our changelog to view the latest updates."
variant={config?.is_self_managed ? "self-managed" : "cloud"}
/>
);
}
return (
<div className="flex flex-col h-[550px] vertical-scrollbar scrollbar-xs overflow-hidden overflow-y-scroll px-6 mx-0.5 relative">
{isLoading && (
<Loader className="flex flex-col gap-3 absolute inset-0 w-full h-full items-center justify-center">
<Loader.Item height="95%" width="95%" />
</Loader>
)}
<iframe
src={changeLogUrl}
className={`w-full h-full ${isLoading ? "opacity-0" : "opacity-100"} transition-opacity duration-200`}
onLoad={handleIframeLoad}
onError={handleIframeError}
/>
</div>
);
});

View File

@@ -1,10 +1,8 @@
import { observer } from "mobx-react";
import packageJson from "package.json";
import { useTranslation } from "@plane/i18n";
import { PlaneLogo } from "@plane/propel/icons";
// helpers
import { cn } from "@plane/utils";
// package.json
import packageJson from "package.json";
export const ProductUpdatesHeader = observer(function ProductUpdatesHeader() {
const { t } = useTranslation();
@@ -20,9 +18,6 @@ export const ProductUpdatesHeader = observer(function ProductUpdatesHeader() {
{t("version")}: v{packageJson.version}
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-8">
<PlaneLogo className="h-6 w-auto text-primary" />
</div>
</div>
);
});

View File

@@ -1,17 +1,41 @@
// components
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@plane/utils";
import { TopNavPowerK } from "@/components/navigation";
import { HelpMenuRoot } from "@/components/workspace/sidebar/help-section/root";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
import { Tooltip } from "@plane/propel/tooltip";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { InboxIcon } from "@plane/propel/icons";
import useSWR from "swr";
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
export const TopNavigationRoot = observer(() => {
// router
const { workspaceSlug, projectId, workItem } = useParams();
const pathname = usePathname();
// store hooks
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
const { preferences } = useAppRailPreferences();
const showLabel = preferences.displayMode === "icon_with_label";
// Fetch notification count
useSWR(
workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null,
workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug.toString()) : null
);
// Calculate notification count
const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0;
const totalNotifications = isMentionsEnabled
? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_unread_notifications_count;
return (
<div
className={cn("flex items-center min-h-11 w-full px-3.5 bg-canvas z-[27] transition-all duration-300", {
@@ -28,6 +52,23 @@ export const TopNavigationRoot = observer(() => {
</div>
{/* Additional Actions */}
<div className="shrink-0 flex-1 flex gap-1 items-center justify-end">
<Tooltip tooltipContent="Inbox" position="bottom">
<AppSidebarItem
variant="link"
item={{
href: `/${workspaceSlug?.toString()}/notifications/`,
icon: (
<div className="relative">
<InboxIcon className="size-5" />
{totalNotifications > 0 && (
<span className="absolute -top-0 -right-0 size-2 rounded-full bg-red-500" />
)}
</div>
),
isActive: pathname?.includes("/notifications/"),
}}
/>
</Tooltip>
<HelpMenuRoot />
<div className="flex items-center justify-center size-8 hover:bg-layer-1 rounded-md">
<UserMenuRoot size="xs" />

View File

@@ -18,7 +18,7 @@ import { useUser } from "@/hooks/store/user";
// local imports
import { TourSidebar } from "./sidebar";
type Props = {
export type TOnboardingTourProps = {
onComplete: () => void;
};
@@ -28,7 +28,7 @@ const TOUR_STEPS: {
key: TTourSteps;
title: string;
description: string;
image: any;
image: string;
prevStep?: TTourSteps;
nextStep?: TTourSteps;
}[] = [
@@ -75,7 +75,7 @@ const TOUR_STEPS: {
},
];
export const TourRoot = observer(function TourRoot(props: Props) {
export const TourRoot = observer(function TourRoot(props: TOnboardingTourProps) {
const { onComplete } = props;
// states
const [step, setStep] = useState<TTourSteps>("welcome");
@@ -89,12 +89,12 @@ export const TourRoot = observer(function TourRoot(props: Props) {
return (
<>
{step === "welcome" ? (
<div className="h-3/4 w-4/5 overflow-hidden rounded-[10px] bg-surface-1 md:w-1/2 lg:w-2/5">
<div className="w-4/5 overflow-hidden rounded-[10px] bg-surface-1 md:w-1/2 lg:w-2/5">
<div className="h-full overflow-hidden">
<div className="grid h-3/5 place-items-center bg-custom-primary-100">
<PlaneLockup className="h-10 w-auto text-primary" />
<div className="grid h-64 place-items-center bg-custom-primary-100">
<PlaneLockup className="h-10 w-auto text-white" />
</div>
<div className="flex h-2/5 flex-col overflow-y-auto p-6">
<div className="flex flex-col overflow-y-auto p-6">
<h3 className="font-semibold sm:text-18">
Welcome to Plane, {currentUser?.first_name} {currentUser?.last_name}
</h3>
@@ -103,7 +103,7 @@ export const TourRoot = observer(function TourRoot(props: Props) {
started by creating a project.
</p>
<div className="flex h-full items-end">
<div className="mt-8 flex items-center gap-6">
<div className="mt-12 flex items-center gap-6">
<Button
variant="primary"
onClick={() => {

View File

@@ -1,12 +1,13 @@
// icons
// plane imports
import { CycleIcon, ModuleIcon, PageIcon, ViewsIcon, WorkItemsIcon } from "@plane/propel/icons";
import type { ISvgIcons } from "@plane/propel/icons";
// types
import type { TTourSteps } from "./root";
const sidebarOptions: {
key: TTourSteps;
label: string;
Icon: any;
Icon: React.FC<ISvgIcons>;
}[] = [
{
key: "work-items",

View File

@@ -1,22 +1,25 @@
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { FormProvider, useForm } from "react-hook-form";
import { DEFAULT_PROJECT_FORM_VALUES, PROJECT_TRACKER_EVENTS } from "@plane/constants";
import { PROJECT_TRACKER_EVENTS, RANDOM_EMOJI_CODES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EFileAssetType } from "@plane/types";
import type { IProject } from "@plane/types";
// constants
import ProjectCommonAttributes from "@/components/project/create/common-attributes";
import ProjectCreateHeader from "@/components/project/create/header";
import ProjectCreateButtons from "@/components/project/create/project-create-buttons";
// hooks
import { DEFAULT_COVER_IMAGE_URL, getCoverImageType, uploadCoverImage } from "@/helpers/cover-image.helper";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useProject } from "@/hooks/store/use-project";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web types
import type { TProject } from "@/plane-web/types/projects";
import ProjectAttributes from "./attributes";
import { getProjectFormValues } from "./utils";
export type TCreateProjectFormProps = {
setToFavorite?: boolean;
@@ -32,12 +35,12 @@ export const CreateProjectForm = observer(function CreateProjectForm(props: TCre
const { setToFavorite, workspaceSlug, data, onClose, handleNextStep, updateCoverImageStatus } = props;
// store
const { t } = useTranslation();
const { addProjectToFavorites, createProject } = useProject();
const { addProjectToFavorites, createProject, updateProject } = useProject();
// states
const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true);
// form info
const methods = useForm<TProject>({
defaultValues: { ...DEFAULT_PROJECT_FORM_VALUES, ...data },
defaultValues: { ...getProjectFormValues(), ...data },
reValidateMode: "onChange",
});
const { handleSubmit, reset, setValue } = methods;
@@ -58,16 +61,42 @@ export const CreateProjectForm = observer(function CreateProjectForm(props: TCre
// Upper case identifier
formData.identifier = formData.identifier?.toUpperCase();
const coverImage = formData.cover_image_url;
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
if (coverImage?.startsWith("http")) {
formData.cover_image = coverImage;
formData.cover_image_asset = null;
let uploadedAssetUrl: string | null = null;
if (coverImage) {
const imageType = getCoverImageType(coverImage);
if (imageType === "local_static") {
try {
uploadedAssetUrl = await uploadCoverImage(coverImage, {
workspaceSlug: workspaceSlug.toString(),
entityIdentifier: "",
entityType: EFileAssetType.PROJECT_COVER,
isUserAsset: false,
});
} catch (error) {
console.error("Error uploading cover image:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: error instanceof Error ? error.message : "Failed to upload cover image",
});
return Promise.reject(error);
}
} else {
formData.cover_image = coverImage;
formData.cover_image_asset = null;
}
}
return createProject(workspaceSlug.toString(), formData)
.then(async (res) => {
if (coverImage) {
if (uploadedAssetUrl) {
await updateCoverImageStatus(res.id, uploadedAssetUrl);
await updateProject(workspaceSlug.toString(), res.id, { cover_image_url: uploadedAssetUrl });
} else if (coverImage && coverImage.startsWith("http")) {
await updateCoverImageStatus(res.id, coverImage);
await updateProject(workspaceSlug.toString(), res.id, { cover_image_url: coverImage });
}
captureSuccess({
eventName: PROJECT_TRACKER_EVENTS.create,

View File

@@ -0,0 +1,18 @@
import { RANDOM_EMOJI_CODES } from "@plane/constants";
import type { IProject } from "@plane/types";
import { getRandomCoverImage } from "@/helpers/cover-image.helper";
export const getProjectFormValues = (): Partial<IProject> => ({
cover_image_url: getRandomCoverImage(),
description: "",
logo_props: {
in_use: "emoji",
emoji: {
value: RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)],
},
},
identifier: "",
name: "",
network: 2,
project_lead: null,
});

View File

@@ -1,11 +1,10 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// layouts
import { ProjectAuthWrapper as CoreProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper";
export type IProjectAuthWrapper = {
workspaceSlug: string;
projectId?: string;
projectId: string;
children: React.ReactNode;
};

View File

@@ -1,67 +0,0 @@
import { useState } from "react";
import { useParams } from "next/navigation";
import { ClipboardList } from "lucide-react";
// plane imports
import { Button } from "@plane/propel/button";
// assets
import Unauthorized from "@/app/assets/auth/unauthorized.svg?url";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
type Props = {
projectId?: string;
isPrivateProject?: boolean;
};
export function JoinProject(props: Props) {
const { projectId, isPrivateProject = false } = props;
// states
const [isJoiningProject, setIsJoiningProject] = useState(false);
// store hooks
const { joinProject } = useUserPermissions();
const { fetchProjectDetails } = useProject();
const { workspaceSlug } = useParams();
const handleJoin = () => {
if (!workspaceSlug || !projectId) return;
setIsJoiningProject(true);
joinProject(workspaceSlug.toString(), projectId.toString())
.then(() => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()))
.finally(() => setIsJoiningProject(false));
};
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-surface-1 text-center">
<div className="h-44 w-72">
<img src={Unauthorized} className="h-[176px] w-[288px] object-contain" alt="JoinProject" />
</div>
<h1 className="text-18 font-medium text-primary">
{!isPrivateProject ? `You are not a member of this project yet.` : `You are not a member of this project.`}
</h1>
<div className="w-full max-w-md text-14 text-secondary">
<p className="mx-auto w-full text-13 md:w-3/4">
{!isPrivateProject
? `Click the button below to join it.`
: `This is a private project. \n We can't tell you more about this project to protect confidentiality.`}
</p>
</div>
{!isPrivateProject && (
<div>
<Button
variant="primary"
prependIcon={<ClipboardList color="white" />}
loading={isJoiningProject}
onClick={handleJoin}
>
{isJoiningProject ? "Taking you in" : "Click to join"}
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
type TProps = {
isWorkspaceAdmin: boolean;
handleJoinProject: () => void;
isJoinButtonDisabled: boolean;
errorStatusCode: number | undefined;
};
export const ProjectAccessRestriction = observer(function ProjectAccessRestriction(props: TProps) {
const { isWorkspaceAdmin, handleJoinProject, isJoinButtonDisabled, errorStatusCode } = props;
// plane hooks
const { t } = useTranslation();
// Show join project screen if:
// - User lacks project membership (409 Conflict)
// - User lacks permission to access the private project (403 Forbidden) but is a workspace admin (can join any project)
if (errorStatusCode === 409 || (errorStatusCode === 403 && isWorkspaceAdmin))
return (
<div className="grid h-full w-full place-items-center bg-custom-background-100">
<EmptyStateDetailed
title={t("project_empty_state.no_access.title")}
description={t("project_empty_state.no_access.join_description")}
assetKey="no-access"
assetClassName="size-40"
actions={[
{
label: isJoinButtonDisabled
? t("project_empty_state.no_access.cta_loading")
: t("project_empty_state.no_access.cta_primary"),
onClick: handleJoinProject,
disabled: isJoinButtonDisabled,
},
]}
/>
</div>
);
// Show no access screen if:
// - User lacks permission to access the private project (403 Forbidden)
if (errorStatusCode === 403) {
return (
<div className="grid h-full w-full place-items-center bg-custom-background-100">
<EmptyStateDetailed
title={t("project_empty_state.no_access.title")}
description={t("project_empty_state.no_access.restricted_description")}
assetKey="no-access"
assetClassName="size-40"
/>
</div>
);
}
// Show empty state screen if:
// - Project not found (404 Not Found)
// - Any other error status code
return (
<div className="grid h-full w-full place-items-center bg-custom-background-100">
<EmptyStateDetailed
title={t("project_empty_state.invalid_project.title")}
description={t("project_empty_state.invalid_project.description")}
assetKey="project"
assetClassName="size-40"
/>
</div>
);
});

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useCallback } from "react";
import React, { useState, useRef, useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useDropzone } from "react-dropzone";
@@ -14,26 +14,18 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EFileAssetType } from "@plane/types";
import { Input, Loader } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
import { STATIC_COVER_IMAGES, getCoverImageDisplayURL } from "@/helpers/cover-image.helper";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down";
// services
import { FileService } from "@/services/file.service";
const tabOptions = [
{
key: "unsplash",
title: "Unsplash",
},
{
key: "images",
title: "Images",
},
{
key: "upload",
title: "Upload",
},
];
type TTabOption = {
key: string;
title: string;
isEnabled: boolean;
};
type Props = {
label: string | React.ReactNode;
@@ -63,6 +55,30 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
const ref = useRef<HTMLDivElement>(null);
// router params
const { workspaceSlug } = useParams();
// store hooks
const { config } = useInstance();
// derived values
const hasUnsplashConfigured = config?.has_unsplash_configured || false;
const tabOptions: TTabOption[] = useMemo(
() => [
{
key: "unsplash",
title: "Unsplash",
isEnabled: hasUnsplashConfigured,
},
{
key: "images",
title: "Images",
isEnabled: true,
},
{
key: "upload",
title: "Upload",
isEnabled: true,
},
],
[hasUnsplashConfigured]
);
const { data: unsplashImages, error: unsplashError } = useSWR(
`UNSPLASH_IMAGES_${searchParams}`,
@@ -73,11 +89,6 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
}
);
const { data: projectCoverImages } = useSWR(`PROJECT_COVER_IMAGES`, () => fileService.getProjectCoverImages(), {
revalidateOnFocus: false,
revalidateOnReconnect: false,
});
const imagePickerRef = useRef<HTMLDivElement>(null);
const onDrop = useCallback((acceptedFiles: File[]) => {
@@ -90,6 +101,11 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
maxSize: MAX_FILE_SIZE,
});
const handleStaticImageSelect = (imageUrl: string) => {
onChange(imageUrl);
setIsOpen(false);
};
const handleSubmit = async () => {
if (!image) return;
setIsImageUploading(true);
@@ -183,130 +199,105 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
>
<Tab.Group>
<Tab.List as="span" className="inline-block rounded-sm bg-layer-1 p-1">
{tabOptions.map((tab) => {
if (!unsplashImages && unsplashError && tab.key === "unsplash") return null;
if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images") return null;
return (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded-sm px-4 py-1 text-center text-13 outline-none transition-colors ${
selected ? "bg-custom-primary text-white" : "text-primary"
}`
}
>
{tab.title}
</Tab>
);
})}
{tabOptions.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded px-4 py-1 text-center text-sm outline-none transition-colors ${
selected ? "bg-custom-primary text-white" : "text-primary"
}`
}
>
{tab.title}
</Tab>
))}
</Tab.List>
<Tab.Panels className="vertical-scrollbar scrollbar-md h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
{(unsplashImages || !unsplashError) && (
<Tab.Panel className="mt-4 h-full w-full space-y-4">
<div className="flex gap-x-2">
<Controller
control={control}
name="search"
render={({ field: { value, ref } }) => (
<Input
id="search"
name="search"
type="text"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
setSearchParams(formData.search);
}
}}
value={value}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
ref={ref}
placeholder="Search for images"
className="w-full text-13"
/>
)}
/>
<Button variant="primary" onClick={() => setSearchParams(formData.search)} size="sm">
Search
</Button>
</div>
{unsplashImages ? (
unsplashImages.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{unsplashImages.map((image) => (
<div
key={image.id}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
<Tab.Panel className="mt-4 h-full w-full space-y-4">
{(unsplashImages || !unsplashError) && (
<>
<div className="flex gap-x-2">
<Controller
control={control}
name="search"
render={({ field: { value, ref } }) => (
<Input
id="search"
name="search"
type="text"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
setSearchParams(formData.search);
}
}}
>
<img
src={image.urls.small}
alt={image.alt_description}
className="absolute left-0 top-0 h-full w-full cursor-pointer rounded-sm object-cover"
/>
</div>
))}
</div>
value={value}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
ref={ref}
placeholder="Search for images"
className="w-full text-sm"
/>
)}
/>
<Button variant="primary" onClick={() => setSearchParams(formData.search)} size="sm">
Search
</Button>
</div>
{unsplashImages ? (
unsplashImages.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{unsplashImages.map((image) => (
<div
key={image.id}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
}}
>
<img
src={image.urls.small}
alt={image.alt_description}
className="absolute left-0 top-0 h-full w-full cursor-pointer rounded object-cover"
/>
</div>
))}
</div>
) : (
<p className="pt-7 text-center text-xs text-secondary">No images found.</p>
)
) : (
<p className="pt-7 text-center text-11 text-tertiary">No images found.</p>
)
) : (
<Loader className="grid grid-cols-4 gap-4">
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
</Loader>
)}
</Tab.Panel>
)}
{(!projectCoverImages || projectCoverImages.length !== 0) && (
<Tab.Panel className="mt-4 h-full w-full space-y-4">
{projectCoverImages ? (
projectCoverImages.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{projectCoverImages.map((image, index) => (
<div
key={image}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => {
setIsOpen(false);
onChange(image);
}}
>
<img
src={image}
alt={`Default project cover image- ${index}`}
className="absolute left-0 top-0 h-full w-full cursor-pointer rounded-sm object-cover"
/>
</div>
))}
</div>
) : (
<p className="pt-7 text-center text-11 text-tertiary">No images found.</p>
)
) : (
<Loader className="grid grid-cols-4 gap-4 pt-4">
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
</Loader>
)}
</Tab.Panel>
)}
<Loader className="grid grid-cols-4 gap-4">
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
</Loader>
)}
</>
)}
</Tab.Panel>
<Tab.Panel className="mt-4 h-full w-full space-y-4">
<div className="grid grid-cols-4 gap-4">
{Object.values(STATIC_COVER_IMAGES).map((imageUrl, index) => (
<div
key={imageUrl}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => handleStaticImageSelect(imageUrl)}
>
<img
src={imageUrl}
alt={`Cover image ${index + 1}`}
className="absolute left-0 top-0 h-full w-full cursor-pointer rounded object-cover hover:opacity-80 transition-opacity"
/>
</div>
))}
</div>
</Tab.Panel>
<Tab.Panel className="mt-4 h-full w-full">
<div className="flex h-full w-full flex-col gap-y-2">
<div className="flex w-full flex-1 items-center gap-3">
@@ -327,7 +318,7 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
{image !== null || (value && value !== "") ? (
<>
<img
src={image ? URL.createObjectURL(image) : value ? (getFileURL(value) ?? "") : ""}
src={image ? URL.createObjectURL(image) : getCoverImageDisplayURL(value, "")}
alt="image"
className="rounded-lg h-full w-full object-cover"
/>

View File

@@ -0,0 +1,43 @@
import { useEffect } from "react";
import { Intercom, shutdown, show } from "@intercom/messenger-js-sdk";
import { observer } from "mobx-react";
// custom events
import { CHAT_SUPPORT_EVENTS } from "@/custom-events/chat-support";
// store hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useUser } from "@/hooks/store/user";
const ChatSupportModal = observer(function ChatSupportModal() {
// store hooks
const { data: user } = useUser();
const { config } = useInstance();
// derived values
const intercomAppId = config?.intercom_app_id;
const isEnabled = Boolean(user && config?.is_intercom_enabled && intercomAppId);
useEffect(() => {
if (!isEnabled || !user || !intercomAppId) return;
Intercom({
app_id: intercomAppId,
user_id: user.id,
name: `${user.first_name} ${user.last_name}`,
email: user.email,
hide_default_launcher: true,
});
const handleOpenChatSupport = () => {
show();
};
window.addEventListener(CHAT_SUPPORT_EVENTS.open, handleOpenChatSupport);
return () => {
window.removeEventListener(CHAT_SUPPORT_EVENTS.open, handleOpenChatSupport);
shutdown();
};
}, [user, intercomAppId, isEnabled]);
return null;
});
export default ChatSupportModal;

View File

@@ -0,0 +1,32 @@
import { EmptyStateDetailed } from "@plane/propel/empty-state";
type TProductUpdatesFallbackProps = {
description: string;
variant: "cloud" | "self-managed";
};
export function ProductUpdatesFallback(props: TProductUpdatesFallbackProps) {
const { description, variant } = props;
// derived values
const changelogUrl =
variant === "cloud"
? "https://plane.so/changelog?category=cloud"
: "https://plane.so/changelog?category=self-hosted";
return (
<div className="py-8">
<EmptyStateDetailed
assetKey="changelog"
description={description}
align="center"
actions={[
{
label: "Go to changelog",
variant: "primary",
onClick: () => window.open(changelogUrl, "_blank"),
},
]}
/>
</div>
);
}

View File

@@ -1,18 +1,15 @@
import type { FC } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { USER_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// components
import { ProductUpdatesFooter } from "@/components/global";
// helpers
import { captureView } from "@/helpers/event-tracker.helper";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
// plane web components
import { ProductUpdatesHeader } from "@/plane-web/components/global";
import { ProductUpdatesChangelog } from "@/plane-web/components/global/product-updates/changelog";
import { ProductUpdatesHeader } from "@/plane-web/components/global/product-updates/header";
export type ProductUpdatesModalProps = {
isOpen: boolean;
@@ -21,8 +18,6 @@ export type ProductUpdatesModalProps = {
export const ProductUpdatesModal = observer(function ProductUpdatesModal(props: ProductUpdatesModalProps) {
const { isOpen, handleClose } = props;
const { t } = useTranslation();
const { config } = useInstance();
useEffect(() => {
if (isOpen) {
@@ -33,27 +28,7 @@ export const ProductUpdatesModal = observer(function ProductUpdatesModal(props:
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXXXL}>
<ProductUpdatesHeader />
<div className="flex flex-col h-[60vh] vertical-scrollbar scrollbar-xs overflow-hidden overflow-y-scroll px-6 mx-0.5">
{config?.instance_changelog_url && config?.instance_changelog_url !== "" ? (
<iframe src={config?.instance_changelog_url} className="w-full h-full" />
) : (
<div className="flex flex-col items-center justify-center w-full h-full mb-8">
<div className="text-16 font-medium">{t("we_are_having_trouble_fetching_the_updates")}</div>
<div className="text-13 text-secondary">
{t("please_visit")}
<a
data-ph-element={USER_TRACKER_ELEMENTS.CHANGELOG_REDIRECTED}
href="https://go.plane.so/p-changelog"
target="_blank"
className="text-13 text-custom-primary-100 font-medium hover:text-custom-primary-200 underline underline-offset-1 outline-none"
>
{t("our_changelogs")}
</a>{" "}
{t("for_the_latest_updates")}.
</div>
</div>
)}
</div>
<ProductUpdatesChangelog />
<ProductUpdatesFooter />
</ModalCore>
);

View File

@@ -4,15 +4,14 @@ import useSWR from "swr";
// plane imports
import { PRODUCT_TOUR_TRACKER_EVENTS } from "@plane/constants";
import { ContentWrapper } from "@plane/ui";
// components
import { TourRoot } from "@/components/onboarding/tour";
// helpers
import { captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useHome } from "@/hooks/store/use-home";
import { useUserProfile, useUser } from "@/hooks/store/user";
// plane web components
// plane web imports
import { HomePeekOverviewsRoot } from "@/plane-web/components/home";
import { TourRoot } from "@/plane-web/components/onboarding/tour/root";
// local imports
import { DashboardWidgets } from "./home-dashboard-widgets";
import { UserGreetingsView } from "./user-greetings";
@@ -53,7 +52,7 @@ export const WorkspaceHomeView = observer(function WorkspaceHomeView() {
return (
<>
{currentUserProfile && !currentUserProfile.is_tour_completed && (
<div className="fixed left-0 top-0 z-20 grid h-full w-full place-items-center bg-backdrop bg-opacity-50 transition-opacity">
<div className="fixed left-0 top-0 z-20 grid h-full w-full place-items-center bg-backdrop bg-opacity-50 transition-opacity overflow-y-auto">
<TourRoot onComplete={handleTourCompleted} />
</div>
)}

View File

@@ -6,7 +6,6 @@ import useSWR from "swr";
import { ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { EIssuesStoreType } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
// hooks
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
@@ -26,7 +25,7 @@ export const ArchivedIssueLayoutRoot = observer(function ArchivedIssueLayoutRoot
// derived values
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
const { isLoading } = useSWR(
useSWR(
workspaceSlug && projectId ? `ARCHIVED_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
async () => {
if (workspaceSlug && projectId) {
@@ -36,15 +35,7 @@ export const ArchivedIssueLayoutRoot = observer(function ArchivedIssueLayoutRoot
{ revalidateIfStale: false, revalidateOnFocus: false }
);
if (!workspaceSlug || !projectId) return <></>;
if (isLoading && !workItemFilters)
return (
<div className="h-full w-full flex items-center justify-center">
<LogoSpinner />
</div>
);
if (!workspaceSlug || !projectId || !workItemFilters) return <></>;
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.ARCHIVED}>
<ProjectLevelWorkItemFiltersHOC

View File

@@ -7,7 +7,6 @@ import useSWR from "swr";
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { TransferIssues } from "@/components/cycles/transfer-issues";
import { TransferIssuesModal } from "@/components/cycles/transfer-issues-modal";
// hooks
@@ -59,7 +58,7 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
const workItemFilters = cycleId ? issuesFilter?.getIssueFilters(cycleId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout;
const { isLoading } = useSWR(
useSWR(
workspaceSlug && projectId && cycleId ? `CYCLE_ISSUES_${workspaceSlug}_${projectId}_${cycleId}` : null,
async () => {
if (workspaceSlug && projectId && cycleId) {
@@ -78,15 +77,7 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
: 0;
const canTransferIssues = isProgressSnapshotEmpty && transferableIssuesCount > 0;
if (!workspaceSlug || !projectId || !cycleId) return <></>;
if (isLoading && !workItemFilters)
return (
<div className="h-full w-full flex items-center justify-center">
<LogoSpinner />
</div>
);
if (!workspaceSlug || !projectId || !cycleId || !workItemFilters) return <></>;
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.CYCLE}>
<ProjectLevelWorkItemFiltersHOC

View File

@@ -6,8 +6,6 @@ import useSWR from "swr";
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
import { Row, ERowVariant } from "@plane/ui";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
// hooks
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
@@ -50,7 +48,7 @@ export const ModuleLayoutRoot = observer(function ModuleLayoutRoot() {
const workItemFilters = moduleId ? issuesFilter?.getIssueFilters(moduleId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout || undefined;
const { isLoading } = useSWR(
useSWR(
workspaceSlug && projectId && moduleId
? `MODULE_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}_${moduleId.toString()}`
: null,
@@ -62,15 +60,7 @@ export const ModuleLayoutRoot = observer(function ModuleLayoutRoot() {
{ revalidateIfStale: false, revalidateOnFocus: false }
);
if (!workspaceSlug || !projectId || !moduleId) return <></>;
if (isLoading && !workItemFilters)
return (
<div className="h-full w-full flex items-center justify-center">
<LogoSpinner />
</div>
);
if (!workspaceSlug || !projectId || !moduleId || !workItemFilters) return <></>;
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.MODULE}>
<ProjectLevelWorkItemFiltersHOC

View File

@@ -1,4 +1,3 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
@@ -7,7 +6,6 @@ import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@p
import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
import { Spinner } from "@plane/ui";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
// hooks
@@ -49,7 +47,7 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() {
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout;
const { isLoading } = useSWR(
useSWR(
workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null,
async () => {
if (workspaceSlug && projectId) {
@@ -59,15 +57,7 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() {
{ revalidateIfStale: false, revalidateOnFocus: false }
);
if (!workspaceSlug || !projectId) return <></>;
if (isLoading && !workItemFilters)
return (
<div className="h-full w-full flex items-center justify-center">
<LogoSpinner />
</div>
);
if (!workspaceSlug || !projectId || !workItemFilters) return <></>;
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.PROJECT}>
<ProjectLevelWorkItemFiltersHOC

View File

@@ -5,8 +5,6 @@ import useSWR from "swr";
// plane constants
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
// hooks
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
@@ -60,7 +58,7 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
}
: undefined;
const { isLoading } = useSWR(
useSWR(
workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}_${viewId}` : null,
async () => {
if (workspaceSlug && projectId && viewId) {
@@ -78,16 +76,7 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
[issuesFilter, workspaceSlug, viewId]
);
if (!workspaceSlug || !projectId || !viewId) return <></>;
if (isLoading && !workItemFilters) {
return (
<div className="relative flex h-screen w-full items-center justify-center">
<LogoSpinner />
</div>
);
}
if (!workspaceSlug || !projectId || !viewId || !workItemFilters) return <></>;
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.PROJECT_VIEW}>
<ProjectLevelWorkItemFiltersHOC

View File

@@ -6,7 +6,6 @@ import { SPREADSHEET_SELECT_GROUP, SPREADSHEET_PROPERTY_LIST } from "@plane/cons
import type { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssueLayoutTypes } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { MultipleSelectGroup } from "@/components/core/multiple-select";
// hooks
import { useProject } from "@/hooks/store/use-project";
@@ -72,13 +71,7 @@ export const SpreadsheetView = observer(function SpreadsheetView(props: Props) {
return true;
});
if (!issueIds || issueIds.length === 0)
return (
<div className="grid h-full w-full place-items-center">
<LogoSpinner />
</div>
);
if (!issueIds || issueIds.length === 0) return <></>;
return (
<div className="relative flex h-full w-full flex-col overflow-x-hidden whitespace-nowrap rounded-lg bg-custom-background-200 text-secondary">
<div ref={portalRef} className="spreadsheet-menu-portal" />

View File

@@ -13,6 +13,11 @@ import type { IIssueLabel } from "@plane/types";
import { Input } from "@plane/ui";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// error codes
const errorCodes = {
LABEL_NAME_ALREADY_EXISTS: "LABEL_NAME_ALREADY_EXISTS",
};
export type TLabelOperationsCallbacks = {
createLabel: (data: Partial<IIssueLabel>) => Promise<IIssueLabel>;
updateLabel: (labelId: string, data: Partial<IIssueLabel>) => Promise<IIssueLabel>;
@@ -59,6 +64,23 @@ export const CreateUpdateLabelInline = observer(
if (onClose) onClose();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getErrorMessage = (error: any, operation: "create" | "update"): string => {
const errorData = error ?? {};
const labelError = errorData.name?.includes(errorCodes.LABEL_NAME_ALREADY_EXISTS);
if (labelError) {
return t("label.create.already_exists");
}
// Fallback to general error messages
if (operation === "create") {
return errorData?.detail ?? errorData?.error ?? t("common.something_went_wrong");
}
return errorData?.error ?? t("project_settings.labels.toast.error");
};
const handleLabelCreate: SubmitHandler<IIssueLabel> = async (formData) => {
if (isSubmitting) return;
@@ -83,10 +105,12 @@ export const CreateUpdateLabelInline = observer(
},
error,
});
const errorMessage = getErrorMessage(error, "create");
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: error?.detail ?? error.error ?? t("common.something_went_wrong"),
message: errorMessage,
});
reset(formData);
});
@@ -117,10 +141,11 @@ export const CreateUpdateLabelInline = observer(
},
error,
});
const errorMessage = getErrorMessage(error, "update");
setToast({
title: "Oops!",
type: TOAST_TYPE.ERROR,
message: error?.error ?? t("project_settings.labels.toast.error"),
message: errorMessage,
});
reset(formData);
});

View File

@@ -0,0 +1,30 @@
import type { TPartialProject } from "@/plane-web/types";
// plane propel imports
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ChevronDownIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
type TProjectHeaderButtonProps = {
project: TPartialProject;
};
export function ProjectHeaderButton({ project }: TProjectHeaderButtonProps) {
return (
<Tooltip tooltipContent={project.name} position="bottom">
<div className="relative flex items-center text-left select-none w-full max-w-48 pr-1">
<div className="size-7 rounded-md bg-custom-background-90 flex items-center justify-center flex-shrink-0">
<Logo logo={project.logo_props} size={16} />
</div>
<div className="relative flex-1 min-w-0">
<p className="truncate text-base font-medium text-custom-sidebar-text-200 px-2">{project.name}</p>
<div className="absolute right-0 top-0 bottom-0 flex items-center justify-end pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="relative h-full w-8 flex items-center justify-end">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-custom-background-90 to-custom-background-90 rounded-r" />
<ChevronDownIcon className="relative z-10 size-4 text-custom-text-300" />
</div>
</div>
</div>
</div>
</Tooltip>
);
}

View File

@@ -1,20 +1,106 @@
import type { FC } from "react";
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import { Logo } from "@plane/propel/emoji-icon-picker";
import type { TLogoProps } from "@plane/types";
import { ProjectIcon } from "@plane/propel/icons";
import type { ICustomSearchSelectOption } from "@plane/types";
import { CustomSearchSelect } from "@plane/ui";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { useNavigationItems } from "@/plane-web/components/navigations";
// local imports
import { SwitcherLabel } from "../common/switcher-label";
import { ProjectHeaderButton } from "./project-header-button";
import { getTabUrl } from "./tab-navigation-utils";
import { useTabPreferences } from "./use-tab-preferences";
type ProjectHeaderProps = {
project: {
name: string;
logo_props: TLogoProps;
};
type TProjectHeaderProps = {
workspaceSlug: string;
projectId: string;
};
export const ProjectHeader: FC<ProjectHeaderProps> = ({ project }) => (
<div className="flex items-center gap-1.5 text-left select-none w-full">
<div className="size-7 rounded-md bg-surface-2 flex items-center justify-center flex-shrink-0">
<Logo logo={project.logo_props} size={16} />
</div>
<p className="truncate text-14 font-medium text-secondary flex-shrink-0">{project.name}</p>
</div>
);
export const ProjectHeader = observer((props: TProjectHeaderProps) => {
const { workspaceSlug, projectId } = props;
// router
const router = useAppRouter();
// store hooks
const { joinedProjectIds, getPartialProjectById } = useProject();
const { allowPermissions } = useUserPermissions();
// Get current project details
const currentProjectDetails = getPartialProjectById(projectId);
// Get available navigation items for this project
const navigationItems = useNavigationItems({
workspaceSlug: workspaceSlug,
projectId,
project: currentProjectDetails,
allowPermissions,
});
// Get preferences from hook
const { tabPreferences } = useTabPreferences(workspaceSlug, projectId);
// Memoize available tab keys
const availableTabKeys = useMemo(() => navigationItems.map((item) => item.key), [navigationItems]);
// Memoize validated default tab key
const validatedDefaultTabKey = useMemo(
() =>
availableTabKeys.includes(tabPreferences.defaultTab)
? tabPreferences.defaultTab
: availableTabKeys[0] || "work_items",
[availableTabKeys, tabPreferences.defaultTab]
);
// Memoize switcher options to prevent recalculation on every render
const switcherOptions = useMemo<ICustomSearchSelectOption[]>(
() =>
joinedProjectIds
.map((id): ICustomSearchSelectOption | null => {
const project = getPartialProjectById(id);
if (!project) return null;
return {
value: id,
query: project.name,
content: (
<SwitcherLabel
name={project.name}
logo_props={project.logo_props}
LabelIcon={ProjectIcon}
type="material"
/>
),
};
})
.filter((option): option is ICustomSearchSelectOption => option !== null),
[joinedProjectIds, getPartialProjectById]
);
// Memoize onChange handler
const handleProjectChange = useCallback(
(value: string) => {
if (value !== currentProjectDetails?.id) {
router.push(getTabUrl(workspaceSlug, value, validatedDefaultTabKey));
}
},
[currentProjectDetails?.id, router, workspaceSlug, validatedDefaultTabKey]
);
// Early return if no project details
if (!currentProjectDetails) return null;
return (
<CustomSearchSelect
options={switcherOptions}
value={currentProjectDetails.id}
onChange={handleProjectChange}
customButton={currentProjectDetails ? <ProjectHeaderButton project={currentProjectDetails} /> : null}
className="h-full rounded"
customButtonClassName="group flex items-center gap-0.5 rounded hover:bg-surface-2 outline-none cursor-pointer h-full"
/>
);
});

View File

@@ -115,7 +115,7 @@ export const TabNavigationRoot: FC<TTabNavigationRootProps> = observer((props) =
const hiddenNavigationItems = allNavigationItems.filter((item) => tabPreferences.hiddenTabs.includes(item.key));
// Responsive tab layout hook
const { visibleItems, overflowItems, hasOverflow, containerRef, itemRefs } = useResponsiveTabLayout({
const { visibleItems, overflowItems, hasOverflow, itemRefs, containerRef } = useResponsiveTabLayout({
visibleNavigationItems,
hiddenNavigationItems,
isActive,
@@ -169,7 +169,7 @@ export const TabNavigationRoot: FC<TTabNavigationRootProps> = observer((props) =
{/* container for the tab navigation */}
<div className="flex items-center gap-3 overflow-hidden size-full">
<div className="flex items-center gap-2 shrink-0">
<ProjectHeader project={project} />
<ProjectHeader workspaceSlug={workspaceSlug} projectId={projectId} />
<div className="shrink-0">
<ProjectActionsMenu
workspaceSlug={workspaceSlug}

View File

@@ -1,9 +1,8 @@
import { useState, useRef, useMemo, useCallback, useEffect } from "react";
import { useState, useMemo, useCallback, useEffect } from "react";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import { useOutsideClickDetector } from "@plane/hooks";
import { CloseIcon, SearchIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils";
// power-k
@@ -14,6 +13,7 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePowerK } from "@/hooks/store/use-power-k";
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useExpandableSearch } from "@/hooks/use-expandable-search";
export const TopNavPowerK = observer(() => {
// router
@@ -22,7 +22,6 @@ export const TopNavPowerK = observer(() => {
const { projectId: routerProjectId, workItem: workItemIdentifier } = params;
// states
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [activeCommand, setActiveCommand] = useState<TPowerKCommandConfig | null>(null);
const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true);
@@ -32,6 +31,25 @@ export const TopNavPowerK = observer(() => {
const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK();
const { data: currentUser } = useUser();
const handleOnClose = useCallback(() => {
setSearchTerm("");
setActivePage(null);
setActiveCommand(null);
}, [setSearchTerm, setActivePage, setActiveCommand]);
// expandable search hook
const {
isOpen,
containerRef,
inputRef,
handleClose: closePanel,
handleMouseDown,
handleFocus,
openPanel,
} = useExpandableSearch({
onClose: handleOnClose,
});
// derived values
const {
issue: { getIssueById, getIssueIdByIdentifier },
@@ -54,12 +72,7 @@ export const TopNavPowerK = observer(() => {
projectId,
},
router,
closePalette: () => {
setIsOpen(false);
setSearchTerm("");
setActivePage(null);
setActiveCommand(null);
},
closePalette: closePanel,
setActiveCommand,
setActivePage,
}),
@@ -72,12 +85,10 @@ export const TopNavPowerK = observer(() => {
projectId,
router,
setActivePage,
closePanel,
]
);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Register input ref with PowerK store for keyboard shortcut access
useEffect(() => {
setTopNavInputRef(inputRef);
@@ -86,18 +97,6 @@ export const TopNavPowerK = observer(() => {
};
}, [setTopNavInputRef]);
useOutsideClickDetector(containerRef, () => {
if (isOpen) {
setIsOpen(false);
setActivePage(null);
setActiveCommand(null);
}
});
const handleFocus = () => {
setIsOpen(true);
};
const handleClear = () => {
setSearchTerm("");
inputRef.current?.focus();
@@ -136,10 +135,7 @@ export const TopNavPowerK = observer(() => {
// Cmd/Ctrl+K closes the search dropdown
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setIsOpen(false);
setSearchTerm("");
setActivePage(null);
context.setActiveCommand(null);
closePanel();
return;
}
@@ -148,9 +144,7 @@ export const TopNavPowerK = observer(() => {
if (searchTerm) {
setSearchTerm("");
}
setIsOpen(false);
inputRef.current?.blur();
closePanel();
return;
}
@@ -203,7 +197,7 @@ export const TopNavPowerK = observer(() => {
return;
}
},
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, isOpen]
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, closePanel]
);
return (
@@ -228,7 +222,11 @@ export const TopNavPowerK = observer(() => {
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Search commands..."
@@ -274,6 +272,7 @@ export const TopNavPowerK = observer(() => {
isWorkspaceLevel={isWorkspaceLevel}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
handleSearchMenuClose={() => closePanel()}
/>
</Command.List>
<PowerKModalFooter

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