diff --git a/.gitignore b/.gitignore index 0c89564230e..a6a407ba9e8 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,8 @@ mediafiles .env .DS_Store logs/ +htmlcov/ +.coverage node_modules/ assets/dist/ diff --git a/admin/package.json b/admin/package.json index 15de777574f..bb2a655e28d 100644 --- a/admin/package.json +++ b/admin/package.json @@ -31,7 +31,7 @@ "lucide-react": "^0.469.0", "mobx": "^6.12.0", "mobx-react": "^9.1.1", - "next": "^14.2.28", + "next": "^14.2.29", "next-themes": "^0.2.1", "postcss": "^8.4.38", "react": "^18.3.1", diff --git a/apiserver/.coveragerc b/apiserver/.coveragerc new file mode 100644 index 00000000000..bd829d14126 --- /dev/null +++ b/apiserver/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = plane +omit = + */tests/* + */migrations/* + */settings/* + */wsgi.py + */asgi.py + */urls.py + manage.py + */admin.py + */apps.py + +[report] +exclude_lines = + pragma: no cover + def __repr__ + if self.debug: + raise NotImplementedError + if __name__ == .__main__. + pass + raise ImportError + +[html] +directory = htmlcov \ No newline at end of file diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index 73c8a85d973..8d521e8e834 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -148,10 +148,13 @@ class Meta: fields = "__all__" -class ProjectMemberRoleSerializer(DynamicBaseSerializer): +class ProjectMemberRoleSerializer(DynamicBaseSerializer): + original_role = serializers.IntegerField(source='role', read_only=True) + class Meta: model = ProjectMember - fields = ("id", "role", "member", "project") + fields = ("id", "role", "member", "project", "original_role", "created_at") + read_only_fields = ["original_role", "created_at"] class ProjectMemberInviteSerializer(BaseSerializer): diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index ebc002c9c45..c5a3d35df0c 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -3,11 +3,22 @@ # Module import from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite +from plane.utils.url import contains_url from .base import BaseSerializer class UserSerializer(BaseSerializer): + def validate_first_name(self, value): + if contains_url(value): + raise serializers.ValidationError("First name cannot contain a URL.") + return value + + def validate_last_name(self, value): + if contains_url(value): + raise serializers.ValidationError("Last name cannot contain a URL.") + return value + class Meta: model = User # Exclude password field from the serializer @@ -99,11 +110,16 @@ def get_workspace(self, obj): workspace_member__member=obj.id, workspace_member__is_active=True, ).first() + logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else "" return { "last_workspace_id": profile.last_workspace_id, "last_workspace_slug": ( workspace.slug if workspace is not None else "" ), + "last_workspace_name": ( + workspace.name if workspace is not None else "" + ), + "last_workspace_logo": (logo_asset_url), "fallback_workspace_id": profile.last_workspace_id, "fallback_workspace_slug": ( workspace.slug if workspace is not None else "" diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index 9fba7256e62..7a9289266cc 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -25,10 +25,12 @@ WorkspaceUserPreference, ) from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS +from plane.utils.url import contains_url # Django imports from django.core.validators import URLValidator from django.core.exceptions import ValidationError +import re class WorkSpaceSerializer(DynamicBaseSerializer): @@ -36,10 +38,21 @@ class WorkSpaceSerializer(DynamicBaseSerializer): logo_url = serializers.CharField(read_only=True) role = serializers.IntegerField(read_only=True) + def validate_name(self, value): + # Check if the name contains a URL + if contains_url(value): + raise serializers.ValidationError("Name must not contain URLs") + return value + def validate_slug(self, value): # Check if the slug is restricted if value in RESTRICTED_WORKSPACE_SLUGS: raise serializers.ValidationError("Slug is not valid") + # Slug should only contain alphanumeric characters, hyphens, and underscores + if not re.match(r"^[a-zA-Z0-9_-]+$", value): + raise serializers.ValidationError( + "Slug can only contain letters, numbers, hyphens (-), and underscores (_)" + ) return value class Meta: diff --git a/apiserver/plane/app/urls/asset.py b/apiserver/plane/app/urls/asset.py index ec0f41b6278..77dd3d00efa 100644 --- a/apiserver/plane/app/urls/asset.py +++ b/apiserver/plane/app/urls/asset.py @@ -12,6 +12,7 @@ AssetRestoreEndpoint, ProjectAssetEndpoint, ProjectBulkAssetEndpoint, + AssetCheckEndpoint, ) @@ -81,5 +82,11 @@ path( "assets/v2/workspaces//projects///bulk/", ProjectBulkAssetEndpoint.as_view(), + name="bulk-asset-update", + ), + path( + "assets/v2/workspaces//check//", + AssetCheckEndpoint.as_view(), + name="asset-check", ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 98dcab84fdb..55642a53358 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -106,6 +106,7 @@ AssetRestoreEndpoint, ProjectAssetEndpoint, ProjectBulkAssetEndpoint, + AssetCheckEndpoint, ) from .issue.base import ( IssueListEndpoint, diff --git a/apiserver/plane/app/views/asset/v2.py b/apiserver/plane/app/views/asset/v2.py index 46e988be297..aecba04b8c3 100644 --- a/apiserver/plane/app/views/asset/v2.py +++ b/apiserver/plane/app/views/asset/v2.py @@ -707,3 +707,14 @@ def post(self, request, slug, project_id, entity_id): pass return Response(status=status.HTTP_204_NO_CONTENT) + + +class AssetCheckEndpoint(BaseAPIView): + """Endpoint to check if an asset exists.""" + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug, asset_id): + asset = FileAsset.all_objects.filter( + id=asset_id, workspace__slug=slug, deleted_at__isnull=True + ).exists() + return Response({"exists": asset}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/issue/link.py b/apiserver/plane/app/views/issue/link.py index d2641e0a4a1..099550b8467 100644 --- a/apiserver/plane/app/views/issue/link.py +++ b/apiserver/plane/app/views/issue/link.py @@ -15,6 +15,7 @@ from plane.app.permissions import ProjectEntityPermission from plane.db.models import IssueLink from plane.bgtasks.issue_activities_task import issue_activity +from plane.bgtasks.work_item_link_task import crawl_work_item_link_title from plane.utils.host import base_host @@ -44,6 +45,9 @@ def create(self, request, slug, project_id, issue_id): serializer = IssueLinkSerializer(data=request.data) if serializer.is_valid(): serializer.save(project_id=project_id, issue_id=issue_id) + crawl_work_item_link_title( + serializer.data.get("id"), serializer.data.get("url") + ) issue_activity.delay( type="link.activity.created", requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), @@ -55,6 +59,10 @@ def create(self, request, slug, project_id, issue_id): notification=True, origin=base_host(request=request, is_app=True), ) + + issue_link = self.get_queryset().get(id=serializer.data.get("id")) + serializer = IssueLinkSerializer(issue_link) + return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -66,9 +74,14 @@ def partial_update(self, request, slug, project_id, issue_id, pk): current_instance = json.dumps( IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder ) + serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) if serializer.is_valid(): serializer.save() + crawl_work_item_link_title( + serializer.data.get("id"), serializer.data.get("url") + ) + issue_activity.delay( type="link.activity.updated", requested_data=requested_data, @@ -80,6 +93,9 @@ def partial_update(self, request, slug, project_id, issue_id, pk): notification=True, origin=base_host(request=request, is_app=True), ) + issue_link = self.get_queryset().get(id=serializer.data.get("id")) + serializer = IssueLinkSerializer(issue_link) + return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index e92e61e5133..803e3806707 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -3,6 +3,7 @@ import io import os from datetime import date +import uuid from dateutil.relativedelta import relativedelta from django.db import IntegrityError @@ -35,6 +36,7 @@ Workspace, WorkspaceMember, WorkspaceTheme, + Profile ) from plane.app.permissions import ROLE, allow_permission from django.utils.decorators import method_decorator @@ -43,6 +45,7 @@ from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS from plane.license.utils.instance_value import get_configuration_value from plane.bgtasks.workspace_seed_task import workspace_seed +from plane.utils.url import contains_url class WorkSpaceViewSet(BaseViewSet): @@ -109,6 +112,12 @@ def create(self, request): status=status.HTTP_400_BAD_REQUEST, ) + if contains_url(name): + return Response( + {"error": "Name cannot contain a URL"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if serializer.is_valid(raise_exception=True): serializer.save(owner=request.user) # Create Workspace member @@ -150,8 +159,19 @@ def list(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs): return super().partial_update(request, *args, **kwargs) + + def remove_last_workspace_ids_from_user_settings(self, id: uuid.UUID) -> None: + """ + Remove the last workspace id from the user settings + """ + Profile.objects.filter(last_workspace_id=id).update(last_workspace_id=None) + return + @allow_permission([ROLE.ADMIN], level="WORKSPACE") def destroy(self, request, *args, **kwargs): + # Get the workspace + workspace = self.get_object() + self.remove_last_workspace_ids_from_user_settings(workspace.id) return super().destroy(request, *args, **kwargs) diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py index d474fe4dfad..d8b5799de1a 100644 --- a/apiserver/plane/authentication/urls.py +++ b/apiserver/plane/authentication/urls.py @@ -42,11 +42,11 @@ # credentials path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"), path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"), - path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="sign-in"), - path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="sign-in"), + path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"), + path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"), # signout path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"), - path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="sign-out"), + path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="space-sign-out"), # csrf token path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"), # Magic sign in @@ -56,17 +56,17 @@ path( "spaces/magic-generate/", MagicGenerateSpaceEndpoint.as_view(), - name="magic-generate", + name="space-magic-generate", ), path( "spaces/magic-sign-in/", MagicSignInSpaceEndpoint.as_view(), - name="magic-sign-in", + name="space-magic-sign-in", ), path( "spaces/magic-sign-up/", MagicSignUpSpaceEndpoint.as_view(), - name="magic-sign-up", + name="space-magic-sign-up", ), ## Google Oauth path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"), @@ -74,12 +74,12 @@ path( "spaces/google/", GoogleOauthInitiateSpaceEndpoint.as_view(), - name="google-initiate", + name="space-google-initiate", ), path( - "google/callback/", + "spaces/google/callback/", GoogleCallbackSpaceEndpoint.as_view(), - name="google-callback", + name="space-google-callback", ), ## Github Oauth path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"), @@ -87,12 +87,12 @@ path( "spaces/github/", GitHubOauthInitiateSpaceEndpoint.as_view(), - name="github-initiate", + name="space-github-initiate", ), path( "spaces/github/callback/", GitHubCallbackSpaceEndpoint.as_view(), - name="github-callback", + name="space-github-callback", ), ## Gitlab Oauth path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"), @@ -100,12 +100,12 @@ path( "spaces/gitlab/", GitLabOauthInitiateSpaceEndpoint.as_view(), - name="gitlab-initiate", + name="space-gitlab-initiate", ), path( "spaces/gitlab/callback/", GitLabCallbackSpaceEndpoint.as_view(), - name="gitlab-callback", + name="space-gitlab-callback", ), # Email Check path("email-check/", EmailCheckEndpoint.as_view(), name="email-check"), @@ -120,12 +120,12 @@ path( "spaces/forgot-password/", ForgotPasswordSpaceEndpoint.as_view(), - name="forgot-password", + name="space-forgot-password", ), path( "spaces/reset-password///", ResetPasswordSpaceEndpoint.as_view(), - name="forgot-password", + name="space-forgot-password", ), path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"), path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"), diff --git a/apiserver/plane/bgtasks/work_item_link_task.py b/apiserver/plane/bgtasks/work_item_link_task.py new file mode 100644 index 00000000000..9a3ac265e1a --- /dev/null +++ b/apiserver/plane/bgtasks/work_item_link_task.py @@ -0,0 +1,185 @@ +# Python imports +import logging + + +# Third party imports +from celery import shared_task +import requests +from bs4 import BeautifulSoup +from urllib.parse import urlparse, urljoin +import base64 +import ipaddress +from typing import Dict, Any +from typing import Optional +from plane.db.models import IssueLink +from plane.utils.exception_logger import log_exception + +logger = logging.getLogger("plane.worker") + + +DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501 + + +@shared_task +def crawl_work_item_link_title(id: str, url: str) -> None: + meta_data = crawl_work_item_link_title_and_favicon(url) + issue_link = IssueLink.objects.get(id=id) + + issue_link.metadata = meta_data + + issue_link.save() + + +def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]: + """ + Crawls a URL to extract the title and favicon. + + Args: + url (str): The URL to crawl + + Returns: + str: JSON string containing title and base64-encoded favicon + """ + try: + # Prevent access to private IP ranges + parsed = urlparse(url) + + try: + ip = ipaddress.ip_address(parsed.hostname) + if ip.is_private or ip.is_loopback or ip.is_reserved: + raise ValueError("Access to private/internal networks is not allowed") + except ValueError: + # Not an IP address, continue with domain validation + pass + + # Set up headers to mimic a real browser + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501 + } + + # Fetch the main page + response = requests.get(url, headers=headers, timeout=2) + + response.raise_for_status() + + # Parse HTML + soup = BeautifulSoup(response.content, "html.parser") + + # Extract title + title_tag = soup.find("title") + title = title_tag.get_text().strip() if title_tag else None + + # Fetch and encode favicon + favicon_base64 = fetch_and_encode_favicon(headers, soup, url) + + # Prepare result + result = { + "title": title, + "favicon": favicon_base64["favicon_base64"], + "url": url, + "favicon_url": favicon_base64["favicon_url"], + } + + return result + + except requests.RequestException as e: + log_exception(e) + return { + "error": f"Request failed: {str(e)}", + "title": None, + "favicon": None, + "url": url, + } + except Exception as e: + log_exception(e) + return { + "error": f"Unexpected error: {str(e)}", + "title": None, + "favicon": None, + "url": url, + } + + +def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]: + """ + Find the favicon URL from HTML soup. + + Args: + soup: BeautifulSoup object + base_url: Base URL for resolving relative paths + + Returns: + str: Absolute URL to favicon or None + """ + # Look for various favicon link tags + favicon_selectors = [ + 'link[rel="icon"]', + 'link[rel="shortcut icon"]', + 'link[rel="apple-touch-icon"]', + 'link[rel="apple-touch-icon-precomposed"]', + ] + + for selector in favicon_selectors: + favicon_tag = soup.select_one(selector) + if favicon_tag and favicon_tag.get("href"): + return urljoin(base_url, favicon_tag["href"]) + + # Fallback to /favicon.ico + parsed_url = urlparse(base_url) + fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico" + + # Check if fallback exists + try: + response = requests.head(fallback_url, timeout=2) + response.raise_for_status() + if response.status_code == 200: + return fallback_url + except requests.RequestException as e: + log_exception(e) + return None + + return None + + +def fetch_and_encode_favicon( + headers: Dict[str, str], soup: BeautifulSoup, url: str +) -> Optional[Dict[str, str]]: + """ + Fetch favicon and encode it as base64. + + Args: + favicon_url: URL to the favicon + headers: Request headers + + Returns: + str: Base64 encoded favicon with data URI prefix or None + """ + try: + favicon_url = find_favicon_url(soup, url) + if favicon_url is None: + return { + "favicon_url": None, + "favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}", + } + + response = requests.get(favicon_url, headers=headers, timeout=2) + response.raise_for_status() + + # Get content type + content_type = response.headers.get("content-type", "image/x-icon") + + # Convert to base64 + favicon_base64 = base64.b64encode(response.content).decode("utf-8") + + # Return as data URI + return { + "favicon_url": favicon_url, + "favicon_base64": f"data:{content_type};base64,{favicon_base64}", + } + + except Exception as e: + logger.warning(f"Failed to fetch favicon: {e}") + return { + "favicon_url": None, + "favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}", + } diff --git a/apiserver/plane/tests/README.md b/apiserver/plane/tests/README.md new file mode 100644 index 00000000000..df9aba6da1d --- /dev/null +++ b/apiserver/plane/tests/README.md @@ -0,0 +1,143 @@ +# Plane Tests + +This directory contains tests for the Plane application. The tests are organized using pytest. + +## Test Structure + +Tests are organized into the following categories: + +- **Unit tests**: Test individual functions or classes in isolation. +- **Contract tests**: Test interactions between components and verify API contracts are fulfilled. + - **API tests**: Test the external API endpoints (under `/api/v1/`). + - **App tests**: Test the web application API endpoints (under `/api/`). +- **Smoke tests**: Basic tests to verify that the application runs correctly. + +## API vs App Endpoints + +Plane has two types of API endpoints: + +1. **External API** (`plane.api`): + - Available at `/api/v1/` endpoint + - Uses API key authentication (X-Api-Key header) + - Designed for external API contracts and third-party access + - Tests use the `api_key_client` fixture for authentication + - Test files are in `contract/api/` + +2. **Web App API** (`plane.app`): + - Available at `/api/` endpoint + - Uses session-based authentication (CSRF disabled) + - Designed for the web application frontend + - Tests use the `session_client` fixture for authentication + - Test files are in `contract/app/` + +## Running Tests + +To run all tests: + +```bash +python -m pytest +``` + +To run specific test categories: + +```bash +# Run unit tests +python -m pytest plane/tests/unit/ + +# Run API contract tests +python -m pytest plane/tests/contract/api/ + +# Run App contract tests +python -m pytest plane/tests/contract/app/ + +# Run smoke tests +python -m pytest plane/tests/smoke/ +``` + +For convenience, we also provide a helper script: + +```bash +# Run all tests +./run_tests.py + +# Run only unit tests +./run_tests.py -u + +# Run contract tests with coverage report +./run_tests.py -c -o + +# Run tests in parallel +./run_tests.py -p +``` + +## Fixtures + +The following fixtures are available for testing: + +- `api_client`: Unauthenticated API client +- `create_user`: Creates a test user +- `api_token`: API token for the test user +- `api_key_client`: API client with API key authentication (for external API tests) +- `session_client`: API client with session authentication (for app API tests) +- `plane_server`: Live Django test server for HTTP-based smoke tests + +## Writing Tests + +When writing tests, follow these guidelines: + +1. Place tests in the appropriate directory based on their type. +2. Use the correct client fixture based on the API being tested: + - For external API (`/api/v1/`), use `api_key_client` + - For web app API (`/api/`), use `session_client` + - For smoke tests with real HTTP, use `plane_server` +3. Use the correct URL namespace when reverse-resolving URLs: + - For external API, use `reverse("api:endpoint_name")` + - For web app API, use `reverse("endpoint_name")` +4. Add the `@pytest.mark.django_db` decorator to tests that interact with the database. +5. Add the appropriate markers (`@pytest.mark.contract`, etc.) to categorize tests. + +## Test Fixtures + +Common fixtures are defined in: + +- `conftest.py`: General fixtures for authentication, database access, etc. +- `conftest_external.py`: Fixtures for external services (Redis, Elasticsearch, Celery, MongoDB) +- `factories.py`: Test factories for easy model instance creation + +## Best Practices + +When writing tests, follow these guidelines: + +1. **Use pytest's assert syntax** instead of Django's `self.assert*` methods. +2. **Add markers to categorize tests**: + ```python + @pytest.mark.unit + @pytest.mark.contract + @pytest.mark.smoke + ``` +3. **Use fixtures instead of setUp/tearDown methods** for cleaner, more reusable test code. +4. **Mock external dependencies** with the provided fixtures to avoid external service dependencies. +5. **Write focused tests** that verify one specific behavior or edge case. +6. **Keep test files small and organized** by logical components or endpoints. +7. **Target 90% code coverage** for models, serializers, and business logic. + +## External Dependencies + +Tests for components that interact with external services should: + +1. Use the `mock_redis`, `mock_elasticsearch`, `mock_mongodb`, and `mock_celery` fixtures for unit and most contract tests. +2. For more comprehensive contract tests, use Docker-based test containers (optional). + +## Coverage Reports + +Generate a coverage report with: + +```bash +python -m pytest --cov=plane --cov-report=term --cov-report=html +``` + +This creates an HTML report in the `htmlcov/` directory. + +## Migration from Old Tests + +Some tests are still in the old format in the `api/` directory. These need to be migrated to the new contract test structure in the appropriate directories. \ No newline at end of file diff --git a/apiserver/plane/tests/TESTING_GUIDE.md b/apiserver/plane/tests/TESTING_GUIDE.md new file mode 100644 index 00000000000..98f4a1dba7c --- /dev/null +++ b/apiserver/plane/tests/TESTING_GUIDE.md @@ -0,0 +1,151 @@ +# Testing Guide for Plane + +This guide explains how to write tests for Plane using our pytest-based testing strategy. + +## Test Categories + +We divide tests into three categories: + +1. **Unit Tests**: Testing individual components in isolation. +2. **Contract Tests**: Testing API endpoints and verifying contracts between components. +3. **Smoke Tests**: Basic end-to-end tests for critical flows. + +## Writing Unit Tests + +Unit tests should be placed in the appropriate directory under `tests/unit/` depending on what you're testing: + +- `tests/unit/models/` - For model tests +- `tests/unit/serializers/` - For serializer tests +- `tests/unit/utils/` - For utility function tests + +### Example Unit Test: + +```python +import pytest +from plane.api.serializers import MySerializer + +@pytest.mark.unit +class TestMySerializer: + def test_serializer_valid_data(self): + # Create input data + data = {"field1": "value1", "field2": 42} + + # Initialize the serializer + serializer = MySerializer(data=data) + + # Validate + assert serializer.is_valid() + + # Check validated data + assert serializer.validated_data["field1"] == "value1" + assert serializer.validated_data["field2"] == 42 +``` + +## Writing Contract Tests + +Contract tests should be placed in `tests/contract/api/` or `tests/contract/app/` directories and should test the API endpoints. + +### Example Contract Test: + +```python +import pytest +from django.urls import reverse +from rest_framework import status + +@pytest.mark.contract +class TestMyEndpoint: + @pytest.mark.django_db + def test_my_endpoint_get(self, auth_client): + # Get the URL + url = reverse("my-endpoint") + + # Make request + response = auth_client.get(url) + + # Check response + assert response.status_code == status.HTTP_200_OK + assert "data" in response.data +``` + +## Writing Smoke Tests + +Smoke tests should be placed in `tests/smoke/` directory and use the `plane_server` fixture to test against a real HTTP server. + +### Example Smoke Test: + +```python +import pytest +import requests + +@pytest.mark.smoke +class TestCriticalFlow: + @pytest.mark.django_db + def test_login_flow(self, plane_server, create_user, user_data): + # Get login URL + url = f"{plane_server.url}/api/auth/signin/" + + # Test login + response = requests.post( + url, + json={ + "email": user_data["email"], + "password": user_data["password"] + } + ) + + # Verify + assert response.status_code == 200 + data = response.json() + assert "access_token" in data +``` + +## Useful Fixtures + +Our test setup provides several useful fixtures: + +1. `api_client`: An unauthenticated DRF APIClient +2. `api_key_client`: API client with API key authentication (for external API tests) +3. `session_client`: API client with session authentication (for web app API tests) +4. `create_user`: Creates and returns a test user +5. `mock_redis`: Mocks Redis interactions +6. `mock_elasticsearch`: Mocks Elasticsearch interactions +7. `mock_celery`: Mocks Celery task execution + +## Using Factory Boy + +For more complex test data setup, use the provided factories: + +```python +from plane.tests.factories import UserFactory, WorkspaceFactory + +# Create a user +user = UserFactory() + +# Create a workspace with a specific owner +workspace = WorkspaceFactory(owner=user) + +# Create multiple objects +users = UserFactory.create_batch(5) +``` + +## Running Tests + +Use pytest to run tests: + +```bash +# Run all tests +python -m pytest + +# Run only unit tests with coverage +python -m pytest -m unit --cov=plane +``` + +## Best Practices + +1. **Keep tests small and focused** - Each test should verify one specific behavior. +2. **Use markers** - Always add appropriate markers (`@pytest.mark.unit`, etc.). +3. **Mock external dependencies** - Use the provided mock fixtures. +4. **Use factories** - For complex data setup, use factories. +5. **Don't test the framework** - Focus on testing your business logic, not Django/DRF itself. +6. **Write readable assertions** - Use plain `assert` statements with clear messaging. +7. **Focus on coverage** - Aim for ≥90% code coverage for critical components. \ No newline at end of file diff --git a/apiserver/plane/tests/__init__.py b/apiserver/plane/tests/__init__.py index 0a0e47b0b01..73d90cd21ba 100644 --- a/apiserver/plane/tests/__init__.py +++ b/apiserver/plane/tests/__init__.py @@ -1 +1 @@ -from .api import * +# Test package initialization diff --git a/apiserver/plane/tests/api/base.py b/apiserver/plane/tests/api/base.py deleted file mode 100644 index e3209a2818b..00000000000 --- a/apiserver/plane/tests/api/base.py +++ /dev/null @@ -1,34 +0,0 @@ -# Third party imports -from rest_framework.test import APITestCase, APIClient - -# Module imports -from plane.db.models import User -from plane.app.views.authentication import get_tokens_for_user - - -class BaseAPITest(APITestCase): - def setUp(self): - self.client = APIClient(HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10") - - -class AuthenticatedAPITest(BaseAPITest): - def setUp(self): - super().setUp() - - ## Create Dummy User - self.email = "user@plane.so" - user = User.objects.create(email=self.email) - user.set_password("user@123") - user.save() - - # Set user - self.user = user - - # Set Up User ID - self.user_id = user.id - - access_token, _ = get_tokens_for_user(user) - self.access_token = access_token - - # Set Up Authentication Token - self.client.credentials(HTTP_AUTHORIZATION="Bearer " + access_token) diff --git a/apiserver/plane/tests/api/test_asset.py b/apiserver/plane/tests/api/test_asset.py deleted file mode 100644 index b15d32e40e4..00000000000 --- a/apiserver/plane/tests/api/test_asset.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Tests for File Asset Uploads diff --git a/apiserver/plane/tests/api/test_auth_extended.py b/apiserver/plane/tests/api/test_auth_extended.py deleted file mode 100644 index af6450ef435..00000000000 --- a/apiserver/plane/tests/api/test_auth_extended.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Tests for ChangePassword and other Endpoints diff --git a/apiserver/plane/tests/api/test_authentication.py b/apiserver/plane/tests/api/test_authentication.py deleted file mode 100644 index 5d7beabdfda..00000000000 --- a/apiserver/plane/tests/api/test_authentication.py +++ /dev/null @@ -1,183 +0,0 @@ -# Python import -import json - -# Django imports -from django.urls import reverse - -# Third Party imports -from rest_framework import status -from .base import BaseAPITest - -# Module imports -from plane.db.models import User -from plane.settings.redis import redis_instance - - -class SignInEndpointTests(BaseAPITest): - def setUp(self): - super().setUp() - user = User.objects.create(email="user@plane.so") - user.set_password("user@123") - user.save() - - def test_without_data(self): - url = reverse("sign-in") - response = self.client.post(url, {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_email_validity(self): - url = reverse("sign-in") - response = self.client.post( - url, {"email": "useremail.com", "password": "user@123"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "Please provide a valid email address."} - ) - - def test_password_validity(self): - url = reverse("sign-in") - response = self.client.post( - url, {"email": "user@plane.so", "password": "user123"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual( - response.data, - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - ) - - def test_user_exists(self): - url = reverse("sign-in") - response = self.client.post( - url, {"email": "user@email.so", "password": "user123"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual( - response.data, - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - ) - - def test_user_login(self): - url = reverse("sign-in") - - response = self.client.post( - url, {"email": "user@plane.so", "password": "user@123"}, format="json" - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get("user").get("email"), "user@plane.so") - - -class MagicLinkGenerateEndpointTests(BaseAPITest): - def setUp(self): - super().setUp() - user = User.objects.create(email="user@plane.so") - user.set_password("user@123") - user.save() - - def test_without_data(self): - url = reverse("magic-generate") - response = self.client.post(url, {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_email_validity(self): - url = reverse("magic-generate") - response = self.client.post(url, {"email": "useremail.com"}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "Please provide a valid email address."} - ) - - def test_magic_generate(self): - url = reverse("magic-generate") - - ri = redis_instance() - ri.delete("magic_user@plane.so") - - response = self.client.post(url, {"email": "user@plane.so"}, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_max_generate_attempt(self): - url = reverse("magic-generate") - - ri = redis_instance() - ri.delete("magic_user@plane.so") - - for _ in range(4): - response = self.client.post(url, {"email": "user@plane.so"}, format="json") - - response = self.client.post(url, {"email": "user@plane.so"}, format="json") - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "Max attempts exhausted. Please try again later."} - ) - - -class MagicSignInEndpointTests(BaseAPITest): - def setUp(self): - super().setUp() - user = User.objects.create(email="user@plane.so") - user.set_password("user@123") - user.save() - - def test_without_data(self): - url = reverse("magic-sign-in") - response = self.client.post(url, {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data, {"error": "User token and key are required"}) - - def test_expired_invalid_magic_link(self): - ri = redis_instance() - ri.delete("magic_user@plane.so") - - url = reverse("magic-sign-in") - response = self.client.post( - url, - {"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"}, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "The magic code/link has expired please try again"} - ) - - def test_invalid_magic_code(self): - ri = redis_instance() - ri.delete("magic_user@plane.so") - ## Create Token - url = reverse("magic-generate") - self.client.post(url, {"email": "user@plane.so"}, format="json") - - url = reverse("magic-sign-in") - response = self.client.post( - url, - {"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"}, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "Your login code was incorrect. Please try again."} - ) - - def test_magic_code_sign_in(self): - ri = redis_instance() - ri.delete("magic_user@plane.so") - ## Create Token - url = reverse("magic-generate") - self.client.post(url, {"email": "user@plane.so"}, format="json") - - # Get the token - user_data = json.loads(ri.get("magic_user@plane.so")) - token = user_data["token"] - - url = reverse("magic-sign-in") - response = self.client.post( - url, {"key": "magic_user@plane.so", "token": token}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get("user").get("email"), "user@plane.so") diff --git a/apiserver/plane/tests/api/test_cycle.py b/apiserver/plane/tests/api/test_cycle.py deleted file mode 100644 index 72b580c99bb..00000000000 --- a/apiserver/plane/tests/api/test_cycle.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Test for Cycle Endpoints diff --git a/apiserver/plane/tests/api/test_issue.py b/apiserver/plane/tests/api/test_issue.py deleted file mode 100644 index a45ff36b1d1..00000000000 --- a/apiserver/plane/tests/api/test_issue.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Test for Issue Endpoints diff --git a/apiserver/plane/tests/api/test_oauth.py b/apiserver/plane/tests/api/test_oauth.py deleted file mode 100644 index 1e7dac0ef34..00000000000 --- a/apiserver/plane/tests/api/test_oauth.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Tests for OAuth Authentication Endpoint diff --git a/apiserver/plane/tests/api/test_people.py b/apiserver/plane/tests/api/test_people.py deleted file mode 100644 index 624281a2ff1..00000000000 --- a/apiserver/plane/tests/api/test_people.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Test for people Endpoint diff --git a/apiserver/plane/tests/api/test_project.py b/apiserver/plane/tests/api/test_project.py deleted file mode 100644 index 9a7c50f1943..00000000000 --- a/apiserver/plane/tests/api/test_project.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Tests for project endpoints diff --git a/apiserver/plane/tests/api/test_shortcut.py b/apiserver/plane/tests/api/test_shortcut.py deleted file mode 100644 index 5103b505943..00000000000 --- a/apiserver/plane/tests/api/test_shortcut.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Test for shortcuts diff --git a/apiserver/plane/tests/api/test_state.py b/apiserver/plane/tests/api/test_state.py deleted file mode 100644 index a336d955af1..00000000000 --- a/apiserver/plane/tests/api/test_state.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Wrote test for state endpoints diff --git a/apiserver/plane/tests/api/test_view.py b/apiserver/plane/tests/api/test_view.py deleted file mode 100644 index c8864f28ada..00000000000 --- a/apiserver/plane/tests/api/test_view.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write test for view endpoints diff --git a/apiserver/plane/tests/api/test_workspace.py b/apiserver/plane/tests/api/test_workspace.py deleted file mode 100644 index d63eab2e09d..00000000000 --- a/apiserver/plane/tests/api/test_workspace.py +++ /dev/null @@ -1,44 +0,0 @@ -# Django imports -from django.urls import reverse - -# Third party import -from rest_framework import status - -# Module imports -from .base import AuthenticatedAPITest -from plane.db.models import Workspace, WorkspaceMember - - -class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest): - def setUp(self): - super().setUp() - - def test_create_workspace(self): - url = reverse("workspace") - - # Test with empty data - response = self.client.post(url, {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - # Test with valid data - response = self.client.post( - url, {"name": "Plane", "slug": "pla-ne"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Workspace.objects.count(), 1) - # Check if the member is created - self.assertEqual(WorkspaceMember.objects.count(), 1) - - # Check other values - workspace = Workspace.objects.get(pk=response.data["id"]) - workspace_member = WorkspaceMember.objects.get( - workspace=workspace, member_id=self.user_id - ) - self.assertEqual(workspace.owner_id, self.user_id) - self.assertEqual(workspace_member.role, 20) - - # Create a already existing workspace - response = self.client.post( - url, {"name": "Plane", "slug": "pla-ne"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) diff --git a/apiserver/plane/tests/conftest.py b/apiserver/plane/tests/conftest.py new file mode 100644 index 00000000000..ce0d3be2b43 --- /dev/null +++ b/apiserver/plane/tests/conftest.py @@ -0,0 +1,78 @@ +import pytest +from django.conf import settings +from rest_framework.test import APIClient +from pytest_django.fixtures import django_db_setup +from unittest.mock import patch, MagicMock + +from plane.db.models import User +from plane.db.models.api import APIToken + + +@pytest.fixture(scope="session") +def django_db_setup(django_db_setup): + """Set up the Django database for the test session""" + pass + + +@pytest.fixture +def api_client(): + """Return an unauthenticated API client""" + return APIClient() + + +@pytest.fixture +def user_data(): + """Return standard user data for tests""" + return { + "email": "test@plane.so", + "password": "test-password", + "first_name": "Test", + "last_name": "User" + } + + +@pytest.fixture +def create_user(db, user_data): + """Create and return a user instance""" + user = User.objects.create( + email=user_data["email"], + first_name=user_data["first_name"], + last_name=user_data["last_name"] + ) + user.set_password(user_data["password"]) + user.save() + return user + + +@pytest.fixture +def api_token(db, create_user): + """Create and return an API token for testing the external API""" + token = APIToken.objects.create( + user=create_user, + label="Test API Token", + token="test-api-token-12345", + ) + return token + + +@pytest.fixture +def api_key_client(api_client, api_token): + """Return an API key authenticated client for external API testing""" + api_client.credentials(HTTP_X_API_KEY=api_token.token) + return api_client + + +@pytest.fixture +def session_client(api_client, create_user): + """Return a session authenticated API client for app API testing, which is what plane.app uses""" + api_client.force_authenticate(user=create_user) + return api_client + + +@pytest.fixture +def plane_server(live_server): + """ + Renamed version of live_server fixture to avoid name clashes. + Returns a live Django server for testing HTTP requests. + """ + return live_server \ No newline at end of file diff --git a/apiserver/plane/tests/conftest_external.py b/apiserver/plane/tests/conftest_external.py new file mode 100644 index 00000000000..d2d6a2df51e --- /dev/null +++ b/apiserver/plane/tests/conftest_external.py @@ -0,0 +1,117 @@ +import pytest +from unittest.mock import MagicMock, patch +from django.conf import settings + + +@pytest.fixture +def mock_redis(): + """ + Mock Redis for testing without actual Redis connection. + + This fixture patches the redis_instance function to return a MagicMock + that behaves like a Redis client. + """ + mock_redis_client = MagicMock() + + # Configure the mock to handle common Redis operations + mock_redis_client.get.return_value = None + mock_redis_client.set.return_value = True + mock_redis_client.delete.return_value = True + mock_redis_client.exists.return_value = 0 + mock_redis_client.ttl.return_value = -1 + + # Start the patch + with patch('plane.settings.redis.redis_instance', return_value=mock_redis_client): + yield mock_redis_client + + +@pytest.fixture +def mock_elasticsearch(): + """ + Mock Elasticsearch for testing without actual ES connection. + + This fixture patches Elasticsearch to return a MagicMock + that behaves like an Elasticsearch client. + """ + mock_es_client = MagicMock() + + # Configure the mock to handle common ES operations + mock_es_client.indices.exists.return_value = True + mock_es_client.indices.create.return_value = {"acknowledged": True} + mock_es_client.search.return_value = {"hits": {"total": {"value": 0}, "hits": []}} + mock_es_client.index.return_value = {"_id": "test_id", "result": "created"} + mock_es_client.update.return_value = {"_id": "test_id", "result": "updated"} + mock_es_client.delete.return_value = {"_id": "test_id", "result": "deleted"} + + # Start the patch + with patch('elasticsearch.Elasticsearch', return_value=mock_es_client): + yield mock_es_client + + +@pytest.fixture +def mock_mongodb(): + """ + Mock MongoDB for testing without actual MongoDB connection. + + This fixture patches PyMongo to return a MagicMock that behaves like a MongoDB client. + """ + # Create mock MongoDB clients and collections + mock_mongo_client = MagicMock() + mock_mongo_db = MagicMock() + mock_mongo_collection = MagicMock() + + # Set up the chain: client -> database -> collection + mock_mongo_client.__getitem__.return_value = mock_mongo_db + mock_mongo_client.get_database.return_value = mock_mongo_db + mock_mongo_db.__getitem__.return_value = mock_mongo_collection + + # Configure common MongoDB collection operations + mock_mongo_collection.find_one.return_value = None + mock_mongo_collection.find.return_value = MagicMock( + __iter__=lambda x: iter([]), + count=lambda: 0 + ) + mock_mongo_collection.insert_one.return_value = MagicMock( + inserted_id="mock_id_123", + acknowledged=True + ) + mock_mongo_collection.insert_many.return_value = MagicMock( + inserted_ids=["mock_id_123", "mock_id_456"], + acknowledged=True + ) + mock_mongo_collection.update_one.return_value = MagicMock( + modified_count=1, + matched_count=1, + acknowledged=True + ) + mock_mongo_collection.update_many.return_value = MagicMock( + modified_count=2, + matched_count=2, + acknowledged=True + ) + mock_mongo_collection.delete_one.return_value = MagicMock( + deleted_count=1, + acknowledged=True + ) + mock_mongo_collection.delete_many.return_value = MagicMock( + deleted_count=2, + acknowledged=True + ) + mock_mongo_collection.count_documents.return_value = 0 + + # Start the patch + with patch('pymongo.MongoClient', return_value=mock_mongo_client): + yield mock_mongo_client + + +@pytest.fixture +def mock_celery(): + """ + Mock Celery for testing without actual task execution. + + This fixture patches Celery's task.delay() to prevent actual task execution. + """ + # Start the patch + with patch('celery.app.task.Task.delay') as mock_delay: + mock_delay.return_value = MagicMock(id="mock-task-id") + yield mock_delay \ No newline at end of file diff --git a/apiserver/plane/tests/api/__init__.py b/apiserver/plane/tests/contract/__init__.py similarity index 100% rename from apiserver/plane/tests/api/__init__.py rename to apiserver/plane/tests/contract/__init__.py diff --git a/apiserver/plane/tests/contract/api/__init__.py b/apiserver/plane/tests/contract/api/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apiserver/plane/tests/contract/app/__init__.py b/apiserver/plane/tests/contract/app/__init__.py new file mode 100644 index 00000000000..0519ecba6ea --- /dev/null +++ b/apiserver/plane/tests/contract/app/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apiserver/plane/tests/contract/app/test_authentication.py b/apiserver/plane/tests/contract/app/test_authentication.py new file mode 100644 index 00000000000..0dc54871046 --- /dev/null +++ b/apiserver/plane/tests/contract/app/test_authentication.py @@ -0,0 +1,459 @@ +import json +import uuid +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from django.test import Client +from django.core.exceptions import ValidationError +from unittest.mock import patch, MagicMock + +from plane.db.models import User +from plane.settings.redis import redis_instance +from plane.license.models import Instance + + +@pytest.fixture +def setup_instance(db): + """Create and configure an instance for authentication tests""" + instance_id = uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id + + # Create or update instance with all required fields + instance, _ = Instance.objects.update_or_create( + id=instance_id, + defaults={ + "instance_name": "Test Instance", + "instance_id": str(uuid.uuid4()), + "current_version": "1.0.0", + "domain": "http://localhost:8000", + "last_checked_at": timezone.now(), + "is_setup_done": True, + } + ) + return instance + + +@pytest.fixture +def django_client(): + """Return a Django test client with User-Agent header for handling redirects""" + client = Client(HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1") + return client + + +@pytest.mark.contract +class TestMagicLinkGenerate: + """Test magic link generation functionality""" + + @pytest.fixture + def setup_user(self, db): + """Create a test user for magic link tests""" + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + return user + + @pytest.mark.django_db + def test_without_data(self, api_client, setup_user, setup_instance): + """Test magic link generation with empty data""" + url = reverse("magic-generate") + try: + response = api_client.post(url, {}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + except ValidationError: + # If a ValidationError is raised directly, that's also acceptable + # as it indicates the empty email was rejected + assert True + + @pytest.mark.django_db + def test_email_validity(self, api_client, setup_user, setup_instance): + """Test magic link generation with invalid email format""" + url = reverse("magic-generate") + try: + response = api_client.post(url, {"email": "useremail.com"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error_code" in response.data # Check for error code in response + except ValidationError: + # If a ValidationError is raised directly, that's also acceptable + # as it indicates the invalid email was rejected + assert True + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_generate(self, mock_magic_link, api_client, setup_user, setup_instance): + """Test successful magic link generation""" + url = reverse("magic-generate") + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + response = api_client.post(url, {"email": "user@plane.so"}, format="json") + assert response.status_code == status.HTTP_200_OK + assert "key" in response.data # Check for key in response + + # Verify the mock was called with the expected arguments + mock_magic_link.assert_called_once() + args = mock_magic_link.call_args[0] + assert args[0] == "user@plane.so" # First arg should be the email + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_max_generate_attempt(self, mock_magic_link, api_client, setup_user, setup_instance): + """Test exceeding maximum magic link generation attempts""" + url = reverse("magic-generate") + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + for _ in range(4): + api_client.post(url, {"email": "user@plane.so"}, format="json") + + response = api_client.post(url, {"email": "user@plane.so"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error_code" in response.data # Check for error code in response + + +@pytest.mark.contract +class TestSignInEndpoint: + """Test sign-in functionality""" + + @pytest.fixture + def setup_user(self, db): + """Create a test user for authentication tests""" + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + return user + + @pytest.mark.django_db + def test_without_data(self, django_client, setup_user, setup_instance): + """Test sign-in with empty data""" + url = reverse("sign-in") + response = django_client.post(url, {}, follow=True) + + # Check redirect contains error code + assert "REQUIRED_EMAIL_PASSWORD_SIGN_IN" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_email_validity(self, django_client, setup_user, setup_instance): + """Test sign-in with invalid email format""" + url = reverse("sign-in") + response = django_client.post( + url, {"email": "useremail.com", "password": "user@123"}, follow=True + ) + + # Check redirect contains error code + assert "INVALID_EMAIL_SIGN_IN" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_user_exists(self, django_client, setup_user, setup_instance): + """Test sign-in with non-existent user""" + url = reverse("sign-in") + response = django_client.post( + url, {"email": "user@email.so", "password": "user123"}, follow=True + ) + + # Check redirect contains error code + assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_password_validity(self, django_client, setup_user, setup_instance): + """Test sign-in with incorrect password""" + url = reverse("sign-in") + response = django_client.post( + url, {"email": "user@plane.so", "password": "user123"}, follow=True + ) + + + # Check for the specific authentication error in the URL + redirect_urls = [url for url, _ in response.redirect_chain] + redirect_contents = ' '.join(redirect_urls) + + # The actual error code for invalid password is AUTHENTICATION_FAILED_SIGN_IN + assert "AUTHENTICATION_FAILED_SIGN_IN" in redirect_contents + + @pytest.mark.django_db + def test_user_login(self, django_client, setup_user, setup_instance): + """Test successful sign-in""" + url = reverse("sign-in") + + # First make the request without following redirects + response = django_client.post( + url, {"email": "user@plane.so", "password": "user@123"}, follow=False + ) + + # Check that the initial response is a redirect (302) without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # Now follow just the first redirect to avoid 404s + response = django_client.get(response.url, follow=False) + + # The user should be authenticated regardless of the final page + assert "_auth_user_id" in django_client.session + + @pytest.mark.django_db + def test_next_path_redirection(self, django_client, setup_user, setup_instance): + """Test sign-in with next_path parameter""" + url = reverse("sign-in") + next_path = "workspaces" + + # First make the request without following redirects + response = django_client.post( + url, + {"email": "user@plane.so", "password": "user@123", "next_path": next_path}, + follow=False + ) + + # Check that the initial response is a redirect (302) without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + + # In a real browser, the next_path would be used to build the absolute URL + # Since we're just testing the authentication logic, we won't check for the exact URL structure + # Instead, just verify that we're authenticated + assert "_auth_user_id" in django_client.session + + +@pytest.mark.contract +class TestMagicSignIn: + """Test magic link sign-in functionality""" + + @pytest.fixture + def setup_user(self, db): + """Create a test user for magic sign-in tests""" + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + return user + + @pytest.mark.django_db + def test_without_data(self, django_client, setup_user, setup_instance): + """Test magic link sign-in with empty data""" + url = reverse("magic-sign-in") + response = django_client.post(url, {}, follow=True) + + # Check redirect contains error code + assert "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_expired_invalid_magic_link(self, django_client, setup_user, setup_instance): + """Test magic link sign-in with expired/invalid link""" + ri = redis_instance() + ri.delete("magic_user@plane.so") + + url = reverse("magic-sign-in") + response = django_client.post( + url, + {"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"}, + follow=False + ) + + # Check that we get a redirect + assert response.status_code == 302 + + # The actual error code is EXPIRED_MAGIC_CODE_SIGN_IN (when key doesn't exist) + # or INVALID_MAGIC_CODE_SIGN_IN (when key exists but code doesn't match) + assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url or "INVALID_MAGIC_CODE_SIGN_IN" in response.url + + @pytest.mark.django_db + def test_user_does_not_exist(self, django_client, setup_instance): + """Test magic sign-in with non-existent user""" + url = reverse("magic-sign-in") + response = django_client.post( + url, + {"email": "nonexistent@plane.so", "code": "xxxx-xxxxx-xxxx"}, + follow=True + ) + + # Check redirect contains error code + assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, setup_user, setup_instance): + """Test successful magic link sign-in process""" + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get("magic_user@plane.so")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-in") + response = django_client.post( + url, + {"email": "user@plane.so", "code": token}, + follow=False + ) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # The user should now be authenticated + assert "_auth_user_id" in django_client.session + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_sign_in_with_next_path(self, mock_magic_link, django_client, api_client, setup_user, setup_instance): + """Test magic sign-in with next_path parameter""" + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get("magic_user@plane.so")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-in") + next_path = "workspaces" + response = django_client.post( + url, + {"email": "user@plane.so", "code": token, "next_path": next_path}, + follow=False + ) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # Check that the redirect URL contains the next_path + assert next_path in response.url + + # The user should now be authenticated + assert "_auth_user_id" in django_client.session + + +@pytest.mark.contract +class TestMagicSignUp: + """Test magic link sign-up functionality""" + + @pytest.mark.django_db + def test_without_data(self, django_client, setup_instance): + """Test magic link sign-up with empty data""" + url = reverse("magic-sign-up") + response = django_client.post(url, {}, follow=True) + + # Check redirect contains error code + assert "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_user_already_exists(self, django_client, db, setup_instance): + """Test magic sign-up with existing user""" + # Create a user that already exists + User.objects.create(email="existing@plane.so") + + url = reverse("magic-sign-up") + response = django_client.post( + url, + {"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"}, + follow=True + ) + + # Check redirect contains error code + assert "USER_ALREADY_EXIST" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_expired_invalid_magic_link(self, django_client, setup_instance): + """Test magic link sign-up with expired/invalid link""" + url = reverse("magic-sign-up") + response = django_client.post( + url, + {"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"}, + follow=False + ) + + # Check that we get a redirect + assert response.status_code == 302 + + # The actual error code is EXPIRED_MAGIC_CODE_SIGN_UP (when key doesn't exist) + # or INVALID_MAGIC_CODE_SIGN_UP (when key exists but code doesn't match) + assert "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url or "INVALID_MAGIC_CODE_SIGN_UP" in response.url + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, setup_instance): + """Test successful magic link sign-up process""" + email = "newuser@plane.so" + + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": email}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get(f"magic_{email}")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-up") + response = django_client.post( + url, + {"email": email, "code": token}, + follow=False + ) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # Check if user was created + assert User.objects.filter(email=email).exists() + + # Check if user is authenticated + assert "_auth_user_id" in django_client.session + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_client, setup_instance): + """Test magic sign-up with next_path parameter""" + email = "newuser2@plane.so" + + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": email}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get(f"magic_{email}")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-up") + next_path = "onboarding" + response = django_client.post( + url, + {"email": email, "code": token, "next_path": next_path}, + follow=False + ) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # In a real browser, the next_path would be used to build the absolute URL + # Since we're just testing the authentication logic, we won't check for the exact URL structure + + # Check if user was created + assert User.objects.filter(email=email).exists() + + # Check if user is authenticated + assert "_auth_user_id" in django_client.session \ No newline at end of file diff --git a/apiserver/plane/tests/contract/app/test_workspace_app.py b/apiserver/plane/tests/contract/app/test_workspace_app.py new file mode 100644 index 00000000000..71ad1d41243 --- /dev/null +++ b/apiserver/plane/tests/contract/app/test_workspace_app.py @@ -0,0 +1,79 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from unittest.mock import patch + +from plane.db.models import Workspace, WorkspaceMember + + +@pytest.mark.contract +class TestWorkspaceAPI: + """Test workspace CRUD operations""" + + @pytest.mark.django_db + def test_create_workspace_empty_data(self, session_client): + """Test creating a workspace with empty data""" + url = reverse("workspace") + + # Test with empty data + response = session_client.post(url, {}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + @patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay") + def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, create_user): + """Test creating a workspace with valid data""" + url = reverse("workspace") + user = create_user # Use the create_user fixture directly as it returns a user object + + # Test with valid data - include all required fields + workspace_data = { + "name": "Plane", + "slug": "pla-ne-test", + "company_name": "Plane Inc." + } + + # Make the request + response = session_client.post(url, workspace_data, format="json") + + # Check response status + assert response.status_code == status.HTTP_201_CREATED + + # Verify workspace was created + assert Workspace.objects.count() == 1 + + # Check if the member is created + assert WorkspaceMember.objects.count() == 1 + + # Check other values + workspace = Workspace.objects.get(slug=workspace_data["slug"]) + workspace_member = WorkspaceMember.objects.filter( + workspace=workspace, member=user + ).first() + assert workspace.owner == user + assert workspace_member.role == 20 + + # Verify the workspace_seed task was called + mock_workspace_seed.assert_called_once_with(response.data["id"]) + + @pytest.mark.django_db + @patch('plane.bgtasks.workspace_seed_task.workspace_seed.delay') + def test_create_duplicate_workspace(self, mock_workspace_seed, session_client): + """Test creating a duplicate workspace""" + url = reverse("workspace") + + # Create first workspace + session_client.post( + url, {"name": "Plane", "slug": "pla-ne"}, format="json" + ) + + # Try to create a workspace with the same slug + response = session_client.post( + url, {"name": "Plane", "slug": "pla-ne"}, format="json" + ) + + # The API returns 400 BAD REQUEST for duplicate slugs, not 409 CONFLICT + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Optionally check the error message to confirm it's related to the duplicate slug + assert "slug" in response.data \ No newline at end of file diff --git a/apiserver/plane/tests/factories.py b/apiserver/plane/tests/factories.py new file mode 100644 index 00000000000..8d95773ded1 --- /dev/null +++ b/apiserver/plane/tests/factories.py @@ -0,0 +1,82 @@ +import factory +from uuid import uuid4 +from django.utils import timezone + +from plane.db.models import ( + User, + Workspace, + WorkspaceMember, + Project, + ProjectMember +) + + +class UserFactory(factory.django.DjangoModelFactory): + """Factory for creating User instances""" + class Meta: + model = User + django_get_or_create = ('email',) + + id = factory.LazyFunction(uuid4) + email = factory.Sequence(lambda n: f'user{n}@plane.so') + password = factory.PostGenerationMethodCall('set_password', 'password') + first_name = factory.Sequence(lambda n: f'First{n}') + last_name = factory.Sequence(lambda n: f'Last{n}') + is_active = True + is_superuser = False + is_staff = False + + +class WorkspaceFactory(factory.django.DjangoModelFactory): + """Factory for creating Workspace instances""" + class Meta: + model = Workspace + django_get_or_create = ('slug',) + + id = factory.LazyFunction(uuid4) + name = factory.Sequence(lambda n: f'Workspace {n}') + slug = factory.Sequence(lambda n: f'workspace-{n}') + owner = factory.SubFactory(UserFactory) + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class WorkspaceMemberFactory(factory.django.DjangoModelFactory): + """Factory for creating WorkspaceMember instances""" + class Meta: + model = WorkspaceMember + + id = factory.LazyFunction(uuid4) + workspace = factory.SubFactory(WorkspaceFactory) + member = factory.SubFactory(UserFactory) + role = 20 # Admin role by default + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class ProjectFactory(factory.django.DjangoModelFactory): + """Factory for creating Project instances""" + class Meta: + model = Project + django_get_or_create = ('name', 'workspace') + + id = factory.LazyFunction(uuid4) + name = factory.Sequence(lambda n: f'Project {n}') + workspace = factory.SubFactory(WorkspaceFactory) + created_by = factory.SelfAttribute('workspace.owner') + updated_by = factory.SelfAttribute('workspace.owner') + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class ProjectMemberFactory(factory.django.DjangoModelFactory): + """Factory for creating ProjectMember instances""" + class Meta: + model = ProjectMember + + id = factory.LazyFunction(uuid4) + project = factory.SubFactory(ProjectFactory) + member = factory.SubFactory(UserFactory) + role = 20 # Admin role by default + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) \ No newline at end of file diff --git a/apiserver/plane/tests/smoke/__init__.py b/apiserver/plane/tests/smoke/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apiserver/plane/tests/smoke/test_auth_smoke.py b/apiserver/plane/tests/smoke/test_auth_smoke.py new file mode 100644 index 00000000000..4d6de6c35c1 --- /dev/null +++ b/apiserver/plane/tests/smoke/test_auth_smoke.py @@ -0,0 +1,100 @@ +import pytest +import requests +from django.urls import reverse + + +@pytest.mark.smoke +class TestAuthSmoke: + """Smoke tests for authentication endpoints""" + + @pytest.mark.django_db + def test_login_endpoint_available(self, plane_server, create_user, user_data): + """Test that the login endpoint is available and responds correctly""" + # Get the sign-in URL + relative_url = reverse("sign-in") + url = f"{plane_server.url}{relative_url}" + + # 1. Test bad login - test with wrong password + response = requests.post( + url, + data={ + "email": user_data["email"], + "password": "wrong-password" + } + ) + + # For bad credentials, any of these status codes would be valid + # The test shouldn't be brittle to minor implementation changes + assert response.status_code != 500, "Authentication should not cause server errors" + assert response.status_code != 404, "Authentication endpoint should exist" + + if response.status_code == 200: + # If API returns 200 for failures, check the response body for error indication + if hasattr(response, 'json'): + try: + data = response.json() + # JSON response might indicate error in its structure + assert "error" in data or "error_code" in data or "detail" in data or response.url.endswith("sign-in"), \ + "Error response should contain error details" + except ValueError: + # It's ok if response isn't JSON format + pass + elif response.status_code in [302, 303]: + # If it's a redirect, it should redirect to a login page or error page + redirect_url = response.headers.get('Location', '') + assert "error" in redirect_url or "sign-in" in redirect_url, \ + "Failed login should redirect to login page or error page" + + # 2. Test good login with correct credentials + response = requests.post( + url, + data={ + "email": user_data["email"], + "password": user_data["password"] + }, + allow_redirects=False # Don't follow redirects + ) + + # Successful auth should not be a client error or server error + assert response.status_code not in range(400, 600), \ + f"Authentication with valid credentials failed with status {response.status_code}" + + # Specific validation based on response type + if response.status_code in [302, 303]: + # Redirect-based auth: check that redirect URL doesn't contain error + redirect_url = response.headers.get('Location', '') + assert "error" not in redirect_url and "error_code" not in redirect_url, \ + "Successful login redirect should not contain error parameters" + + elif response.status_code == 200: + # API token-based auth: check for tokens or user session + if hasattr(response, 'json'): + try: + data = response.json() + # If it's a token response + if "access_token" in data: + assert "refresh_token" in data, "JWT auth should return both access and refresh tokens" + # If it's a user session response + elif "user" in data: + assert "is_authenticated" in data and data["is_authenticated"], \ + "User session response should indicate authentication" + # Otherwise it should at least indicate success + else: + assert not any(error_key in data for error_key in ["error", "error_code", "detail"]), \ + "Success response should not contain error keys" + except ValueError: + # Non-JSON is acceptable if it's a redirect or HTML response + pass + + +@pytest.mark.smoke +class TestHealthCheckSmoke: + """Smoke test for health check endpoint""" + + def test_healthcheck_endpoint(self, plane_server): + """Test that the health check endpoint is available and responds correctly""" + # Make a request to the health check endpoint + response = requests.get(f"{plane_server.url}/") + + # Should be OK + assert response.status_code == 200, "Health check endpoint should return 200 OK" \ No newline at end of file diff --git a/apiserver/plane/tests/unit/__init__.py b/apiserver/plane/tests/unit/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apiserver/plane/tests/unit/models/__init__.py b/apiserver/plane/tests/unit/models/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apiserver/plane/tests/unit/models/test_workspace_model.py b/apiserver/plane/tests/unit/models/test_workspace_model.py new file mode 100644 index 00000000000..40380fa0f47 --- /dev/null +++ b/apiserver/plane/tests/unit/models/test_workspace_model.py @@ -0,0 +1,50 @@ +import pytest +from uuid import uuid4 + +from plane.db.models import Workspace, WorkspaceMember, User + + +@pytest.mark.unit +class TestWorkspaceModel: + """Test the Workspace model""" + + @pytest.mark.django_db + def test_workspace_creation(self, create_user): + """Test creating a workspace""" + # Create a workspace + workspace = Workspace.objects.create( + name="Test Workspace", + slug="test-workspace", + id=uuid4(), + owner=create_user + ) + + # Verify it was created + assert workspace.id is not None + assert workspace.name == "Test Workspace" + assert workspace.slug == "test-workspace" + assert workspace.owner == create_user + + @pytest.mark.django_db + def test_workspace_member_creation(self, create_user): + """Test creating a workspace member""" + # Create a workspace + workspace = Workspace.objects.create( + name="Test Workspace", + slug="test-workspace", + id=uuid4(), + owner=create_user + ) + + # Create a workspace member + workspace_member = WorkspaceMember.objects.create( + workspace=workspace, + member=create_user, + role=20 # Admin role + ) + + # Verify it was created + assert workspace_member.id is not None + assert workspace_member.workspace == workspace + assert workspace_member.member == create_user + assert workspace_member.role == 20 \ No newline at end of file diff --git a/apiserver/plane/tests/unit/serializers/__init__.py b/apiserver/plane/tests/unit/serializers/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apiserver/plane/tests/unit/serializers/test_workspace.py b/apiserver/plane/tests/unit/serializers/test_workspace.py new file mode 100644 index 00000000000..19767a7c61f --- /dev/null +++ b/apiserver/plane/tests/unit/serializers/test_workspace.py @@ -0,0 +1,71 @@ +import pytest +from uuid import uuid4 + +from plane.api.serializers import WorkspaceLiteSerializer +from plane.db.models import Workspace, User + + +@pytest.mark.unit +class TestWorkspaceLiteSerializer: + """Test the WorkspaceLiteSerializer""" + + def test_workspace_lite_serializer_fields(self, db): + """Test that the serializer includes the correct fields""" + # Create a user to be the owner + owner = User.objects.create( + email="test@example.com", + first_name="Test", + last_name="User" + ) + + # Create a workspace with explicit ID to test serialization + workspace_id = uuid4() + workspace = Workspace.objects.create( + name="Test Workspace", + slug="test-workspace", + id=workspace_id, + owner=owner + ) + + # Serialize the workspace + serialized_data = WorkspaceLiteSerializer(workspace).data + + # Check fields are present and correct + assert "name" in serialized_data + assert "slug" in serialized_data + assert "id" in serialized_data + + assert serialized_data["name"] == "Test Workspace" + assert serialized_data["slug"] == "test-workspace" + assert str(serialized_data["id"]) == str(workspace_id) + + def test_workspace_lite_serializer_read_only(self, db): + """Test that the serializer fields are read-only""" + # Create a user to be the owner + owner = User.objects.create( + email="test2@example.com", + first_name="Test", + last_name="User" + ) + + # Create a workspace + workspace = Workspace.objects.create( + name="Test Workspace", + slug="test-workspace", + id=uuid4(), + owner=owner + ) + + # Try to update via serializer + serializer = WorkspaceLiteSerializer( + workspace, + data={"name": "Updated Name", "slug": "updated-slug"} + ) + + # Serializer should be valid (since read-only fields are ignored) + assert serializer.is_valid() + + # Save should not update the read-only fields + updated_workspace = serializer.save() + assert updated_workspace.name == "Test Workspace" + assert updated_workspace.slug == "test-workspace" \ No newline at end of file diff --git a/apiserver/plane/tests/unit/utils/__init__.py b/apiserver/plane/tests/unit/utils/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apiserver/plane/tests/unit/utils/test_uuid.py b/apiserver/plane/tests/unit/utils/test_uuid.py new file mode 100644 index 00000000000..81403c5bef2 --- /dev/null +++ b/apiserver/plane/tests/unit/utils/test_uuid.py @@ -0,0 +1,49 @@ +import uuid +import pytest +from plane.utils.uuid import is_valid_uuid, convert_uuid_to_integer + + +@pytest.mark.unit +class TestUUIDUtils: + """Test the UUID utilities""" + + def test_is_valid_uuid_with_valid_uuid(self): + """Test is_valid_uuid with a valid UUID""" + # Generate a valid UUID + valid_uuid = str(uuid.uuid4()) + assert is_valid_uuid(valid_uuid) is True + + def test_is_valid_uuid_with_invalid_uuid(self): + """Test is_valid_uuid with invalid UUID strings""" + # Test with different invalid formats + assert is_valid_uuid("not-a-uuid") is False + assert is_valid_uuid("123456789") is False + assert is_valid_uuid("") is False + assert is_valid_uuid("00000000-0000-0000-0000-000000000000") is False # This is a valid UUID but version 1 + + def test_convert_uuid_to_integer(self): + """Test convert_uuid_to_integer function""" + # Create a known UUID + test_uuid = uuid.UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479") + + # Convert to integer + result = convert_uuid_to_integer(test_uuid) + + # Check that the result is an integer + assert isinstance(result, int) + + # Ensure consistent results with the same input + assert convert_uuid_to_integer(test_uuid) == result + + # Different UUIDs should produce different integers + different_uuid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000") + assert convert_uuid_to_integer(different_uuid) != result + + def test_convert_uuid_to_integer_string_input(self): + """Test convert_uuid_to_integer handles string UUID""" + # Test with a UUID string + test_uuid_str = "f47ac10b-58cc-4372-a567-0e02b2c3d479" + test_uuid = uuid.UUID(test_uuid_str) + + # Should get the same result whether passing UUID or string + assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer(test_uuid_str) \ No newline at end of file diff --git a/apiserver/plane/utils/url.py b/apiserver/plane/utils/url.py index e485f93df61..1b4a229a82e 100644 --- a/apiserver/plane/utils/url.py +++ b/apiserver/plane/utils/url.py @@ -4,6 +4,14 @@ from urllib.parse import urlparse, urlunparse +def contains_url(value: str) -> bool: + """ + Check if the value contains a URL. + """ + url_pattern = re.compile(r"https?://|www\\.") + return bool(url_pattern.search(value)) + + def is_valid_url(url: str) -> bool: """ Validates whether the given string is a well-formed URL. diff --git a/apiserver/pytest.ini b/apiserver/pytest.ini new file mode 100644 index 00000000000..e2f19445677 --- /dev/null +++ b/apiserver/pytest.ini @@ -0,0 +1,17 @@ +[pytest] +DJANGO_SETTINGS_MODULE = plane.settings.test +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +markers = + unit: Unit tests for models, serializers, and utility functions + contract: Contract tests for API endpoints + smoke: Smoke tests for critical functionality + slow: Tests that are slow and might be skipped in some contexts + +addopts = + --strict-markers + --reuse-db + --nomigrations + -vs \ No newline at end of file diff --git a/apiserver/requirements/test.txt b/apiserver/requirements/test.txt index 1ffc82d006e..9536ab1e276 100644 --- a/apiserver/requirements/test.txt +++ b/apiserver/requirements/test.txt @@ -1,4 +1,12 @@ -r base.txt -# test checker -pytest==7.1.2 -coverage==6.5.0 \ No newline at end of file +# test framework +pytest==7.4.0 +pytest-django==4.5.2 +pytest-cov==4.1.0 +pytest-xdist==3.3.1 +pytest-mock==3.11.1 +factory-boy==3.3.0 +freezegun==1.2.2 +coverage==7.2.7 +httpx==0.24.1 +requests==2.32.2 \ No newline at end of file diff --git a/apiserver/run_tests.py b/apiserver/run_tests.py new file mode 100755 index 00000000000..f4f0951b199 --- /dev/null +++ b/apiserver/run_tests.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +import argparse +import subprocess +import sys + + +def main(): + parser = argparse.ArgumentParser(description="Run Plane tests") + parser.add_argument( + "-u", "--unit", + action="store_true", + help="Run unit tests only" + ) + parser.add_argument( + "-c", "--contract", + action="store_true", + help="Run contract tests only" + ) + parser.add_argument( + "-s", "--smoke", + action="store_true", + help="Run smoke tests only" + ) + parser.add_argument( + "-o", "--coverage", + action="store_true", + help="Generate coverage report" + ) + parser.add_argument( + "-p", "--parallel", + action="store_true", + help="Run tests in parallel" + ) + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="Verbose output" + ) + args = parser.parse_args() + + # Build command + cmd = ["python", "-m", "pytest"] + markers = [] + + # Add test markers + if args.unit: + markers.append("unit") + if args.contract: + markers.append("contract") + if args.smoke: + markers.append("smoke") + + # Add markers filter + if markers: + cmd.extend(["-m", " or ".join(markers)]) + + # Add coverage + if args.coverage: + cmd.extend(["--cov=plane", "--cov-report=term", "--cov-report=html"]) + + # Add parallel + if args.parallel: + cmd.extend(["-n", "auto"]) + + # Add verbose + if args.verbose: + cmd.append("-v") + + # Add common flags + cmd.extend(["--reuse-db", "--nomigrations"]) + + # Print command + print(f"Running: {' '.join(cmd)}") + + # Execute command + result = subprocess.run(cmd) + + # Check coverage thresholds if coverage is enabled + if args.coverage: + print("Checking coverage thresholds...") + coverage_cmd = ["python", "-m", "coverage", "report", "--fail-under=90"] + coverage_result = subprocess.run(coverage_cmd) + if coverage_result.returncode != 0: + print("Coverage below threshold (90%)") + sys.exit(coverage_result.returncode) + + sys.exit(result.returncode) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/apiserver/run_tests.sh b/apiserver/run_tests.sh new file mode 100755 index 00000000000..7e22479b57e --- /dev/null +++ b/apiserver/run_tests.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# This is a simple wrapper script that calls the main test runner in the tests directory +exec tests/run_tests.sh "$@" \ No newline at end of file diff --git a/live/package.json b/live/package.json index 9616b151307..f020fb16e73 100644 --- a/live/package.json +++ b/live/package.json @@ -57,7 +57,7 @@ "concurrently": "^9.0.1", "nodemon": "^3.1.7", "ts-node": "^10.9.2", - "tsup": "^8.4.0", + "tsup": "8.4.0", "typescript": "5.3.3" } } diff --git a/package.json b/package.json index 593d84459a5..0506158786e 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "nanoid": "3.3.8", "esbuild": "0.25.0", "@babel/helpers": "7.26.10", - "@babel/runtime": "7.26.10" + "@babel/runtime": "7.26.10", + "chokidar": "3.6.0" }, "packageManager": "yarn@1.22.22" } diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 49e10c3d194..58b51ed723a 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -32,5 +32,6 @@ export * from "./dashboard"; export * from "./page"; export * from "./emoji"; export * from "./subscription"; +export * from "./settings"; export * from "./icon"; export * from "./analytics-v2"; diff --git a/packages/constants/src/profile.ts b/packages/constants/src/profile.ts index 032e4526a84..983a787d473 100644 --- a/packages/constants/src/profile.ts +++ b/packages/constants/src/profile.ts @@ -1,39 +1,53 @@ -export const PROFILE_ACTION_LINKS: { - key: string; - i18n_label: string; - href: string; - highlight: (pathname: string) => boolean; -}[] = [ - { +export const PROFILE_SETTINGS = { + profile: { key: "profile", i18n_label: "profile.actions.profile", - href: `/profile`, - highlight: (pathname: string) => pathname === "/profile/", + href: `/settings/account`, + highlight: (pathname: string) => pathname === "/settings/account/", }, - { + security: { key: "security", i18n_label: "profile.actions.security", - href: `/profile/security`, - highlight: (pathname: string) => pathname === "/profile/security/", + href: `/settings/account/security`, + highlight: (pathname: string) => pathname === "/settings/account/security/", }, - { + activity: { key: "activity", i18n_label: "profile.actions.activity", - href: `/profile/activity`, - highlight: (pathname: string) => pathname === "/profile/activity/", + href: `/settings/account/activity`, + highlight: (pathname: string) => pathname === "/settings/account/activity/", }, - { - key: "appearance", - i18n_label: "profile.actions.appearance", - href: `/profile/appearance`, - highlight: (pathname: string) => pathname.includes("/profile/appearance"), + preferences: { + key: "preferences", + i18n_label: "profile.actions.preferences", + href: `/settings/account/preferences`, + highlight: (pathname: string) => pathname === "/settings/account/preferences", }, - { + notifications: { key: "notifications", i18n_label: "profile.actions.notifications", - href: `/profile/notifications`, - highlight: (pathname: string) => pathname === "/profile/notifications/", + href: `/settings/account/notifications`, + highlight: (pathname: string) => pathname === "/settings/account/notifications/", + }, + "api-tokens": { + key: "api-tokens", + i18n_label: "profile.actions.api-tokens", + href: `/settings/account/api-tokens`, + highlight: (pathname: string) => pathname === "/settings/account/api-tokens/", }, +}; +export const PROFILE_ACTION_LINKS: { + key: string; + i18n_label: string; + href: string; + highlight: (pathname: string) => boolean; +}[] = [ + PROFILE_SETTINGS["profile"], + PROFILE_SETTINGS["security"], + PROFILE_SETTINGS["activity"], + PROFILE_SETTINGS["preferences"], + PROFILE_SETTINGS["notifications"], + PROFILE_SETTINGS["api-tokens"], ]; export const PROFILE_VIEWER_TAB = [ @@ -72,6 +86,23 @@ export const PROFILE_ADMINS_TAB = [ }, ]; +export const PREFERENCE_OPTIONS: { + id: string; + title: string; + description: string; +}[] = [ + { + id: "theme", + title: "theme", + description: "select_or_customize_your_interface_color_scheme", + }, + { + id: "start_of_week", + title: "First day of the week", + description: "This will change how all calendars in your app look.", + }, +]; + /** * @description The start of the week for the user * @enum {number} diff --git a/packages/constants/src/settings.ts b/packages/constants/src/settings.ts new file mode 100644 index 00000000000..f42374dc735 --- /dev/null +++ b/packages/constants/src/settings.ts @@ -0,0 +1,52 @@ +import { PROFILE_SETTINGS } from "."; +import { WORKSPACE_SETTINGS } from "./workspace"; + +export enum WORKSPACE_SETTINGS_CATEGORY { + ADMINISTRATION = "administration", + FEATURES = "features", + DEVELOPER = "developer", +} + +export enum PROFILE_SETTINGS_CATEGORY { + YOUR_PROFILE = "your profile", + DEVELOPER = "developer", +} + +export enum PROJECT_SETTINGS_CATEGORY { + PROJECTS = "projects", +} + +export const WORKSPACE_SETTINGS_CATEGORIES = [ + WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION, + WORKSPACE_SETTINGS_CATEGORY.FEATURES, + WORKSPACE_SETTINGS_CATEGORY.DEVELOPER, +]; + +export const PROFILE_SETTINGS_CATEGORIES = [ + PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE, + PROFILE_SETTINGS_CATEGORY.DEVELOPER, +]; + +export const PROJECT_SETTINGS_CATEGORIES = [PROJECT_SETTINGS_CATEGORY.PROJECTS]; + +export const GROUPED_WORKSPACE_SETTINGS = { + [WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION]: [ + WORKSPACE_SETTINGS["general"], + WORKSPACE_SETTINGS["members"], + WORKSPACE_SETTINGS["billing-and-plans"], + WORKSPACE_SETTINGS["export"], + ], + [WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [], + [WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]], +}; + +export const GROUPED_PROFILE_SETTINGS = { + [PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [ + PROFILE_SETTINGS["profile"], + PROFILE_SETTINGS["preferences"], + PROFILE_SETTINGS["notifications"], + PROFILE_SETTINGS["security"], + PROFILE_SETTINGS["activity"], + ], + [PROFILE_SETTINGS_CATEGORY.DEVELOPER]: [PROFILE_SETTINGS["api-tokens"]], +}; diff --git a/packages/constants/src/workspace.ts b/packages/constants/src/workspace.ts index c1c60f392a5..25c942dfc64 100644 --- a/packages/constants/src/workspace.ts +++ b/packages/constants/src/workspace.ts @@ -114,13 +114,6 @@ export const WORKSPACE_SETTINGS = { access: [EUserWorkspaceRoles.ADMIN], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`, }, - "api-tokens": { - key: "api-tokens", - i18n_label: "workspace_settings.settings.api_tokens.title", - href: `/settings/api-tokens`, - access: [EUserWorkspaceRoles.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`, - }, }; export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries( @@ -139,7 +132,6 @@ export const WORKSPACE_SETTINGS_LINKS: { WORKSPACE_SETTINGS["billing-and-plans"], WORKSPACE_SETTINGS["export"], WORKSPACE_SETTINGS["webhooks"], - WORKSPACE_SETTINGS["api-tokens"], ]; export const ROLE = { diff --git a/packages/editor/package.json b/packages/editor/package.json index cfbd0861e7d..f2da418c7fd 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -81,7 +81,7 @@ "@types/react": "^18.3.11", "@types/react-dom": "^18.2.18", "postcss": "^8.4.38", - "tsup": "^8.4.0", + "tsup": "8.4.0", "typescript": "5.3.3" }, "keywords": [ diff --git a/packages/editor/src/ce/constants/utility.ts b/packages/editor/src/ce/constants/utility.ts new file mode 100644 index 00000000000..616838a6268 --- /dev/null +++ b/packages/editor/src/ce/constants/utility.ts @@ -0,0 +1,14 @@ +import { ExtensionFileSetStorageKey } from "@/plane-editor/types/storage"; + +export const NODE_FILE_MAP: { + [key: string]: { + fileSetName: ExtensionFileSetStorageKey; + }; +} = { + image: { + fileSetName: "deletedImageSet", + }, + imageComponent: { + fileSetName: "deletedImageSet", + }, +}; diff --git a/packages/editor/src/ce/extensions/document-extensions.tsx b/packages/editor/src/ce/extensions/document-extensions.tsx index 445f5e0f809..29072b41c36 100644 --- a/packages/editor/src/ce/extensions/document-extensions.tsx +++ b/packages/editor/src/ce/extensions/document-extensions.tsx @@ -1,5 +1,4 @@ import { HocuspocusProvider } from "@hocuspocus/provider"; -import { Extensions } from "@tiptap/core"; import { AnyExtension } from "@tiptap/core"; import { SlashCommands } from "@/extensions"; // plane editor types diff --git a/packages/editor/src/ce/types/storage.ts b/packages/editor/src/ce/types/storage.ts index 4e106738b52..5f576df5090 100644 --- a/packages/editor/src/ce/types/storage.ts +++ b/packages/editor/src/ce/types/storage.ts @@ -1,13 +1,20 @@ -import { HeadingExtensionStorage } from "@/extensions"; -import { CustomImageExtensionStorage } from "@/extensions/custom-image"; -import { CustomLinkStorage } from "@/extensions/custom-link"; -import { MentionExtensionStorage } from "@/extensions/mentions"; -import { ImageExtensionStorage } from "@/plugins/image"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// extensions +import { type HeadingExtensionStorage } from "@/extensions"; +import { type CustomImageExtensionStorage } from "@/extensions/custom-image"; +import { type CustomLinkStorage } from "@/extensions/custom-link"; +import { type ImageExtensionStorage } from "@/extensions/image"; +import { type MentionExtensionStorage } from "@/extensions/mentions"; +import { type UtilityExtensionStorage } from "@/extensions/utility"; export type ExtensionStorageMap = { - imageComponent: CustomImageExtensionStorage; - image: ImageExtensionStorage; - link: CustomLinkStorage; - headingList: HeadingExtensionStorage; - mention: MentionExtensionStorage; + [CORE_EXTENSIONS.CUSTOM_IMAGE]: CustomImageExtensionStorage; + [CORE_EXTENSIONS.IMAGE]: ImageExtensionStorage; + [CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage; + [CORE_EXTENSIONS.HEADINGS_LIST]: HeadingExtensionStorage; + [CORE_EXTENSIONS.MENTION]: MentionExtensionStorage; + [CORE_EXTENSIONS.UTILITY]: UtilityExtensionStorage; }; + +export type ExtensionFileSetStorageKey = Extract; diff --git a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx index 623ec9508c8..d1398ff5ae1 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx @@ -7,7 +7,7 @@ import { DocumentContentLoader, PageRenderer } from "@/components/editors"; // constants import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config"; // extensions -import { IssueWidget } from "@/extensions"; +import { WorkItemEmbedExtension } from "@/extensions"; // helpers import { getEditorClassNames } from "@/helpers/common"; // hooks @@ -39,9 +39,10 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { } = props; const extensions: Extensions = []; + if (embedHandler?.issue) { extensions.push( - IssueWidget({ + WorkItemEmbedExtension({ widgetCallback: embedHandler.issue.widgetCallback, }) ); diff --git a/packages/editor/src/core/components/editors/document/read-only-editor.tsx b/packages/editor/src/core/components/editors/document/read-only-editor.tsx index 54a1f96e2c2..2d2e3083016 100644 --- a/packages/editor/src/core/components/editors/document/read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/document/read-only-editor.tsx @@ -7,7 +7,7 @@ import { PageRenderer } from "@/components/editors"; // constants import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config"; // extensions -import { IssueWidget } from "@/extensions"; +import { WorkItemEmbedExtension } from "@/extensions"; // helpers import { getEditorClassNames } from "@/helpers/common"; // hooks @@ -53,7 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { const extensions: Extensions = []; if (embedHandler?.issue) { extensions.push( - IssueWidget({ + WorkItemEmbedExtension({ widgetCallback: embedHandler.issue.widgetCallback, }) ); diff --git a/packages/editor/src/core/components/editors/editor-container.tsx b/packages/editor/src/core/components/editors/editor-container.tsx index d0811cd410c..6daa0719a0f 100644 --- a/packages/editor/src/core/components/editors/editor-container.tsx +++ b/packages/editor/src/core/components/editors/editor-container.tsx @@ -4,6 +4,7 @@ import { FC, ReactNode, useRef } from "react"; import { cn } from "@plane/utils"; // constants import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config"; +import { CORE_EXTENSIONS } from "@/constants/extension"; // types import { TDisplayConfig } from "@/types"; // components @@ -36,12 +37,12 @@ export const EditorContainer: FC = (props) => { if ( currentNode.content.size === 0 && // Check if the current node is empty !( - editor.isActive("orderedList") || - editor.isActive("bulletList") || - editor.isActive("taskItem") || - editor.isActive("table") || - editor.isActive("blockquote") || - editor.isActive("codeBlock") + editor.isActive(CORE_EXTENSIONS.ORDERED_LIST) || + editor.isActive(CORE_EXTENSIONS.BULLET_LIST) || + editor.isActive(CORE_EXTENSIONS.TASK_ITEM) || + editor.isActive(CORE_EXTENSIONS.TABLE) || + editor.isActive(CORE_EXTENSIONS.BLOCKQUOTE) || + editor.isActive(CORE_EXTENSIONS.CODE_BLOCK) ) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block ) { return; @@ -53,10 +54,10 @@ export const EditorContainer: FC = (props) => { const lastNode = lastNodePos.node(); // Check if the last node is a not paragraph - if (lastNode && lastNode.type.name !== "paragraph") { + if (lastNode && lastNode.type.name !== CORE_EXTENSIONS.PARAGRAPH) { // If last node is not a paragraph, insert a new paragraph at the end const endPosition = editor?.state.doc.content.size; - editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run(); + editor?.chain().insertContentAt(endPosition, { type: CORE_EXTENSIONS.PARAGRAPH }).run(); // Focus the newly added paragraph for immediate editing editor diff --git a/packages/editor/src/core/components/editors/link-view-container.tsx b/packages/editor/src/core/components/editors/link-view-container.tsx index 41263a9962e..68fa33dde4f 100644 --- a/packages/editor/src/core/components/editors/link-view-container.tsx +++ b/packages/editor/src/core/components/editors/link-view-container.tsx @@ -12,7 +12,7 @@ interface LinkViewContainerProps { export const LinkViewContainer: FC = ({ editor, containerRef }) => { const [linkViewProps, setLinkViewProps] = useState(); const [isOpen, setIsOpen] = useState(false); - const [virtualElement, setVirtualElement] = useState(null); + const [virtualElement, setVirtualElement] = useState(null); const editorState = useEditorState({ editor, diff --git a/packages/editor/src/core/components/links/link-edit-view.tsx b/packages/editor/src/core/components/links/link-edit-view.tsx index ad66ce4b45a..1e9a62b0e1e 100644 --- a/packages/editor/src/core/components/links/link-edit-view.tsx +++ b/packages/editor/src/core/components/links/link-edit-view.tsx @@ -51,7 +51,9 @@ export const LinkEditView = ({ viewProps }: LinkEditViewProps) => { if (!hasSubmitted.current && !linkRemoved && initialUrl === "") { try { removeLink(); - } catch (e) {} + } catch (e) { + console.error("Error removing link", e); + } } }, [linkRemoved, initialUrl] diff --git a/packages/editor/src/core/components/menus/block-menu.tsx b/packages/editor/src/core/components/menus/block-menu.tsx index c143abd009c..bd86628cb32 100644 --- a/packages/editor/src/core/components/menus/block-menu.tsx +++ b/packages/editor/src/core/components/menus/block-menu.tsx @@ -1,7 +1,9 @@ -import { useCallback, useEffect, useRef } from "react"; import { Editor } from "@tiptap/react"; -import tippy, { Instance } from "tippy.js"; import { Copy, LucideIcon, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useRef } from "react"; +import tippy, { Instance } from "tippy.js"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; interface BlockMenuProps { editor: Editor; @@ -102,7 +104,8 @@ export const BlockMenu = (props: BlockMenuProps) => { key: "duplicate", label: "Duplicate", isDisabled: - editor.state.selection.content().content.firstChild?.type.name === "image" || editor.isActive("imageComponent"), + editor.state.selection.content().content.firstChild?.type.name === CORE_EXTENSIONS.IMAGE || + editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE), onClick: (e) => { e.preventDefault(); e.stopPropagation(); diff --git a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx index 1dd47c5bb33..6f582f89c67 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx @@ -1,8 +1,10 @@ import { Editor } from "@tiptap/core"; import { Check, Link, Trash2 } from "lucide-react"; import { Dispatch, FC, SetStateAction, useCallback, useRef, useState } from "react"; -// plane utils +// plane imports import { cn } from "@plane/utils"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // helpers import { isValidHttpUrl } from "@/helpers/common"; import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands"; @@ -43,7 +45,7 @@ export const BubbleMenuLinkSelector: FC = (props) => { "h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors", { "bg-custom-background-80": isOpen, - "text-custom-text-100": editor.isActive("link"), + "text-custom-text-100": editor.isActive(CORE_EXTENSIONS.CUSTOM_LINK), } )} onClick={(e) => { diff --git a/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx index 7d1378800c9..564f7d97cab 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx @@ -1,6 +1,6 @@ -import { Dispatch, FC, SetStateAction } from "react"; import { Editor } from "@tiptap/react"; import { Check, ChevronDown } from "lucide-react"; +import { Dispatch, FC, SetStateAction } from "react"; // plane utils import { cn } from "@plane/utils"; // components diff --git a/packages/editor/src/core/components/menus/bubble-menu/root.tsx b/packages/editor/src/core/components/menus/bubble-menu/root.tsx index 02eb8d48675..30a7c5620b5 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -18,6 +18,7 @@ import { } from "@/components/menus"; // constants import { COLORS_LIST } from "@/constants/common"; +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; // local components @@ -90,8 +91,8 @@ export const EditorBubbleMenu: FC = (props: { editor: Edi if ( empty || !editor.isEditable || - editor.isActive("image") || - editor.isActive("imageComponent") || + editor.isActive(CORE_EXTENSIONS.IMAGE) || + editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE) || isNodeSelection(selection) || isCellSelection(selection) || isSelecting diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index 4268ccb6c48..c3aa4d414a7 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -23,6 +23,8 @@ import { Palette, AlignCenter, } from "lucide-react"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // helpers import { insertHorizontalRule, @@ -35,12 +37,7 @@ import { toggleBold, toggleBulletList, toggleCodeBlock, - toggleHeadingFive, - toggleHeadingFour, - toggleHeadingOne, - toggleHeadingSix, - toggleHeadingThree, - toggleHeadingTwo, + toggleHeading, toggleItalic, toggleOrderedList, toggleStrike, @@ -65,63 +62,49 @@ export type EditorMenuItem = { export const TextItem = (editor: Editor): EditorMenuItem<"text"> => ({ key: "text", name: "Text", - isActive: () => editor.isActive("paragraph"), + isActive: () => editor.isActive(CORE_EXTENSIONS.PARAGRAPH), command: () => setText(editor), icon: CaseSensitive, }); -export const HeadingOneItem = (editor: Editor): EditorMenuItem<"h1"> => ({ - key: "h1", - name: "Heading 1", - isActive: () => editor.isActive("heading", { level: 1 }), - command: () => toggleHeadingOne(editor), - icon: Heading1, -}); +type SupportedHeadingLevels = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; -export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> => ({ - key: "h2", - name: "Heading 2", - isActive: () => editor.isActive("heading", { level: 2 }), - command: () => toggleHeadingTwo(editor), - icon: Heading2, +const HeadingItem = ( + editor: Editor, + level: 1 | 2 | 3 | 4 | 5 | 6, + key: T, + name: string, + icon: LucideIcon +): EditorMenuItem => ({ + key, + name, + isActive: () => editor.isActive(CORE_EXTENSIONS.HEADING, { level }), + command: () => toggleHeading(editor, level), + icon, }); -export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> => ({ - key: "h3", - name: "Heading 3", - isActive: () => editor.isActive("heading", { level: 3 }), - command: () => toggleHeadingThree(editor), - icon: Heading3, -}); +export const HeadingOneItem = (editor: Editor): EditorMenuItem<"h1"> => + HeadingItem(editor, 1, "h1", "Heading 1", Heading1); -export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> => ({ - key: "h4", - name: "Heading 4", - isActive: () => editor.isActive("heading", { level: 4 }), - command: () => toggleHeadingFour(editor), - icon: Heading4, -}); +export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> => + HeadingItem(editor, 2, "h2", "Heading 2", Heading2); -export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> => ({ - key: "h5", - name: "Heading 5", - isActive: () => editor.isActive("heading", { level: 5 }), - command: () => toggleHeadingFive(editor), - icon: Heading5, -}); +export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> => + HeadingItem(editor, 3, "h3", "Heading 3", Heading3); -export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> => ({ - key: "h6", - name: "Heading 6", - isActive: () => editor.isActive("heading", { level: 6 }), - command: () => toggleHeadingSix(editor), - icon: Heading6, -}); +export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> => + HeadingItem(editor, 4, "h4", "Heading 4", Heading4); + +export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> => + HeadingItem(editor, 5, "h5", "Heading 5", Heading5); + +export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> => + HeadingItem(editor, 6, "h6", "Heading 6", Heading6); export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({ key: "bold", name: "Bold", - isActive: () => editor?.isActive("bold"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.BOLD), command: () => toggleBold(editor), icon: BoldIcon, }); @@ -129,7 +112,7 @@ export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({ export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({ key: "italic", name: "Italic", - isActive: () => editor?.isActive("italic"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.ITALIC), command: () => toggleItalic(editor), icon: ItalicIcon, }); @@ -137,7 +120,7 @@ export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({ export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({ key: "underline", name: "Underline", - isActive: () => editor?.isActive("underline"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.UNDERLINE), command: () => toggleUnderline(editor), icon: UnderlineIcon, }); @@ -145,7 +128,7 @@ export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({ key: "strikethrough", name: "Strikethrough", - isActive: () => editor?.isActive("strike"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.STRIKETHROUGH), command: () => toggleStrike(editor), icon: StrikethroughIcon, }); @@ -153,7 +136,7 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({ key: "bulleted-list", name: "Bulleted list", - isActive: () => editor?.isActive("bulletList"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.BULLET_LIST), command: () => toggleBulletList(editor), icon: ListIcon, }); @@ -161,7 +144,7 @@ export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({ key: "numbered-list", name: "Numbered list", - isActive: () => editor?.isActive("orderedList"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.ORDERED_LIST), command: () => toggleOrderedList(editor), icon: ListOrderedIcon, }); @@ -169,7 +152,7 @@ export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list" export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({ key: "to-do-list", name: "To-do list", - isActive: () => editor.isActive("taskItem"), + isActive: () => editor.isActive(CORE_EXTENSIONS.TASK_ITEM), command: () => toggleTaskList(editor), icon: CheckSquare, }); @@ -177,7 +160,7 @@ export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({ export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({ key: "quote", name: "Quote", - isActive: () => editor?.isActive("blockquote"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.BLOCKQUOTE), command: () => toggleBlockquote(editor), icon: TextQuote, }); @@ -185,7 +168,7 @@ export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({ export const CodeItem = (editor: Editor): EditorMenuItem<"code"> => ({ key: "code", name: "Code", - isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.CODE_INLINE) || editor?.isActive(CORE_EXTENSIONS.CODE_BLOCK), command: () => toggleCodeBlock(editor), icon: CodeIcon, }); @@ -193,7 +176,7 @@ export const CodeItem = (editor: Editor): EditorMenuItem<"code"> => ({ export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({ key: "table", name: "Table", - isActive: () => editor?.isActive("table"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.TABLE), command: () => insertTableCommand(editor), icon: TableIcon, }); @@ -201,7 +184,7 @@ export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({ export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({ key: "image", name: "Image", - isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.IMAGE) || editor?.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE), command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }), icon: ImageIcon, }); @@ -210,7 +193,7 @@ export const HorizontalRuleItem = (editor: Editor) => ({ key: "divider", name: "Divider", - isActive: () => editor?.isActive("horizontalRule"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.HORIZONTAL_RULE), command: () => insertHorizontalRule(editor), icon: MinusSquare, }) as const; @@ -218,7 +201,7 @@ export const HorizontalRuleItem = (editor: Editor) => export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ({ key: "text-color", name: "Color", - isActive: (props) => editor.isActive("customColor", { color: props?.color }), + isActive: (props) => editor.isActive(CORE_EXTENSIONS.CUSTOM_COLOR, { color: props?.color }), command: (props) => { if (!props) return; toggleTextColor(props.color, editor); @@ -229,7 +212,7 @@ export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ( export const BackgroundColorItem = (editor: Editor): EditorMenuItem<"background-color"> => ({ key: "background-color", name: "Background color", - isActive: (props) => editor.isActive("customColor", { backgroundColor: props?.color }), + isActive: (props) => editor.isActive(CORE_EXTENSIONS.CUSTOM_COLOR, { backgroundColor: props?.color }), command: (props) => { if (!props) return; toggleBackgroundColor(props.color, editor); diff --git a/packages/editor/src/core/constants/extension.ts b/packages/editor/src/core/constants/extension.ts new file mode 100644 index 00000000000..db070cb7bfd --- /dev/null +++ b/packages/editor/src/core/constants/extension.ts @@ -0,0 +1,44 @@ +export enum CORE_EXTENSIONS { + BLOCKQUOTE = "blockquote", + BOLD = "bold", + BULLET_LIST = "bulletList", + CALLOUT = "calloutComponent", + CHARACTER_COUNT = "characterCount", + CODE_BLOCK = "codeBlock", + CODE_INLINE = "code", + CUSTOM_COLOR = "customColor", + CUSTOM_IMAGE = "imageComponent", + CUSTOM_LINK = "link", + DOCUMENT = "doc", + DROP_CURSOR = "dropCursor", + ENTER_KEY = "enterKey", + GAP_CURSOR = "gapCursor", + HARD_BREAK = "hardBreak", + HEADING = "heading", + HEADINGS_LIST = "headingsList", + HISTORY = "history", + HORIZONTAL_RULE = "horizontalRule", + IMAGE = "image", + ITALIC = "italic", + LIST_ITEM = "listItem", + MARKDOWN_CLIPBOARD = "markdownClipboard", + MENTION = "mention", + ORDERED_LIST = "orderedList", + PARAGRAPH = "paragraph", + PLACEHOLDER = "placeholder", + SIDE_MENU = "editorSideMenu", + SLASH_COMMANDS = "slash-command", + STRIKETHROUGH = "strike", + TABLE = "table", + TABLE_CELL = "tableCell", + TABLE_HEADER = "tableHeader", + TABLE_ROW = "tableRow", + TASK_ITEM = "taskItem", + TASK_LIST = "taskList", + TEXT_ALIGN = "textAlign", + TEXT_STYLE = "textStyle", + TYPOGRAPHY = "typography", + UNDERLINE = "underline", + UTILITY = "utility", + WORK_ITEM_EMBED = "issue-embed-component", +} diff --git a/packages/editor/src/core/constants/meta.ts b/packages/editor/src/core/constants/meta.ts new file mode 100644 index 00000000000..66769bb82c9 --- /dev/null +++ b/packages/editor/src/core/constants/meta.ts @@ -0,0 +1,3 @@ +export enum CORE_EDITOR_META { + SKIP_FILE_DELETION = "skipFileDeletion", +} diff --git a/packages/editor/src/core/extensions/callout/block.tsx b/packages/editor/src/core/extensions/callout/block.tsx index b6c6d7991bb..662a5ad3971 100644 --- a/packages/editor/src/core/extensions/callout/block.tsx +++ b/packages/editor/src/core/extensions/callout/block.tsx @@ -1,5 +1,5 @@ -import React, { useState } from "react"; import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import React, { useState } from "react"; // constants import { COLORS_LIST } from "@/constants/common"; // local components diff --git a/packages/editor/src/core/extensions/callout/extension-config.ts b/packages/editor/src/core/extensions/callout/extension-config.ts index 546311509ea..e52be72d650 100644 --- a/packages/editor/src/core/extensions/callout/extension-config.ts +++ b/packages/editor/src/core/extensions/callout/extension-config.ts @@ -1,6 +1,8 @@ import { Node, mergeAttributes } from "@tiptap/core"; -import { Node as NodeType } from "@tiptap/pm/model"; import { MarkdownSerializerState } from "@tiptap/pm/markdown"; +import { Node as NodeType } from "@tiptap/pm/model"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // types import { EAttributeNames, TCalloutBlockAttributes } from "./types"; // utils @@ -9,14 +11,14 @@ import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils"; // Extend Tiptap's Commands interface declare module "@tiptap/core" { interface Commands { - calloutComponent: { + [CORE_EXTENSIONS.CALLOUT]: { insertCallout: () => ReturnType; }; } } export const CustomCalloutExtensionConfig = Node.create({ - name: "calloutComponent", + name: CORE_EXTENSIONS.CALLOUT, group: "block", content: "block+", diff --git a/packages/editor/src/core/extensions/callout/logo-selector.tsx b/packages/editor/src/core/extensions/callout/logo-selector.tsx index 8ea47d50d0a..7a552cd16f0 100644 --- a/packages/editor/src/core/extensions/callout/logo-selector.tsx +++ b/packages/editor/src/core/extensions/callout/logo-selector.tsx @@ -1,9 +1,6 @@ -// plane helpers -import { convertHexEmojiToDecimal } from "@plane/utils"; -// plane ui +// plane imports import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui"; -// plane utils -import { cn } from "@plane/utils"; +import { cn, convertHexEmojiToDecimal } from "@plane/utils"; // types import { TCalloutBlockAttributes } from "./types"; // utils diff --git a/packages/editor/src/core/extensions/callout/types.ts b/packages/editor/src/core/extensions/callout/types.ts index 17c55d9e5bb..8e650d87332 100644 --- a/packages/editor/src/core/extensions/callout/types.ts +++ b/packages/editor/src/core/extensions/callout/types.ts @@ -20,7 +20,7 @@ export type TCalloutBlockEmojiAttributes = { export type TCalloutBlockAttributes = { [EAttributeNames.LOGO_IN_USE]: "emoji" | "icon"; - [EAttributeNames.BACKGROUND]: string; + [EAttributeNames.BACKGROUND]: string | undefined; [EAttributeNames.BLOCK_TYPE]: "callout-component"; } & TCalloutBlockIconAttributes & TCalloutBlockEmojiAttributes; diff --git a/packages/editor/src/core/extensions/callout/utils.ts b/packages/editor/src/core/extensions/callout/utils.ts index 6568a40e3ea..3bf07f0a9ca 100644 --- a/packages/editor/src/core/extensions/callout/utils.ts +++ b/packages/editor/src/core/extensions/callout/utils.ts @@ -1,7 +1,6 @@ -// plane helpers -import { sanitizeHTML } from "@plane/utils"; -// plane ui +// plane imports import { TEmojiLogoProps } from "@plane/ui"; +import { sanitizeHTML } from "@plane/utils"; // types import { EAttributeNames, @@ -12,11 +11,11 @@ import { export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = { "data-logo-in-use": "emoji", - "data-icon-color": null, - "data-icon-name": null, + "data-icon-color": undefined, + "data-icon-name": undefined, "data-emoji-unicode": "128161", "data-emoji-url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png", - "data-background": null, + "data-background": undefined, "data-block-type": "callout-component", }; @@ -32,7 +31,7 @@ export const getStoredLogo = (): TStoredLogoValue => { }; if (typeof window !== "undefined") { - const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo")); + const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo") ?? ""); if (storedData) { let parsedData: TEmojiLogoProps; try { @@ -69,7 +68,7 @@ export const updateStoredLogo = (value: TEmojiLogoProps): void => { // function to get the stored background color from local storage export const getStoredBackgroundColor = (): string | null => { if (typeof window !== "undefined") { - return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background")); + return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background") ?? ""); } return null; }; diff --git a/packages/editor/src/core/extensions/clipboard.ts b/packages/editor/src/core/extensions/clipboard.ts deleted file mode 100644 index 252f0a113ff..00000000000 --- a/packages/editor/src/core/extensions/clipboard.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Extension } from "@tiptap/core"; -import { Fragment, Node } from "@tiptap/pm/model"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; - -export const MarkdownClipboard = Extension.create({ - name: "markdownClipboard", - - addProseMirrorPlugins() { - return [ - new Plugin({ - key: new PluginKey("markdownClipboard"), - props: { - clipboardTextSerializer: (slice) => { - const markdownSerializer = this.editor.storage.markdown.serializer; - const isTableRow = slice.content.firstChild?.type?.name === "tableRow"; - const nodeSelect = slice.openStart === 0 && slice.openEnd === 0; - - if (nodeSelect) { - return markdownSerializer.serialize(slice.content); - } - - const processTableContent = (tableNode: Node | Fragment) => { - let result = ""; - tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => { - tableRowNode.content?.forEach?.((cell: Node) => { - const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : ""; - result += cellContent + "\n"; - }); - }); - return result; - }; - - if (isTableRow) { - const rowsCount = slice.content?.childCount || 0; - const cellsCount = slice.content?.firstChild?.content?.childCount || 0; - if (rowsCount === 1 || cellsCount === 1) { - return processTableContent(slice.content); - } else { - return markdownSerializer.serialize(slice.content); - } - } - - const traverseToParentOfLeaf = ( - node: Node | null, - parent: Fragment | Node, - depth: number - ): Node | Fragment => { - let currentNode = node; - let currentParent = parent; - let currentDepth = depth; - - while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) { - if (currentNode.content?.childCount > 1) { - if (currentNode.content.firstChild?.type?.name === "listItem") { - return currentParent; - } else { - return currentNode.content; - } - } - - currentParent = currentNode; - currentNode = currentNode.content?.firstChild || null; - currentDepth--; - } - - return currentParent; - }; - - if (slice.content.childCount > 1) { - return markdownSerializer.serialize(slice.content); - } else { - const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart); - - let currentNode = targetNode; - while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) { - currentNode = currentNode.firstChild; - } - if (currentNode instanceof Node && currentNode.isText) { - return currentNode.text; - } - - return markdownSerializer.serialize(targetNode); - } - }, - }, - }), - ]; - }, -}); diff --git a/packages/editor/src/core/extensions/code-inline/index.tsx b/packages/editor/src/core/extensions/code-inline/index.tsx index 6e023b6ed16..ae320cf6a29 100644 --- a/packages/editor/src/core/extensions/code-inline/index.tsx +++ b/packages/editor/src/core/extensions/code-inline/index.tsx @@ -1,4 +1,6 @@ import { Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface CodeOptions { HTMLAttributes: Record; @@ -6,7 +8,7 @@ export interface CodeOptions { declare module "@tiptap/core" { interface Commands { - code: { + [CORE_EXTENSIONS.CODE_INLINE]: { /** * Set a code mark */ @@ -27,7 +29,7 @@ export const inputRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))$/; const pasteRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))/g; export const CustomCodeInlineExtension = Mark.create({ - name: "code", + name: CORE_EXTENSIONS.CODE_INLINE, addOptions() { return { diff --git a/packages/editor/src/core/extensions/code/code-block-node-view.tsx b/packages/editor/src/core/extensions/code/code-block-node-view.tsx index a06d839908a..7626031bc21 100644 --- a/packages/editor/src/core/extensions/code/code-block-node-view.tsx +++ b/packages/editor/src/core/extensions/code/code-block-node-view.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState } from "react"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { NodeViewWrapper, NodeViewContent } from "@tiptap/react"; import ts from "highlight.js/lib/languages/typescript"; import { common, createLowlight } from "lowlight"; import { CopyIcon, CheckIcon } from "lucide-react"; +import { useState } from "react"; // ui import { Tooltip } from "@plane/ui"; // plane utils @@ -27,7 +27,7 @@ export const CodeBlockComponent: React.FC = ({ node }) await navigator.clipboard.writeText(node.textContent); setCopied(true); setTimeout(() => setCopied(false), 1000); - } catch (error) { + } catch { setCopied(false); } e.preventDefault(); diff --git a/packages/editor/src/core/extensions/code/code-block.ts b/packages/editor/src/core/extensions/code/code-block.ts index b2218ee45ce..3b07617ca79 100644 --- a/packages/editor/src/core/extensions/code/code-block.ts +++ b/packages/editor/src/core/extensions/code/code-block.ts @@ -1,5 +1,7 @@ import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface CodeBlockOptions { /** @@ -25,7 +27,7 @@ export interface CodeBlockOptions { declare module "@tiptap/core" { interface Commands { - codeBlock: { + [CORE_EXTENSIONS.CODE_BLOCK]: { /** * Set a code block */ @@ -42,7 +44,7 @@ export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/; export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/; export const CodeBlock = Node.create({ - name: "codeBlock", + name: CORE_EXTENSIONS.CODE_BLOCK, addOptions() { return { @@ -118,7 +120,7 @@ export const CodeBlock = Node.create({ toggleCodeBlock: (attributes) => ({ commands }) => - commands.toggleNode(this.name, "paragraph", attributes), + commands.toggleNode(this.name, CORE_EXTENSIONS.PARAGRAPH, attributes), }; }, @@ -126,7 +128,7 @@ export const CodeBlock = Node.create({ return { "Mod-Alt-c": () => this.editor.commands.toggleCodeBlock(), - // remove code block when at start of document or code block is empty + // remove codeBlock when at start of document or codeBlock is empty Backspace: () => { try { const { empty, $anchor } = this.editor.state.selection; @@ -259,7 +261,7 @@ export const CodeBlock = Node.create({ return false; } - if (this.editor.isActive("code")) { + if (this.editor.isActive(CORE_EXTENSIONS.CODE_INLINE)) { // Check if it's an inline code block event.preventDefault(); const text = event.clipboardData.getData("text/plain"); diff --git a/packages/editor/src/core/extensions/code/lowlight-plugin.ts b/packages/editor/src/core/extensions/code/lowlight-plugin.ts index 5ac30c27ea7..0b8ed71ad70 100644 --- a/packages/editor/src/core/extensions/code/lowlight-plugin.ts +++ b/packages/editor/src/core/extensions/code/lowlight-plugin.ts @@ -88,7 +88,7 @@ export function LowlightPlugin({ throw Error("You should provide an instance of lowlight to use the code-block-lowlight extension"); } - const lowlightPlugin: Plugin = new Plugin({ + const lowlightPlugin: Plugin = new Plugin({ key: new PluginKey("lowlight"), state: { diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts index ed9f5c1a4b5..a309c2013af 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -3,24 +3,24 @@ import TaskList from "@tiptap/extension-task-list"; import TextStyle from "@tiptap/extension-text-style"; import TiptapUnderline from "@tiptap/extension-underline"; import StarterKit from "@tiptap/starter-kit"; -// extensions // helpers import { isValidHttpUrl } from "@/helpers/common"; +// plane editor imports +import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props"; +// extensions +import { CustomCalloutExtensionConfig } from "./callout/extension-config"; import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props"; import { CustomCodeInlineExtension } from "./code-inline"; +import { CustomColorExtension } from "./custom-color"; import { CustomLinkExtension } from "./custom-link"; import { CustomHorizontalRule } from "./horizontal-rule"; import { ImageExtensionWithoutProps } from "./image"; import { CustomImageComponentWithoutProps } from "./image/image-component-without-props"; -import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props"; import { CustomMentionExtensionConfig } from "./mentions/extension-config"; import { CustomQuoteExtension } from "./quote"; import { TableHeader, TableCell, TableRow, Table } from "./table"; import { CustomTextAlignExtension } from "./text-align"; -import { CustomCalloutExtensionConfig } from "./callout/extension-config"; -import { CustomColorExtension } from "./custom-color"; -// plane editor extensions -import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props"; +import { WorkItemEmbedExtensionConfig } from "./work-item-embed/extension-config"; export const CoreEditorExtensionsWithoutProps = [ StarterKit.configure({ @@ -72,12 +72,12 @@ export const CoreEditorExtensionsWithoutProps = [ "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", }, }), - ImageExtensionWithoutProps().configure({ + ImageExtensionWithoutProps.configure({ HTMLAttributes: { class: "rounded-md", }, }), - CustomImageComponentWithoutProps(), + CustomImageComponentWithoutProps, TiptapUnderline, TextStyle, TaskList.configure({ @@ -104,4 +104,4 @@ export const CoreEditorExtensionsWithoutProps = [ ...CoreEditorAdditionalExtensionsWithoutProps, ]; -export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()]; +export const DocumentEditorExtensionsWithoutProps = [WorkItemEmbedExtensionConfig]; diff --git a/packages/editor/src/core/extensions/custom-code-inline.ts b/packages/editor/src/core/extensions/custom-code-inline.ts deleted file mode 100644 index 3b3cfaab1e1..00000000000 --- a/packages/editor/src/core/extensions/custom-code-inline.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Extension } from "@tiptap/core"; -import codemark from "prosemirror-codemark"; - -export const CustomCodeMarkPlugin = Extension.create({ - name: "codemarkPlugin", - addProseMirrorPlugins() { - return codemark({ markType: this.editor.schema.marks.code }); - }, -}); diff --git a/packages/editor/src/core/extensions/custom-color.ts b/packages/editor/src/core/extensions/custom-color.ts index b377099fb59..8b516e8ecd3 100644 --- a/packages/editor/src/core/extensions/custom-color.ts +++ b/packages/editor/src/core/extensions/custom-color.ts @@ -1,10 +1,11 @@ import { Mark, mergeAttributes } from "@tiptap/core"; // constants import { COLORS_LIST } from "@/constants/common"; +import { CORE_EXTENSIONS } from "@/constants/extension"; declare module "@tiptap/core" { interface Commands { - color: { + [CORE_EXTENSIONS.CUSTOM_COLOR]: { /** * Set the text color * @param {string} color The color to set @@ -34,7 +35,7 @@ declare module "@tiptap/core" { } export const CustomColorExtension = Mark.create({ - name: "customColor", + name: CORE_EXTENSIONS.CUSTOM_COLOR, addOptions() { return { diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx index 0cc38f5a43a..5dfbad01294 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -3,7 +3,7 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from // plane utils import { cn } from "@plane/utils"; // extensions -import { CustoBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image"; +import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image"; import { ImageUploadStatus } from "./upload-status"; const MIN_SIZE = 100; @@ -38,7 +38,7 @@ const ensurePixelString = (value: Pixel | TDefault | number | undefin return value; }; -type CustomImageBlockProps = CustoBaseImageNodeViewProps & { +type CustomImageBlockProps = CustomBaseImageNodeViewProps & { imageFromFileSystem: string | undefined; setFailedToLoadImage: (isError: boolean) => void; editorContainer: HTMLDivElement | null; diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx index e525bc6da4b..8dfe6974b75 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx @@ -1,10 +1,13 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { useEffect, useRef, useState } from "react"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image"; +// helpers import { getExtensionStorage } from "@/helpers/get-extension-storage"; -export type CustoBaseImageNodeViewProps = { +export type CustomBaseImageNodeViewProps = { getPos: () => number; editor: Editor; node: NodeViewProps["node"] & { @@ -14,7 +17,7 @@ export type CustoBaseImageNodeViewProps = { selected: boolean; }; -export type CustomImageNodeProps = NodeViewProps & CustoBaseImageNodeViewProps; +export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps; export const CustomImageNode = (props: CustomImageNodeProps) => { const { getPos, editor, node, updateAttributes, selected } = props; @@ -77,7 +80,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => { failedToLoadImage={failedToLoadImage} getPos={getPos} loadImageFromFileSystem={setImageFromFileSystem} - maxFileSize={getExtensionStorage(editor, "imageComponent").maxFileSize} + maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize} node={node} setIsUploaded={setIsUploaded} selected={selected} diff --git a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx index 0a3ee1a1c36..17c9f817736 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx @@ -4,12 +4,16 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react"; import { cn } from "@plane/utils"; // constants import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config"; +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions -import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image"; +import { CustomBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image"; +// helpers +import { EFileError } from "@/helpers/file"; +import { getExtensionStorage } from "@/helpers/get-extension-storage"; // hooks import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload"; -type CustomImageUploaderProps = CustoBaseImageNodeViewProps & { +type CustomImageUploaderProps = CustomBaseImageNodeViewProps & { maxFileSize: number; loadImageFromFileSystem: (file: string) => void; failedToLoadImage: boolean; @@ -57,7 +61,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { // control cursor position after upload const nextNode = editor.state.doc.nodeAt(pos + 1); - if (nextNode && nextNode.type.name === "paragraph") { + if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) { // If there is a paragraph node after the image component, move the focus to the next node editor.commands.setTextSelection(pos + 1); } else { @@ -69,22 +73,37 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { }, [imageComponentImageFileMap, imageEntityId, updateAttributes, getPos] ); + + const uploadImageEditorCommand = useCallback( + async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file), + [editor, imageEntityId] + ); + + const handleProgressStatus = useCallback( + (isUploading: boolean) => { + getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).uploadInProgress = isUploading; + }, + [editor] + ); + + const handleInvalidFile = useCallback((_error: EFileError, _file: File, message: string) => { + alert(message); + }, []); + // hooks const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({ acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, // @ts-expect-error - TODO: fix typings, and don't remove await from here for now - editorCommand: async (file) => await editor?.commands.uploadImage(imageEntityId, file), - handleProgressStatus: (isUploading) => { - editor.storage.imageComponent.uploadInProgress = isUploading; - }, + editorCommand: uploadImageEditorCommand, + handleProgressStatus, loadFileFromFileSystem: loadImageFromFileSystem, maxFileSize, + onInvalidFile: handleInvalidFile, onUpload, }); + const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ - acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, editor, - maxFileSize, pos: getPos(), type: "image", uploader: uploadFile, @@ -119,10 +138,8 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { return; } await uploadFirstFileAndInsertRemaining({ - acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, editor, filesList, - maxFileSize, pos: getPos(), type: "image", uploader: uploadFile, diff --git a/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx b/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx index 8b71713d24a..f88c69c6f37 100644 --- a/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx @@ -1,6 +1,10 @@ import { Editor } from "@tiptap/core"; import { useEditorState } from "@tiptap/react"; import { useEffect, useRef, useState } from "react"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// helpers +import { getExtensionStorage } from "@/helpers/get-extension-storage"; type Props = { editor: Editor; @@ -16,7 +20,7 @@ export const ImageUploadStatus: React.FC = (props) => { // subscribe to image upload status const uploadStatus: number | undefined = useEditorState({ editor, - selector: ({ editor }) => editor.storage.imageComponent?.assetsUploadStatus[nodeId], + selector: ({ editor }) => getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.assetsUploadStatus?.[nodeId], }); useEffect(() => { diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts index 11586bf8614..afd02fd099b 100644 --- a/packages/editor/src/core/extensions/custom-image/custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts @@ -1,17 +1,16 @@ import { Editor, mergeAttributes } from "@tiptap/core"; -import { Image } from "@tiptap/extension-image"; +import { Image as BaseImageExtension } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; import { v4 as uuidv4 } from "uuid"; // constants import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config"; +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { CustomImageNode } from "@/extensions/custom-image"; // helpers import { isFileValid } from "@/helpers/file"; import { getExtensionStorage } from "@/helpers/get-extension-storage"; import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; -// plugins -import { TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image"; // types import { TFileHandler } from "@/types"; @@ -23,23 +22,21 @@ export type InsertImageComponentProps = { declare module "@tiptap/core" { interface Commands { - imageComponent: { + [CORE_EXTENSIONS.CUSTOM_IMAGE]: { insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType; uploadImage: (blockId: string, file: File) => () => Promise | undefined; - updateAssetsUploadStatus?: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void; getImageSource?: (path: string) => () => Promise; restoreImage: (src: string) => () => Promise; }; } } -export const getImageComponentImageFileMap = (editor: Editor) => getExtensionStorage(editor, "imageComponent")?.fileMap; +export const getImageComponentImageFileMap = (editor: Editor) => + getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap; export interface CustomImageExtensionStorage { - assetsUploadStatus: TFileHandler["assetsUploadStatus"]; fileMap: Map; deletedImageSet: Map; - uploadInProgress: boolean; maxFileSize: number; } @@ -47,16 +44,14 @@ export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) export const CustomImageExtension = (props: TFileHandler) => { const { - assetsUploadStatus, getAssetSrc, upload, - delete: deleteImageFn, restore: restoreImageFn, validation: { maxFileSize }, } = props; - return Image.extend, CustomImageExtensionStorage>({ - name: "imageComponent", + return BaseImageExtension.extend, CustomImageExtensionStorage>({ + name: CORE_EXTENSIONS.CUSTOM_IMAGE, selectable: true, group: "block", atom: true, @@ -102,41 +97,15 @@ export const CustomImageExtension = (props: TFileHandler) => { }; }, - addProseMirrorPlugins() { - return [ - TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name), - TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name), - ]; - }, - - onCreate(this) { - const imageSources = new Set(); - this.editor.state.doc.descendants((node) => { - if (node.type.name === this.name) { - if (!node.attrs.src?.startsWith("http")) return; - imageSources.add(node.attrs.src); - } - }); - imageSources.forEach(async (src) => { - try { - await restoreImageFn(src); - } catch (error) { - console.error("Error restoring image: ", error); - } - }); - }, - addStorage() { return { fileMap: new Map(), deletedImageSet: new Map(), - uploadInProgress: false, maxFileSize, // escape markdown for images markdown: { serialize() {}, }, - assetsUploadStatus, }; }, @@ -152,6 +121,7 @@ export const CustomImageExtension = (props: TFileHandler) => { acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, file: props.file, maxFileSize, + onError: (_error, message) => alert(message), }) ) { return false; @@ -196,9 +166,6 @@ export const CustomImageExtension = (props: TFileHandler) => { const fileUrl = await upload(blockId, file); return fileUrl; }, - updateAssetsUploadStatus: (updatedStatus) => () => { - this.storage.assetsUploadStatus = updatedStatus; - }, getImageSource: (path) => async () => await getAssetSrc(path), restoreImage: (src) => async () => { await restoreImageFn(src); diff --git a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts index 51b758898b2..4a85ffd94cb 100644 --- a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts @@ -1,6 +1,8 @@ import { mergeAttributes } from "@tiptap/core"; -import { Image } from "@tiptap/extension-image"; +import { Image as BaseImageExtension } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // components import { CustomImageNode, CustomImageExtensionStorage } from "@/extensions/custom-image"; // types @@ -9,8 +11,8 @@ import { TReadOnlyFileHandler } from "@/types"; export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { const { getAssetSrc, restore: restoreImageFn } = props; - return Image.extend, CustomImageExtensionStorage>({ - name: "imageComponent", + return BaseImageExtension.extend, CustomImageExtensionStorage>({ + name: CORE_EXTENSIONS.CUSTOM_IMAGE, selectable: false, group: "block", atom: true, @@ -53,13 +55,11 @@ export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { return { fileMap: new Map(), deletedImageSet: new Map(), - uploadInProgress: false, maxFileSize: 0, // escape markdown for images markdown: { serialize() {}, }, - assetsUploadStatus: {}, }; }, diff --git a/packages/editor/src/core/extensions/custom-link/extension.tsx b/packages/editor/src/core/extensions/custom-link/extension.tsx index 27c1bb598da..182afc9f8fe 100644 --- a/packages/editor/src/core/extensions/custom-link/extension.tsx +++ b/packages/editor/src/core/extensions/custom-link/extension.tsx @@ -1,6 +1,9 @@ import { Mark, markPasteRule, mergeAttributes, PasteRuleMatch } from "@tiptap/core"; import { Plugin } from "@tiptap/pm/state"; import { find, registerCustomProtocol, reset } from "linkifyjs"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// local imports import { autolink } from "./helpers/autolink"; import { clickHandler } from "./helpers/clickHandler"; import { pasteHandler } from "./helpers/pasteHandler"; @@ -46,7 +49,7 @@ export interface LinkOptions { declare module "@tiptap/core" { interface Commands { - link: { + [CORE_EXTENSIONS.CUSTOM_LINK]: { /** * Set a link mark */ @@ -79,7 +82,7 @@ export type CustomLinkStorage = { }; export const CustomLinkExtension = Mark.create({ - name: "link", + name: CORE_EXTENSIONS.CUSTOM_LINK, priority: 1000, diff --git a/packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts b/packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts index 1b084d1ac52..72906bc9424 100644 --- a/packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts +++ b/packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts @@ -16,7 +16,7 @@ export function clickHandler(options: ClickHandlerOptions): Plugin { } let a = event.target as HTMLElement; - const els = []; + const els: HTMLElement[] = []; while (a?.nodeName !== "DIV") { els.push(a); diff --git a/packages/editor/src/core/extensions/custom-list-keymap/list-helpers.ts b/packages/editor/src/core/extensions/custom-list-keymap/list-helpers.ts index 7d4cad17e00..547f9f17e10 100644 --- a/packages/editor/src/core/extensions/custom-list-keymap/list-helpers.ts +++ b/packages/editor/src/core/extensions/custom-list-keymap/list-helpers.ts @@ -1,12 +1,14 @@ import { Editor, getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core"; import { Node, NodeType } from "@tiptap/pm/model"; import { EditorState } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => { const { $from } = state.selection; const nodeType = getNodeType(typeOrName, state.schema); - let currentNode = null; + let currentNode: Node | null = null; let currentDepth = $from.depth; let currentPos = $from.pos; let targetDepth: number | null = null; @@ -72,7 +74,11 @@ const getPrevListDepth = (typeOrName: string, state: EditorState) => { // Traverse up the document structure from the adjusted position for (let d = resolvedPos.depth; d > 0; d--) { const node = resolvedPos.node(d); - if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") { + if ( + [CORE_EXTENSIONS.BULLET_LIST, CORE_EXTENSIONS.ORDERED_LIST, CORE_EXTENSIONS.TASK_LIST].includes( + node.type.name as CORE_EXTENSIONS + ) + ) { // Increment depth for each list ancestor found depth++; } @@ -309,12 +315,12 @@ const isCurrentParagraphASibling = (state: EditorState): boolean => { // Ensure we're in a paragraph and the parent is a list item. if ( - currentParagraphNode.type.name === "paragraph" && - (listItemNode.type.name === "listItem" || listItemNode.type.name === "taskItem") + currentParagraphNode.type.name === CORE_EXTENSIONS.PARAGRAPH && + [CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes(listItemNode.type.name as CORE_EXTENSIONS) ) { let paragraphNodesCount = 0; listItemNode.forEach((child) => { - if (child.type.name === "paragraph") { + if (child.type.name === CORE_EXTENSIONS.PARAGRAPH) { paragraphNodesCount++; } }); diff --git a/packages/editor/src/core/extensions/custom-list-keymap/list-keymap.ts b/packages/editor/src/core/extensions/custom-list-keymap/list-keymap.ts index 2a17838fd8c..576888f55a5 100644 --- a/packages/editor/src/core/extensions/custom-list-keymap/list-keymap.ts +++ b/packages/editor/src/core/extensions/custom-list-keymap/list-keymap.ts @@ -1,4 +1,6 @@ import { Extension } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { handleBackspace, handleDelete } from "@/extensions/custom-list-keymap/list-helpers"; @@ -31,10 +33,10 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) => addKeyboardShortcuts() { return { Tab: () => { - if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) { - if (this.editor.commands.sinkListItem("listItem")) { + if (this.editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || this.editor.isActive(CORE_EXTENSIONS.TASK_ITEM)) { + if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.LIST_ITEM)) { return true; - } else if (this.editor.commands.sinkListItem("taskItem")) { + } else if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.TASK_ITEM)) { return true; } return true; @@ -46,9 +48,9 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) => return true; }, "Shift-Tab": () => { - if (this.editor.commands.liftListItem("listItem")) { + if (this.editor.commands.liftListItem(CORE_EXTENSIONS.LIST_ITEM)) { return true; - } else if (this.editor.commands.liftListItem("taskItem")) { + } else if (this.editor.commands.liftListItem(CORE_EXTENSIONS.TASK_ITEM)) { return true; } // if tabIndex is set, we don't want to handle Tab key diff --git a/packages/editor/src/core/extensions/drop.ts b/packages/editor/src/core/extensions/drop.ts deleted file mode 100644 index 2a5a994f8ab..00000000000 --- a/packages/editor/src/core/extensions/drop.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Extension, Editor } from "@tiptap/core"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; -// constants -import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config"; -// types -import { TEditorCommands } from "@/types"; - -export const DropHandlerExtension = Extension.create({ - name: "dropHandler", - priority: 1000, - - addProseMirrorPlugins() { - const editor = this.editor; - return [ - new Plugin({ - key: new PluginKey("drop-handler-plugin"), - props: { - handlePaste: (view, event) => { - if ( - editor.isEditable && - event.clipboardData && - event.clipboardData.files && - event.clipboardData.files.length > 0 - ) { - event.preventDefault(); - const files = Array.from(event.clipboardData.files); - const acceptedFiles = files.filter( - (f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type) - ); - - if (acceptedFiles.length) { - const pos = view.state.selection.from; - insertFilesSafely({ - editor, - files: acceptedFiles, - initialPos: pos, - event: "drop", - }); - } - return true; - } - return false; - }, - handleDrop: (view, event, _slice, moved) => { - if ( - editor.isEditable && - !moved && - event.dataTransfer && - event.dataTransfer.files && - event.dataTransfer.files.length > 0 - ) { - event.preventDefault(); - const files = Array.from(event.dataTransfer.files); - const acceptedFiles = files.filter( - (f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type) - ); - - if (acceptedFiles.length) { - const coordinates = view.posAtCoords({ - left: event.clientX, - top: event.clientY, - }); - - if (coordinates) { - const pos = coordinates.pos; - insertFilesSafely({ - editor, - files: acceptedFiles, - initialPos: pos, - event: "drop", - }); - } - return true; - } - } - return false; - }, - }, - }), - ]; - }, -}); - -type InsertFilesSafelyArgs = { - editor: Editor; - event: "insert" | "drop"; - files: File[]; - initialPos: number; - type?: Extract; -}; - -export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => { - const { editor, event, files, initialPos, type } = args; - let pos = initialPos; - - for (const file of files) { - // safe insertion - const docSize = editor.state.doc.content.size; - pos = Math.min(pos, docSize); - - let fileType: "image" | "attachment" | null = null; - - try { - if (type) { - if (["image", "attachment"].includes(type)) fileType = type; - else throw new Error("Wrong file type passed"); - } else { - if (ACCEPTED_IMAGE_MIME_TYPES.includes(file.type)) fileType = "image"; - else if (ACCEPTED_ATTACHMENT_MIME_TYPES.includes(file.type)) fileType = "attachment"; - } - // insert file depending on the type at the current position - if (fileType === "image") { - editor.commands.insertImageComponent({ - file, - pos, - event, - }); - } else if (fileType === "attachment") { - } - } catch (error) { - console.error(`Error while ${event}ing file:`, error); - } - - // Move to the next position - pos += 1; - } -}; diff --git a/packages/editor/src/core/extensions/enter-key-extension.tsx b/packages/editor/src/core/extensions/enter-key.ts similarity index 53% rename from packages/editor/src/core/extensions/enter-key-extension.tsx rename to packages/editor/src/core/extensions/enter-key.ts index d67ceb78b8e..65119425fc1 100644 --- a/packages/editor/src/core/extensions/enter-key-extension.tsx +++ b/packages/editor/src/core/extensions/enter-key.ts @@ -1,16 +1,19 @@ import { Extension } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// helpers +import { getExtensionStorage } from "@/helpers/get-extension-storage"; export const EnterKeyExtension = (onEnterKeyPress?: () => void) => Extension.create({ - name: "enterKey", + name: CORE_EXTENSIONS.ENTER_KEY, addKeyboardShortcuts(this) { return { Enter: () => { - if (!this.editor.storage.mentionsOpen) { - if (onEnterKeyPress) { - onEnterKeyPress(); - } + const isMentionOpen = getExtensionStorage(this.editor, CORE_EXTENSIONS.MENTION)?.mentionsOpen; + if (!isMentionOpen) { + onEnterKeyPress?.(); return true; } return false; @@ -18,8 +21,8 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) => "Shift-Enter": ({ editor }) => editor.commands.first(({ commands }) => [ () => commands.newlineInCode(), - () => commands.splitListItem("listItem"), - () => commands.splitListItem("taskItem"), + () => commands.splitListItem(CORE_EXTENSIONS.LIST_ITEM), + () => commands.splitListItem(CORE_EXTENSIONS.TASK_ITEM), () => commands.createParagraphNear(), () => commands.liftEmptyBlock(), () => commands.splitBlock(), diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.ts similarity index 86% rename from packages/editor/src/core/extensions/extensions.tsx rename to packages/editor/src/core/extensions/extensions.ts index 1ef0a3b157c..51969cd5cfa 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.ts @@ -7,12 +7,13 @@ import TextStyle from "@tiptap/extension-text-style"; import TiptapUnderline from "@tiptap/extension-underline"; import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "tiptap-markdown"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { CustomCalloutExtension, CustomCodeBlockExtension, CustomCodeInlineExtension, - CustomCodeMarkPlugin, CustomColorExtension, CustomHorizontalRule, CustomImageExtension, @@ -22,17 +23,17 @@ import { CustomQuoteExtension, CustomTextAlignExtension, CustomTypographyExtension, - DropHandlerExtension, ImageExtension, ListKeymap, Table, TableCell, TableHeader, TableRow, - MarkdownClipboard, + UtilityExtension, } from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; +import { getExtensionStorage } from "@/helpers/get-extension-storage"; // plane editor extensions import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions"; // types @@ -49,7 +50,7 @@ type TArguments = { }; export const CoreEditorExtensions = (args: TArguments): Extensions => { - const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex } = args; + const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex, editable } = args; const extensions = [ StarterKit.configure({ @@ -89,7 +90,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { ...(enableHistory ? {} : { history: false }), }), CustomQuoteExtension, - DropHandlerExtension, CustomHorizontalRule.configure({ HTMLAttributes: { class: "py-4 border-custom-border-400", @@ -127,7 +127,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { class: "", }, }), - CustomCodeMarkPlugin, CustomCodeInlineExtension, Markdown.configure({ html: true, @@ -135,7 +134,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { transformPastedText: true, breaks: true, }), - MarkdownClipboard, Table, TableHeader, TableCell, @@ -145,15 +143,17 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { placeholder: ({ editor, node }) => { if (!editor.isEditable) return ""; - if (node.type.name === "heading") return `Heading ${node.attrs.level}`; + if (node.type.name === CORE_EXTENSIONS.HEADING) return `Heading ${node.attrs.level}`; - if (editor.storage.imageComponent?.uploadInProgress) return ""; + const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress; + + if (isUploadInProgress) return ""; const shouldHidePlaceholder = - editor.isActive("table") || - editor.isActive("codeBlock") || - editor.isActive("image") || - editor.isActive("imageComponent"); + editor.isActive(CORE_EXTENSIONS.TABLE) || + editor.isActive(CORE_EXTENSIONS.CODE_BLOCK) || + editor.isActive(CORE_EXTENSIONS.IMAGE) || + editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE); if (shouldHidePlaceholder) return ""; @@ -169,6 +169,10 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { CharacterCount, CustomTextAlignExtension, CustomCalloutExtension, + UtilityExtension({ + isEditable: editable, + fileHandler, + }), CustomColorExtension, ...CoreEditorAdditionalExtensions({ disabledExtensions, diff --git a/packages/editor/src/core/extensions/headers.ts b/packages/editor/src/core/extensions/headings-list.ts similarity index 86% rename from packages/editor/src/core/extensions/headers.ts rename to packages/editor/src/core/extensions/headings-list.ts index 958cf6ca32b..51a9aeedc26 100644 --- a/packages/editor/src/core/extensions/headers.ts +++ b/packages/editor/src/core/extensions/headings-list.ts @@ -1,5 +1,7 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface IMarking { type: "heading"; @@ -12,8 +14,8 @@ export type HeadingExtensionStorage = { headings: IMarking[]; }; -export const HeadingListExtension = Extension.create({ - name: "headingList", +export const HeadingListExtension = Extension.create({ + name: CORE_EXTENSIONS.HEADINGS_LIST, addStorage() { return { diff --git a/packages/editor/src/core/extensions/horizontal-rule.ts b/packages/editor/src/core/extensions/horizontal-rule.ts index b9be1a314df..99a5dacc3ef 100644 --- a/packages/editor/src/core/extensions/horizontal-rule.ts +++ b/packages/editor/src/core/extensions/horizontal-rule.ts @@ -1,5 +1,7 @@ import { isNodeSelection, mergeAttributes, Node, nodeInputRule } from "@tiptap/core"; import { NodeSelection, TextSelection } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface HorizontalRuleOptions { HTMLAttributes: Record; @@ -7,7 +9,7 @@ export interface HorizontalRuleOptions { declare module "@tiptap/core" { interface Commands { - horizontalRule: { + [CORE_EXTENSIONS.HORIZONTAL_RULE]: { /** * Add a horizontal rule */ @@ -17,7 +19,7 @@ declare module "@tiptap/core" { } export const CustomHorizontalRule = Node.create({ - name: "horizontalRule", + name: CORE_EXTENSIONS.HORIZONTAL_RULE, addOptions() { return { diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index 6766b4d0c03..12844149cf8 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -1,23 +1,23 @@ -import ImageExt from "@tiptap/extension-image"; +import { Image as BaseImageExtension } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; // extensions import { CustomImageNode } from "@/extensions"; // helpers import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; -// plugins -import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image"; // types import { TFileHandler } from "@/types"; +export type ImageExtensionStorage = { + deletedImageSet: Map; +}; + export const ImageExtension = (fileHandler: TFileHandler) => { const { getAssetSrc, - delete: deleteImageFn, - restore: restoreImageFn, validation: { maxFileSize }, } = fileHandler; - return ImageExt.extend({ + return BaseImageExtension.extend({ addKeyboardShortcuts() { return { ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), @@ -25,36 +25,10 @@ export const ImageExtension = (fileHandler: TFileHandler) => { }; }, - addProseMirrorPlugins() { - return [ - TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name), - TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name), - ]; - }, - - onCreate(this) { - const imageSources = new Set(); - this.editor.state.doc.descendants((node) => { - if (node.type.name === this.name) { - if (!node.attrs.src?.startsWith("http")) return; - - imageSources.add(node.attrs.src); - } - }); - imageSources.forEach(async (src) => { - try { - await restoreImageFn(src); - } catch (error) { - console.error("Error restoring image: ", error); - } - }); - }, - // storage to keep track of image states Map addStorage() { return { deletedImageSet: new Map(), - uploadInProgress: false, maxFileSize, }; }, diff --git a/packages/editor/src/core/extensions/image/image-component-without-props.tsx b/packages/editor/src/core/extensions/image/image-component-without-props.tsx index c17bcc5598e..bd2c3f16b5f 100644 --- a/packages/editor/src/core/extensions/image/image-component-without-props.tsx +++ b/packages/editor/src/core/extensions/image/image-component-without-props.tsx @@ -1,58 +1,56 @@ import { mergeAttributes } from "@tiptap/core"; -import { Image } from "@tiptap/extension-image"; -// extensions -import { ImageExtensionStorage } from "@/plugins/image"; +import { Image as BaseImageExtension } from "@tiptap/extension-image"; +// local imports +import { ImageExtensionStorage } from "./extension"; -export const CustomImageComponentWithoutProps = () => - Image.extend, ImageExtensionStorage>({ - name: "imageComponent", - selectable: true, - group: "block", - atom: true, - draggable: true, +export const CustomImageComponentWithoutProps = BaseImageExtension.extend< + Record, + ImageExtensionStorage +>({ + name: "imageComponent", + selectable: true, + group: "block", + atom: true, + draggable: true, - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - src: { - default: null, - }, - height: { - default: "auto", - }, - ["id"]: { - default: null, - }, - aspectRatio: { - default: null, - }, - }; - }, + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + src: { + default: null, + }, + height: { + default: "auto", + }, + ["id"]: { + default: null, + }, + aspectRatio: { + default: null, + }, + }; + }, - parseHTML() { - return [ - { - tag: "image-component", - }, - ]; - }, + parseHTML() { + return [ + { + tag: "image-component", + }, + ]; + }, - renderHTML({ HTMLAttributes }) { - return ["image-component", mergeAttributes(HTMLAttributes)]; - }, + renderHTML({ HTMLAttributes }) { + return ["image-component", mergeAttributes(HTMLAttributes)]; + }, - addStorage() { - return { - fileMap: new Map(), - deletedImageSet: new Map(), - uploadInProgress: false, - maxFileSize: 0, - assetsUploadStatus: {}, - }; - }, - }); - -export default CustomImageComponentWithoutProps; + addStorage() { + return { + fileMap: new Map(), + deletedImageSet: new Map(), + maxFileSize: 0, + }; + }, +}); diff --git a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx index bb6c5b4ad81..ba064bef485 100644 --- a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx +++ b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx @@ -1,19 +1,18 @@ -import ImageExt from "@tiptap/extension-image"; +import { Image as BaseImageExtension } from "@tiptap/extension-image"; -export const ImageExtensionWithoutProps = () => - ImageExt.extend({ - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - height: { - default: null, - }, - aspectRatio: { - default: null, - }, - }; - }, - }); +export const ImageExtensionWithoutProps = BaseImageExtension.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + height: { + default: null, + }, + aspectRatio: { + default: null, + }, + }; + }, +}); diff --git a/packages/editor/src/core/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx index a656078037c..271c39fd8d5 100644 --- a/packages/editor/src/core/extensions/image/read-only-image.tsx +++ b/packages/editor/src/core/extensions/image/read-only-image.tsx @@ -1,4 +1,4 @@ -import Image from "@tiptap/extension-image"; +import { Image as BaseImageExtension } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; // extensions import { CustomImageNode } from "@/extensions"; @@ -8,7 +8,7 @@ import { TReadOnlyFileHandler } from "@/types"; export const ReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { const { getAssetSrc } = props; - return Image.extend({ + return BaseImageExtension.extend({ addAttributes() { return { ...this.parent?.(), diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index e9860758520..3c3232885fc 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -5,22 +5,20 @@ export * from "./custom-image"; export * from "./custom-link"; export * from "./custom-list-keymap"; export * from "./image"; -export * from "./issue-embed"; export * from "./mentions"; export * from "./slash-commands"; export * from "./table"; export * from "./typography"; +export * from "./work-item-embed"; export * from "./core-without-props"; -export * from "./custom-code-inline"; export * from "./custom-color"; -export * from "./drop"; -export * from "./enter-key-extension"; +export * from "./enter-key"; export * from "./extensions"; -export * from "./headers"; +export * from "./headings-list"; export * from "./horizontal-rule"; export * from "./keymap"; export * from "./quote"; export * from "./read-only-extensions"; export * from "./side-menu"; export * from "./text-align"; -export * from "./clipboard"; +export * from "./utility"; diff --git a/packages/editor/src/core/extensions/issue-embed/index.ts b/packages/editor/src/core/extensions/issue-embed/index.ts deleted file mode 100644 index f47619a0342..00000000000 --- a/packages/editor/src/core/extensions/issue-embed/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./widget-node"; -export * from "./issue-embed-without-props"; diff --git a/packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts b/packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts deleted file mode 100644 index bef366cbab0..00000000000 --- a/packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { mergeAttributes, Node } from "@tiptap/core"; - -export const IssueWidgetWithoutProps = () => - Node.create({ - name: "issue-embed-component", - group: "block", - atom: true, - selectable: true, - draggable: true, - - addAttributes() { - return { - entity_identifier: { - default: undefined, - }, - project_identifier: { - default: undefined, - }, - workspace_identifier: { - default: undefined, - }, - id: { - default: undefined, - }, - entity_name: { - default: undefined, - }, - }; - }, - - parseHTML() { - return [ - { - tag: "issue-embed-component", - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - return ["issue-embed-component", mergeAttributes(HTMLAttributes)]; - }, - }); diff --git a/packages/editor/src/core/extensions/issue-embed/widget-node.tsx b/packages/editor/src/core/extensions/issue-embed/widget-node.tsx deleted file mode 100644 index a216ab6d92f..00000000000 --- a/packages/editor/src/core/extensions/issue-embed/widget-node.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { mergeAttributes, Node } from "@tiptap/core"; -import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; - -type Props = { - widgetCallback: ({ - issueId, - projectId, - workspaceSlug, - }: { - issueId: string; - projectId: string | undefined; - workspaceSlug: string | undefined; - }) => React.ReactNode; -}; - -export const IssueWidget = (props: Props) => - Node.create({ - name: "issue-embed-component", - group: "block", - atom: true, - selectable: true, - draggable: true, - - addAttributes() { - return { - entity_identifier: { - default: undefined, - }, - project_identifier: { - default: undefined, - }, - workspace_identifier: { - default: undefined, - }, - id: { - default: undefined, - }, - entity_name: { - default: undefined, - }, - }; - }, - - addNodeView() { - return ReactNodeViewRenderer((issueProps: any) => ( - - {props.widgetCallback({ - issueId: issueProps.node.attrs.entity_identifier, - projectId: issueProps.node.attrs.project_identifier, - workspaceSlug: issueProps.node.attrs.workspace_identifier, - })} - - )); - }, - - parseHTML() { - return [ - { - tag: "issue-embed-component", - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - return ["issue-embed-component", mergeAttributes(HTMLAttributes)]; - }, - }); diff --git a/packages/editor/src/core/extensions/keymap.tsx b/packages/editor/src/core/extensions/keymap.ts similarity index 92% rename from packages/editor/src/core/extensions/keymap.tsx rename to packages/editor/src/core/extensions/keymap.ts index 81d60e34f67..a4961bb9617 100644 --- a/packages/editor/src/core/extensions/keymap.tsx +++ b/packages/editor/src/core/extensions/keymap.ts @@ -2,11 +2,13 @@ import { Extension } from "@tiptap/core"; import { NodeType } from "@tiptap/pm/model"; import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; import { canJoin } from "@tiptap/pm/transform"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; declare module "@tiptap/core" { // eslint-disable-next-line no-unused-vars interface Commands { - customkeymap: { + customKeymap: { /** * Select text between node boundaries */ @@ -59,7 +61,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeTypes: NodeType[]) { } export const CustomKeymap = Extension.create({ - name: "CustomKeymap", + name: "customKeymap", addCommands() { return { @@ -87,9 +89,9 @@ export const CustomKeymap = Extension.create({ const newTr = newState.tr; const joinableNodes = [ - newState.schema.nodes["orderedList"], - newState.schema.nodes["taskList"], - newState.schema.nodes["bulletList"], + newState.schema.nodes[CORE_EXTENSIONS.ORDERED_LIST], + newState.schema.nodes[CORE_EXTENSIONS.TASK_LIST], + newState.schema.nodes[CORE_EXTENSIONS.BULLET_LIST], ]; let joined = false; diff --git a/packages/editor/src/core/extensions/mentions/mention-node-view.tsx b/packages/editor/src/core/extensions/mentions/mention-node-view.tsx index 006336fbb67..aac00de884a 100644 --- a/packages/editor/src/core/extensions/mentions/mention-node-view.tsx +++ b/packages/editor/src/core/extensions/mentions/mention-node-view.tsx @@ -18,7 +18,7 @@ export const MentionNodeView = (props: Props) => { return ( {(extension.options as TMentionExtensionOptions).renderComponent({ - entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER], + entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER] ?? "", entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention", })} diff --git a/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx b/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx index 4f09ed2ae71..da11d0f9953 100644 --- a/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx +++ b/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx @@ -1,7 +1,7 @@ "use client"; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; import { Editor } from "@tiptap/react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; // plane utils import { cn } from "@plane/utils"; @@ -61,7 +61,9 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps sections, selectedIndex, }); - setSelectedIndex(newIndex); + if (newIndex) { + setSelectedIndex(newIndex); + } }, })); @@ -79,7 +81,9 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps setIsLoading(true); try { const sectionsResponse = await searchCallback?.(query); - setSections(sectionsResponse); + if (sectionsResponse) { + setSections(sectionsResponse); + } } catch (error) { console.error("Failed to fetch suggestions:", error); } finally { diff --git a/packages/editor/src/core/extensions/mentions/utils.ts b/packages/editor/src/core/extensions/mentions/utils.ts index e8e7ed4b7a9..5a7550c834d 100644 --- a/packages/editor/src/core/extensions/mentions/utils.ts +++ b/packages/editor/src/core/extensions/mentions/utils.ts @@ -1,7 +1,7 @@ import { Editor } from "@tiptap/core"; -import { SuggestionOptions } from "@tiptap/suggestion"; import { ReactRenderer } from "@tiptap/react"; -import tippy from "tippy.js"; +import { SuggestionOptions } from "@tiptap/suggestion"; +import tippy, { Instance } from "tippy.js"; // helpers import { CommandListInstance } from "@/helpers/tippy"; // types @@ -15,7 +15,7 @@ export const renderMentionsDropdown = () => { const { searchCallback } = props; let component: ReactRenderer | null = null; - let popup: any | null = null; + let popup: Instance | null = null; return { onStart: (props: { editor: Editor; clientRect: DOMRect }) => { diff --git a/packages/editor/src/core/extensions/quote.tsx b/packages/editor/src/core/extensions/quote.ts similarity index 85% rename from packages/editor/src/core/extensions/quote.tsx rename to packages/editor/src/core/extensions/quote.ts index 4ae81ffe4f9..99a6c10f05b 100644 --- a/packages/editor/src/core/extensions/quote.tsx +++ b/packages/editor/src/core/extensions/quote.ts @@ -1,4 +1,6 @@ import Blockquote from "@tiptap/extension-blockquote"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export const CustomQuoteExtension = Blockquote.extend({ addKeyboardShortcuts() { @@ -10,7 +12,7 @@ export const CustomQuoteExtension = Blockquote.extend({ if (!parent) return false; - if (parent.type.name !== "blockquote") { + if (parent.type.name !== CORE_EXTENSIONS.BLOCKQUOTE) { return false; } if ($from.pos !== $to.pos) return false; diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.ts similarity index 97% rename from packages/editor/src/core/extensions/read-only-extensions.tsx rename to packages/editor/src/core/extensions/read-only-extensions.ts index 3881c548b3f..bcfc7641159 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.ts @@ -24,7 +24,7 @@ import { CustomTextAlignExtension, CustomCalloutReadOnlyExtension, CustomColorExtension, - MarkdownClipboard, + UtilityExtension, } from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; @@ -117,7 +117,6 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { html: true, transformCopiedText: false, }), - MarkdownClipboard, Table, TableHeader, TableCell, @@ -127,6 +126,10 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { CustomColorExtension, CustomTextAlignExtension, CustomCalloutReadOnlyExtension, + UtilityExtension({ + isEditable: false, + fileHandler, + }), ...CoreReadOnlyEditorAdditionalExtensions({ disabledExtensions, }), diff --git a/packages/editor/src/core/extensions/side-menu.tsx b/packages/editor/src/core/extensions/side-menu.ts similarity index 97% rename from packages/editor/src/core/extensions/side-menu.tsx rename to packages/editor/src/core/extensions/side-menu.ts index 5f11286b5c4..34e3c45e5f2 100644 --- a/packages/editor/src/core/extensions/side-menu.tsx +++ b/packages/editor/src/core/extensions/side-menu.ts @@ -1,6 +1,8 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { EditorView } from "@tiptap/pm/view"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // plugins import { AIHandlePlugin } from "@/plugins/ai-handle"; import { DragHandlePlugin, nodeDOMAtCoords } from "@/plugins/drag-handle"; @@ -33,7 +35,7 @@ export const SideMenuExtension = (props: Props) => { const { aiEnabled, dragDropEnabled } = props; return Extension.create({ - name: "editorSideMenu", + name: CORE_EXTENSIONS.SIDE_MENU, addProseMirrorPlugins() { return [ SideMenu({ diff --git a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx index 9fcc733aef6..fe9ec06a6d9 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx @@ -26,22 +26,17 @@ import { toggleBulletList, toggleOrderedList, toggleTaskList, - toggleHeadingOne, - toggleHeadingTwo, - toggleHeadingThree, - toggleHeadingFour, - toggleHeadingFive, - toggleHeadingSix, + toggleHeading, toggleTextColor, toggleBackgroundColor, insertImage, insertCallout, setText, } from "@/helpers/editor-commands"; -// types -import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types"; // plane editor extensions import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions"; +// types +import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types"; // local types import { TExtensionProps, TSlashCommandAdditionalOption } from "./root"; @@ -75,7 +70,7 @@ export const getSlashCommandFilteredSections = description: "Big section heading.", searchTerms: ["title", "big", "large"], icon: , - command: ({ editor, range }) => toggleHeadingOne(editor, range), + command: ({ editor, range }) => toggleHeading(editor, 1, range), }, { commandKey: "h2", @@ -84,7 +79,7 @@ export const getSlashCommandFilteredSections = description: "Medium section heading.", searchTerms: ["subtitle", "medium"], icon: , - command: ({ editor, range }) => toggleHeadingTwo(editor, range), + command: ({ editor, range }) => toggleHeading(editor, 2, range), }, { commandKey: "h3", @@ -93,7 +88,7 @@ export const getSlashCommandFilteredSections = description: "Small section heading.", searchTerms: ["subtitle", "small"], icon: , - command: ({ editor, range }) => toggleHeadingThree(editor, range), + command: ({ editor, range }) => toggleHeading(editor, 3, range), }, { commandKey: "h4", @@ -102,7 +97,7 @@ export const getSlashCommandFilteredSections = description: "Small section heading.", searchTerms: ["subtitle", "small"], icon: , - command: ({ editor, range }) => toggleHeadingFour(editor, range), + command: ({ editor, range }) => toggleHeading(editor, 4, range), }, { commandKey: "h5", @@ -111,7 +106,7 @@ export const getSlashCommandFilteredSections = description: "Small section heading.", searchTerms: ["subtitle", "small"], icon: , - command: ({ editor, range }) => toggleHeadingFive(editor, range), + command: ({ editor, range }) => toggleHeading(editor, 5, range), }, { commandKey: "h6", @@ -120,7 +115,7 @@ export const getSlashCommandFilteredSections = description: "Small section heading.", searchTerms: ["subtitle", "small"], icon: , - command: ({ editor, range }) => toggleHeadingSix(editor, range), + command: ({ editor, range }) => toggleHeading(editor, 6, range), }, { commandKey: "to-do-list", diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx index 4ecd3f8fa2c..9d85266f2b2 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx @@ -1,15 +1,16 @@ -import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; import { Editor } from "@tiptap/core"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; // helpers import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy"; // components +import { ISlashCommandItem } from "@/types"; import { TSlashCommandSection } from "./command-items-list"; import { CommandMenuItem } from "./command-menu-item"; export type SlashCommandsMenuProps = { editor: Editor; items: TSlashCommandSection[]; - command: any; + command: (item: ISlashCommandItem) => void; }; export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => { @@ -103,7 +104,9 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) sections, selectedIndex, }); - setSelectedIndex(newIndex); + if (newIndex) { + setSelectedIndex(newIndex); + } }, })); diff --git a/packages/editor/src/core/extensions/slash-commands/root.tsx b/packages/editor/src/core/extensions/slash-commands/root.tsx index c0c078a2dda..828149d5027 100644 --- a/packages/editor/src/core/extensions/slash-commands/root.tsx +++ b/packages/editor/src/core/extensions/slash-commands/root.tsx @@ -1,7 +1,9 @@ import { Editor, Range, Extension } from "@tiptap/core"; import { ReactRenderer } from "@tiptap/react"; import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; -import tippy from "tippy.js"; +import tippy, { Instance } from "tippy.js"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // helpers import { CommandListInstance } from "@/helpers/tippy"; // types @@ -20,7 +22,7 @@ export type TSlashCommandAdditionalOption = ISlashCommandItem & { }; const Command = Extension.create({ - name: "slash-command", + name: CORE_EXTENSIONS.SLASH_COMMANDS, addOptions() { return { suggestion: { @@ -34,11 +36,11 @@ const Command = Extension.create({ const parentNode = selection.$from.node(selection.$from.depth); const blockType = parentNode.type.name; - if (blockType === "codeBlock") { + if (blockType === CORE_EXTENSIONS.CODE_BLOCK) { return false; } - if (editor.isActive("table")) { + if (editor.isActive(CORE_EXTENSIONS.TABLE)) { return false; } @@ -59,7 +61,7 @@ const Command = Extension.create({ const renderItems = () => { let component: ReactRenderer | null = null; - let popup: any | null = null; + let popup: Instance | null = null; return { onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { component = new ReactRenderer(SlashCommandsMenu, { diff --git a/packages/editor/src/core/extensions/table/table-cell/table-cell.ts b/packages/editor/src/core/extensions/table/table-cell.ts similarity index 91% rename from packages/editor/src/core/extensions/table/table-cell/table-cell.ts rename to packages/editor/src/core/extensions/table/table-cell.ts index 403bd3f02c7..2ba06845a6c 100644 --- a/packages/editor/src/core/extensions/table/table-cell/table-cell.ts +++ b/packages/editor/src/core/extensions/table/table-cell.ts @@ -1,11 +1,12 @@ import { mergeAttributes, Node } from "@tiptap/core"; - +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface TableCellOptions { HTMLAttributes: Record; } export const TableCell = Node.create({ - name: "tableCell", + name: CORE_EXTENSIONS.TABLE_CELL, addOptions() { return { diff --git a/packages/editor/src/core/extensions/table/table-cell/index.ts b/packages/editor/src/core/extensions/table/table-cell/index.ts deleted file mode 100644 index 68a25a9c3de..00000000000 --- a/packages/editor/src/core/extensions/table/table-cell/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TableCell } from "./table-cell"; diff --git a/packages/editor/src/core/extensions/table/table-header/table-header.ts b/packages/editor/src/core/extensions/table/table-header.ts similarity index 90% rename from packages/editor/src/core/extensions/table/table-header/table-header.ts rename to packages/editor/src/core/extensions/table/table-header.ts index bd994f467d5..491889eefae 100644 --- a/packages/editor/src/core/extensions/table/table-header/table-header.ts +++ b/packages/editor/src/core/extensions/table/table-header.ts @@ -1,11 +1,12 @@ import { mergeAttributes, Node } from "@tiptap/core"; - +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface TableHeaderOptions { HTMLAttributes: Record; } export const TableHeader = Node.create({ - name: "tableHeader", + name: CORE_EXTENSIONS.TABLE_HEADER, addOptions() { return { diff --git a/packages/editor/src/core/extensions/table/table-header/index.ts b/packages/editor/src/core/extensions/table/table-header/index.ts deleted file mode 100644 index 290f37d0b78..00000000000 --- a/packages/editor/src/core/extensions/table/table-header/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TableHeader } from "./table-header"; diff --git a/packages/editor/src/core/extensions/table/table-row/table-row.ts b/packages/editor/src/core/extensions/table/table-row.ts similarity index 88% rename from packages/editor/src/core/extensions/table/table-row/table-row.ts rename to packages/editor/src/core/extensions/table/table-row.ts index f961c058246..48f95a41c93 100644 --- a/packages/editor/src/core/extensions/table/table-row/table-row.ts +++ b/packages/editor/src/core/extensions/table/table-row.ts @@ -1,11 +1,13 @@ import { mergeAttributes, Node } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface TableRowOptions { HTMLAttributes: Record; } export const TableRow = Node.create({ - name: "tableRow", + name: CORE_EXTENSIONS.TABLE_ROW, addOptions() { return { diff --git a/packages/editor/src/core/extensions/table/table-row/index.ts b/packages/editor/src/core/extensions/table/table-row/index.ts deleted file mode 100644 index 24dafb7e012..00000000000 --- a/packages/editor/src/core/extensions/table/table-row/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TableRow } from "./table-row"; diff --git a/packages/editor/src/core/extensions/table/table/table-controls.ts b/packages/editor/src/core/extensions/table/table/table-controls.ts index 05292257960..d499b1b6a71 100644 --- a/packages/editor/src/core/extensions/table/table/table-controls.ts +++ b/packages/editor/src/core/extensions/table/table/table-controls.ts @@ -1,6 +1,8 @@ import { findParentNode } from "@tiptap/core"; -import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import { Plugin, PluginKey, TextSelection, Transaction } from "@tiptap/pm/state"; import { DecorationSet, Decoration } from "@tiptap/pm/view"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; const key = new PluginKey("tableControls"); @@ -17,16 +19,14 @@ export function tableControls() { }, props: { handleTripleClickOn(view, pos, node, nodePos, event, direct) { - if (node.type.name === 'tableCell') { + if (node.type.name === CORE_EXTENSIONS.TABLE_CELL) { event.preventDefault(); const $pos = view.state.doc.resolve(pos); const line = $pos.parent; const linePos = $pos.start(); const start = linePos; const end = linePos + line.nodeSize - 1; - const tr = view.state.tr.setSelection( - TextSelection.create(view.state.doc, start, end) - ); + const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, start, end)); view.dispatch(tr); return true; } @@ -52,12 +52,12 @@ export function tableControls() { if (!pos || pos.pos < 0 || pos.pos > view.state.doc.content.size) return; - const table = findParentNode((node) => node.type.name === "table")( - TextSelection.create(view.state.doc, pos.pos) - ); - const cell = findParentNode((node) => node.type.name === "tableCell" || node.type.name === "tableHeader")( + const table = findParentNode((node) => node.type.name === CORE_EXTENSIONS.TABLE)( TextSelection.create(view.state.doc, pos.pos) ); + const cell = findParentNode((node) => + [CORE_EXTENSIONS.TABLE_CELL, CORE_EXTENSIONS.TABLE_HEADER].includes(node.type.name as CORE_EXTENSIONS) + )(TextSelection.create(view.state.doc, pos.pos)); if (!table || !cell) return; @@ -112,7 +112,7 @@ class TableControlsState { }; } - apply(tr: any) { + apply(tr: Transaction) { const actions = tr.getMeta(key); if (actions?.setHoveredTable !== undefined) { diff --git a/packages/editor/src/core/extensions/table/table/table-view.tsx b/packages/editor/src/core/extensions/table/table/table-view.tsx index 2a480212673..f78d964ed49 100644 --- a/packages/editor/src/core/extensions/table/table/table-view.tsx +++ b/packages/editor/src/core/extensions/table/table/table-view.tsx @@ -1,12 +1,12 @@ -import { h } from "jsx-dom-cjs"; -import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model"; -import { Decoration, NodeView } from "@tiptap/pm/view"; -import tippy, { Instance, Props } from "tippy.js"; - import { Editor } from "@tiptap/core"; +import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model"; import { CellSelection, TableMap, updateColumnsOnResize } from "@tiptap/pm/tables"; - +import { Decoration, NodeView } from "@tiptap/pm/view"; +import { h } from "jsx-dom-cjs"; import { icons } from "src/core/extensions/table/table/icons"; +import tippy, { Instance, Props } from "tippy.js"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; type ToolboxItem = { label: string; @@ -30,10 +30,10 @@ export function updateColumns( if (!row) return; for (let i = 0, col = 0; i < row.childCount; i += 1) { - const { colspan, colwidth } = row.child(i).attrs; + const { colspan, colWidth } = row.child(i).attrs; for (let j = 0; j < colspan; j += 1, col += 1) { - const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j]; + const hasWidth = overrideCol === col ? overrideValue : colWidth && colWidth[j]; const cssWidth = hasWidth ? `${hasWidth}px` : ""; totalWidth += hasWidth || cellMinWidth; @@ -85,7 +85,7 @@ function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: strin return editor .chain() .focus() - .updateAttributes("tableCell", { + .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, { background: color.backgroundColor, textColor: color.textColor, }) @@ -104,12 +104,12 @@ function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: st // Find the depth of the table row node let rowDepth = hoveredCell.depth; - while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") { + while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== CORE_EXTENSIONS.TABLE_ROW) { rowDepth--; } // If we couldn't find a tableRow node, we can't set the background color - if (hoveredCell.node(rowDepth).type.name !== "tableRow") { + if (hoveredCell.node(rowDepth).type.name !== CORE_EXTENSIONS.TABLE_ROW) { return false; } diff --git a/packages/editor/src/core/extensions/table/table/table.ts b/packages/editor/src/core/extensions/table/table/table.ts index fd775d211ee..4810706b395 100644 --- a/packages/editor/src/core/extensions/table/table/table.ts +++ b/packages/editor/src/core/extensions/table/table/table.ts @@ -19,11 +19,14 @@ import { toggleHeader, toggleHeaderCell, } from "@tiptap/pm/tables"; - -import { tableControls } from "@/extensions/table/table/table-controls"; -import { TableView } from "@/extensions/table/table/table-view"; -import { createTable } from "@/extensions/table/table/utilities/create-table"; -import { deleteTableWhenAllCellsSelected } from "@/extensions/table/table/utilities/delete-table-when-all-cells-selected"; +import { Decoration } from "@tiptap/pm/view"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// local imports +import { tableControls } from "./table-controls"; +import { TableView } from "./table-view"; +import { createTable } from "./utilities/create-table"; +import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected"; import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action"; import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action"; @@ -38,7 +41,7 @@ export interface TableOptions { declare module "@tiptap/core" { interface Commands { - table: { + [CORE_EXTENSIONS.TABLE]: { insertTable: (options?: { rows?: number; cols?: number; @@ -79,7 +82,7 @@ declare module "@tiptap/core" { } export const Table = Node.create({ - name: "table", + name: CORE_EXTENSIONS.TABLE, addOptions() { return { @@ -219,8 +222,8 @@ export const Table = Node.create({ addKeyboardShortcuts() { return { Tab: () => { - if (this.editor.isActive("table")) { - if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) { + if (this.editor.isActive(CORE_EXTENSIONS.TABLE)) { + if (this.editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || this.editor.isActive(CORE_EXTENSIONS.TASK_ITEM)) { return false; } if (this.editor.commands.goToNextCell()) { @@ -249,7 +252,7 @@ export const Table = Node.create({ return ({ editor, getPos, node, decorations }) => { const { cellMinWidth } = this.options; - return new TableView(node, cellMinWidth, decorations as any, editor, getPos as () => number); + return new TableView(node, cellMinWidth, decorations as Decoration[], editor, getPos as () => number); }; }, diff --git a/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts b/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts index 53388fbf238..5c84b8617da 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts @@ -1,4 +1,6 @@ import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; @@ -10,14 +12,17 @@ export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ edito } let cellCount = 0; - const table = findParentNodeClosestToPos(selection.ranges[0].$from, (node) => node.type.name === "table"); + const table = findParentNodeClosestToPos( + selection.ranges[0].$from, + (node) => node.type.name === CORE_EXTENSIONS.TABLE + ); table?.node.descendants((node) => { - if (node.type.name === "table") { + if (node.type.name === CORE_EXTENSIONS.TABLE) { return false; } - if (["tableCell", "tableHeader"].includes(node.type.name)) { + if ([CORE_EXTENSIONS.TABLE_CELL, CORE_EXTENSIONS.TABLE_HEADER].includes(node.type.name as CORE_EXTENSIONS)) { cellCount += 1; } }); diff --git a/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts b/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts index ca5ed3d7e87..35c2ee3c713 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts @@ -1,17 +1,19 @@ import { KeyboardShortcutCommand } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // helpers import { findParentNodeOfType } from "@/helpers/common"; export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => { // Check if the current selection or the closest node is a table - if (!editor.isActive("table")) return false; + if (!editor.isActive(CORE_EXTENSIONS.TABLE)) return false; try { // Get the current selection const { selection } = editor.state; // Find the table node and its position - const tableNode = findParentNodeOfType(selection, "table"); + const tableNode = findParentNodeOfType(selection, CORE_EXTENSIONS.TABLE); if (!tableNode) return false; const tablePos = tableNode.pos; @@ -39,7 +41,7 @@ export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) const prevNode = editor.state.doc.nodeAt(prevNodePos - 1); - if (prevNode && prevNode.type.name === "paragraph") { + if (prevNode && prevNode.type.name === CORE_EXTENSIONS.PARAGRAPH) { // If there's a paragraph before the table, move the cursor to the end of that paragraph const endOfParagraphPos = tablePos - prevNode.nodeSize; editor.chain().setTextSelection(endOfParagraphPos).run(); diff --git a/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts b/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts index 7edca9f30df..6c26e22a2f6 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts @@ -1,17 +1,19 @@ import { KeyboardShortcutCommand } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // helpers import { findParentNodeOfType } from "@/helpers/common"; export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => { // Check if the current selection or the closest node is a table - if (!editor.isActive("table")) return false; + if (!editor.isActive(CORE_EXTENSIONS.TABLE)) return false; try { // Get the current selection const { selection } = editor.state; // Find the table node and its position - const tableNode = findParentNodeOfType(selection, "table"); + const tableNode = findParentNodeOfType(selection, CORE_EXTENSIONS.TABLE); if (!tableNode) return false; const tablePos = tableNode.pos; @@ -31,13 +33,13 @@ export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) // Check for an existing node immediately after the table const nextNode = editor.state.doc.nodeAt(nextNodePos); - if (nextNode && nextNode.type.name === "paragraph") { + if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) { // If the next node is an paragraph, move the cursor there const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1; editor.chain().setTextSelection(endOfParagraphPos).run(); } else if (!nextNode) { // If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there - editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run(); + editor.chain().insertContentAt(nextNodePos, { type: CORE_EXTENSIONS.PARAGRAPH }).run(); editor .chain() .setTextSelection(nextNodePos + 1) diff --git a/packages/editor/src/core/extensions/typography/index.ts b/packages/editor/src/core/extensions/typography/index.ts index 6b736953b53..32ffea6a24c 100644 --- a/packages/editor/src/core/extensions/typography/index.ts +++ b/packages/editor/src/core/extensions/typography/index.ts @@ -1,4 +1,6 @@ import { Extension, InputRule } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; import { TypographyOptions, emDash, @@ -23,7 +25,7 @@ import { } from "./inputRules"; export const CustomTypographyExtension = Extension.create({ - name: "typography", + name: CORE_EXTENSIONS.TYPOGRAPHY, addInputRules() { const rules: InputRule[] = []; diff --git a/packages/editor/src/core/extensions/utility.ts b/packages/editor/src/core/extensions/utility.ts new file mode 100644 index 00000000000..1d656de5a8e --- /dev/null +++ b/packages/editor/src/core/extensions/utility.ts @@ -0,0 +1,71 @@ +import { Extension } from "@tiptap/core"; +// prosemirror plugins +import codemark from "prosemirror-codemark"; +// helpers +import { restorePublicImages } from "@/helpers/image-helpers"; +// plugins +import { DropHandlerPlugin } from "@/plugins/drop"; +import { FilePlugins } from "@/plugins/file/root"; +import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard"; +// types +import { TFileHandler, TReadOnlyFileHandler } from "@/types"; + +declare module "@tiptap/core" { + interface Commands { + utility: { + updateAssetsUploadStatus: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void; + }; + } +} + +export interface UtilityExtensionStorage { + assetsUploadStatus: TFileHandler["assetsUploadStatus"]; + uploadInProgress: boolean; +} + +type Props = { + fileHandler: TFileHandler | TReadOnlyFileHandler; + isEditable: boolean; +}; + +export const UtilityExtension = (props: Props) => { + const { fileHandler, isEditable } = props; + const { restore: restoreImageFn } = fileHandler; + + return Extension.create, UtilityExtensionStorage>({ + name: "utility", + priority: 1000, + + addProseMirrorPlugins() { + return [ + ...FilePlugins({ + editor: this.editor, + isEditable, + fileHandler, + }), + ...codemark({ markType: this.editor.schema.marks.code }), + MarkdownClipboardPlugin(this.editor), + DropHandlerPlugin(this.editor), + ]; + }, + + onCreate() { + restorePublicImages(this.editor, restoreImageFn); + }, + + addStorage() { + return { + assetsUploadStatus: isEditable && "assetsUploadStatus" in fileHandler ? fileHandler.assetsUploadStatus : {}, + uploadInProgress: false, + }; + }, + + addCommands() { + return { + updateAssetsUploadStatus: (updatedStatus) => () => { + this.storage.assetsUploadStatus = updatedStatus; + }, + }; + }, + }); +}; diff --git a/packages/editor/src/core/extensions/work-item-embed/extension-config.ts b/packages/editor/src/core/extensions/work-item-embed/extension-config.ts new file mode 100644 index 00000000000..0ea25c770d5 --- /dev/null +++ b/packages/editor/src/core/extensions/work-item-embed/extension-config.ts @@ -0,0 +1,43 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; + +export const WorkItemEmbedExtensionConfig = Node.create({ + name: CORE_EXTENSIONS.WORK_ITEM_EMBED, + group: "block", + atom: true, + selectable: true, + draggable: true, + + addAttributes() { + return { + entity_identifier: { + default: undefined, + }, + project_identifier: { + default: undefined, + }, + workspace_identifier: { + default: undefined, + }, + id: { + default: undefined, + }, + entity_name: { + default: undefined, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "issue-embed-component", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["issue-embed-component", mergeAttributes(HTMLAttributes)]; + }, +}); diff --git a/packages/editor/src/core/extensions/work-item-embed/extension.tsx b/packages/editor/src/core/extensions/work-item-embed/extension.tsx new file mode 100644 index 00000000000..64e655a4088 --- /dev/null +++ b/packages/editor/src/core/extensions/work-item-embed/extension.tsx @@ -0,0 +1,30 @@ +import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; +// local imports +import { WorkItemEmbedExtensionConfig } from "./extension-config"; + +type Props = { + widgetCallback: ({ + issueId, + projectId, + workspaceSlug, + }: { + issueId: string; + projectId: string | undefined; + workspaceSlug: string | undefined; + }) => React.ReactNode; +}; + +export const WorkItemEmbedExtension = (props: Props) => + WorkItemEmbedExtensionConfig.extend({ + addNodeView() { + return ReactNodeViewRenderer((issueProps: any) => ( + + {props.widgetCallback({ + issueId: issueProps.node.attrs.entity_identifier, + projectId: issueProps.node.attrs.project_identifier, + workspaceSlug: issueProps.node.attrs.workspace_identifier, + })} + + )); + }, + }); diff --git a/packages/editor/src/core/extensions/work-item-embed/index.ts b/packages/editor/src/core/extensions/work-item-embed/index.ts new file mode 100644 index 00000000000..2ce32da8ba5 --- /dev/null +++ b/packages/editor/src/core/extensions/work-item-embed/index.ts @@ -0,0 +1 @@ +export * from "./extension"; diff --git a/packages/editor/src/core/helpers/common.ts b/packages/editor/src/core/helpers/common.ts index 974b111d09f..e694e1e8539 100644 --- a/packages/editor/src/core/helpers/common.ts +++ b/packages/editor/src/core/helpers/common.ts @@ -1,6 +1,8 @@ import { EditorState, Selection } from "@tiptap/pm/state"; -// plane utils +// plane imports import { cn } from "@plane/utils"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; interface EditorClassNames { noBorder?: boolean; @@ -67,7 +69,7 @@ export const isValidHttpUrl = (string: string): { isValid: boolean; url: string url: string, }; } - } catch (_) { + } catch { // Original string wasn't a valid URL - that's okay, we'll try with https } @@ -79,7 +81,7 @@ export const isValidHttpUrl = (string: string): { isValid: boolean; url: string isValid: true, url: urlWithHttps, }; - } catch (_) { + } catch { return { isValid: false, url: string, @@ -91,7 +93,7 @@ export const getParagraphCount = (editorState: EditorState | undefined) => { if (!editorState) return 0; let paragraphCount = 0; editorState.doc.descendants((node) => { - if (node.type.name === "paragraph" && node.content.size > 0) paragraphCount++; + if (node.type.name === CORE_EXTENSIONS.PARAGRAPH && node.content.size > 0) paragraphCount++; }); return paragraphCount; }; diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index e8c98ada573..5fa15cb08dd 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -1,4 +1,6 @@ import { Editor, Range } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { InsertImageComponentProps } from "@/extensions"; import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text"; @@ -6,44 +8,14 @@ import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block- import { findTableAncestor } from "@/helpers/common"; export const setText = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).setNode("paragraph").run(); - else editor.chain().focus().setNode("paragraph").run(); + if (range) editor.chain().focus().deleteRange(range).setNode(CORE_EXTENSIONS.PARAGRAPH).run(); + else editor.chain().focus().setNode(CORE_EXTENSIONS.PARAGRAPH).run(); }; -export const toggleHeadingOne = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); +export const toggleHeading = (editor: Editor, level: 1 | 2 | 3 | 4 | 5 | 6, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).setNode(CORE_EXTENSIONS.HEADING, { level }).run(); // @ts-expect-error tiptap types are incorrect - else editor.chain().focus().toggleHeading({ level: 1 }).run(); -}; - -export const toggleHeadingTwo = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); - // @ts-expect-error tiptap types are incorrect - else editor.chain().focus().toggleHeading({ level: 2 }).run(); -}; - -export const toggleHeadingThree = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); - // @ts-expect-error tiptap types are incorrect - else editor.chain().focus().toggleHeading({ level: 3 }).run(); -}; - -export const toggleHeadingFour = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run(); - // @ts-expect-error tiptap types are incorrect - else editor.chain().focus().toggleHeading({ level: 4 }).run(); -}; - -export const toggleHeadingFive = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run(); - // @ts-expect-error tiptap types are incorrect - else editor.chain().focus().toggleHeading({ level: 5 }).run(); -}; - -export const toggleHeadingSix = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run(); - // @ts-expect-error tiptap types are incorrect - else editor.chain().focus().toggleHeading({ level: 6 }).run(); + else editor.chain().focus().toggleHeading({ level }).run(); }; export const toggleBold = (editor: Editor, range?: Range) => { @@ -68,7 +40,7 @@ export const toggleUnderline = (editor: Editor, range?: Range) => { export const toggleCodeBlock = (editor: Editor, range?: Range) => { try { // if it's a code block, replace it with the code with paragraphs - if (editor.isActive("codeBlock")) { + if (editor.isActive(CORE_EXTENSIONS.CODE_BLOCK)) { replaceCodeWithText(editor); return; } @@ -77,12 +49,12 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => { const text = editor.state.doc.textBetween(from, to, "\n"); const isMultiline = text.includes("\n"); - // if the selection is not a range i.e. empty, then simply convert it into a code block + // if the selection is not a range i.e. empty, then simply convert it into a codeBlock if (editor.state.selection.empty) { editor.chain().focus().toggleCodeBlock().run(); } else if (isMultiline) { // if the selection is multiline, then also replace the text content with - // a code block + // a codeBlock editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, `\`\`\`\n${text}\n\`\`\``).run(); } else { // if the selection is single line, then simply convert it into inline diff --git a/packages/editor/src/core/helpers/file.ts b/packages/editor/src/core/helpers/file.ts index f2c9968f003..33d3c7d781a 100644 --- a/packages/editor/src/core/helpers/file.ts +++ b/packages/editor/src/core/helpers/file.ts @@ -1,24 +1,34 @@ +export enum EFileError { + INVALID_FILE_TYPE = "INVALID_FILE_TYPE", + FILE_SIZE_TOO_LARGE = "FILE_SIZE_TOO_LARGE", + NO_FILE_SELECTED = "NO_FILE_SELECTED", +} + type TArgs = { acceptedMimeTypes: string[]; file: File; maxFileSize: number; + onError: (error: EFileError, message: string) => void; }; export const isFileValid = (args: TArgs): boolean => { - const { acceptedMimeTypes, file, maxFileSize } = args; + const { acceptedMimeTypes, file, maxFileSize, onError } = args; if (!file) { - alert("No file selected. Please select a file to upload."); + onError(EFileError.NO_FILE_SELECTED, "No file selected. Please select a file to upload."); return false; } if (!acceptedMimeTypes.includes(file.type)) { - alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP or GIF file."); + onError(EFileError.INVALID_FILE_TYPE, "Invalid file type."); return false; } if (file.size > maxFileSize) { - alert(`File size too large. Please select a file smaller than ${maxFileSize / 1024 / 1024}MB.`); + onError( + EFileError.FILE_SIZE_TOO_LARGE, + `File size too large. Please select a file smaller than ${maxFileSize / 1024 / 1024}MB.` + ); return false; } diff --git a/packages/editor/src/core/helpers/image-helpers.ts b/packages/editor/src/core/helpers/image-helpers.ts new file mode 100644 index 00000000000..9fcb877f9d6 --- /dev/null +++ b/packages/editor/src/core/helpers/image-helpers.ts @@ -0,0 +1,32 @@ +import { Editor } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// types +import { TFileHandler } from "@/types"; + +/** + * Finds all public image nodes in the document and restores them using the provided restore function + * + * Never remove this onCreate hook, it's a hack to restore old public + * images, since they don't give error if they've been deleted as they are + * rendered directly from image source instead of going through the + * apiserver + */ +export const restorePublicImages = (editor: Editor, restoreImageFn: TFileHandler["restore"]) => { + const imageSources = new Set(); + editor.state.doc.descendants((node) => { + if ([CORE_EXTENSIONS.IMAGE, CORE_EXTENSIONS.CUSTOM_IMAGE].includes(node.type.name as CORE_EXTENSIONS)) { + if (!node.attrs.src?.startsWith("http")) return; + + imageSources.add(node.attrs.src); + } + }); + + imageSources.forEach(async (src) => { + try { + await restoreImageFn(src); + } catch (error) { + console.error("Error restoring image: ", error); + } + }); +}; diff --git a/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts b/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts index ffad88d4e7e..b9449b494dd 100644 --- a/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts +++ b/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts @@ -1,5 +1,7 @@ import { KeyboardShortcutCommand } from "@tiptap/core"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; type Direction = "up" | "down"; @@ -39,13 +41,13 @@ export const insertEmptyParagraphAtNodeBoundaries: ( if (insertPosUp === 0) { // If at the very start of the document, insert a new paragraph at the start - editor.chain().insertContentAt(insertPosUp, { type: "paragraph" }).run(); + editor.chain().insertContentAt(insertPosUp, { type: CORE_EXTENSIONS.PARAGRAPH }).run(); editor.chain().setTextSelection(insertPosUp).run(); // Set the cursor to the new paragraph } else { // Otherwise, check the node immediately before the target node const prevNode = doc.nodeAt(insertPosUp - 1); - if (prevNode && prevNode.type.name === "paragraph") { + if (prevNode && prevNode.type.name === CORE_EXTENSIONS.PARAGRAPH) { // If the previous node is a paragraph, move the cursor there editor .chain() @@ -67,13 +69,13 @@ export const insertEmptyParagraphAtNodeBoundaries: ( // Check the node immediately after the target node const nextNode = doc.nodeAt(insertPosDown); - if (nextNode && nextNode.type.name === "paragraph") { + if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) { // If the next node is a paragraph, move the cursor to the end of it const endOfParagraphPos = insertPosDown + nextNode.nodeSize - 1; editor.chain().setTextSelection(endOfParagraphPos).run(); } else if (!nextNode) { // If there is no next node (end of document), insert a new paragraph - editor.chain().insertContentAt(insertPosDown, { type: "paragraph" }).run(); + editor.chain().insertContentAt(insertPosDown, { type: CORE_EXTENSIONS.PARAGRAPH }).run(); editor .chain() .setTextSelection(insertPosDown + 1) diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index 4abf7d6d1ff..8677b29edb9 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from "react"; import { HocuspocusProvider } from "@hocuspocus/provider"; import Collaboration from "@tiptap/extension-collaboration"; +import { useEffect, useMemo, useState } from "react"; import { IndexeddbPersistence } from "y-indexeddb"; // extensions import { HeadingListExtension, SideMenuExtension } from "@/extensions"; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index cf9d04d83e1..ce3cdbe5fb5 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -6,10 +6,13 @@ import { useImperativeHandle, MutableRefObject, useEffect } from "react"; import * as Y from "yjs"; // components import { getEditorMenuItems } from "@/components/menus"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { CoreEditorExtensions } from "@/extensions"; // helpers import { getParagraphCount } from "@/helpers/common"; +import { getExtensionStorage } from "@/helpers/get-extension-storage"; import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position"; import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helpers/scroll-to-node"; // props @@ -23,6 +26,7 @@ import type { TExtensions, TMentionHandler, } from "@/types"; +import { CORE_EDITOR_META } from "@/constants/meta"; export interface CustomEditorProps { editable: boolean; @@ -77,6 +81,7 @@ export const useEditor = (props: CustomEditorProps) => { immediatelyRender: false, shouldRerenderOnTransaction: false, autofocus, + parseOptions: { preserveWhitespace: true }, editorProps: { ...CoreEditorProps({ editorClassName, @@ -111,16 +116,19 @@ export const useEditor = (props: CustomEditorProps) => { // value is null when intentionally passed where syncing is not yet // supported and value is undefined when the data from swr is not populated if (value == null) return; - if (editor && !editor.isDestroyed && !editor.storage.imageComponent?.uploadInProgress) { - try { - editor.commands.setContent(value, false, { preserveWhitespace: "full" }); - if (editor.state.selection) { - const docLength = editor.state.doc.content.size; - const relativePosition = Math.min(editor.state.selection.from, docLength - 1); - editor.commands.setTextSelection(relativePosition); + if (editor) { + const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress; + if (!editor.isDestroyed && !isUploadInProgress) { + try { + editor.commands.setContent(value, false, { preserveWhitespace: true }); + if (editor.state.selection) { + const docLength = editor.state.doc.content.size; + const relativePosition = Math.min(editor.state.selection.from, docLength - 1); + editor.commands.setTextSelection(relativePosition); + } + } catch (error) { + console.error("Error syncing editor content with external value:", error); } - } catch (error) { - console.error("Error syncing editor content with external value:", error); } } }, [editor, value, id]); @@ -143,10 +151,10 @@ export const useEditor = (props: CustomEditorProps) => { }, getCurrentCursorPosition: () => editor?.state.selection.from, clearEditor: (emitUpdate = false) => { - editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); + editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run(); }, setEditorValue: (content: string, emitUpdate = false) => { - editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" }); + editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true }); }, setEditorValueAtCursorPosition: (content: string) => { if (editor?.state.selection) { @@ -179,7 +187,10 @@ export const useEditor = (props: CustomEditorProps) => { onHeadingChange: (callback: (headings: IMarking[]) => void) => { // Subscribe to update event emitted from headers extension editor?.on("update", () => { - callback(editor?.storage.headingList.headings); + const headings = getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings; + if (headings) { + callback(headings); + } }); // Return a function to unsubscribe to the continuous transactions of // the editor on unmounting the component that has subscribed to this @@ -188,7 +199,7 @@ export const useEditor = (props: CustomEditorProps) => { editor?.off("update"); }; }, - getHeadings: () => editor?.storage.headingList.headings, + getHeadings: () => (editor ? getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings : []), onStateChange: (callback: () => void) => { // Subscribe to editor state changes editor?.on("transaction", () => { @@ -221,7 +232,8 @@ export const useEditor = (props: CustomEditorProps) => { if (!editor) return; scrollSummary(editor, marking); }, - isEditorReadyToDiscard: () => editor?.storage.imageComponent?.uploadInProgress === false, + isEditorReadyToDiscard: () => + !!editor && getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress === false, setFocusAtPosition: (position: number) => { if (!editor || editor.isDestroyed) { console.error("Editor reference is not available or has been destroyed."); @@ -232,7 +244,7 @@ export const useEditor = (props: CustomEditorProps) => { const safePosition = Math.max(0, Math.min(position, docSize)); editor .chain() - .insertContentAt(safePosition, [{ type: "paragraph" }]) + .insertContentAt(safePosition, [{ type: CORE_EXTENSIONS.PARAGRAPH }]) .focus() .run(); } catch (error) { diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts index b707824f260..dce48cca5fb 100644 --- a/packages/editor/src/core/hooks/use-file-upload.ts +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -1,24 +1,32 @@ import { Editor } from "@tiptap/core"; import { DragEvent, useCallback, useEffect, useState } from "react"; -// extensions -import { insertFilesSafely } from "@/extensions/drop"; +// helpers +import { EFileError, isFileValid } from "@/helpers/file"; // plugins -import { isFileValid } from "@/helpers/file"; +import { insertFilesSafely } from "@/plugins/drop"; // types import { TEditorCommands } from "@/types"; type TUploaderArgs = { acceptedMimeTypes: string[]; - editorCommand: (file: File) => Promise; + editorCommand: (file: File) => Promise; handleProgressStatus?: (isUploading: boolean) => void; loadFileFromFileSystem?: (file: string) => void; maxFileSize: number; + onInvalidFile: (error: EFileError, file: File, message: string) => void; onUpload: (url: string, file: File) => void; }; export const useUploader = (args: TUploaderArgs) => { - const { acceptedMimeTypes, editorCommand, handleProgressStatus, loadFileFromFileSystem, maxFileSize, onUpload } = - args; + const { + acceptedMimeTypes, + editorCommand, + handleProgressStatus, + loadFileFromFileSystem, + maxFileSize, + onInvalidFile, + onUpload, + } = args; // states const [isUploading, setIsUploading] = useState(false); @@ -30,6 +38,7 @@ export const useUploader = (args: TUploaderArgs) => { acceptedMimeTypes, file, maxFileSize, + onError: (error, message) => onInvalidFile(error, file, message), }); if (!isValid) { handleProgressStatus?.(false); @@ -51,7 +60,7 @@ export const useUploader = (args: TUploaderArgs) => { }; reader.readAsDataURL(file); } - const url: string = await editorCommand(file); + const url = await editorCommand(file); if (!url) { throw new Error("Something went wrong while uploading the file."); @@ -65,23 +74,29 @@ export const useUploader = (args: TUploaderArgs) => { setIsUploading(false); } }, - [acceptedMimeTypes, editorCommand, handleProgressStatus, loadFileFromFileSystem, maxFileSize, onUpload] + [ + acceptedMimeTypes, + editorCommand, + handleProgressStatus, + loadFileFromFileSystem, + maxFileSize, + onInvalidFile, + onUpload, + ] ); return { isUploading, uploadFile }; }; type TDropzoneArgs = { - acceptedMimeTypes: string[]; editor: Editor; - maxFileSize: number; pos: number; type: Extract; uploader: (file: File) => Promise; }; export const useDropZone = (args: TDropzoneArgs) => { - const { acceptedMimeTypes, editor, maxFileSize, pos, type, uploader } = args; + const { editor, pos, type, uploader } = args; // states const [isDragging, setIsDragging] = useState(false); const [draggedInside, setDraggedInside] = useState(false); @@ -108,21 +123,21 @@ export const useDropZone = (args: TDropzoneArgs) => { async (e: DragEvent) => { e.preventDefault(); setDraggedInside(false); - if (e.dataTransfer.files.length === 0 || !editor.isEditable) { + const filesList = e.dataTransfer.files; + + if (filesList.length === 0 || !editor.isEditable) { return; } - const filesList = e.dataTransfer.files; + await uploadFirstFileAndInsertRemaining({ - acceptedMimeTypes, editor, filesList, - maxFileSize, pos, type, uploader, }); }, - [acceptedMimeTypes, editor, maxFileSize, pos, type, uploader] + [editor, pos, type, uploader] ); const onDragEnter = useCallback(() => setDraggedInside(true), []); const onDragLeave = useCallback(() => setDraggedInside(false), []); @@ -137,10 +152,8 @@ export const useDropZone = (args: TDropzoneArgs) => { }; type TMultipleFileArgs = { - acceptedMimeTypes: string[]; editor: Editor; filesList: FileList; - maxFileSize: number; pos: number; type: Extract; uploader: (file: File) => Promise; @@ -148,34 +161,18 @@ type TMultipleFileArgs = { // Upload the first file and insert the remaining ones for uploading multiple files export const uploadFirstFileAndInsertRemaining = async (args: TMultipleFileArgs) => { - const { acceptedMimeTypes, editor, filesList, maxFileSize, pos, type, uploader } = args; - const filteredFiles: File[] = []; - for (let i = 0; i < filesList.length; i += 1) { - const file = filesList.item(i); - if ( - file && - isFileValid({ - acceptedMimeTypes, - file, - maxFileSize, - }) - ) { - filteredFiles.push(file); - } - } - if (filteredFiles.length !== filesList.length) { - console.warn("Some files were invalid and have been ignored."); - } - if (filteredFiles.length === 0) { + const { editor, filesList, pos, type, uploader } = args; + const filesArray = Array.from(filesList); + if (filesArray.length === 0) { console.error("No files found to upload."); return; } // Upload the first file - const firstFile = filteredFiles[0]; + const firstFile = filesArray[0]; uploader(firstFile); // Insert the remaining files - const remainingFiles = filteredFiles.slice(1); + const remainingFiles = filesArray.slice(1); if (remainingFiles.length > 0) { const docSize = editor.state.doc.content.size; const posOfNextFileToBeInserted = Math.min(pos + 1, docSize); diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index b50b56b02dc..6a6e25d9fd5 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -12,6 +12,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; import { CoreReadOnlyEditorProps } from "@/props"; // types import type { EditorReadOnlyRefApi, TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types"; +import { CORE_EDITOR_META } from "@/constants/meta"; interface CustomReadOnlyEditorProps { disabledExtensions: TExtensions[]; @@ -45,6 +46,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { immediatelyRender: true, shouldRerenderOnTransaction: false, content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

", + parseOptions: { preserveWhitespace: true }, editorProps: { ...CoreReadOnlyEditorProps({ editorClassName, @@ -70,15 +72,15 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { // for syncing swr data on tab refocus etc useEffect(() => { if (initialValue === null || initialValue === undefined) return; - if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" }); + if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: true }); }, [editor, initialValue]); useImperativeHandle(forwardedRef, () => ({ clearEditor: (emitUpdate = false) => { - editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); + editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run(); }, setEditorValue: (content: string, emitUpdate = false) => { - editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" }); + editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true }); }, getMarkDown: (): string => { const markdownOutput = editor?.storage.markdown.getMarkdown(); diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index f9a60a48c12..aa00fa32d90 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -2,6 +2,8 @@ import { Fragment, Slice, Node, Schema } from "@tiptap/pm/model"; import { NodeSelection } from "@tiptap/pm/state"; // @ts-expect-error __serializeForClipboard's is not exported import { __serializeForClipboard, EditorView } from "@tiptap/pm/view"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions"; @@ -132,7 +134,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp let listType = ""; let isDragging = false; let lastClientY = 0; - let scrollAnimationFrame = null; + let scrollAnimationFrame: number | null = null; let isDraggedOutsideWindow: "top" | "bottom" | boolean = false; let isMouseInsideWhileDragging = false; let currentScrollSpeed = 0; @@ -142,8 +144,10 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp }; const handleDragStart = (event: DragEvent, view: EditorView) => { - const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options); - listType = listTypeFromDragStart; + const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options) ?? {}; + if (listTypeFromDragStart) { + listType = listTypeFromDragStart; + } isDragging = true; lastClientY = event.clientY; scroll(); @@ -297,7 +301,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp // Traverse up the document tree to find if we're inside a list item for (let i = resolvedPos.depth; i > 0; i--) { - if (resolvedPos.node(i).type.name === "listItem") { + if (resolvedPos.node(i).type.name === CORE_EXTENSIONS.LIST_ITEM) { isDroppedInsideList = true; dropDepth = i; break; @@ -305,7 +309,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp } // Handle nested list items and task items - if (droppedNode.type.name === "listItem") { + if (droppedNode.type.name === CORE_EXTENSIONS.LIST_ITEM) { let slice = view.state.selection.content(); let newFragment = slice.content; @@ -348,8 +352,8 @@ function flattenListStructure(fragment: Fragment, schema: Schema): Fragment { (node.content.firstChild.type === schema.nodes.bulletList || node.content.firstChild.type === schema.nodes.orderedList) ) { - const sublist = node.content.firstChild; - const flattened = flattenListStructure(sublist.content, schema); + const subList = node.content.firstChild; + const flattened = flattenListStructure(subList.content, schema); flattened.forEach((subNode) => result.push(subNode)); } } @@ -376,7 +380,7 @@ const handleNodeSelection = ( let draggedNodePos = nodePosAtDOM(node, view, options); if (draggedNodePos == null || draggedNodePos < 0) return; - // Handle blockquotes separately + // Handle blockquote separately if (node.matches("blockquote")) { draggedNodePos = nodePosAtDOMForBlockQuotes(node, view); if (draggedNodePos === null || draggedNodePos === undefined) return; @@ -385,7 +389,10 @@ const handleNodeSelection = ( const $pos = view.state.doc.resolve(draggedNodePos); // If it's a nested list item or task item, move up to the item level - if (($pos.parent.type.name === "listItem" || $pos.parent.type.name === "taskItem") && $pos.depth > 1) { + if ( + [CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes($pos.parent.type.name as CORE_EXTENSIONS) && + $pos.depth > 1 + ) { draggedNodePos = $pos.before($pos.depth); } } @@ -403,14 +410,16 @@ const handleNodeSelection = ( // Additional logic for drag start if (event instanceof DragEvent && !event.dataTransfer) return; - if (nodeSelection.node.type.name === "listItem" || nodeSelection.node.type.name === "taskItem") { + if ( + [CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes(nodeSelection.node.type.name as CORE_EXTENSIONS) + ) { listType = node.closest("ol, ul")?.tagName || ""; } const slice = view.state.selection.content(); const { dom, text } = __serializeForClipboard(view, slice); - if (event instanceof DragEvent) { + if (event instanceof DragEvent && event.dataTransfer) { event.dataTransfer.clearData(); event.dataTransfer.setData("text/html", dom.innerHTML); event.dataTransfer.setData("text/plain", text); diff --git a/packages/editor/src/core/plugins/drop.ts b/packages/editor/src/core/plugins/drop.ts new file mode 100644 index 00000000000..a0bb65779fd --- /dev/null +++ b/packages/editor/src/core/plugins/drop.ts @@ -0,0 +1,118 @@ +import { Editor } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +// constants +import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config"; +// types +import { TEditorCommands } from "@/types"; + +export const DropHandlerPlugin = (editor: Editor): Plugin => + new Plugin({ + key: new PluginKey("drop-handler-plugin"), + props: { + handlePaste: (view, event) => { + if ( + editor.isEditable && + event.clipboardData && + event.clipboardData.files && + event.clipboardData.files.length > 0 + ) { + event.preventDefault(); + const files = Array.from(event.clipboardData.files); + const acceptedFiles = files.filter( + (f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type) + ); + + if (acceptedFiles.length) { + const pos = view.state.selection.from; + insertFilesSafely({ + editor, + files: acceptedFiles, + initialPos: pos, + event: "drop", + }); + } + return true; + } + return false; + }, + handleDrop: (view, event, _slice, moved) => { + if ( + editor.isEditable && + !moved && + event.dataTransfer && + event.dataTransfer.files && + event.dataTransfer.files.length > 0 + ) { + event.preventDefault(); + const files = Array.from(event.dataTransfer.files); + const acceptedFiles = files.filter( + (f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type) + ); + + if (acceptedFiles.length) { + const coordinates = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + + if (coordinates) { + const pos = coordinates.pos; + insertFilesSafely({ + editor, + files: acceptedFiles, + initialPos: pos, + event: "drop", + }); + } + return true; + } + } + return false; + }, + }, + }); + +type InsertFilesSafelyArgs = { + editor: Editor; + event: "insert" | "drop"; + files: File[]; + initialPos: number; + type?: Extract; +}; + +export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => { + const { editor, event, files, initialPos, type } = args; + let pos = initialPos; + + for (const file of files) { + // safe insertion + const docSize = editor.state.doc.content.size; + pos = Math.min(pos, docSize); + + let fileType: "image" | "attachment" | null = null; + + try { + if (type) { + if (["image", "attachment"].includes(type)) fileType = type; + else throw new Error("Wrong file type passed"); + } else { + if (ACCEPTED_IMAGE_MIME_TYPES.includes(file.type)) fileType = "image"; + else if (ACCEPTED_ATTACHMENT_MIME_TYPES.includes(file.type)) fileType = "attachment"; + } + // insert file depending on the type at the current position + if (fileType === "image") { + editor.commands.insertImageComponent({ + file, + pos, + event, + }); + } else if (fileType === "attachment") { + } + } catch (error) { + console.error(`Error while ${event}ing file:`, error); + } + + // Move to the next position + pos += 1; + } +}; diff --git a/packages/editor/src/core/plugins/file/delete.ts b/packages/editor/src/core/plugins/file/delete.ts new file mode 100644 index 00000000000..ac69b18194b --- /dev/null +++ b/packages/editor/src/core/plugins/file/delete.ts @@ -0,0 +1,69 @@ +import { Editor } from "@tiptap/core"; +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +// constants +import { CORE_EDITOR_META } from "@/constants/meta"; +// plane editor imports +import { NODE_FILE_MAP } from "@/plane-editor/constants/utility"; +// types +import { TFileHandler } from "@/types"; +// local imports +import { TFileNode } from "./types"; + +const DELETE_PLUGIN_KEY = new PluginKey("delete-utility"); + +export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHandler["delete"]): Plugin => + new Plugin({ + key: DELETE_PLUGIN_KEY, + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + const newFileSources: { + [nodeType: string]: Set | undefined; + } = {}; + if (!transactions.some((tr) => tr.docChanged)) return null; + + newState.doc.descendants((node) => { + const nodeType = node.type.name; + const nodeFileSetDetails = NODE_FILE_MAP[nodeType]; + if (nodeFileSetDetails) { + if (newFileSources[nodeType]) { + newFileSources[nodeType].add(node.attrs.src); + } else { + newFileSources[nodeType] = new Set([node.attrs.src]); + } + } + }); + + transactions.forEach((transaction) => { + // if the transaction has meta of skipFileDeletion set to true, then return (like while clearing the editor content programmatically) + if (transaction.getMeta(CORE_EDITOR_META.SKIP_FILE_DELETION)) return; + + const removedFiles: TFileNode[] = []; + + // iterate through all the nodes in the old state + oldState.doc.descendants((node) => { + const nodeType = node.type.name; + const isAValidNode = NODE_FILE_MAP[nodeType]; + // if the node doesn't match, then return as no point in checking + if (!isAValidNode) return; + // Check if the node has been deleted or replaced + if (!newFileSources[nodeType]?.has(node.attrs.src)) { + removedFiles.push(node as TFileNode); + } + }); + + removedFiles.forEach(async (node) => { + const nodeType = node.type.name; + const src = node.attrs.src; + const nodeFileSetDetails = NODE_FILE_MAP[nodeType]; + if (!nodeFileSetDetails || !src) return; + try { + editor.storage[nodeType][nodeFileSetDetails.fileSetName]?.set(src, true); + await deleteHandler(src); + } catch (error) { + console.error("Error deleting file via delete utility plugin:", error); + } + }); + }); + + return null; + }, + }); diff --git a/packages/editor/src/core/plugins/file/restore.ts b/packages/editor/src/core/plugins/file/restore.ts new file mode 100644 index 00000000000..04a4c295ccd --- /dev/null +++ b/packages/editor/src/core/plugins/file/restore.ts @@ -0,0 +1,72 @@ +import { Editor } from "@tiptap/core"; +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// plane editor imports +import { NODE_FILE_MAP } from "@/plane-editor/constants/utility"; +// types +import { TFileHandler } from "@/types"; +// local imports +import { TFileNode } from "./types"; + +const RESTORE_PLUGIN_KEY = new PluginKey("restore-utility"); + +export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFileHandler["restore"]): Plugin => + new Plugin({ + key: RESTORE_PLUGIN_KEY, + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + if (!transactions.some((tr) => tr.docChanged)) return null; + + const oldFileSources: { + [key: string]: Set | undefined; + } = {}; + oldState.doc.descendants((node) => { + const nodeType = node.type.name; + const nodeFileSetDetails = NODE_FILE_MAP[nodeType]; + if (nodeFileSetDetails) { + if (oldFileSources[nodeType]) { + oldFileSources[nodeType].add(node.attrs.src); + } else { + oldFileSources[nodeType] = new Set([node.attrs.src]); + } + } + }); + + transactions.forEach(() => { + const addedFiles: TFileNode[] = []; + + newState.doc.descendants((node, pos) => { + const nodeType = node.type.name; + const isAValidNode = NODE_FILE_MAP[nodeType]; + // if the node doesn't match, then return as no point in checking + if (!isAValidNode) return; + if (pos < 0 || pos > newState.doc.content.size) return; + if (oldFileSources[nodeType]?.has(node.attrs.src)) return; + // if the src is just a id (private bucket), then we don't need to handle restore from here but + // only while it fails to load + if (nodeType === CORE_EXTENSIONS.CUSTOM_IMAGE && !node.attrs.src?.startsWith("http")) return; + addedFiles.push(node as TFileNode); + }); + + addedFiles.forEach(async (node) => { + const nodeType = node.type.name; + const src = node.attrs.src; + const nodeFileSetDetails = NODE_FILE_MAP[nodeType]; + const extensionFileSetStorage = editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName]; + const wasDeleted = extensionFileSetStorage?.get(src); + if (!nodeFileSetDetails || !src) return; + if (wasDeleted === undefined) { + extensionFileSetStorage?.set(src, false); + } else if (wasDeleted === true) { + try { + await restoreHandler(src); + extensionFileSetStorage?.set(src, false); + } catch (error) { + console.error("Error restoring file via restore utility plugin:", error); + } + } + }); + }); + return null; + }, + }); diff --git a/packages/editor/src/core/plugins/file/root.ts b/packages/editor/src/core/plugins/file/root.ts new file mode 100644 index 00000000000..693ac6964ba --- /dev/null +++ b/packages/editor/src/core/plugins/file/root.ts @@ -0,0 +1,22 @@ +import { Editor } from "@tiptap/core"; +import { Plugin } from "@tiptap/pm/state"; +// types +import { TFileHandler, TReadOnlyFileHandler } from "@/types"; +// local imports +import { TrackFileDeletionPlugin } from "./delete"; +import { TrackFileRestorationPlugin } from "./restore"; + +type TArgs = { + editor: Editor; + fileHandler: TFileHandler | TReadOnlyFileHandler; + isEditable: boolean; +}; + +export const FilePlugins = (args: TArgs): Plugin[] => { + const { editor, fileHandler, isEditable } = args; + + return [ + ...(isEditable && "delete" in fileHandler ? [TrackFileDeletionPlugin(editor, fileHandler.delete)] : []), + TrackFileRestorationPlugin(editor, fileHandler.restore), + ]; +}; diff --git a/packages/editor/src/core/plugins/file/types.ts b/packages/editor/src/core/plugins/file/types.ts new file mode 100644 index 00000000000..164d12ae7ee --- /dev/null +++ b/packages/editor/src/core/plugins/file/types.ts @@ -0,0 +1,8 @@ +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +export type TFileNode = ProseMirrorNode & { + attrs: { + src: string; + id: string; + }; +}; diff --git a/packages/editor/src/core/plugins/image/delete-image.ts b/packages/editor/src/core/plugins/image/delete-image.ts deleted file mode 100644 index 459d9fd7068..00000000000 --- a/packages/editor/src/core/plugins/image/delete-image.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Editor } from "@tiptap/core"; -import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; -// plugins -import { type ImageNode } from "@/plugins/image"; -// types -import { DeleteImage } from "@/types"; - -export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage, nodeType: string): Plugin => - new Plugin({ - key: new PluginKey(`delete-${nodeType}`), - appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { - const newImageSources = new Set(); - newState.doc.descendants((node) => { - if (node.type.name === nodeType) { - newImageSources.add(node.attrs.src); - } - }); - - transactions.forEach((transaction) => { - // if the transaction has meta of skipImageDeletion get to true, then return (like while clearing the editor content programatically) - if (transaction.getMeta("skipImageDeletion")) return; - // transaction could be a selection - if (!transaction.docChanged) return; - - const removedImages: ImageNode[] = []; - - // iterate through all the nodes in the old state - oldState.doc.descendants((oldNode) => { - // if the node is not an image, then return as no point in checking - if (oldNode.type.name !== nodeType) return; - - // Check if the node has been deleted or replaced - if (!newImageSources.has(oldNode.attrs.src)) { - removedImages.push(oldNode as ImageNode); - } - }); - - removedImages.forEach(async (node) => { - const src = node.attrs.src; - editor.storage[nodeType].deletedImageSet?.set(src, true); - if (!src) return; - try { - await deleteImage(src); - } catch (error) { - console.error("Error deleting image:", error); - } - }); - }); - - return null; - }, - }); diff --git a/packages/editor/src/core/plugins/image/index.ts b/packages/editor/src/core/plugins/image/index.ts deleted file mode 100644 index c0dc631c533..00000000000 --- a/packages/editor/src/core/plugins/image/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./types"; -export * from "./delete-image"; -export * from "./restore-image"; diff --git a/packages/editor/src/core/plugins/image/restore-image.ts b/packages/editor/src/core/plugins/image/restore-image.ts deleted file mode 100644 index 4eecf01d7e2..00000000000 --- a/packages/editor/src/core/plugins/image/restore-image.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Editor } from "@tiptap/core"; -import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; -// plugins -import { ImageNode } from "@/plugins/image"; -// types -import { RestoreImage } from "@/types"; - -export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage, nodeType: string): Plugin => - new Plugin({ - key: new PluginKey(`restore-${nodeType}`), - appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { - const oldImageSources = new Set(); - oldState.doc.descendants((node) => { - if (node.type.name === nodeType) { - oldImageSources.add(node.attrs.src); - } - }); - - transactions.forEach((transaction) => { - if (!transaction.docChanged) return; - - const addedImages: ImageNode[] = []; - - newState.doc.descendants((node, pos) => { - if (node.type.name !== nodeType) return; - if (pos < 0 || pos > newState.doc.content.size) return; - if (oldImageSources.has(node.attrs.src)) return; - // if the src is just a id (private bucket), then we don't need to handle restore from here but - // only while it fails to load - if (!node.attrs.src?.startsWith("http")) return; - addedImages.push(node as ImageNode); - }); - - addedImages.forEach(async (image) => { - const src = image.attrs.src; - const wasDeleted = editor.storage[nodeType].deletedImageSet.get(src); - if (wasDeleted === undefined) { - editor.storage[nodeType].deletedImageSet.set(src, false); - } else if (wasDeleted === true) { - try { - await onNodeRestored(src, restoreImage); - editor.storage[nodeType].deletedImageSet.set(src, false); - } catch (error) { - console.error("Error restoring image: ", error); - } - } - }); - }); - return null; - }, - }); - -async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise { - if (!src) return; - try { - await restoreImage(src); - } catch (error) { - console.error("Error restoring image: ", error); - throw error; - } -} diff --git a/packages/editor/src/core/plugins/image/types/image-node.ts b/packages/editor/src/core/plugins/image/types/image-node.ts deleted file mode 100644 index 67afc8315a4..00000000000 --- a/packages/editor/src/core/plugins/image/types/image-node.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Node as ProseMirrorNode } from "@tiptap/pm/model"; - -export interface ImageNode extends ProseMirrorNode { - attrs: { - src: string; - id: string; - }; -} - -export type ImageExtensionStorage = { - deletedImageSet: Map; - uploadInProgress: boolean; -}; diff --git a/packages/editor/src/core/plugins/image/types/index.ts b/packages/editor/src/core/plugins/image/types/index.ts deleted file mode 100644 index 2fddf3bf646..00000000000 --- a/packages/editor/src/core/plugins/image/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./image-node"; diff --git a/packages/editor/src/core/plugins/markdown-clipboard.ts b/packages/editor/src/core/plugins/markdown-clipboard.ts new file mode 100644 index 00000000000..78f649b23d4 --- /dev/null +++ b/packages/editor/src/core/plugins/markdown-clipboard.ts @@ -0,0 +1,80 @@ +import { Editor } from "@tiptap/core"; +import { Fragment, Node } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; + +export const MarkdownClipboardPlugin = (editor: Editor): Plugin => + new Plugin({ + key: new PluginKey("markdownClipboard"), + props: { + clipboardTextSerializer: (slice) => { + const markdownSerializer = editor.storage.markdown.serializer; + const isTableRow = slice.content.firstChild?.type?.name === CORE_EXTENSIONS.TABLE_ROW; + const nodeSelect = slice.openStart === 0 && slice.openEnd === 0; + + if (nodeSelect) { + return markdownSerializer.serialize(slice.content); + } + + const processTableContent = (tableNode: Node | Fragment) => { + let result = ""; + tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => { + tableRowNode.content?.forEach?.((cell: Node) => { + const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : ""; + result += cellContent + "\n"; + }); + }); + return result; + }; + + if (isTableRow) { + const rowsCount = slice.content?.childCount || 0; + const cellsCount = slice.content?.firstChild?.content?.childCount || 0; + if (rowsCount === 1 || cellsCount === 1) { + return processTableContent(slice.content); + } else { + return markdownSerializer.serialize(slice.content); + } + } + + const traverseToParentOfLeaf = (node: Node | null, parent: Fragment | Node, depth: number): Node | Fragment => { + let currentNode = node; + let currentParent = parent; + let currentDepth = depth; + + while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) { + if (currentNode.content?.childCount > 1) { + if (currentNode.content.firstChild?.type?.name === CORE_EXTENSIONS.LIST_ITEM) { + return currentParent; + } else { + return currentNode.content; + } + } + + currentParent = currentNode; + currentNode = currentNode.content?.firstChild || null; + currentDepth--; + } + + return currentParent; + }; + + if (slice.content.childCount > 1) { + return markdownSerializer.serialize(slice.content); + } else { + const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart); + + let currentNode = targetNode; + while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) { + currentNode = currentNode.firstChild; + } + if (currentNode instanceof Node && currentNode.isText) { + return currentNode.text; + } + + return markdownSerializer.serialize(targetNode); + } + }, + }, + }); diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts index 82e2f81f9a3..55608623204 100644 --- a/packages/editor/src/core/types/collaboration.ts +++ b/packages/editor/src/core/types/collaboration.ts @@ -22,7 +22,7 @@ export type TServerHandler = { type TCollaborativeEditorHookProps = { disabledExtensions: TExtensions[]; - editable?: boolean; + editable: boolean; editorClassName: string; editorProps?: EditorProps; extensions?: Extensions; diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts index 4c91fec5d10..ace2220ed00 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -1,19 +1,18 @@ -import { DeleteImage, RestoreImage, UploadImage } from "@/types"; - export type TReadOnlyFileHandler = { + checkIfAssetExists: (assetId: string) => Promise; getAssetSrc: (path: string) => Promise; - restore: RestoreImage; + restore: (assetSrc: string) => Promise; }; export type TFileHandler = TReadOnlyFileHandler & { assetsUploadStatus: Record; // blockId => progress percentage cancel: () => void; - delete: DeleteImage; - upload: UploadImage; + delete: (assetSrc: string) => Promise; + upload: (blockId: string, file: File) => Promise; validation: { /** * @description max file size in bytes - * @example enter 5242880( 5* 1024 * 1024) for 5MB + * @example enter 5242880(5 * 1024 * 1024) for 5MB */ maxFileSize: number; }; diff --git a/packages/editor/src/core/types/image.ts b/packages/editor/src/core/types/image.ts deleted file mode 100644 index ca6f76fb1b8..00000000000 --- a/packages/editor/src/core/types/image.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; - -export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; - -export type UploadImage = (blockId: string, file: File) => Promise; diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts index e99a74b28ee..66cb249425b 100644 --- a/packages/editor/src/core/types/index.ts +++ b/packages/editor/src/core/types/index.ts @@ -4,7 +4,6 @@ export * from "./config"; export * from "./editor"; export * from "./embed"; export * from "./extensions"; -export * from "./image"; export * from "./mention"; export * from "./slash-commands-suggestion"; export * from "@/plane-editor/types"; diff --git a/packages/hooks/package.json b/packages/hooks/package.json index d444bedda8b..e477c6446ec 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -22,7 +22,7 @@ "@plane/eslint-config": "*", "@types/node": "^22.5.4", "@types/react": "^18.3.11", - "tsup": "^8.4.0", + "tsup": "8.4.0", "typescript": "^5.3.3" } } diff --git a/packages/i18n/src/constants/language.ts b/packages/i18n/src/constants/language.ts index d3d3a887a34..4969178a598 100644 --- a/packages/i18n/src/constants/language.ts +++ b/packages/i18n/src/constants/language.ts @@ -24,4 +24,13 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [ { label: "Türkçe", value: "tr-TR" }, ]; +/** + * Enum for translation file names + * These are the JSON files that contain translations each category + */ +export enum ETranslationFiles { + TRANSLATIONS = "translations", + ACCESSIBILITY = "accessibility", +} + export const LANGUAGE_STORAGE_KEY = "userLanguage"; diff --git a/packages/i18n/src/locales/cs/accessibility.json b/packages/i18n/src/locales/cs/accessibility.json new file mode 100644 index 00000000000..676c2d44236 --- /dev/null +++ b/packages/i18n/src/locales/cs/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo pracovního prostoru", + "open_workspace_switcher": "Otevřít přepínač pracovního prostoru", + "open_user_menu": "Otevřít uživatelské menu", + "open_command_palette": "Otevřít paletu příkazů", + "open_extended_sidebar": "Otevřít rozšířený postranní panel", + "close_extended_sidebar": "Zavřít rozšířený postranní panel", + "create_favorites_folder": "Vytvořit složku oblíbených", + "open_folder": "Otevřít složku", + "close_folder": "Zavřít složku", + "open_favorites_menu": "Otevřít menu oblíbených", + "close_favorites_menu": "Zavřít menu oblíbených", + "enter_folder_name": "Zadejte název složky", + "create_new_project": "Vytvořit nový projekt", + "open_projects_menu": "Otevřít menu projektů", + "close_projects_menu": "Zavřít menu projektů", + "toggle_quick_actions_menu": "Přepnout menu rychlých akcí", + "open_project_menu": "Otevřít menu projektu", + "close_project_menu": "Zavřít menu projektu", + "collapse_sidebar": "Sbalit postranní panel", + "expand_sidebar": "Rozbalit postranní panel", + "edition_badge": "Otevřít modal placených plánů" + }, + "auth_forms": { + "clear_email": "Vymazat e-mail", + "show_password": "Zobrazit heslo", + "hide_password": "Skrýt heslo", + "close_alert": "Zavřít upozornění", + "close_popover": "Zavřít vyskakovací okno" + } + } +} diff --git a/packages/i18n/src/locales/cs/translations.json b/packages/i18n/src/locales/cs/translations.json index 28109cd9d26..78a396051e3 100644 --- a/packages/i18n/src/locales/cs/translations.json +++ b/packages/i18n/src/locales/cs/translations.json @@ -848,6 +848,7 @@ "live": "Živě", "change_history": "Historie změn", "coming_soon": "Již brzy", + "member": "Člen", "members": "Členové", "you": "Vy", "upgrade_cta": { diff --git a/packages/i18n/src/locales/de/accessibility.json b/packages/i18n/src/locales/de/accessibility.json new file mode 100644 index 00000000000..edf90970f2c --- /dev/null +++ b/packages/i18n/src/locales/de/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Arbeitsbereich-Logo", + "open_workspace_switcher": "Arbeitsbereich-Umschalter öffnen", + "open_user_menu": "Benutzermenü öffnen", + "open_command_palette": "Befehlspalette öffnen", + "open_extended_sidebar": "Erweiterte Seitenleiste öffnen", + "close_extended_sidebar": "Erweiterte Seitenleiste schließen", + "create_favorites_folder": "Favoriten-Ordner erstellen", + "open_folder": "Ordner öffnen", + "close_folder": "Ordner schließen", + "open_favorites_menu": "Favoriten-Menü öffnen", + "close_favorites_menu": "Favoriten-Menü schließen", + "enter_folder_name": "Ordnername eingeben", + "create_new_project": "Neues Projekt erstellen", + "open_projects_menu": "Projekt-Menü öffnen", + "close_projects_menu": "Projekt-Menü schließen", + "toggle_quick_actions_menu": "Schnellaktionen-Menü umschalten", + "open_project_menu": "Projekt-Menü öffnen", + "close_project_menu": "Projekt-Menü schließen", + "collapse_sidebar": "Seitenleiste einklappen", + "expand_sidebar": "Seitenleiste ausklappen", + "edition_badge": "Modal für kostenpflichtige Pläne öffnen" + }, + "auth_forms": { + "clear_email": "E-Mail löschen", + "show_password": "Passwort anzeigen", + "hide_password": "Passwort verbergen", + "close_alert": "Warnung schließen", + "close_popover": "Popover schließen" + } + } +} diff --git a/packages/i18n/src/locales/de/translations.json b/packages/i18n/src/locales/de/translations.json index 1cc37072872..1e9ba1e3d5b 100644 --- a/packages/i18n/src/locales/de/translations.json +++ b/packages/i18n/src/locales/de/translations.json @@ -848,6 +848,7 @@ "live": "Live", "change_history": "Änderungsverlauf", "coming_soon": "Demnächst verfügbar", + "member": "Mitglied", "members": "Mitglieder", "you": "Sie", "upgrade_cta": { @@ -2459,4 +2460,4 @@ "previously_edited_by": "Zuvor bearbeitet von", "edited_by": "Bearbeitet von" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/en/accessibility.json b/packages/i18n/src/locales/en/accessibility.json new file mode 100644 index 00000000000..86660d640ec --- /dev/null +++ b/packages/i18n/src/locales/en/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Workspace logo", + "open_workspace_switcher": "Open workspace switcher", + "open_user_menu": "Open user menu", + "open_command_palette": "Open command palette", + "open_extended_sidebar": "Open extended sidebar", + "close_extended_sidebar": "Close extended sidebar", + "create_favorites_folder": "Create favorites folder", + "open_folder": "Open folder", + "close_folder": "Close folder", + "open_favorites_menu": "Open favorites menu", + "close_favorites_menu": "Close favorites menu", + "enter_folder_name": "Enter folder name", + "create_new_project": "Create new project", + "open_projects_menu": "Open projects menu", + "close_projects_menu": "Close projects menu", + "toggle_quick_actions_menu": "Toggle quick actions menu", + "open_project_menu": "Open project menu", + "close_project_menu": "Close project menu", + "collapse_sidebar": "Collapse sidebar", + "expand_sidebar": "Expand sidebar", + "edition_badge": "Open paid plans' modal" + }, + "auth_forms": { + "clear_email": "Clear email", + "show_password": "Show password", + "hide_password": "Hide password", + "close_alert": "Close alert", + "close_popover": "Close popover" + } + } +} diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index c959108e0a1..196e1ce4caf 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -43,7 +43,8 @@ "your_account": "Your account", "security": "Security", "activity": "Activity", - "appearance": "Appearance", + "preferences": "Preferences", + "language_and_time": "Language & Time", "notifications": "Notifications", "workspaces": "Workspaces", "create_workspace": "Create workspace", @@ -56,6 +57,10 @@ "something_went_wrong_please_try_again": "Something went wrong. Please try again.", "load_more": "Load more", "select_or_customize_your_interface_color_scheme": "Select or customize your interface color scheme.", + "timezone_setting": "Current timezone setting.", + "language_setting": "Choose the language used in the user interface.", + "settings_moved_to_preferences": "Timezone & Language settings have been moved to preferences.", + "go_to_preferences": "Go to preferences", "theme": "Theme", "system_preference": "System preference", "light": "Light", @@ -334,6 +339,8 @@ "new_password_must_be_different_from_old_password": "New password must be different from old password", "edited": "edited", "bot": "Bot", + "settings_description": "Manage your account, workspace, and project preferences all in one place. Switch between tabs to easily configure.", + "back_to_workspace": "Back to workspace", "project_view": { "sort_by": { "created_at": "Created at", @@ -683,6 +690,7 @@ "live": "Live", "change_history": "Change History", "coming_soon": "Coming soon", + "member": "Member", "members": "Members", "you": "You", "upgrade_cta": { @@ -1301,6 +1309,28 @@ } } }, + "account_settings": { + "profile":{}, + "preferences":{ + "heading": "Preferences", + "description": "Customize your app experience the way you work" + }, + "notifications":{ + "heading": "Email notifications", + "description": "Stay in the loop on Work items you are subscribed to. Enable this to get notified." + }, + "security":{ + "heading": "Security" + }, + "api_tokens":{ + "heading": "Personal Access Tokens", + "description": "Generate secure API tokens to integrate your data with external systems and applications." + }, + "activity":{ + "heading": "Activity", + "description": "Track your recent actions and changes across all projects and work items." + } + }, "workspace_settings": { "label": "Workspace settings", "page_label": "{workspace} - General settings", @@ -1367,16 +1397,22 @@ } }, "billing_and_plans": { + "heading": "Billing & Plans", + "description":"Choose your plan, manage subscriptions, and easily upgrade as your needs grow.", "title": "Billing & Plans", "current_plan": "Current plan", "free_plan": "You are currently using the free plan", "view_plans": "View plans" }, "exports": { + "heading": "Exports", + "description": "Export your project data in various formats and access your export history with download links.", "title": "Exports", "exporting": "Exporting", "previous_exports": "Previous exports", "export_separate_files": "Export the data into separate files", + "exporting_projects": "Exporting project", + "format": "Format", "modal": { "title": "Export to", "toasts": { @@ -1392,6 +1428,8 @@ } }, "webhooks": { + "heading": "Webhooks", + "description": "Automate notifications to external services when project events occur.", "title": "Webhooks", "add_webhook": "Add webhook", "modal": { @@ -1443,29 +1481,29 @@ } }, "api_tokens": { - "title": "API Tokens", - "add_token": "Add API token", + "title": "Personal Access Tokens", + "add_token": "Add personal access token", "create_token": "Create token", "never_expires": "Never expires", "generate_token": "Generate token", "generating": "Generating", "delete": { - "title": "Delete API token", + "title": "Delete personal access token", "description": "Any application using this token will no longer have the access to Plane data. This action cannot be undone.", "success": { "title": "Success!", - "message": "The API token has been successfully deleted" + "message": "The token has been successfully deleted" }, "error": { "title": "Error!", - "message": "The API token could not be deleted" + "message": "The token could not be deleted" } } } }, "empty_state": { "api_tokens": { - "title": "No API tokens created", + "title": "No personal access tokens created", "description": "Plane APIs can be used to integrate your data in Plane with any external system. Create a token to get started." }, "webhooks": { @@ -1515,8 +1553,9 @@ "profile": "Profile", "security": "Security", "activity": "Activity", - "appearance": "Appearance", - "notifications": "Notifications" + "preferences": "Preferences", + "notifications": "Notifications", + "api-tokens": "Personal Access Tokens" }, "tabs": { "summary": "Summary", @@ -1578,6 +1617,8 @@ } }, "states": { + "heading": "States", + "description": "Define and customize workflow states to track the progress of your work items.", "describe_this_state_for_your_members": "Describe this state for your members.", "empty_state": { "title": "No states available for the {groupKey} group", @@ -1585,6 +1626,8 @@ } }, "labels": { + "heading": "Labels", + "description": "Create custom labels to categorize and organize your work items", "label_title": "Label title", "label_title_is_required": "Label title is required", "label_max_char": "Label name should not exceed 255 characters", @@ -1593,9 +1636,11 @@ } }, "estimates": { + "heading": "Estimates", + "description": "Set up estimation systems to track and communicate the effort required for each work item.", "label": "Estimates", "title": "Enable estimates for my project", - "description": "They help you in communicating complexity and workload of the team.", + "enable_description": "They help you in communicating complexity and workload of the team.", "no_estimate": "No estimate", "new": "New estimate system", "create": { @@ -1677,6 +1722,8 @@ }, "automations": { "label": "Automations", + "heading": "Automations", + "description": "Configure automated actions to streamline your project management workflow and reduce manual tasks.", "auto-archive": { "title": "Auto-archive closed work items", "description": "Plane will auto archive work items that have been completed or canceled.", @@ -2296,4 +2343,4 @@ "previously_edited_by": "Previously edited by", "edited_by": "Edited by" } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/es/accessibility.json b/packages/i18n/src/locales/es/accessibility.json new file mode 100644 index 00000000000..4d957f5a9f5 --- /dev/null +++ b/packages/i18n/src/locales/es/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo del espacio de trabajo", + "open_workspace_switcher": "Abrir cambiador de espacio de trabajo", + "open_user_menu": "Abrir menú de usuario", + "open_command_palette": "Abrir paleta de comandos", + "open_extended_sidebar": "Abrir barra lateral extendida", + "close_extended_sidebar": "Cerrar barra lateral extendida", + "create_favorites_folder": "Crear carpeta de favoritos", + "open_folder": "Abrir carpeta", + "close_folder": "Cerrar carpeta", + "open_favorites_menu": "Abrir menú de favoritos", + "close_favorites_menu": "Cerrar menú de favoritos", + "enter_folder_name": "Ingresar nombre de carpeta", + "create_new_project": "Crear nuevo proyecto", + "open_projects_menu": "Abrir menú de proyectos", + "close_projects_menu": "Cerrar menú de proyectos", + "toggle_quick_actions_menu": "Alternar menú de acciones rápidas", + "open_project_menu": "Abrir menú de proyecto", + "close_project_menu": "Cerrar menú de proyecto", + "collapse_sidebar": "Colapsar barra lateral", + "expand_sidebar": "Expandir barra lateral", + "edition_badge": "Abrir modal de planes de pago" + }, + "auth_forms": { + "clear_email": "Limpiar correo electrónico", + "show_password": "Mostrar contraseña", + "hide_password": "Ocultar contraseña", + "close_alert": "Cerrar alerta", + "close_popover": "Cerrar ventana emergente" + } + } +} diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json index 94f1819a48d..8fbeb87ad59 100644 --- a/packages/i18n/src/locales/es/translations.json +++ b/packages/i18n/src/locales/es/translations.json @@ -851,6 +851,7 @@ "live": "En vivo", "change_history": "Historial de cambios", "coming_soon": "Próximamente", + "member": "Miembro", "members": "Miembros", "you": "Tú", "upgrade_cta": { diff --git a/packages/i18n/src/locales/fr/accessibility.json b/packages/i18n/src/locales/fr/accessibility.json new file mode 100644 index 00000000000..435247c58bd --- /dev/null +++ b/packages/i18n/src/locales/fr/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo de l'espace de travail", + "open_workspace_switcher": "Ouvrir le sélecteur d'espace de travail", + "open_user_menu": "Ouvrir le menu utilisateur", + "open_command_palette": "Ouvrir la palette de commandes", + "open_extended_sidebar": "Ouvrir la barre latérale étendue", + "close_extended_sidebar": "Fermer la barre latérale étendue", + "create_favorites_folder": "Créer un dossier de favoris", + "open_folder": "Ouvrir le dossier", + "close_folder": "Fermer le dossier", + "open_favorites_menu": "Ouvrir le menu des favoris", + "close_favorites_menu": "Fermer le menu des favoris", + "enter_folder_name": "Saisir le nom du dossier", + "create_new_project": "Créer un nouveau projet", + "open_projects_menu": "Ouvrir le menu des projets", + "close_projects_menu": "Fermer le menu des projets", + "toggle_quick_actions_menu": "Basculer le menu d'actions rapides", + "open_project_menu": "Ouvrir le menu du projet", + "close_project_menu": "Fermer le menu du projet", + "collapse_sidebar": "Réduire la barre latérale", + "expand_sidebar": "Étendre la barre latérale", + "edition_badge": "Ouvrir le modal des plans payants" + }, + "auth_forms": { + "clear_email": "Effacer l'e-mail", + "show_password": "Afficher le mot de passe", + "hide_password": "Masquer le mot de passe", + "close_alert": "Fermer l'alerte", + "close_popover": "Fermer la fenêtre contextuelle" + } + } +} diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json index 4f356f1491e..16f4a74e489 100644 --- a/packages/i18n/src/locales/fr/translations.json +++ b/packages/i18n/src/locales/fr/translations.json @@ -849,6 +849,7 @@ "live": "En direct", "change_history": "Historique des modifications", "coming_soon": "À venir", + "member": "Membre", "members": "Membres", "you": "Vous", "upgrade_cta": { diff --git a/packages/i18n/src/locales/id/accessibility.json b/packages/i18n/src/locales/id/accessibility.json new file mode 100644 index 00000000000..73207340152 --- /dev/null +++ b/packages/i18n/src/locales/id/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo ruang kerja", + "open_workspace_switcher": "Buka penukar ruang kerja", + "open_user_menu": "Buka menu pengguna", + "open_command_palette": "Buka palet perintah", + "open_extended_sidebar": "Buka sidebar diperluas", + "close_extended_sidebar": "Tutup sidebar diperluas", + "create_favorites_folder": "Buat folder favorit", + "open_folder": "Buka folder", + "close_folder": "Tutup folder", + "open_favorites_menu": "Buka menu favorit", + "close_favorites_menu": "Tutup menu favorit", + "enter_folder_name": "Masukkan nama folder", + "create_new_project": "Buat proyek baru", + "open_projects_menu": "Buka menu proyek", + "close_projects_menu": "Tutup menu proyek", + "toggle_quick_actions_menu": "Alihkan menu tindakan cepat", + "open_project_menu": "Buka menu proyek", + "close_project_menu": "Tutup menu proyek", + "collapse_sidebar": "Tutup sidebar", + "expand_sidebar": "Perluas sidebar", + "edition_badge": "Buka modal paket berbayar" + }, + "auth_forms": { + "clear_email": "Hapus email", + "show_password": "Tampilkan kata sandi", + "hide_password": "Sembunyikan kata sandi", + "close_alert": "Tutup peringatan", + "close_popover": "Tutup popover" + } + } +} diff --git a/packages/i18n/src/locales/id/translations.json b/packages/i18n/src/locales/id/translations.json index 20b683c65fe..577086dad11 100644 --- a/packages/i18n/src/locales/id/translations.json +++ b/packages/i18n/src/locales/id/translations.json @@ -848,6 +848,7 @@ "live": "Langsung", "change_history": "Riwayat Perubahan", "coming_soon": "Segera hadir", + "member": "Anggota", "members": "Anggota", "you": "Anda", "upgrade_cta": { diff --git a/packages/i18n/src/locales/it/accessibility.json b/packages/i18n/src/locales/it/accessibility.json new file mode 100644 index 00000000000..16d22bcbc10 --- /dev/null +++ b/packages/i18n/src/locales/it/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo dell'area di lavoro", + "open_workspace_switcher": "Apri selettore area di lavoro", + "open_user_menu": "Apri menu utente", + "open_command_palette": "Apri tavolozza comandi", + "open_extended_sidebar": "Apri barra laterale estesa", + "close_extended_sidebar": "Chiudi barra laterale estesa", + "create_favorites_folder": "Crea cartella preferiti", + "open_folder": "Apri cartella", + "close_folder": "Chiudi cartella", + "open_favorites_menu": "Apri menu preferiti", + "close_favorites_menu": "Chiudi menu preferiti", + "enter_folder_name": "Inserisci nome cartella", + "create_new_project": "Crea nuovo progetto", + "open_projects_menu": "Apri menu progetti", + "close_projects_menu": "Chiudi menu progetti", + "toggle_quick_actions_menu": "Attiva/disattiva menu azioni rapide", + "open_project_menu": "Apri menu progetto", + "close_project_menu": "Chiudi menu progetto", + "collapse_sidebar": "Comprimi barra laterale", + "expand_sidebar": "Espandi barra laterale", + "edition_badge": "Apri modal piani a pagamento" + }, + "auth_forms": { + "clear_email": "Cancella email", + "show_password": "Mostra password", + "hide_password": "Nascondi password", + "close_alert": "Chiudi avviso", + "close_popover": "Chiudi popover" + } + } +} diff --git a/packages/i18n/src/locales/it/translations.json b/packages/i18n/src/locales/it/translations.json index 5534d885c10..20abc2d165c 100644 --- a/packages/i18n/src/locales/it/translations.json +++ b/packages/i18n/src/locales/it/translations.json @@ -847,6 +847,7 @@ "live": "Live", "change_history": "Cronologia modifiche", "coming_soon": "Prossimamente", + "member": "Membro", "members": "Membri", "you": "Tu", "upgrade_cta": { diff --git a/packages/i18n/src/locales/ja/accessibility.json b/packages/i18n/src/locales/ja/accessibility.json new file mode 100644 index 00000000000..b983500ff1c --- /dev/null +++ b/packages/i18n/src/locales/ja/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "ワークスペースロゴ", + "open_workspace_switcher": "ワークスペーススイッチャーを開く", + "open_user_menu": "ユーザーメニューを開く", + "open_command_palette": "コマンドパレットを開く", + "open_extended_sidebar": "拡張サイドバーを開く", + "close_extended_sidebar": "拡張サイドバーを閉じる", + "create_favorites_folder": "お気に入りフォルダを作成", + "open_folder": "フォルダを開く", + "close_folder": "フォルダを閉じる", + "open_favorites_menu": "お気に入りメニューを開く", + "close_favorites_menu": "お気に入りメニューを閉じる", + "enter_folder_name": "フォルダ名を入力", + "create_new_project": "新しいプロジェクトを作成", + "open_projects_menu": "プロジェクトメニューを開く", + "close_projects_menu": "プロジェクトメニューを閉じる", + "toggle_quick_actions_menu": "クイックアクションメニューの切り替え", + "open_project_menu": "プロジェクトメニューを開く", + "close_project_menu": "プロジェクトメニューを閉じる", + "collapse_sidebar": "サイドバーを折りたたむ", + "expand_sidebar": "サイドバーを展開", + "edition_badge": "有料プランのモーダルを開く" + }, + "auth_forms": { + "clear_email": "メールをクリア", + "show_password": "パスワードを表示", + "hide_password": "パスワードを非表示", + "close_alert": "アラートを閉じる", + "close_popover": "ポップオーバーを閉じる" + } + } +} diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json index a6f36a65b5f..cd27d8bde73 100644 --- a/packages/i18n/src/locales/ja/translations.json +++ b/packages/i18n/src/locales/ja/translations.json @@ -849,6 +849,7 @@ "live": "ライブ", "change_history": "変更履歴", "coming_soon": "近日公開", + "member": "メンバー", "members": "メンバー", "you": "あなた", "upgrade_cta": { diff --git a/packages/i18n/src/locales/ko/accessibility.json b/packages/i18n/src/locales/ko/accessibility.json new file mode 100644 index 00000000000..298a7e122d8 --- /dev/null +++ b/packages/i18n/src/locales/ko/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "워크스페이스 로고", + "open_workspace_switcher": "워크스페이스 전환기 열기", + "open_user_menu": "사용자 메뉴 열기", + "open_command_palette": "명령 팔레트 열기", + "open_extended_sidebar": "확장된 사이드바 열기", + "close_extended_sidebar": "확장된 사이드바 닫기", + "create_favorites_folder": "즐겨찾기 폴더 생성", + "open_folder": "폴더 열기", + "close_folder": "폴더 닫기", + "open_favorites_menu": "즐겨찾기 메뉴 열기", + "close_favorites_menu": "즐겨찾기 메뉴 닫기", + "enter_folder_name": "폴더 이름 입력", + "create_new_project": "새 프로젝트 생성", + "open_projects_menu": "프로젝트 메뉴 열기", + "close_projects_menu": "프로젝트 메뉴 닫기", + "toggle_quick_actions_menu": "빠른 작업 메뉴 토글", + "open_project_menu": "프로젝트 메뉴 열기", + "close_project_menu": "프로젝트 메뉴 닫기", + "collapse_sidebar": "사이드바 축소", + "expand_sidebar": "사이드바 확장", + "edition_badge": "유료 플랜 모달 열기" + }, + "auth_forms": { + "clear_email": "이메일 지우기", + "show_password": "비밀번호 표시", + "hide_password": "비밀번호 숨기기", + "close_alert": "알림 닫기", + "close_popover": "팝오버 닫기" + } + } +} diff --git a/packages/i18n/src/locales/ko/translations.json b/packages/i18n/src/locales/ko/translations.json index 2858d729cb4..c7610b003a0 100644 --- a/packages/i18n/src/locales/ko/translations.json +++ b/packages/i18n/src/locales/ko/translations.json @@ -850,6 +850,7 @@ "live": "라이브", "change_history": "변경 기록", "coming_soon": "곧 출시", + "member": "멤버", "members": "멤버", "you": "나", "upgrade_cta": { diff --git a/packages/i18n/src/locales/pl/accessibility.json b/packages/i18n/src/locales/pl/accessibility.json new file mode 100644 index 00000000000..c1407911acd --- /dev/null +++ b/packages/i18n/src/locales/pl/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo obszaru roboczego", + "open_workspace_switcher": "Otwórz przełącznik obszaru roboczego", + "open_user_menu": "Otwórz menu użytkownika", + "open_command_palette": "Otwórz paletę poleceń", + "open_extended_sidebar": "Otwórz rozszerzoną pasek boczny", + "close_extended_sidebar": "Zamknij rozszerzoną pasek boczny", + "create_favorites_folder": "Utwórz folder ulubionych", + "open_folder": "Otwórz folder", + "close_folder": "Zamknij folder", + "open_favorites_menu": "Otwórz menu ulubionych", + "close_favorites_menu": "Zamknij menu ulubionych", + "enter_folder_name": "Wprowadź nazwę folderu", + "create_new_project": "Utwórz nowy projekt", + "open_projects_menu": "Otwórz menu projektów", + "close_projects_menu": "Zamknij menu projektów", + "toggle_quick_actions_menu": "Przełącz menu szybkich akcji", + "open_project_menu": "Otwórz menu projektu", + "close_project_menu": "Zamknij menu projektu", + "collapse_sidebar": "Zwiń pasek boczny", + "expand_sidebar": "Rozwiń pasek boczny", + "edition_badge": "Otwórz modal płatnych planów" + }, + "auth_forms": { + "clear_email": "Wyczyść e-mail", + "show_password": "Pokaż hasło", + "hide_password": "Ukryj hasło", + "close_alert": "Zamknij alert", + "close_popover": "Zamknij popover" + } + } +} diff --git a/packages/i18n/src/locales/pl/translations.json b/packages/i18n/src/locales/pl/translations.json index d11005833fd..06d9182de6c 100644 --- a/packages/i18n/src/locales/pl/translations.json +++ b/packages/i18n/src/locales/pl/translations.json @@ -850,6 +850,7 @@ "live": "Na żywo", "change_history": "Historia zmian", "coming_soon": "Wkrótce", + "member": "Członek", "members": "Członkowie", "you": "Ty", "upgrade_cta": { diff --git a/packages/i18n/src/locales/pt-BR/accessibility.json b/packages/i18n/src/locales/pt-BR/accessibility.json new file mode 100644 index 00000000000..de90eeb36d5 --- /dev/null +++ b/packages/i18n/src/locales/pt-BR/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo do espaço de trabalho", + "open_workspace_switcher": "Abrir seletor de espaço de trabalho", + "open_user_menu": "Abrir menu do usuário", + "open_command_palette": "Abrir paleta de comandos", + "open_extended_sidebar": "Abrir barra lateral estendida", + "close_extended_sidebar": "Fechar barra lateral estendida", + "create_favorites_folder": "Criar pasta de favoritos", + "open_folder": "Abrir pasta", + "close_folder": "Fechar pasta", + "open_favorites_menu": "Abrir menu de favoritos", + "close_favorites_menu": "Fechar menu de favoritos", + "enter_folder_name": "Digite o nome da pasta", + "create_new_project": "Criar novo projeto", + "open_projects_menu": "Abrir menu de projetos", + "close_projects_menu": "Fechar menu de projetos", + "toggle_quick_actions_menu": "Alternar menu de ações rápidas", + "open_project_menu": "Abrir menu do projeto", + "close_project_menu": "Fechar menu do projeto", + "collapse_sidebar": "Recolher barra lateral", + "expand_sidebar": "Expandir barra lateral", + "edition_badge": "Abrir modal de planos pagos" + }, + "auth_forms": { + "clear_email": "Limpar e-mail", + "show_password": "Mostrar senha", + "hide_password": "Ocultar senha", + "close_alert": "Fechar alerta", + "close_popover": "Fechar popover" + } + } +} diff --git a/packages/i18n/src/locales/pt-BR/translations.json b/packages/i18n/src/locales/pt-BR/translations.json index de630da9745..da2bcc7bbf5 100644 --- a/packages/i18n/src/locales/pt-BR/translations.json +++ b/packages/i18n/src/locales/pt-BR/translations.json @@ -850,6 +850,7 @@ "live": "Ao vivo", "change_history": "Histórico de alterações", "coming_soon": "Em breve", + "member": "Membro", "members": "Membros", "you": "Você", "upgrade_cta": { diff --git a/packages/i18n/src/locales/ro/accessibility.json b/packages/i18n/src/locales/ro/accessibility.json new file mode 100644 index 00000000000..52f55548157 --- /dev/null +++ b/packages/i18n/src/locales/ro/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo spațiu de lucru", + "open_workspace_switcher": "Deschide comutator spațiu de lucru", + "open_user_menu": "Deschide meniul utilizatorului", + "open_command_palette": "Deschide paleta de comenzi", + "open_extended_sidebar": "Deschide bara laterală extinsă", + "close_extended_sidebar": "Închide bara laterală extinsă", + "create_favorites_folder": "Creează folder de favorite", + "open_folder": "Deschide folderul", + "close_folder": "Închide folderul", + "open_favorites_menu": "Deschide meniul de favorite", + "close_favorites_menu": "Închide meniul de favorite", + "enter_folder_name": "Introduceți numele folderului", + "create_new_project": "Creează proiect nou", + "open_projects_menu": "Deschide meniul de proiecte", + "close_projects_menu": "Închide meniul de proiecte", + "toggle_quick_actions_menu": "Comută meniul de acțiuni rapide", + "open_project_menu": "Deschide meniul proiectului", + "close_project_menu": "Închide meniul proiectului", + "collapse_sidebar": "Restrânge bara laterală", + "expand_sidebar": "Extinde bara laterală", + "edition_badge": "Deschide modalul planurilor plătite" + }, + "auth_forms": { + "clear_email": "Șterge e-mailul", + "show_password": "Afișează parola", + "hide_password": "Ascunde parola", + "close_alert": "Închide alerta", + "close_popover": "Închide popover-ul" + } + } +} diff --git a/packages/i18n/src/locales/ro/translations.json b/packages/i18n/src/locales/ro/translations.json index f60a4881b55..04ae61e6b27 100644 --- a/packages/i18n/src/locales/ro/translations.json +++ b/packages/i18n/src/locales/ro/translations.json @@ -848,6 +848,7 @@ "live": "În direct", "change_history": "Istoric modificări", "coming_soon": "În curând", + "member": "Membru", "members": "Membri", "you": "Tu", "upgrade_cta": { diff --git a/packages/i18n/src/locales/ru/accessibility.json b/packages/i18n/src/locales/ru/accessibility.json new file mode 100644 index 00000000000..dd4dde76b14 --- /dev/null +++ b/packages/i18n/src/locales/ru/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Логотип рабочей области", + "open_workspace_switcher": "Открыть переключатель рабочей области", + "open_user_menu": "Открыть пользовательское меню", + "open_command_palette": "Открыть палитру команд", + "open_extended_sidebar": "Открыть расширенную боковую панель", + "close_extended_sidebar": "Закрыть расширенную боковую панель", + "create_favorites_folder": "Создать папку избранного", + "open_folder": "Открыть папку", + "close_folder": "Закрыть папку", + "open_favorites_menu": "Открыть меню избранного", + "close_favorites_menu": "Закрыть меню избранного", + "enter_folder_name": "Введите имя папки", + "create_new_project": "Создать новый проект", + "open_projects_menu": "Открыть меню проектов", + "close_projects_menu": "Закрыть меню проектов", + "toggle_quick_actions_menu": "Переключить меню быстрых действий", + "open_project_menu": "Открыть меню проекта", + "close_project_menu": "Закрыть меню проекта", + "collapse_sidebar": "Свернуть боковую панель", + "expand_sidebar": "Развернуть боковую панель", + "edition_badge": "Открыть модал платных планов" + }, + "auth_forms": { + "clear_email": "Очистить email", + "show_password": "Показать пароль", + "hide_password": "Скрыть пароль", + "close_alert": "Закрыть уведомление", + "close_popover": "Закрыть всплывающее окно" + } + } +} diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index 564716529d2..e6678186831 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -850,6 +850,7 @@ "live": "В прямом эфире", "change_history": "История изменений", "coming_soon": "Скоро", + "member": "Участник", "members": "Участники", "you": "Вы", "upgrade_cta": { diff --git a/packages/i18n/src/locales/sk/accessibility.json b/packages/i18n/src/locales/sk/accessibility.json new file mode 100644 index 00000000000..26c5c8be6fe --- /dev/null +++ b/packages/i18n/src/locales/sk/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo pracovného priestoru", + "open_workspace_switcher": "Otvoriť prepínač pracovného priestoru", + "open_user_menu": "Otvoriť používateľské menu", + "open_command_palette": "Otvoriť paletu príkazov", + "open_extended_sidebar": "Otvoriť rozšírený bočný panel", + "close_extended_sidebar": "Zavrieť rozšírený bočný panel", + "create_favorites_folder": "Vytvoriť priečinok obľúbených", + "open_folder": "Otvoriť priečinok", + "close_folder": "Zavrieť priečinok", + "open_favorites_menu": "Otvoriť menu obľúbených", + "close_favorites_menu": "Zavrieť menu obľúbených", + "enter_folder_name": "Zadajte názov priečinka", + "create_new_project": "Vytvoriť nový projekt", + "open_projects_menu": "Otvoriť menu projektov", + "close_projects_menu": "Zavrieť menu projektov", + "toggle_quick_actions_menu": "Prepnúť menu rýchlych akcií", + "open_project_menu": "Otvoriť menu projektu", + "close_project_menu": "Zavrieť menu projektu", + "collapse_sidebar": "Zbaliť bočný panel", + "expand_sidebar": "Rozbaliť bočný panel", + "edition_badge": "Otvoriť modal platených plánov" + }, + "auth_forms": { + "clear_email": "Vymazať e-mail", + "show_password": "Zobraziť heslo", + "hide_password": "Skryť heslo", + "close_alert": "Zavrieť upozornenie", + "close_popover": "Zavrieť vyskakovacie okno" + } + } +} diff --git a/packages/i18n/src/locales/sk/translations.json b/packages/i18n/src/locales/sk/translations.json index 60f2c21ca86..019bbba8699 100644 --- a/packages/i18n/src/locales/sk/translations.json +++ b/packages/i18n/src/locales/sk/translations.json @@ -850,6 +850,7 @@ "live": "Živé", "change_history": "História zmien", "coming_soon": "Už čoskoro", + "member": "Člen", "members": "Členovia", "you": "Vy", "upgrade_cta": { diff --git a/packages/i18n/src/locales/tr-TR/accessibility.json b/packages/i18n/src/locales/tr-TR/accessibility.json new file mode 100644 index 00000000000..80a35611c2d --- /dev/null +++ b/packages/i18n/src/locales/tr-TR/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Çalışma alanı logosu", + "open_workspace_switcher": "Çalışma alanı değiştiricisini aç", + "open_user_menu": "Kullanıcı menüsünü aç", + "open_command_palette": "Komut paletini aç", + "open_extended_sidebar": "Genişletilmiş kenar çubuğunu aç", + "close_extended_sidebar": "Genişletilmiş kenar çubuğunu kapat", + "create_favorites_folder": "Favoriler klasörü oluştur", + "open_folder": "Klasörü aç", + "close_folder": "Klasörü kapat", + "open_favorites_menu": "Favoriler menüsünü aç", + "close_favorites_menu": "Favoriler menüsünü kapat", + "enter_folder_name": "Klasör adını girin", + "create_new_project": "Yeni proje oluştur", + "open_projects_menu": "Projeler menüsünü aç", + "close_projects_menu": "Projeler menüsünü kapat", + "toggle_quick_actions_menu": "Hızlı eylemler menüsünü aç/kapat", + "open_project_menu": "Proje menüsünü aç", + "close_project_menu": "Proje menüsünü kapat", + "collapse_sidebar": "Kenar çubuğunu daralt", + "expand_sidebar": "Kenar çubuğunu genişlet", + "edition_badge": "Ücretli planlar modalını aç" + }, + "auth_forms": { + "clear_email": "E-postayı temizle", + "show_password": "Şifreyi göster", + "hide_password": "Şifreyi gizle", + "close_alert": "Uyarıyı kapat", + "close_popover": "Açılır pencereyi kapat" + } + } +} diff --git a/packages/i18n/src/locales/tr-TR/translations.json b/packages/i18n/src/locales/tr-TR/translations.json index cec11a99217..1be2b8b7414 100644 --- a/packages/i18n/src/locales/tr-TR/translations.json +++ b/packages/i18n/src/locales/tr-TR/translations.json @@ -851,6 +851,7 @@ "live": "Canlı", "change_history": "Değişiklik Geçmişi", "coming_soon": "Çok Yakında", + "member": "Üye", "members": "Üyeler", "you": "Siz", "upgrade_cta": { diff --git a/packages/i18n/src/locales/ua/accessibility.json b/packages/i18n/src/locales/ua/accessibility.json new file mode 100644 index 00000000000..42766731214 --- /dev/null +++ b/packages/i18n/src/locales/ua/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Логотип робочого простору", + "open_workspace_switcher": "Відкрити перемикач робочого простору", + "open_user_menu": "Відкрити меню користувача", + "open_command_palette": "Відкрити палітру команд", + "open_extended_sidebar": "Відкрити розширену бічну панель", + "close_extended_sidebar": "Закрити розширену бічну панель", + "create_favorites_folder": "Створити папку улюблених", + "open_folder": "Відкрити папку", + "close_folder": "Закрити папку", + "open_favorites_menu": "Відкрити меню улюблених", + "close_favorites_menu": "Закрити меню улюблених", + "enter_folder_name": "Введіть назву папки", + "create_new_project": "Створити новий проект", + "open_projects_menu": "Відкрити меню проектів", + "close_projects_menu": "Закрити меню проектів", + "toggle_quick_actions_menu": "Перемкнути меню швидких дій", + "open_project_menu": "Відкрити меню проекту", + "close_project_menu": "Закрити меню проекту", + "collapse_sidebar": "Згорнути бічну панель", + "expand_sidebar": "Розгорнути бічну панель", + "edition_badge": "Відкрити модал платних планів" + }, + "auth_forms": { + "clear_email": "Очистити email", + "show_password": "Показати пароль", + "hide_password": "Приховати пароль", + "close_alert": "Закрити сповіщення", + "close_popover": "Закрити спливаюче вікно" + } + } +} diff --git a/packages/i18n/src/locales/ua/translations.json b/packages/i18n/src/locales/ua/translations.json index 2a82df68f5e..0dd0161cd4d 100644 --- a/packages/i18n/src/locales/ua/translations.json +++ b/packages/i18n/src/locales/ua/translations.json @@ -850,6 +850,7 @@ "live": "Наживо", "change_history": "Історія змін", "coming_soon": "Незабаром", + "member": "Учасник", "members": "Учасники", "you": "Ви", "upgrade_cta": { diff --git a/packages/i18n/src/locales/vi-VN/accessibility.json b/packages/i18n/src/locales/vi-VN/accessibility.json new file mode 100644 index 00000000000..b3ab93530e0 --- /dev/null +++ b/packages/i18n/src/locales/vi-VN/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo không gian làm việc", + "open_workspace_switcher": "Mở trình chuyển đổi không gian làm việc", + "open_user_menu": "Mở menu người dùng", + "open_command_palette": "Mở bảng lệnh", + "open_extended_sidebar": "Mở thanh bên mở rộng", + "close_extended_sidebar": "Đóng thanh bên mở rộng", + "create_favorites_folder": "Tạo thư mục yêu thích", + "open_folder": "Mở thư mục", + "close_folder": "Đóng thư mục", + "open_favorites_menu": "Mở menu yêu thích", + "close_favorites_menu": "Đóng menu yêu thích", + "enter_folder_name": "Nhập tên thư mục", + "create_new_project": "Tạo dự án mới", + "open_projects_menu": "Mở menu dự án", + "close_projects_menu": "Đóng menu dự án", + "toggle_quick_actions_menu": "Bật/tắt menu hành động nhanh", + "open_project_menu": "Mở menu dự án", + "close_project_menu": "Đóng menu dự án", + "collapse_sidebar": "Thu gọn thanh bên", + "expand_sidebar": "Mở rộng thanh bên", + "edition_badge": "Mở modal gói trả phí" + }, + "auth_forms": { + "clear_email": "Xóa email", + "show_password": "Hiển thị mật khẩu", + "hide_password": "Ẩn mật khẩu", + "close_alert": "Đóng cảnh báo", + "close_popover": "Đóng popover" + } + } +} diff --git a/packages/i18n/src/locales/vi-VN/translations.json b/packages/i18n/src/locales/vi-VN/translations.json index 418d96ac43e..3f9158b9d61 100644 --- a/packages/i18n/src/locales/vi-VN/translations.json +++ b/packages/i18n/src/locales/vi-VN/translations.json @@ -849,6 +849,7 @@ "live": "Trực tiếp", "change_history": "Lịch sử thay đổi", "coming_soon": "Sắp ra mắt", + "member": "Thành viên", "members": "Thành viên", "you": "Bạn", "upgrade_cta": { diff --git a/packages/i18n/src/locales/zh-CN/accessibility.json b/packages/i18n/src/locales/zh-CN/accessibility.json new file mode 100644 index 00000000000..fea84d06373 --- /dev/null +++ b/packages/i18n/src/locales/zh-CN/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "工作空间徽标", + "open_workspace_switcher": "打开工作空间切换器", + "open_user_menu": "打开用户菜单", + "open_command_palette": "打开命令面板", + "open_extended_sidebar": "打开扩展侧边栏", + "close_extended_sidebar": "关闭扩展侧边栏", + "create_favorites_folder": "创建收藏夹文件夹", + "open_folder": "打开文件夹", + "close_folder": "关闭文件夹", + "open_favorites_menu": "打开收藏夹菜单", + "close_favorites_menu": "关闭收藏夹菜单", + "enter_folder_name": "输入文件夹名称", + "create_new_project": "创建新项目", + "open_projects_menu": "打开项目菜单", + "close_projects_menu": "关闭项目菜单", + "toggle_quick_actions_menu": "切换快速操作菜单", + "open_project_menu": "打开项目菜单", + "close_project_menu": "关闭项目菜单", + "collapse_sidebar": "折叠侧边栏", + "expand_sidebar": "展开侧边栏", + "edition_badge": "打开付费计划模态框" + }, + "auth_forms": { + "clear_email": "清除邮箱", + "show_password": "显示密码", + "hide_password": "隐藏密码", + "close_alert": "关闭警告", + "close_popover": "关闭弹出框" + } + } +} diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index 8f8ca2d26fa..d2fa34d6efd 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -849,6 +849,7 @@ "live": "实时", "change_history": "变更历史", "coming_soon": "即将推出", + "member": "成员", "members": "成员", "you": "你", "upgrade_cta": { diff --git a/packages/i18n/src/locales/zh-TW/accessibility.json b/packages/i18n/src/locales/zh-TW/accessibility.json new file mode 100644 index 00000000000..75747f86124 --- /dev/null +++ b/packages/i18n/src/locales/zh-TW/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "工作空間標誌", + "open_workspace_switcher": "打開工作空間切換器", + "open_user_menu": "打開用戶選單", + "open_command_palette": "打開命令面板", + "open_extended_sidebar": "打開擴展側邊欄", + "close_extended_sidebar": "關閉擴展側邊欄", + "create_favorites_folder": "創建收藏夾文件夾", + "open_folder": "打開文件夾", + "close_folder": "關閉文件夾", + "open_favorites_menu": "打開收藏夾選單", + "close_favorites_menu": "關閉收藏夾選單", + "enter_folder_name": "輸入文件夾名稱", + "create_new_project": "創建新項目", + "open_projects_menu": "打開項目選單", + "close_projects_menu": "關閉項目選單", + "toggle_quick_actions_menu": "切換快速操作選單", + "open_project_menu": "打開項目選單", + "close_project_menu": "關閉項目選單", + "collapse_sidebar": "摺疊側邊欄", + "expand_sidebar": "展開側邊欄", + "edition_badge": "打開付費計劃模態框" + }, + "auth_forms": { + "clear_email": "清除電子郵件", + "show_password": "顯示密碼", + "hide_password": "隱藏密碼", + "close_alert": "關閉警告", + "close_popover": "關閉彈出框" + } + } +} diff --git a/packages/i18n/src/locales/zh-TW/translations.json b/packages/i18n/src/locales/zh-TW/translations.json index 472ba631c1d..6741ac4b42d 100644 --- a/packages/i18n/src/locales/zh-TW/translations.json +++ b/packages/i18n/src/locales/zh-TW/translations.json @@ -850,6 +850,7 @@ "live": "即時", "change_history": "變更歷史記錄", "coming_soon": "即將推出", + "member": "成員", "members": "成員", "you": "您", "upgrade_cta": { diff --git a/packages/i18n/src/store/index.ts b/packages/i18n/src/store/index.ts index ff4cee10762..c75d7b8a324 100644 --- a/packages/i18n/src/store/index.ts +++ b/packages/i18n/src/store/index.ts @@ -3,7 +3,7 @@ import get from "lodash/get"; import merge from "lodash/merge"; import { makeAutoObservable, runInAction } from "mobx"; // constants -import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY } from "../constants"; +import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY, ETranslationFiles } from "../constants"; // core translations imports import coreEn from "../locales/en/core.json"; // types @@ -130,54 +130,32 @@ export class TranslationStore { } } + /** + * Helper function to import and merge multiple translation files for a language + * @param language - The language code + * @param files - Array of file names to import (without .json extension) + * @returns Promise that resolves to merged translations + */ + private async importAndMergeFiles(language: TLanguage, files: string[]): Promise { + try { + const importPromises = files.map((file) => import(`../locales/${language}/${file}.json`)); + + const modules = await Promise.all(importPromises); + const merged = modules.reduce((acc, module) => merge(acc, module.default), {}); + return { default: merged }; + } catch (error) { + throw new Error(`Failed to import and merge files for ${language}: ${error}`); + } + } + /** * Imports the translations for the given language * @param language - The language to import the translations for * @returns {Promise} */ - private importLanguageFile(language: TLanguage): Promise { - switch (language) { - case "en": - return import("../locales/en/translations.json"); - case "fr": - return import("../locales/fr/translations.json"); - case "es": - return import("../locales/es/translations.json"); - case "ja": - return import("../locales/ja/translations.json"); - case "zh-CN": - return import("../locales/zh-CN/translations.json"); - case "zh-TW": - return import("../locales/zh-TW/translations.json"); - case "ru": - return import("../locales/ru/translations.json"); - case "it": - return import("../locales/it/translations.json"); - case "cs": - return import("../locales/cs/translations.json"); - case "sk": - return import("../locales/sk/translations.json"); - case "de": - return import("../locales/de/translations.json"); - case "ua": - return import("../locales/ua/translations.json"); - case "pl": - return import("../locales/pl/translations.json"); - case "ko": - return import("../locales/ko/translations.json"); - case "pt-BR": - return import("../locales/pt-BR/translations.json"); - case "id": - return import("../locales/id/translations.json"); - case "ro": - return import("../locales/ro/translations.json"); - case "vi-VN": - return import("../locales/vi-VN/translations.json"); - case "tr-TR": - return import("../locales/tr-TR/translations.json"); - default: - throw new Error(`Unsupported language: ${language}`); - } + private async importLanguageFile(language: TLanguage): Promise { + const files = Object.values(ETranslationFiles); + return this.importAndMergeFiles(language, files); } /** Checks if the language is valid based on the supported languages */ diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json index 0f17d7936f8..bbc9398c989 100644 --- a/packages/tailwind-config/package.json +++ b/packages/tailwind-config/package.json @@ -6,6 +6,7 @@ "main": "tailwind.config.js", "private": true, "devDependencies": { + "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/typography": "^0.5.9", "autoprefixer": "^10.4.14", "postcss": "^8.4.38", diff --git a/packages/tailwind-config/tailwind.config.js b/packages/tailwind-config/tailwind.config.js index 5beff4bf831..168c54e621e 100644 --- a/packages/tailwind-config/tailwind.config.js +++ b/packages/tailwind-config/tailwind.config.js @@ -469,6 +469,7 @@ module.exports = { plugins: [ require("tailwindcss-animate"), require("@tailwindcss/typography"), + require("@tailwindcss/container-queries"), function ({ addUtilities }) { const newUtilities = { // Mobile screens diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index e1d9117a1be..ae1897d3071 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -1,14 +1,5 @@ import { EUserProjectRoles } from "@plane/constants"; -import type { - IProjectViewProps, - IUser, - IUserLite, - IUserMemberLite, - IWorkspace, - IWorkspaceLite, - TLogoProps, - TStateGroups, -} from ".."; +import type { IUser, IUserLite, IWorkspace, TLogoProps, TStateGroups } from ".."; import { TUserPermissions } from "../enums"; export interface IPartialProject { @@ -91,30 +82,20 @@ export interface IProjectMemberLite { member_id: string; } -export interface IProjectMember { - id: string; - member: IUserMemberLite; - project: IProjectLite; - workspace: IWorkspaceLite; - comment: string; - role: TUserPermissions; - - preferences: ProjectPreferences; - - view_props: IProjectViewProps; - default_props: IProjectViewProps; - - created_at: Date; - updated_at: Date; - created_by: string; - updated_by: string; -} - -export interface IProjectMembership { - id: string; +export type TProjectMembership = { member: string; - role: TUserPermissions; -} + role: TUserPermissions | EUserProjectRoles; + created_at: string; +} & ( + | { + id: string; + original_role: EUserProjectRoles; + } + | { + id: null; + original_role: null; + } +); export interface IProjectBulkAddFormData { members: { role: TUserPermissions | EUserProjectRoles; member_id: string }[]; diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 9f6ac490559..7694c240646 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -12,6 +12,7 @@ export interface IUserLite { id: string; is_bot: boolean; last_name: string; + joining_date?: string; } export interface IUser extends IUserLite { // only for uploading the cover image @@ -78,6 +79,8 @@ export interface IUserSettings { workspace: { last_workspace_id: string | undefined; last_workspace_slug: string | undefined; + last_workspace_name: string | undefined; + last_workspace_logo: string | undefined; fallback_workspace_id: string | undefined; fallback_workspace_slug: string | undefined; invites: number | undefined; diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index 4393a911f6d..0b81e3b9103 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -1,4 +1,4 @@ -import type { ICycle, IProjectMember, IUser, IUserLite, IWorkspaceViewProps, TPaginationInfo } from "@plane/types"; +import type { ICycle, TProjectMembership, IUser, IUserLite, IWorkspaceViewProps, TPaginationInfo } from "@plane/types"; import { EUserWorkspaceRoles } from "@plane/constants"; // TODO: check if importing this over here causes circular dependency import { TUserPermissions } from "./enums"; @@ -93,7 +93,7 @@ export interface IWorkspaceMemberMe { export interface ILastActiveWorkspaceDetails { workspace_details: IWorkspace; - project_details?: IProjectMember[]; + project_details?: TProjectMembership[]; } export interface IWorkspaceDefaultSearchResult { diff --git a/packages/ui/package.json b/packages/ui/package.json index 45550f36979..2581999f362 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -71,7 +71,7 @@ "postcss-cli": "^11.0.0", "postcss-nested": "^6.0.1", "storybook": "^8.1.1", - "tsup": "^8.4.0", + "tsup": "8.4.0", "typescript": "5.3.3" } } diff --git a/packages/ui/src/avatar/avatar.tsx b/packages/ui/src/avatar/avatar.tsx index 0c57ccebabb..84a8ab89558 100644 --- a/packages/ui/src/avatar/avatar.tsx +++ b/packages/ui/src/avatar/avatar.tsx @@ -160,7 +160,7 @@ export const Avatar: React.FC = (props) => { color: fallbackTextColor ?? "#ffffff", }} > - {name ? name[0].toUpperCase() : fallbackText ?? "?"} + {name?.[0]?.toUpperCase() ?? fallbackText ?? "?"} )} diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 24c8a106a08..688f1489749 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -14,6 +14,7 @@ import { ICustomMenuDropdownProps, ICustomMenuItemProps } from "./helper"; const CustomMenu = (props: ICustomMenuDropdownProps) => { const { + ariaLabel, buttonClassName = "", customButtonClassName = "", customButtonTabIndex = 0, @@ -75,7 +76,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { e.stopPropagation(); e.preventDefault(); isOpen ? closeDropdown() : openDropdown(); - if (menuButtonOnClick) menuButtonOnClick(); + menuButtonOnClick?.(); }; const handleMouseEnter = () => { @@ -147,6 +148,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { className={customButtonClassName} tabIndex={customButtonTabIndex} disabled={disabled} + aria-label={ariaLabel} > {customButton} @@ -164,6 +166,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} tabIndex={customButtonTabIndex} + aria-label={ariaLabel} > @@ -183,6 +186,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { onClick={handleMenuButtonClick} tabIndex={customButtonTabIndex} disabled={disabled} + aria-label={ariaLabel} > {label} {!noChevron && } diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 0e758705110..1d40acef795 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -32,6 +32,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps { closeOnSelect?: boolean; portalElement?: Element | null; openOnHover?: boolean; + ariaLabel?: string; } export interface ICustomSelectProps extends IDropdownProps { diff --git a/packages/utils/package.json b/packages/utils/package.json index fc8600077d8..ea5027a1e6d 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -4,14 +4,10 @@ "description": "Helper functions shared across multiple apps internally", "license": "AGPL-3.0", "private": true, - "main": "./dist/index.js", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", - "files": [ - "dist/**" - ], + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", "scripts": { - "build": "tsup ./src/index.ts --format esm,cjs --dts --external react --minify", "lint": "eslint src --ext .ts,.tsx", "lint:errors": "eslint src --ext .ts,.tsx --quiet" }, @@ -29,7 +25,6 @@ "@types/node": "^22.5.4", "@types/react": "^18.3.11", "@types/zxcvbn": "^4.4.5", - "tsup": "^8.4.0", "typescript": "^5.3.3" } } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 495d065dfb8..30f06b8c4cf 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,19 +1,17 @@ export * from "./array"; export * from "./attachment"; export * from "./auth"; -export * from "./datetime"; export * from "./color"; export * from "./common"; export * from "./datetime"; export * from "./emoji"; export * from "./file"; +export * from "./get-icon-for-link"; export * from "./issue"; +export * from "./permission"; export * from "./state"; export * from "./string"; +export * from "./subscription"; export * from "./theme"; -export * from "./workspace"; export * from "./work-item"; - -export * from "./get-icon-for-link"; - -export * from "./subscription"; +export * from "./workspace"; \ No newline at end of file diff --git a/packages/utils/src/permission.ts b/packages/utils/src/permission.ts new file mode 100644 index 00000000000..78105000594 --- /dev/null +++ b/packages/utils/src/permission.ts @@ -0,0 +1,13 @@ +import { EUserPermissions, EUserProjectRoles, EUserWorkspaceRoles } from "@plane/constants"; + +type TSupportedRole = EUserPermissions | EUserProjectRoles | EUserWorkspaceRoles; + +/** + * @description Returns the highest role from an array of supported roles + * @param { TSupportedRole[] } roles + * @returns { TSupportedRole | undefined } + */ +export const getHighestRole = (roles: T[]): T | undefined => { + if (!roles || roles.length === 0) return undefined; + return roles.reduce((highest, current) => (current > highest ? current : highest)); +}; diff --git a/space/helpers/editor.helper.ts b/space/helpers/editor.helper.ts index 5126b99c7b2..cde842c7251 100644 --- a/space/helpers/editor.helper.ts +++ b/space/helpers/editor.helper.ts @@ -29,6 +29,7 @@ export const getReadOnlyEditorFileHandlers = (args: Pick true, getAssetSrc: async (path) => { if (!path) return ""; if (path?.startsWith("http")) { diff --git a/space/package.json b/space/package.json index 4cccd65d120..76f030ea8ef 100644 --- a/space/package.json +++ b/space/package.json @@ -37,7 +37,7 @@ "mobx": "^6.10.0", "mobx-react": "^9.1.1", "mobx-utils": "^6.0.8", - "next": "^14.2.28", + "next": "^14.2.29", "next-themes": "^0.2.1", "nprogress": "^0.2.0", "react": "^18.3.1", diff --git a/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx index e0003eeba5a..baa41eb9fb9 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx @@ -96,7 +96,7 @@ export const ExtendedAppSidebar = observer(() => { useExtendedSidebarOutsideClickDetector( extendedSidebarRef, - () => toggleExtendedSidebar(false), + () => toggleExtendedSidebar(true), "extended-sidebar-toggle" ); @@ -106,8 +106,8 @@ export const ExtendedAppSidebar = observer(() => { className={cn( "absolute top-0 h-full z-[19] flex flex-col gap-0.5 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md pb-6", { - "translate-x-0 opacity-100 pointer-events-auto": extendedSidebarCollapsed, - "-translate-x-full opacity-0 pointer-events-none": !extendedSidebarCollapsed, + "-translate-x-full opacity-0 pointer-events-none": extendedSidebarCollapsed, + "translate-x-0 opacity-100 pointer-events-auto": !extendedSidebarCollapsed, "left-[70px]": sidebarCollapsed, "left-[250px]": !sidebarCollapsed, } diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx index 5dfcd25f9de..e23396f4df5 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx @@ -69,7 +69,7 @@ const ProjectCyclesPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.cycle.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !hasAdminLevelPermission, }} diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx index 5cb8509e0a3..5b6ad8b5f1f 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx @@ -42,7 +42,7 @@ const ProjectInboxPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.inbox.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !canPerformEmptyStateActions, }} diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx index 572cb3862f7..7c62a4d51ae 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx @@ -61,7 +61,7 @@ const ProjectModulesPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.module.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !canPerformEmptyStateActions, }} diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index 56d89d7f452..270faf985de 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -54,7 +54,7 @@ const ProjectPagesPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.page.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !canPerformEmptyStateActions, }} diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx deleted file mode 100644 index 221ecf44288..00000000000 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { FC, ReactNode } from "react"; -// components -import { AppHeader } from "@/components/core"; -// local components -import { ProjectSettingHeader } from "../header"; -import { ProjectSettingsSidebar } from "./sidebar"; - -export interface IProjectSettingLayout { - children: ReactNode; -} - -const ProjectSettingLayout: FC = (props) => { - const { children } = props; - return ( - <> - } /> -
-
- -
-
-
- {children} -
-
-
- - ); -}; - -export default ProjectSettingLayout; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx deleted file mode 100644 index 9deaef126ad..00000000000 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { observer } from "mobx-react"; -// components -import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { NotAuthorizedView } from "@/components/auth-screens"; -import { PageHead } from "@/components/core"; -import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project"; -// hooks -import { useProject, useUserPermissions } from "@/hooks/store"; - -const MembersSettingsPage = observer(() => { - // store - const { currentProjectDetails } = useProject(); - const { workspaceUserInfo, allowPermissions } = useUserPermissions(); - // derived values - const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined; - const isProjectMemberOrAdmin = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT - ); - const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); - const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin; - - if (workspaceUserInfo && !canPerformProjectMemberActions) { - return ; - } - - return ( - <> - -
- - -
- - ); -}); - -export default MembersSettingsPage; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx deleted file mode 100644 index 7bb1984c89f..00000000000 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import React from "react"; -import range from "lodash/range"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams, usePathname } from "next/navigation"; -import { EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -// ui -import { Loader } from "@plane/ui"; -// components -import { SidebarNavItem } from "@/components/sidebar"; -// hooks -import { useUserPermissions } from "@/hooks/store"; -// plane web constants -import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; - -export const ProjectSettingsSidebar = observer(() => { - const { workspaceSlug, projectId } = useParams(); - const pathname = usePathname(); - // mobx store - const { allowPermissions, projectUserInfo } = useUserPermissions(); - - const { t } = useTranslation(); - - // derived values - const currentProjectRole = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()]?.role; - - if (!currentProjectRole) { - return ( -
-
- SETTINGS - - {range(8).map((index) => ( - - ))} - -
-
- ); - } - - return ( -
-
- SETTINGS -
- {PROJECT_SETTINGS_LINKS.map( - (link) => - allowPermissions( - link.access, - EUserPermissionsLevel.PROJECT, - workspaceSlug.toString(), - projectId.toString() - ) && ( - - - {t(link.i18n_label)} - - - ) - )} -
-
-
- ); -}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx deleted file mode 100644 index 6fa36db34e1..00000000000 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// ui -import { Settings } from "lucide-react"; -import { EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { Breadcrumbs, CustomMenu, Header } from "@plane/ui"; -// components -import { BreadcrumbLink } from "@/components/common"; -// hooks -import { useProject, useUserPermissions } from "@/hooks/store"; -import { useAppRouter } from "@/hooks/use-app-router"; -// plane web -import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; -import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; - -export const ProjectSettingHeader: FC = observer(() => { - // router - const router = useAppRouter(); - const { workspaceSlug, projectId } = useParams(); - // store hooks - const { allowPermissions } = useUserPermissions(); - const { loader } = useProject(); - - const { t } = useTranslation(); - - return ( -
- -
-
- - -
- } /> - } - /> -
-
-
-
- - Settings - - } - placement="bottom-start" - closeOnSelect - > - {PROJECT_SETTINGS_LINKS.map( - (item) => - allowPermissions( - item.access, - EUserPermissionsLevel.PROJECT, - workspaceSlug.toString(), - projectId.toString() - ) && ( - router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)} - > - {t(item.i18n_label)} - - ) - )} - -
-
- ); -}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx index d4a3051ec94..4e21defc93d 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx @@ -68,7 +68,7 @@ const ProjectViewsPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.view.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !canPerformEmptyStateActions, }} diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx deleted file mode 100644 index e51106bfe5f..00000000000 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { FC, ReactNode } from "react"; -import { observer } from "mobx-react"; -// components -import { useParams, usePathname } from "next/navigation"; -import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants"; -import { NotAuthorizedView } from "@/components/auth-screens"; -import { AppHeader } from "@/components/core"; -// hooks -import { useUserPermissions } from "@/hooks/store"; -// plane web constants -// local components -import { WorkspaceSettingHeader } from "../header"; -import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs"; -import { WorkspaceSettingsSidebar } from "./sidebar"; - -export interface IWorkspaceSettingLayout { - children: ReactNode; -} - -const WorkspaceSettingLayout: FC = observer((props) => { - const { children } = props; - - const { workspaceUserInfo } = useUserPermissions(); - const pathname = usePathname(); - const [workspaceSlug, suffix, route] = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes - - // derived values - const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role; - const isAuthorized = - pathname && - workspaceSlug && - userWorkspaceRole && - WORKSPACE_SETTINGS_ACCESS[route ? `/${suffix}/${route}` : `/${suffix}`]?.includes( - userWorkspaceRole as EUserWorkspaceRoles - ); - - return ( - <> - } /> - -
- {workspaceUserInfo && !isAuthorized ? ( - - ) : ( - <> -
- -
-
-
- {children} -
-
- - )} -
- - ); -}); - -export default WorkspaceSettingLayout; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx deleted file mode 100644 index 95cb20c6cd6..00000000000 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import React from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams, usePathname } from "next/navigation"; -import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -// components -import { SidebarNavItem } from "@/components/sidebar"; -// hooks -import { useUserPermissions } from "@/hooks/store"; -// plane web helpers -import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; - -export const WorkspaceSettingsSidebar = observer(() => { - // router - const { workspaceSlug } = useParams(); - const pathname = usePathname(); - // mobx store - const { t } = useTranslation(); - const { allowPermissions } = useUserPermissions(); - - return ( -
-
- {t("settings")} -
- {WORKSPACE_SETTINGS_LINKS.map( - (link) => - shouldRenderSettingLink(workspaceSlug.toString(), link.key) && - allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && ( - - - {t(link.i18n_label)} - - - ) - )} -
-
-
- ); -}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/header.tsx deleted file mode 100644 index 003e7274319..00000000000 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/header.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react"; -import { Settings } from "lucide-react"; -// ui -import { useTranslation } from "@plane/i18n"; -import { Breadcrumbs, Header } from "@plane/ui"; -// components -import { BreadcrumbLink } from "@/components/common"; -// hooks -import { useWorkspace } from "@/hooks/store"; - -export const WorkspaceSettingHeader: FC = observer(() => { - const { currentWorkspace, loader } = useWorkspace(); - const { t } = useTranslation(); - - return ( -
- - - } - /> - } - /> - } /> - - -
- ); -}); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx b/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx new file mode 100644 index 00000000000..d05da405839 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { CommandPalette } from "@/components/command-palette"; +import { ContentWrapper } from "@/components/core"; +import { SettingsContentLayout, SettingsHeader } from "@/components/settings"; +import { AuthenticationWrapper } from "@/lib/wrappers"; +import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; + +export default function SettingsLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
+ {/* Header */} + + {/* Content */} + + {children} + +
+
+
+ ); +} diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx similarity index 83% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx index 801b04b3783..0286476694c 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx @@ -6,6 +6,7 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useUserPermissions, useWorkspace } from "@/hooks/store"; // plane web components import { BillingRoot } from "@/plane-web/components/workspace"; @@ -19,14 +20,14 @@ const BillingSettingsPage = observer(() => { const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Billing & Plans` : undefined; if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { - return ; + return ; } return ( - <> + - + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx similarity index 75% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx index 1b63406de34..9f08259c6e7 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx @@ -8,6 +8,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import ExportGuide from "@/components/exporter/guide"; // helpers +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { cn } from "@/helpers/common.helper"; // hooks import { useUserPermissions, useWorkspace } from "@/hooks/store"; @@ -29,23 +30,24 @@ const ExportsPage = observer(() => { // if user is not authorized to view this page if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { - return ; + return ; } return ( - <> +
-
-

{t("workspace_settings.settings.exports.title")}

-
+
- +
); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx similarity index 61% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx index 718742804ad..10d1a76e66f 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx @@ -3,40 +3,32 @@ import { observer } from "mobx-react"; // components import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import IntegrationGuide from "@/components/integration/guide"; // hooks +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { useUserPermissions, useWorkspace } from "@/hooks/store"; const ImportsPage = observer(() => { + // router // store hooks const { currentWorkspace } = useWorkspace(); const { allowPermissions } = useUserPermissions(); - // derived values const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined; - if (!isAdmin) - return ( - <> - -
-

You are not authorized to access this page.

-
- - ); + if (!isAdmin) return ; return ( - <> + -
-
-

Imports

-
+
+
- + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx similarity index 86% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx index ef31bd82feb..335631a2ed4 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx @@ -4,8 +4,10 @@ import { useParams } from "next/navigation"; import useSWR from "swr"; // components import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { SingleIntegrationCard } from "@/components/integration"; +import { SettingsContentWrapper } from "@/components/settings"; import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui"; // constants import { APP_INTEGRATIONS } from "@/constants/fetch-keys"; @@ -26,23 +28,14 @@ const WorkspaceIntegrationsPage = observer(() => { // derived values const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined; - - if (!isAdmin) - return ( - <> - -
-

You are not authorized to access this page.

-
- - ); - const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () => workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null ); + if (!isAdmin) return ; + return ( - <> +
@@ -56,7 +49,7 @@ const WorkspaceIntegrationsPage = observer(() => { )}
- +
); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx new file mode 100644 index 00000000000..5b2af44b757 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import { observer } from "mobx-react"; +// components +import { usePathname } from "next/navigation"; +import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants"; +// hooks +import { NotAuthorizedView } from "@/components/auth-screens"; +import { CommandPalette } from "@/components/command-palette"; +import { SettingsMobileNav } from "@/components/settings"; +import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper"; +import { useUserPermissions } from "@/hooks/store"; +// local components +import { WorkspaceSettingsSidebar } from "./sidebar"; + +export interface IWorkspaceSettingLayout { + children: ReactNode; +} + +const WorkspaceSettingLayout: FC = observer((props) => { + const { children } = props; + // store hooks + const { workspaceUserInfo, getWorkspaceRoleByWorkspaceSlug } = useUserPermissions(); + // next hooks + const pathname = usePathname(); + // derived values + const { workspaceSlug, accessKey } = pathnameToAccessKey(pathname); + const userWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug.toString()); + + let isAuthorized: boolean | string = false; + if (pathname && workspaceSlug && userWorkspaceRole) { + isAuthorized = WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles); + } + + return ( + <> + + +
+ {workspaceUserInfo && !isAuthorized ? ( + + ) : ( +
+
{}
+ {children} +
+ )} +
+ + ); +}); + +export default WorkspaceSettingLayout; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx similarity index 95% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx index 8be7a9d22f5..250b5bc02fc 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx @@ -14,6 +14,7 @@ import { Button, TOAST_TYPE, setToast } from "@plane/ui"; import { NotAuthorizedView } from "@/components/auth-screens"; import { CountChip } from "@/components/common"; import { PageHead } from "@/components/core"; +import { SettingsContentWrapper } from "@/components/settings"; import { WorkspaceMembersList } from "@/components/workspace"; // helpers import { cn } from "@/helpers/common.helper"; @@ -95,11 +96,11 @@ const WorkspaceMembersSettingsPage = observer(() => { // if user is not authorized to view this page if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { - return ; + return ; } return ( - <> + { onSubmit={handleWorkspaceInvite} />
@@ -137,7 +138,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
- +
); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx similarity index 85% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx index 6088cf0a50f..736c3481069 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx @@ -4,6 +4,7 @@ import { observer } from "mobx-react"; // components import { useTranslation } from "@plane/i18n"; import { PageHead } from "@/components/core"; +import { SettingsContentWrapper } from "@/components/settings"; import { WorkspaceDetails } from "@/components/workspace"; // hooks import { useWorkspace } from "@/hooks/store"; @@ -18,10 +19,10 @@ const WorkspaceSettingsPage = observer(() => { : undefined; return ( - <> + - + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx new file mode 100644 index 00000000000..8a97c8b050f --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx @@ -0,0 +1,73 @@ +import { useParams, usePathname } from "next/navigation"; +import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react"; +import { + EUserPermissionsLevel, + GROUPED_WORKSPACE_SETTINGS, + WORKSPACE_SETTINGS_CATEGORIES, + EUserWorkspaceRoles, + EUserPermissions, + WORKSPACE_SETTINGS_CATEGORY, +} from "@plane/constants"; +import { SettingsSidebar } from "@/components/settings"; +import { useUserPermissions } from "@/hooks/store/user"; +import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; + +const ICONS = { + general: Building, + members: Users, + export: ArrowUpToLine, + "billing-and-plans": CreditCard, + webhooks: Webhook, +}; + +export const WorkspaceActionIcons = ({ + type, + size, + className, +}: { + type: string; + size?: number; + className?: string; +}) => { + if (type === undefined) return null; + const Icon = ICONS[type as keyof typeof ICONS]; + if (!Icon) return null; + return ; +}; + +type TWorkspaceSettingsSidebarProps = { + isMobile?: boolean; +}; + +export const WorkspaceSettingsSidebar = (props: TWorkspaceSettingsSidebarProps) => { + const { isMobile = false } = props; + // router + const pathname = usePathname(); + const { workspaceSlug } = useParams(); // store hooks + const { allowPermissions } = useUserPermissions(); + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + + return ( + + isAdmin || ![WORKSPACE_SETTINGS_CATEGORY.FEATURES, WORKSPACE_SETTINGS_CATEGORY.DEVELOPER].includes(category) + )} + groupedSettings={GROUPED_WORKSPACE_SETTINGS} + workspaceSlug={workspaceSlug.toString()} + isActive={(data: { href: string }) => + data.href === "/settings" + ? pathname === `/${workspaceSlug}${data.href}/` + : new RegExp(`^/${workspaceSlug}${data.href}/`).test(pathname) + } + shouldRender={(data: { key: string; access?: EUserWorkspaceRoles[] | undefined }) => + data.access + ? shouldRenderSettingLink(workspaceSlug.toString(), data.key) && + allowPermissions(data.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) + : false + } + actionIcons={WorkspaceActionIcons} + /> + ); +}; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx similarity index 96% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx index 5edc914e908..a775ff3b1fb 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx @@ -11,6 +11,7 @@ import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; +import { SettingsContentWrapper } from "@/components/settings"; import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks"; // hooks import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store"; @@ -87,7 +88,7 @@ const WebhookDetailsPage = observer(() => { ); return ( - <> + setDeleteWebhookModal(false)} />
@@ -96,7 +97,7 @@ const WebhookDetailsPage = observer(() => {
{currentWebhook && setDeleteWebhookModal(true)} />} - +
); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx similarity index 75% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx index 2623660da88..d1692168eda 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx @@ -7,11 +7,11 @@ import useSWR from "swr"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/ui"; // components import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { DetailedEmptyState } from "@/components/empty-state"; +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { WebhookSettingsLoader } from "@/components/ui"; import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks"; // hooks @@ -48,15 +48,15 @@ const WebhooksListPage = observer(() => { }, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]); if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { - return ; + return ; } if (!webhooks) return ; return ( - <> + -
+
{ setShowCreateWebhookModal(false); }} /> + setShowCreateWebhookModal(true), + }} + /> {Object.keys(webhooks).length > 0 ? (
-
-
{t("workspace_settings.settings.webhooks.title")}
- -
) : (
-
-
{t("workspace_settings.settings.webhooks.title")}
- -
setShowCreateWebhookModal(true), + }} />
)}
- + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx new file mode 100644 index 00000000000..05777e64891 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Button } from "@plane/ui"; +// components +import { PageHead } from "@/components/core"; +import { DetailedEmptyState } from "@/components/empty-state"; +import { ProfileActivityListPage } from "@/components/profile"; +// hooks +import { SettingsHeading } from "@/components/settings"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const PER_PAGE = 100; + +const ProfileActivityPage = observer(() => { + // states + const [pageCount, setPageCount] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [resultsCount, setResultsCount] = useState(0); + const [isEmpty, setIsEmpty] = useState(false); + // plane hooks + const { t } = useTranslation(); + // derived values + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/profile/activity" }); + + const updateTotalPages = (count: number) => setTotalPages(count); + + const updateResultsCount = (count: number) => setResultsCount(count); + + const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty); + + const handleLoadMore = () => setPageCount((prev) => prev + 1); + + const activityPages: JSX.Element[] = []; + for (let i = 0; i < pageCount; i++) + activityPages.push( + + ); + + const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0; + + if (isEmpty) { + return ( +
+ + +
+ ); + } + + return ( + <> + + +
{activityPages}
+ {isLoadMoreVisible && ( +
+ +
+ )} + + ); +}); + +export default ProfileActivityPage; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx similarity index 71% rename from web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx index 21334ff23cd..10461db071f 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx @@ -7,12 +7,12 @@ import useSWR from "swr"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/ui"; // component import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token"; import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { DetailedEmptyState } from "@/components/empty-state"; +import { SettingsHeading } from "@/components/settings"; import { APITokenSettingsLoader } from "@/components/ui"; import { API_TOKENS_LIST } from "@/constants/fetch-keys"; // store hooks @@ -48,7 +48,7 @@ const ApiTokensPage = observer(() => { : undefined; if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { - return ; + return ; } if (!tokens) { @@ -56,18 +56,20 @@ const ApiTokensPage = observer(() => { } return ( - <> +
setIsCreateTokenModalOpen(false)} /> -
+
{tokens.length > 0 ? ( <> -
-

{t("workspace_settings.settings.api_tokens.title")}

- -
+ setIsCreateTokenModalOpen(true), + }} + />
{tokens.map((token) => ( @@ -76,23 +78,31 @@ const ApiTokensPage = observer(() => { ) : (
-
-

{t("workspace_settings.settings.api_tokens.title")}

- -
+ setIsCreateTokenModalOpen(true), + }} + />
setIsCreateTokenModalOpen(true), + }} />
)}
- +
); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx new file mode 100644 index 00000000000..9dcffd57cf6 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { ReactNode } from "react"; +// components +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +import { SettingsContentWrapper, SettingsMobileNav } from "@/components/settings"; +import { getProfileActivePath } from "@/components/settings/helper"; +import { ProfileSidebar } from "./sidebar"; + +type Props = { + children: ReactNode; +}; + +const ProfileSettingsLayout = observer((props: Props) => { + const { children } = props; + // router + const pathname = usePathname(); + + return ( + <> + +
+
+ +
+ {children} +
+ + ); +}); + +export default ProfileSettingsLayout; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx new file mode 100644 index 00000000000..cc71877af08 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import useSWR from "swr"; +// components +import { useTranslation } from "@plane/i18n"; +import { PageHead } from "@/components/core"; +import { EmailNotificationForm } from "@/components/profile/notification"; +import { SettingsHeading } from "@/components/settings"; +import { EmailSettingsLoader } from "@/components/ui"; +// services +import { UserService } from "@/services/user.service"; + +const userService = new UserService(); + +export default function ProfileNotificationPage() { + const { t } = useTranslation(); + // fetching user email notification settings + const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => + userService.currentUserEmailNotificationSettings() + ); + + if (!data || isLoading) { + return ; + } + + return ( + <> + + + + + + ); +} diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx new file mode 100644 index 00000000000..f37178c2a09 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// components +import { LogoSpinner } from "@/components/common"; +import { PageHead } from "@/components/core"; +import { ProfileForm } from "@/components/profile"; +// hooks +import { useUser } from "@/hooks/store"; + +const ProfileSettingsPage = observer(() => { + const { t } = useTranslation(); + // store hooks + const { data: currentUser, userProfile } = useUser(); + + if (!currentUser) + return ( +
+ +
+ ); + + return ( + <> + + + + ); +}); + +export default ProfileSettingsPage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx new file mode 100644 index 00000000000..81c37ae4c43 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// components +import { LogoSpinner } from "@/components/common"; +import { PageHead } from "@/components/core"; +import { PreferencesList } from "@/components/preferences/list"; +import { LanguageTimezone, ProfileSettingContentHeader } from "@/components/profile"; +// hooks +import { SettingsHeading } from "@/components/settings"; +import { useUserProfile } from "@/hooks/store"; +const ProfileAppearancePage = observer(() => { + const { t } = useTranslation(); + // hooks + const { data: userProfile } = useUserProfile(); + + return ( + <> + + {userProfile ? ( + <> +
+
+ + +
+
+ + +
+
+ + ) : ( +
+ +
+ )} + + ); +}); + +export default ProfileAppearancePage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx new file mode 100644 index 00000000000..b9cdf9d2687 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +import { Eye, EyeOff } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { PasswordStrengthMeter } from "@/components/account"; +import { PageHead } from "@/components/core"; +import { ProfileSettingContentHeader } from "@/components/profile"; +// helpers +import { authErrorHandler } from "@/helpers/authentication.helper"; +import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; +// hooks +import { useUser } from "@/hooks/store"; +// services +import { AuthService } from "@/services/auth.service"; + +export interface FormValues { + old_password: string; + new_password: string; + confirm_password: string; +} + +const defaultValues: FormValues = { + old_password: "", + new_password: "", + confirm_password: "", +}; + +const authService = new AuthService(); + +const defaultShowPassword = { + oldPassword: false, + password: false, + confirmPassword: false, +}; + +const SecurityPage = observer(() => { + // store + const { data: currentUser, changePassword } = useUser(); + // states + const [showPassword, setShowPassword] = useState(defaultShowPassword); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + + // use form + const { + control, + handleSubmit, + watch, + formState: { errors, isSubmitting }, + reset, + } = useForm({ defaultValues }); + // derived values + const oldPassword = watch("old_password"); + const password = watch("new_password"); + const confirmPassword = watch("confirm_password"); + const oldPasswordRequired = !currentUser?.is_password_autoset; + // i18n + const { t } = useTranslation(); + + const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword; + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleChangePassword = async (formData: FormValues) => { + const { old_password, new_password } = formData; + try { + const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token); + if (!csrfToken) throw new Error("csrf token not found"); + + await changePassword(csrfToken, { + ...(oldPasswordRequired && { old_password }), + new_password, + }); + + reset(defaultValues); + setShowPassword(defaultShowPassword); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("auth.common.password.toast.change_password.success.title"), + message: t("auth.common.password.toast.change_password.success.message"), + }); + } catch (err: any) { + const errorInfo = authErrorHandler(err.error_code?.toString()); + setToast({ + type: TOAST_TYPE.ERROR, + title: errorInfo?.title ?? t("auth.common.password.toast.error.title"), + message: + typeof errorInfo?.message === "string" ? errorInfo.message : t("auth.common.password.toast.error.message"), + }); + } + }; + + const isButtonDisabled = + getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID || + (oldPasswordRequired && oldPassword.trim() === "") || + password.trim() === "" || + confirmPassword.trim() === "" || + password !== confirmPassword || + password === oldPassword; + + const passwordSupport = password.length > 0 && + getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && ( + + ); + + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + return ( + <> + + +
+
+ {oldPasswordRequired && ( +
+

{t("auth.common.password.current_password.label")}

+
+ ( + + )} + /> + {showPassword?.oldPassword ? ( + handleShowPassword("oldPassword")} + /> + ) : ( + handleShowPassword("oldPassword")} + /> + )} +
+ {errors.old_password && {errors.old_password.message}} +
+ )} +
+

{t("auth.common.password.new_password.label")}

+
+ ( + setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + /> + )} + /> + {showPassword?.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} + /> + )} +
+ {passwordSupport} + {isNewPasswordSameAsOldPassword && !isPasswordInputFocused && ( + {t("new_password_must_be_different_from_old_password")} + )} +
+
+

{t("auth.common.password.confirm_password.label")}

+
+ ( + setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + )} + /> + {showPassword?.confirmPassword ? ( + handleShowPassword("confirmPassword")} + /> + ) : ( + handleShowPassword("confirmPassword")} + /> + )} +
+ {!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )} +
+
+ +
+ +
+
+ + ); +}); + +export default SecurityPage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx new file mode 100644 index 00000000000..6e495daff10 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx @@ -0,0 +1,82 @@ +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks, Lock } from "lucide-react"; +import { + EUserPermissions, + EUserPermissionsLevel, + GROUPED_PROFILE_SETTINGS, + PROFILE_SETTINGS_CATEGORIES, + PROFILE_SETTINGS_CATEGORY, +} from "@plane/constants"; +import { SettingsSidebar } from "@/components/settings"; +import { getFileURL } from "@/helpers/file.helper"; +import { useUser, useUserPermissions } from "@/hooks/store/user"; + +const ICONS = { + profile: CircleUser, + security: Lock, + activity: Activity, + preferences: Settings2, + notifications: Bell, + "api-tokens": KeyRound, + connections: Blocks, +}; + +export const ProjectActionIcons = ({ type, size, className }: { type: string; size?: number; className?: string }) => { + if (type === undefined) return null; + const Icon = ICONS[type as keyof typeof ICONS]; + if (!Icon) return null; + return ; +}; + +type TProfileSidebarProps = { + isMobile?: boolean; +}; + +export const ProfileSidebar = observer((props: TProfileSidebarProps) => { + const { isMobile = false } = props; + // router + const pathname = usePathname(); + const { workspaceSlug } = useParams(); + // store hooks + const { data: currentUser } = useUser(); + + const { allowPermissions } = useUserPermissions(); + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + return ( + isAdmin || category !== PROFILE_SETTINGS_CATEGORY.DEVELOPER + )} + groupedSettings={GROUPED_PROFILE_SETTINGS} + workspaceSlug={workspaceSlug?.toString() ?? ""} + isActive={(data: { href: string }) => pathname === `/${workspaceSlug}${data.href}/`} + customHeader={ +
+
+ {!currentUser?.avatar_url || currentUser?.avatar_url === "" ? ( +
+ +
+ ) : ( +
+ {currentUser?.display_name} +
+ )} +
+
+
{currentUser?.display_name}
+
{currentUser?.email}
+
+
+ } + actionIcons={ProjectActionIcons} + shouldRender + /> + ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx similarity index 79% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx index 5fc536d91be..c7542b4f064 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx @@ -13,6 +13,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; import { PageHead } from "@/components/core"; // hooks +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const AutomationSettingsPage = observer(() => { @@ -43,20 +44,21 @@ const AutomationSettingsPage = observer(() => { const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined; if (workspaceUserInfo && !canPerformProjectAdminActions) { - return ; + return ; } return ( - <> + -
-
-

{t("project_settings.automations.label")}

-
+
+
- + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx similarity index 80% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx index 0a19713e880..db9d17e89be 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx @@ -8,6 +8,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { EstimateRoot } from "@/components/estimates"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const EstimatesSettingsPage = observer(() => { @@ -23,22 +24,20 @@ const EstimatesSettingsPage = observer(() => { if (!workspaceSlug || !projectId) return <>; if (workspaceUserInfo && !canPerformProjectAdminActions) { - return ; + return ; } return ( - <> + -
+
- + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx similarity index 81% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx index 23aa8ad45cb..d84ba10c4ab 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx @@ -8,6 +8,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectFeaturesList } from "@/components/project"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const FeaturesSettingsPage = observer(() => { @@ -23,20 +24,20 @@ const FeaturesSettingsPage = observer(() => { if (!workspaceSlug || !projectId) return null; if (workspaceUserInfo && !canPerformProjectAdminActions) { - return ; + return ; } return ( - <> + -
+
- + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx similarity index 87% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx index 17a466a8010..317e7692981 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx @@ -10,6 +10,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectSettingsLabelList } from "@/components/labels"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const LabelsSettingsPage = observer(() => { @@ -38,19 +39,19 @@ const LabelsSettingsPage = observer(() => { element, }) ); - }, [scrollableContainerRef?.current]); + }, []); if (workspaceUserInfo && !canPerformProjectMemberActions) { - return ; + return ; } return ( - <> + -
+
- + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx new file mode 100644 index 00000000000..458279b8781 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// components +import { NotAuthorizedView } from "@/components/auth-screens"; +import { PageHead } from "@/components/core"; +import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project"; +// hooks +import { SettingsContentWrapper } from "@/components/settings"; +import { useProject, useUserPermissions } from "@/hooks/store"; +// plane web imports +import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces"; +import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings"; + +const MembersSettingsPage = observer(() => { + // router + const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams(); + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentProjectDetails } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + // derived values + const projectId = routerProjectId?.toString(); + const workspaceSlug = routerWorkspaceSlug?.toString(); + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined; + const isProjectMemberOrAdmin = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin; + + if (workspaceUserInfo && !canPerformProjectMemberActions) { + return ; + } + + return ( + + +
+
+
+ {t(getProjectSettingsPageLabelI18nKey("members", "common.members"))} +
+
+ + + +
+
+ ); +}); + +export default MembersSettingsPage; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx similarity index 91% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx index 96ff1bcc3f6..cf79fa127d3 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx @@ -16,9 +16,9 @@ import { ProjectDetailsFormLoader, } from "@/components/project"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; - -const GeneralSettingsPage = observer(() => { +const ProjectSettingsPage = observer(() => { // states const [selectProject, setSelectedProject] = useState(null); const [archiveProject, setArchiveProject] = useState(false); @@ -45,7 +45,7 @@ const GeneralSettingsPage = observer(() => { const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined; return ( - <> + {currentProjectDetails && workspaceSlug && projectId && ( <> @@ -64,7 +64,7 @@ const GeneralSettingsPage = observer(() => { )} -
+
{currentProjectDetails && workspaceSlug && projectId && !isLoading ? ( { )}
- + ); }); -export default GeneralSettingsPage; +export default ProjectSettingsPage; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx similarity index 68% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx index 54fca1c0821..30f6c3da638 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx @@ -9,6 +9,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectStateRoot } from "@/components/project-states"; // hook +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const StatesSettingsPage = observer(() => { @@ -28,19 +29,22 @@ const StatesSettingsPage = observer(() => { ); if (workspaceUserInfo && !canPerformProjectMemberActions) { - return ; + return ; } return ( - <> + -
-

{t("common.states")}

+
+ + {workspaceSlug && projectId && ( + + )}
- {workspaceSlug && projectId && ( - - )} - + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx new file mode 100644 index 00000000000..4701775b460 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { ReactNode, useEffect } from "react"; +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +// components +import { SettingsMobileNav } from "@/components/settings"; +import { getProjectActivePath } from "@/components/settings/helper"; +import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar"; +import { useProject } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; + +type Props = { + children: ReactNode; +}; + +const ProjectSettingsLayout = observer((props: Props) => { + const { children } = props; + // router + const router = useAppRouter(); + const pathname = usePathname(); + const { workspaceSlug, projectId } = useParams(); + const { joinedProjectIds } = useProject(); + + useEffect(() => { + if (projectId) return; + if (joinedProjectIds.length > 0) { + router.push(`/${workspaceSlug}/settings/projects/${joinedProjectIds[0]}`); + } + }, [joinedProjectIds, router, workspaceSlug, projectId]); + + return ( + <> + + +
+
{projectId && }
+ {children} +
+
+ + ); +}); + +export default ProjectSettingsLayout; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx new file mode 100644 index 00000000000..65ea6270152 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx @@ -0,0 +1,38 @@ +"use client"; +import Image from "next/image"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +import { Button, getButtonStyling } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { useCommandPalette } from "@/hooks/store"; + +const ProjectSettingsPage = () => { + // store hooks + 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"; + return ( +
+ No projects yet +
No projects yet
+
+ Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you + need to get things done. +
+
+ + Learn more about projects + + +
+
+ ); +}; + +export default ProjectSettingsPage; diff --git a/web/app/(all)/onboarding/page.tsx b/web/app/(all)/onboarding/page.tsx index a26bef3a605..2211ad07db4 100644 --- a/web/app/(all)/onboarding/page.tsx +++ b/web/app/(all)/onboarding/page.tsx @@ -74,7 +74,6 @@ const OnboardingPage = observer(() => { await finishUserOnboarding() .then(() => { captureEvent(USER_ONBOARDING_COMPLETED, { - // user_role: user.role, email: user.email, user_id: user.id, status: "SUCCESS", diff --git a/web/app/(all)/profile/appearance/page.tsx b/web/app/(all)/profile/appearance/page.tsx index db367e49a7c..ac5beec3764 100644 --- a/web/app/(all)/profile/appearance/page.tsx +++ b/web/app/(all)/profile/appearance/page.tsx @@ -11,7 +11,7 @@ import { setPromiseToast } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common"; import { ThemeSwitch, PageHead, CustomThemeSelector } from "@/components/core"; -import { ProfileSettingContentHeader, ProfileSettingContentWrapper, StartOfWeekPreference } from "@/components/profile"; +import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile"; // helpers import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper"; // hooks @@ -75,7 +75,6 @@ const ProfileAppearancePage = observer(() => {
{userProfile?.theme?.theme === "custom" && } - ) : (
diff --git a/web/app/layout.tsx b/web/app/layout.tsx index a36c75c49bc..d368a70d7bd 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -69,7 +69,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) "app-container" )} > -
{children}
+
{children}
diff --git a/web/ce/components/preferences/config.ts b/web/ce/components/preferences/config.ts new file mode 100644 index 00000000000..1a67ab7d341 --- /dev/null +++ b/web/ce/components/preferences/config.ts @@ -0,0 +1,7 @@ +import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference"; +import { ThemeSwitcher } from "./theme-switcher"; + +export const PREFERENCE_COMPONENTS = { + theme: ThemeSwitcher, + start_of_week: StartOfWeekPreference, +}; diff --git a/web/ce/components/preferences/theme-switcher.tsx b/web/ce/components/preferences/theme-switcher.tsx new file mode 100644 index 00000000000..6fd39723105 --- /dev/null +++ b/web/ce/components/preferences/theme-switcher.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; +import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { IUserTheme } from "@plane/types"; +import { setPromiseToast } from "@plane/ui"; + +// components +import { CustomThemeSelector, ThemeSwitch } from "@/components/core"; +// helpers +import { PreferencesSection } from "@/components/preferences/section"; +import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper"; +// hooks +import { useUserProfile } from "@/hooks/store"; + +export const ThemeSwitcher = observer( + (props: { + option: { + id: string; + title: string; + description: string; + }; + }) => { + // hooks + const { setTheme } = useTheme(); + const { data: userProfile, updateUserTheme } = useUserProfile(); + + // states + const [currentTheme, setCurrentTheme] = useState(null); + + const { t } = useTranslation(); + + // initialize theme + useEffect(() => { + if (!userProfile?.theme?.theme) return; + + const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile.theme.theme); + + if (userThemeOption) { + setCurrentTheme(userThemeOption); + } + }, [userProfile?.theme?.theme]); + + // handlers + const applyThemeChange = useCallback( + (theme: Partial) => { + const themeValue = theme?.theme || "system"; + setTheme(themeValue); + + if (theme?.theme === "custom" && theme?.palette) { + const defaultPalette = "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5"; + const palette = theme.palette !== ",,,," ? theme.palette : defaultPalette; + applyTheme(palette, false); + } else { + unsetCustomCssVariables(); + } + }, + [setTheme] + ); + + const handleThemeChange = useCallback( + async (themeOption: I_THEME_OPTION) => { + try { + applyThemeChange({ theme: themeOption.value }); + + const updatePromise = updateUserTheme({ theme: themeOption.value }); + setPromiseToast(updatePromise, { + loading: "Updating theme...", + success: { + title: "Success!", + message: () => "Theme updated successfully!", + }, + error: { + title: "Error!", + message: () => "Failed to update the theme", + }, + }); + } catch (error) { + console.error("Error updating theme:", error); + } + }, + [applyThemeChange, updateUserTheme] + ); + + if (!userProfile) return null; + + return ( + <> + + +
+ } + /> + {userProfile.theme?.theme === "custom" && } + + ); + } +); diff --git a/web/ce/components/projects/settings/useProjectColumns.tsx b/web/ce/components/projects/settings/useProjectColumns.tsx index bd9e589ad17..a1ef7f3f2e3 100644 --- a/web/ce/components/projects/settings/useProjectColumns.tsx +++ b/web/ce/components/projects/settings/useProjectColumns.tsx @@ -1,27 +1,37 @@ import { useState } from "react"; -import { useParams } from "next/navigation"; +// plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { IWorkspaceMember } from "@plane/types"; +import { IWorkspaceMember, TProjectMembership } from "@plane/types"; +// components import { AccountTypeColumn, NameColumn } from "@/components/project/settings/member-columns"; +// hooks import { useUser, useUserPermissions } from "@/hooks/store"; -export interface RowData { +export interface RowData extends Pick { member: IWorkspaceMember; - role: EUserPermissions; } -export const useProjectColumns = () => { +type TUseProjectColumnsProps = { + projectId: string; + workspaceSlug: string; +}; + +export const useProjectColumns = (props: TUseProjectColumnsProps) => { + const { projectId, workspaceSlug } = props; // states const [removeMemberModal, setRemoveMemberModal] = useState(null); - - const { workspaceSlug, projectId } = useParams(); - + // store hooks const { data: currentUser } = useUser(); - const { allowPermissions, projectUserInfo } = useUserPermissions(); - + const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); + // derived values + const isAdmin = allowPermissions( + [EUserPermissions.ADMIN], + EUserPermissionsLevel.PROJECT, + workspaceSlug.toString(), + projectId.toString() + ); const currentProjectRole = - (projectUserInfo?.[workspaceSlug.toString()]?.[projectId.toString()]?.role as unknown as EUserPermissions) ?? - EUserPermissions.GUEST; + getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug.toString(), projectId.toString()) ?? EUserPermissions.GUEST; const getFormattedDate = (dateStr: string) => { const date = new Date(dateStr); @@ -29,13 +39,6 @@ export const useProjectColumns = () => { const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" }; return date.toLocaleDateString("en-US", options); }; - // derived values - const isAdmin = allowPermissions( - [EUserPermissions.ADMIN], - EUserPermissionsLevel.PROJECT, - workspaceSlug.toString(), - projectId.toString() - ); const columns = [ { @@ -76,5 +79,5 @@ export const useProjectColumns = () => { tdRender: (rowData: RowData) =>
{getFormattedDate(rowData?.member?.joining_date || "")}
, }, ]; - return { columns, workspaceSlug, projectId, removeMemberModal, setRemoveMemberModal }; + return { columns, removeMemberModal, setRemoveMemberModal }; }; diff --git a/web/ce/components/projects/teamspaces/index.ts b/web/ce/components/projects/teamspaces/index.ts new file mode 100644 index 00000000000..968205a9b1c --- /dev/null +++ b/web/ce/components/projects/teamspaces/index.ts @@ -0,0 +1 @@ +export * from "./teamspace-list"; diff --git a/web/ce/components/projects/teamspaces/teamspace-list.tsx b/web/ce/components/projects/teamspaces/teamspace-list.tsx new file mode 100644 index 00000000000..05d401c5769 --- /dev/null +++ b/web/ce/components/projects/teamspaces/teamspace-list.tsx @@ -0,0 +1,6 @@ +export type TProjectTeamspaceList = { + workspaceSlug: string; + projectId: string; +}; + +export const ProjectTeamspaceList: React.FC = () => null; diff --git a/web/ce/components/workspace/billing/root.tsx b/web/ce/components/workspace/billing/root.tsx index f760525841f..6dd45213409 100644 --- a/web/ce/components/workspace/billing/root.tsx +++ b/web/ce/components/workspace/billing/root.tsx @@ -6,9 +6,11 @@ import { EProductSubscriptionEnum, SUBSCRIPTION_WITH_BILLING_FREQUENCY, } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { TBillingFrequency, TProductBillingFrequency } from "@plane/types"; import { cn } from "@plane/utils"; // components +import { SettingsHeading } from "@/components/settings"; import { getSubscriptionTextColor } from "@/components/workspace/billing/subscription"; // local imports import { PlansComparison } from "./comparison/root"; @@ -20,6 +22,7 @@ export const BillingRoot = observer(() => { const [productBillingFrequency, setProductBillingFrequency] = useState( DEFAULT_PRODUCT_BILLING_FREQUENCY ); + const { t } = useTranslation(); /** * Retrieves the billing frequency for a given subscription type @@ -56,11 +59,10 @@ export const BillingRoot = observer(() => { return (
-
-
-

Billing and plans

-
-
+
{ - const { isMobile } = usePlatformOS(); // states const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false); + // translation + const { t } = useTranslation(); + // platform + const { isMobile } = usePlatformOS(); return ( <> @@ -25,6 +29,8 @@ export const WorkspaceEditionBadge = observer(() => { variant="accent-primary" className="w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none" onClick={() => setIsPaidPlanPurchaseModalOpen(true)} + aria-haspopup="dialog" + aria-label={t("aria_labels.projects_sidebar.edition_badge")} > Community diff --git a/web/ce/components/workspace/sidebar/app-search.tsx b/web/ce/components/workspace/sidebar/app-search.tsx index 6ab92b99604..1ba0ea72c2a 100644 --- a/web/ce/components/workspace/sidebar/app-search.tsx +++ b/web/ce/components/workspace/sidebar/app-search.tsx @@ -1,5 +1,7 @@ import { observer } from "mobx-react"; import { Search } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; // helpers import { cn } from "@/helpers/common.helper"; // hooks @@ -9,9 +11,12 @@ export const AppSearch = observer(() => { // store hooks const { sidebarCollapsed } = useAppTheme(); const { toggleCommandPaletteModal } = useCommandPalette(); + // translation + const { t } = useTranslation(); return ( diff --git a/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx b/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx index 5bb2fe885f5..5e7343e0a8a 100644 --- a/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx +++ b/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx @@ -55,7 +55,7 @@ export const ExtendedSidebarItem: FC = observer((prop const sidebarPreference = getNavigationPreferences(workspaceSlug.toString()); const isPinned = sidebarPreference?.[item.key]?.is_pinned; - const handleLinkClick = () => toggleExtendedSidebar(); + const handleLinkClick = () => toggleExtendedSidebar(true); if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) { return null; diff --git a/web/ce/components/workspace/sidebar/sidebar-item.tsx b/web/ce/components/workspace/sidebar/sidebar-item.tsx index 51a5735de35..3645bde3ddd 100644 --- a/web/ce/components/workspace/sidebar/sidebar-item.tsx +++ b/web/ce/components/workspace/sidebar/sidebar-item.tsx @@ -38,7 +38,7 @@ export const SidebarItem: FC = observer((props) => { if (window.innerWidth < 768) { toggleSidebar(); } - if (extendedSidebarCollapsed) toggleExtendedSidebar(); + if (!extendedSidebarCollapsed) toggleExtendedSidebar(); }; const staticItems = ["home", "inbox", "pi-chat", "projects"]; diff --git a/web/ce/constants/project/settings/tabs.ts b/web/ce/constants/project/settings/tabs.ts index 15869c186cd..5443c642452 100644 --- a/web/ce/constants/project/settings/tabs.ts +++ b/web/ce/constants/project/settings/tabs.ts @@ -9,57 +9,57 @@ export const PROJECT_SETTINGS = { general: { key: "general", i18n_label: "common.general", - href: `/settings`, + href: ``, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`, Icon: SettingIcon, }, members: { key: "members", - i18n_label: "members", - href: `/settings/members`, + i18n_label: "common.members", + href: `/members`, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/members/`, Icon: SettingIcon, }, features: { key: "features", i18n_label: "common.features", - href: `/settings/features`, + href: `/features`, access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/`, Icon: SettingIcon, }, states: { key: "states", i18n_label: "common.states", - href: `/settings/states`, + href: `/states`, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/states/`, Icon: SettingIcon, }, labels: { key: "labels", i18n_label: "common.labels", - href: `/settings/labels`, + href: `/labels`, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/labels/`, Icon: SettingIcon, }, estimates: { key: "estimates", i18n_label: "common.estimates", - href: `/settings/estimates`, + href: `/estimates`, access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/estimates/`, Icon: SettingIcon, }, automations: { key: "automations", i18n_label: "project_settings.automations.label", - href: `/settings/automations`, + href: `/automations`, access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/automations/`, Icon: SettingIcon, }, }; diff --git a/web/ce/constants/sidebar-favorites.ts b/web/ce/constants/sidebar-favorites.ts index 9fa80e05f47..a6f49f8aaf8 100644 --- a/web/ce/constants/sidebar-favorites.ts +++ b/web/ce/constants/sidebar-favorites.ts @@ -1,7 +1,7 @@ -import { Briefcase, ContrastIcon, FileText, Layers, LucideIcon } from "lucide-react"; +import { Briefcase, FileText, Layers, LucideIcon } from "lucide-react"; // plane imports import { IFavorite } from "@plane/types"; -import { DiceIcon, FavoriteFolderIcon, ISvgIcons } from "@plane/ui"; +import { ContrastIcon, DiceIcon, FavoriteFolderIcon, ISvgIcons } from "@plane/ui"; export const FAVORITE_ITEM_ICONS: Record | LucideIcon> = { page: FileText, diff --git a/web/ce/helpers/project-settings.ts b/web/ce/helpers/project-settings.ts new file mode 100644 index 00000000000..dbe06507afc --- /dev/null +++ b/web/ce/helpers/project-settings.ts @@ -0,0 +1,7 @@ +/** + * @description Get the i18n key for the project settings page label + * @param _settingsKey - The key of the project settings page + * @param defaultLabelKey - The default i18n key for the project settings page label + * @returns The i18n key for the project settings page label + */ +export const getProjectSettingsPageLabelI18nKey = (_settingsKey: string, defaultLabelKey: string) => defaultLabelKey; diff --git a/web/ce/store/member/project-member.store.ts b/web/ce/store/member/project-member.store.ts new file mode 100644 index 00000000000..1b90c9c1179 --- /dev/null +++ b/web/ce/store/member/project-member.store.ts @@ -0,0 +1,43 @@ +import { computedFn } from "mobx-utils"; +// plane imports +import { EUserProjectRoles } from "@plane/constants"; +// plane web imports +import { RootStore } from "@/plane-web/store/root.store"; +// store +import { IMemberRootStore } from "@/store/member"; +import { BaseProjectMemberStore, IBaseProjectMemberStore } from "@/store/member/base-project-member.store"; + +export type IProjectMemberStore = IBaseProjectMemberStore; + +export class ProjectMemberStore extends BaseProjectMemberStore implements IProjectMemberStore { + constructor(_memberRoot: IMemberRootStore, rootStore: RootStore) { + super(_memberRoot, rootStore); + } + + /** + * @description Returns the highest role from the project membership + * @param { string } userId + * @param { string } projectId + * @returns { EUserProjectRoles | undefined } + */ + getUserProjectRole = computedFn((userId: string, projectId: string): EUserProjectRoles | undefined => + this.getRoleFromProjectMembership(userId, projectId) + ); + + /** + * @description Returns the role from the project membership + * @param projectId + * @param userId + * @param role + */ + getProjectMemberRoleForUpdate = (_projectId: string, _userId: string, role: EUserProjectRoles): EUserProjectRoles => + role; + + /** + * @description Processes the removal of a member from a project + * This method handles the cleanup of member data from the project member map + * @param projectId - The ID of the project to remove the member from + * @param userId - The ID of the user to remove from the project + */ + processMemberRemoval = (projectId: string, userId: string) => this.handleMemberRemoval(projectId, userId); +} diff --git a/web/ce/store/user/permission.store.ts b/web/ce/store/user/permission.store.ts new file mode 100644 index 00000000000..00300cdf2e8 --- /dev/null +++ b/web/ce/store/user/permission.store.ts @@ -0,0 +1,23 @@ +import { computedFn } from "mobx-utils"; +import { EUserPermissions } from "@plane/constants"; +import { RootStore } from "@/plane-web/store/root.store"; +import { BaseUserPermissionStore, IBaseUserPermissionStore } from "@/store/user/base-permissions.store"; + +export type IUserPermissionStore = IBaseUserPermissionStore; + +export class UserPermissionStore extends BaseUserPermissionStore implements IUserPermissionStore { + constructor(store: RootStore) { + super(store); + } + + /** + * @description Returns the project role from the workspace + * @param { string } workspaceSlug + * @param { string } projectId + * @returns { EUserPermissions | undefined } + */ + getProjectRoleByWorkspaceSlugAndProjectId = computedFn( + (workspaceSlug: string, projectId: string): EUserPermissions | undefined => + this.getProjectRole(workspaceSlug, projectId) + ); +} diff --git a/web/core/components/account/auth-forms/auth-banner.tsx b/web/core/components/account/auth-forms/auth-banner.tsx index 191d7a0a772..da1c57c4a8b 100644 --- a/web/core/components/account/auth-forms/auth-banner.tsx +++ b/web/core/components/account/auth-forms/auth-banner.tsx @@ -1,5 +1,7 @@ import { FC } from "react"; import { Info, X } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; // helpers import { TAuthErrorInfo } from "@/helpers/authentication.helper"; @@ -10,20 +12,28 @@ type TAuthBanner = { export const AuthBanner: FC = (props) => { const { bannerData, handleBannerData } = props; + // translation + const { t } = useTranslation(); if (!bannerData) return <>; + return ( -
-
+
+
-
{bannerData?.message}
-
handleBannerData && handleBannerData(undefined)} +

{bannerData?.message}

+
+ +
); }; diff --git a/web/core/components/account/auth-forms/auth-header.tsx b/web/core/components/account/auth-forms/auth-header.tsx index b33c694bab5..c705c7edd6a 100644 --- a/web/core/components/account/auth-forms/auth-header.tsx +++ b/web/core/components/account/auth-forms/auth-header.tsx @@ -102,9 +102,9 @@ export const AuthHeader: FC = observer((props) => { return ( <>
-

+

{typeof header === "string" ? t(header) : header} -

+

{t(subHeader)}

{children} diff --git a/web/core/components/account/auth-forms/email.tsx b/web/core/components/account/auth-forms/email.tsx index 724f524421e..9f3129364e0 100644 --- a/web/core/components/account/auth-forms/email.tsx +++ b/web/core/components/account/auth-forms/email.tsx @@ -47,7 +47,7 @@ export const AuthEmailForm: FC = observer((props) => { return (
-
)} diff --git a/web/core/components/account/auth-forms/password.tsx b/web/core/components/account/auth-forms/password.tsx index 979899679a3..0692eb86d86 100644 --- a/web/core/components/account/auth-forms/password.tsx +++ b/web/core/components/account/auth-forms/password.tsx @@ -167,7 +167,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { {nextPath && }
-
-
{mode === EAuthModes.SIGN_UP && (
-