From 0eafbb698a317e453422241bfc69a27788807479 Mon Sep 17 00:00:00 2001 From: JayashTripathy <76092296+JayashTripathy@users.noreply.github.com> Date: Mon, 26 May 2025 15:22:16 +0530 Subject: [PATCH 01/15] [WEB-3494] fix: size of created at value #7112 --- web/core/components/issues/peek-overview/properties.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/core/components/issues/peek-overview/properties.tsx b/web/core/components/issues/peek-overview/properties.tsx index 8fd7fd58f38..93516297f9a 100644 --- a/web/core/components/issues/peek-overview/properties.tsx +++ b/web/core/components/issues/peek-overview/properties.tsx @@ -135,7 +135,7 @@ export const PeekOverviewProperties: FC = observer((pro showTooltip userIds={createdByDetails?.display_name.includes("-intake") ? null : createdByDetails?.id} /> - + {createdByDetails?.display_name.includes("-intake") ? "Plane" : createdByDetails?.display_name} From 5a208cb1b9360d727951dbd0e99634fec9021069 Mon Sep 17 00:00:00 2001 From: JayashTripathy <76092296+JayashTripathy@users.noreply.github.com> Date: Mon, 26 May 2025 15:23:39 +0530 Subject: [PATCH 02/15] [WEB-2403] fix: alignment of project states in collapsed view #7114 --- web/core/components/issues/issue-layouts/kanban/default.tsx | 2 +- .../issues/issue-layouts/kanban/headers/group-by-card.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/core/components/issues/issue-layouts/kanban/default.tsx b/web/core/components/issues/issue-layouts/kanban/default.tsx index d1361540496..32cddc5a2d7 100644 --- a/web/core/components/issues/issue-layouts/kanban/default.tsx +++ b/web/core/components/issues/issue-layouts/kanban/default.tsx @@ -162,7 +162,7 @@ export const KanBan: React.FC = observer((props) => { } `} > {sub_group_by === null && ( -
+
= observer((props) => { verticalAlignPosition ? `w-[44px] flex-col items-center` : `w-full flex-row items-center` }`} > -
+
{icon ? icon : }
From 4e485d6402d71ee263ddc507b7029d0b6b73e106 Mon Sep 17 00:00:00 2001 From: JayashTripathy <76092296+JayashTripathy@users.noreply.github.com> Date: Mon, 26 May 2025 15:24:13 +0530 Subject: [PATCH 03/15] [WEB-4160] fix: close the context menu after select #7113 --- web/core/components/workspace/sidebar/projects-list-item.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/core/components/workspace/sidebar/projects-list-item.tsx b/web/core/components/workspace/sidebar/projects-list-item.tsx index 75b10aa4c79..715d02cb1c1 100644 --- a/web/core/components/workspace/sidebar/projects-list-item.tsx +++ b/web/core/components/workspace/sidebar/projects-list-item.tsx @@ -311,6 +311,7 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { customButtonClassName="grid place-items-center" placement="bottom-start" useCaptureForOutsideClick + closeOnSelect > {/* TODO: Removed is_favorite logic due to the optimization in projects API */} {/* {isAuthorized && ( From 78cc32765bfb9ef4b5d7a6901d26332e2cd072eb Mon Sep 17 00:00:00 2001 From: Dheeraj Kumar Ketireddy Date: Mon, 26 May 2025 15:26:26 +0530 Subject: [PATCH 04/15] [WEB-3707] pytest based test suite for apiserver (#7010) * pytest bases tests for apiserver * Trimmed spaces * Updated .gitignore for pytest local files --- .gitignore | 2 + apiserver/.coveragerc | 25 + apiserver/plane/authentication/urls.py | 30 +- apiserver/plane/tests/README.md | 143 ++++++ apiserver/plane/tests/TESTING_GUIDE.md | 151 ++++++ apiserver/plane/tests/__init__.py | 2 +- apiserver/plane/tests/api/base.py | 34 -- apiserver/plane/tests/api/test_asset.py | 1 - .../plane/tests/api/test_auth_extended.py | 1 - .../plane/tests/api/test_authentication.py | 183 ------- apiserver/plane/tests/api/test_cycle.py | 1 - apiserver/plane/tests/api/test_issue.py | 1 - apiserver/plane/tests/api/test_oauth.py | 1 - apiserver/plane/tests/api/test_people.py | 1 - apiserver/plane/tests/api/test_project.py | 1 - apiserver/plane/tests/api/test_shortcut.py | 1 - apiserver/plane/tests/api/test_state.py | 1 - apiserver/plane/tests/api/test_view.py | 1 - apiserver/plane/tests/api/test_workspace.py | 44 -- apiserver/plane/tests/conftest.py | 78 +++ apiserver/plane/tests/conftest_external.py | 117 +++++ .../plane/tests/{api => contract}/__init__.py | 0 .../plane/tests/contract/api/__init__.py | 0 .../plane/tests/contract/app/__init__.py | 1 + .../tests/contract/app/test_authentication.py | 459 ++++++++++++++++++ .../tests/contract/app/test_workspace_app.py | 79 +++ apiserver/plane/tests/factories.py | 82 ++++ apiserver/plane/tests/smoke/__init__.py | 0 .../plane/tests/smoke/test_auth_smoke.py | 100 ++++ apiserver/plane/tests/unit/__init__.py | 0 apiserver/plane/tests/unit/models/__init__.py | 0 .../tests/unit/models/test_workspace_model.py | 50 ++ .../plane/tests/unit/serializers/__init__.py | 0 .../tests/unit/serializers/test_workspace.py | 71 +++ apiserver/plane/tests/unit/utils/__init__.py | 0 apiserver/plane/tests/unit/utils/test_uuid.py | 49 ++ apiserver/pytest.ini | 17 + apiserver/requirements/test.txt | 14 +- apiserver/run_tests.py | 91 ++++ apiserver/run_tests.sh | 4 + 40 files changed, 1546 insertions(+), 290 deletions(-) create mode 100644 apiserver/.coveragerc create mode 100644 apiserver/plane/tests/README.md create mode 100644 apiserver/plane/tests/TESTING_GUIDE.md delete mode 100644 apiserver/plane/tests/api/base.py delete mode 100644 apiserver/plane/tests/api/test_asset.py delete mode 100644 apiserver/plane/tests/api/test_auth_extended.py delete mode 100644 apiserver/plane/tests/api/test_authentication.py delete mode 100644 apiserver/plane/tests/api/test_cycle.py delete mode 100644 apiserver/plane/tests/api/test_issue.py delete mode 100644 apiserver/plane/tests/api/test_oauth.py delete mode 100644 apiserver/plane/tests/api/test_people.py delete mode 100644 apiserver/plane/tests/api/test_project.py delete mode 100644 apiserver/plane/tests/api/test_shortcut.py delete mode 100644 apiserver/plane/tests/api/test_state.py delete mode 100644 apiserver/plane/tests/api/test_view.py delete mode 100644 apiserver/plane/tests/api/test_workspace.py create mode 100644 apiserver/plane/tests/conftest.py create mode 100644 apiserver/plane/tests/conftest_external.py rename apiserver/plane/tests/{api => contract}/__init__.py (100%) create mode 100644 apiserver/plane/tests/contract/api/__init__.py create mode 100644 apiserver/plane/tests/contract/app/__init__.py create mode 100644 apiserver/plane/tests/contract/app/test_authentication.py create mode 100644 apiserver/plane/tests/contract/app/test_workspace_app.py create mode 100644 apiserver/plane/tests/factories.py create mode 100644 apiserver/plane/tests/smoke/__init__.py create mode 100644 apiserver/plane/tests/smoke/test_auth_smoke.py create mode 100644 apiserver/plane/tests/unit/__init__.py create mode 100644 apiserver/plane/tests/unit/models/__init__.py create mode 100644 apiserver/plane/tests/unit/models/test_workspace_model.py create mode 100644 apiserver/plane/tests/unit/serializers/__init__.py create mode 100644 apiserver/plane/tests/unit/serializers/test_workspace.py create mode 100644 apiserver/plane/tests/unit/utils/__init__.py create mode 100644 apiserver/plane/tests/unit/utils/test_uuid.py create mode 100644 apiserver/pytest.ini create mode 100755 apiserver/run_tests.py create mode 100755 apiserver/run_tests.sh 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/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/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/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/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..85978128b92 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.31.0 \ 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 From 04c7c53e0926910aa2dae2b68348794a12ba742f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 May 2025 19:45:15 +0530 Subject: [PATCH 05/15] chore(deps): bump requests (#7120) Bumps the pip group with 1 update in the /apiserver/requirements directory: [requests](https://github.com/psf/requests). Updates `requests` from 2.31.0 to 2.32.2 - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.31.0...v2.32.2) --- updated-dependencies: - dependency-name: requests dependency-version: 2.32.2 dependency-type: direct:production dependency-group: pip ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apiserver/requirements/test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/requirements/test.txt b/apiserver/requirements/test.txt index 85978128b92..9536ab1e276 100644 --- a/apiserver/requirements/test.txt +++ b/apiserver/requirements/test.txt @@ -9,4 +9,4 @@ factory-boy==3.3.0 freezegun==1.2.2 coverage==7.2.7 httpx==0.24.1 -requests==2.31.0 \ No newline at end of file +requests==2.32.2 \ No newline at end of file From b4bc49971c6a5078757612113f51a3155a4d52b0 Mon Sep 17 00:00:00 2001 From: Akshita Goyal <36129505+gakshita@users.noreply.github.com> Date: Wed, 28 May 2025 00:54:21 +0530 Subject: [PATCH 06/15] [WEB-4130] fix: cycle charts minor optimizations (#7123) --- .../cycles/analytics-sidebar/issue-progress.tsx | 2 +- .../cycles/dropdowns/estimate-type-dropdown.tsx | 11 +++++++---- web/core/store/estimates/project-estimate.store.ts | 7 +++++++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx index c0ece831c00..f911b4418ad 100644 --- a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx +++ b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx @@ -32,7 +32,7 @@ type Options = { export const cycleEstimateOptions: Options[] = [ { value: "issues", label: "Work items" }, - { value: "points", label: "Points" }, + { value: "points", label: "Estimates" }, ]; export const cycleChartOptions: Options[] = [ { value: "burndown", label: "Burn-down" }, diff --git a/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx b/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx index 7eba6418d90..2899b7ca3af 100644 --- a/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx +++ b/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx @@ -1,5 +1,7 @@ import React from "react"; +import { observer } from "mobx-react-lite"; import { TCycleEstimateType } from "@plane/types"; +import { EEstimateSystem } from "@plane/types/src/enums"; import { CustomSelect } from "@plane/ui"; import { useCycle, useProjectEstimates } from "@/hooks/store"; import { cycleEstimateOptions } from "../analytics-sidebar"; @@ -12,12 +14,13 @@ type TProps = { cycleId: string; }; -export const EstimateTypeDropdown = (props: TProps) => { +export const EstimateTypeDropdown = observer((props: TProps) => { const { value, onChange, projectId, cycleId, showDefault = false } = props; const { getIsPointsDataAvailable } = useCycle(); - const { areEstimateEnabledByProjectId } = useProjectEstimates(); + const { areEstimateEnabledByProjectId, currentProjectEstimateType } = useProjectEstimates(); const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; - return getIsPointsDataAvailable(cycleId) || isCurrentProjectEstimateEnabled ? ( + return (getIsPointsDataAvailable(cycleId) || isCurrentProjectEstimateEnabled) && + currentProjectEstimateType !== EEstimateSystem.CATEGORIES ? (
{ ) : showDefault ? ( {value} ) : null; -}; +}); diff --git a/web/core/store/estimates/project-estimate.store.ts b/web/core/store/estimates/project-estimate.store.ts index 773a7d6dc94..c8c3a17aaa4 100644 --- a/web/core/store/estimates/project-estimate.store.ts +++ b/web/core/store/estimates/project-estimate.store.ts @@ -27,6 +27,7 @@ export interface IProjectEstimateStore { currentActiveEstimateId: string | undefined; currentActiveEstimate: IEstimate | undefined; archivedEstimateIds: string[] | undefined; + currentProjectEstimateType: TEstimateSystemKeys | undefined; areEstimateEnabledByProjectId: (projectId: string) => boolean; estimateIdsByProjectId: (projectId: string) => string[] | undefined; currentActiveEstimateIdByProjectId: (projectId: string) => string | undefined; @@ -63,6 +64,7 @@ export class ProjectEstimateStore implements IProjectEstimateStore { currentActiveEstimateId: computed, currentActiveEstimate: computed, archivedEstimateIds: computed, + currentProjectEstimateType: computed, // actions getWorkspaceEstimates: action, getProjectEstimates: action, @@ -73,6 +75,11 @@ export class ProjectEstimateStore implements IProjectEstimateStore { } // computed + + get currentProjectEstimateType(): TEstimateSystemKeys | undefined { + return this.currentActiveEstimateId ? this.estimates[this.currentActiveEstimateId]?.type : undefined; + } + /** * @description get current active estimate id for a project * @returns { string | undefined } From a3a580923c43c7550efeb623886911f15dd7560a Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 28 May 2025 00:58:22 +0530 Subject: [PATCH 07/15] [WEB-4166] chore: projects app sidebar accessibility (#7115) * chore: add ARIA attributes * chore: add missing translations * chore: add accessibility translations for multiple languages and configured store according to it * chore: refactor translation file handling and introduce TranslationFiles enum * fix: accessibility issues in workspace sidebar --------- Co-authored-by: JayashTripathy Co-authored-by: Prateek Shourya --- packages/i18n/src/constants/language.ts | 9 ++ .../i18n/src/locales/cs/accessibility.json | 27 ++++++ .../i18n/src/locales/de/accessibility.json | 27 ++++++ .../i18n/src/locales/en/accessibility.json | 27 ++++++ .../i18n/src/locales/en/translations.json | 2 +- .../i18n/src/locales/es/accessibility.json | 27 ++++++ .../i18n/src/locales/fr/accessibility.json | 27 ++++++ .../i18n/src/locales/id/accessibility.json | 27 ++++++ .../i18n/src/locales/it/accessibility.json | 27 ++++++ .../i18n/src/locales/ja/accessibility.json | 27 ++++++ .../i18n/src/locales/ko/accessibility.json | 27 ++++++ .../i18n/src/locales/pl/accessibility.json | 27 ++++++ .../i18n/src/locales/pt-BR/accessibility.json | 27 ++++++ .../i18n/src/locales/ro/accessibility.json | 27 ++++++ .../i18n/src/locales/ru/accessibility.json | 27 ++++++ .../i18n/src/locales/sk/accessibility.json | 27 ++++++ .../i18n/src/locales/tr-TR/accessibility.json | 27 ++++++ .../i18n/src/locales/ua/accessibility.json | 27 ++++++ .../i18n/src/locales/vi-VN/accessibility.json | 27 ++++++ .../i18n/src/locales/zh-CN/accessibility.json | 27 ++++++ .../i18n/src/locales/zh-TW/accessibility.json | 27 ++++++ packages/i18n/src/store/index.ts | 66 +++++--------- packages/ui/src/avatar/avatar.tsx | 2 +- packages/ui/src/dropdowns/custom-menu.tsx | 6 +- packages/ui/src/dropdowns/helper.tsx | 1 + .../(projects)/extended-sidebar.tsx | 6 +- web/ce/components/workspace/edition-badge.tsx | 10 ++- .../workspace/sidebar/app-search.tsx | 6 ++ .../sidebar/extended-sidebar-item.tsx | 2 +- .../workspace/sidebar/sidebar-item.tsx | 2 +- web/ce/constants/sidebar-favorites.ts | 4 +- web/core/components/workspace/logo.tsx | 47 +++++----- .../components/workspace/sidebar/dropdown.tsx | 21 ++--- .../sidebar/favorites/favorite-folder.tsx | 20 +++-- .../common/favorite-item-quick-action.tsx | 14 ++- .../sidebar/favorites/favorites-menu.tsx | 87 +++++++++++++------ .../sidebar/favorites/new-fav-folder.tsx | 9 +- .../workspace/sidebar/help-section.tsx | 7 +- .../workspace/sidebar/projects-list-item.tsx | 15 +++- .../workspace/sidebar/projects-list.tsx | 15 +++- .../workspace/sidebar/quick-actions.tsx | 4 +- .../workspace/sidebar/sidebar-menu-items.tsx | 62 +++++++------ web/core/store/theme.store.ts | 15 ++-- 43 files changed, 776 insertions(+), 169 deletions(-) create mode 100644 packages/i18n/src/locales/cs/accessibility.json create mode 100644 packages/i18n/src/locales/de/accessibility.json create mode 100644 packages/i18n/src/locales/en/accessibility.json create mode 100644 packages/i18n/src/locales/es/accessibility.json create mode 100644 packages/i18n/src/locales/fr/accessibility.json create mode 100644 packages/i18n/src/locales/id/accessibility.json create mode 100644 packages/i18n/src/locales/it/accessibility.json create mode 100644 packages/i18n/src/locales/ja/accessibility.json create mode 100644 packages/i18n/src/locales/ko/accessibility.json create mode 100644 packages/i18n/src/locales/pl/accessibility.json create mode 100644 packages/i18n/src/locales/pt-BR/accessibility.json create mode 100644 packages/i18n/src/locales/ro/accessibility.json create mode 100644 packages/i18n/src/locales/ru/accessibility.json create mode 100644 packages/i18n/src/locales/sk/accessibility.json create mode 100644 packages/i18n/src/locales/tr-TR/accessibility.json create mode 100644 packages/i18n/src/locales/ua/accessibility.json create mode 100644 packages/i18n/src/locales/vi-VN/accessibility.json create mode 100644 packages/i18n/src/locales/zh-CN/accessibility.json create mode 100644 packages/i18n/src/locales/zh-TW/accessibility.json 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..4a715f75be1 --- /dev/null +++ b/packages/i18n/src/locales/cs/accessibility.json @@ -0,0 +1,27 @@ +{ + "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ů" + } + } +} \ No newline at end of file diff --git a/packages/i18n/src/locales/de/accessibility.json b/packages/i18n/src/locales/de/accessibility.json new file mode 100644 index 00000000000..0faf0091681 --- /dev/null +++ b/packages/i18n/src/locales/de/accessibility.json @@ -0,0 +1,27 @@ +{ + "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" + } + } +} \ 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..35759d26627 --- /dev/null +++ b/packages/i18n/src/locales/en/accessibility.json @@ -0,0 +1,27 @@ +{ + "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" + } + } +} \ No newline at end of file diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index c959108e0a1..ead40fd1e53 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -2296,4 +2296,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..41bf0b777d0 --- /dev/null +++ b/packages/i18n/src/locales/es/accessibility.json @@ -0,0 +1,27 @@ +{ + "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" + } + } +} \ No newline at end of file diff --git a/packages/i18n/src/locales/fr/accessibility.json b/packages/i18n/src/locales/fr/accessibility.json new file mode 100644 index 00000000000..ba42a4f4183 --- /dev/null +++ b/packages/i18n/src/locales/fr/accessibility.json @@ -0,0 +1,27 @@ +{ + "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" + } + } +} \ No newline at end of file diff --git a/packages/i18n/src/locales/id/accessibility.json b/packages/i18n/src/locales/id/accessibility.json new file mode 100644 index 00000000000..2aca032cc11 --- /dev/null +++ b/packages/i18n/src/locales/id/accessibility.json @@ -0,0 +1,27 @@ +{ + "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" + } + } +} \ No newline at end of file diff --git a/packages/i18n/src/locales/it/accessibility.json b/packages/i18n/src/locales/it/accessibility.json new file mode 100644 index 00000000000..8f22d3b8ea4 --- /dev/null +++ b/packages/i18n/src/locales/it/accessibility.json @@ -0,0 +1,27 @@ +{ + "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" + } + } +} \ No newline at end of file diff --git a/packages/i18n/src/locales/ja/accessibility.json b/packages/i18n/src/locales/ja/accessibility.json new file mode 100644 index 00000000000..a598c435a9a --- /dev/null +++ b/packages/i18n/src/locales/ja/accessibility.json @@ -0,0 +1,27 @@ +{ + "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": "有料プランのモーダルを開く" + } + } +} \ No newline at end of file diff --git a/packages/i18n/src/locales/ko/accessibility.json b/packages/i18n/src/locales/ko/accessibility.json new file mode 100644 index 00000000000..491b8c35c6a --- /dev/null +++ b/packages/i18n/src/locales/ko/accessibility.json @@ -0,0 +1,27 @@ +{ + "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": "유료 플랜 모달 열기" + } + } +} \ No newline at end of file diff --git a/packages/i18n/src/locales/pl/accessibility.json b/packages/i18n/src/locales/pl/accessibility.json new file mode 100644 index 00000000000..5ff936d47f2 --- /dev/null +++ b/packages/i18n/src/locales/pl/accessibility.json @@ -0,0 +1,27 @@ +{ + "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" + } + } +} \ No newline at end of file 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..333b55a7fbc --- /dev/null +++ b/packages/i18n/src/locales/pt-BR/accessibility.json @@ -0,0 +1,27 @@ +{ + "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" + } + } +} \ No newline at end of file diff --git a/packages/i18n/src/locales/ro/accessibility.json b/packages/i18n/src/locales/ro/accessibility.json new file mode 100644 index 00000000000..1a201a48c8a --- /dev/null +++ b/packages/i18n/src/locales/ro/accessibility.json @@ -0,0 +1,27 @@ +{ + "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" + } + } +} \ No newline at end of file diff --git a/packages/i18n/src/locales/ru/accessibility.json b/packages/i18n/src/locales/ru/accessibility.json new file mode 100644 index 00000000000..ebec8dc2f86 --- /dev/null +++ b/packages/i18n/src/locales/ru/accessibility.json @@ -0,0 +1,27 @@ +{ + "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": "Открыть модал платных планов" + } + } +} \ No newline at end of file diff --git a/packages/i18n/src/locales/sk/accessibility.json b/packages/i18n/src/locales/sk/accessibility.json new file mode 100644 index 00000000000..59a309f6032 --- /dev/null +++ b/packages/i18n/src/locales/sk/accessibility.json @@ -0,0 +1,27 @@ +{ + "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" + } + } +} \ No newline at end of file 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..35b8f340e9c --- /dev/null +++ b/packages/i18n/src/locales/tr-TR/accessibility.json @@ -0,0 +1,27 @@ +{ + "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ç" + } + } +} \ No newline at end of file diff --git a/packages/i18n/src/locales/ua/accessibility.json b/packages/i18n/src/locales/ua/accessibility.json new file mode 100644 index 00000000000..b6bdc7d52be --- /dev/null +++ b/packages/i18n/src/locales/ua/accessibility.json @@ -0,0 +1,27 @@ +{ + "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": "Відкрити модал платних планів" + } + } +} \ No newline at end of file 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..8071da9e388 --- /dev/null +++ b/packages/i18n/src/locales/vi-VN/accessibility.json @@ -0,0 +1,27 @@ +{ + "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í" + } + } +} \ No newline at end of file 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..b19f68676ac --- /dev/null +++ b/packages/i18n/src/locales/zh-CN/accessibility.json @@ -0,0 +1,27 @@ +{ + "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": "打开付费计划模态框" + } + } +} \ No newline at end of file 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..97e07ae73b9 --- /dev/null +++ b/packages/i18n/src/locales/zh-TW/accessibility.json @@ -0,0 +1,27 @@ +{ + "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": "打開付費計劃模態框" + } + } +} \ No newline at end of file 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/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/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/ce/components/workspace/edition-badge.tsx b/web/ce/components/workspace/edition-badge.tsx index b32ce9e61ad..e0fefc3568c 100644 --- a/web/ce/components/workspace/edition-badge.tsx +++ b/web/ce/components/workspace/edition-badge.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import { observer } from "mobx-react"; import packageJson from "package.json"; -// ui +// plane imports +import { useTranslation } from "@plane/i18n"; import { Button, Tooltip } from "@plane/ui"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -9,9 +10,12 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; import { PaidPlanUpgradeModal } from "../license"; export const WorkspaceEditionBadge = observer(() => { - 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/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/core/components/workspace/logo.tsx b/web/core/components/workspace/logo.tsx index 460e076c5fc..f25615dfc22 100644 --- a/web/core/components/workspace/logo.tsx +++ b/web/core/components/workspace/logo.tsx @@ -1,4 +1,6 @@ -// plane utils +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; import { cn } from "@plane/utils"; // helpers import { getFileURL } from "@/helpers/file.helper"; @@ -9,22 +11,27 @@ type Props = { classNames?: string; }; -export const WorkspaceLogo = (props: Props) => ( -
- {props.logo && props.logo !== "" ? ( - Workspace Logo - ) : ( - (props.name?.charAt(0) ?? "...") - )} -
-); +export const WorkspaceLogo = observer((props: Props) => { + // translation + const { t } = useTranslation(); + + return ( +
+ {props.logo && props.logo !== "" ? ( + {t("aria_labels.projects_sidebar.workspace_logo")} + ) : ( + (props.name?.[0] ?? "...") + )} +
+ ); +}); diff --git a/web/core/components/workspace/sidebar/dropdown.tsx b/web/core/components/workspace/sidebar/dropdown.tsx index f9e2aca5989..b4e6b93d7b0 100644 --- a/web/core/components/workspace/sidebar/dropdown.tsx +++ b/web/core/components/workspace/sidebar/dropdown.tsx @@ -25,21 +25,17 @@ import { WorkspaceLogo } from "../logo"; import SidebarDropdownItem from "./dropdown-item"; export const SidebarDropdown = observer(() => { - const { t } = useTranslation(); - // store hooks const { sidebarCollapsed, toggleSidebar } = useAppTheme(); const { data: currentUser } = useUser(); - const { - // updateCurrentUser, - // isUserInstanceAdmin, - signOut, - } = useUser(); + const { signOut } = useUser(); const { updateUserProfile } = useUserProfile(); + const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace(); + // derived values const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false; - const isUserInstanceAdmin = false; - const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace(); + // translation + const { t } = useTranslation(); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -87,6 +83,7 @@ export const SidebarDropdown = observer(() => { "group/menu-button flex items-center justify-between gap-1 p-1 truncate rounded text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:outline-none", { "flex-grow": !sidebarCollapsed } )} + aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")} >
@@ -190,7 +187,11 @@ export const SidebarDropdown = observer(() => { )} - + = (props) => { const [isDragging, setIsDragging] = useState(false); const [folderToRename, setFolderToRename] = useState(null); const [instruction, setInstruction] = useState(undefined); - // refs const actionSectionRef = useRef(null); const elementRef = useRef(null); + // translation + const { t } = useTranslation(); useEffect(() => { if (favorite.children === undefined && workspaceSlug) { @@ -231,11 +231,11 @@ export const FavoriteFolder: React.FC = (props) => { setIsMenuActive(!isMenuActive)} > - + } + menuButtonOnClick={() => setIsMenuActive(!isMenuActive)} className={cn( "opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto", { @@ -244,6 +244,7 @@ export const FavoriteFolder: React.FC = (props) => { )} customButtonClassName="grid place-items-center" placement="bottom-start" + ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")} > handleRemoveFromFavorites(favorite)}> @@ -267,9 +268,12 @@ export const FavoriteFolder: React.FC = (props) => { "inline-block": isMenuActive, } )} + aria-label={t( + open ? "aria_labels.projects_sidebar.close_folder" : "aria_labels.projects_sidebar.open_folder" + )} > diff --git a/web/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-quick-action.tsx b/web/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-quick-action.tsx index eadaedb3458..cf6436733f4 100644 --- a/web/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-quick-action.tsx +++ b/web/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-quick-action.tsx @@ -1,8 +1,10 @@ "use client"; import React, { FC } from "react"; +import { observer } from "mobx-react"; import { MoreHorizontal, Star } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; import { IFavorite } from "@plane/types"; -// ui import { CustomMenu } from "@plane/ui"; // helpers import { cn } from "@/helpers/common.helper"; @@ -15,19 +17,22 @@ type Props = { handleRemoveFromFavorites: (favorite: IFavorite) => void; }; -export const FavoriteItemQuickAction: FC = (props) => { +export const FavoriteItemQuickAction: FC = observer((props) => { const { ref, isMenuActive, onChange, handleRemoveFromFavorites, favorite } = props; + // translation + const { t } = useTranslation(); + return ( onChange(!isMenuActive)} > } + menuButtonOnClick={() => onChange(!isMenuActive)} className={cn( "opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto", { @@ -36,6 +41,7 @@ export const FavoriteItemQuickAction: FC = (props) => { )} customButtonClassName="grid place-items-center" placement="bottom-start" + ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")} > handleRemoveFromFavorites(favorite)}> @@ -45,4 +51,4 @@ export const FavoriteItemQuickAction: FC = (props) => { ); -}; +}); diff --git a/web/core/components/workspace/sidebar/favorites/favorites-menu.tsx b/web/core/components/workspace/sidebar/favorites/favorites-menu.tsx index 2beed74f66b..f83f0ed061b 100644 --- a/web/core/components/workspace/sidebar/favorites/favorites-menu.tsx +++ b/web/core/components/workspace/sidebar/favorites/favorites-menu.tsx @@ -34,13 +34,12 @@ import { getInstructionFromPayload, TargetData } from "./favorites.helpers"; import { NewFavoriteFolder } from "./new-fav-folder"; export const SidebarFavoritesMenu = observer(() => { - //state + // states const [createNewFolder, setCreateNewFolder] = useState(null); - const [isDragging, setIsDragging] = useState(false); - + // navigation + const { workspaceSlug } = useParams(); // store hooks - const { t } = useTranslation(); const { sidebarCollapsed } = useAppTheme(); const { favoriteIds, @@ -50,17 +49,17 @@ export const SidebarFavoritesMenu = observer(() => { reOrderFavorite, moveFavoriteToFolder, } = useFavorite(); - const { workspaceSlug } = useParams(); - + // translation + const { t } = useTranslation(); + // platform hooks const { isMobile } = usePlatformOS(); - // local storage const { setValue: toggleFavoriteMenu, storedValue } = useLocalStorage(IS_FAVORITE_MENU_OPEN, false); // derived values const isFavoriteMenuOpen = !!storedValue; // refs - const containerRef = useRef(null); - const elementRef = useRef(null); + const containerRef = useRef(null); + const elementRef = useRef(null); const handleMoveToFolder = (sourceId: string, destinationId: string) => { moveFavoriteToFolder(workspaceSlug.toString(), sourceId, { @@ -131,6 +130,7 @@ export const SidebarFavoritesMenu = observer(() => { }); }); }; + const handleRemoveFromFavoritesFolder = (favoriteId: string) => { removeFromFavoriteFolder(workspaceSlug.toString(), favoriteId).catch(() => { setToast({ @@ -151,7 +151,7 @@ export const SidebarFavoritesMenu = observer(() => { }); }); }, - [workspaceSlug, reOrderFavorite] + [workspaceSlug, reOrderFavorite, t] ); useEffect(() => { @@ -190,37 +190,68 @@ export const SidebarFavoritesMenu = observer(() => { <> {!sidebarCollapsed && ( - - toggleFavoriteMenu(!isFavoriteMenuOpen)} className="flex-1 text-start"> - {t("favorites")} - - + toggleFavoriteMenu(!isFavoriteMenuOpen)} + aria-label={t( + isFavoriteMenuOpen + ? "aria_labels.projects_sidebar.close_favorites_menu" + : "aria_labels.projects_sidebar.open_favorites_menu" + )} + > + {t("favorites")} + +
- { setCreateNewFolder(true); if (!isFavoriteMenuOpen) toggleFavoriteMenu(!isFavoriteMenuOpen); }} - className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform")} - /> + aria-label={t("aria_labels.projects_sidebar.create_favorites_folder")} + > + + - toggleFavoriteMenu(!isFavoriteMenuOpen)} - className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", { - "rotate-90": isFavoriteMenuOpen, - })} - /> - - + aria-label={t( + isFavoriteMenuOpen + ? "aria_labels.projects_sidebar.close_favorites_menu" + : "aria_labels.projects_sidebar.open_favorites_menu" + )} + > + + +
+
)} { name="name" control={control} rules={{ required: true }} - render={({ field }) => } + render={({ field }) => ( + + )} />
diff --git a/web/core/components/workspace/sidebar/help-section.tsx b/web/core/components/workspace/sidebar/help-section.tsx index 04ef02fea86..9c1b98fe8be 100644 --- a/web/core/components/workspace/sidebar/help-section.tsx +++ b/web/core/components/workspace/sidebar/help-section.tsx @@ -175,8 +175,13 @@ export const SidebarHelpSection: React.FC = observer( isCollapsed ? "w-full" : "" }`} onClick={() => toggleSidebar()} + aria-label={t( + isCollapsed + ? "aria_labels.projects_sidebar.expand_sidebar" + : "aria_labels.projects_sidebar.collapse_sidebar" + )} > - +
diff --git a/web/core/components/workspace/sidebar/projects-list-item.tsx b/web/core/components/workspace/sidebar/projects-list-item.tsx index 715d02cb1c1..40b1f2be17b 100644 --- a/web/core/components/workspace/sidebar/projects-list-item.tsx +++ b/web/core/components/workspace/sidebar/projects-list-item.tsx @@ -184,13 +184,13 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { const sourceId = source?.data?.id as string | undefined; const destinationId = self?.data?.id as string | undefined; - handleOnProjectDrop && handleOnProjectDrop(sourceId, destinationId, currentInstruction === "DRAG_BELOW"); + handleOnProjectDrop?.(sourceId, destinationId, currentInstruction === "DRAG_BELOW"); highlightIssueOnDrop(`sidebar-${sourceId}-${projectListType}`); }, }) ); - }, [projectRef?.current, dragHandleRef?.current, projectId, isLastChild, projectListType, handleOnProjectDrop]); + }, [projectId, isLastChild, projectListType, handleOnProjectDrop]); useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); useOutsideClickDetector(projectRef, () => projectRef?.current?.classList?.remove(HIGHLIGHT_CLASS)); @@ -284,6 +284,11 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", { "justify-center": isSidebarCollapsed, })} + aria-label={ + isProjectListOpen + ? t("aria_labels.projects_sidebar.close_project_menu") + : t("aria_labels.projects_sidebar.open_project_menu") + } >
@@ -310,6 +315,7 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { )} customButtonClassName="grid place-items-center" placement="bottom-start" + ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")} useCaptureForOutsideClick closeOnSelect > @@ -384,6 +390,11 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { } )} onClick={() => setIsProjectListOpen(!isProjectListOpen)} + aria-label={t( + isProjectListOpen + ? "aria_labels.projects_sidebar.close_project_menu" + : "aria_labels.projects_sidebar.open_project_menu" + )} > { as="button" type="button" className={cn( - "group w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400", + "w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400", { "!text-center w-8 px-2 py-1.5 justify-center": isCollapsed, } )} onClick={() => toggleListDisclosure(!isAllProjectsListOpen)} + aria-label={t( + isAllProjectsListOpen + ? "aria_labels.projects_sidebar.close_projects_menu" + : "aria_labels.projects_sidebar.open_projects_menu" + )} > <> @@ -195,6 +200,7 @@ export const SidebarProjectsList: FC = observer(() => { setTrackElement(`APP_SIDEBAR_JOINED_BLOCK`); setIsProjectModalOpen(true); }} + aria-label={t("aria_labels.projects_sidebar.create_new_project")} > @@ -205,9 +211,14 @@ export const SidebarProjectsList: FC = observer(() => { type="button" className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0" onClick={() => toggleListDisclosure(!isAllProjectsListOpen)} + aria-label={t( + isAllProjectsListOpen + ? "aria_labels.projects_sidebar.close_projects_menu" + : "aria_labels.projects_sidebar.open_projects_menu" + )} > diff --git a/web/core/components/workspace/sidebar/quick-actions.tsx b/web/core/components/workspace/sidebar/quick-actions.tsx index 150c08d6838..86ab8960300 100644 --- a/web/core/components/workspace/sidebar/quick-actions.tsx +++ b/web/core/components/workspace/sidebar/quick-actions.tsx @@ -46,7 +46,9 @@ export const SidebarQuickActions = observer(() => { const handleMouseEnter = () => { // if enter before time out clear the timeout - timeoutRef?.current && clearTimeout(timeoutRef.current); + if (timeoutRef?.current) { + clearTimeout(timeoutRef.current); + } setIsDraftButtonOpen(true); }; diff --git a/web/core/components/workspace/sidebar/sidebar-menu-items.tsx b/web/core/components/workspace/sidebar/sidebar-menu-items.tsx index bedfa02a30d..89c0f150eaa 100644 --- a/web/core/components/workspace/sidebar/sidebar-menu-items.tsx +++ b/web/core/components/workspace/sidebar/sidebar-menu-items.tsx @@ -8,6 +8,7 @@ import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS, } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { cn } from "@plane/utils"; // components import { SidebarNavItem } from "@/components/sidebar"; @@ -20,9 +21,10 @@ export const SidebarMenuItems = observer(() => { // routers const { workspaceSlug } = useParams(); // store hooks - const { sidebarCollapsed, toggleExtendedSidebar } = useAppTheme(); + const { sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme(); const { getNavigationPreferences } = useWorkspace(); - + // translation + const { t } = useTranslation(); // derived values const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString()); @@ -39,31 +41,35 @@ export const SidebarMenuItems = observer(() => { ); return ( - <> -
- {WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS.map((item, _index) => ( - - ))} - {sortedNavigationItems.map((item, _index) => ( - - ))} - - - -
- +
+ {WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS.map((item, _index) => ( + + ))} + {sortedNavigationItems.map((item, _index) => ( + + ))} + + + +
); }); diff --git a/web/core/store/theme.store.ts b/web/core/store/theme.store.ts index a5d961e2db2..b37fb0ef5f3 100644 --- a/web/core/store/theme.store.ts +++ b/web/core/store/theme.store.ts @@ -1,4 +1,4 @@ -import { action, observable, makeObservable } from "mobx"; +import { action, observable, makeObservable, runInAction } from "mobx"; export interface IThemeStore { // observables @@ -26,7 +26,7 @@ export interface IThemeStore { export class ThemeStore implements IThemeStore { // observables sidebarCollapsed: boolean | undefined = undefined; - extendedSidebarCollapsed: boolean | undefined = undefined; + extendedSidebarCollapsed: boolean | undefined = true; extendedProjectSidebarCollapsed: boolean | undefined = undefined; profileSidebarCollapsed: boolean | undefined = undefined; workspaceAnalyticsSidebarCollapsed: boolean | undefined = undefined; @@ -78,12 +78,11 @@ export class ThemeStore implements IThemeStore { * @param collapsed */ toggleExtendedSidebar = (collapsed?: boolean) => { - if (collapsed === undefined) { - this.extendedSidebarCollapsed = !this.extendedSidebarCollapsed; - } else { - this.extendedSidebarCollapsed = collapsed; - } - localStorage.setItem("extended_sidebar_collapsed", this.extendedSidebarCollapsed.toString()); + const updatedState = collapsed ?? !this.extendedSidebarCollapsed; + runInAction(() => { + this.extendedSidebarCollapsed = updatedState; + }); + localStorage.setItem("extended_sidebar_collapsed", updatedState.toString()); }; /** From e388a9a2797f8e65e201c4d3d38df2f53fce0cf7 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 28 May 2025 01:43:01 +0530 Subject: [PATCH 08/15] [WIKI-181] refactor: file plugins and types (#7074) * refactor: file plugins and types * refactor: image extension storage types * chore: update meta tag name * chore: extension fileset storage key * fix: build errors * refactor: utility extension * refactor: file plugins * chore: remove standalone plugin extensions * chore: refactoring out onCreate into a common utility * refactor: work item embed extension * chore: use extension enums * fix: errors and warnings * refactor: rename extension files * fix: tsup reloading issue * fix: image upload types and heading types * fix: file plugin object reference * fix: iseditable is hard coded * fix: image extension names * fix: collaborative editor editable value * chore: add constants for editor meta as well --------- Co-authored-by: Palanikannan M --- live/package.json | 2 +- packages/decorators/package.json | 2 +- packages/editor/package.json | 2 +- packages/editor/src/ce/constants/utility.ts | 14 + .../src/ce/extensions/document-extensions.tsx | 1 - packages/editor/src/ce/types/storage.ts | 27 +- .../editors/document/collaborative-editor.tsx | 5 +- .../editors/document/read-only-editor.tsx | 4 +- .../components/editors/editor-container.tsx | 17 +- .../editors/link-view-container.tsx | 2 +- .../core/components/links/link-edit-view.tsx | 4 +- .../src/core/components/menus/block-menu.tsx | 9 +- .../menus/bubble-menu/link-selector.tsx | 6 +- .../menus/bubble-menu/node-selector.tsx | 2 +- .../components/menus/bubble-menu/root.tsx | 5 +- .../src/core/components/menus/menu-items.ts | 107 ++--- .../editor/src/core/constants/extension.ts | 44 ++ packages/editor/src/core/constants/meta.ts | 3 + .../src/core/extensions/callout/block.tsx | 2 +- .../extensions/callout/extension-config.ts | 8 +- .../core/extensions/callout/logo-selector.tsx | 7 +- .../src/core/extensions/callout/types.ts | 2 +- .../src/core/extensions/callout/utils.ts | 15 +- .../editor/src/core/extensions/clipboard.ts | 89 ---- .../src/core/extensions/code-inline/index.tsx | 6 +- .../extensions/code/code-block-node-view.tsx | 4 +- .../src/core/extensions/code/code-block.ts | 12 +- .../core/extensions/code/lowlight-plugin.ts | 2 +- .../src/core/extensions/core-without-props.ts | 18 +- .../src/core/extensions/custom-code-inline.ts | 9 - .../src/core/extensions/custom-color.ts | 5 +- .../custom-image/components/image-node.tsx | 5 +- .../components/image-uploader.tsx | 8 +- .../custom-image/components/upload-status.tsx | 6 +- .../extensions/custom-image/custom-image.ts | 49 +-- .../custom-image/read-only-custom-image.ts | 10 +- .../core/extensions/custom-link/extension.tsx | 7 +- .../custom-link/helpers/clickHandler.ts | 2 +- .../custom-list-keymap/list-helpers.ts | 16 +- .../custom-list-keymap/list-keymap.ts | 12 +- packages/editor/src/core/extensions/drop.ts | 127 ------ .../{enter-key-extension.tsx => enter-key.ts} | 17 +- .../{extensions.tsx => extensions.ts} | 30 +- .../{headers.ts => headings-list.ts} | 6 +- .../src/core/extensions/horizontal-rule.ts | 6 +- .../src/core/extensions/image/extension.tsx | 38 +- .../image/image-component-without-props.tsx | 102 +++-- .../image/image-extension-without-props.tsx | 35 +- .../core/extensions/image/read-only-image.tsx | 4 +- packages/editor/src/core/extensions/index.ts | 10 +- .../src/core/extensions/issue-embed/index.ts | 2 - .../issue-embed/issue-embed-without-props.ts | 41 -- .../extensions/issue-embed/widget-node.tsx | 66 --- .../core/extensions/{keymap.tsx => keymap.ts} | 12 +- .../extensions/mentions/mention-node-view.tsx | 2 +- .../mentions/mentions-list-dropdown.tsx | 10 +- .../src/core/extensions/mentions/utils.ts | 6 +- .../core/extensions/{quote.tsx => quote.ts} | 4 +- ...extensions.tsx => read-only-extensions.ts} | 7 +- .../{side-menu.tsx => side-menu.ts} | 4 +- .../slash-commands/command-items-list.tsx | 23 +- .../slash-commands/command-menu.tsx | 9 +- .../core/extensions/slash-commands/root.tsx | 12 +- .../table/{table-cell => }/table-cell.ts | 5 +- .../core/extensions/table/table-cell/index.ts | 1 - .../table/{table-header => }/table-header.ts | 5 +- .../extensions/table/table-header/index.ts | 1 - .../table/{table-row => }/table-row.ts | 4 +- .../core/extensions/table/table-row/index.ts | 1 - .../extensions/table/table/table-controls.ts | 20 +- .../extensions/table/table/table-view.tsx | 22 +- .../src/core/extensions/table/table/table.ts | 23 +- .../delete-table-when-all-cells-selected.ts | 11 +- .../insert-line-above-table-action.ts | 8 +- .../insert-line-below-table-action.ts | 10 +- .../src/core/extensions/typography/index.ts | 4 +- .../editor/src/core/extensions/utility.ts | 71 ++++ .../work-item-embed/extension-config.ts | 43 ++ .../extensions/work-item-embed/extension.tsx | 30 ++ .../core/extensions/work-item-embed/index.ts | 1 + packages/editor/src/core/helpers/common.ts | 10 +- .../src/core/helpers/editor-commands.ts | 48 +-- packages/editor/src/core/helpers/file.ts | 18 +- .../editor/src/core/helpers/image-helpers.ts | 32 ++ ...insert-empty-paragraph-at-node-boundary.ts | 10 +- .../core/hooks/use-collaborative-editor.ts | 2 +- packages/editor/src/core/hooks/use-editor.ts | 39 +- .../editor/src/core/hooks/use-file-upload.ts | 29 +- .../src/core/hooks/use-read-only-editor.ts | 3 +- .../editor/src/core/plugins/drag-handle.ts | 31 +- packages/editor/src/core/plugins/drop.ts | 118 ++++++ .../editor/src/core/plugins/file/delete.ts | 67 +++ .../editor/src/core/plugins/file/restore.ts | 72 ++++ packages/editor/src/core/plugins/file/root.ts | 22 + .../editor/src/core/plugins/file/types.ts | 8 + .../src/core/plugins/image/delete-image.ts | 52 --- .../editor/src/core/plugins/image/index.ts | 3 - .../src/core/plugins/image/restore-image.ts | 61 --- .../core/plugins/image/types/image-node.ts | 13 - .../src/core/plugins/image/types/index.ts | 1 - .../src/core/plugins/markdown-clipboard.ts | 80 ++++ .../editor/src/core/types/collaboration.ts | 2 +- packages/editor/src/core/types/config.ts | 10 +- packages/editor/src/core/types/image.ts | 5 - packages/editor/src/core/types/index.ts | 1 - packages/hooks/package.json | 2 +- packages/ui/package.json | 2 +- packages/utils/package.json | 2 +- web/core/store/pages/base-page.ts | 1 - yarn.lock | 391 ++++++++++-------- 110 files changed, 1362 insertions(+), 1156 deletions(-) create mode 100644 packages/editor/src/ce/constants/utility.ts create mode 100644 packages/editor/src/core/constants/extension.ts create mode 100644 packages/editor/src/core/constants/meta.ts delete mode 100644 packages/editor/src/core/extensions/clipboard.ts delete mode 100644 packages/editor/src/core/extensions/custom-code-inline.ts delete mode 100644 packages/editor/src/core/extensions/drop.ts rename packages/editor/src/core/extensions/{enter-key-extension.tsx => enter-key.ts} (53%) rename packages/editor/src/core/extensions/{extensions.tsx => extensions.ts} (86%) rename packages/editor/src/core/extensions/{headers.ts => headings-list.ts} (86%) delete mode 100644 packages/editor/src/core/extensions/issue-embed/index.ts delete mode 100644 packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts delete mode 100644 packages/editor/src/core/extensions/issue-embed/widget-node.tsx rename packages/editor/src/core/extensions/{keymap.tsx => keymap.ts} (92%) rename packages/editor/src/core/extensions/{quote.tsx => quote.ts} (85%) rename packages/editor/src/core/extensions/{read-only-extensions.tsx => read-only-extensions.ts} (97%) rename packages/editor/src/core/extensions/{side-menu.tsx => side-menu.ts} (97%) rename packages/editor/src/core/extensions/table/{table-cell => }/table-cell.ts (91%) delete mode 100644 packages/editor/src/core/extensions/table/table-cell/index.ts rename packages/editor/src/core/extensions/table/{table-header => }/table-header.ts (90%) delete mode 100644 packages/editor/src/core/extensions/table/table-header/index.ts rename packages/editor/src/core/extensions/table/{table-row => }/table-row.ts (88%) delete mode 100644 packages/editor/src/core/extensions/table/table-row/index.ts create mode 100644 packages/editor/src/core/extensions/utility.ts create mode 100644 packages/editor/src/core/extensions/work-item-embed/extension-config.ts create mode 100644 packages/editor/src/core/extensions/work-item-embed/extension.tsx create mode 100644 packages/editor/src/core/extensions/work-item-embed/index.ts create mode 100644 packages/editor/src/core/helpers/image-helpers.ts create mode 100644 packages/editor/src/core/plugins/drop.ts create mode 100644 packages/editor/src/core/plugins/file/delete.ts create mode 100644 packages/editor/src/core/plugins/file/restore.ts create mode 100644 packages/editor/src/core/plugins/file/root.ts create mode 100644 packages/editor/src/core/plugins/file/types.ts delete mode 100644 packages/editor/src/core/plugins/image/delete-image.ts delete mode 100644 packages/editor/src/core/plugins/image/index.ts delete mode 100644 packages/editor/src/core/plugins/image/restore-image.ts delete mode 100644 packages/editor/src/core/plugins/image/types/image-node.ts delete mode 100644 packages/editor/src/core/plugins/image/types/index.ts create mode 100644 packages/editor/src/core/plugins/markdown-clipboard.ts delete mode 100644 packages/editor/src/core/types/image.ts diff --git a/live/package.json b/live/package.json index 9616b151307..3dcb8b35ead 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.3.0", "typescript": "5.3.3" } } diff --git a/packages/decorators/package.json b/packages/decorators/package.json index 433b5c11a4b..198fdc69808 100644 --- a/packages/decorators/package.json +++ b/packages/decorators/package.json @@ -27,7 +27,7 @@ "@types/node": "^20.14.9", "@types/reflect-metadata": "^0.1.0", "@types/ws": "^8.5.10", - "tsup": "8.4.0", + "tsup": "8.3.0", "typescript": "^5.3.3" }, "peerDependencies": { diff --git a/packages/editor/package.json b/packages/editor/package.json index cfbd0861e7d..5a899f738db 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.3.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-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx index e525bc6da4b..f8bfcf4a1f4 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,7 +1,10 @@ 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 = { @@ -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..5af4f556d72 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,10 +4,12 @@ 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"; // hooks import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload"; +import { getExtensionStorage } from "@/helpers/get-extension-storage"; type CustomImageUploaderProps = CustoBaseImageNodeViewProps & { maxFileSize: number; @@ -57,7 +59,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 { @@ -75,7 +77,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { // @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; + getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).uploadInProgress = isUploading; }, loadFileFromFileSystem: loadImageFromFileSystem, maxFileSize, @@ -85,6 +87,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, editor, maxFileSize, + onInvalidFile: (_error, message) => alert(message), pos: getPos(), type: "image", uploader: uploadFile, @@ -123,6 +126,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { editor, filesList, maxFileSize, + onInvalidFile: (_error, message) => alert(message), 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..a0cd739157c 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; @@ -111,16 +115,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: "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); + } + } 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,7 +150,7 @@ 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" }); @@ -179,7 +186,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 +198,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 +231,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 +243,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..e40c1591341 100644 --- a/packages/editor/src/core/hooks/use-file-upload.ts +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -1,9 +1,9 @@ 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"; @@ -13,12 +13,20 @@ type TUploaderArgs = { handleProgressStatus?: (isUploading: boolean) => void; loadFileFromFileSystem?: (file: string) => void; maxFileSize: number; + onInvalidFile: (error: EFileError, 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: onInvalidFile, }); if (!isValid) { handleProgressStatus?.(false); @@ -75,13 +84,14 @@ type TDropzoneArgs = { acceptedMimeTypes: string[]; editor: Editor; maxFileSize: number; + onInvalidFile: (error: EFileError, message: string) => void; pos: number; type: Extract; uploader: (file: File) => Promise; }; export const useDropZone = (args: TDropzoneArgs) => { - const { acceptedMimeTypes, editor, maxFileSize, pos, type, uploader } = args; + const { acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader } = args; // states const [isDragging, setIsDragging] = useState(false); const [draggedInside, setDraggedInside] = useState(false); @@ -117,12 +127,13 @@ export const useDropZone = (args: TDropzoneArgs) => { editor, filesList, maxFileSize, + onInvalidFile, pos, type, uploader, }); }, - [acceptedMimeTypes, editor, maxFileSize, pos, type, uploader] + [acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader] ); const onDragEnter = useCallback(() => setDraggedInside(true), []); const onDragLeave = useCallback(() => setDraggedInside(false), []); @@ -141,6 +152,7 @@ type TMultipleFileArgs = { editor: Editor; filesList: FileList; maxFileSize: number; + onInvalidFile: (error: EFileError, message: string) => void; pos: number; type: Extract; uploader: (file: File) => Promise; @@ -148,7 +160,7 @@ 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 { acceptedMimeTypes, editor, filesList, maxFileSize, onInvalidFile, pos, type, uploader } = args; const filteredFiles: File[] = []; for (let i = 0; i < filesList.length; i += 1) { const file = filesList.item(i); @@ -158,6 +170,7 @@ export const uploadFirstFileAndInsertRemaining = async (args: TMultipleFileArgs) acceptedMimeTypes, file, maxFileSize, + onError: onInvalidFile, }) ) { filteredFiles.push(file); 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..5bd731d5f19 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[]; @@ -75,7 +76,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { 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" }); 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..b77841c2298 --- /dev/null +++ b/packages/editor/src/core/plugins/file/delete.ts @@ -0,0 +1,67 @@ +import { Editor } from "@tiptap/core"; +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +// 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("skipFileDeletion")) 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..b72e3dcf654 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -1,19 +1,17 @@ -import { DeleteImage, RestoreImage, UploadImage } from "@/types"; - export type TReadOnlyFileHandler = { 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..320203e2d8d 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.3.0", "typescript": "^5.3.3" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 45550f36979..e0920d49ec9 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.3.0", "typescript": "5.3.3" } } diff --git a/packages/utils/package.json b/packages/utils/package.json index fc8600077d8..07504d9a48d 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -29,7 +29,7 @@ "@types/node": "^22.5.4", "@types/react": "^18.3.11", "@types/zxcvbn": "^4.4.5", - "tsup": "^8.4.0", + "tsup": "8.3.0", "typescript": "^5.3.3" } } diff --git a/web/core/store/pages/base-page.ts b/web/core/store/pages/base-page.ts index 32a087c95ff..6639e8e84dc 100644 --- a/web/core/store/pages/base-page.ts +++ b/web/core/store/pages/base-page.ts @@ -529,7 +529,6 @@ export class BasePage implements TBasePage { }; setEditorRef = (editorRef: EditorRefApi | null) => { - console.log("store editorRef", editorRef); runInAction(() => { this.editorRef = editorRef; }); diff --git a/yarn.lock b/yarn.lock index 22d8754dc6e..6fca25f4d00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2236,100 +2236,105 @@ resolved "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz#96fdb89d25c62e7b6a5d08caf0ce5114370e3b8f" integrity sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg== -"@rollup/rollup-android-arm-eabi@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.35.0.tgz#e1d7700735f7e8de561ef7d1fa0362082a180c43" - integrity sha512-uYQ2WfPaqz5QtVgMxfN6NpLD+no0MYHDBywl7itPYd3K5TjjSghNKmX8ic9S8NU8w81NVhJv/XojcHptRly7qQ== - -"@rollup/rollup-android-arm64@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.35.0.tgz#fa6cdfb1fc9e2c8e227a7f35d524d8f7f90cf4db" - integrity sha512-FtKddj9XZudurLhdJnBl9fl6BwCJ3ky8riCXjEw3/UIbjmIY58ppWwPEvU3fNu+W7FUsAsB1CdH+7EQE6CXAPA== - -"@rollup/rollup-darwin-arm64@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.35.0.tgz#6da5a1ddc4f11d4a7ae85ab443824cb6bf614e30" - integrity sha512-Uk+GjOJR6CY844/q6r5DR/6lkPFOw0hjfOIzVx22THJXMxktXG6CbejseJFznU8vHcEBLpiXKY3/6xc+cBm65Q== - -"@rollup/rollup-darwin-x64@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.35.0.tgz#25b74ce2d8d3f9ea8e119b01384d44a1c0a0d3ae" - integrity sha512-3IrHjfAS6Vkp+5bISNQnPogRAW5GAV1n+bNCrDwXmfMHbPl5EhTmWtfmwlJxFRUCBZ+tZ/OxDyU08aF6NI/N5Q== - -"@rollup/rollup-freebsd-arm64@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.35.0.tgz#be3d39e3441df5d6e187c83d158c60656c82e203" - integrity sha512-sxjoD/6F9cDLSELuLNnY0fOrM9WA0KrM0vWm57XhrIMf5FGiN8D0l7fn+bpUeBSU7dCgPV2oX4zHAsAXyHFGcQ== - -"@rollup/rollup-freebsd-x64@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.35.0.tgz#cd932d3ec679711efd65ca25821fb318e25b7ce4" - integrity sha512-2mpHCeRuD1u/2kruUiHSsnjWtHjqVbzhBkNVQ1aVD63CcexKVcQGwJ2g5VphOd84GvxfSvnnlEyBtQCE5hxVVw== - -"@rollup/rollup-linux-arm-gnueabihf@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.35.0.tgz#d300b74c6f805474225632f185daaeae760ac2bb" - integrity sha512-mrA0v3QMy6ZSvEuLs0dMxcO2LnaCONs1Z73GUDBHWbY8tFFocM6yl7YyMu7rz4zS81NDSqhrUuolyZXGi8TEqg== - -"@rollup/rollup-linux-arm-musleabihf@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.35.0.tgz#2caac622380f314c41934ed1e68ceaf6cc380cc3" - integrity sha512-DnYhhzcvTAKNexIql8pFajr0PiDGrIsBYPRvCKlA5ixSS3uwo/CWNZxB09jhIapEIg945KOzcYEAGGSmTSpk7A== - -"@rollup/rollup-linux-arm64-gnu@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.35.0.tgz#1ec841650b038cc15c194c26326483fd7ebff3e3" - integrity sha512-uagpnH2M2g2b5iLsCTZ35CL1FgyuzzJQ8L9VtlJ+FckBXroTwNOaD0z0/UF+k5K3aNQjbm8LIVpxykUOQt1m/A== - -"@rollup/rollup-linux-arm64-musl@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.35.0.tgz#2fc70a446d986e27f6101ea74e81746987f69150" - integrity sha512-XQxVOCd6VJeHQA/7YcqyV0/88N6ysSVzRjJ9I9UA/xXpEsjvAgDTgH3wQYz5bmr7SPtVK2TsP2fQ2N9L4ukoUg== - -"@rollup/rollup-linux-loongarch64-gnu@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.35.0.tgz#561bd045cd9ce9e08c95f42e7a8688af8c93d764" - integrity sha512-5pMT5PzfgwcXEwOaSrqVsz/LvjDZt+vQ8RT/70yhPU06PTuq8WaHhfT1LW+cdD7mW6i/J5/XIkX/1tCAkh1W6g== - -"@rollup/rollup-linux-powerpc64le-gnu@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.35.0.tgz#45d849a0b33813f33fe5eba9f99e0ff15ab5caad" - integrity sha512-c+zkcvbhbXF98f4CtEIP1EBA/lCic5xB0lToneZYvMeKu5Kamq3O8gqrxiYYLzlZH6E3Aq+TSW86E4ay8iD8EA== - -"@rollup/rollup-linux-riscv64-gnu@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.35.0.tgz#78dde3e6fcf5b5733a97d0a67482d768aa1e83a5" - integrity sha512-s91fuAHdOwH/Tad2tzTtPX7UZyytHIRR6V4+2IGlV0Cej5rkG0R61SX4l4y9sh0JBibMiploZx3oHKPnQBKe4g== - -"@rollup/rollup-linux-s390x-gnu@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.35.0.tgz#2e34835020f9e03dfb411473a5c2a0e8a9c5037b" - integrity sha512-hQRkPQPLYJZYGP+Hj4fR9dDBMIM7zrzJDWFEMPdTnTy95Ljnv0/4w/ixFw3pTBMEuuEuoqtBINYND4M7ujcuQw== - -"@rollup/rollup-linux-x64-gnu@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.35.0.tgz#4f9774beddc6f4274df57ac99862eb23040de461" - integrity sha512-Pim1T8rXOri+0HmV4CdKSGrqcBWX0d1HoPnQ0uw0bdp1aP5SdQVNBy8LjYncvnLgu3fnnCt17xjWGd4cqh8/hA== - -"@rollup/rollup-linux-x64-musl@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.35.0.tgz#dfcff2c1aed518b3d23ccffb49afb349d74fb608" - integrity sha512-QysqXzYiDvQWfUiTm8XmJNO2zm9yC9P/2Gkrwg2dH9cxotQzunBHYr6jk4SujCTqnfGxduOmQcI7c2ryuW8XVg== - -"@rollup/rollup-win32-arm64-msvc@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.35.0.tgz#b0b37e2d77041e3aa772f519291309abf4c03a84" - integrity sha512-OUOlGqPkVJCdJETKOCEf1mw848ZyJ5w50/rZ/3IBQVdLfR5jk/6Sr5m3iO2tdPgwo0x7VcncYuOvMhBWZq8ayg== - -"@rollup/rollup-win32-ia32-msvc@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.35.0.tgz#5b5a40e44a743ddc0e06b8e1b3982f856dc9ce0a" - integrity sha512-2/lsgejMrtwQe44glq7AFFHLfJBPafpsTa6JvP2NGef/ifOa4KBoglVf7AKN7EV9o32evBPRqfg96fEHzWo5kw== - -"@rollup/rollup-win32-x64-msvc@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.35.0.tgz#05f25dbc9981bee1ae6e713daab10397044a46ca" - integrity sha512-PIQeY5XDkrOysbQblSW7v3l1MDZzkTEzAfTPkj5VAu3FW8fS4ynyLg2sINp0fp3SjZ8xkRYpLqoKcYqAkhU1dw== +"@rollup/rollup-android-arm-eabi@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.1.tgz#f39f09f60d4a562de727c960d7b202a2cf797424" + integrity sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw== + +"@rollup/rollup-android-arm64@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.1.tgz#d19af7e23760717f1d879d4ca3d2cd247742dff2" + integrity sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA== + +"@rollup/rollup-darwin-arm64@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.1.tgz#1c3a2fbf205d80641728e05f4a56c909e95218b7" + integrity sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w== + +"@rollup/rollup-darwin-x64@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.1.tgz#aa66d2ba1a25e609500e13bef06dc0e71cc0c0d4" + integrity sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg== + +"@rollup/rollup-freebsd-arm64@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.1.tgz#df10a7b6316a0ef1028c6ca71a081124c537e30d" + integrity sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg== + +"@rollup/rollup-freebsd-x64@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.1.tgz#a3fdce8a05e95b068cbcb46e4df5185e407d0c35" + integrity sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA== + +"@rollup/rollup-linux-arm-gnueabihf@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.1.tgz#49f766c55383bd0498014a9d76924348c2f3890c" + integrity sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg== + +"@rollup/rollup-linux-arm-musleabihf@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.1.tgz#1d4d7d32fc557e17d52e1857817381ea365e2959" + integrity sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA== + +"@rollup/rollup-linux-arm64-gnu@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.1.tgz#f4fc317268441e9589edad3be8f62b6c03009bc1" + integrity sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA== + +"@rollup/rollup-linux-arm64-musl@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.1.tgz#63a1f1b0671cb17822dabae827fef0e443aebeb7" + integrity sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg== + +"@rollup/rollup-linux-loongarch64-gnu@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.1.tgz#c659b01cc6c0730b547571fc3973e1e955369f98" + integrity sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw== + +"@rollup/rollup-linux-powerpc64le-gnu@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.1.tgz#612e746f9ad7e58480f964d65e0d6c3f4aae69a8" + integrity sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A== + +"@rollup/rollup-linux-riscv64-gnu@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.1.tgz#4610dbd1dcfbbae32fbc10c20ae7387acb31110c" + integrity sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw== + +"@rollup/rollup-linux-riscv64-musl@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.1.tgz#054911fab40dc83fafc21e470193c058108f19d8" + integrity sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw== + +"@rollup/rollup-linux-s390x-gnu@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.1.tgz#98896eca8012547c7f04bd07eaa6896825f9e1a5" + integrity sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g== + +"@rollup/rollup-linux-x64-gnu@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.1.tgz#01cf56844a1e636ee80dfb364e72c2b7142ad896" + integrity sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A== + +"@rollup/rollup-linux-x64-musl@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.1.tgz#e67c7531df6dff0b4c241101d4096617fbca87c3" + integrity sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ== + +"@rollup/rollup-win32-arm64-msvc@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.1.tgz#7eeada98444e580674de6989284e4baacd48ea65" + integrity sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ== + +"@rollup/rollup-win32-ia32-msvc@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.1.tgz#516c4b54f80587b4a390aaf4940b40870271d35d" + integrity sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg== + +"@rollup/rollup-win32-x64-msvc@4.41.1": + version "4.41.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.1.tgz#848f99b0d9936d92221bb6070baeff4db6947a30" + integrity sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw== "@rtsao/scc@^1.1.0": version "1.1.0" @@ -3323,11 +3328,16 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@1.0.6", "@types/estree@^1.0.0", "@types/estree@^1.0.6": +"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6": version "1.0.6" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== +"@types/estree@1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" + integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== + "@types/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0": version "5.0.6" resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz#41fec4ea20e9c7b22f024ab88a95c6bb288f51b8" @@ -4604,9 +4614,9 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" -bundle-require@^5.1.0: +bundle-require@^5.0.0: version "5.1.0" - resolved "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz#8db66f41950da3d77af1ef3322f4c3e04009faee" + resolved "https://registry.yarnpkg.com/bundle-require/-/bundle-require-5.1.0.tgz#8db66f41950da3d77af1ef3322f4c3e04009faee" integrity sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA== dependencies: load-tsconfig "^0.2.3" @@ -4761,13 +4771,6 @@ chokidar@^3.3.0, chokidar@^3.5.2, chokidar@^3.5.3, chokidar@^3.6.0: optionalDependencies: fsevents "~2.3.2" -chokidar@^4.0.3: - version "4.0.3" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" - integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== - dependencies: - readdirp "^4.0.1" - chownr@^1.1.1: version "1.1.4" resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" @@ -4992,10 +4995,10 @@ concurrently@^9.0.1: tree-kill "^1.2.2" yargs "^17.7.2" -consola@^3.4.0: - version "3.4.0" - resolved "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz#4cfc9348fd85ed16a17940b3032765e31061ab88" - integrity sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA== +consola@^3.2.3: + version "3.4.2" + resolved "https://registry.yarnpkg.com/consola/-/consola-3.4.2.tgz#5af110145397bb67afdab77013fdc34cae590ea7" + integrity sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA== constant-case@^3.0.4: version "3.0.4" @@ -5099,7 +5102,7 @@ cross-fetch@^3.1.5: dependencies: node-fetch "^2.7.0" -cross-spawn@^7.0.0, cross-spawn@^7.0.2: +cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.6" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -5364,7 +5367,7 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.7, debug@^4.4.0: +debug@4, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.7: version "4.4.0" resolved "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a" integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== @@ -5378,6 +5381,13 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +debug@^4.3.5: + version "4.4.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== + dependencies: + ms "^2.1.3" + decimal.js-light@^2.4.1: version "2.5.1" resolved "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" @@ -5930,7 +5940,7 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -esbuild@0.25.0, "esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0", esbuild@^0.25.0: +esbuild@0.25.0, "esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0", esbuild@^0.23.0: version "0.25.0" resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz#0de1787a77206c5a79eeb634a623d39b5006ce92" integrity sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw== @@ -6266,6 +6276,21 @@ events@^3.2.0, events@^3.3.0: resolved "https://registry.npmjs.org/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +execa@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + expand-template@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" @@ -6400,10 +6425,10 @@ fault@^2.0.0: dependencies: format "^0.2.0" -fdir@^6.4.3: - version "6.4.3" - resolved "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz#011cdacf837eca9b811c89dbb902df714273db72" - integrity sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw== +fdir@^6.4.4: + version "6.4.4" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.4.tgz#1cfcf86f875a883e19a8fab53622cfe992e8d2f9" + integrity sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg== fecha@^4.2.0: version "4.2.3" @@ -6737,6 +6762,11 @@ get-stdin@^9.0.0: resolved "https://registry.npmjs.org/get-stdin/-/get-stdin-9.0.0.tgz#3983ff82e03d56f1b2ea0d3e60325f39d703a575" integrity sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA== +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + get-symbol-description@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" @@ -7064,6 +7094,11 @@ https-proxy-agent@^7.0.6: agent-base "^7.1.2" debug "4" +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + hyphen@^1.6.4: version "1.10.6" resolved "https://registry.npmjs.org/hyphen/-/hyphen-1.10.6.tgz#0e779d280e696102b97d7e42f5ca5de2cc97e274" @@ -8286,6 +8321,11 @@ mime@1.6.0: resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + mimic-response@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" @@ -8547,6 +8587,13 @@ normalize.css@^8.0.1: resolved "https://registry.npmjs.org/normalize.css/-/normalize.css-8.0.1.tgz#9b98a208738b9cc2634caacbc42d131c97487bf3" integrity sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg== +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + nprogress@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1" @@ -8685,6 +8732,13 @@ one-time@^1.0.0: dependencies: fn.name "1.x.x" +onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + open@^8.0.4: version "8.4.2" resolved "https://registry.npmjs.org/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" @@ -8867,7 +8921,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== -path-key@^3.1.0: +path-key@^3.0.0, path-key@^3.1.0: version "3.1.1" resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== @@ -9807,11 +9861,6 @@ readable-stream@^4.0.0: process "^0.11.10" string_decoder "^1.3.0" -readdirp@^4.0.1: - version "4.1.2" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" - integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== - readdirp@~3.6.0: version "3.6.0" resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -10072,32 +10121,33 @@ robust-predicates@^3.0.2: resolved "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771" integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== -rollup@^4.34.8: - version "4.35.0" - resolved "https://registry.npmjs.org/rollup/-/rollup-4.35.0.tgz#76c95dba17a579df4c00c3955aed32aa5d4dc66d" - integrity sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg== +rollup@^4.19.0: + version "4.41.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.41.1.tgz#46ddc1b33cf1b0baa99320d3b0b4973dc2253b6a" + integrity sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw== dependencies: - "@types/estree" "1.0.6" + "@types/estree" "1.0.7" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.35.0" - "@rollup/rollup-android-arm64" "4.35.0" - "@rollup/rollup-darwin-arm64" "4.35.0" - "@rollup/rollup-darwin-x64" "4.35.0" - "@rollup/rollup-freebsd-arm64" "4.35.0" - "@rollup/rollup-freebsd-x64" "4.35.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.35.0" - "@rollup/rollup-linux-arm-musleabihf" "4.35.0" - "@rollup/rollup-linux-arm64-gnu" "4.35.0" - "@rollup/rollup-linux-arm64-musl" "4.35.0" - "@rollup/rollup-linux-loongarch64-gnu" "4.35.0" - "@rollup/rollup-linux-powerpc64le-gnu" "4.35.0" - "@rollup/rollup-linux-riscv64-gnu" "4.35.0" - "@rollup/rollup-linux-s390x-gnu" "4.35.0" - "@rollup/rollup-linux-x64-gnu" "4.35.0" - "@rollup/rollup-linux-x64-musl" "4.35.0" - "@rollup/rollup-win32-arm64-msvc" "4.35.0" - "@rollup/rollup-win32-ia32-msvc" "4.35.0" - "@rollup/rollup-win32-x64-msvc" "4.35.0" + "@rollup/rollup-android-arm-eabi" "4.41.1" + "@rollup/rollup-android-arm64" "4.41.1" + "@rollup/rollup-darwin-arm64" "4.41.1" + "@rollup/rollup-darwin-x64" "4.41.1" + "@rollup/rollup-freebsd-arm64" "4.41.1" + "@rollup/rollup-freebsd-x64" "4.41.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.41.1" + "@rollup/rollup-linux-arm-musleabihf" "4.41.1" + "@rollup/rollup-linux-arm64-gnu" "4.41.1" + "@rollup/rollup-linux-arm64-musl" "4.41.1" + "@rollup/rollup-linux-loongarch64-gnu" "4.41.1" + "@rollup/rollup-linux-powerpc64le-gnu" "4.41.1" + "@rollup/rollup-linux-riscv64-gnu" "4.41.1" + "@rollup/rollup-linux-riscv64-musl" "4.41.1" + "@rollup/rollup-linux-s390x-gnu" "4.41.1" + "@rollup/rollup-linux-x64-gnu" "4.41.1" + "@rollup/rollup-linux-x64-musl" "4.41.1" + "@rollup/rollup-win32-arm64-msvc" "4.41.1" + "@rollup/rollup-win32-ia32-msvc" "4.41.1" + "@rollup/rollup-win32-x64-msvc" "4.41.1" fsevents "~2.3.2" rope-sequence@^1.3.0: @@ -10404,6 +10454,11 @@ side-channel@^1.0.6, side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" +signal-exit@^3.0.3: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + signal-exit@^4.0.1: version "4.1.0" resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" @@ -10689,6 +10744,11 @@ strip-bom@^3.0.0: resolved "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + strip-indent@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" @@ -10969,17 +11029,12 @@ tinycolor2@^1.4.1: resolved "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== -tinyexec@^0.3.2: - version "0.3.2" - resolved "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" - integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== - -tinyglobby@^0.2.11: - version "0.2.12" - resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz#ac941a42e0c5773bd0b5d08f32de82e74a1a61b5" - integrity sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww== +tinyglobby@^0.2.1: + version "0.2.14" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d" + integrity sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ== dependencies: - fdir "^6.4.3" + fdir "^6.4.4" picomatch "^4.0.2" tinyrainbow@^1.2.0: @@ -11152,26 +11207,26 @@ tslib@~2.5.0: resolved "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz#24944ba2d990940e6e982c4bea147aba80209913" integrity sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w== -tsup@8.4.0, tsup@^8.4.0: - version "8.4.0" - resolved "https://registry.npmjs.org/tsup/-/tsup-8.4.0.tgz#2fdf537e7abc8f1ccbbbfe4228f16831457d4395" - integrity sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ== +tsup@8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/tsup/-/tsup-8.3.0.tgz#c7dae40b13d11d81fb144c0f90077a99102a572a" + integrity sha512-ALscEeyS03IomcuNdFdc0YWGVIkwH1Ws7nfTbAPuoILvEV2hpGQAY72LIOjglGo4ShWpZfpBqP/jpQVCzqYQag== dependencies: - bundle-require "^5.1.0" + bundle-require "^5.0.0" cac "^6.7.14" - chokidar "^4.0.3" - consola "^3.4.0" - debug "^4.4.0" - esbuild "^0.25.0" + chokidar "^3.6.0" + consola "^3.2.3" + debug "^4.3.5" + esbuild "^0.23.0" + execa "^5.1.1" joycon "^3.1.1" - picocolors "^1.1.1" + picocolors "^1.0.1" postcss-load-config "^6.0.1" resolve-from "^5.0.0" - rollup "^4.34.8" + rollup "^4.19.0" source-map "0.8.0-beta.0" sucrase "^3.35.0" - tinyexec "^0.3.2" - tinyglobby "^0.2.11" + tinyglobby "^0.2.1" tree-kill "^1.2.2" tsutils@^3.21.0: From 26b62c4a70ee7aee780504fd2bace8c023e66482 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Wed, 28 May 2025 02:17:23 +0530 Subject: [PATCH 09/15] fix: tsup version 8.4.0 --- live/package.json | 2 +- packages/decorators/package.json | 2 +- packages/editor/package.json | 2 +- packages/hooks/package.json | 2 +- packages/ui/package.json | 2 +- packages/utils/package.json | 2 +- yarn.lock | 113 +++++++++++-------------------- 7 files changed, 44 insertions(+), 81 deletions(-) diff --git a/live/package.json b/live/package.json index 3dcb8b35ead..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.3.0", + "tsup": "8.4.0", "typescript": "5.3.3" } } diff --git a/packages/decorators/package.json b/packages/decorators/package.json index 198fdc69808..433b5c11a4b 100644 --- a/packages/decorators/package.json +++ b/packages/decorators/package.json @@ -27,7 +27,7 @@ "@types/node": "^20.14.9", "@types/reflect-metadata": "^0.1.0", "@types/ws": "^8.5.10", - "tsup": "8.3.0", + "tsup": "8.4.0", "typescript": "^5.3.3" }, "peerDependencies": { diff --git a/packages/editor/package.json b/packages/editor/package.json index 5a899f738db..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.3.0", + "tsup": "8.4.0", "typescript": "5.3.3" }, "keywords": [ diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 320203e2d8d..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.3.0", + "tsup": "8.4.0", "typescript": "^5.3.3" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index e0920d49ec9..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.3.0", + "tsup": "8.4.0", "typescript": "5.3.3" } } diff --git a/packages/utils/package.json b/packages/utils/package.json index 07504d9a48d..75df0d2637f 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -29,7 +29,7 @@ "@types/node": "^22.5.4", "@types/react": "^18.3.11", "@types/zxcvbn": "^4.4.5", - "tsup": "8.3.0", + "tsup": "8.4.0", "typescript": "^5.3.3" } } diff --git a/yarn.lock b/yarn.lock index 6fca25f4d00..27e8f516582 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4614,7 +4614,7 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" -bundle-require@^5.0.0: +bundle-require@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/bundle-require/-/bundle-require-5.1.0.tgz#8db66f41950da3d77af1ef3322f4c3e04009faee" integrity sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA== @@ -4771,6 +4771,13 @@ chokidar@^3.3.0, chokidar@^3.5.2, chokidar@^3.5.3, chokidar@^3.6.0: optionalDependencies: fsevents "~2.3.2" +chokidar@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + chownr@^1.1.1: version "1.1.4" resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" @@ -4995,7 +5002,7 @@ concurrently@^9.0.1: tree-kill "^1.2.2" yargs "^17.7.2" -consola@^3.2.3: +consola@^3.4.0: version "3.4.2" resolved "https://registry.yarnpkg.com/consola/-/consola-3.4.2.tgz#5af110145397bb67afdab77013fdc34cae590ea7" integrity sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA== @@ -5102,7 +5109,7 @@ cross-fetch@^3.1.5: dependencies: node-fetch "^2.7.0" -cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-spawn@^7.0.0, cross-spawn@^7.0.2: version "7.0.6" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -5381,7 +5388,7 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.3.5: +debug@^4.4.0: version "4.4.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== @@ -5940,7 +5947,7 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -esbuild@0.25.0, "esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0", esbuild@^0.23.0: +esbuild@0.25.0, "esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0", esbuild@^0.25.0: version "0.25.0" resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz#0de1787a77206c5a79eeb634a623d39b5006ce92" integrity sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw== @@ -6276,21 +6283,6 @@ events@^3.2.0, events@^3.3.0: resolved "https://registry.npmjs.org/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -execa@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" - integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== - dependencies: - cross-spawn "^7.0.3" - get-stream "^6.0.0" - human-signals "^2.1.0" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.1" - onetime "^5.1.2" - signal-exit "^3.0.3" - strip-final-newline "^2.0.0" - expand-template@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" @@ -6762,11 +6754,6 @@ get-stdin@^9.0.0: resolved "https://registry.npmjs.org/get-stdin/-/get-stdin-9.0.0.tgz#3983ff82e03d56f1b2ea0d3e60325f39d703a575" integrity sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA== -get-stream@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" - integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== - get-symbol-description@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" @@ -7094,11 +7081,6 @@ https-proxy-agent@^7.0.6: agent-base "^7.1.2" debug "4" -human-signals@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" - integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== - hyphen@^1.6.4: version "1.10.6" resolved "https://registry.npmjs.org/hyphen/-/hyphen-1.10.6.tgz#0e779d280e696102b97d7e42f5ca5de2cc97e274" @@ -8321,11 +8303,6 @@ mime@1.6.0: resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - mimic-response@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" @@ -8587,13 +8564,6 @@ normalize.css@^8.0.1: resolved "https://registry.npmjs.org/normalize.css/-/normalize.css-8.0.1.tgz#9b98a208738b9cc2634caacbc42d131c97487bf3" integrity sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg== -npm-run-path@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== - dependencies: - path-key "^3.0.0" - nprogress@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1" @@ -8732,13 +8702,6 @@ one-time@^1.0.0: dependencies: fn.name "1.x.x" -onetime@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" - integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== - dependencies: - mimic-fn "^2.1.0" - open@^8.0.4: version "8.4.2" resolved "https://registry.npmjs.org/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" @@ -8921,7 +8884,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== -path-key@^3.0.0, path-key@^3.1.0: +path-key@^3.1.0: version "3.1.1" resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== @@ -9861,6 +9824,11 @@ readable-stream@^4.0.0: process "^0.11.10" string_decoder "^1.3.0" +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -10121,7 +10089,7 @@ robust-predicates@^3.0.2: resolved "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771" integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== -rollup@^4.19.0: +rollup@^4.34.8: version "4.41.1" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.41.1.tgz#46ddc1b33cf1b0baa99320d3b0b4973dc2253b6a" integrity sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw== @@ -10454,11 +10422,6 @@ side-channel@^1.0.6, side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" -signal-exit@^3.0.3: - version "3.0.7" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - signal-exit@^4.0.1: version "4.1.0" resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" @@ -10744,11 +10707,6 @@ strip-bom@^3.0.0: resolved "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== -strip-final-newline@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" - integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== - strip-indent@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" @@ -11029,7 +10987,12 @@ tinycolor2@^1.4.1: resolved "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== -tinyglobby@^0.2.1: +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== + +tinyglobby@^0.2.11: version "0.2.14" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d" integrity sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ== @@ -11207,26 +11170,26 @@ tslib@~2.5.0: resolved "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz#24944ba2d990940e6e982c4bea147aba80209913" integrity sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w== -tsup@8.3.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/tsup/-/tsup-8.3.0.tgz#c7dae40b13d11d81fb144c0f90077a99102a572a" - integrity sha512-ALscEeyS03IomcuNdFdc0YWGVIkwH1Ws7nfTbAPuoILvEV2hpGQAY72LIOjglGo4ShWpZfpBqP/jpQVCzqYQag== +tsup@8.4.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/tsup/-/tsup-8.4.0.tgz#2fdf537e7abc8f1ccbbbfe4228f16831457d4395" + integrity sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ== dependencies: - bundle-require "^5.0.0" + bundle-require "^5.1.0" cac "^6.7.14" - chokidar "^3.6.0" - consola "^3.2.3" - debug "^4.3.5" - esbuild "^0.23.0" - execa "^5.1.1" + chokidar "^4.0.3" + consola "^3.4.0" + debug "^4.4.0" + esbuild "^0.25.0" joycon "^3.1.1" - picocolors "^1.0.1" + picocolors "^1.1.1" postcss-load-config "^6.0.1" resolve-from "^5.0.0" - rollup "^4.19.0" + rollup "^4.34.8" source-map "0.8.0-beta.0" sucrase "^3.35.0" - tinyglobby "^0.2.1" + tinyexec "^0.3.2" + tinyglobby "^0.2.11" tree-kill "^1.2.2" tsutils@^3.21.0: From 141cb17e8a9d6bc26dacb1c7173356d698b93529 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 28 May 2025 19:03:14 +0530 Subject: [PATCH 10/15] fix: Optimize image uploads in Editor (#7129) * fix: memoize file upload functions * chore: update extension name * chore: update notation * chore: resolve chokidar package * fix: spelling mistakes --- package.json | 3 +- .../custom-image/components/image-block.tsx | 4 +-- .../custom-image/components/image-node.tsx | 4 +-- .../components/image-uploader.tsx | 34 ++++++++++++++----- .../editor/src/core/hooks/use-file-upload.ts | 10 +++++- 5 files changed, 41 insertions(+), 14 deletions(-) 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/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 f8bfcf4a1f4..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 @@ -7,7 +7,7 @@ import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extens // helpers import { getExtensionStorage } from "@/helpers/get-extension-storage"; -export type CustoBaseImageNodeViewProps = { +export type CustomBaseImageNodeViewProps = { getPos: () => number; editor: Editor; node: NodeViewProps["node"] & { @@ -17,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; 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 5af4f556d72..7a7f71f00de 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 @@ -6,12 +6,14 @@ import { cn } from "@plane/utils"; 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"; -import { getExtensionStorage } from "@/helpers/get-extension-storage"; -type CustomImageUploaderProps = CustoBaseImageNodeViewProps & { +type CustomImageUploaderProps = CustomBaseImageNodeViewProps & { maxFileSize: number; loadImageFromFileSystem: (file: string) => void; failedToLoadImage: boolean; @@ -71,23 +73,39 @@ 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] + ); + // 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) => { - getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).uploadInProgress = isUploading; - }, + editorCommand: uploadImageEditorCommand, + handleProgressStatus, loadFileFromFileSystem: loadImageFromFileSystem, maxFileSize, onUpload, }); + + const handleInvalidFile = useCallback((_error: EFileError, message: string) => { + alert(message); + }, []); + const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, editor, maxFileSize, - onInvalidFile: (_error, message) => alert(message), + onInvalidFile: handleInvalidFile, pos: getPos(), type: "image", uploader: uploadFile, diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts index e40c1591341..51116fe52bd 100644 --- a/packages/editor/src/core/hooks/use-file-upload.ts +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -74,7 +74,15 @@ export const useUploader = (args: TUploaderArgs) => { setIsUploading(false); } }, - [acceptedMimeTypes, editorCommand, handleProgressStatus, loadFileFromFileSystem, maxFileSize, onUpload] + [ + acceptedMimeTypes, + editorCommand, + handleProgressStatus, + loadFileFromFileSystem, + maxFileSize, + onInvalidFile, + onUpload, + ] ); return { isUploading, uploadFile }; From 4a97d7c28c26b6bddc0ea2450d48d4ee3185008a Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Thu, 29 May 2025 17:53:48 +0530 Subject: [PATCH 11/15] fix: adding url validations for workspace name and user name --- apiserver/plane/app/serializers/user.py | 11 +++++++++++ apiserver/plane/app/serializers/workspace.py | 13 +++++++++++++ apiserver/plane/app/views/workspace/base.py | 7 +++++++ apiserver/plane/utils/url.py | 8 ++++++++ 4 files changed, 39 insertions(+) diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index ebc002c9c45..c0e1061784e 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 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/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index e92e61e5133..8ca29526d68 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -43,6 +43,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 +110,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 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. From b16a5851021ee3880d735785c661a8a95d964e29 Mon Sep 17 00:00:00 2001 From: Vipin Chaudhary Date: Fri, 30 May 2025 18:17:03 +0530 Subject: [PATCH 12/15] [WIKI-343] [WIKI-312] Fix: html characters (#7049) * fix: handle symbols and space * chore: refactor --- packages/editor/src/core/hooks/use-editor.ts | 5 +++-- packages/editor/src/core/hooks/use-read-only-editor.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index a0cd739157c..ce3cdbe5fb5 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -81,6 +81,7 @@ export const useEditor = (props: CustomEditorProps) => { immediatelyRender: false, shouldRerenderOnTransaction: false, autofocus, + parseOptions: { preserveWhitespace: true }, editorProps: { ...CoreEditorProps({ editorClassName, @@ -119,7 +120,7 @@ export const useEditor = (props: CustomEditorProps) => { const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress; if (!editor.isDestroyed && !isUploadInProgress) { try { - editor.commands.setContent(value, false, { preserveWhitespace: "full" }); + 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); @@ -153,7 +154,7 @@ export const useEditor = (props: CustomEditorProps) => { 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) { 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 5bd731d5f19..6a6e25d9fd5 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -46,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, @@ -71,7 +72,7 @@ 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, () => ({ @@ -79,7 +80,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { 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(); From 01b685ea5726f8c9e6f994d3ca04de939b36537e Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Fri, 30 May 2025 18:18:05 +0530 Subject: [PATCH 13/15] [WIKI-181] refactor: invalid file handling #7139 --- .../components/image-uploader.tsx | 15 ++---- .../editor/src/core/hooks/use-file-upload.ts | 54 ++++++------------- .../editor/src/core/plugins/file/delete.ts | 4 +- 3 files changed, 23 insertions(+), 50 deletions(-) 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 7a7f71f00de..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 @@ -86,6 +86,10 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { [editor] ); + const handleInvalidFile = useCallback((_error: EFileError, _file: File, message: string) => { + alert(message); + }, []); + // hooks const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({ acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, @@ -94,18 +98,12 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { handleProgressStatus, loadFileFromFileSystem: loadImageFromFileSystem, maxFileSize, + onInvalidFile: handleInvalidFile, onUpload, }); - const handleInvalidFile = useCallback((_error: EFileError, message: string) => { - alert(message); - }, []); - const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ - acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, editor, - maxFileSize, - onInvalidFile: handleInvalidFile, pos: getPos(), type: "image", uploader: uploadFile, @@ -140,11 +138,8 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { return; } await uploadFirstFileAndInsertRemaining({ - acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, editor, filesList, - maxFileSize, - onInvalidFile: (_error, message) => alert(message), pos: getPos(), type: "image", uploader: uploadFile, diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts index 51116fe52bd..dce48cca5fb 100644 --- a/packages/editor/src/core/hooks/use-file-upload.ts +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -9,11 +9,11 @@ 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, message: string) => void; + onInvalidFile: (error: EFileError, file: File, message: string) => void; onUpload: (url: string, file: File) => void; }; @@ -38,7 +38,7 @@ export const useUploader = (args: TUploaderArgs) => { acceptedMimeTypes, file, maxFileSize, - onError: onInvalidFile, + onError: (error, message) => onInvalidFile(error, file, message), }); if (!isValid) { handleProgressStatus?.(false); @@ -60,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."); @@ -89,17 +89,14 @@ export const useUploader = (args: TUploaderArgs) => { }; type TDropzoneArgs = { - acceptedMimeTypes: string[]; editor: Editor; - maxFileSize: number; - onInvalidFile: (error: EFileError, message: string) => void; pos: number; type: Extract; uploader: (file: File) => Promise; }; export const useDropZone = (args: TDropzoneArgs) => { - const { acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader } = args; + const { editor, pos, type, uploader } = args; // states const [isDragging, setIsDragging] = useState(false); const [draggedInside, setDraggedInside] = useState(false); @@ -126,22 +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, - onInvalidFile, pos, type, uploader, }); }, - [acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader] + [editor, pos, type, uploader] ); const onDragEnter = useCallback(() => setDraggedInside(true), []); const onDragLeave = useCallback(() => setDraggedInside(false), []); @@ -156,11 +152,8 @@ export const useDropZone = (args: TDropzoneArgs) => { }; type TMultipleFileArgs = { - acceptedMimeTypes: string[]; editor: Editor; filesList: FileList; - maxFileSize: number; - onInvalidFile: (error: EFileError, message: string) => void; pos: number; type: Extract; uploader: (file: File) => Promise; @@ -168,35 +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, onInvalidFile, 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, - onError: onInvalidFile, - }) - ) { - 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/plugins/file/delete.ts b/packages/editor/src/core/plugins/file/delete.ts index b77841c2298..ac69b18194b 100644 --- a/packages/editor/src/core/plugins/file/delete.ts +++ b/packages/editor/src/core/plugins/file/delete.ts @@ -1,5 +1,7 @@ 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 @@ -32,7 +34,7 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand 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("skipFileDeletion")) return; + if (transaction.getMeta(CORE_EDITOR_META.SKIP_FILE_DELETION)) return; const removedFiles: TFileNode[] = []; From cb92108bf4764f4dbe6bfe33b666837bc352e00d Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Fri, 30 May 2025 18:22:20 +0530 Subject: [PATCH 14/15] [WEB-4197] chore: auth forms semantics and accessibility #7128 --- .../i18n/src/locales/cs/accessibility.json | 9 ++- .../i18n/src/locales/de/accessibility.json | 9 ++- .../i18n/src/locales/en/accessibility.json | 9 ++- .../i18n/src/locales/es/accessibility.json | 9 ++- .../i18n/src/locales/fr/accessibility.json | 9 ++- .../i18n/src/locales/id/accessibility.json | 9 ++- .../i18n/src/locales/it/accessibility.json | 9 ++- .../i18n/src/locales/ja/accessibility.json | 9 ++- .../i18n/src/locales/ko/accessibility.json | 9 ++- .../i18n/src/locales/pl/accessibility.json | 9 ++- .../i18n/src/locales/pt-BR/accessibility.json | 9 ++- .../i18n/src/locales/ro/accessibility.json | 9 ++- .../i18n/src/locales/ru/accessibility.json | 9 ++- .../i18n/src/locales/sk/accessibility.json | 9 ++- .../i18n/src/locales/tr-TR/accessibility.json | 9 ++- .../i18n/src/locales/ua/accessibility.json | 9 ++- .../i18n/src/locales/vi-VN/accessibility.json | 9 ++- .../i18n/src/locales/zh-CN/accessibility.json | 9 ++- .../i18n/src/locales/zh-TW/accessibility.json | 9 ++- web/app/layout.tsx | 2 +- .../account/auth-forms/auth-banner.tsx | 26 ++++--- .../account/auth-forms/auth-header.tsx | 4 +- .../components/account/auth-forms/email.tsx | 12 ++-- .../auth-forms/forgot-password-popover.tsx | 9 ++- .../account/auth-forms/password.tsx | 70 +++++++++++-------- .../account/auth-forms/unique-code.tsx | 29 +++++--- .../account/terms-and-conditions.tsx | 4 +- 27 files changed, 252 insertions(+), 75 deletions(-) diff --git a/packages/i18n/src/locales/cs/accessibility.json b/packages/i18n/src/locales/cs/accessibility.json index 4a715f75be1..676c2d44236 100644 --- a/packages/i18n/src/locales/cs/accessibility.json +++ b/packages/i18n/src/locales/cs/accessibility.json @@ -22,6 +22,13 @@ "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" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/de/accessibility.json b/packages/i18n/src/locales/de/accessibility.json index 0faf0091681..edf90970f2c 100644 --- a/packages/i18n/src/locales/de/accessibility.json +++ b/packages/i18n/src/locales/de/accessibility.json @@ -22,6 +22,13 @@ "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" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/en/accessibility.json b/packages/i18n/src/locales/en/accessibility.json index 35759d26627..86660d640ec 100644 --- a/packages/i18n/src/locales/en/accessibility.json +++ b/packages/i18n/src/locales/en/accessibility.json @@ -22,6 +22,13 @@ "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" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/es/accessibility.json b/packages/i18n/src/locales/es/accessibility.json index 41bf0b777d0..4d957f5a9f5 100644 --- a/packages/i18n/src/locales/es/accessibility.json +++ b/packages/i18n/src/locales/es/accessibility.json @@ -22,6 +22,13 @@ "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" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/fr/accessibility.json b/packages/i18n/src/locales/fr/accessibility.json index ba42a4f4183..435247c58bd 100644 --- a/packages/i18n/src/locales/fr/accessibility.json +++ b/packages/i18n/src/locales/fr/accessibility.json @@ -22,6 +22,13 @@ "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" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/id/accessibility.json b/packages/i18n/src/locales/id/accessibility.json index 2aca032cc11..73207340152 100644 --- a/packages/i18n/src/locales/id/accessibility.json +++ b/packages/i18n/src/locales/id/accessibility.json @@ -22,6 +22,13 @@ "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" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/it/accessibility.json b/packages/i18n/src/locales/it/accessibility.json index 8f22d3b8ea4..16d22bcbc10 100644 --- a/packages/i18n/src/locales/it/accessibility.json +++ b/packages/i18n/src/locales/it/accessibility.json @@ -22,6 +22,13 @@ "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" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/ja/accessibility.json b/packages/i18n/src/locales/ja/accessibility.json index a598c435a9a..b983500ff1c 100644 --- a/packages/i18n/src/locales/ja/accessibility.json +++ b/packages/i18n/src/locales/ja/accessibility.json @@ -22,6 +22,13 @@ "collapse_sidebar": "サイドバーを折りたたむ", "expand_sidebar": "サイドバーを展開", "edition_badge": "有料プランのモーダルを開く" + }, + "auth_forms": { + "clear_email": "メールをクリア", + "show_password": "パスワードを表示", + "hide_password": "パスワードを非表示", + "close_alert": "アラートを閉じる", + "close_popover": "ポップオーバーを閉じる" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/ko/accessibility.json b/packages/i18n/src/locales/ko/accessibility.json index 491b8c35c6a..298a7e122d8 100644 --- a/packages/i18n/src/locales/ko/accessibility.json +++ b/packages/i18n/src/locales/ko/accessibility.json @@ -22,6 +22,13 @@ "collapse_sidebar": "사이드바 축소", "expand_sidebar": "사이드바 확장", "edition_badge": "유료 플랜 모달 열기" + }, + "auth_forms": { + "clear_email": "이메일 지우기", + "show_password": "비밀번호 표시", + "hide_password": "비밀번호 숨기기", + "close_alert": "알림 닫기", + "close_popover": "팝오버 닫기" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/pl/accessibility.json b/packages/i18n/src/locales/pl/accessibility.json index 5ff936d47f2..c1407911acd 100644 --- a/packages/i18n/src/locales/pl/accessibility.json +++ b/packages/i18n/src/locales/pl/accessibility.json @@ -22,6 +22,13 @@ "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" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/pt-BR/accessibility.json b/packages/i18n/src/locales/pt-BR/accessibility.json index 333b55a7fbc..de90eeb36d5 100644 --- a/packages/i18n/src/locales/pt-BR/accessibility.json +++ b/packages/i18n/src/locales/pt-BR/accessibility.json @@ -22,6 +22,13 @@ "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" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/ro/accessibility.json b/packages/i18n/src/locales/ro/accessibility.json index 1a201a48c8a..52f55548157 100644 --- a/packages/i18n/src/locales/ro/accessibility.json +++ b/packages/i18n/src/locales/ro/accessibility.json @@ -22,6 +22,13 @@ "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" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/ru/accessibility.json b/packages/i18n/src/locales/ru/accessibility.json index ebec8dc2f86..dd4dde76b14 100644 --- a/packages/i18n/src/locales/ru/accessibility.json +++ b/packages/i18n/src/locales/ru/accessibility.json @@ -22,6 +22,13 @@ "collapse_sidebar": "Свернуть боковую панель", "expand_sidebar": "Развернуть боковую панель", "edition_badge": "Открыть модал платных планов" + }, + "auth_forms": { + "clear_email": "Очистить email", + "show_password": "Показать пароль", + "hide_password": "Скрыть пароль", + "close_alert": "Закрыть уведомление", + "close_popover": "Закрыть всплывающее окно" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/sk/accessibility.json b/packages/i18n/src/locales/sk/accessibility.json index 59a309f6032..26c5c8be6fe 100644 --- a/packages/i18n/src/locales/sk/accessibility.json +++ b/packages/i18n/src/locales/sk/accessibility.json @@ -22,6 +22,13 @@ "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" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/tr-TR/accessibility.json b/packages/i18n/src/locales/tr-TR/accessibility.json index 35b8f340e9c..80a35611c2d 100644 --- a/packages/i18n/src/locales/tr-TR/accessibility.json +++ b/packages/i18n/src/locales/tr-TR/accessibility.json @@ -22,6 +22,13 @@ "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" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/ua/accessibility.json b/packages/i18n/src/locales/ua/accessibility.json index b6bdc7d52be..42766731214 100644 --- a/packages/i18n/src/locales/ua/accessibility.json +++ b/packages/i18n/src/locales/ua/accessibility.json @@ -22,6 +22,13 @@ "collapse_sidebar": "Згорнути бічну панель", "expand_sidebar": "Розгорнути бічну панель", "edition_badge": "Відкрити модал платних планів" + }, + "auth_forms": { + "clear_email": "Очистити email", + "show_password": "Показати пароль", + "hide_password": "Приховати пароль", + "close_alert": "Закрити сповіщення", + "close_popover": "Закрити спливаюче вікно" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/vi-VN/accessibility.json b/packages/i18n/src/locales/vi-VN/accessibility.json index 8071da9e388..b3ab93530e0 100644 --- a/packages/i18n/src/locales/vi-VN/accessibility.json +++ b/packages/i18n/src/locales/vi-VN/accessibility.json @@ -22,6 +22,13 @@ "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" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/zh-CN/accessibility.json b/packages/i18n/src/locales/zh-CN/accessibility.json index b19f68676ac..fea84d06373 100644 --- a/packages/i18n/src/locales/zh-CN/accessibility.json +++ b/packages/i18n/src/locales/zh-CN/accessibility.json @@ -22,6 +22,13 @@ "collapse_sidebar": "折叠侧边栏", "expand_sidebar": "展开侧边栏", "edition_badge": "打开付费计划模态框" + }, + "auth_forms": { + "clear_email": "清除邮箱", + "show_password": "显示密码", + "hide_password": "隐藏密码", + "close_alert": "关闭警告", + "close_popover": "关闭弹出框" } } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/zh-TW/accessibility.json b/packages/i18n/src/locales/zh-TW/accessibility.json index 97e07ae73b9..75747f86124 100644 --- a/packages/i18n/src/locales/zh-TW/accessibility.json +++ b/packages/i18n/src/locales/zh-TW/accessibility.json @@ -22,6 +22,13 @@ "collapse_sidebar": "摺疊側邊欄", "expand_sidebar": "展開側邊欄", "edition_badge": "打開付費計劃模態框" + }, + "auth_forms": { + "clear_email": "清除電子郵件", + "show_password": "顯示密碼", + "hide_password": "隱藏密碼", + "close_alert": "關閉警告", + "close_popover": "關閉彈出框" } } -} \ No newline at end of file +} 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/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 && (
-